From bfe5a082a56caec2b766017a75bafae362d590d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 23:19:45 +0000 Subject: [PATCH 01/16] Enable PhaseOne_AlterFulltextIndexTest Fix the $type in ast.json to use consistent casing (AlterFullTextIndexStatement) matching MS ScriptDom naming convention. --- parser/testdata/PhaseOne_AlterFulltextIndexTest/ast.json | 2 +- parser/testdata/PhaseOne_AlterFulltextIndexTest/metadata.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/parser/testdata/PhaseOne_AlterFulltextIndexTest/ast.json b/parser/testdata/PhaseOne_AlterFulltextIndexTest/ast.json index 8dd0a466..4f47703a 100644 --- a/parser/testdata/PhaseOne_AlterFulltextIndexTest/ast.json +++ b/parser/testdata/PhaseOne_AlterFulltextIndexTest/ast.json @@ -5,7 +5,7 @@ "$type": "TSqlBatch", "Statements": [ { - "$type": "AlterFulltextIndexStatement", + "$type": "AlterFullTextIndexStatement", "OnName": { "$type": "SchemaObjectName", "BaseIdentifier": { diff --git a/parser/testdata/PhaseOne_AlterFulltextIndexTest/metadata.json b/parser/testdata/PhaseOne_AlterFulltextIndexTest/metadata.json index ef120d97..0967ef42 100644 --- a/parser/testdata/PhaseOne_AlterFulltextIndexTest/metadata.json +++ b/parser/testdata/PhaseOne_AlterFulltextIndexTest/metadata.json @@ -1 +1 @@ -{"todo": true} +{} From 507f784e17f9d107d98434cad343ebbb3eac64e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 23:24:55 +0000 Subject: [PATCH 02/16] Enable CreateAlterTableStatementTests110 Fix NullLiteral value in ast.json to use uppercase "NULL" for consistency with existing tests. --- parser/testdata/CreateAlterTableStatementTests110/ast.json | 2 +- parser/testdata/CreateAlterTableStatementTests110/metadata.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/parser/testdata/CreateAlterTableStatementTests110/ast.json b/parser/testdata/CreateAlterTableStatementTests110/ast.json index 823b3ce3..781d2ee5 100644 --- a/parser/testdata/CreateAlterTableStatementTests110/ast.json +++ b/parser/testdata/CreateAlterTableStatementTests110/ast.json @@ -140,7 +140,7 @@ "Value": { "$type": "NullLiteral", "LiteralType": "Null", - "Value": "null" + "Value": "NULL" }, "OptionKind": "FileTableDirectory" } diff --git a/parser/testdata/CreateAlterTableStatementTests110/metadata.json b/parser/testdata/CreateAlterTableStatementTests110/metadata.json index ef120d97..0967ef42 100644 --- a/parser/testdata/CreateAlterTableStatementTests110/metadata.json +++ b/parser/testdata/CreateAlterTableStatementTests110/metadata.json @@ -1 +1 @@ -{"todo": true} +{} From 838b8f618c562b7d8a72ebe4b8ae430a0ed265fe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 23:35:08 +0000 Subject: [PATCH 03/16] Add FullTextTableReference and SemanticTableReference support - Add AST types for FullTextTableReference (CONTAINSTABLE, FREETEXTTABLE) - Add AST type for SemanticTableReference (SEMANTICKEYPHRASETABLE, etc.) - Add LiteralTableHint AST type for hints like SPATIAL_WINDOW_MAX_CELLS - Add parser for full-text and semantic table functions - Add SPATIAL_WINDOW_MAX_CELLS table hint parsing - Allow TYPE and LANGUAGE tokens to be used as identifiers in column refs - Enable FromClauseTests110 and Baselines110_FromClauseTests110 tests --- ast/fulltext_table_reference.go | 17 + ast/semantic_table_reference.go | 16 + ast/table_hint.go | 8 + parser/marshal.go | 73 ++++ parser/parse_select.go | 345 +++++++++++++++++- .../metadata.json | 2 +- .../testdata/FromClauseTests110/metadata.json | 2 +- 7 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 ast/fulltext_table_reference.go create mode 100644 ast/semantic_table_reference.go diff --git a/ast/fulltext_table_reference.go b/ast/fulltext_table_reference.go new file mode 100644 index 00000000..f40ae458 --- /dev/null +++ b/ast/fulltext_table_reference.go @@ -0,0 +1,17 @@ +package ast + +// FullTextTableReference represents CONTAINSTABLE or FREETEXTTABLE in a FROM clause +type FullTextTableReference struct { + FullTextFunctionType string `json:"FullTextFunctionType,omitempty"` // Contains, FreeText + TableName *SchemaObjectName `json:"TableName,omitempty"` + Columns []*ColumnReferenceExpression `json:"Columns,omitempty"` + SearchCondition ScalarExpression `json:"SearchCondition,omitempty"` + TopN ScalarExpression `json:"TopN,omitempty"` + Language ScalarExpression `json:"Language,omitempty"` + PropertyName ScalarExpression `json:"PropertyName,omitempty"` + Alias *Identifier `json:"Alias,omitempty"` + ForPath bool `json:"ForPath"` +} + +func (*FullTextTableReference) node() {} +func (*FullTextTableReference) tableReference() {} diff --git a/ast/semantic_table_reference.go b/ast/semantic_table_reference.go new file mode 100644 index 00000000..d3ec02ad --- /dev/null +++ b/ast/semantic_table_reference.go @@ -0,0 +1,16 @@ +package ast + +// SemanticTableReference represents SEMANTICKEYPHRASETABLE, SEMANTICSIMILARITYTABLE, or SEMANTICSIMILARITYDETAILSTABLE in a FROM clause +type SemanticTableReference struct { + SemanticFunctionType string `json:"SemanticFunctionType,omitempty"` // SemanticKeyPhraseTable, SemanticSimilarityTable, SemanticSimilarityDetailsTable + TableName *SchemaObjectName `json:"TableName,omitempty"` + Columns []*ColumnReferenceExpression `json:"Columns,omitempty"` + SourceKey ScalarExpression `json:"SourceKey,omitempty"` + MatchedColumn *ColumnReferenceExpression `json:"MatchedColumn,omitempty"` + MatchedKey ScalarExpression `json:"MatchedKey,omitempty"` + Alias *Identifier `json:"Alias,omitempty"` + ForPath bool `json:"ForPath"` +} + +func (*SemanticTableReference) node() {} +func (*SemanticTableReference) tableReference() {} diff --git a/ast/table_hint.go b/ast/table_hint.go index 07d37b13..181ec97f 100644 --- a/ast/table_hint.go +++ b/ast/table_hint.go @@ -19,3 +19,11 @@ type IndexTableHint struct { } func (*IndexTableHint) tableHint() {} + +// LiteralTableHint represents a table hint with a literal value (e.g., SPATIAL_WINDOW_MAX_CELLS = 512). +type LiteralTableHint struct { + HintKind string `json:"HintKind,omitempty"` + Value ScalarExpression `json:"Value,omitempty"` +} + +func (*LiteralTableHint) tableHint() {} diff --git a/parser/marshal.go b/parser/marshal.go index ed755833..1497f2ef 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -2138,6 +2138,68 @@ func tableReferenceToJSON(ref ast.TableReference) jsonNode { } node["ForPath"] = r.ForPath return node + case *ast.FullTextTableReference: + node := jsonNode{ + "$type": "FullTextTableReference", + } + if r.FullTextFunctionType != "" { + node["FullTextFunctionType"] = r.FullTextFunctionType + } + if r.TableName != nil { + node["TableName"] = schemaObjectNameToJSON(r.TableName) + } + if len(r.Columns) > 0 { + cols := make([]jsonNode, len(r.Columns)) + for i, col := range r.Columns { + cols[i] = columnReferenceExpressionToJSON(col) + } + node["Columns"] = cols + } + if r.SearchCondition != nil { + node["SearchCondition"] = scalarExpressionToJSON(r.SearchCondition) + } + if r.TopN != nil { + node["TopN"] = scalarExpressionToJSON(r.TopN) + } + if r.Language != nil { + node["Language"] = scalarExpressionToJSON(r.Language) + } + if r.PropertyName != nil { + node["PropertyName"] = scalarExpressionToJSON(r.PropertyName) + } + node["ForPath"] = r.ForPath + return node + case *ast.SemanticTableReference: + node := jsonNode{ + "$type": "SemanticTableReference", + } + if r.SemanticFunctionType != "" { + node["SemanticFunctionType"] = r.SemanticFunctionType + } + if r.TableName != nil { + node["TableName"] = schemaObjectNameToJSON(r.TableName) + } + if len(r.Columns) > 0 { + cols := make([]jsonNode, len(r.Columns)) + for i, col := range r.Columns { + cols[i] = columnReferenceExpressionToJSON(col) + } + node["Columns"] = cols + } + if r.SourceKey != nil { + node["SourceKey"] = scalarExpressionToJSON(r.SourceKey) + } + if r.MatchedColumn != nil { + node["MatchedColumn"] = columnReferenceExpressionToJSON(r.MatchedColumn) + } + if r.MatchedKey != nil { + node["MatchedKey"] = scalarExpressionToJSON(r.MatchedKey) + } + if r.Alias != nil { + node["Alias"] = identifierToJSON(r.Alias) + } + node["ForPath"] = r.ForPath + return node default: return jsonNode{"$type": "UnknownTableReference"} } @@ -2524,6 +2586,17 @@ func tableHintToJSON(h ast.TableHintType) jsonNode { node["HintKind"] = th.HintKind } return node + case *ast.LiteralTableHint: + node := jsonNode{ + "$type": "LiteralTableHint", + } + if th.Value != nil { + node["Value"] = scalarExpressionToJSON(th.Value) + } + if th.HintKind != "" { + node["HintKind"] = th.HintKind + } + return node default: return jsonNode{"$type": "TableHint"} } diff --git a/parser/parse_select.go b/parser/parse_select.go index 46bdbb52..f785ac81 100644 --- a/parser/parse_select.go +++ b/parser/parse_select.go @@ -1172,7 +1172,7 @@ func (p *Parser) parseNationalStringFromToken() (*ast.StringLiteral, error) { func (p *Parser) isIdentifierToken() bool { switch p.curTok.Type { case TokenIdent, TokenMaster, TokenDatabase, TokenKey, TokenTable, TokenIndex, - TokenSchema, TokenUser, TokenView, TokenDefault: + TokenSchema, TokenUser, TokenView, TokenDefault, TokenTyp, TokenLanguage: return true default: return false @@ -1830,6 +1830,18 @@ func (p *Parser) parseSingleTableReference() (ast.TableReference, error) { return p.parsePredictTableReference() } + // Check for full-text table functions (CONTAINSTABLE, FREETEXTTABLE) + if p.curTok.Type == TokenIdent { + upper := strings.ToUpper(p.curTok.Literal) + if upper == "CONTAINSTABLE" || upper == "FREETEXTTABLE" { + return p.parseFullTextTableReference(upper) + } + // Check for semantic table functions + if upper == "SEMANTICKEYPHRASETABLE" || upper == "SEMANTICSIMILARITYTABLE" || upper == "SEMANTICSIMILARITYDETAILSTABLE" { + return p.parseSemanticTableReference(upper) + } + } + // Check for variable table reference if p.curTok.Type == TokenIdent && strings.HasPrefix(p.curTok.Literal, "@") { name := p.curTok.Literal @@ -2030,6 +2042,319 @@ func (p *Parser) parseNamedTableReferenceWithName(son *ast.SchemaObjectName) (*a return ref, nil } +// parseFullTextTableReference parses CONTAINSTABLE or FREETEXTTABLE +func (p *Parser) parseFullTextTableReference(funcType string) (*ast.FullTextTableReference, error) { + ref := &ast.FullTextTableReference{ + ForPath: false, + } + if funcType == "CONTAINSTABLE" { + ref.FullTextFunctionType = "Contains" + } else { + ref.FullTextFunctionType = "FreeText" + } + p.nextToken() // consume function name + + // Expect ( + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after %s, got %s", funcType, p.curTok.Literal) + } + p.nextToken() // consume ( + + // Parse table name + tableName, err := p.parseSchemaObjectName() + if err != nil { + return nil, err + } + ref.TableName = tableName + + // Expect comma + if p.curTok.Type != TokenComma { + return nil, fmt.Errorf("expected , after table name, got %s", p.curTok.Literal) + } + p.nextToken() // consume , + + // Parse column specification - could be *, (columns), or PROPERTY(column, 'property') + if p.curTok.Type == TokenStar { + ref.Columns = []*ast.ColumnReferenceExpression{{ColumnType: "Wildcard"}} + p.nextToken() + } else if p.curTok.Type == TokenLParen { + // Column list + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + if p.curTok.Type == TokenStar { + ref.Columns = append(ref.Columns, &ast.ColumnReferenceExpression{ColumnType: "Wildcard"}) + p.nextToken() + } else { + col := p.parseIdentifier() + ref.Columns = append(ref.Columns, &ast.ColumnReferenceExpression{ + ColumnType: "Regular", + MultiPartIdentifier: &ast.MultiPartIdentifier{ + Identifiers: []*ast.Identifier{col}, + Count: 1, + }, + }) + } + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } else if p.curTok.Type == TokenIdent && strings.ToUpper(p.curTok.Literal) == "PROPERTY" { + // PROPERTY(column, 'property_name') + p.nextToken() // consume PROPERTY + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after PROPERTY, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + + // Parse column name + col := p.parseIdentifier() + ref.Columns = []*ast.ColumnReferenceExpression{{ + ColumnType: "Regular", + MultiPartIdentifier: &ast.MultiPartIdentifier{ + Identifiers: []*ast.Identifier{col}, + Count: 1, + }, + }} + + // Expect comma + if p.curTok.Type != TokenComma { + return nil, fmt.Errorf("expected , after column in PROPERTY, got %s", p.curTok.Literal) + } + p.nextToken() // consume , + + // Parse property name (string literal) + propExpr, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + ref.PropertyName = propExpr + + // Expect ) + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) after PROPERTY, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + } else { + // Single column + col := p.parseIdentifier() + ref.Columns = []*ast.ColumnReferenceExpression{{ + ColumnType: "Regular", + MultiPartIdentifier: &ast.MultiPartIdentifier{ + Identifiers: []*ast.Identifier{col}, + Count: 1, + }, + }} + } + + // Expect comma + if p.curTok.Type != TokenComma { + return nil, fmt.Errorf("expected , after columns, got %s", p.curTok.Literal) + } + p.nextToken() // consume , + + // Parse search condition (string literal or expression) + searchCond, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + ref.SearchCondition = searchCond + + // Parse optional LANGUAGE and top_n - can come in any order + for p.curTok.Type == TokenComma { + p.nextToken() // consume , + + if p.curTok.Type == TokenLanguage { + p.nextToken() // consume LANGUAGE + langExpr, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + ref.Language = langExpr + } else { + // top_n value + topExpr, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + ref.TopN = topExpr + } + } + + // Expect ) + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) after CONTAINSTABLE/FREETEXTTABLE, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Parse optional alias + if p.curTok.Type == TokenAs { + p.nextToken() + ref.Alias = p.parseIdentifier() + } else if p.curTok.Type == TokenIdent { + upper := strings.ToUpper(p.curTok.Literal) + if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "ORDER" && upper != "OPTION" && upper != "GO" && upper != "WITH" && upper != "ON" && upper != "JOIN" && upper != "INNER" && upper != "LEFT" && upper != "RIGHT" && upper != "FULL" && upper != "CROSS" && upper != "OUTER" && upper != "FOR" { + ref.Alias = p.parseIdentifier() + } + } + + return ref, nil +} + +// parseSemanticTableReference parses SEMANTICKEYPHRASETABLE, SEMANTICSIMILARITYTABLE, or SEMANTICSIMILARITYDETAILSTABLE +func (p *Parser) parseSemanticTableReference(funcType string) (*ast.SemanticTableReference, error) { + ref := &ast.SemanticTableReference{ + ForPath: false, + } + switch funcType { + case "SEMANTICKEYPHRASETABLE": + ref.SemanticFunctionType = "SemanticKeyPhraseTable" + case "SEMANTICSIMILARITYTABLE": + ref.SemanticFunctionType = "SemanticSimilarityTable" + case "SEMANTICSIMILARITYDETAILSTABLE": + ref.SemanticFunctionType = "SemanticSimilarityDetailsTable" + } + p.nextToken() // consume function name + + // Expect ( + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after %s, got %s", funcType, p.curTok.Literal) + } + p.nextToken() // consume ( + + // Parse table name + tableName, err := p.parseSchemaObjectName() + if err != nil { + return nil, err + } + ref.TableName = tableName + + // Expect comma + if p.curTok.Type != TokenComma { + return nil, fmt.Errorf("expected , after table name, got %s", p.curTok.Literal) + } + p.nextToken() // consume , + + // Parse column specification - could be *, (columns), or single column + if p.curTok.Type == TokenStar { + ref.Columns = []*ast.ColumnReferenceExpression{{ColumnType: "Wildcard"}} + p.nextToken() + } else if p.curTok.Type == TokenLParen { + // Column list + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + if p.curTok.Type == TokenStar { + ref.Columns = append(ref.Columns, &ast.ColumnReferenceExpression{ColumnType: "Wildcard"}) + p.nextToken() + } else { + col := p.parseIdentifier() + ref.Columns = append(ref.Columns, &ast.ColumnReferenceExpression{ + ColumnType: "Regular", + MultiPartIdentifier: &ast.MultiPartIdentifier{ + Identifiers: []*ast.Identifier{col}, + Count: 1, + }, + }) + } + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } else { + // Single column + col := p.parseIdentifier() + ref.Columns = []*ast.ColumnReferenceExpression{{ + ColumnType: "Regular", + MultiPartIdentifier: &ast.MultiPartIdentifier{ + Identifiers: []*ast.Identifier{col}, + Count: 1, + }, + }} + } + + // For SEMANTICSIMILARITYTABLE and SEMANTICKEYPHRASETABLE: optional source_key + // For SEMANTICSIMILARITYDETAILSTABLE: source_key, matched_column, matched_key + if p.curTok.Type == TokenComma { + p.nextToken() // consume , + // Parse source_key expression + sourceKey, err := p.parseSimpleExpression() + if err != nil { + return nil, err + } + ref.SourceKey = sourceKey + + // For SEMANTICSIMILARITYDETAILSTABLE, parse matched_column and matched_key + if funcType == "SEMANTICSIMILARITYDETAILSTABLE" { + if p.curTok.Type == TokenComma { + p.nextToken() // consume , + // Parse matched_column + col := p.parseIdentifier() + ref.MatchedColumn = &ast.ColumnReferenceExpression{ + ColumnType: "Regular", + MultiPartIdentifier: &ast.MultiPartIdentifier{ + Identifiers: []*ast.Identifier{col}, + Count: 1, + }, + } + + if p.curTok.Type == TokenComma { + p.nextToken() // consume , + // Parse matched_key expression + matchedKey, err := p.parseSimpleExpression() + if err != nil { + return nil, err + } + ref.MatchedKey = matchedKey + } + } + } + } + + // Expect ) + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) after semantic table function, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Parse optional alias + if p.curTok.Type == TokenAs { + p.nextToken() + ref.Alias = p.parseIdentifier() + } else if p.curTok.Type == TokenIdent { + upper := strings.ToUpper(p.curTok.Literal) + if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "ORDER" && upper != "OPTION" && upper != "GO" && upper != "WITH" && upper != "ON" && upper != "JOIN" && upper != "INNER" && upper != "LEFT" && upper != "RIGHT" && upper != "FULL" && upper != "CROSS" && upper != "OUTER" && upper != "FOR" { + ref.Alias = p.parseIdentifier() + } + } + + return ref, nil +} + +// parseSimpleExpression parses a simple expression (including unary minus for negative numbers) +func (p *Parser) parseSimpleExpression() (ast.ScalarExpression, error) { + if p.curTok.Type == TokenMinus { + p.nextToken() // consume - + expr, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + return &ast.UnaryExpression{ + UnaryExpressionType: "Negative", + Expression: expr, + }, nil + } + return p.parsePrimaryExpression() +} + // parseTableHint parses a single table hint func (p *Parser) parseTableHint() (ast.TableHintType, error) { hintName := strings.ToUpper(p.curTok.Literal) @@ -2079,6 +2404,24 @@ func (p *Parser) parseTableHint() (ast.TableHintType, error) { return hint, nil } + // SPATIAL_WINDOW_MAX_CELLS hint with value + if hintName == "SPATIAL_WINDOW_MAX_CELLS" { + hint := &ast.LiteralTableHint{ + HintKind: "SpatialWindowMaxCells", + } + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + if p.curTok.Type == TokenNumber { + hint.Value = &ast.IntegerLiteral{ + LiteralType: "Integer", + Value: p.curTok.Literal, + } + p.nextToken() + } + return hint, nil + } + // Map hint names to HintKind hintKind := getTableHintKind(hintName) if hintKind == "" { diff --git a/parser/testdata/Baselines110_FromClauseTests110/metadata.json b/parser/testdata/Baselines110_FromClauseTests110/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines110_FromClauseTests110/metadata.json +++ b/parser/testdata/Baselines110_FromClauseTests110/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/FromClauseTests110/metadata.json b/parser/testdata/FromClauseTests110/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/FromClauseTests110/metadata.json +++ b/parser/testdata/FromClauseTests110/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 09a72460f53675b31f2ab7770ed378c7708cdb42 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 23:40:51 +0000 Subject: [PATCH 04/16] Add CREATE/ALTER RESOURCE POOL statement support - Add AST types for CreateResourcePoolStatement and AlterResourcePoolStatement - Add ResourcePoolParameter, ResourcePoolAffinitySpecification, LiteralRange types - Parse various resource pool parameters (CPU, memory, IO percent) - Parse AFFINITY SCHEDULER/NUMANODE specifications with ranges - Enable 4 resource pool related tests --- ast/resource_pool_statement.go | 39 +++ parser/marshal.go | 85 ++++++ parser/parse_ddl.go | 245 ++++++++++++++++++ parser/parse_statements.go | 9 + .../metadata.json | 2 +- .../metadata.json | 2 +- .../metadata.json | 2 +- .../metadata.json | 2 +- 8 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 ast/resource_pool_statement.go diff --git a/ast/resource_pool_statement.go b/ast/resource_pool_statement.go new file mode 100644 index 00000000..371900c5 --- /dev/null +++ b/ast/resource_pool_statement.go @@ -0,0 +1,39 @@ +package ast + +// CreateResourcePoolStatement represents a CREATE RESOURCE POOL statement +type CreateResourcePoolStatement struct { + Name *Identifier `json:"Name,omitempty"` + ResourcePoolParameters []*ResourcePoolParameter `json:"ResourcePoolParameters,omitempty"` +} + +func (*CreateResourcePoolStatement) node() {} +func (*CreateResourcePoolStatement) statement() {} + +// AlterResourcePoolStatement represents an ALTER RESOURCE POOL statement +type AlterResourcePoolStatement struct { + Name *Identifier `json:"Name,omitempty"` + ResourcePoolParameters []*ResourcePoolParameter `json:"ResourcePoolParameters,omitempty"` +} + +func (*AlterResourcePoolStatement) node() {} +func (*AlterResourcePoolStatement) statement() {} + +// ResourcePoolParameter represents a parameter in a resource pool statement +type ResourcePoolParameter struct { + ParameterType string `json:"ParameterType,omitempty"` // MinCpuPercent, MaxCpuPercent, CapCpuPercent, MinMemoryPercent, MaxMemoryPercent, MinIoPercent, MaxIoPercent, CapIoPercent, Affinity, etc. + ParameterValue ScalarExpression `json:"ParameterValue,omitempty"` + AffinitySpecification *ResourcePoolAffinitySpecification `json:"AffinitySpecification,omitempty"` +} + +// ResourcePoolAffinitySpecification represents an AFFINITY specification in a resource pool +type ResourcePoolAffinitySpecification struct { + AffinityType string `json:"AffinityType,omitempty"` // Scheduler, NumaNode + IsAuto bool `json:"IsAuto"` + PoolAffinityRanges []*LiteralRange `json:"PoolAffinityRanges,omitempty"` +} + +// LiteralRange represents a range of values (e.g., 50 TO 60) +type LiteralRange struct { + From ScalarExpression `json:"From,omitempty"` + To ScalarExpression `json:"To,omitempty"` +} diff --git a/parser/marshal.go b/parser/marshal.go index 1497f2ef..d32f485b 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -146,6 +146,10 @@ func statementToJSON(stmt ast.Statement) jsonNode { return alterDatabaseScopedConfigurationClearStatementToJSON(s) case *ast.AlterResourceGovernorStatement: return alterResourceGovernorStatementToJSON(s) + case *ast.CreateResourcePoolStatement: + return createResourcePoolStatementToJSON(s) + case *ast.AlterResourcePoolStatement: + return alterResourcePoolStatementToJSON(s) case *ast.CreateCryptographicProviderStatement: return createCryptographicProviderStatementToJSON(s) case *ast.CreateColumnMasterKeyStatement: @@ -14565,6 +14569,87 @@ func alterResourceGovernorStatementToJSON(s *ast.AlterResourceGovernorStatement) return node } +func createResourcePoolStatementToJSON(s *ast.CreateResourcePoolStatement) jsonNode { + node := jsonNode{ + "$type": "CreateResourcePoolStatement", + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + if len(s.ResourcePoolParameters) > 0 { + params := make([]jsonNode, len(s.ResourcePoolParameters)) + for i, param := range s.ResourcePoolParameters { + params[i] = resourcePoolParameterToJSON(param) + } + node["ResourcePoolParameters"] = params + } + return node +} + +func alterResourcePoolStatementToJSON(s *ast.AlterResourcePoolStatement) jsonNode { + node := jsonNode{ + "$type": "AlterResourcePoolStatement", + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + if len(s.ResourcePoolParameters) > 0 { + params := make([]jsonNode, len(s.ResourcePoolParameters)) + for i, param := range s.ResourcePoolParameters { + params[i] = resourcePoolParameterToJSON(param) + } + node["ResourcePoolParameters"] = params + } + return node +} + +func resourcePoolParameterToJSON(p *ast.ResourcePoolParameter) jsonNode { + node := jsonNode{ + "$type": "ResourcePoolParameter", + } + if p.ParameterType != "" { + node["ParameterType"] = p.ParameterType + } + if p.ParameterValue != nil { + node["ParameterValue"] = scalarExpressionToJSON(p.ParameterValue) + } + if p.AffinitySpecification != nil { + node["AffinitySpecification"] = resourcePoolAffinitySpecificationToJSON(p.AffinitySpecification) + } + return node +} + +func resourcePoolAffinitySpecificationToJSON(s *ast.ResourcePoolAffinitySpecification) jsonNode { + node := jsonNode{ + "$type": "ResourcePoolAffinitySpecification", + } + if s.AffinityType != "" { + node["AffinityType"] = s.AffinityType + } + node["IsAuto"] = s.IsAuto + if len(s.PoolAffinityRanges) > 0 { + ranges := make([]jsonNode, len(s.PoolAffinityRanges)) + for i, r := range s.PoolAffinityRanges { + ranges[i] = literalRangeToJSON(r) + } + node["PoolAffinityRanges"] = ranges + } + return node +} + +func literalRangeToJSON(r *ast.LiteralRange) jsonNode { + node := jsonNode{ + "$type": "LiteralRange", + } + if r.From != nil { + node["From"] = scalarExpressionToJSON(r.From) + } + if r.To != nil { + node["To"] = scalarExpressionToJSON(r.To) + } + return node +} + func createCryptographicProviderStatementToJSON(s *ast.CreateCryptographicProviderStatement) jsonNode { node := jsonNode{ "$type": "CreateCryptographicProviderStatement", diff --git a/parser/parse_ddl.go b/parser/parse_ddl.go index e870f418..a7b4f572 100644 --- a/parser/parse_ddl.go +++ b/parser/parse_ddl.go @@ -1644,6 +1644,11 @@ func (p *Parser) parseAlterResourceGovernorStatement() (ast.Statement, error) { // Consume RESOURCE p.nextToken() + // Check if this is RESOURCE POOL or RESOURCE GOVERNOR + if strings.ToUpper(p.curTok.Literal) == "POOL" { + return p.parseAlterResourcePoolStatement() + } + // Consume GOVERNOR if strings.ToUpper(p.curTok.Literal) == "GOVERNOR" { p.nextToken() @@ -7476,3 +7481,243 @@ func (p *Parser) parseAlterSearchPropertyListStatement() (*ast.AlterSearchProper return stmt, nil } + +// parseCreateResourcePoolStatement parses CREATE RESOURCE POOL statement +func (p *Parser) parseCreateResourcePoolStatement() (*ast.CreateResourcePoolStatement, error) { + // We've already consumed CREATE RESOURCE + // Consume POOL + if strings.ToUpper(p.curTok.Literal) == "POOL" { + p.nextToken() + } + + stmt := &ast.CreateResourcePoolStatement{} + + // Parse pool name + stmt.Name = p.parseIdentifier() + + // Parse optional WITH clause + if p.curTok.Type == TokenWith { + p.nextToken() // consume WITH + params, err := p.parseResourcePoolParameters() + if err != nil { + return nil, err + } + stmt.ResourcePoolParameters = params + } + + return stmt, nil +} + +// parseAlterResourcePoolStatement parses ALTER RESOURCE POOL statement +func (p *Parser) parseAlterResourcePoolStatement() (*ast.AlterResourcePoolStatement, error) { + // Consume POOL (we've already consumed ALTER RESOURCE) + p.nextToken() + + stmt := &ast.AlterResourcePoolStatement{} + + // Parse pool name + stmt.Name = p.parseIdentifier() + + // Parse optional WITH clause + if p.curTok.Type == TokenWith { + p.nextToken() // consume WITH + params, err := p.parseResourcePoolParameters() + if err != nil { + return nil, err + } + stmt.ResourcePoolParameters = params + } + + return stmt, nil +} + +// parseResourcePoolParameters parses resource pool parameters within WITH (...) +func (p *Parser) parseResourcePoolParameters() ([]*ast.ResourcePoolParameter, error) { + var params []*ast.ResourcePoolParameter + + // Expect ( + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after WITH, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + param, err := p.parseResourcePoolParameter() + if err != nil { + return nil, err + } + if param != nil { + params = append(params, param) + } + + if p.curTok.Type == TokenComma { + p.nextToken() // consume , + } + } + + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + + return params, nil +} + +// parseResourcePoolParameter parses a single resource pool parameter +func (p *Parser) parseResourcePoolParameter() (*ast.ResourcePoolParameter, error) { + paramName := strings.ToUpper(p.curTok.Literal) + p.nextToken() // consume parameter name + + param := &ast.ResourcePoolParameter{} + + switch paramName { + case "MIN_CPU_PERCENT": + param.ParameterType = "MinCpuPercent" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + param.ParameterValue = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + case "MAX_CPU_PERCENT": + param.ParameterType = "MaxCpuPercent" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + param.ParameterValue = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + case "CAP_CPU_PERCENT": + param.ParameterType = "CapCpuPercent" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + param.ParameterValue = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + case "MIN_MEMORY_PERCENT": + param.ParameterType = "MinMemoryPercent" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + param.ParameterValue = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + case "MAX_MEMORY_PERCENT": + param.ParameterType = "MaxMemoryPercent" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + param.ParameterValue = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + case "TARGET_MEMORY_PERCENT": + param.ParameterType = "TargetMemoryPercent" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + param.ParameterValue = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + case "MIN_IO_PERCENT": + param.ParameterType = "MinIoPercent" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + param.ParameterValue = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + case "MAX_IO_PERCENT": + param.ParameterType = "MaxIoPercent" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + param.ParameterValue = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + case "CAP_IO_PERCENT": + param.ParameterType = "CapIoPercent" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + param.ParameterValue = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + case "MIN_IOPS_PER_VOLUME": + param.ParameterType = "MinIopsPerVolume" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + param.ParameterValue = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + case "MAX_IOPS_PER_VOLUME": + param.ParameterType = "MaxIopsPerVolume" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + param.ParameterValue = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + case "AFFINITY": + param.ParameterType = "Affinity" + affSpec, err := p.parseResourcePoolAffinitySpecification() + if err != nil { + return nil, err + } + param.AffinitySpecification = affSpec + default: + // Skip unknown parameter + return nil, nil + } + + return param, nil +} + +// parseResourcePoolAffinitySpecification parses AFFINITY SCHEDULER/NUMANODE specification +func (p *Parser) parseResourcePoolAffinitySpecification() (*ast.ResourcePoolAffinitySpecification, error) { + spec := &ast.ResourcePoolAffinitySpecification{} + + // Parse SCHEDULER or NUMANODE + affinityType := strings.ToUpper(p.curTok.Literal) + p.nextToken() + + switch affinityType { + case "SCHEDULER": + spec.AffinityType = "Scheduler" + case "NUMANODE": + spec.AffinityType = "NumaNode" + default: + return nil, fmt.Errorf("expected SCHEDULER or NUMANODE after AFFINITY, got %s", affinityType) + } + + // Expect = + if p.curTok.Type == TokenEquals { + p.nextToken() + } + + // Check for AUTO or range list + if strings.ToUpper(p.curTok.Literal) == "AUTO" { + spec.IsAuto = true + p.nextToken() + } else if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + spec.IsAuto = false + + // Parse range list + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + lr := &ast.LiteralRange{} + + // Parse 'from' value + lr.From = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + + // Check for TO + if strings.ToUpper(p.curTok.Literal) == "TO" { + p.nextToken() // consume TO + lr.To = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + } + + spec.PoolAffinityRanges = append(spec.PoolAffinityRanges, lr) + + if p.curTok.Type == TokenComma { + p.nextToken() + } + } + + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } + + return spec, nil +} diff --git a/parser/parse_statements.go b/parser/parse_statements.go index 4498d590..5cbcbe9c 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -2474,6 +2474,15 @@ func (p *Parser) parseCreateStatement() (ast.Statement, error) { return p.parseCreateWorkloadClassifierStatement() } return p.parseCreateWorkloadGroupStatement() + case "RESOURCE": + // Check if it's RESOURCE POOL or RESOURCE GOVERNOR + p.nextToken() // consume RESOURCE + if strings.ToUpper(p.curTok.Literal) == "POOL" { + return p.parseCreateResourcePoolStatement() + } + // RESOURCE GOVERNOR not supported for CREATE + p.skipToEndOfStatement() + return &ast.CreateProcedureStatement{}, nil case "SEQUENCE": return p.parseCreateSequenceStatement() case "SPATIAL": diff --git a/parser/testdata/AlterCreateResourcePoolStatement120/metadata.json b/parser/testdata/AlterCreateResourcePoolStatement120/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/AlterCreateResourcePoolStatement120/metadata.json +++ b/parser/testdata/AlterCreateResourcePoolStatement120/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/Baselines110_CreateAlterResourcePoolStatementTests110/metadata.json b/parser/testdata/Baselines110_CreateAlterResourcePoolStatementTests110/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines110_CreateAlterResourcePoolStatementTests110/metadata.json +++ b/parser/testdata/Baselines110_CreateAlterResourcePoolStatementTests110/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/Baselines120_AlterCreateResourcePoolStatement120/metadata.json b/parser/testdata/Baselines120_AlterCreateResourcePoolStatement120/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines120_AlterCreateResourcePoolStatement120/metadata.json +++ b/parser/testdata/Baselines120_AlterCreateResourcePoolStatement120/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/CreateAlterResourcePoolStatementTests110/metadata.json b/parser/testdata/CreateAlterResourcePoolStatementTests110/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/CreateAlterResourcePoolStatementTests110/metadata.json +++ b/parser/testdata/CreateAlterResourcePoolStatementTests110/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 362d2d542395491e03a0791da82adc8fdf470aef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 23:48:43 +0000 Subject: [PATCH 05/16] Fix CREATE DATABASE statement handling - Always output AttachMode field in CreateDatabaseStatement - Add TokenScoped to isKeywordAsIdentifier() to allow SCOPED as database name - Fix SCOPED keyword handling with lookahead to avoid consuming it when not followed by CREDENTIAL - Enable BaselinesCommon_CreateDatabaseStatementTests and CreateDatabaseStatementTests - Update PhaseOne_CreateDatabase ast.json to include AttachMode --- parser/marshal.go | 6 ++---- parser/parse_select.go | 2 +- parser/parse_statements.go | 8 +++++--- .../metadata.json | 2 +- .../testdata/CreateDatabaseStatementTests/metadata.json | 2 +- parser/testdata/PhaseOne_CreateDatabase/ast.json | 3 ++- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/parser/marshal.go b/parser/marshal.go index d32f485b..ef313565 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -13502,10 +13502,8 @@ func createDatabaseStatementToJSON(s *ast.CreateDatabaseStatement) jsonNode { } node["LogOn"] = logs } - // AttachMode is output when there are FileGroups, Options, Collation, CopyOf, or Containment - if len(s.FileGroups) > 0 || len(s.Options) > 0 || s.Collation != nil || s.CopyOf != nil || s.Containment != nil { - node["AttachMode"] = s.AttachMode - } + // Always output AttachMode + node["AttachMode"] = s.AttachMode if s.CopyOf != nil { node["CopyOf"] = multiPartIdentifierToJSON(s.CopyOf) } diff --git a/parser/parse_select.go b/parser/parse_select.go index f785ac81..826f01cc 100644 --- a/parser/parse_select.go +++ b/parser/parse_select.go @@ -634,7 +634,7 @@ func (p *Parser) isKeywordAsIdentifier() bool { TokenExternal, TokenSymmetric, TokenAsymmetric, TokenGroup, TokenAdd, TokenGrant, TokenRevoke, TokenBackup, TokenRestore, TokenQuery, TokenJob, TokenStats, TokenPassword, TokenTime, TokenDelay, - TokenTyp: + TokenTyp, TokenScoped: return true default: return false diff --git a/parser/parse_statements.go b/parser/parse_statements.go index 5cbcbe9c..1b6e2c05 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -7910,11 +7910,13 @@ func (p *Parser) parseCreateDatabaseStatement() (ast.Statement, error) { } // Check for DATABASE SCOPED CREDENTIAL - if strings.ToUpper(p.curTok.Literal) == "SCOPED" { - p.nextToken() // consume SCOPED - if p.curTok.Type == TokenCredential { + if p.curTok.Type == TokenScoped || strings.ToUpper(p.curTok.Literal) == "SCOPED" { + // Look ahead to see if it's SCOPED CREDENTIAL + if p.peekTok.Type == TokenCredential { + p.nextToken() // consume SCOPED return p.parseCreateCredentialStatement(true) } + // Otherwise SCOPED is the database name } stmt := &ast.CreateDatabaseStatement{ diff --git a/parser/testdata/BaselinesCommon_CreateDatabaseStatementTests/metadata.json b/parser/testdata/BaselinesCommon_CreateDatabaseStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/BaselinesCommon_CreateDatabaseStatementTests/metadata.json +++ b/parser/testdata/BaselinesCommon_CreateDatabaseStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/CreateDatabaseStatementTests/metadata.json b/parser/testdata/CreateDatabaseStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/CreateDatabaseStatementTests/metadata.json +++ b/parser/testdata/CreateDatabaseStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/PhaseOne_CreateDatabase/ast.json b/parser/testdata/PhaseOne_CreateDatabase/ast.json index 3040d9bc..34ced1b6 100644 --- a/parser/testdata/PhaseOne_CreateDatabase/ast.json +++ b/parser/testdata/PhaseOne_CreateDatabase/ast.json @@ -10,7 +10,8 @@ "$type": "Identifier", "QuoteType": "NotQuoted", "Value": "d1" - } + }, + "AttachMode": "None" } ] } From 97ca7c79278a89eae72298fc215d8f8c9597d3ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 23:54:27 +0000 Subject: [PATCH 06/16] Add support for implicit procedure execution In T-SQL, at the start of a batch, you can call a stored procedure without the EXEC keyword (e.g., "sp_grantdbaccess 'user'"). This change modifies parseLabelOrError to detect such cases and create an ExecuteStatement. - Add isImplicitExecuteParameter() to detect valid parameter tokens - Add parseImplicitExecuteStatement() to construct the ExecuteStatement AST - Enable ExecuteStatementTests --- parser/parse_statements.go | 80 ++++++++++++++++++- .../ExecuteStatementTests/metadata.json | 2 +- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/parser/parse_statements.go b/parser/parse_statements.go index 1b6e2c05..58ee8883 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -5690,12 +5690,90 @@ func (p *Parser) parseLabelOrError() (ast.Statement, error) { return &ast.LabelStatement{Value: label + ":"}, nil } - // Not a label - be lenient and skip to end of statement + // Check for implicit procedure execution (identifier followed by parameters) + // This happens at batch start where you can call a stored procedure without EXEC + if p.isImplicitExecuteParameter() { + return p.parseImplicitExecuteStatement(label) + } + + // Not a label or implicit execute - be lenient and skip to end of statement // This handles malformed SQL like "abcde" or other unknown identifiers p.skipToEndOfStatement() return &ast.LabelStatement{Value: label}, nil } +// isImplicitExecuteParameter checks if current token could be a parameter for implicit EXEC +func (p *Parser) isImplicitExecuteParameter() bool { + switch p.curTok.Type { + case TokenString, TokenNationalString, TokenNumber: + return true + case TokenIdent: + // Variables (@var) or identifiers followed by comma/semicolon + if strings.HasPrefix(p.curTok.Literal, "@") { + return true + } + // DEFAULT keyword + if strings.ToUpper(p.curTok.Literal) == "DEFAULT" { + return true + } + // Regular identifier as parameter (like sp_addtype birthday, datetime) + return true + case TokenSemicolon, TokenEOF: + // No parameters - could still be implicit exec + return true + default: + return false + } +} + +// parseImplicitExecuteStatement parses an implicit EXEC statement (procedure call without EXEC keyword) +func (p *Parser) parseImplicitExecuteStatement(procName string) (ast.Statement, error) { + // Build the SchemaObjectName from the procedure name + // Use the same identifier pointer for both Identifiers array and BaseIdentifier + // so that JSON marshaling can use $ref + baseIdent := &ast.Identifier{Value: procName, QuoteType: "NotQuoted"} + son := &ast.SchemaObjectName{ + Count: 1, + Identifiers: []*ast.Identifier{baseIdent}, + BaseIdentifier: baseIdent, + } + + procRef := &ast.ExecutableProcedureReference{ + ProcedureReference: &ast.ProcedureReferenceName{ + ProcedureReference: &ast.ProcedureReference{ + Name: son, + }, + }, + } + + // Parse parameters + for p.curTok.Type != TokenEOF && p.curTok.Type != TokenSemicolon && !p.isStatementTerminator() { + param, err := p.parseExecuteParameter() + if err != nil { + break + } + procRef.Parameters = append(procRef.Parameters, param) + + if p.curTok.Type != TokenComma { + break + } + p.nextToken() + } + + spec := &ast.ExecuteSpecification{ + ExecutableEntity: procRef, + } + + stmt := &ast.ExecuteStatement{ExecuteSpecification: spec} + + // Skip optional semicolon + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + func isKeyword(s string) bool { _, ok := keywords[strings.ToUpper(s)] return ok diff --git a/parser/testdata/ExecuteStatementTests/metadata.json b/parser/testdata/ExecuteStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/ExecuteStatementTests/metadata.json +++ b/parser/testdata/ExecuteStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 8521e2b9d282c94107ac8a7e6ac7bed50fffcd5f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 00:02:14 +0000 Subject: [PATCH 07/16] Add support for WITH clause (CTEs and XMLNAMESPACES) - Add XmlNamespaces parsing to parseWithStatement - Add XmlNamespacesDefaultElement AST type for DEFAULT namespace elements - Update WithCtesAndXmlNamespaces to include XmlNamespaces field - Add WithCtesAndXmlNamespaces field to SelectStatement - Fix parseStringLiteral to handle national strings (N'...') - Update cursor parsing to handle WITH clause in SELECT - Update JSON marshaling for new types Enables Baselines90_CTEStatementTests and CTEStatementTests --- ast/alter_index_statement.go | 18 ++++++- ast/cte.go | 1 + ast/select_statement.go | 9 ++-- parser/marshal.go | 29 ++++++++++- parser/parse_dml.go | 48 ++++++++++++++++--- parser/parse_select.go | 14 +++++- parser/parse_statements.go | 23 +++++++-- .../metadata.json | 2 +- .../testdata/CTEStatementTests/metadata.json | 2 +- 9 files changed, 125 insertions(+), 21 deletions(-) diff --git a/ast/alter_index_statement.go b/ast/alter_index_statement.go index cb1adfb1..317f98c9 100644 --- a/ast/alter_index_statement.go +++ b/ast/alter_index_statement.go @@ -28,18 +28,32 @@ func (s *SelectiveXmlIndexPromotedPath) node() {} // XmlNamespaces represents a WITH XMLNAMESPACES clause type XmlNamespaces struct { - XmlNamespacesElements []*XmlNamespacesAliasElement + XmlNamespacesElements []XmlNamespacesElement } func (x *XmlNamespaces) node() {} +// XmlNamespacesElement is an interface for XML namespace elements +type XmlNamespacesElement interface { + xmlNamespacesElement() +} + // XmlNamespacesAliasElement represents an alias element in XMLNAMESPACES type XmlNamespacesAliasElement struct { Identifier *Identifier String *StringLiteral } -func (x *XmlNamespacesAliasElement) node() {} +func (x *XmlNamespacesAliasElement) node() {} +func (x *XmlNamespacesAliasElement) xmlNamespacesElement() {} + +// XmlNamespacesDefaultElement represents a default element in XMLNAMESPACES +type XmlNamespacesDefaultElement struct { + String *StringLiteral +} + +func (x *XmlNamespacesDefaultElement) node() {} +func (x *XmlNamespacesDefaultElement) xmlNamespacesElement() {} // PartitionSpecifier represents a partition specifier type PartitionSpecifier struct { diff --git a/ast/cte.go b/ast/cte.go index d9f67207..d1f03d38 100644 --- a/ast/cte.go +++ b/ast/cte.go @@ -2,6 +2,7 @@ package ast // WithCtesAndXmlNamespaces represents the WITH clause containing CTEs and/or XML namespaces. type WithCtesAndXmlNamespaces struct { + XmlNamespaces *XmlNamespaces `json:"XmlNamespaces,omitempty"` CommonTableExpressions []*CommonTableExpression `json:"CommonTableExpressions,omitempty"` ChangeTrackingContext ScalarExpression `json:"ChangeTrackingContext,omitempty"` } diff --git a/ast/select_statement.go b/ast/select_statement.go index f9b64644..4e18097f 100644 --- a/ast/select_statement.go +++ b/ast/select_statement.go @@ -2,10 +2,11 @@ package ast // SelectStatement represents a SELECT statement. type SelectStatement struct { - QueryExpression QueryExpression `json:"QueryExpression,omitempty"` - Into *SchemaObjectName `json:"Into,omitempty"` - On *Identifier `json:"On,omitempty"` - OptimizerHints []OptimizerHintBase `json:"OptimizerHints,omitempty"` + QueryExpression QueryExpression `json:"QueryExpression,omitempty"` + Into *SchemaObjectName `json:"Into,omitempty"` + On *Identifier `json:"On,omitempty"` + OptimizerHints []OptimizerHintBase `json:"OptimizerHints,omitempty"` + WithCtesAndXmlNamespaces *WithCtesAndXmlNamespaces `json:"WithCtesAndXmlNamespaces,omitempty"` } func (*SelectStatement) node() {} diff --git a/parser/marshal.go b/parser/marshal.go index ef313565..a13427ed 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -1197,6 +1197,9 @@ func selectStatementToJSON(s *ast.SelectStatement) jsonNode { } node["OptimizerHints"] = hints } + if s.WithCtesAndXmlNamespaces != nil { + node["WithCtesAndXmlNamespaces"] = withCtesAndXmlNamespacesToJSON(s.WithCtesAndXmlNamespaces) + } return node } @@ -3057,6 +3060,9 @@ func withCtesAndXmlNamespacesToJSON(w *ast.WithCtesAndXmlNamespaces) jsonNode { node := jsonNode{ "$type": "WithCtesAndXmlNamespaces", } + if w.XmlNamespaces != nil { + node["XmlNamespaces"] = xmlNamespacesToJSON(w.XmlNamespaces) + } if len(w.CommonTableExpressions) > 0 { ctes := make([]jsonNode, len(w.CommonTableExpressions)) for i, cte := range w.CommonTableExpressions { @@ -11346,13 +11352,24 @@ func xmlNamespacesToJSON(x *ast.XmlNamespaces) jsonNode { if len(x.XmlNamespacesElements) > 0 { elems := make([]jsonNode, len(x.XmlNamespacesElements)) for i, e := range x.XmlNamespacesElements { - elems[i] = xmlNamespacesAliasElementToJSON(e) + elems[i] = xmlNamespacesElementToJSON(e) } node["XmlNamespacesElements"] = elems } return node } +func xmlNamespacesElementToJSON(e ast.XmlNamespacesElement) jsonNode { + switch elem := e.(type) { + case *ast.XmlNamespacesAliasElement: + return xmlNamespacesAliasElementToJSON(elem) + case *ast.XmlNamespacesDefaultElement: + return xmlNamespacesDefaultElementToJSON(elem) + default: + return jsonNode{} + } +} + func xmlNamespacesAliasElementToJSON(e *ast.XmlNamespacesAliasElement) jsonNode { node := jsonNode{ "$type": "XmlNamespacesAliasElement", @@ -11366,6 +11383,16 @@ func xmlNamespacesAliasElementToJSON(e *ast.XmlNamespacesAliasElement) jsonNode return node } +func xmlNamespacesDefaultElementToJSON(e *ast.XmlNamespacesDefaultElement) jsonNode { + node := jsonNode{ + "$type": "XmlNamespacesDefaultElement", + } + if e.String != nil { + node["String"] = stringLiteralToJSON(e.String) + } + return node +} + func partitionSpecifierToJSON(p *ast.PartitionSpecifier) jsonNode { node := jsonNode{ "$type": "PartitionSpecifier", diff --git a/parser/parse_dml.go b/parser/parse_dml.go index 80d0d07f..d59250c8 100644 --- a/parser/parse_dml.go +++ b/parser/parse_dml.go @@ -14,9 +14,42 @@ func (p *Parser) parseWithStatement() (ast.Statement, error) { withClause := &ast.WithCtesAndXmlNamespaces{} - // Parse CHANGE_TRACKING_CONTEXT or CTEs + // Parse XMLNAMESPACES, CHANGE_TRACKING_CONTEXT or CTEs for { - if strings.ToUpper(p.curTok.Literal) == "CHANGE_TRACKING_CONTEXT" { + if strings.ToUpper(p.curTok.Literal) == "XMLNAMESPACES" { + p.nextToken() // consume XMLNAMESPACES + xmlNs := &ast.XmlNamespaces{} + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + // Check for DEFAULT element + if strings.ToUpper(p.curTok.Literal) == "DEFAULT" { + p.nextToken() // consume DEFAULT + strLit, _ := p.parseStringLiteral() + elem := &ast.XmlNamespacesDefaultElement{String: strLit} + xmlNs.XmlNamespacesElements = append(xmlNs.XmlNamespacesElements, elem) + } else { + // Alias element: string AS identifier + strLit, _ := p.parseStringLiteral() + elem := &ast.XmlNamespacesAliasElement{String: strLit} + if p.curTok.Type == TokenAs { + p.nextToken() // consume AS + elem.Identifier = p.parseIdentifier() + } + xmlNs.XmlNamespacesElements = append(xmlNs.XmlNamespacesElements, elem) + } + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + withClause.XmlNamespaces = xmlNs + } else if strings.ToUpper(p.curTok.Literal) == "CHANGE_TRACKING_CONTEXT" { p.nextToken() // consume CHANGE_TRACKING_CONTEXT if p.curTok.Type == TokenLParen { p.nextToken() // consume ( @@ -69,7 +102,7 @@ func (p *Parser) parseWithStatement() (ast.Statement, error) { break } - // Check for comma (more CTEs) + // Check for comma (more CTEs or XMLNAMESPACES followed by CTEs) if p.curTok.Type == TokenComma { p.nextToken() } else { @@ -105,9 +138,12 @@ func (p *Parser) parseWithStatement() (ast.Statement, error) { stmt.WithCtesAndXmlNamespaces = withClause return stmt, nil case TokenSelect: - // For SELECT, we need to handle it differently - // Skip for now - return the select without CTE - return p.parseSelectStatement() + stmt, err := p.parseSelectStatement() + if err != nil { + return nil, err + } + stmt.WithCtesAndXmlNamespaces = withClause + return stmt, nil } return nil, fmt.Errorf("expected INSERT, UPDATE, DELETE, or SELECT after WITH clause, got %s", p.curTok.Literal) diff --git a/parser/parse_select.go b/parser/parse_select.go index 826f01cc..2fa2bb1c 100644 --- a/parser/parse_select.go +++ b/parser/parse_select.go @@ -1070,6 +1070,16 @@ func (p *Parser) parseOdbcLiteral() (*ast.OdbcLiteral, error) { func (p *Parser) parseStringLiteral() (*ast.StringLiteral, error) { raw := p.curTok.Literal + isNational := false + + // Check for national string (N'...') + if p.curTok.Type == TokenNationalString { + isNational = true + // Strip the N prefix + if len(raw) >= 3 && (raw[0] == 'N' || raw[0] == 'n') && raw[1] == '\'' { + raw = raw[1:] // Remove the N, keep the rest including quotes + } + } p.nextToken() // Remove surrounding quotes and handle escaped quotes @@ -1079,7 +1089,7 @@ func (p *Parser) parseStringLiteral() (*ast.StringLiteral, error) { value := strings.ReplaceAll(inner, "''", "'") return &ast.StringLiteral{ LiteralType: "String", - IsNational: false, + IsNational: isNational, IsLargeObject: false, Value: value, }, nil @@ -1087,7 +1097,7 @@ func (p *Parser) parseStringLiteral() (*ast.StringLiteral, error) { return &ast.StringLiteral{ LiteralType: "String", - IsNational: false, + IsNational: isNational, IsLargeObject: false, Value: raw, }, nil diff --git a/parser/parse_statements.go b/parser/parse_statements.go index 58ee8883..7264da72 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -11286,10 +11286,25 @@ func (p *Parser) parseDeclareCursorStatementContinued(cursorName *ast.Identifier p.nextToken() } - // Parse SELECT statement - selectStmt, err := p.parseSelectStatement() - if err != nil { - return nil, err + // Parse SELECT statement (may have WITH clause for CTEs) + var selectStmt *ast.SelectStatement + if p.curTok.Type == TokenWith { + // Parse WITH + SELECT statement + withStmt, err := p.parseWithStatement() + if err != nil { + return nil, err + } + if sel, ok := withStmt.(*ast.SelectStatement); ok { + selectStmt = sel + } else { + return nil, fmt.Errorf("expected SELECT statement after WITH in cursor definition") + } + } else { + var err error + selectStmt, err = p.parseSelectStatement() + if err != nil { + return nil, err + } } stmt.CursorDefinition.Select = selectStmt diff --git a/parser/testdata/Baselines90_CTEStatementTests/metadata.json b/parser/testdata/Baselines90_CTEStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines90_CTEStatementTests/metadata.json +++ b/parser/testdata/Baselines90_CTEStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/CTEStatementTests/metadata.json b/parser/testdata/CTEStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/CTEStatementTests/metadata.json +++ b/parser/testdata/CTEStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From fa71049ea0bb8ba91c22258a28667613b835d1f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 00:07:14 +0000 Subject: [PATCH 08/16] Add support for CREATE LOGIN FROM EXTERNAL PROVIDER - Add Source field to CreateLoginStatement AST - Add CreateLoginSource interface and ExternalCreateLoginSource type - Add PrincipalOption interface for login options - Update LiteralPrincipalOption and IdentifierPrincipalOption to implement PrincipalOption - Parse SID, TYPE, DEFAULT_DATABASE, DEFAULT_LANGUAGE options - Add JSON marshaling for new types Enables LoginStatementTests150 and Baselines150_LoginStatementTests150 --- ast/create_simple_statements.go | 20 ++++- ast/create_user_statement.go | 6 +- parser/marshal.go | 47 ++++++++++ parser/parse_statements.go | 90 ++++++++++++++++++- .../metadata.json | 2 +- .../LoginStatementTests150/metadata.json | 2 +- 6 files changed, 160 insertions(+), 7 deletions(-) diff --git a/ast/create_simple_statements.go b/ast/create_simple_statements.go index 3d916306..509e3ecd 100644 --- a/ast/create_simple_statements.go +++ b/ast/create_simple_statements.go @@ -26,12 +26,30 @@ func (s *CreateDatabaseStatement) statement() {} // CreateLoginStatement represents a CREATE LOGIN statement. type CreateLoginStatement struct { - Name *Identifier `json:"Name,omitempty"` + Name *Identifier `json:"Name,omitempty"` + Source CreateLoginSource `json:"Source,omitempty"` } func (s *CreateLoginStatement) node() {} func (s *CreateLoginStatement) statement() {} +// CreateLoginSource is an interface for login sources +type CreateLoginSource interface { + createLoginSource() +} + +// ExternalCreateLoginSource represents FROM EXTERNAL PROVIDER source +type ExternalCreateLoginSource struct { + Options []PrincipalOption `json:"Options,omitempty"` +} + +func (s *ExternalCreateLoginSource) createLoginSource() {} + +// PrincipalOption is an interface for principal options (SID, TYPE, etc.) +type PrincipalOption interface { + principalOptionNode() +} + // ServiceContract represents a contract in CREATE/ALTER SERVICE. type ServiceContract struct { Name *Identifier `json:"Name,omitempty"` diff --git a/ast/create_user_statement.go b/ast/create_user_statement.go index c25a5bd8..59084406 100644 --- a/ast/create_user_statement.go +++ b/ast/create_user_statement.go @@ -27,7 +27,8 @@ type LiteralPrincipalOption struct { Value ScalarExpression } -func (o *LiteralPrincipalOption) userOptionNode() {} +func (o *LiteralPrincipalOption) userOptionNode() {} +func (o *LiteralPrincipalOption) principalOptionNode() {} // IdentifierPrincipalOption represents an identifier-based user option type IdentifierPrincipalOption struct { @@ -35,7 +36,8 @@ type IdentifierPrincipalOption struct { Identifier *Identifier } -func (o *IdentifierPrincipalOption) userOptionNode() {} +func (o *IdentifierPrincipalOption) userOptionNode() {} +func (o *IdentifierPrincipalOption) principalOptionNode() {} // DefaultSchemaPrincipalOption represents a default schema option type DefaultSchemaPrincipalOption struct { diff --git a/parser/marshal.go b/parser/marshal.go index a13427ed..b4019f60 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -13732,9 +13732,56 @@ func createLoginStatementToJSON(s *ast.CreateLoginStatement) jsonNode { if s.Name != nil { node["Name"] = identifierToJSON(s.Name) } + if s.Source != nil { + node["Source"] = createLoginSourceToJSON(s.Source) + } return node } +func createLoginSourceToJSON(s ast.CreateLoginSource) jsonNode { + switch src := s.(type) { + case *ast.ExternalCreateLoginSource: + node := jsonNode{ + "$type": "ExternalCreateLoginSource", + } + if len(src.Options) > 0 { + opts := make([]jsonNode, len(src.Options)) + for i, opt := range src.Options { + opts[i] = principalOptionToJSON(opt) + } + node["Options"] = opts + } + return node + default: + return jsonNode{} + } +} + +func principalOptionToJSON(o ast.PrincipalOption) jsonNode { + switch opt := o.(type) { + case *ast.LiteralPrincipalOption: + node := jsonNode{ + "$type": "LiteralPrincipalOption", + "OptionKind": opt.OptionKind, + } + if opt.Value != nil { + node["Value"] = scalarExpressionToJSON(opt.Value) + } + return node + case *ast.IdentifierPrincipalOption: + node := jsonNode{ + "$type": "IdentifierPrincipalOption", + "OptionKind": opt.OptionKind, + } + if opt.Identifier != nil { + node["Identifier"] = identifierToJSON(opt.Identifier) + } + return node + default: + return jsonNode{} + } +} + func createIndexStatementToJSON(s *ast.CreateIndexStatement) jsonNode { node := jsonNode{ "$type": "CreateIndexStatement", diff --git a/parser/parse_statements.go b/parser/parse_statements.go index 7264da72..a4039902 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -8604,11 +8604,97 @@ func (p *Parser) parseCreateLoginStatement() (*ast.CreateLoginStatement, error) Name: p.parseIdentifier(), } - // Skip rest of statement - p.skipToEndOfStatement() + // Check for FROM clause + if p.curTok.Type == TokenFrom { + p.nextToken() // consume FROM + + // Check for EXTERNAL PROVIDER + if strings.ToUpper(p.curTok.Literal) == "EXTERNAL" { + p.nextToken() // consume EXTERNAL + if strings.ToUpper(p.curTok.Literal) == "PROVIDER" { + p.nextToken() // consume PROVIDER + } + + source := &ast.ExternalCreateLoginSource{} + + // Parse WITH options + if p.curTok.Type == TokenWith { + p.nextToken() // consume WITH + source.Options = p.parseExternalLoginOptions() + } + + stmt.Source = source + } + } + + // Skip optional semicolon + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + return stmt, nil } +func (p *Parser) parseExternalLoginOptions() []ast.PrincipalOption { + var options []ast.PrincipalOption + + for { + optName := strings.ToUpper(p.curTok.Literal) + p.nextToken() // consume option name + + // Expect = + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + + switch optName { + case "SID": + // SID = 0x... (binary literal) + if p.curTok.Type == TokenBinary { + options = append(options, &ast.LiteralPrincipalOption{ + OptionKind: "Sid", + Value: &ast.BinaryLiteral{ + LiteralType: "Binary", + IsLargeObject: false, + Value: p.curTok.Literal, + }, + }) + p.nextToken() + } + case "TYPE": + // TYPE = X or TYPE = [X] or TYPE = E + options = append(options, &ast.IdentifierPrincipalOption{ + OptionKind: "Type", + Identifier: p.parseIdentifier(), + }) + case "DEFAULT_DATABASE": + options = append(options, &ast.IdentifierPrincipalOption{ + OptionKind: "DefaultDatabase", + Identifier: p.parseIdentifier(), + }) + case "DEFAULT_LANGUAGE": + options = append(options, &ast.IdentifierPrincipalOption{ + OptionKind: "DefaultLanguage", + Identifier: p.parseIdentifier(), + }) + default: + // Unknown option, skip value + if p.curTok.Type != TokenComma && p.curTok.Type != TokenSemicolon && p.curTok.Type != TokenEOF { + p.nextToken() + } + } + + // Check for comma (more options) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + + return options +} + func (p *Parser) parseCreateIndexStatement() (*ast.CreateIndexStatement, error) { stmt := &ast.CreateIndexStatement{ Translated80SyntaxTo90: false, diff --git a/parser/testdata/Baselines150_LoginStatementTests150/metadata.json b/parser/testdata/Baselines150_LoginStatementTests150/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines150_LoginStatementTests150/metadata.json +++ b/parser/testdata/Baselines150_LoginStatementTests150/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/LoginStatementTests150/metadata.json b/parser/testdata/LoginStatementTests150/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/LoginStatementTests150/metadata.json +++ b/parser/testdata/LoginStatementTests150/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 115b97e9fc93d624c3ad3c1d968751d1e18a2959 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 00:18:28 +0000 Subject: [PATCH 09/16] Add FULLTEXT STOPLIST statement support - Add AST types for CREATE/ALTER/DROP FULLTEXT STOPLIST statements - Add DROP FULLTEXT CATALOG and DROP FULLTEXT INDEX AST types - Add parsing for all FULLTEXT STOPLIST variations including: - CREATE FULLTEXT STOPLIST with SYSTEM STOPLIST, source, and AUTHORIZATION - ALTER FULLTEXT STOPLIST ADD/DROP with LANGUAGE support - DROP FULLTEXT STOPLIST - Add JSON marshaling for all new statement types - Update parseIdentifierOrValueExpression to handle TokenBinary/TokenNationalString --- ast/fulltext_stoplist_statement.go | 57 +++++++++++ parser/marshal.go | 65 +++++++++++++ parser/parse_ddl.go | 97 ++++++++++++++++++- parser/parse_dml.go | 27 +++--- parser/parse_statements.go | 48 +++++++++ .../metadata.json | 2 +- .../metadata.json | 2 +- 7 files changed, 281 insertions(+), 17 deletions(-) create mode 100644 ast/fulltext_stoplist_statement.go diff --git a/ast/fulltext_stoplist_statement.go b/ast/fulltext_stoplist_statement.go new file mode 100644 index 00000000..1a77dd7d --- /dev/null +++ b/ast/fulltext_stoplist_statement.go @@ -0,0 +1,57 @@ +package ast + +// CreateFullTextStopListStatement represents CREATE FULLTEXT STOPLIST statement +type CreateFullTextStopListStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsSystemStopList bool `json:"IsSystemStopList"` + DatabaseName *Identifier `json:"DatabaseName,omitempty"` + SourceStopListName *Identifier `json:"SourceStopListName,omitempty"` + Owner *Identifier `json:"Owner,omitempty"` +} + +func (s *CreateFullTextStopListStatement) node() {} +func (s *CreateFullTextStopListStatement) statement() {} + +// AlterFullTextStopListStatement represents ALTER FULLTEXT STOPLIST statement +type AlterFullTextStopListStatement struct { + Name *Identifier `json:"Name,omitempty"` + Action *FullTextStopListAction `json:"Action,omitempty"` +} + +func (s *AlterFullTextStopListStatement) node() {} +func (s *AlterFullTextStopListStatement) statement() {} + +// FullTextStopListAction represents an action in ALTER FULLTEXT STOPLIST +type FullTextStopListAction struct { + IsAdd bool `json:"IsAdd"` + IsAll bool `json:"IsAll"` + StopWord *StringLiteral `json:"StopWord,omitempty"` + LanguageTerm *IdentifierOrValueExpression `json:"LanguageTerm,omitempty"` +} + +func (a *FullTextStopListAction) node() {} + +// DropFullTextStopListStatement represents DROP FULLTEXT STOPLIST statement +type DropFullTextStopListStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` +} + +func (s *DropFullTextStopListStatement) node() {} +func (s *DropFullTextStopListStatement) statement() {} + +// DropFullTextCatalogStatement represents DROP FULLTEXT CATALOG statement +type DropFullTextCatalogStatement struct { + Name *Identifier `json:"Name,omitempty"` +} + +func (s *DropFullTextCatalogStatement) node() {} +func (s *DropFullTextCatalogStatement) statement() {} + +// DropFulltextIndexStatement represents DROP FULLTEXT INDEX statement +type DropFulltextIndexStatement struct { + OnName *SchemaObjectName `json:"OnName,omitempty"` +} + +func (s *DropFulltextIndexStatement) node() {} +func (s *DropFulltextIndexStatement) statement() {} diff --git a/parser/marshal.go b/parser/marshal.go index b4019f60..40bf212c 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -532,6 +532,12 @@ func statementToJSON(stmt ast.Statement) jsonNode { return createFullTextCatalogStatementToJSON(s) case *ast.AlterFulltextIndexStatement: return alterFulltextIndexStatementToJSON(s) + case *ast.CreateFullTextStopListStatement: + return createFullTextStopListStatementToJSON(s) + case *ast.AlterFullTextStopListStatement: + return alterFullTextStopListStatementToJSON(s) + case *ast.DropFullTextStopListStatement: + return dropFullTextStopListStatementToJSON(s) case *ast.AlterSymmetricKeyStatement: return alterSymmetricKeyStatementToJSON(s) case *ast.AlterServiceMasterKeyStatement: @@ -13375,6 +13381,65 @@ func createFullTextCatalogStatementToJSON(s *ast.CreateFullTextCatalogStatement) return node } +func createFullTextStopListStatementToJSON(s *ast.CreateFullTextStopListStatement) jsonNode { + node := jsonNode{ + "$type": "CreateFullTextStopListStatement", + "IsSystemStopList": s.IsSystemStopList, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + if s.DatabaseName != nil { + node["DatabaseName"] = identifierToJSON(s.DatabaseName) + } + if s.SourceStopListName != nil { + node["SourceStopListName"] = identifierToJSON(s.SourceStopListName) + } + if s.Owner != nil { + node["Owner"] = identifierToJSON(s.Owner) + } + return node +} + +func alterFullTextStopListStatementToJSON(s *ast.AlterFullTextStopListStatement) jsonNode { + node := jsonNode{ + "$type": "AlterFullTextStopListStatement", + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + if s.Action != nil { + node["Action"] = fullTextStopListActionToJSON(s.Action) + } + return node +} + +func fullTextStopListActionToJSON(a *ast.FullTextStopListAction) jsonNode { + node := jsonNode{ + "$type": "FullTextStopListAction", + "IsAdd": a.IsAdd, + "IsAll": a.IsAll, + } + if a.StopWord != nil { + node["StopWord"] = stringLiteralToJSON(a.StopWord) + } + if a.LanguageTerm != nil { + node["LanguageTerm"] = identifierOrValueExpressionToJSON(a.LanguageTerm) + } + return node +} + +func dropFullTextStopListStatementToJSON(s *ast.DropFullTextStopListStatement) jsonNode { + node := jsonNode{ + "$type": "DropFullTextStopListStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + func alterFulltextIndexStatementToJSON(s *ast.AlterFulltextIndexStatement) jsonNode { node := jsonNode{ "$type": "AlterFullTextIndexStatement", diff --git a/parser/parse_ddl.go b/parser/parse_ddl.go index a7b4f572..12a71d7b 100644 --- a/parser/parse_ddl.go +++ b/parser/parse_ddl.go @@ -124,11 +124,59 @@ func (p *Parser) parseDropStatement() (ast.Statement, error) { return p.parseDropSignatureStatement(true) case "SENSITIVITY": return p.parseDropSensitivityClassificationStatement() + case "FULLTEXT": + return p.parseDropFulltextStatement() } return nil, fmt.Errorf("unexpected token after DROP: %s", p.curTok.Literal) } +func (p *Parser) parseDropFulltextStatement() (ast.Statement, error) { + // Consume FULLTEXT + p.nextToken() + + keyword := strings.ToUpper(p.curTok.Literal) + p.nextToken() // consume CATALOG/INDEX/STOPLIST + + switch keyword { + case "STOPLIST": + stmt := &ast.DropFullTextStopListStatement{ + Name: p.parseIdentifier(), + IsIfExists: false, + } + // Skip optional semicolon + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + return stmt, nil + case "CATALOG": + stmt := &ast.DropFullTextCatalogStatement{ + Name: p.parseIdentifier(), + } + // Skip optional semicolon + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + return stmt, nil + case "INDEX": + // DROP FULLTEXT INDEX ON table + if p.curTok.Type == TokenOn { + p.nextToken() // consume ON + } + name, _ := p.parseSchemaObjectName() + stmt := &ast.DropFulltextIndexStatement{ + OnName: name, + } + // Skip optional semicolon + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + return stmt, nil + } + + return nil, fmt.Errorf("unexpected token after DROP FULLTEXT: %s", keyword) +} + func (p *Parser) parseDropExternalStatement() (ast.Statement, error) { // Consume EXTERNAL p.nextToken() @@ -5862,10 +5910,14 @@ func (p *Parser) parseAlterFulltextStatement() (ast.Statement, error) { // Consume FULLTEXT p.nextToken() - // Check CATALOG or INDEX + // Check CATALOG, INDEX, or STOPLIST keyword := strings.ToUpper(p.curTok.Literal) p.nextToken() + if keyword == "STOPLIST" { + return p.parseAlterFulltextStopListStatement() + } + if keyword == "CATALOG" { stmt := &ast.AlterFulltextCatalogStatement{} stmt.Name = p.parseIdentifier() @@ -5936,6 +5988,49 @@ func (p *Parser) parseAlterFulltextStatement() (ast.Statement, error) { return stmt, nil } +func (p *Parser) parseAlterFulltextStopListStatement() (*ast.AlterFullTextStopListStatement, error) { + stmt := &ast.AlterFullTextStopListStatement{ + Name: p.parseIdentifier(), + } + + action := &ast.FullTextStopListAction{} + + // Parse ADD or DROP + actionLit := strings.ToUpper(p.curTok.Literal) + if actionLit == "ADD" { + action.IsAdd = true + p.nextToken() // consume ADD + } else if actionLit == "DROP" { + action.IsAdd = false + p.nextToken() // consume DROP + } + + // Check for ALL + if strings.ToUpper(p.curTok.Literal) == "ALL" { + action.IsAll = true + p.nextToken() // consume ALL + } else if p.curTok.Type == TokenString || p.curTok.Type == TokenNationalString { + // Parse stopword + strLit, _ := p.parseStringLiteral() + action.StopWord = strLit + } + + // Parse LANGUAGE term + if p.curTok.Type == TokenLanguage || strings.ToUpper(p.curTok.Literal) == "LANGUAGE" { + p.nextToken() // consume LANGUAGE + action.LanguageTerm, _ = p.parseIdentifierOrValueExpression() + } + + stmt.Action = action + + // Skip optional semicolon + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + func (p *Parser) tryParseAlterFullTextIndexAction() ast.AlterFullTextIndexActionOption { actionLit := strings.ToUpper(p.curTok.Literal) diff --git a/parser/parse_dml.go b/parser/parse_dml.go index d59250c8..c9ea5fd5 100644 --- a/parser/parse_dml.go +++ b/parser/parse_dml.go @@ -1934,21 +1934,11 @@ func (p *Parser) parseBulkInsertStatement() (*ast.BulkInsertStatement, error) { func (p *Parser) parseIdentifierOrValueExpression() (*ast.IdentifierOrValueExpression, error) { result := &ast.IdentifierOrValueExpression{} - if p.curTok.Type == TokenString { + if p.curTok.Type == TokenString || p.curTok.Type == TokenNationalString { // String literal - value := p.curTok.Literal - // Remove quotes - if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' { - value = value[1 : len(value)-1] - } - result.Value = value - result.ValueExpression = &ast.StringLiteral{ - LiteralType: "String", - IsNational: false, - IsLargeObject: false, - Value: value, - } - p.nextToken() + strLit, _ := p.parseStringLiteral() + result.Value = strLit.Value + result.ValueExpression = strLit } else if p.curTok.Type == TokenNumber { // Integer literal result.Value = p.curTok.Literal @@ -1957,6 +1947,15 @@ func (p *Parser) parseIdentifierOrValueExpression() (*ast.IdentifierOrValueExpre Value: p.curTok.Literal, } p.nextToken() + } else if p.curTok.Type == TokenBinary { + // Binary/hex literal + result.Value = p.curTok.Literal + result.ValueExpression = &ast.BinaryLiteral{ + LiteralType: "Binary", + IsLargeObject: false, + Value: p.curTok.Literal, + } + p.nextToken() } else if p.curTok.Type == TokenIdent { // Identifier - use parseIdentifier to handle bracketed identifiers properly ident := p.parseIdentifier() diff --git a/parser/parse_statements.go b/parser/parse_statements.go index a4039902..0482bc8c 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -10463,6 +10463,8 @@ func (p *Parser) parseCreateFulltextStatement() (ast.Statement, error) { switch strings.ToUpper(p.curTok.Literal) { case "CATALOG": return p.parseCreateFulltextCatalogStatement() + case "STOPLIST": + return p.parseCreateFulltextStopListStatement() case "INDEX": p.nextToken() // consume INDEX // FULLTEXT INDEX ON table_name @@ -10485,6 +10487,52 @@ func (p *Parser) parseCreateFulltextStatement() (ast.Statement, error) { } } +func (p *Parser) parseCreateFulltextStopListStatement() (*ast.CreateFullTextStopListStatement, error) { + p.nextToken() // consume STOPLIST + + stmt := &ast.CreateFullTextStopListStatement{ + Name: p.parseIdentifier(), + IsSystemStopList: false, + } + + // Parse FROM clause + if p.curTok.Type == TokenFrom { + p.nextToken() // consume FROM + + // Check for SYSTEM STOPLIST + if strings.ToUpper(p.curTok.Literal) == "SYSTEM" { + p.nextToken() // consume SYSTEM + if strings.ToUpper(p.curTok.Literal) == "STOPLIST" { + p.nextToken() // consume STOPLIST + } + stmt.IsSystemStopList = true + } else { + // Parse schema.name or just name + first := p.parseIdentifier() + if p.curTok.Type == TokenDot { + p.nextToken() // consume . + stmt.DatabaseName = first + stmt.SourceStopListName = p.parseIdentifier() + } else { + stmt.SourceStopListName = first + } + } + } + + // Parse AUTHORIZATION clause + if strings.ToUpper(p.curTok.Literal) == "AUTHORIZATION" { + p.nextToken() // consume AUTHORIZATION + stmt.Owner = p.parseIdentifier() + } + + // Skip optional semicolon + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + func (p *Parser) parseCreateFulltextCatalogStatement() (*ast.CreateFullTextCatalogStatement, error) { p.nextToken() // consume CATALOG diff --git a/parser/testdata/Baselines100_CreateAlterDropFulltextStoplistStatementTests/metadata.json b/parser/testdata/Baselines100_CreateAlterDropFulltextStoplistStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines100_CreateAlterDropFulltextStoplistStatementTests/metadata.json +++ b/parser/testdata/Baselines100_CreateAlterDropFulltextStoplistStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/CreateAlterDropFulltextStoplistStatementTests/metadata.json b/parser/testdata/CreateAlterDropFulltextStoplistStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/CreateAlterDropFulltextStoplistStatementTests/metadata.json +++ b/parser/testdata/CreateAlterDropFulltextStoplistStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From dbf2f7086a73e15464baa9ee61e95057113c4378 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 00:34:01 +0000 Subject: [PATCH 10/16] Add support for complex CREATE TABLE syntax - Add REFERENCES as standalone constraint (inline foreign key without FOREIGN KEY prefix) - Add NATIONAL prefix support for data types (NATIONAL TEXT -> NText, etc.) - Add schema-qualified data type handling (sys.int, sys.text, etc.) - Add multi-word type handling with schema prefix (sys.Char varying -> NVarChar) - Add XML data type with schema collection for schema-qualified paths - Add BINARY VARYING -> VarBinary conversion --- parser/marshal.go | 32 +++++ parser/parse_statements.go | 128 +++++++++++++++++- .../testdata/CreateTableTests90/metadata.json | 2 +- 3 files changed, 159 insertions(+), 3 deletions(-) diff --git a/parser/marshal.go b/parser/marshal.go index 40bf212c..51f01c9e 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -4812,6 +4812,38 @@ func (p *Parser) parseColumnDefinition() (*ast.ColumnDefinition, error) { constraint.ConstraintIdentifier = constraintName constraintName = nil col.Constraints = append(col.Constraints, constraint) + } else if upperLit == "REFERENCES" { + // Parse inline REFERENCES constraint (shorthand for FOREIGN KEY) + p.nextToken() // consume REFERENCES + constraint := &ast.ForeignKeyConstraintDefinition{ + ConstraintIdentifier: constraintName, + DeleteAction: "NotSpecified", + UpdateAction: "NotSpecified", + } + constraintName = nil + // Parse reference table name + refTable, err := p.parseSchemaObjectName() + if err != nil { + return nil, err + } + constraint.ReferenceTableName = refTable + // Parse referenced column list + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + ident := p.parseIdentifier() + constraint.ReferencedColumns = append(constraint.ReferencedColumns, ident) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + col.Constraints = append(col.Constraints, constraint) } else if upperLit == "CONSTRAINT" { p.nextToken() // consume CONSTRAINT // Parse and save constraint name for next constraint diff --git a/parser/parse_statements.go b/parser/parse_statements.go index 0482bc8c..64576968 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -693,6 +693,13 @@ func (p *Parser) parseDataTypeReference() (ast.DataTypeReference, error) { return dt, nil } + // Handle NATIONAL prefix (NATIONAL CHAR, NATIONAL TEXT, etc.) + isNational := false + if p.curTok.Type == TokenIdent && strings.ToUpper(p.curTok.Literal) == "NATIONAL" { + isNational = true + p.nextToken() // consume NATIONAL + } + if p.curTok.Type != TokenIdent { return nil, fmt.Errorf("expected data type, got %s", p.curTok.Literal) } @@ -760,12 +767,17 @@ func (p *Parser) parseDataTypeReference() (ast.DataTypeReference, error) { sqlOption, isKnownType := getSqlDataTypeOption(typeName) // Check for multi-word types: CHAR VARYING -> VarChar, DOUBLE PRECISION -> Float - if upper := strings.ToUpper(typeName); upper == "CHAR" || upper == "DOUBLE" { + // Also handle BINARY VARYING -> VarBinary + if upper := strings.ToUpper(typeName); upper == "CHAR" || upper == "DOUBLE" || upper == "BINARY" { nextUpper := strings.ToUpper(p.curTok.Literal) if upper == "CHAR" && nextUpper == "VARYING" { sqlOption = "VarChar" isKnownType = true p.nextToken() // consume VARYING + } else if upper == "BINARY" && nextUpper == "VARYING" { + sqlOption = "VarBinary" + isKnownType = true + p.nextToken() // consume VARYING } else if upper == "DOUBLE" && nextUpper == "PRECISION" { baseName.BaseIdentifier.Value = "FLOAT" // Use FLOAT for output sqlOption = "Float" @@ -774,8 +786,20 @@ func (p *Parser) parseDataTypeReference() (ast.DataTypeReference, error) { } } + // Apply NATIONAL prefix to convert to national types + if isNational && isKnownType { + switch sqlOption { + case "Text": + sqlOption = "NText" + case "Char": + sqlOption = "NChar" + case "VarChar": + sqlOption = "NVarChar" + } + } + if !isKnownType { - // Check for multi-part type name (e.g., dbo.mytype) + // Check for multi-part type name (e.g., dbo.mytype or sys.text) if p.curTok.Type == TokenDot { p.nextToken() // consume . // Get the next identifier @@ -797,6 +821,106 @@ func (p *Parser) parseDataTypeReference() (ast.DataTypeReference, error) { baseName.Count = 3 baseName.Identifiers = []*ast.Identifier{baseId, nextIdent, thirdIdent} } + + // Re-check if the base type (after schema) is a known SQL type + // This handles cases like sys.int, sys.text, etc. + baseTypeName := baseName.BaseIdentifier.Value + baseOption, baseIsKnown := getSqlDataTypeOption(baseTypeName) + + // Handle multi-word types with schema prefix: sys.Char varying -> VarChar + if baseUpper := strings.ToUpper(baseTypeName); baseUpper == "CHAR" || baseUpper == "BINARY" { + nextUpper := strings.ToUpper(p.curTok.Literal) + if baseUpper == "CHAR" && nextUpper == "VARYING" { + baseOption = "VarChar" + baseIsKnown = true + p.nextToken() // consume VARYING + } else if baseUpper == "BINARY" && nextUpper == "VARYING" { + baseOption = "VarBinary" + baseIsKnown = true + p.nextToken() // consume VARYING + } + } + + // Apply NATIONAL prefix for schema-qualified national types + if isNational && baseIsKnown { + switch baseOption { + case "Text": + baseOption = "NText" + case "Char": + baseOption = "NChar" + case "VarChar": + baseOption = "NVarChar" + } + } + + if baseIsKnown { + // Special handling for XML type with schema prefix: sys.[xml](CONTENT schema_collection) + if strings.ToUpper(baseName.BaseIdentifier.Value) == "XML" { + xmlRef := &ast.XmlDataTypeReference{ + XmlDataTypeOption: "None", + Name: baseName, + } + // Check for schema collection: XML(CONTENT|DOCUMENT schema_collection) + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + + // Check for CONTENT or DOCUMENT keyword + upper := strings.ToUpper(p.curTok.Literal) + if upper == "CONTENT" { + xmlRef.XmlDataTypeOption = "Content" + p.nextToken() + } else if upper == "DOCUMENT" { + xmlRef.XmlDataTypeOption = "Document" + p.nextToken() + } + + // Parse the schema collection name + schemaName, err := p.parseSchemaObjectName() + if err != nil { + return nil, err + } + xmlRef.XmlSchemaCollection = schemaName + + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } + return xmlRef, nil + } + + // Return SqlDataTypeReference for known types with schema prefix + dt := &ast.SqlDataTypeReference{ + SqlDataTypeOption: baseOption, + Name: baseName, + } + // Handle parameters + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + if p.curTok.Type == TokenIdent && strings.ToUpper(p.curTok.Literal) == "MAX" { + dt.Parameters = append(dt.Parameters, &ast.MaxLiteral{ + LiteralType: "Max", + Value: p.curTok.Literal, + }) + p.nextToken() + } else { + expr, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + dt.Parameters = append(dt.Parameters, expr) + } + if p.curTok.Type != TokenComma { + break + } + p.nextToken() // consume comma + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + return dt, nil + } } userRef := &ast.UserDataTypeReference{ diff --git a/parser/testdata/CreateTableTests90/metadata.json b/parser/testdata/CreateTableTests90/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/CreateTableTests90/metadata.json +++ b/parser/testdata/CreateTableTests90/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From b6f2ba4ea50d84bec7f418a813d2d43ebb17611d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 00:38:28 +0000 Subject: [PATCH 11/16] Add function call detection for INSERT targets - Add isInsertFunctionParams() to distinguish between: - dbo.f1() - table-valued function call (empty or non-identifier params) - table (c1, c2) - table with column list (identifier-only params) - Update parseInsertTarget() to use SchemaObjectFunctionTableReference for function calls --- parser/parse_dml.go | 54 +++++++++++++++++-- .../metadata.json | 2 +- .../InsertStatementTests/metadata.json | 2 +- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/parser/parse_dml.go b/parser/parse_dml.go index c9ea5fd5..6578ab8f 100644 --- a/parser/parse_dml.go +++ b/parser/parse_dml.go @@ -289,8 +289,10 @@ func (p *Parser) parseDMLTarget() (ast.TableReference, error) { } // parseInsertTarget parses the target for INSERT statements. -// Unlike parseDMLTarget, it does NOT treat parentheses as function parameters -// because in INSERT statements, parentheses after the table name are column names. +// Unlike parseDMLTarget, it needs to distinguish between: +// - dbo.f1() - a table-valued function call +// - table (c1, c2) - a table with column list +// We check if parentheses contain function parameters vs column names. func (p *Parser) parseInsertTarget() (ast.TableReference, error) { // Check for variable if p.curTok.Type == TokenIdent && strings.HasPrefix(p.curTok.Literal, "@") { @@ -313,8 +315,24 @@ func (p *Parser) parseInsertTarget() (ast.TableReference, error) { return nil, err } - // For INSERT targets, parentheses are column names, not function parameters - // So we don't parse them here - the caller handles the column list + // Check for function call: dbo.f1() or dbo.tvf(1, -1, DEFAULT) + // This is a function if the parens contain: + // - Empty: () + // - Non-identifier tokens: numbers, strings, DEFAULT, operators + // Otherwise, it's a column list and we don't consume it here. + if p.curTok.Type == TokenLParen { + if p.isInsertFunctionParams() { + params, err := p.parseFunctionParameters() + if err != nil { + return nil, err + } + return &ast.SchemaObjectFunctionTableReference{ + SchemaObject: son, + Parameters: params, + ForPath: false, + }, nil + } + } ref := &ast.NamedTableReference{ SchemaObject: son, @@ -345,6 +363,34 @@ func (p *Parser) parseInsertTarget() (ast.TableReference, error) { return ref, nil } +// isInsertFunctionParams checks if the current parentheses contain function parameters +// rather than a column list. Returns true if: +// - Empty parens: () +// - Contains non-identifier tokens: numbers, strings, DEFAULT, minus, etc. +func (p *Parser) isInsertFunctionParams() bool { + // We're at '(' - look at peekTok to see what's inside + + // Empty parens () - definitely function call + if p.peekTok.Type == TokenRParen { + return true + } + + // Look at the first token after ( + // If it's a number, string, DEFAULT, minus, or other non-identifier, it's function params + switch p.peekTok.Type { + case TokenNumber, TokenString, TokenNationalString, TokenMinus, TokenPlus: + return true + case TokenIdent: + // Check for DEFAULT keyword + if strings.ToUpper(p.peekTok.Literal) == "DEFAULT" { + return true + } + } + + // Otherwise, it's likely a column list + return false +} + func (p *Parser) parseOpenRowset() (ast.TableReference, error) { // Consume OPENROWSET p.nextToken() diff --git a/parser/testdata/BaselinesCommon_InsertStatementTests/metadata.json b/parser/testdata/BaselinesCommon_InsertStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/BaselinesCommon_InsertStatementTests/metadata.json +++ b/parser/testdata/BaselinesCommon_InsertStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/InsertStatementTests/metadata.json b/parser/testdata/InsertStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/InsertStatementTests/metadata.json +++ b/parser/testdata/InsertStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 07329b5f6e94667bc9958a209d70a0b965eb6850 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 00:52:36 +0000 Subject: [PATCH 12/16] Add GROUPING SETS and UniqueRowFilter support for GROUP BY parsing - Add GroupingSetsGroupingSpecification AST type - Add parseGroupingSetsGroupingSpecification() with support for nested CUBE/ROLLUP - Add parseGroupingSetsArgument() and parseGroupingSetsCompositeArgument() helpers - Marshal GROUPING SETS with "Sets" field name (matching expected schema) - Always output UniqueRowFilter as "NotSpecified" when not set - Enables Baselines100_GroupByClauseTests100 and GroupByClauseTests100 --- ast/rollup_grouping_specification.go | 14 ++ parser/marshal.go | 18 +++ parser/parse_select.go | 137 +++++++++++++++++- .../metadata.json | 2 +- .../GroupByClauseTests100/metadata.json | 2 +- 5 files changed, 170 insertions(+), 3 deletions(-) diff --git a/ast/rollup_grouping_specification.go b/ast/rollup_grouping_specification.go index 2fba4578..56c9c867 100644 --- a/ast/rollup_grouping_specification.go +++ b/ast/rollup_grouping_specification.go @@ -23,3 +23,17 @@ type CompositeGroupingSpecification struct { func (*CompositeGroupingSpecification) node() {} func (*CompositeGroupingSpecification) groupingSpecification() {} + +// GrandTotalGroupingSpecification represents empty parentheses () which means grand total. +type GrandTotalGroupingSpecification struct{} + +func (*GrandTotalGroupingSpecification) node() {} +func (*GrandTotalGroupingSpecification) groupingSpecification() {} + +// GroupingSetsGroupingSpecification represents GROUP BY GROUPING SETS (...) syntax. +type GroupingSetsGroupingSpecification struct { + Arguments []GroupingSpecification `json:"Arguments,omitempty"` +} + +func (*GroupingSetsGroupingSpecification) node() {} +func (*GroupingSetsGroupingSpecification) groupingSpecification() {} diff --git a/parser/marshal.go b/parser/marshal.go index 51f01c9e..75d816fe 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -1347,6 +1347,8 @@ func querySpecificationToJSON(q *ast.QuerySpecification) jsonNode { } if q.UniqueRowFilter != "" { node["UniqueRowFilter"] = q.UniqueRowFilter + } else { + node["UniqueRowFilter"] = "NotSpecified" } if q.TopRowFilter != nil { node["TopRowFilter"] = topRowFilterToJSON(q.TopRowFilter) @@ -2502,6 +2504,22 @@ func groupingSpecificationToJSON(spec ast.GroupingSpecification) jsonNode { node["Items"] = items } return node + case *ast.GrandTotalGroupingSpecification: + return jsonNode{ + "$type": "GrandTotalGroupingSpecification", + } + case *ast.GroupingSetsGroupingSpecification: + node := jsonNode{ + "$type": "GroupingSetsGroupingSpecification", + } + if len(s.Arguments) > 0 { + args := make([]jsonNode, len(s.Arguments)) + for i, arg := range s.Arguments { + args[i] = groupingSpecificationToJSON(arg) + } + node["Sets"] = args + } + return node default: return jsonNode{"$type": "UnknownGroupingSpecification"} } diff --git a/parser/parse_select.go b/parser/parse_select.go index 2fa2bb1c..1d9773a0 100644 --- a/parser/parse_select.go +++ b/parser/parse_select.go @@ -3150,8 +3150,20 @@ func (p *Parser) parseGroupingSpecification() (ast.GroupingSpecification, error) return p.parseCubeGroupingSpecification() } - // Check for composite grouping (c1, c2, ...) + // Check for GROUPING SETS (...) + if p.curTok.Type == TokenIdent && strings.ToUpper(p.curTok.Literal) == "GROUPING" && + p.peekTok.Type == TokenIdent && strings.ToUpper(p.peekTok.Literal) == "SETS" { + return p.parseGroupingSetsGroupingSpecification() + } + + // Check for grand total () or composite grouping (c1, c2, ...) if p.curTok.Type == TokenLParen { + // Check for empty parens () which is grand total + if p.peekTok.Type == TokenRParen { + p.nextToken() // consume ( + p.nextToken() // consume ) + return &ast.GrandTotalGroupingSpecification{}, nil + } return p.parseCompositeGroupingSpecification() } @@ -3245,6 +3257,129 @@ func (p *Parser) parseCubeGroupingSpecification() (*ast.CubeGroupingSpecificatio return spec, nil } +// parseGroupingSetsGroupingSpecification parses GROUPING SETS (...) +func (p *Parser) parseGroupingSetsGroupingSpecification() (*ast.GroupingSetsGroupingSpecification, error) { + p.nextToken() // consume GROUPING + p.nextToken() // consume SETS + + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after GROUPING SETS, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + + spec := &ast.GroupingSetsGroupingSpecification{} + + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + arg, err := p.parseGroupingSetsArgument() + if err != nil { + return nil, err + } + spec.Arguments = append(spec.Arguments, arg) + + if p.curTok.Type != TokenComma { + break + } + p.nextToken() // consume comma + } + + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + + return spec, nil +} + +// parseGroupingSetsArgument parses an argument inside GROUPING SETS which can be +// CUBE(...), ROLLUP(...), a column, or a parenthesized group +func (p *Parser) parseGroupingSetsArgument() (ast.GroupingSpecification, error) { + // Check for CUBE + if p.curTok.Type == TokenCube { + return p.parseCubeGroupingSpecification() + } + + // Check for ROLLUP + if p.curTok.Type == TokenRollup { + return p.parseRollupGroupingSpecification() + } + + // Check for parenthesized group + if p.curTok.Type == TokenLParen { + // Check for empty parens () which is grand total + if p.peekTok.Type == TokenRParen { + p.nextToken() // consume ( + p.nextToken() // consume ) + return &ast.GrandTotalGroupingSpecification{}, nil + } + return p.parseGroupingSetsCompositeArgument() + } + + // Regular expression (column reference or literal) + expr, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + + return &ast.ExpressionGroupingSpecification{ + Expression: expr, + DistributedAggregation: false, + }, nil +} + +// parseGroupingSetsCompositeArgument parses a parenthesized group inside GROUPING SETS +// which can contain CUBE, ROLLUP, columns, or a mix +func (p *Parser) parseGroupingSetsCompositeArgument() (ast.GroupingSpecification, error) { + p.nextToken() // consume ( + + // Check what's inside - might be CUBE, ROLLUP, or columns + var items []ast.GroupingSpecification + + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + var item ast.GroupingSpecification + var err error + + if p.curTok.Type == TokenCube { + item, err = p.parseCubeGroupingSpecification() + } else if p.curTok.Type == TokenRollup { + item, err = p.parseRollupGroupingSpecification() + } else if p.curTok.Type == TokenLParen { + // Check for empty parens () which is grand total + if p.peekTok.Type == TokenRParen { + p.nextToken() // consume ( + p.nextToken() // consume ) + item = &ast.GrandTotalGroupingSpecification{} + } else { + item, err = p.parseGroupingSetsCompositeArgument() + } + } else { + // Expression + expr, e := p.parseScalarExpression() + if e != nil { + return nil, e + } + item = &ast.ExpressionGroupingSpecification{ + Expression: expr, + DistributedAggregation: false, + } + } + + if err != nil { + return nil, err + } + items = append(items, item) + + if p.curTok.Type != TokenComma { + break + } + p.nextToken() // consume comma + } + + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + + return &ast.CompositeGroupingSpecification{Items: items}, nil +} + // parseGroupingSpecificationArgument parses an argument inside ROLLUP/CUBE which can be // an expression or a composite grouping like (c2, c3) func (p *Parser) parseGroupingSpecificationArgument() (ast.GroupingSpecification, error) { diff --git a/parser/testdata/Baselines100_GroupByClauseTests100/metadata.json b/parser/testdata/Baselines100_GroupByClauseTests100/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines100_GroupByClauseTests100/metadata.json +++ b/parser/testdata/Baselines100_GroupByClauseTests100/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/GroupByClauseTests100/metadata.json b/parser/testdata/GroupByClauseTests100/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/GroupByClauseTests100/metadata.json +++ b/parser/testdata/GroupByClauseTests100/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 73e28de4bccf6b95f810129b5bf8b14ee0f0fb5a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 00:54:36 +0000 Subject: [PATCH 13/16] Add default JoinHint 'None' for qualified joins - Output JoinHint as "None" when not explicitly set - Enables BaselinesCommon_QueryExpressionTests and QueryExpressionTests --- parser/marshal.go | 2 ++ .../testdata/BaselinesCommon_QueryExpressionTests/metadata.json | 2 +- parser/testdata/QueryExpressionTests/metadata.json | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/parser/marshal.go b/parser/marshal.go index 75d816fe..9ab82e25 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -2015,6 +2015,8 @@ func tableReferenceToJSON(ref ast.TableReference) jsonNode { } if r.JoinHint != "" { node["JoinHint"] = r.JoinHint + } else { + node["JoinHint"] = "None" } if r.FirstTableReference != nil { node["FirstTableReference"] = tableReferenceToJSON(r.FirstTableReference) diff --git a/parser/testdata/BaselinesCommon_QueryExpressionTests/metadata.json b/parser/testdata/BaselinesCommon_QueryExpressionTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/BaselinesCommon_QueryExpressionTests/metadata.json +++ b/parser/testdata/BaselinesCommon_QueryExpressionTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/QueryExpressionTests/metadata.json b/parser/testdata/QueryExpressionTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/QueryExpressionTests/metadata.json +++ b/parser/testdata/QueryExpressionTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 16ddd2a21c5d1ef8581621f1302159cc0088f8ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 01:01:41 +0000 Subject: [PATCH 14/16] Add BROKER PRIORITY statement support - Add BrokerPriorityParameter, CreateBrokerPriorityStatement, AlterBrokerPriorityStatement, DropBrokerPriorityStatement AST types - Add parsing for CREATE/ALTER/DROP BROKER PRIORITY statements - Add JSON marshaling for broker priority statements - Support PRIORITY_LEVEL, CONTRACT_NAME, REMOTE_SERVICE_NAME, LOCAL_SERVICE_NAME parameters with DEFAULT/ANY/value options - Enables CreateAlterDropBrokerPriorityStatementTests and Baselines100_CreateAlterDropBrokerPriorityStatementTests --- ast/broker_priority_statement.go | 37 ++++++++ parser/marshal.go | 67 ++++++++++++++ parser/parse_ddl.go | 69 ++++++++++++++ parser/parse_statements.go | 91 +++++++++++++++++++ .../metadata.json | 2 +- .../metadata.json | 2 +- 6 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 ast/broker_priority_statement.go diff --git a/ast/broker_priority_statement.go b/ast/broker_priority_statement.go new file mode 100644 index 00000000..deae14ec --- /dev/null +++ b/ast/broker_priority_statement.go @@ -0,0 +1,37 @@ +package ast + +// BrokerPriorityParameter represents a parameter in a BROKER PRIORITY statement. +type BrokerPriorityParameter struct { + IsDefaultOrAny string `json:"IsDefaultOrAny,omitempty"` // None, Default, Any + ParameterType string `json:"ParameterType,omitempty"` // PriorityLevel, ContractName, RemoteServiceName, LocalServiceName + ParameterValue *IdentifierOrValueExpression `json:"ParameterValue,omitempty"` +} + +func (*BrokerPriorityParameter) node() {} + +// CreateBrokerPriorityStatement represents CREATE BROKER PRIORITY statement. +type CreateBrokerPriorityStatement struct { + Name *Identifier `json:"Name,omitempty"` + BrokerPriorityParameters []*BrokerPriorityParameter `json:"BrokerPriorityParameters,omitempty"` +} + +func (*CreateBrokerPriorityStatement) node() {} +func (*CreateBrokerPriorityStatement) statement() {} + +// AlterBrokerPriorityStatement represents ALTER BROKER PRIORITY statement. +type AlterBrokerPriorityStatement struct { + Name *Identifier `json:"Name,omitempty"` + BrokerPriorityParameters []*BrokerPriorityParameter `json:"BrokerPriorityParameters,omitempty"` +} + +func (*AlterBrokerPriorityStatement) node() {} +func (*AlterBrokerPriorityStatement) statement() {} + +// DropBrokerPriorityStatement represents DROP BROKER PRIORITY statement. +type DropBrokerPriorityStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists,omitempty"` +} + +func (*DropBrokerPriorityStatement) node() {} +func (*DropBrokerPriorityStatement) statement() {} diff --git a/parser/marshal.go b/parser/marshal.go index 9ab82e25..e9c44a87 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -160,6 +160,12 @@ func statementToJSON(stmt ast.Statement) jsonNode { return alterCryptographicProviderStatementToJSON(s) case *ast.DropCryptographicProviderStatement: return dropCryptographicProviderStatementToJSON(s) + case *ast.CreateBrokerPriorityStatement: + return createBrokerPriorityStatementToJSON(s) + case *ast.AlterBrokerPriorityStatement: + return alterBrokerPriorityStatementToJSON(s) + case *ast.DropBrokerPriorityStatement: + return dropBrokerPriorityStatementToJSON(s) case *ast.UseFederationStatement: return useFederationStatementToJSON(s) case *ast.CreateFederationStatement: @@ -14940,6 +14946,67 @@ func dropCryptographicProviderStatementToJSON(s *ast.DropCryptographicProviderSt return node } +func createBrokerPriorityStatementToJSON(s *ast.CreateBrokerPriorityStatement) jsonNode { + node := jsonNode{ + "$type": "CreateBrokerPriorityStatement", + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + if len(s.BrokerPriorityParameters) > 0 { + params := make([]jsonNode, len(s.BrokerPriorityParameters)) + for i, p := range s.BrokerPriorityParameters { + params[i] = brokerPriorityParameterToJSON(p) + } + node["BrokerPriorityParameters"] = params + } + return node +} + +func alterBrokerPriorityStatementToJSON(s *ast.AlterBrokerPriorityStatement) jsonNode { + node := jsonNode{ + "$type": "AlterBrokerPriorityStatement", + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + if len(s.BrokerPriorityParameters) > 0 { + params := make([]jsonNode, len(s.BrokerPriorityParameters)) + for i, p := range s.BrokerPriorityParameters { + params[i] = brokerPriorityParameterToJSON(p) + } + node["BrokerPriorityParameters"] = params + } + return node +} + +func dropBrokerPriorityStatementToJSON(s *ast.DropBrokerPriorityStatement) jsonNode { + node := jsonNode{ + "$type": "DropBrokerPriorityStatement", + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + node["IsIfExists"] = s.IsIfExists + return node +} + +func brokerPriorityParameterToJSON(p *ast.BrokerPriorityParameter) jsonNode { + node := jsonNode{ + "$type": "BrokerPriorityParameter", + } + if p.IsDefaultOrAny != "" { + node["IsDefaultOrAny"] = p.IsDefaultOrAny + } + if p.ParameterType != "" { + node["ParameterType"] = p.ParameterType + } + if p.ParameterValue != nil { + node["ParameterValue"] = identifierOrValueExpressionToJSON(p.ParameterValue) + } + return node +} + func useFederationStatementToJSON(s *ast.UseFederationStatement) jsonNode { node := jsonNode{ "$type": "UseFederationStatement", diff --git a/parser/parse_ddl.go b/parser/parse_ddl.go index 12a71d7b..699133f1 100644 --- a/parser/parse_ddl.go +++ b/parser/parse_ddl.go @@ -126,6 +126,8 @@ func (p *Parser) parseDropStatement() (ast.Statement, error) { return p.parseDropSensitivityClassificationStatement() case "FULLTEXT": return p.parseDropFulltextStatement() + case "BROKER": + return p.parseDropBrokerPriorityStatement() } return nil, fmt.Errorf("unexpected token after DROP: %s", p.curTok.Literal) @@ -1673,6 +1675,8 @@ func (p *Parser) parseAlterStatement() (ast.Statement, error) { return p.parseAlterResourceGovernorStatement() case "CRYPTOGRAPHIC": return p.parseAlterCryptographicProviderStatement() + case "BROKER": + return p.parseAlterBrokerPriorityStatement() case "FEDERATION": return p.parseAlterFederationStatement() case "WORKLOAD": @@ -7816,3 +7820,68 @@ func (p *Parser) parseResourcePoolAffinitySpecification() (*ast.ResourcePoolAffi return spec, nil } + +func (p *Parser) parseAlterBrokerPriorityStatement() (*ast.AlterBrokerPriorityStatement, error) { + // Consume BROKER + p.nextToken() + + // Consume PRIORITY + if strings.ToUpper(p.curTok.Literal) == "PRIORITY" { + p.nextToken() + } + + stmt := &ast.AlterBrokerPriorityStatement{} + + // Parse priority name + stmt.Name = p.parseIdentifier() + + // Parse FOR CONVERSATION + if strings.ToUpper(p.curTok.Literal) == "FOR" { + p.nextToken() // consume FOR + if strings.ToUpper(p.curTok.Literal) == "CONVERSATION" { + p.nextToken() // consume CONVERSATION + } + } + + // Parse SET (parameters) + if strings.ToUpper(p.curTok.Literal) == "SET" { + p.nextToken() // consume SET + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + stmt.BrokerPriorityParameters = p.parseBrokerPriorityParameters() + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + } + + p.skipToEndOfStatement() + return stmt, nil +} + +func (p *Parser) parseDropBrokerPriorityStatement() (*ast.DropBrokerPriorityStatement, error) { + // Consume BROKER + p.nextToken() + + // Consume PRIORITY + if strings.ToUpper(p.curTok.Literal) == "PRIORITY" { + p.nextToken() + } + + stmt := &ast.DropBrokerPriorityStatement{} + + // Check for IF EXISTS + if p.curTok.Type == TokenIf { + p.nextToken() // consume IF + if strings.ToUpper(p.curTok.Literal) == "EXISTS" { + stmt.IsIfExists = true + p.nextToken() // consume EXISTS + } + } + + // Parse priority name + stmt.Name = p.parseIdentifier() + + p.skipToEndOfStatement() + return stmt, nil +} diff --git a/parser/parse_statements.go b/parser/parse_statements.go index 64576968..00b8f8e0 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -2589,6 +2589,8 @@ func (p *Parser) parseCreateStatement() (ast.Statement, error) { return p.parseCreateColumnMasterKeyStatement() case "CRYPTOGRAPHIC": return p.parseCreateCryptographicProviderStatement() + case "BROKER": + return p.parseCreateBrokerPriorityStatement() case "FEDERATION": return p.parseCreateFederationStatement() case "WORKLOAD": @@ -12216,3 +12218,92 @@ func (p *Parser) convertDbccOptionKind(opt string) string { } return opt } + +func (p *Parser) parseCreateBrokerPriorityStatement() (*ast.CreateBrokerPriorityStatement, error) { + // Consume BROKER + p.nextToken() + + // Consume PRIORITY + if strings.ToUpper(p.curTok.Literal) == "PRIORITY" { + p.nextToken() + } + + stmt := &ast.CreateBrokerPriorityStatement{} + + // Parse priority name + stmt.Name = p.parseIdentifier() + + // Parse FOR CONVERSATION + if strings.ToUpper(p.curTok.Literal) == "FOR" { + p.nextToken() // consume FOR + if strings.ToUpper(p.curTok.Literal) == "CONVERSATION" { + p.nextToken() // consume CONVERSATION + } + } + + // Parse SET (parameters) + if strings.ToUpper(p.curTok.Literal) == "SET" { + p.nextToken() // consume SET + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + stmt.BrokerPriorityParameters = p.parseBrokerPriorityParameters() + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + } + + p.skipToEndOfStatement() + return stmt, nil +} + +func (p *Parser) parseBrokerPriorityParameters() []*ast.BrokerPriorityParameter { + var params []*ast.BrokerPriorityParameter + + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF && p.curTok.Type != TokenSemicolon { + param := &ast.BrokerPriorityParameter{} + + // Get parameter type + paramType := strings.ToUpper(p.curTok.Literal) + switch paramType { + case "PRIORITY_LEVEL": + param.ParameterType = "PriorityLevel" + case "CONTRACT_NAME": + param.ParameterType = "ContractName" + case "REMOTE_SERVICE_NAME": + param.ParameterType = "RemoteServiceName" + case "LOCAL_SERVICE_NAME": + param.ParameterType = "LocalServiceName" + default: + param.ParameterType = paramType + } + p.nextToken() // consume parameter name + + // Consume = if present + if p.curTok.Type == TokenEquals { + p.nextToken() + } + + // Parse value: DEFAULT, ANY, or an expression + valLiteral := strings.ToUpper(p.curTok.Literal) + if valLiteral == "DEFAULT" { + param.IsDefaultOrAny = "Default" + p.nextToken() // consume DEFAULT + } else if valLiteral == "ANY" { + param.IsDefaultOrAny = "Any" + p.nextToken() // consume ANY + } else { + param.IsDefaultOrAny = "None" + param.ParameterValue, _ = p.parseIdentifierOrValueExpression() + } + + params = append(params, param) + + // Skip comma + if p.curTok.Type == TokenComma { + p.nextToken() + } + } + + return params +} diff --git a/parser/testdata/Baselines100_CreateAlterDropBrokerPriorityStatementTests/metadata.json b/parser/testdata/Baselines100_CreateAlterDropBrokerPriorityStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines100_CreateAlterDropBrokerPriorityStatementTests/metadata.json +++ b/parser/testdata/Baselines100_CreateAlterDropBrokerPriorityStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/CreateAlterDropBrokerPriorityStatementTests/metadata.json b/parser/testdata/CreateAlterDropBrokerPriorityStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/CreateAlterDropBrokerPriorityStatementTests/metadata.json +++ b/parser/testdata/CreateAlterDropBrokerPriorityStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 6b505643296ed99776603bb43f96de6ae176f997 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 01:07:05 +0000 Subject: [PATCH 15/16] Add support for function calls with leading dot - Fix parseColumnReferenceWithLeadingDots to detect function calls - Handle .f2() style function calls with empty identifier prefix --- parser/parse_select.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/parser/parse_select.go b/parser/parse_select.go index 1d9773a0..8fc5b98a 100644 --- a/parser/parse_select.go +++ b/parser/parse_select.go @@ -1404,6 +1404,11 @@ func (p *Parser) parseColumnReferenceWithLeadingDots() (ast.ScalarExpression, er // Don't consume .* here - let the caller (parseSelectElement) handle qualified stars + // Check if this is a function call + if p.curTok.Type == TokenLParen && len(identifiers) > 1 { + return p.parseFunctionCallFromIdentifiers(identifiers) + } + return &ast.ColumnReferenceExpression{ ColumnType: "Regular", MultiPartIdentifier: &ast.MultiPartIdentifier{ From dfa625ffbd2451514967176ba0ac7568c748cf3a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 01:33:11 +0000 Subject: [PATCH 16/16] Add PartitionFunctionCall and double-colon expression support - Add PartitionFunctionCall AST type for $PARTITION syntax - Add parsing for [db.]$PARTITION.func(args) expressions - Add JSON marshaling for PartitionFunctionCall - Fix bracket-quoted identifier parsing in double-colon expressions - Add double-colon (::) handling for expressions starting with leading dots - Filter empty identifiers from leading dots when building SchemaObjectName - Enable Baselines90_ExpressionTests90 and ExpressionTests90 --- ast/partition_function_call.go | 13 + parser/marshal.go | 21 + parser/parse_select.go | 439 +++++++++++++++++- .../metadata.json | 2 +- .../testdata/ExpressionTests90/metadata.json | 2 +- 5 files changed, 454 insertions(+), 23 deletions(-) create mode 100644 ast/partition_function_call.go diff --git a/ast/partition_function_call.go b/ast/partition_function_call.go new file mode 100644 index 00000000..2e8ede69 --- /dev/null +++ b/ast/partition_function_call.go @@ -0,0 +1,13 @@ +package ast + +// PartitionFunctionCall represents a $PARTITION function call. +// Syntax: [database.]$PARTITION.function(args) +type PartitionFunctionCall struct { + DatabaseName *Identifier `json:"DatabaseName,omitempty"` + SchemaName *Identifier `json:"SchemaName,omitempty"` + FunctionName *Identifier `json:"FunctionName,omitempty"` + Parameters []ScalarExpression `json:"Parameters,omitempty"` +} + +func (*PartitionFunctionCall) node() {} +func (*PartitionFunctionCall) scalarExpression() {} diff --git a/parser/marshal.go b/parser/marshal.go index e9c44a87..c799e4f0 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -1628,6 +1628,27 @@ func scalarExpressionToJSON(expr ast.ScalarExpression) jsonNode { node["Collation"] = identifierToJSON(e.Collation) } return node + case *ast.PartitionFunctionCall: + node := jsonNode{ + "$type": "PartitionFunctionCall", + } + if e.DatabaseName != nil { + node["DatabaseName"] = identifierToJSON(e.DatabaseName) + } + if e.SchemaName != nil { + node["SchemaName"] = identifierToJSON(e.SchemaName) + } + if e.FunctionName != nil { + node["FunctionName"] = identifierToJSON(e.FunctionName) + } + if len(e.Parameters) > 0 { + params := make([]jsonNode, len(e.Parameters)) + for i, p := range e.Parameters { + params[i] = scalarExpressionToJSON(p) + } + node["Parameters"] = params + } + return node case *ast.UserDefinedTypePropertyAccess: node := jsonNode{ "$type": "UserDefinedTypePropertyAccess", diff --git a/parser/parse_select.go b/parser/parse_select.go index 8fc5b98a..eab89615 100644 --- a/parser/parse_select.go +++ b/parser/parse_select.go @@ -443,8 +443,13 @@ func (p *Parser) parseSelectElement() (ast.SelectElement, error) { // Not an assignment, treat as regular scalar expression starting with variable varRef := &ast.VariableReference{Name: varName} + // Handle postfix operations (method calls, property access) + expr, err := p.handlePostfixOperations(varRef) + if err != nil { + return nil, err + } + // Check if next token is a binary operator - if so, continue parsing the expression - var expr ast.ScalarExpression = varRef for p.curTok.Type == TokenPlus || p.curTok.Type == TokenMinus || p.curTok.Type == TokenStar || p.curTok.Type == TokenSlash || p.curTok.Type == TokenPercent || p.curTok.Type == TokenDoublePipe { @@ -748,24 +753,267 @@ func (p *Parser) parsePostfixExpression() (ast.ScalarExpression, error) { return nil, err } - // Check for AT TIME ZONE - only if followed by "TIME" - for strings.ToUpper(p.curTok.Literal) == "AT" && strings.ToUpper(p.peekTok.Literal) == "TIME" { - p.nextToken() // consume AT - p.nextToken() // consume TIME - if strings.ToUpper(p.curTok.Literal) != "ZONE" { - return nil, fmt.Errorf("expected ZONE after TIME, got %s", p.curTok.Literal) + // Handle postfix operations: method calls, property access, AT TIME ZONE + for { + // Check for method/property access: expr.func() or expr.prop + // The next token after the dot must be an identifier (plain or bracketed) + if p.curTok.Type == TokenDot && (p.peekTok.Type == TokenIdent || (len(p.peekTok.Literal) > 0 && p.peekTok.Literal[0] == '[')) { + p.nextToken() // consume dot + + if !p.isIdentifierToken() { + return nil, fmt.Errorf("expected identifier after dot, got %s", p.curTok.Literal) + } + + // Parse method/property name + quoteType := "NotQuoted" + name := p.curTok.Literal + if len(name) >= 2 && name[0] == '[' && name[len(name)-1] == ']' { + quoteType = "SquareBracket" + name = name[1 : len(name)-1] + } + methodName := &ast.Identifier{Value: name, QuoteType: quoteType} + p.nextToken() + + if p.curTok.Type == TokenLParen { + // It's a method call: expr.func() + p.nextToken() // consume ( + + fc := &ast.FunctionCall{ + CallTarget: &ast.ExpressionCallTarget{Expression: expr}, + FunctionName: methodName, + UniqueRowFilter: "NotSpecified", + WithArrayWrapper: false, + } + + // Parse parameters + if p.curTok.Type != TokenRParen { + for { + param, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + fc.Parameters = append(fc.Parameters, param) + + if p.curTok.Type != TokenComma { + break + } + p.nextToken() // consume comma + } + } + + // Expect ) + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) after method call, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Check for OVER clause + if strings.ToUpper(p.curTok.Literal) == "OVER" { + p.nextToken() // consume OVER + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after OVER, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + + overClause := &ast.OverClause{} + + // Parse PARTITION BY + if strings.ToUpper(p.curTok.Literal) == "PARTITION" { + p.nextToken() // consume PARTITION + if strings.ToUpper(p.curTok.Literal) == "BY" { + p.nextToken() // consume BY + } + // Parse partition expressions + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + partExpr, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + overClause.Partitions = append(overClause.Partitions, partExpr) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + } + + // Parse ORDER BY + if p.curTok.Type == TokenOrder { + orderBy, err := p.parseOrderByClause() + if err != nil { + return nil, err + } + overClause.OrderByClause = orderBy + } + + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) in OVER clause, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + fc.OverClause = overClause + } + + expr = fc + } else { + // It's a property access: expr.prop + expr = &ast.UserDefinedTypePropertyAccess{ + CallTarget: &ast.ExpressionCallTarget{ + Expression: expr, + }, + PropertyName: methodName, + } + } + continue } - p.nextToken() // consume ZONE - timezone, err := p.parsePrimaryExpression() - if err != nil { - return nil, err + // Check for AT TIME ZONE - only if followed by "TIME" + if strings.ToUpper(p.curTok.Literal) == "AT" && strings.ToUpper(p.peekTok.Literal) == "TIME" { + p.nextToken() // consume AT + p.nextToken() // consume TIME + if strings.ToUpper(p.curTok.Literal) != "ZONE" { + return nil, fmt.Errorf("expected ZONE after TIME, got %s", p.curTok.Literal) + } + p.nextToken() // consume ZONE + + timezone, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + + expr = &ast.AtTimeZoneCall{ + DateValue: expr, + TimeZone: timezone, + } + continue } - expr = &ast.AtTimeZoneCall{ - DateValue: expr, - TimeZone: timezone, + // No more postfix operations + break + } + + return expr, nil +} + +// handlePostfixOperations handles method calls and property access on an existing expression +func (p *Parser) handlePostfixOperations(expr ast.ScalarExpression) (ast.ScalarExpression, error) { + for { + // Check for method/property access: expr.func() or expr.prop + if p.curTok.Type == TokenDot && (p.peekTok.Type == TokenIdent || (len(p.peekTok.Literal) > 0 && p.peekTok.Literal[0] == '[')) { + p.nextToken() // consume dot + + // Check for bracket-quoted identifier or regular identifier token + isBracketQuoted := len(p.curTok.Literal) >= 2 && p.curTok.Literal[0] == '[' && p.curTok.Literal[len(p.curTok.Literal)-1] == ']' + if !p.isIdentifierToken() && !isBracketQuoted { + return nil, fmt.Errorf("expected identifier after dot, got %s", p.curTok.Literal) + } + + // Parse method/property name + quoteType := "NotQuoted" + name := p.curTok.Literal + if len(name) >= 2 && name[0] == '[' && name[len(name)-1] == ']' { + quoteType = "SquareBracket" + name = name[1 : len(name)-1] + } + methodName := &ast.Identifier{Value: name, QuoteType: quoteType} + p.nextToken() + + if p.curTok.Type == TokenLParen { + // It's a method call: expr.func() + p.nextToken() // consume ( + + fc := &ast.FunctionCall{ + CallTarget: &ast.ExpressionCallTarget{Expression: expr}, + FunctionName: methodName, + UniqueRowFilter: "NotSpecified", + WithArrayWrapper: false, + } + + // Parse parameters + if p.curTok.Type != TokenRParen { + for { + param, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + fc.Parameters = append(fc.Parameters, param) + + if p.curTok.Type != TokenComma { + break + } + p.nextToken() // consume comma + } + } + + // Expect ) + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) after method call, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Check for OVER clause + if strings.ToUpper(p.curTok.Literal) == "OVER" { + p.nextToken() // consume OVER + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after OVER, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + + overClause := &ast.OverClause{} + + // Parse PARTITION BY + if strings.ToUpper(p.curTok.Literal) == "PARTITION" { + p.nextToken() // consume PARTITION + if strings.ToUpper(p.curTok.Literal) == "BY" { + p.nextToken() // consume BY + } + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + partExpr, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + overClause.Partitions = append(overClause.Partitions, partExpr) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + } + + // Parse ORDER BY + if p.curTok.Type == TokenOrder { + orderBy, err := p.parseOrderByClause() + if err != nil { + return nil, err + } + overClause.OrderByClause = orderBy + } + + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) in OVER clause, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + fc.OverClause = overClause + } + + expr = fc + } else { + // It's a property access: expr.prop + expr = &ast.UserDefinedTypePropertyAccess{ + CallTarget: &ast.ExpressionCallTarget{ + Expression: expr, + }, + PropertyName: methodName, + } + } + continue } + + // No more postfix operations + break } return expr, nil @@ -892,7 +1140,7 @@ func (p *Parser) parsePrimaryExpression() (ast.ScalarExpression, error) { // Multi-part identifier starting with empty parts (e.g., ..t1.c1) return p.parseColumnReferenceWithLeadingDots() case TokenMaster, TokenDatabase, TokenKey, TokenTable, TokenIndex, - TokenSchema, TokenUser, TokenView: + TokenSchema, TokenUser, TokenView, TokenTime: // Keywords that can be used as identifiers in column/table references return p.parseColumnReferenceOrFunctionCall() default: @@ -1182,7 +1430,8 @@ func (p *Parser) parseNationalStringFromToken() (*ast.StringLiteral, error) { func (p *Parser) isIdentifierToken() bool { switch p.curTok.Type { case TokenIdent, TokenMaster, TokenDatabase, TokenKey, TokenTable, TokenIndex, - TokenSchema, TokenUser, TokenView, TokenDefault, TokenTyp, TokenLanguage: + TokenSchema, TokenUser, TokenView, TokenDefault, TokenTyp, TokenLanguage, + TokenTime: return true default: return false @@ -1242,19 +1491,83 @@ func (p *Parser) parseColumnReferenceOrFunctionCall() (ast.ScalarExpression, err } } + // Check for $PARTITION function call: [db.]$PARTITION.func(args) + if len(identifiers) >= 2 && p.curTok.Type == TokenLParen { + // Check if $PARTITION is in the identifiers + partitionIdx := -1 + for i, id := range identifiers { + if strings.ToUpper(id.Value) == "$PARTITION" { + partitionIdx = i + break + } + } + + if partitionIdx >= 0 { + // Build PartitionFunctionCall + pfc := &ast.PartitionFunctionCall{} + + // DatabaseName comes before $PARTITION if present + if partitionIdx == 1 { + pfc.DatabaseName = identifiers[0] + } + + // FunctionName comes after $PARTITION + if partitionIdx+1 < len(identifiers) { + pfc.FunctionName = identifiers[partitionIdx+1] + } + + // Parse parameters + p.nextToken() // consume ( + if p.curTok.Type != TokenRParen { + for { + param, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + pfc.Parameters = append(pfc.Parameters, param) + + if p.curTok.Type != TokenComma { + break + } + p.nextToken() // consume comma + } + } + + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) in $PARTITION function call, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + return pfc, nil + } + } + // Check for :: (user-defined type method call or property access): a.b::func() or a::prop if p.curTok.Type == TokenColonColon && len(identifiers) > 0 { p.nextToken() // consume :: - // Parse function/property name - if p.curTok.Type != TokenIdent { + // Parse function/property name - can be regular identifier or bracket-quoted + isBracketQuoted := len(p.curTok.Literal) >= 2 && p.curTok.Literal[0] == '[' && p.curTok.Literal[len(p.curTok.Literal)-1] == ']' + if p.curTok.Type != TokenIdent && !isBracketQuoted { return nil, fmt.Errorf("expected identifier after ::, got %s", p.curTok.Literal) } - name := &ast.Identifier{Value: p.curTok.Literal, QuoteType: "NotQuoted"} + nameValue := p.curTok.Literal + quoteType := "NotQuoted" + if isBracketQuoted { + quoteType = "SquareBracket" + nameValue = nameValue[1 : len(nameValue)-1] + } + name := &ast.Identifier{Value: nameValue, QuoteType: quoteType} p.nextToken() - // Build SchemaObjectName from identifiers - schemaObjName := identifiersToSchemaObjectName(identifiers) + // Build SchemaObjectName from identifiers (filter out empty identifiers from leading dots) + var nonEmptyIdents []*ast.Identifier + for _, id := range identifiers { + if id.Value != "" { + nonEmptyIdents = append(nonEmptyIdents, id) + } + } + schemaObjName := identifiersToSchemaObjectName(nonEmptyIdents) // If followed by ( it's a method call, otherwise property access if p.curTok.Type == TokenLParen { @@ -1404,6 +1717,90 @@ func (p *Parser) parseColumnReferenceWithLeadingDots() (ast.ScalarExpression, er // Don't consume .* here - let the caller (parseSelectElement) handle qualified stars + // Check for :: (user-defined type method call or property access): .t::func() or .t::prop + if p.curTok.Type == TokenColonColon && len(identifiers) > 0 { + p.nextToken() // consume :: + + // Parse function/property name - can be regular identifier or bracket-quoted + isBracketQuoted := len(p.curTok.Literal) >= 2 && p.curTok.Literal[0] == '[' && p.curTok.Literal[len(p.curTok.Literal)-1] == ']' + if p.curTok.Type != TokenIdent && !isBracketQuoted { + return nil, fmt.Errorf("expected identifier after ::, got %s", p.curTok.Literal) + } + nameValue := p.curTok.Literal + quoteType := "NotQuoted" + if isBracketQuoted { + quoteType = "SquareBracket" + nameValue = nameValue[1 : len(nameValue)-1] + } + name := &ast.Identifier{Value: nameValue, QuoteType: quoteType} + p.nextToken() + + // Build SchemaObjectName from identifiers (filter out empty identifiers from leading dots) + var nonEmptyIdents []*ast.Identifier + for _, id := range identifiers { + if id.Value != "" { + nonEmptyIdents = append(nonEmptyIdents, id) + } + } + schemaObjName := identifiersToSchemaObjectName(nonEmptyIdents) + + // If followed by ( it's a method call, otherwise property access + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + + fc := &ast.FunctionCall{ + CallTarget: &ast.UserDefinedTypeCallTarget{ + SchemaObjectName: schemaObjName, + }, + FunctionName: name, + UniqueRowFilter: "NotSpecified", + WithArrayWrapper: false, + } + + // Parse parameters + if p.curTok.Type != TokenRParen { + for { + param, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + fc.Parameters = append(fc.Parameters, param) + + if p.curTok.Type != TokenComma { + break + } + p.nextToken() // consume comma + } + } + + // Expect ) + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) in function call with ::, got %s", p.curTok.Literal) + } + p.nextToken() + + // Check for OVER clause or property access after method call + return p.parsePostExpressionAccess(fc) + } + + // Property access: .t::a + propAccess := &ast.UserDefinedTypePropertyAccess{ + CallTarget: &ast.UserDefinedTypeCallTarget{ + SchemaObjectName: schemaObjName, + }, + PropertyName: name, + } + + // Check for COLLATE clause + if strings.ToUpper(p.curTok.Literal) == "COLLATE" { + p.nextToken() // consume COLLATE + propAccess.Collation = p.parseIdentifier() + } + + // Check for chained property access + return p.parsePostExpressionAccess(propAccess) + } + // Check if this is a function call if p.curTok.Type == TokenLParen && len(identifiers) > 1 { return p.parseFunctionCallFromIdentifiers(identifiers) diff --git a/parser/testdata/Baselines90_ExpressionTests90/metadata.json b/parser/testdata/Baselines90_ExpressionTests90/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines90_ExpressionTests90/metadata.json +++ b/parser/testdata/Baselines90_ExpressionTests90/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/ExpressionTests90/metadata.json b/parser/testdata/ExpressionTests90/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/ExpressionTests90/metadata.json +++ b/parser/testdata/ExpressionTests90/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{}