From 0bbbc94571bc826d2b967fbb73972636ed05fb24 Mon Sep 17 00:00:00 2001 From: emrberk Date: Wed, 4 Mar 2026 17:53:28 +0300 Subject: [PATCH] add horizon join support --- src/autocomplete/provider.ts | 8 +- src/parser/ast.ts | 7 ++ src/parser/cst-types.d.ts | 39 ++++++ src/parser/lexer.ts | 4 + src/parser/parser.ts | 69 ++++++++++ src/parser/toSql.ts | 18 +++ src/parser/tokens.ts | 5 +- src/parser/visitor.ts | 57 +++++++++ tests/autocomplete.test.ts | 99 +++++++++++++++ tests/content-assist.test.ts | 29 +++++ tests/fixtures/docs-queries.json | 39 ++++++ tests/parser.test.ts | 209 +++++++++++++++++++++++++++++-- 12 files changed, 569 insertions(+), 14 deletions(-) diff --git a/src/autocomplete/provider.ts b/src/autocomplete/provider.ts index 19f451f..03a0aa2 100644 --- a/src/autocomplete/provider.ts +++ b/src/autocomplete/provider.ts @@ -43,9 +43,7 @@ const TABLE_NAME_TOKENS = new Set([ * Built once at provider creation time so per-request ranking is O(N×M) * rather than O(N×C). */ -function buildColumnIndex( - schema: SchemaInfo, -): Map> { +function buildColumnIndex(schema: SchemaInfo): Map> { const index = new Map>() for (const table of schema.tables) { const key = table.name.toLowerCase() @@ -98,8 +96,8 @@ function rankTableSuggestions( if (score === undefined) continue s.priority = score === referencedColumns.size - ? SuggestionPriority.High // full match - : SuggestionPriority.Medium // partial match + ? SuggestionPriority.High // full match + : SuggestionPriority.Medium // partial match } } diff --git a/src/parser/ast.ts b/src/parser/ast.ts index 6d34baf..0ef415c 100644 --- a/src/parser/ast.ts +++ b/src/parser/ast.ts @@ -840,6 +840,7 @@ export interface JoinClause extends AstNode { | "lt" | "splice" | "window" + | "horizon" outer?: boolean table: TableRef on?: Expression @@ -849,6 +850,12 @@ export interface JoinClause extends AstNode { range?: { start: WindowJoinBound; end: WindowJoinBound } /** INCLUDE/EXCLUDE PREVAILING clause for WINDOW JOIN */ prevailing?: "include" | "exclude" + /** RANGE FROM/TO/STEP for HORIZON JOIN */ + horizonRange?: { from: string; to: string; step: string } + /** LIST offsets for HORIZON JOIN */ + horizonList?: string[] + /** Alias for the horizon pseudo-table */ + horizonAlias?: string } export interface WindowJoinBound extends AstNode { diff --git a/src/parser/cst-types.d.ts b/src/parser/cst-types.d.ts index 1bca903..f57365b 100644 --- a/src/parser/cst-types.d.ts +++ b/src/parser/cst-types.d.ts @@ -259,6 +259,7 @@ export type JoinClauseCstChildren = { asofLtJoin?: AsofLtJoinCstNode[]; spliceJoin?: SpliceJoinCstNode[]; windowJoin?: WindowJoinCstNode[]; + horizonJoin?: HorizonJoinCstNode[]; standardJoin?: StandardJoinCstNode[]; }; @@ -311,6 +312,42 @@ export type WindowJoinCstChildren = { Exclude?: IToken[]; }; +export interface HorizonJoinCstNode extends CstNode { + name: "horizonJoin"; + children: HorizonJoinCstChildren; +} + +export type HorizonJoinCstChildren = { + Horizon: IToken[]; + Join: IToken[]; + tableName: TableNameCstNode[]; + As?: (IToken)[]; + identifier?: (IdentifierCstNode)[]; + Identifier?: IToken[]; + On?: IToken[]; + expression?: ExpressionCstNode[]; + Range?: IToken[]; + From?: IToken[]; + horizonOffset?: (HorizonOffsetCstNode)[]; + To?: IToken[]; + Step?: IToken[]; + List?: IToken[]; + LParen?: IToken[]; + Comma?: IToken[]; + RParen?: IToken[]; +}; + +export interface HorizonOffsetCstNode extends CstNode { + name: "horizonOffset"; + children: HorizonOffsetCstChildren; +} + +export type HorizonOffsetCstChildren = { + Minus?: IToken[]; + DurationLiteral?: IToken[]; + NumberLiteral?: IToken[]; +}; + export interface StandardJoinCstNode extends CstNode { name: "standardJoin"; children: StandardJoinCstChildren; @@ -2417,6 +2454,8 @@ export interface ICstNodeVisitor extends ICstVisitor { asofLtJoin(children: AsofLtJoinCstChildren, param?: IN): OUT; spliceJoin(children: SpliceJoinCstChildren, param?: IN): OUT; windowJoin(children: WindowJoinCstChildren, param?: IN): OUT; + horizonJoin(children: HorizonJoinCstChildren, param?: IN): OUT; + horizonOffset(children: HorizonOffsetCstChildren, param?: IN): OUT; standardJoin(children: StandardJoinCstChildren, param?: IN): OUT; windowJoinBound(children: WindowJoinBoundCstChildren, param?: IN): OUT; durationExpression(children: DurationExpressionCstChildren, param?: IN): OUT; diff --git a/src/parser/lexer.ts b/src/parser/lexer.ts index 35e27a0..c212a95 100644 --- a/src/parser/lexer.ts +++ b/src/parser/lexer.ts @@ -101,6 +101,7 @@ import { Group, Groups, Header, + Horizon, Hour, Hours, Http, @@ -229,6 +230,7 @@ import { Splice, Squash, StandardConformingStrings, + Step, Start, StatisticsEnabled, String, @@ -389,6 +391,7 @@ export { Group, Groups, Header, + Horizon, Hour, Hours, Http, @@ -517,6 +520,7 @@ export { Splice, Squash, StandardConformingStrings, + Step, Start, StatisticsEnabled, String, diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 0544ab7..8870861 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -310,6 +310,8 @@ import { LongLiteral, DecimalLiteral, Window, + Horizon, + Step, Ignore, Respect, Nulls, @@ -818,6 +820,7 @@ class QuestDBParser extends CstParser { { ALT: () => this.SUBRULE(this.asofLtJoin) }, { ALT: () => this.SUBRULE(this.spliceJoin) }, { ALT: () => this.SUBRULE(this.windowJoin) }, + { ALT: () => this.SUBRULE(this.horizonJoin) }, { ALT: () => this.SUBRULE(this.standardJoin) }, ]) }) @@ -879,6 +882,72 @@ class QuestDBParser extends CstParser { }) }) + // HORIZON JOIN: tableName alias [ON expr] (RANGE FROM/TO/STEP | LIST (...)) AS alias + // Uses tableName + custom alias instead of tableRef to avoid ambiguity + // between implicit keyword aliases and RANGE/LIST/ON keywords. + private horizonJoin = this.RULE("horizonJoin", () => { + this.CONSUME(Horizon) + this.CONSUME(Join) + this.SUBRULE(this.tableName) + // Table alias (mandatory): explicit (AS ) or implicit (bare Identifier only, not keywords) + this.OR1([ + { + ALT: () => { + this.CONSUME(As) + this.SUBRULE(this.identifier) + }, + }, + { + // Implicit alias: only base Identifier, never keywords like RANGE/LIST + ALT: () => this.CONSUME(Identifier), + }, + ]) + this.OPTION(() => { + this.CONSUME(On) + this.SUBRULE(this.expression) + }) + this.OR2([ + { + // RANGE FROM TO STEP AS + ALT: () => { + this.CONSUME(Range) + this.CONSUME(From) + this.SUBRULE(this.horizonOffset) + this.CONSUME(To) + this.SUBRULE1(this.horizonOffset) + this.CONSUME(Step) + this.SUBRULE2(this.horizonOffset) + this.CONSUME1(As) + this.SUBRULE1(this.identifier) + }, + }, + { + // LIST (, ...) AS + ALT: () => { + this.CONSUME(List) + this.CONSUME(LParen) + this.SUBRULE3(this.horizonOffset) + this.MANY(() => { + this.CONSUME(Comma) + this.SUBRULE4(this.horizonOffset) + }) + this.CONSUME(RParen) + this.CONSUME2(As) + this.SUBRULE2(this.identifier) + }, + }, + ]) + }) + + // Horizon offset value: optional minus sign + DurationLiteral | NumberLiteral + private horizonOffset = this.RULE("horizonOffset", () => { + this.OPTION(() => this.CONSUME(Minus)) + this.OR([ + { ALT: () => this.CONSUME(DurationLiteral) }, + { ALT: () => this.CONSUME(NumberLiteral) }, + ]) + }) + // Standard joins: (INNER | LEFT [OUTER] | RIGHT [OUTER] | FULL [OUTER] | CROSS)? JOIN + ON private standardJoin = this.RULE("standardJoin", () => { this.OPTION(() => { diff --git a/src/parser/toSql.ts b/src/parser/toSql.ts index 6cf5f92..1f44569 100644 --- a/src/parser/toSql.ts +++ b/src/parser/toSql.ts @@ -373,6 +373,24 @@ function joinToSql(join: AST.JoinClause): string { ) } + if (join.horizonRange) { + parts.push("RANGE FROM") + parts.push(join.horizonRange.from) + parts.push("TO") + parts.push(join.horizonRange.to) + parts.push("STEP") + parts.push(join.horizonRange.step) + } + + if (join.horizonList) { + parts.push("LIST (" + join.horizonList.join(", ") + ")") + } + + if (join.horizonAlias) { + parts.push("AS") + parts.push(escapeIdentifier(join.horizonAlias)) + } + return parts.join(" ") } diff --git a/src/parser/tokens.ts b/src/parser/tokens.ts index 3f790b4..5178aeb 100644 --- a/src/parser/tokens.ts +++ b/src/parser/tokens.ts @@ -229,9 +229,9 @@ export const IDENTIFIER_KEYWORD_NAMES = new globalThis.Set([ "Capacity", "Cancel", "Prevailing", + "Range", "Writer", "Materialized", - "Range", "Snapshot", "Unlock", "Refresh", @@ -350,7 +350,6 @@ export const IDENTIFIER_KEYWORD_NAMES = new globalThis.Set([ "Every", "Prev", "Linear", - "Horizon", "Step", ]) @@ -481,6 +480,7 @@ export const Grant = getToken("Grant") export const Group = getToken("Group") export const Groups = getToken("Groups") export const Header = getToken("Header") +export const Horizon = getToken("Horizon") export const Http = getToken("Http") export const If = getToken("If") export const Ignore = getToken("Ignore") @@ -581,6 +581,7 @@ export const Snapshot = getToken("Snapshot") export const Splice = getToken("Splice") export const Squash = getToken("Squash") export const StandardConformingStrings = getToken("StandardConformingStrings") +export const Step = getToken("Step") export const Start = getToken("Start") export const StatisticsEnabled = getToken("StatisticsEnabled") export const Suspend = getToken("Suspend") diff --git a/src/parser/visitor.ts b/src/parser/visitor.ts index d321329..983308d 100644 --- a/src/parser/visitor.ts +++ b/src/parser/visitor.ts @@ -139,6 +139,8 @@ import type { SimpleSelectCstChildren, SnapshotStatementCstChildren, SpliceJoinCstChildren, + HorizonJoinCstChildren, + HorizonOffsetCstChildren, StandardJoinCstChildren, StatementCstChildren, StatementsCstChildren, @@ -659,6 +661,7 @@ class QuestDBVisitor extends BaseVisitor { if (ctx.asofLtJoin) return this.visit(ctx.asofLtJoin) as AST.JoinClause if (ctx.spliceJoin) return this.visit(ctx.spliceJoin) as AST.JoinClause if (ctx.windowJoin) return this.visit(ctx.windowJoin) as AST.JoinClause + if (ctx.horizonJoin) return this.visit(ctx.horizonJoin) as AST.JoinClause return this.visit(ctx.standardJoin!) as AST.JoinClause } @@ -711,6 +714,60 @@ class QuestDBVisitor extends BaseVisitor { return result } + horizonJoin(ctx: HorizonJoinCstChildren): AST.JoinClause { + // Build TableRef from tableName + alias + const tableRef: AST.TableRef = { + type: "tableRef", + table: this.visit(ctx.tableName) as AST.QualifiedName, + } + if (ctx.Identifier) { + // Implicit alias (bare identifier, not a keyword) + tableRef.alias = ctx.Identifier[0].image + } else if (ctx.identifier && ctx.identifier.length > 1) { + // Explicit alias (AS ): first identifier is table alias, last is horizon alias + tableRef.alias = ( + this.visit(ctx.identifier[0]) as AST.QualifiedName + ).parts[0] + } + + const result: AST.JoinClause = { + type: "join", + joinType: "horizon", + table: tableRef, + } + if (ctx.expression) { + result.on = this.visit(ctx.expression) as AST.Expression + } + if (ctx.Range) { + // RANGE form: horizonOffset[0]=from, [1]=to, [2]=step + const offsets = ctx.horizonOffset! + result.horizonRange = { + from: this.visit(offsets[0]) as string, + to: this.visit(offsets[1]) as string, + step: this.visit(offsets[2]) as string, + } + } else if (ctx.List) { + // LIST form: all horizonOffset children are list entries + result.horizonList = ctx.horizonOffset!.map( + (o) => this.visit(o) as string, + ) + } + // Horizon alias is always the last identifier + if (ctx.identifier) { + const lastId = ctx.identifier[ctx.identifier.length - 1] + result.horizonAlias = (this.visit(lastId) as AST.QualifiedName).parts[0] + } + return result + } + + horizonOffset(ctx: HorizonOffsetCstChildren): string { + const sign = ctx.Minus ? "-" : "" + if (ctx.DurationLiteral) { + return sign + ctx.DurationLiteral[0].image + } + return sign + ctx.NumberLiteral![0].image + } + standardJoin(ctx: StandardJoinCstChildren): AST.JoinClause { const result: AST.JoinClause = { type: "join", diff --git a/tests/autocomplete.test.ts b/tests/autocomplete.test.ts index 4b35e12..d17d7af 100644 --- a/tests/autocomplete.test.ts +++ b/tests/autocomplete.test.ts @@ -746,6 +746,105 @@ describe("JOIN autocomplete", () => { expect(labels).not.toContain("TOLERANCE") }) + it("HORIZON JOIN: should suggest ON, RANGE, LIST, not TOLERANCE/INCLUDE/EXCLUDE", () => { + const labels = getLabelsAt( + provider, + "SELECT * FROM trades t HORIZON JOIN quotes q ", + ) + expect(labels).toContain("ON") + expect(labels).toContain("RANGE") + expect(labels).toContain("LIST") + expect(labels).not.toContain("TOLERANCE") + expect(labels).not.toContain("INCLUDE") + expect(labels).not.toContain("EXCLUDE") + }) + + it("HORIZON JOIN: should suggest AS after table name (alias mandatory)", () => { + const labels = getLabelsAt( + provider, + "SELECT * FROM trades t HORIZON JOIN quotes ", + ) + expect(labels).toContain("AS") + }) + + it("HORIZON JOIN: should suggest RANGE and LIST after ON clause", () => { + const labels = getLabelsAt( + provider, + "SELECT * FROM trades t HORIZON JOIN quotes q ON (symbol) ", + ) + expect(labels).toContain("RANGE") + expect(labels).toContain("LIST") + }) + + it("HORIZON JOIN RANGE: should suggest FROM after RANGE", () => { + const labels = getLabelsAt( + provider, + "SELECT * FROM trades t HORIZON JOIN quotes q ON (symbol) RANGE ", + ) + expect(labels).toContain("FROM") + }) + + it("HORIZON JOIN RANGE: should suggest TO after FROM offset", () => { + const labels = getLabelsAt( + provider, + "SELECT * FROM trades t HORIZON JOIN quotes q ON (symbol) RANGE FROM 1s ", + ) + expect(labels).toContain("TO") + }) + + it("HORIZON JOIN RANGE: should suggest STEP after TO offset", () => { + const labels = getLabelsAt( + provider, + "SELECT * FROM trades t HORIZON JOIN quotes q ON (symbol) RANGE FROM 1s TO 60s ", + ) + expect(labels).toContain("STEP") + }) + + it("HORIZON JOIN RANGE: should suggest AS after STEP offset", () => { + const labels = getLabelsAt( + provider, + "SELECT * FROM trades t HORIZON JOIN quotes q ON (symbol) RANGE FROM 1s TO 60s STEP 1s ", + ) + expect(labels).toContain("AS") + }) + + it("HORIZON JOIN RANGE: should suggest WHERE, GROUP BY, ORDER BY after AS alias", () => { + const labels = getLabelsAt( + provider, + "SELECT * FROM trades t HORIZON JOIN quotes q ON (symbol) RANGE FROM 1s TO 60s STEP 1s AS h ", + ) + expect(labels).toContain("WHERE") + expect(labels).toContain("GROUP") + expect(labels).toContain("ORDER") + expect(labels).toContain("LIMIT") + expect(labels).toContain("SAMPLE") + }) + + it("HORIZON JOIN LIST: should suggest AS after closing paren", () => { + const labels = getLabelsAt( + provider, + "SELECT * FROM trades t HORIZON JOIN quotes q ON (symbol) LIST (1s, 5s) ", + ) + expect(labels).toContain("AS") + }) + + it("HORIZON JOIN LIST: should suggest WHERE, GROUP BY, ORDER BY after AS alias", () => { + const labels = getLabelsAt( + provider, + "SELECT * FROM trades t HORIZON JOIN quotes q ON (symbol) LIST (1s, 5s) AS h ", + ) + expect(labels).toContain("WHERE") + expect(labels).toContain("GROUP") + expect(labels).toContain("ORDER") + expect(labels).toContain("LIMIT") + expect(labels).toContain("SAMPLE") + }) + + it("should suggest join types including HORIZON after FROM table", () => { + const labels = getLabelsAt(provider, "SELECT * FROM trades t ") + expect(labels).toContain("HORIZON") + }) + it("INNER JOIN: should suggest ON, not TOLERANCE/INCLUDE/EXCLUDE/RANGE", () => { const labels = getLabelsAt( provider, diff --git a/tests/content-assist.test.ts b/tests/content-assist.test.ts index 5446d2c..a433757 100644 --- a/tests/content-assist.test.ts +++ b/tests/content-assist.test.ts @@ -110,6 +110,35 @@ describe("Content Assist", () => { ) expect(tokens).toContain("DurationLiteral") }) + + // HORIZON JOIN + it("should suggest HORIZON as a join type", () => { + const tokens = getNextValidTokens("SELECT * FROM t ") + expect(tokens).toContain("Horizon") + }) + + it("should suggest JOIN after HORIZON", () => { + const tokens = getNextValidTokens("SELECT * FROM t HORIZON ") + expect(tokens).toContain("Join") + }) + + it("should suggest AS or Identifier after HORIZON JOIN table (alias mandatory)", () => { + const tokens = getNextValidTokens("SELECT * FROM t HORIZON JOIN u ") + expect(tokens).toContain("As") + expect(tokens).toContain("Identifier") + }) + + it("should suggest On after HORIZON JOIN table AS alias", () => { + const tokens = getNextValidTokens("SELECT * FROM t HORIZON JOIN u AS m ") + expect(tokens).toContain("On") + }) + + it("HORIZON JOIN: should not suggest TOLERANCE, INCLUDE, EXCLUDE", () => { + const tokens = getNextValidTokens("SELECT * FROM t HORIZON JOIN u AS m ") + expect(tokens).not.toContain("Tolerance") + expect(tokens).not.toContain("Include") + expect(tokens).not.toContain("Exclude") + }) }) describe("getContentAssist", () => { diff --git a/tests/fixtures/docs-queries.json b/tests/fixtures/docs-queries.json index a2c129c..f12d6fa 100644 --- a/tests/fixtures/docs-queries.json +++ b/tests/fixtures/docs-queries.json @@ -5239,5 +5239,44 @@ }, { "query": "INSERT INTO market_data VALUES\n ('2025-07-01T12:00:00Z', 'EURUSD', ARRAY[ [5.0, 5.1], [10.0, 20] ], ARRAY[ [6.0, 6.1], [15.0, 25] ]),\n ('2025-07-01T12:00:01Z', 'EURUSD', ARRAY[ [5.1, 5.2], [20.0, 25] ], ARRAY[ [6.2, 6.4], [20.0, 9] ])" + }, + { + "query": "SELECT\n h.offset / 1000000000 AS horizon_sec,\n t.symbol,\n avg((m.best_bid + m.best_ask) / 2) AS avg_mid\nFROM fx_trades AS t\nHORIZON JOIN market_data AS m ON (symbol)\nRANGE FROM 1s TO 60s STEP 1s AS h\nORDER BY t.symbol, horizon_sec" + }, + { + "query": "SELECT\n h.offset / 1000000000 AS horizon_sec,\n t.symbol,\n avg((m.best_bid + m.best_ask) / 2 - t.price) AS avg_markout\nFROM fx_trades AS t\nHORIZON JOIN market_data AS m ON (symbol)\nLIST (1s, 5s, 30s, 1m) AS h\nORDER BY t.symbol, horizon_sec" + }, + { + "query": "SELECT\n h.offset / 1000000000 AS horizon_sec,\n t.symbol,\n avg((m.best_bid + m.best_ask) / 2) AS avg_mid,\n count() AS sample_size\nFROM fx_trades AS t\nHORIZON JOIN market_data AS m ON (symbol)\nRANGE FROM -5s TO 5s STEP 1s AS h\nORDER BY t.symbol, horizon_sec" + }, + { + "query": "SELECT\n h.offset / 1000000000 AS horizon_sec,\n sum(((m.best_bid + m.best_ask) / 2 - t.price) * t.quantity)\n / sum(t.quantity) AS vwap_markout\nFROM fx_trades AS t\nHORIZON JOIN market_data AS m ON (symbol)\nRANGE FROM 1s TO 60s STEP 1s AS h\nORDER BY horizon_sec" + }, + { + "query": "EXPLAIN SELECT\n h.offset / 1000000000 AS horizon_sec,\n t.symbol,\n avg((m.best_bid + m.best_ask) / 2) AS avg_mid\nFROM fx_trades AS t\nHORIZON JOIN market_data AS m ON (symbol)\nRANGE FROM 1s TO 60s STEP 1s AS h\nORDER BY t.symbol, horizon_sec" + }, + { + "query": "SELECT\n t.symbol,\n t.ecn,\n t.counterparty,\n t.passive,\n h.offset / 1000000000 AS horizon_sec,\n count() AS n,\n avg(\n CASE t.side\n WHEN 'buy' THEN ((m.best_bid + m.best_ask) / 2 - t.price)\n / t.price * 10000\n WHEN 'sell' THEN (t.price - (m.best_bid + m.best_ask) / 2)\n / t.price * 10000\n END\n ) AS avg_markout_bps,\n sum(\n CASE t.side\n WHEN 'buy' THEN ((m.best_bid + m.best_ask) / 2 - t.price)\n * t.quantity\n WHEN 'sell' THEN (t.price - (m.best_bid + m.best_ask) / 2)\n * t.quantity\n END\n ) AS total_pnl\nFROM fx_trades t\nHORIZON JOIN market_data m ON (symbol)\n RANGE FROM 0s TO 5m STEP 1s AS h\nWHERE t.timestamp IN '$yesterday'\nGROUP BY t.symbol, t.ecn, t.counterparty, t.passive, horizon_sec\nORDER BY t.symbol, t.ecn, t.counterparty, t.passive, horizon_sec" + }, + { + "query": "SELECT\n t.ecn,\n t.passive,\n h.offset / 1000000000 AS horizon_sec,\n count() AS n,\n round(avg(\n CASE t.side\n WHEN 'buy' THEN ((m.best_bid + m.best_ask) / 2 - t.price)\n / t.price * 10000\n WHEN 'sell' THEN (t.price - (m.best_bid + m.best_ask) / 2)\n / t.price * 10000\n END\n ), 3) AS avg_markout_bps\nFROM fx_trades t\nHORIZON JOIN market_data m ON (symbol)\n LIST (0, 1s, 5s, 30s, 1m, 5m) AS h\nWHERE t.timestamp IN '$yesterday'\nGROUP BY t.ecn, t.passive, horizon_sec\nORDER BY t.ecn, t.passive, horizon_sec" + }, + { + "query": "SELECT\n h.offset / 1000000000 AS horizon_sec,\n count() AS n,\n round(avg(\n CASE t.side\n WHEN 'buy' THEN ((m.best_bid + m.best_ask) / 2 - t.price)\n / t.price * 10000\n WHEN 'sell' THEN (t.price - (m.best_bid + m.best_ask) / 2)\n / t.price * 10000\n END\n ), 3) AS avg_markout_bps\nFROM fx_trades t\nHORIZON JOIN market_data m ON (symbol)\n RANGE FROM -30s TO 30s STEP 1s AS h\nWHERE t.timestamp IN '$yesterday'\nGROUP BY horizon_sec\nORDER BY horizon_sec" + }, + { + "query": "SELECT\n t.ecn,\n t.side,\n h.offset / 1000000000 AS horizon_sec,\n count() AS n,\n round(avg(\n CASE t.side\n WHEN 'buy' THEN ((m.best_bid + m.best_ask) / 2 - t.price)\n / t.price * 10000\n WHEN 'sell' THEN (t.price - (m.best_bid + m.best_ask) / 2)\n / t.price * 10000\n END\n ), 3) AS avg_markout_bps\nFROM fx_trades t\nHORIZON JOIN market_data m ON (symbol)\n LIST (0, 1s, 5s, 30s, 1m, 5m) AS h\nWHERE t.timestamp IN '$yesterday'\nGROUP BY t.ecn, t.side, horizon_sec\nORDER BY t.ecn, t.side, horizon_sec" + }, + { + "query": "SELECT\n t.symbol,\n h.offset / 1000000000 AS horizon_sec,\n count() AS n,\n avg(((m.best_bid + m.best_ask) / 2 - t.price) / t.price * 10000) AS avg_markout_bps,\n sum(((m.best_bid + m.best_ask) / 2 - t.price) * t.quantity) AS total_pnl\nFROM fx_trades t\nHORIZON JOIN market_data m ON (symbol)\n RANGE FROM 0s TO 10m STEP 5s AS h\nWHERE t.side = 'buy'\n AND t.timestamp IN '$yesterday'\nGROUP BY t.symbol, horizon_sec\nORDER BY t.symbol, horizon_sec" + }, + { + "query": "SELECT\n t.symbol,\n h.offset / 1000000000 AS horizon_sec,\n count() AS n,\n avg((t.price - (m.best_bid + m.best_ask) / 2) / t.price * 10000) AS avg_markout_bps,\n sum((t.price - (m.best_bid + m.best_ask) / 2) * t.quantity) AS total_pnl\nFROM fx_trades t\nHORIZON JOIN market_data m ON (symbol)\n RANGE FROM 0s TO 10m STEP 5s AS h\nWHERE t.side = 'sell'\n AND t.timestamp IN '$yesterday'\nGROUP BY t.symbol, horizon_sec\nORDER BY t.symbol, horizon_sec" + }, + { + "query": "SELECT\n t.symbol,\n t.counterparty,\n h.offset / 1000000000 AS horizon_sec,\n count() AS n,\n avg(((m.best_bid + m.best_ask) / 2 - t.price) / t.price * 10000) AS avg_markout_bps,\n sum(t.quantity) AS total_volume\nFROM fx_trades t\nHORIZON JOIN market_data m ON (symbol)\n LIST (0, 1s, 5s, 10s, 30s, 1m, 5m) AS h\nWHERE t.side = 'buy'\n AND t.timestamp IN '$yesterday'\nGROUP BY t.symbol, t.counterparty, horizon_sec\nORDER BY t.symbol, t.counterparty, horizon_sec" + }, + { + "query": "SELECT\n t.symbol,\n t.ecn,\n t.passive,\n h.offset / 1000000000 AS horizon_sec,\n count() AS n,\n avg(((m.best_bid + m.best_ask) / 2 - t.price)\n / t.price * 10000) AS avg_markout_bps,\n avg((m.best_ask - m.best_bid)\n / ((m.best_bid + m.best_ask) / 2) * 10000) / 2 AS avg_half_spread_bps\nFROM fx_trades t\nHORIZON JOIN market_data m ON (symbol)\n RANGE FROM 0s TO 5m STEP 1s AS h\nWHERE t.side = 'buy'\n AND t.timestamp IN '$yesterday'\nGROUP BY t.symbol, t.ecn, t.passive, horizon_sec\nORDER BY t.symbol, t.ecn, t.passive, horizon_sec" } ] diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 9a73d99..6cd3338 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -216,6 +216,198 @@ describe("QuestDB Parser", () => { } }) + // --- HORIZON JOIN --- + + it("should parse HORIZON JOIN with RANGE form and ON clause", () => { + const result = parseToAst( + "SELECT h.offset / 1000000000 AS horizon_sec, t.symbol, avg((m.best_bid + m.best_ask) / 2) AS avg_mid FROM fx_trades AS t HORIZON JOIN market_data AS m ON (symbol) RANGE FROM 1s TO 60s STEP 1s AS h ORDER BY t.symbol, horizon_sec", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] + expect(stmt.type).toBe("select") + if (stmt.type === "select") { + const join = stmt.from?.[0].joins?.[0] + expect(join?.joinType).toBe("horizon") + expect(join?.on).toBeDefined() + expect(join?.horizonRange).toEqual({ + from: "1s", + to: "60s", + step: "1s", + }) + expect(join?.horizonAlias).toBe("h") + } + }) + + it("should parse HORIZON JOIN with LIST form and ON clause", () => { + const result = parseToAst( + "SELECT h.offset / 1000000000 AS horizon_sec, t.symbol, avg((m.best_bid + m.best_ask) / 2 - t.price) AS avg_markout FROM fx_trades AS t HORIZON JOIN market_data AS m ON (symbol) LIST (1s, 5s, 30s, 1m) AS h ORDER BY t.symbol, horizon_sec", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] + if (stmt.type === "select") { + const join = stmt.from?.[0].joins?.[0] + expect(join?.joinType).toBe("horizon") + expect(join?.horizonList).toEqual(["1s", "5s", "30s", "1m"]) + expect(join?.horizonAlias).toBe("h") + } + }) + + it("should parse HORIZON JOIN with negative offsets", () => { + const result = parseToAst( + "SELECT h.offset / 1000000000 AS horizon_sec, t.symbol, avg((m.best_bid + m.best_ask) / 2) AS avg_mid, count() AS sample_size FROM fx_trades AS t HORIZON JOIN market_data AS m ON (symbol) RANGE FROM -5s TO 5s STEP 1s AS h ORDER BY t.symbol, horizon_sec", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] + if (stmt.type === "select") { + const join = stmt.from?.[0].joins?.[0] + expect(join?.horizonRange).toEqual({ + from: "-5s", + to: "5s", + step: "1s", + }) + } + }) + + it("should parse HORIZON JOIN LIST with bare 0", () => { + const result = parseToAst( + "SELECT * FROM fx_trades t HORIZON JOIN market_data m ON (symbol) LIST (0, 1s, 5s, 30s, 1m, 5m) AS h", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] + if (stmt.type === "select") { + const join = stmt.from?.[0].joins?.[0] + expect(join?.horizonList).toEqual(["0", "1s", "5s", "30s", "1m", "5m"]) + } + }) + + it("should parse HORIZON JOIN RANGE without ON clause", () => { + const result = parseToAst( + "SELECT * FROM fx_trades t HORIZON JOIN market_data m RANGE FROM 1s TO 60s STEP 1s AS h", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] + if (stmt.type === "select") { + const join = stmt.from?.[0].joins?.[0] + expect(join?.joinType).toBe("horizon") + expect(join?.on).toBeUndefined() + expect(join?.horizonRange).toBeDefined() + } + }) + + it("should parse HORIZON JOIN LIST without ON clause", () => { + const result = parseToAst( + "SELECT * FROM fx_trades t HORIZON JOIN market_data m LIST (1s, 5s, 30s) AS h", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] + if (stmt.type === "select") { + const join = stmt.from?.[0].joins?.[0] + expect(join?.joinType).toBe("horizon") + expect(join?.on).toBeUndefined() + expect(join?.horizonList).toEqual(["1s", "5s", "30s"]) + } + }) + + it("should parse HORIZON JOIN LIST with single offset", () => { + const result = parseToAst( + "SELECT * FROM fx_trades t HORIZON JOIN market_data m ON (symbol) LIST (5s) AS h", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] + if (stmt.type === "select") { + const join = stmt.from?.[0].joins?.[0] + expect(join?.horizonList).toEqual(["5s"]) + } + }) + + it("should roundtrip HORIZON JOIN with RANGE form", () => { + const sql = + "SELECT h.offset FROM fx_trades t HORIZON JOIN market_data m ON (symbol) RANGE FROM 1s TO 60s STEP 1s AS h" + const result = parseToAst(sql) + expect(result.errors).toHaveLength(0) + const regenerated = toSql(result.ast[0]) + const reparsed = parseToAst(regenerated) + expect(reparsed.errors).toHaveLength(0) + }) + + it("should roundtrip HORIZON JOIN with LIST form", () => { + const sql = + "SELECT h.offset FROM fx_trades t HORIZON JOIN market_data m ON (symbol) LIST (1s, 5s, 30s, 1m) AS h" + const result = parseToAst(sql) + expect(result.errors).toHaveLength(0) + const regenerated = toSql(result.ast[0]) + const reparsed = parseToAst(regenerated) + expect(reparsed.errors).toHaveLength(0) + }) + + it("should roundtrip HORIZON JOIN RANGE without ON", () => { + const sql = + "SELECT * FROM fx_trades t HORIZON JOIN market_data m RANGE FROM 1s TO 60s STEP 1s AS h" + const result = parseToAst(sql) + expect(result.errors).toHaveLength(0) + const regenerated = toSql(result.ast[0]) + const reparsed = parseToAst(regenerated) + expect(reparsed.errors).toHaveLength(0) + }) + + it("should roundtrip HORIZON JOIN LIST without ON", () => { + const sql = + "SELECT * FROM fx_trades t HORIZON JOIN market_data m LIST (1s, 5s, 30s) AS h" + const result = parseToAst(sql) + expect(result.errors).toHaveLength(0) + const regenerated = toSql(result.ast[0]) + const reparsed = parseToAst(regenerated) + expect(reparsed.errors).toHaveLength(0) + }) + + it("should roundtrip HORIZON JOIN with negative offsets", () => { + const sql = + "SELECT * FROM fx_trades t HORIZON JOIN market_data m ON (symbol) RANGE FROM -5s TO 5s STEP 1s AS h" + const result = parseToAst(sql) + expect(result.errors).toHaveLength(0) + const regenerated = toSql(result.ast[0]) + const reparsed = parseToAst(regenerated) + expect(reparsed.errors).toHaveLength(0) + }) + + it("should parse EXPLAIN with HORIZON JOIN", () => { + const result = parseToAst( + "EXPLAIN SELECT h.offset / 1000000000 AS horizon_sec, t.symbol, avg((m.best_bid + m.best_ask) / 2) AS avg_mid FROM fx_trades AS t HORIZON JOIN market_data AS m ON (symbol) RANGE FROM 1s TO 60s STEP 1s AS h ORDER BY t.symbol, horizon_sec", + ) + expect(result.errors).toHaveLength(0) + expect(result.ast[0].type).toBe("explain") + }) + + it("should parse HORIZON JOIN with RANGE FROM 0s", () => { + const result = parseToAst( + "SELECT * FROM fx_trades t HORIZON JOIN market_data m ON (symbol) RANGE FROM 0s TO 5m STEP 1s AS h", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] + if (stmt.type === "select") { + const join = stmt.from?.[0].joins?.[0] + expect(join?.horizonRange).toEqual({ + from: "0s", + to: "5m", + step: "1s", + }) + } + }) + + it("should parse HORIZON JOIN with WHERE and GROUP BY", () => { + const result = parseToAst( + "SELECT t.symbol, h.offset / 1000000000 AS horizon_sec, count() AS n FROM fx_trades t HORIZON JOIN market_data m ON (symbol) RANGE FROM 0s TO 5m STEP 1s AS h WHERE t.timestamp IN '$yesterday' GROUP BY t.symbol, horizon_sec ORDER BY t.symbol, horizon_sec", + ) + expect(result.errors).toHaveLength(0) + const stmt = result.ast[0] + expect(stmt.type).toBe("select") + if (stmt.type === "select") { + expect(stmt.where).toBeDefined() + expect(stmt.groupBy).toBeDefined() + expect(stmt.orderBy).toBeDefined() + } + }) + it("should parse INSERT statement", () => { const result = parseToAst( "INSERT INTO trades (symbol, price) VALUES ('BTC', 100)", @@ -5856,9 +6048,12 @@ orders PIVOT (sum(amount) FOR status IN ('open'))` }) }) - describe("'horizon' and 'step' as non-reserved identifier keywords", () => { - it("should parse 'horizon' as a table name", () => { - const result = parseToAst("SELECT * FROM horizon") + describe("'horizon' and 'step' as identifier keywords", () => { + // 'horizon' is not an identifier keyword (like WINDOW, ASOF, SPLICE) + // due to HORIZON JOIN. It must be quoted to use as an identifier. + // 'step' remains an identifier keyword. + it("should parse quoted 'horizon' as a table name", () => { + const result = parseToAst('SELECT * FROM "horizon"') expect(result.errors).toHaveLength(0) const stmt = result.ast[0] as AST.SelectStatement const tableRef = stmt.from?.[0] as AST.TableRef @@ -5870,8 +6065,8 @@ orders PIVOT (sum(amount) FOR status IN ('open'))` expect(result.errors).toHaveLength(0) }) - it("should parse 'horizon' as a column name", () => { - const result = parseToAst("SELECT horizon FROM trades") + it("should parse quoted 'horizon' as a column name", () => { + const result = parseToAst('SELECT "horizon" FROM trades') expect(result.errors).toHaveLength(0) }) @@ -5883,8 +6078,8 @@ orders PIVOT (sum(amount) FOR status IN ('open'))` expect((tableRef.table as AST.QualifiedName).parts).toEqual(["step"]) }) - it("should round-trip 'horizon' through toSql", () => { - const sql = "SELECT * FROM horizon" + it("should round-trip quoted 'horizon' through toSql", () => { + const sql = 'SELECT * FROM "horizon"' const result = parseToAst(sql) expect(result.errors).toHaveLength(0) expect(toSql(result.ast[0])).toBe(sql)