Skip to content

feat(editor): complete columns of every CTE shape and ORDER BY aliases - #3713

Merged
datlechin merged 3 commits into
mainfrom
feat/3608-cte-completion
Oct 21, 2026
Merged

datlechin merged 3 commits into
mainfrom
feat/3608-cte-completion

Conversation

@datlechin

@datlechin datlechin commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Summary

  • A CTE's columns now come from AS MATERIALIZED and AS NOT MATERIALIZED bodies, INSERT/UPDATE/DELETE/MERGE ... RETURNING, VALUES with a column list, TABLE t and nested WITH bodies.
  • * and t.* in a CTE or derived table, and RETURNING *, expand to the table's columns from the schema cache. RETURNING * adds the UPDATE ... FROM and DELETE ... USING tables, and a USING join lists its join columns once, as PostgreSQL 17 returns them.
  • A column list (WITH c (a, b) AS, AS x(a, b)) renames by position and keeps the rest. Implicit aliases (count(*) total) and LATERAL derived tables resolve.
  • ORDER BY offers the SELECT list's aliases ahead of the table columns, none inside an aggregate or WITHIN GROUP.
  • SET and an INSERT column list read the table the statement writes, not the first table found in the statement: UPDATE t SET .. FROM u, INSERT INTO t (..) SELECT .. FROM s, a CTE body, UPDATE o SET .. FROM orders o.
  • DerivedTableParser reads the analyzer's lexed code view as tokens, so a dollar quote, a backslash escape or a comment no longer breaks paren matching. CTE names come from it instead of two regexes, and a CTE binds only where it is in scope.
  • LATERAL is no longer read as a table, which fetched columns for a table named LATERAL on each keystroke.

Test plan

  • TableProAutocompleteTests (swift test): DerivedTableParserTests per shape, SQLContextAnalyzerStatementShapeTests, provider cases against a stub catalog.
  • Changed on purpose: "SELECT star yields no resolvable columns" now asserts the table the star leaves to the catalog.
  • Hosted autocomplete suites and the iOS completion suites.
  • RETURNING, MERGE, VALUES, USING and column-list widths checked against PostgreSQL 17.

Docs: TableProApp/docs#71

Fixes #3608

# Conflicts:
#	apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift
#	apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift
#	apple/TablePro/Core/Autocomplete/CompletionEngine.swift

@J2TeamNNL J2TeamNNL left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review findings, mostly on completion scoping. Items 2 and 3 are worth checking first.

continue
if nextCTE < ctes.count, ctes[nextCTE].open == index {
let cte = ctes[nextCTE]
found.append((DerivedTable(alias: cte.name, projection: cte.projection, isCommonTableExpression: true), depth))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

visibleTables returns every CTE of the WITH list, including the CTE whose own body holds the cursor and CTEs defined after it. In WITH orders AS (SELECT id FROM orders WHERE |) SELECT ... the inner orders is the real table, but mergeDerivedTables swaps it for the CTE projection, so completion offers only id. collect appends every CTE to found unconditionally, so the in-scope rule applies to projections but not to visibleTables.

default:
return nil
}
if stream.isWord(index, "ONLY") { index += 1 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

modifiedTable treats the first word after UPDATE as the table. UPDATE OR REPLACE users SET | (SQLite), UPDATE LOW_PRIORITY t SET and UPDATE TOP (n) t SET resolve the target to OR, LOW_PRIORITY or TOP. .set now prefers context.targetTable over tableReferences.first, so no columns are offered. This is a regression. path() rejects IGNORE through nonAliasWords but not these.

}
for cteName in cteNames where present.insert(cteName.lowercased()).inserted {
references.append(TableReference(tableName: cteName, alias: nil))
references.append(TableReference(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every visible CTE is appended to tableReferences with its columns, even when the query never reads it. WITH a AS (SELECT x FROM t), b AS (SELECT y FROM u) SELECT | FROM a now offers y from b. Only derived tables used in FROM or JOIN should add unqualified columns. Unreferenced CTEs should stay resolvable for b. and FROM completion only.

])

// Innermost first, so a nearer definition shadows an outer one of the same name.
func visibleTables(in stream: SQLTokenStream, cursor: Int) -> [DerivedTable] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The whole statement is re-tokenized and every definition's projection is recomputed on each completion request, and several projections are computed twice (collect calls withList, mainStatement calls it again per inner body, definition and collect repeat one level down). This runs on every keystroke. Cache projections per call or project only what the cursor scope uses.

}

