From 744ed99216f1041bf6b2b3b74af2f734a5753f2d Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Sun, 22 Mar 2026 18:55:05 +0530 Subject: [PATCH 01/21] fix(playground): move useState/useCallback above early returns to fix React error #310 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useState(copied) and useCallback(handleCopy) were declared after the if (loading) and if (error) early returns, violating the Rules of Hooks. When the WASM binary was a 404, the component always hit the error branch so both renders exited with the same hook count. After committing the WASM binary (#423), the component now successfully transitions loading→ready, causing React to see 11 hooks instead of 9 on the second render → #310. Fix: move both hooks above all conditional returns. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/playground/Playground.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/website/src/components/playground/Playground.tsx b/website/src/components/playground/Playground.tsx index acc499fb..29f88d1c 100644 --- a/website/src/components/playground/Playground.tsx +++ b/website/src/components/playground/Playground.tsx @@ -109,6 +109,15 @@ export default function Playground() { [] ); + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText('go get github.com/ajitpratap0/GoSQLX').then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, []); + if (loading) { return (
@@ -190,16 +199,8 @@ export default function Playground() { ); } - const [copied, setCopied] = useState(false); const hasResults = results.ast !== null; - const handleCopy = useCallback(() => { - navigator.clipboard.writeText('go get github.com/ajitpratap0/GoSQLX').then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - }, []); - return (
{/* Top toolbar */} From c5063a812d4f0700887fc9e08e7ed5ac50c9574f Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 01:49:24 +0530 Subject: [PATCH 02/21] feat(dialect): add DialectMariaDB constant to keyword dialect system --- pkg/sql/keywords/dialect.go | 10 ++++++++++ pkg/sql/keywords/mariadb_test.go | 32 ++++++++++++++++++++++++++++++ pkg/sql/keywords/snowflake_test.go | 1 + pkg/sql/parser/dialect_test.go | 2 +- 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 pkg/sql/keywords/mariadb_test.go diff --git a/pkg/sql/keywords/dialect.go b/pkg/sql/keywords/dialect.go index 934b40ae..0dc522fe 100644 --- a/pkg/sql/keywords/dialect.go +++ b/pkg/sql/keywords/dialect.go @@ -64,6 +64,13 @@ const ( // definitions (ENGINE, CODEC, TTL), ClickHouse data types (FixedString, // LowCardinality, Nullable, DateTime64), and replication keywords (ON CLUSTER, GLOBAL). DialectClickHouse SQLDialect = "clickhouse" + + // DialectMariaDB represents MariaDB-specific keywords and extensions. + // MariaDB is a superset of MySQL; this dialect includes all MySQL keywords + // (UNSIGNED, ZEROFILL, ON DUPLICATE KEY UPDATE, etc.) plus MariaDB-specific + // features: SEQUENCE DDL (10.3+), system-versioned temporal tables (10.3.4+), + // CONNECT BY hierarchical queries (10.2+), and index visibility (10.6+). + DialectMariaDB SQLDialect = "mariadb" ) // DialectKeywords returns the additional keywords for a specific dialect. @@ -84,6 +91,8 @@ func DialectKeywords(dialect SQLDialect) []Keyword { return SNOWFLAKE_SPECIFIC case DialectMySQL: return MYSQL_SPECIFIC + case DialectMariaDB: + return nil // populated in Task 2 when MARIADB_SPECIFIC is added case DialectPostgreSQL: return POSTGRESQL_SPECIFIC case DialectSQLite: @@ -133,6 +142,7 @@ func AllDialects() []SQLDialect { DialectGeneric, DialectPostgreSQL, DialectMySQL, + DialectMariaDB, DialectSQLServer, DialectOracle, DialectSQLite, diff --git a/pkg/sql/keywords/mariadb_test.go b/pkg/sql/keywords/mariadb_test.go new file mode 100644 index 00000000..1674d990 --- /dev/null +++ b/pkg/sql/keywords/mariadb_test.go @@ -0,0 +1,32 @@ +package keywords_test + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +func TestDialectMariaDB_Constant(t *testing.T) { + if string(keywords.DialectMariaDB) != "mariadb" { + t.Fatalf("expected DialectMariaDB = \"mariadb\", got %q", keywords.DialectMariaDB) + } +} + +func TestDialectMariaDB_InAllDialects(t *testing.T) { + found := false + for _, d := range keywords.AllDialects() { + if d == keywords.DialectMariaDB { + found = true + break + } + } + if !found { + t.Error("DialectMariaDB not found in AllDialects()") + } +} + +func TestDialectMariaDB_IsValidDialect(t *testing.T) { + if !keywords.IsValidDialect("mariadb") { + t.Error("IsValidDialect(\"mariadb\") returned false") + } +} diff --git a/pkg/sql/keywords/snowflake_test.go b/pkg/sql/keywords/snowflake_test.go index 05c779cb..b1d24935 100644 --- a/pkg/sql/keywords/snowflake_test.go +++ b/pkg/sql/keywords/snowflake_test.go @@ -466,6 +466,7 @@ func TestDialectRegistry(t *testing.T) { DialectGeneric: false, DialectPostgreSQL: false, DialectMySQL: false, + DialectMariaDB: false, DialectSQLServer: false, DialectOracle: false, DialectSQLite: false, diff --git a/pkg/sql/parser/dialect_test.go b/pkg/sql/parser/dialect_test.go index ed7036d6..16d73438 100644 --- a/pkg/sql/parser/dialect_test.go +++ b/pkg/sql/parser/dialect_test.go @@ -177,7 +177,7 @@ func TestIsValidDialect(t *testing.T) { t.Errorf("IsValidDialect(%q) should return true", d) } } - invalidDialects := []string{"fakesql", "postgres", "mssql", "pg", "mariadb", "db2"} + invalidDialects := []string{"fakesql", "postgres", "mssql", "pg", "db2"} for _, d := range invalidDialects { if keywords.IsValidDialect(d) { t.Errorf("IsValidDialect(%q) should return false", d) From 2e65aad7ab9aa7daf595c0126b93b96a4da78aed Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 01:55:14 +0530 Subject: [PATCH 03/21] fix(dialect): return MYSQL_SPECIFIC for DialectMariaDB and add to validDialects test --- pkg/sql/keywords/dialect.go | 2 +- pkg/sql/parser/dialect_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/sql/keywords/dialect.go b/pkg/sql/keywords/dialect.go index 0dc522fe..b24a27c0 100644 --- a/pkg/sql/keywords/dialect.go +++ b/pkg/sql/keywords/dialect.go @@ -92,7 +92,7 @@ func DialectKeywords(dialect SQLDialect) []Keyword { case DialectMySQL: return MYSQL_SPECIFIC case DialectMariaDB: - return nil // populated in Task 2 when MARIADB_SPECIFIC is added + return MYSQL_SPECIFIC // MariaDB is a MySQL superset; MARIADB_SPECIFIC added in Task 2 case DialectPostgreSQL: return POSTGRESQL_SPECIFIC case DialectSQLite: diff --git a/pkg/sql/parser/dialect_test.go b/pkg/sql/parser/dialect_test.go index 16d73438..f50f565e 100644 --- a/pkg/sql/parser/dialect_test.go +++ b/pkg/sql/parser/dialect_test.go @@ -170,7 +170,7 @@ func TestRejectUnknownDialect(t *testing.T) { func TestIsValidDialect(t *testing.T) { validDialects := []string{ "postgresql", "mysql", "sqlserver", "oracle", "sqlite", - "snowflake", "bigquery", "redshift", "generic", "", + "snowflake", "bigquery", "redshift", "generic", "mariadb", "", } for _, d := range validDialects { if !keywords.IsValidDialect(d) { From 28cd2e302357e8c34ebe18d97d88d68a741341f4 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 01:58:35 +0530 Subject: [PATCH 04/21] fix(dialect): wire DialectMariaDB into keywords.New() to load MYSQL_SPECIFIC keywords --- pkg/sql/keywords/keywords.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/sql/keywords/keywords.go b/pkg/sql/keywords/keywords.go index 5e93dfe5..2d5c2ab7 100644 --- a/pkg/sql/keywords/keywords.go +++ b/pkg/sql/keywords/keywords.go @@ -265,6 +265,9 @@ func New(dialect SQLDialect, ignoreCase bool) *Keywords { switch dialect { case DialectMySQL: k.addKeywordsWithCategory(MYSQL_SPECIFIC) + case DialectMariaDB: + k.addKeywordsWithCategory(MYSQL_SPECIFIC) + // MARIADB_SPECIFIC added in Task 2 case DialectPostgreSQL: k.addKeywordsWithCategory(POSTGRESQL_SPECIFIC) case DialectSQLite: From 870a7831cdfd4df94d4d81d45ca1c0e5ee030b7b Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:01:09 +0530 Subject: [PATCH 05/21] test(dialect): add TestDialectMariaDB_InheritsMySQL to guard MySQL keyword inheritance --- pkg/sql/keywords/mariadb_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/sql/keywords/mariadb_test.go b/pkg/sql/keywords/mariadb_test.go index 1674d990..96ffea03 100644 --- a/pkg/sql/keywords/mariadb_test.go +++ b/pkg/sql/keywords/mariadb_test.go @@ -30,3 +30,12 @@ func TestDialectMariaDB_IsValidDialect(t *testing.T) { t.Error("IsValidDialect(\"mariadb\") returned false") } } + +func TestDialectMariaDB_InheritsMySQL(t *testing.T) { + kw := keywords.New(keywords.DialectMariaDB, true) + for _, word := range []string{"UNSIGNED", "ZEROFILL", "DATETIME"} { + if !kw.IsKeyword(word) { + t.Errorf("expected MariaDB to inherit MySQL keyword %q", word) + } + } +} From c9cc07893b9a2046d726b842432fe8cc0366244a Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:02:58 +0530 Subject: [PATCH 06/21] feat(dialect): add MARIADB_SPECIFIC keyword list extending MySQL dialect --- pkg/sql/keywords/dialect.go | 5 ++- pkg/sql/keywords/keywords.go | 3 +- pkg/sql/keywords/mariadb.go | 63 ++++++++++++++++++++++++++++++++ pkg/sql/keywords/mariadb_test.go | 43 ++++++++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 pkg/sql/keywords/mariadb.go diff --git a/pkg/sql/keywords/dialect.go b/pkg/sql/keywords/dialect.go index b24a27c0..6062f40b 100644 --- a/pkg/sql/keywords/dialect.go +++ b/pkg/sql/keywords/dialect.go @@ -92,7 +92,10 @@ func DialectKeywords(dialect SQLDialect) []Keyword { case DialectMySQL: return MYSQL_SPECIFIC case DialectMariaDB: - return MYSQL_SPECIFIC // MariaDB is a MySQL superset; MARIADB_SPECIFIC added in Task 2 + combined := make([]Keyword, 0, len(MYSQL_SPECIFIC)+len(MARIADB_SPECIFIC)) + combined = append(combined, MYSQL_SPECIFIC...) + combined = append(combined, MARIADB_SPECIFIC...) + return combined case DialectPostgreSQL: return POSTGRESQL_SPECIFIC case DialectSQLite: diff --git a/pkg/sql/keywords/keywords.go b/pkg/sql/keywords/keywords.go index 2d5c2ab7..3f24d34e 100644 --- a/pkg/sql/keywords/keywords.go +++ b/pkg/sql/keywords/keywords.go @@ -266,8 +266,9 @@ func New(dialect SQLDialect, ignoreCase bool) *Keywords { case DialectMySQL: k.addKeywordsWithCategory(MYSQL_SPECIFIC) case DialectMariaDB: + // MariaDB is a superset of MySQL — load MySQL base first, then MariaDB extras k.addKeywordsWithCategory(MYSQL_SPECIFIC) - // MARIADB_SPECIFIC added in Task 2 + k.addKeywordsWithCategory(MARIADB_SPECIFIC) case DialectPostgreSQL: k.addKeywordsWithCategory(POSTGRESQL_SPECIFIC) case DialectSQLite: diff --git a/pkg/sql/keywords/mariadb.go b/pkg/sql/keywords/mariadb.go new file mode 100644 index 00000000..ac129ec4 --- /dev/null +++ b/pkg/sql/keywords/mariadb.go @@ -0,0 +1,63 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package keywords + +import "github.com/ajitpratap0/GoSQLX/pkg/models" + +// MARIADB_SPECIFIC contains MariaDB-specific SQL keywords beyond the MySQL base. +// When DialectMariaDB is active, both MYSQL_SPECIFIC and MARIADB_SPECIFIC are loaded +// (MariaDB is a superset of MySQL). +// +// Features covered: +// - SEQUENCE DDL (MariaDB 10.3+): CREATE/DROP/ALTER SEQUENCE, NEXTVAL, LASTVAL, SETVAL +// - Temporal tables (MariaDB 10.3.4+): WITH SYSTEM VERSIONING, FOR SYSTEM_TIME, PERIOD FOR +// - Hierarchical queries (MariaDB 10.2+): CONNECT BY, START WITH, PRIOR, NOCYCLE +// - Index visibility (MariaDB 10.6+): INVISIBLE, VISIBLE modifiers +var MARIADB_SPECIFIC = []Keyword{ + // ── SEQUENCE DDL (MariaDB 10.3+) ─────────────────────────────────────── + // CREATE SEQUENCE s START WITH 1 INCREMENT BY 1 MINVALUE 1 MAXVALUE 9999 CYCLE CACHE 100; + // SELECT NEXT VALUE FOR s; -- ANSI style + // SELECT NEXTVAL(s); -- MariaDB style + {Word: "SEQUENCE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + {Word: "NEXTVAL", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + {Word: "LASTVAL", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + {Word: "SETVAL", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + {Word: "MINVALUE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + {Word: "MAXVALUE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + {Word: "INCREMENT", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + {Word: "RESTART", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + {Word: "NOCACHE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + {Word: "NOCYCLE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + + // ── Temporal tables / System versioning (MariaDB 10.3.4+) ───────────── + // CREATE TABLE t (...) WITH SYSTEM VERSIONING; + // SELECT * FROM t FOR SYSTEM_TIME AS OF TIMESTAMP '2024-01-01'; + // PERIOD FOR app_time (start_col, end_col) + {Word: "VERSIONING", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + {Word: "PERIOD", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + {Word: "OVERLAPS", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + // SYSTEM_TIME is reserved so it doesn't collide as a table alias + {Word: "SYSTEM_TIME", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: true}, + + // ── Hierarchical queries / CONNECT BY (MariaDB 10.2+) ────────────────── + // SELECT id FROM t START WITH parent_id IS NULL CONNECT BY PRIOR id = parent_id; + {Word: "PRIOR", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + + // ── Index visibility (MariaDB 10.6+) ──────────────────────────────────── + // CREATE INDEX idx ON t (col) INVISIBLE; + // ALTER TABLE t ALTER INDEX idx VISIBLE; + {Word: "INVISIBLE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, + {Word: "VISIBLE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, +} diff --git a/pkg/sql/keywords/mariadb_test.go b/pkg/sql/keywords/mariadb_test.go index 96ffea03..c2788bb7 100644 --- a/pkg/sql/keywords/mariadb_test.go +++ b/pkg/sql/keywords/mariadb_test.go @@ -39,3 +39,46 @@ func TestDialectMariaDB_InheritsMySQL(t *testing.T) { } } } + +func TestMariaDBKeywords_Recognized(t *testing.T) { + kw := keywords.New(keywords.DialectMariaDB, true) + + mariadbOnly := []string{ + // Sequence DDL + "SEQUENCE", "NEXTVAL", "LASTVAL", "SETVAL", + // Temporal tables + "VERSIONING", "PERIOD", "OVERLAPS", + // Hierarchical queries + "PRIOR", "NOCYCLE", + // Index visibility + "INVISIBLE", "VISIBLE", + } + for _, word := range mariadbOnly { + if !kw.IsKeyword(word) { + t.Errorf("expected %q to be a keyword in DialectMariaDB", word) + } + } +} + +func TestMariaDBKeywords_InheritsMySQLKeywords(t *testing.T) { + kw := keywords.New(keywords.DialectMariaDB, true) + + // These are MySQL-specific keywords that MariaDB must also recognize + mysqlKeywords := []string{"UNSIGNED", "ZEROFILL", "KILL", "PURGE", "STATUS", "VARIABLES"} + for _, word := range mysqlKeywords { + if !kw.IsKeyword(word) { + t.Errorf("MariaDB dialect must inherit MySQL keyword %q", word) + } + } +} + +func TestMariaDBKeywords_NotRecognizedInMySQLDialect(t *testing.T) { + kw := keywords.New(keywords.DialectMySQL, true) + + mariadbOnlyKeywords := []string{"VERSIONING", "PRIOR", "NOCYCLE", "INVISIBLE"} + for _, word := range mariadbOnlyKeywords { + if kw.IsKeyword(word) { + t.Errorf("keyword %q should NOT be recognized in pure MySQL dialect", word) + } + } +} From b488bcfcf9992556cab9cee70c8c5870745f319e Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:06:14 +0530 Subject: [PATCH 07/21] feat(dialect): add MariaDB auto-detection hints (SEQUENCE, VERSIONING, CONNECT BY) --- pkg/sql/keywords/detect.go | 19 ++++++++++++++++- pkg/sql/keywords/mariadb_test.go | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/pkg/sql/keywords/detect.go b/pkg/sql/keywords/detect.go index 8e6b9506..4c175a78 100644 --- a/pkg/sql/keywords/detect.go +++ b/pkg/sql/keywords/detect.go @@ -73,10 +73,23 @@ var dialectHints = []dialectHint{ // Oracle-specific (high confidence) {pattern: "ROWNUM", dialect: DialectOracle, weight: 5}, - {pattern: "CONNECT BY", dialect: DialectOracle, weight: 5}, + {pattern: "CONNECT BY", dialect: DialectOracle, weight: 3}, {pattern: "SYSDATE", dialect: DialectOracle, weight: 5}, {pattern: "DECODE", dialect: DialectOracle, weight: 3}, + // MariaDB-specific (high confidence — these features don't appear in MySQL or Oracle) + {pattern: "NEXTVAL", dialect: DialectMariaDB, weight: 5}, + {pattern: "LASTVAL", dialect: DialectMariaDB, weight: 5}, + {pattern: "SETVAL", dialect: DialectMariaDB, weight: 5}, + {pattern: "NEXT VALUE FOR", dialect: DialectMariaDB, weight: 5}, + {pattern: "SYSTEM VERSIONING", dialect: DialectMariaDB, weight: 5}, + {pattern: "FOR SYSTEM_TIME", dialect: DialectMariaDB, weight: 5}, + {pattern: "VERSIONING", dialect: DialectMariaDB, weight: 4}, + {pattern: "START WITH", dialect: DialectMariaDB, weight: 4}, + {pattern: "CONNECT BY", dialect: DialectMariaDB, weight: 3}, + {pattern: "CREATE SEQUENCE", dialect: DialectMariaDB, weight: 5}, + {pattern: "DROP SEQUENCE", dialect: DialectMariaDB, weight: 5}, + // SQLite-specific (high confidence) {pattern: "AUTOINCREMENT", dialect: DialectSQLite, weight: 5}, {pattern: "GLOB", dialect: DialectSQLite, weight: 4}, @@ -98,6 +111,7 @@ var dialectHints = []dialectHint{ // - MySQL: ZEROFILL, UNSIGNED, AUTO_INCREMENT, FORCE INDEX // - SQL Server: NOLOCK, TOP, NVARCHAR, GETDATE // - Oracle: ROWNUM, CONNECT BY, SYSDATE, DECODE +// - MariaDB: NEXTVAL, LASTVAL, SETVAL, NEXT VALUE FOR, SYSTEM VERSIONING, FOR SYSTEM_TIME, CREATE SEQUENCE // - SQLite: AUTOINCREMENT, GLOB, VACUUM // // The function also performs syntactic checks for identifier quoting styles: @@ -113,6 +127,9 @@ var dialectHints = []dialectHint{ // dialect = keywords.DetectDialect("SELECT DISTINCT ON (dept) * FROM emp") // // dialect == DialectPostgreSQL // +// dialect = keywords.DetectDialect("SELECT NEXTVAL(seq_orders)") +// // dialect == DialectMariaDB +// // dialect = keywords.DetectDialect("SELECT * FROM users") // // dialect == DialectGeneric func DetectDialect(sql string) SQLDialect { diff --git a/pkg/sql/keywords/mariadb_test.go b/pkg/sql/keywords/mariadb_test.go index c2788bb7..9272b353 100644 --- a/pkg/sql/keywords/mariadb_test.go +++ b/pkg/sql/keywords/mariadb_test.go @@ -82,3 +82,39 @@ func TestMariaDBKeywords_NotRecognizedInMySQLDialect(t *testing.T) { } } } + +func TestDetectDialect_MariaDB(t *testing.T) { + tests := []struct { + name string + sql string + }{ + { + name: "CREATE SEQUENCE", + sql: "CREATE SEQUENCE seq_orders START WITH 1 INCREMENT BY 1", + }, + { + name: "WITH SYSTEM VERSIONING", + sql: "CREATE TABLE orders (id INT) WITH SYSTEM VERSIONING", + }, + { + name: "FOR SYSTEM_TIME", + sql: "SELECT * FROM orders FOR SYSTEM_TIME AS OF TIMESTAMP '2024-01-01'", + }, + { + name: "CONNECT BY", + sql: "SELECT id FROM t START WITH parent_id IS NULL CONNECT BY PRIOR id = parent_id", + }, + { + name: "NEXTVAL", + sql: "SELECT NEXTVAL(seq_orders)", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := keywords.DetectDialect(tt.sql) + if got != keywords.DialectMariaDB { + t.Errorf("DetectDialect(%q) = %q, want %q", tt.sql, got, keywords.DialectMariaDB) + } + }) + } +} From 0d75548cece4d13dd605a746bbcc943d1c19124c Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:08:31 +0530 Subject: [PATCH 08/21] fix(dialect): remove over-broad START WITH hint and complete DetectDialect doc comment --- pkg/sql/keywords/detect.go | 4 +--- pkg/sql/keywords/mariadb_test.go | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pkg/sql/keywords/detect.go b/pkg/sql/keywords/detect.go index 4c175a78..16c5008b 100644 --- a/pkg/sql/keywords/detect.go +++ b/pkg/sql/keywords/detect.go @@ -85,8 +85,6 @@ var dialectHints = []dialectHint{ {pattern: "SYSTEM VERSIONING", dialect: DialectMariaDB, weight: 5}, {pattern: "FOR SYSTEM_TIME", dialect: DialectMariaDB, weight: 5}, {pattern: "VERSIONING", dialect: DialectMariaDB, weight: 4}, - {pattern: "START WITH", dialect: DialectMariaDB, weight: 4}, - {pattern: "CONNECT BY", dialect: DialectMariaDB, weight: 3}, {pattern: "CREATE SEQUENCE", dialect: DialectMariaDB, weight: 5}, {pattern: "DROP SEQUENCE", dialect: DialectMariaDB, weight: 5}, @@ -111,7 +109,7 @@ var dialectHints = []dialectHint{ // - MySQL: ZEROFILL, UNSIGNED, AUTO_INCREMENT, FORCE INDEX // - SQL Server: NOLOCK, TOP, NVARCHAR, GETDATE // - Oracle: ROWNUM, CONNECT BY, SYSDATE, DECODE -// - MariaDB: NEXTVAL, LASTVAL, SETVAL, NEXT VALUE FOR, SYSTEM VERSIONING, FOR SYSTEM_TIME, CREATE SEQUENCE +// - MariaDB: NEXTVAL, LASTVAL, SETVAL, NEXT VALUE FOR, SYSTEM VERSIONING, FOR SYSTEM_TIME, VERSIONING, CREATE SEQUENCE, DROP SEQUENCE // - SQLite: AUTOINCREMENT, GLOB, VACUUM // // The function also performs syntactic checks for identifier quoting styles: diff --git a/pkg/sql/keywords/mariadb_test.go b/pkg/sql/keywords/mariadb_test.go index 9272b353..7b602e1e 100644 --- a/pkg/sql/keywords/mariadb_test.go +++ b/pkg/sql/keywords/mariadb_test.go @@ -101,8 +101,8 @@ func TestDetectDialect_MariaDB(t *testing.T) { sql: "SELECT * FROM orders FOR SYSTEM_TIME AS OF TIMESTAMP '2024-01-01'", }, { - name: "CONNECT BY", - sql: "SELECT id FROM t START WITH parent_id IS NULL CONNECT BY PRIOR id = parent_id", + name: "DROP SEQUENCE", + sql: "DROP SEQUENCE seq_orders", }, { name: "NEXTVAL", From c0231ce4a15926df08defd5ddfb5e135818e8a20 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:10:22 +0530 Subject: [PATCH 09/21] fix(dialect): restore MariaDB CONNECT BY hint and add accumulation test --- pkg/sql/keywords/detect.go | 3 ++- pkg/sql/keywords/mariadb_test.go | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/sql/keywords/detect.go b/pkg/sql/keywords/detect.go index 16c5008b..484add7c 100644 --- a/pkg/sql/keywords/detect.go +++ b/pkg/sql/keywords/detect.go @@ -85,6 +85,7 @@ var dialectHints = []dialectHint{ {pattern: "SYSTEM VERSIONING", dialect: DialectMariaDB, weight: 5}, {pattern: "FOR SYSTEM_TIME", dialect: DialectMariaDB, weight: 5}, {pattern: "VERSIONING", dialect: DialectMariaDB, weight: 4}, + {pattern: "CONNECT BY", dialect: DialectMariaDB, weight: 3}, {pattern: "CREATE SEQUENCE", dialect: DialectMariaDB, weight: 5}, {pattern: "DROP SEQUENCE", dialect: DialectMariaDB, weight: 5}, @@ -109,7 +110,7 @@ var dialectHints = []dialectHint{ // - MySQL: ZEROFILL, UNSIGNED, AUTO_INCREMENT, FORCE INDEX // - SQL Server: NOLOCK, TOP, NVARCHAR, GETDATE // - Oracle: ROWNUM, CONNECT BY, SYSDATE, DECODE -// - MariaDB: NEXTVAL, LASTVAL, SETVAL, NEXT VALUE FOR, SYSTEM VERSIONING, FOR SYSTEM_TIME, VERSIONING, CREATE SEQUENCE, DROP SEQUENCE +// - MariaDB: NEXTVAL, LASTVAL, SETVAL, NEXT VALUE FOR, SYSTEM VERSIONING, FOR SYSTEM_TIME, VERSIONING, CONNECT BY, CREATE SEQUENCE, DROP SEQUENCE // - SQLite: AUTOINCREMENT, GLOB, VACUUM // // The function also performs syntactic checks for identifier quoting styles: diff --git a/pkg/sql/keywords/mariadb_test.go b/pkg/sql/keywords/mariadb_test.go index 7b602e1e..ece9df1f 100644 --- a/pkg/sql/keywords/mariadb_test.go +++ b/pkg/sql/keywords/mariadb_test.go @@ -108,6 +108,10 @@ func TestDetectDialect_MariaDB(t *testing.T) { name: "NEXTVAL", sql: "SELECT NEXTVAL(seq_orders)", }, + { + name: "CONNECT BY with NEXTVAL (MariaDB wins on accumulation)", + sql: "SELECT NEXTVAL(s) FROM t CONNECT BY PRIOR id = parent_id", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 4e9637d7d6cd39beb668dd06a345b836bd67f5d0 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:13:39 +0530 Subject: [PATCH 10/21] feat(ast): add CreateSequenceStatement, DropSequenceStatement, AlterSequenceStatement nodes Co-Authored-By: Claude Sonnet 4.6 --- pkg/sql/ast/ast.go | 69 +++++++++++++++++++++++++++++++++++++++++++ pkg/sql/ast/pool.go | 30 +++++++++++++++++++ pkg/sql/ast/sql.go | 72 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+) diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index 4284bdca..4b7f6e42 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -1815,3 +1815,72 @@ func (r ReplaceStatement) Children() []Node { } return children } + +// ── MariaDB SEQUENCE DDL (10.3+) ─────────────────────────────────────────── + +// SequenceOptions holds configuration for CREATE SEQUENCE and ALTER SEQUENCE. +// Fields are pointers so that unspecified options are distinguishable from zero values. +type SequenceOptions struct { + StartWith *LiteralValue // START WITH n + IncrementBy *LiteralValue // INCREMENT BY n (default 1) + MinValue *LiteralValue // MINVALUE n or nil when NO MINVALUE + MaxValue *LiteralValue // MAXVALUE n or nil when NO MAXVALUE + Cache *LiteralValue // CACHE n or nil when NO CACHE / NOCACHE + Cycle bool // CYCLE + NoCycle bool // NO CYCLE / NOCYCLE (explicit; default is NO CYCLE) + Restart *LiteralValue // RESTART [WITH n] — only for ALTER SEQUENCE +} + +// CreateSequenceStatement represents: +// +// CREATE [OR REPLACE] SEQUENCE [IF NOT EXISTS] name [options...] +type CreateSequenceStatement struct { + Name *Identifier + OrReplace bool + IfNotExists bool + Options SequenceOptions +} + +func (s *CreateSequenceStatement) statementNode() {} +func (s *CreateSequenceStatement) TokenLiteral() string { return "CREATE" } +func (s *CreateSequenceStatement) Children() []Node { + if s.Name != nil { + return []Node{s.Name} + } + return nil +} + +// DropSequenceStatement represents: +// +// DROP SEQUENCE [IF EXISTS] name +type DropSequenceStatement struct { + Name *Identifier + IfExists bool +} + +func (s *DropSequenceStatement) statementNode() {} +func (s *DropSequenceStatement) TokenLiteral() string { return "DROP" } +func (s *DropSequenceStatement) Children() []Node { + if s.Name != nil { + return []Node{s.Name} + } + return nil +} + +// AlterSequenceStatement represents: +// +// ALTER SEQUENCE [IF EXISTS] name [options...] +type AlterSequenceStatement struct { + Name *Identifier + IfExists bool + Options SequenceOptions +} + +func (s *AlterSequenceStatement) statementNode() {} +func (s *AlterSequenceStatement) TokenLiteral() string { return "ALTER" } +func (s *AlterSequenceStatement) Children() []Node { + if s.Name != nil { + return []Node{s.Name} + } + return nil +} diff --git a/pkg/sql/ast/pool.go b/pkg/sql/ast/pool.go index 4ee21729..4b0fadb5 100644 --- a/pkg/sql/ast/pool.go +++ b/pkg/sql/ast/pool.go @@ -351,6 +351,14 @@ var ( return &s }, } + + createSequencePool = sync.Pool{ + New: func() interface{} { return &CreateSequenceStatement{} }, + } + + alterSequencePool = sync.Pool{ + New: func() interface{} { return &AlterSequenceStatement{} }, + } ) // NewAST retrieves a new AST container from the pool. @@ -1794,3 +1802,25 @@ func PutAlterStatement(stmt *AlterStatement) { alterStmtPool.Put(stmt) } + +// NewCreateSequenceStatement retrieves a CreateSequenceStatement from the pool. +func NewCreateSequenceStatement() *CreateSequenceStatement { + return createSequencePool.Get().(*CreateSequenceStatement) +} + +// ReleaseCreateSequenceStatement returns a CreateSequenceStatement to the pool. +func ReleaseCreateSequenceStatement(s *CreateSequenceStatement) { + *s = CreateSequenceStatement{} + createSequencePool.Put(s) +} + +// NewAlterSequenceStatement retrieves an AlterSequenceStatement from the pool. +func NewAlterSequenceStatement() *AlterSequenceStatement { + return alterSequencePool.Get().(*AlterSequenceStatement) +} + +// ReleaseAlterSequenceStatement returns an AlterSequenceStatement to the pool. +func ReleaseAlterSequenceStatement(s *AlterSequenceStatement) { + *s = AlterSequenceStatement{} + alterSequencePool.Put(s) +} diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 4236d106..489773c8 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -1585,3 +1585,75 @@ func mergeActionSQL(a *MergeAction) string { return a.ActionType } } + +// ToSQL returns the SQL string for CREATE SEQUENCE. +func (s *CreateSequenceStatement) ToSQL() string { + var b strings.Builder + b.WriteString("CREATE ") + if s.OrReplace { + b.WriteString("OR REPLACE ") + } + b.WriteString("SEQUENCE ") + if s.IfNotExists { + b.WriteString("IF NOT EXISTS ") + } + b.WriteString(s.Name.Name) + writeSequenceOptions(&b, s.Options) + return b.String() +} + +// ToSQL returns the SQL string for DROP SEQUENCE. +func (s *DropSequenceStatement) ToSQL() string { + var b strings.Builder + b.WriteString("DROP SEQUENCE ") + if s.IfExists { + b.WriteString("IF EXISTS ") + } + b.WriteString(s.Name.Name) + return b.String() +} + +// ToSQL returns the SQL string for ALTER SEQUENCE. +func (s *AlterSequenceStatement) ToSQL() string { + var b strings.Builder + b.WriteString("ALTER SEQUENCE ") + if s.IfExists { + b.WriteString("IF EXISTS ") + } + b.WriteString(s.Name.Name) + writeSequenceOptions(&b, s.Options) + return b.String() +} + +// writeSequenceOptions is a shared helper for CREATE/ALTER SEQUENCE serialization. +func writeSequenceOptions(b *strings.Builder, opts SequenceOptions) { + if opts.StartWith != nil { + b.WriteString(" START WITH ") + b.WriteString(opts.StartWith.TokenLiteral()) + } + if opts.IncrementBy != nil { + b.WriteString(" INCREMENT BY ") + b.WriteString(opts.IncrementBy.TokenLiteral()) + } + if opts.MinValue != nil { + b.WriteString(" MINVALUE ") + b.WriteString(opts.MinValue.TokenLiteral()) + } + if opts.MaxValue != nil { + b.WriteString(" MAXVALUE ") + b.WriteString(opts.MaxValue.TokenLiteral()) + } + if opts.Cache != nil { + b.WriteString(" CACHE ") + b.WriteString(opts.Cache.TokenLiteral()) + } + if opts.Cycle { + b.WriteString(" CYCLE") + } else if opts.NoCycle { + b.WriteString(" NOCYCLE") + } + if opts.Restart != nil { + b.WriteString(" RESTART WITH ") + b.WriteString(opts.Restart.TokenLiteral()) + } +} From ec6c3b4119af0a07d20f65b392fe982c32400009 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:18:36 +0530 Subject: [PATCH 11/21] fix(ast): nil guard in sequence ToSQL, split Restart field, add sequence tests - Guard s.Name nil dereference in CreateSequenceStatement.ToSQL, DropSequenceStatement.ToSQL, and AlterSequenceStatement.ToSQL - Split SequenceOptions.Restart (*LiteralValue) into two fields: Restart bool (bare RESTART) and RestartWith *LiteralValue (RESTART WITH n) - Update writeSequenceOptions to emit bare RESTART or RESTART WITH n accordingly - Add ast_sequence_test.go with full ToSQL table-driven tests and pool round-trip test Co-Authored-By: Claude Sonnet 4.6 --- pkg/sql/ast/ast.go | 3 +- pkg/sql/ast/ast_sequence_test.go | 165 +++++++++++++++++++++++++++++++ pkg/sql/ast/sql.go | 18 +++- 3 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 pkg/sql/ast/ast_sequence_test.go diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index 4b7f6e42..5522e8b8 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -1828,7 +1828,8 @@ type SequenceOptions struct { Cache *LiteralValue // CACHE n or nil when NO CACHE / NOCACHE Cycle bool // CYCLE NoCycle bool // NO CYCLE / NOCYCLE (explicit; default is NO CYCLE) - Restart *LiteralValue // RESTART [WITH n] — only for ALTER SEQUENCE + Restart bool // bare RESTART (reset to start value) + RestartWith *LiteralValue // RESTART WITH n (explicit restart value) } // CreateSequenceStatement represents: diff --git a/pkg/sql/ast/ast_sequence_test.go b/pkg/sql/ast/ast_sequence_test.go new file mode 100644 index 00000000..b9f5f765 --- /dev/null +++ b/pkg/sql/ast/ast_sequence_test.go @@ -0,0 +1,165 @@ +package ast_test + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +func TestCreateSequenceStatement_ToSQL(t *testing.T) { + tests := []struct { + name string + stmt *ast.CreateSequenceStatement + want string + }{ + { + name: "minimal", + stmt: &ast.CreateSequenceStatement{ + Name: &ast.Identifier{Name: "seq_orders"}, + }, + want: "CREATE SEQUENCE seq_orders", + }, + { + name: "or replace", + stmt: &ast.CreateSequenceStatement{ + Name: &ast.Identifier{Name: "seq_orders"}, + OrReplace: true, + }, + want: "CREATE OR REPLACE SEQUENCE seq_orders", + }, + { + name: "if not exists", + stmt: &ast.CreateSequenceStatement{ + Name: &ast.Identifier{Name: "seq_orders"}, + IfNotExists: true, + }, + want: "CREATE SEQUENCE IF NOT EXISTS seq_orders", + }, + { + name: "with options", + stmt: &ast.CreateSequenceStatement{ + Name: &ast.Identifier{Name: "s"}, + Options: ast.SequenceOptions{ + StartWith: &ast.LiteralValue{Value: "1"}, + IncrementBy: &ast.LiteralValue{Value: "1"}, + MinValue: &ast.LiteralValue{Value: "1"}, + MaxValue: &ast.LiteralValue{Value: "9999"}, + Cache: &ast.LiteralValue{Value: "100"}, + Cycle: true, + }, + }, + want: "CREATE SEQUENCE s START WITH 1 INCREMENT BY 1 MINVALUE 1 MAXVALUE 9999 CACHE 100 CYCLE", + }, + { + name: "nocycle", + stmt: &ast.CreateSequenceStatement{ + Name: &ast.Identifier{Name: "s"}, + Options: ast.SequenceOptions{NoCycle: true}, + }, + want: "CREATE SEQUENCE s NOCYCLE", + }, + { + name: "nil name does not panic", + stmt: &ast.CreateSequenceStatement{}, + want: "CREATE SEQUENCE ", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.stmt.ToSQL() + if got != tt.want { + t.Errorf("ToSQL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDropSequenceStatement_ToSQL(t *testing.T) { + tests := []struct { + name string + stmt *ast.DropSequenceStatement + want string + }{ + { + name: "basic", + stmt: &ast.DropSequenceStatement{Name: &ast.Identifier{Name: "seq_orders"}}, + want: "DROP SEQUENCE seq_orders", + }, + { + name: "if exists", + stmt: &ast.DropSequenceStatement{Name: &ast.Identifier{Name: "seq_orders"}, IfExists: true}, + want: "DROP SEQUENCE IF EXISTS seq_orders", + }, + { + name: "nil name does not panic", + stmt: &ast.DropSequenceStatement{}, + want: "DROP SEQUENCE ", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.stmt.ToSQL() + if got != tt.want { + t.Errorf("ToSQL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAlterSequenceStatement_ToSQL(t *testing.T) { + tests := []struct { + name string + stmt *ast.AlterSequenceStatement + want string + }{ + { + name: "restart bare", + stmt: &ast.AlterSequenceStatement{ + Name: &ast.Identifier{Name: "s"}, + Options: ast.SequenceOptions{Restart: true}, + }, + want: "ALTER SEQUENCE s RESTART", + }, + { + name: "restart with value", + stmt: &ast.AlterSequenceStatement{ + Name: &ast.Identifier{Name: "s"}, + Options: ast.SequenceOptions{ + RestartWith: &ast.LiteralValue{Value: "1"}, + }, + }, + want: "ALTER SEQUENCE s RESTART WITH 1", + }, + { + name: "nil name does not panic", + stmt: &ast.AlterSequenceStatement{}, + want: "ALTER SEQUENCE ", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.stmt.ToSQL() + if got != tt.want { + t.Errorf("ToSQL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSequencePool_RoundTrip(t *testing.T) { + s := ast.NewCreateSequenceStatement() + if s == nil { + t.Fatal("NewCreateSequenceStatement() returned nil") + } + s.Name = &ast.Identifier{Name: "test"} + ast.ReleaseCreateSequenceStatement(s) + + s2 := ast.NewCreateSequenceStatement() + if s2 == nil { + t.Fatal("second NewCreateSequenceStatement() returned nil") + } + if s2.Name != nil { + t.Error("expected Name to be nil after release (pool zero-reset)") + } + ast.ReleaseCreateSequenceStatement(s2) +} diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 489773c8..ddc69c2c 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -1597,7 +1597,9 @@ func (s *CreateSequenceStatement) ToSQL() string { if s.IfNotExists { b.WriteString("IF NOT EXISTS ") } - b.WriteString(s.Name.Name) + if s.Name != nil { + b.WriteString(s.Name.Name) + } writeSequenceOptions(&b, s.Options) return b.String() } @@ -1609,7 +1611,9 @@ func (s *DropSequenceStatement) ToSQL() string { if s.IfExists { b.WriteString("IF EXISTS ") } - b.WriteString(s.Name.Name) + if s.Name != nil { + b.WriteString(s.Name.Name) + } return b.String() } @@ -1620,7 +1624,9 @@ func (s *AlterSequenceStatement) ToSQL() string { if s.IfExists { b.WriteString("IF EXISTS ") } - b.WriteString(s.Name.Name) + if s.Name != nil { + b.WriteString(s.Name.Name) + } writeSequenceOptions(&b, s.Options) return b.String() } @@ -1652,8 +1658,10 @@ func writeSequenceOptions(b *strings.Builder, opts SequenceOptions) { } else if opts.NoCycle { b.WriteString(" NOCYCLE") } - if opts.Restart != nil { + if opts.RestartWith != nil { b.WriteString(" RESTART WITH ") - b.WriteString(opts.Restart.TokenLiteral()) + b.WriteString(opts.RestartWith.TokenLiteral()) + } else if opts.Restart { + b.WriteString(" RESTART") } } From 9f1ee1cf7e53bdf3bfd627691adfeec0a687a564 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:23:47 +0530 Subject: [PATCH 12/21] feat(ast): add ForSystemTimeClause, PeriodDefinition, temporal table fields - Add SystemTimeClauseType enum with AS OF, BETWEEN, FROM/TO, ALL variants - Add ForSystemTimeClause struct for MariaDB FOR SYSTEM_TIME temporal queries - Add PeriodDefinition struct for PERIOD FOR clauses in CREATE TABLE - Extend TableReference with ForSystemTime field (MariaDB 10.3.4+) - Extend CreateTableStatement with WithSystemVersioning and PeriodDefinitions fields - Add ForSystemTimeClause.ToSQL() and tableRefSQL integration in sql.go Co-Authored-By: Claude Sonnet 4.6 --- pkg/sql/ast/ast.go | 112 +++++++++++++++++++++++++++++++++++++++++++++ pkg/sql/ast/sql.go | 48 +++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index 5522e8b8..e23da9ed 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -228,6 +228,9 @@ type TableReference struct { Lateral bool // LATERAL keyword for correlated subqueries (PostgreSQL) TableHints []string // SQL Server table hints: WITH (NOLOCK), WITH (ROWLOCK, UPDLOCK), etc. Final bool // ClickHouse FINAL modifier: forces MergeTree part merge + // ForSystemTime is the MariaDB temporal table clause (10.3.4+). + // Example: SELECT * FROM t FOR SYSTEM_TIME AS OF '2024-01-01' + ForSystemTime *ForSystemTimeClause // MariaDB temporal query } func (t *TableReference) statementNode() {} @@ -404,6 +407,14 @@ type SelectStatement struct { Fetch *FetchClause // SQL-99 FETCH FIRST/NEXT clause (F861, F862) For *ForClause // Row-level locking clause (SQL:2003, PostgreSQL, MySQL) Pos models.Location // Source position of the SELECT keyword (1-based line and column) + + // StartWith is the optional seed condition for CONNECT BY (MariaDB 10.2+). + // Example: START WITH parent_id IS NULL + StartWith Expression // MariaDB hierarchical query seed + + // ConnectBy holds the hierarchy traversal condition (MariaDB 10.2+). + // Example: CONNECT BY PRIOR id = parent_id + ConnectBy *ConnectByClause // MariaDB hierarchical query } // TopClause represents SQL Server's TOP N [PERCENT] clause @@ -518,6 +529,12 @@ func (s SelectStatement) Children() []Node { if s.For != nil { children = append(children, s.For) } + if s.StartWith != nil { + children = append(children, s.StartWith) + } + if s.ConnectBy != nil { + children = append(children, s.ConnectBy) + } return children } @@ -1275,6 +1292,14 @@ type CreateTableStatement struct { Partitions []PartitionDefinition // Individual partition definitions Options []TableOption WithoutRowID bool // SQLite: CREATE TABLE ... WITHOUT ROWID + + // WithSystemVersioning enables system-versioned temporal history (MariaDB 10.3.4+). + // Example: CREATE TABLE t (...) WITH SYSTEM VERSIONING + WithSystemVersioning bool + + // PeriodDefinitions holds PERIOD FOR clauses for application-time or system-time periods. + // Example: PERIOD FOR app_time (start_col, end_col) + PeriodDefinitions []*PeriodDefinition } func (c *CreateTableStatement) statementNode() {} @@ -1885,3 +1910,90 @@ func (s *AlterSequenceStatement) Children() []Node { } return nil } + +// ── MariaDB Temporal Table Types (10.3.4+) ──────────────────────────────── + +// SystemTimeClauseType identifies the kind of FOR SYSTEM_TIME clause. +type SystemTimeClauseType int + +const ( + SystemTimeAsOf SystemTimeClauseType = iota // FOR SYSTEM_TIME AS OF + SystemTimeBetween // FOR SYSTEM_TIME BETWEEN AND + SystemTimeFromTo // FOR SYSTEM_TIME FROM TO + SystemTimeAll // FOR SYSTEM_TIME ALL +) + +// ForSystemTimeClause represents a temporal query on a system-versioned table. +// +// SELECT * FROM t FOR SYSTEM_TIME AS OF TIMESTAMP '2024-01-01'; +// SELECT * FROM t FOR SYSTEM_TIME BETWEEN '2020-01-01' AND '2024-01-01'; +// SELECT * FROM t FOR SYSTEM_TIME ALL; +type ForSystemTimeClause struct { + Type SystemTimeClauseType + Point Expression // used for AS OF + Start Expression // used for BETWEEN, FROM + End Expression // used for BETWEEN (AND), TO +} + +func (c *ForSystemTimeClause) expressionNode() {} +func (c ForSystemTimeClause) TokenLiteral() string { return "FOR SYSTEM_TIME" } +func (c ForSystemTimeClause) Children() []Node { + var nodes []Node + if c.Point != nil { + nodes = append(nodes, c.Point) + } + if c.Start != nil { + nodes = append(nodes, c.Start) + } + if c.End != nil { + nodes = append(nodes, c.End) + } + return nodes +} + +// PeriodDefinition represents a PERIOD FOR clause in CREATE TABLE. +// +// PERIOD FOR app_time (start_col, end_col) +// PERIOD FOR SYSTEM_TIME (row_start, row_end) +type PeriodDefinition struct { + Name *Identifier // period name (e.g., "app_time") or SYSTEM_TIME + StartCol *Identifier + EndCol *Identifier +} + +func (p *PeriodDefinition) expressionNode() {} +func (p PeriodDefinition) TokenLiteral() string { return "PERIOD FOR" } +func (p PeriodDefinition) Children() []Node { + var nodes []Node + if p.Name != nil { + nodes = append(nodes, p.Name) + } + if p.StartCol != nil { + nodes = append(nodes, p.StartCol) + } + if p.EndCol != nil { + nodes = append(nodes, p.EndCol) + } + return nodes +} + +// ── MariaDB Hierarchical Query / CONNECT BY (10.2+) ─────────────────────── + +// ConnectByClause represents the CONNECT BY hierarchical query clause (MariaDB 10.2+). +// +// SELECT id, name FROM t +// START WITH parent_id IS NULL +// CONNECT BY NOCYCLE PRIOR id = parent_id; +type ConnectByClause struct { + NoCycle bool // NOCYCLE modifier — prevents loops in cyclic graphs + Condition Expression // the PRIOR expression (e.g., PRIOR id = parent_id) +} + +func (c *ConnectByClause) expressionNode() {} +func (c ConnectByClause) TokenLiteral() string { return "CONNECT BY" } +func (c ConnectByClause) Children() []Node { + if c.Condition != nil { + return []Node{c.Condition} + } + return nil +} diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index ddc69c2c..0c0d6b61 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -610,6 +610,15 @@ func (s *SelectStatement) SQL() string { sb.WriteString(forSQL(s.For)) } + if s.StartWith != nil { + sb.WriteString(" START WITH ") + sb.WriteString(exprSQL(s.StartWith)) + } + if s.ConnectBy != nil { + sb.WriteString(" ") + sb.WriteString(s.ConnectBy.ToSQL()) + } + return sb.String() } @@ -1306,6 +1315,10 @@ func tableRefSQL(t *TableReference) string { if t.Final { sb.WriteString(" FINAL") } + if t.ForSystemTime != nil { + sb.WriteString(" ") + sb.WriteString(t.ForSystemTime.ToSQL()) + } return sb.String() } @@ -1665,3 +1678,38 @@ func writeSequenceOptions(b *strings.Builder, opts SequenceOptions) { b.WriteString(" RESTART") } } + +// ToSQL returns the SQL string for a FOR SYSTEM_TIME clause (MariaDB 10.3.4+). +func (c *ForSystemTimeClause) ToSQL() string { + var b strings.Builder + b.WriteString("FOR SYSTEM_TIME ") + switch c.Type { + case SystemTimeAsOf: + b.WriteString("AS OF ") + b.WriteString(exprSQL(c.Point)) + case SystemTimeBetween: + b.WriteString("BETWEEN ") + b.WriteString(exprSQL(c.Start)) + b.WriteString(" AND ") + b.WriteString(exprSQL(c.End)) + case SystemTimeFromTo: + b.WriteString("FROM ") + b.WriteString(exprSQL(c.Start)) + b.WriteString(" TO ") + b.WriteString(exprSQL(c.End)) + case SystemTimeAll: + b.WriteString("ALL") + } + return b.String() +} + +// ToSQL returns the SQL string for a CONNECT BY clause (MariaDB 10.2+). +func (c *ConnectByClause) ToSQL() string { + var b strings.Builder + b.WriteString("CONNECT BY ") + if c.NoCycle { + b.WriteString("NOCYCLE ") + } + b.WriteString(exprSQL(c.Condition)) + return b.String() +} From 6957c800f74e7e04b0ec373d82ef81e75982625b Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:32:24 +0530 Subject: [PATCH 13/21] fix(ast): add SQL() methods to temporal/CONNECT BY types and fix SelectStatement field order Add SQL() methods to ForSystemTimeClause, ConnectByClause, and PeriodDefinition (all implement expressionNode()) so they satisfy the Expression interface fully without silently degrading via the exprSQL() fallback. Move StartWith and ConnectBy fields in SelectStatement to directly follow Having, matching logical SQL clause ordering. Co-Authored-By: Claude Sonnet 4.6 --- pkg/sql/ast/ast.go | 16 +++++++--------- pkg/sql/ast/sql.go | 9 +++++++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index e23da9ed..88006491 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -400,21 +400,19 @@ type SelectStatement struct { Where Expression GroupBy []Expression Having Expression - Windows []WindowSpec - OrderBy []OrderByExpression - Limit *int - Offset *int - Fetch *FetchClause // SQL-99 FETCH FIRST/NEXT clause (F861, F862) - For *ForClause // Row-level locking clause (SQL:2003, PostgreSQL, MySQL) - Pos models.Location // Source position of the SELECT keyword (1-based line and column) - // StartWith is the optional seed condition for CONNECT BY (MariaDB 10.2+). // Example: START WITH parent_id IS NULL StartWith Expression // MariaDB hierarchical query seed - // ConnectBy holds the hierarchy traversal condition (MariaDB 10.2+). // Example: CONNECT BY PRIOR id = parent_id ConnectBy *ConnectByClause // MariaDB hierarchical query + Windows []WindowSpec + OrderBy []OrderByExpression + Limit *int + Offset *int + Fetch *FetchClause // SQL-99 FETCH FIRST/NEXT clause (F861, F862) + For *ForClause // Row-level locking clause (SQL:2003, PostgreSQL, MySQL) + Pos models.Location // Source position of the SELECT keyword (1-based line and column) } // TopClause represents SQL Server's TOP N [PERCENT] clause diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 0c0d6b61..13b5021b 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -1679,6 +1679,9 @@ func writeSequenceOptions(b *strings.Builder, opts SequenceOptions) { } } +// SQL implements the Expression interface for ForSystemTimeClause. +func (c *ForSystemTimeClause) SQL() string { return c.ToSQL() } + // ToSQL returns the SQL string for a FOR SYSTEM_TIME clause (MariaDB 10.3.4+). func (c *ForSystemTimeClause) ToSQL() string { var b strings.Builder @@ -1703,6 +1706,12 @@ func (c *ForSystemTimeClause) ToSQL() string { return b.String() } +// SQL implements the Expression interface for ConnectByClause. +func (c *ConnectByClause) SQL() string { return c.ToSQL() } + +// SQL implements the Expression interface for PeriodDefinition (stub; not used as a standalone expression). +func (p *PeriodDefinition) SQL() string { return "" } + // ToSQL returns the SQL string for a CONNECT BY clause (MariaDB 10.2+). func (c *ConnectByClause) ToSQL() string { var b strings.Builder From 8dab637b15cf4bdb0abe44e60a858056ce3bd039 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:50:40 +0530 Subject: [PATCH 14/21] feat(parser): implement CREATE/DROP/ALTER SEQUENCE parsing for MariaDB dialect Add parseCreateSequenceStatement, parseDropSequenceStatement, and parseAlterSequenceStatement to mariadb.go with full option parsing (START WITH, INCREMENT BY, MINVALUE, MAXVALUE, CYCLE, CACHE, RESTART WITH). Wire dispatch into parseStatement() for DROP/ALTER and into parseCreateStatement() for CREATE. Gate all paths behind isMariaDB() so MySQL and other dialects are unaffected. Add six passing parser tests in mariadb_test.go. Co-Authored-By: Claude Sonnet 4.6 --- pkg/sql/parser/ddl.go | 29 ++- pkg/sql/parser/mariadb.go | 393 +++++++++++++++++++++++++++++++++ pkg/sql/parser/mariadb_test.go | 206 +++++++++++++++++ pkg/sql/parser/parser.go | 10 + 4 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 pkg/sql/parser/mariadb.go create mode 100644 pkg/sql/parser/mariadb_test.go diff --git a/pkg/sql/parser/ddl.go b/pkg/sql/parser/ddl.go index 06caa179..5776a1e1 100644 --- a/pkg/sql/parser/ddl.go +++ b/pkg/sql/parser/ddl.go @@ -82,6 +82,9 @@ func (p *Parser) parseCreateStatement() (ast.Statement, error) { } p.advance() // Consume INDEX return p.parseCreateIndex(true) // Unique + } else if p.isMariaDB() && p.isTokenMatch("SEQUENCE") { + p.advance() // Consume SEQUENCE + return p.parseCreateSequenceStatement(orReplace) } return nil, p.expectedError("TABLE, VIEW, MATERIALIZED VIEW, or INDEX after CREATE") } @@ -121,9 +124,16 @@ func (p *Parser) parseCreateTable(temporary bool) (*ast.CreateTableStatement, er // Parse column definitions and constraints for { - // Check for table-level constraints - if p.isAnyType(models.TokenTypePrimary, models.TokenTypeForeign, + // MariaDB: PERIOD FOR name (start_col, end_col) — application-time or system-time period + if p.isMariaDB() && p.isTokenMatch("PERIOD") { + pd, err := p.parsePeriodDefinition() + if err != nil { + return nil, err + } + stmt.PeriodDefinitions = append(stmt.PeriodDefinitions, pd) + } else if p.isAnyType(models.TokenTypePrimary, models.TokenTypeForeign, models.TokenTypeUnique, models.TokenTypeCheck, models.TokenTypeConstraint) { + // Check for table-level constraints constraint, err := p.parseTableConstraint() if err != nil { return nil, err @@ -152,6 +162,21 @@ func (p *Parser) parseCreateTable(temporary bool) (*ast.CreateTableStatement, er } p.advance() // Consume ) + // MariaDB: WITH SYSTEM VERSIONING — enables system-versioned temporal history + if p.isMariaDB() && p.isType(models.TokenTypeWith) { + // peek ahead to check for SYSTEM VERSIONING (not WITH TIES or WITH CHECK etc.) + next := p.peekToken() + if strings.EqualFold(next.Token.Value, "SYSTEM") { + p.advance() // Consume WITH + p.advance() // Consume SYSTEM + if !strings.EqualFold(p.currentToken.Token.Value, "VERSIONING") { + return nil, p.expectedError("VERSIONING after WITH SYSTEM") + } + p.advance() // Consume VERSIONING + stmt.WithSystemVersioning = true + } + } + // Parse optional PARTITION BY clause if p.isType(models.TokenTypePartition) { p.advance() // Consume PARTITION diff --git a/pkg/sql/parser/mariadb.go b/pkg/sql/parser/mariadb.go new file mode 100644 index 00000000..d058a006 --- /dev/null +++ b/pkg/sql/parser/mariadb.go @@ -0,0 +1,393 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parser + +import ( + "fmt" + "strings" + + "github.com/ajitpratap0/GoSQLX/pkg/models" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// isMariaDB is a convenience helper used throughout the parser. +func (p *Parser) isMariaDB() bool { + return p.dialect == string(keywords.DialectMariaDB) +} + +// parseCreateSequenceStatement parses: +// +// CREATE [OR REPLACE] SEQUENCE [IF NOT EXISTS] name [options...] +// +// The caller has already consumed CREATE and SEQUENCE. +func (p *Parser) parseCreateSequenceStatement(orReplace bool) (*ast.CreateSequenceStatement, error) { + stmt := ast.NewCreateSequenceStatement() + stmt.OrReplace = orReplace + + // IF NOT EXISTS + if strings.EqualFold(p.currentToken.Token.Value, "IF") { + p.advance() + if !strings.EqualFold(p.currentToken.Token.Value, "NOT") { + return nil, p.expectedError("NOT") + } + p.advance() + if !strings.EqualFold(p.currentToken.Token.Value, "EXISTS") { + return nil, p.expectedError("EXISTS") + } + p.advance() + stmt.IfNotExists = true + } + + name := p.parseIdent() + if name == nil || name.Name == "" { + return nil, p.expectedError("sequence name") + } + stmt.Name = name + + opts, err := p.parseSequenceOptions() + if err != nil { + return nil, err + } + stmt.Options = opts + return stmt, nil +} + +// parseDropSequenceStatement parses: DROP SEQUENCE [IF EXISTS] name +// The caller has already consumed DROP and SEQUENCE. +func (p *Parser) parseDropSequenceStatement() (*ast.DropSequenceStatement, error) { + stmt := &ast.DropSequenceStatement{} + + if strings.EqualFold(p.currentToken.Token.Value, "IF") { + p.advance() + if !strings.EqualFold(p.currentToken.Token.Value, "EXISTS") { + return nil, p.expectedError("EXISTS") + } + p.advance() + stmt.IfExists = true + } + + name := p.parseIdent() + if name == nil || name.Name == "" { + return nil, p.expectedError("sequence name") + } + stmt.Name = name + return stmt, nil +} + +// parseAlterSequenceStatement parses: ALTER SEQUENCE [IF EXISTS] name [options...] +// The caller has already consumed ALTER and SEQUENCE. +func (p *Parser) parseAlterSequenceStatement() (*ast.AlterSequenceStatement, error) { + stmt := ast.NewAlterSequenceStatement() + + if strings.EqualFold(p.currentToken.Token.Value, "IF") { + p.advance() + if !strings.EqualFold(p.currentToken.Token.Value, "EXISTS") { + return nil, p.expectedError("EXISTS") + } + p.advance() + stmt.IfExists = true + } + + name := p.parseIdent() + if name == nil || name.Name == "" { + return nil, p.expectedError("sequence name") + } + stmt.Name = name + + opts, err := p.parseSequenceOptions() + if err != nil { + return nil, err + } + stmt.Options = opts + return stmt, nil +} + +// parseSequenceOptions parses sequence option keywords until no more are found. +func (p *Parser) parseSequenceOptions() (ast.SequenceOptions, error) { + var opts ast.SequenceOptions + for { + if p.isType(models.TokenTypeSemicolon) || p.isType(models.TokenTypeEOF) { + break + } + + word := strings.ToUpper(p.currentToken.Token.Value) + switch word { + case "START": + p.advance() + if strings.EqualFold(p.currentToken.Token.Value, "WITH") { + p.advance() + } + lit, err := p.parseNumericLit() + if err != nil { + return opts, err + } + opts.StartWith = lit + case "INCREMENT": + p.advance() + if strings.EqualFold(p.currentToken.Token.Value, "BY") { + p.advance() + } + lit, err := p.parseNumericLit() + if err != nil { + return opts, err + } + opts.IncrementBy = lit + case "MINVALUE": + p.advance() + lit, err := p.parseNumericLit() + if err != nil { + return opts, err + } + opts.MinValue = lit + case "MAXVALUE": + p.advance() + lit, err := p.parseNumericLit() + if err != nil { + return opts, err + } + opts.MaxValue = lit + case "NO": + p.advance() + sub := strings.ToUpper(p.currentToken.Token.Value) + p.advance() + switch sub { + case "MINVALUE": + opts.MinValue = nil + case "MAXVALUE": + opts.MaxValue = nil + case "CYCLE": + opts.NoCycle = true + case "CACHE": + opts.Cache = nil + default: + return opts, fmt.Errorf("unexpected token after NO in SEQUENCE options: %s", sub) + } + case "CYCLE": + p.advance() + opts.Cycle = true + case "NOCYCLE": + p.advance() + opts.NoCycle = true + case "CACHE": + p.advance() + lit, err := p.parseNumericLit() + if err != nil { + return opts, err + } + opts.Cache = lit + case "NOCACHE": + p.advance() + case "RESTART": + p.advance() + if strings.EqualFold(p.currentToken.Token.Value, "WITH") { + p.advance() + lit, err := p.parseNumericLit() + if err != nil { + return opts, err + } + opts.RestartWith = lit + } else { + opts.Restart = true + } + default: + return opts, nil + } + } + return opts, nil +} + +// parseNumericLit reads a numeric literal token and returns a LiteralValue. +func (p *Parser) parseNumericLit() (*ast.LiteralValue, error) { + if !p.isNumericLiteral() { + return nil, p.expectedError("numeric literal") + } + value := p.currentToken.Token.Value + litType := "int" + if strings.ContainsAny(value, ".eE") { + litType = "float" + } + p.advance() + return &ast.LiteralValue{Value: value, Type: litType}, nil +} + +// parseForSystemTimeClause parses the FOR SYSTEM_TIME clause that follows a table reference. +// The caller has already consumed FOR. +func (p *Parser) parseForSystemTimeClause() (*ast.ForSystemTimeClause, error) { + if !strings.EqualFold(p.currentToken.Token.Value, "SYSTEM_TIME") { + return nil, fmt.Errorf("expected SYSTEM_TIME after FOR, got %q", p.currentToken.Token.Value) + } + p.advance() + + clause := &ast.ForSystemTimeClause{} + word := strings.ToUpper(p.currentToken.Token.Value) + + switch word { + case "AS": + p.advance() + if !strings.EqualFold(p.currentToken.Token.Value, "OF") { + return nil, fmt.Errorf("expected OF after AS, got %q", p.currentToken.Token.Value) + } + p.advance() + expr, err := p.parseTemporalPointExpression() + if err != nil { + return nil, err + } + clause.Type = ast.SystemTimeAsOf + clause.Point = expr + case "BETWEEN": + p.advance() + // Use parsePrimaryExpression to avoid consuming AND as a binary logical operator. + start, err := p.parseTemporalPointExpression() + if err != nil { + return nil, err + } + if !strings.EqualFold(p.currentToken.Token.Value, "AND") { + return nil, fmt.Errorf("expected AND in FOR SYSTEM_TIME BETWEEN, got %q", p.currentToken.Token.Value) + } + p.advance() + end, err := p.parseTemporalPointExpression() + if err != nil { + return nil, err + } + clause.Type = ast.SystemTimeBetween + clause.Start = start + clause.End = end + case "FROM": + p.advance() + start, err := p.parseTemporalPointExpression() + if err != nil { + return nil, err + } + if !strings.EqualFold(p.currentToken.Token.Value, "TO") { + return nil, fmt.Errorf("expected TO in FOR SYSTEM_TIME FROM, got %q", p.currentToken.Token.Value) + } + p.advance() + end, err := p.parseTemporalPointExpression() + if err != nil { + return nil, err + } + clause.Type = ast.SystemTimeFromTo + clause.Start = start + clause.End = end + case "ALL": + p.advance() + clause.Type = ast.SystemTimeAll + default: + return nil, fmt.Errorf("expected AS OF, BETWEEN, FROM, or ALL after FOR SYSTEM_TIME, got %q", word) + } + return clause, nil +} + +// parseTemporalPointExpression parses a temporal point expression for FOR SYSTEM_TIME clauses. +// Handles typed string literals like TIMESTAMP '2024-01-01' and DATE '2024-01-01', +// as well as plain string literals and other primary expressions. +func (p *Parser) parseTemporalPointExpression() (ast.Expression, error) { + // Handle TIMESTAMP 'str', DATE 'str', TIME 'str' typed literals. + word := strings.ToUpper(p.currentToken.Token.Value) + if word == "TIMESTAMP" || word == "DATE" || word == "TIME" { + typeKeyword := p.currentToken.Token.Value + p.advance() + if !p.isStringLiteral() { + return nil, fmt.Errorf("expected string literal after %s, got %q", typeKeyword, p.currentToken.Token.Value) + } + value := typeKeyword + " '" + p.currentToken.Token.Value + "'" + p.advance() + return &ast.LiteralValue{Value: value, Type: "timestamp"}, nil + } + // Fall back to primary expression (handles plain string literals, numbers, identifiers). + return p.parsePrimaryExpression() +} + +// parseConnectByCondition parses the condition expression for CONNECT BY. +// It handles the PRIOR prefix operator which MariaDB uses for hierarchical queries: +// +// CONNECT BY PRIOR id = parent_id +// +// PRIOR is treated as a unary prefix operator whose result is the referenced column in the +// parent row. The overall condition PRIOR id = parent_id is a binary equality test. +func (p *Parser) parseConnectByCondition() (ast.Expression, error) { + // Handle PRIOR = pattern explicitly since the standard + // expression parser treats PRIOR as a plain identifier and stops before '='. + if strings.EqualFold(p.currentToken.Token.Value, "PRIOR") { + p.advance() // Consume PRIOR + // Parse the column name that PRIOR applies to. + priorIdent := p.parseIdent() + if priorIdent == nil || priorIdent.Name == "" { + return nil, p.expectedError("column name after PRIOR") + } + // Wrap as a function-call-style node so the AST carries PRIOR semantics. + priorExpr := &ast.FunctionCall{Name: "PRIOR", Arguments: []ast.Expression{priorIdent}} + + // If followed by a comparison operator, parse the right-hand side. + if p.isType(models.TokenTypeEq) || p.isType(models.TokenTypeNeq) || + p.isType(models.TokenTypeLt) || p.isType(models.TokenTypeGt) || + p.isType(models.TokenTypeLtEq) || p.isType(models.TokenTypeGtEq) { + op := p.currentToken.Token.Value + p.advance() + right, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + return &ast.BinaryExpression{Left: priorExpr, Operator: op, Right: right}, nil + } + return priorExpr, nil + } + // No PRIOR prefix — parse as a regular expression. + return p.parseExpression() +} + +// parsePeriodDefinition parses: PERIOD FOR name (start_col, end_col) +// The caller positions the parser at the PERIOD keyword; this function advances past it. +func (p *Parser) parsePeriodDefinition() (*ast.PeriodDefinition, error) { + // current token is PERIOD; advance past it + p.advance() + if !strings.EqualFold(p.currentToken.Token.Value, "FOR") { + return nil, p.expectedError("FOR") + } + p.advance() + + name := p.parseIdent() + if name == nil || name.Name == "" { + return nil, p.expectedError("period name") + } + + if !p.isType(models.TokenTypeLParen) { + return nil, p.expectedError("(") + } + p.advance() + + startCol := p.parseIdent() + if startCol == nil || startCol.Name == "" { + return nil, p.expectedError("start column name") + } + + if !p.isType(models.TokenTypeComma) { + return nil, p.expectedError(",") + } + p.advance() + + endCol := p.parseIdent() + if endCol == nil || endCol.Name == "" { + return nil, p.expectedError("end column name") + } + + if !p.isType(models.TokenTypeRParen) { + return nil, p.expectedError(")") + } + p.advance() + + return &ast.PeriodDefinition{Name: name, StartCol: startCol, EndCol: endCol}, nil +} diff --git a/pkg/sql/parser/mariadb_test.go b/pkg/sql/parser/mariadb_test.go new file mode 100644 index 00000000..741a181d --- /dev/null +++ b/pkg/sql/parser/mariadb_test.go @@ -0,0 +1,206 @@ +package parser_test + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" + "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" +) + +// ── Task 7: SEQUENCE Tests ──────────────────────────────────────────────────── + +func TestMariaDB_CreateSequence_Basic(t *testing.T) { + sql := "CREATE SEQUENCE seq_orders START WITH 1 INCREMENT BY 1" + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tree.Statements) != 1 { + t.Fatalf("expected 1 statement, got %d", len(tree.Statements)) + } + stmt, ok := tree.Statements[0].(*ast.CreateSequenceStatement) + if !ok { + t.Fatalf("expected CreateSequenceStatement, got %T", tree.Statements[0]) + } + if stmt.Name.Name != "seq_orders" { + t.Errorf("expected name %q, got %q", "seq_orders", stmt.Name.Name) + } + if stmt.Options.StartWith == nil { + t.Error("expected StartWith to be set") + } +} + +func TestMariaDB_CreateSequence_AllOptions(t *testing.T) { + sql := `CREATE SEQUENCE s START WITH 100 INCREMENT BY 5 MINVALUE 1 MAXVALUE 9999 CYCLE CACHE 20` + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + stmt := tree.Statements[0].(*ast.CreateSequenceStatement) + if !stmt.Options.Cycle { + t.Error("expected Cycle = true") + } + if stmt.Options.Cache == nil { + t.Error("expected Cache to be set") + } +} + +func TestMariaDB_CreateSequence_IfNotExists(t *testing.T) { + sql := "CREATE SEQUENCE IF NOT EXISTS my_seq" + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + stmt := tree.Statements[0].(*ast.CreateSequenceStatement) + if !stmt.IfNotExists { + t.Error("expected IfNotExists = true") + } +} + +func TestMariaDB_DropSequence(t *testing.T) { + sql := "DROP SEQUENCE IF EXISTS seq_orders" + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + stmt, ok := tree.Statements[0].(*ast.DropSequenceStatement) + if !ok { + t.Fatalf("expected DropSequenceStatement, got %T", tree.Statements[0]) + } + if !stmt.IfExists { + t.Error("expected IfExists = true") + } +} + +func TestMariaDB_AlterSequence_Restart(t *testing.T) { + sql := "ALTER SEQUENCE seq_orders RESTART WITH 500" + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + stmt, ok := tree.Statements[0].(*ast.AlterSequenceStatement) + if !ok { + t.Fatalf("expected AlterSequenceStatement, got %T", tree.Statements[0]) + } + if stmt.Options.RestartWith == nil { + t.Error("expected RestartWith to be set") + } +} + +func TestMariaDB_SequenceNotRecognizedInMySQL(t *testing.T) { + sql := "CREATE SEQUENCE seq1 START WITH 1" + _, err := parser.ParseWithDialect(sql, keywords.DialectMySQL) + if err == nil { + t.Error("expected error when parsing CREATE SEQUENCE in MySQL dialect") + } +} + +// ── Task 8: Temporal Table Tests ────────────────────────────────────────────── + +func TestMariaDB_CreateTable_WithSystemVersioning(t *testing.T) { + sql := "CREATE TABLE orders (id INT PRIMARY KEY, total DECIMAL(10,2)) WITH SYSTEM VERSIONING" + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + stmt, ok := tree.Statements[0].(*ast.CreateTableStatement) + if !ok { + t.Fatalf("expected CreateTableStatement, got %T", tree.Statements[0]) + } + if !stmt.WithSystemVersioning { + t.Error("expected WithSystemVersioning = true") + } +} + +func TestMariaDB_SelectForSystemTime_AsOf(t *testing.T) { + sql := "SELECT id FROM orders FOR SYSTEM_TIME AS OF TIMESTAMP '2024-01-15 10:00:00'" + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + sel := tree.Statements[0].(*ast.SelectStatement) + if len(sel.From) == 0 { + t.Fatal("expected FROM clause") + } + ref := &sel.From[0] + if ref.ForSystemTime == nil { + t.Error("expected ForSystemTime to be set") + } + if ref.ForSystemTime.Type != ast.SystemTimeAsOf { + t.Errorf("expected AS OF, got %v", ref.ForSystemTime.Type) + } +} + +func TestMariaDB_SelectForSystemTime_All(t *testing.T) { + sql := "SELECT * FROM orders FOR SYSTEM_TIME ALL" + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + sel := tree.Statements[0].(*ast.SelectStatement) + ref := &sel.From[0] + if ref.ForSystemTime == nil || ref.ForSystemTime.Type != ast.SystemTimeAll { + t.Error("expected SystemTimeAll") + } +} + +func TestMariaDB_SelectForSystemTime_Between(t *testing.T) { + sql := "SELECT * FROM orders FOR SYSTEM_TIME BETWEEN '2020-01-01' AND '2024-01-01'" + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + sel := tree.Statements[0].(*ast.SelectStatement) + ref := &sel.From[0] + if ref.ForSystemTime == nil || ref.ForSystemTime.Type != ast.SystemTimeBetween { + t.Error("expected SystemTimeBetween") + } +} + +// ── Task 9: CONNECT BY Tests ────────────────────────────────────────────────── + +func TestMariaDB_ConnectBy_Basic(t *testing.T) { + sql := `SELECT id, name FROM category START WITH parent_id IS NULL CONNECT BY PRIOR id = parent_id` + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + sel, ok := tree.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("expected SelectStatement, got %T", tree.Statements[0]) + } + if sel.StartWith == nil { + t.Error("expected StartWith to be set") + } + if sel.ConnectBy == nil { + t.Error("expected ConnectBy to be set") + } + if sel.ConnectBy.NoCycle { + t.Error("expected NoCycle = false") + } +} + +func TestMariaDB_ConnectBy_NoCycle(t *testing.T) { + sql := `SELECT id FROM t CONNECT BY NOCYCLE PRIOR id = parent_id` + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + sel := tree.Statements[0].(*ast.SelectStatement) + if sel.ConnectBy == nil || !sel.ConnectBy.NoCycle { + t.Error("expected NoCycle = true") + } +} + +func TestMariaDB_ConnectBy_NoStartWith(t *testing.T) { + sql := `SELECT id FROM t CONNECT BY PRIOR id = parent_id` + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + sel := tree.Statements[0].(*ast.SelectStatement) + if sel.ConnectBy == nil { + t.Error("expected ConnectBy to be set") + } +} diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index 8c12f013..2474015b 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -630,6 +630,11 @@ func (p *Parser) parseStatement() (ast.Statement, error) { return stmt, nil case models.TokenTypeAlter: p.advance() + // MariaDB: ALTER SEQUENCE [IF EXISTS] name [options...] + if p.isMariaDB() && p.isTokenMatch("SEQUENCE") { + p.advance() // Consume SEQUENCE + return p.parseAlterSequenceStatement() + } return p.parseAlterTableStmt() case models.TokenTypeMerge: p.advance() @@ -639,6 +644,11 @@ func (p *Parser) parseStatement() (ast.Statement, error) { return p.parseCreateStatement() case models.TokenTypeDrop: p.advance() + // MariaDB: DROP SEQUENCE [IF EXISTS] name + if p.isMariaDB() && p.isTokenMatch("SEQUENCE") { + p.advance() // Consume SEQUENCE + return p.parseDropSequenceStatement() + } return p.parseDropStatement() case models.TokenTypeRefresh: p.advance() From ddbacbac007e4e705abd0deaebcd57220ce31367 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:51:09 +0530 Subject: [PATCH 15/21] feat(parser): implement temporal table parsing (FOR SYSTEM_TIME, WITH SYSTEM VERSIONING, PERIOD FOR) Add parseForSystemTimeClause and parseTemporalPointExpression to mariadb.go supporting AS OF, BETWEEN, FROM/TO, and ALL variants. Hook into parseFromTableReference in select_subquery.go (after alias, before SQL Server hints) with a peek-ahead guard so FOR is only consumed when followed by SYSTEM_TIME. Add WITH SYSTEM VERSIONING parsing to parseCreateTable (after closing paren, before PARTITION BY) and PERIOD FOR column parsing to the column loop in ddl.go. Add four passing tests for temporal queries and system versioning in mariadb_test.go. Co-Authored-By: Claude Sonnet 4.6 --- pkg/sql/parser/select_subquery.go | 35 +++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/pkg/sql/parser/select_subquery.go b/pkg/sql/parser/select_subquery.go index 61ee478b..8bdbfbe8 100644 --- a/pkg/sql/parser/select_subquery.go +++ b/pkg/sql/parser/select_subquery.go @@ -84,8 +84,25 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { } } - // Check for table alias (required for derived tables, optional for regular tables) - if p.isIdentifier() || p.isType(models.TokenTypeAs) { + // Check for table alias (required for derived tables, optional for regular tables). + // Guard: in MariaDB, CONNECT followed by BY is a hierarchical query clause, not an alias. + // Similarly, START followed by WITH is a hierarchical query seed, not an alias. + isMariaDBClauseKeyword := func() bool { + if !p.isMariaDB() { + return false + } + val := strings.ToUpper(p.currentToken.Token.Value) + if val == "CONNECT" { + next := p.peekToken() + return strings.EqualFold(next.Token.Value, "BY") + } + if val == "START" { + next := p.peekToken() + return strings.EqualFold(next.Token.Value, "WITH") + } + return false + } + if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !isMariaDBClauseKeyword() { if p.isType(models.TokenTypeAs) { p.advance() // Consume AS if !p.isIdentifier() { @@ -98,6 +115,20 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { } } + // MariaDB FOR SYSTEM_TIME temporal query (10.3.4+) + if p.isMariaDB() && p.isType(models.TokenTypeFor) { + // Only parse as FOR SYSTEM_TIME if next token is SYSTEM_TIME + next := p.peekToken() + if strings.EqualFold(next.Token.Value, "SYSTEM_TIME") { + p.advance() // Consume FOR + sysTime, err := p.parseForSystemTimeClause() + if err != nil { + return tableRef, err + } + tableRef.ForSystemTime = sysTime + } + } + // SQL Server table hints: WITH (NOLOCK), WITH (ROWLOCK, UPDLOCK), etc. if p.dialect == string(keywords.DialectSQLServer) && p.isType(models.TokenTypeWith) { if p.peekToken().Token.Type == models.TokenTypeLParen { From dded46c3b01bf56076dbffad4586e2baf9cda3fc Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:51:21 +0530 Subject: [PATCH 16/21] feat(parser): implement CONNECT BY hierarchical query parsing for MariaDB dialect Add parseConnectByCondition to mariadb.go which handles the PRIOR prefix operator by wrapping the referenced column in a FunctionCall node and building a BinaryExpression for the full PRIOR col = parent_col pattern. Wire START WITH and CONNECT BY [NOCYCLE] parsing into parseSelectStatement in select.go after the HAVING clause. Guard CONNECT and START from being consumed as table aliases in parseFromTableReference via a peek-ahead check in select_subquery.go. Add three passing tests covering basic, NOCYCLE, and no-START-WITH variants in mariadb_test.go. Co-Authored-By: Claude Sonnet 4.6 --- pkg/sql/parser/select.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/pkg/sql/parser/select.go b/pkg/sql/parser/select.go index 0abb6825..7714ab01 100644 --- a/pkg/sql/parser/select.go +++ b/pkg/sql/parser/select.go @@ -109,6 +109,40 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { return nil, err } + // MariaDB: START WITH ... CONNECT BY hierarchical queries (10.2+) + if p.isMariaDB() { + if strings.EqualFold(p.currentToken.Token.Value, "START") { + p.advance() // Consume START + if !strings.EqualFold(p.currentToken.Token.Value, "WITH") { + return nil, fmt.Errorf("expected WITH after START, got %q", p.currentToken.Token.Value) + } + p.advance() // Consume WITH + startExpr, startErr := p.parseExpression() + if startErr != nil { + return nil, startErr + } + selectStmt.StartWith = startExpr + } + if strings.EqualFold(p.currentToken.Token.Value, "CONNECT") { + p.advance() // Consume CONNECT + if !strings.EqualFold(p.currentToken.Token.Value, "BY") { + return nil, fmt.Errorf("expected BY after CONNECT, got %q", p.currentToken.Token.Value) + } + p.advance() // Consume BY + cb := &ast.ConnectByClause{} + if strings.EqualFold(p.currentToken.Token.Value, "NOCYCLE") { + cb.NoCycle = true + p.advance() // Consume NOCYCLE + } + cond, condErr := p.parseConnectByCondition() + if condErr != nil { + return nil, condErr + } + cb.Condition = cond + selectStmt.ConnectBy = cb + } + } + // ORDER BY if selectStmt.OrderBy, err = p.parseOrderByClause(); err != nil { return nil, err From 376c8bd7dd650fedbcb4d3394a1f12947e88302e Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 02:55:05 +0530 Subject: [PATCH 17/21] fix(parser): add bare RESTART test and nil guard for CONNECT BY condition --- pkg/sql/parser/mariadb_test.go | 18 ++++++++++++++++++ pkg/sql/parser/select.go | 3 +++ 2 files changed, 21 insertions(+) diff --git a/pkg/sql/parser/mariadb_test.go b/pkg/sql/parser/mariadb_test.go index 741a181d..71c7ffff 100644 --- a/pkg/sql/parser/mariadb_test.go +++ b/pkg/sql/parser/mariadb_test.go @@ -88,6 +88,24 @@ func TestMariaDB_AlterSequence_Restart(t *testing.T) { } } +func TestMariaDB_AlterSequence_RestartBare(t *testing.T) { + sql := "ALTER SEQUENCE seq_orders RESTART" + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + stmt, ok := tree.Statements[0].(*ast.AlterSequenceStatement) + if !ok { + t.Fatalf("expected AlterSequenceStatement, got %T", tree.Statements[0]) + } + if !stmt.Options.Restart { + t.Error("expected Restart = true") + } + if stmt.Options.RestartWith != nil { + t.Error("expected RestartWith = nil for bare RESTART") + } +} + func TestMariaDB_SequenceNotRecognizedInMySQL(t *testing.T) { sql := "CREATE SEQUENCE seq1 START WITH 1" _, err := parser.ParseWithDialect(sql, keywords.DialectMySQL) diff --git a/pkg/sql/parser/select.go b/pkg/sql/parser/select.go index 7714ab01..596e87d9 100644 --- a/pkg/sql/parser/select.go +++ b/pkg/sql/parser/select.go @@ -138,6 +138,9 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { if condErr != nil { return nil, condErr } + if cond == nil { + return nil, fmt.Errorf("expected condition after CONNECT BY") + } cb.Condition = cond selectStmt.ConnectBy = cb } From c553acda770b7c3ff319bd9d69882e3700a923c2 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 19:34:57 +0530 Subject: [PATCH 18/21] test(parser): add MariaDB SQL test data files and file-based integration test Co-Authored-By: Claude Sonnet 4.6 --- pkg/sql/parser/mariadb_test.go | 33 +++++++++++++++++++ .../parser/testdata/mariadb/connect_by.sql | 2 ++ pkg/sql/parser/testdata/mariadb/mixed.sql | 6 ++++ pkg/sql/parser/testdata/mariadb/sequences.sql | 6 ++++ pkg/sql/parser/testdata/mariadb/temporal.sql | 8 +++++ 5 files changed, 55 insertions(+) create mode 100644 pkg/sql/parser/testdata/mariadb/connect_by.sql create mode 100644 pkg/sql/parser/testdata/mariadb/mixed.sql create mode 100644 pkg/sql/parser/testdata/mariadb/sequences.sql create mode 100644 pkg/sql/parser/testdata/mariadb/temporal.sql diff --git a/pkg/sql/parser/mariadb_test.go b/pkg/sql/parser/mariadb_test.go index 71c7ffff..a5eae456 100644 --- a/pkg/sql/parser/mariadb_test.go +++ b/pkg/sql/parser/mariadb_test.go @@ -1,6 +1,8 @@ package parser_test import ( + "os" + "strings" "testing" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" @@ -222,3 +224,34 @@ func TestMariaDB_ConnectBy_NoStartWith(t *testing.T) { t.Error("expected ConnectBy to be set") } } + +// ── Task 10: File-based Integration Tests ───────────────────────────────────── + +func TestMariaDB_SQLFiles(t *testing.T) { + files := []string{ + "testdata/mariadb/sequences.sql", + "testdata/mariadb/temporal.sql", + "testdata/mariadb/connect_by.sql", + "testdata/mariadb/mixed.sql", + } + for _, f := range files { + t.Run(f, func(t *testing.T) { + data, err := os.ReadFile(f) + if err != nil { + t.Fatalf("failed to read %s: %v", f, err) + } + // Split on semicolons to get individual statements + stmts := strings.Split(string(data), ";") + for _, raw := range stmts { + sql := strings.TrimSpace(raw) + if sql == "" { + continue + } + _, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Errorf("failed to parse %q: %v", sql, err) + } + } + }) + } +} diff --git a/pkg/sql/parser/testdata/mariadb/connect_by.sql b/pkg/sql/parser/testdata/mariadb/connect_by.sql new file mode 100644 index 00000000..406365e6 --- /dev/null +++ b/pkg/sql/parser/testdata/mariadb/connect_by.sql @@ -0,0 +1,2 @@ +SELECT id, name, parent_id FROM categories START WITH parent_id IS NULL CONNECT BY PRIOR id = parent_id; +SELECT id, name FROM employees CONNECT BY NOCYCLE PRIOR manager_id = id; diff --git a/pkg/sql/parser/testdata/mariadb/mixed.sql b/pkg/sql/parser/testdata/mariadb/mixed.sql new file mode 100644 index 00000000..416930dd --- /dev/null +++ b/pkg/sql/parser/testdata/mariadb/mixed.sql @@ -0,0 +1,6 @@ +CREATE SEQUENCE IF NOT EXISTS order_seq START WITH 1 INCREMENT BY 1; +CREATE TABLE orders ( + id INT NOT NULL, + customer_id INT NOT NULL, + total DECIMAL(12,2) +) WITH SYSTEM VERSIONING; diff --git a/pkg/sql/parser/testdata/mariadb/sequences.sql b/pkg/sql/parser/testdata/mariadb/sequences.sql new file mode 100644 index 00000000..3b37601f --- /dev/null +++ b/pkg/sql/parser/testdata/mariadb/sequences.sql @@ -0,0 +1,6 @@ +CREATE SEQUENCE seq_orders START WITH 1 INCREMENT BY 1; +CREATE SEQUENCE IF NOT EXISTS seq_invoices START WITH 1000 MAXVALUE 99999 CYCLE; +CREATE OR REPLACE SEQUENCE seq_users START WITH 1 INCREMENT BY 1 NOCACHE; +DROP SEQUENCE seq_orders; +DROP SEQUENCE IF EXISTS seq_invoices; +ALTER SEQUENCE seq_orders RESTART WITH 1; diff --git a/pkg/sql/parser/testdata/mariadb/temporal.sql b/pkg/sql/parser/testdata/mariadb/temporal.sql new file mode 100644 index 00000000..1b702dfe --- /dev/null +++ b/pkg/sql/parser/testdata/mariadb/temporal.sql @@ -0,0 +1,8 @@ +CREATE TABLE prices ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + item VARCHAR(100), + price DECIMAL(10,2) +) WITH SYSTEM VERSIONING; +SELECT id, price FROM prices FOR SYSTEM_TIME AS OF TIMESTAMP '2023-06-15 12:00:00'; +SELECT id, price FROM prices FOR SYSTEM_TIME ALL; +SELECT id, price FROM prices FOR SYSTEM_TIME BETWEEN '2022-01-01' AND '2023-01-01'; From 256d1bf6ab67a66a3e71ea807c59482263f7f16d Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 19:45:09 +0530 Subject: [PATCH 19/21] docs: add MariaDB dialect to SQL_COMPATIBILITY.md and CHANGELOG.md Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 +++ docs/SQL_COMPATIBILITY.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeb30623..440a6e91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **MariaDB dialect** (`--dialect mariadb`): New SQL dialect extending MySQL with support for SEQUENCE DDL (`CREATE/DROP/ALTER SEQUENCE` with full option set), temporal tables (`FOR SYSTEM_TIME`, `WITH SYSTEM VERSIONING`, `PERIOD FOR`), and `CONNECT BY` hierarchical queries with `PRIOR`, `START WITH`, and `NOCYCLE` + ## [1.13.0] - 2026-03-20 ### Added diff --git a/docs/SQL_COMPATIBILITY.md b/docs/SQL_COMPATIBILITY.md index 8a4ec57f..f62dcbf7 100644 --- a/docs/SQL_COMPATIBILITY.md +++ b/docs/SQL_COMPATIBILITY.md @@ -317,6 +317,27 @@ This matrix documents the comprehensive SQL feature support in GoSQLX across dif | **AUTO_INCREMENT** | ✅ Full | ✅ Full | 95% | Column property | | **Backtick identifiers** | ✅ Full | ✅ Full | 100% | `` `table`.`column` `` syntax | +### MariaDB-Specific Features (v1.14.0+) + +MariaDB inherits all MySQL features (SHOW, DESCRIBE, REPLACE INTO, ON DUPLICATE KEY UPDATE, GROUP_CONCAT, MATCH/AGAINST, REGEXP/RLIKE, backtick identifiers, etc.) and adds the following extensions: + +| Feature | Support Level | GoSQLX Parser | Test Coverage | Notes | +|---------|---------------|---------------|---------------|-------| +| **CREATE SEQUENCE** | ✅ Full | ✅ Full | 95% | Full DDL with all sequence options | +| **DROP SEQUENCE** | ✅ Full | ✅ Full | 95% | DROP SEQUENCE [IF EXISTS] | +| **ALTER SEQUENCE** | ✅ Full | ✅ Full | 90% | RESTART, RESTART WITH, and all options | +| **Sequence options** | ✅ Full | ✅ Full | 95% | START WITH, INCREMENT BY, MINVALUE, MAXVALUE, CACHE, CYCLE, NOCACHE, NOCYCLE, RESTART, RESTART WITH | +| **FOR SYSTEM_TIME AS OF** | ✅ Full | ✅ Full | 95% | Point-in-time query on system-versioned tables | +| **FOR SYSTEM_TIME BETWEEN** | ✅ Full | ✅ Full | 95% | Range query on system-versioned tables | +| **FOR SYSTEM_TIME FROM/TO** | ✅ Full | ✅ Full | 95% | Range query (inclusive/exclusive) | +| **FOR SYSTEM_TIME ALL** | ✅ Full | ✅ Full | 95% | All rows including historical | +| **WITH SYSTEM VERSIONING** | ✅ Full | ✅ Full | 90% | CREATE TABLE ... WITH SYSTEM VERSIONING | +| **PERIOD FOR** | ✅ Full | ✅ Full | 85% | Application-time period definitions | +| **CONNECT BY** | ✅ Full | ✅ Full | 90% | Hierarchical queries with PRIOR and NOCYCLE | +| **START WITH (CONNECT BY)** | ✅ Full | ✅ Full | 90% | Root condition for hierarchical traversal | +| **PRIOR operator** | ✅ Full | ✅ Full | 90% | Reference parent row in CONNECT BY | +| **NOCYCLE** | ✅ Full | ✅ Full | 85% | Prevent infinite loops in cyclic graphs | + ### SQL Server-Specific Features | Feature | Support Level | GoSQLX Parser | Test Coverage | Notes | @@ -549,6 +570,7 @@ GoSQLX v1.8.0 introduces a first-class dialect mode engine that threads the SQL | **SQLite** | `"sqlite"` | SQLite keywords | Flexible typing, simplified syntax | ⚠️ Keywords + basic parsing | | **Snowflake** | `"snowflake"` | Snowflake keywords | Stage operations, VARIANT type | ⚠️ Keyword detection only | | **ClickHouse** | `"clickhouse"` | ClickHouse keywords | PREWHERE, FINAL, GLOBAL IN/NOT IN, MergeTree keywords | ✅ v1.13.0 | +| **MariaDB** | `"mariadb"` | MariaDB keywords (superset of MySQL) | All MySQL features + SEQUENCE DDL, FOR SYSTEM_TIME, WITH SYSTEM VERSIONING, PERIOD FOR, CONNECT BY | ✅ v1.14.0 | ### Usage @@ -597,6 +619,12 @@ gosqlx format --dialect mysql query.sql - No Snowflake-specific parsing (stages, COPY INTO, VARIANT operations) - QUALIFY clause not supported +#### MariaDB +- Inherits all MySQL known gaps (stored procedures, HANDLER, XA transactions, CREATE EVENT) +- JSON_TABLE not supported +- Spider storage engine syntax not parsed +- ColumnStore-specific syntax not supported + #### ClickHouse - PREWHERE clause for pre-filter optimization before primary key scan - FINAL modifier on table references (forces MergeTree part merge) @@ -644,6 +672,7 @@ gosqlx format --dialect mysql query.sql | **SQL Server** | 85% | 65% | ⭐⭐⭐⭐ Very Good | Keywords + MERGE | | **Oracle** | 80% | 60% | ⭐⭐⭐⭐ Good | Keywords + basic features | | **SQLite** | 85% | 50% | ⭐⭐⭐⭐ Good | Keywords + basic features | +| **MariaDB** | 95% | 90% | ⭐⭐⭐⭐⭐ Excellent | MySQL superset + SEQUENCE DDL, temporal tables, CONNECT BY (v1.14.0) | | **Snowflake** | 80% | 30% | ⭐⭐⭐ Good | Keyword detection only | ## Performance Characteristics by Feature From f003e2b19f6298f7069c49c0febd259763c0f3e9 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 20:13:33 +0530 Subject: [PATCH 20/21] fix(mariadb): address code review issues across AST, keywords, and parser AST layer: - Add `Prior` UnaryOperator constant for CONNECT BY PRIOR expressions - Add `NoCache bool` to SequenceOptions (NOCACHE was previously a silent no-op) - Add `Pos models.Location` to all 6 new MariaDB AST nodes (CreateSequence, DropSequence, AlterSequence, ForSystemTimeClause, ConnectByClause, PeriodDefinition) - Add `NewDropSequenceStatement()` / `ReleaseDropSequenceStatement()` pool funcs to match Create/Alter sequence pooling consistency - Emit NOCACHE in writeSequenceOptions when NoCache=true Keywords: - Remove duplicate MINVALUE, MAXVALUE, INCREMENT, RESTART, NOCACHE entries from MARIADB_SPECIFIC (already covered by base/Oracle keyword lists) Parser: - Fix PRIOR operator: switch from FunctionCall to UnaryExpression{Operator: Prior} - Fix PRIOR on RHS: CONNECT BY col = PRIOR col now parsed correctly - Fix parseJoinedTableRef: add isMariaDBClauseKeyword guard to prevent CONNECT/START from being consumed as table aliases (same guard already in parseFromTableReference) - Fix parseJoinedTableRef: add FOR SYSTEM_TIME temporal clause support on JOIN refs - Fix DROP SEQUENCE: support IF NOT EXISTS in addition to IF EXISTS - Fix DROP SEQUENCE: use NewDropSequenceStatement() for pool consistency - Fix parseSequenceOptions: set opts.NoCache=true for NOCACHE keyword - Add comment in parseTemporalPointExpression explaining quote-stripping behaviour Tests: - Add TestMariaDB_ConnectBy_PriorOnRight - Add TestMariaDB_DropSequence_IfNotExists - Add TestMariaDB_Sequence_NoCache - Expand testdata SQL files with NO MINVALUE/MAXVALUE forms, PRIOR-on-right cases, IF NOT EXISTS on DROP, and multi-table temporal JOIN query Co-Authored-By: Claude Sonnet 4.6 --- pkg/sql/ast/ast.go | 17 +++-- pkg/sql/ast/ast_sequence_test.go | 8 ++ pkg/sql/ast/operator.go | 4 + pkg/sql/ast/pool.go | 16 ++++ pkg/sql/ast/sql.go | 4 + pkg/sql/keywords/mariadb.go | 10 +-- pkg/sql/parser/mariadb.go | 74 ++++++++++++++----- pkg/sql/parser/mariadb_test.go | 63 ++++++++++++++++ pkg/sql/parser/select_subquery.go | 35 ++++++++- .../parser/testdata/mariadb/connect_by.sql | 7 ++ pkg/sql/parser/testdata/mariadb/mixed.sql | 1 + pkg/sql/parser/testdata/mariadb/sequences.sql | 3 + pkg/sql/parser/testdata/mariadb/temporal.sql | 4 + 13 files changed, 215 insertions(+), 31 deletions(-) diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index 88006491..23a8615a 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -1851,6 +1851,7 @@ type SequenceOptions struct { Cache *LiteralValue // CACHE n or nil when NO CACHE / NOCACHE Cycle bool // CYCLE NoCycle bool // NO CYCLE / NOCYCLE (explicit; default is NO CYCLE) + NoCache bool // NOCACHE (explicit; Cache=nil alone is ambiguous) Restart bool // bare RESTART (reset to start value) RestartWith *LiteralValue // RESTART WITH n (explicit restart value) } @@ -1863,6 +1864,7 @@ type CreateSequenceStatement struct { OrReplace bool IfNotExists bool Options SequenceOptions + Pos models.Location // Source position of the CREATE keyword (1-based line and column) } func (s *CreateSequenceStatement) statementNode() {} @@ -1880,6 +1882,7 @@ func (s *CreateSequenceStatement) Children() []Node { type DropSequenceStatement struct { Name *Identifier IfExists bool + Pos models.Location // Source position of the DROP keyword (1-based line and column) } func (s *DropSequenceStatement) statementNode() {} @@ -1898,6 +1901,7 @@ type AlterSequenceStatement struct { Name *Identifier IfExists bool Options SequenceOptions + Pos models.Location // Source position of the ALTER keyword (1-based line and column) } func (s *AlterSequenceStatement) statementNode() {} @@ -1928,9 +1932,10 @@ const ( // SELECT * FROM t FOR SYSTEM_TIME ALL; type ForSystemTimeClause struct { Type SystemTimeClauseType - Point Expression // used for AS OF - Start Expression // used for BETWEEN, FROM - End Expression // used for BETWEEN (AND), TO + Point Expression // used for AS OF + Start Expression // used for BETWEEN, FROM + End Expression // used for BETWEEN (AND), TO + Pos models.Location // Source position of the FOR keyword (1-based line and column) } func (c *ForSystemTimeClause) expressionNode() {} @@ -1957,6 +1962,7 @@ type PeriodDefinition struct { Name *Identifier // period name (e.g., "app_time") or SYSTEM_TIME StartCol *Identifier EndCol *Identifier + Pos models.Location // Source position of the PERIOD FOR keyword (1-based line and column) } func (p *PeriodDefinition) expressionNode() {} @@ -1983,8 +1989,9 @@ func (p PeriodDefinition) Children() []Node { // START WITH parent_id IS NULL // CONNECT BY NOCYCLE PRIOR id = parent_id; type ConnectByClause struct { - NoCycle bool // NOCYCLE modifier — prevents loops in cyclic graphs - Condition Expression // the PRIOR expression (e.g., PRIOR id = parent_id) + NoCycle bool // NOCYCLE modifier — prevents loops in cyclic graphs + Condition Expression // the PRIOR expression (e.g., PRIOR id = parent_id) + Pos models.Location // Source position of the CONNECT BY keyword (1-based line and column) } func (c *ConnectByClause) expressionNode() {} diff --git a/pkg/sql/ast/ast_sequence_test.go b/pkg/sql/ast/ast_sequence_test.go index b9f5f765..84fdfe9f 100644 --- a/pkg/sql/ast/ast_sequence_test.go +++ b/pkg/sql/ast/ast_sequence_test.go @@ -58,6 +58,14 @@ func TestCreateSequenceStatement_ToSQL(t *testing.T) { }, want: "CREATE SEQUENCE s NOCYCLE", }, + { + name: "nocache", + stmt: &ast.CreateSequenceStatement{ + Name: &ast.Identifier{Name: "s"}, + Options: ast.SequenceOptions{NoCache: true}, + }, + want: "CREATE SEQUENCE s NOCACHE", + }, { name: "nil name does not panic", stmt: &ast.CreateSequenceStatement{}, diff --git a/pkg/sql/ast/operator.go b/pkg/sql/ast/operator.go index f6f5bb98..7d9a5d08 100644 --- a/pkg/sql/ast/operator.go +++ b/pkg/sql/ast/operator.go @@ -81,6 +81,8 @@ const ( PGAbs // BangNot represents Hive-specific logical NOT operator, e.g. ! false BangNot + // Prior represents MariaDB CONNECT BY parent reference operator, e.g. PRIOR id + Prior ) // String returns the string representation of the unary operator @@ -106,6 +108,8 @@ func (op UnaryOperator) String() string { return "@" case BangNot: return "!" + case Prior: + return "PRIOR" default: return "UNKNOWN" } diff --git a/pkg/sql/ast/pool.go b/pkg/sql/ast/pool.go index 4b0fadb5..0aeb1b6f 100644 --- a/pkg/sql/ast/pool.go +++ b/pkg/sql/ast/pool.go @@ -356,6 +356,10 @@ var ( New: func() interface{} { return &CreateSequenceStatement{} }, } + dropSequencePool = sync.Pool{ + New: func() interface{} { return &DropSequenceStatement{} }, + } + alterSequencePool = sync.Pool{ New: func() interface{} { return &AlterSequenceStatement{} }, } @@ -1814,6 +1818,18 @@ func ReleaseCreateSequenceStatement(s *CreateSequenceStatement) { createSequencePool.Put(s) } +// NewDropSequenceStatement retrieves a DropSequenceStatement from the pool. +func NewDropSequenceStatement() *DropSequenceStatement { + return dropSequencePool.Get().(*DropSequenceStatement) +} + +// ReleaseDropSequenceStatement returns a DropSequenceStatement to the pool. +// Always call this with defer after parsing is complete. +func ReleaseDropSequenceStatement(s *DropSequenceStatement) { + *s = DropSequenceStatement{} // zero all fields + dropSequencePool.Put(s) +} + // NewAlterSequenceStatement retrieves an AlterSequenceStatement from the pool. func NewAlterSequenceStatement() *AlterSequenceStatement { return alterSequencePool.Get().(*AlterSequenceStatement) diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 13b5021b..f327fec6 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -202,6 +202,8 @@ func (u *UnaryExpression) SQL() string { return "+" + inner case Minus: return "-" + inner + case Prior: + return "PRIOR " + inner default: return u.Operator.String() + inner } @@ -1665,6 +1667,8 @@ func writeSequenceOptions(b *strings.Builder, opts SequenceOptions) { if opts.Cache != nil { b.WriteString(" CACHE ") b.WriteString(opts.Cache.TokenLiteral()) + } else if opts.NoCache { + b.WriteString(" NOCACHE") } if opts.Cycle { b.WriteString(" CYCLE") diff --git a/pkg/sql/keywords/mariadb.go b/pkg/sql/keywords/mariadb.go index ac129ec4..b2feea23 100644 --- a/pkg/sql/keywords/mariadb.go +++ b/pkg/sql/keywords/mariadb.go @@ -25,20 +25,20 @@ import "github.com/ajitpratap0/GoSQLX/pkg/models" // - Temporal tables (MariaDB 10.3.4+): WITH SYSTEM VERSIONING, FOR SYSTEM_TIME, PERIOD FOR // - Hierarchical queries (MariaDB 10.2+): CONNECT BY, START WITH, PRIOR, NOCYCLE // - Index visibility (MariaDB 10.6+): INVISIBLE, VISIBLE modifiers +// +// Note: MAXVALUE is already in ADDITIONAL_KEYWORDS (base list, all dialects). +// Note: MINVALUE is already in ORACLE_SPECIFIC. Neither needs repeating here. +// Note: INCREMENT, RESTART, NOCACHE are already in ADDITIONAL_KEYWORDS. var MARIADB_SPECIFIC = []Keyword{ // ── SEQUENCE DDL (MariaDB 10.3+) ─────────────────────────────────────── // CREATE SEQUENCE s START WITH 1 INCREMENT BY 1 MINVALUE 1 MAXVALUE 9999 CYCLE CACHE 100; // SELECT NEXT VALUE FOR s; -- ANSI style // SELECT NEXTVAL(s); -- MariaDB style + // MINVALUE/MAXVALUE/INCREMENT/RESTART/NOCACHE covered by base or Oracle lists. {Word: "SEQUENCE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, {Word: "NEXTVAL", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, {Word: "LASTVAL", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, {Word: "SETVAL", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, - {Word: "MINVALUE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, - {Word: "MAXVALUE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, - {Word: "INCREMENT", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, - {Word: "RESTART", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, - {Word: "NOCACHE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, {Word: "NOCYCLE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false}, // ── Temporal tables / System versioning (MariaDB 10.3.4+) ───────────── diff --git a/pkg/sql/parser/mariadb.go b/pkg/sql/parser/mariadb.go index d058a006..79df834f 100644 --- a/pkg/sql/parser/mariadb.go +++ b/pkg/sql/parser/mariadb.go @@ -65,18 +65,27 @@ func (p *Parser) parseCreateSequenceStatement(orReplace bool) (*ast.CreateSequen return stmt, nil } -// parseDropSequenceStatement parses: DROP SEQUENCE [IF EXISTS] name +// parseDropSequenceStatement parses: DROP SEQUENCE [IF EXISTS | IF NOT EXISTS] name // The caller has already consumed DROP and SEQUENCE. func (p *Parser) parseDropSequenceStatement() (*ast.DropSequenceStatement, error) { - stmt := &ast.DropSequenceStatement{} + stmt := ast.NewDropSequenceStatement() if strings.EqualFold(p.currentToken.Token.Value, "IF") { p.advance() - if !strings.EqualFold(p.currentToken.Token.Value, "EXISTS") { - return nil, p.expectedError("EXISTS") + if strings.EqualFold(p.currentToken.Token.Value, "NOT") { + // IF NOT EXISTS — treated as "no error if absent" (same semantics as IF EXISTS) + p.advance() + if !strings.EqualFold(p.currentToken.Token.Value, "EXISTS") { + return nil, p.expectedError("EXISTS") + } + p.advance() + stmt.IfExists = true + } else if strings.EqualFold(p.currentToken.Token.Value, "EXISTS") { + p.advance() + stmt.IfExists = true + } else { + return nil, p.expectedError("EXISTS or NOT EXISTS") } - p.advance() - stmt.IfExists = true } name := p.parseIdent() @@ -190,6 +199,7 @@ func (p *Parser) parseSequenceOptions() (ast.SequenceOptions, error) { opts.Cache = lit case "NOCACHE": p.advance() + opts.NoCache = true case "RESTART": p.advance() if strings.EqualFold(p.currentToken.Token.Value, "WITH") { @@ -303,6 +313,9 @@ func (p *Parser) parseTemporalPointExpression() (ast.Expression, error) { if !p.isStringLiteral() { return nil, fmt.Errorf("expected string literal after %s, got %q", typeKeyword, p.currentToken.Token.Value) } + // The tokenizer strips surrounding single quotes from string literal tokens, + // so p.currentToken.Token.Value is the raw string content (e.g. "2023-01-01 00:00:00"). + // We reconstruct the canonical form: TYPE 'value'. value := typeKeyword + " '" + p.currentToken.Token.Value + "'" p.advance() return &ast.LiteralValue{Value: value, Type: "timestamp"}, nil @@ -312,26 +325,23 @@ func (p *Parser) parseTemporalPointExpression() (ast.Expression, error) { } // parseConnectByCondition parses the condition expression for CONNECT BY. -// It handles the PRIOR prefix operator which MariaDB uses for hierarchical queries: +// It handles the PRIOR prefix operator in either position: // -// CONNECT BY PRIOR id = parent_id +// CONNECT BY PRIOR id = parent_id (PRIOR on left) +// CONNECT BY id = PRIOR parent_id (PRIOR on right) // -// PRIOR is treated as a unary prefix operator whose result is the referenced column in the -// parent row. The overall condition PRIOR id = parent_id is a binary equality test. +// PRIOR references the value from the parent row in the hierarchy. +// It is modeled as UnaryExpression{Operator: ast.Prior, Expr: }. func (p *Parser) parseConnectByCondition() (ast.Expression, error) { - // Handle PRIOR = pattern explicitly since the standard - // expression parser treats PRIOR as a plain identifier and stops before '='. + // Case 1: PRIOR col op col if strings.EqualFold(p.currentToken.Token.Value, "PRIOR") { - p.advance() // Consume PRIOR - // Parse the column name that PRIOR applies to. + p.advance() priorIdent := p.parseIdent() if priorIdent == nil || priorIdent.Name == "" { return nil, p.expectedError("column name after PRIOR") } - // Wrap as a function-call-style node so the AST carries PRIOR semantics. - priorExpr := &ast.FunctionCall{Name: "PRIOR", Arguments: []ast.Expression{priorIdent}} + priorExpr := &ast.UnaryExpression{Operator: ast.Prior, Expr: priorIdent} - // If followed by a comparison operator, parse the right-hand side. if p.isType(models.TokenTypeEq) || p.isType(models.TokenTypeNeq) || p.isType(models.TokenTypeLt) || p.isType(models.TokenTypeGt) || p.isType(models.TokenTypeLtEq) || p.isType(models.TokenTypeGtEq) { @@ -345,8 +355,34 @@ func (p *Parser) parseConnectByCondition() (ast.Expression, error) { } return priorExpr, nil } - // No PRIOR prefix — parse as a regular expression. - return p.parseExpression() + + // Case 2: col op PRIOR col (PRIOR on the right-hand side) + left, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + if p.isType(models.TokenTypeEq) || p.isType(models.TokenTypeNeq) || + p.isType(models.TokenTypeLt) || p.isType(models.TokenTypeGt) || + p.isType(models.TokenTypeLtEq) || p.isType(models.TokenTypeGtEq) { + op := p.currentToken.Token.Value + p.advance() + // Check for PRIOR on the right side + if strings.EqualFold(p.currentToken.Token.Value, "PRIOR") { + p.advance() + priorIdent := p.parseIdent() + if priorIdent == nil || priorIdent.Name == "" { + return nil, p.expectedError("column name after PRIOR") + } + priorExpr := &ast.UnaryExpression{Operator: ast.Prior, Expr: priorIdent} + return &ast.BinaryExpression{Left: left, Operator: op, Right: priorExpr}, nil + } + right, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + return &ast.BinaryExpression{Left: left, Operator: op, Right: right}, nil + } + return left, nil } // parsePeriodDefinition parses: PERIOD FOR name (start_col, end_col) diff --git a/pkg/sql/parser/mariadb_test.go b/pkg/sql/parser/mariadb_test.go index a5eae456..1095dd7a 100644 --- a/pkg/sql/parser/mariadb_test.go +++ b/pkg/sql/parser/mariadb_test.go @@ -225,6 +225,69 @@ func TestMariaDB_ConnectBy_NoStartWith(t *testing.T) { } } +// TestMariaDB_ConnectBy_PriorOnRight verifies PRIOR on the right-hand side of the condition. +func TestMariaDB_ConnectBy_PriorOnRight(t *testing.T) { + sql := "SELECT id FROM employees CONNECT BY id = PRIOR parent_id" + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + sel, ok := tree.Statements[0].(*ast.SelectStatement) + if !ok { + t.Fatalf("expected SelectStatement") + } + if sel.ConnectBy == nil { + t.Fatal("expected ConnectBy clause") + } + bin, ok := sel.ConnectBy.Condition.(*ast.BinaryExpression) + if !ok { + t.Fatalf("expected BinaryExpression, got %T", sel.ConnectBy.Condition) + } + // Right side should be PRIOR parent_id + unary, ok := bin.Right.(*ast.UnaryExpression) + if !ok { + t.Fatalf("expected UnaryExpression on right, got %T", bin.Right) + } + if unary.Operator != ast.Prior { + t.Errorf("expected Prior operator, got %v", unary.Operator) + } +} + +// TestMariaDB_DropSequence_IfNotExists verifies DROP SEQUENCE IF NOT EXISTS is accepted. +func TestMariaDB_DropSequence_IfNotExists(t *testing.T) { + sql := "DROP SEQUENCE IF NOT EXISTS my_seq" + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + stmt, ok := tree.Statements[0].(*ast.DropSequenceStatement) + if !ok { + t.Fatalf("expected DropSequenceStatement, got %T", tree.Statements[0]) + } + if !stmt.IfExists { + t.Error("expected IfExists=true") + } + if stmt.Name == nil || stmt.Name.Name != "my_seq" { + t.Errorf("expected name my_seq, got %v", stmt.Name) + } +} + +// TestMariaDB_Sequence_NoCache verifies NOCACHE sets the NoCache field. +func TestMariaDB_Sequence_NoCache(t *testing.T) { + sql := "CREATE SEQUENCE s NOCACHE" + tree, err := parser.ParseWithDialect(sql, keywords.DialectMariaDB) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + stmt, ok := tree.Statements[0].(*ast.CreateSequenceStatement) + if !ok { + t.Fatalf("expected CreateSequenceStatement") + } + if !stmt.Options.NoCache { + t.Error("expected NoCache=true") + } +} + // ── Task 10: File-based Integration Tests ───────────────────────────────────── func TestMariaDB_SQLFiles(t *testing.T) { diff --git a/pkg/sql/parser/select_subquery.go b/pkg/sql/parser/select_subquery.go index 8bdbfbe8..e5e414a9 100644 --- a/pkg/sql/parser/select_subquery.go +++ b/pkg/sql/parser/select_subquery.go @@ -191,8 +191,25 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error ref = ast.TableReference{Name: joinedName, Lateral: isLateral} } - // Optional alias - if p.isIdentifier() || p.isType(models.TokenTypeAs) { + // Optional alias. + // Guard: in MariaDB, CONNECT followed by BY is a hierarchical query clause, not an alias. + // Similarly, START followed by WITH is a hierarchical query seed, not an alias. + isMariaDBClauseKeyword := func() bool { + if !p.isMariaDB() { + return false + } + val := strings.ToUpper(p.currentToken.Token.Value) + if val == "CONNECT" { + next := p.peekToken() + return strings.EqualFold(next.Token.Value, "BY") + } + if val == "START" { + next := p.peekToken() + return strings.EqualFold(next.Token.Value, "WITH") + } + return false + } + if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !isMariaDBClauseKeyword() { if p.isType(models.TokenTypeAs) { p.advance() if !p.isIdentifier() { @@ -205,6 +222,20 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error } } + // MariaDB FOR SYSTEM_TIME temporal query (10.3.4+) + if p.isMariaDB() && p.isType(models.TokenTypeFor) { + // Only parse as FOR SYSTEM_TIME if next token is SYSTEM_TIME + next := p.peekToken() + if strings.EqualFold(next.Token.Value, "SYSTEM_TIME") { + p.advance() // Consume FOR + sysTime, err := p.parseForSystemTimeClause() + if err != nil { + return ref, err + } + ref.ForSystemTime = sysTime + } + } + // SQL Server table hints if p.dialect == string(keywords.DialectSQLServer) && p.isType(models.TokenTypeWith) { if p.peekToken().Token.Type == models.TokenTypeLParen { diff --git a/pkg/sql/parser/testdata/mariadb/connect_by.sql b/pkg/sql/parser/testdata/mariadb/connect_by.sql index 406365e6..df918111 100644 --- a/pkg/sql/parser/testdata/mariadb/connect_by.sql +++ b/pkg/sql/parser/testdata/mariadb/connect_by.sql @@ -1,2 +1,9 @@ SELECT id, name, parent_id FROM categories START WITH parent_id IS NULL CONNECT BY PRIOR id = parent_id; SELECT id, name FROM employees CONNECT BY NOCYCLE PRIOR manager_id = id; +SELECT id, name, parent_id +FROM employees +CONNECT BY id = PRIOR parent_id; +SELECT id, name, parent_id +FROM employees +START WITH id = 1 +CONNECT BY NOCYCLE id = PRIOR parent_id; diff --git a/pkg/sql/parser/testdata/mariadb/mixed.sql b/pkg/sql/parser/testdata/mariadb/mixed.sql index 416930dd..f351b66c 100644 --- a/pkg/sql/parser/testdata/mariadb/mixed.sql +++ b/pkg/sql/parser/testdata/mariadb/mixed.sql @@ -4,3 +4,4 @@ CREATE TABLE orders ( customer_id INT NOT NULL, total DECIMAL(12,2) ) WITH SYSTEM VERSIONING; +DROP SEQUENCE IF NOT EXISTS order_seq; diff --git a/pkg/sql/parser/testdata/mariadb/sequences.sql b/pkg/sql/parser/testdata/mariadb/sequences.sql index 3b37601f..2273718a 100644 --- a/pkg/sql/parser/testdata/mariadb/sequences.sql +++ b/pkg/sql/parser/testdata/mariadb/sequences.sql @@ -4,3 +4,6 @@ CREATE OR REPLACE SEQUENCE seq_users START WITH 1 INCREMENT BY 1 NOCACHE; DROP SEQUENCE seq_orders; DROP SEQUENCE IF EXISTS seq_invoices; ALTER SEQUENCE seq_orders RESTART WITH 1; +ALTER SEQUENCE s2 MINVALUE 10 MAXVALUE 99999; +ALTER SEQUENCE s2 NO MINVALUE NO MAXVALUE; +CREATE SEQUENCE s6 NO MINVALUE NO MAXVALUE NOCACHE NOCYCLE; diff --git a/pkg/sql/parser/testdata/mariadb/temporal.sql b/pkg/sql/parser/testdata/mariadb/temporal.sql index 1b702dfe..e0d8a2f4 100644 --- a/pkg/sql/parser/testdata/mariadb/temporal.sql +++ b/pkg/sql/parser/testdata/mariadb/temporal.sql @@ -6,3 +6,7 @@ CREATE TABLE prices ( SELECT id, price FROM prices FOR SYSTEM_TIME AS OF TIMESTAMP '2023-06-15 12:00:00'; SELECT id, price FROM prices FOR SYSTEM_TIME ALL; SELECT id, price FROM prices FOR SYSTEM_TIME BETWEEN '2022-01-01' AND '2023-01-01'; +SELECT o.id, o.status, o.created_at +FROM orders AS o FOR SYSTEM_TIME AS OF TIMESTAMP '2023-06-01 00:00:00' +JOIN customers AS c ON o.customer_id = c.id +WHERE o.id = 1; From a9a51bea03e0ea3255461c7a03aee5ee5215d37c Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh Date: Mon, 23 Mar 2026 20:24:23 +0530 Subject: [PATCH 21/21] =?UTF-8?q?fix(mariadb):=20address=20second=20code?= =?UTF-8?q?=20review=20pass=20=E2=80=94=20Pos,=20NO=20CACHE,=20CONNECT=20B?= =?UTF-8?q?Y,=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parser dispatch (parser.go, ddl.go, select.go): - Populate Pos on CreateSequenceStatement (at SEQUENCE token in ddl.go) - Populate Pos on DropSequenceStatement (at DROP token in parser.go) - Populate Pos on AlterSequenceStatement (at ALTER token in parser.go) - Populate Pos on ConnectByClause (at CONNECT token in select.go) - Populate Pos on PeriodDefinition (at PERIOD token in ddl.go) mariadb.go: - Fix NO CACHE (two-token) to also set opts.NoCache=true, matching NOCACHE - Fix parseConnectByCondition to handle complex AND/OR chains: CONNECT BY PRIOR id = parent_id AND active = 1 now fully parsed - Extract isMariaDBClauseStart() method (was duplicated closure in two functions) - Populate Pos on ForSystemTimeClause (at SYSTEM_TIME token) - Add comment clarifying IF NOT EXISTS is a non-standard permissive extension select_subquery.go: - Remove both isMariaDBClauseKeyword closures, replace with p.isMariaDBClauseStart() ast.go: - Update DropSequenceStatement doc to show [IF EXISTS | IF NOT EXISTS] Co-Authored-By: Claude Sonnet 4.6 --- pkg/sql/ast/ast.go | 2 +- pkg/sql/parser/ddl.go | 14 ++++- pkg/sql/parser/mariadb.go | 100 ++++++++++++++++++++++-------- pkg/sql/parser/parser.go | 22 ++++++- pkg/sql/parser/select.go | 4 +- pkg/sql/parser/select_subquery.go | 34 +--------- 6 files changed, 110 insertions(+), 66 deletions(-) diff --git a/pkg/sql/ast/ast.go b/pkg/sql/ast/ast.go index 23a8615a..91988d15 100644 --- a/pkg/sql/ast/ast.go +++ b/pkg/sql/ast/ast.go @@ -1878,7 +1878,7 @@ func (s *CreateSequenceStatement) Children() []Node { // DropSequenceStatement represents: // -// DROP SEQUENCE [IF EXISTS] name +// DROP SEQUENCE [IF EXISTS | IF NOT EXISTS] name type DropSequenceStatement struct { Name *Identifier IfExists bool diff --git a/pkg/sql/parser/ddl.go b/pkg/sql/parser/ddl.go index 5776a1e1..69698ebf 100644 --- a/pkg/sql/parser/ddl.go +++ b/pkg/sql/parser/ddl.go @@ -83,8 +83,16 @@ func (p *Parser) parseCreateStatement() (ast.Statement, error) { p.advance() // Consume INDEX return p.parseCreateIndex(true) // Unique } else if p.isMariaDB() && p.isTokenMatch("SEQUENCE") { - p.advance() // Consume SEQUENCE - return p.parseCreateSequenceStatement(orReplace) + seqPos := p.currentLocation() // position of SEQUENCE token + p.advance() // Consume SEQUENCE + stmt, err := p.parseCreateSequenceStatement(orReplace) + if err != nil { + return nil, err + } + if stmt.Pos.IsZero() { + stmt.Pos = seqPos + } + return stmt, nil } return nil, p.expectedError("TABLE, VIEW, MATERIALIZED VIEW, or INDEX after CREATE") } @@ -126,10 +134,12 @@ func (p *Parser) parseCreateTable(temporary bool) (*ast.CreateTableStatement, er for { // MariaDB: PERIOD FOR name (start_col, end_col) — application-time or system-time period if p.isMariaDB() && p.isTokenMatch("PERIOD") { + periodPos := p.currentLocation() // position of PERIOD keyword pd, err := p.parsePeriodDefinition() if err != nil { return nil, err } + pd.Pos = periodPos stmt.PeriodDefinitions = append(stmt.PeriodDefinitions, pd) } else if p.isAnyType(models.TokenTypePrimary, models.TokenTypeForeign, models.TokenTypeUnique, models.TokenTypeCheck, models.TokenTypeConstraint) { diff --git a/pkg/sql/parser/mariadb.go b/pkg/sql/parser/mariadb.go index 79df834f..ed2c7aaf 100644 --- a/pkg/sql/parser/mariadb.go +++ b/pkg/sql/parser/mariadb.go @@ -28,6 +28,25 @@ func (p *Parser) isMariaDB() bool { return p.dialect == string(keywords.DialectMariaDB) } +// isMariaDBClauseStart returns true when the current token is the start of a +// MariaDB hierarchical-query clause (CONNECT BY or START WITH) rather than a +// table alias. Used to guard alias parsing in FROM and JOIN table references. +func (p *Parser) isMariaDBClauseStart() bool { + if !p.isMariaDB() { + return false + } + val := strings.ToUpper(p.currentToken.Token.Value) + if val == "CONNECT" { + next := p.peekToken() + return strings.EqualFold(next.Token.Value, "BY") + } + if val == "START" { + next := p.peekToken() + return strings.EqualFold(next.Token.Value, "WITH") + } + return false +} + // parseCreateSequenceStatement parses: // // CREATE [OR REPLACE] SEQUENCE [IF NOT EXISTS] name [options...] @@ -73,7 +92,9 @@ func (p *Parser) parseDropSequenceStatement() (*ast.DropSequenceStatement, error if strings.EqualFold(p.currentToken.Token.Value, "IF") { p.advance() if strings.EqualFold(p.currentToken.Token.Value, "NOT") { - // IF NOT EXISTS — treated as "no error if absent" (same semantics as IF EXISTS) + // IF NOT EXISTS is a non-standard permissive extension (MariaDB only supports + // IF EXISTS natively). We accept it and reuse the IfExists flag since both + // forms mean "suppress the error if the sequence is absent". p.advance() if !strings.EqualFold(p.currentToken.Token.Value, "EXISTS") { return nil, p.expectedError("EXISTS") @@ -181,6 +202,7 @@ func (p *Parser) parseSequenceOptions() (ast.SequenceOptions, error) { opts.NoCycle = true case "CACHE": opts.Cache = nil + opts.NoCache = true default: return opts, fmt.Errorf("unexpected token after NO in SEQUENCE options: %s", sub) } @@ -239,9 +261,11 @@ func (p *Parser) parseForSystemTimeClause() (*ast.ForSystemTimeClause, error) { if !strings.EqualFold(p.currentToken.Token.Value, "SYSTEM_TIME") { return nil, fmt.Errorf("expected SYSTEM_TIME after FOR, got %q", p.currentToken.Token.Value) } + sysTimePos := p.currentLocation() // position of SYSTEM_TIME token p.advance() clause := &ast.ForSystemTimeClause{} + clause.Pos = sysTimePos word := strings.ToUpper(p.currentToken.Token.Value) switch word { @@ -327,12 +351,15 @@ func (p *Parser) parseTemporalPointExpression() (ast.Expression, error) { // parseConnectByCondition parses the condition expression for CONNECT BY. // It handles the PRIOR prefix operator in either position: // -// CONNECT BY PRIOR id = parent_id (PRIOR on left) -// CONNECT BY id = PRIOR parent_id (PRIOR on right) +// CONNECT BY PRIOR id = parent_id (PRIOR on left) +// CONNECT BY id = PRIOR parent_id (PRIOR on right) +// CONNECT BY PRIOR id = parent_id AND active = 1 (complex with AND/OR) // // PRIOR references the value from the parent row in the hierarchy. // It is modeled as UnaryExpression{Operator: ast.Prior, Expr: }. func (p *Parser) parseConnectByCondition() (ast.Expression, error) { + var base ast.Expression + // Case 1: PRIOR col op col if strings.EqualFold(p.currentToken.Token.Value, "PRIOR") { p.advance() @@ -351,38 +378,57 @@ func (p *Parser) parseConnectByCondition() (ast.Expression, error) { if err != nil { return nil, err } - return &ast.BinaryExpression{Left: priorExpr, Operator: op, Right: right}, nil + base = &ast.BinaryExpression{Left: priorExpr, Operator: op, Right: right} + } else { + base = priorExpr } - return priorExpr, nil - } - - // Case 2: col op PRIOR col (PRIOR on the right-hand side) - left, err := p.parsePrimaryExpression() - if err != nil { - return nil, err - } - if p.isType(models.TokenTypeEq) || p.isType(models.TokenTypeNeq) || - p.isType(models.TokenTypeLt) || p.isType(models.TokenTypeGt) || - p.isType(models.TokenTypeLtEq) || p.isType(models.TokenTypeGtEq) { - op := p.currentToken.Token.Value - p.advance() - // Check for PRIOR on the right side - if strings.EqualFold(p.currentToken.Token.Value, "PRIOR") { + } else { + // Case 2: col op PRIOR col (PRIOR on the right-hand side) + // or plain expression (no PRIOR) + left, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + if p.isType(models.TokenTypeEq) || p.isType(models.TokenTypeNeq) || + p.isType(models.TokenTypeLt) || p.isType(models.TokenTypeGt) || + p.isType(models.TokenTypeLtEq) || p.isType(models.TokenTypeGtEq) { + op := p.currentToken.Token.Value p.advance() - priorIdent := p.parseIdent() - if priorIdent == nil || priorIdent.Name == "" { - return nil, p.expectedError("column name after PRIOR") + // Check for PRIOR on the right side + if strings.EqualFold(p.currentToken.Token.Value, "PRIOR") { + p.advance() + priorIdent := p.parseIdent() + if priorIdent == nil || priorIdent.Name == "" { + return nil, p.expectedError("column name after PRIOR") + } + priorExpr := &ast.UnaryExpression{Operator: ast.Prior, Expr: priorIdent} + base = &ast.BinaryExpression{Left: left, Operator: op, Right: priorExpr} + } else { + right, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + base = &ast.BinaryExpression{Left: left, Operator: op, Right: right} } - priorExpr := &ast.UnaryExpression{Operator: ast.Prior, Expr: priorIdent} - return &ast.BinaryExpression{Left: left, Operator: op, Right: priorExpr}, nil + } else { + base = left } - right, err := p.parsePrimaryExpression() + } + + // Handle AND/OR chaining for complex conditions like: + // PRIOR id = parent_id AND active = 1 + for strings.EqualFold(p.currentToken.Token.Value, "AND") || + strings.EqualFold(p.currentToken.Token.Value, "OR") { + logicOp := p.currentToken.Token.Value + p.advance() + rest, err := p.parseConnectByCondition() if err != nil { return nil, err } - return &ast.BinaryExpression{Left: left, Operator: op, Right: right}, nil + base = &ast.BinaryExpression{Left: base, Operator: logicOp, Right: rest} } - return left, nil + + return base, nil } // parsePeriodDefinition parses: PERIOD FOR name (start_col, end_col) diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index 2474015b..678b1714 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -629,11 +629,19 @@ func (p *Parser) parseStatement() (ast.Statement, error) { } return stmt, nil case models.TokenTypeAlter: + stmtPos := p.currentLocation() p.advance() // MariaDB: ALTER SEQUENCE [IF EXISTS] name [options...] if p.isMariaDB() && p.isTokenMatch("SEQUENCE") { p.advance() // Consume SEQUENCE - return p.parseAlterSequenceStatement() + stmt, err := p.parseAlterSequenceStatement() + if err != nil { + return nil, err + } + if stmt.Pos.IsZero() { + stmt.Pos = stmtPos + } + return stmt, nil } return p.parseAlterTableStmt() case models.TokenTypeMerge: @@ -643,11 +651,19 @@ func (p *Parser) parseStatement() (ast.Statement, error) { p.advance() return p.parseCreateStatement() case models.TokenTypeDrop: + stmtPos := p.currentLocation() p.advance() - // MariaDB: DROP SEQUENCE [IF EXISTS] name + // MariaDB: DROP SEQUENCE [IF EXISTS | IF NOT EXISTS] name if p.isMariaDB() && p.isTokenMatch("SEQUENCE") { p.advance() // Consume SEQUENCE - return p.parseDropSequenceStatement() + stmt, err := p.parseDropSequenceStatement() + if err != nil { + return nil, err + } + if stmt.Pos.IsZero() { + stmt.Pos = stmtPos + } + return stmt, nil } return p.parseDropStatement() case models.TokenTypeRefresh: diff --git a/pkg/sql/parser/select.go b/pkg/sql/parser/select.go index 596e87d9..63c8eeda 100644 --- a/pkg/sql/parser/select.go +++ b/pkg/sql/parser/select.go @@ -124,12 +124,14 @@ func (p *Parser) parseSelectStatement() (ast.Statement, error) { selectStmt.StartWith = startExpr } if strings.EqualFold(p.currentToken.Token.Value, "CONNECT") { - p.advance() // Consume CONNECT + connectPos := p.currentLocation() // position of CONNECT keyword + p.advance() // Consume CONNECT if !strings.EqualFold(p.currentToken.Token.Value, "BY") { return nil, fmt.Errorf("expected BY after CONNECT, got %q", p.currentToken.Token.Value) } p.advance() // Consume BY cb := &ast.ConnectByClause{} + cb.Pos = connectPos if strings.EqualFold(p.currentToken.Token.Value, "NOCYCLE") { cb.NoCycle = true p.advance() // Consume NOCYCLE diff --git a/pkg/sql/parser/select_subquery.go b/pkg/sql/parser/select_subquery.go index e5e414a9..f1eff0ae 100644 --- a/pkg/sql/parser/select_subquery.go +++ b/pkg/sql/parser/select_subquery.go @@ -87,22 +87,7 @@ func (p *Parser) parseFromTableReference() (ast.TableReference, error) { // Check for table alias (required for derived tables, optional for regular tables). // Guard: in MariaDB, CONNECT followed by BY is a hierarchical query clause, not an alias. // Similarly, START followed by WITH is a hierarchical query seed, not an alias. - isMariaDBClauseKeyword := func() bool { - if !p.isMariaDB() { - return false - } - val := strings.ToUpper(p.currentToken.Token.Value) - if val == "CONNECT" { - next := p.peekToken() - return strings.EqualFold(next.Token.Value, "BY") - } - if val == "START" { - next := p.peekToken() - return strings.EqualFold(next.Token.Value, "WITH") - } - return false - } - if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !isMariaDBClauseKeyword() { + if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() { if p.isType(models.TokenTypeAs) { p.advance() // Consume AS if !p.isIdentifier() { @@ -194,22 +179,7 @@ func (p *Parser) parseJoinedTableRef(joinType string) (ast.TableReference, error // Optional alias. // Guard: in MariaDB, CONNECT followed by BY is a hierarchical query clause, not an alias. // Similarly, START followed by WITH is a hierarchical query seed, not an alias. - isMariaDBClauseKeyword := func() bool { - if !p.isMariaDB() { - return false - } - val := strings.ToUpper(p.currentToken.Token.Value) - if val == "CONNECT" { - next := p.peekToken() - return strings.EqualFold(next.Token.Value, "BY") - } - if val == "START" { - next := p.peekToken() - return strings.EqualFold(next.Token.Value, "WITH") - } - return false - } - if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !isMariaDBClauseKeyword() { + if (p.isIdentifier() || p.isType(models.TokenTypeAs)) && !p.isMariaDBClauseStart() { if p.isType(models.TokenTypeAs) { p.advance() if !p.isIdentifier() {