-
-
Notifications
You must be signed in to change notification settings - Fork 296
fix(editor): suggest columns for derived-table and CTE aliases in autocomplete #1712
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2cad9d0
56b2325
e99664f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,17 +56,25 @@ internal struct TableReference: Hashable, Sendable { | |
| let tableName: String | ||
| let alias: String? | ||
| let schema: String? | ||
| let derivedColumns: [String]? | ||
|
|
||
| init(tableName: String, alias: String?, schema: String? = nil) { | ||
| init(tableName: String, alias: String?, schema: String? = nil, derivedColumns: [String]? = nil) { | ||
| self.tableName = tableName | ||
| self.alias = alias | ||
| self.schema = schema | ||
| self.derivedColumns = derivedColumns | ||
| } | ||
|
|
||
| /// Returns the identifier that should be used to reference this table | ||
| var identifier: String { | ||
| alias ?? tableName | ||
| } | ||
|
|
||
| /// A derived table (subquery or CTE) carries its own column list parsed from | ||
| /// the subquery's SELECT list, rather than resolving columns from the schema. | ||
| var isDerived: Bool { | ||
| derivedColumns != nil | ||
| } | ||
| } | ||
|
|
||
| /// Result of context analysis | ||
|
|
@@ -251,6 +259,8 @@ final class SQLContextAnalyzer { | |
| "|WINDOW|FETCH|FOR)\\b|[;()]|$)" | ||
| ) | ||
|
|
||
| private static let derivedTableParser = DerivedTableParser() | ||
|
|
||
| // MARK: - UTF-16 Helpers | ||
|
|
||
| /// Check if a UTF-16 code unit is whitespace (space, tab, newline, CR) | ||
|
|
@@ -307,18 +317,16 @@ final class SQLContextAnalyzer { | |
|
|
||
| // Find all table references in the current statement | ||
| var tableReferences = extractTableReferences(from: currentStatement) | ||
| var seenReferences = Set<TableReference>(tableReferences) | ||
|
|
||
| // Extract CTEs from the current statement | ||
| let cteNames = extractCTENames(from: currentStatement) | ||
|
|
||
| // Add CTE names as table references | ||
| for cteName in cteNames { | ||
| let cteRef = TableReference(tableName: cteName, alias: nil) | ||
| if seenReferences.insert(cteRef).inserted { | ||
| tableReferences.append(cteRef) | ||
| } | ||
| } | ||
| // Resolve derived tables (FROM/JOIN subqueries and CTEs) to their | ||
| // SELECT-list columns so `alias.` completes the subquery's output. | ||
| let derivedTables = Self.derivedTableParser.parse(currentStatement) | ||
| mergeDerivedTables(derivedTables, cteNames: cteNames, into: &tableReferences) | ||
|
|
||
| var seenReferences = Set<TableReference>(tableReferences) | ||
|
|
||
| // Extract ALTER TABLE table name and add to references | ||
| if let alterTableName = extractAlterTableName(from: currentStatement) { | ||
|
|
@@ -781,6 +789,43 @@ final class SQLContextAnalyzer { | |
| return references | ||
| } | ||
|
|
||
| /// Attach derived-table columns to references and add any derived table or | ||
| /// CTE not already in scope. A derived alias wins over a plain reference of | ||
| /// the same identifier (e.g. a CTE used directly in a FROM clause). | ||
| private func mergeDerivedTables( | ||
| _ derivedTables: [DerivedTable], | ||
| cteNames: [String], | ||
| into references: inout [TableReference] | ||
| ) { | ||
| let derivedColumnsByAlias = Dictionary( | ||
| derivedTables.map { ($0.alias.lowercased(), $0.columns) }, | ||
| uniquingKeysWith: { first, _ in first } | ||
| ) | ||
|
|
||
| var consumed = Set<String>() | ||
| for index in references.indices { | ||
| let ref = references[index] | ||
| for key in [ref.tableName.lowercased(), ref.identifier.lowercased()] { | ||
| guard let columns = derivedColumnsByAlias[key] else { continue } | ||
| consumed.insert(key) | ||
| references[index] = TableReference( | ||
| tableName: ref.tableName, alias: ref.alias, schema: ref.schema, derivedColumns: columns | ||
|
Comment on lines
+808
to
+812
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This matches every parsed CTE/derived name against both the table operand and the effective alias, so a query with Useful? React with 👍 / 👎. |
||
| ) | ||
| break | ||
| } | ||
| } | ||
|
|
||
| var present = Set(references.map { $0.identifier.lowercased() }).union(consumed) | ||
| for derived in derivedTables where present.insert(derived.alias.lowercased()).inserted { | ||
| references.append( | ||
| TableReference(tableName: derived.alias, alias: derived.alias, derivedColumns: derived.columns) | ||
|
Comment on lines
+819
to
+821
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a statement defines a CTE but does not reference it in the current Useful? React with 👍 / 👎. |
||
| ) | ||
| } | ||
| for cteName in cteNames where present.insert(cteName.lowercased()).inserted { | ||
| references.append(TableReference(tableName: cteName, alias: nil)) | ||
| } | ||
| } | ||
|
|
||
| /// Parse each comma-separated entry in a FROM list into a table reference. | ||
| private func appendFromListReferences( | ||
| from query: String, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| // | ||
| // DerivedTableParserTests.swift | ||
| // TableProTests | ||
| // | ||
| // Tests for derived-table and CTE column extraction. | ||
| // | ||
|
|
||
| import Foundation | ||
| @testable import TablePro | ||
| import Testing | ||
|
|
||
| @Suite("Derived Table Parser") | ||
| struct DerivedTableParserTests { | ||
| let parser = DerivedTableParser() | ||
|
|
||
| private func table(_ tables: [DerivedTable], alias: String) -> DerivedTable? { | ||
| tables.first { $0.alias.caseInsensitiveCompare(alias) == .orderedSame } | ||
| } | ||
|
|
||
| @Test("Resolves the reported derived-table join") | ||
| func resolvesReportedQuery() { | ||
| let query = """ | ||
| SELECT YEAR, hs.country, happiness_score, ahs.avg_happiness_score | ||
| FROM happiness_scores hs | ||
| LEFT JOIN ( | ||
| SELECT country, AVG(happiness_score) AS avg_happiness_score | ||
| FROM happiness_scores | ||
| GROUP BY country | ||
| ) ahs | ||
| ON hs.country = ahs.country; | ||
| """ | ||
| let ahs = table(parser.parse(query), alias: "ahs") | ||
| #expect(ahs?.columns == ["country", "avg_happiness_score"]) | ||
| } | ||
|
|
||
| @Test("Derived table in FROM clause") | ||
| func derivedTableInFrom() { | ||
| let query = "SELECT * FROM (SELECT id, name FROM users) u" | ||
| #expect(table(parser.parse(query), alias: "u")?.columns == ["id", "name"]) | ||
| } | ||
|
|
||
| @Test("Derived table with AS keyword before alias") | ||
| func derivedTableWithAsAlias() { | ||
| let query = "SELECT * FROM (SELECT id FROM users) AS u" | ||
| #expect(table(parser.parse(query), alias: "u")?.columns == ["id"]) | ||
| } | ||
|
|
||
| @Test("Derived table in a comma-separated FROM list") | ||
| func derivedTableInFromList() { | ||
| let query = "SELECT * FROM orders o, (SELECT total FROM payments) p" | ||
| #expect(table(parser.parse(query), alias: "p")?.columns == ["total"]) | ||
| } | ||
|
|
||
| @Test("Qualified column keeps only the trailing identifier") | ||
| func qualifiedColumn() { | ||
| let query = "SELECT * FROM (SELECT u.id, u.email FROM users u) d" | ||
| #expect(table(parser.parse(query), alias: "d")?.columns == ["id", "email"]) | ||
| } | ||
|
|
||
| @Test("SELECT star yields no resolvable columns") | ||
| func selectStarYieldsNoColumns() { | ||
| let query = "SELECT * FROM (SELECT * FROM users) d" | ||
| #expect(table(parser.parse(query), alias: "d")?.columns.isEmpty == true) | ||
| } | ||
|
|
||
| @Test("Qualified star is skipped") | ||
| func qualifiedStarSkipped() { | ||
| let query = "SELECT * FROM (SELECT u.*, u.id FROM users u) d" | ||
| #expect(table(parser.parse(query), alias: "d")?.columns == ["id"]) | ||
| } | ||
|
|
||
| @Test("Unaliased expressions are skipped") | ||
| func unaliasedExpressionSkipped() { | ||
| let query = "SELECT * FROM (SELECT name, COUNT(*) FROM users GROUP BY name) d" | ||
| #expect(table(parser.parse(query), alias: "d")?.columns == ["name"]) | ||
| } | ||
|
|
||
| @Test("Nested subquery exposes only the outer alias") | ||
| func nestedSubquery() { | ||
| let query = "SELECT * FROM (SELECT a FROM (SELECT id AS a FROM t) inner_q) outer_q" | ||
| let tables = parser.parse(query) | ||
| #expect(table(tables, alias: "outer_q")?.columns == ["a"]) | ||
| #expect(table(tables, alias: "inner_q") == nil) | ||
| } | ||
|
|
||
| @Test("CTE columns come from its SELECT list") | ||
| func cteColumns() { | ||
| let query = "WITH totals AS (SELECT country, SUM(score) AS total FROM s GROUP BY country) SELECT * FROM totals" | ||
| #expect(table(parser.parse(query), alias: "totals")?.columns == ["country", "total"]) | ||
| } | ||
|
|
||
| @Test("CTE explicit column list overrides the SELECT list") | ||
| func cteExplicitColumnList() { | ||
| let query = "WITH totals (region, amount) AS (SELECT country, SUM(score) FROM s GROUP BY country) SELECT * FROM totals" | ||
| #expect(table(parser.parse(query), alias: "totals")?.columns == ["region", "amount"]) | ||
| } | ||
|
|
||
| @Test("Multiple CTEs are each resolved") | ||
| func multipleCtes() { | ||
| let query = """ | ||
| WITH a AS (SELECT x FROM t1), | ||
| b AS (SELECT y, z FROM t2) | ||
| SELECT * FROM a JOIN b ON a.x = b.y | ||
| """ | ||
| let tables = parser.parse(query) | ||
| #expect(table(tables, alias: "a")?.columns == ["x"]) | ||
| #expect(table(tables, alias: "b")?.columns == ["y", "z"]) | ||
| } | ||
|
|
||
| @Test("Quoted alias is unquoted") | ||
| func quotedAlias() { | ||
| let query = "SELECT * FROM (SELECT AVG(score) AS `avg_score` FROM s) d" | ||
| #expect(table(parser.parse(query), alias: "d")?.columns == ["avg_score"]) | ||
| } | ||
|
|
||
| @Test("Subquery in WHERE IN is not a derived table") | ||
| func subqueryInWhereIsNotDerived() { | ||
| let query = "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)" | ||
| #expect(parser.parse(query).isEmpty) | ||
| } | ||
|
|
||
| @Test("Function call parentheses are not derived tables") | ||
| func functionCallNotDerived() { | ||
| let query = "SELECT COUNT(id), AVG(score) FROM users" | ||
| #expect(parser.parse(query).isEmpty) | ||
| } | ||
|
|
||
| @Test("Distinct in a derived select list is ignored") | ||
| func distinctIgnored() { | ||
| let query = "SELECT * FROM (SELECT DISTINCT country FROM s) d" | ||
| #expect(table(parser.parse(query), alias: "d")?.columns == ["country"]) | ||
| } | ||
|
|
||
| @Test("Commas inside function arguments do not split columns") | ||
| func commasInFunctionArgs() { | ||
| let query = "SELECT * FROM (SELECT COALESCE(a, b) AS first_set, c FROM t) d" | ||
| #expect(table(parser.parse(query), alias: "d")?.columns == ["first_set", "c"]) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a derived table has no parsed columns, such as
FROM (SELECT * FROM users) d,derivedColumnsis an empty array, so this condition skips the derived match and falls through toresolveAlias. Because derived references are stored withtableName == alias,d.can then fetch and show columns from a real base table namedd, even thoughdis a derived table whose columns this parser intentionally skipped. Return immediately for a derived match even when the column list is empty.Useful? React with 👍 / 👎.