diff --git a/oracle/ast/outfuncs.go b/oracle/ast/outfuncs.go index 111b806e..76bd977b 100644 --- a/oracle/ast/outfuncs.go +++ b/oracle/ast/outfuncs.go @@ -1793,9 +1793,16 @@ func writeInlineExternalTable(sb *strings.Builder, n *InlineExternalTable) { if n.Directory != "" { sb.WriteString(fmt.Sprintf(" :directory %q", n.Directory)) } + if n.AccessParams != "" { + sb.WriteString(fmt.Sprintf(" :accessParams %q", n.AccessParams)) + } if n.Location != "" { sb.WriteString(fmt.Sprintf(" :location %q", n.Location)) } + if n.RejectLimit != nil { + sb.WriteString(" :rejectLimit ") + writeNode(sb, n.RejectLimit) + } if n.Alias != nil { sb.WriteString(" :alias ") writeNode(sb, n.Alias) diff --git a/oracle/parser/inline_external_table_test.go b/oracle/parser/inline_external_table_test.go new file mode 100644 index 00000000..e042a109 --- /dev/null +++ b/oracle/parser/inline_external_table_test.go @@ -0,0 +1,60 @@ +package parser + +import ( + "testing" + + "github.com/bytebase/omni/oracle/ast" +) + +func TestParseInlineExternalTable(t *testing.T) { + result := ParseAndCheck(t, ` +SELECT ext.id, ext.name +FROM EXTERNAL ( + (id NUMBER, name VARCHAR2(100)) + TYPE ORACLE_LOADER + DEFAULT DIRECTORY data_dir + ACCESS PARAMETERS (FIELDS TERMINATED BY ',') + LOCATION ('file.csv') + REJECT LIMIT UNLIMITED +) ext`) + + raw := result.Items[0].(*ast.RawStmt) + stmt := raw.Stmt.(*ast.SelectStmt) + if stmt.FromClause == nil || stmt.FromClause.Len() != 1 { + t.Fatalf("FromClause len = %d, want 1", stmt.FromClause.Len()) + } + + ext, ok := stmt.FromClause.Items[0].(*ast.InlineExternalTable) + if !ok { + t.Fatalf("FROM item = %T, want *ast.InlineExternalTable", stmt.FromClause.Items[0]) + } + if ext.Columns == nil || ext.Columns.Len() != 2 { + t.Fatalf("Columns len = %d, want 2", ext.Columns.Len()) + } + col0 := ext.Columns.Items[0].(*ast.ColumnDef) + if col0.Name != "ID" || col0.TypeName == nil { + t.Fatalf("first column = %#v, want ID NUMBER", col0) + } + col1 := ext.Columns.Items[1].(*ast.ColumnDef) + if col1.Name != "NAME" || col1.TypeName == nil { + t.Fatalf("second column = %#v, want NAME VARCHAR2(100)", col1) + } + if ext.Type != "ORACLE_LOADER" { + t.Fatalf("Type = %q, want ORACLE_LOADER", ext.Type) + } + if ext.Directory != "DATA_DIR" { + t.Fatalf("Directory = %q, want DATA_DIR", ext.Directory) + } + if ext.AccessParams == "" { + t.Fatal("AccessParams is empty, want captured ACCESS PARAMETERS text") + } + if ext.Location != "file.csv" { + t.Fatalf("Location = %q, want file.csv", ext.Location) + } + if _, ok := ext.RejectLimit.(*ast.ColumnRef); !ok { + t.Fatalf("RejectLimit = %T, want *ast.ColumnRef for UNLIMITED", ext.RejectLimit) + } + if ext.Alias == nil || ext.Alias.Name != "EXT" { + t.Fatalf("Alias = %#v, want EXT", ext.Alias) + } +} diff --git a/oracle/parser/select.go b/oracle/parser/select.go index 0d27631f..13522d4a 100644 --- a/oracle/parser/select.go +++ b/oracle/parser/select.go @@ -645,6 +645,11 @@ func (p *Parser) parseTableRef() (nodes.TableExpr, error) { return p.parseTableCollectionExpr(start) } + // EXTERNAL ((columns) TYPE ... DEFAULT DIRECTORY ... LOCATION ...) + if p.isIdentLikeStr("EXTERNAL") && p.peekNext().Type == '(' { + return p.parseInlineExternalTable(start) + } + // CONTAINERS(table) or SHARDS(table) if p.isIdentLikeStr("CONTAINERS") && p.peekNext().Type == '(' { return p.parseContainersOrShards(start, false) @@ -817,6 +822,218 @@ func (p *Parser) parseSubqueryRef(start int) (nodes.TableExpr, error) { return ref, nil } +// parseInlineExternalTable parses Oracle's inline external table source. +// +// inline_external_table: +// EXTERNAL ( ( column_definition [, column_definition ]... ) +// TYPE external_table_type +// DEFAULT DIRECTORY directory +// [ ACCESS PARAMETERS { ( opaque_format_spec ) | USING CLOB subquery } ] +// LOCATION ( 'file_uri_list' ) +// [ REJECT LIMIT { integer | UNLIMITED } ] ) [ alias ] +func (p *Parser) parseInlineExternalTable(start int) (nodes.TableExpr, error) { + p.advance() // consume EXTERNAL + if p.cur.Type != '(' { + return nil, p.syntaxErrorAtCur() + } + p.advance() // consume outer '(' + + if p.cur.Type != '(' { + return nil, p.syntaxErrorAtCur() + } + p.advance() // consume column-definition '(' + + ref := &nodes.InlineExternalTable{ + Columns: &nodes.List{}, + Loc: nodes.Loc{Start: start}, + } + for { + col, err := p.parseColumnDef() + if err != nil { + return nil, err + } + if col == nil { + return nil, p.syntaxErrorAtCur() + } + ref.Columns.Items = append(ref.Columns.Items, col) + if p.cur.Type != ',' { + break + } + p.advance() + } + if p.cur.Type != ')' { + return nil, p.syntaxErrorAtCur() + } + p.advance() // consume column-definition ')' + + if p.cur.Type != kwTYPE { + return nil, p.syntaxErrorAtCur() + } + p.advance() + var err error + ref.Type, err = p.parseIdentifier() + if err != nil { + return nil, err + } + if ref.Type == "" { + return nil, p.syntaxErrorAtCur() + } + + if p.cur.Type != kwDEFAULT { + return nil, p.syntaxErrorAtCur() + } + p.advance() + if p.cur.Type != kwDIRECTORY { + return nil, p.syntaxErrorAtCur() + } + p.advance() + ref.Directory, err = p.parseIdentifier() + if err != nil { + return nil, err + } + if ref.Directory == "" { + return nil, p.syntaxErrorAtCur() + } + + if p.cur.Type == kwACCESS { + p.advance() + if !p.isIdentLikeStr("PARAMETERS") { + return nil, p.syntaxErrorAtCur() + } + p.advance() + ref.AccessParams, err = p.parseInlineExternalAccessParams() + if err != nil { + return nil, err + } + } + + if !p.isIdentLikeStr("LOCATION") { + return nil, p.syntaxErrorAtCur() + } + p.advance() + ref.Location, err = p.parseInlineExternalLocation() + if err != nil { + return nil, err + } + + if p.cur.Type == kwREJECT { + p.advance() + if p.cur.Type != kwLIMIT { + return nil, p.syntaxErrorAtCur() + } + p.advance() + ref.RejectLimit, err = p.parseExpr() + if err != nil { + return nil, err + } + if ref.RejectLimit == nil { + return nil, p.syntaxErrorAtCur() + } + } + + if p.cur.Type != ')' { + return nil, p.syntaxErrorAtCur() + } + p.advance() // consume outer ')' + + if p.cur.Type == kwAS { + p.advance() + ref.Alias, err = p.parseAlias() + if err != nil { + return nil, err + } + } else if p.isTableAliasCandidate() { + ref.Alias, err = p.parseAlias() + if err != nil { + return nil, err + } + } + + ref.Loc.End = p.prev.End + return ref, nil +} + +func (p *Parser) parseInlineExternalAccessParams() (string, error) { + if p.cur.Type == '(' { + return p.collectBalancedRaw() + } + start := p.pos() + depth := 0 + for p.cur.Type != tokEOF { + if depth == 0 && p.isIdentLikeStr("LOCATION") { + break + } + switch p.cur.Type { + case '(': + depth++ + case ')': + if depth == 0 { + return "", p.syntaxErrorAtCur() + } + depth-- + } + p.advance() + } + if p.cur.Type == tokEOF { + return "", p.syntaxErrorAtCur() + } + return strings.TrimSpace(p.source[start:p.cur.Loc]), nil +} + +func (p *Parser) parseInlineExternalLocation() (string, error) { + if p.cur.Type != '(' { + return "", p.syntaxErrorAtCur() + } + p.advance() + start := p.pos() + var files []string + depth := 0 + for p.cur.Type != tokEOF { + if depth == 0 && p.cur.Type == ')' { + raw := strings.TrimSpace(p.source[start:p.cur.Loc]) + p.advance() + if len(files) == 1 { + return files[0], nil + } + return raw, nil + } + if p.cur.Type == tokSCONST { + files = append(files, p.cur.Str) + } + switch p.cur.Type { + case '(': + depth++ + case ')': + depth-- + } + p.advance() + } + return "", p.syntaxErrorAtCur() +} + +func (p *Parser) collectBalancedRaw() (string, error) { + if p.cur.Type != '(' { + return "", p.syntaxErrorAtCur() + } + p.advance() + start := p.pos() + depth := 1 + for p.cur.Type != tokEOF { + if p.cur.Type == '(' { + depth++ + } else if p.cur.Type == ')' { + depth-- + if depth == 0 { + raw := strings.TrimSpace(p.source[start:p.cur.Loc]) + p.advance() + return raw, nil + } + } + p.advance() + } + return "", p.syntaxErrorAtCur() +} + // parseLateralRef parses a LATERAL inline view. // // LATERAL ( subquery ) [ alias ]