Skip to content

Commit 3dc45fa

Browse files
ajitpratap0Ajit Pratap Singhclaude
authored
feat: implement materialized views, partitioning, and DDL statement parsing (Issue #69) (#122)
Phase 4 DDL Support: - CREATE/DROP/REFRESH MATERIALIZED VIEW with PostgreSQL-style syntax - CREATE VIEW with OR REPLACE, TEMPORARY, IF NOT EXISTS - CREATE TABLE with PARTITION BY RANGE/LIST/HASH and partition definitions - DROP TABLE/VIEW/INDEX with IF EXISTS and CASCADE/RESTRICT - CREATE INDEX with UNIQUE and IF NOT EXISTS AST Nodes Added: - CreateViewStatement, CreateMaterializedViewStatement - RefreshMaterializedViewStatement, DropStatement - PartitionDefinition for table partitioning Parser Changes: - New ddl.go with 800+ lines of DDL parsing logic - Added CREATE, DROP, REFRESH to parseStatement() - isTokenMatch helper for flexible keyword/identifier matching Tokenizer Updates: - Added DDL keywords: CREATE, DROP, ALTER, TABLE, VIEW, etc. - Added partitioning keywords: PARTITION, RANGE, LIST, HASH, etc. Formatter Support: - formatDrop, formatCreateView, formatCreateMaterializedView - formatRefreshMaterializedView Tests: - Comprehensive DDL test suite with 30+ test cases - All existing tests pass with no regressions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Ajit Pratap Singh <ajitpratapsingh@Ajits-Mac-mini.local> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2fcbdc1 commit 3dc45fa

7 files changed

Lines changed: 1770 additions & 0 deletions

File tree

cmd/gosqlx/cmd/sql_formatter.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ func (f *SQLFormatter) formatStatement(stmt ast.Statement) error {
8080
return f.formatCreateIndex(s)
8181
case *ast.AlterTableStatement:
8282
return f.formatAlterTable(s)
83+
case *ast.DropStatement:
84+
return f.formatDrop(s)
85+
case *ast.CreateViewStatement:
86+
return f.formatCreateView(s)
87+
case *ast.CreateMaterializedViewStatement:
88+
return f.formatCreateMaterializedView(s)
89+
case *ast.RefreshMaterializedViewStatement:
90+
return f.formatRefreshMaterializedView(s)
8391
default:
8492
return fmt.Errorf("unsupported statement type: %T", stmt)
8593
}
@@ -688,3 +696,139 @@ func (f *SQLFormatter) currentIndent() string {
688696
}
689697
return strings.Repeat(f.indent, f.newlineLevel)
690698
}
699+
700+
// formatDrop formats DROP statements
701+
func (f *SQLFormatter) formatDrop(stmt *ast.DropStatement) error {
702+
f.writeKeyword("DROP")
703+
f.builder.WriteString(" ")
704+
f.writeKeyword(stmt.ObjectType)
705+
706+
if stmt.IfExists {
707+
f.builder.WriteString(" ")
708+
f.writeKeyword("IF EXISTS")
709+
}
710+
711+
for i, name := range stmt.Names {
712+
if i > 0 {
713+
f.builder.WriteString(",")
714+
}
715+
f.builder.WriteString(" " + name)
716+
}
717+
718+
if stmt.CascadeType != "" {
719+
f.builder.WriteString(" ")
720+
f.writeKeyword(stmt.CascadeType)
721+
}
722+
723+
return nil
724+
}
725+
726+
// formatCreateView formats CREATE VIEW statements
727+
func (f *SQLFormatter) formatCreateView(stmt *ast.CreateViewStatement) error {
728+
f.writeKeyword("CREATE")
729+
if stmt.OrReplace {
730+
f.builder.WriteString(" ")
731+
f.writeKeyword("OR REPLACE")
732+
}
733+
if stmt.Temporary {
734+
f.builder.WriteString(" ")
735+
f.writeKeyword("TEMPORARY")
736+
}
737+
f.builder.WriteString(" ")
738+
f.writeKeyword("VIEW")
739+
740+
if stmt.IfNotExists {
741+
f.builder.WriteString(" ")
742+
f.writeKeyword("IF NOT EXISTS")
743+
}
744+
745+
f.builder.WriteString(" " + stmt.Name)
746+
747+
if len(stmt.Columns) > 0 {
748+
f.builder.WriteString(" (")
749+
for i, col := range stmt.Columns {
750+
if i > 0 {
751+
f.builder.WriteString(", ")
752+
}
753+
f.builder.WriteString(col)
754+
}
755+
f.builder.WriteString(")")
756+
}
757+
758+
f.builder.WriteString(" ")
759+
f.writeKeyword("AS")
760+
f.writeNewline()
761+
f.increaseIndent()
762+
if err := f.formatStatement(stmt.Query); err != nil {
763+
return err
764+
}
765+
f.decreaseIndent()
766+
767+
return nil
768+
}
769+
770+
// formatCreateMaterializedView formats CREATE MATERIALIZED VIEW statements
771+
func (f *SQLFormatter) formatCreateMaterializedView(stmt *ast.CreateMaterializedViewStatement) error {
772+
f.writeKeyword("CREATE MATERIALIZED VIEW")
773+
774+
if stmt.IfNotExists {
775+
f.builder.WriteString(" ")
776+
f.writeKeyword("IF NOT EXISTS")
777+
}
778+
779+
f.builder.WriteString(" " + stmt.Name)
780+
781+
if len(stmt.Columns) > 0 {
782+
f.builder.WriteString(" (")
783+
for i, col := range stmt.Columns {
784+
if i > 0 {
785+
f.builder.WriteString(", ")
786+
}
787+
f.builder.WriteString(col)
788+
}
789+
f.builder.WriteString(")")
790+
}
791+
792+
f.builder.WriteString(" ")
793+
f.writeKeyword("AS")
794+
f.writeNewline()
795+
f.increaseIndent()
796+
if err := f.formatStatement(stmt.Query); err != nil {
797+
return err
798+
}
799+
f.decreaseIndent()
800+
801+
if stmt.WithData != nil {
802+
f.writeNewline()
803+
if *stmt.WithData {
804+
f.writeKeyword("WITH DATA")
805+
} else {
806+
f.writeKeyword("WITH NO DATA")
807+
}
808+
}
809+
810+
return nil
811+
}
812+
813+
// formatRefreshMaterializedView formats REFRESH MATERIALIZED VIEW statements
814+
func (f *SQLFormatter) formatRefreshMaterializedView(stmt *ast.RefreshMaterializedViewStatement) error {
815+
f.writeKeyword("REFRESH MATERIALIZED VIEW")
816+
817+
if stmt.Concurrently {
818+
f.builder.WriteString(" ")
819+
f.writeKeyword("CONCURRENTLY")
820+
}
821+
822+
f.builder.WriteString(" " + stmt.Name)
823+
824+
if stmt.WithData != nil {
825+
f.builder.WriteString(" ")
826+
if *stmt.WithData {
827+
f.writeKeyword("WITH DATA")
828+
} else {
829+
f.writeKeyword("WITH NO DATA")
830+
}
831+
}
832+
833+
return nil
834+
}

