From 2c956b47327c3ca2b532116a27380c86ad39fc20 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:16:09 +0000 Subject: [PATCH 01/22] Fix inline index ON/FILESTREAM_ON clause parsing Capture filegroup and filestream names before calling parseIdentifier to ensure correct evaluation order in struct initialization. Enables Baselines120_CreateTableTests120 test. --- parser/parse_statements.go | 6 ++++-- .../testdata/Baselines120_CreateTableTests120/metadata.json | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/parser/parse_statements.go b/parser/parse_statements.go index af18b5cc..81a8327a 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -747,9 +747,10 @@ func (p *Parser) parseInlineIndexDefinition() (*ast.IndexDefinition, error) { // Parse optional ON filegroup clause if p.curTok.Type == TokenOn { p.nextToken() // consume ON + fgName := p.curTok.Literal fg := &ast.FileGroupOrPartitionScheme{ Name: &ast.IdentifierOrValueExpression{ - Value: p.curTok.Literal, + Value: fgName, Identifier: p.parseIdentifier(), }, } @@ -759,8 +760,9 @@ func (p *Parser) parseInlineIndexDefinition() (*ast.IndexDefinition, error) { // Parse optional FILESTREAM_ON clause if strings.ToUpper(p.curTok.Literal) == "FILESTREAM_ON" { p.nextToken() // consume FILESTREAM_ON + fsName := p.curTok.Literal indexDef.FileStreamOn = &ast.IdentifierOrValueExpression{ - Value: p.curTok.Literal, + Value: fsName, Identifier: p.parseIdentifier(), } } diff --git a/parser/testdata/Baselines120_CreateTableTests120/metadata.json b/parser/testdata/Baselines120_CreateTableTests120/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines120_CreateTableTests120/metadata.json +++ b/parser/testdata/Baselines120_CreateTableTests120/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From bdb621a38ae286f73b88b5d64c85724d73c14a0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:20:16 +0000 Subject: [PATCH 02/22] Add CREATE EXTERNAL RESOURCE POOL statement support - Add CreateExternalResourcePoolStatement AST type - Add parsing for CREATE EXTERNAL RESOURCE POOL with parameters - Support MAX_CPU_PERCENT, MAX_MEMORY_PERCENT, MAX_PROCESSES, AFFINITY - Support AFFINITY CPU/NUMANODE with AUTO or range lists - Add JSON marshaling for the new statement type Enables CreateExternalResourcePoolStatementTests130 tests. --- ast/resource_pool_statement.go | 11 +- parser/marshal.go | 19 +++ parser/parse_statements.go | 149 ++++++++++++++++++ .../metadata.json | 2 +- .../metadata.json | 2 +- 5 files changed, 180 insertions(+), 3 deletions(-) diff --git a/ast/resource_pool_statement.go b/ast/resource_pool_statement.go index 4abd54bd..38cd0be5 100644 --- a/ast/resource_pool_statement.go +++ b/ast/resource_pool_statement.go @@ -47,9 +47,18 @@ type LiteralRange struct { To ScalarExpression `json:"To,omitempty"` } +// CreateExternalResourcePoolStatement represents a CREATE EXTERNAL RESOURCE POOL statement +type CreateExternalResourcePoolStatement struct { + Name *Identifier `json:"Name,omitempty"` + ExternalResourcePoolParameters []*ExternalResourcePoolParameter `json:"ExternalResourcePoolParameters,omitempty"` +} + +func (*CreateExternalResourcePoolStatement) node() {} +func (*CreateExternalResourcePoolStatement) statement() {} + // AlterExternalResourcePoolStatement represents an ALTER EXTERNAL RESOURCE POOL statement type AlterExternalResourcePoolStatement struct { - Name *Identifier `json:"Name,omitempty"` + Name *Identifier `json:"Name,omitempty"` ExternalResourcePoolParameters []*ExternalResourcePoolParameter `json:"ExternalResourcePoolParameters,omitempty"` } diff --git a/parser/marshal.go b/parser/marshal.go index 33e96dcf..e02f71b4 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -160,6 +160,8 @@ func statementToJSON(stmt ast.Statement) jsonNode { return dropResourcePoolStatementToJSON(s) case *ast.AlterExternalResourcePoolStatement: return alterExternalResourcePoolStatementToJSON(s) + case *ast.CreateExternalResourcePoolStatement: + return createExternalResourcePoolStatementToJSON(s) case *ast.CreateCryptographicProviderStatement: return createCryptographicProviderStatementToJSON(s) case *ast.CreateColumnMasterKeyStatement: @@ -16388,6 +16390,23 @@ func alterExternalResourcePoolStatementToJSON(s *ast.AlterExternalResourcePoolSt return node } +func createExternalResourcePoolStatementToJSON(s *ast.CreateExternalResourcePoolStatement) jsonNode { + node := jsonNode{ + "$type": "CreateExternalResourcePoolStatement", + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + if len(s.ExternalResourcePoolParameters) > 0 { + params := make([]jsonNode, len(s.ExternalResourcePoolParameters)) + for i, param := range s.ExternalResourcePoolParameters { + params[i] = externalResourcePoolParameterToJSON(param) + } + node["ExternalResourcePoolParameters"] = params + } + return node +} + func externalResourcePoolParameterToJSON(p *ast.ExternalResourcePoolParameter) jsonNode { node := jsonNode{ "$type": "ExternalResourcePoolParameter", diff --git a/parser/parse_statements.go b/parser/parse_statements.go index 81a8327a..1441bd1c 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -7890,6 +7890,8 @@ func (p *Parser) parseCreateExternalStatement() (ast.Statement, error) { return p.parseCreateExternalLanguageStatement() case "LIBRARY": return p.parseCreateExternalLibraryStatement() + case "RESOURCE": + return p.parseCreateExternalResourcePoolStatement() } return nil, fmt.Errorf("unexpected token after CREATE EXTERNAL: %s", p.curTok.Literal) } @@ -8488,6 +8490,153 @@ func (p *Parser) parseCreateExternalLibraryStatement() (*ast.CreateExternalLibra return stmt, nil } +func (p *Parser) parseCreateExternalResourcePoolStatement() (*ast.CreateExternalResourcePoolStatement, error) { + // Consume RESOURCE + p.nextToken() + + // Expect POOL + if strings.ToUpper(p.curTok.Literal) != "POOL" { + return nil, fmt.Errorf("expected POOL after RESOURCE, got %s", p.curTok.Literal) + } + p.nextToken() + + stmt := &ast.CreateExternalResourcePoolStatement{} + + // Parse pool name + stmt.Name = p.parseIdentifier() + + // Check for optional WITH clause + if p.curTok.Type == TokenWith || strings.ToUpper(p.curTok.Literal) == "WITH" { + p.nextToken() // consume WITH + + // Expect ( + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected (, got %s", p.curTok.Literal) + } + p.nextToken() + + // Parse parameters + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + paramName := strings.ToUpper(p.curTok.Literal) + p.nextToken() + + param := &ast.ExternalResourcePoolParameter{} + + switch paramName { + case "MAX_CPU_PERCENT": + param.ParameterType = "MaxCpuPercent" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + val, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + param.ParameterValue = val + case "MAX_MEMORY_PERCENT": + param.ParameterType = "MaxMemoryPercent" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + val, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + param.ParameterValue = val + case "MAX_PROCESSES": + param.ParameterType = "MaxProcesses" + if p.curTok.Type == TokenEquals { + p.nextToken() + } + val, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + param.ParameterValue = val + case "AFFINITY": + param.ParameterType = "Affinity" + affinitySpec := &ast.ExternalResourcePoolAffinitySpecification{} + + // Parse CPU or NUMANODE + affinityType := strings.ToUpper(p.curTok.Literal) + p.nextToken() + if affinityType == "CPU" { + affinitySpec.AffinityType = "Cpu" + } else if affinityType == "NUMANODE" { + affinitySpec.AffinityType = "NumaNode" + } + + // Expect = + if p.curTok.Type == TokenEquals { + p.nextToken() + } + + // Check for AUTO or range list + if strings.ToUpper(p.curTok.Literal) == "AUTO" { + affinitySpec.IsAuto = true + p.nextToken() + } else { + // Parse range list: (1) or (1 TO 5, 6 TO 7) + if p.curTok.Type == TokenLParen { + p.nextToken() + } + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + fromVal, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + rangeItem := &ast.LiteralRange{From: fromVal} + + // Check for TO + if strings.ToUpper(p.curTok.Literal) == "TO" { + p.nextToken() + toVal, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + rangeItem.To = toVal + } + + affinitySpec.PoolAffinityRanges = append(affinitySpec.PoolAffinityRanges, rangeItem) + + // Check for comma + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } + param.AffinitySpecification = affinitySpec + } + + stmt.ExternalResourcePoolParameters = append(stmt.ExternalResourcePoolParameters, param) + + // Check for comma + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + + // Consume ) + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } + + // Skip optional semicolon + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + func (p *Parser) parseCreateEventSessionStatement() (*ast.CreateEventSessionStatement, error) { p.nextToken() // consume EVENT if strings.ToUpper(p.curTok.Literal) != "SESSION" { diff --git a/parser/testdata/Baselines130_CreateExternalResourcePoolStatementTests130/metadata.json b/parser/testdata/Baselines130_CreateExternalResourcePoolStatementTests130/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines130_CreateExternalResourcePoolStatementTests130/metadata.json +++ b/parser/testdata/Baselines130_CreateExternalResourcePoolStatementTests130/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/CreateExternalResourcePoolStatementTests130/metadata.json b/parser/testdata/CreateExternalResourcePoolStatementTests130/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/CreateExternalResourcePoolStatementTests130/metadata.json +++ b/parser/testdata/CreateExternalResourcePoolStatementTests130/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From c25239937a0b00e5f7b7b08bab93ba81fe3feb4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:23:31 +0000 Subject: [PATCH 03/22] Add TRIM function LEADING/TRAILING/BOTH option support - Add TrimOptions field to FunctionCall AST type - Parse LEADING, TRAILING, BOTH keywords before TRIM characters - Add JSON marshaling for TrimOptions Enables TrimFunctionTests160 tests. --- ast/function_call.go | 1 + parser/marshal.go | 3 +++ parser/parse_select.go | 11 +++++++++++ .../Baselines160_TrimFunctionTests160/metadata.json | 2 +- parser/testdata/TrimFunctionTests160/metadata.json | 2 +- 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/ast/function_call.go b/ast/function_call.go index ff3cd9fc..316585a8 100644 --- a/ast/function_call.go +++ b/ast/function_call.go @@ -50,6 +50,7 @@ type FunctionCall struct { OverClause *OverClause `json:"OverClause,omitempty"` IgnoreRespectNulls []*Identifier `json:"IgnoreRespectNulls,omitempty"` WithArrayWrapper bool `json:"WithArrayWrapper,omitempty"` + TrimOptions *Identifier `json:"TrimOptions,omitempty"` // For TRIM(LEADING/TRAILING/BOTH chars FROM string) Collation *Identifier `json:"Collation,omitempty"` } diff --git a/parser/marshal.go b/parser/marshal.go index e02f71b4..572c60d0 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -1832,6 +1832,9 @@ func scalarExpressionToJSON(expr ast.ScalarExpression) jsonNode { node["IgnoreRespectNulls"] = idents } node["WithArrayWrapper"] = e.WithArrayWrapper + if e.TrimOptions != nil { + node["TrimOptions"] = identifierToJSON(e.TrimOptions) + } if e.Collation != nil { node["Collation"] = identifierToJSON(e.Collation) } diff --git a/parser/parse_select.go b/parser/parse_select.go index a9c8d4bc..45095fe3 100644 --- a/parser/parse_select.go +++ b/parser/parse_select.go @@ -2044,6 +2044,17 @@ func (p *Parser) parseFunctionCallFromIdentifiers(identifiers []*ast.Identifier) // Parse parameters funcNameUpper := strings.ToUpper(fc.FunctionName.Value) + + // Special handling for TRIM function with LEADING/TRAILING/BOTH options + if funcNameUpper == "TRIM" && p.curTok.Type != TokenRParen { + // Check for LEADING, TRAILING, or BOTH keyword + trimOpt := strings.ToUpper(p.curTok.Literal) + if trimOpt == "LEADING" || trimOpt == "TRAILING" || trimOpt == "BOTH" { + fc.TrimOptions = &ast.Identifier{Value: trimOpt, QuoteType: "NotQuoted"} + p.nextToken() + } + } + if p.curTok.Type != TokenRParen { for { param, err := p.parseScalarExpression() diff --git a/parser/testdata/Baselines160_TrimFunctionTests160/metadata.json b/parser/testdata/Baselines160_TrimFunctionTests160/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines160_TrimFunctionTests160/metadata.json +++ b/parser/testdata/Baselines160_TrimFunctionTests160/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/TrimFunctionTests160/metadata.json b/parser/testdata/TrimFunctionTests160/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/TrimFunctionTests160/metadata.json +++ b/parser/testdata/TrimFunctionTests160/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From b2cb43e3f6a018bbf8f9636b27c247e1689b0b59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:33:14 +0000 Subject: [PATCH 04/22] Add comprehensive RESTORE statement support - Add TAPE device type support in FROM clause - Add FILELISTONLY, VERIFYONLY, LABELONLY, REWINDONLY, HEADERONLY kinds - Add MOVE restore option parsing (MOVE 'file' TO 'path') - Add FILE, MEDIANAME, MEDIAPASSWORD, PASSWORD, STOPAT options - Add ENABLE_BROKER, ERROR_BROKER_CONVERSATIONS, NEW_BROKER options - Add KEEP_REPLICATION, RESTRICTED_USER options - Fix STATS option to handle optional value (STATS or STATS = n) - Update MoveRestoreOption to use ScalarExpression types Enables RestoreStatementTests tests. --- ast/restore_statement.go | 6 +- parser/marshal.go | 231 +++++++++++++----- .../metadata.json | 2 +- .../RestoreStatementTests/metadata.json | 2 +- 4 files changed, 172 insertions(+), 69 deletions(-) diff --git a/ast/restore_statement.go b/ast/restore_statement.go index 6e5e1795..fc844261 100644 --- a/ast/restore_statement.go +++ b/ast/restore_statement.go @@ -49,9 +49,9 @@ func (o *GeneralSetCommandRestoreOption) restoreOptionNode() {} // MoveRestoreOption represents a MOVE restore option type MoveRestoreOption struct { - OptionKind string - LogicalFileName *IdentifierOrValueExpression - OSFileName *IdentifierOrValueExpression + OptionKind string + LogicalFileName ScalarExpression + OSFileName ScalarExpression } func (o *MoveRestoreOption) restoreOptionNode() {} diff --git a/parser/marshal.go b/parser/marshal.go index 572c60d0..565bb7ea 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -9131,6 +9131,7 @@ func (p *Parser) parseRestoreStatement() (ast.Statement, error) { } stmt := &ast.RestoreStatement{} + hasDatabaseName := true // Parse restore kind (DATABASE, LOG, etc.) switch strings.ToUpper(p.curTok.Literal) { @@ -9140,74 +9141,95 @@ func (p *Parser) parseRestoreStatement() (ast.Statement, error) { case "LOG": stmt.Kind = "TransactionLog" p.nextToken() + case "FILELISTONLY": + stmt.Kind = "FileListOnly" + p.nextToken() + hasDatabaseName = false + case "VERIFYONLY": + stmt.Kind = "VerifyOnly" + p.nextToken() + hasDatabaseName = false + case "LABELONLY": + stmt.Kind = "LabelOnly" + p.nextToken() + hasDatabaseName = false + case "REWINDONLY": + stmt.Kind = "RewindOnly" + p.nextToken() + hasDatabaseName = false + case "HEADERONLY": + stmt.Kind = "HeaderOnly" + p.nextToken() + hasDatabaseName = false default: stmt.Kind = "Database" } - // Parse database name - dbName := &ast.IdentifierOrValueExpression{} - if p.curTok.Type == TokenIdent && strings.HasPrefix(p.curTok.Literal, "@") { - // Variable reference - varRef := &ast.VariableReference{Name: p.curTok.Literal} - p.nextToken() - dbName.Value = varRef.Name - dbName.ValueExpression = varRef - } else { - ident := p.parseIdentifier() - dbName.Value = ident.Value - dbName.Identifier = ident - } - stmt.DatabaseName = dbName - - // Parse optional FILE = or FILEGROUP = before FROM - for strings.ToUpper(p.curTok.Literal) == "FILE" || strings.ToUpper(p.curTok.Literal) == "FILEGROUP" { - itemKind := "Files" - if strings.ToUpper(p.curTok.Literal) == "FILEGROUP" { - itemKind = "FileGroups" - } - p.nextToken() // consume FILE/FILEGROUP - if p.curTok.Type != TokenEquals { - return nil, fmt.Errorf("expected = after FILE/FILEGROUP, got %s", p.curTok.Literal) - } - p.nextToken() // consume = - - fileInfo := &ast.BackupRestoreFileInfo{ItemKind: itemKind} - // Parse the file name - var item ast.ScalarExpression - if p.curTok.Type == TokenString || p.curTok.Type == TokenNationalString { - // Strip surrounding quotes - val := p.curTok.Literal - if len(val) >= 2 && ((val[0] == '\'' && val[len(val)-1] == '\'') || (val[0] == '"' && val[len(val)-1] == '"')) { - val = val[1 : len(val)-1] - } - item = &ast.StringLiteral{ - LiteralType: "String", - Value: val, - IsNational: p.curTok.Type == TokenNationalString, - IsLargeObject: false, - } - p.nextToken() - } else if p.curTok.Type == TokenIdent && strings.HasPrefix(p.curTok.Literal, "@") { - item = &ast.VariableReference{Name: p.curTok.Literal} + // Parse database name (only for DATABASE and LOG kinds) + if hasDatabaseName { + dbName := &ast.IdentifierOrValueExpression{} + if p.curTok.Type == TokenIdent && strings.HasPrefix(p.curTok.Literal, "@") { + // Variable reference + varRef := &ast.VariableReference{Name: p.curTok.Literal} p.nextToken() + dbName.Value = varRef.Name + dbName.ValueExpression = varRef } else { ident := p.parseIdentifier() - item = &ast.ColumnReferenceExpression{ - ColumnType: "Regular", - MultiPartIdentifier: &ast.MultiPartIdentifier{ - Identifiers: []*ast.Identifier{ident}, - Count: 1, - }, - } + dbName.Value = ident.Value + dbName.Identifier = ident } - fileInfo.Items = append(fileInfo.Items, item) - stmt.Files = append(stmt.Files, fileInfo) + stmt.DatabaseName = dbName - if p.curTok.Type == TokenComma { - p.nextToken() + // Parse optional FILE = or FILEGROUP = before FROM + for strings.ToUpper(p.curTok.Literal) == "FILE" || strings.ToUpper(p.curTok.Literal) == "FILEGROUP" { + itemKind := "Files" + if strings.ToUpper(p.curTok.Literal) == "FILEGROUP" { + itemKind = "FileGroups" + } + p.nextToken() // consume FILE/FILEGROUP + if p.curTok.Type != TokenEquals { + return nil, fmt.Errorf("expected = after FILE/FILEGROUP, got %s", p.curTok.Literal) + } + p.nextToken() // consume = + + fileInfo := &ast.BackupRestoreFileInfo{ItemKind: itemKind} + // Parse the file name + var item ast.ScalarExpression + if p.curTok.Type == TokenString || p.curTok.Type == TokenNationalString { + // Strip surrounding quotes + val := p.curTok.Literal + if len(val) >= 2 && ((val[0] == '\'' && val[len(val)-1] == '\'') || (val[0] == '"' && val[len(val)-1] == '"')) { + val = val[1 : len(val)-1] + } + item = &ast.StringLiteral{ + LiteralType: "String", + Value: val, + IsNational: p.curTok.Type == TokenNationalString, + IsLargeObject: false, + } + p.nextToken() + } else if p.curTok.Type == TokenIdent && strings.HasPrefix(p.curTok.Literal, "@") { + item = &ast.VariableReference{Name: p.curTok.Literal} + p.nextToken() + } else { + ident := p.parseIdentifier() + item = &ast.ColumnReferenceExpression{ + ColumnType: "Regular", + MultiPartIdentifier: &ast.MultiPartIdentifier{ + Identifiers: []*ast.Identifier{ident}, + Count: 1, + }, + } + } + fileInfo.Items = append(fileInfo.Items, item) + stmt.Files = append(stmt.Files, fileInfo) + + if p.curTok.Type == TokenComma { + p.nextToken() + } } } - // Check for optional FROM clause if strings.ToUpper(p.curTok.Literal) != "FROM" { // No FROM clause - check for WITH clause @@ -9228,6 +9250,13 @@ func (p *Parser) parseRestoreStatement() (ast.Statement, error) { // Check for device type switch strings.ToUpper(p.curTok.Literal) { + case "TAPE": + device.DeviceType = "Tape" + p.nextToken() + if p.curTok.Type != TokenEquals { + return nil, fmt.Errorf("expected = after TAPE, got %s", p.curTok.Literal) + } + p.nextToken() case "DISK": device.DeviceType = "Disk" p.nextToken() @@ -9245,8 +9274,8 @@ func (p *Parser) parseRestoreStatement() (ast.Statement, error) { } // Parse device name - if device.DeviceType == "Disk" || device.DeviceType == "URL" { - // For DISK and URL, use PhysicalDevice with the string literal directly + if device.DeviceType == "Disk" || device.DeviceType == "URL" || device.DeviceType == "Tape" { + // For DISK, URL, and TAPE, use PhysicalDevice with the string literal directly if p.curTok.Type == TokenString || p.curTok.Type == TokenNationalString { // Strip the surrounding quotes from the literal val := p.curTok.Literal @@ -9358,6 +9387,28 @@ parseWithClause: } stmt.Options = append(stmt.Options, fsOpt) + case "MOVE": + // MOVE 'logical_file_name' TO 'os_file_name' + opt := &ast.MoveRestoreOption{OptionKind: "Move"} + // Parse logical file name + expr, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + opt.LogicalFileName = expr + // Expect TO + if strings.ToUpper(p.curTok.Literal) != "TO" { + return nil, fmt.Errorf("expected TO after logical file name, got %s", p.curTok.Literal) + } + p.nextToken() + // Parse OS file name + osExpr, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + opt.OSFileName = osExpr + stmt.Options = append(stmt.Options, opt) + case "STOPATMARK", "STOPBEFOREMARK": opt := &ast.StopRestoreOption{ OptionKind: "StopAt", @@ -9399,21 +9450,73 @@ parseWithClause: } stmt.Options = append(stmt.Options, opt) - case "KEEP_TEMPORAL_RETENTION", "NOREWIND", "NOUNLOAD", "STATS", + case "FILE", "MEDIANAME", "MEDIAPASSWORD", "PASSWORD", "STOPAT": + // Options that take a scalar expression value + optKind := optionName + switch optionName { + case "MEDIANAME": + optKind = "MediaName" + case "MEDIAPASSWORD": + optKind = "MediaPassword" + case "PASSWORD": + optKind = "Password" + case "STOPAT": + optKind = "StopAt" + case "FILE": + optKind = "File" + } + opt := &ast.ScalarExpressionRestoreOption{OptionKind: optKind} + if p.curTok.Type == TokenEquals { + p.nextToken() + expr, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + opt.Value = expr + } + stmt.Options = append(stmt.Options, opt) + + case "STATS": + // STATS can optionally have a value: STATS or STATS = 10 + if p.curTok.Type == TokenEquals { + p.nextToken() + expr, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + stmt.Options = append(stmt.Options, &ast.ScalarExpressionRestoreOption{ + OptionKind: "Stats", + Value: expr, + }) + } else { + stmt.Options = append(stmt.Options, &ast.SimpleRestoreOption{OptionKind: "Stats"}) + } + + case "ENABLE_BROKER", "ERROR_BROKER_CONVERSATIONS", "NEW_BROKER", + "KEEP_REPLICATION", "RESTRICTED_USER", + "KEEP_TEMPORAL_RETENTION", "NOREWIND", "NOUNLOAD", "RECOVERY", "NORECOVERY", "REPLACE", "RESTART", "REWIND", "UNLOAD", "CHECKSUM", "NO_CHECKSUM", "STOP_ON_ERROR", "CONTINUE_AFTER_ERROR": // Map option names to proper casing optKind := optionName switch optionName { + case "ENABLE_BROKER": + optKind = "EnableBroker" + case "ERROR_BROKER_CONVERSATIONS": + optKind = "ErrorBrokerConversations" + case "NEW_BROKER": + optKind = "NewBroker" + case "KEEP_REPLICATION": + optKind = "KeepReplication" + case "RESTRICTED_USER": + optKind = "RestrictedUser" case "KEEP_TEMPORAL_RETENTION": optKind = "KeepTemporalRetention" case "NOREWIND": optKind = "NoRewind" case "NOUNLOAD": optKind = "NoUnload" - case "STATS": - optKind = "Stats" case "RECOVERY": optKind = "Recovery" case "NORECOVERY": @@ -12115,10 +12218,10 @@ func restoreOptionToJSON(o ast.RestoreOption) jsonNode { "OptionKind": opt.OptionKind, } if opt.LogicalFileName != nil { - node["LogicalFileName"] = identifierOrValueExpressionToJSON(opt.LogicalFileName) + node["LogicalFileName"] = scalarExpressionToJSON(opt.LogicalFileName) } if opt.OSFileName != nil { - node["OSFileName"] = identifierOrValueExpressionToJSON(opt.OSFileName) + node["OSFileName"] = scalarExpressionToJSON(opt.OSFileName) } return node default: diff --git a/parser/testdata/BaselinesCommon_RestoreStatementTests/metadata.json b/parser/testdata/BaselinesCommon_RestoreStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/BaselinesCommon_RestoreStatementTests/metadata.json +++ b/parser/testdata/BaselinesCommon_RestoreStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/RestoreStatementTests/metadata.json b/parser/testdata/RestoreStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/RestoreStatementTests/metadata.json +++ b/parser/testdata/RestoreStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From c0eab4519a700096fc703d65996795bb625997ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:40:21 +0000 Subject: [PATCH 05/22] Add standalone HASH support for inline index definitions Handle HASH keyword without preceding NONCLUSTERED in table-level inline index definitions. When HASH appears directly after UNIQUE, it is now correctly interpreted as NonClusteredHash index type. --- parser/parse_statements.go | 4 ++++ parser/testdata/CreateTableTests120/metadata.json | 2 +- parser/testdata/UniqueInlineIndex130/metadata.json | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/parser/parse_statements.go b/parser/parse_statements.go index 1441bd1c..61ebab6a 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -479,6 +479,10 @@ func (p *Parser) parseInlineIndexDefinition() (*ast.IndexDefinition, error) { // Implicit NONCLUSTERED COLUMNSTORE indexDef.IndexType = &ast.IndexType{IndexTypeKind: "NonClusteredColumnStore"} p.nextToken() + } else if strings.ToUpper(p.curTok.Literal) == "HASH" { + // Implicit NONCLUSTERED HASH + indexDef.IndexType = &ast.IndexType{IndexTypeKind: "NonClusteredHash"} + p.nextToken() } // Parse column list diff --git a/parser/testdata/CreateTableTests120/metadata.json b/parser/testdata/CreateTableTests120/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/CreateTableTests120/metadata.json +++ b/parser/testdata/CreateTableTests120/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/UniqueInlineIndex130/metadata.json b/parser/testdata/UniqueInlineIndex130/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/UniqueInlineIndex130/metadata.json +++ b/parser/testdata/UniqueInlineIndex130/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 38fd44bbfc37fa09f4d5b1db45f3d1f4e8dc809f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:46:55 +0000 Subject: [PATCH 06/22] Add OpenRowset Cosmos and TableReference support Add parsing for OPENROWSET with named parameters (PROVIDER, CONNECTION, OBJECT, CREDENTIAL) for Cosmos DB integration, and traditional OPENROWSET syntax with positional arguments ('provider', 'connstr', tablename). New AST types: OpenRowsetCosmos, OpenRowsetTableReference, LiteralOpenRowsetCosmosOption, OpenRowsetColumnDefinition --- ast/openrowset.go | 44 ++++ parser/marshal.go | 77 +++++++ parser/parse_dml.go | 196 ++++++++++++++++++ .../metadata.json | 2 +- .../metadata.json | 2 +- 5 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 ast/openrowset.go diff --git a/ast/openrowset.go b/ast/openrowset.go new file mode 100644 index 00000000..8a2c9e58 --- /dev/null +++ b/ast/openrowset.go @@ -0,0 +1,44 @@ +package ast + +// OpenRowsetCosmos represents an OPENROWSET with PROVIDER = ..., CONNECTION = ..., OBJECT = ... syntax. +type OpenRowsetCosmos struct { + Options []OpenRowsetCosmosOption `json:"Options,omitempty"` + WithColumns []*OpenRowsetColumnDefinition `json:"WithColumns,omitempty"` + Alias *Identifier `json:"Alias,omitempty"` + ForPath bool `json:"ForPath"` +} + +func (o *OpenRowsetCosmos) node() {} +func (o *OpenRowsetCosmos) tableReference() {} + +// OpenRowsetCosmosOption is the interface for OpenRowset Cosmos options. +type OpenRowsetCosmosOption interface { + openRowsetCosmosOption() +} + +// LiteralOpenRowsetCosmosOption represents an option with a literal value. +type LiteralOpenRowsetCosmosOption struct { + Value ScalarExpression `json:"Value,omitempty"` + OptionKind string `json:"OptionKind,omitempty"` +} + +func (l *LiteralOpenRowsetCosmosOption) openRowsetCosmosOption() {} + +// OpenRowsetTableReference represents a traditional OPENROWSET('provider', 'connstr', object) syntax. +type OpenRowsetTableReference struct { + ProviderName ScalarExpression `json:"ProviderName,omitempty"` + ProviderString ScalarExpression `json:"ProviderString,omitempty"` + Object *SchemaObjectName `json:"Object,omitempty"` + WithColumns []*OpenRowsetColumnDefinition `json:"WithColumns,omitempty"` + Alias *Identifier `json:"Alias,omitempty"` + ForPath bool `json:"ForPath"` +} + +func (o *OpenRowsetTableReference) node() {} +func (o *OpenRowsetTableReference) tableReference() {} + +// OpenRowsetColumnDefinition represents a column definition in WITH clause. +type OpenRowsetColumnDefinition struct { + ColumnIdentifier *Identifier `json:"ColumnIdentifier,omitempty"` + DataType DataTypeReference `json:"DataType,omitempty"` +} diff --git a/parser/marshal.go b/parser/marshal.go index 565bb7ea..aaf24357 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -2401,6 +2401,54 @@ func tableReferenceToJSON(ref ast.TableReference) jsonNode { } node["ForPath"] = r.ForPath return node + case *ast.OpenRowsetCosmos: + node := jsonNode{ + "$type": "OpenRowsetCosmos", + } + if len(r.Options) > 0 { + opts := make([]jsonNode, len(r.Options)) + for i, o := range r.Options { + opts[i] = openRowsetCosmosOptionToJSON(o) + } + node["Options"] = opts + } + if len(r.WithColumns) > 0 { + cols := make([]jsonNode, len(r.WithColumns)) + for i, c := range r.WithColumns { + cols[i] = openRowsetColumnDefinitionToJSON(c) + } + node["WithColumns"] = cols + } + if r.Alias != nil { + node["Alias"] = identifierToJSON(r.Alias) + } + node["ForPath"] = r.ForPath + return node + case *ast.OpenRowsetTableReference: + node := jsonNode{ + "$type": "OpenRowsetTableReference", + } + if r.ProviderName != nil { + node["ProviderName"] = scalarExpressionToJSON(r.ProviderName) + } + if r.ProviderString != nil { + node["ProviderString"] = scalarExpressionToJSON(r.ProviderString) + } + if r.Object != nil { + node["Object"] = schemaObjectNameToJSON(r.Object) + } + if len(r.WithColumns) > 0 { + cols := make([]jsonNode, len(r.WithColumns)) + for i, c := range r.WithColumns { + cols[i] = openRowsetColumnDefinitionToJSON(c) + } + node["WithColumns"] = cols + } + if r.Alias != nil { + node["Alias"] = identifierToJSON(r.Alias) + } + node["ForPath"] = r.ForPath + return node case *ast.PredictTableReference: node := jsonNode{ "$type": "PredictTableReference", @@ -17217,3 +17265,32 @@ func sensitivityClassificationOptionToJSON(opt *ast.SensitivityClassificationOpt } return node } + +func openRowsetCosmosOptionToJSON(opt ast.OpenRowsetCosmosOption) jsonNode { + switch o := opt.(type) { + case *ast.LiteralOpenRowsetCosmosOption: + node := jsonNode{ + "$type": "LiteralOpenRowsetCosmosOption", + "OptionKind": o.OptionKind, + } + if o.Value != nil { + node["Value"] = scalarExpressionToJSON(o.Value) + } + return node + default: + return jsonNode{"$type": "UnknownOpenRowsetCosmosOption"} + } +} + +func openRowsetColumnDefinitionToJSON(col *ast.OpenRowsetColumnDefinition) jsonNode { + node := jsonNode{ + "$type": "OpenRowsetColumnDefinition", + } + if col.ColumnIdentifier != nil { + node["ColumnIdentifier"] = identifierToJSON(col.ColumnIdentifier) + } + if col.DataType != nil { + node["DataType"] = dataTypeReferenceToJSON(col.DataType) + } + return node +} diff --git a/parser/parse_dml.go b/parser/parse_dml.go index d56ddb65..a1045769 100644 --- a/parser/parse_dml.go +++ b/parser/parse_dml.go @@ -405,6 +405,16 @@ func (p *Parser) parseOpenRowset() (ast.TableReference, error) { return p.parseBulkOpenRowset() } + // Check for Cosmos form: OPENROWSET(PROVIDER = '...', CONNECTION = '...', ...) + if p.curTok.Type == TokenIdent && strings.ToUpper(p.curTok.Literal) == "PROVIDER" && p.peekTok.Type == TokenEquals { + return p.parseOpenRowsetCosmos() + } + + // Check for traditional form: OPENROWSET('provider', 'connstr', tablename) + if p.curTok.Type == TokenString { + return p.parseOpenRowsetTableReference() + } + // Parse identifier if p.curTok.Type != TokenIdent { return nil, fmt.Errorf("expected identifier in OPENROWSET, got %s", p.curTok.Literal) @@ -434,6 +444,192 @@ func (p *Parser) parseOpenRowset() (ast.TableReference, error) { }, nil } +func (p *Parser) parseOpenRowsetCosmos() (*ast.OpenRowsetCosmos, error) { + result := &ast.OpenRowsetCosmos{ + ForPath: false, + } + + // Parse options: PROVIDER = 'value', CONNECTION = 'value', etc. + // Note: Some option names like CREDENTIAL are keywords, so check for those too + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + // Check if this is a valid option name (identifier or keyword like CREDENTIAL) + optionName := strings.ToUpper(p.curTok.Literal) + isValidOption := p.curTok.Type == TokenIdent || p.curTok.Type == TokenCredential || + optionName == "PROVIDER" || optionName == "CONNECTION" || optionName == "OBJECT" || + optionName == "SERVER_CREDENTIAL" + if !isValidOption { + break + } + + p.nextToken() // consume option name + + if p.curTok.Type != TokenEquals { + return nil, fmt.Errorf("expected = after %s, got %s", optionName, p.curTok.Literal) + } + p.nextToken() // consume = + + // Parse option value + value, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + + // Map option names to expected OptionKind values + optionKind := optionName + switch optionName { + case "PROVIDER": + optionKind = "Provider" + case "CONNECTION": + optionKind = "Connection" + case "OBJECT": + optionKind = "Object" + case "CREDENTIAL": + optionKind = "Credential" + case "SERVER_CREDENTIAL": + optionKind = "Server_Credential" + } + + opt := &ast.LiteralOpenRowsetCosmosOption{ + Value: value, + OptionKind: optionKind, + } + result.Options = append(result.Options, opt) + + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) in OPENROWSET, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Parse optional WITH (columns) + if strings.ToUpper(p.curTok.Literal) == "WITH" { + p.nextToken() // consume WITH + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + colDef := &ast.OpenRowsetColumnDefinition{} + colDef.ColumnIdentifier = p.parseIdentifier() + + // Parse data type + dataType, err := p.parseDataTypeReference() + if err != nil { + return nil, err + } + colDef.DataType = dataType + + result.WithColumns = append(result.WithColumns, colDef) + + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + } + + // Parse optional alias + if p.curTok.Type == TokenAs { + p.nextToken() // consume AS + if p.curTok.Type == TokenIdent || p.curTok.Type == TokenLBracket { + result.Alias = p.parseIdentifier() + } + } + + return result, nil +} + +func (p *Parser) parseOpenRowsetTableReference() (*ast.OpenRowsetTableReference, error) { + result := &ast.OpenRowsetTableReference{ + ForPath: false, + } + + // Parse provider name (string literal) + providerName, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + result.ProviderName = providerName + + if p.curTok.Type != TokenComma { + return nil, fmt.Errorf("expected , after provider name, got %s", p.curTok.Literal) + } + p.nextToken() // consume , + + // Parse provider string (string literal) + providerString, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + result.ProviderString = providerString + + if p.curTok.Type != TokenComma { + return nil, fmt.Errorf("expected , after provider string, got %s", p.curTok.Literal) + } + p.nextToken() // consume , + + // Parse object (schema object name or expression) + obj, err := p.parseSchemaObjectName() + if err != nil { + return nil, err + } + result.Object = obj + + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) in OPENROWSET, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Parse optional WITH (columns) + if strings.ToUpper(p.curTok.Literal) == "WITH" { + p.nextToken() // consume WITH + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + colDef := &ast.OpenRowsetColumnDefinition{} + colDef.ColumnIdentifier = p.parseIdentifier() + + // Parse data type + dataType, err := p.parseDataTypeReference() + if err != nil { + return nil, err + } + colDef.DataType = dataType + + result.WithColumns = append(result.WithColumns, colDef) + + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + } + + // Parse optional alias + if p.curTok.Type == TokenAs { + p.nextToken() // consume AS + if p.curTok.Type == TokenIdent || p.curTok.Type == TokenLBracket { + result.Alias = p.parseIdentifier() + } + } + + return result, nil +} + func (p *Parser) parseBulkOpenRowset() (*ast.BulkOpenRowset, error) { // We're positioned on BULK, consume it p.nextToken() diff --git a/parser/testdata/Baselines160_OpenRowsetBulkStatementTests160/metadata.json b/parser/testdata/Baselines160_OpenRowsetBulkStatementTests160/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines160_OpenRowsetBulkStatementTests160/metadata.json +++ b/parser/testdata/Baselines160_OpenRowsetBulkStatementTests160/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/OpenRowsetBulkStatementTests160/metadata.json b/parser/testdata/OpenRowsetBulkStatementTests160/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/OpenRowsetBulkStatementTests160/metadata.json +++ b/parser/testdata/OpenRowsetBulkStatementTests160/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 888f46660f3a8ed6fc72aabd3268ee4fe193a4d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 21:55:38 +0000 Subject: [PATCH 07/22] Add AS keyword support for function parameter declarations Handle optional AS keyword in function parameter syntax like @param AS datatype. This applies to CREATE FUNCTION, ALTER FUNCTION, and CREATE OR ALTER FUNCTION statements. --- parser/marshal.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/parser/marshal.go b/parser/marshal.go index aaf24357..bdd147df 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -10285,6 +10285,11 @@ func (p *Parser) parseAlterFunctionStatement() (*ast.AlterFunctionStatement, err p.nextToken() } + // Skip optional AS keyword (e.g., @param AS int) + if p.curTok.Type == TokenAs { + p.nextToken() + } + // Parse data type if present if p.curTok.Type != TokenRParen && p.curTok.Type != TokenComma && p.curTok.Type != TokenEquals { dataType, err := p.parseDataType() @@ -10992,6 +10997,11 @@ func (p *Parser) parseCreateFunctionStatement() (*ast.CreateFunctionStatement, e p.nextToken() } + // Skip optional AS keyword (e.g., @param AS int) + if p.curTok.Type == TokenAs { + p.nextToken() + } + // Parse data type if present if p.curTok.Type != TokenRParen && p.curTok.Type != TokenComma { dataType, err := p.parseDataTypeReference() @@ -11452,6 +11462,11 @@ func (p *Parser) parseCreateOrAlterFunctionStatement() (*ast.CreateOrAlterFuncti p.nextToken() } + // Skip optional AS keyword (e.g., @param AS int) + if p.curTok.Type == TokenAs { + p.nextToken() + } + // Parse data type if present if p.curTok.Type != TokenRParen && p.curTok.Type != TokenComma { dataType, err := p.parseDataTypeReference() From ca81ad13e7e216c7615fc5e644649c66e0d4089c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 22:02:04 +0000 Subject: [PATCH 08/22] Add WITH clause column schema support for BULK OPENROWSET Add WithColumns field to BulkOpenRowset for schema specification in OPENROWSET BULK statements. Supports COLLATE clause and column ordinal or JSON path specifications. --- ast/bulk_insert_statement.go | 11 ++++---- ast/openrowset.go | 2 ++ parser/marshal.go | 13 +++++++++ parser/parse_dml.go | 54 ++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/ast/bulk_insert_statement.go b/ast/bulk_insert_statement.go index a0803e2f..bcd38749 100644 --- a/ast/bulk_insert_statement.go +++ b/ast/bulk_insert_statement.go @@ -66,11 +66,12 @@ func (o *OrderBulkInsertOption) bulkInsertOption() {} // BulkOpenRowset represents an OPENROWSET (BULK ...) table reference. type BulkOpenRowset struct { - DataFiles []ScalarExpression `json:"DataFiles,omitempty"` - Options []BulkInsertOption `json:"Options,omitempty"` - Columns []*Identifier `json:"Columns,omitempty"` - Alias *Identifier `json:"Alias,omitempty"` - ForPath bool `json:"ForPath"` + DataFiles []ScalarExpression `json:"DataFiles,omitempty"` + Options []BulkInsertOption `json:"Options,omitempty"` + WithColumns []*OpenRowsetColumnDefinition `json:"WithColumns,omitempty"` + Columns []*Identifier `json:"Columns,omitempty"` + Alias *Identifier `json:"Alias,omitempty"` + ForPath bool `json:"ForPath"` } func (b *BulkOpenRowset) node() {} diff --git a/ast/openrowset.go b/ast/openrowset.go index 8a2c9e58..23434300 100644 --- a/ast/openrowset.go +++ b/ast/openrowset.go @@ -39,6 +39,8 @@ func (o *OpenRowsetTableReference) tableReference() {} // OpenRowsetColumnDefinition represents a column definition in WITH clause. type OpenRowsetColumnDefinition struct { + ColumnOrdinal ScalarExpression `json:"ColumnOrdinal,omitempty"` ColumnIdentifier *Identifier `json:"ColumnIdentifier,omitempty"` DataType DataTypeReference `json:"DataType,omitempty"` + Collation *Identifier `json:"Collation,omitempty"` } diff --git a/parser/marshal.go b/parser/marshal.go index bdd147df..a00f6082 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -2389,6 +2389,13 @@ func tableReferenceToJSON(ref ast.TableReference) jsonNode { } node["Options"] = opts } + if len(r.WithColumns) > 0 { + cols := make([]jsonNode, len(r.WithColumns)) + for i, c := range r.WithColumns { + cols[i] = openRowsetColumnDefinitionToJSON(c) + } + node["WithColumns"] = cols + } if len(r.Columns) > 0 { cols := make([]jsonNode, len(r.Columns)) for i, c := range r.Columns { @@ -17301,11 +17308,17 @@ func openRowsetColumnDefinitionToJSON(col *ast.OpenRowsetColumnDefinition) jsonN node := jsonNode{ "$type": "OpenRowsetColumnDefinition", } + if col.ColumnOrdinal != nil { + node["ColumnOrdinal"] = scalarExpressionToJSON(col.ColumnOrdinal) + } if col.ColumnIdentifier != nil { node["ColumnIdentifier"] = identifierToJSON(col.ColumnIdentifier) } if col.DataType != nil { node["DataType"] = dataTypeReferenceToJSON(col.DataType) } + if col.Collation != nil { + node["Collation"] = identifierToJSON(col.Collation) + } return node } diff --git a/parser/parse_dml.go b/parser/parse_dml.go index a1045769..d00d1cde 100644 --- a/parser/parse_dml.go +++ b/parser/parse_dml.go @@ -687,6 +687,60 @@ func (p *Parser) parseBulkOpenRowset() (*ast.BulkOpenRowset, error) { } p.nextToken() + // Parse optional WITH (column_definitions) - for schema specification + if p.curTok.Type == TokenWith { + p.nextToken() // consume WITH + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + colDef := &ast.OpenRowsetColumnDefinition{} + colDef.ColumnIdentifier = p.parseIdentifier() + + // Parse data type + dataType, err := p.parseDataTypeReference() + if err != nil { + return nil, err + } + colDef.DataType = dataType + + // Parse optional COLLATE + if strings.ToUpper(p.curTok.Literal) == "COLLATE" { + p.nextToken() // consume COLLATE + colDef.Collation = p.parseIdentifier() + } + + // Parse optional column ordinal (integer) or JSON path (string) + if p.curTok.Type == TokenNumber { + colDef.ColumnOrdinal = &ast.IntegerLiteral{ + LiteralType: "Integer", + Value: p.curTok.Literal, + } + p.nextToken() + } else if p.curTok.Type == TokenString { + // JSON path specification like '$.stateName' or 'strict $.population' + colDef.ColumnOrdinal = &ast.StringLiteral{ + LiteralType: "String", + IsNational: false, + IsLargeObject: false, + Value: strings.Trim(p.curTok.Literal, "'"), + } + p.nextToken() + } + + result.WithColumns = append(result.WithColumns, colDef) + + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + } + // Parse optional alias if p.curTok.Type == TokenAs { p.nextToken() From b07e1aed1ebdec0df768846965429135d14000a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 22:11:14 +0000 Subject: [PATCH 09/22] Fix table hint and alias parsing order in FROM clause Support both T-SQL table hint syntaxes: 1. Old-style: table_name (nolock) AS alias - hints before alias 2. New-style: table_name AS alias WITH (hints) - alias before hints The parser now correctly handles both patterns by checking for old-style hints first (parentheses without WITH), then parsing alias, then checking for new-style hints (WITH keyword followed by parentheses). --- parser/parse_select.go | 77 +++++++++++++++---- .../FunctionStatementTests/metadata.json | 2 +- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/parser/parse_select.go b/parser/parse_select.go index 45095fe3..36e39ba6 100644 --- a/parser/parse_select.go +++ b/parser/parse_select.go @@ -2546,6 +2546,36 @@ func (p *Parser) parseNamedTableReference() (*ast.NamedTableReference, error) { } ref.SchemaObject = son + // T-SQL supports two syntaxes for table hints: + // 1. Old-style: table_name (nolock) AS alias - hints before alias, no WITH + // 2. New-style: table_name AS alias WITH (hints) - alias before hints, WITH required + + // Check for old-style hints (without WITH keyword): table (nolock) as alias + if p.curTok.Type == TokenLParen && p.peekIsTableHint() { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + hint, err := p.parseTableHint() + if err != nil { + return nil, err + } + if hint != nil { + ref.TableHints = append(ref.TableHints, hint) + } + if p.curTok.Type == TokenComma { + p.nextToken() + } else if p.curTok.Type != TokenRParen { + // Check if the next token is a valid table hint (space-separated hints) + if p.isTableHintToken() { + continue // Continue parsing space-separated hints + } + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } + // Parse optional alias (AS alias or just alias) if p.curTok.Type == TokenAs { p.nextToken() @@ -2563,14 +2593,10 @@ func (p *Parser) parseNamedTableReference() (*ast.NamedTableReference, error) { } } - // Parse optional table hints WITH (hint, hint, ...) or old-style (hint, hint, ...) + // Check for new-style hints (with WITH keyword): alias WITH (hints) if p.curTok.Type == TokenWith && p.peekTok.Type == TokenLParen { p.nextToken() // consume WITH - } - if p.curTok.Type == TokenLParen { - // Check if this looks like hints (first token is a hint keyword) - // Save position to peek - if p.peekIsTableHint() { + if p.curTok.Type == TokenLParen && p.peekIsTableHint() { p.nextToken() // consume ( for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { hint, err := p.parseTableHint() @@ -2583,9 +2609,8 @@ func (p *Parser) parseNamedTableReference() (*ast.NamedTableReference, error) { if p.curTok.Type == TokenComma { p.nextToken() } else if p.curTok.Type != TokenRParen { - // Check if the next token is a valid table hint (space-separated hints) if p.isTableHintToken() { - continue // Continue parsing space-separated hints + continue } break } @@ -2606,6 +2631,35 @@ func (p *Parser) parseNamedTableReferenceWithName(son *ast.SchemaObjectName) (*a ForPath: false, } + // T-SQL supports two syntaxes for table hints: + // 1. Old-style: table_name (nolock) AS alias - hints before alias, no WITH + // 2. New-style: table_name AS alias WITH (hints) - alias before hints, WITH required + + // Check for old-style hints (without WITH keyword): table (nolock) as alias + if p.curTok.Type == TokenLParen && p.peekIsTableHint() { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + hint, err := p.parseTableHint() + if err != nil { + return nil, err + } + if hint != nil { + ref.TableHints = append(ref.TableHints, hint) + } + if p.curTok.Type == TokenComma { + p.nextToken() + } else if p.curTok.Type != TokenRParen { + if p.isTableHintToken() { + continue + } + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } + // Parse optional alias (AS alias or just alias) if p.curTok.Type == TokenAs { p.nextToken() @@ -2625,13 +2679,10 @@ func (p *Parser) parseNamedTableReferenceWithName(son *ast.SchemaObjectName) (*a } } - // Parse optional table hints WITH (hint, hint, ...) or old-style (hint, hint, ...) + // Check for new-style hints (with WITH keyword): alias WITH (hints) if p.curTok.Type == TokenWith && p.peekTok.Type == TokenLParen { p.nextToken() // consume WITH - } - if p.curTok.Type == TokenLParen { - // Check if this looks like hints (first token is a hint keyword) - if p.peekIsTableHint() { + if p.curTok.Type == TokenLParen && p.peekIsTableHint() { p.nextToken() // consume ( for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { hint, err := p.parseTableHint() diff --git a/parser/testdata/FunctionStatementTests/metadata.json b/parser/testdata/FunctionStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/FunctionStatementTests/metadata.json +++ b/parser/testdata/FunctionStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 468530bf1d7a1823a64dd3539415987b6b232f62 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 22:31:34 +0000 Subject: [PATCH 10/22] Add CREATE/ALTER/DROP LOGIN statement support Add parsing for: - CREATE LOGIN with PASSWORD, WINDOWS, CERTIFICATE, ASYMMETRIC KEY sources - ALTER LOGIN ENABLE/DISABLE statements - ALTER LOGIN WITH options (PASSWORD, NO CREDENTIAL, etc.) - DROP LOGIN statement Add new AST types: - PasswordCreateLoginSource, WindowsCreateLoginSource - CertificateCreateLoginSource, AsymmetricKeyCreateLoginSource - AlterLoginEnableDisableStatement, AlterLoginOptionsStatement - DropLoginStatement, OnOffPrincipalOption, PrincipalOptionSimple Update PasswordAlterPrincipalOption to support both String and Binary passwords. --- ast/create_simple_statements.go | 59 ++++++++ ast/create_user_statement.go | 24 +++- parser/marshal.go | 123 +++++++++++++++- parser/parse_ddl.go | 243 +++++++++++++++++++++++++++++--- parser/parse_statements.go | 165 +++++++++++++++++++++- 5 files changed, 585 insertions(+), 29 deletions(-) diff --git a/ast/create_simple_statements.go b/ast/create_simple_statements.go index eaa30c7c..137caec7 100644 --- a/ast/create_simple_statements.go +++ b/ast/create_simple_statements.go @@ -34,6 +34,32 @@ type CreateLoginStatement struct { func (s *CreateLoginStatement) node() {} func (s *CreateLoginStatement) statement() {} +// AlterLoginEnableDisableStatement represents ALTER LOGIN name ENABLE/DISABLE +type AlterLoginEnableDisableStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsEnable bool `json:"IsEnable"` +} + +func (s *AlterLoginEnableDisableStatement) node() {} +func (s *AlterLoginEnableDisableStatement) statement() {} + +// AlterLoginOptionsStatement represents ALTER LOGIN name WITH options +type AlterLoginOptionsStatement struct { + Name *Identifier `json:"Name,omitempty"` + Options []PrincipalOption `json:"Options,omitempty"` +} + +func (s *AlterLoginOptionsStatement) node() {} +func (s *AlterLoginOptionsStatement) statement() {} + +// DropLoginStatement represents DROP LOGIN name +type DropLoginStatement struct { + Name *Identifier `json:"Name,omitempty"` +} + +func (s *DropLoginStatement) node() {} +func (s *DropLoginStatement) statement() {} + // CreateLoginSource is an interface for login sources type CreateLoginSource interface { createLoginSource() @@ -46,6 +72,39 @@ type ExternalCreateLoginSource struct { func (s *ExternalCreateLoginSource) createLoginSource() {} +// PasswordCreateLoginSource represents WITH PASSWORD = '...' source +type PasswordCreateLoginSource struct { + Password ScalarExpression `json:"Password,omitempty"` + Hashed bool `json:"Hashed"` + MustChange bool `json:"MustChange"` + Options []PrincipalOption `json:"Options,omitempty"` +} + +func (s *PasswordCreateLoginSource) createLoginSource() {} + +// WindowsCreateLoginSource represents FROM WINDOWS source +type WindowsCreateLoginSource struct { + Options []PrincipalOption `json:"Options,omitempty"` +} + +func (s *WindowsCreateLoginSource) createLoginSource() {} + +// CertificateCreateLoginSource represents FROM CERTIFICATE source +type CertificateCreateLoginSource struct { + Certificate *Identifier `json:"Certificate,omitempty"` + Credential *Identifier `json:"Credential,omitempty"` +} + +func (s *CertificateCreateLoginSource) createLoginSource() {} + +// AsymmetricKeyCreateLoginSource represents FROM ASYMMETRIC KEY source +type AsymmetricKeyCreateLoginSource struct { + Key *Identifier `json:"Key,omitempty"` + Credential *Identifier `json:"Credential,omitempty"` +} + +func (s *AsymmetricKeyCreateLoginSource) createLoginSource() {} + // PrincipalOption is an interface for principal options (SID, TYPE, etc.) type PrincipalOption interface { principalOptionNode() diff --git a/ast/create_user_statement.go b/ast/create_user_statement.go index 59084406..bbf9a9bf 100644 --- a/ast/create_user_statement.go +++ b/ast/create_user_statement.go @@ -39,6 +39,23 @@ type IdentifierPrincipalOption struct { func (o *IdentifierPrincipalOption) userOptionNode() {} func (o *IdentifierPrincipalOption) principalOptionNode() {} +// OnOffPrincipalOption represents an ON/OFF principal option +type OnOffPrincipalOption struct { + OptionKind string + OptionState string // "On" or "Off" +} + +func (o *OnOffPrincipalOption) userOptionNode() {} +func (o *OnOffPrincipalOption) principalOptionNode() {} + +// PrincipalOptionSimple represents a simple principal option with just an option kind +type PrincipalOptionSimple struct { + OptionKind string +} + +func (o *PrincipalOptionSimple) userOptionNode() {} +func (o *PrincipalOptionSimple) principalOptionNode() {} + // DefaultSchemaPrincipalOption represents a default schema option type DefaultSchemaPrincipalOption struct { OptionKind string @@ -47,9 +64,9 @@ type DefaultSchemaPrincipalOption struct { func (o *DefaultSchemaPrincipalOption) userOptionNode() {} -// PasswordAlterPrincipalOption represents a password option for ALTER USER +// PasswordAlterPrincipalOption represents a password option for ALTER USER/LOGIN type PasswordAlterPrincipalOption struct { - Password *StringLiteral + Password ScalarExpression OldPassword *StringLiteral MustChange bool Unlock bool @@ -57,4 +74,5 @@ type PasswordAlterPrincipalOption struct { OptionKind string } -func (o *PasswordAlterPrincipalOption) userOptionNode() {} +func (o *PasswordAlterPrincipalOption) userOptionNode() {} +func (o *PasswordAlterPrincipalOption) principalOptionNode() {} diff --git a/parser/marshal.go b/parser/marshal.go index a00f6082..cbf307f5 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -446,6 +446,12 @@ func statementToJSON(stmt ast.Statement) jsonNode { return dropDatabaseEncryptionKeyStatementToJSON(s) case *ast.CreateLoginStatement: return createLoginStatementToJSON(s) + case *ast.AlterLoginEnableDisableStatement: + return alterLoginEnableDisableStatementToJSON(s) + case *ast.AlterLoginOptionsStatement: + return alterLoginOptionsStatementToJSON(s) + case *ast.DropLoginStatement: + return dropLoginStatementToJSON(s) case *ast.CreateIndexStatement: return createIndexStatementToJSON(s) case *ast.CreateSpatialIndexStatement: @@ -12370,7 +12376,7 @@ func userOptionToJSON(o ast.UserOption) jsonNode { "Hashed": opt.Hashed, } if opt.Password != nil { - node["Password"] = stringLiteralToJSON(opt.Password) + node["Password"] = scalarExpressionToJSON(opt.Password) } if opt.OldPassword != nil { node["OldPassword"] = stringLiteralToJSON(opt.OldPassword) @@ -15555,6 +15561,57 @@ func createLoginSourceToJSON(s ast.CreateLoginSource) jsonNode { node["Options"] = opts } return node + case *ast.PasswordCreateLoginSource: + node := jsonNode{ + "$type": "PasswordCreateLoginSource", + "Hashed": src.Hashed, + "MustChange": src.MustChange, + } + if src.Password != nil { + node["Password"] = scalarExpressionToJSON(src.Password) + } + 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 + case *ast.WindowsCreateLoginSource: + node := jsonNode{ + "$type": "WindowsCreateLoginSource", + } + 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 + case *ast.CertificateCreateLoginSource: + node := jsonNode{ + "$type": "CertificateCreateLoginSource", + } + if src.Certificate != nil { + node["Certificate"] = identifierToJSON(src.Certificate) + } + if src.Credential != nil { + node["Credential"] = identifierToJSON(src.Credential) + } + return node + case *ast.AsymmetricKeyCreateLoginSource: + node := jsonNode{ + "$type": "AsymmetricKeyCreateLoginSource", + } + if src.Key != nil { + node["Key"] = identifierToJSON(src.Key) + } + if src.Credential != nil { + node["Credential"] = identifierToJSON(src.Credential) + } + return node default: return jsonNode{} } @@ -15580,11 +15637,75 @@ func principalOptionToJSON(o ast.PrincipalOption) jsonNode { node["Identifier"] = identifierToJSON(opt.Identifier) } return node + case *ast.OnOffPrincipalOption: + return jsonNode{ + "$type": "OnOffPrincipalOption", + "OptionKind": opt.OptionKind, + "OptionState": opt.OptionState, + } + case *ast.PrincipalOptionSimple: + return jsonNode{ + "$type": "PrincipalOption", + "OptionKind": opt.OptionKind, + } + case *ast.PasswordAlterPrincipalOption: + node := jsonNode{ + "$type": "PasswordAlterPrincipalOption", + "OptionKind": opt.OptionKind, + "MustChange": opt.MustChange, + "Unlock": opt.Unlock, + "Hashed": opt.Hashed, + } + if opt.Password != nil { + node["Password"] = scalarExpressionToJSON(opt.Password) + } + if opt.OldPassword != nil { + node["OldPassword"] = stringLiteralToJSON(opt.OldPassword) + } + return node default: return jsonNode{} } } +func alterLoginEnableDisableStatementToJSON(s *ast.AlterLoginEnableDisableStatement) jsonNode { + node := jsonNode{ + "$type": "AlterLoginEnableDisableStatement", + "IsEnable": s.IsEnable, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + +func alterLoginOptionsStatementToJSON(s *ast.AlterLoginOptionsStatement) jsonNode { + node := jsonNode{ + "$type": "AlterLoginOptionsStatement", + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + if len(s.Options) > 0 { + opts := make([]jsonNode, len(s.Options)) + for i, opt := range s.Options { + opts[i] = principalOptionToJSON(opt) + } + node["Options"] = opts + } + return node +} + +func dropLoginStatementToJSON(s *ast.DropLoginStatement) jsonNode { + node := jsonNode{ + "$type": "DropLoginStatement", + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + func createIndexStatementToJSON(s *ast.CreateIndexStatement) jsonNode { node := jsonNode{ "$type": "CreateIndexStatement", diff --git a/parser/parse_ddl.go b/parser/parse_ddl.go index e0da5aac..deb288c2 100644 --- a/parser/parse_ddl.go +++ b/parser/parse_ddl.go @@ -130,6 +130,13 @@ func (p *Parser) parseDropStatement() (ast.Statement, error) { return p.parseDropBrokerPriorityStatement() case "RESOURCE": return p.parseDropResourcePoolStatement() + case "LOGIN": + return p.parseDropLoginStatement() + } + + // Handle LOGIN token explicitly + if p.curTok.Type == TokenLogin { + return p.parseDropLoginStatement() } return nil, fmt.Errorf("unexpected token after DROP: %s", p.curTok.Literal) @@ -659,6 +666,22 @@ func (p *Parser) parseDropSynonymStatement() (*ast.DropSynonymStatement, error) return stmt, nil } +func (p *Parser) parseDropLoginStatement() (*ast.DropLoginStatement, error) { + // Consume LOGIN + p.nextToken() + + stmt := &ast.DropLoginStatement{ + Name: p.parseIdentifier(), + } + + // Skip optional semicolon + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + func (p *Parser) parseDropUserStatement() (*ast.DropUserStatement, error) { // Consume USER p.nextToken() @@ -5898,39 +5921,217 @@ func (p *Parser) parseAlterSchemaStatement() (*ast.AlterSchemaStatement, error) return stmt, nil } -func (p *Parser) parseAlterLoginStatement() (*ast.AlterLoginAddDropCredentialStatement, error) { +func (p *Parser) parseAlterLoginStatement() (ast.Statement, error) { // Consume LOGIN p.nextToken() - stmt := &ast.AlterLoginAddDropCredentialStatement{} - // Parse login name - stmt.Name = p.parseIdentifier() + name := p.parseIdentifier() - // Check for ADD or DROP - if not present, skip to end - if p.curTok.Type == TokenAdd { - stmt.IsAdd = true - p.nextToken() // consume ADD - } else if p.curTok.Type == TokenDrop { - stmt.IsAdd = false - p.nextToken() // consume DROP - } else { - // Handle incomplete statement - p.skipToEndOfStatement() + // Check for ENABLE/DISABLE + upper := strings.ToUpper(p.curTok.Literal) + if upper == "ENABLE" { + p.nextToken() + return &ast.AlterLoginEnableDisableStatement{ + Name: name, + IsEnable: true, + }, nil + } else if upper == "DISABLE" { + p.nextToken() + return &ast.AlterLoginEnableDisableStatement{ + Name: name, + IsEnable: false, + }, nil + } + + // Check for ADD or DROP CREDENTIAL + if p.curTok.Type == TokenAdd || p.curTok.Type == TokenDrop { + stmt := &ast.AlterLoginAddDropCredentialStatement{ + Name: name, + IsAdd: p.curTok.Type == TokenAdd, + } + p.nextToken() // consume ADD/DROP + + // Expect CREDENTIAL + if p.curTok.Type == TokenCredential { + p.nextToken() + stmt.CredentialName = p.parseIdentifier() + } + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } return stmt, nil } - // Expect CREDENTIAL - if p.curTok.Type != TokenCredential { + // Handle WITH options + if p.curTok.Type == TokenWith { + p.nextToken() // consume WITH + + // Check if we have valid options to parse + upper := strings.ToUpper(p.curTok.Literal) + if upper == "PASSWORD" || upper == "NO" || upper == "NAME" || upper == "DEFAULT_DATABASE" || + upper == "DEFAULT_LANGUAGE" || upper == "CHECK_POLICY" || upper == "CHECK_EXPIRATION" || upper == "CREDENTIAL" { + return p.parseAlterLoginOptions(name) + } + // For incomplete statements like "alter login l1 with", fall back to old behavior p.skipToEndOfStatement() - return stmt, nil + return &ast.AlterLoginAddDropCredentialStatement{Name: name, IsAdd: false}, nil } - p.nextToken() - // Parse credential name - stmt.CredentialName = p.parseIdentifier() + // Skip to end if we don't recognize the syntax + p.skipToEndOfStatement() + return &ast.AlterLoginAddDropCredentialStatement{Name: name, IsAdd: false}, nil +} + +func (p *Parser) parseAlterLoginOptions(name *ast.Identifier) (*ast.AlterLoginOptionsStatement, error) { + stmt := &ast.AlterLoginOptionsStatement{ + Name: name, + } + + for { + optName := strings.ToUpper(p.curTok.Literal) + + if optName == "PASSWORD" { + p.nextToken() // consume PASSWORD + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + + opt := &ast.PasswordAlterPrincipalOption{ + OptionKind: "Password", + } + + // Parse password value + if p.curTok.Type == TokenString || p.curTok.Type == TokenNationalString { + val := p.curTok.Literal + if len(val) >= 2 && val[0] == '\'' && val[len(val)-1] == '\'' { + val = val[1 : len(val)-1] + } + opt.Password = &ast.StringLiteral{ + LiteralType: "String", + Value: val, + IsNational: p.curTok.Type == TokenNationalString, + IsLargeObject: false, + } + p.nextToken() + } else if p.curTok.Type == TokenBinary { + opt.Password = &ast.BinaryLiteral{ + LiteralType: "Binary", + IsLargeObject: false, + Value: p.curTok.Literal, + } + p.nextToken() + } + + // Parse optional flags + for { + upper := strings.ToUpper(p.curTok.Literal) + if upper == "HASHED" { + opt.Hashed = true + p.nextToken() + } else if upper == "MUST_CHANGE" { + opt.MustChange = true + p.nextToken() + } else if upper == "UNLOCK" { + opt.Unlock = true + p.nextToken() + } else if upper == "OLD_PASSWORD" { + p.nextToken() // consume OLD_PASSWORD + if p.curTok.Type == TokenEquals { + p.nextToken() + } + if p.curTok.Type == TokenString { + opt.OldPassword = p.parseStringLiteralValue() + p.nextToken() + } + } else { + break + } + } + + stmt.Options = append(stmt.Options, opt) + } else if optName == "NO" && strings.ToUpper(p.peekTok.Literal) == "CREDENTIAL" { + p.nextToken() // consume NO + p.nextToken() // consume CREDENTIAL + stmt.Options = append(stmt.Options, &ast.PrincipalOptionSimple{ + OptionKind: "NoCredential", + }) + } else if optName == "NAME" { + p.nextToken() // consume NAME + if p.curTok.Type == TokenEquals { + p.nextToken() + } + stmt.Options = append(stmt.Options, &ast.IdentifierPrincipalOption{ + OptionKind: "Name", + Identifier: p.parseIdentifier(), + }) + } else if optName == "DEFAULT_DATABASE" { + p.nextToken() // consume DEFAULT_DATABASE + if p.curTok.Type == TokenEquals { + p.nextToken() + } + stmt.Options = append(stmt.Options, &ast.IdentifierPrincipalOption{ + OptionKind: "DefaultDatabase", + Identifier: p.parseIdentifier(), + }) + } else if optName == "DEFAULT_LANGUAGE" { + p.nextToken() // consume DEFAULT_LANGUAGE + if p.curTok.Type == TokenEquals { + p.nextToken() + } + stmt.Options = append(stmt.Options, &ast.IdentifierPrincipalOption{ + OptionKind: "DefaultLanguage", + Identifier: p.parseIdentifier(), + }) + } else if optName == "CHECK_POLICY" { + p.nextToken() // consume CHECK_POLICY + if p.curTok.Type == TokenEquals { + p.nextToken() + } + optState := "On" + if strings.ToUpper(p.curTok.Literal) == "OFF" { + optState = "Off" + } + p.nextToken() + stmt.Options = append(stmt.Options, &ast.OnOffPrincipalOption{ + OptionKind: "CheckPolicy", + OptionState: optState, + }) + } else if optName == "CHECK_EXPIRATION" { + p.nextToken() // consume CHECK_EXPIRATION + if p.curTok.Type == TokenEquals { + p.nextToken() + } + optState := "On" + if strings.ToUpper(p.curTok.Literal) == "OFF" { + optState = "Off" + } + p.nextToken() + stmt.Options = append(stmt.Options, &ast.OnOffPrincipalOption{ + OptionKind: "CheckExpiration", + OptionState: optState, + }) + } else if optName == "CREDENTIAL" { + p.nextToken() // consume CREDENTIAL + if p.curTok.Type == TokenEquals { + p.nextToken() + } + stmt.Options = append(stmt.Options, &ast.IdentifierPrincipalOption{ + OptionKind: "Credential", + Identifier: p.parseIdentifier(), + }) + } else { + break + } + + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } - // Skip optional semicolon if p.curTok.Type == TokenSemicolon { p.nextToken() } diff --git a/parser/parse_statements.go b/parser/parse_statements.go index 61ebab6a..9b0fa7d1 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -10123,8 +10123,10 @@ func (p *Parser) parseCreateLoginStatement() (*ast.CreateLoginStatement, error) if p.curTok.Type == TokenFrom { p.nextToken() // consume FROM - // Check for EXTERNAL PROVIDER - if strings.ToUpper(p.curTok.Literal) == "EXTERNAL" { + upper := strings.ToUpper(p.curTok.Literal) + switch upper { + case "EXTERNAL": + // FROM EXTERNAL PROVIDER p.nextToken() // consume EXTERNAL if strings.ToUpper(p.curTok.Literal) == "PROVIDER" { p.nextToken() // consume PROVIDER @@ -10135,11 +10137,109 @@ func (p *Parser) parseCreateLoginStatement() (*ast.CreateLoginStatement, error) // Parse WITH options if p.curTok.Type == TokenWith { p.nextToken() // consume WITH - source.Options = p.parseExternalLoginOptions() + source.Options = p.parsePrincipalOptions() + } + + stmt.Source = source + + case "WINDOWS": + // FROM WINDOWS + p.nextToken() // consume WINDOWS + + source := &ast.WindowsCreateLoginSource{} + + // Parse WITH options + if p.curTok.Type == TokenWith { + p.nextToken() // consume WITH + source.Options = p.parsePrincipalOptions() + } + + stmt.Source = source + + case "CERTIFICATE": + // FROM CERTIFICATE certname + p.nextToken() // consume CERTIFICATE + + source := &ast.CertificateCreateLoginSource{ + Certificate: p.parseIdentifier(), + } + + // Parse WITH CREDENTIAL option + if p.curTok.Type == TokenWith { + p.nextToken() // consume WITH + if p.curTok.Type == TokenCredential || strings.ToUpper(p.curTok.Literal) == "CREDENTIAL" { + p.nextToken() // consume CREDENTIAL + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + source.Credential = p.parseIdentifier() + } } stmt.Source = source + + case "ASYMMETRIC": + // FROM ASYMMETRIC KEY keyname + p.nextToken() // consume ASYMMETRIC + if p.curTok.Type == TokenKey || strings.ToUpper(p.curTok.Literal) == "KEY" { + p.nextToken() // consume KEY + } + + source := &ast.AsymmetricKeyCreateLoginSource{ + Key: p.parseIdentifier(), + } + + // Parse WITH CREDENTIAL option + if p.curTok.Type == TokenWith { + p.nextToken() // consume WITH + if p.curTok.Type == TokenCredential || strings.ToUpper(p.curTok.Literal) == "CREDENTIAL" { + p.nextToken() // consume CREDENTIAL + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + source.Credential = p.parseIdentifier() + } + } + + stmt.Source = source + } + } else if p.curTok.Type == TokenWith { + // WITH PASSWORD = '...' + p.nextToken() // consume WITH + + source := &ast.PasswordCreateLoginSource{} + + // Parse PASSWORD = 'value' [HASHED] [MUST_CHANGE] + if strings.ToUpper(p.curTok.Literal) == "PASSWORD" { + p.nextToken() // consume PASSWORD + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + + // Parse password value (string or binary) + source.Password = p.parsePasswordValue() + + // Parse optional flags and other options + for p.curTok.Type != TokenSemicolon && p.curTok.Type != TokenEOF && strings.ToUpper(p.curTok.Literal) != "GO" { + upper := strings.ToUpper(p.curTok.Literal) + if upper == "HASHED" { + source.Hashed = true + p.nextToken() + } else if upper == "MUST_CHANGE" { + source.MustChange = true + p.nextToken() + } else if p.curTok.Type == TokenComma { + p.nextToken() + // Parse remaining options + source.Options = append(source.Options, p.parsePrincipalOptions()...) + break + } else { + break + } + } } + + stmt.Source = source } // Skip optional semicolon @@ -10150,7 +10250,37 @@ func (p *Parser) parseCreateLoginStatement() (*ast.CreateLoginStatement, error) return stmt, nil } -func (p *Parser) parseExternalLoginOptions() []ast.PrincipalOption { +func (p *Parser) parsePasswordValue() ast.ScalarExpression { + if p.curTok.Type == TokenString { + value := p.curTok.Literal + isNational := false + if len(value) > 0 && (value[0] == 'N' || value[0] == 'n') && len(value) > 1 && value[1] == '\'' { + isNational = true + value = value[2 : len(value)-1] + } else if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' { + value = value[1 : len(value)-1] + } + p.nextToken() + return &ast.StringLiteral{ + LiteralType: "String", + IsNational: isNational, + IsLargeObject: false, + Value: value, + } + } else if p.curTok.Type == TokenBinary { + value := p.curTok.Literal + p.nextToken() + return &ast.BinaryLiteral{ + LiteralType: "Binary", + IsLargeObject: false, + Value: value, + } + } + // Return nil if not a recognized password value + return nil +} + +func (p *Parser) parsePrincipalOptions() []ast.PrincipalOption { var options []ast.PrincipalOption for { @@ -10192,6 +10322,33 @@ func (p *Parser) parseExternalLoginOptions() []ast.PrincipalOption { OptionKind: "DefaultLanguage", Identifier: p.parseIdentifier(), }) + case "CHECK_EXPIRATION": + // CHECK_EXPIRATION = ON/OFF + optState := "On" + if strings.ToUpper(p.curTok.Literal) == "OFF" { + optState = "Off" + } + options = append(options, &ast.OnOffPrincipalOption{ + OptionKind: "CheckExpiration", + OptionState: optState, + }) + p.nextToken() + case "CHECK_POLICY": + // CHECK_POLICY = ON/OFF + optState := "On" + if strings.ToUpper(p.curTok.Literal) == "OFF" { + optState = "Off" + } + options = append(options, &ast.OnOffPrincipalOption{ + OptionKind: "CheckPolicy", + OptionState: optState, + }) + p.nextToken() + case "CREDENTIAL": + options = append(options, &ast.IdentifierPrincipalOption{ + OptionKind: "Credential", + Identifier: p.parseIdentifier(), + }) default: // Unknown option, skip value if p.curTok.Type != TokenComma && p.curTok.Type != TokenSemicolon && p.curTok.Type != TokenEOF { From 7f60105a1c0d50499b90b3955f420462e130e054 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 22:36:28 +0000 Subject: [PATCH 11/22] Fix LoginStatementTests - handle national strings and IsIfExists - Add IsIfExists field to DropLoginStatement - Fix national string handling: strip N prefix and quotes from value - Enable LoginStatementTests and Baselines90_LoginStatementTests --- ast/create_simple_statements.go | 3 ++- parser/marshal.go | 3 ++- parser/parse_ddl.go | 8 +++++++- .../Baselines90_LoginStatementTests/metadata.json | 2 +- parser/testdata/LoginStatementTests/metadata.json | 2 +- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/ast/create_simple_statements.go b/ast/create_simple_statements.go index 137caec7..d9f9bd2d 100644 --- a/ast/create_simple_statements.go +++ b/ast/create_simple_statements.go @@ -54,7 +54,8 @@ func (s *AlterLoginOptionsStatement) statement() {} // DropLoginStatement represents DROP LOGIN name type DropLoginStatement struct { - Name *Identifier `json:"Name,omitempty"` + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` } func (s *DropLoginStatement) node() {} diff --git a/parser/marshal.go b/parser/marshal.go index cbf307f5..c22468fb 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -15698,7 +15698,8 @@ func alterLoginOptionsStatementToJSON(s *ast.AlterLoginOptionsStatement) jsonNod func dropLoginStatementToJSON(s *ast.DropLoginStatement) jsonNode { node := jsonNode{ - "$type": "DropLoginStatement", + "$type": "DropLoginStatement", + "IsIfExists": s.IsIfExists, } if s.Name != nil { node["Name"] = identifierToJSON(s.Name) diff --git a/parser/parse_ddl.go b/parser/parse_ddl.go index deb288c2..3c8f70cc 100644 --- a/parser/parse_ddl.go +++ b/parser/parse_ddl.go @@ -6005,13 +6005,19 @@ func (p *Parser) parseAlterLoginOptions(name *ast.Identifier) (*ast.AlterLoginOp // Parse password value if p.curTok.Type == TokenString || p.curTok.Type == TokenNationalString { val := p.curTok.Literal + isNational := p.curTok.Type == TokenNationalString + // Strip N prefix for national strings + if isNational && len(val) > 0 && (val[0] == 'N' || val[0] == 'n') { + val = val[1:] + } + // Strip surrounding quotes if len(val) >= 2 && val[0] == '\'' && val[len(val)-1] == '\'' { val = val[1 : len(val)-1] } opt.Password = &ast.StringLiteral{ LiteralType: "String", Value: val, - IsNational: p.curTok.Type == TokenNationalString, + IsNational: isNational, IsLargeObject: false, } p.nextToken() diff --git a/parser/testdata/Baselines90_LoginStatementTests/metadata.json b/parser/testdata/Baselines90_LoginStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines90_LoginStatementTests/metadata.json +++ b/parser/testdata/Baselines90_LoginStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/LoginStatementTests/metadata.json b/parser/testdata/LoginStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/LoginStatementTests/metadata.json +++ b/parser/testdata/LoginStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 4b9f707c9d40607820f5e46b977c1e7525aa320d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 22:50:26 +0000 Subject: [PATCH 12/22] Add support for service broker and other DROP statements - Added DROP PARTITION FUNCTION/SCHEME, DROP APPLICATION ROLE, DROP CERTIFICATE, DROP MASTER KEY, DROP XML SCHEMA COLLECTION, DROP CONTRACT, DROP ENDPOINT, DROP MESSAGE TYPE, DROP QUEUE, DROP REMOTE SERVICE BINDING, DROP ROUTE, DROP SERVICE, DROP EVENT NOTIFICATION statements - Added WITH NO DEPENDENTS option for DROP ASSEMBLY - Added MAXDOP option for DROP INDEX - Added DropBehavior (Cascade/Restrict) for DROP SCHEMA - Added marshaling for DropFullTextCatalogStatement and DropFulltextIndexStatement - Enabled Baselines90_DropStatementsTests2 and DropStatementsTests2 tests --- ast/alter_table_alter_index_statement.go | 5 +- ast/drop_service_broker_statements.go | 122 ++++++ ast/fulltext_stoplist_statement.go | 3 +- parser/marshal.go | 222 +++++++++++ parser/parse_ddl.go | 352 +++++++++++++++++- .../metadata.json | 2 +- .../DropStatementsTests2/metadata.json | 2 +- 7 files changed, 702 insertions(+), 6 deletions(-) create mode 100644 ast/drop_service_broker_statements.go diff --git a/ast/alter_table_alter_index_statement.go b/ast/alter_table_alter_index_statement.go index e69de983..86cf90ca 100644 --- a/ast/alter_table_alter_index_statement.go +++ b/ast/alter_table_alter_index_statement.go @@ -32,8 +32,9 @@ type IndexExpressionOption struct { Expression ScalarExpression } -func (i *IndexExpressionOption) indexOption() {} -func (i *IndexExpressionOption) node() {} +func (i *IndexExpressionOption) indexOption() {} +func (i *IndexExpressionOption) dropIndexOption() {} +func (i *IndexExpressionOption) node() {} // CompressionDelayIndexOption represents a COMPRESSION_DELAY option type CompressionDelayIndexOption struct { diff --git a/ast/drop_service_broker_statements.go b/ast/drop_service_broker_statements.go new file mode 100644 index 00000000..ad156815 --- /dev/null +++ b/ast/drop_service_broker_statements.go @@ -0,0 +1,122 @@ +package ast + +// DropPartitionFunctionStatement represents a DROP PARTITION FUNCTION statement +type DropPartitionFunctionStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` +} + +func (*DropPartitionFunctionStatement) statement() {} +func (*DropPartitionFunctionStatement) node() {} + +// DropPartitionSchemeStatement represents a DROP PARTITION SCHEME statement +type DropPartitionSchemeStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` +} + +func (*DropPartitionSchemeStatement) statement() {} +func (*DropPartitionSchemeStatement) node() {} + +// DropApplicationRoleStatement represents a DROP APPLICATION ROLE statement +type DropApplicationRoleStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` +} + +func (*DropApplicationRoleStatement) statement() {} +func (*DropApplicationRoleStatement) node() {} + +// DropCertificateStatement represents a DROP CERTIFICATE statement +type DropCertificateStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` +} + +func (*DropCertificateStatement) statement() {} +func (*DropCertificateStatement) node() {} + +// DropMasterKeyStatement represents a DROP MASTER KEY statement +type DropMasterKeyStatement struct{} + +func (*DropMasterKeyStatement) statement() {} +func (*DropMasterKeyStatement) node() {} + +// DropXmlSchemaCollectionStatement represents a DROP XML SCHEMA COLLECTION statement +type DropXmlSchemaCollectionStatement struct { + Name *SchemaObjectName `json:"Name,omitempty"` +} + +func (*DropXmlSchemaCollectionStatement) statement() {} +func (*DropXmlSchemaCollectionStatement) node() {} + +// DropContractStatement represents a DROP CONTRACT statement +type DropContractStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` +} + +func (*DropContractStatement) statement() {} +func (*DropContractStatement) node() {} + +// DropEndpointStatement represents a DROP ENDPOINT statement +type DropEndpointStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` +} + +func (*DropEndpointStatement) statement() {} +func (*DropEndpointStatement) node() {} + +// DropMessageTypeStatement represents a DROP MESSAGE TYPE statement +type DropMessageTypeStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` +} + +func (*DropMessageTypeStatement) statement() {} +func (*DropMessageTypeStatement) node() {} + +// DropQueueStatement represents a DROP QUEUE statement +type DropQueueStatement struct { + Name *SchemaObjectName `json:"Name,omitempty"` +} + +func (*DropQueueStatement) statement() {} +func (*DropQueueStatement) node() {} + +// DropRemoteServiceBindingStatement represents a DROP REMOTE SERVICE BINDING statement +type DropRemoteServiceBindingStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` +} + +func (*DropRemoteServiceBindingStatement) statement() {} +func (*DropRemoteServiceBindingStatement) node() {} + +// DropRouteStatement represents a DROP ROUTE statement +type DropRouteStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` +} + +func (*DropRouteStatement) statement() {} +func (*DropRouteStatement) node() {} + +// DropServiceStatement represents a DROP SERVICE statement +type DropServiceStatement struct { + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` +} + +func (*DropServiceStatement) statement() {} +func (*DropServiceStatement) node() {} + +// DropEventNotificationStatement represents a DROP EVENT NOTIFICATION statement +type DropEventNotificationStatement struct { + Notifications []*Identifier `json:"Notifications,omitempty"` + Scope *EventNotificationObjectScope `json:"Scope,omitempty"` +} + +func (*DropEventNotificationStatement) statement() {} +func (*DropEventNotificationStatement) node() {} diff --git a/ast/fulltext_stoplist_statement.go b/ast/fulltext_stoplist_statement.go index 1a77dd7d..cb58ac06 100644 --- a/ast/fulltext_stoplist_statement.go +++ b/ast/fulltext_stoplist_statement.go @@ -42,7 +42,8 @@ func (s *DropFullTextStopListStatement) statement() {} // DropFullTextCatalogStatement represents DROP FULLTEXT CATALOG statement type DropFullTextCatalogStatement struct { - Name *Identifier `json:"Name,omitempty"` + Name *Identifier `json:"Name,omitempty"` + IsIfExists bool `json:"IsIfExists"` } func (s *DropFullTextCatalogStatement) node() {} diff --git a/parser/marshal.go b/parser/marshal.go index c22468fb..816d4987 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -522,6 +522,34 @@ func statementToJSON(stmt ast.Statement) jsonNode { return dropRuleStatementToJSON(s) case *ast.DropSchemaStatement: return dropSchemaStatementToJSON(s) + case *ast.DropPartitionFunctionStatement: + return dropPartitionFunctionStatementToJSON(s) + case *ast.DropPartitionSchemeStatement: + return dropPartitionSchemeStatementToJSON(s) + case *ast.DropApplicationRoleStatement: + return dropApplicationRoleStatementToJSON(s) + case *ast.DropCertificateStatement: + return dropCertificateStatementToJSON(s) + case *ast.DropMasterKeyStatement: + return dropMasterKeyStatementToJSON(s) + case *ast.DropXmlSchemaCollectionStatement: + return dropXmlSchemaCollectionStatementToJSON(s) + case *ast.DropContractStatement: + return dropContractStatementToJSON(s) + case *ast.DropEndpointStatement: + return dropEndpointStatementToJSON(s) + case *ast.DropMessageTypeStatement: + return dropMessageTypeStatementToJSON(s) + case *ast.DropQueueStatement: + return dropQueueStatementToJSON(s) + case *ast.DropRemoteServiceBindingStatement: + return dropRemoteServiceBindingStatementToJSON(s) + case *ast.DropRouteStatement: + return dropRouteStatementToJSON(s) + case *ast.DropServiceStatement: + return dropServiceStatementToJSON(s) + case *ast.DropEventNotificationStatement: + return dropEventNotificationStatementToJSON(s) case *ast.AlterTableTriggerModificationStatement: return alterTableTriggerModificationStatementToJSON(s) case *ast.AlterTableFileTableNamespaceStatement: @@ -576,6 +604,10 @@ func statementToJSON(stmt ast.Statement) jsonNode { return alterFullTextStopListStatementToJSON(s) case *ast.DropFullTextStopListStatement: return dropFullTextStopListStatementToJSON(s) + case *ast.DropFullTextCatalogStatement: + return dropFullTextCatalogStatementToJSON(s) + case *ast.DropFulltextIndexStatement: + return dropFulltextIndexStatementToJSON(s) case *ast.AlterSymmetricKeyStatement: return alterSymmetricKeyStatementToJSON(s) case *ast.AlterServiceMasterKeyStatement: @@ -13414,6 +13446,15 @@ func dropIndexOptionToJSON(opt ast.DropIndexOption) jsonNode { node["Options"] = options } return node + case *ast.IndexExpressionOption: + node := jsonNode{ + "$type": "IndexExpressionOption", + "OptionKind": o.OptionKind, + } + if o.Expression != nil { + node["Expression"] = scalarExpressionToJSON(o.Expression) + } + return node } return jsonNode{} } @@ -13479,6 +13520,166 @@ func dropSchemaStatementToJSON(s *ast.DropSchemaStatement) jsonNode { return node } +func dropPartitionFunctionStatementToJSON(s *ast.DropPartitionFunctionStatement) jsonNode { + node := jsonNode{ + "$type": "DropPartitionFunctionStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + +func dropPartitionSchemeStatementToJSON(s *ast.DropPartitionSchemeStatement) jsonNode { + node := jsonNode{ + "$type": "DropPartitionSchemeStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + +func dropApplicationRoleStatementToJSON(s *ast.DropApplicationRoleStatement) jsonNode { + node := jsonNode{ + "$type": "DropApplicationRoleStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + +func dropCertificateStatementToJSON(s *ast.DropCertificateStatement) jsonNode { + node := jsonNode{ + "$type": "DropCertificateStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + +func dropMasterKeyStatementToJSON(s *ast.DropMasterKeyStatement) jsonNode { + return jsonNode{ + "$type": "DropMasterKeyStatement", + } +} + +func dropXmlSchemaCollectionStatementToJSON(s *ast.DropXmlSchemaCollectionStatement) jsonNode { + node := jsonNode{ + "$type": "DropXmlSchemaCollectionStatement", + } + if s.Name != nil { + node["Name"] = schemaObjectNameToJSON(s.Name) + } + return node +} + +func dropContractStatementToJSON(s *ast.DropContractStatement) jsonNode { + node := jsonNode{ + "$type": "DropContractStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + +func dropEndpointStatementToJSON(s *ast.DropEndpointStatement) jsonNode { + node := jsonNode{ + "$type": "DropEndpointStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + +func dropMessageTypeStatementToJSON(s *ast.DropMessageTypeStatement) jsonNode { + node := jsonNode{ + "$type": "DropMessageTypeStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + +func dropQueueStatementToJSON(s *ast.DropQueueStatement) jsonNode { + node := jsonNode{ + "$type": "DropQueueStatement", + } + if s.Name != nil { + node["Name"] = schemaObjectNameToJSON(s.Name) + } + return node +} + +func dropRemoteServiceBindingStatementToJSON(s *ast.DropRemoteServiceBindingStatement) jsonNode { + node := jsonNode{ + "$type": "DropRemoteServiceBindingStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + +func dropRouteStatementToJSON(s *ast.DropRouteStatement) jsonNode { + node := jsonNode{ + "$type": "DropRouteStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + +func dropServiceStatementToJSON(s *ast.DropServiceStatement) jsonNode { + node := jsonNode{ + "$type": "DropServiceStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + +func dropEventNotificationStatementToJSON(s *ast.DropEventNotificationStatement) jsonNode { + node := jsonNode{ + "$type": "DropEventNotificationStatement", + } + if len(s.Notifications) > 0 { + notifications := make([]jsonNode, len(s.Notifications)) + for i, n := range s.Notifications { + notifications[i] = identifierToJSON(n) + } + node["Notifications"] = notifications + } + if s.Scope != nil { + scope := jsonNode{ + "$type": "EventNotificationObjectScope", + "Target": s.Scope.Target, + } + if s.Scope.QueueName != nil { + scope["QueueName"] = schemaObjectNameToJSON(s.Scope.QueueName) + } + node["Scope"] = scope + } + return node +} + func dropSecurityPolicyStatementToJSON(s *ast.DropSecurityPolicyStatement) jsonNode { node := jsonNode{ "$type": "DropSecurityPolicyStatement", @@ -15165,6 +15366,27 @@ func dropFullTextStopListStatementToJSON(s *ast.DropFullTextStopListStatement) j return node } +func dropFullTextCatalogStatementToJSON(s *ast.DropFullTextCatalogStatement) jsonNode { + node := jsonNode{ + "$type": "DropFullTextCatalogStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + +func dropFulltextIndexStatementToJSON(s *ast.DropFulltextIndexStatement) jsonNode { + node := jsonNode{ + "$type": "DropFullTextIndexStatement", + } + if s.OnName != nil { + node["OnName"] = schemaObjectNameToJSON(s.OnName) + } + 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 3c8f70cc..82cd020e 100644 --- a/parser/parse_ddl.go +++ b/parser/parse_ddl.go @@ -132,6 +132,34 @@ func (p *Parser) parseDropStatement() (ast.Statement, error) { return p.parseDropResourcePoolStatement() case "LOGIN": return p.parseDropLoginStatement() + case "PARTITION": + return p.parseDropPartitionStatement() + case "APPLICATION": + return p.parseDropApplicationRoleStatement() + case "CERTIFICATE": + return p.parseDropCertificateStatement() + case "CREDENTIAL": + return p.parseDropCredentialStatement(false) + case "MASTER": + return p.parseDropMasterKeyStatement() + case "XML": + return p.parseDropXmlSchemaCollectionStatement() + case "CONTRACT": + return p.parseDropContractStatement() + case "ENDPOINT": + return p.parseDropEndpointStatement() + case "MESSAGE": + return p.parseDropMessageTypeStatement() + case "QUEUE": + return p.parseDropQueueStatement() + case "REMOTE": + return p.parseDropRemoteServiceBindingStatement() + case "ROUTE": + return p.parseDropRouteStatement() + case "SERVICE": + return p.parseDropServiceStatement() + case "EVENT": + return p.parseDropEventNotificationStatement() } // Handle LOGIN token explicitly @@ -766,6 +794,18 @@ func (p *Parser) parseDropAssemblyStatement() (*ast.DropAssemblyStatement, error p.nextToken() // consume comma } + // Check for WITH NO DEPENDENTS + if p.curTok.Type == TokenWith { + p.nextToken() // consume WITH + if strings.ToUpper(p.curTok.Literal) == "NO" { + p.nextToken() // consume NO + if strings.ToUpper(p.curTok.Literal) == "DEPENDENTS" { + p.nextToken() // consume DEPENDENTS + stmt.WithNoDependents = true + } + } + } + // Skip optional semicolon if p.curTok.Type == TokenSemicolon { p.nextToken() @@ -1441,6 +1481,23 @@ func (p *Parser) parseDropIndexOptions() []ast.DropIndexOption { CompressionLevel: level, OptionKind: "DataCompression", }) + case "MAXDOP": + p.nextToken() // consume MAXDOP + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + var expr ast.ScalarExpression + if p.curTok.Type == TokenNumber { + expr = &ast.IntegerLiteral{ + LiteralType: "Integer", + Value: p.curTok.Literal, + } + p.nextToken() + } + options = append(options, &ast.IndexExpressionOption{ + Expression: expr, + OptionKind: "MaxDop", + }) case "WAIT_AT_LOW_PRIORITY": p.nextToken() // consume WAIT_AT_LOW_PRIORITY waitOpt := &ast.WaitAtLowPriorityOption{ @@ -1635,7 +1692,9 @@ func (p *Parser) parseDropSchemaStatement() (*ast.DropSchemaStatement, error) { // Consume SCHEMA p.nextToken() - stmt := &ast.DropSchemaStatement{} + stmt := &ast.DropSchemaStatement{ + DropBehavior: "None", + } // Check for IF EXISTS if strings.ToUpper(p.curTok.Literal) == "IF" { @@ -1654,6 +1713,16 @@ func (p *Parser) parseDropSchemaStatement() (*ast.DropSchemaStatement, error) { } stmt.Schema = schema + // Check for CASCADE or RESTRICT + switch strings.ToUpper(p.curTok.Literal) { + case "CASCADE": + stmt.DropBehavior = "Cascade" + p.nextToken() + case "RESTRICT": + stmt.DropBehavior = "Restrict" + p.nextToken() + } + // Skip optional semicolon if p.curTok.Type == TokenSemicolon { p.nextToken() @@ -1662,6 +1731,287 @@ func (p *Parser) parseDropSchemaStatement() (*ast.DropSchemaStatement, error) { return stmt, nil } +func (p *Parser) parseDropPartitionStatement() (ast.Statement, error) { + // Consume PARTITION + p.nextToken() + + keyword := strings.ToUpper(p.curTok.Literal) + p.nextToken() + + switch keyword { + case "FUNCTION": + stmt := &ast.DropPartitionFunctionStatement{ + Name: p.parseIdentifier(), + } + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + return stmt, nil + case "SCHEME": + stmt := &ast.DropPartitionSchemeStatement{ + Name: p.parseIdentifier(), + } + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + return stmt, nil + } + + return nil, fmt.Errorf("expected FUNCTION or SCHEME after PARTITION, got %s", keyword) +} + +func (p *Parser) parseDropApplicationRoleStatement() (*ast.DropApplicationRoleStatement, error) { + // Consume APPLICATION + p.nextToken() + // Consume ROLE + if strings.ToUpper(p.curTok.Literal) == "ROLE" { + p.nextToken() + } + + stmt := &ast.DropApplicationRoleStatement{ + Name: p.parseIdentifier(), + } + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + +func (p *Parser) parseDropCertificateStatement() (*ast.DropCertificateStatement, error) { + // Consume CERTIFICATE + p.nextToken() + + stmt := &ast.DropCertificateStatement{ + Name: p.parseIdentifier(), + } + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + +func (p *Parser) parseDropMasterKeyStatement() (*ast.DropMasterKeyStatement, error) { + // Consume MASTER + p.nextToken() + // Consume KEY + if strings.ToUpper(p.curTok.Literal) == "KEY" { + p.nextToken() + } + + stmt := &ast.DropMasterKeyStatement{} + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + +func (p *Parser) parseDropXmlSchemaCollectionStatement() (*ast.DropXmlSchemaCollectionStatement, error) { + // Consume XML + p.nextToken() + // Consume SCHEMA + if strings.ToUpper(p.curTok.Literal) == "SCHEMA" { + p.nextToken() + } + // Consume COLLECTION + if strings.ToUpper(p.curTok.Literal) == "COLLECTION" { + p.nextToken() + } + + name, err := p.parseSchemaObjectName() + if err != nil { + return nil, err + } + + stmt := &ast.DropXmlSchemaCollectionStatement{ + Name: name, + } + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + +func (p *Parser) parseDropContractStatement() (*ast.DropContractStatement, error) { + // Consume CONTRACT + p.nextToken() + + stmt := &ast.DropContractStatement{ + Name: p.parseIdentifier(), + } + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + +func (p *Parser) parseDropEndpointStatement() (*ast.DropEndpointStatement, error) { + // Consume ENDPOINT + p.nextToken() + + stmt := &ast.DropEndpointStatement{ + Name: p.parseIdentifier(), + } + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + +func (p *Parser) parseDropMessageTypeStatement() (*ast.DropMessageTypeStatement, error) { + // Consume MESSAGE + p.nextToken() + // Consume TYPE + if strings.ToUpper(p.curTok.Literal) == "TYPE" { + p.nextToken() + } + + stmt := &ast.DropMessageTypeStatement{ + Name: p.parseIdentifier(), + } + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + +func (p *Parser) parseDropQueueStatement() (*ast.DropQueueStatement, error) { + // Consume QUEUE + p.nextToken() + + name, err := p.parseSchemaObjectName() + if err != nil { + return nil, err + } + + stmt := &ast.DropQueueStatement{ + Name: name, + } + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + +func (p *Parser) parseDropRemoteServiceBindingStatement() (*ast.DropRemoteServiceBindingStatement, error) { + // Consume REMOTE + p.nextToken() + // Consume SERVICE + if strings.ToUpper(p.curTok.Literal) == "SERVICE" { + p.nextToken() + } + // Consume BINDING + if strings.ToUpper(p.curTok.Literal) == "BINDING" { + p.nextToken() + } + + stmt := &ast.DropRemoteServiceBindingStatement{ + Name: p.parseIdentifier(), + } + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + +func (p *Parser) parseDropRouteStatement() (*ast.DropRouteStatement, error) { + // Consume ROUTE + p.nextToken() + + stmt := &ast.DropRouteStatement{ + Name: p.parseIdentifier(), + } + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + +func (p *Parser) parseDropServiceStatement() (*ast.DropServiceStatement, error) { + // Consume SERVICE + p.nextToken() + + stmt := &ast.DropServiceStatement{ + Name: p.parseIdentifier(), + } + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + +func (p *Parser) parseDropEventNotificationStatement() (*ast.DropEventNotificationStatement, error) { + // Consume EVENT + p.nextToken() + // Consume NOTIFICATION + if strings.ToUpper(p.curTok.Literal) == "NOTIFICATION" { + p.nextToken() + } + + stmt := &ast.DropEventNotificationStatement{} + + // Parse notification names (comma-separated) + for { + stmt.Notifications = append(stmt.Notifications, p.parseIdentifier()) + if p.curTok.Type == TokenComma { + p.nextToken() + continue + } + break + } + + // Parse ON clause + if p.curTok.Type == TokenOn { + p.nextToken() + } + + scope := &ast.EventNotificationObjectScope{} + switch strings.ToUpper(p.curTok.Literal) { + case "SERVER": + scope.Target = "Server" + p.nextToken() + case "DATABASE": + scope.Target = "Database" + p.nextToken() + case "QUEUE": + scope.Target = "Queue" + p.nextToken() + queueName, err := p.parseSchemaObjectName() + if err != nil { + return nil, err + } + scope.QueueName = queueName + } + stmt.Scope = scope + + if p.curTok.Type == TokenSemicolon { + p.nextToken() + } + + return stmt, nil +} + func (p *Parser) parseAlterStatement() (ast.Statement, error) { // Consume ALTER p.nextToken() diff --git a/parser/testdata/Baselines90_DropStatementsTests2/metadata.json b/parser/testdata/Baselines90_DropStatementsTests2/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines90_DropStatementsTests2/metadata.json +++ b/parser/testdata/Baselines90_DropStatementsTests2/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/DropStatementsTests2/metadata.json b/parser/testdata/DropStatementsTests2/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/DropStatementsTests2/metadata.json +++ b/parser/testdata/DropStatementsTests2/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From aa2f616645406830ccb4d7076ec83508566b8419 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 23:10:12 +0000 Subject: [PATCH 13/22] Add CHANGETABLE, VALUES, and GlobalFunction table references - Add CHANGETABLE(CHANGES) and CHANGETABLE(VERSION) parsing - Add InlineDerivedTable for VALUES clause in FROM - Add GlobalFunctionTableReference for STRING_SPLIT and GENERATE_SERIES - Add Alias and Columns support for table function references Enables: Baselines150_FromClauseTests150, FromClauseTests150, Baselines160_BuiltInFunctionTests160, BuiltInFunctionTests160 --- ast/changetable_reference.go | 28 ++ ast/global_function_table_reference.go | 13 + ast/inline_derived_table.go | 13 + ast/schema_object_function_table_reference.go | 2 + parser/marshal.go | 126 +++++++ parser/parse_select.go | 335 +++++++++++++++++- .../metadata.json | 2 +- .../metadata.json | 2 +- .../BuiltInFunctionTests160/metadata.json | 2 +- .../testdata/FromClauseTests150/metadata.json | 2 +- 10 files changed, 520 insertions(+), 5 deletions(-) create mode 100644 ast/changetable_reference.go create mode 100644 ast/global_function_table_reference.go create mode 100644 ast/inline_derived_table.go diff --git a/ast/changetable_reference.go b/ast/changetable_reference.go new file mode 100644 index 00000000..056f79b8 --- /dev/null +++ b/ast/changetable_reference.go @@ -0,0 +1,28 @@ +package ast + +// ChangeTableChangesTableReference represents CHANGETABLE(CHANGES ...) table reference +type ChangeTableChangesTableReference struct { + Target *SchemaObjectName `json:"Target,omitempty"` + SinceVersion ScalarExpression `json:"SinceVersion,omitempty"` + ForceSeek bool `json:"ForceSeek"` + Columns []*Identifier `json:"Columns,omitempty"` + Alias *Identifier `json:"Alias,omitempty"` + ForPath bool `json:"ForPath"` +} + +func (c *ChangeTableChangesTableReference) node() {} +func (c *ChangeTableChangesTableReference) tableReference() {} + +// ChangeTableVersionTableReference represents CHANGETABLE(VERSION ...) table reference +type ChangeTableVersionTableReference struct { + Target *SchemaObjectName `json:"Target,omitempty"` + PrimaryKeyColumns []*Identifier `json:"PrimaryKeyColumns,omitempty"` + PrimaryKeyValues []ScalarExpression `json:"PrimaryKeyValues,omitempty"` + ForceSeek bool `json:"ForceSeek"` + Columns []*Identifier `json:"Columns,omitempty"` + Alias *Identifier `json:"Alias,omitempty"` + ForPath bool `json:"ForPath"` +} + +func (c *ChangeTableVersionTableReference) node() {} +func (c *ChangeTableVersionTableReference) tableReference() {} diff --git a/ast/global_function_table_reference.go b/ast/global_function_table_reference.go new file mode 100644 index 00000000..7ea30858 --- /dev/null +++ b/ast/global_function_table_reference.go @@ -0,0 +1,13 @@ +package ast + +// GlobalFunctionTableReference represents a built-in function used as a table source (e.g., STRING_SPLIT, OPENJSON) +type GlobalFunctionTableReference struct { + Name *Identifier `json:"Name,omitempty"` + Parameters []ScalarExpression `json:"Parameters,omitempty"` + Alias *Identifier `json:"Alias,omitempty"` + Columns []*Identifier `json:"Columns,omitempty"` // For column list in AS alias(c1, c2, ...) + ForPath bool `json:"ForPath"` +} + +func (g *GlobalFunctionTableReference) node() {} +func (g *GlobalFunctionTableReference) tableReference() {} diff --git a/ast/inline_derived_table.go b/ast/inline_derived_table.go new file mode 100644 index 00000000..0a1a6250 --- /dev/null +++ b/ast/inline_derived_table.go @@ -0,0 +1,13 @@ +package ast + +// InlineDerivedTable represents a VALUES clause used as a table reference +// Example: (VALUES ('a'), ('b')) AS x(col) +type InlineDerivedTable struct { + RowValues []*RowValue `json:"RowValues,omitempty"` + Alias *Identifier `json:"Alias,omitempty"` + Columns []*Identifier `json:"Columns,omitempty"` + ForPath bool `json:"ForPath"` +} + +func (t *InlineDerivedTable) node() {} +func (t *InlineDerivedTable) tableReference() {} diff --git a/ast/schema_object_function_table_reference.go b/ast/schema_object_function_table_reference.go index 3a35fc31..59f0e0e2 100644 --- a/ast/schema_object_function_table_reference.go +++ b/ast/schema_object_function_table_reference.go @@ -4,6 +4,8 @@ package ast type SchemaObjectFunctionTableReference struct { SchemaObject *SchemaObjectName `json:"SchemaObject,omitempty"` Parameters []ScalarExpression `json:"Parameters,omitempty"` + Alias *Identifier `json:"Alias,omitempty"` + Columns []*Identifier `json:"Columns,omitempty"` // For column list in AS alias(c1, c2, ...) ForPath bool `json:"ForPath"` } diff --git a/parser/marshal.go b/parser/marshal.go index 816d4987..c64f7252 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -2391,6 +2391,132 @@ func tableReferenceToJSON(ref ast.TableReference) jsonNode { } node["Parameters"] = params } + if r.Alias != nil { + node["Alias"] = identifierToJSON(r.Alias) + } + if len(r.Columns) > 0 { + cols := make([]jsonNode, len(r.Columns)) + for i, c := range r.Columns { + cols[i] = identifierToJSON(c) + } + node["Columns"] = cols + } + node["ForPath"] = r.ForPath + return node + case *ast.GlobalFunctionTableReference: + node := jsonNode{ + "$type": "GlobalFunctionTableReference", + } + if r.Name != nil { + node["Name"] = identifierToJSON(r.Name) + } + if len(r.Parameters) > 0 { + params := make([]jsonNode, len(r.Parameters)) + for i, p := range r.Parameters { + params[i] = scalarExpressionToJSON(p) + } + node["Parameters"] = params + } + if r.Alias != nil { + node["Alias"] = identifierToJSON(r.Alias) + } + if len(r.Columns) > 0 { + cols := make([]jsonNode, len(r.Columns)) + for i, c := range r.Columns { + cols[i] = identifierToJSON(c) + } + node["Columns"] = cols + } + node["ForPath"] = r.ForPath + return node + case *ast.InlineDerivedTable: + node := jsonNode{ + "$type": "InlineDerivedTable", + } + if len(r.RowValues) > 0 { + rows := make([]jsonNode, len(r.RowValues)) + for i, row := range r.RowValues { + rowNode := jsonNode{ + "$type": "RowValue", + } + if len(row.ColumnValues) > 0 { + vals := make([]jsonNode, len(row.ColumnValues)) + for j, v := range row.ColumnValues { + vals[j] = scalarExpressionToJSON(v) + } + rowNode["ColumnValues"] = vals + } + rows[i] = rowNode + } + node["RowValues"] = rows + } + if len(r.Columns) > 0 { + cols := make([]jsonNode, len(r.Columns)) + for i, c := range r.Columns { + cols[i] = identifierToJSON(c) + } + node["Columns"] = cols + } + if r.Alias != nil { + node["Alias"] = identifierToJSON(r.Alias) + } + node["ForPath"] = r.ForPath + return node + case *ast.ChangeTableChangesTableReference: + node := jsonNode{ + "$type": "ChangeTableChangesTableReference", + } + if r.Target != nil { + node["Target"] = schemaObjectNameToJSON(r.Target) + } + if r.SinceVersion != nil { + node["SinceVersion"] = scalarExpressionToJSON(r.SinceVersion) + } + node["ForceSeek"] = r.ForceSeek + if len(r.Columns) > 0 { + cols := make([]jsonNode, len(r.Columns)) + for i, c := range r.Columns { + cols[i] = identifierToJSON(c) + } + node["Columns"] = cols + } + if r.Alias != nil { + node["Alias"] = identifierToJSON(r.Alias) + } + node["ForPath"] = r.ForPath + return node + case *ast.ChangeTableVersionTableReference: + node := jsonNode{ + "$type": "ChangeTableVersionTableReference", + } + if r.Target != nil { + node["Target"] = schemaObjectNameToJSON(r.Target) + } + if len(r.PrimaryKeyColumns) > 0 { + cols := make([]jsonNode, len(r.PrimaryKeyColumns)) + for i, c := range r.PrimaryKeyColumns { + cols[i] = identifierToJSON(c) + } + node["PrimaryKeyColumns"] = cols + } + if len(r.PrimaryKeyValues) > 0 { + vals := make([]jsonNode, len(r.PrimaryKeyValues)) + for i, v := range r.PrimaryKeyValues { + vals[i] = scalarExpressionToJSON(v) + } + node["PrimaryKeyValues"] = vals + } + node["ForceSeek"] = r.ForceSeek + if len(r.Columns) > 0 { + cols := make([]jsonNode, len(r.Columns)) + for i, c := range r.Columns { + cols[i] = identifierToJSON(c) + } + node["Columns"] = cols + } + if r.Alias != nil { + node["Alias"] = identifierToJSON(r.Alias) + } node["ForPath"] = r.ForPath return node case *ast.InternalOpenRowset: diff --git a/parser/parse_select.go b/parser/parse_select.go index 36e39ba6..cf12ab3f 100644 --- a/parser/parse_select.go +++ b/parser/parse_select.go @@ -2447,6 +2447,11 @@ func (p *Parser) parseSingleTableReference() (ast.TableReference, error) { return p.parsePredictTableReference() } + // Check for CHANGETABLE + if p.curTok.Type == TokenIdent && strings.ToUpper(p.curTok.Literal) == "CHANGETABLE" { + return p.parseChangeTableReference() + } + // Check for full-text table functions (CONTAINSTABLE, FREETEXTTABLE) if p.curTok.Type == TokenIdent { upper := strings.ToUpper(p.curTok.Literal) @@ -2482,9 +2487,57 @@ func (p *Parser) parseSingleTableReference() (ast.TableReference, error) { if err != nil { return nil, err } + + // Parse optional alias (AS alias or just alias) and optional column list + var alias *ast.Identifier + var columns []*ast.Identifier + if p.curTok.Type == TokenAs { + p.nextToken() + 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" { + alias = p.parseIdentifier() + } + } + // Check for column list: alias(c1, c2, ...) + if alias != nil && p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + columns = append(columns, p.parseIdentifier()) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } + + // Use GlobalFunctionTableReference for specific built-in global functions + if son.Count == 1 && son.BaseIdentifier != nil { + upper := strings.ToUpper(son.BaseIdentifier.Value) + if upper == "STRING_SPLIT" || upper == "GENERATE_SERIES" { + return &ast.GlobalFunctionTableReference{ + Name: son.BaseIdentifier, + Parameters: params, + Alias: alias, + Columns: columns, + ForPath: false, + }, nil + } + } + ref := &ast.SchemaObjectFunctionTableReference{ SchemaObject: son, Parameters: params, + Alias: alias, + Columns: columns, ForPath: false, } return ref, nil @@ -2495,9 +2548,15 @@ func (p *Parser) parseSingleTableReference() (ast.TableReference, error) { } // parseDerivedTableReference parses a derived table (parenthesized query) like (SELECT ...) AS alias -func (p *Parser) parseDerivedTableReference() (*ast.QueryDerivedTable, error) { +// or an inline derived table (VALUES clause) like (VALUES (...), (...)) AS alias(cols) +func (p *Parser) parseDerivedTableReference() (ast.TableReference, error) { p.nextToken() // consume ( + // Check for VALUES clause (inline derived table) + if strings.ToUpper(p.curTok.Literal) == "VALUES" { + return p.parseInlineDerivedTable() + } + // Parse the query expression qe, err := p.parseQueryExpression() if err != nil { @@ -2534,6 +2593,86 @@ func (p *Parser) parseDerivedTableReference() (*ast.QueryDerivedTable, error) { return ref, nil } +// parseInlineDerivedTable parses a VALUES clause used as a table source +// Called after ( is consumed and VALUES is the current token +func (p *Parser) parseInlineDerivedTable() (*ast.InlineDerivedTable, error) { + p.nextToken() // consume VALUES + + ref := &ast.InlineDerivedTable{ + ForPath: false, + } + + // Parse row values: (val1, val2), (val3, val4), ... + for { + if p.curTok.Type != TokenLParen { + break + } + p.nextToken() // consume ( + + row := &ast.RowValue{} + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + expr, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + row.ColumnValues = append(row.ColumnValues, expr) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + ref.RowValues = append(ref.RowValues, row) + + if p.curTok.Type == TokenComma { + p.nextToken() // consume , between rows + } else { + break + } + } + + // Expect ) to close the VALUES clause + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) after VALUES clause, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Parse optional alias: AS alias or just 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() + } + } + + // Parse optional column list: alias(col1, col2, ...) + if ref.Alias != nil && p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + ref.Columns = append(ref.Columns, p.parseIdentifier()) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + + return ref, nil +} + func (p *Parser) parseNamedTableReference() (*ast.NamedTableReference, error) { ref := &ast.NamedTableReference{ ForPath: false, @@ -5722,3 +5861,197 @@ func (p *Parser) parseParseCall(isTry bool) (ast.ScalarExpression, error) { Culture: culture, }, nil } + +// parseChangeTableReference parses CHANGETABLE(CHANGES ...) or CHANGETABLE(VERSION ...) +func (p *Parser) parseChangeTableReference() (ast.TableReference, error) { + p.nextToken() // consume CHANGETABLE + + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after CHANGETABLE, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + + upper := strings.ToUpper(p.curTok.Literal) + if upper == "CHANGES" { + return p.parseChangeTableChangesReference() + } else if upper == "VERSION" { + return p.parseChangeTableVersionReference() + } + + return nil, fmt.Errorf("expected CHANGES or VERSION after CHANGETABLE(, got %s", p.curTok.Literal) +} + +// parseChangeTableChangesReference parses CHANGETABLE(CHANGES table, version [, FORCESEEK]) +func (p *Parser) parseChangeTableChangesReference() (*ast.ChangeTableChangesTableReference, error) { + p.nextToken() // consume CHANGES + + ref := &ast.ChangeTableChangesTableReference{ + ForPath: false, + } + + // Parse target table + son, err := p.parseSchemaObjectName() + if err != nil { + return nil, err + } + ref.Target = son + + // Expect comma + if p.curTok.Type != TokenComma { + return nil, fmt.Errorf("expected , after table name in CHANGETABLE, got %s", p.curTok.Literal) + } + p.nextToken() // consume , + + // Parse since version + version, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + ref.SinceVersion = version + + // Check for optional FORCESEEK + if p.curTok.Type == TokenComma { + p.nextToken() // consume , + if strings.ToUpper(p.curTok.Literal) == "FORCESEEK" { + ref.ForceSeek = true + p.nextToken() + } + } + + // Expect ) + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) after CHANGETABLE arguments, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Parse AS alias + if p.curTok.Type != TokenAs { + return nil, fmt.Errorf("expected AS after CHANGETABLE(...), got %s", p.curTok.Literal) + } + p.nextToken() // consume AS + ref.Alias = p.parseIdentifier() + + // Check for column list: alias(c1, c2, ...) + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + ref.Columns = append(ref.Columns, p.parseIdentifier()) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } + + return ref, nil +} + +// parseChangeTableVersionReference parses CHANGETABLE(VERSION table, (cols), (vals) [, FORCESEEK]) +func (p *Parser) parseChangeTableVersionReference() (*ast.ChangeTableVersionTableReference, error) { + p.nextToken() // consume VERSION + + ref := &ast.ChangeTableVersionTableReference{ + ForPath: false, + } + + // Parse target table + son, err := p.parseSchemaObjectName() + if err != nil { + return nil, err + } + ref.Target = son + + // Expect comma + if p.curTok.Type != TokenComma { + return nil, fmt.Errorf("expected , after table name in CHANGETABLE VERSION, got %s", p.curTok.Literal) + } + p.nextToken() // consume , + + // Parse primary key columns: (c1, c2, ...) + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( for primary key columns, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + ref.PrimaryKeyColumns = append(ref.PrimaryKeyColumns, p.parseIdentifier()) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + + // Expect comma + if p.curTok.Type != TokenComma { + return nil, fmt.Errorf("expected , after primary key columns, got %s", p.curTok.Literal) + } + p.nextToken() // consume , + + // Parse primary key values: (v1, v2, ...) + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( for primary key values, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + val, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + ref.PrimaryKeyValues = append(ref.PrimaryKeyValues, val) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + + // Check for optional FORCESEEK + if p.curTok.Type == TokenComma { + p.nextToken() // consume , + if strings.ToUpper(p.curTok.Literal) == "FORCESEEK" { + ref.ForceSeek = true + p.nextToken() + } + } + + // Expect ) + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) after CHANGETABLE VERSION arguments, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Parse AS alias + if p.curTok.Type != TokenAs { + return nil, fmt.Errorf("expected AS after CHANGETABLE(...), got %s", p.curTok.Literal) + } + p.nextToken() // consume AS + ref.Alias = p.parseIdentifier() + + // Check for column list: alias(c1, c2, ...) + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + ref.Columns = append(ref.Columns, p.parseIdentifier()) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } + + return ref, nil +} diff --git a/parser/testdata/Baselines150_FromClauseTests150/metadata.json b/parser/testdata/Baselines150_FromClauseTests150/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines150_FromClauseTests150/metadata.json +++ b/parser/testdata/Baselines150_FromClauseTests150/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/Baselines160_BuiltInFunctionTests160/metadata.json b/parser/testdata/Baselines160_BuiltInFunctionTests160/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines160_BuiltInFunctionTests160/metadata.json +++ b/parser/testdata/Baselines160_BuiltInFunctionTests160/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/BuiltInFunctionTests160/metadata.json b/parser/testdata/BuiltInFunctionTests160/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/BuiltInFunctionTests160/metadata.json +++ b/parser/testdata/BuiltInFunctionTests160/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/FromClauseTests150/metadata.json b/parser/testdata/FromClauseTests150/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/FromClauseTests150/metadata.json +++ b/parser/testdata/FromClauseTests150/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 9f130b99f221a4b4f1a5fa124288f8e3e4ca1227 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 23:21:20 +0000 Subject: [PATCH 14/22] Add DATABASE AUDIT SPECIFICATION action and group support - Add AuditActionSpecification and DatabaseAuditAction types - Add DropDatabaseAuditSpecificationStatement - Update parseAuditSpecificationPart to handle action specs - Add batch group mappings (BatchCompletedGroup, BatchStartedGroup) Enables: DatabaseAuditSpecificationStatementTests (all 4 variants) --- ast/server_audit_statement.go | 27 +++ parser/marshal.go | 45 +++++ parser/parse_ddl.go | 19 ++ parser/parse_statements.go | 172 +++++++++++++++--- .../metadata.json | 2 +- .../metadata.json | 2 +- .../metadata.json | 2 +- .../metadata.json | 2 +- 8 files changed, 237 insertions(+), 34 deletions(-) diff --git a/ast/server_audit_statement.go b/ast/server_audit_statement.go index 175c6d49..44363235 100644 --- a/ast/server_audit_statement.go +++ b/ast/server_audit_statement.go @@ -205,3 +205,30 @@ type AuditActionGroupReference struct { func (r *AuditActionGroupReference) node() {} func (r *AuditActionGroupReference) auditSpecificationDetail() {} + +// AuditActionSpecification represents an action specification in audit parts +// Example: (select, INSERT, update ON t1 BY dbo) +type AuditActionSpecification struct { + Actions []*DatabaseAuditAction + Principals []*SecurityPrincipal + TargetObject *SecurityTargetObject +} + +func (a *AuditActionSpecification) node() {} +func (a *AuditActionSpecification) auditSpecificationDetail() {} + +// DatabaseAuditAction represents a database audit action +type DatabaseAuditAction struct { + ActionKind string // Select, Insert, Update, Delete, Execute, Receive, References +} + +func (a *DatabaseAuditAction) node() {} + +// DropDatabaseAuditSpecificationStatement represents DROP DATABASE AUDIT SPECIFICATION +type DropDatabaseAuditSpecificationStatement struct { + Name *Identifier + IsIfExists bool +} + +func (s *DropDatabaseAuditSpecificationStatement) statement() {} +func (s *DropDatabaseAuditSpecificationStatement) node() {} diff --git a/parser/marshal.go b/parser/marshal.go index c64f7252..679f10c5 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -200,6 +200,8 @@ func statementToJSON(stmt ast.Statement) jsonNode { return dropServerRoleStatementToJSON(s) case *ast.DropServerAuditStatement: return dropServerAuditStatementToJSON(s) + case *ast.DropDatabaseAuditSpecificationStatement: + return dropDatabaseAuditSpecificationStatementToJSON(s) case *ast.DropAvailabilityGroupStatement: return dropAvailabilityGroupStatementToJSON(s) case *ast.DropFederationStatement: @@ -8854,6 +8856,38 @@ func auditSpecificationDetailToJSON(d ast.AuditSpecificationDetail) jsonNode { "$type": "AuditActionGroupReference", "Group": detail.Group, } + case *ast.AuditActionSpecification: + node := jsonNode{ + "$type": "AuditActionSpecification", + } + if len(detail.Actions) > 0 { + actions := make([]jsonNode, len(detail.Actions)) + for i, a := range detail.Actions { + actions[i] = jsonNode{ + "$type": "DatabaseAuditAction", + "ActionKind": a.ActionKind, + } + } + node["Actions"] = actions + } + if len(detail.Principals) > 0 { + principals := make([]jsonNode, len(detail.Principals)) + for i, p := range detail.Principals { + principalNode := jsonNode{ + "$type": "SecurityPrincipal", + "PrincipalType": p.PrincipalType, + } + if p.Identifier != nil { + principalNode["Identifier"] = identifierToJSON(p.Identifier) + } + principals[i] = principalNode + } + node["Principals"] = principals + } + if detail.TargetObject != nil { + node["TargetObject"] = securityTargetObjectToJSON(detail.TargetObject) + } + return node default: return jsonNode{} } @@ -8870,6 +8904,17 @@ func dropServerAuditStatementToJSON(s *ast.DropServerAuditStatement) jsonNode { return node } +func dropDatabaseAuditSpecificationStatementToJSON(s *ast.DropDatabaseAuditSpecificationStatement) jsonNode { + node := jsonNode{ + "$type": "DropDatabaseAuditSpecificationStatement", + "IsIfExists": s.IsIfExists, + } + if s.Name != nil { + node["Name"] = identifierToJSON(s.Name) + } + return node +} + func auditTargetToJSON(t *ast.AuditTarget) jsonNode { node := jsonNode{ "$type": "AuditTarget", diff --git a/parser/parse_ddl.go b/parser/parse_ddl.go index 82cd020e..8500a88a 100644 --- a/parser/parse_ddl.go +++ b/parser/parse_ddl.go @@ -906,6 +906,25 @@ func (p *Parser) parseDropDatabaseStatement() (ast.Statement, error) { // Consume DATABASE p.nextToken() + // Check for DATABASE AUDIT SPECIFICATION + if strings.ToUpper(p.curTok.Literal) == "AUDIT" { + p.nextToken() // consume AUDIT + if strings.ToUpper(p.curTok.Literal) == "SPECIFICATION" { + p.nextToken() // consume SPECIFICATION + } + stmt := &ast.DropDatabaseAuditSpecificationStatement{} + // Check for IF EXISTS + if strings.ToUpper(p.curTok.Literal) == "IF" { + p.nextToken() + if strings.ToUpper(p.curTok.Literal) == "EXISTS" { + p.nextToken() + stmt.IsIfExists = true + } + } + stmt.Name = p.parseIdentifier() + return stmt, nil + } + // Check for DATABASE ENCRYPTION KEY if strings.ToUpper(p.curTok.Literal) == "ENCRYPTION" { p.nextToken() // consume ENCRYPTION diff --git a/parser/parse_statements.go b/parser/parse_statements.go index 9b0fa7d1..f113c0bf 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -3649,21 +3649,9 @@ func (p *Parser) parseCreateDatabaseAuditSpecificationStatement() (*ast.CreateDa for { upperLit := strings.ToUpper(p.curTok.Literal) if upperLit == "ADD" || upperLit == "DROP" { - part := &ast.AuditSpecificationPart{ - IsDrop: upperLit == "DROP", - } - p.nextToken() // consume ADD/DROP - if p.curTok.Type == TokenLParen { - p.nextToken() // consume ( - // Parse audit action group reference - groupName := p.curTok.Literal - part.Details = &ast.AuditActionGroupReference{ - Group: convertAuditGroupName(groupName), - } - p.nextToken() // consume group name - if p.curTok.Type == TokenRParen { - p.nextToken() // consume ) - } + part, err := p.parseAuditSpecificationPart(upperLit == "DROP") + if err != nil { + return nil, err } stmt.Parts = append(stmt.Parts, part) if p.curTok.Type == TokenComma { @@ -3724,21 +3712,9 @@ func (p *Parser) parseAlterDatabaseAuditSpecificationStatement() (*ast.AlterData for { upperLit := strings.ToUpper(p.curTok.Literal) if upperLit == "ADD" || upperLit == "DROP" { - part := &ast.AuditSpecificationPart{ - IsDrop: upperLit == "DROP", - } - p.nextToken() // consume ADD/DROP - if p.curTok.Type == TokenLParen { - p.nextToken() // consume ( - // Parse audit action group reference - groupName := p.curTok.Literal - part.Details = &ast.AuditActionGroupReference{ - Group: convertAuditGroupName(groupName), - } - p.nextToken() // consume group name - if p.curTok.Type == TokenRParen { - p.nextToken() // consume ) - } + part, err := p.parseAuditSpecificationPart(upperLit == "DROP") + if err != nil { + return nil, err } stmt.Parts = append(stmt.Parts, part) if p.curTok.Type == TokenComma { @@ -3784,6 +3760,27 @@ func convertAuditGroupName(name string) string { "DATABASE_LOGOUT_GROUP": "DatabaseLogoutGroup", "USER_CHANGE_PASSWORD_GROUP": "UserChangePasswordGroup", "USER_DEFINED_AUDIT_GROUP": "UserDefinedAuditGroup", + "DATABASE_PERMISSION_CHANGE_GROUP": "DatabasePermissionChange", + "SCHEMA_OBJECT_PERMISSION_CHANGE_GROUP": "SchemaObjectPermissionChange", + "DATABASE_ROLE_MEMBER_CHANGE_GROUP": "DatabaseRoleMemberChange", + "APPLICATION_ROLE_CHANGE_PASSWORD_GROUP": "ApplicationRoleChangePassword", + "SCHEMA_OBJECT_ACCESS_GROUP": "SchemaObjectAccess", + "BACKUP_RESTORE_GROUP": "BackupRestore", + "DBCC_GROUP": "Dbcc", + "AUDIT_CHANGE_GROUP": "AuditChange", + "DATABASE_CHANGE_GROUP": "DatabaseChange", + "DATABASE_OBJECT_CHANGE_GROUP": "DatabaseObjectChange", + "DATABASE_PRINCIPAL_CHANGE_GROUP": "DatabasePrincipalChange", + "SCHEMA_OBJECT_CHANGE_GROUP": "SchemaObjectChange", + "DATABASE_PRINCIPAL_IMPERSONATION_GROUP": "DatabasePrincipalImpersonation", + "DATABASE_OBJECT_OWNERSHIP_CHANGE_GROUP": "DatabaseObjectOwnershipChange", + "DATABASE_OWNERSHIP_CHANGE_GROUP": "DatabaseOwnershipChange", + "SCHEMA_OBJECT_OWNERSHIP_CHANGE_GROUP": "SchemaObjectOwnershipChange", + "DATABASE_OBJECT_PERMISSION_CHANGE_GROUP": "DatabaseObjectPermissionChange", + "DATABASE_OPERATION_GROUP": "DatabaseOperation", + "DATABASE_OBJECT_ACCESS_GROUP": "DatabaseObjectAccess", + "BATCH_COMPLETED_GROUP": "BatchCompletedGroup", + "BATCH_STARTED_GROUP": "BatchStartedGroup", } if mapped, ok := groupMap[strings.ToUpper(name)]; ok { return mapped @@ -3791,6 +3788,121 @@ func convertAuditGroupName(name string) string { return capitalizeFirst(strings.ToLower(strings.ReplaceAll(name, "_", " "))) } +// isAuditAction checks if the given word is a database audit action +func isAuditAction(word string) bool { + actions := map[string]bool{ + "SELECT": true, "INSERT": true, "UPDATE": true, "DELETE": true, + "EXECUTE": true, "RECEIVE": true, "REFERENCES": true, + } + return actions[word] +} + +// convertAuditActionKind converts audit action to expected format +func convertAuditActionKind(action string) string { + actionMap := map[string]string{ + "SELECT": "Select", + "INSERT": "Insert", + "UPDATE": "Update", + "DELETE": "Delete", + "EXECUTE": "Execute", + "RECEIVE": "Receive", + "REFERENCES": "References", + } + if mapped, ok := actionMap[action]; ok { + return mapped + } + return capitalizeFirst(strings.ToLower(action)) +} + +// parseAuditSpecificationPart parses an ADD or DROP part of an audit specification +func (p *Parser) parseAuditSpecificationPart(isDrop bool) (*ast.AuditSpecificationPart, error) { + part := &ast.AuditSpecificationPart{ + IsDrop: isDrop, + } + p.nextToken() // consume ADD/DROP + + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + + // Check if it's an action specification (SELECT, INSERT, etc.) or an audit group + firstWord := strings.ToUpper(p.curTok.Literal) + if isAuditAction(firstWord) { + // Parse action specification + spec := &ast.AuditActionSpecification{} + + // Parse actions + for { + actionKind := convertAuditActionKind(strings.ToUpper(p.curTok.Literal)) + spec.Actions = append(spec.Actions, &ast.DatabaseAuditAction{ActionKind: actionKind}) + p.nextToken() + if p.curTok.Type == TokenComma { + p.nextToken() + // Check if next is ON (end of actions) or another action + if strings.ToUpper(p.curTok.Literal) == "ON" { + break + } + } else { + break + } + } + + // Parse ON object + if strings.ToUpper(p.curTok.Literal) == "ON" { + p.nextToken() // consume ON + objIdent := p.parseIdentifier() + spec.TargetObject = &ast.SecurityTargetObject{ + ObjectKind: "NotSpecified", + ObjectName: &ast.SecurityTargetObjectName{ + MultiPartIdentifier: &ast.MultiPartIdentifier{ + Identifiers: []*ast.Identifier{objIdent}, + Count: 1, + }, + }, + } + } + + // Parse BY principals + if strings.ToUpper(p.curTok.Literal) == "BY" { + p.nextToken() // consume BY + for { + principal := &ast.SecurityPrincipal{} + upper := strings.ToUpper(p.curTok.Literal) + if upper == "PUBLIC" { + principal.PrincipalType = "Public" + p.nextToken() + } else if upper == "NULL" { + principal.PrincipalType = "Null" + p.nextToken() + } else { + principal.PrincipalType = "Identifier" + principal.Identifier = p.parseIdentifier() + } + spec.Principals = append(spec.Principals, principal) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + } + part.Details = spec + } else { + // Parse audit action group reference + groupName := p.curTok.Literal + part.Details = &ast.AuditActionGroupReference{ + Group: convertAuditGroupName(groupName), + } + p.nextToken() // consume group name + } + + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + + return part, nil +} + func (p *Parser) parseAuditTarget() (*ast.AuditTarget, error) { target := &ast.AuditTarget{} diff --git a/parser/testdata/Baselines100_DatabaseAuditSpecificationStatementTests/metadata.json b/parser/testdata/Baselines100_DatabaseAuditSpecificationStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines100_DatabaseAuditSpecificationStatementTests/metadata.json +++ b/parser/testdata/Baselines100_DatabaseAuditSpecificationStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/Baselines150_DatabaseAuditSpecificationStatementTests150/metadata.json b/parser/testdata/Baselines150_DatabaseAuditSpecificationStatementTests150/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines150_DatabaseAuditSpecificationStatementTests150/metadata.json +++ b/parser/testdata/Baselines150_DatabaseAuditSpecificationStatementTests150/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/DatabaseAuditSpecificationStatementTests/metadata.json b/parser/testdata/DatabaseAuditSpecificationStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/DatabaseAuditSpecificationStatementTests/metadata.json +++ b/parser/testdata/DatabaseAuditSpecificationStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/DatabaseAuditSpecificationStatementTests150/metadata.json b/parser/testdata/DatabaseAuditSpecificationStatementTests150/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/DatabaseAuditSpecificationStatementTests150/metadata.json +++ b/parser/testdata/DatabaseAuditSpecificationStatementTests150/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From bed111819f816509c16a1de774912b13868f9e11 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 23:28:31 +0000 Subject: [PATCH 15/22] Add DATA_COMPRESSION option for columnstore indexes - Add DATA_COMPRESSION parsing to columnstore index WITH clause - Add DataCompressionOption case to columnStoreIndexOptionToJSON Enables: CreateIndexStatementTests120 (both variants) --- parser/marshal.go | 39 +++++++++++++++++++ .../metadata.json | 2 +- .../metadata.json | 2 +- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/parser/marshal.go b/parser/marshal.go index 679f10c5..59257c7f 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -10419,6 +10419,31 @@ func (p *Parser) parseCreateColumnStoreIndexStatement() (*ast.CreateColumnStoreI stmt.IndexOptions = append(stmt.IndexOptions, orderOpt) } + case "DATA_COMPRESSION": + p.nextToken() // consume DATA_COMPRESSION + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + level := strings.ToUpper(p.curTok.Literal) + compressionLevel := "None" + switch level { + case "COLUMNSTORE": + compressionLevel = "ColumnStore" + case "COLUMNSTORE_ARCHIVE": + compressionLevel = "ColumnStoreArchive" + case "PAGE": + compressionLevel = "Page" + case "ROW": + compressionLevel = "Row" + case "NONE": + compressionLevel = "None" + } + p.nextToken() // consume compression level + stmt.IndexOptions = append(stmt.IndexOptions, &ast.DataCompressionOption{ + CompressionLevel: compressionLevel, + OptionKind: "DataCompression", + }) + default: // Skip unknown options p.nextToken() @@ -12709,6 +12734,20 @@ func columnStoreIndexOptionToJSON(opt ast.IndexOption) jsonNode { node["Expression"] = scalarExpressionToJSON(o.Expression) } return node + case *ast.DataCompressionOption: + node := jsonNode{ + "$type": "DataCompressionOption", + "CompressionLevel": o.CompressionLevel, + "OptionKind": o.OptionKind, + } + if len(o.PartitionRanges) > 0 { + ranges := make([]jsonNode, len(o.PartitionRanges)) + for i, r := range o.PartitionRanges { + ranges[i] = compressionPartitionRangeToJSON(r) + } + node["PartitionRanges"] = ranges + } + return node default: return jsonNode{"$type": "UnknownIndexOption"} } diff --git a/parser/testdata/Baselines120_CreateIndexStatementTests120/metadata.json b/parser/testdata/Baselines120_CreateIndexStatementTests120/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines120_CreateIndexStatementTests120/metadata.json +++ b/parser/testdata/Baselines120_CreateIndexStatementTests120/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/CreateIndexStatementTests120/metadata.json b/parser/testdata/CreateIndexStatementTests120/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/CreateIndexStatementTests120/metadata.json +++ b/parser/testdata/CreateIndexStatementTests120/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 269492f5c12f9fade1720b0e77f873de0ec652cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 23:38:42 +0000 Subject: [PATCH 16/22] Add XML_COMPRESSION table option support (partial progress) - Add XmlCompressionOption and TableXmlCompressionOption types - Add XML_COMPRESSION parsing in CREATE TABLE WITH clause - Add partition ranges support for XML compression Part of work towards CreateTableTests160 --- ast/xml_compression_option.go | 20 +++++++ parser/marshal.go | 101 ++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 ast/xml_compression_option.go diff --git a/ast/xml_compression_option.go b/ast/xml_compression_option.go new file mode 100644 index 00000000..c6d0789f --- /dev/null +++ b/ast/xml_compression_option.go @@ -0,0 +1,20 @@ +package ast + +// XmlCompressionOption represents an XML compression option +type XmlCompressionOption struct { + IsCompressed string // "On", "Off" + PartitionRanges []*CompressionPartitionRange + OptionKind string // "XmlCompression" +} + +func (x *XmlCompressionOption) node() {} +func (x *XmlCompressionOption) tableOption() {} + +// TableXmlCompressionOption represents a table-level XML compression option +type TableXmlCompressionOption struct { + XmlCompressionOption *XmlCompressionOption + OptionKind string // "XmlCompression" +} + +func (t *TableXmlCompressionOption) node() {} +func (t *TableXmlCompressionOption) tableOption() {} diff --git a/parser/marshal.go b/parser/marshal.go index 59257c7f..ccafcd89 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -4509,6 +4509,18 @@ func (p *Parser) parseCreateTableStatement() (*ast.CreateTableStatement, error) DataCompressionOption: opt, OptionKind: "DataCompression", }) + } else if optionName == "XML_COMPRESSION" { + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + opt, err := p.parseXmlCompressionOption() + if err != nil { + break + } + stmt.Options = append(stmt.Options, &ast.TableXmlCompressionOption{ + XmlCompressionOption: opt, + OptionKind: "XmlCompression", + }) } else if optionName == "MEMORY_OPTIMIZED" { if p.curTok.Type == TokenEquals { p.nextToken() // consume = @@ -5382,6 +5394,70 @@ func (p *Parser) parseDataCompressionOption() (*ast.DataCompressionOption, error return opt, nil } +func (p *Parser) parseXmlCompressionOption() (*ast.XmlCompressionOption, error) { + opt := &ast.XmlCompressionOption{ + OptionKind: "XmlCompression", + } + + // Parse ON or OFF + levelStr := strings.ToUpper(p.curTok.Literal) + if levelStr == "ON" { + opt.IsCompressed = "On" + } else { + opt.IsCompressed = "Off" + } + p.nextToken() + + // Parse optional ON PARTITIONS clause + if p.curTok.Type == TokenOn { + p.nextToken() // consume ON + if strings.ToUpper(p.curTok.Literal) == "PARTITIONS" { + p.nextToken() // consume PARTITIONS + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + // Parse partition ranges + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + pr := &ast.CompressionPartitionRange{} + + // Parse From + if p.curTok.Type == TokenNumber { + pr.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 + if p.curTok.Type == TokenNumber { + pr.To = &ast.IntegerLiteral{ + LiteralType: "Integer", + Value: p.curTok.Literal, + } + p.nextToken() + } + } + + opt.PartitionRanges = append(opt.PartitionRanges, pr) + + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } + } + } + + return opt, nil +} + func (p *Parser) parseColumnDefinition() (*ast.ColumnDefinition, error) { col := &ast.ColumnDefinition{} @@ -7338,6 +7414,15 @@ func tableOptionToJSON(opt ast.TableOption) jsonNode { node["DataCompressionOption"] = dataCompressionOptionToJSON(o.DataCompressionOption) } return node + case *ast.TableXmlCompressionOption: + node := jsonNode{ + "$type": "TableXmlCompressionOption", + "OptionKind": o.OptionKind, + } + if o.XmlCompressionOption != nil { + node["XmlCompressionOption"] = xmlCompressionOptionToJSON(o.XmlCompressionOption) + } + return node case *ast.SystemVersioningTableOption: return systemVersioningTableOptionToJSON(o) case *ast.MemoryOptimizedTableOption: @@ -7439,6 +7524,22 @@ func dataCompressionOptionToJSON(opt *ast.DataCompressionOption) jsonNode { return node } +func xmlCompressionOptionToJSON(opt *ast.XmlCompressionOption) jsonNode { + node := jsonNode{ + "$type": "XmlCompressionOption", + "IsCompressed": opt.IsCompressed, + "OptionKind": opt.OptionKind, + } + if len(opt.PartitionRanges) > 0 { + ranges := make([]jsonNode, len(opt.PartitionRanges)) + for i, pr := range opt.PartitionRanges { + ranges[i] = compressionPartitionRangeToJSON(pr) + } + node["PartitionRanges"] = ranges + } + return node +} + func compressionPartitionRangeToJSON(pr *ast.CompressionPartitionRange) jsonNode { node := jsonNode{ "$type": "CompressionPartitionRange", From 7a69bac16dfb21b203e9655bb37b515c02937222 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 23:50:50 +0000 Subject: [PATCH 17/22] Add TABLE DISTRIBUTION, CLUSTERED INDEX, and HEAP options for CREATE TABLE - Add TableDistributionOption and TableHashDistributionPolicy AST types - Add TableIndexOption, TableClusteredIndexType, and TableNonClusteredIndexType AST types - Parse DISTRIBUTION = HASH(col1, col2, ...) in CREATE TABLE WITH clause - Parse CLUSTERED INDEX(col), CLUSTERED COLUMNSTORE INDEX, and HEAP options - Add JSON marshaling for all new table option types - Enables Baselines160_CreateTableTests160 and CreateTableTests160 tests --- ast/table_distribution_option.go | 18 +++ ast/table_index_option.go | 31 ++++ parser/marshal.go | 153 ++++++++++++++++++ .../metadata.json | 2 +- .../CreateTableTests160/metadata.json | 2 +- 5 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 ast/table_distribution_option.go create mode 100644 ast/table_index_option.go diff --git a/ast/table_distribution_option.go b/ast/table_distribution_option.go new file mode 100644 index 00000000..17b47ee8 --- /dev/null +++ b/ast/table_distribution_option.go @@ -0,0 +1,18 @@ +package ast + +// TableDistributionOption represents DISTRIBUTION option for tables +type TableDistributionOption struct { + Value *TableHashDistributionPolicy + OptionKind string // "Distribution" +} + +func (t *TableDistributionOption) node() {} +func (t *TableDistributionOption) tableOption() {} + +// TableHashDistributionPolicy represents HASH distribution for tables +type TableHashDistributionPolicy struct { + DistributionColumn *Identifier + DistributionColumns []*Identifier +} + +func (t *TableHashDistributionPolicy) node() {} diff --git a/ast/table_index_option.go b/ast/table_index_option.go new file mode 100644 index 00000000..81104d51 --- /dev/null +++ b/ast/table_index_option.go @@ -0,0 +1,31 @@ +package ast + +// TableIndexOption represents a table index option in CREATE TABLE WITH +type TableIndexOption struct { + Value TableIndexType + OptionKind string // "LockEscalation" (incorrect but matches expected output) +} + +func (t *TableIndexOption) node() {} +func (t *TableIndexOption) tableOption() {} + +// TableIndexType is an interface for different table index types +type TableIndexType interface { + Node + tableIndexType() +} + +// TableClusteredIndexType represents a clustered index type +type TableClusteredIndexType struct { + Columns []*ColumnWithSortOrder + ColumnStore bool +} + +func (t *TableClusteredIndexType) node() {} +func (t *TableClusteredIndexType) tableIndexType() {} + +// TableNonClusteredIndexType represents HEAP (non-clustered) +type TableNonClusteredIndexType struct{} + +func (t *TableNonClusteredIndexType) node() {} +func (t *TableNonClusteredIndexType) tableIndexType() {} diff --git a/parser/marshal.go b/parser/marshal.go index ccafcd89..7fd00ba2 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -4555,6 +4555,95 @@ func (p *Parser) parseCreateTableStatement() (*ast.CreateTableStatement, error) return nil, err } stmt.Options = append(stmt.Options, opt) + } else if optionName == "CLUSTERED" { + // Could be CLUSTERED INDEX or CLUSTERED COLUMNSTORE INDEX + if strings.ToUpper(p.curTok.Literal) == "COLUMNSTORE" { + p.nextToken() // consume COLUMNSTORE + if p.curTok.Type == TokenIndex { + p.nextToken() // consume INDEX + } + stmt.Options = append(stmt.Options, &ast.TableIndexOption{ + Value: &ast.TableClusteredIndexType{ + ColumnStore: true, + }, + OptionKind: "LockEscalation", + }) + } else if p.curTok.Type == TokenIndex { + p.nextToken() // consume INDEX + // Parse column list + indexType := &ast.TableClusteredIndexType{ + ColumnStore: false, + } + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + col := &ast.ColumnWithSortOrder{ + SortOrder: ast.SortOrderNotSpecified, + Column: &ast.ColumnReferenceExpression{ + ColumnType: "Regular", + MultiPartIdentifier: &ast.MultiPartIdentifier{ + Identifiers: []*ast.Identifier{p.parseIdentifier()}, + Count: 1, + }, + }, + } + indexType.Columns = append(indexType.Columns, col) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } + stmt.Options = append(stmt.Options, &ast.TableIndexOption{ + Value: indexType, + OptionKind: "LockEscalation", + }) + } + } else if optionName == "HEAP" { + stmt.Options = append(stmt.Options, &ast.TableIndexOption{ + Value: &ast.TableNonClusteredIndexType{}, + OptionKind: "LockEscalation", + }) + } else if optionName == "DISTRIBUTION" { + // Parse DISTRIBUTION = HASH(col1, col2, ...) or ROUND_ROBIN or REPLICATE + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + distTypeUpper := strings.ToUpper(p.curTok.Literal) + if distTypeUpper == "HASH" { + p.nextToken() // consume HASH + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + distOpt := &ast.TableDistributionOption{ + OptionKind: "Distribution", + Value: &ast.TableHashDistributionPolicy{}, + } + // Parse column list + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + col := p.parseIdentifier() + if distOpt.Value.DistributionColumn == nil { + distOpt.Value.DistributionColumn = col + } + distOpt.Value.DistributionColumns = append(distOpt.Value.DistributionColumns, col) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + stmt.Options = append(stmt.Options, distOpt) + } + } else { + // ROUND_ROBIN or REPLICATE - skip for now + p.nextToken() + } } else { // Skip unknown option value if p.curTok.Type == TokenEquals { @@ -7423,6 +7512,24 @@ func tableOptionToJSON(opt ast.TableOption) jsonNode { node["XmlCompressionOption"] = xmlCompressionOptionToJSON(o.XmlCompressionOption) } return node + case *ast.TableIndexOption: + node := jsonNode{ + "$type": "TableIndexOption", + "OptionKind": o.OptionKind, + } + if o.Value != nil { + node["Value"] = tableIndexTypeToJSON(o.Value) + } + return node + case *ast.TableDistributionOption: + node := jsonNode{ + "$type": "TableDistributionOption", + "OptionKind": o.OptionKind, + } + if o.Value != nil { + node["Value"] = tableHashDistributionPolicyToJSON(o.Value) + } + return node case *ast.SystemVersioningTableOption: return systemVersioningTableOptionToJSON(o) case *ast.MemoryOptimizedTableOption: @@ -7540,6 +7647,52 @@ func xmlCompressionOptionToJSON(opt *ast.XmlCompressionOption) jsonNode { return node } +func tableHashDistributionPolicyToJSON(policy *ast.TableHashDistributionPolicy) jsonNode { + node := jsonNode{ + "$type": "TableHashDistributionPolicy", + } + if policy.DistributionColumn != nil { + node["DistributionColumn"] = identifierToJSON(policy.DistributionColumn) + } + if len(policy.DistributionColumns) > 0 { + cols := make([]jsonNode, len(policy.DistributionColumns)) + for i, c := range policy.DistributionColumns { + // First column is same as DistributionColumn, use $ref + if i == 0 && policy.DistributionColumn != nil { + cols[i] = jsonNode{"$ref": "Identifier"} + } else { + cols[i] = identifierToJSON(c) + } + } + node["DistributionColumns"] = cols + } + return node +} + +func tableIndexTypeToJSON(t ast.TableIndexType) jsonNode { + switch v := t.(type) { + case *ast.TableClusteredIndexType: + node := jsonNode{ + "$type": "TableClusteredIndexType", + "ColumnStore": v.ColumnStore, + } + if len(v.Columns) > 0 { + cols := make([]jsonNode, len(v.Columns)) + for i, c := range v.Columns { + cols[i] = columnWithSortOrderToJSON(c) + } + node["Columns"] = cols + } + return node + case *ast.TableNonClusteredIndexType: + return jsonNode{ + "$type": "TableNonClusteredIndexType", + } + default: + return jsonNode{"$type": "UnknownTableIndexType"} + } +} + func compressionPartitionRangeToJSON(pr *ast.CompressionPartitionRange) jsonNode { node := jsonNode{ "$type": "CompressionPartitionRange", diff --git a/parser/testdata/Baselines160_CreateTableTests160/metadata.json b/parser/testdata/Baselines160_CreateTableTests160/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines160_CreateTableTests160/metadata.json +++ b/parser/testdata/Baselines160_CreateTableTests160/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/CreateTableTests160/metadata.json b/parser/testdata/CreateTableTests160/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/CreateTableTests160/metadata.json +++ b/parser/testdata/CreateTableTests160/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 22cb6a974f9e597c996212b5d9759233abbdb3ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Jan 2026 00:01:46 +0000 Subject: [PATCH 18/22] Add WINDOW clause and improve OVER clause support - Add WindowClause and WindowDefinition AST types for SELECT WINDOW clause - Add WindowFrameClause and WindowDelimiter AST types for ROWS/RANGE frames - Add WindowName field to OverClause for window name references (OVER Win1) - Create parseOverClause helper function and refactor all OVER parsing - Add parseWindowClause for WINDOW Win1 AS (...) syntax - Add parseWindowFrameClause for ROWS/RANGE BETWEEN ... AND ... - Add JSON marshaling for all new window-related types - Add WINDOW to reserved keyword lists for alias detection - Enables Baselines160_WindowClauseTests160, WindowClauseTests160, Baselines110_OverClauseTests110, and OverClauseTests110 tests --- ast/function_call.go | 23 +- ast/query_specification.go | 1 + ast/window_clause.go | 18 + parser/marshal.go | 71 +++ parser/parse_select.go | 476 +++++++++++------- .../metadata.json | 2 +- .../metadata.json | 2 +- .../testdata/OverClauseTests110/metadata.json | 2 +- .../WindowClauseTests160/metadata.json | 2 +- 9 files changed, 412 insertions(+), 185 deletions(-) create mode 100644 ast/window_clause.go diff --git a/ast/function_call.go b/ast/function_call.go index 316585a8..c5bbca47 100644 --- a/ast/function_call.go +++ b/ast/function_call.go @@ -28,10 +28,29 @@ func (*UserDefinedTypeCallTarget) callTarget() {} // OverClause represents an OVER clause for window functions. type OverClause struct { - Partitions []ScalarExpression `json:"Partitions,omitempty"` - OrderByClause *OrderByClause `json:"OrderByClause,omitempty"` + WindowName *Identifier `json:"WindowName,omitempty"` + Partitions []ScalarExpression `json:"Partitions,omitempty"` + OrderByClause *OrderByClause `json:"OrderByClause,omitempty"` + WindowFrameClause *WindowFrameClause `json:"WindowFrameClause,omitempty"` } +// WindowFrameClause represents ROWS/RANGE frame specification in OVER clause +type WindowFrameClause struct { + WindowFrameType string // "Rows", "Range" + Top *WindowDelimiter // Top boundary + Bottom *WindowDelimiter // Bottom boundary (for BETWEEN) +} + +func (w *WindowFrameClause) node() {} + +// WindowDelimiter represents window frame boundary +type WindowDelimiter struct { + WindowDelimiterType string // "CurrentRow", "UnboundedPreceding", "UnboundedFollowing", "ValuePreceding", "ValueFollowing" + OffsetValue ScalarExpression // For ValuePreceding/ValueFollowing +} + +func (w *WindowDelimiter) node() {} + // WithinGroupClause represents a WITHIN GROUP clause for ordered set aggregate functions. type WithinGroupClause struct { OrderByClause *OrderByClause `json:"OrderByClause,omitempty"` diff --git a/ast/query_specification.go b/ast/query_specification.go index 531e18c2..80eeafdf 100644 --- a/ast/query_specification.go +++ b/ast/query_specification.go @@ -9,6 +9,7 @@ type QuerySpecification struct { WhereClause *WhereClause `json:"WhereClause,omitempty"` GroupByClause *GroupByClause `json:"GroupByClause,omitempty"` HavingClause *HavingClause `json:"HavingClause,omitempty"` + WindowClause *WindowClause `json:"WindowClause,omitempty"` OrderByClause *OrderByClause `json:"OrderByClause,omitempty"` OffsetClause *OffsetClause `json:"OffsetClause,omitempty"` ForClause ForClause `json:"ForClause,omitempty"` diff --git a/ast/window_clause.go b/ast/window_clause.go new file mode 100644 index 00000000..ba059700 --- /dev/null +++ b/ast/window_clause.go @@ -0,0 +1,18 @@ +package ast + +// WindowClause represents a WINDOW clause in SELECT statement +type WindowClause struct { + WindowDefinition []*WindowDefinition +} + +func (w *WindowClause) node() {} + +// WindowDefinition represents a single window definition (WindowName AS (...)) +type WindowDefinition struct { + WindowName *Identifier // The name of this window + RefWindowName *Identifier // Reference to another window name (optional) + Partitions []ScalarExpression // PARTITION BY expressions + OrderByClause *OrderByClause // ORDER BY clause +} + +func (w *WindowDefinition) node() {} diff --git a/parser/marshal.go b/parser/marshal.go index 7fd00ba2..67c5db0e 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -1626,6 +1626,9 @@ func querySpecificationToJSON(q *ast.QuerySpecification) jsonNode { if q.HavingClause != nil { node["HavingClause"] = havingClauseToJSON(q.HavingClause) } + if q.WindowClause != nil { + node["WindowClause"] = windowClauseToJSON(q.WindowClause) + } if q.OrderByClause != nil { node["OrderByClause"] = orderByClauseToJSON(q.OrderByClause) } @@ -3106,6 +3109,43 @@ func havingClauseToJSON(hc *ast.HavingClause) jsonNode { return node } +func windowClauseToJSON(wc *ast.WindowClause) jsonNode { + node := jsonNode{ + "$type": "WindowClause", + } + if len(wc.WindowDefinition) > 0 { + defs := make([]jsonNode, len(wc.WindowDefinition)) + for i, def := range wc.WindowDefinition { + defs[i] = windowDefinitionToJSON(def) + } + node["WindowDefinition"] = defs + } + return node +} + +func windowDefinitionToJSON(wd *ast.WindowDefinition) jsonNode { + node := jsonNode{ + "$type": "WindowDefinition", + } + if wd.WindowName != nil { + node["WindowName"] = identifierToJSON(wd.WindowName) + } + if wd.RefWindowName != nil { + node["RefWindowName"] = identifierToJSON(wd.RefWindowName) + } + if len(wd.Partitions) > 0 { + partitions := make([]jsonNode, len(wd.Partitions)) + for i, p := range wd.Partitions { + partitions[i] = scalarExpressionToJSON(p) + } + node["Partitions"] = partitions + } + if wd.OrderByClause != nil { + node["OrderByClause"] = orderByClauseToJSON(wd.OrderByClause) + } + return node +} + func orderByClauseToJSON(obc *ast.OrderByClause) jsonNode { node := jsonNode{ "$type": "OrderByClause", @@ -3148,6 +3188,9 @@ func overClauseToJSON(oc *ast.OverClause) jsonNode { node := jsonNode{ "$type": "OverClause", } + if oc.WindowName != nil { + node["WindowName"] = identifierToJSON(oc.WindowName) + } if len(oc.Partitions) > 0 { partitions := make([]jsonNode, len(oc.Partitions)) for i, p := range oc.Partitions { @@ -3158,6 +3201,34 @@ func overClauseToJSON(oc *ast.OverClause) jsonNode { if oc.OrderByClause != nil { node["OrderByClause"] = orderByClauseToJSON(oc.OrderByClause) } + if oc.WindowFrameClause != nil { + node["WindowFrameClause"] = windowFrameClauseToJSON(oc.WindowFrameClause) + } + return node +} + +func windowFrameClauseToJSON(wfc *ast.WindowFrameClause) jsonNode { + node := jsonNode{ + "$type": "WindowFrameClause", + "WindowFrameType": wfc.WindowFrameType, + } + if wfc.Top != nil { + node["Top"] = windowDelimiterToJSON(wfc.Top) + } + if wfc.Bottom != nil { + node["Bottom"] = windowDelimiterToJSON(wfc.Bottom) + } + return node +} + +func windowDelimiterToJSON(wd *ast.WindowDelimiter) jsonNode { + node := jsonNode{ + "$type": "WindowDelimiter", + "WindowDelimiterType": wd.WindowDelimiterType, + } + if wd.OffsetValue != nil { + node["OffsetValue"] = scalarExpressionToJSON(wd.OffsetValue) + } return node } diff --git a/parser/parse_select.go b/parser/parse_select.go index cf12ab3f..306c09c5 100644 --- a/parser/parse_select.go +++ b/parser/parse_select.go @@ -308,6 +308,15 @@ func (p *Parser) parseQuerySpecificationWithInto() (*ast.QuerySpecification, *as qs.HavingClause = havingClause } + // Parse optional WINDOW clause + if strings.ToUpper(p.curTok.Literal) == "WINDOW" { + windowClause, err := p.parseWindowClause() + if err != nil { + return nil, nil, nil, err + } + qs.WindowClause = windowClause + } + // Note: ORDER BY is parsed at the top level in parseQueryExpressionWithInto // to correctly handle UNION/EXCEPT/INTERSECT cases @@ -530,7 +539,7 @@ func (p *Parser) parseSelectElement() (ast.SelectElement, error) { } } else if p.curTok.Type == TokenIdent { upper := strings.ToUpper(p.curTok.Literal) - if upper != "FROM" && upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "ORDER" && upper != "OPTION" && upper != "INTO" && upper != "UNION" && upper != "EXCEPT" && upper != "INTERSECT" && upper != "GO" { + if upper != "FROM" && upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && upper != "ORDER" && upper != "OPTION" && upper != "INTO" && upper != "UNION" && upper != "EXCEPT" && upper != "INTERSECT" && upper != "GO" { alias := p.parseIdentifier() sse.ColumnName = &ast.IdentifierOrValueExpression{ Value: alias.Value, @@ -670,7 +679,7 @@ func (p *Parser) parseSelectElement() (ast.SelectElement, error) { } else if p.curTok.Type == TokenIdent { // Check if this is an alias (not a keyword that starts a new clause) upper := strings.ToUpper(p.curTok.Literal) - if upper != "FROM" && upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "ORDER" && upper != "OPTION" && upper != "INTO" && upper != "UNION" && upper != "EXCEPT" && upper != "INTERSECT" && upper != "GO" && upper != "COLLATE" { + if upper != "FROM" && upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && upper != "ORDER" && upper != "OPTION" && upper != "INTO" && upper != "UNION" && upper != "EXCEPT" && upper != "INTERSECT" && upper != "GO" && upper != "COLLATE" { alias := p.parseIdentifier() sse.ColumnName = &ast.IdentifierOrValueExpression{ Value: alias.Value, @@ -890,49 +899,10 @@ func (p *Parser) parsePostfixExpression() (ast.ScalarExpression, error) { // 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) + overClause, err := p.parseOverClause() + if err != nil { + return nil, err } - p.nextToken() // consume ) - fc.OverClause = overClause } @@ -1035,48 +1005,10 @@ func (p *Parser) handlePostfixOperations(expr ast.ScalarExpression) (ast.ScalarE // 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) + overClause, err := p.parseOverClause() + if err != nil { + return nil, err } - p.nextToken() // consume ) - fc.OverClause = overClause } @@ -1379,51 +1311,11 @@ func (p *Parser) parseNextValueForExpression() (*ast.NextValueForExpression, err expr.SequenceName = seqName // Check for optional OVER clause - if p.curTok.Type == TokenOver { - 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) + if p.curTok.Type == TokenOver || strings.ToUpper(p.curTok.Literal) == "OVER" { + overClause, err := p.parseOverClause() + if err != nil { + return nil, err } - p.nextToken() // consume ) - expr.OverClause = overClause } @@ -2217,50 +2109,10 @@ func (p *Parser) parsePostExpressionAccess(expr ast.ScalarExpression) (ast.Scala // Check for OVER clause for function calls if fc, ok := expr.(*ast.FunctionCall); ok && 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) + overClause, err := p.parseOverClause() + if err != nil { + return nil, err } - p.nextToken() // consume ) - fc.OverClause = overClause } @@ -2496,7 +2348,7 @@ func (p *Parser) parseSingleTableReference() (ast.TableReference, error) { alias = p.parseIdentifier() } else if p.curTok.Type == TokenIdent { upper := strings.ToUpper(p.curTok.Literal) - if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "ORDER" && + if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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" { @@ -2582,7 +2434,7 @@ func (p *Parser) parseDerivedTableReference() (ast.TableReference, error) { // Could be an alias without AS, but need to be careful not to consume keywords 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" && upper != "USING" && upper != "WHEN" && upper != "OUTPUT" { + if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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" && upper != "USING" && upper != "WHEN" && upper != "OUTPUT" { ref.Alias = p.parseIdentifier() } } else { @@ -2646,7 +2498,7 @@ func (p *Parser) parseInlineDerivedTable() (*ast.InlineDerivedTable, error) { 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" && + if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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" { @@ -2726,7 +2578,7 @@ func (p *Parser) parseNamedTableReference() (*ast.NamedTableReference, error) { } else if p.curTok.Type == TokenIdent { // Could be an alias without AS, but need to be careful not to consume keywords 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" && upper != "USING" && upper != "WHEN" && upper != "OUTPUT" { + if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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" && upper != "USING" && upper != "WHEN" && upper != "OUTPUT" { ref.Alias = &ast.Identifier{Value: p.curTok.Literal, QuoteType: "NotQuoted"} p.nextToken() } @@ -2810,7 +2662,7 @@ func (p *Parser) parseNamedTableReferenceWithName(son *ast.SchemaObjectName) (*a // Could be an alias without AS, but need to be careful not to consume keywords 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" && upper != "USING" && upper != "WHEN" && upper != "OUTPUT" { + if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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" && upper != "USING" && upper != "WHEN" && upper != "OUTPUT" { ref.Alias = p.parseIdentifier() } } else { @@ -3004,7 +2856,7 @@ func (p *Parser) parseFullTextTableReference(funcType string) (*ast.FullTextTabl 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" { + if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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() } } @@ -3138,7 +2990,7 @@ func (p *Parser) parseSemanticTableReference(funcType string) (*ast.SemanticTabl 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" { + if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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() } } @@ -6055,3 +5907,269 @@ func (p *Parser) parseChangeTableVersionReference() (*ast.ChangeTableVersionTabl return ref, nil } + +// parseOverClause parses an OVER clause after a function call +// Handles both: OVER Win1 and OVER (PARTITION BY c1 ORDER BY c2 ROWS ...) +func (p *Parser) parseOverClause() (*ast.OverClause, error) { + // Current token should be OVER, consume it + p.nextToken() // consume OVER + + overClause := &ast.OverClause{} + + // Check if it's just a window name (no parentheses) + if p.curTok.Type != TokenLParen { + // It's OVER WindowName + if p.curTok.Type == TokenIdent || p.curTok.Type == TokenLBracket { + overClause.WindowName = p.parseIdentifier() + return overClause, nil + } + return nil, fmt.Errorf("expected ( or window name after OVER, got %s", p.curTok.Literal) + } + + p.nextToken() // consume ( + + // Check if it starts with a window name reference + // OVER (Win1 ORDER BY ...) or OVER (Win1 PARTITION BY ... ) + // This is tricky because we need to distinguish between Win1 (window name) and c1 (column name in PARTITION BY) + if p.curTok.Type == TokenIdent && p.peekTok.Type != TokenComma && p.peekTok.Type != TokenRParen { + upperPeek := strings.ToUpper(p.peekTok.Literal) + if upperPeek != "BY" && upperPeek != "," { + // Could be a window name reference if followed by ORDER, PARTITION, ROWS, RANGE, or ) + if upperPeek == "ORDER" || upperPeek == "PARTITION" || upperPeek == "ROWS" || upperPeek == "RANGE" || p.peekTok.Type == TokenRParen { + overClause.WindowName = p.parseIdentifier() + } + } + } + + // 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 { + if strings.ToUpper(p.curTok.Literal) == "ORDER" || strings.ToUpper(p.curTok.Literal) == "ROWS" || strings.ToUpper(p.curTok.Literal) == "RANGE" { + break + } + 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 + } + + // Parse window frame (ROWS/RANGE) + upperLit := strings.ToUpper(p.curTok.Literal) + if upperLit == "ROWS" || upperLit == "RANGE" { + frameClause, err := p.parseWindowFrameClause() + if err != nil { + return nil, err + } + overClause.WindowFrameClause = frameClause + } + + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) in OVER clause, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + return overClause, nil +} + +// parseWindowFrameClause parses ROWS/RANGE ... BETWEEN ... AND ... +func (p *Parser) parseWindowFrameClause() (*ast.WindowFrameClause, error) { + frame := &ast.WindowFrameClause{} + + // Parse ROWS or RANGE + upperLit := strings.ToUpper(p.curTok.Literal) + if upperLit == "ROWS" { + frame.WindowFrameType = "Rows" + } else if upperLit == "RANGE" { + frame.WindowFrameType = "Range" + } else { + return nil, fmt.Errorf("expected ROWS or RANGE, got %s", p.curTok.Literal) + } + p.nextToken() + + // Parse BETWEEN or single boundary + if strings.ToUpper(p.curTok.Literal) == "BETWEEN" { + p.nextToken() // consume BETWEEN + top, err := p.parseWindowDelimiter() + if err != nil { + return nil, err + } + frame.Top = top + + if strings.ToUpper(p.curTok.Literal) != "AND" { + return nil, fmt.Errorf("expected AND in ROWS BETWEEN, got %s", p.curTok.Literal) + } + p.nextToken() // consume AND + + bottom, err := p.parseWindowDelimiter() + if err != nil { + return nil, err + } + frame.Bottom = bottom + } else { + // Single boundary (e.g., ROWS UNBOUNDED PRECEDING) + top, err := p.parseWindowDelimiter() + if err != nil { + return nil, err + } + frame.Top = top + } + + return frame, nil +} + +// parseWindowDelimiter parses UNBOUNDED PRECEDING/FOLLOWING, CURRENT ROW, n PRECEDING/FOLLOWING +func (p *Parser) parseWindowDelimiter() (*ast.WindowDelimiter, error) { + delim := &ast.WindowDelimiter{} + + upperLit := strings.ToUpper(p.curTok.Literal) + + if upperLit == "CURRENT" { + p.nextToken() // consume CURRENT + if strings.ToUpper(p.curTok.Literal) != "ROW" { + return nil, fmt.Errorf("expected ROW after CURRENT, got %s", p.curTok.Literal) + } + p.nextToken() // consume ROW + delim.WindowDelimiterType = "CurrentRow" + } else if upperLit == "UNBOUNDED" { + p.nextToken() // consume UNBOUNDED + upperDir := strings.ToUpper(p.curTok.Literal) + if upperDir == "PRECEDING" { + delim.WindowDelimiterType = "UnboundedPreceding" + } else if upperDir == "FOLLOWING" { + delim.WindowDelimiterType = "UnboundedFollowing" + } else { + return nil, fmt.Errorf("expected PRECEDING or FOLLOWING after UNBOUNDED, got %s", p.curTok.Literal) + } + p.nextToken() + } else { + // n PRECEDING or n FOLLOWING + offset, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + delim.OffsetValue = offset + + upperDir := strings.ToUpper(p.curTok.Literal) + if upperDir == "PRECEDING" { + delim.WindowDelimiterType = "ValuePreceding" + } else if upperDir == "FOLLOWING" { + delim.WindowDelimiterType = "ValueFollowing" + } else { + return nil, fmt.Errorf("expected PRECEDING or FOLLOWING after value, got %s", p.curTok.Literal) + } + p.nextToken() + } + + return delim, nil +} + +// parseWindowClause parses WINDOW Win1 AS (...), Win2 AS (...) +func (p *Parser) parseWindowClause() (*ast.WindowClause, error) { + p.nextToken() // consume WINDOW + + clause := &ast.WindowClause{} + + for { + def := &ast.WindowDefinition{} + + // Parse window name + def.WindowName = p.parseIdentifier() + + // Expect AS + if strings.ToUpper(p.curTok.Literal) != "AS" { + return nil, fmt.Errorf("expected AS after window name, got %s", p.curTok.Literal) + } + p.nextToken() // consume AS + + // Expect ( + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after AS in window definition, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + + // Check if it references another window name + if p.curTok.Type == TokenIdent { + upperPeek := strings.ToUpper(p.peekTok.Literal) + // It's a reference if followed by ) or PARTITION or ORDER + if p.peekTok.Type == TokenRParen || upperPeek == "PARTITION" || upperPeek == "ORDER" { + // Could be a window name reference + if p.peekTok.Type == TokenRParen { + // Just a window name reference: Win1 AS (Win2) + def.RefWindowName = p.parseIdentifier() + } else if upperPeek != "BY" { + // Window name followed by more clauses + def.RefWindowName = p.parseIdentifier() + } + } + } + + // 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 { + if strings.ToUpper(p.curTok.Literal) == "ORDER" { + break + } + partExpr, err := p.parseScalarExpression() + if err != nil { + return nil, err + } + def.Partitions = append(def.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 + } + def.OrderByClause = orderBy + } + + // Expect ) + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) in window definition, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + clause.WindowDefinition = append(clause.WindowDefinition, def) + + // Check for comma (more window definitions) + if p.curTok.Type != TokenComma { + break + } + p.nextToken() // consume , + } + + return clause, nil +} diff --git a/parser/testdata/Baselines110_OverClauseTests110/metadata.json b/parser/testdata/Baselines110_OverClauseTests110/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines110_OverClauseTests110/metadata.json +++ b/parser/testdata/Baselines110_OverClauseTests110/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/Baselines160_WindowClauseTests160/metadata.json b/parser/testdata/Baselines160_WindowClauseTests160/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines160_WindowClauseTests160/metadata.json +++ b/parser/testdata/Baselines160_WindowClauseTests160/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/OverClauseTests110/metadata.json b/parser/testdata/OverClauseTests110/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/OverClauseTests110/metadata.json +++ b/parser/testdata/OverClauseTests110/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/WindowClauseTests160/metadata.json b/parser/testdata/WindowClauseTests160/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/WindowClauseTests160/metadata.json +++ b/parser/testdata/WindowClauseTests160/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 99e4b3a3c03dc9a8bb224bdc705f704ac3f60882 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Jan 2026 00:06:21 +0000 Subject: [PATCH 19/22] Add BACKUP statement FILE/FILEGROUP list parsing and option kinds - Support parenthesized list for FILE = ('f1', 'f2') and FILEGROUP = ('fg1', 'fg2') - Add all BACKUP option kind mappings (BlockSize, BufferCount, Description, etc.) - Enables BaselinesCommon_BackupStatementTests and BackupStatementTests tests --- parser/parse_statements.go | 102 ++++++++++++++++-- .../BackupStatementTests/metadata.json | 2 +- .../metadata.json | 2 +- 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/parser/parse_statements.go b/parser/parse_statements.go index f113c0bf..89e79acc 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -7156,11 +7156,31 @@ func (p *Parser) parseBackupStatement() (ast.Statement, error) { fileInfo := &ast.BackupRestoreFileInfo{ ItemKind: "Files", } - expr, err := p.parseScalarExpression() - if err != nil { - return nil, err + // Check for parenthesized list: FILE = ('f1', 'f2') + if p.curTok.Type == TokenLParen { + p.nextToken() + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + expr, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + fileInfo.Items = append(fileInfo.Items, expr) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } else { + expr, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + fileInfo.Items = append(fileInfo.Items, expr) } - fileInfo.Items = append(fileInfo.Items, expr) files = append(files, fileInfo) } else if upperLiteral == "FILEGROUP" { p.nextToken() @@ -7171,11 +7191,31 @@ func (p *Parser) parseBackupStatement() (ast.Statement, error) { fileInfo := &ast.BackupRestoreFileInfo{ ItemKind: "FileGroups", } - expr, err := p.parseScalarExpression() - if err != nil { - return nil, err + // Check for parenthesized list: FILEGROUP = ('fg1', 'fg2') + if p.curTok.Type == TokenLParen { + p.nextToken() + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + expr, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + fileInfo.Items = append(fileInfo.Items, expr) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() + } + } else { + expr, err := p.parsePrimaryExpression() + if err != nil { + return nil, err + } + fileInfo.Items = append(fileInfo.Items, expr) } - fileInfo.Items = append(fileInfo.Items, expr) files = append(files, fileInfo) } else { break @@ -7412,6 +7452,52 @@ func (p *Parser) parseBackupStatement() (ast.Statement, error) { option.OptionKind = "NoFormat" case "STATS": option.OptionKind = "Stats" + case "BLOCKSIZE": + option.OptionKind = "BlockSize" + case "BUFFERCOUNT": + option.OptionKind = "BufferCount" + case "DESCRIPTION": + option.OptionKind = "Description" + case "DIFFERENTIAL": + option.OptionKind = "Differential" + case "EXPIREDATE": + option.OptionKind = "ExpireDate" + case "MEDIANAME": + option.OptionKind = "MediaName" + case "MEDIADESCRIPTION": + option.OptionKind = "MediaDescription" + case "RETAINDAYS": + option.OptionKind = "RetainDays" + case "SKIP": + option.OptionKind = "Skip" + case "NOSKIP": + option.OptionKind = "NoSkip" + case "REWIND": + option.OptionKind = "Rewind" + case "NOREWIND": + option.OptionKind = "NoRewind" + case "UNLOAD": + option.OptionKind = "Unload" + case "NOUNLOAD": + option.OptionKind = "NoUnload" + case "RESTART": + option.OptionKind = "Restart" + case "COPY_ONLY": + option.OptionKind = "CopyOnly" + case "NAME": + option.OptionKind = "Name" + case "MAXTRANSFERSIZE": + option.OptionKind = "MaxTransferSize" + case "NO_TRUNCATE": + option.OptionKind = "NoTruncate" + case "NORECOVERY": + option.OptionKind = "NoRecovery" + case "STANDBY": + option.OptionKind = "Standby" + case "NO_LOG": + option.OptionKind = "NoLog" + case "TRUNCATE_ONLY": + option.OptionKind = "TruncateOnly" default: option.OptionKind = optionName } diff --git a/parser/testdata/BackupStatementTests/metadata.json b/parser/testdata/BackupStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/BackupStatementTests/metadata.json +++ b/parser/testdata/BackupStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/BaselinesCommon_BackupStatementTests/metadata.json b/parser/testdata/BaselinesCommon_BackupStatementTests/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/BaselinesCommon_BackupStatementTests/metadata.json +++ b/parser/testdata/BaselinesCommon_BackupStatementTests/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 21d6e2dc4ac50775f6045fdc7e0aeac2f161ceb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Jan 2026 00:20:31 +0000 Subject: [PATCH 20/22] Add ONLINE WAIT_AT_LOW_PRIORITY and XML_COMPRESSION support for CREATE INDEX - Add OnlineIndexLowPriorityLockWaitOption AST type with LowPriorityLockWaitOption field - Parse ONLINE = ON (WAIT_AT_LOW_PRIORITY (MAX_DURATION = N MINUTES, ABORT_AFTER_WAIT = SELF)) - Add XmlCompressionOption indexOption() method for index options - Parse XML_COMPRESSION = ON/OFF [ON PARTITIONS(range)] in CREATE INDEX - Add JSON marshaling for OnlineIndexOption with LowPriorityLockWaitOption - Add JSON marshaling for XmlCompressionOption in index context Enables Baselines160_CreateIndexStatementTests160 and CreateIndexStatementTests160. --- ast/drop_statements.go | 12 +- ast/xml_compression_option.go | 1 + parser/marshal.go | 40 ++++++- parser/parse_ddl.go | 69 ++++++++++- parser/parse_statements.go | 111 +++++++++++++++++- .../metadata.json | 2 +- .../metadata.json | 2 +- 7 files changed, 227 insertions(+), 10 deletions(-) diff --git a/ast/drop_statements.go b/ast/drop_statements.go index e8da6ad6..8c48c864 100644 --- a/ast/drop_statements.go +++ b/ast/drop_statements.go @@ -81,14 +81,22 @@ type DropIndexOption interface { // OnlineIndexOption represents the ONLINE option type OnlineIndexOption struct { - OptionState string // On, Off - OptionKind string // Online + LowPriorityLockWaitOption *OnlineIndexLowPriorityLockWaitOption // For ONLINE = ON (WAIT_AT_LOW_PRIORITY (...)) + OptionState string // On, Off + OptionKind string // Online } func (o *OnlineIndexOption) node() {} func (o *OnlineIndexOption) dropIndexOption() {} func (o *OnlineIndexOption) indexOption() {} +// OnlineIndexLowPriorityLockWaitOption represents WAIT_AT_LOW_PRIORITY options for ONLINE = ON +type OnlineIndexLowPriorityLockWaitOption struct { + Options []LowPriorityLockWaitOption +} + +func (o *OnlineIndexLowPriorityLockWaitOption) node() {} + // MoveToDropIndexOption represents the MOVE TO option type MoveToDropIndexOption struct { MoveTo *FileGroupOrPartitionScheme diff --git a/ast/xml_compression_option.go b/ast/xml_compression_option.go index c6d0789f..391ba8eb 100644 --- a/ast/xml_compression_option.go +++ b/ast/xml_compression_option.go @@ -9,6 +9,7 @@ type XmlCompressionOption struct { func (x *XmlCompressionOption) node() {} func (x *XmlCompressionOption) tableOption() {} +func (x *XmlCompressionOption) indexOption() {} // TableXmlCompressionOption represents a table-level XML compression option type TableXmlCompressionOption struct { diff --git a/parser/marshal.go b/parser/marshal.go index 67c5db0e..efd55d38 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -853,6 +853,20 @@ func lowPriorityLockWaitOptionToJSON(o ast.LowPriorityLockWaitOption) jsonNode { } } +func onlineIndexLowPriorityLockWaitOptionToJSON(o *ast.OnlineIndexLowPriorityLockWaitOption) jsonNode { + node := jsonNode{ + "$type": "OnlineIndexLowPriorityLockWaitOption", + } + if len(o.Options) > 0 { + options := make([]jsonNode, len(o.Options)) + for i, opt := range o.Options { + options[i] = lowPriorityLockWaitOptionToJSON(opt) + } + node["Options"] = options + } + return node +} + func fileGroupOrPartitionSchemeToJSON(fg *ast.FileGroupOrPartitionScheme) jsonNode { node := jsonNode{ "$type": "FileGroupOrPartitionScheme", @@ -13699,11 +13713,15 @@ func indexOptionToJSON(opt ast.IndexOption) jsonNode { "OptionKind": o.OptionKind, } case *ast.OnlineIndexOption: - return jsonNode{ + node := jsonNode{ "$type": "OnlineIndexOption", "OptionState": o.OptionState, "OptionKind": o.OptionKind, } + if o.LowPriorityLockWaitOption != nil { + node["LowPriorityLockWaitOption"] = onlineIndexLowPriorityLockWaitOptionToJSON(o.LowPriorityLockWaitOption) + } + return node case *ast.CompressionDelayIndexOption: node := jsonNode{ "$type": "CompressionDelayIndexOption", @@ -13726,6 +13744,20 @@ func indexOptionToJSON(opt ast.IndexOption) jsonNode { node["Unit"] = o.Unit } return node + case *ast.XmlCompressionOption: + node := jsonNode{ + "$type": "XmlCompressionOption", + "IsCompressed": o.IsCompressed, + "OptionKind": o.OptionKind, + } + if len(o.PartitionRanges) > 0 { + ranges := make([]jsonNode, len(o.PartitionRanges)) + for i, r := range o.PartitionRanges { + ranges[i] = compressionPartitionRangeToJSON(r) + } + node["PartitionRanges"] = ranges + } + return node default: return jsonNode{"$type": "UnknownIndexOption"} } @@ -13939,11 +13971,15 @@ func childObjectNameToJSON(s *ast.SchemaObjectName) jsonNode { func dropIndexOptionToJSON(opt ast.DropIndexOption) jsonNode { switch o := opt.(type) { case *ast.OnlineIndexOption: - return jsonNode{ + node := jsonNode{ "$type": "OnlineIndexOption", "OptionState": o.OptionState, "OptionKind": o.OptionKind, } + if o.LowPriorityLockWaitOption != nil { + node["LowPriorityLockWaitOption"] = onlineIndexLowPriorityLockWaitOptionToJSON(o.LowPriorityLockWaitOption) + } + return node case *ast.MoveToDropIndexOption: node := jsonNode{ "$type": "MoveToDropIndexOption", diff --git a/parser/parse_ddl.go b/parser/parse_ddl.go index 8500a88a..0e6b6084 100644 --- a/parser/parse_ddl.go +++ b/parser/parse_ddl.go @@ -1434,10 +1434,75 @@ func (p *Parser) parseDropIndexOptions() []ast.DropIndexOption { optState = "On" } p.nextToken() - options = append(options, &ast.OnlineIndexOption{ + onlineOpt := &ast.OnlineIndexOption{ OptionState: optState, OptionKind: "Online", - }) + } + // Check for optional (WAIT_AT_LOW_PRIORITY (...)) + if optState == "On" && p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + if strings.ToUpper(p.curTok.Literal) == "WAIT_AT_LOW_PRIORITY" { + p.nextToken() // consume WAIT_AT_LOW_PRIORITY + lowPriorityOpt := &ast.OnlineIndexLowPriorityLockWaitOption{} + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + optName := strings.ToUpper(p.curTok.Literal) + if optName == "MAX_DURATION" { + p.nextToken() // consume MAX_DURATION + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + durVal, _ := p.parsePrimaryExpression() + unit := "Minutes" + if strings.ToUpper(p.curTok.Literal) == "MINUTES" { + p.nextToken() + } else if strings.ToUpper(p.curTok.Literal) == "SECONDS" { + unit = "Seconds" + p.nextToken() + } + lowPriorityOpt.Options = append(lowPriorityOpt.Options, &ast.LowPriorityLockWaitMaxDurationOption{ + MaxDuration: durVal, + Unit: unit, + OptionKind: "MaxDuration", + }) + } else if optName == "ABORT_AFTER_WAIT" { + p.nextToken() // consume ABORT_AFTER_WAIT + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + abortType := "None" + switch strings.ToUpper(p.curTok.Literal) { + case "NONE": + abortType = "None" + case "SELF": + abortType = "Self" + case "BLOCKERS": + abortType = "Blockers" + } + p.nextToken() + lowPriorityOpt.Options = append(lowPriorityOpt.Options, &ast.LowPriorityLockWaitAbortAfterWaitOption{ + AbortAfterWait: abortType, + OptionKind: "AbortAfterWait", + }) + } else { + break + } + if p.curTok.Type == TokenComma { + p.nextToken() + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + onlineOpt.LowPriorityLockWaitOption = lowPriorityOpt + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + options = append(options, onlineOpt) case "MOVE": p.nextToken() // consume MOVE if strings.ToUpper(p.curTok.Literal) == "TO" { diff --git a/parser/parse_statements.go b/parser/parse_statements.go index 89e79acc..8ebf5aa8 100644 --- a/parser/parse_statements.go +++ b/parser/parse_statements.go @@ -10747,10 +10747,75 @@ func (p *Parser) parseCreateIndexOptions() []ast.IndexOption { OptionState: p.capitalizeFirst(strings.ToLower(valueStr)), }) case "ONLINE": - options = append(options, &ast.OnlineIndexOption{ + onlineOpt := &ast.OnlineIndexOption{ OptionKind: "Online", OptionState: p.capitalizeFirst(strings.ToLower(valueStr)), - }) + } + // Check for optional (WAIT_AT_LOW_PRIORITY (...)) + if valueStr == "ON" && p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + if strings.ToUpper(p.curTok.Literal) == "WAIT_AT_LOW_PRIORITY" { + p.nextToken() // consume WAIT_AT_LOW_PRIORITY + lowPriorityOpt := &ast.OnlineIndexLowPriorityLockWaitOption{} + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + optName := strings.ToUpper(p.curTok.Literal) + if optName == "MAX_DURATION" { + p.nextToken() // consume MAX_DURATION + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + durVal, _ := p.parsePrimaryExpression() + unit := "Minutes" + if strings.ToUpper(p.curTok.Literal) == "MINUTES" { + p.nextToken() + } else if strings.ToUpper(p.curTok.Literal) == "SECONDS" { + unit = "Seconds" + p.nextToken() + } + lowPriorityOpt.Options = append(lowPriorityOpt.Options, &ast.LowPriorityLockWaitMaxDurationOption{ + MaxDuration: durVal, + Unit: unit, + OptionKind: "MaxDuration", + }) + } else if optName == "ABORT_AFTER_WAIT" { + p.nextToken() // consume ABORT_AFTER_WAIT + if p.curTok.Type == TokenEquals { + p.nextToken() // consume = + } + abortType := "None" + switch strings.ToUpper(p.curTok.Literal) { + case "NONE": + abortType = "None" + case "SELF": + abortType = "Self" + case "BLOCKERS": + abortType = "Blockers" + } + p.nextToken() + lowPriorityOpt.Options = append(lowPriorityOpt.Options, &ast.LowPriorityLockWaitAbortAfterWaitOption{ + AbortAfterWait: abortType, + OptionKind: "AbortAfterWait", + }) + } else { + break + } + if p.curTok.Type == TokenComma { + p.nextToken() + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + onlineOpt.LowPriorityLockWaitOption = lowPriorityOpt + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + options = append(options, onlineOpt) case "ALLOW_ROW_LOCKS": options = append(options, &ast.IndexStateOption{ OptionKind: "AllowRowLocks", @@ -10829,6 +10894,48 @@ func (p *Parser) parseCreateIndexOptions() []ast.IndexOption { } } options = append(options, opt) + case "XML_COMPRESSION": + // Parse XML_COMPRESSION = ON/OFF [ON PARTITIONS(range)] + isCompressed := "On" + if valueStr == "OFF" { + isCompressed = "Off" + } + opt := &ast.XmlCompressionOption{ + IsCompressed: isCompressed, + OptionKind: "XmlCompression", + } + // Check for optional ON PARTITIONS(range) + if p.curTok.Type == TokenOn { + p.nextToken() // consume ON + if strings.ToUpper(p.curTok.Literal) == "PARTITIONS" { + p.nextToken() // consume PARTITIONS + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + partRange := &ast.CompressionPartitionRange{} + // Parse From value + partRange.From = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + // Check for TO keyword indicating a range + if strings.ToUpper(p.curTok.Literal) == "TO" { + p.nextToken() // consume TO + partRange.To = &ast.IntegerLiteral{LiteralType: "Integer", Value: p.curTok.Literal} + p.nextToken() + } + opt.PartitionRanges = append(opt.PartitionRanges, partRange) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + } + } + options = append(options, opt) default: // Generic handling for other options if valueStr == "ON" || valueStr == "OFF" { diff --git a/parser/testdata/Baselines160_CreateIndexStatementTests160/metadata.json b/parser/testdata/Baselines160_CreateIndexStatementTests160/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines160_CreateIndexStatementTests160/metadata.json +++ b/parser/testdata/Baselines160_CreateIndexStatementTests160/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} diff --git a/parser/testdata/CreateIndexStatementTests160/metadata.json b/parser/testdata/CreateIndexStatementTests160/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/CreateIndexStatementTests160/metadata.json +++ b/parser/testdata/CreateIndexStatementTests160/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{} From 14332f35f5e976d2b7e73e77b8162276c55e9eb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Jan 2026 00:43:14 +0000 Subject: [PATCH 21/22] Add DataModificationTableReference and PIVOT/UNPIVOT support - Add DataModificationTableReference AST for DML statements as table sources - Add DataModificationSpecification interface implemented by Insert/Update/Delete/MergeSpecification - Add parsing for INSERT/UPDATE/DELETE/MERGE in FROM clause subqueries - Add parseInsertSpecification, parseUpdateSpecification, parseDeleteSpecification, parseMergeSpecification - Add PivotedTableReference and UnpivotedTableReference AST types - Add PIVOT and UNPIVOT parsing in parseTableReference - Fix PIVOT/UNPIVOT being consumed as table aliases - Add JSON marshaling for new table reference types This is partial work toward Baselines100_FromClauseTests100 which still requires FORCESEEK hints with index/columns and TABLE HINT optimizer hints. --- ast/data_modification_table_reference.go | 19 ++ ast/delete_statement.go | 3 + ast/insert_statement.go | 3 + ast/merge_statement.go | 3 +- ast/pivoted_table_reference.go | 29 +++ ast/update_statement.go | 3 + parser/marshal.go | 171 ++++++++++++++ parser/parse_dml.go | 201 ++++++++++++++++ parser/parse_select.go | 286 ++++++++++++++++++++++- 9 files changed, 714 insertions(+), 4 deletions(-) create mode 100644 ast/data_modification_table_reference.go create mode 100644 ast/pivoted_table_reference.go diff --git a/ast/data_modification_table_reference.go b/ast/data_modification_table_reference.go new file mode 100644 index 00000000..f87bbadb --- /dev/null +++ b/ast/data_modification_table_reference.go @@ -0,0 +1,19 @@ +package ast + +// DataModificationTableReference represents a DML statement used as a table source in FROM clause +// This allows using INSERT/UPDATE/DELETE/MERGE with OUTPUT clause as table sources +type DataModificationTableReference struct { + DataModificationSpecification DataModificationSpecification + Alias *Identifier + Columns []*Identifier + ForPath bool +} + +func (d *DataModificationTableReference) node() {} +func (d *DataModificationTableReference) tableReference() {} + +// DataModificationSpecification is the interface for DML specifications +type DataModificationSpecification interface { + Node + dataModificationSpecification() +} diff --git a/ast/delete_statement.go b/ast/delete_statement.go index d1a2b665..be6f172d 100644 --- a/ast/delete_statement.go +++ b/ast/delete_statement.go @@ -19,3 +19,6 @@ type DeleteSpecification struct { OutputClause *OutputClause `json:"OutputClause,omitempty"` OutputIntoClause *OutputIntoClause `json:"OutputIntoClause,omitempty"` } + +func (d *DeleteSpecification) node() {} +func (d *DeleteSpecification) dataModificationSpecification() {} diff --git a/ast/insert_statement.go b/ast/insert_statement.go index 8c9c55c9..4a6c3215 100644 --- a/ast/insert_statement.go +++ b/ast/insert_statement.go @@ -21,6 +21,9 @@ type InsertSpecification struct { OutputIntoClause *OutputIntoClause `json:"OutputIntoClause,omitempty"` } +func (i *InsertSpecification) node() {} +func (i *InsertSpecification) dataModificationSpecification() {} + // OutputClause represents an OUTPUT clause. type OutputClause struct { SelectColumns []SelectElement `json:"SelectColumns,omitempty"` diff --git a/ast/merge_statement.go b/ast/merge_statement.go index 4abb8c52..5873d991 100644 --- a/ast/merge_statement.go +++ b/ast/merge_statement.go @@ -18,7 +18,8 @@ type MergeSpecification struct { OutputClause *OutputClause } -func (s *MergeSpecification) node() {} +func (s *MergeSpecification) node() {} +func (s *MergeSpecification) dataModificationSpecification() {} // MergeActionClause represents a WHEN clause in a MERGE statement type MergeActionClause struct { diff --git a/ast/pivoted_table_reference.go b/ast/pivoted_table_reference.go new file mode 100644 index 00000000..03eb723e --- /dev/null +++ b/ast/pivoted_table_reference.go @@ -0,0 +1,29 @@ +package ast + +// PivotedTableReference represents a table with PIVOT +type PivotedTableReference struct { + TableReference TableReference + InColumns []*Identifier + PivotColumn *ColumnReferenceExpression + ValueColumns []*ColumnReferenceExpression + AggregateFunctionIdentifier *MultiPartIdentifier + Alias *Identifier + ForPath bool +} + +func (p *PivotedTableReference) node() {} +func (p *PivotedTableReference) tableReference() {} + +// UnpivotedTableReference represents a table with UNPIVOT +type UnpivotedTableReference struct { + TableReference TableReference + InColumns []*ColumnReferenceExpression + PivotColumn *Identifier + PivotValue *Identifier + NullHandling string // "None", "ExcludeNulls", "IncludeNulls" + Alias *Identifier + ForPath bool +} + +func (u *UnpivotedTableReference) node() {} +func (u *UnpivotedTableReference) tableReference() {} diff --git a/ast/update_statement.go b/ast/update_statement.go index 0935bbc6..27649699 100644 --- a/ast/update_statement.go +++ b/ast/update_statement.go @@ -21,6 +21,9 @@ type UpdateSpecification struct { OutputIntoClause *OutputIntoClause `json:"OutputIntoClause,omitempty"` } +func (u *UpdateSpecification) node() {} +func (u *UpdateSpecification) dataModificationSpecification() {} + // SetClause is an interface for SET clauses. type SetClause interface { setClause() diff --git a/parser/marshal.go b/parser/marshal.go index efd55d38..7e2d8a1b 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -2481,6 +2481,25 @@ func tableReferenceToJSON(ref ast.TableReference) jsonNode { } node["ForPath"] = r.ForPath return node + case *ast.DataModificationTableReference: + node := jsonNode{ + "$type": "DataModificationTableReference", + } + if r.DataModificationSpecification != nil { + node["DataModificationSpecification"] = dataModificationSpecificationToJSON(r.DataModificationSpecification) + } + if len(r.Columns) > 0 { + cols := make([]jsonNode, len(r.Columns)) + for i, c := range r.Columns { + cols[i] = identifierToJSON(c) + } + node["Columns"] = cols + } + if r.Alias != nil { + node["Alias"] = identifierToJSON(r.Alias) + } + node["ForPath"] = r.ForPath + return node case *ast.ChangeTableChangesTableReference: node := jsonNode{ "$type": "ChangeTableChangesTableReference", @@ -2672,6 +2691,66 @@ func tableReferenceToJSON(ref ast.TableReference) jsonNode { node["Join"] = tableReferenceToJSON(r.Join) } return node + case *ast.PivotedTableReference: + node := jsonNode{ + "$type": "PivotedTableReference", + } + if r.TableReference != nil { + node["TableReference"] = tableReferenceToJSON(r.TableReference) + } + if len(r.InColumns) > 0 { + cols := make([]jsonNode, len(r.InColumns)) + for i, col := range r.InColumns { + cols[i] = identifierToJSON(col) + } + node["InColumns"] = cols + } + if r.PivotColumn != nil { + node["PivotColumn"] = columnReferenceExpressionToJSON(r.PivotColumn) + } + if len(r.ValueColumns) > 0 { + cols := make([]jsonNode, len(r.ValueColumns)) + for i, col := range r.ValueColumns { + cols[i] = columnReferenceExpressionToJSON(col) + } + node["ValueColumns"] = cols + } + if r.AggregateFunctionIdentifier != nil { + node["AggregateFunctionIdentifier"] = multiPartIdentifierToJSON(r.AggregateFunctionIdentifier) + } + if r.Alias != nil { + node["Alias"] = identifierToJSON(r.Alias) + } + node["ForPath"] = r.ForPath + return node + case *ast.UnpivotedTableReference: + node := jsonNode{ + "$type": "UnpivotedTableReference", + } + if r.TableReference != nil { + node["TableReference"] = tableReferenceToJSON(r.TableReference) + } + if len(r.InColumns) > 0 { + cols := make([]jsonNode, len(r.InColumns)) + for i, col := range r.InColumns { + cols[i] = columnReferenceExpressionToJSON(col) + } + node["InColumns"] = cols + } + if r.PivotColumn != nil { + node["PivotColumn"] = identifierToJSON(r.PivotColumn) + } + if r.PivotValue != nil { + node["PivotValue"] = identifierToJSON(r.PivotValue) + } + if r.NullHandling != "" && r.NullHandling != "None" { + node["NullHandling"] = r.NullHandling + } + if r.Alias != nil { + node["Alias"] = identifierToJSON(r.Alias) + } + node["ForPath"] = r.ForPath + return node case *ast.QueryDerivedTable: node := jsonNode{ "$type": "QueryDerivedTable", @@ -3309,6 +3388,21 @@ func insertStatementToJSON(s *ast.InsertStatement) jsonNode { return node } +func dataModificationSpecificationToJSON(spec ast.DataModificationSpecification) jsonNode { + switch s := spec.(type) { + case *ast.InsertSpecification: + return insertSpecificationToJSON(s) + case *ast.UpdateSpecification: + return updateSpecificationToJSON(s) + case *ast.DeleteSpecification: + return deleteSpecificationToJSON(s) + case *ast.MergeSpecification: + return mergeSpecificationToJSON(s) + default: + return jsonNode{"$type": "UnknownDataModificationSpecification"} + } +} + func insertSpecificationToJSON(spec *ast.InsertSpecification) jsonNode { node := jsonNode{ "$type": "InsertSpecification", @@ -5191,6 +5285,83 @@ func (p *Parser) parseMergeStatement() (*ast.MergeStatement, error) { return stmt, nil } +// parseMergeSpecification parses a MERGE specification (used in DataModificationTableReference) +func (p *Parser) parseMergeSpecification() (*ast.MergeSpecification, error) { + // Consume MERGE + p.nextToken() + + spec := &ast.MergeSpecification{} + + // Optional INTO keyword + if strings.ToUpper(p.curTok.Literal) == "INTO" { + p.nextToken() + } + + // Parse target table + target, err := p.parseSingleTableReference() + if err != nil { + return nil, err + } + // If target has an alias, move it to TableAlias (ScriptDOM convention) + if ntr, ok := target.(*ast.NamedTableReference); ok && ntr.Alias != nil { + spec.TableAlias = ntr.Alias + ntr.Alias = nil + } + spec.Target = target + + // Expect USING + if strings.ToUpper(p.curTok.Literal) == "USING" { + p.nextToken() + } + + // Parse source table reference (may be parenthesized join or subquery) + sourceRef, err := p.parseMergeSourceTableReference() + if err != nil { + return nil, err + } + spec.TableReference = sourceRef + + // Expect ON + if p.curTok.Type == TokenOn { + p.nextToken() + } + + // Parse ON condition - check for MATCH predicate + if strings.ToUpper(p.curTok.Literal) == "MATCH" { + matchPred, err := p.parseGraphMatchPredicate() + if err != nil { + return nil, err + } + spec.SearchCondition = matchPred + } else { + cond, err := p.parseBooleanExpression() + if err != nil { + return nil, err + } + spec.SearchCondition = cond + } + + // Parse WHEN clauses + for strings.ToUpper(p.curTok.Literal) == "WHEN" { + clause, err := p.parseMergeActionClause() + if err != nil { + return nil, err + } + spec.ActionClauses = append(spec.ActionClauses, clause) + } + + // Parse optional OUTPUT clause + if strings.ToUpper(p.curTok.Literal) == "OUTPUT" { + output, _, err := p.parseOutputClause() + if err != nil { + return nil, err + } + spec.OutputClause = output + } + + return spec, nil +} + // parseMergeSourceTableReference parses the source table reference in a MERGE statement func (p *Parser) parseMergeSourceTableReference() (ast.TableReference, error) { // Check for parenthesized expression diff --git a/parser/parse_dml.go b/parser/parse_dml.go index d00d1cde..2315a379 100644 --- a/parser/parse_dml.go +++ b/parser/parse_dml.go @@ -236,6 +236,73 @@ func (p *Parser) parseInsertStatement() (ast.Statement, error) { return stmt, nil } +// parseInsertSpecification parses an INSERT specification (used in DataModificationTableReference) +func (p *Parser) parseInsertSpecification() (*ast.InsertSpecification, error) { + // Consume INSERT + p.nextToken() + + spec := &ast.InsertSpecification{ + InsertOption: "None", + } + + // Check for TOP clause + if p.curTok.Type == TokenTop { + top, err := p.parseTopRowFilter() + if err != nil { + return nil, err + } + spec.TopRowFilter = top + } + + // Check for INTO or OVER + if p.curTok.Type == TokenInto { + spec.InsertOption = "Into" + p.nextToken() + } else if p.curTok.Type == TokenOver { + spec.InsertOption = "Over" + p.nextToken() + } + + // Parse target + target, err := p.parseInsertTarget() + if err != nil { + return nil, err + } + spec.Target = target + + // Parse optional column list + if p.curTok.Type == TokenLParen { + cols, err := p.parseColumnList() + if err != nil { + return nil, err + } + spec.Columns = cols + } + + // Parse OUTPUT clauses (can have OUTPUT INTO followed by OUTPUT) + for p.curTok.Type == TokenIdent && strings.ToUpper(p.curTok.Literal) == "OUTPUT" { + outputClause, outputIntoClause, err := p.parseOutputClause() + if err != nil { + return nil, err + } + if outputIntoClause != nil { + spec.OutputIntoClause = outputIntoClause + } + if outputClause != nil { + spec.OutputClause = outputClause + } + } + + // Parse insert source + source, err := p.parseInsertSource() + if err != nil { + return nil, err + } + spec.InsertSource = source + + return spec, nil +} + func (p *Parser) parseDMLTarget() (ast.TableReference, error) { // Check for variable if p.curTok.Type == TokenIdent && strings.HasPrefix(p.curTok.Literal, "@") { @@ -1570,6 +1637,77 @@ func (p *Parser) parseUpdateStatement() (*ast.UpdateStatement, error) { return stmt, nil } +// parseUpdateSpecification parses an UPDATE specification (used in DataModificationTableReference) +func (p *Parser) parseUpdateSpecification() (*ast.UpdateSpecification, error) { + // Consume UPDATE + p.nextToken() + + spec := &ast.UpdateSpecification{} + + // Check for TOP clause + if p.curTok.Type == TokenTop { + top, err := p.parseTopRowFilter() + if err != nil { + return nil, err + } + spec.TopRowFilter = top + } + + // Parse target + target, err := p.parseDMLTarget() + if err != nil { + return nil, err + } + spec.Target = target + + // Expect SET + if p.curTok.Type != TokenSet { + return nil, fmt.Errorf("expected SET, got %s", p.curTok.Literal) + } + p.nextToken() + + // Parse SET clauses + setClauses, err := p.parseSetClauses() + if err != nil { + return nil, err + } + spec.SetClauses = setClauses + + // Parse OUTPUT clauses (can have OUTPUT INTO followed by OUTPUT) + for p.curTok.Type == TokenIdent && strings.ToUpper(p.curTok.Literal) == "OUTPUT" { + outputClause, outputIntoClause, err := p.parseOutputClause() + if err != nil { + return nil, err + } + if outputIntoClause != nil { + spec.OutputIntoClause = outputIntoClause + } + if outputClause != nil { + spec.OutputClause = outputClause + } + } + + // Parse optional FROM clause + if p.curTok.Type == TokenFrom { + fromClause, err := p.parseFromClause() + if err != nil { + return nil, err + } + spec.FromClause = fromClause + } + + // Parse optional WHERE clause + if p.curTok.Type == TokenWhere { + whereClause, err := p.parseWhereClause() + if err != nil { + return nil, err + } + spec.WhereClause = whereClause + } + + return spec, nil +} + func (p *Parser) parseSetClauses() ([]ast.SetClause, error) { var clauses []ast.SetClause @@ -1900,6 +2038,69 @@ func (p *Parser) parseDeleteStatement() (*ast.DeleteStatement, error) { return stmt, nil } +// parseDeleteSpecification parses a DELETE specification (used in DataModificationTableReference) +func (p *Parser) parseDeleteSpecification() (*ast.DeleteSpecification, error) { + // Consume DELETE + p.nextToken() + + spec := &ast.DeleteSpecification{} + + // Parse optional TOP clause + if p.curTok.Type == TokenTop { + topRowFilter, err := p.parseTopRowFilter() + if err != nil { + return nil, err + } + spec.TopRowFilter = topRowFilter + } + + // Skip optional FROM + if p.curTok.Type == TokenFrom { + p.nextToken() + } + + // Parse target + target, err := p.parseDMLTarget() + if err != nil { + return nil, err + } + spec.Target = target + + // Parse OUTPUT clauses (can have OUTPUT INTO followed by OUTPUT) + for p.curTok.Type == TokenIdent && strings.ToUpper(p.curTok.Literal) == "OUTPUT" { + outputClause, outputIntoClause, err := p.parseOutputClause() + if err != nil { + return nil, err + } + if outputIntoClause != nil { + spec.OutputIntoClause = outputIntoClause + } + if outputClause != nil { + spec.OutputClause = outputClause + } + } + + // Parse optional FROM clause + if p.curTok.Type == TokenFrom { + fromClause, err := p.parseFromClause() + if err != nil { + return nil, err + } + spec.FromClause = fromClause + } + + // Parse optional WHERE clause + if p.curTok.Type == TokenWhere { + whereClause, err := p.parseDeleteWhereClause() + if err != nil { + return nil, err + } + spec.WhereClause = whereClause + } + + return spec, nil +} + func (p *Parser) parseDeleteWhereClause() (*ast.WhereClause, error) { // Consume WHERE p.nextToken() diff --git a/parser/parse_select.go b/parser/parse_select.go index 306c09c5..5c9e885d 100644 --- a/parser/parse_select.go +++ b/parser/parse_select.go @@ -2164,6 +2164,21 @@ func (p *Parser) parseTableReference() (ast.TableReference, error) { } var left ast.TableReference = baseRef + // Check for PIVOT or UNPIVOT + if strings.ToUpper(p.curTok.Literal) == "PIVOT" { + pivoted, err := p.parsePivotedTableReference(left) + if err != nil { + return nil, err + } + left = pivoted + } else if strings.ToUpper(p.curTok.Literal) == "UNPIVOT" { + unpivoted, err := p.parseUnpivotedTableReference(left) + if err != nil { + return nil, err + } + left = unpivoted + } + // Check for JOINs for { // Check for CROSS JOIN or CROSS APPLY @@ -2401,6 +2416,7 @@ func (p *Parser) parseSingleTableReference() (ast.TableReference, error) { // parseDerivedTableReference parses a derived table (parenthesized query) like (SELECT ...) AS alias // or an inline derived table (VALUES clause) like (VALUES (...), (...)) AS alias(cols) +// or a data modification table reference (DML with OUTPUT) like (INSERT ... OUTPUT ...) AS alias func (p *Parser) parseDerivedTableReference() (ast.TableReference, error) { p.nextToken() // consume ( @@ -2409,6 +2425,20 @@ func (p *Parser) parseDerivedTableReference() (ast.TableReference, error) { return p.parseInlineDerivedTable() } + // Check for DML statements (INSERT, UPDATE, DELETE, MERGE) as table sources + if p.curTok.Type == TokenInsert { + return p.parseDataModificationTableReference("INSERT") + } + if p.curTok.Type == TokenUpdate { + return p.parseDataModificationTableReference("UPDATE") + } + if p.curTok.Type == TokenDelete { + return p.parseDataModificationTableReference("DELETE") + } + if strings.ToUpper(p.curTok.Literal) == "MERGE" { + return p.parseDataModificationTableReference("MERGE") + } + // Parse the query expression qe, err := p.parseQueryExpression() if err != nil { @@ -2434,7 +2464,7 @@ func (p *Parser) parseDerivedTableReference() (ast.TableReference, error) { // Could be an alias without AS, but need to be careful not to consume keywords if p.curTok.Type == TokenIdent { upper := strings.ToUpper(p.curTok.Literal) - if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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" && upper != "USING" && upper != "WHEN" && upper != "OUTPUT" { + if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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" && upper != "USING" && upper != "WHEN" && upper != "OUTPUT" && upper != "PIVOT" && upper != "UNPIVOT" { ref.Alias = p.parseIdentifier() } } else { @@ -2445,6 +2475,69 @@ func (p *Parser) parseDerivedTableReference() (ast.TableReference, error) { return ref, nil } +// parseDataModificationTableReference parses a DML statement used as a table source +// This is called after ( is consumed and the DML keyword is the current token +func (p *Parser) parseDataModificationTableReference(dmlType string) (*ast.DataModificationTableReference, error) { + ref := &ast.DataModificationTableReference{ + ForPath: false, + } + + var err error + switch dmlType { + case "INSERT": + spec, parseErr := p.parseInsertSpecification() + if parseErr != nil { + return nil, parseErr + } + ref.DataModificationSpecification = spec + case "UPDATE": + spec, parseErr := p.parseUpdateSpecification() + if parseErr != nil { + return nil, parseErr + } + ref.DataModificationSpecification = spec + case "DELETE": + spec, parseErr := p.parseDeleteSpecification() + if parseErr != nil { + return nil, parseErr + } + ref.DataModificationSpecification = spec + case "MERGE": + spec, parseErr := p.parseMergeSpecification() + if parseErr != nil { + return nil, parseErr + } + ref.DataModificationSpecification = spec + default: + return nil, fmt.Errorf("unknown DML type: %s", dmlType) + } + if err != nil { + return nil, err + } + + // Expect ) + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) after data modification statement, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Parse required alias (AS 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 != "WINDOW" && 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 +} + // parseInlineDerivedTable parses a VALUES clause used as a table source // Called after ( is consumed and VALUES is the current token func (p *Parser) parseInlineDerivedTable() (*ast.InlineDerivedTable, error) { @@ -2578,7 +2671,7 @@ func (p *Parser) parseNamedTableReference() (*ast.NamedTableReference, error) { } else if p.curTok.Type == TokenIdent { // Could be an alias without AS, but need to be careful not to consume keywords upper := strings.ToUpper(p.curTok.Literal) - if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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" && upper != "USING" && upper != "WHEN" && upper != "OUTPUT" { + if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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" && upper != "USING" && upper != "WHEN" && upper != "OUTPUT" && upper != "PIVOT" && upper != "UNPIVOT" { ref.Alias = &ast.Identifier{Value: p.curTok.Literal, QuoteType: "NotQuoted"} p.nextToken() } @@ -2662,7 +2755,7 @@ func (p *Parser) parseNamedTableReferenceWithName(son *ast.SchemaObjectName) (*a // Could be an alias without AS, but need to be careful not to consume keywords if p.curTok.Type == TokenIdent { upper := strings.ToUpper(p.curTok.Literal) - if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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" && upper != "USING" && upper != "WHEN" && upper != "OUTPUT" { + if upper != "WHERE" && upper != "GROUP" && upper != "HAVING" && upper != "WINDOW" && 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" && upper != "USING" && upper != "WHEN" && upper != "OUTPUT" && upper != "PIVOT" && upper != "UNPIVOT" { ref.Alias = p.parseIdentifier() } } else { @@ -6173,3 +6266,190 @@ func (p *Parser) parseWindowClause() (*ast.WindowClause, error) { return clause, nil } + +// parsePivotedTableReference parses PIVOT clause +// Syntax: table PIVOT (aggregate_func(columns) FOR pivot_column IN (value1, value2, ...)) AS alias +func (p *Parser) parsePivotedTableReference(tableRef ast.TableReference) (*ast.PivotedTableReference, error) { + p.nextToken() // consume PIVOT + + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after PIVOT, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + + pivoted := &ast.PivotedTableReference{ + TableReference: tableRef, + ForPath: false, + } + + // Parse aggregate function identifier (may be multi-part like dbo.z1.MyAggregate) + aggregateId := &ast.MultiPartIdentifier{} + for { + id := p.parseIdentifier() + aggregateId.Identifiers = append(aggregateId.Identifiers, id) + aggregateId.Count++ + if p.curTok.Type == TokenDot { + p.nextToken() // consume . + } else { + break + } + } + pivoted.AggregateFunctionIdentifier = aggregateId + + // Expect ( for aggregate function parameters + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( for aggregate function parameters, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + + // Parse value columns (parameters to aggregate function) + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + col, err := p.parseColumnReference() + if err != nil { + return nil, err + } + pivoted.ValueColumns = append(pivoted.ValueColumns, col) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) after aggregate function parameters, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Expect FOR keyword + if strings.ToUpper(p.curTok.Literal) != "FOR" { + return nil, fmt.Errorf("expected FOR in PIVOT clause, got %s", p.curTok.Literal) + } + p.nextToken() // consume FOR + + // Parse pivot column + col, err := p.parseColumnReference() + if err != nil { + return nil, err + } + pivoted.PivotColumn = col + + // Expect IN keyword + if p.curTok.Type != TokenIn { + return nil, fmt.Errorf("expected IN in PIVOT clause, got %s", p.curTok.Literal) + } + p.nextToken() // consume IN + + // Expect ( + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after IN, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + + // Parse IN columns (values) + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + id := p.parseIdentifier() + pivoted.InColumns = append(pivoted.InColumns, id) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) after IN values, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Expect ) to close PIVOT clause + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) to close PIVOT clause, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Parse required alias (AS alias) + if p.curTok.Type == TokenAs { + p.nextToken() + } + if p.curTok.Type == TokenIdent || p.curTok.Type == TokenLBracket { + pivoted.Alias = p.parseIdentifier() + } + + return pivoted, nil +} + +// parseUnpivotedTableReference parses UNPIVOT clause +func (p *Parser) parseUnpivotedTableReference(tableRef ast.TableReference) (*ast.UnpivotedTableReference, error) { + p.nextToken() // consume UNPIVOT + + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after UNPIVOT, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + + unpivoted := &ast.UnpivotedTableReference{ + TableReference: tableRef, + NullHandling: "None", + ForPath: false, + } + + // Parse pivot value column + unpivoted.PivotValue = p.parseIdentifier() + + // Expect FOR keyword + if strings.ToUpper(p.curTok.Literal) != "FOR" { + return nil, fmt.Errorf("expected FOR in UNPIVOT clause, got %s", p.curTok.Literal) + } + p.nextToken() // consume FOR + + // Parse pivot column + unpivoted.PivotColumn = p.parseIdentifier() + + // Expect IN keyword + if p.curTok.Type != TokenIn { + return nil, fmt.Errorf("expected IN in UNPIVOT clause, got %s", p.curTok.Literal) + } + p.nextToken() // consume IN + + // Expect ( + if p.curTok.Type != TokenLParen { + return nil, fmt.Errorf("expected ( after IN, got %s", p.curTok.Literal) + } + p.nextToken() // consume ( + + // Parse IN columns + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + col, err := p.parseColumnReference() + if err != nil { + return nil, err + } + unpivoted.InColumns = append(unpivoted.InColumns, col) + if p.curTok.Type == TokenComma { + p.nextToken() + } else { + break + } + } + + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) after IN columns, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Expect ) to close UNPIVOT clause + if p.curTok.Type != TokenRParen { + return nil, fmt.Errorf("expected ) to close UNPIVOT clause, got %s", p.curTok.Literal) + } + p.nextToken() // consume ) + + // Parse required alias (AS alias) + if p.curTok.Type == TokenAs { + p.nextToken() + } + if p.curTok.Type == TokenIdent || p.curTok.Type == TokenLBracket { + unpivoted.Alias = p.parseIdentifier() + } + + return unpivoted, nil +} From ce3ef59a19294da206b30fcc381aa101a6e4eb32 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Jan 2026 00:57:21 +0000 Subject: [PATCH 22/22] Add ForceSeekTableHint and FORCESCAN support for table hints - Add ForceSeekTableHint AST type for FORCESEEK with optional index and column parameters - Add FORCESEEK parsing with index name/number and column list support - Add FORCESCAN to table hint kind mapping - Add JSON marshaling for ForceSeekTableHint - Enable Baselines100_FromClauseTests100 test --- ast/table_hint.go | 9 +++ parser/marshal.go | 18 ++++++ parser/parse_select.go | 59 +++++++++++++++++++ .../metadata.json | 2 +- 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/ast/table_hint.go b/ast/table_hint.go index 181ec97f..fcf44ffa 100644 --- a/ast/table_hint.go +++ b/ast/table_hint.go @@ -27,3 +27,12 @@ type LiteralTableHint struct { } func (*LiteralTableHint) tableHint() {} + +// ForceSeekTableHint represents FORCESEEK table hint with optional index and column list. +type ForceSeekTableHint struct { + HintKind string `json:"HintKind,omitempty"` + IndexValue *IdentifierOrValueExpression `json:"IndexValue,omitempty"` + ColumnValues []*ColumnReferenceExpression `json:"ColumnValues,omitempty"` +} + +func (*ForceSeekTableHint) tableHint() {} diff --git a/parser/marshal.go b/parser/marshal.go index 7e2d8a1b..4726c3ea 100644 --- a/parser/marshal.go +++ b/parser/marshal.go @@ -3363,6 +3363,24 @@ func tableHintToJSON(h ast.TableHintType) jsonNode { node["HintKind"] = th.HintKind } return node + case *ast.ForceSeekTableHint: + node := jsonNode{ + "$type": "ForceSeekTableHint", + } + if th.IndexValue != nil { + node["IndexValue"] = identifierOrValueExpressionToJSON(th.IndexValue) + } + if len(th.ColumnValues) > 0 { + cols := make([]jsonNode, len(th.ColumnValues)) + for i, c := range th.ColumnValues { + cols[i] = columnReferenceExpressionToJSON(c) + } + node["ColumnValues"] = cols + } + 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 5c9e885d..1a764faf 100644 --- a/parser/parse_select.go +++ b/parser/parse_select.go @@ -3174,6 +3174,61 @@ func (p *Parser) parseTableHint() (ast.TableHintType, error) { return hint, nil } + // FORCESEEK hint with optional index and column list + if hintName == "FORCESEEK" { + hint := &ast.ForceSeekTableHint{ + HintKind: "ForceSeek", + } + // Check for optional parenthesis with index and columns + if p.curTok.Type != TokenLParen { + return hint, nil + } + p.nextToken() // consume ( + // Parse index value (identifier or number) + if p.curTok.Type == TokenNumber { + hint.IndexValue = &ast.IdentifierOrValueExpression{ + Value: p.curTok.Literal, + ValueExpression: &ast.IntegerLiteral{ + LiteralType: "Integer", + Value: p.curTok.Literal, + }, + } + p.nextToken() + } else if p.curTok.Type == TokenIdent { + hint.IndexValue = &ast.IdentifierOrValueExpression{ + Value: p.curTok.Literal, + Identifier: &ast.Identifier{ + Value: p.curTok.Literal, + QuoteType: "NotQuoted", + }, + } + p.nextToken() + } + // Parse optional column list + if p.curTok.Type == TokenLParen { + p.nextToken() // consume ( + for p.curTok.Type != TokenRParen && p.curTok.Type != TokenEOF { + col, _ := p.parseColumnReference() + if col != nil { + hint.ColumnValues = append(hint.ColumnValues, col) + } + if p.curTok.Type == TokenComma { + p.nextToken() + } else if p.curTok.Type != TokenRParen { + break + } + } + if p.curTok.Type == TokenRParen { + p.nextToken() // consume ) + } + } + // Consume outer ) + if p.curTok.Type == TokenRParen { + p.nextToken() + } + return hint, nil + } + // Map hint names to HintKind hintKind := getTableHintKind(hintName) if hintKind == "" { @@ -3218,6 +3273,10 @@ func getTableHintKind(name string) string { return "XLock" case "NOWAIT": return "NoWait" + case "FORCESEEK": + return "ForceSeek" + case "FORCESCAN": + return "ForceScan" default: return "" } diff --git a/parser/testdata/Baselines100_FromClauseTests100/metadata.json b/parser/testdata/Baselines100_FromClauseTests100/metadata.json index ccffb5b9..0967ef42 100644 --- a/parser/testdata/Baselines100_FromClauseTests100/metadata.json +++ b/parser/testdata/Baselines100_FromClauseTests100/metadata.json @@ -1 +1 @@ -{"todo": true} \ No newline at end of file +{}