diff --git a/src/autocomplete/provider.ts b/src/autocomplete/provider.ts index 03a0aa2..b78a89d 100644 --- a/src/autocomplete/provider.ts +++ b/src/autocomplete/provider.ts @@ -96,8 +96,8 @@ function rankTableSuggestions( if (score === undefined) continue s.priority = score === referencedColumns.size - ? SuggestionPriority.High // full match - : SuggestionPriority.Medium // partial match + ? SuggestionPriority.Medium // full match — below columns (High) + : SuggestionPriority.MediumLow // partial match } } diff --git a/src/autocomplete/suggestion-builder.ts b/src/autocomplete/suggestion-builder.ts index e97bd6b..1441783 100644 --- a/src/autocomplete/suggestion-builder.ts +++ b/src/autocomplete/suggestion-builder.ts @@ -75,6 +75,24 @@ function getAllColumns(schema: SchemaInfo): ColumnWithTable[] { return columns } +/** + * Join prefix tokens → compound keyword. + * When "Join" is among the valid next tokens, these prefixes are combined + * into compound suggestions (e.g., "Left" → "LEFT JOIN") instead of + * suggesting bare "LEFT" which is incomplete on its own. + */ +const JOIN_COMPOUND_MAP = new Map([ + ["Left", "LEFT JOIN"], + ["Inner", "INNER JOIN"], + ["Cross", "CROSS JOIN"], + ["Asof", "ASOF JOIN"], + ["Lt", "LT JOIN"], + ["Splice", "SPLICE JOIN"], + ["Window", "WINDOW JOIN"], + ["Horizon", "HORIZON JOIN"], + ["Outer", "OUTER JOIN"], +]) + /** * Build suggestions from parser's nextTokenTypes * @@ -100,6 +118,10 @@ export function buildSuggestions( const includeTables = options?.includeTables ?? true const isMidWord = options?.isMidWord ?? false + // Detect join context: when "Join" is a valid next token, join prefix + // keywords (LEFT, RIGHT, ASOF, etc.) should be suggested as compounds. + const isJoinContext = tokenTypes.some((t) => t.name === "Join") + // Process each token type from the parser for (const tokenType of tokenTypes) { const name = tokenType.name @@ -120,6 +142,22 @@ export function buildSuggestions( continue } + // In join context, combine join prefix tokens into compound keywords + // (e.g., "Left" → "LEFT JOIN") instead of suggesting bare "LEFT". + if (isJoinContext && JOIN_COMPOUND_MAP.has(name)) { + const compound = JOIN_COMPOUND_MAP.get(name)! + if (seenKeywords.has(compound)) continue + seenKeywords.add(compound) + suggestions.push({ + label: compound, + kind: SuggestionKind.Keyword, + insertText: compound, + filterText: compound.toLowerCase(), + priority: SuggestionPriority.Medium, + }) + continue + } + // Convert token name to keyword display string const keyword = tokenNameToKeyword(name) diff --git a/src/parser/ast.ts b/src/parser/ast.ts index 0ef415c..25b5838 100644 --- a/src/parser/ast.ts +++ b/src/parser/ast.ts @@ -833,8 +833,6 @@ export interface JoinClause extends AstNode { joinType?: | "inner" | "left" - | "right" - | "full" | "cross" | "asof" | "lt" diff --git a/src/parser/cst-types.d.ts b/src/parser/cst-types.d.ts index f57365b..565735a 100644 --- a/src/parser/cst-types.d.ts +++ b/src/parser/cst-types.d.ts @@ -59,7 +59,7 @@ export type WithStatementCstChildren = { withClause: WithClauseCstNode[]; insertStatement?: InsertStatementCstNode[]; updateStatement?: UpdateStatementCstNode[]; - selectStatement?: SelectStatementCstNode[]; + selectBody?: SelectBodyCstNode[]; }; export interface SelectStatementCstNode extends CstNode { @@ -70,6 +70,15 @@ export interface SelectStatementCstNode extends CstNode { export type SelectStatementCstChildren = { declareClause?: DeclareClauseCstNode[]; withClause?: WithClauseCstNode[]; + selectBody: SelectBodyCstNode[]; +}; + +export interface SelectBodyCstNode extends CstNode { + name: "selectBody"; + children: SelectBodyCstChildren; +} + +export type SelectBodyCstChildren = { simpleSelect: SimpleSelectCstNode[]; setOperation?: SetOperationCstNode[]; }; @@ -355,8 +364,6 @@ export interface StandardJoinCstNode extends CstNode { export type StandardJoinCstChildren = { Left?: IToken[]; - Right?: IToken[]; - Full?: IToken[]; Outer?: IToken[]; Inner?: IToken[]; Cross?: IToken[]; @@ -2437,6 +2444,7 @@ export interface ICstNodeVisitor extends ICstVisitor { statement(children: StatementCstChildren, param?: IN): OUT; withStatement(children: WithStatementCstChildren, param?: IN): OUT; selectStatement(children: SelectStatementCstChildren, param?: IN): OUT; + selectBody(children: SelectBodyCstChildren, param?: IN): OUT; withClause(children: WithClauseCstChildren, param?: IN): OUT; cteDefinition(children: CteDefinitionCstChildren, param?: IN): OUT; simpleSelect(children: SimpleSelectCstChildren, param?: IN): OUT; diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 8870861..4d8383d 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -531,9 +531,8 @@ class QuestDBParser extends CstParser { ALT: () => this.SUBRULE(this.updateStatement), }, { - // SELECT: delegate to selectStatement (its optional declareClause/ - // withClause simply won't match since WITH was already consumed) - ALT: () => this.SUBRULE(this.selectStatement), + // SELECT after WITH: no DECLARE/WITH prefixes allowed here + ALT: () => this.SUBRULE(this.selectBody), }, ]) }) @@ -545,6 +544,10 @@ class QuestDBParser extends CstParser { private selectStatement = this.RULE("selectStatement", () => { this.OPTION(() => this.SUBRULE(this.declareClause)) this.OPTION2(() => this.SUBRULE(this.withClause)) + this.SUBRULE(this.selectBody) + }) + + private selectBody = this.RULE("selectBody", () => { this.SUBRULE(this.simpleSelect) this.MANY(() => { this.SUBRULE(this.setOperation) @@ -948,17 +951,13 @@ class QuestDBParser extends CstParser { ]) }) - // Standard joins: (INNER | LEFT [OUTER] | RIGHT [OUTER] | FULL [OUTER] | CROSS)? JOIN + ON + // Standard joins: (INNER | LEFT [OUTER] | CROSS)? JOIN + ON private standardJoin = this.RULE("standardJoin", () => { this.OPTION(() => { this.OR([ { ALT: () => { - this.OR1([ - { ALT: () => this.CONSUME(Left) }, - { ALT: () => this.CONSUME(Right) }, - { ALT: () => this.CONSUME(Full) }, - ]) + this.CONSUME(Left) this.OPTION1(() => this.CONSUME(Outer)) }, }, diff --git a/src/parser/visitor.ts b/src/parser/visitor.ts index 983308d..e652d03 100644 --- a/src/parser/visitor.ts +++ b/src/parser/visitor.ts @@ -130,6 +130,7 @@ import type { SampleByClauseCstChildren, SelectItemCstChildren, SelectListCstChildren, + SelectBodyCstChildren, SelectStatementCstChildren, SetClauseCstChildren, SetExpressionCstChildren, @@ -332,8 +333,8 @@ class QuestDBVisitor extends BaseVisitor { inner = this.visit(ctx.insertStatement) as AST.InsertStatement } else if (ctx.updateStatement) { inner = this.visit(ctx.updateStatement) as AST.UpdateStatement - } else if (ctx.selectStatement) { - inner = this.visit(ctx.selectStatement) as AST.SelectStatement + } else if (ctx.selectBody) { + inner = this.visit(ctx.selectBody) as AST.SelectStatement } else { throw new Error("withStatement: expected insert, update, or select") } @@ -347,7 +348,7 @@ class QuestDBVisitor extends BaseVisitor { // ========================================================================== selectStatement(ctx: SelectStatementCstChildren): AST.SelectStatement { - const result = this.visit(ctx.simpleSelect) as AST.SelectStatement + const result = this.visit(ctx.selectBody) as AST.SelectStatement if (ctx.declareClause) { result.declare = this.visit(ctx.declareClause) as AST.DeclareClause @@ -357,6 +358,12 @@ class QuestDBVisitor extends BaseVisitor { result.with = this.visit(ctx.withClause) as AST.CTE[] } + return result + } + + selectBody(ctx: SelectBodyCstChildren): AST.SelectStatement { + const result = this.visit(ctx.simpleSelect) as AST.SelectStatement + if (ctx.setOperation && ctx.setOperation.length > 0) { result.setOperations = ctx.setOperation.map( (op: CstNode) => this.visit(op) as AST.SetOperation, @@ -775,8 +782,6 @@ class QuestDBVisitor extends BaseVisitor { } if (ctx.Inner) result.joinType = "inner" else if (ctx.Left) result.joinType = "left" - else if (ctx.Right) result.joinType = "right" - else if (ctx.Full) result.joinType = "full" else if (ctx.Cross) result.joinType = "cross" if (ctx.Outer) result.outer = true if (ctx.expression) { diff --git a/tests/autocomplete.test.ts b/tests/autocomplete.test.ts index d17d7af..5ed07ce 100644 --- a/tests/autocomplete.test.ts +++ b/tests/autocomplete.test.ts @@ -625,19 +625,25 @@ describe("SAMPLE BY walkthrough", () => { // ============================================================================= describe("JOIN autocomplete", () => { - it("should suggest JOIN types after FROM table", () => { + it("should suggest compound JOIN types after FROM table", () => { const labels = getLabelsAt(provider, "SELECT * FROM trades ") expect(labels).toContain("JOIN") - expect(labels).toContain("INNER") - expect(labels).toContain("LEFT") - expect(labels).toContain("CROSS") - expect(labels).toContain("ASOF") - }) - - it("should suggest JOIN after LEFT/RIGHT/FULL", () => { + expect(labels).toContain("INNER JOIN") + expect(labels).toContain("LEFT JOIN") + expect(labels).toContain("CROSS JOIN") + expect(labels).toContain("ASOF JOIN") + // Bare prefixes should NOT appear when compounds are available + expect(labels).not.toContain("INNER") + expect(labels).not.toContain("LEFT") + expect(labels).not.toContain("CROSS") + expect(labels).not.toContain("ASOF") + }) + + it("should suggest JOIN and OUTER JOIN after LEFT", () => { const labels = getLabelsAt(provider, "SELECT * FROM trades LEFT ") expect(labels).toContain("JOIN") - expect(labels).toContain("OUTER") + expect(labels).toContain("OUTER JOIN") + expect(labels).not.toContain("OUTER") }) it("should suggest ON after JOIN table", () => { @@ -661,7 +667,7 @@ describe("JOIN autocomplete", () => { assertSuggestionsWalkthrough(provider, [ { typed: "SELECT * FROM trades ", - expects: ["JOIN", "ASOF", "LEFT", "CROSS"], + expects: ["JOIN", "ASOF JOIN", "LEFT JOIN", "CROSS JOIN"], }, { typed: "SELECT * FROM trades ASOF ", @@ -679,21 +685,21 @@ describe("JOIN autocomplete", () => { expect(labels).not.toContain("OUTER") }) - it("should suggest OUTER after LEFT", () => { + it("should suggest OUTER JOIN after LEFT", () => { const labels = getLabelsAt(provider, "SELECT * FROM trades LEFT ") - expect(labels).toContain("OUTER") + expect(labels).toContain("OUTER JOIN") expect(labels).toContain("JOIN") }) - it("should suggest join types after ON condition (chained joins)", () => { + it("should suggest compound join types after ON condition (chained joins)", () => { const labels = getLabelsAt( provider, "SELECT * FROM trades t ASOF JOIN quotes q ON (symbol) ", ) - expect(labels).toContain("ASOF") + expect(labels).toContain("ASOF JOIN") expect(labels).toContain("JOIN") - expect(labels).toContain("CROSS") - expect(labels).toContain("LEFT") + expect(labels).toContain("CROSS JOIN") + expect(labels).toContain("LEFT JOIN") }) }) @@ -840,9 +846,9 @@ describe("JOIN autocomplete", () => { expect(labels).toContain("SAMPLE") }) - it("should suggest join types including HORIZON after FROM table", () => { + it("should suggest join types including HORIZON JOIN after FROM table", () => { const labels = getLabelsAt(provider, "SELECT * FROM trades t ") - expect(labels).toContain("HORIZON") + expect(labels).toContain("HORIZON JOIN") }) it("INNER JOIN: should suggest ON, not TOLERANCE/INCLUDE/EXCLUDE/RANGE", () => { @@ -1344,6 +1350,34 @@ describe("CTE autocomplete", () => { expect(labels).toContain("INSERT") }) + it("should NOT suggest WITH or DECLARE after CTE definition", () => { + // Multiple CTEs use comma separation, not chained WITH keywords. + // DECLARE can only appear before WITH, not after it. + const sql = "WITH x AS (SELECT 1) " + const labels = getLabelsAt(provider, sql) + expect(labels).not.toContain("WITH") + expect(labels).not.toContain("DECLARE") + }) + + it("should suggest comma for additional CTEs after CTE definition", () => { + // WITH x AS (...), y AS (...) — comma separates CTEs + const sql = "WITH x AS (SELECT 1) , y AS (SELECT 2) " + const labels = getLabelsAt(provider, sql) + expect(labels).toContain("SELECT") + }) + + it("should allow DECLARE before WITH at statement level", () => { + const sql = "DECLARE @y := 10 WITH x AS (SELECT @y) " + const labels = getLabelsAt(provider, sql) + expect(labels).toContain("SELECT") + }) + + it("should allow DECLARE inside CTE subquery", () => { + const sql = "WITH x AS (DECLARE @y := 10 SELECT @y) " + const labels = getLabelsAt(provider, sql) + expect(labels).toContain("SELECT") + }) + // --------------------------------------------------------------------------- // Doc: bollinger-bands.md — Multi-CTE with window function aliases // --------------------------------------------------------------------------- @@ -3014,38 +3048,20 @@ describe("CTE autocomplete", () => { const trades = tables.find((s) => s.label === "trades") const orders = tables.find((s) => s.label === "orders") const users = tables.find((s) => s.label === "users") - expect(trades?.priority).toBe(SuggestionPriority.High) + expect(trades?.priority).toBe(SuggestionPriority.Medium) expect(orders?.priority).toBe(SuggestionPriority.MediumLow) expect(users?.priority).toBe(SuggestionPriority.MediumLow) }) - it("partially matching tables get Medium priority; no-match tables stay MediumLow", () => { - // "symbol" is in trades; "id" is in orders — each table has one of the two + it("partial column match does not boost tables", () => { + // "symbol" is in trades; "id" is in orders — neither has all columns const sql = "SELECT symbol, id FROM " const suggestions = provider.getSuggestions(sql, sql.length) const tables = suggestions.filter((s) => s.kind === SuggestionKind.Table) - const trades = tables.find((s) => s.label === "trades") - const orders = tables.find((s) => s.label === "orders") - const users = tables.find((s) => s.label === "users") - // partial match → Medium (boosted but not full match) - expect(trades?.priority).toBe(SuggestionPriority.Medium) - expect(orders?.priority).toBe(SuggestionPriority.Medium) - // no match → default - expect(users?.priority).toBe(SuggestionPriority.MediumLow) - }) - - it("columns from two tables: both partially-matching tables get Medium", () => { - // "symbol" and "price" only in trades; "status" only in orders; "name" only in users - // → trades and orders both partially match (2 and 1 out of 3); users has none - const sql = "SELECT symbol, price, status FROM " - const suggestions = provider.getSuggestions(sql, sql.length) - const tables = suggestions.filter((s) => s.kind === SuggestionKind.Table) - const trades = tables.find((s) => s.label === "trades") - const orders = tables.find((s) => s.label === "orders") - const users = tables.find((s) => s.label === "users") - expect(trades?.priority).toBe(SuggestionPriority.Medium) - expect(orders?.priority).toBe(SuggestionPriority.Medium) - expect(users?.priority).toBe(SuggestionPriority.MediumLow) + // No table contains both columns → no boost, all stay at default + for (const t of tables) { + expect(t.priority).toBe(SuggestionPriority.MediumLow) + } }) it("graceful fallback: no boost when no table has any referenced column", () => { @@ -3066,21 +3082,19 @@ describe("CTE autocomplete", () => { const trades = tables.find((s) => s.label === "trades") const orders = tables.find((s) => s.label === "orders") // trades has "symbol" → boosted; orders does not - expect(trades?.priority).toBe(SuggestionPriority.High) + expect(trades?.priority).toBe(SuggestionPriority.Medium) expect(orders?.priority).toBe(SuggestionPriority.MediumLow) }) - it("qualified references from multiple aliases boost the correct tables", () => { + it("qualified references with partial match do not boost tables", () => { // c.symbol → symbol in trades; o.id → id in orders + // Neither table has both columns → no boost const sql = "SELECT c.symbol, o.id FROM " const suggestions = provider.getSuggestions(sql, sql.length) const tables = suggestions.filter((s) => s.kind === SuggestionKind.Table) - const trades = tables.find((s) => s.label === "trades") - const orders = tables.find((s) => s.label === "orders") - const users = tables.find((s) => s.label === "users") - expect(trades?.priority).toBe(SuggestionPriority.Medium) // partial: symbol but not id - expect(orders?.priority).toBe(SuggestionPriority.Medium) // partial: id but not symbol - expect(users?.priority).toBe(SuggestionPriority.MediumLow) + for (const t of tables) { + expect(t.priority).toBe(SuggestionPriority.MediumLow) + } }) it("function calls are excluded from column inference", () => { @@ -3111,7 +3125,7 @@ describe("CTE autocomplete", () => { const tables = suggestions.filter((s) => s.kind === SuggestionKind.Table) const orders = tables.find((s) => s.label === "orders") const trades = tables.find((s) => s.label === "trades") - expect(orders?.priority).toBe(SuggestionPriority.High) + expect(orders?.priority).toBe(SuggestionPriority.Medium) expect(trades?.priority).toBe(SuggestionPriority.MediumLow) }) diff --git a/tests/content-assist.test.ts b/tests/content-assist.test.ts index a433757..041e74a 100644 --- a/tests/content-assist.test.ts +++ b/tests/content-assist.test.ts @@ -63,8 +63,9 @@ describe("Content Assist", () => { expect(tokens).toContain("Join") expect(tokens).toContain("Inner") expect(tokens).toContain("Left") - expect(tokens).toContain("Right") expect(tokens).toContain("Cross") + expect(tokens).not.toContain("Right") + expect(tokens).not.toContain("Full") }) it("should suggest WHERE, ORDER BY, etc. after FROM clause", () => { diff --git a/tests/docs-autocomplete.test.ts b/tests/docs-autocomplete.test.ts index 25b17e6..eeaeacc 100644 --- a/tests/docs-autocomplete.test.ts +++ b/tests/docs-autocomplete.test.ts @@ -341,10 +341,15 @@ function autocompleteWalkthrough( expectedLabel = word found = labels.some((l) => l.toLowerCase() === word) } else { - // Keyword token: check if keyword label appears in suggestions + // Keyword token: check if keyword label appears in suggestions. + // Join prefix tokens (LEFT, ASOF, etc.) are now suggested as compound + // keywords ("LEFT JOIN", "ASOF JOIN"), so also accept those forms. expectedLabel = tokenNameToKeyword(tokenType) const expectedUpper = expectedLabel.toUpperCase() - found = labels.some((l) => l.toUpperCase() === expectedUpper) + found = labels.some((l) => { + const upper = l.toUpperCase() + return upper === expectedUpper || upper === expectedUpper + " JOIN" + }) // Fallback: keyword tokens used as identifiers (e.g., `timestamp` // as a column name). Accept if the word is a known column/table in diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 6cd3338..a9ec939 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -132,28 +132,6 @@ describe("QuestDB Parser", () => { } }) - it("should parse RIGHT JOIN", () => { - const result = parseToAst( - "SELECT * FROM trades t RIGHT JOIN orders o ON t.id = o.trade_id", - ) - expect(result.errors).toHaveLength(0) - const select = result.ast[0] - if (select.type === "select") { - expect(select.from?.[0].joins?.[0].joinType).toBe("right") - } - }) - - it("should parse FULL JOIN", () => { - const result = parseToAst( - "SELECT * FROM trades t FULL JOIN orders o ON t.id = o.trade_id", - ) - expect(result.errors).toHaveLength(0) - const select = result.ast[0] - if (select.type === "select") { - expect(select.from?.[0].joins?.[0].joinType).toBe("full") - } - }) - it("should parse CROSS JOIN", () => { const result = parseToAst("SELECT * FROM trades t CROSS JOIN orders o") expect(result.errors).toHaveLength(0) @@ -2102,8 +2080,6 @@ describe("QuestDB Parser", () => { "SELECT * FROM trades t JOIN orders o ON t.id = o.trade_id", "SELECT * FROM trades t LEFT JOIN orders o ON t.id = o.trade_id", "SELECT * FROM trades t LEFT OUTER JOIN orders o ON t.id = o.trade_id", - "SELECT * FROM trades t RIGHT JOIN orders o ON t.id = o.trade_id", - "SELECT * FROM trades t FULL JOIN orders o ON t.id = o.trade_id", "SELECT * FROM trades t CROSS JOIN orders o", "SELECT * FROM trades t ASOF JOIN quotes q ON t.symbol = q.symbol", "SELECT * FROM trades t ASOF JOIN quotes q ON t.ts = q.ts TOLERANCE 1h", @@ -5743,6 +5719,80 @@ orders PIVOT (sum(amount) FOR status IN ('open'))` expect(stmt2.with![0].name).toBe("a") expect(stmt2.with![1].name).toBe("b") }) + + it("should parse three comma-separated CTEs", () => { + const result = parseToAst( + "WITH a AS (SELECT 1), b AS (SELECT 2), c AS (SELECT 3) SELECT * FROM c", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] as AST.SelectStatement + expect(stmt.with).toHaveLength(3) + expect(stmt.with![0].name).toBe("a") + expect(stmt.with![1].name).toBe("b") + expect(stmt.with![2].name).toBe("c") + }) + + it("should parse DECLARE before WITH at statement level", () => { + const result = parseToAst( + "DECLARE @y := 10 WITH x AS (SELECT @y) SELECT * FROM x", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] as AST.SelectStatement + expect(stmt.declare).toBeDefined() + expect(stmt.declare?.assignments[0].name).toBe("y") + expect(stmt.with).toHaveLength(1) + expect(stmt.with![0].name).toBe("x") + }) + + it("should parse DECLARE inside CTE subquery", () => { + const result = parseToAst( + "WITH x AS (DECLARE @y := 10 SELECT @y) SELECT * FROM x", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] as AST.SelectStatement + expect(stmt.with).toHaveLength(1) + const cteQuery = stmt.with![0].query + expect(cteQuery.declare).toBeDefined() + expect(cteQuery.declare?.assignments[0].name).toBe("y") + }) + + it("should fail to parse DECLARE after WITH clause", () => { + const result = parseToAst( + "WITH x AS (SELECT 1) DECLARE @y := 10 SELECT * FROM x", + ) + expect(result.errors.length).toBeGreaterThan(0) + }) + + it("should fail to parse chained WITH keywords instead of comma", () => { + const result = parseToAst( + "WITH x AS (SELECT 1) WITH y AS (SELECT 2) SELECT * FROM y", + ) + expect(result.errors.length).toBeGreaterThan(0) + }) + + it("should parse multiple CTEs with INSERT", () => { + const result = parseToAst( + "WITH a AS (SELECT 1 x), b AS (SELECT 2 y) INSERT INTO t SELECT * FROM a, b", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] as AST.InsertStatement + expect(stmt.type).toBe("insert") + expect(stmt.with).toHaveLength(2) + expect(stmt.with![0].name).toBe("a") + expect(stmt.with![1].name).toBe("b") + }) + + it("should parse multiple CTEs with UPDATE", () => { + const result = parseToAst( + "WITH a AS (SELECT 1 x), b AS (SELECT 2 y) UPDATE t SET col = a.x FROM a", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] as AST.UpdateStatement + expect(stmt.type).toBe("update") + expect(stmt.with).toHaveLength(2) + expect(stmt.with![0].name).toBe("a") + expect(stmt.with![1].name).toBe("b") + }) }) describe("Set operations with ORDER BY and LIMIT", () => {