// CTEs were bound to their own scope by the parser, so what is left to expand is a table the catalog knows.
private func derivedColumns(of reference: TableReference) async -> [String] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

derivedColumns(of:) awaits the catalog sequentially per table, and columnItems(for:) calls it once per reference. The same tables are queried again in allColumnsInScope. N derived tables using * mean N sequential awaits plus repeated fetches on every SELECT-list or WHERE completion. Use a TaskGroup or batch the lookups and reuse the results.

}

// The open parentheses around `cursor`, outermost first.
func enclosingParens(before cursor: Int) -> [Int] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The statement-body check in targetTable accepts a column-list paren whose first column is named like a body keyword. INSERT INTO t (values, |, (table, | or (update, | makes isStatementBody true for the column list, targetTable finds no statement and returns nil, and the result falls back to tableReferences.first. Check the token before the paren so INSERT column lists are excluded.

let isCommonTableExpression: Bool

// What the statement text names, before the catalog expands a `*`.
var columns: [String] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

DerivedTable.columns has no production caller. The analyzer and provider use projection, so this property only exists for tests and can drift from projection.columns(expanding:). Delete it and have tests call projection.columns { _ in nil }.

while i < end {
if let skipped = skipNonCode(in: ns, at: i, limit: end) {
i = skipped
// MARK: - Definitions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

About 58 new // lines restate what the code does, for example // MARK: - Definitions and the one above nextFromItem. AGENTS.md says code comments say why, never what. Remove the ones that only describe the code beneath them.

@J2TeamNNL

Copy link
Copy Markdown
Contributor

Fixes for the review findings, in J2TeamNNL@559b114

  1. CTE visibility (visibleTables): fixed. A CTE body now sees only the CTEs before it, and itself only under RECURSIVE.
  2. UPDATE modifiers: fixed. OR REPLACE, LOW_PRIORITY, IGNORE and TOP (n) are skipped in the parser and in the reference regex.
  3. Unread CTEs: fixed. A CTE or derived table the query never reads stays resolvable for b., but adds no unqualified column.
  4. Re-parsing cost: partly fixed. Projections are now cached per statement, and targetTable and firstSelect no longer project the WITH list. Skipped: the statement is tokenized once per request, and that cannot be avoided without a cross-request cache.
  5. Sequential catalog awaits: fixed. Catalog lookups for all derived tables run in one task group and are shared across references. allColumnsInScope still reads the catalog itself.
  6. Column list named like a body keyword: fixed. INSERT INTO t (values, is no longer a statement body.
  7. DerivedTable.columns: fixed. Removed; tests call projection.columns(expanding:).
  8. Comments that restate the code: fixed. Removed the MARK lines and the comments that only described the next line.

Checks: the autocomplete sources and tests compile with Xcode 26.3 in a scratch build, and all 441 tests pass. The app target was not built and SwiftLint was not run.

To take it: git fetch https://github.com/J2TeamNNL/TablePro review-fix/3713 && git cherry-pick 559b114c120936c194c3736b4681556178862916

@datlechin
datlechin merged commit 91653dd into main Oct 21, 2026
9 checks passed
@datlechin
datlechin deleted the feat/3608-cte-completion branch October 21, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Complete columns from MATERIALIZED and data-modifying CTEs, and output aliases in ORDER BY

2 participants