Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- SQL autocomplete now suggests columns for a derived-table or CTE alias, reading the subquery's output columns. Before, typing `alias.` for a `JOIN (SELECT ...) alias` or a `WITH` table returned nothing. (#1697)
- Redis entries no longer disappear after the connection sits idle. The health check was running `SELECT 1`, which on Redis switches the active database, so a later refresh scanned the wrong database. (#1701)
- Redis key browsing now lists every key in a database or namespace and pages through them correctly. It was reading only the first SCAN batch, so large keyspaces showed a partial, fixed set of keys. (#1701)
- A dropped Redis connection now reconnects on the next command and replays auth and the selected database, instead of failing until the next health check. (#1701)
Expand Down
458 changes: 458 additions & 0 deletions TablePro/Core/Autocomplete/DerivedTableParser.swift

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions TablePro/Core/Autocomplete/SQLCompletionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ final class SQLCompletionProvider {
// schema name parsed out of the FROM clause itself.
if let dotPrefix = context.dotPrefix {
guard let schemaProvider else { return [] }
if let derived = context.tableReferences.first(where: {
$0.isDerived && $0.identifier.caseInsensitiveCompare(dotPrefix) == .orderedSame
}), let columns = derived.derivedColumns, !columns.isEmpty {
Comment on lines +137 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop derived aliases from falling through to table lookup

When a derived table has no parsed columns, such as FROM (SELECT * FROM users) d, derivedColumns is an empty array, so this condition skips the derived match and falls through to resolveAlias. Because derived references are stored with tableName == alias, d. can then fetch and show columns from a real base table named d, even though d is 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 👍 / 👎.

return columns.map { SQLCompletionItem.column($0, dataType: nil, tableName: derived.identifier) }
}
if let tableName = await schemaProvider.resolveAlias(dotPrefix, in: context.tableReferences) {
let schema = context.tableReferences.first {
$0.tableName.caseInsensitiveCompare(tableName) == .orderedSame
Expand Down
63 changes: 54 additions & 9 deletions TablePro/Core/Autocomplete/SQLContextAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep CTE columns off unrelated aliases

This matches every parsed CTE/derived name against both the table operand and the effective alias, so a query with WITH c AS (...) SELECT c.| FROM users c rewrites the in-scope users c reference as derived and c. suggests the CTE columns instead of users columns. That is separate from appending unused CTEs later: the actual table reference is mutated here before the append path. CTE columns should attach only when the referenced table name is the CTE, not when an unrelated table alias happens to share the CTE name.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep unused CTEs out of column scope

When a statement defines a CTE but does not reference it in the current FROM/JOIN, this loop still appends it as a TableReference with derivedColumns. For a query like WITH unused AS (SELECT secret FROM users) SELECT * FROM orders o WHERE |, column completion sees both orders and unused, so it can suggest unused.secret even though the CTE is only available as a table operand and is not in the query's range table.

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,
Expand Down
16 changes: 14 additions & 2 deletions TablePro/Core/Autocomplete/SQLSchemaProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -390,14 +390,26 @@ actor SQLSchemaProvider {
func allColumnsInScope(for references: [TableReference]) async -> [SQLCompletionItem] {
// swiftlint:disable:next large_tuple
var itemDataBuilder: [(
label: String, insertText: String, type: String, table: String,
label: String, insertText: String, type: String?, table: String,
isPK: Bool, isNullable: Bool, defaultValue: String?, comment: String?
)] = []

let hasMultipleRefs = references.count > 1
for ref in references {
let columns = await getColumns(for: ref.tableName, schema: ref.schema)
let refId = ref.identifier
if let derivedColumns = ref.derivedColumns {
for name in derivedColumns {
let label = hasMultipleRefs ? "\(refId).\(name)" : name
itemDataBuilder.append(
(
label: label, insertText: label, type: nil,
table: refId, isPK: false, isNullable: true,
defaultValue: nil, comment: nil
))
}
continue
}
let columns = await getColumns(for: ref.tableName, schema: ref.schema)
for column in columns {
let label = hasMultipleRefs ? "\(refId).\(column.name)" : column.name
let insertText = hasMultipleRefs ? "\(refId).\(column.name)" : column.name
Expand Down
34 changes: 34 additions & 0 deletions TableProTests/Core/Autocomplete/CompletionEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,38 @@ struct CompletionEngineTests {
}
}
}

@Test("Derived-table alias suggests the subquery's output columns")
func testDerivedTableAliasCompletion() async {
let driver = MockDatabaseDriver()
driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "happiness_scores")]
await schemaProvider.loadSchema(using: driver, connection: TestFixtures.makeConnection())

let prefix = "SELECT ahs."
let text = prefix + " FROM happiness_scores hs "
+ "LEFT JOIN (SELECT country, AVG(score) AS avg_score FROM happiness_scores GROUP BY country) ahs "
+ "ON hs.country = ahs.country"
let result = await engine.getCompletions(text: text, cursorPosition: (prefix as NSString).length)

#expect(result != nil)
let labels = result?.items.map(\.label) ?? []
#expect(labels.contains("country"))
#expect(labels.contains("avg_score"))
}

@Test("CTE alias suggests the CTE's output columns")
func testCteAliasCompletion() async {
let driver = MockDatabaseDriver()
driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "sales")]
await schemaProvider.loadSchema(using: driver, connection: TestFixtures.makeConnection())

let prefix = "WITH totals AS (SELECT region, SUM(amount) AS total FROM sales GROUP BY region) SELECT t."
let text = prefix + " FROM totals t"
let result = await engine.getCompletions(text: text, cursorPosition: (prefix as NSString).length)

#expect(result != nil)
let labels = result?.items.map(\.label) ?? []
#expect(labels.contains("region"))
#expect(labels.contains("total"))
}
}
139 changes: 139 additions & 0 deletions TableProTests/Core/Autocomplete/DerivedTableParserTests.swift
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"])
}
}
21 changes: 21 additions & 0 deletions TableProTests/Core/Autocomplete/SQLContextAnalyzerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,27 @@ struct SQLContextAnalyzerTests {
#expect(context.tableReferences.contains { $0.tableName == "orders" && $0.alias == "o" })
}

@Test("Registers derived-table alias with its columns")
func testDerivedTableReference() {
let query = """
SELECT * FROM happiness_scores hs
LEFT JOIN (SELECT country, AVG(score) AS avg_score FROM happiness_scores GROUP BY country) ahs
ON hs.country = ahs.country
"""
let context = analyzer.analyze(query: query, cursorPosition: (query as NSString).length)
let ahs = context.tableReferences.first { $0.identifier == "ahs" }
#expect(ahs?.isDerived == true)
#expect(ahs?.derivedColumns == ["country", "avg_score"])
}

@Test("Registers CTE alias with its columns")
func testCteReference() {
let query = "WITH totals AS (SELECT region, SUM(amount) AS total FROM sales GROUP BY region) SELECT * FROM totals t"
let context = analyzer.analyze(query: query, cursorPosition: (query as NSString).length)
let totals = context.tableReferences.first { $0.identifier == "totals" }
#expect(totals?.derivedColumns == ["region", "total"])
}

@Test("Extracts table reference from UPDATE")
func testTableReferenceFromUpdate() {
let context = analyzer.analyze(query: "UPDATE users SET", cursorPosition: 16)
Expand Down
21 changes: 21 additions & 0 deletions docs/features/autocomplete.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,27 @@ FROM users u
JOIN orders o ON u.id = o.| -- id, user_id, total (from orders)
```

#### Derived Tables and CTEs

An alias for a subquery (derived table) or a `WITH` table completes the columns that subquery's SELECT list produces, including `AS` renames:

```sql
SELECT ahs.| -- country, avg_score
FROM happiness_scores hs
LEFT JOIN (
SELECT country, AVG(score) AS avg_score
FROM happiness_scores
GROUP BY country
) ahs ON hs.country = ahs.country

WITH totals AS (
SELECT region, SUM(amount) AS total FROM sales GROUP BY region
)
SELECT t.| FROM totals t -- region, total
```

Explicit (`AS name`), bare (`country`), and qualified (`t.col`) columns resolve. A `SELECT *` subquery and unaliased expressions like `AVG(score)` have no name to suggest, so they are skipped.

{/* Screenshot: Column suggestions after alias */}
<Frame caption="Column suggestions for aliased table">
<img
Expand Down
Loading