pkg/sql/ast/ast.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,7 @@ type CreateTableStatement struct {
654654
Constraints []TableConstraint
655655
Inherits []string
656656
PartitionBy *PartitionBy
657+
Partitions []PartitionDefinition // Individual partition definitions
657658
Options []TableOption
658659
}
659660

@@ -672,6 +673,10 @@ func (c CreateTableStatement) Children() []Node {
672673
if c.PartitionBy != nil {
673674
children = append(children, c.PartitionBy)
674675
}
676+
for _, p := range c.Partitions {
677+
p := p // G601: Create local copy
678+
children = append(children, &p)
679+
}
675680
return children
676681
}
677682

@@ -976,6 +981,107 @@ func (s SetClause) Children() []Node {
976981
return nil
977982
}
978983

984+
// CreateViewStatement represents a CREATE VIEW statement
985+
// Syntax: CREATE [OR REPLACE] [TEMP|TEMPORARY] VIEW [IF NOT EXISTS] name [(columns)] AS select
986+
type CreateViewStatement struct {
987+
OrReplace bool
988+
Temporary bool
989+
IfNotExists bool
990+
Name string
991+
Columns []string // Optional column list
992+
Query Statement // The SELECT statement
993+
WithOption string // PostgreSQL: WITH (CHECK OPTION | CASCADED | LOCAL)
994+
}
995+
996+
func (c *CreateViewStatement) statementNode() {}
997+
func (c CreateViewStatement) TokenLiteral() string { return "CREATE VIEW" }
998+
func (c CreateViewStatement) Children() []Node {
999+
if c.Query != nil {
1000+
return []Node{c.Query}
1001+
}
1002+
return nil
1003+
}
1004+
1005+
// CreateMaterializedViewStatement represents a CREATE MATERIALIZED VIEW statement
1006+
// Syntax: CREATE MATERIALIZED VIEW [IF NOT EXISTS] name [(columns)] AS select [WITH [NO] DATA]
1007+
type CreateMaterializedViewStatement struct {
1008+
IfNotExists bool
1009+
Name string
1010+
Columns []string // Optional column list
1011+
Query Statement // The SELECT statement
1012+
WithData *bool // nil = default, true = WITH DATA, false = WITH NO DATA
1013+
Tablespace string // Optional tablespace (PostgreSQL)
1014+
}
1015+
1016+
func (c *CreateMaterializedViewStatement) statementNode() {}
1017+
func (c CreateMaterializedViewStatement) TokenLiteral() string { return "CREATE MATERIALIZED VIEW" }
1018+
func (c CreateMaterializedViewStatement) Children() []Node {
1019+
if c.Query != nil {
1020+
return []Node{c.Query}
1021+
}
1022+
return nil
1023+
}
1024+
1025+
// RefreshMaterializedViewStatement represents a REFRESH MATERIALIZED VIEW statement
1026+
// Syntax: REFRESH MATERIALIZED VIEW [CONCURRENTLY] name [WITH [NO] DATA]
1027+
type RefreshMaterializedViewStatement struct {
1028+
Concurrently bool
1029+
Name string
1030+
WithData *bool // nil = default, true = WITH DATA, false = WITH NO DATA
1031+
}
1032+
1033+
func (r *RefreshMaterializedViewStatement) statementNode() {}
1034+
func (r RefreshMaterializedViewStatement) TokenLiteral() string { return "REFRESH MATERIALIZED VIEW" }
1035+
func (r RefreshMaterializedViewStatement) Children() []Node { return nil }
1036+
1037+
// DropStatement represents a DROP statement for tables, views, indexes, etc.
1038+
// Syntax: DROP object_type [IF EXISTS] name [CASCADE|RESTRICT]
1039+
type DropStatement struct {
1040+
ObjectType string // TABLE, VIEW, MATERIALIZED VIEW, INDEX, etc.
1041+
IfExists bool
1042+
Names []string // Can drop multiple objects
1043+
CascadeType string // CASCADE, RESTRICT, or empty
1044+
}
1045+
1046+
func (d *DropStatement) statementNode() {}
1047+
func (d DropStatement) TokenLiteral() string { return "DROP " + d.ObjectType }
1048+
func (d DropStatement) Children() []Node { return nil }
1049+
1050+
// PartitionDefinition represents a partition definition in CREATE TABLE
1051+
// Syntax: PARTITION name VALUES { LESS THAN (expr) | IN (list) | FROM (expr) TO (expr) }
1052+
type PartitionDefinition struct {
1053+
Name string
1054+
Type string // FOR VALUES, IN, LESS THAN
1055+
Values []Expression // Partition values or bounds
1056+
LessThan Expression // For RANGE: LESS THAN (value)
1057+
From Expression // For RANGE: FROM (value)
1058+
To Expression // For RANGE: TO (value)
1059+
InValues []Expression // For LIST: IN (values)
1060+
Tablespace string // Optional tablespace
1061+
}
1062+
1063+
func (p *PartitionDefinition) expressionNode() {}
1064+
func (p PartitionDefinition) TokenLiteral() string { return "PARTITION " + p.Name }
1065+
func (p PartitionDefinition) Children() []Node {
1066+
children := make([]Node, 0)
1067+
for _, v := range p.Values {
1068+
children = append(children, v)
1069+
}
1070+
if p.LessThan != nil {
1071+
children = append(children, p.LessThan)
1072+
}
1073+
if p.From != nil {
1074+
children = append(children, p.From)
1075+
}
1076+
if p.To != nil {
1077+
children = append(children, p.To)
1078+
}
1079+
for _, v := range p.InValues {
1080+
children = append(children, v)
1081+
}
1082+
return children
1083+
}
1084+
9791085
// AST represents the root of the Abstract Syntax Tree
9801086
type AST struct {
9811087
Statements []Statement

pkg/sql/keywords/keywords.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,35 @@ var ADDITIONAL_KEYWORDS = []Keyword{
121121
{Word: "MATCHED", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: false},
122122
{Word: "SOURCE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
123123
{Word: "TARGET", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
124+
// DDL statement keywords (Phase 4 - Materialized Views & Partitioning)
125+
{Word: "CREATE", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: true},
126+
{Word: "DROP", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: true},
127+
{Word: "ALTER", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: true},
128+
{Word: "TABLE", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: true},
129+
{Word: "INDEX", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: true},
130+
// MATERIALIZED is PostgreSQL-specific, defined in dialect.go
131+
{Word: "REFRESH", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: true},
132+
{Word: "CONCURRENTLY", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: false},
133+
{Word: "CASCADE", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: false},
134+
{Word: "RESTRICT", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: false},
135+
// DATA is commonly used as table/column name, handled as identifier
136+
{Word: "TEMPORARY", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: false},
137+
// TEMP is commonly used as identifier (e.g., CTE name "temp"), handled via isTokenMatch in parser
138+
{Word: "REPLACE", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: false},
139+
{Word: "EXISTS", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: false},
140+
{Word: "IF", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: false},
141+
// Partitioning keywords
142+
{Word: "HASH", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
143+
{Word: "LIST", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
144+
{Word: "VALUES", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: false},
145+
{Word: "LESS", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
146+
{Word: "THAN", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
147+
{Word: "MAXVALUE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
148+
{Word: "TABLESPACE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
149+
{Word: "CHECK", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: false},
150+
{Word: "OPTION", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
151+
{Word: "CASCADED", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
152+
{Word: "LOCAL", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
124153
}
125154

126155
// addKeywordsWithCategory is a helper method to add multiple keywords
@@ -142,6 +171,17 @@ func New(dialect SQLDialect, ignoreCase bool) *Keywords {
142171
k.CompoundKeywords["CROSS JOIN"] = models.TokenTypeKeyword
143172
k.CompoundKeywords["NATURAL JOIN"] = models.TokenTypeKeyword
144173
k.CompoundKeywords["GROUPING SETS"] = models.TokenTypeKeyword // SQL-99 grouping operation
174+
// Materialized views and DDL compound keywords
175+
k.CompoundKeywords["MATERIALIZED VIEW"] = models.TokenTypeKeyword
176+
k.CompoundKeywords["IF EXISTS"] = models.TokenTypeKeyword
177+
k.CompoundKeywords["IF NOT EXISTS"] = models.TokenTypeKeyword
178+
k.CompoundKeywords["OR REPLACE"] = models.TokenTypeKeyword
179+
k.CompoundKeywords["WITH DATA"] = models.TokenTypeKeyword
180+
k.CompoundKeywords["WITH NO DATA"] = models.TokenTypeKeyword
181+
// Partitioning compound keywords
182+
k.CompoundKeywords["PARTITION BY"] = models.TokenTypeKeyword
183+
k.CompoundKeywords["LESS THAN"] = models.TokenTypeKeyword
184+
k.CompoundKeywords["CHECK OPTION"] = models.TokenTypeKeyword
145185

146186
// Add standard keywords
147187
k.addKeywordsWithCategory(RESERVED_FOR_TABLE_ALIAS)

0 commit comments

Comments
 (0)