Skip to content

Commit 3800dd7

Browse files
authored
add horizon join support (#9)
1 parent 2675c29 commit 3800dd7

File tree

12 files changed

+569
-14
lines changed

12 files changed

+569
-14
lines changed

src/autocomplete/provider.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,7 @@ const TABLE_NAME_TOKENS = new Set([
4343
* Built once at provider creation time so per-request ranking is O(N×M)
4444
* rather than O(N×C).
4545
*/
46-
function buildColumnIndex(
47-
schema: SchemaInfo,
48-
): Map<string, Set<string>> {
46+
function buildColumnIndex(schema: SchemaInfo): Map<string, Set<string>> {
4947
const index = new Map<string, Set<string>>()
5048
for (const table of schema.tables) {
5149
const key = table.name.toLowerCase()
@@ -98,8 +96,8 @@ function rankTableSuggestions(
9896
if (score === undefined) continue
9997
s.priority =
10098
score === referencedColumns.size
101-
? SuggestionPriority.High // full match
102-
: SuggestionPriority.Medium // partial match
99+
? SuggestionPriority.High // full match
100+
: SuggestionPriority.Medium // partial match
103101
}
104102
}
105103

src/parser/ast.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,7 @@ export interface JoinClause extends AstNode {
840840
| "lt"
841841
| "splice"
842842
| "window"
843+
| "horizon"
843844
outer?: boolean
844845
table: TableRef
845846
on?: Expression
@@ -849,6 +850,12 @@ export interface JoinClause extends AstNode {
849850
range?: { start: WindowJoinBound; end: WindowJoinBound }
850851
/** INCLUDE/EXCLUDE PREVAILING clause for WINDOW JOIN */
851852
prevailing?: "include" | "exclude"
853+
/** RANGE FROM/TO/STEP for HORIZON JOIN */
854+
horizonRange?: { from: string; to: string; step: string }
855+
/** LIST offsets for HORIZON JOIN */
856+
horizonList?: string[]
857+
/** Alias for the horizon pseudo-table */
858+
horizonAlias?: string
852859
}
853860

854861
export interface WindowJoinBound extends AstNode {

src/parser/cst-types.d.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ export type JoinClauseCstChildren = {
259259
asofLtJoin?: AsofLtJoinCstNode[];
260260
spliceJoin?: SpliceJoinCstNode[];
261261
windowJoin?: WindowJoinCstNode[];
262+
horizonJoin?: HorizonJoinCstNode[];
262263
standardJoin?: StandardJoinCstNode[];
263264
};
264265

@@ -311,6 +312,42 @@ export type WindowJoinCstChildren = {
311312
Exclude?: IToken[];
312313
};
313314

315+
export interface HorizonJoinCstNode extends CstNode {
316+
name: "horizonJoin";
317+
children: HorizonJoinCstChildren;
318+
}
319+
320+
export type HorizonJoinCstChildren = {
321+
Horizon: IToken[];
322+
Join: IToken[];
323+
tableName: TableNameCstNode[];
324+
As?: (IToken)[];
325+
identifier?: (IdentifierCstNode)[];
326+
Identifier?: IToken[];
327+
On?: IToken[];
328+
expression?: ExpressionCstNode[];
329+
Range?: IToken[];
330+
From?: IToken[];
331+
horizonOffset?: (HorizonOffsetCstNode)[];
332+
To?: IToken[];
333+
Step?: IToken[];
334+
List?: IToken[];
335+
LParen?: IToken[];
336+
Comma?: IToken[];
337+
RParen?: IToken[];
338+
};
339+
340+
export interface HorizonOffsetCstNode extends CstNode {
341+
name: "horizonOffset";
342+
children: HorizonOffsetCstChildren;
343+
}
344+
345+
export type HorizonOffsetCstChildren = {
346+
Minus?: IToken[];
347+
DurationLiteral?: IToken[];
348+
NumberLiteral?: IToken[];
349+
};
350+
314351
export interface StandardJoinCstNode extends CstNode {
315352
name: "standardJoin";
316353
children: StandardJoinCstChildren;
@@ -2417,6 +2454,8 @@ export interface ICstNodeVisitor<IN, OUT> extends ICstVisitor<IN, OUT> {
24172454
asofLtJoin(children: AsofLtJoinCstChildren, param?: IN): OUT;
24182455
spliceJoin(children: SpliceJoinCstChildren, param?: IN): OUT;
24192456
windowJoin(children: WindowJoinCstChildren, param?: IN): OUT;
2457+
horizonJoin(children: HorizonJoinCstChildren, param?: IN): OUT;
2458+
horizonOffset(children: HorizonOffsetCstChildren, param?: IN): OUT;
24202459
standardJoin(children: StandardJoinCstChildren, param?: IN): OUT;
24212460
windowJoinBound(children: WindowJoinBoundCstChildren, param?: IN): OUT;
24222461
durationExpression(children: DurationExpressionCstChildren, param?: IN): OUT;

src/parser/lexer.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ import {
101101
Group,
102102
Groups,
103103
Header,
104+
Horizon,
104105
Hour,
105106
Hours,
106107
Http,
@@ -229,6 +230,7 @@ import {
229230
Splice,
230231
Squash,
231232
StandardConformingStrings,
233+
Step,
232234
Start,
233235
StatisticsEnabled,
234236
String,
@@ -389,6 +391,7 @@ export {
389391
Group,
390392
Groups,
391393
Header,
394+
Horizon,
392395
Hour,
393396
Hours,
394397
Http,
@@ -517,6 +520,7 @@ export {
517520
Splice,
518521
Squash,
519522
StandardConformingStrings,
523+
Step,
520524
Start,
521525
StatisticsEnabled,
522526
String,

src/parser/parser.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,8 @@ import {
310310
LongLiteral,
311311
DecimalLiteral,
312312
Window,
313+
Horizon,
314+
Step,
313315
Ignore,
314316
Respect,
315317
Nulls,
@@ -818,6 +820,7 @@ class QuestDBParser extends CstParser {
818820
{ ALT: () => this.SUBRULE(this.asofLtJoin) },
819821
{ ALT: () => this.SUBRULE(this.spliceJoin) },
820822
{ ALT: () => this.SUBRULE(this.windowJoin) },
823+
{ ALT: () => this.SUBRULE(this.horizonJoin) },
821824
{ ALT: () => this.SUBRULE(this.standardJoin) },
822825
])
823826
})
@@ -879,6 +882,72 @@ class QuestDBParser extends CstParser {
879882
})
880883
})
881884

885+
// HORIZON JOIN: tableName alias [ON expr] (RANGE FROM/TO/STEP | LIST (...)) AS alias
886+
// Uses tableName + custom alias instead of tableRef to avoid ambiguity
887+
// between implicit keyword aliases and RANGE/LIST/ON keywords.
888+
private horizonJoin = this.RULE("horizonJoin", () => {
889+
this.CONSUME(Horizon)
890+
this.CONSUME(Join)
891+
this.SUBRULE(this.tableName)
892+
// Table alias (mandatory): explicit (AS <id>) or implicit (bare Identifier only, not keywords)
893+
this.OR1([
894+
{
895+
ALT: () => {
896+
this.CONSUME(As)
897+
this.SUBRULE(this.identifier)
898+
},
899+
},
900+
{
901+
// Implicit alias: only base Identifier, never keywords like RANGE/LIST
902+
ALT: () => this.CONSUME(Identifier),
903+
},
904+
])
905+
this.OPTION(() => {
906+
this.CONSUME(On)
907+
this.SUBRULE(this.expression)
908+
})
909+
this.OR2([
910+
{
911+
// RANGE FROM <offset> TO <offset> STEP <offset> AS <alias>
912+
ALT: () => {
913+
this.CONSUME(Range)
914+
this.CONSUME(From)
915+
this.SUBRULE(this.horizonOffset)
916+
this.CONSUME(To)
917+
this.SUBRULE1(this.horizonOffset)
918+
this.CONSUME(Step)
919+
this.SUBRULE2(this.horizonOffset)
920+
this.CONSUME1(As)
921+
this.SUBRULE1(this.identifier)
922+
},
923+
},
924+
{
925+
// LIST (<offset>, ...) AS <alias>
926+
ALT: () => {
927+
this.CONSUME(List)
928+
this.CONSUME(LParen)
929+
this.SUBRULE3(this.horizonOffset)
930+
this.MANY(() => {
931+
this.CONSUME(Comma)
932+
this.SUBRULE4(this.horizonOffset)
933+
})
934+
this.CONSUME(RParen)
935+
this.CONSUME2(As)
936+
this.SUBRULE2(this.identifier)
937+
},
938+
},
939+
])
940+
})
941+
942+
// Horizon offset value: optional minus sign + DurationLiteral | NumberLiteral
943+
private horizonOffset = this.RULE("horizonOffset", () => {
944+
this.OPTION(() => this.CONSUME(Minus))
945+
this.OR([
946+
{ ALT: () => this.CONSUME(DurationLiteral) },
947+
{ ALT: () => this.CONSUME(NumberLiteral) },
948+
])
949+
})
950+
882951
// Standard joins: (INNER | LEFT [OUTER] | RIGHT [OUTER] | FULL [OUTER] | CROSS)? JOIN + ON
883952
private standardJoin = this.RULE("standardJoin", () => {
884953
this.OPTION(() => {

src/parser/toSql.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,24 @@ function joinToSql(join: AST.JoinClause): string {
373373
)
374374
}
375375

376+
if (join.horizonRange) {
377+
parts.push("RANGE FROM")
378+
parts.push(join.horizonRange.from)
379+
parts.push("TO")
380+
parts.push(join.horizonRange.to)
381+
parts.push("STEP")
382+
parts.push(join.horizonRange.step)
383+
}
384+
385+
if (join.horizonList) {
386+
parts.push("LIST (" + join.horizonList.join(", ") + ")")
387+
}
388+
389+
if (join.horizonAlias) {
390+
parts.push("AS")
391+
parts.push(escapeIdentifier(join.horizonAlias))
392+
}
393+
376394
return parts.join(" ")
377395
}
378396

src/parser/tokens.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,9 @@ export const IDENTIFIER_KEYWORD_NAMES = new globalThis.Set([
229229
"Capacity",
230230
"Cancel",
231231
"Prevailing",
232+
"Range",
232233
"Writer",
233234
"Materialized",
234-
"Range",
235235
"Snapshot",
236236
"Unlock",
237237
"Refresh",
@@ -350,7 +350,6 @@ export const IDENTIFIER_KEYWORD_NAMES = new globalThis.Set([
350350
"Every",
351351
"Prev",
352352
"Linear",
353-
"Horizon",
354353
"Step",
355354
])
356355

@@ -481,6 +480,7 @@ export const Grant = getToken("Grant")
481480
export const Group = getToken("Group")
482481
export const Groups = getToken("Groups")
483482
export const Header = getToken("Header")
483+
export const Horizon = getToken("Horizon")
484484
export const Http = getToken("Http")
485485
export const If = getToken("If")
486486
export const Ignore = getToken("Ignore")
@@ -581,6 +581,7 @@ export const Snapshot = getToken("Snapshot")
581581
export const Splice = getToken("Splice")
582582
export const Squash = getToken("Squash")
583583
export const StandardConformingStrings = getToken("StandardConformingStrings")
584+
export const Step = getToken("Step")
584585
export const Start = getToken("Start")
585586
export const StatisticsEnabled = getToken("StatisticsEnabled")
586587
export const Suspend = getToken("Suspend")

src/parser/visitor.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ import type {
139139
SimpleSelectCstChildren,
140140
SnapshotStatementCstChildren,
141141
SpliceJoinCstChildren,
142+
HorizonJoinCstChildren,
143+
HorizonOffsetCstChildren,
142144
StandardJoinCstChildren,
143145
StatementCstChildren,
144146
StatementsCstChildren,
@@ -659,6 +661,7 @@ class QuestDBVisitor extends BaseVisitor {
659661
if (ctx.asofLtJoin) return this.visit(ctx.asofLtJoin) as AST.JoinClause
660662
if (ctx.spliceJoin) return this.visit(ctx.spliceJoin) as AST.JoinClause
661663
if (ctx.windowJoin) return this.visit(ctx.windowJoin) as AST.JoinClause
664+
if (ctx.horizonJoin) return this.visit(ctx.horizonJoin) as AST.JoinClause
662665
return this.visit(ctx.standardJoin!) as AST.JoinClause
663666
}
664667

@@ -711,6 +714,60 @@ class QuestDBVisitor extends BaseVisitor {
711714
return result
712715
}
713716

717+
horizonJoin(ctx: HorizonJoinCstChildren): AST.JoinClause {
718+
// Build TableRef from tableName + alias
719+
const tableRef: AST.TableRef = {
720+
type: "tableRef",
721+
table: this.visit(ctx.tableName) as AST.QualifiedName,
722+
}
723+
if (ctx.Identifier) {
724+
// Implicit alias (bare identifier, not a keyword)
725+
tableRef.alias = ctx.Identifier[0].image
726+
} else if (ctx.identifier && ctx.identifier.length > 1) {
727+
// Explicit alias (AS <id>): first identifier is table alias, last is horizon alias
728+
tableRef.alias = (
729+
this.visit(ctx.identifier[0]) as AST.QualifiedName
730+
).parts[0]
731+
}
732+
733+
const result: AST.JoinClause = {
734+
type: "join",
735+
joinType: "horizon",
736+
table: tableRef,
737+
}
738+
if (ctx.expression) {
739+
result.on = this.visit(ctx.expression) as AST.Expression
740+
}
741+
if (ctx.Range) {
742+
// RANGE form: horizonOffset[0]=from, [1]=to, [2]=step
743+
const offsets = ctx.horizonOffset!
744+
result.horizonRange = {
745+
from: this.visit(offsets[0]) as string,
746+
to: this.visit(offsets[1]) as string,
747+
step: this.visit(offsets[2]) as string,
748+
}
749+
} else if (ctx.List) {
750+
// LIST form: all horizonOffset children are list entries
751+
result.horizonList = ctx.horizonOffset!.map(
752+
(o) => this.visit(o) as string,
753+
)
754+
}
755+
// Horizon alias is always the last identifier
756+
if (ctx.identifier) {
757+
const lastId = ctx.identifier[ctx.identifier.length - 1]
758+
result.horizonAlias = (this.visit(lastId) as AST.QualifiedName).parts[0]
759+
}
760+
return result
761+
}
762+
763+
horizonOffset(ctx: HorizonOffsetCstChildren): string {
764+
const sign = ctx.Minus ? "-" : ""
765+
if (ctx.DurationLiteral) {
766+
return sign + ctx.DurationLiteral[0].image
767+
}
768+
return sign + ctx.NumberLiteral![0].image
769+
}
770+
714771
standardJoin(ctx: StandardJoinCstChildren): AST.JoinClause {
715772
const result: AST.JoinClause = {
716773
type: "join",

0 commit comments

Comments
 (0)