diff --git a/src/autocomplete/content-assist.ts b/src/autocomplete/content-assist.ts index fb31af7..b420d4a 100644 --- a/src/autocomplete/content-assist.ts +++ b/src/autocomplete/content-assist.ts @@ -7,7 +7,10 @@ import { IDENTIFIER_KEYWORD_TOKENS, EXPRESSION_OPERATORS, } from "./token-classification" -import { computeContentAssistBudgeted } from "./budgeted-content-assist" +import { + computeContentAssistBudgeted, + type ContentAssistSuggestion, +} from "./budgeted-content-assist" // ============================================================================= // Constants @@ -99,6 +102,14 @@ export interface ContentAssistResult { referencedColumns: Set /** Whether the cursor is inside a WHERE clause expression */ isConditionContext: boolean + /** + * Concrete keywords the grammar accepts only through the generic `identifier` + * sub-rule (so the word stays non-reserved) and therefore never surface via + * the normal `IdentifierKeyword` path. Re-injected per rule context so + * multi-word `GRANT`/`REVOKE`, `CONVERT PARTITION` and `SHOW CREATE DATABASE` + * clauses can be autocompleted. See `contextKeywordSuggestions`. + */ + contextKeywords: string[] } // ============================================================================= @@ -829,6 +840,86 @@ function accumulateFlags(target: CategoryFlags, kind: PositionKind): void { interface ComputeResult extends CategoryFlags { nextTokenTypes: TokenType[] isConditionContext: boolean + contextKeywords: string[] +} + +// Category names valid inside SHOW CREATE DATABASE (INCLUDE|EXCLUDE) ( ... ). +// The grammar consumes these via `identifier` (they are non-reserved), so only +// the explicit `ALL` token surfaces on its own; re-inject the rest. +const DATABASE_CATEGORY_KEYWORDS = [ + "ALL", + "TABLES", + "VIEWS", + "MATERIALIZED_VIEWS", + "USERS", + "GROUPS", + "SERVICE_ACCOUNTS", + "PERMISSIONS", + "SCHEMA", + "ACL", +] + +/** + * Re-inject the concrete keywords that the grammar only accepts through the + * generic `identifier` sub-rule — kept non-reserved on purpose, which means + * Chevrotain's content-assist reports them merely as the abstract + * `IdentifierKeyword` category and the suggestion builder renders that slot as + * "type a name here" rather than a keyword. We recover the intended words from + * the active rule context (the `ruleStack` of any `IdentifierKeyword` path) plus + * the immediately preceding tokens, so multi-word `GRANT`/`REVOKE`, + * `CONVERT PARTITION` and `SHOW CREATE DATABASE` clauses autocomplete + * token-by-token. Parsing already accepts all of these; this only affects hints. + */ +function contextKeywordSuggestions( + tokens: IToken[], + suggestions: ContentAssistSuggestion[], +): string[] { + // Rule contexts in which the `identifier` catch-all is a valid next token. + const idKeywordStacks = suggestions + .filter((s) => s.nextTokenType.name === "IdentifierKeyword") + .map((s) => s.ruleStack) + if (idKeywordStacks.length === 0) return [] + const inRule = (r: string): boolean => + idKeywordStacks.some((stack) => stack.includes(r)) + + const last = tokens[tokens.length - 1]?.tokenType.name + const prev = tokens[tokens.length - 2]?.tokenType.name + const out = new Set() + + // GRANT / REVOKE permission phrases whose words route through `identifier`: + // CONVERT PARTITION [TO PARQUET|NATIVE], SET TABLE FORMAT, SET TABLE TYPE, + // SET STORAGE POLICY, REMOVE STORAGE POLICY. + // (SET / TABLE / TO / PARQUET / NATIVE at their spots are explicit CONSUMEs + // and already surface; we only add the identifier-path words.) + if (inRule("permissionToken")) { + if (last === "Grant" || last === "Revoke") { + out.add("CONVERT") + out.add("REMOVE") + } else if (last === "Convert") { + out.add("PARTITION") + } else if (last === "Set" || last === "Remove") { + out.add("STORAGE") + } else if (last === "Table" && prev === "Set") { + out.add("FORMAT") + out.add("TYPE") + } else if (last === "Storage" && (prev === "Set" || prev === "Remove")) { + out.add("POLICY") + } + } + + // ALTER TABLE ... CONVERT PARTITION TO { PARQUET | NATIVE }: the target is + // OR([CONSUME(Table), SUBRULE(identifier)]) so PARQUET/NATIVE go via identifier. + if (inRule("convertPartitionTarget") && last === "To") { + out.add("PARQUET") + out.add("NATIVE") + } + + // SHOW CREATE DATABASE (INCLUDE|EXCLUDE) ( , ... ). + if (inRule("showCreateDatabaseCategory")) { + for (const c of DATABASE_CATEGORY_KEYWORDS) out.add(c) + } + + return [...out] } /** @@ -907,10 +998,13 @@ function computeSuggestions(tokens: IToken[]): ComputeResult { s.ruleStack.includes("whereClause"), ) + const contextKeywords = contextKeywordSuggestions(tokens, suggestions) + return { nextTokenTypes: result, ...flags, isConditionContext, + contextKeywords, } } @@ -1051,6 +1145,7 @@ export function getContentAssist( suggestTableValuedFunctions: false, referencedColumns: new Set(), isConditionContext: false, + contextKeywords: [], } } } @@ -1084,6 +1179,7 @@ export function getContentAssist( let suggestWindowFunctions = false let suggestTableValuedFunctions = false let isConditionContext = false + let contextKeywords: string[] = [] try { const computed = computeSuggestions(tokensForAssist) nextTokenTypes = computed.nextTokenTypes @@ -1094,6 +1190,7 @@ export function getContentAssist( suggestWindowFunctions = computed.suggestWindowFunctions suggestTableValuedFunctions = computed.suggestTableValuedFunctions isConditionContext = computed.isConditionContext + contextKeywords = computed.contextKeywords } catch (e) { // If content assist fails, return empty suggestions // This can happen with malformed input @@ -1185,6 +1282,7 @@ export function getContentAssist( suggestTableValuedFunctions, referencedColumns, isConditionContext, + contextKeywords, } } diff --git a/src/autocomplete/provider.ts b/src/autocomplete/provider.ts index 91b157c..3b0cddc 100644 --- a/src/autocomplete/provider.ts +++ b/src/autocomplete/provider.ts @@ -194,6 +194,7 @@ export function createAutocompleteProvider( suggestTableValuedFunctions, referencedColumns, isConditionContext, + contextKeywords, } = getContentAssist(query, cursorOffset) // Merge CTE columns into the schema so getColumnsInScope() can find them @@ -264,6 +265,28 @@ export function createAutocompleteProvider( partialPrefix, }, ) + if (contextKeywords.length > 0) { + const seen = new Set(suggestions.map((s) => s.label.toUpperCase())) + for (const kw of contextKeywords) { + if (seen.has(kw.toUpperCase())) continue + if ( + isMidWord && + partialPrefix && + !kw.toLowerCase().startsWith(partialPrefix) + ) { + continue + } + seen.add(kw.toUpperCase()) + suggestions.push({ + label: kw, + kind: SuggestionKind.Keyword, + insertText: kw, + filterText: kw.toLowerCase(), + priority: SuggestionPriority.Medium, + }) + } + } + if (suggestTables) { rankTableSuggestions(suggestions, referencedColumns, columnIndex) } diff --git a/src/grammar/constants.ts b/src/grammar/constants.ts index 43c807a..da590c8 100644 --- a/src/grammar/constants.ts +++ b/src/grammar/constants.ts @@ -1,21 +1,29 @@ export const constants: string[] = [ + "acl", "asc", + "beginning", + "bitmap", "brotli", "century", "complete", + "daily", "datestyle", "day", "days", "decade", "default_transaction_read_only", + "delta", "delta_binary_packed", "delta_length_byte_array", "desc", "dow", "doy", + "ef", "epoch", + "expression", "false", "gzip", + "highest", "hour", "hours", "http", @@ -27,10 +35,12 @@ export const constants: string[] = [ "jwk", "linear", "local", + "lowest", "lz4", "lz4_raw", "lzo", "manual", + "materialized_views", "max_identifier_length", "microsecond", "microseconds", @@ -46,28 +56,34 @@ export const constants: string[] = [ "nanoseconds", "native", "none", + "now", "null", "parquet", "pgwire", "plain", + "posting", "prepare", "prev", "quarter", "remote", "rest", "rle_dictionary", + "schema", "search_path", "second", "seconds", "server_version", "server_version_num", + "service_accounts", "skip_column", "skip_row", "snappy", "standard_conforming_strings", + "stats", "transaction_isolation", "true", "uncompressed", + "views", "week", "weeks", "year", diff --git a/src/grammar/functions.ts b/src/grammar/functions.ts index ff48fc6..806e989 100644 --- a/src/grammar/functions.ts +++ b/src/grammar/functions.ts @@ -16,8 +16,8 @@ // letter "p" alone doesn't bury real schema results in JOIN positions. // `information_schema.*` is NOT gated — those names are SQL-standard. // -// Removed from earlier flat list: 17 entries absent from QuestDB runtime AND -// docs (e.g. `array_agg`, `lcase`, `len`, `nvl`, `headers`, `show`, +// Removed from earlier flat list: entries absent from QuestDB runtime AND +// docs (e.g. `lcase`, `len`, `nvl`, `headers`, `show`, // `commitLag`, `batch`); 11 SQL operators / syntactic keywords (e.g. `and`, // `or`, `between`, `case`, `cast`) — they exist as factories internally but // users always write them as syntax, and the parser keyword path covers them. @@ -84,6 +84,7 @@ export const scalarFunctions: string[] = [ "insertion_point", "interval_end", "interval_start", + "is_end_of_month", "is_leap_year", "json_extract", "l2price", @@ -229,6 +230,7 @@ export const aggregateFunctions: string[] = [ "approx_percentile", "arg_max", "arg_min", + "array_agg", "array_elem_avg", "array_elem_max", "array_elem_min", @@ -250,6 +252,9 @@ export const aggregateFunctions: string[] = [ "haversine_dist_deg", "isOrdered", "ksum", + "kurtosis", + "kurtosis_pop", + "kurtosis_samp", "last", "last_not_null", "max", @@ -257,7 +262,11 @@ export const aggregateFunctions: string[] = [ "mode", "nsum", "regr_intercept", + "regr_r2", "regr_slope", + "skewness", + "skewness_pop", + "skewness_samp", "sparkline", "stddev", "stddev_pop", @@ -277,11 +286,14 @@ export const aggregateFunctions: string[] = [ ] export const windowFunctions: string[] = [ + "cume_dist", "dense_rank", "first_value", "lag", "last_value", "lead", + "nth_value", + "ntile", "percent_rank", "rank", "row_number", @@ -290,6 +302,7 @@ export const windowFunctions: string[] = [ export const tableValuedFunctions: string[] = [ // CURSOR type — explicitly row-returning "all_permissions", + "backups", "generate_series", "information_schema._pg_expandarray", "long_sequence", @@ -313,6 +326,7 @@ export const tableValuedFunctions: string[] = [ "pg_roles", "pg_type", "read_parquet", + "sleep", "table_partitions", // STANDARD type but succeed as `SELECT * FROM ()` — meta pseudo-tables "all_tables", @@ -320,6 +334,7 @@ export const tableValuedFunctions: string[] = [ "functions", "import_files", "keywords", + "live_views", "materialized_views", "memory_metrics", "permissions", diff --git a/src/grammar/keywords.ts b/src/grammar/keywords.ts index 07a29bf..699a836 100644 --- a/src/grammar/keywords.ts +++ b/src/grammar/keywords.ts @@ -7,6 +7,7 @@ export const keywords: string[] = [ "align", "all", "alter", + "anchor", "and", "any", "as", @@ -29,6 +30,7 @@ export const keywords: string[] = [ "case", "cast", "checkpoint", + "cleanup", "column", "columns", "compile", @@ -65,10 +67,12 @@ export const keywords: string[] = [ "exclusive", "exists", "exit", + "expire", "explain", "external", "fill", "first", + "flush", "following", "for", "foreign", @@ -105,11 +109,13 @@ export const keywords: string[] = [ "like", "limit", "list", + "live", "lock", "lt", "maps", "materialized", "maxUncommittedRows", + "memory", "no", "nocache", "not", @@ -146,6 +152,7 @@ export const keywords: string[] = [ "query", "range", "raw_array_encoding", + "rebase", "references", "refresh", "release", @@ -154,10 +161,12 @@ export const keywords: string[] = [ "rename", "repair", "replace", + "replica", "respect", "resume", "revoke", "right", + "role", "row", "row_group_size", "rows", @@ -171,15 +180,18 @@ export const keywords: string[] = [ "splice", "squash", "start", + "status", "step", "storage", "statistics_enabled", "suspend", + "switch", "system", "table", "tables", "then", "time", + "timeout", "to", "token", "tolerance", diff --git a/src/parser/ast.ts b/src/parser/ast.ts index eed5cbb..1002371 100644 --- a/src/parser/ast.ts +++ b/src/parser/ast.ts @@ -17,6 +17,7 @@ export type Statement = | UpdateStatement | CreateTableStatement | CreateMaterializedViewStatement + | CreateLiveViewStatement | CreateUserStatement | CreateGroupStatement | CreateServiceAccountStatement @@ -26,9 +27,11 @@ export type Statement = | AlterServiceAccountStatement | CreateViewStatement | AlterViewStatement + | AlterLiveViewStatement | DropTableStatement | DropMaterializedViewStatement | DropViewStatement + | DropLiveViewStatement | DropUserStatement | DropGroupStatement | DropServiceAccountStatement @@ -55,6 +58,7 @@ export type Statement = | RefreshMaterializedViewStatement | PivotStatement | BackupStatement + | SwitchStatement | AlterGroupStatement | CompileViewStatement @@ -93,6 +97,7 @@ export interface NamedWindow extends AstNode { partitionBy?: Expression[] orderBy?: OrderByItem[] frame?: WindowFrame + anchor?: AnchorClause } export interface CTE extends AstNode { @@ -172,6 +177,7 @@ export interface CreateTableStatement extends AstNode { volume?: string ownedBy?: string dedupKeys?: string[] + tableFormat?: "parquet" | "native" } export interface TableParam extends AstNode { @@ -190,6 +196,8 @@ export interface IndexDefinition extends AstNode { type: "indexDefinition" column: QualifiedName capacity?: number + indexType?: "posting" | "posting_delta" | "posting_ef" | "bitmap" | "none" + include?: string[] } export interface CreateUserStatement extends AstNode { @@ -216,6 +224,23 @@ export interface CreateServiceAccountStatement extends AstNode { ownedBy?: string } +// EXPIRE ROWS row-retention clause (materialized views). +export interface ExpireRowsClause { + mode: "when" | "keepLatest" | "keepExtremum" + // when: EXPIRE ROWS WHEN + predicate?: Expression + // keepLatest: KEEP LATEST [ON ] PARTITION BY + on?: string + // keepExtremum: KEEP [] HIGHEST|LOWEST [PARTITION BY ] + keepCount?: number + extremum?: "highest" | "lowest" + column?: string + // partition columns (required for keepLatest, optional for keepExtremum) + partitionBy?: string[] + // optional trailing CLEANUP EVERY + cleanupEvery?: string +} + export interface CreateMaterializedViewStatement extends AstNode { type: "createMaterializedView" view: QualifiedName @@ -235,6 +260,49 @@ export interface CreateMaterializedViewStatement extends AstNode { storagePolicy?: StoragePolicy volume?: string ownedBy?: string + expireRows?: ExpireRowsClause +} + +// LIVE VIEWS (#6939) +export interface CreateLiveViewStatement extends AstNode { + type: "createLiveView" + view: QualifiedName + ifNotExists?: boolean + flushEvery: string + inMemory?: string + partitionBy?: "NONE" | "HOUR" | "DAY" | "WEEK" | "MONTH" | "YEAR" + startFrom?: { + kind: "now" | "beginning" | "timestamp" + value?: string + } + query: SelectStatement +} + +export interface DropLiveViewStatement extends AstNode { + type: "dropLiveView" + view: QualifiedName + ifExists?: boolean +} + +export interface AlterLiveViewStatement extends AstNode { + type: "alterLiveView" + view: QualifiedName + action: "resumeWal" | "suspendWal" + // RESUME WAL [FROM TXN|TRANSACTION n] + fromTxn?: number + fromTransaction?: number + // SUSPEND WAL [WITH code, 'message'] + code?: number | string + message?: string +} + +// OVER(...) / named WINDOW ANCHOR clause (live views): +// ANCHOR EXPRESSION | ANCHOR DAILY '' [''] +export interface AnchorClause { + kind: "expression" | "daily" + expr?: Expression + time?: string + timezone?: string } export interface StoragePolicy extends AstNode { @@ -298,6 +366,10 @@ export interface ColumnDefinition extends AstNode { indexed?: boolean /** INDEX CAPACITY value */ indexCapacity?: number + /** Index type (#6861 posting index) */ + indexType?: "posting" | "posting_delta" | "posting_ef" | "bitmap" | "none" + /** POSTING index INCLUDE columns */ + indexInclude?: string[] /** PARQUET encoding/compression/bloom filter config */ parquetConfig?: ParquetConfig } @@ -323,6 +395,9 @@ export type AlterMaterializedViewAction = | AlterMaterializedViewSetRefresh | AlterMaterializedViewResumeWal | AlterMaterializedViewSuspendWal + | AlterMaterializedViewRebaseWal + | AlterMaterializedViewSetExpireRows + | AlterMaterializedViewDropExpire | AlterMaterializedViewSetStoragePolicy | AlterMaterializedViewDropStoragePolicy | AlterMaterializedViewEnableStoragePolicy @@ -376,6 +451,20 @@ export interface AlterMaterializedViewSuspendWal { actionType: "suspendWal" } +export interface AlterMaterializedViewRebaseWal { + actionType: "rebaseWal" + targetDir?: string +} + +export interface AlterMaterializedViewSetExpireRows { + actionType: "setExpireRows" + expireRows: ExpireRowsClause +} + +export interface AlterMaterializedViewDropExpire { + actionType: "dropExpire" +} + export interface AlterMaterializedViewSetStoragePolicy { actionType: "setStoragePolicy" policy: StoragePolicy @@ -453,11 +542,13 @@ export type AlterTableAction = | SquashPartitionsAction | SetParamAction | SetTtlAction + | SetTableFormatAction | DedupDisableAction | DedupEnableAction | SetTypeWalAction | SuspendWalAction | ResumeWalAction + | RebaseWalAction | ConvertPartitionAction | SetStoragePolicyAction | DropStoragePolicyAction @@ -494,6 +585,9 @@ export interface AlterColumnAction { | "setParquet" newType?: string capacity?: number + // ADD INDEX options (#6861 posting index) + indexType?: "posting" | "posting_delta" | "posting_ef" | "bitmap" | "none" + indexInclude?: string[] cache?: boolean /** PARQUET config for SET PARQUET(...) */ parquetConfig?: ParquetConfig @@ -533,6 +627,11 @@ export interface SetTtlAction { } } +export interface SetTableFormatAction { + actionType: "setTableFormat" + format: "parquet" | "native" +} + export interface SetStoragePolicyAction { actionType: "setStoragePolicy" policy: StoragePolicy @@ -576,6 +675,11 @@ export interface ResumeWalAction { fromTransaction?: number } +export interface RebaseWalAction { + actionType: "rebaseWal" + targetDir?: string +} + export interface ConvertPartitionAction { actionType: "convertPartition" partitions?: string[] @@ -686,6 +790,8 @@ export interface ShowStatement extends AstNode { | "createTable" | "createView" | "createMaterializedView" + | "createLiveView" + | "createDatabase" | "user" | "users" | "groups" @@ -704,6 +810,12 @@ export interface ShowStatement extends AstNode { | "defaultTransactionReadOnly" table?: QualifiedName name?: QualifiedName + // SHOW CREATE DATABASE optional (INCLUDE|EXCLUDE) (ALL | (categories)) + databaseInclude?: { + mode: "include" | "exclude" + all?: boolean + categories?: string[] + } } export interface ExplainStatement extends AstNode { @@ -716,12 +828,20 @@ export type CopyStatement = | CopyCancelStatement | CopyFromStatement | CopyToStatement + | CopyPermissionsStatement export interface CopyCancelStatement extends AstNode { type: "copyCancel" id: string } +// Enterprise: COPY PERMISSIONS FROM TO +export interface CopyPermissionsStatement extends AstNode { + type: "copyPermissions" + from: QualifiedName + to: QualifiedName +} + export interface CopyFromStatement extends AstNode { type: "copyFrom" table: QualifiedName @@ -780,6 +900,10 @@ export interface GrantTableTarget extends AstNode { type: "grantTableTarget" table: QualifiedName columns?: string[] + // Column wildcard: tab(*) sets allColumns; tab(* EXCLUDE(c1, c2)) also sets + // excludeColumns. Mutually exclusive with `columns`. + allColumns?: boolean + excludeColumns?: string[] } export interface GrantAssumeServiceAccountStatement extends AstNode { @@ -823,7 +947,7 @@ export interface ReindexTableStatement extends AstNode { export interface RefreshMaterializedViewStatement extends AstNode { type: "refreshMaterializedView" view: QualifiedName - mode?: "full" | "incremental" | "range" + mode?: "full" | "incremental" | "range" | "stats" from?: string to?: string } @@ -834,6 +958,14 @@ export interface BackupStatement extends AstNode { table?: QualifiedName } +// Enterprise: SWITCH ROLE TO {PRIMARY|REPLICA} [TIMEOUT ] | SWITCH STATUS +export interface SwitchStatement extends AstNode { + type: "switch" + action: "role" | "status" + role?: "PRIMARY" | "REPLICA" + timeout?: number +} + export interface AlterGroupStatement extends AstNode { type: "alterGroup" group: QualifiedName @@ -991,6 +1123,10 @@ export interface WindowJoinBound extends AstNode { boundType: "currentRow" | "duration" direction?: "preceding" | "following" duration?: string + // Dynamic bound (#6859): a column/cast/function expression, with an optional + // time unit (e.g. `wndBound SECONDS PRECEDING`). + boundExpr?: Expression + unit?: string } export interface SampleByClause extends AstNode { @@ -1114,6 +1250,7 @@ export interface WindowSpecification extends AstNode { partitionBy?: Expression[] orderBy?: OrderByItem[] frame?: WindowFrame + anchor?: AnchorClause } export interface WindowFrame extends AstNode { diff --git a/src/parser/cst-types.d.ts b/src/parser/cst-types.d.ts index 5ff8d23..c4bb340 100644 --- a/src/parser/cst-types.d.ts +++ b/src/parser/cst-types.d.ts @@ -46,6 +46,7 @@ export type StatementCstChildren = { refreshMaterializedViewStatement?: RefreshMaterializedViewStatementCstNode[]; pivotStatement?: PivotStatementCstNode[]; backupStatement?: BackupStatementCstNode[]; + switchStatement?: SwitchStatementCstNode[]; compileViewStatement?: CompileViewStatementCstNode[]; implicitSelectStatement?: ImplicitSelectStatementCstNode[]; }; @@ -438,6 +439,8 @@ export type WindowJoinBoundCstChildren = { Preceding?: (IToken)[]; Following?: (IToken)[]; durationExpression?: DurationExpressionCstNode[]; + expression?: ExpressionCstNode[]; + timeUnit?: TimeUnitCstNode[]; }; export interface DurationExpressionCstNode extends CstNode { @@ -472,6 +475,7 @@ export type SampleByClauseCstChildren = { By: IToken[]; DurationLiteral?: IToken[]; VariableReference?: IToken[]; + Identifier?: IToken[]; fromToClause?: FromToClauseCstNode[]; fillClause?: FillClauseCstNode[]; alignToClause?: AlignToClauseCstNode[]; @@ -694,6 +698,7 @@ export type CreateStatementCstChildren = { Create: IToken[]; createTableBody?: CreateTableBodyCstNode[]; createMaterializedViewBody?: CreateMaterializedViewBodyCstNode[]; + createLiveViewBody?: CreateLiveViewBodyCstNode[]; createViewBody?: CreateViewBodyCstNode[]; createUserStatement?: CreateUserStatementCstNode[]; createGroupStatement?: CreateGroupStatementCstNode[]; @@ -765,6 +770,7 @@ export type CreateTableBodyCstChildren = { Month?: IToken[]; Year?: IToken[]; optionalStoragePolicy: OptionalStoragePolicyCstNode[]; + optionalTableFormat: (OptionalTableFormatCstNode)[]; Bypass?: IToken[]; Wal?: (IToken)[]; With?: IToken[]; @@ -778,6 +784,26 @@ export type CreateTableBodyCstChildren = { stringOrIdentifier?: StringOrIdentifierCstNode[]; }; +export interface TableFormatKindCstNode extends CstNode { + name: "tableFormatKind"; + children: TableFormatKindCstChildren; +} + +export type TableFormatKindCstChildren = { + Format: IToken[]; + Parquet?: IToken[]; + Native?: IToken[]; +}; + +export interface OptionalTableFormatCstNode extends CstNode { + name: "optionalTableFormat"; + children: OptionalTableFormatCstChildren; +} + +export type OptionalTableFormatCstChildren = { + tableFormatKind?: TableFormatKindCstNode[]; +}; + export interface BatchClauseCstNode extends CstNode { name: "batchClause"; children: BatchClauseCstChildren; @@ -890,6 +916,7 @@ export type CreateMaterializedViewBodyCstChildren = { columnRef?: ColumnRefCstNode[]; materializedViewPartition?: MaterializedViewPartitionCstNode[]; optionalStoragePolicy: OptionalStoragePolicyCstNode[]; + optionalExpireRows: OptionalExpireRowsCstNode[]; In?: IToken[]; Volume?: IToken[]; StringLiteral?: IToken[]; @@ -899,6 +926,40 @@ export type CreateMaterializedViewBodyCstChildren = { stringOrIdentifier?: StringOrIdentifierCstNode[]; }; +export interface ExpireRowsClauseCstNode extends CstNode { + name: "expireRowsClause"; + children: ExpireRowsClauseCstChildren; +} + +export type ExpireRowsClauseCstChildren = { + Expire: IToken[]; + Rows: IToken[]; + When?: IToken[]; + expression?: ExpressionCstNode[]; + Keep?: IToken[]; + Latest?: IToken[]; + On?: IToken[]; + identifier?: (IdentifierCstNode)[]; + Partition?: (IToken)[]; + By?: (IToken)[]; + Comma?: (IToken)[]; + NumberLiteral?: IToken[]; + Highest?: IToken[]; + Lowest?: IToken[]; + Cleanup?: IToken[]; + Every?: IToken[]; + DurationLiteral?: IToken[]; +}; + +export interface OptionalExpireRowsCstNode extends CstNode { + name: "optionalExpireRows"; + children: OptionalExpireRowsCstChildren; +} + +export type OptionalExpireRowsCstChildren = { + expireRowsClause?: ExpireRowsClauseCstNode[]; +}; + export interface MaterializedViewRefreshCstNode extends CstNode { name: "materializedViewRefresh"; children: MaterializedViewRefreshCstChildren; @@ -969,11 +1030,12 @@ export interface ColumnDefinitionCstNode extends CstNode { export type ColumnDefinitionCstChildren = { identifier: IdentifierCstNode[]; dataType: DataTypeCstNode[]; - Capacity?: (IToken)[]; - NumberLiteral?: (IToken)[]; + Capacity?: IToken[]; + NumberLiteral?: IToken[]; Cache?: IToken[]; Nocache?: IToken[]; Index?: IToken[]; + indexTypeOptions?: IndexTypeOptionsCstNode[]; parquetConfig?: ParquetConfigCstNode[]; }; @@ -1100,6 +1162,27 @@ export type CastDefinitionCstChildren = { RParen: IToken[]; }; +export interface IndexTypeOptionsCstNode extends CstNode { + name: "indexTypeOptions"; + children: IndexTypeOptionsCstChildren; +} + +export type IndexTypeOptionsCstChildren = { + Type?: IToken[]; + Posting?: IToken[]; + Delta?: IToken[]; + Ef?: IToken[]; + Bitmap?: IToken[]; + None?: IToken[]; + Include?: IToken[]; + LParen?: IToken[]; + identifier?: (IdentifierCstNode)[]; + Comma?: IToken[]; + RParen?: IToken[]; + Capacity?: IToken[]; + NumberLiteral?: IToken[]; +}; + export interface IndexDefinitionCstNode extends CstNode { name: "indexDefinition"; children: IndexDefinitionCstChildren; @@ -1109,8 +1192,7 @@ export type IndexDefinitionCstChildren = { Index: IToken[]; LParen: IToken[]; columnRef: ColumnRefCstNode[]; - Capacity?: IToken[]; - NumberLiteral?: IToken[]; + indexTypeOptions: IndexTypeOptionsCstNode[]; RParen: IToken[]; }; @@ -1134,6 +1216,84 @@ export type TableParamCstChildren = { expression?: ExpressionCstNode[]; }; +export interface CreateLiveViewBodyCstNode extends CstNode { + name: "createLiveViewBody"; + children: CreateLiveViewBodyCstChildren; +} + +export type CreateLiveViewBodyCstChildren = { + Live: IToken[]; + View: IToken[]; + If?: IToken[]; + Not?: IToken[]; + Exists?: IToken[]; + stringOrQualifiedName: StringOrQualifiedNameCstNode[]; + Flush: IToken[]; + Every: IToken[]; + DurationLiteral: (IToken)[]; + In?: IToken[]; + Memory?: IToken[]; + Partition?: IToken[]; + By?: IToken[]; + partitionPeriod?: PartitionPeriodCstNode[]; + Start?: IToken[]; + From?: IToken[]; + Beginning?: IToken[]; + StringLiteral?: IToken[]; + Now?: IToken[]; + As: IToken[]; + LParen?: IToken[]; + selectStatement?: (SelectStatementCstNode)[]; + RParen?: IToken[]; +}; + +export interface DropLiveViewStatementCstNode extends CstNode { + name: "dropLiveViewStatement"; + children: DropLiveViewStatementCstChildren; +} + +export type DropLiveViewStatementCstChildren = { + Live: IToken[]; + View: IToken[]; + If?: IToken[]; + Exists?: IToken[]; + stringOrQualifiedName: StringOrQualifiedNameCstNode[]; +}; + +export interface AlterLiveViewStatementCstNode extends CstNode { + name: "alterLiveViewStatement"; + children: AlterLiveViewStatementCstChildren; +} + +export type AlterLiveViewStatementCstChildren = { + Live: IToken[]; + View: IToken[]; + tableName: TableNameCstNode[]; + Resume?: IToken[]; + Wal?: (IToken)[]; + From?: IToken[]; + Txn?: IToken[]; + Transaction?: IToken[]; + NumberLiteral?: (IToken)[]; + Suspend?: IToken[]; + With?: IToken[]; + StringLiteral?: (IToken)[]; + Comma?: IToken[]; +}; + +export interface AnchorClauseCstNode extends CstNode { + name: "anchorClause"; + children: AnchorClauseCstChildren; +} + +export type AnchorClauseCstChildren = { + Anchor: IToken[]; + Expression?: IToken[]; + expression?: ExpressionCstNode[]; + Daily?: IToken[]; + StringLiteral?: (IToken)[]; +}; + export interface PartitionPeriodCstNode extends CstNode { name: "partitionPeriod"; children: PartitionPeriodCstChildren; @@ -1185,6 +1345,7 @@ export type AlterStatementCstChildren = { Alter: IToken[]; alterTableStatement?: AlterTableStatementCstNode[]; alterMaterializedViewStatement?: AlterMaterializedViewStatementCstNode[]; + alterLiveViewStatement?: AlterLiveViewStatementCstNode[]; alterViewStatement?: AlterViewStatementCstNode[]; alterUserStatement?: AlterUserStatementCstNode[]; alterServiceAccountStatement?: AlterServiceAccountStatementCstNode[]; @@ -1314,6 +1475,7 @@ export type AlterTableActionCstChildren = { Cache?: (IToken)[]; Nocache?: (IToken)[]; Index?: (IToken)[]; + indexTypeOptions?: IndexTypeOptionsCstNode[]; Symbol?: IToken[]; Set?: (IToken)[]; parquetConfig?: ParquetConfigCstNode[]; @@ -1329,6 +1491,7 @@ export type AlterTableActionCstChildren = { Bypass?: IToken[]; Wal?: (IToken)[]; storagePolicy?: StoragePolicyCstNode[]; + tableFormatKind?: TableFormatKindCstNode[]; Dedup?: IToken[]; Disable?: (IToken)[]; Enable?: (IToken)[]; @@ -1342,6 +1505,8 @@ export type AlterTableActionCstChildren = { From?: IToken[]; Txn?: IToken[]; Transaction?: IToken[]; + Rebase?: IToken[]; + Into?: IToken[]; Convert?: IToken[]; convertPartitionTarget?: ConvertPartitionTargetCstNode[]; }; @@ -1398,12 +1563,18 @@ export type AlterMaterializedViewActionCstChildren = { materializedViewRefresh?: MaterializedViewRefreshCstNode[]; materializedViewPeriod?: MaterializedViewPeriodCstNode[]; storagePolicy?: StoragePolicyCstNode[]; + expireRowsClause?: ExpireRowsClauseCstNode[]; Resume?: IToken[]; Wal?: (IToken)[]; From?: IToken[]; Transaction?: IToken[]; Txn?: IToken[]; Suspend?: IToken[]; + Rebase?: IToken[]; + Into?: IToken[]; + StringLiteral?: IToken[]; + Expire?: IToken[]; + Rows?: IToken[]; Storage?: (IToken)[]; Policy?: (IToken)[]; Enable?: IToken[]; @@ -1419,6 +1590,7 @@ export type DropStatementCstChildren = { Drop: IToken[]; dropTableStatement?: DropTableStatementCstNode[]; dropMaterializedViewStatement?: DropMaterializedViewStatementCstNode[]; + dropLiveViewStatement?: DropLiveViewStatementCstNode[]; dropViewStatement?: DropViewStatementCstNode[]; dropUserStatement?: DropUserStatementCstNode[]; dropGroupStatement?: DropGroupStatementCstNode[]; @@ -1594,6 +1766,16 @@ export type CancelQueryStatementCstChildren = { StringLiteral?: IToken[]; }; +export interface ShowCreateDatabaseCategoryCstNode extends CstNode { + name: "showCreateDatabaseCategory"; + children: ShowCreateDatabaseCategoryCstChildren; +} + +export type ShowCreateDatabaseCategoryCstChildren = { + All?: IToken[]; + identifier?: IdentifierCstNode[]; +}; + export interface ShowStatementCstNode extends CstNode { name: "showStatement"; children: ShowStatementCstChildren; @@ -1610,6 +1792,16 @@ export type ShowStatementCstChildren = { Table?: IToken[]; View?: (IToken)[]; Materialized?: IToken[]; + Live?: IToken[]; + tableName?: TableNameCstNode[]; + Database?: IToken[]; + Include?: IToken[]; + Exclude?: IToken[]; + All?: IToken[]; + LParen?: IToken[]; + showCreateDatabaseCategory?: (ShowCreateDatabaseCategoryCstNode)[]; + Comma?: IToken[]; + RParen?: IToken[]; User?: IToken[]; Users?: IToken[]; Groups?: IToken[]; @@ -1655,6 +1847,7 @@ export interface CopyStatementCstNode extends CstNode { export type CopyStatementCstChildren = { Copy: IToken[]; copyCancel?: CopyCancelCstNode[]; + copyPermissions?: CopyPermissionsCstNode[]; copyFrom?: CopyFromCstNode[]; copyTo?: CopyToCstNode[]; }; @@ -1671,6 +1864,18 @@ export type CopyCancelCstChildren = { Cancel: IToken[]; }; +export interface CopyPermissionsCstNode extends CstNode { + name: "copyPermissions"; + children: CopyPermissionsCstChildren; +} + +export type CopyPermissionsCstChildren = { + Permissions: IToken[]; + From: IToken[]; + identifier: (IdentifierCstNode)[]; + To: IToken[]; +}; + export interface CopyFromCstNode extends CstNode { name: "copyFrom"; children: CopyFromCstChildren; @@ -1785,6 +1990,22 @@ export type BackupStatementCstChildren = { Abort?: IToken[]; }; +export interface SwitchStatementCstNode extends CstNode { + name: "switchStatement"; + children: SwitchStatementCstChildren; +} + +export type SwitchStatementCstChildren = { + Switch: IToken[]; + Role?: IToken[]; + To?: IToken[]; + Primary?: IToken[]; + Replica?: IToken[]; + Timeout?: IToken[]; + NumberLiteral?: IToken[]; + Status?: IToken[]; +}; + export interface CompileViewStatementCstNode extends CstNode { name: "compileViewStatement"; children: CompileViewStatementCstChildren; @@ -1864,12 +2085,16 @@ export type PermissionTokenCstChildren = { Show?: IToken[]; Vacuum?: IToken[]; Lock?: IToken[]; + Set?: IToken[]; Materialized?: IToken[]; View?: IToken[]; Service?: IToken[]; Account?: IToken[]; Table?: IToken[]; Group?: IToken[]; + To?: IToken[]; + Parquet?: IToken[]; + Native?: IToken[]; }; export interface GrantTableTargetCstNode extends CstNode { @@ -1879,10 +2104,12 @@ export interface GrantTableTargetCstNode extends CstNode { export type GrantTableTargetCstChildren = { tableName: TableNameCstNode[]; - LParen?: IToken[]; + LParen?: (IToken)[]; + Star?: IToken[]; + Exclude?: IToken[]; identifier?: (IdentifierCstNode)[]; - Comma?: IToken[]; - RParen?: IToken[]; + Comma?: (IToken)[]; + RParen?: (IToken)[]; }; export interface GrantAssumeServiceAccountStatementCstNode extends CstNode { @@ -1983,6 +2210,7 @@ export type RefreshMaterializedViewStatementCstChildren = { tableName: TableNameCstNode[]; Full?: IToken[]; Incremental?: IToken[]; + Stats?: IToken[]; Range?: IToken[]; From?: IToken[]; stringOrIdentifier?: (StringOrIdentifierCstNode)[]; @@ -2449,6 +2677,7 @@ export type OverClauseCstChildren = { windowPartitionByClause?: WindowPartitionByClauseCstNode[]; orderByClause?: OrderByClauseCstNode[]; windowFrameClause?: WindowFrameClauseCstNode[]; + anchorClause?: AnchorClauseCstNode[]; RParen?: IToken[]; identifier?: IdentifierCstNode[]; }; @@ -2487,6 +2716,7 @@ export type WindowSpecCstChildren = { windowPartitionByClause?: WindowPartitionByClauseCstNode[]; orderByClause?: OrderByClauseCstNode[]; windowFrameClause?: WindowFrameClauseCstNode[]; + anchorClause?: AnchorClauseCstNode[]; }; export interface WindowPartitionByClauseCstNode extends CstNode { @@ -2710,12 +2940,16 @@ export interface ICstNodeVisitor extends ICstVisitor { createStatement(children: CreateStatementCstChildren, param?: IN): OUT; createViewBody(children: CreateViewBodyCstChildren, param?: IN): OUT; createTableBody(children: CreateTableBodyCstChildren, param?: IN): OUT; + tableFormatKind(children: TableFormatKindCstChildren, param?: IN): OUT; + optionalTableFormat(children: OptionalTableFormatCstChildren, param?: IN): OUT; batchClause(children: BatchClauseCstChildren, param?: IN): OUT; dedupClause(children: DedupClauseCstChildren, param?: IN): OUT; createUserStatement(children: CreateUserStatementCstChildren, param?: IN): OUT; createGroupStatement(children: CreateGroupStatementCstChildren, param?: IN): OUT; createServiceAccountStatement(children: CreateServiceAccountStatementCstChildren, param?: IN): OUT; createMaterializedViewBody(children: CreateMaterializedViewBodyCstChildren, param?: IN): OUT; + expireRowsClause(children: ExpireRowsClauseCstChildren, param?: IN): OUT; + optionalExpireRows(children: OptionalExpireRowsCstChildren, param?: IN): OUT; materializedViewRefresh(children: MaterializedViewRefreshCstChildren, param?: IN): OUT; materializedViewPeriod(children: MaterializedViewPeriodCstChildren, param?: IN): OUT; materializedViewPartition(children: MaterializedViewPartitionCstChildren, param?: IN): OUT; @@ -2729,9 +2963,14 @@ export interface ICstNodeVisitor extends ICstVisitor { storagePolicyTtl(children: StoragePolicyTtlCstChildren, param?: IN): OUT; storagePolicyTimeUnit(children: StoragePolicyTimeUnitCstChildren, param?: IN): OUT; castDefinition(children: CastDefinitionCstChildren, param?: IN): OUT; + indexTypeOptions(children: IndexTypeOptionsCstChildren, param?: IN): OUT; indexDefinition(children: IndexDefinitionCstChildren, param?: IN): OUT; tableParamName(children: TableParamNameCstChildren, param?: IN): OUT; tableParam(children: TableParamCstChildren, param?: IN): OUT; + createLiveViewBody(children: CreateLiveViewBodyCstChildren, param?: IN): OUT; + dropLiveViewStatement(children: DropLiveViewStatementCstChildren, param?: IN): OUT; + alterLiveViewStatement(children: AlterLiveViewStatementCstChildren, param?: IN): OUT; + anchorClause(children: AnchorClauseCstChildren, param?: IN): OUT; partitionPeriod(children: PartitionPeriodCstChildren, param?: IN): OUT; timeUnit(children: TimeUnitCstChildren, param?: IN): OUT; alterStatement(children: AlterStatementCstChildren, param?: IN): OUT; @@ -2759,10 +2998,12 @@ export interface ICstNodeVisitor extends ICstVisitor { assumeServiceAccountStatement(children: AssumeServiceAccountStatementCstChildren, param?: IN): OUT; exitServiceAccountStatement(children: ExitServiceAccountStatementCstChildren, param?: IN): OUT; cancelQueryStatement(children: CancelQueryStatementCstChildren, param?: IN): OUT; + showCreateDatabaseCategory(children: ShowCreateDatabaseCategoryCstChildren, param?: IN): OUT; showStatement(children: ShowStatementCstChildren, param?: IN): OUT; explainStatement(children: ExplainStatementCstChildren, param?: IN): OUT; copyStatement(children: CopyStatementCstChildren, param?: IN): OUT; copyCancel(children: CopyCancelCstChildren, param?: IN): OUT; + copyPermissions(children: CopyPermissionsCstChildren, param?: IN): OUT; copyFrom(children: CopyFromCstChildren, param?: IN): OUT; copyTo(children: CopyToCstChildren, param?: IN): OUT; copyOptions(children: CopyOptionsCstChildren, param?: IN): OUT; @@ -2770,6 +3011,7 @@ export interface ICstNodeVisitor extends ICstVisitor { checkpointStatement(children: CheckpointStatementCstChildren, param?: IN): OUT; snapshotStatement(children: SnapshotStatementCstChildren, param?: IN): OUT; backupStatement(children: BackupStatementCstChildren, param?: IN): OUT; + switchStatement(children: SwitchStatementCstChildren, param?: IN): OUT; compileViewStatement(children: CompileViewStatementCstChildren, param?: IN): OUT; grantStatement(children: GrantStatementCstChildren, param?: IN): OUT; revokeStatement(children: RevokeStatementCstChildren, param?: IN): OUT; diff --git a/src/parser/lexer.ts b/src/parser/lexer.ts index fbc6500..73753dc 100644 --- a/src/parser/lexer.ts +++ b/src/parser/lexer.ts @@ -184,6 +184,7 @@ import { Owned, Param, Parameters, + Native, Parquet, ParquetVersion, PartitionBy, @@ -302,6 +303,29 @@ import { Policy, Local, Remote, + Rebase, + Stats, + Switch, + Role, + Status, + Replica, + Timeout, + Expire, + Cleanup, + Highest, + Lowest, + Live, + Flush, + Anchor, + Beginning, + Now, + Memory, + Daily, + Expression, + Posting, + Delta, + Ef, + Bitmap, } from "./tokens" // Re-export all keyword tokens for parser and external use @@ -486,6 +510,7 @@ export { Owned, Param, Parameters, + Native, Parquet, ParquetVersion, PartitionBy, @@ -604,6 +629,29 @@ export { Policy, Local, Remote, + Rebase, + Stats, + Switch, + Role, + Status, + Replica, + Timeout, + Expire, + Cleanup, + Highest, + Lowest, + Live, + Flush, + Anchor, + Beginning, + Now, + Memory, + Daily, + Expression, + Posting, + Delta, + Ef, + Bitmap, } // ============================================================================= diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 29673c5..23afab5 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -180,6 +180,7 @@ import { Complete, Release, Vacuum, + Rebase, Resume, Transaction, Txn, @@ -203,9 +204,33 @@ import { Header, Delimiter, Format, + Native, Error, Abort, StatisticsEnabled, + Stats, + Primary, + Switch, + Role, + Status, + Replica, + Timeout, + Expire, + Cleanup, + Highest, + Lowest, + Live, + Flush, + Anchor, + Beginning, + Now, + Memory, + Daily, + Expression, + Posting, + Delta, + Ef, + Bitmap, CompressionCodec, CompressionLevel, Capacity, @@ -443,6 +468,11 @@ class QuestDBParser extends CstParser { ) } + // QuestDB accepts these case-sensitively. See SqlParser.isValidSampleByPeriodLetter. + private isSampleByPeriodLetter(image: string): boolean { + return image.length === 1 && "nUTsmhdwMy".includes(image) + } + // ========================================================================== // Entry point // ========================================================================== @@ -517,6 +547,7 @@ class QuestDBParser extends CstParser { ALT: () => this.SUBRULE(this.pivotStatement), }, { ALT: () => this.SUBRULE(this.backupStatement) }, + { ALT: () => this.SUBRULE(this.switchStatement) }, { GATE: () => this.LA(1).tokenType === Compile && this.LA(2).tokenType === View, @@ -1025,37 +1056,41 @@ class QuestDBParser extends CstParser { 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) + // RANGE/LIST is optional: only the LAST join in a chain of HORIZON JOINs + // carries the trailing RANGE/LIST clause (#6881). + this.OPTION1(() => { + 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) + { + // 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 @@ -1107,6 +1142,15 @@ class QuestDBParser extends CstParser { }, }, { + // Static bound: DurationLiteral | (Number|String) timeUnit, PRECEDING|FOLLOWING + GATE: () => { + const la1 = this.LA(1).tokenType + if (la1 === DurationLiteral) return true + return ( + (la1 === NumberLiteral || la1 === StringLiteral) && + this.isTimeUnit(this.LA(2).tokenType) + ) + }, ALT: () => { this.SUBRULE(this.durationExpression) this.OR2([ @@ -1115,6 +1159,18 @@ class QuestDBParser extends CstParser { ]) }, }, + { + // Dynamic bound (#6859): [timeUnit] PRECEDING|FOLLOWING, + // where the bound is a column/cast/function expression. + ALT: () => { + this.SUBRULE(this.expression) + this.OPTION1(() => this.SUBRULE(this.timeUnit)) + this.OR3([ + { ALT: () => this.CONSUME2(Preceding) }, + { ALT: () => this.CONSUME2(Following) }, + ]) + }, + }, ]) }) @@ -1153,6 +1209,13 @@ class QuestDBParser extends CstParser { this.OR([ { ALT: () => this.CONSUME(DurationLiteral) }, { ALT: () => this.CONSUME(VariableReference) }, + // Bare single-letter period unit with no leading digit, e.g. `SAMPLE BY w` + // (equivalent to `SAMPLE BY 1w`). Lexes as an Identifier; the GATE keeps + // this alternative restricted to valid unit letters. + { + GATE: () => this.isSampleByPeriodLetter(this.LA(1).image), + ALT: () => this.CONSUME(Identifier), + }, ]) // Java order: FROM/TO → FILL → ALIGN TO this.OPTION(() => this.SUBRULE(this.fromToClause)) @@ -1421,6 +1484,7 @@ class QuestDBParser extends CstParser { this.OR([ { ALT: () => this.SUBRULE(this.createTableBody) }, { ALT: () => this.SUBRULE(this.createMaterializedViewBody) }, + { ALT: () => this.SUBRULE(this.createLiveViewBody) }, { ALT: () => this.SUBRULE(this.createViewBody) }, { ALT: () => this.SUBRULE(this.createUserStatement) }, { ALT: () => this.SUBRULE(this.createGroupStatement) }, @@ -1575,6 +1639,9 @@ class QuestDBParser extends CstParser { this.SUBRULE(this.optionalStoragePolicy) + // Optional FORMAT { PARQUET | NATIVE } (before WAL) + this.SUBRULE(this.optionalTableFormat) + // Optional WAL / BYPASS WAL this.OPTION5(() => { this.OR3([ @@ -1588,6 +1655,9 @@ class QuestDBParser extends CstParser { ]) }) + // Optional FORMAT { PARQUET | NATIVE } (after WAL) + this.SUBRULE1(this.optionalTableFormat) + // Optional WITH table parameters (comma-separated) // GATE: distinguish from WITH...AS (CTE) which starts the next statement. // Table params: WITH maxUncommittedRows=10 @@ -1638,6 +1708,9 @@ class QuestDBParser extends CstParser { this.SUBRULE(this.dedupClause) }) + // Optional FORMAT { PARQUET | NATIVE } (after DEDUP UPSERT KEYS) + this.SUBRULE2(this.optionalTableFormat) + // Optional OWNED BY this.OPTION9(() => { this.CONSUME(Owned) @@ -1646,6 +1719,23 @@ class QuestDBParser extends CstParser { }) }) + // FORMAT { PARQUET | NATIVE } — the mandatory kind, shared by CREATE TABLE + // (via optionalTableFormat) and ALTER TABLE ... SET FORMAT. + private tableFormatKind = this.RULE("tableFormatKind", () => { + this.CONSUME(Format) + this.OR([ + { ALT: () => this.CONSUME(Parquet) }, + { ALT: () => this.CONSUME(Native) }, + ]) + }) + + // Optional FORMAT clause; used in three positions in createTableBody. + // Optionality lives inside the rule so the caller only needs a SUBRULE + // (createTableBody has no free OPTION index). + private optionalTableFormat = this.RULE("optionalTableFormat", () => { + this.OPTION(() => this.SUBRULE(this.tableFormatKind)) + }) + private batchClause = this.RULE("batchClause", () => { this.CONSUME(Batch) this.CONSUME(NumberLiteral) @@ -1805,6 +1895,7 @@ class QuestDBParser extends CstParser { this.SUBRULE(this.materializedViewPartition) }) this.SUBRULE(this.optionalStoragePolicy) + this.SUBRULE(this.optionalExpireRows) this.OPTION8(() => { this.CONSUME(In) this.CONSUME(Volume) @@ -1821,6 +1912,82 @@ class QuestDBParser extends CstParser { }, ) + // EXPIRE ROWS row-retention clause (materialized views): + // EXPIRE ROWS WHEN + // EXPIRE ROWS KEEP LATEST [ON ] PARTITION BY + // EXPIRE ROWS KEEP [] (HIGHEST|LOWEST) [PARTITION BY ] + // [ CLEANUP EVERY ] + private expireRowsClause = this.RULE("expireRowsClause", () => { + this.CONSUME(Expire) + this.CONSUME(Rows) + this.OR([ + // WHEN + { + ALT: () => { + this.CONSUME(When) + this.SUBRULE(this.expression) + }, + }, + // KEEP ... + { + ALT: () => { + this.CONSUME(Keep) + this.OR1([ + // KEEP LATEST [ON ] PARTITION BY + { + ALT: () => { + this.CONSUME(Latest) + this.OPTION(() => { + this.CONSUME(On) + this.SUBRULE(this.identifier) + }) + this.CONSUME(Partition) + this.CONSUME(By) + this.SUBRULE1(this.identifier) + this.MANY(() => { + this.CONSUME(Comma) + this.SUBRULE2(this.identifier) + }) + }, + }, + // KEEP [] HIGHEST|LOWEST [PARTITION BY ] + { + ALT: () => { + this.OPTION1(() => this.CONSUME(NumberLiteral)) + this.OR2([ + { ALT: () => this.CONSUME(Highest) }, + { ALT: () => this.CONSUME(Lowest) }, + ]) + this.SUBRULE3(this.identifier) + this.OPTION2(() => { + this.CONSUME1(Partition) + this.CONSUME1(By) + this.SUBRULE4(this.identifier) + this.MANY1(() => { + this.CONSUME1(Comma) + this.SUBRULE5(this.identifier) + }) + }) + }, + }, + ]) + }, + }, + ]) + // Optional CLEANUP EVERY + this.OPTION3(() => { + this.CONSUME(Cleanup) + this.CONSUME(Every) + this.CONSUME(DurationLiteral) + }) + }) + + // Optional EXPIRE ROWS clause; optionality lives inside so callers only need + // a SUBRULE (createMaterializedViewBody has no free OPTION index). + private optionalExpireRows = this.RULE("optionalExpireRows", () => { + this.OPTION(() => this.SUBRULE(this.expireRowsClause)) + }) + private materializedViewRefresh = this.RULE("materializedViewRefresh", () => { this.OR([ { @@ -1929,10 +2096,7 @@ class QuestDBParser extends CstParser { }) this.OPTION2(() => { this.CONSUME(Index) - this.OPTION3(() => { - this.CONSUME1(Capacity) - this.CONSUME1(NumberLiteral) - }) + this.SUBRULE(this.indexTypeOptions) }) // Optional PARQUET config this.OPTION4(() => this.SUBRULE(this.parquetConfig)) @@ -2092,14 +2256,49 @@ class QuestDBParser extends CstParser { this.CONSUME(RParen) }) - private indexDefinition = this.RULE("indexDefinition", () => { - this.CONSUME(Index) - this.CONSUME(LParen) - this.SUBRULE(this.columnRef) + // Shared index options (#6861 posting index): + // [TYPE (POSTING [DELTA|EF] | BITMAP | NONE)] [INCLUDE (cols)] [CAPACITY n] + // Used by both column-level `INDEX …` and table-level `INDEX(col …)`. + private indexTypeOptions = this.RULE("indexTypeOptions", () => { this.OPTION(() => { + this.CONSUME(Type) + this.OR([ + { + ALT: () => { + this.CONSUME(Posting) + this.OPTION1(() => + this.OR1([ + { ALT: () => this.CONSUME(Delta) }, + { ALT: () => this.CONSUME(Ef) }, + ]), + ) + }, + }, + { ALT: () => this.CONSUME(Bitmap) }, + { ALT: () => this.CONSUME(None) }, + ]) + }) + this.OPTION2(() => { + this.CONSUME(Include) + this.CONSUME(LParen) + this.SUBRULE(this.identifier) + this.MANY(() => { + this.CONSUME(Comma) + this.SUBRULE1(this.identifier) + }) + this.CONSUME(RParen) + }) + this.OPTION3(() => { this.CONSUME(Capacity) this.CONSUME(NumberLiteral) }) + }) + + private indexDefinition = this.RULE("indexDefinition", () => { + this.CONSUME(Index) + this.CONSUME(LParen) + this.SUBRULE(this.columnRef) + this.SUBRULE(this.indexTypeOptions) this.CONSUME(RParen) }) @@ -2115,6 +2314,143 @@ class QuestDBParser extends CstParser { }) }) + // ========================================================================== + // LIVE VIEW statements + // ========================================================================== + + private createLiveViewBody = this.RULE("createLiveViewBody", () => { + this.CONSUME(Live) + this.CONSUME(View) + this.OPTION(() => { + this.CONSUME(If) + this.CONSUME(Not) + this.CONSUME(Exists) + }) + this.SUBRULE(this.stringOrQualifiedName) + this.CONSUME(Flush) + this.CONSUME(Every) + this.CONSUME(DurationLiteral) + // Optional clauses (any order): IN MEMORY , PARTITION BY , + // START FROM (NOW | BEGINNING | ''). + this.MANY(() => { + this.OR([ + { + ALT: () => { + this.CONSUME(In) + this.CONSUME(Memory) + this.CONSUME1(DurationLiteral) + }, + }, + { + ALT: () => { + this.CONSUME(Partition) + this.CONSUME(By) + this.SUBRULE(this.partitionPeriod) + }, + }, + { + ALT: () => { + this.CONSUME(Start) + this.CONSUME(From) + this.OR1([ + { ALT: () => this.CONSUME(Beginning) }, + { ALT: () => this.CONSUME(StringLiteral) }, + // NOW is a registered constant token (see grammar/constants.ts); + // this also lets autocomplete/highlighting treat it as a keyword. + { ALT: () => this.CONSUME(Now) }, + ]) + }, + }, + ]) + }) + this.CONSUME(As) + // Parens are optional (QuestDB accepts a bare SELECT), like CREATE VIEW. + this.OR2([ + { + GATE: () => { + const la2 = this.LA(2).tokenType + return la2 === Select || la2 === With || la2 === Declare + }, + ALT: () => { + this.CONSUME1(LParen) + this.SUBRULE(this.selectStatement) + this.CONSUME1(RParen) + }, + }, + { ALT: () => this.SUBRULE1(this.selectStatement) }, + ]) + }) + + private dropLiveViewStatement = this.RULE("dropLiveViewStatement", () => { + this.CONSUME(Live) + this.CONSUME(View) + this.OPTION(() => { + this.CONSUME(If) + this.CONSUME(Exists) + }) + this.SUBRULE(this.stringOrQualifiedName) + }) + + private alterLiveViewStatement = this.RULE("alterLiveViewStatement", () => { + this.CONSUME(Live) + this.CONSUME(View) + this.SUBRULE(this.tableName) + this.OR([ + { + // RESUME WAL [FROM (TXN|TRANSACTION) ] + ALT: () => { + this.CONSUME(Resume) + this.CONSUME(Wal) + this.OPTION(() => { + this.CONSUME(From) + this.OR1([ + { ALT: () => this.CONSUME(Txn) }, + { ALT: () => this.CONSUME(Transaction) }, + ]) + this.CONSUME(NumberLiteral) + }) + }, + }, + { + // SUSPEND WAL [WITH (|''), ''] + ALT: () => { + this.CONSUME(Suspend) + this.CONSUME1(Wal) + this.OPTION1(() => { + this.CONSUME(With) + this.OR2([ + { ALT: () => this.CONSUME1(NumberLiteral) }, + { ALT: () => this.CONSUME(StringLiteral) }, + ]) + this.CONSUME(Comma) + this.CONSUME1(StringLiteral) + }) + }, + }, + ]) + }) + + // OVER(... ) / WINDOW spec ANCHOR clause: + // ANCHOR EXPRESSION | ANCHOR DAILY '' [''] + private anchorClause = this.RULE("anchorClause", () => { + this.CONSUME(Anchor) + this.OR([ + { + ALT: () => { + this.CONSUME(Expression) + this.SUBRULE(this.expression) + }, + }, + { + ALT: () => { + this.CONSUME(Daily) + this.CONSUME(StringLiteral) + this.OPTION(() => this.CONSUME1(StringLiteral)) + }, + }, + ]) + }) + private partitionPeriod = this.RULE("partitionPeriod", () => { this.OR([ { ALT: () => this.CONSUME(None) }, @@ -2160,6 +2496,7 @@ class QuestDBParser extends CstParser { this.OR([ { ALT: () => this.SUBRULE(this.alterTableStatement) }, { ALT: () => this.SUBRULE(this.alterMaterializedViewStatement) }, + { ALT: () => this.SUBRULE(this.alterLiveViewStatement) }, { ALT: () => this.SUBRULE(this.alterViewStatement) }, { ALT: () => this.SUBRULE(this.alterUserStatement) }, { ALT: () => this.SUBRULE(this.alterServiceAccountStatement) }, @@ -2417,6 +2754,8 @@ class QuestDBParser extends CstParser { ALT: () => { this.CONSUME1(Add) this.CONSUME(Index) + // TYPE POSTING [DELTA|EF] | BITMAP | NONE, INCLUDE(cols), CAPACITY n (#6861) + this.SUBRULE(this.indexTypeOptions) }, }, { @@ -2532,6 +2871,8 @@ class QuestDBParser extends CstParser { }, // SET STORAGE POLICY(...) { ALT: () => this.SUBRULE(this.storagePolicy) }, + // SET FORMAT { PARQUET | NATIVE } + { ALT: () => this.SUBRULE(this.tableFormatKind) }, ]) }, }, @@ -2589,6 +2930,17 @@ class QuestDBParser extends CstParser { }) }, }, + // REBASE WAL [INTO ''] + { + ALT: () => { + this.CONSUME(Rebase) + this.CONSUME4(Wal) + this.OPTION1(() => { + this.CONSUME(Into) + this.CONSUME8(StringLiteral) + }) + }, + }, // CONVERT PARTITION { ALT: () => { @@ -2744,6 +3096,8 @@ class QuestDBParser extends CstParser { }, // SET STORAGE POLICY(...) { ALT: () => this.SUBRULE(this.storagePolicy) }, + // SET EXPIRE ROWS ... + { ALT: () => this.SUBRULE(this.expireRowsClause) }, ]) }, }, @@ -2769,6 +3123,25 @@ class QuestDBParser extends CstParser { this.CONSUME1(Wal) }, }, + // REBASE WAL [INTO ''] + { + ALT: () => { + this.CONSUME(Rebase) + this.CONSUME2(Wal) + this.OPTION4(() => { + this.CONSUME(Into) + this.CONSUME(StringLiteral) + }) + }, + }, + // DROP EXPIRE [ROWS] + { + ALT: () => { + this.CONSUME1(Drop) + this.CONSUME(Expire) + this.OPTION5(() => this.CONSUME(Rows)) + }, + }, // DROP STORAGE POLICY { ALT: () => { @@ -2806,6 +3179,7 @@ class QuestDBParser extends CstParser { this.OR([ { ALT: () => this.SUBRULE(this.dropTableStatement) }, { ALT: () => this.SUBRULE(this.dropMaterializedViewStatement) }, + { ALT: () => this.SUBRULE(this.dropLiveViewStatement) }, { ALT: () => this.SUBRULE(this.dropViewStatement) }, { ALT: () => this.SUBRULE(this.dropUserStatement) }, { ALT: () => this.SUBRULE(this.dropGroupStatement) }, @@ -2981,6 +3355,18 @@ class QuestDBParser extends CstParser { // SHOW Statement // ========================================================================== + // A SHOW CREATE DATABASE category: a plain word, or the `all` keyword + // (which QuestDB accepts inside the list, meaning "everything"). + private showCreateDatabaseCategory = this.RULE( + "showCreateDatabaseCategory", + () => { + this.OR([ + { ALT: () => this.CONSUME(All) }, + { ALT: () => this.SUBRULE(this.identifier) }, + ]) + }, + ) + private showStatement = this.RULE("showStatement", () => { this.CONSUME(Show) this.OR([ @@ -3022,6 +3408,40 @@ class QuestDBParser extends CstParser { this.SUBRULE9(this.qualifiedName) }, }, + // SHOW CREATE LIVE VIEW + { + ALT: () => { + this.CONSUME(Live) + this.CONSUME2(View) + this.SUBRULE(this.tableName) + }, + }, + // SHOW CREATE DATABASE [ (INCLUDE|EXCLUDE) (ALL | (cat, ...)) ] + { + ALT: () => { + this.CONSUME(Database) + this.OPTION5(() => { + this.OR3([ + { ALT: () => this.CONSUME(Include) }, + { ALT: () => this.CONSUME(Exclude) }, + ]) + this.OR4([ + { ALT: () => this.CONSUME(All) }, + { + ALT: () => { + this.CONSUME(LParen) + this.SUBRULE(this.showCreateDatabaseCategory) + this.MANY(() => { + this.CONSUME(Comma) + this.SUBRULE1(this.showCreateDatabaseCategory) + }) + this.CONSUME(RParen) + }, + }, + ]) + }) + }, + }, ]) }, }, @@ -3122,6 +3542,10 @@ class QuestDBParser extends CstParser { this.CONSUME(Copy) this.OR([ { ALT: () => this.SUBRULE(this.copyCancel) }, + { + GATE: this.BACKTRACK(this.copyPermissions), + ALT: () => this.SUBRULE(this.copyPermissions), + }, { GATE: this.BACKTRACK(this.copyFrom), ALT: () => this.SUBRULE(this.copyFrom), @@ -3142,6 +3566,15 @@ class QuestDBParser extends CstParser { this.CONSUME(Cancel) }) + // Enterprise: COPY PERMISSIONS FROM TO + private copyPermissions = this.RULE("copyPermissions", () => { + this.CONSUME(Permissions) + this.CONSUME(From) + this.SUBRULE(this.identifier) + this.CONSUME(To) + this.SUBRULE1(this.identifier) + }) + private copyFrom = this.RULE("copyFrom", () => { this.SUBRULE(this.tableName) this.CONSUME(From) @@ -3321,6 +3754,28 @@ class QuestDBParser extends CstParser { ]) }) + // SWITCH ROLE TO { PRIMARY | REPLICA } [ TIMEOUT ] | SWITCH STATUS + private switchStatement = this.RULE("switchStatement", () => { + this.CONSUME(Switch) + this.OR([ + { + ALT: () => { + this.CONSUME(Role) + this.CONSUME(To) + this.OR1([ + { ALT: () => this.CONSUME(Primary) }, + { ALT: () => this.CONSUME(Replica) }, + ]) + this.OPTION(() => { + this.CONSUME(Timeout) + this.CONSUME(NumberLiteral) + }) + }, + }, + { ALT: () => this.CONSUME(Status) }, + ]) + }) + // ========================================================================== // COMPILE VIEW Statement // ========================================================================== @@ -3425,6 +3880,7 @@ class QuestDBParser extends CstParser { { ALT: () => this.CONSUME(Show) }, { ALT: () => this.CONSUME(Vacuum) }, { ALT: () => this.CONSUME(Lock) }, + { ALT: () => this.CONSUME(Set) }, ]) // Optional second word (for compound permissions like CREATE TABLE, ALTER MATERIALIZED VIEW) this.OPTION(() => { @@ -3448,6 +3904,25 @@ class QuestDBParser extends CstParser { // DATABASE ADMIN, etc.) { ALT: () => this.SUBRULE1(this.identifier) }, ]) + // Optional third word for 3-word permissions, e.g. SET TABLE FORMAT, + // SET STORAGE POLICY, SET TABLE TYPE, REMOVE STORAGE POLICY. + this.OPTION1(() => this.SUBRULE2(this.identifier)) + // Trailing "TO PARQUET|NATIVE" for the CONVERT PARTITION TO {PARQUET|NATIVE} + // permission names, whose phrase embeds the TO that normally delimits + // GRANT … TO / REVOKE … FROM. Gated so an ordinary "… TO " is + // left untouched. + this.OPTION2({ + GATE: () => + this.LA(1).tokenType === To && + (this.LA(2).tokenType === Parquet || this.LA(2).tokenType === Native), + DEF: () => { + this.CONSUME(To) + this.OR2([ + { ALT: () => this.CONSUME(Parquet) }, + { ALT: () => this.CONSUME(Native) }, + ]) + }, + }) }) }) @@ -3455,11 +3930,34 @@ class QuestDBParser extends CstParser { this.SUBRULE(this.tableName) this.OPTION(() => { this.CONSUME(LParen) - this.SUBRULE(this.identifier) - this.MANY(() => { - this.CONSUME(Comma) - this.SUBRULE1(this.identifier) - }) + this.OR([ + { + // (*) or (* EXCLUDE(c1, c2, ...)) + ALT: () => { + this.CONSUME(Star) + this.OPTION1(() => { + this.CONSUME(Exclude) + this.CONSUME1(LParen) + this.SUBRULE(this.identifier) + this.MANY(() => { + this.CONSUME(Comma) + this.SUBRULE1(this.identifier) + }) + this.CONSUME1(RParen) + }) + }, + }, + { + // (col1, col2, ...) + ALT: () => { + this.SUBRULE2(this.identifier) + this.MANY1(() => { + this.CONSUME1(Comma) + this.SUBRULE3(this.identifier) + }) + }, + }, + ]) this.CONSUME(RParen) }) }) @@ -3569,6 +4067,7 @@ class QuestDBParser extends CstParser { this.OR([ { ALT: () => this.CONSUME(Full) }, { ALT: () => this.CONSUME(Incremental) }, + { ALT: () => this.CONSUME(Stats) }, { ALT: () => { this.CONSUME(Range) @@ -4247,6 +4746,7 @@ class QuestDBParser extends CstParser { this.OPTION(() => this.SUBRULE(this.windowPartitionByClause)) this.OPTION1(() => this.SUBRULE(this.orderByClause)) this.OPTION2(() => this.SUBRULE(this.windowFrameClause)) + this.OPTION3(() => this.SUBRULE(this.anchorClause)) this.CONSUME(RParen) }, }, @@ -4308,6 +4808,7 @@ class QuestDBParser extends CstParser { this.OPTION1(() => this.SUBRULE(this.windowPartitionByClause)) this.OPTION2(() => this.SUBRULE(this.orderByClause)) this.OPTION3(() => this.SUBRULE(this.windowFrameClause)) + this.OPTION4(() => this.SUBRULE(this.anchorClause)) }) private windowPartitionByClause = this.RULE("windowPartitionByClause", () => { diff --git a/src/parser/toSql.ts b/src/parser/toSql.ts index 7afa8c5..32f245a 100644 --- a/src/parser/toSql.ts +++ b/src/parser/toSql.ts @@ -46,14 +46,20 @@ function statementToSql(stmt: AST.Statement): string { return createTableToSql(stmt) case "createView": return createViewToSql(stmt) + case "createLiveView": + return createLiveViewToSql(stmt) case "alterTable": return alterTableToSql(stmt) case "alterView": return alterViewToSql(stmt) + case "alterLiveView": + return alterLiveViewToSql(stmt) case "dropTable": return dropTableToSql(stmt) case "dropView": return dropViewToSql(stmt) + case "dropLiveView": + return dropLiveViewToSql(stmt) case "truncateTable": return truncateTableToSql(stmt) case "renameTable": @@ -123,12 +129,16 @@ function statementToSql(stmt: AST.Statement): string { return reindexTableToSql(stmt) case "copyCancel": return `COPY ${escapeString(stmt.id)} CANCEL` + case "copyPermissions": + return `COPY PERMISSIONS FROM ${qualifiedNameToSql(stmt.from)} TO ${qualifiedNameToSql(stmt.to)}` case "copyFrom": return copyFromToSql(stmt) case "copyTo": return copyToToSql(stmt) case "backup": return backupToSql(stmt) + case "switch": + return switchToSql(stmt) case "alterGroup": return alterGroupToSql(stmt) case "compileView": @@ -443,7 +453,12 @@ function windowJoinBoundToSql(bound: AST.WindowJoinBound): string { } return "CURRENT ROW" } - return `${bound.duration} ${bound.direction!.toUpperCase()}` + const dir = bound.direction!.toUpperCase() + if (bound.boundExpr) { + const e = expressionToSql(bound.boundExpr) + return bound.unit ? `${e} ${bound.unit} ${dir}` : `${e} ${dir}` + } + return `${bound.duration} ${dir}` } function orderByItemToSql(item: AST.OrderByItem): string { @@ -549,6 +564,42 @@ function updateToSql(stmt: AST.UpdateStatement): string { // CREATE TABLE // ============================================================================= +function indexTypeToSql( + t: NonNullable, +): string { + switch (t) { + case "posting": + return "POSTING" + case "posting_delta": + return "POSTING DELTA" + case "posting_ef": + return "POSTING EF" + case "bitmap": + return "BITMAP" + case "none": + return "NONE" + } +} + +// [TYPE (POSTING [DELTA|EF] | BITMAP | NONE)] [INCLUDE(cols)] [CAPACITY n] +function indexOptionsToSql( + indexType: AST.ColumnDefinition["indexType"], + include: string[] | undefined, + capacity: number | undefined, +): string { + let s = "" + if (indexType) s += ` TYPE ${indexTypeToSql(indexType)}` + if (include && include.length > 0) { + s += ` INCLUDE(${include.map(escapeIdentifier).join(", ")})` + } + if (capacity != null) s += ` CAPACITY ${capacity}` + return s +} + +function indexDefToSql(idx: AST.IndexDefinition): string { + return `INDEX(${qualifiedNameToSql(idx.column)}${indexOptionsToSql(idx.indexType, idx.include, idx.capacity)})` +} + function columnDefToSql(c: AST.ColumnDefinition): string { let sql = `${escapeIdentifier(c.name)} ${c.dataType}` if (c.symbolCapacity != null) { @@ -561,9 +612,7 @@ function columnDefToSql(c: AST.ColumnDefinition): string { } if (c.indexed) { sql += " INDEX" - if (c.indexCapacity != null) { - sql += ` CAPACITY ${c.indexCapacity}` - } + sql += indexOptionsToSql(c.indexType, c.indexInclude, c.indexCapacity) } if (c.parquetConfig) { sql += " " + parquetConfigToSql(c.parquetConfig) @@ -645,11 +694,7 @@ function createTableToSql(stmt: AST.CreateTableStatement): string { } if (stmt.indexes && stmt.indexes.length > 0) { for (const idx of stmt.indexes) { - asSql += `, INDEX(${qualifiedNameToSql(idx.column)}` - if (idx.capacity != null) { - asSql += ` CAPACITY ${idx.capacity}` - } - asSql += ")" + asSql += ", " + indexDefToSql(idx) } } parts.push(asSql) @@ -657,11 +702,7 @@ function createTableToSql(stmt: AST.CreateTableStatement): string { let colSql = `(${stmt.columns.map(columnDefToSql).join(", ")})` if (stmt.indexes && stmt.indexes.length > 0) { for (const idx of stmt.indexes) { - colSql += `, INDEX(${qualifiedNameToSql(idx.column)}` - if (idx.capacity != null) { - colSql += ` CAPACITY ${idx.capacity}` - } - colSql += ")" + colSql += ", " + indexDefToSql(idx) } } parts.push(colSql) @@ -683,6 +724,10 @@ function createTableToSql(stmt: AST.CreateTableStatement): string { parts.push(storagePolicyToSql(stmt.storagePolicy)) } + if (stmt.tableFormat) { + parts.push(`FORMAT ${stmt.tableFormat.toUpperCase()}`) + } + if (stmt.bypassWal) { parts.push("BYPASS WAL") } else if (stmt.wal) { @@ -752,6 +797,56 @@ function dropViewToSql(stmt: AST.DropViewStatement): string { return parts.join(" ") } +function createLiveViewToSql(stmt: AST.CreateLiveViewStatement): string { + const parts: string[] = ["CREATE LIVE VIEW"] + if (stmt.ifNotExists) parts.push("IF NOT EXISTS") + parts.push(qualifiedNameToSql(stmt.view)) + parts.push(`FLUSH EVERY ${stmt.flushEvery}`) + if (stmt.inMemory) parts.push(`IN MEMORY ${stmt.inMemory}`) + if (stmt.partitionBy) parts.push(`PARTITION BY ${stmt.partitionBy}`) + if (stmt.startFrom) { + if (stmt.startFrom.kind === "now") parts.push("START FROM NOW") + else if (stmt.startFrom.kind === "beginning") + parts.push("START FROM BEGINNING") + else parts.push(`START FROM ${escapeString(stmt.startFrom.value!)}`) + } + parts.push(`AS (${selectToSql(stmt.query)})`) + return parts.join(" ") +} + +function dropLiveViewToSql(stmt: AST.DropLiveViewStatement): string { + const parts: string[] = ["DROP LIVE VIEW"] + if (stmt.ifExists) parts.push("IF EXISTS") + parts.push(qualifiedNameToSql(stmt.view)) + return parts.join(" ") +} + +function alterLiveViewToSql(stmt: AST.AlterLiveViewStatement): string { + const parts = [`ALTER LIVE VIEW ${qualifiedNameToSql(stmt.view)}`] + if (stmt.action === "suspendWal") { + parts.push("SUSPEND WAL") + if (stmt.code != null || stmt.message) { + const w: string[] = [] + if (stmt.code != null) { + w.push( + typeof stmt.code === "number" + ? String(stmt.code) + : escapeString(stmt.code), + ) + } + if (stmt.message) w.push(escapeString(stmt.message)) + parts.push(`WITH ${w.join(", ")}`) + } + } else { + parts.push("RESUME WAL") + if (stmt.fromTxn !== undefined) parts.push(`FROM TXN ${stmt.fromTxn}`) + else if (stmt.fromTransaction !== undefined) { + parts.push(`FROM TRANSACTION ${stmt.fromTransaction}`) + } + } + return parts.join(" ") +} + // ============================================================================= // ALTER TABLE // ============================================================================= @@ -795,7 +890,14 @@ function alterTableToSql(stmt: AST.AlterTableStatement): string { parts.push("NOCACHE") } } else if (action.alterType === "addIndex") { - parts.push("ADD INDEX") + parts.push( + "ADD INDEX" + + indexOptionsToSql( + action.indexType, + action.indexInclude, + action.capacity, + ), + ) } else if (action.alterType === "dropIndex") { parts.push("DROP INDEX") } else if ( @@ -863,6 +965,9 @@ function alterTableToSql(stmt: AST.AlterTableStatement): string { parts.push("SET TTL") parts.push(formatTimeUnit(action.ttl.value, action.ttl.unit)) break + case "setTableFormat": + parts.push(`SET FORMAT ${action.format.toUpperCase()}`) + break case "dedupDisable": parts.push("DEDUP DISABLE") break @@ -900,6 +1005,13 @@ function alterTableToSql(stmt: AST.AlterTableStatement): string { } break } + case "rebaseWal": { + parts.push("REBASE WAL") + if (action.targetDir != null) { + parts.push(`INTO ${escapeString(action.targetDir)}`) + } + break + } case "convertPartition": { parts.push("CONVERT PARTITION") if (action.partitions && action.partitions.length > 0) { @@ -1006,6 +1118,17 @@ function showToSql(stmt: AST.ShowStatement): string { return `SHOW CREATE VIEW ${qualifiedNameToSql(stmt.table!)}` case "createMaterializedView": return `SHOW CREATE MATERIALIZED VIEW ${qualifiedNameToSql(stmt.table!)}` + case "createLiveView": + return `SHOW CREATE LIVE VIEW ${qualifiedNameToSql(stmt.table!)}` + case "createDatabase": { + let s = "SHOW CREATE DATABASE" + const inc = stmt.databaseInclude + if (inc) { + s += inc.mode === "include" ? " INCLUDE" : " EXCLUDE" + s += inc.all ? " ALL" : ` (${(inc.categories ?? []).join(", ")})` + } + return s + } case "serverVersion": return "SHOW SERVER_VERSION" case "parameters": @@ -1099,6 +1222,31 @@ function materializedViewPeriodToSql( return `PERIOD (${inner.join(" ")})` } +function expireRowsClauseToSql(clause: AST.ExpireRowsClause): string { + const parts = ["EXPIRE ROWS"] + if (clause.mode === "when") { + parts.push("WHEN", expressionToSql(clause.predicate!)) + } else if (clause.mode === "keepLatest") { + parts.push("KEEP LATEST") + if (clause.on) parts.push("ON", escapeIdentifier(clause.on)) + parts.push( + `PARTITION BY ${(clause.partitionBy ?? []).map(escapeIdentifier).join(", ")}`, + ) + } else { + parts.push("KEEP") + if (clause.keepCount !== undefined) parts.push(String(clause.keepCount)) + parts.push(clause.extremum === "lowest" ? "LOWEST" : "HIGHEST") + parts.push(escapeIdentifier(clause.column!)) + if (clause.partitionBy && clause.partitionBy.length > 0) { + parts.push( + `PARTITION BY ${clause.partitionBy.map(escapeIdentifier).join(", ")}`, + ) + } + } + if (clause.cleanupEvery) parts.push("CLEANUP EVERY", clause.cleanupEvery) + return parts.join(" ") +} + function createMaterializedViewToSql( stmt: AST.CreateMaterializedViewStatement, ): string { @@ -1114,9 +1262,7 @@ function createMaterializedViewToSql( : `AS ${selectToSql(stmt.query)}` if (stmt.indexes && stmt.indexes.length > 0) { for (const idx of stmt.indexes) { - asSql += `, INDEX(${qualifiedNameToSql(idx.column)}` - if (idx.capacity != null) asSql += ` CAPACITY ${idx.capacity}` - asSql += ")" + asSql += ", " + indexDefToSql(idx) } } parts.push(asSql) @@ -1126,6 +1272,7 @@ function createMaterializedViewToSql( if (stmt.ttl) parts.push(`TTL ${formatTimeUnit(stmt.ttl.value, stmt.ttl.unit)}`) if (stmt.storagePolicy) parts.push(storagePolicyToSql(stmt.storagePolicy)) + if (stmt.expireRows) parts.push(expireRowsClauseToSql(stmt.expireRows)) if (stmt.volume) parts.push(`IN VOLUME ${escapeIdentifier(stmt.volume)}`) if (stmt.ownedBy) parts.push(`OWNED BY ${escapeIdentifier(stmt.ownedBy)}`) return parts.join(" ") @@ -1153,6 +1300,13 @@ function alterMaterializedViewToSql( case "setTtl": parts.push(`SET TTL ${formatTimeUnit(action.ttl.value, action.ttl.unit)}`) break + case "setExpireRows": + parts.push("SET") + parts.push(expireRowsClauseToSql(action.expireRows)) + break + case "dropExpire": + parts.push("DROP EXPIRE ROWS") + break case "setRefreshLimit": parts.push( `SET REFRESH LIMIT ${formatTimeUnit(action.limit.value, action.limit.unit)}`, @@ -1181,6 +1335,13 @@ function alterMaterializedViewToSql( case "suspendWal": parts.push("SUSPEND WAL") break + case "rebaseWal": { + parts.push("REBASE WAL") + if (action.targetDir != null) { + parts.push(`INTO ${escapeString(action.targetDir)}`) + } + break + } case "setStoragePolicy": parts.push("SET") parts.push(storagePolicyToSql(action.policy)) @@ -1216,6 +1377,7 @@ function refreshMaterializedViewToSql( ] if (stmt.mode === "full") parts.push("FULL") else if (stmt.mode === "incremental") parts.push("INCREMENTAL") + else if (stmt.mode === "stats") parts.push("STATS") else if (stmt.mode === "range") { parts.push( `RANGE FROM ${escapeString(stmt.from!)} TO ${escapeString(stmt.to!)}`, @@ -1275,6 +1437,13 @@ function backupToSql(stmt: AST.BackupStatement): string { return `BACKUP TABLE ${qualifiedNameToSql(stmt.table!)}` } +function switchToSql(stmt: AST.SwitchStatement): string { + if (stmt.action === "status") return "SWITCH STATUS" + let s = `SWITCH ROLE TO ${stmt.role}` + if (stmt.timeout !== undefined) s += ` TIMEOUT ${stmt.timeout}` + return s +} + function createServiceAccountToSql( stmt: AST.CreateServiceAccountStatement, ): string { @@ -1363,7 +1532,12 @@ function grantOnTargetToSql(on: AST.GrantOnTarget): string { if (on.tables) { const tableParts = on.tables.map((t) => { let sql = qualifiedNameToSql(t.table) - if (t.columns && t.columns.length > 0) { + if (t.allColumns) { + sql += + t.excludeColumns && t.excludeColumns.length > 0 + ? ` (* EXCLUDE(${t.excludeColumns.map(escapeIdentifier).join(", ")}))` + : " (*)" + } else if (t.columns && t.columns.length > 0) { sql += ` (${t.columns.map(escapeIdentifier).join(", ")})` } return sql @@ -1793,9 +1967,22 @@ function windowSpecToSql(spec: AST.WindowSpecification): string { parts.push(windowFrameToSql(spec.frame)) } + if (spec.anchor) { + parts.push(anchorClauseToSql(spec.anchor)) + } + return parts.join(" ") } +function anchorClauseToSql(a: AST.AnchorClause): string { + if (a.kind === "expression") { + return `ANCHOR EXPRESSION ${expressionToSql(a.expr!)}` + } + let s = `ANCHOR DAILY ${escapeString(a.time!)}` + if (a.timezone) s += ` ${escapeString(a.timezone)}` + return s +} + function namedWindowToSql(w: AST.NamedWindow): string { const inner: string[] = [] if (w.baseWindow) inner.push(w.baseWindow) @@ -1808,6 +1995,9 @@ function namedWindowToSql(w: AST.NamedWindow): string { if (w.frame) { inner.push(windowFrameToSql(w.frame)) } + if (w.anchor) { + inner.push(anchorClauseToSql(w.anchor)) + } return `${w.name} AS (${inner.join(" ")})` } diff --git a/src/parser/tokens.ts b/src/parser/tokens.ts index 980a97e..ae4be8c 100644 --- a/src/parser/tokens.ts +++ b/src/parser/tokens.ts @@ -364,6 +364,35 @@ export const IDENTIFIER_KEYWORD_NAMES = new globalThis.Set([ "Lateral", "Ordinality", "BloomFilter", + "Rebase", + "Stats", + "Switch", + "Role", + "Status", + "Replica", + "Timeout", + "Expire", + "Cleanup", + "Highest", + "Lowest", + "Live", + "Flush", + "Anchor", + "Beginning", + "Memory", + "Daily", + "Expression", + "Posting", + "Delta", + "Ef", + "Bitmap", + "Schema", + "Acl", + "Views", + "MaterializedViews", + "ServiceAccounts", + // CREATE LIVE VIEW ... START FROM NOW (same word as the now() function) + "Now", ]) for (const name of IDENTIFIER_KEYWORD_NAMES) { @@ -553,6 +582,7 @@ export const Owned = getToken("Owned") export const Param = getToken("Param") export const Parameters = getToken("Parameters") export const Parquet = getToken("Parquet") +export const Native = getToken("Native") export const ParquetVersion = getToken("ParquetVersion") export const PartitionBy = getToken("PartitionBy") export const Partition = getToken("Partition") @@ -726,3 +756,26 @@ export const Doy = getToken("Doy") export const Epoch = getToken("Epoch") export const Isodow = getToken("Isodow") export const Isoyear = getToken("Isoyear") +export const Rebase = getToken("Rebase") +export const Stats = getToken("Stats") +export const Switch = getToken("Switch") +export const Role = getToken("Role") +export const Status = getToken("Status") +export const Replica = getToken("Replica") +export const Timeout = getToken("Timeout") +export const Expire = getToken("Expire") +export const Cleanup = getToken("Cleanup") +export const Highest = getToken("Highest") +export const Lowest = getToken("Lowest") +export const Live = getToken("Live") +export const Flush = getToken("Flush") +export const Anchor = getToken("Anchor") +export const Beginning = getToken("Beginning") +export const Now = getToken("Now") +export const Memory = getToken("Memory") +export const Daily = getToken("Daily") +export const Expression = getToken("Expression") +export const Posting = getToken("Posting") +export const Delta = getToken("Delta") +export const Ef = getToken("Ef") +export const Bitmap = getToken("Bitmap") diff --git a/src/parser/visitor.ts b/src/parser/visitor.ts index 720091d..a625b65 100644 --- a/src/parser/visitor.ts +++ b/src/parser/visitor.ts @@ -22,7 +22,9 @@ import type { AlterTableStatementCstChildren, AlterUserActionCstChildren, AlterUserStatementCstChildren, + AlterLiveViewStatementCstChildren, AlterViewStatementCstChildren, + AnchorClauseCstChildren, AndExpressionCstChildren, ArrayBracketBodyCstChildren, ArrayElementCstChildren, @@ -48,12 +50,14 @@ import type { Ipv4ContainmentExpressionCstChildren, ConvertPartitionTargetCstChildren, CopyCancelCstChildren, + CopyPermissionsCstChildren, CopyFromCstChildren, CopyOptionCstChildren, CopyOptionsCstChildren, CopyStatementCstChildren, CopyToCstChildren, CreateGroupStatementCstChildren, + CreateLiveViewBodyCstChildren, CreateMaterializedViewBodyCstChildren, CreateServiceAccountStatementCstChildren, CreateStatementCstChildren, @@ -71,10 +75,12 @@ import type { DropStatementCstChildren, DropTableStatementCstChildren, DropUserStatementCstChildren, + DropLiveViewStatementCstChildren, DropViewStatementCstChildren, DurationExpressionCstChildren, EqualityExpressionCstChildren, ExitServiceAccountStatementCstChildren, + ExpireRowsClauseCstChildren, ExplainStatementCstChildren, ExpressionCstChildren, FillClauseCstChildren, @@ -94,6 +100,7 @@ import type { ImplicitSelectBodyCstChildren, ImplicitSelectStatementCstChildren, IndexDefinitionCstChildren, + IndexTypeOptionsCstChildren, InsertStatementCstChildren, IntervalValueCstChildren, JoinClauseCstChildren, @@ -109,7 +116,9 @@ import type { OrderByClauseCstChildren, OrderByItemCstChildren, OverClauseCstChildren, + OptionalExpireRowsCstChildren, OptionalStoragePolicyCstChildren, + OptionalTableFormatCstChildren, ParquetCompressionCstChildren, ParquetConfigCstChildren, ParquetEncodingCstChildren, @@ -141,6 +150,7 @@ import type { SetExpressionCstChildren, SetOperationCstChildren, SetTypeStatementCstChildren, + ShowCreateDatabaseCategoryCstChildren, ShowStatementCstChildren, SimpleSelectCstChildren, SnapshotStatementCstChildren, @@ -158,6 +168,8 @@ import type { StringOrQualifiedNameCstChildren, TableFunctionCallCstChildren, TableFunctionNameCstChildren, + SwitchStatementCstChildren, + TableFormatKindCstChildren, TableNameCstChildren, TableNameOrStringCstChildren, TableParamCstChildren, @@ -353,6 +365,9 @@ class QuestDBVisitor extends BaseVisitor { if (ctx.backupStatement) { return this.visit(ctx.backupStatement) as AST.BackupStatement } + if (ctx.switchStatement) { + return this.visit(ctx.switchStatement) as AST.SwitchStatement + } if (ctx.compileViewStatement) { return this.visit(ctx.compileViewStatement) as AST.CompileViewStatement } @@ -840,8 +855,9 @@ class QuestDBVisitor extends BaseVisitor { 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 + } else if (ctx.identifier && ctx.identifier.length > 0) { + // Explicit alias (AS ): the FIRST identifier is the table alias. + // (The horizon alias, when present, is the last identifier.) tableRef.alias = ( this.visit(ctx.identifier[0]) as AST.QualifiedName ).parts[0] @@ -869,8 +885,9 @@ class QuestDBVisitor extends BaseVisitor { (o) => this.visit(o) as string, ) } - // Horizon alias is always the last identifier - if (ctx.identifier) { + // Horizon alias (the trailing `AS `) exists only with a RANGE/LIST clause, + // and is the LAST identifier. Non-last joins in a chain have none (#6881). + if ((ctx.Range || ctx.List) && ctx.identifier) { const lastId = ctx.identifier[ctx.identifier.length - 1] result.horizonAlias = (this.visit(lastId) as AST.QualifiedName).parts[0] } @@ -913,6 +930,11 @@ class QuestDBVisitor extends BaseVisitor { } if (ctx.durationExpression) { result.duration = this.visit(ctx.durationExpression) as string + } else if (ctx.expression) { + result.boundExpr = this.visit(ctx.expression[0]) as AST.Expression + if (ctx.timeUnit) { + result.unit = this.visit(ctx.timeUnit[0]) as string + } } return result } @@ -945,7 +967,9 @@ class QuestDBVisitor extends BaseVisitor { type: "sampleBy", duration: ctx.DurationLiteral ? this.tokenImage(ctx.DurationLiteral[0]) - : this.tokenImage(ctx.VariableReference![0]), + : ctx.VariableReference + ? this.tokenImage(ctx.VariableReference[0]) + : this.tokenImage(ctx.Identifier![0]), } if (ctx.fillClause) { result.fill = this.visit(ctx.fillClause) as string[] @@ -1200,6 +1224,9 @@ class QuestDBVisitor extends BaseVisitor { ctx.createMaterializedViewBody, ) as AST.CreateMaterializedViewStatement } + if (ctx.createLiveViewBody) { + return this.visit(ctx.createLiveViewBody) as AST.CreateLiveViewStatement + } if (ctx.createViewBody) { return this.visit(ctx.createViewBody) as AST.CreateViewStatement } @@ -1330,9 +1357,33 @@ class QuestDBVisitor extends BaseVisitor { result.dedupKeys = this.visit(ctx.dedupClause) as string[] } + // FORMAT { PARQUET | NATIVE } — accepted in three positions; take the + // first one that carried a value. + if (ctx.optionalTableFormat) { + for (const node of ctx.optionalTableFormat) { + const fmt = this.visit(node) as "parquet" | "native" | undefined + if (fmt) { + result.tableFormat = fmt + break + } + } + } + return result } + tableFormatKind(ctx: TableFormatKindCstChildren): "parquet" | "native" { + return ctx.Native ? "native" : "parquet" + } + + optionalTableFormat( + ctx: OptionalTableFormatCstChildren, + ): "parquet" | "native" | undefined { + return ctx.tableFormatKind + ? (this.visit(ctx.tableFormatKind) as "parquet" | "native") + : undefined + } + columnDefinition(ctx: ColumnDefinitionCstChildren): AST.ColumnDefinition { const result: AST.ColumnDefinition = { type: "columnDefinition", @@ -1375,6 +1426,16 @@ class QuestDBVisitor extends BaseVisitor { // INDEX if (indexToken) { result.indexed = true + if (ctx.indexTypeOptions) { + const opts = this.visit(ctx.indexTypeOptions[0]) as { + capacity?: number + indexType?: AST.ColumnDefinition["indexType"] + include?: string[] + } + if (opts.capacity !== undefined) result.indexCapacity = opts.capacity + if (opts.indexType) result.indexType = opts.indexType + if (opts.include) result.indexInclude = opts.include + } } // PARQUET config @@ -1498,12 +1559,53 @@ class QuestDBVisitor extends BaseVisitor { type: "indexDefinition", column: colRef.name ?? colRef, } - if (ctx.Capacity && ctx.NumberLiteral) { - result.capacity = tokenInt(ctx.NumberLiteral[0].image) + if (ctx.indexTypeOptions) { + const opts = this.visit(ctx.indexTypeOptions[0]) as { + capacity?: number + indexType?: AST.IndexDefinition["indexType"] + include?: string[] + } + if (opts.capacity !== undefined) result.capacity = opts.capacity + if (opts.indexType) result.indexType = opts.indexType + if (opts.include) result.include = opts.include } return result } + indexTypeOptions(ctx: IndexTypeOptionsCstChildren): { + capacity?: number + indexType?: AST.ColumnDefinition["indexType"] + include?: string[] + } { + const r: { + capacity?: number + indexType?: AST.ColumnDefinition["indexType"] + include?: string[] + } = {} + if (ctx.Type) { + if (ctx.Posting) { + r.indexType = ctx.Delta + ? "posting_delta" + : ctx.Ef + ? "posting_ef" + : "posting" + } else if (ctx.Bitmap) { + r.indexType = "bitmap" + } else if (ctx.None) { + r.indexType = "none" + } + } + if (ctx.Include && ctx.identifier) { + r.include = ctx.identifier.map( + (id) => (this.visit(id) as AST.QualifiedName).parts[0], + ) + } + if (ctx.Capacity && ctx.NumberLiteral) { + r.capacity = tokenInt(ctx.NumberLiteral[0].image) + } + return r + } + tableParamName(ctx: TableParamNameCstChildren): string { if (ctx.identifier) return this.extractIdentifierName(ctx.identifier[0].children) @@ -1683,9 +1785,56 @@ class QuestDBVisitor extends BaseVisitor { if (ctx.Owned && ctx.stringOrIdentifier) { result.ownedBy = this.visit(ctx.stringOrIdentifier) as string } + if (ctx.optionalExpireRows) { + const er = this.visit(ctx.optionalExpireRows) as + | AST.ExpireRowsClause + | undefined + if (er) result.expireRows = er + } return result } + expireRowsClause(ctx: ExpireRowsClauseCstChildren): AST.ExpireRowsClause { + const idents = (ctx.identifier ?? []).map((id: IdentifierCstNode) => + this.extractIdentifierName(id.children), + ) + let clause: AST.ExpireRowsClause + if (ctx.When) { + clause = { + mode: "when", + predicate: this.visit(ctx.expression!) as AST.Expression, + } + } else if (ctx.Latest) { + let i = 0 + const on = ctx.On ? idents[i++] : undefined + clause = { mode: "keepLatest", partitionBy: idents.slice(i) } + if (on) clause.on = on + } else { + clause = { + mode: "keepExtremum", + extremum: ctx.Highest ? "highest" : "lowest", + column: idents[0], + } + const partitionBy = idents.slice(1) + if (partitionBy.length > 0) clause.partitionBy = partitionBy + if (ctx.NumberLiteral) { + clause.keepCount = tokenInt(ctx.NumberLiteral[0].image) + } + } + if (ctx.Cleanup && ctx.DurationLiteral) { + clause.cleanupEvery = ctx.DurationLiteral[0].image + } + return clause + } + + optionalExpireRows( + ctx: OptionalExpireRowsCstChildren, + ): AST.ExpireRowsClause | undefined { + return ctx.expireRowsClause + ? (this.visit(ctx.expireRowsClause) as AST.ExpireRowsClause) + : undefined + } + materializedViewRefresh( ctx: MaterializedViewRefreshCstChildren, ): AST.MaterializedViewRefresh { @@ -1750,6 +1899,92 @@ class QuestDBVisitor extends BaseVisitor { return result } + createLiveViewBody( + ctx: CreateLiveViewBodyCstChildren, + ): AST.CreateLiveViewStatement { + const durations = (ctx.DurationLiteral ?? []).map((t) => t.image) + const result: AST.CreateLiveViewStatement = { + type: "createLiveView", + view: this.visit(ctx.stringOrQualifiedName) as AST.QualifiedName, + flushEvery: durations[0], + query: this.visit(ctx.selectStatement![0]) as AST.SelectStatement, + } + if (ctx.If) result.ifNotExists = true + if (ctx.Memory && durations.length > 1) result.inMemory = durations[1] + if (ctx.partitionPeriod) { + result.partitionBy = this.visit( + ctx.partitionPeriod, + ) as AST.CreateLiveViewStatement["partitionBy"] + } + if (ctx.Start) { + if (ctx.Beginning) { + result.startFrom = { kind: "beginning" } + } else if (ctx.StringLiteral) { + result.startFrom = { + kind: "timestamp", + value: ctx.StringLiteral[0].image.slice(1, -1), + } + } else { + result.startFrom = { kind: "now" } + } + } + return result + } + + dropLiveViewStatement( + ctx: DropLiveViewStatementCstChildren, + ): AST.DropLiveViewStatement { + return { + type: "dropLiveView", + view: this.visit(ctx.stringOrQualifiedName) as AST.QualifiedName, + ifExists: !!ctx.If, + } + } + + alterLiveViewStatement( + ctx: AlterLiveViewStatementCstChildren, + ): AST.AlterLiveViewStatement { + const result: AST.AlterLiveViewStatement = { + type: "alterLiveView", + view: this.visit(ctx.tableName) as AST.QualifiedName, + action: ctx.Suspend ? "suspendWal" : "resumeWal", + } + if (ctx.Resume && ctx.NumberLiteral) { + const n = tokenInt(ctx.NumberLiteral[0].image) + if (ctx.Txn) result.fromTxn = n + else if (ctx.Transaction) result.fromTransaction = n + } + if (ctx.Suspend && ctx.With) { + if (ctx.NumberLiteral) { + result.code = tokenInt(ctx.NumberLiteral[0].image) + } else if (ctx.StringLiteral && ctx.StringLiteral.length > 0) { + result.code = ctx.StringLiteral[0].image.slice(1, -1) + } + const strings = ctx.StringLiteral || [] + if (strings.length > 0) { + result.message = strings[strings.length - 1].image.slice(1, -1) + } + } + return result + } + + anchorClause(ctx: AnchorClauseCstChildren): AST.AnchorClause { + if (ctx.Expression) { + return { + kind: "expression", + expr: this.visit(ctx.expression!) as AST.Expression, + } + } + const result: AST.AnchorClause = { + kind: "daily", + time: ctx.StringLiteral![0].image.slice(1, -1), + } + if (ctx.StringLiteral && ctx.StringLiteral.length > 1) { + result.timezone = ctx.StringLiteral[1].image.slice(1, -1) + } + return result + } + partitionPeriod( ctx: PartitionPeriodCstChildren, ): "NONE" | "HOUR" | "DAY" | "WEEK" | "MONTH" | "YEAR" { @@ -1816,6 +2051,11 @@ class QuestDBVisitor extends BaseVisitor { ctx.alterMaterializedViewStatement, ) as AST.AlterMaterializedViewStatement } + if (ctx.alterLiveViewStatement) { + return this.visit( + ctx.alterLiveViewStatement, + ) as AST.AlterLiveViewStatement + } if (ctx.alterViewStatement) { return this.visit(ctx.alterViewStatement) as AST.AlterViewStatement } @@ -1896,6 +2136,17 @@ class QuestDBVisitor extends BaseVisitor { policy: this.visit(ctx.storagePolicy) as AST.StoragePolicy, } } + // SET EXPIRE ROWS ... + if (ctx.Set && ctx.expireRowsClause) { + return { + actionType: "setExpireRows", + expireRows: this.visit(ctx.expireRowsClause) as AST.ExpireRowsClause, + } + } + // DROP EXPIRE [ROWS] + if (ctx.Drop && ctx.Expire) { + return { actionType: "dropExpire" } + } // DROP STORAGE POLICY if (ctx.Drop && ctx.Storage && ctx.Policy && !ctx.Alter) { return { actionType: "dropStoragePolicy" } @@ -1967,6 +2218,17 @@ class QuestDBVisitor extends BaseVisitor { return { actionType: "suspendWal" } } + // REBASE WAL [INTO ''] + if (ctx.Rebase) { + const result: AST.AlterMaterializedViewRebaseWal = { + actionType: "rebaseWal", + } + if (ctx.Into && ctx.StringLiteral && ctx.StringLiteral.length > 0) { + result.targetDir = ctx.StringLiteral[0].image.slice(1, -1) + } + return result + } + return { actionType: "setRefresh", refresh: ctx.materializedViewRefresh @@ -2128,6 +2390,8 @@ class QuestDBVisitor extends BaseVisitor { | "symbolCapacity" = "type" let newType: string | undefined let capacity: number | undefined + let indexType: AST.AlterColumnAction["indexType"] + let indexInclude: string[] | undefined if (ctx.Type) { alterType = "type" @@ -2137,6 +2401,16 @@ class QuestDBVisitor extends BaseVisitor { } } else if (ctx.Add && ctx.Index) { alterType = "addIndex" + if (ctx.indexTypeOptions) { + const opts = this.visit(ctx.indexTypeOptions[0]) as { + capacity?: number + indexType?: AST.AlterColumnAction["indexType"] + include?: string[] + } + if (opts.capacity !== undefined) capacity = opts.capacity + if (opts.indexType) indexType = opts.indexType + if (opts.include) indexInclude = opts.include + } } else if (ctx.Drop && ctx.Index) { alterType = "dropIndex" } else if (ctx.Symbol && ctx.Capacity) { @@ -2159,6 +2433,8 @@ class QuestDBVisitor extends BaseVisitor { if (capacity !== undefined) { result.capacity = capacity } + if (indexType) result.indexType = indexType + if (indexInclude) result.indexInclude = indexInclude if (alterType === "type") { if (ctx.Cache) result.cache = true else if (ctx.Nocache) result.cache = false @@ -2229,6 +2505,13 @@ class QuestDBVisitor extends BaseVisitor { } } + if (ctx.Set && ctx.tableFormatKind) { + return { + actionType: "setTableFormat", + format: this.visit(ctx.tableFormatKind) as "parquet" | "native", + } + } + if (ctx.Dedup && ctx.Disable) { return { actionType: "dedupDisable", @@ -2277,6 +2560,15 @@ class QuestDBVisitor extends BaseVisitor { return result } + // REBASE WAL [INTO ''] + if (ctx.Rebase) { + const result: AST.RebaseWalAction = { actionType: "rebaseWal" } + if (ctx.Into && ctx.StringLiteral && ctx.StringLiteral.length > 0) { + result.targetDir = ctx.StringLiteral[0].image.slice(1, -1) + } + return result + } + // CONVERT PARTITION if (ctx.Convert) { const target = this.visit( @@ -2312,6 +2604,9 @@ class QuestDBVisitor extends BaseVisitor { ctx.dropMaterializedViewStatement, ) as AST.DropMaterializedViewStatement } + if (ctx.dropLiveViewStatement) { + return this.visit(ctx.dropLiveViewStatement) as AST.DropLiveViewStatement + } if (ctx.dropViewStatement) { return this.visit(ctx.dropViewStatement) as AST.DropViewStatement } @@ -2497,6 +2792,14 @@ class QuestDBVisitor extends BaseVisitor { // SHOW Statement // ========================================================================== + showCreateDatabaseCategory( + ctx: ShowCreateDatabaseCategoryCstChildren, + ): string { + return ctx.All + ? "all" + : (this.visit(ctx.identifier![0]) as AST.QualifiedName).parts[0] + } + showStatement(ctx: ShowStatementCstChildren): AST.ShowStatement { if (ctx.Tables) { return { @@ -2529,6 +2832,13 @@ class QuestDBVisitor extends BaseVisitor { table: this.visit(ctx.qualifiedName!) as AST.QualifiedName, } } + if (ctx.Live) { + return { + type: "show", + showType: "createLiveView", + table: this.visit(ctx.tableName!) as AST.QualifiedName, + } + } if (ctx.View) { return { type: "show", @@ -2536,6 +2846,26 @@ class QuestDBVisitor extends BaseVisitor { table: this.visit(ctx.qualifiedName!) as AST.QualifiedName, } } + if (ctx.Database) { + const result: AST.ShowStatement = { + type: "show", + showType: "createDatabase", + } + if (ctx.Include || ctx.Exclude) { + const mode = ctx.Include ? "include" : "exclude" + if (ctx.All) { + result.databaseInclude = { mode, all: true } + } else { + result.databaseInclude = { + mode, + categories: (ctx.showCreateDatabaseCategory ?? []).map( + (n) => this.visit(n) as string, + ), + } + } + } + return result + } return { type: "show", showType: "createTable", @@ -2668,6 +2998,9 @@ class QuestDBVisitor extends BaseVisitor { if (ctx.copyCancel) { return this.visit(ctx.copyCancel) as AST.CopyCancelStatement } + if (ctx.copyPermissions) { + return this.visit(ctx.copyPermissions) as AST.CopyPermissionsStatement + } if (ctx.copyFrom) { return this.visit(ctx.copyFrom) as AST.CopyFromStatement } @@ -2689,6 +3022,16 @@ class QuestDBVisitor extends BaseVisitor { } } + copyPermissions( + ctx: CopyPermissionsCstChildren, + ): AST.CopyPermissionsStatement { + return { + type: "copyPermissions", + from: this.visit(ctx.identifier[0]) as AST.QualifiedName, + to: this.visit(ctx.identifier[1]) as AST.QualifiedName, + } + } + copyFrom(ctx: CopyFromCstChildren): AST.CopyFromStatement { const result: AST.CopyFromStatement = { type: "copyFrom", @@ -2843,6 +3186,21 @@ class QuestDBVisitor extends BaseVisitor { } } + switchStatement(ctx: SwitchStatementCstChildren): AST.SwitchStatement { + if (ctx.Status) { + return { type: "switch", action: "status" } + } + const result: AST.SwitchStatement = { + type: "switch", + action: "role", + role: ctx.Replica ? "REPLICA" : "PRIMARY", + } + if (ctx.Timeout && ctx.NumberLiteral) { + result.timeout = tokenInt(ctx.NumberLiteral[0].image) + } + return result + } + compileViewStatement( ctx: CompileViewStatementCstChildren, ): AST.CompileViewStatement { @@ -2979,10 +3337,16 @@ class QuestDBVisitor extends BaseVisitor { type: "grantTableTarget", table: this.visit(ctx.tableName) as AST.QualifiedName, } - if (ctx.identifier && ctx.identifier.length > 0) { - result.columns = ctx.identifier.map((id: IdentifierCstNode) => - this.extractIdentifierName(id.children), - ) + const cols = (ctx.identifier ?? []).map((id: IdentifierCstNode) => + this.extractIdentifierName(id.children), + ) + if (ctx.Star) { + result.allColumns = true + if (ctx.Exclude && cols.length > 0) { + result.excludeColumns = cols + } + } else if (cols.length > 0) { + result.columns = cols } return result } @@ -3076,6 +3440,7 @@ class QuestDBVisitor extends BaseVisitor { } if (ctx.Full) result.mode = "full" if (ctx.Incremental) result.mode = "incremental" + if (ctx.Stats) result.mode = "stats" if (ctx.Range) { result.mode = "range" if (ctx.stringOrIdentifier) { @@ -3952,6 +4317,10 @@ class QuestDBVisitor extends BaseVisitor { result.frame = this.visit(ctx.windowFrameClause) as AST.WindowFrame } + if (ctx.anchorClause) { + result.anchor = this.visit(ctx.anchorClause) as AST.AnchorClause + } + return result } @@ -3995,6 +4364,10 @@ class QuestDBVisitor extends BaseVisitor { result.frame = this.visit(ctx.windowFrameClause) as AST.WindowFrame } + if (ctx.anchorClause) { + result.anchor = this.visit(ctx.anchorClause) as AST.AnchorClause + } + return result } diff --git a/tests/autocomplete.test.ts b/tests/autocomplete.test.ts index a8a2363..0a1327d 100644 --- a/tests/autocomplete.test.ts +++ b/tests/autocomplete.test.ts @@ -4691,4 +4691,126 @@ describe("Position-typed suggestions — by statement type", () => { expect(labels).toContain("WINDOW") }) }) + + // =========================================================================== + // Non-reserved keyword clauses that the grammar accepts only via the + // `identifier` sub-rule (so Chevrotain reports them as the abstract + // IdentifierKeyword category). content-assist re-injects the concrete words + // per rule context so these multi-word clauses autocomplete token-by-token. + // Regression guard for CAVEATS.md gaps #1–#4. Parsing already accepted all of + // these (see tests/parser.test.ts); this covers the autocomplete hint only. + // =========================================================================== + describe("context keywords: GRANT / CONVERT PARTITION / SHOW CREATE DATABASE", () => { + describe("GRANT/REVOKE CONVERT PARTITION permission", () => { + it("suggests CONVERT (and REMOVE) as a GRANT permission", () => { + const labels = getLabelsAt(provider, "GRANT ") + expect(labels).toContain("CONVERT") + expect(labels).toContain("REMOVE") + // existing explicit permissions still there + expect(labels).toContain("SELECT") + expect(labels).toContain("SET") + }) + + it("suggests CONVERT as a REVOKE permission", () => { + expect(getLabelsAt(provider, "REVOKE ")).toContain("CONVERT") + }) + + it("suggests PARTITION after GRANT CONVERT", () => { + expect(getLabelsAt(provider, "GRANT CONVERT ")).toContain("PARTITION") + }) + + it("suggests PARTITION after REVOKE CONVERT", () => { + expect(getLabelsAt(provider, "REVOKE CONVERT ")).toContain("PARTITION") + }) + + it("does NOT leak CONVERT into the grantee position", () => { + // After ON TO we are choosing a grantee, not a permission. + const labels = getLabelsAt(provider, "GRANT SELECT ON trades TO ") + expect(labels).not.toContain("CONVERT") + expect(labels).not.toContain("PARTITION") + }) + }) + + describe("three-word GRANT permissions", () => { + it("suggests FORMAT and TYPE after GRANT SET TABLE", () => { + const labels = getLabelsAt(provider, "GRANT SET TABLE ") + expect(labels).toContain("FORMAT") + expect(labels).toContain("TYPE") + }) + + it("suggests STORAGE after GRANT SET", () => { + expect(getLabelsAt(provider, "GRANT SET ")).toContain("STORAGE") + }) + + it("suggests POLICY after GRANT SET STORAGE", () => { + expect(getLabelsAt(provider, "GRANT SET STORAGE ")).toContain("POLICY") + }) + + it("suggests POLICY after GRANT REMOVE STORAGE", () => { + expect(getLabelsAt(provider, "GRANT REMOVE STORAGE ")).toContain( + "POLICY", + ) + }) + }) + + describe("ALTER TABLE ... CONVERT PARTITION TO { PARQUET | NATIVE }", () => { + it("suggests PARQUET and NATIVE after CONVERT PARTITION TO", () => { + const labels = getLabelsAt( + provider, + "ALTER TABLE trades CONVERT PARTITION TO ", + ) + expect(labels).toContain("PARQUET") + expect(labels).toContain("NATIVE") + }) + }) + + describe("SHOW CREATE DATABASE (INCLUDE|EXCLUDE) categories", () => { + it("suggests category names after INCLUDE (", () => { + const labels = getLabelsAt(provider, "SHOW CREATE DATABASE INCLUDE (") + expect(labels).toContain("TABLES") + expect(labels).toContain("USERS") + expect(labels).toContain("PERMISSIONS") + expect(labels).toContain("ALL") + }) + + it("suggests category names after EXCLUDE (", () => { + expect( + getLabelsAt(provider, "SHOW CREATE DATABASE EXCLUDE ("), + ).toContain("VIEWS") + }) + + it("suggests remaining categories after a comma", () => { + const labels = getLabelsAt( + provider, + "SHOW CREATE DATABASE INCLUDE (PERMISSIONS, ", + ) + expect(labels).toContain("TABLES") + expect(labels).toContain("MATERIALIZED_VIEWS") + }) + }) + + it("does NOT leak permission keywords into an ordinary SELECT", () => { + const labels = getLabelsAt(provider, "SELECT ") + expect(labels).not.toContain("POLICY") + expect(labels).not.toContain("CONVERT") + expect(labels).not.toContain("STORAGE") + }) + + describe("CREATE LIVE VIEW ... START FROM", () => { + it("suggests NOW and BEGINNING after START FROM", () => { + const labels = getLabelsAt( + provider, + "CREATE LIVE VIEW lv FLUSH EVERY 1s START FROM ", + ) + // NOW is matched via a gated Identifier; re-injected here. + expect(labels).toContain("NOW") + // BEGINNING comes from an explicit token. + expect(labels).toContain("BEGINNING") + }) + + it("does NOT leak NOW into an unrelated FROM clause", () => { + expect(getLabelsAt(provider, "SELECT * FROM ")).not.toContain("NOW") + }) + }) + }) }) diff --git a/tests/fixtures/docs-queries.json b/tests/fixtures/docs-queries.json index 926b30c..5f4570d 100644 --- a/tests/fixtures/docs-queries.json +++ b/tests/fixtures/docs-queries.json @@ -4088,10 +4088,10 @@ "query": "CREATE VIEW mixed_params AS (\n DECLARE @fixed := 5, OVERRIDABLE @adjustable := 10\n SELECT * FROM data WHERE a >= @fixed AND b <= @adjustable\n)" }, { - "query": "CREATE VIEW 日本語ビュー AS (SELECT * FROM trades)" + "query": "CREATE VIEW \u65e5\u672c\u8a9e\u30d3\u30e5\u30fc AS (SELECT * FROM trades)" }, { - "query": "CREATE VIEW Részvény_árak AS (SELECT * FROM prices)" + "query": "CREATE VIEW R\u00e9szv\u00e9ny_\u00e1rak AS (SELECT * FROM prices)" }, { "query": "CREATE VIEW with_timestamp AS (\n (SELECT ts, value FROM my_view ORDER BY ts) timestamp(ts)\n)" @@ -4442,7 +4442,7 @@ "skipAutocomplete": true }, { - "query": "-- This would fail if combinations × aggregates > 5000\ntrades PIVOT (\n avg(price)\n FOR symbol IN (SELECT DISTINCT symbol FROM trades) -- many symbols\n side IN ('buy', 'sell') -- × 2\n)", + "query": "-- This would fail if combinations \u00d7 aggregates > 5000\ntrades PIVOT (\n avg(price)\n FOR symbol IN (SELECT DISTINCT symbol FROM trades) -- many symbols\n side IN ('buy', 'sell') -- \u00d7 2\n)", "skipAutocomplete": true }, { @@ -5362,5 +5362,107 @@ }, { "query": "CREATE TABLE t (x LONG, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY TTL 12h" + }, + { + "query": "SELECT ts, avg(px) FROM trades SAMPLE BY w", + "skipAutocomplete": true + }, + { + "query": "REFRESH MATERIALIZED VIEW price_1h STATS" + }, + { + "query": "ALTER TABLE trades REBASE WAL" + }, + { + "query": "ALTER TABLE trades REBASE WAL INTO 'replica_dir'" + }, + { + "query": "ALTER MATERIALIZED VIEW price_1h REBASE WAL" + }, + { + "query": "SHOW CREATE DATABASE" + }, + { + "query": "SHOW CREATE DATABASE INCLUDE ALL" + }, + { + "query": "SHOW CREATE DATABASE INCLUDE (tables, views, users)" + }, + { + "query": "CREATE TABLE trades (ts TIMESTAMP, px DOUBLE) TIMESTAMP(ts) PARTITION BY DAY FORMAT PARQUET WAL" + }, + { + "query": "ALTER TABLE trades SET FORMAT NATIVE" + }, + { + "query": "SWITCH ROLE TO PRIMARY" + }, + { + "query": "SWITCH ROLE TO REPLICA TIMEOUT 5000" + }, + { + "query": "SWITCH STATUS" + }, + { + "query": "GRANT SELECT ON trades(*) TO alice" + }, + { + "query": "GRANT SELECT ON trades(* EXCLUDE(secret)) TO alice" + }, + { + "query": "GRANT SET TABLE FORMAT ON trades TO alice" + }, + { + "query": "CREATE MATERIALIZED VIEW price_1h AS (SELECT ts, avg(px) FROM trades SAMPLE BY 1h) PARTITION BY DAY EXPIRE ROWS KEEP LATEST PARTITION BY sym" + }, + { + "query": "ALTER MATERIALIZED VIEW price_1h SET EXPIRE ROWS KEEP HIGHEST px PARTITION BY sym CLEANUP EVERY 1h" + }, + { + "query": "ALTER MATERIALIZED VIEW price_1h DROP EXPIRE ROWS" + }, + { + "query": "CREATE LIVE VIEW lv FLUSH EVERY 5s START FROM NOW AS (SELECT * FROM trades)", + "skipAutocomplete": true + }, + { + "query": "CREATE LIVE VIEW lv FLUSH EVERY 5s IN MEMORY 1h PARTITION BY DAY START FROM BEGINNING AS (SELECT ts, px FROM trades)", + "skipAutocomplete": true + }, + { + "query": "DROP LIVE VIEW IF EXISTS lv" + }, + { + "query": "ALTER LIVE VIEW lv RESUME WAL" + }, + { + "query": "SHOW CREATE LIVE VIEW lv" + }, + { + "query": "SHOW CREATE DATABASE INCLUDE (tables, all)" + }, + { + "query": "SELECT avg(px) OVER (PARTITION BY sym ORDER BY ts ANCHOR DAILY '09:30') FROM trades", + "skipAutocomplete": true + }, + { + "query": "CREATE TABLE trades (sym SYMBOL INDEX TYPE POSTING DELTA CAPACITY 256, px DOUBLE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY WAL" + }, + { + "query": "CREATE TABLE t (sym SYMBOL INDEX TYPE POSTING INCLUDE (px), px DOUBLE, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY" + }, + { + "query": "SELECT * FROM trades t WINDOW JOIN prices p ON t.sym = p.sym RANGE BETWEEN wndBound SECONDS PRECEDING AND wndBound SECONDS FOLLOWING", + "skipAutocomplete": true + }, + { + "query": "SELECT * FROM trades t HORIZON JOIN bids AS b ON (t.sym = b.sym) HORIZON JOIN asks AS a ON (t.sym = a.sym) LIST (-1s, 0s, 1s) AS h", + "skipAutocomplete": true + }, + { + "query": "COPY PERMISSIONS FROM alice TO bob" + }, + { + "query": "GRANT CONVERT PARTITION TO PARQUET ON trades TO alice" } -] +] \ No newline at end of file diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 8d948eb..7f61c84 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -7929,4 +7929,622 @@ orders PIVOT (sum(amount) FOR status IN ('open'))` expect(reparsed.ast[0]).toEqual(result.ast[0]) }) }) + // =========================================================================== + // QuestDB parity: parse + AST shape for the new / modified statements + // =========================================================================== + + describe("SAMPLE BY bare unit — parse & AST", () => { + for (const u of ["s", "m", "h", "d", "w", "M", "y", "T", "U", "n"]) { + it(`parses SAMPLE BY ${u}`, () => { + const r = parseToAst(`SELECT ts, avg(x) FROM t SAMPLE BY ${u}`) + expect(r.errors).toHaveLength(0) + const s = r.ast[0] + if (s.type === "select") expect(s.sampleBy?.duration).toBe(u) + }) + } + it("rejects an arbitrary identifier unit", () => { + expect( + parseToAst("SELECT ts, avg(x) FROM t SAMPLE BY foo").errors.length, + ).toBeGreaterThan(0) + }) + }) + + describe("REFRESH MATERIALIZED VIEW STATS — parse & AST", () => { + it("STATS mode", () => { + const r = parseToAst("REFRESH MATERIALIZED VIEW mv STATS") + expect(r.errors).toHaveLength(0) + const s = r.ast[0] as AST.RefreshMaterializedViewStatement + expect(s.type).toBe("refreshMaterializedView") + expect(s.mode).toBe("stats") + }) + }) + + describe("REBASE WAL — parse & AST", () => { + it("ALTER TABLE ... REBASE WAL INTO dir", () => { + const r = parseToAst("ALTER TABLE t REBASE WAL INTO 'd1'") + expect(r.errors).toHaveLength(0) + const s = r.ast[0] as AST.AlterTableStatement + expect(s.action.actionType).toBe("rebaseWal") + }) + it("ALTER MATERIALIZED VIEW ... REBASE WAL", () => { + expect( + parseToAst("ALTER MATERIALIZED VIEW mv REBASE WAL").errors, + ).toHaveLength(0) + }) + }) + + describe("SHOW CREATE DATABASE — parse & AST", () => { + it("bare", () => { + const s = parseToAst("SHOW CREATE DATABASE").ast[0] as AST.ShowStatement + expect(s.showType).toBe("createDatabase") + }) + it("EXCLUDE (list)", () => { + const s = parseToAst("SHOW CREATE DATABASE EXCLUDE (users, groups)") + .ast[0] as AST.ShowStatement + expect(s.databaseInclude).toEqual({ + mode: "exclude", + categories: ["users", "groups"], + }) + }) + it("`all` inside the category list", () => { + expect( + parseToAst("SHOW CREATE DATABASE INCLUDE (tables, all)").errors, + ).toHaveLength(0) + }) + + // The nine category names are now registered grammar constants (previously + // schema/views/acl/materialized_views/service_accounts lexed as plain + // Identifier). Each must still parse and round-trip into the AST as its + // lowercase name. + it("captures every category name", () => { + const s = parseToAst( + "SHOW CREATE DATABASE INCLUDE (tables, views, materialized_views, users, groups, service_accounts, permissions, schema, acl)", + ).ast[0] as AST.ShowStatement + expect(s.databaseInclude).toEqual({ + mode: "include", + categories: [ + "tables", + "views", + "materialized_views", + "users", + "groups", + "service_accounts", + "permissions", + "schema", + "acl", + ], + }) + }) + + // Registering these as constants must NOT make them reserved — they stay + // usable as table/column names and aliases. + it("newly-registered category words remain usable as identifiers", () => { + for (const sql of [ + "SELECT schema, views, acl FROM t", + "CREATE TABLE t (schema INT, views LONG, acl STRING, ts TIMESTAMP) TIMESTAMP(ts)", + "SELECT * FROM schema", + "SELECT views AS materialized_views FROM t", + ]) { + expect(parseToAst(sql).errors, sql).toHaveLength(0) + } + }) + }) + + describe("table FORMAT — parse & AST", () => { + it("CREATE TABLE ... FORMAT PARQUET", () => { + const s = parseToAst( + "CREATE TABLE t (a INT) TIMESTAMP(a) PARTITION BY DAY FORMAT PARQUET WAL", + ).ast[0] as AST.CreateTableStatement + expect(s.tableFormat).toBe("parquet") + }) + it("ALTER TABLE ... SET FORMAT NATIVE", () => { + expect(parseToAst("ALTER TABLE t SET FORMAT NATIVE").errors).toHaveLength( + 0, + ) + }) + }) + + describe("SWITCH ROLE / STATUS — parse & AST", () => { + it("SWITCH ROLE TO REPLICA TIMEOUT n", () => { + const s = parseToAst("SWITCH ROLE TO REPLICA TIMEOUT 5000") + .ast[0] as AST.SwitchStatement + expect(s.type).toBe("switch") + expect(s.action).toBe("role") + expect(s.role).toBe("REPLICA") + expect(s.timeout).toBe(5000) + }) + it("SWITCH STATUS", () => { + const s = parseToAst("SWITCH STATUS").ast[0] as AST.SwitchStatement + expect(s.action).toBe("status") + }) + it("switch() function is unaffected", () => { + expect( + parseToAst("SELECT switch(x, 1, 'a', 'b') FROM t").errors, + ).toHaveLength(0) + }) + }) + + describe("GRANT/REVOKE column wildcard + EXCLUDE — parse & AST", () => { + it("wildcard", () => { + const r = parseToAst("GRANT SELECT ON tab(*) TO alice") + expect(r.errors).toHaveLength(0) + expect(JSON.stringify(r.ast[0])).toContain('"allColumns":true') + }) + it("wildcard EXCLUDE", () => { + const r = parseToAst("GRANT SELECT ON tab(* EXCLUDE(a, b)) TO alice") + expect(r.errors).toHaveLength(0) + expect(JSON.stringify(r.ast[0])).toContain('"excludeColumns":["a","b"]') + }) + it("3-word permission and CONVERT PARTITION permission parse", () => { + expect( + parseToAst("GRANT SET TABLE FORMAT ON t TO alice").errors, + ).toHaveLength(0) + expect( + parseToAst("GRANT CONVERT PARTITION TO PARQUET ON tab TO alice").errors, + ).toHaveLength(0) + }) + }) + + describe("mat-view EXPIRE ROWS — parse & AST", () => { + it("KEEP LATEST PARTITION BY", () => { + const s = parseToAst( + "CREATE MATERIALIZED VIEW mv AS (SELECT * FROM base) EXPIRE ROWS KEEP LATEST PARTITION BY sym", + ).ast[0] as AST.CreateMaterializedViewStatement + expect(s.expireRows?.mode).toBe("keepLatest") + }) + it("ALTER ... DROP EXPIRE", () => { + expect( + parseToAst("ALTER MATERIALIZED VIEW mv DROP EXPIRE").errors, + ).toHaveLength(0) + }) + }) + + describe("LIVE VIEWS — parse & AST", () => { + it("CREATE with clauses", () => { + const s = parseToAst( + "CREATE LIVE VIEW lv FLUSH EVERY 5s IN MEMORY 1h START FROM BEGINNING AS (SELECT * FROM t)", + ).ast[0] as AST.CreateLiveViewStatement + expect(s.type).toBe("createLiveView") + expect(s.flushEvery).toBe("5s") + expect(s.inMemory).toBe("1h") + expect(s.startFrom).toEqual({ kind: "beginning" }) + }) + it("ALTER LIVE VIEW RESUME WAL FROM TXN", () => { + expect( + parseToAst("ALTER LIVE VIEW lv RESUME WAL FROM TXN 1").errors, + ).toHaveLength(0) + }) + it("SHOW CREATE LIVE VIEW", () => { + const s = parseToAst("SHOW CREATE LIVE VIEW lv") + .ast[0] as AST.ShowStatement + expect(s.showType).toBe("createLiveView") + }) + it("ANCHOR in named window and inline OVER", () => { + expect( + parseToAst( + "SELECT avg(x) OVER w FROM t WINDOW w AS (ORDER BY ts ANCHOR DAILY '09:30')", + ).errors, + ).toHaveLength(0) + expect( + parseToAst( + "SELECT avg(x) OVER (ORDER BY ts ANCHOR EXPRESSION timestamp_floor('1d', ts)) FROM t", + ).errors, + ).toHaveLength(0) + }) + }) + + describe("COPY PERMISSIONS — parse & AST", () => { + it("COPY PERMISSIONS FROM src TO dst", () => { + const s = parseToAst("COPY PERMISSIONS FROM src TO dst") + .ast[0] as AST.CopyPermissionsStatement + expect(s.type).toBe("copyPermissions") + }) + }) + + describe("posting index — parse & AST", () => { + it("column TYPE POSTING DELTA INCLUDE", () => { + const s = parseToAst( + "CREATE TABLE t (s SYMBOL INDEX TYPE POSTING DELTA INCLUDE (a, b), a INT, b INT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY", + ).ast[0] as AST.CreateTableStatement + expect(s.columns![0].indexType).toBe("posting_delta") + expect(s.columns![0].indexInclude).toEqual(["a", "b"]) + }) + it("table-level INDEX(col TYPE POSTING)", () => { + expect( + parseToAst( + "CREATE TABLE t (s SYMBOL, ts TIMESTAMP), INDEX(s TYPE POSTING) TIMESTAMP(ts) PARTITION BY DAY", + ).errors, + ).toHaveLength(0) + }) + it("ALTER COLUMN ADD INDEX TYPE POSTING INCLUDE", () => { + expect( + parseToAst( + "ALTER TABLE t ALTER COLUMN sym ADD INDEX TYPE POSTING INCLUDE (p)", + ).errors, + ).toHaveLength(0) + }) + }) + + describe("dynamic WINDOW JOIN bound — parse", () => { + it("column / cast expression bound", () => { + expect( + parseToAst( + "SELECT * FROM t WINDOW JOIN p ON t.sym = p.sym RANGE BETWEEN wndBound SECONDS PRECEDING AND wndBound SECONDS FOLLOWING", + ).errors, + ).toHaveLength(0) + }) + }) + + describe("multi-RHS HORIZON JOIN — parse", () => { + it("chained HORIZON JOINs before one trailing LIST", () => { + expect( + parseToAst( + "SELECT * FROM trades t HORIZON JOIN bids AS b ON (t.sym = b.sym) HORIZON JOIN asks AS a ON (t.sym = a.sym) LIST (-1s, 0s, 1s) AS h", + ).errors, + ).toHaveLength(0) + }) + }) + + describe("new function names — parse", () => { + const fns = [ + "SELECT sleep(1.5)", + "SELECT regr_r2(y, x) FROM t", + "SELECT is_end_of_month(ts) FROM t", + "SELECT kurtosis(x), skewness(x) FROM t", + "SELECT array_agg(x) FROM t", + "SELECT * FROM live_views()", + "SELECT cume_dist() OVER (ORDER BY x) FROM t", + "SELECT ntile(4) OVER (ORDER BY x) FROM t", + "SELECT * FROM backups()", + ] + for (const q of fns) + it(`parses: ${q}`, () => expect(parseToAst(q).errors).toHaveLength(0)) + }) + + // =========================================================================== + // QuestDB parity: round-trip over the SQL harvested from each feature's own + // QuestDB / QuestDB-Enterprise test suite (see commits-since-release.md). + // =========================================================================== + + describe("SAMPLE BY bare unit (QuestDB #7391) — round-trip", () => { + const queries = [ + "select ts, avg(x) from fromto sample by w from '2017-12-20' to '2018-01-31' fill(null) align to calendar", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("REFRESH MATERIALIZED VIEW STATS (QuestDB #7112) — round-trip", () => { + const queries = ["refresh materialized view price_1h stats"] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("REBASE WAL (QuestDB #7239) — round-trip", () => { + const queries = [ + "alter table t rebase wal", + "alter table base_price rebase wal", + "alter materialized view price_1h rebase wal", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("SHOW CREATE DATABASE (QuestDB #7232) — round-trip", () => { + const queries = [ + "SHOW CREATE DATABASE", + "SHOW CREATE DATABASE INCLUDE (TABLES)", + "SHOW CREATE DATABASE INCLUDE (SCHEMA)", + "SHOW CREATE DATABASE EXCLUDE (VIEWS)", + "SHOW CREATE DATABASE INCLUDE ALL", + "SHOW CREATE DATABASE INCLUDE (USERS)", + "SHOW CREATE DATABASE INCLUDE (GROUPS)", + "SHOW CREATE DATABASE INCLUDE (SERVICE_ACCOUNTS)", + "SHOW CREATE DATABASE INCLUDE (PERMISSIONS)", + "SHOW CREATE DATABASE INCLUDE (ACL)", + "SHOW CREATE DATABASE INCLUDE (PERMISSIONS, TABLES)", + "SHOW CREATE DATABASE INCLUDE (ALL)", + "SHOW CREATE DATABASE INCLUDE (VIEWS)", + "SHOW CREATE DATABASE INCLUDE (MATERIALIZED_VIEWS)", + "SHOW CREATE DATABASE EXCLUDE ALL", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("table FORMAT PARQUET|NATIVE (QuestDB #7107) — round-trip", () => { + const queries = [ + "CREATE TABLE tango (ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY FORMAT PARQUET WAL", + "CREATE TABLE tango (ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY WAL FORMAT PARQUET", + "CREATE TABLE tango (ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY FORMAT NATIVE WAL", + "CREATE TABLE tango (ts TIMESTAMP, n LONG) TIMESTAMP(ts) PARTITION BY DAY WAL DEDUP UPSERT KEYS(ts) FORMAT PARQUET", + "CREATE TABLE tango (ts TIMESTAMP, n LONG) TIMESTAMP(ts) PARTITION BY DAY WAL FORMAT PARQUET DEDUP UPSERT KEYS(ts)", + "CREATE TABLE tango (val INT, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY WAL FORMAT PARQUET", + "CREATE TABLE tango (val INT PARQUET(BLOOM_FILTER), ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY WAL FORMAT PARQUET", + "CREATE TABLE tango (ts TIMESTAMP, sym SYMBOL, n LONG) TIMESTAMP(ts) PARTITION BY DAY FORMAT PARQUET WAL", + "CREATE TABLE tango (ts TIMESTAMP, s STRING, v VARCHAR, b BINARY) TIMESTAMP(ts) PARTITION BY DAY FORMAT PARQUET WAL", + "CREATE TABLE tango (ts TIMESTAMP PARQUET(plain), v LONG) TIMESTAMP(ts) PARTITION BY DAY FORMAT PARQUET WAL", + "CREATE TABLE 'tango' (ts TIMESTAMP) timestamp(ts) PARTITION BY DAY FORMAT PARQUET", + "ALTER TABLE tango SET FORMAT NATIVE", + "ALTER TABLE tango SET FORMAT PARQUET", + "ALTER TABLE no_partition SET FORMAT NATIVE", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("SWITCH ROLE / SWITCH STATUS (Enterprise #1024) — round-trip", () => { + const queries = [ + "SWITCH ROLE TO REPLICA", + "SWITCH ROLE TO PRIMARY", + "SWITCH STATUS", + "SWITCH ROLE TO REPLICA TIMEOUT 10000", + "SWITCH ROLE TO PRIMARY TIMEOUT 10000", + "SWITCH ROLE TO REPLICA TIMEOUT 1", + "SWITCH ROLE TO REPLICA TIMEOUT 500", + "SWITCH ROLE TO REPLICA TIMEOUT 8000", + "SWITCH ROLE TO REPLICA TIMEOUT 42000", + "SWITCH ROLE TO PRIMARY TIMEOUT 1000", + "SWITCH ROLE TO PRIMARY TIMEOUT 2000", + "SWITCH ROLE TO PRIMARY TIMEOUT 42000", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("GRANT/REVOKE column wildcard + EXCLUDE (Enterprise #1033) — round-trip", () => { + const queries = [ + "grant select on t1(*) to ddd", + "grant select on t1(* exclude(b, c)) to ddd", + "grant select on t1(* exclude(b)) to ddd with grant option", + 'grant select on t1(* exclude("a", "B")) to ddd', + "grant select on t1(* exclude(c)) to ddd", + "grant all on t1(*) to ddd", + "grant select, update on t1(*) to ddd", + "grant select on t1(a, b), t2(*), t3(* exclude(q)) to ddd", + "grant select on t1(* exclude(a)), t2(* exclude(z)) to ddd", + "grant select on mv1(* exclude(avg_x)) to ddd", + "grant select on mv1(*) to ddd", + "grant select on v1(*) to ddd", + "grant select on v1(* exclude(a)) to ddd", + "GRANT SELECT ON tgs1(* EXCLUDE(b)) TO ugs1", + "GRANT SELECT ON tgs1(*) TO ugs1", + "revoke select on t1(*) from ddd", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("mat-view EXPIRE ROWS (QuestDB #7263) — round-trip", () => { + const queries = [ + "create materialized view mv as (select * from base) EXPIRE ROWS WHEN v < 2.0", + "create materialized view mv as (select * from base) expire rows when v < 2.0 cleanup every 30m", + "create materialized view mv as (select * from base) EXPIRE ROWS WHEN v < 2.0 cleanup every 15m", + "create materialized view mv as (select * from base) expire rows when v < 2.0 cleanup every 90m", + "create materialized view mv as (select * from base) EXPIRE ROWS WHEN (v < 2.0) CLEANUP EVERY 30m", + "create materialized view mv as (select * from base) EXPIRE ROWS WHEN cleanup > 5", + "create materialized view mv as (select * from base) expire rows when v < 0", + "create materialized view mv as (select * from base) expire rows when v < 0.0", + "create materialized view mv as (select * from base) expire rows when v > 100", + "create materialized view mv as (select * from base) expire rows when ts < '2024-01-02T00:00:00.000000Z'", + "create materialized view mv as (select * from base) expire rows when ts < '2024-01-02T00:00:00.000000Z' cleanup every 1h", + "create materialized view mv as (select * from base) expire rows when ts <= '2024-01-02T00:00:00.000000Z'", + "create materialized view mv as (select * from base) expire rows when ts < dateadd('d', -1, now())", + "create materialized view mv as (select * from base) expire rows when ts < now()", + "create materialized view mv as (select * from base) expire rows when ts > now()", + "create materialized view mv as (select * from base) expire rows when ts < cast(null as timestamp)", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("LIVE VIEWS (QuestDB #6939) — round-trip", () => { + const queries = [ + "CREATE LIVE VIEW lv FLUSH EVERY 1s START FROM NOW AS SELECT ts, x, count(*) OVER (PARTITION BY x ORDER BY ts ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS rn FROM base", + "CREATE LIVE VIEW lv FLUSH EVERY 100ms START FROM NOW AS SELECT ts, sym, x, count(*) OVER (PARTITION BY g ORDER BY ts ROWS BETWEEN 1_000_000 PRECEDING AND CURRENT ROW) AS rn FROM base", + "CREATE LIVE VIEW IF NOT EXISTS lv FLUSH EVERY 1s START FROM NOW AS SELECT ts, x, count(*) OVER (PARTITION BY x ORDER BY ts ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS rn FROM base", + "CREATE LIVE VIEW lv FLUSH EVERY 1_200s IN MEMORY 1_800s START FROM NOW AS SELECT ts, x, count(*) OVER (PARTITION BY x ORDER BY ts ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS rn FROM base", + "CREATE LIVE VIEW lv2 FLUSH EVERY 1_500ms START FROM NOW AS SELECT ts, x, count(*) OVER (PARTITION BY x ORDER BY ts ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS rn FROM base", + "CREATE LIVE VIEW lv0 FLUSH EVERY 1s START FROM BEGINNING AS SELECT ts, x, count(*) OVER (PARTITION BY 0 ORDER BY ts ROWS BETWEEN 1000000 PRECEDING AND CURRENT ROW) AS rn FROM base WHERE x > 0", + "CREATE LIVE VIEW lv FLUSH EVERY 1s START FROM '2026-04-01T00:00:15.000000Z' AS SELECT ts, x, count(*) OVER (PARTITION BY x ORDER BY ts ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS rn FROM base", + "CREATE LIVE VIEW lv FLUSH EVERY 1s START FROM '2026-04-01T00:00:15.000000123Z' AS SELECT ts, x, count(*) OVER (PARTITION BY x ORDER BY ts ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS rn FROM base_ns", + "CREATE LIVE VIEW lv FLUSH EVERY 1s START FROM NOW AS (SELECT ts, x, count(*) OVER (PARTITION BY x ORDER BY ts ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS rn FROM tab)", + 'CREATE LIVE VIEW "select" FLUSH EVERY 1s START FROM NOW AS SELECT ts, x, count(*) OVER (PARTITION BY x ORDER BY ts ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS rn FROM base', + "CREATE LIVE VIEW public.lv FLUSH EVERY 1s START FROM NOW AS SELECT ts, x, count(*) OVER (PARTITION BY x ORDER BY ts ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS rn FROM base", + "CREATE LIVE VIEW lv FLUSH EVERY 1s START FROM NOW AS SELECT ts, sym, sum(x) OVER w AS s FROM base WINDOW w AS (PARTITION BY sym ORDER BY ts ANCHOR EXPRESSION timestamp_floor('1d', ts))", + "CREATE LIVE VIEW lv_daily FLUSH EVERY 1s START FROM NOW AS SELECT ts, sym, x, row_number() OVER w AS rn FROM base WINDOW w AS (PARTITION BY sym ORDER BY ts ANCHOR DAILY '00:00')", + "CREATE LIVE VIEW lv FLUSH EVERY 1s START FROM NOW AS SELECT ts, sym, sum(x) OVER w AS s FROM base WINDOW w AS (PARTITION BY sym ORDER BY ts ANCHOR DAILY '09:30')", + "CREATE LIVE VIEW lv FLUSH EVERY 1s START FROM NOW AS SELECT ts, sym, sum(x) OVER w AS s FROM base WINDOW w AS (PARTITION BY sym ORDER BY ts ANCHOR DAILY '00:00' 'Europe/London')", + "CREATE LIVE VIEW lv FLUSH EVERY 1s START FROM NOW AS SELECT ts, sym, sum(x) OVER w AS s FROM base WINDOW w AS (PARTITION BY sym ORDER BY ts ANCHOR EXPRESSION timestamp_floor('4h', ts))", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("COPY PERMISSIONS (Enterprise #939) — round-trip", () => { + const queries = [ + "COPY PERMISSIONS FROM src TO dst", + "COPY PERMISSIONS FROM src TO dst;", + "COPY PERMISSIONS FROM 'my table' TO 'other table'", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("CONVERT PARTITION permissions (Enterprise #956) — round-trip", () => { + const queries = [ + "GRANT CONVERT PARTITION TO PARQUET ON tab TO testUser", + "GRANT CONVERT PARTITION TO NATIVE ON tab TO testUser", + "REVOKE CONVERT PARTITION TO PARQUET ON tab FROM testUser", + "ALTER TABLE tab CONVERT PARTITION TO PARQUET WHERE ts > 0", + "ALTER TABLE tab CONVERT PARTITION TO NATIVE WHERE ts > 0", + "alter table test convert partition to parquet where ts > 0", + "alter table test convert partition to native where ts > 0", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("posting index (QuestDB #6861) — round-trip", () => { + const queries = [ + "create table x (t TIMESTAMP, x SYMBOL index type posting) timestamp(t)", + "create table x (t TIMESTAMP, x SYMBOL index type bitmap) timestamp(t)", + "create table x (t TIMESTAMP, x SYMBOL index type bitmap capacity 64) timestamp(t)", + "create table x (t TIMESTAMP, x SYMBOL index type posting delta) timestamp(t)", + "create table x (t TIMESTAMP, x SYMBOL index type posting ef) timestamp(t)", + "create table x (t TIMESTAMP, p DOUBLE, x SYMBOL index include (p)) timestamp(t)", + "create table x (t TIMESTAMP, p DOUBLE, x SYMBOL index type posting include (p)) timestamp(t)", + "create table x (t TIMESTAMP, p DOUBLE, x SYMBOL index type posting ef include (p)) timestamp(t)", + "create table x (t TIMESTAMP, x SYMBOL), index(x type posting delta) timestamp(t)", + "create table x (t TIMESTAMP, x SYMBOL), index(x type posting ef) timestamp(t)", + "CREATE TABLE tab (s SYMBOL INDEX TYPE POSTING, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY", + "CREATE TABLE tab (s SYMBOL INDEX TYPE POSTING DELTA, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY", + "CREATE TABLE tab (s SYMBOL INDEX TYPE POSTING EF, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY", + "create table tab (s symbol index type posting, ts timestamp) timestamp(ts)", + "create table tab (s symbol index type bitmap, ts timestamp) timestamp(ts)", + "CREATE TABLE tab (ts TIMESTAMP, s SYMBOL INDEX TYPE POSTING INCLUDE (v), v DOUBLE) TIMESTAMP(ts) PARTITION BY DAY", + 'CREATE TABLE tab (ts TIMESTAMP, s SYMBOL INDEX TYPE POSTING INCLUDE ("v"), v DOUBLE) TIMESTAMP(ts) PARTITION BY DAY', + "CREATE TABLE t (ts TIMESTAMP, s SYMBOL INDEX TYPE POSTING) TIMESTAMP(ts) PARTITION BY DAY BYPASS WAL", + "CREATE TABLE t (ts TIMESTAMP, s SYMBOL INDEX TYPE POSTING) TIMESTAMP(ts) PARTITION BY DAY WAL", + "CREATE TABLE tab (s SYMBOL, ts TIMESTAMP), INDEX(s TYPE POSTING) TIMESTAMP(ts) PARTITION BY DAY", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("dynamic WINDOW JOIN bound (QuestDB #6859) — round-trip", () => { + const queries = [ + "SELECT t.sym, t.price, t.ts, sum(p.price) AS window_price FROM trades t WINDOW JOIN prices p RANGE BETWEEN t.price::long minutes PRECEDING AND 1 minute FOLLOWING INCLUDE PREVAILING ORDER BY t.ts;", + "SELECT t.sym, t.price, t.ts, sum(p.price) AS window_price FROM trades t WINDOW JOIN prices p RANGE BETWEEN t.price::long minutes PRECEDING AND 1 minute FOLLOWING EXCLUDE PREVAILING ORDER BY t.ts;", + "SELECT t.sym, t.price, t.ts, sum(p.price) AS window_price FROM trades t WINDOW JOIN prices p RANGE BETWEEN t.price::long PRECEDING AND 60_000_000 FOLLOWING INCLUDE PREVAILING ORDER BY t.ts;", + "SELECT t.sym, t.price, t.ts, sum(p.price) AS window_price FROM trades t WINDOW JOIN prices p RANGE BETWEEN t.price::long PRECEDING AND 60_000_000 FOLLOWING EXCLUDE PREVAILING ORDER BY t.ts;", + "SELECT t.sym, t.price, t.ts, sum(p.price) AS window_price FROM trades t WINDOW JOIN prices p RANGE BETWEEN 1 minute PRECEDING AND t.price::long seconds FOLLOWING INCLUDE PREVAILING ORDER BY t.ts;", + "SELECT t.sym, t.price, t.ts, sum(p.price) AS window_price FROM trades t WINDOW JOIN prices p RANGE BETWEEN 1 minute PRECEDING AND t.price::long seconds FOLLOWING EXCLUDE PREVAILING ORDER BY t.ts;", + "SELECT t.sym, t.price, t.ts, sum(p.price) AS window_price FROM trades t WINDOW JOIN prices p RANGE BETWEEN price::long seconds PRECEDING AND 1 minute FOLLOWING INCLUDE PREVAILING ORDER BY t.ts;", + "SELECT t.sym, t.price, t.ts, sum(p.price) AS window_price FROM trades t WINDOW JOIN prices p RANGE BETWEEN price::long seconds PRECEDING AND 1 minute FOLLOWING EXCLUDE PREVAILING ORDER BY t.ts;", + "SELECT m.ts, m.lo_bound, m.hi_bound, sum(s.val) AS agg FROM master m WINDOW JOIN slave s RANGE BETWEEN lo_bound minutes PRECEDING AND hi_bound minutes FOLLOWING EXCLUDE PREVAILING ORDER BY m.ts", + "SELECT m.ts, m.lo_bound, m.hi_bound, sum(s.val) AS agg FROM master m WINDOW JOIN slave s RANGE BETWEEN lo_bound minutes PRECEDING AND hi_bound minutes FOLLOWING INCLUDE PREVAILING ORDER BY m.ts", + "SELECT m.ts, sum(s.val) AS agg FROM master m WINDOW JOIN slave s RANGE BETWEEN bound minutes PRECEDING AND 0 seconds FOLLOWING EXCLUDE PREVAILING ORDER BY m.ts", + "SELECT m.ts, sum(s.val) AS agg FROM (SELECT * FROM master LIMIT 4) m WINDOW JOIN slave s RANGE BETWEEN bound minutes PRECEDING AND 0 seconds FOLLOWING EXCLUDE PREVAILING ORDER BY m.ts", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) + + describe("multi-RHS HORIZON JOIN (QuestDB #6881) — round-trip", () => { + const queries = [ + "SELECT avg(b.bid) AS avg_bid, avg(a.ask) AS avg_ask FROM trades AS t HORIZON JOIN bids AS b ON (t.sym = b.sym) HORIZON JOIN asks AS a ON (t.sym = a.sym) LIST (-1s, 0s, 1s) AS h", + "SELECT avg(b.bid) AS avg_bid, avg(a.ask) AS avg_ask FROM trades AS t HORIZON JOIN bids AS b HORIZON JOIN asks AS a LIST (0s) AS h", + "SELECT avg(b.bid) AS avg_bid, avg(a.ask) AS avg_ask FROM trades AS t HORIZON JOIN bids AS b ON (t.sym = b.sym) HORIZON JOIN asks AS a ON (t.sym = a.sym) RANGE FROM -1s TO 1s STEP 1s AS h", + "SELECT avg(b.arr[1]), avg(a.ask), sum(t.qty) FROM trades AS t HORIZON JOIN bids AS b ON (t.sym = b.sym) HORIZON JOIN asks AS a ON (t.sym = a.sym) LIST (0) AS h", + "SELECT avg(b.bid) AS avg_bid, avg(a.ask) AS avg_ask FROM trades AS t HORIZON JOIN bids AS b ON (t.sym = b.sym) HORIZON JOIN asks AS a ON (t.sym = a.sym) LIST (0) AS h", + "EXPLAIN SELECT avg(b.bid) AS avg_bid, avg(a.ask) AS avg_ask FROM trades AS t HORIZON JOIN bids AS b ON (t.sym = b.sym) HORIZON JOIN asks AS a ON (t.sym = a.sym) LIST (0) AS h", + "SELECT avg(b.bid + a.ask) AS avg_spread, avg(b.bid) AS avg_bid FROM trades AS t HORIZON JOIN bids AS b ON (t.sym = b.sym) HORIZON JOIN asks AS a ON (t.sym = a.sym) LIST (0) AS h", + "SELECT avg(p.price) AS avg_price, avg(f.fee) AS avg_fee FROM trades AS t HORIZON JOIN prices AS p ON (t.sym = p.sym) HORIZON JOIN fees AS f ON (t.exchange = f.exchange) LIST (0) AS h", + "SELECT min(h.timestamp), max(h.timestamp), avg(b.bid) AS avg_bid FROM trades AS t HORIZON JOIN bids AS b ON (t.sym = b.sym) HORIZON JOIN asks AS a ON (t.sym = a.sym) LIST (0) AS h", + "SELECT avg(b.bid) AS avg_bid, avg(a.ask) AS avg_ask FROM (SELECT * FROM trades LIMIT 2) AS t HORIZON JOIN bids AS b ON (t.sym = b.sym) HORIZON JOIN asks AS a ON (t.sym = a.sym) LIST (0) AS h", + "SELECT avg(b.bid) AS avg_bid, avg(a.ask) AS avg_ask FROM (SELECT * FROM trades LIMIT 2) AS t HORIZON JOIN bids AS b HORIZON JOIN asks AS a LIST (0) AS h", + "SELECT avg(p.price) AS avg_price, avg(r.rate) AS avg_rate FROM trades AS t HORIZON JOIN prices AS p ON (t.sym = p.sym) HORIZON JOIN rates AS r LIST (0) AS h", + ] + for (const query of queries) { + it(`round-trips: ${query.slice(0, 64)}`, () => { + const r = parseToAst(query) + expect(r.errors, JSON.stringify(r.errors)).toHaveLength(0) + const r2 = parseToAst(toSql(r.ast[0])) + expect(r2.errors).toHaveLength(0) + expect(r2.ast[0].type).toBe(r.ast[0].type) + }) + } + }) })