From 811349e95dcc9521add8faeec682d7a7756f8cee Mon Sep 17 00:00:00 2001 From: javier Date: Wed, 22 Apr 2026 23:19:17 +0200 Subject: [PATCH 1/3] Add SUBSAMPLE keyword and algorithm constants Add subsample keyword and lttb, m4, minmax constants for syntax highlighting and autocomplete. Fix pre-existing sort disorders in keywords.ts. --- src/grammar/constants.ts | 3 +++ src/grammar/keywords.ts | 13 +++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/grammar/constants.ts b/src/grammar/constants.ts index 43c807a..16d8854 100644 --- a/src/grammar/constants.ts +++ b/src/grammar/constants.ts @@ -27,9 +27,11 @@ export const constants: string[] = [ "jwk", "linear", "local", + "lttb", "lz4", "lz4_raw", "lzo", + "m4", "manual", "max_identifier_length", "microsecond", @@ -37,6 +39,7 @@ export const constants: string[] = [ "millennium", "millisecond", "milliseconds", + "minmax", "minute", "minutes", "month", diff --git a/src/grammar/keywords.ts b/src/grammar/keywords.ts index 07a29bf..6a6c8ee 100644 --- a/src/grammar/keywords.ts +++ b/src/grammar/keywords.ts @@ -12,8 +12,8 @@ export const keywords: string[] = [ "as", "asof", "assume", - "attach", "atomic", + "attach", "backup", "base", "batch", @@ -31,10 +31,10 @@ export const keywords: string[] = [ "checkpoint", "column", "columns", + "commitLag", "compile", "compression_codec", "compression_level", - "commitLag", "convert", "copy", "create", @@ -97,8 +97,8 @@ export const keywords: string[] = [ "keep", "key", "keys", - "latest", "lateral", + "latest", "left", "length", "level", @@ -131,8 +131,8 @@ export const keywords: string[] = [ "param", "parameters", "parquet_version", - "partition_by", "partition", + "partition_by", "partitions", "password", "period", @@ -148,8 +148,8 @@ export const keywords: string[] = [ "raw_array_encoding", "references", "refresh", - "release", "reindex", + "release", "remove", "rename", "repair", @@ -171,9 +171,10 @@ export const keywords: string[] = [ "splice", "squash", "start", + "statistics_enabled", "step", "storage", - "statistics_enabled", + "subsample", "suspend", "system", "table", From 7066f1acf375f0819891db202c8f83a39bc019c1 Mon Sep 17 00:00:00 2001 From: javier Date: Thu, 23 Apr 2026 11:27:40 +0200 Subject: [PATCH 2/3] Add uniform and cadence constants --- src/grammar/constants.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/grammar/constants.ts b/src/grammar/constants.ts index 16d8854..a10f19a 100644 --- a/src/grammar/constants.ts +++ b/src/grammar/constants.ts @@ -1,6 +1,7 @@ export const constants: string[] = [ "asc", "brotli", + "cadence", "century", "complete", "datestyle", @@ -71,6 +72,7 @@ export const constants: string[] = [ "transaction_isolation", "true", "uncompressed", + "uniform", "week", "weeks", "year", From bf0e7541d3e11b5d10510e45dc479f438e9e8481 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 21 Sep 2026 12:52:52 +0300 Subject: [PATCH 3/3] subsample parser, autocomplete, formatter updates --- src/autocomplete/content-assist.ts | 35 ++++++ src/autocomplete/provider.ts | 18 +++ src/formatter/phrases.ts | 1 + src/grammar/constants.ts | 1 + src/grammar/functions.ts | 6 + src/parser/ast.ts | 16 +++ src/parser/cst-types.d.ts | 17 +++ src/parser/lexer.ts | 2 + src/parser/parser.ts | 22 ++++ src/parser/toSql.ts | 6 + src/parser/tokens.ts | 7 ++ src/parser/visitor.ts | 18 +++ tests/autocomplete.test.ts | 61 +++++++++ tests/formatter/capitalize.test.ts | 11 ++ tests/formatter/fixtures.ts | 35 ++++++ tests/lexer.test.ts | 24 ++++ tests/parser.test.ts | 196 +++++++++++++++++++++++++++++ 17 files changed, 476 insertions(+) diff --git a/src/autocomplete/content-assist.ts b/src/autocomplete/content-assist.ts index b420d4a..1de5662 100644 --- a/src/autocomplete/content-assist.ts +++ b/src/autocomplete/content-assist.ts @@ -110,6 +110,12 @@ export interface ContentAssistResult { * clauses can be autocompleted. See `contextKeywordSuggestions`. */ contextKeywords: string[] + /** + * Function names the grammar accepts only through `identifier` at the cursor, + * such as the SUBSAMPLE methods. Rendered as functions, not keywords. + * See `contextFunctionSuggestions`. + */ + contextFunctions: string[] } // ============================================================================= @@ -841,6 +847,29 @@ interface ComputeResult extends CategoryFlags { nextTokenTypes: TokenType[] isConditionContext: boolean contextKeywords: string[] + contextFunctions: string[] +} + +// SUBSAMPLE methods (questdb/questdb#7013). The grammar reads the method +// through `identifier` so the names stay non-reserved; suggest them here. +const SUBSAMPLE_METHODS = ["uniform", "cadence", "m4", "minmax", "lttb", "sdt"] + +/** + * Function names to suggest where the grammar only accepts them through the + * generic `identifier` sub-rule. Today that is the method after SUBSAMPLE. + */ +function contextFunctionSuggestions( + tokens: IToken[], + suggestions: ContentAssistSuggestion[], +): string[] { + const last = tokens[tokens.length - 1]?.tokenType.name + if (last !== "Subsample") return [] + const inSubsample = suggestions.some( + (s) => + s.nextTokenType.name === "IdentifierKeyword" && + s.ruleStack.includes("subsampleClause"), + ) + return inSubsample ? [...SUBSAMPLE_METHODS] : [] } // Category names valid inside SHOW CREATE DATABASE (INCLUDE|EXCLUDE) ( ... ). @@ -999,12 +1028,14 @@ function computeSuggestions(tokens: IToken[]): ComputeResult { ) const contextKeywords = contextKeywordSuggestions(tokens, suggestions) + const contextFunctions = contextFunctionSuggestions(tokens, suggestions) return { nextTokenTypes: result, ...flags, isConditionContext, contextKeywords, + contextFunctions, } } @@ -1146,6 +1177,7 @@ export function getContentAssist( referencedColumns: new Set(), isConditionContext: false, contextKeywords: [], + contextFunctions: [], } } } @@ -1180,6 +1212,7 @@ export function getContentAssist( let suggestTableValuedFunctions = false let isConditionContext = false let contextKeywords: string[] = [] + let contextFunctions: string[] = [] try { const computed = computeSuggestions(tokensForAssist) nextTokenTypes = computed.nextTokenTypes @@ -1191,6 +1224,7 @@ export function getContentAssist( suggestTableValuedFunctions = computed.suggestTableValuedFunctions isConditionContext = computed.isConditionContext contextKeywords = computed.contextKeywords + contextFunctions = computed.contextFunctions } catch (e) { // If content assist fails, return empty suggestions // This can happen with malformed input @@ -1283,6 +1317,7 @@ export function getContentAssist( referencedColumns, isConditionContext, contextKeywords, + contextFunctions, } } diff --git a/src/autocomplete/provider.ts b/src/autocomplete/provider.ts index 3b0cddc..1bcd2c5 100644 --- a/src/autocomplete/provider.ts +++ b/src/autocomplete/provider.ts @@ -195,6 +195,7 @@ export function createAutocompleteProvider( referencedColumns, isConditionContext, contextKeywords, + contextFunctions, } = getContentAssist(query, cursorOffset) // Merge CTE columns into the schema so getColumnsInScope() can find them @@ -286,6 +287,23 @@ export function createAutocompleteProvider( }) } } + if (contextFunctions.length > 0) { + const seen = new Set(suggestions.map((s) => s.label.toLowerCase())) + for (const fn of contextFunctions) { + if (seen.has(fn)) continue + if (isMidWord && partialPrefix && !fn.startsWith(partialPrefix)) { + continue + } + seen.add(fn) + suggestions.push({ + label: fn, + kind: SuggestionKind.Function, + insertText: fn, + filterText: fn, + priority: SuggestionPriority.Medium, + }) + } + } if (suggestTables) { rankTableSuggestions(suggestions, referencedColumns, columnIndex) diff --git a/src/formatter/phrases.ts b/src/formatter/phrases.ts index cde04fd..92276e4 100644 --- a/src/formatter/phrases.ts +++ b/src/formatter/phrases.ts @@ -34,6 +34,7 @@ const selectPhrases: Phrase[] = [ ["Latest", "By"], ["Sample", "By"], ["Group", "By"], + ["Subsample"], ["Order", "By"], ["Limit"], ["Window"], diff --git a/src/grammar/constants.ts b/src/grammar/constants.ts index b198f7b..f65760e 100644 --- a/src/grammar/constants.ts +++ b/src/grammar/constants.ts @@ -78,6 +78,7 @@ export const constants: string[] = [ "rest", "rle_dictionary", "schema", + "sdt", "search_path", "second", "seconds", diff --git a/src/grammar/functions.ts b/src/grammar/functions.ts index 1ff5d08..646205b 100644 --- a/src/grammar/functions.ts +++ b/src/grammar/functions.ts @@ -286,17 +286,23 @@ export const aggregateFunctions: string[] = [ ] export const windowFunctions: string[] = [ + "cadence", "cume_dist", "dense_rank", "first_value", "lag", "last_value", "lead", + "lttb", + "m4", + "minmax", "nth_value", "ntile", "percent_rank", "rank", "row_number", + "sdt", + "uniform", ] export const tableValuedFunctions: string[] = [ diff --git a/src/parser/ast.ts b/src/parser/ast.ts index 6f1eabc..71bd539 100644 --- a/src/parser/ast.ts +++ b/src/parser/ast.ts @@ -78,6 +78,8 @@ export interface SelectStatement extends AstNode { pivot?: PivotClause /** Named window definitions: SELECT ... WINDOW w AS (...) [, w2 AS (...)] */ namedWindows?: NamedWindow[] + /** SUBSAMPLE method(args): server-side downsampling, before ORDER BY */ + subsample?: SubsampleClause orderBy?: OrderByItem[] limit?: LimitClause setOperations?: SetOperation[] @@ -1172,6 +1174,20 @@ export interface LatestOnClause extends AstNode { partitionBy: QualifiedName[] } +/** + * SUBSAMPLE clause: `SELECT ... SUBSAMPLE lttb(price, 2000)`. + * Reduces the result to a representative subset of its original rows. + * Methods as of QuestDB Sep 2026: uniform(points), cadence(stride[, seed]), + * m4(column, points), minmax(column, points), lttb(column, points[, gap]), + * sdt(column, compdev). The parser accepts any method name; the server + * validates it. + */ +export interface SubsampleClause extends AstNode { + type: "subsample" + method: string + args: Expression[] +} + export interface OrderByItem extends AstNode { type: "orderByItem" expression: Expression diff --git a/src/parser/cst-types.d.ts b/src/parser/cst-types.d.ts index fa4e3ff..fdb40df 100644 --- a/src/parser/cst-types.d.ts +++ b/src/parser/cst-types.d.ts @@ -129,6 +129,7 @@ export type SimpleSelectCstChildren = { pivotBody?: PivotBodyCstNode[]; RParen?: IToken[]; windowClause?: WindowClauseCstNode[]; + subsampleClause?: SubsampleClauseCstNode[]; orderByClause?: OrderByClauseCstNode[]; limitClause?: LimitClauseCstNode[]; }; @@ -256,6 +257,7 @@ export type ImplicitSelectBodyCstChildren = { sampleByClause?: SampleByClauseCstNode[]; latestOnClause?: LatestOnClauseCstNode[]; groupByClause?: GroupByClauseCstNode[]; + subsampleClause?: SubsampleClauseCstNode[]; orderByClause?: OrderByClauseCstNode[]; limitClause?: LimitClauseCstNode[]; }; @@ -510,6 +512,20 @@ export type LatestOnClauseCstChildren = { Comma?: (IToken)[]; }; +export interface SubsampleClauseCstNode extends CstNode { + name: "subsampleClause"; + children: SubsampleClauseCstChildren; +} + +export type SubsampleClauseCstChildren = { + Subsample: IToken[]; + identifier: IdentifierCstNode[]; + LParen: IToken[]; + expression: (ExpressionCstNode)[]; + Comma?: IToken[]; + RParen: IToken[]; +}; + export interface FillClauseCstNode extends CstNode { name: "fillClause"; children: FillClauseCstChildren; @@ -2974,6 +2990,7 @@ export interface ICstNodeVisitor extends ICstVisitor { whereClause(children: WhereClauseCstChildren, param?: IN): OUT; sampleByClause(children: SampleByClauseCstChildren, param?: IN): OUT; latestOnClause(children: LatestOnClauseCstChildren, param?: IN): OUT; + subsampleClause(children: SubsampleClauseCstChildren, param?: IN): OUT; fillClause(children: FillClauseCstChildren, param?: IN): OUT; fillValue(children: FillValueCstChildren, param?: IN): OUT; alignToClause(children: AlignToClauseCstChildren, param?: IN): OUT; diff --git a/src/parser/lexer.ts b/src/parser/lexer.ts index 999ff8e..c7788e3 100644 --- a/src/parser/lexer.ts +++ b/src/parser/lexer.ts @@ -303,6 +303,7 @@ import { Brotli, Lzo, Storage, + Subsample, Policy, Local, Remote, @@ -636,6 +637,7 @@ export { Brotli, Lzo, Storage, + Subsample, Policy, Local, Remote, diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 998bde9..d6b90da 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -370,6 +370,7 @@ import { Default, // Storage policy tokens Storage, + Subsample, Policy, Local, Remote, @@ -661,6 +662,9 @@ class QuestDBParser extends CstParser { this.LA(1).tokenType === Window && this.LA(2).tokenType !== Join, DEF: () => this.SUBRULE(this.windowClause), }) + // SUBSAMPLE method(args): after WHERE / LATEST ON / SAMPLE BY / GROUP BY / + // WINDOW and before ORDER BY / LIMIT (questdb/questdb#7013). + this.OPTION8(() => this.SUBRULE(this.subsampleClause)) this.OPTION6(() => this.SUBRULE(this.orderByClause)) this.OPTION7(() => this.SUBRULE(this.limitClause)) }) @@ -859,6 +863,7 @@ class QuestDBParser extends CstParser { this.OPTION1(() => this.SUBRULE(this.sampleByClause)) this.OPTION2(() => this.SUBRULE(this.latestOnClause)) this.OPTION3(() => this.SUBRULE(this.groupByClause)) + this.OPTION6(() => this.SUBRULE(this.subsampleClause)) this.OPTION4(() => this.SUBRULE(this.orderByClause)) this.OPTION5(() => this.SUBRULE(this.limitClause)) }) @@ -1292,6 +1297,23 @@ class QuestDBParser extends CstParser { ]) }) + // SUBSAMPLE method(arg, ...): server-side downsampling that returns original + // rows (uniform, cadence, m4, minmax, lttb, sdt). The method name goes + // through `identifier`, as QuestDB's parser reads any word here and the + // optimiser rejects unknown methods, so the six names stay usable as plain + // identifiers and window functions elsewhere. + private subsampleClause = this.RULE("subsampleClause", () => { + this.CONSUME(Subsample) + this.SUBRULE(this.identifier) + this.CONSUME(LParen) + this.SUBRULE(this.expression) + this.MANY(() => { + this.CONSUME(Comma) + this.SUBRULE1(this.expression) + }) + this.CONSUME(RParen) + }) + private fillClause = this.RULE("fillClause", () => { this.CONSUME(Fill) this.CONSUME(LParen) diff --git a/src/parser/toSql.ts b/src/parser/toSql.ts index 28d9141..53af9c3 100644 --- a/src/parser/toSql.ts +++ b/src/parser/toSql.ts @@ -263,6 +263,12 @@ function selectToSql(stmt: AST.SelectStatement): string { parts.push(stmt.namedWindows.map(namedWindowToSql).join(", ")) } + // SUBSAMPLE method(args) + if (stmt.subsample) { + const args = stmt.subsample.args.map(expressionToSql).join(", ") + parts.push(`SUBSAMPLE ${escapeIdentifier(stmt.subsample.method)}(${args})`) + } + // ORDER BY if (stmt.orderBy && stmt.orderBy.length > 0) { parts.push("ORDER BY") diff --git a/src/parser/tokens.ts b/src/parser/tokens.ts index 47342bd..604ff21 100644 --- a/src/parser/tokens.ts +++ b/src/parser/tokens.ts @@ -349,6 +349,12 @@ export const IDENTIFIER_KEYWORD_NAMES = new globalThis.Set([ "Pgwire", "Storage", "Policy", + "Cadence", + "Lttb", + "M4", + "Minmax", + "Sdt", + "Uniform", // Window frame keywords "Row", "Rows", @@ -704,6 +710,7 @@ export const Lzo = getToken("Lzo") // Storage policy keywords / constants export const Storage = getToken("Storage") +export const Subsample = getToken("Subsample") export const Policy = getToken("Policy") export const Local = getToken("Local") export const Remote = getToken("Remote") diff --git a/src/parser/visitor.ts b/src/parser/visitor.ts index 18337e6..00bba98 100644 --- a/src/parser/visitor.ts +++ b/src/parser/visitor.ts @@ -144,6 +144,7 @@ import type { RevokeAssumeServiceAccountStatementCstChildren, RevokeStatementCstChildren, SampleByClauseCstChildren, + SubsampleClauseCstChildren, SelectItemCstChildren, SelectListCstChildren, SelectBodyCstChildren, @@ -491,6 +492,10 @@ class QuestDBVisitor extends BaseVisitor { result.namedWindows = this.visit(ctx.windowClause) as AST.NamedWindow[] } + if (ctx.subsampleClause) { + result.subsample = this.visit(ctx.subsampleClause) as AST.SubsampleClause + } + if (ctx.orderByClause) { result.orderBy = this.visit(ctx.orderByClause) as AST.OrderByItem[] } @@ -697,6 +702,11 @@ class QuestDBVisitor extends BaseVisitor { if (ctx.groupByClause) { result.groupBy = this.visitSafe(ctx.groupByClause) as AST.Expression[] } + if (ctx.subsampleClause) { + result.subsample = this.visitSafe( + ctx.subsampleClause, + ) as AST.SubsampleClause + } if (ctx.orderByClause) { result.orderBy = this.visitSafe(ctx.orderByClause) as AST.OrderByItem[] } @@ -1068,6 +1078,14 @@ class QuestDBVisitor extends BaseVisitor { } } + subsampleClause(ctx: SubsampleClauseCstChildren): AST.SubsampleClause { + return { + type: "subsample", + method: (this.visit(ctx.identifier) as AST.QualifiedName).parts[0], + args: ctx.expression.map((e: CstNode) => this.visit(e) as AST.Expression), + } + } + // ========================================================================== // GROUP BY, HAVING, ORDER BY, LIMIT // ========================================================================== diff --git a/tests/autocomplete.test.ts b/tests/autocomplete.test.ts index d6f74e7..1500126 100644 --- a/tests/autocomplete.test.ts +++ b/tests/autocomplete.test.ts @@ -4843,3 +4843,64 @@ describe("Position-typed suggestions — by statement type", () => { }) }) }) + +// ============================================================================= +// SUBSAMPLE clause (questdb/questdb#7013) +// ============================================================================= +describe("SUBSAMPLE autocomplete", () => { + const methods = ["uniform", "cadence", "m4", "minmax", "lttb", "sdt"] + + it("offers SUBSAMPLE after the FROM source and after WHERE", () => { + expect(getLabelsAt(provider, "SELECT * FROM trades ")).toContain( + "SUBSAMPLE", + ) + expect( + getLabelsAt(provider, "SELECT * FROM trades WHERE price > 1 "), + ).toContain("SUBSAMPLE") + expect( + getLabelsAt(provider, "SELECT avg(price) FROM trades SAMPLE BY 1h "), + ).toContain("SUBSAMPLE") + }) + + it("offers the six methods as functions after SUBSAMPLE, and no columns", () => { + const suggestions = provider.getSuggestions( + "SELECT * FROM trades SUBSAMPLE ", + "SELECT * FROM trades SUBSAMPLE ".length, + ) + const functions = suggestions + .filter((s) => s.kind === SuggestionKind.Function) + .map((s) => s.label) + for (const method of methods) expect(functions).toContain(method) + const labels = suggestions.map((s) => s.label) + expect(labels).not.toContain("price") + expect(labels).not.toContain("trades") + }) + + it("filters the methods by the typed prefix", () => { + const labels = getLabelsAt(provider, "SELECT * FROM trades SUBSAMPLE lt") + expect(labels).toContain("lttb") + expect(labels).not.toContain("uniform") + expect(labels).not.toContain("m4") + }) + + it("suggests columns inside the method arguments", () => { + const labels = getLabelsAt(provider, "SELECT * FROM trades SUBSAMPLE lttb(") + expect(labels).toContain("price") + expect(labels).toContain("timestamp") + }) + + it("continues with ORDER BY and LIMIT after the clause, never a second SUBSAMPLE", () => { + assertSuggestionsWalkthrough(provider, [ + { + typed: "SELECT * FROM trades SUBSAMPLE lttb(price, 2000) ", + expects: ["ORDER", "LIMIT"], + rejects: ["SUBSAMPLE", "WHERE", "SAMPLE"], + }, + { + typed: "SELECT * FROM trades ORDER BY timestamp ", + expects: ["LIMIT"], + rejects: ["SUBSAMPLE"], + }, + ]) + }) +}) diff --git a/tests/formatter/capitalize.test.ts b/tests/formatter/capitalize.test.ts index d356705..4f9d4b3 100644 --- a/tests/formatter/capitalize.test.ts +++ b/tests/formatter/capitalize.test.ts @@ -28,6 +28,17 @@ describe("format with capitalize", () => { "WITH maxUncommittedRows = 10", ].join("\n"), ], + [ + "raises SUBSAMPLE but not its method, which the grammar reads as a name", + "select ts, price from trades where ts in '2024-06' subsample m4(price, 4000) order by ts", + [ + "SELECT ts, price", + "FROM trades", + "WHERE ts IN '2024-06'", + "SUBSAMPLE m4(price, 4000)", + "ORDER BY ts", + ].join("\n"), + ], [ "leaves columns alone where a keyword names one", "select symbol, avg(price) from trades where ts in today() latest on ts partition by symbol", diff --git a/tests/formatter/fixtures.ts b/tests/formatter/fixtures.ts index 3d94a5f..cc47e98 100644 --- a/tests/formatter/fixtures.ts +++ b/tests/formatter/fixtures.ts @@ -35,6 +35,41 @@ export const fixtures: Fixture[] = [ input: "SELECT * FROM trades LATEST BY symbol", expected: ["SELECT *", "FROM trades", "LATEST BY symbol"].join("\n"), }, + { + name: "SUBSAMPLE starts a line between WHERE and ORDER BY", + input: + "SELECT ts, price FROM trades WHERE symbol = 'BTC-USD' SUBSAMPLE lttb(price, 2000, '1h') ORDER BY ts DESC LIMIT 100", + expected: [ + "SELECT ts, price", + "FROM trades", + "WHERE symbol = 'BTC-USD'", + "SUBSAMPLE lttb(price, 2000, '1h')", + "ORDER BY ts DESC", + "LIMIT 100", + ].join("\n"), + }, + { + name: "SUBSAMPLE follows SAMPLE BY on its own line", + input: + "SELECT ts, avg(price) avg FROM trades SAMPLE BY 1h SUBSAMPLE lttb(avg, 500)", + expected: [ + "SELECT ts, avg(price) avg", + "FROM trades", + "SAMPLE BY 1h", + "SUBSAMPLE lttb(avg, 500)", + ].join("\n"), + }, + { + name: "SUBSAMPLE in the implicit-select shorthand opens a block", + input: "SELECT v, ts FROM (t SUBSAMPLE uniform(4)) x", + expected: [ + "SELECT v, ts", + "FROM (", + " t", + " SUBSAMPLE uniform(4)", + ") x", + ].join("\n"), + }, { name: "ALTER TABLE WAL and storage policy actions start a line", input: "ALTER TABLE t SUSPEND WAL", diff --git a/tests/lexer.test.ts b/tests/lexer.test.ts index cc7fe7e..888050d 100644 --- a/tests/lexer.test.ts +++ b/tests/lexer.test.ts @@ -135,6 +135,30 @@ describe("QuestDB Lexer", () => { expect(identTokens).toHaveLength(1) }) + it("should tokenize the SUBSAMPLE clause with its method as a name", () => { + const result = tokenize("SUBSAMPLE lttb(price, 2000, '1h')") + + expect(result.errors).toHaveLength(0) + expect(result.tokens.map((token) => token.tokenType.name)).toEqual([ + "Subsample", + "Lttb", + "LParen", + "Identifier", + "Comma", + "NumberLiteral", + "Comma", + "StringLiteral", + "RParen", + ]) + // subsample is reserved; the six methods stay usable as identifiers + const subsample = result.tokens[0].tokenType + const lttb = result.tokens[1].tokenType + const categories = (t: typeof lttb) => + (t.CATEGORIES ?? []).map((c) => c.name) + expect(categories(subsample)).not.toContain("IdentifierKeyword") + expect(categories(lttb)).toContain("IdentifierKeyword") + }) + it("should tokenize duration literals for SAMPLE BY", () => { const durations = ["1s", "5m", "1h", "1d", "1M", "1y"] diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 7fe2247..1f37533 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -8779,3 +8779,199 @@ describe("Docs gaps (2026-09): memory limits, outer joins, unnest joins, fill pr }) }) }) + +describe("SUBSAMPLE clause (questdb/questdb#7013)", () => { + const roundtrip = (sql: string) => { + const result = parseToAst(sql) + expect(result.errors).toHaveLength(0) + const regenerated = toSql(result.ast[0]) + const reparsed = parseToAst(regenerated) + expect(reparsed.errors).toHaveLength(0) + expect(toSql(reparsed.ast[0])).toBe(regenerated) + return { ast: result.ast[0], regenerated } + } + + const subsampleOf = (ast: AST.Statement): AST.SubsampleClause => { + expect(ast.type).toBe("select") + const clause = (ast as AST.SelectStatement).subsample + expect(clause).toBeDefined() + return clause! + } + + it.each([ + ["SELECT * FROM sensors SUBSAMPLE uniform(500)", "uniform", 1], + ["SELECT * FROM sensors SUBSAMPLE cadence(10)", "cadence", 1], + ["SELECT * FROM sensors SUBSAMPLE cadence(10, 42)", "cadence", 2], + ["SELECT * FROM sensors SUBSAMPLE cadence(10, NULL)", "cadence", 2], + ["SELECT ts, price FROM trades SUBSAMPLE m4(price, 4000)", "m4", 2], + ["SELECT ts, price FROM trades SUBSAMPLE minmax(price, 2000)", "minmax", 2], + ["SELECT ts, price FROM trades SUBSAMPLE lttb(price, 2000)", "lttb", 2], + [ + "SELECT ts, price FROM trades SUBSAMPLE lttb(price, 2000, '1h')", + "lttb", + 3, + ], + [ + "SELECT ts, temperature FROM sensors SUBSAMPLE sdt(temperature, 0.5)", + "sdt", + 2, + ], + ])("parses and round-trips %s", (sql, method, argCount) => { + const { ast, regenerated } = roundtrip(sql) + const clause = subsampleOf(ast) + expect(clause.type).toBe("subsample") + expect(clause.method).toBe(method) + expect(clause.args).toHaveLength(argCount) + expect(regenerated).toBe(sql) + }) + + it("keeps the method name case as written", () => { + const { ast, regenerated } = roundtrip( + "SELECT * FROM trades SUBSAMPLE LTTB(price, 2000)", + ) + expect(subsampleOf(ast).method).toBe("LTTB") + expect(regenerated).toBe("SELECT * FROM trades SUBSAMPLE LTTB(price, 2000)") + }) + + it("accepts any method name and leaves validation to the server", () => { + const { ast } = roundtrip("SELECT * FROM trades SUBSAMPLE whatever(1)") + expect(subsampleOf(ast).method).toBe("whatever") + }) + + it("sits after WHERE, and before ORDER BY and LIMIT", () => { + const { ast, regenerated } = roundtrip( + "SELECT ts, price FROM trades WHERE ts IN '2024-06' SUBSAMPLE m4(price, 4000) ORDER BY ts DESC LIMIT 100", + ) + expect(regenerated).toBe( + "SELECT ts, price FROM trades WHERE ts IN '2024-06' SUBSAMPLE m4(price, 4000) ORDER BY ts DESC LIMIT 100", + ) + if (ast.type === "select") { + expect(ast.where).toBeDefined() + expect(ast.orderBy).toHaveLength(1) + expect(ast.limit).toBeDefined() + } + }) + + it("follows SAMPLE BY and reads the projected alias", () => { + const { ast } = roundtrip( + "SELECT ts, avg(price) avg FROM trades SAMPLE BY 1h SUBSAMPLE lttb(avg, 500)", + ) + if (ast.type === "select") { + expect(ast.sampleBy).toBeDefined() + expect(ast.subsample?.args[0]).toMatchObject({ + type: "column", + name: { type: "qualifiedName", parts: ["avg"] }, + }) + } + }) + + it("follows LATEST ON, GROUP BY and a named WINDOW clause", () => { + roundtrip( + "SELECT * FROM trades LATEST ON ts PARTITION BY symbol SUBSAMPLE uniform(10)", + ) + roundtrip( + "SELECT symbol, max(price) FROM trades GROUP BY symbol SUBSAMPLE uniform(10)", + ) + const { ast } = roundtrip( + "SELECT ts, avg(price) OVER w FROM trades WINDOW w AS (ORDER BY ts) SUBSAMPLE minmax(price, 10)", + ) + if (ast.type === "select") { + expect(ast.namedWindows).toHaveLength(1) + expect(ast.subsample?.method).toBe("minmax") + } + }) + + it("takes DECLARE variables as arguments", () => { + const { regenerated } = roundtrip( + "DECLARE @points := 2000 SELECT price, ts FROM trades SUBSAMPLE lttb(price, @points)", + ) + expect(regenerated).toBe( + "DECLARE @points := 2000 SELECT price, ts FROM trades SUBSAMPLE lttb(price, @points)", + ) + }) + + it("parses the parenthesised implicit-select shorthand", () => { + const { ast, regenerated } = roundtrip( + "SELECT v, ts FROM (t SUBSAMPLE uniform(4)) x", + ) + expect(regenerated).toBe("SELECT v, ts FROM (t SUBSAMPLE uniform(4)) AS x") + if (ast.type === "select") { + const source = ast.from![0].table + expect(source.type).toBe("select") + if (source.type === "select") { + expect(source.implicit).toBe(true) + expect(source.subsample?.method).toBe("uniform") + } + } + roundtrip("SELECT v, ts FROM (SELECT * FROM t SUBSAMPLE m4(v, 4))") + }) + + it("composes with CTEs, joins and UNION", () => { + roundtrip( + "WITH c AS (SELECT * FROM trades SUBSAMPLE lttb(price, 100)) SELECT * FROM c", + ) + roundtrip( + "SELECT t.ts, t.price FROM trades t ASOF JOIN quotes q SUBSAMPLE lttb(price, 100)", + ) + roundtrip( + "SELECT * FROM a SUBSAMPLE uniform(10) UNION SELECT * FROM b SUBSAMPLE uniform(10)", + ) + }) + + it("rejects SUBSAMPLE after ORDER BY or LIMIT", () => { + expect( + parseToAst("SELECT * FROM trades ORDER BY ts SUBSAMPLE uniform(4)") + .errors, + ).not.toHaveLength(0) + expect( + parseToAst("SELECT * FROM trades LIMIT 10 SUBSAMPLE uniform(4)").errors, + ).not.toHaveLength(0) + }) + + it("rejects a duplicate SUBSAMPLE clause and a missing argument list", () => { + expect( + parseToAst( + "SELECT * FROM trades SUBSAMPLE uniform(4) SUBSAMPLE uniform(4)", + ).errors, + ).not.toHaveLength(0) + expect( + parseToAst("SELECT * FROM trades SUBSAMPLE uniform").errors, + ).not.toHaveLength(0) + expect( + parseToAst("SELECT * FROM trades SUBSAMPLE").errors, + ).not.toHaveLength(0) + }) + + it("treats subsample as a reserved word, quoted names still work", () => { + expect(parseToAst("SELECT subsample FROM t").errors).not.toHaveLength(0) + expect(parseToAst("SELECT * FROM t subsample").errors).not.toHaveLength(0) + const { regenerated } = roundtrip('SELECT "subsample", ts FROM readings') + expect(regenerated).toBe('SELECT "subsample", ts FROM readings') + roundtrip('SELECT * FROM "subsample"') + }) + + it.each([ + "SELECT ts, price, m4(ts, price, 8) OVER (ORDER BY ts) AS keep FROM trades", + "SELECT ts, minmax(ts, price, 8) OVER (ORDER BY ts) keep FROM trades", + "SELECT ts, lttb(ts, price, 2000, '1h') OVER (ORDER BY ts) keep FROM trades", + "SELECT ts, sdt(ts, value, 0.5) OVER (PARTITION BY sensor ORDER BY ts) AS keep FROM readings", + "SELECT uniform(500) OVER (ORDER BY ts), cadence(10, 42) OVER (ORDER BY ts) FROM t", + ])("still parses the method as a window function: %s", (sql) => { + const { ast } = roundtrip(sql) + if (ast.type === "select") { + expect(ast.subsample).toBeUndefined() + const windowed = ast.columns.find( + (c) => + c.type === "selectItem" && + c.expression.type === "function" && + c.expression.over !== undefined, + ) + expect(windowed).toBeDefined() + } + }) + + it("still accepts the method names as identifiers", () => { + roundtrip("SELECT uniform, cadence, m4, minmax, lttb, sdt FROM t") + roundtrip("SELECT * FROM lttb") + }) +})