From e70a3ebd7654d882f0d2957f1d393ef5591407f4 Mon Sep 17 00:00:00 2001 From: rebelice Date: Thu, 14 May 2026 12:57:57 +0900 Subject: [PATCH] fix(oracle): parse extract and interval partition gaps --- oracle/ast/loc.go | 4 ++ oracle/ast/outfuncs.go | 22 +++++++ oracle/ast/parsenodes.go | 21 ++++++ oracle/parser/create_index_test.go | 14 ++++ oracle/parser/create_table.go | 25 +++++++ oracle/parser/create_table_test.go | 17 +++++ oracle/parser/expr.go | 65 +++++++++++++++++++ .../testdata/coverage/loc_node_coverage.tsv | 2 + 8 files changed, 170 insertions(+) diff --git a/oracle/ast/loc.go b/oracle/ast/loc.go index f73aa942..b8255be7 100644 --- a/oracle/ast/loc.go +++ b/oracle/ast/loc.go @@ -221,6 +221,8 @@ func NodeLoc(n Node) Loc { return v.Loc case *FuncCallExpr: return v.Loc + case *ExtractExpr: + return v.Loc case *CaseExpr: return v.Loc case *CaseWhen: @@ -241,6 +243,8 @@ func NodeLoc(n Node) Loc { return v.Loc case *IntervalExpr: return v.Loc + case *DateTimeLiteral: + return v.Loc case *ParenExpr: return v.Loc case *DecodeExpr: diff --git a/oracle/ast/outfuncs.go b/oracle/ast/outfuncs.go index e9de410c..0a2e2d50 100644 --- a/oracle/ast/outfuncs.go +++ b/oracle/ast/outfuncs.go @@ -94,6 +94,8 @@ func writeNode(sb *strings.Builder, node Node) { writeBoolExpr(sb, n) case *FuncCallExpr: writeFuncCallExpr(sb, n) + case *ExtractExpr: + writeExtractExpr(sb, n) case *CaseExpr: writeCaseExpr(sb, n) case *CaseWhen: @@ -112,6 +114,11 @@ func writeNode(sb *strings.Builder, node Node) { writeTreatExpr(sb, n) case *IntervalExpr: writeIntervalExpr(sb, n) + case *DateTimeLiteral: + sb.WriteString("{DATETIMELIT") + sb.WriteString(fmt.Sprintf(" :typeName %q :val %q", n.TypeName, n.Val)) + sb.WriteString(fmt.Sprintf(" :loc_start %d :loc_end %d", n.Loc.Start, n.Loc.End)) + sb.WriteString("}") case *BetweenExpr: writeBetweenExpr(sb, n) case *InExpr: @@ -727,6 +734,17 @@ func writeFuncCallExpr(sb *strings.Builder, n *FuncCallExpr) { sb.WriteString("}") } +func writeExtractExpr(sb *strings.Builder, n *ExtractExpr) { + sb.WriteString("{EXTRACT") + sb.WriteString(fmt.Sprintf(" :field %q", n.Field)) + if n.Expr != nil { + sb.WriteString(" :expr ") + writeNode(sb, n.Expr) + } + sb.WriteString(fmt.Sprintf(" :loc_start %d :loc_end %d", n.Loc.Start, n.Loc.End)) + sb.WriteString("}") +} + func writeCaseExpr(sb *strings.Builder, n *CaseExpr) { sb.WriteString("{CASE") if n.Arg != nil { @@ -2063,6 +2081,10 @@ func writePartitionClause(sb *strings.Builder, n *PartitionClause) { sb.WriteString(" :columns ") writeNode(sb, n.Columns) } + if n.Interval != nil { + sb.WriteString(" :interval ") + writeNode(sb, n.Interval) + } if n.Partitions != nil { sb.WriteString(" :partitions ") writeNode(sb, n.Partitions) diff --git a/oracle/ast/parsenodes.go b/oracle/ast/parsenodes.go index 9634151e..4f72b001 100644 --- a/oracle/ast/parsenodes.go +++ b/oracle/ast/parsenodes.go @@ -429,6 +429,16 @@ type FuncCallExpr struct { func (n *FuncCallExpr) nodeTag() {} func (n *FuncCallExpr) exprNode() {} +// ExtractExpr represents EXTRACT(datetime_field FROM expr). +type ExtractExpr struct { + Field string // YEAR, MONTH, DAY, HOUR, MINUTE, SECOND + Expr ExprNode // source datetime or interval expression + Loc Loc +} + +func (n *ExtractExpr) nodeTag() {} +func (n *ExtractExpr) exprNode() {} + // CaseExpr represents a CASE expression. type CaseExpr struct { Arg ExprNode // test expression for simple CASE (nil for searched CASE) @@ -606,6 +616,16 @@ type StringLiteral struct { func (n *StringLiteral) nodeTag() {} func (n *StringLiteral) exprNode() {} +// DateTimeLiteral represents ANSI datetime literals such as DATE '2020-01-01'. +type DateTimeLiteral struct { + TypeName string // DATE, TIMESTAMP + Val string // literal value without quotes + Loc Loc +} + +func (n *DateTimeLiteral) nodeTag() {} +func (n *DateTimeLiteral) exprNode() {} + // NumberLiteral represents a numeric literal. type NumberLiteral struct { Val string // raw numeric text @@ -1537,6 +1557,7 @@ func (n *StorageClause) nodeTag() {} type PartitionClause struct { Type PartitionType // RANGE/LIST/HASH Columns *List // partition columns + Interval ExprNode // INTERVAL (expr), for range interval partitioning Partitions *List // list of *PartitionDef Subpartition *PartitionClause // subpartition template Loc Loc // start location diff --git a/oracle/parser/create_index_test.go b/oracle/parser/create_index_test.go index f11b11c6..1d5eb56b 100644 --- a/oracle/parser/create_index_test.go +++ b/oracle/parser/create_index_test.go @@ -62,6 +62,20 @@ func TestP2CreateIndexMalformedStorageOption(t *testing.T) { ParseShouldFail(t, "CREATE INDEX ix_sales ON sales (sale_date) STORAGE") } +func TestCreateIndexFunctionBasedExtract(t *testing.T) { + tests := []string{ + "CREATE INDEX idx_sales_month_year ON sales_data(EXTRACT(YEAR FROM sale_date), EXTRACT(MONTH FROM sale_date))", + "CREATE INDEX i_extract_year ON t(EXTRACT(YEAR FROM d))", + "CREATE INDEX i_extract_month ON t(EXTRACT(MONTH FROM d))", + } + + for _, sql := range tests { + t.Run(sql, func(t *testing.T) { + ParseAndCheck(t, sql) + }) + } +} + func parseCreateIndexForP2(t *testing.T, sql string) *ast.CreateIndexStmt { t.Helper() result := ParseAndCheck(t, sql) diff --git a/oracle/parser/create_table.go b/oracle/parser/create_table.go index 24e8f149..1dab1e22 100644 --- a/oracle/parser/create_table.go +++ b/oracle/parser/create_table.go @@ -2047,6 +2047,27 @@ func (p *Parser) parsePartitionClause() (*nodes.PartitionClause, error) { } } + // Optional INTERVAL (expr) for range-interval partitioned tables. + if p.cur.Type == kwINTERVAL { + p.advance() + if p.cur.Type != '(' { + return nil, p.syntaxErrorAtCur() + } + p.advance() + interval, parseErr560 := p.parseExpr() + if parseErr560 != nil { + return nil, parseErr560 + } + if interval == nil { + return nil, p.syntaxErrorAtCur() + } + if p.cur.Type != ')' { + return nil, p.syntaxErrorAtCur() + } + p.advance() + clause.Interval = interval + } + // Optional SUBPARTITION BY if p.isIdentLike() && p.cur.Str == "SUBPARTITION" { p.advance() @@ -2083,6 +2104,10 @@ func (p *Parser) parsePartitionClause() (*nodes.PartitionClause, error) { } } + if clause.Interval != nil && (clause.Partitions == nil || clause.Partitions.Len() == 0) { + return nil, p.syntaxErrorAtCur() + } + clause.Loc.End = p.prev.End return clause, nil } diff --git a/oracle/parser/create_table_test.go b/oracle/parser/create_table_test.go index bb3f3a00..2238c6ee 100644 --- a/oracle/parser/create_table_test.go +++ b/oracle/parser/create_table_test.go @@ -62,6 +62,23 @@ func TestP2CreateTableMalformedComplexOption(t *testing.T) { ParseShouldFail(t, "CREATE TABLE docs (id NUMBER, doc CLOB) LOB STORE AS lob_seg") } +func TestCreateTableIntervalPartitioning(t *testing.T) { + ParseAndCheck(t, `CREATE TABLE t_interval_full (d DATE) +PARTITION BY RANGE (d) +INTERVAL (NUMTOYMINTERVAL(1,'MONTH')) +( + PARTITION p1 VALUES LESS THAN (TO_DATE('2012-01-01', 'YYYY-MM-DD')) +)`) +} + +func TestCreateTableIntervalPartitioningRequiresRangePartition(t *testing.T) { + ParseShouldFail(t, "CREATE TABLE t_interval_min (d DATE) PARTITION BY RANGE (d) INTERVAL (NUMTOYMINTERVAL(1,'MONTH'))") +} + +func TestCreateTableDateLiteralPartitionBound(t *testing.T) { + ParseAndCheck(t, "CREATE TABLE t_date_bound (d DATE) PARTITION BY RANGE (d) (PARTITION p1 VALUES LESS THAN (DATE '2020-01-01'))") +} + func parseCreateTableForP2(t *testing.T, sql string) *ast.CreateTableStmt { t.Helper() result := ParseAndCheck(t, sql) diff --git a/oracle/parser/expr.go b/oracle/parser/expr.go index dfbc0dd6..39cd01f5 100644 --- a/oracle/parser/expr.go +++ b/oracle/parser/expr.go @@ -357,6 +357,12 @@ func (p *Parser) parsePrimary() (nodes.ExprNode, error) { case kwCAST: return p.parseCastExpr() + case kwDATE, kwTIMESTAMP: + if p.peekNext().Type == tokSCONST { + return p.parseDateTimeLiteral() + } + return p.parseIdentExpr() + case kwDECODE: return p.parseDecodeExpr() @@ -413,6 +419,21 @@ func (p *Parser) parsePrimary() (nodes.ExprNode, error) { } } +// parseDateTimeLiteral parses ANSI datetime literals such as DATE '2020-01-01'. +func (p *Parser) parseDateTimeLiteral() (nodes.ExprNode, error) { + start := p.pos() + typeTok := p.advance() + if p.cur.Type != tokSCONST { + return nil, p.syntaxErrorAtCur() + } + valueTok := p.advance() + return &nodes.DateTimeLiteral{ + TypeName: typeTok.Str, + Val: valueTok.Str, + Loc: nodes.Loc{Start: start, End: p.prev.End}, + }, nil +} + // isIntervalField returns true if the current token is an interval date field keyword. func (p *Parser) isIntervalField() bool { if !p.isIdentLike() { @@ -509,6 +530,9 @@ func (p *Parser) parseIdentExpr() (nodes.ExprNode, error) { Loc: nodes.Loc{Start: start, End: p.prev.End}, }) } + if name1 == "EXTRACT" { + return p.parseExtractExpr(start) + } // But first check if this looks like a schema-qualified function: name.name( // No — just name( is the function call case return p.parseFuncCall(name1, "", start) @@ -598,6 +622,47 @@ func (p *Parser) parseIdentExpr() (nodes.ExprNode, error) { }, nil } +// parseExtractExpr parses Oracle EXTRACT(datetime_field FROM expr). +// +// Ref: https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/EXTRACT-datetime.html +// +// EXTRACT({ YEAR | MONTH | DAY | HOUR | MINUTE | SECOND } FROM expr) +func (p *Parser) parseExtractExpr(start int) (nodes.ExprNode, error) { + p.advance() // consume '(' + + field, parseErr713 := p.parseIdentifier() + if parseErr713 != nil { + return nil, parseErr713 + } + if field == "" { + return nil, p.syntaxErrorAtCur() + } + + if p.cur.Type != kwFROM { + return nil, p.syntaxErrorAtCur() + } + p.advance() + + expr, parseErr714 := p.parseExpr() + if parseErr714 != nil { + return nil, parseErr714 + } + if expr == nil { + return nil, p.syntaxErrorAtCur() + } + + if p.cur.Type != ')' { + return nil, p.syntaxErrorAtCur() + } + p.advance() + + return &nodes.ExtractExpr{ + Field: field, + Expr: expr, + Loc: nodes.Loc{Start: start, End: p.prev.End}, + }, nil +} + // parseSubscriptIfPresent checks if the current token is '[' and parses a // MODEL cell reference subscript: expr[dim1, dim2, ...]. // Returns the original expression if no '[' is found. diff --git a/oracle/parser/testdata/coverage/loc_node_coverage.tsv b/oracle/parser/testdata/coverage/loc_node_coverage.tsv index 7a2e6c6a..484e886c 100644 --- a/oracle/parser/testdata/coverage/loc_node_coverage.tsv +++ b/oracle/parser/testdata/coverage/loc_node_coverage.tsv @@ -105,6 +105,7 @@ node_type family status fixture notes debt_class approval next_action *nodes.BoolExpr expression covered SELECT 1 FROM dual WHERE NOT (a = 1 AND b = 2) boolean expression span none covered keep_regression_guard *nodes.ColumnRef expression covered SELECT a FROM t column reference span none covered keep_regression_guard *nodes.FuncCallExpr expression covered SELECT f(1) FROM dual function call span none covered keep_regression_guard +*nodes.ExtractExpr expression covered SELECT EXTRACT(YEAR FROM d) FROM t EXTRACT datetime expression span none covered keep_regression_guard *nodes.CaseExpr expression covered SELECT CASE WHEN a = 1 THEN 2 ELSE 3 END FROM dual case expression span none covered keep_regression_guard *nodes.CaseWhen ast covered SELECT CASE WHEN a = 1 THEN 2 ELSE 3 END FROM dual case expression span none covered keep_regression_guard *nodes.CastExpr expression covered SELECT CAST(1 AS NUMBER) FROM dual cast expression span none covered keep_regression_guard @@ -124,6 +125,7 @@ node_type family status fixture notes debt_class approval next_action *nodes.ContainersExpr expression deferred Loc-bearing AST node classified; fixture expansion deferred loc_fixture_deferred oracle-parser-strict-audit-2026-04-28 add_direct_fixture_or_reclassify_node_support *nodes.NumberLiteral expression covered SELECT 1 FROM dual number literal span none covered keep_regression_guard *nodes.StringLiteral expression covered SELECT 'x' FROM dual string literal span none covered keep_regression_guard +*nodes.DateTimeLiteral expression covered SELECT DATE '2020-01-01' FROM dual ANSI datetime literal span none covered keep_regression_guard *nodes.NullLiteral expression covered SELECT NULL FROM dual null literal span none covered keep_regression_guard *nodes.Star expression covered SELECT * FROM t star span none covered keep_regression_guard *nodes.BindVariable expression covered SELECT :id FROM dual bind variable span none covered keep_regression_guard