chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# ANTLR intermediate build files
src/grammar/.antlr/
+22
View File
@@ -0,0 +1,22 @@
Portions of this package are derived from PostHog (MIT License).
Copyright (c) 2020-2025 PostHog Inc.
The original license is reproduced below:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19
View File
@@ -0,0 +1,19 @@
# TSQL (TriggerSQL)
TriggerSQL is a DSL that is safely converted into ClickHouse SQL queries with protection against SQL injection and it's tenant-safe (users can only query their own data).
## Attribution
This package is derived from [PostHog's HogQL](https://github.com/PostHog/posthog/tree/master/posthog/hogql) (MIT License). See [NOTICE.md](./NOTICE.md) for the full copyright notice.
## ANTLR Grammar
The ANTLR grammar is heavily inspired by PostHog's HogQL.
These are found in [./src/grammar](./src/grammar) and are the `.g4` files.
## Generating the source code
```sh
pnpm run grammar:build
```
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@internal/tsql",
"private": true,
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"type": "module",
"dependencies": {
"@trigger.dev/core": "workspace:*",
"antlr4ts": "0.5.0-alpha.4",
"zod": "3.25.76"
},
"scripts": {
"typecheck": "tsc --noEmit",
"grammar:build": "pnpm grammar:build:typescript",
"grammar:build:typescript": "cat src/grammar/TSQLLexer.typescript.g4 > src/grammar/TSQLLexer.g4 && tail -n +2 src/grammar/TSQLLexer.common.g4 |sed s/isOpeningTag/self.isOpeningTag/ >> src/grammar/TSQLLexer.g4 && antlr4ts src/grammar/TSQLLexer.g4 && rm src/grammar/TSQLLexer.g4 && antlr4ts -visitor -no-listener -Dlanguage=TypeScript src/grammar/TSQLParser.g4",
"test": "vitest --sequence.concurrent=false --no-file-parallelism",
"test:coverage": "vitest --sequence.concurrent=false --no-file-parallelism --coverage.enabled"
},
"devDependencies": {
"antlr4ts-cli": "0.5.0-alpha.4"
}
}
@@ -0,0 +1,288 @@
lexer grammar TSQLLexer;
// NB! We cat TSQLLexter.typescript.g4 when generating the grammar.
// NOTE: don't forget to add new keywords to the parser rule "keyword"!
// Keywords
ALL: A L L;
AND: A N D;
ANTI: A N T I;
ANY: A N Y;
ARRAY: A R R A Y;
AS: A S;
ASCENDING: A S C | A S C E N D I N G;
ASOF: A S O F;
BETWEEN: B E T W E E N;
BOTH: B O T H;
BY: B Y;
CASE: C A S E;
CAST: C A S T;
CATCH: C A T C H;
COHORT: C O H O R T;
COLLATE: C O L L A T E;
CROSS: C R O S S;
CUBE: C U B E;
CURRENT: C U R R E N T;
DATE: D A T E;
DAY: D A Y;
DESC: D E S C;
DESCENDING: D E S C E N D I N G;
DISTINCT: D I S T I N C T;
ELSE: E L S E;
END: E N D;
EXCEPT: E X C E P T;
EXTRACT: E X T R A C T;
FINAL: F I N A L;
FINALLY: F I N A L L Y;
FIRST: F I R S T;
FN: F N;
FOLLOWING: F O L L O W I N G;
FOR: F O R;
FROM: F R O M;
FULL: F U L L;
FUN: F U N;
GROUP: G R O U P;
HAVING: H A V I N G;
HOUR: H O U R;
ID: I D;
IF: I F;
ILIKE: I L I K E;
IN: I N;
INF: I N F | I N F I N I T Y;
INNER: I N N E R;
INTERSECT: I N T E R S E C T;
INTERVAL: I N T E R V A L;
IS: I S;
JOIN: J O I N;
KEY: K E Y;
LAST: L A S T;
LEADING: L E A D I N G;
LEFT: L E F T;
LET: L E T;
LIKE: L I K E;
LIMIT: L I M I T;
MINUTE: M I N U T E;
MONTH: M O N T H;
NAN_SQL: N A N; // conflicts with macro NAN
NOT: N O T;
NULL_SQL: N U L L; // conflicts with macro NULL
NULLS: N U L L S;
OFFSET: O F F S E T;
ON: O N;
OR: O R;
ORDER: O R D E R;
OUTER: O U T E R;
OVER: O V E R;
PARTITION: P A R T I T I O N;
PRECEDING: P R E C E D I N G;
PREWHERE: P R E W H E R E;
QUARTER: Q U A R T E R;
RANGE: R A N G E;
RETURN: R E T U R N;
RIGHT: R I G H T;
ROLLUP: R O L L U P;
ROW: R O W;
ROWS: R O W S;
SAMPLE: S A M P L E;
SECOND: S E C O N D;
SELECT: S E L E C T;
SEMI: S E M I;
SETTINGS: S E T T I N G S;
SUBSTRING: S U B S T R I N G;
THEN: T H E N;
THROW: T H R O W;
TIES: T I E S;
TIMESTAMP: T I M E S T A M P;
TO: T O;
TOP: T O P;
TOTALS: T O T A L S;
TRAILING: T R A I L I N G;
TRIM: T R I M;
TRUNCATE: T R U N C A T E;
TRY: T R Y;
UNBOUNDED: U N B O U N D E D;
UNION: U N I O N;
USING: U S I N G;
WEEK: W E E K;
WHEN: W H E N;
WHERE: W H E R E;
WHILE: W H I L E;
WINDOW: W I N D O W;
WITH: W I T H;
YEAR: Y E A R | Y Y Y Y;
// Tokens
// copied from clickhouse_driver/util/escape.py
ESCAPE_CHAR_COMMON
: BACKSLASH B
| BACKSLASH F
| BACKSLASH R
| BACKSLASH N
| BACKSLASH T
| BACKSLASH '0'
| BACKSLASH A
| BACKSLASH V
| BACKSLASH BACKSLASH;
IDENTIFIER
: (LETTER | UNDERSCORE | DOLLAR) (LETTER | UNDERSCORE | DEC_DIGIT | DOLLAR)*
| BACKQUOTE ( ~([\\`]) | ESCAPE_CHAR_COMMON | BACKSLASH QUOTE_SINGLE | (BACKQUOTE BACKQUOTE) )* BACKQUOTE
| QUOTE_DOUBLE ( ~([\\"]) | ESCAPE_CHAR_COMMON | BACKSLASH QUOTE_DOUBLE | (QUOTE_DOUBLE QUOTE_DOUBLE) )* QUOTE_DOUBLE
;
FLOATING_LITERAL
: HEXADECIMAL_LITERAL DOT HEX_DIGIT* (P | E) (PLUS | DASH)? DEC_DIGIT+
| HEXADECIMAL_LITERAL (P | E) (PLUS | DASH)? DEC_DIGIT+
| DECIMAL_LITERAL DOT DEC_DIGIT* E (PLUS | DASH)? DEC_DIGIT+
| DOT DECIMAL_LITERAL E (PLUS | DASH)? DEC_DIGIT+
| DECIMAL_LITERAL E (PLUS | DASH)? DEC_DIGIT+
;
OCTAL_LITERAL: '0' OCT_DIGIT+;
DECIMAL_LITERAL: DEC_DIGIT+;
HEXADECIMAL_LITERAL: '0' X HEX_DIGIT+;
// It's important that quote-symbol is a single character.
STRING_LITERAL: QUOTE_SINGLE ( ~([\\']) | ESCAPE_CHAR_COMMON | BACKSLASH QUOTE_SINGLE | (QUOTE_SINGLE QUOTE_SINGLE) )* QUOTE_SINGLE;
// Alphabet and allowed symbols
fragment A: [aA];
fragment B: [bB];
fragment C: [cC];
fragment D: [dD];
fragment E: [eE];
fragment F: [fF];
fragment G: [gG];
fragment H: [hH];
fragment I: [iI];
fragment J: [jJ];
fragment K: [kK];
fragment L: [lL];
fragment M: [mM];
fragment N: [nN];
fragment O: [oO];
fragment P: [pP];
fragment Q: [qQ];
fragment R: [rR];
fragment S: [sS];
fragment T: [tT];
fragment U: [uU];
fragment V: [vV];
fragment W: [wW];
fragment X: [xX];
fragment Y: [yY];
fragment Z: [zZ];
fragment LETTER: [a-zA-Z];
fragment OCT_DIGIT: [0-7];
fragment DEC_DIGIT: [0-9];
fragment HEX_DIGIT: [0-9a-fA-F];
ARROW: '->';
ASTERISK: '*';
BACKQUOTE: '`';
BACKSLASH: '\\';
COLON: ':';
COMMA: ',';
CONCAT: '||';
DASH: '-';
DOLLAR: '$';
DOT: '.';
EQ_DOUBLE: '==';
EQ_SINGLE: '=';
GT_EQ: '>=';
GT: '>';
HASH: '#';
IREGEX_SINGLE: '~*';
IREGEX_DOUBLE: '=~*';
LBRACE: '{' -> pushMode(DEFAULT_MODE);
LBRACKET: '[';
LPAREN: '(';
LT_EQ: '<=';
TAG_LT_SLASH: '</' -> type(LT_SLASH), pushMode(TSQLX_TAG_CLOSE);
TAG_LT_OPEN: '<' {isOpeningTag()}? -> type(LT), pushMode(TSQLX_TAG_OPEN);
LT: '<';
LT_SLASH: '</';
NOT_EQ: '!=' | '<>';
NOT_IREGEX: '!~*';
NOT_REGEX: '!~';
NULL_PROPERTY: '?.';
NULLISH: '??';
PERCENT: '%';
PLUS: '+';
QUERY: '?';
QUOTE_DOUBLE: '"';
QUOTE_SINGLE_TEMPLATE: 'f\'' -> pushMode(IN_TEMPLATE_STRING); // start of regular f'' template strings
QUOTE_SINGLE_TEMPLATE_FULL: 'F\'' -> pushMode(IN_FULL_TEMPLATE_STRING); // magic F' symbol used to parse "full text" templates
QUOTE_SINGLE: '\'';
REGEX_SINGLE: '~';
REGEX_DOUBLE: '=~';
RBRACE: '}' -> popMode;
RBRACKET: ']';
RPAREN: ')';
SEMICOLON: ';';
SLASH: '/';
SLASH_GT: '/>';
UNDERSCORE: '_';
// Comments and whitespace
MULTI_LINE_COMMENT: '/*' .*? '*/' -> skip;
SINGLE_LINE_COMMENT: ('--' | '//') ~('\n'|'\r')* ('\n' | '\r' | EOF) -> skip;
// whitespace is hidden and not skipped so that it's preserved in ANTLR errors like "no viable alternative"
WHITESPACE: [ \u000B\u000C\t\r\n] -> channel(HIDDEN);
// ───────── f' TEMPLATE STRING MODE ─────────
mode IN_TEMPLATE_STRING;
STRING_TEXT: ((~([\\'{])) | ESCAPE_CHAR_COMMON | BACKSLASH QUOTE_SINGLE | (BACKSLASH LBRACE) | (QUOTE_SINGLE QUOTE_SINGLE))+;
STRING_ESCAPE_TRIGGER: LBRACE -> pushMode(DEFAULT_MODE);
STRING_QUOTE_SINGLE: QUOTE_SINGLE -> type(QUOTE_SINGLE), popMode;
// ───────── F' FULL TEMPLATE STRING MODE ─────────
// a magic F' takes us to "full template strings" mode, where we don't need to escape single quotes and parse until EOF
// this can't be used within a normal columnExpr, but has to be parsed for separately
mode IN_FULL_TEMPLATE_STRING;
FULL_STRING_TEXT: ((~([{])) | ESCAPE_CHAR_COMMON | (BACKSLASH LBRACE))+;
FULL_STRING_ESCAPE_TRIGGER: LBRACE -> pushMode(DEFAULT_MODE);
// ───────── TSQLX TAG MODE for opening/self-closing tags ─────────
mode TSQLX_TAG_OPEN;
TAG_SELF_CLOSE_GT : '/>' -> type(SLASH_GT), popMode; // <tag …/>
TAG_OPEN_GT : '>' -> type(GT), popMode, pushMode(TSQLX_TEXT); // <tag …>
// minimal token set; map everything back to the default token types
TAG_IDENT : [a-zA-Z_][a-zA-Z0-9_-]* -> type(IDENTIFIER);
TAG_EQ : '=' -> type(EQ_SINGLE);
TAG_STRING : STRING_LITERAL -> type(STRING_LITERAL);
TAG_WS : [ \t\r\n]+ -> channel(HIDDEN);
TAG_LBRACE : '{' -> type(LBRACE), pushMode(DEFAULT_MODE);
// ───────── TSQLX TAG MODE for closing tags ─────────
mode TSQLX_TAG_CLOSE;
TAGC_GT : '>' -> type(GT), popMode; // *** no TEXT push ***
TAGC_IDENT : [a-zA-Z_][a-zA-Z0-9_-]* -> type(IDENTIFIER);
TAGC_WS : [ \t\r\n]+ -> channel(HIDDEN);
// ───────── TSQLX TEXT MODE ─────────
mode TSQLX_TEXT;
TSQLX_TEXT_TEXT
: ~[<{]+ ; // everything except “{” or “<”
TSQLX_TEXT_LBRACE
: '{' -> type(LBRACE), pushMode(DEFAULT_MODE);
TSQLX_TEXT_LT_SLASH
: '</' -> type(LT_SLASH), popMode, pushMode(TSQLX_TAG_CLOSE);
TSQLX_TEXT_LT
: '<' -> type(LT), pushMode(TSQLX_TAG_OPEN);
TSQLX_TEXT_WS
: [ \t\r\n]+ -> channel(HIDDEN);
File diff suppressed because one or more lines are too long
@@ -0,0 +1,208 @@
ALL=1
AND=2
ANTI=3
ANY=4
ARRAY=5
AS=6
ASCENDING=7
ASOF=8
BETWEEN=9
BOTH=10
BY=11
CASE=12
CAST=13
CATCH=14
COHORT=15
COLLATE=16
CROSS=17
CUBE=18
CURRENT=19
DATE=20
DAY=21
DESC=22
DESCENDING=23
DISTINCT=24
ELSE=25
END=26
EXCEPT=27
EXTRACT=28
FINAL=29
FINALLY=30
FIRST=31
FN=32
FOLLOWING=33
FOR=34
FROM=35
FULL=36
FUN=37
GROUP=38
HAVING=39
HOUR=40
ID=41
IF=42
ILIKE=43
IN=44
INF=45
INNER=46
INTERSECT=47
INTERVAL=48
IS=49
JOIN=50
KEY=51
LAST=52
LEADING=53
LEFT=54
LET=55
LIKE=56
LIMIT=57
MINUTE=58
MONTH=59
NAN_SQL=60
NOT=61
NULL_SQL=62
NULLS=63
OFFSET=64
ON=65
OR=66
ORDER=67
OUTER=68
OVER=69
PARTITION=70
PRECEDING=71
PREWHERE=72
QUARTER=73
RANGE=74
RETURN=75
RIGHT=76
ROLLUP=77
ROW=78
ROWS=79
SAMPLE=80
SECOND=81
SELECT=82
SEMI=83
SETTINGS=84
SUBSTRING=85
THEN=86
THROW=87
TIES=88
TIMESTAMP=89
TO=90
TOP=91
TOTALS=92
TRAILING=93
TRIM=94
TRUNCATE=95
TRY=96
UNBOUNDED=97
UNION=98
USING=99
WEEK=100
WHEN=101
WHERE=102
WHILE=103
WINDOW=104
WITH=105
YEAR=106
ESCAPE_CHAR_COMMON=107
IDENTIFIER=108
FLOATING_LITERAL=109
OCTAL_LITERAL=110
DECIMAL_LITERAL=111
HEXADECIMAL_LITERAL=112
STRING_LITERAL=113
ARROW=114
ASTERISK=115
BACKQUOTE=116
BACKSLASH=117
COLON=118
COMMA=119
CONCAT=120
DASH=121
DOLLAR=122
DOT=123
EQ_DOUBLE=124
EQ_SINGLE=125
GT_EQ=126
GT=127
HASH=128
IREGEX_SINGLE=129
IREGEX_DOUBLE=130
LBRACE=131
LBRACKET=132
LPAREN=133
LT_EQ=134
LT=135
LT_SLASH=136
NOT_EQ=137
NOT_IREGEX=138
NOT_REGEX=139
NULL_PROPERTY=140
NULLISH=141
PERCENT=142
PLUS=143
QUERY=144
QUOTE_DOUBLE=145
QUOTE_SINGLE_TEMPLATE=146
QUOTE_SINGLE_TEMPLATE_FULL=147
QUOTE_SINGLE=148
REGEX_SINGLE=149
REGEX_DOUBLE=150
RBRACE=151
RBRACKET=152
RPAREN=153
SEMICOLON=154
SLASH=155
SLASH_GT=156
UNDERSCORE=157
MULTI_LINE_COMMENT=158
SINGLE_LINE_COMMENT=159
WHITESPACE=160
STRING_TEXT=161
STRING_ESCAPE_TRIGGER=162
FULL_STRING_TEXT=163
FULL_STRING_ESCAPE_TRIGGER=164
TAG_WS=165
TAGC_WS=166
TSQLX_TEXT_TEXT=167
TSQLX_TEXT_WS=168
'->'=114
'*'=115
'`'=116
'\\'=117
':'=118
','=119
'||'=120
'-'=121
'$'=122
'.'=123
'=='=124
'>='=126
'#'=128
'~*'=129
'=~*'=130
'{'=131
'['=132
'('=133
'<='=134
'<'=135
'</'=136
'!~*'=138
'!~'=139
'?.'=140
'??'=141
'%'=142
'+'=143
'?'=144
'"'=145
'f\''=146
'F\''=147
'\''=148
'~'=149
'=~'=150
'}'=151
']'=152
')'=153
';'=154
'/'=155
'_'=157
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
lexer grammar TSQLLexer;
@header {
// put any global imports you need here
}
@members {
private _peekChar(k: number): string {
// Return the k-th look-ahead as a *single-char string* or '\0' at EOF.
const c = this._input.LA(k); // int code point or IntStream.EOF (-1)
if (c < 0 || c > 0x10FFFF) { // EOF or out-of-range → sentinel
return '\0';
}
return String.fromCharCode(c);
}
private _skipWsAndComments(idx: number): number {
// Return the first index ≥ idx that is *not* whitespace / single-line comment.
while (true) {
const ch = this._peekChar(idx);
if (/\s/.test(ch)) { // spaces, newlines, tabs …
idx++;
continue;
}
// single-line comments
if (ch === '/' && this._peekChar(idx + 1) === '/') { // //
idx += 2;
} else if (ch === '-' && this._peekChar(idx + 1) === '-') { // --
idx += 2;
} else if (ch === '#') { // #
idx++;
} else {
break; // no ws / comment
}
// consume until EOL / EOF
while (!['\0', '\n', '\r'].includes(this._peekChar(idx))) {
idx++;
}
}
return idx;
}
// ───── opening tag test ─────
isOpeningTag(): boolean {
const ch1 = this._peekChar(1);
if (!(/[a-zA-Z]/.test(ch1) || ch1 === '_')) {
return false; // not a tag name start
}
// skip tag name
let i = 2;
while (true) {
const ch = this._peekChar(i);
if (/[a-zA-Z0-9]/.test(ch) || ch === '_' || ch === '-') {
i++;
} else {
break;
}
}
let ch = this._peekChar(i);
// immediate delimiter → tag
if (ch === '>' || ch === '/') {
return true;
}
// need to look beyond whitespace
if (/\s/.test(ch)) {
i = this._skipWsAndComments(i + 1);
ch = this._peekChar(i);
return ch === '>' || ch === '/' || /[a-zA-Z0-9]/.test(ch) || ch === '_';
}
// anything else → not a tag
return false;
}
}
@@ -0,0 +1,315 @@
parser grammar TSQLParser;
options {
tokenVocab = TSQLLexer;
}
program: declaration* EOF;
declaration: varDecl | statement ;
expression: columnExpr;
varDecl: LET identifier ( COLON EQ_SINGLE expression )? ;
identifierList: identifier (COMMA identifier)* COMMA?;
statement : returnStmt
| throwStmt
| tryCatchStmt
| ifStmt
| whileStmt
| forInStmt
| forStmt
| funcStmt
| varAssignment
| block
| exprStmt
| emptyStmt
;
returnStmt : RETURN expression? SEMICOLON?;
throwStmt : THROW expression? SEMICOLON?;
catchBlock : CATCH (LPAREN catchVar=identifier (COLON catchType=identifier)? RPAREN)? catchStmt=block;
tryCatchStmt : TRY tryStmt=block catchBlock* (FINALLY finallyStmt=block)?;
ifStmt : IF LPAREN expression RPAREN statement ( ELSE statement )? ;
whileStmt : WHILE LPAREN expression RPAREN statement SEMICOLON?;
forStmt : FOR LPAREN
(initializerVarDeclr=varDecl | initializerVarAssignment=varAssignment | initializerExpression=expression)? SEMICOLON
condition=expression? SEMICOLON
(incrementVarDeclr=varDecl | incrementVarAssignment=varAssignment | incrementExpression=expression)?
RPAREN statement SEMICOLON?;
forInStmt : FOR LPAREN LET identifier (COMMA identifier)? IN expression RPAREN statement SEMICOLON?;
funcStmt : (FN | FUN) identifier LPAREN identifierList? RPAREN block;
varAssignment : expression COLON EQ_SINGLE expression ;
exprStmt : expression SEMICOLON?;
emptyStmt : SEMICOLON ;
block : LBRACE declaration* RBRACE ;
kvPair: expression ':' expression ;
kvPairList: kvPair (COMMA kvPair)* COMMA?;
// SELECT statement
select: (selectSetStmt | selectStmt | tSQLxTagElement) SEMICOLON? EOF;
selectStmtWithParens: selectStmt | LPAREN selectSetStmt RPAREN | placeholder;
subsequentSelectSetClause: (EXCEPT | UNION ALL | UNION DISTINCT | INTERSECT | INTERSECT DISTINCT) selectStmtWithParens;
selectSetStmt: selectStmtWithParens (subsequentSelectSetClause)*;
selectStmt:
with=withClause?
SELECT DISTINCT? topClause?
columns=columnExprList
from=fromClause?
arrayJoinClause?
prewhereClause?
where=whereClause?
groupByClause? (WITH (CUBE | ROLLUP))? (WITH TOTALS)?
havingClause?
windowClause?
orderByClause?
limitByClause?
(limitAndOffsetClause | offsetOnlyClause)?
settingsClause?
;
withClause: WITH withExprList;
topClause: TOP DECIMAL_LITERAL (WITH TIES)?;
fromClause: FROM joinExpr;
arrayJoinClause: (LEFT | INNER)? ARRAY JOIN columnExprList;
windowClause: WINDOW identifier AS LPAREN windowExpr RPAREN (COMMA identifier AS LPAREN windowExpr RPAREN)*;
prewhereClause: PREWHERE columnExpr;
whereClause: WHERE columnExpr;
groupByClause: GROUP BY ((CUBE | ROLLUP) LPAREN columnExprList RPAREN | columnExprList);
havingClause: HAVING columnExpr;
orderByClause: ORDER BY orderExprList;
projectionOrderByClause: ORDER BY columnExprList;
limitByClause: LIMIT limitExpr BY columnExprList;
limitAndOffsetClause
: LIMIT columnExpr (COMMA columnExpr)? (WITH TIES)? // compact OFFSET-optional form
| LIMIT columnExpr (WITH TIES)? OFFSET columnExpr // verbose OFFSET-included form with WITH TIES
;
offsetOnlyClause: OFFSET columnExpr;
settingsClause: SETTINGS settingExprList;
joinExpr
: joinExpr joinOp? JOIN joinExpr joinConstraintClause # JoinExprOp
| joinExpr joinOpCross joinExpr # JoinExprCrossOp
| tableExpr FINAL? sampleClause? # JoinExprTable
| LPAREN joinExpr RPAREN # JoinExprParens
;
joinOp
: ((ALL | ANY | ASOF)? INNER | INNER (ALL | ANY | ASOF)? | (ALL | ANY | ASOF)) # JoinOpInner
| ( (SEMI | ALL | ANTI | ANY | ASOF)? (LEFT | RIGHT) OUTER?
| (LEFT | RIGHT) OUTER? (SEMI | ALL | ANTI | ANY | ASOF)?
) # JoinOpLeftRight
| ((ALL | ANY)? FULL OUTER? | FULL OUTER? (ALL | ANY)?) # JoinOpFull
;
joinOpCross
: CROSS JOIN
| COMMA
;
joinConstraintClause
: ON columnExprList
| USING LPAREN columnExprList RPAREN
| USING columnExprList
;
sampleClause: SAMPLE ratioExpr (OFFSET ratioExpr)?;
limitExpr: columnExpr ((COMMA | OFFSET) columnExpr)?;
orderExprList: orderExpr (COMMA orderExpr)*;
orderExpr: columnExpr (ASCENDING | DESCENDING | DESC)? (NULLS (FIRST | LAST))? (COLLATE STRING_LITERAL)?;
ratioExpr: placeholder | numberLiteral (SLASH numberLiteral)?;
settingExprList: settingExpr (COMMA settingExpr)*;
settingExpr: identifier EQ_SINGLE literal;
windowExpr: winPartitionByClause? winOrderByClause? winFrameClause?;
winPartitionByClause: PARTITION BY columnExprList;
winOrderByClause: ORDER BY orderExprList;
winFrameClause: (ROWS | RANGE) winFrameExtend;
winFrameExtend
: winFrameBound # frameStart
| BETWEEN winFrameBound AND winFrameBound # frameBetween
;
winFrameBound: (CURRENT ROW | UNBOUNDED PRECEDING | UNBOUNDED FOLLOWING | numberLiteral PRECEDING | numberLiteral FOLLOWING);
//rangeClause: RANGE LPAREN (MIN identifier MAX identifier | MAX identifier MIN identifier) RPAREN;
// Columns
expr: columnExpr EOF;
columnTypeExpr
: identifier # ColumnTypeExprSimple // UInt64
| identifier LPAREN identifier columnTypeExpr (COMMA identifier columnTypeExpr)* COMMA? RPAREN # ColumnTypeExprNested // Nested
| identifier LPAREN enumValue (COMMA enumValue)* COMMA? RPAREN # ColumnTypeExprEnum // Enum
| identifier LPAREN columnTypeExpr (COMMA columnTypeExpr)* COMMA? RPAREN # ColumnTypeExprComplex // Array, Tuple
| identifier LPAREN columnExprList? RPAREN # ColumnTypeExprParam // FixedString(N)
;
columnExprList: columnExpr (COMMA columnExpr)* COMMA?;
columnExpr
: CASE caseExpr=columnExpr? (WHEN whenExpr=columnExpr THEN thenExpr=columnExpr)+ (ELSE elseExpr=columnExpr)? END # ColumnExprCase
| CAST LPAREN columnExpr AS columnTypeExpr RPAREN # ColumnExprCast
| DATE STRING_LITERAL # ColumnExprDate
// | EXTRACT LPAREN interval FROM columnExpr RPAREN # ColumnExprExtract // Interferes with a function call
| INTERVAL STRING_LITERAL # ColumnExprIntervalString
| INTERVAL columnExpr interval # ColumnExprInterval
| SUBSTRING LPAREN columnExpr FROM columnExpr (FOR columnExpr)? RPAREN # ColumnExprSubstring
| TIMESTAMP STRING_LITERAL # ColumnExprTimestamp
| TRIM LPAREN (BOTH | LEADING | TRAILING) string FROM columnExpr RPAREN # ColumnExprTrim
| identifier (LPAREN columnExprs=columnExprList? RPAREN) (LPAREN DISTINCT? columnArgList=columnExprList? RPAREN)? OVER LPAREN windowExpr RPAREN # ColumnExprWinFunction
| identifier (LPAREN columnExprs=columnExprList? RPAREN) (LPAREN DISTINCT? columnArgList=columnExprList? RPAREN)? OVER identifier # ColumnExprWinFunctionTarget
| identifier (LPAREN columnExprs=columnExprList? RPAREN)? LPAREN DISTINCT? columnArgList=columnExprList? RPAREN # ColumnExprFunction
| columnExpr LPAREN selectSetStmt RPAREN # ColumnExprCallSelect
| columnExpr LPAREN columnExprList? RPAREN # ColumnExprCall
| tSQLxTagElement # ColumnExprTagElement
| templateString # ColumnExprTemplateString
| literal # ColumnExprLiteral
// FIXME(ilezhankin): this part looks very ugly, maybe there is another way to express it
| columnExpr LBRACKET columnExpr RBRACKET # ColumnExprArrayAccess
| columnExpr DOT DECIMAL_LITERAL # ColumnExprTupleAccess
| columnExpr DOT identifier # ColumnExprPropertyAccess
| columnExpr NULL_PROPERTY LBRACKET columnExpr RBRACKET # ColumnExprNullArrayAccess
| columnExpr NULL_PROPERTY DECIMAL_LITERAL # ColumnExprNullTupleAccess
| columnExpr NULL_PROPERTY identifier # ColumnExprNullPropertyAccess
| DASH columnExpr # ColumnExprNegate
| left=columnExpr ( operator=ASTERISK // *
| operator=SLASH // /
| operator=PERCENT // %
) right=columnExpr # ColumnExprPrecedence1
| left=columnExpr ( operator=PLUS // +
| operator=DASH // -
| operator=CONCAT // ||
) right=columnExpr # ColumnExprPrecedence2
| left=columnExpr ( operator=EQ_DOUBLE // =
| operator=EQ_SINGLE // ==
| operator=NOT_EQ // !=
| operator=LT_EQ // <=
| operator=LT // <
| operator=GT_EQ // >=
| operator=GT // >
| operator=NOT? IN COHORT? // in, not in; in cohort; not in cohort
| operator=NOT? (LIKE | ILIKE) // like, not like, ilike, not ilike
| operator=REGEX_SINGLE // ~
| operator=REGEX_DOUBLE // =~
| operator=NOT_REGEX // !~
| operator=IREGEX_SINGLE // ~*
| operator=IREGEX_DOUBLE // =~*
| operator=NOT_IREGEX // !~*
) right=columnExpr # ColumnExprPrecedence3
| columnExpr IS NOT? NULL_SQL # ColumnExprIsNull
| columnExpr NULLISH columnExpr # ColumnExprNullish
| NOT columnExpr # ColumnExprNot
| columnExpr AND columnExpr # ColumnExprAnd
| columnExpr OR columnExpr # ColumnExprOr
// TODO(ilezhankin): `BETWEEN a AND b AND c` is parsed in a wrong way: `BETWEEN (a AND b) AND c`
| columnExpr NOT? BETWEEN columnExpr AND columnExpr # ColumnExprBetween
| <assoc=right> columnExpr QUERY columnExpr COLON columnExpr # ColumnExprTernaryOp
| columnExpr (AS identifier | AS STRING_LITERAL) # ColumnExprAlias
| (tableIdentifier DOT)? ASTERISK # ColumnExprAsterisk // single-column only
| LPAREN selectSetStmt RPAREN # ColumnExprSubquery // single-column only
| LPAREN columnExpr RPAREN # ColumnExprParens // single-column only
| LPAREN columnExprList RPAREN # ColumnExprTuple
| LBRACKET columnExprList? RBRACKET # ColumnExprArray
| LBRACE (kvPairList)? RBRACE # ColumnExprDict
| columnLambdaExpr # ColumnExprLambda
| columnIdentifier # ColumnExprIdentifier
;
columnLambdaExpr:
( LPAREN identifier (COMMA identifier)* COMMA? RPAREN
| identifier (COMMA identifier)* COMMA?
| LPAREN RPAREN
)
ARROW (columnExpr | block)
;
tSQLxChildElement
: tSQLxTagElement
| TSQLX_TEXT_TEXT
| LBRACE columnExpr RBRACE;
tSQLxTagElement
: LT identifier tSQLxTagAttribute* SLASH_GT
| LT identifier tSQLxTagAttribute* GT tSQLxChildElement* LT_SLASH identifier GT
;
tSQLxTagAttribute
: identifier EQ_SINGLE string
| identifier EQ_SINGLE LBRACE columnExpr RBRACE
| identifier
;
withExprList: withExpr (COMMA withExpr)* COMMA?;
withExpr
: identifier AS LPAREN selectSetStmt RPAREN # WithExprSubquery
// NOTE: asterisk and subquery goes before |columnExpr| so that we can mark them as multi-column expressions.
| columnExpr AS identifier # WithExprColumn
;
// This is slightly different in TSQL compared to ClickHouse SQL
// TSQL allows unlimited ("*") nestedIdentifier-s "properties.b.a.a.w.a.s".
// We parse and convert "databaseIdentifier.tableIdentifier.columnIdentifier.nestedIdentifier.*"
// to just one ast.Field(chain=['a','b','columnIdentifier','on','and','on']).
columnIdentifier: placeholder | ((tableIdentifier DOT)? nestedIdentifier);
nestedIdentifier: identifier (DOT identifier)*;
tableExpr
: tableIdentifier # TableExprIdentifier
| tableFunctionExpr # TableExprFunction
| LPAREN selectSetStmt RPAREN # TableExprSubquery
| tableExpr (alias | AS identifier) # TableExprAlias
| tSQLxTagElement # TableExprTag
| placeholder # TableExprPlaceholder
;
tableFunctionExpr: identifier LPAREN tableArgList? RPAREN;
tableIdentifier: (databaseIdentifier DOT)? nestedIdentifier;
tableArgList: columnExpr (COMMA columnExpr)* COMMA?;
// Databases
databaseIdentifier: identifier;
// Basics
floatingLiteral
: FLOATING_LITERAL
| DOT (DECIMAL_LITERAL | OCTAL_LITERAL)
| DECIMAL_LITERAL DOT (DECIMAL_LITERAL | OCTAL_LITERAL)? // can't move this to the lexer or it will break nested tuple access: t.1.2
;
numberLiteral: (PLUS | DASH)? (floatingLiteral | OCTAL_LITERAL | DECIMAL_LITERAL | HEXADECIMAL_LITERAL | INF | NAN_SQL);
literal
: numberLiteral
| STRING_LITERAL
| NULL_SQL
;
interval: SECOND | MINUTE | HOUR | DAY | WEEK | MONTH | QUARTER | YEAR;
keyword
// except NULL_SQL, INF, NAN_SQL
: ALL | AND | ANTI | ANY | ARRAY | AS | ASCENDING | ASOF | BETWEEN | BOTH | BY | CASE
| CAST | COHORT | COLLATE | CROSS | CUBE | CURRENT | DATE | DESC | DESCENDING
| DISTINCT | ELSE | END | EXTRACT | FINAL | FIRST
| FOR | FOLLOWING | FROM | FULL | GROUP | HAVING | ID | IS
| IF | ILIKE | IN | INNER | INTERVAL | JOIN | KEY
| LAST | LEADING | LEFT | LIKE | LIMIT
| NOT | NULLS | OFFSET | ON | OR | ORDER | OUTER | OVER | PARTITION
| PRECEDING | PREWHERE | RANGE | RETURN | RIGHT | ROLLUP | ROW
| ROWS | SAMPLE | SELECT | SEMI | SETTINGS | SUBSTRING
| THEN | TIES | TIMESTAMP | TOTALS | TRAILING | TRIM | TRUNCATE | TO | TOP
| UNBOUNDED | UNION | USING | WHEN | WHERE | WINDOW | WITH
;
keywordForAlias
: DATE | FIRST | ID | KEY
;
alias: IDENTIFIER | keywordForAlias; // |interval| can't be an alias, otherwise 'INTERVAL 1 SOMETHING' becomes ambiguous.
identifier: IDENTIFIER | interval | keyword;
enumValue: string EQ_SINGLE numberLiteral;
placeholder: LBRACE columnExpr RBRACE;
string: STRING_LITERAL | templateString;
templateString : QUOTE_SINGLE_TEMPLATE stringContents* QUOTE_SINGLE ;
stringContents : STRING_ESCAPE_TRIGGER columnExpr RBRACE | STRING_TEXT;
// These are magic "full template strings", which are used to parse "full text field" templates without the surrounding SQL.
// We will need to add F' to the start of the string to change the lexer's mode.
fullTemplateString: QUOTE_SINGLE_TEMPLATE_FULL stringContentsFull* EOF ;
stringContentsFull : FULL_STRING_ESCAPE_TRIGGER columnExpr RBRACE | FULL_STRING_TEXT;
File diff suppressed because one or more lines are too long
@@ -0,0 +1,208 @@
ALL=1
AND=2
ANTI=3
ANY=4
ARRAY=5
AS=6
ASCENDING=7
ASOF=8
BETWEEN=9
BOTH=10
BY=11
CASE=12
CAST=13
CATCH=14
COHORT=15
COLLATE=16
CROSS=17
CUBE=18
CURRENT=19
DATE=20
DAY=21
DESC=22
DESCENDING=23
DISTINCT=24
ELSE=25
END=26
EXCEPT=27
EXTRACT=28
FINAL=29
FINALLY=30
FIRST=31
FN=32
FOLLOWING=33
FOR=34
FROM=35
FULL=36
FUN=37
GROUP=38
HAVING=39
HOUR=40
ID=41
IF=42
ILIKE=43
IN=44
INF=45
INNER=46
INTERSECT=47
INTERVAL=48
IS=49
JOIN=50
KEY=51
LAST=52
LEADING=53
LEFT=54
LET=55
LIKE=56
LIMIT=57
MINUTE=58
MONTH=59
NAN_SQL=60
NOT=61
NULL_SQL=62
NULLS=63
OFFSET=64
ON=65
OR=66
ORDER=67
OUTER=68
OVER=69
PARTITION=70
PRECEDING=71
PREWHERE=72
QUARTER=73
RANGE=74
RETURN=75
RIGHT=76
ROLLUP=77
ROW=78
ROWS=79
SAMPLE=80
SECOND=81
SELECT=82
SEMI=83
SETTINGS=84
SUBSTRING=85
THEN=86
THROW=87
TIES=88
TIMESTAMP=89
TO=90
TOP=91
TOTALS=92
TRAILING=93
TRIM=94
TRUNCATE=95
TRY=96
UNBOUNDED=97
UNION=98
USING=99
WEEK=100
WHEN=101
WHERE=102
WHILE=103
WINDOW=104
WITH=105
YEAR=106
ESCAPE_CHAR_COMMON=107
IDENTIFIER=108
FLOATING_LITERAL=109
OCTAL_LITERAL=110
DECIMAL_LITERAL=111
HEXADECIMAL_LITERAL=112
STRING_LITERAL=113
ARROW=114
ASTERISK=115
BACKQUOTE=116
BACKSLASH=117
COLON=118
COMMA=119
CONCAT=120
DASH=121
DOLLAR=122
DOT=123
EQ_DOUBLE=124
EQ_SINGLE=125
GT_EQ=126
GT=127
HASH=128
IREGEX_SINGLE=129
IREGEX_DOUBLE=130
LBRACE=131
LBRACKET=132
LPAREN=133
LT_EQ=134
LT=135
LT_SLASH=136
NOT_EQ=137
NOT_IREGEX=138
NOT_REGEX=139
NULL_PROPERTY=140
NULLISH=141
PERCENT=142
PLUS=143
QUERY=144
QUOTE_DOUBLE=145
QUOTE_SINGLE_TEMPLATE=146
QUOTE_SINGLE_TEMPLATE_FULL=147
QUOTE_SINGLE=148
REGEX_SINGLE=149
REGEX_DOUBLE=150
RBRACE=151
RBRACKET=152
RPAREN=153
SEMICOLON=154
SLASH=155
SLASH_GT=156
UNDERSCORE=157
MULTI_LINE_COMMENT=158
SINGLE_LINE_COMMENT=159
WHITESPACE=160
STRING_TEXT=161
STRING_ESCAPE_TRIGGER=162
FULL_STRING_TEXT=163
FULL_STRING_ESCAPE_TRIGGER=164
TAG_WS=165
TAGC_WS=166
TSQLX_TEXT_TEXT=167
TSQLX_TEXT_WS=168
'->'=114
'*'=115
'`'=116
'\\'=117
':'=118
','=119
'||'=120
'-'=121
'$'=122
'.'=123
'=='=124
'>='=126
'#'=128
'~*'=129
'=~*'=130
'{'=131
'['=132
'('=133
'<='=134
'<'=135
'</'=136
'!~*'=138
'!~'=139
'?.'=140
'??'=141
'%'=142
'+'=143
'?'=144
'"'=145
'f\''=146
'F\''=147
'\''=148
'~'=149
'=~'=150
'}'=151
']'=152
')'=153
';'=154
'/'=155
'_'=157
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
import { describe, it, expect } from "vitest";
import { CharStreams, CommonTokenStream } from "antlr4ts";
import { TSQLLexer } from "./TSQLLexer.js";
import { TSQLParser } from "./TSQLParser.js";
describe("TSQLParser", () => {
function parse(input: string) {
const inputStream = CharStreams.fromString(input);
const lexer = new TSQLLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TSQLParser(tokenStream);
return parser;
}
describe("select statements", () => {
it("should parse a simple SELECT statement", () => {
const parser = parse("SELECT * FROM users;");
const tree = parser.select();
expect(tree).toBeDefined();
// The select rule can return selectStmt, selectSetStmt, or tSQLxTagElement
// Most SELECT statements are wrapped in selectSetStmt
const selectSetStmt = tree.selectSetStmt();
expect(selectSetStmt).toBeDefined();
// Get the underlying selectStmt from selectSetStmt -> selectStmtWithParens
const selectStmtWithParens = selectSetStmt!.selectStmtWithParens();
const selectStmt = selectStmtWithParens.selectStmt();
expect(selectStmt).toBeDefined();
expect(selectStmt!.SELECT()).toBeDefined();
expect(selectStmt!.columnExprList()).toBeDefined();
expect(selectStmt!.fromClause()).toBeDefined();
});
it("should parse SELECT with WHERE clause", () => {
const parser = parse("SELECT id, name FROM users WHERE id = 1;");
const tree = parser.select();
expect(tree).toBeDefined();
const selectSetStmt = tree.selectSetStmt();
const selectStmt = selectSetStmt!.selectStmtWithParens().selectStmt()!;
expect(selectStmt.whereClause()).toBeDefined();
expect(selectStmt.whereClause()!.WHERE()).toBeDefined();
expect(selectStmt.whereClause()!.columnExpr()).toBeDefined();
});
it("should parse SELECT with multiple columns", () => {
const parser = parse("SELECT id, name, email FROM users;");
const tree = parser.select();
expect(tree).toBeDefined();
const selectSetStmt = tree.selectSetStmt();
const selectStmt = selectSetStmt!.selectStmtWithParens().selectStmt()!;
const columnList = selectStmt.columnExprList();
expect(columnList).toBeDefined();
// columnExprList contains comma-separated expressions
expect(columnList.columnExpr().length).toBeGreaterThanOrEqual(1);
});
it("should parse SELECT with DISTINCT", () => {
const parser = parse("SELECT DISTINCT id FROM users;");
const tree = parser.select();
expect(tree).toBeDefined();
const selectSetStmt = tree.selectSetStmt();
const selectStmt = selectSetStmt!.selectStmtWithParens().selectStmt()!;
expect(selectStmt.DISTINCT()).toBeDefined();
});
});
describe("expressions", () => {
it("should parse a simple addition expression", () => {
const parser = parse("1 + 2");
const tree = parser.expr();
expect(tree).toBeDefined();
const columnExpr = tree.columnExpr();
expect(columnExpr).toBeDefined();
// Check that the expression has children (the operands and operator)
expect(columnExpr.text).toBeDefined();
const text = columnExpr.text;
expect(text).toContain("1");
expect(text).toContain("2");
expect(text).toContain("+");
});
it("should parse arithmetic expressions with parentheses", () => {
const parser = parse("(1 + 2) * 3");
const tree = parser.expr();
expect(tree).toBeDefined();
const columnExpr = tree.columnExpr();
expect(columnExpr).toBeDefined();
// The expression should contain the operators
const text = columnExpr.text;
expect(text).toContain("+");
expect(text).toContain("*");
expect(text).toContain("1");
expect(text).toContain("2");
expect(text).toContain("3");
});
it("should parse string literals", () => {
const parser = parse("'hello world'");
const tree = parser.expr();
expect(tree).toBeDefined();
const columnExpr = tree.columnExpr();
expect(columnExpr).toBeDefined();
const text = columnExpr.text;
expect(text).toContain("hello");
expect(text).toContain("world");
});
it("should parse numeric literals", () => {
const parser = parse("42");
const tree = parser.expr();
expect(tree).toBeDefined();
const columnExpr = tree.columnExpr();
expect(columnExpr).toBeDefined();
expect(columnExpr.text).toContain("42");
});
});
describe("program", () => {
it("should parse an empty program", () => {
const parser = parse("");
const tree = parser.program();
expect(tree).toBeDefined();
expect(tree.EOF()).toBeDefined();
expect(tree.declaration().length).toBe(0);
});
it("should parse variable declarations", () => {
const parser = parse("let x := 1");
const tree = parser.program();
expect(tree).toBeDefined();
expect(tree.declaration().length).toBe(1);
const declaration = tree.declaration(0);
// Check if it's a varDecl
const varDecl = declaration.varDecl();
expect(varDecl).toBeDefined();
expect(varDecl!.LET()).toBeDefined();
expect(varDecl!.identifier()).toBeDefined();
expect(varDecl!.expression()).toBeDefined();
});
it("should parse multiple declarations", () => {
const parser = parse("let x := 1; let y := 2");
const tree = parser.program();
expect(tree).toBeDefined();
// Count only non-empty declarations (varDecl or non-empty statements)
const varDecls = tree.declaration().filter((d) => d.varDecl() !== undefined);
expect(varDecls.length).toBe(2);
});
});
});
+810
View File
@@ -0,0 +1,810 @@
import { describe, it, expect } from "vitest";
import {
compileTSQL,
parseTSQLSelect,
isColumnReferencedInExpression,
createFallbackExpression,
injectFallbackConditions,
type WhereClauseCondition,
} from "./index.js";
import { column, type TableSchema } from "./query/schema.js";
/**
* Test table schema for enforcedWhereClause tests
*/
const taskRunsSchema: TableSchema = {
name: "task_runs",
clickhouseName: "trigger_dev.task_runs_v2",
columns: {
id: { name: "id", ...column("String") },
status: { name: "status", ...column("String") },
created_at: { name: "created_at", ...column("DateTime64") },
updated_at: { name: "updated_at", ...column("DateTime64") },
time: { name: "time", ...column("DateTime64") },
triggered_at: { name: "triggered_at", ...column("DateTime64") },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
/**
* Test table schema with tenant columns (lookup table with tenant isolation)
*/
const _lookupTableSchema: TableSchema = {
name: "lookup_table",
clickhouseName: "trigger_dev.lookup_table",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
columns: {
id: { name: "id", ...column("String") },
name: { name: "name", ...column("String") },
},
};
/**
* Test table schema WITHOUT tenant columns (e.g., global reference data)
*/
const nonTenantTableSchema: TableSchema = {
name: "reference_data",
clickhouseName: "trigger_dev.reference_data",
// No tenantColumns - this is a global table
columns: {
id: { name: "id", ...column("String") },
value: { name: "value", ...column("String") },
},
};
/**
* Base options with tenant isolation for tests
*/
const baseEnforcedWhereClause: Record<string, WhereClauseCondition> = {
organization_id: { op: "eq", value: "org_test123" },
project_id: { op: "eq", value: "proj_test456" },
environment_id: { op: "eq", value: "env_test789" },
};
describe("isColumnReferencedInExpression", () => {
it("should detect column in simple WHERE clause", () => {
const ast = parseTSQLSelect("SELECT * FROM task_runs WHERE time > '2024-01-01'");
if (ast.expression_type === "select_query") {
expect(isColumnReferencedInExpression(ast.where, "time")).toBe(true);
expect(isColumnReferencedInExpression(ast.where, "status")).toBe(false);
}
});
it("should detect column in AND expression", () => {
const ast = parseTSQLSelect(
"SELECT * FROM task_runs WHERE time > '2024-01-01' AND status = 'completed'"
);
if (ast.expression_type === "select_query") {
expect(isColumnReferencedInExpression(ast.where, "time")).toBe(true);
expect(isColumnReferencedInExpression(ast.where, "status")).toBe(true);
expect(isColumnReferencedInExpression(ast.where, "id")).toBe(false);
}
});
it("should detect column in OR expression", () => {
const ast = parseTSQLSelect(
"SELECT * FROM task_runs WHERE time > '2024-01-01' OR status = 'completed'"
);
if (ast.expression_type === "select_query") {
expect(isColumnReferencedInExpression(ast.where, "time")).toBe(true);
expect(isColumnReferencedInExpression(ast.where, "status")).toBe(true);
}
});
it("should detect column in BETWEEN expression", () => {
const ast = parseTSQLSelect(
"SELECT * FROM task_runs WHERE time BETWEEN '2024-01-01' AND '2024-12-31'"
);
if (ast.expression_type === "select_query") {
expect(isColumnReferencedInExpression(ast.where, "time")).toBe(true);
expect(isColumnReferencedInExpression(ast.where, "status")).toBe(false);
}
});
it("should detect column in qualified reference (table.column)", () => {
const ast = parseTSQLSelect("SELECT * FROM task_runs WHERE task_runs.time > '2024-01-01'");
if (ast.expression_type === "select_query") {
expect(isColumnReferencedInExpression(ast.where, "time")).toBe(true);
}
});
it("should return false for empty WHERE clause", () => {
const ast = parseTSQLSelect("SELECT * FROM task_runs");
if (ast.expression_type === "select_query") {
expect(isColumnReferencedInExpression(ast.where, "time")).toBe(false);
}
});
it("should detect column in nested NOT expression", () => {
const ast = parseTSQLSelect("SELECT * FROM task_runs WHERE NOT time > '2024-01-01'");
if (ast.expression_type === "select_query") {
expect(isColumnReferencedInExpression(ast.where, "time")).toBe(true);
}
});
});
describe("createFallbackExpression", () => {
it("should create a simple comparison expression (gt)", () => {
const expr = createFallbackExpression("time", { op: "gt", value: "2024-01-01" });
expect(expr.expression_type).toBe("compare_operation");
});
it("should create a simple comparison expression (eq)", () => {
const expr = createFallbackExpression("status", { op: "eq", value: "completed" });
expect(expr.expression_type).toBe("compare_operation");
});
it("should create a between expression", () => {
const expr = createFallbackExpression("time", {
op: "between",
low: "2024-01-01",
high: "2024-12-31",
});
expect(expr.expression_type).toBe("between_expr");
});
it("should convert Date values to ISO strings", () => {
const date = new Date("2024-06-15T12:00:00Z");
const expr = createFallbackExpression("time", { op: "gte", value: date });
expect(expr.expression_type).toBe("compare_operation");
});
});
describe("injectFallbackConditions", () => {
it("should inject fallback when column is not in WHERE", () => {
const ast = parseTSQLSelect("SELECT * FROM task_runs WHERE status = 'completed'");
const fallbacks: Record<string, WhereClauseCondition> = {
time: { op: "gte", value: "2024-01-01" },
};
const modified = injectFallbackConditions(ast, fallbacks);
if (modified.expression_type === "select_query") {
expect(modified.where).toBeDefined();
expect(isColumnReferencedInExpression(modified.where, "time")).toBe(true);
expect(isColumnReferencedInExpression(modified.where, "status")).toBe(true);
}
});
it("should NOT inject fallback when column is already in WHERE", () => {
const ast = parseTSQLSelect("SELECT * FROM task_runs WHERE time > '2024-06-01'");
const fallbacks: Record<string, WhereClauseCondition> = {
time: { op: "gte", value: "2024-01-01" },
};
const modified = injectFallbackConditions(ast, fallbacks);
// The WHERE clause should remain as is (no additional time condition)
if (modified.expression_type === "select_query" && modified.where) {
// It should not be an AND expression - it should be the original compare_operation
expect(modified.where.expression_type).toBe("compare_operation");
}
});
it("should inject fallback when query has no WHERE clause", () => {
const ast = parseTSQLSelect("SELECT * FROM task_runs LIMIT 10");
const fallbacks: Record<string, WhereClauseCondition> = {
time: { op: "gte", value: "2024-01-01" },
};
const modified = injectFallbackConditions(ast, fallbacks);
if (modified.expression_type === "select_query") {
expect(modified.where).toBeDefined();
expect(isColumnReferencedInExpression(modified.where, "time")).toBe(true);
}
});
it("should inject multiple fallbacks", () => {
const ast = parseTSQLSelect("SELECT * FROM task_runs LIMIT 10");
const fallbacks: Record<string, WhereClauseCondition> = {
time: { op: "gte", value: "2024-01-01" },
status: { op: "eq", value: "completed" },
};
const modified = injectFallbackConditions(ast, fallbacks);
if (modified.expression_type === "select_query") {
expect(modified.where).toBeDefined();
expect(isColumnReferencedInExpression(modified.where, "time")).toBe(true);
expect(isColumnReferencedInExpression(modified.where, "status")).toBe(true);
}
});
it("should only inject fallbacks for unreferenced columns", () => {
const ast = parseTSQLSelect("SELECT * FROM task_runs WHERE time > '2024-06-01'");
const fallbacks: Record<string, WhereClauseCondition> = {
time: { op: "gte", value: "2024-01-01" }, // Should NOT be injected
status: { op: "eq", value: "completed" }, // Should be injected
};
const modified = injectFallbackConditions(ast, fallbacks);
if (modified.expression_type === "select_query" && modified.where) {
// Should be an AND expression combining fallback status with original time
expect(modified.where.expression_type).toBe("and");
}
});
});
describe("compileTSQL with whereClauseFallback", () => {
const baseOptions = {
tableSchema: [taskRunsSchema],
enforcedWhereClause: baseEnforcedWhereClause,
};
describe("simple comparison fallbacks", () => {
it("should apply gt fallback when column not in WHERE", () => {
const { sql } = compileTSQL("SELECT * FROM task_runs WHERE status = 'completed'", {
...baseOptions,
whereClauseFallback: {
time: { op: "gt", value: "2024-01-01" },
},
});
// Should contain a time comparison
expect(sql).toContain("time");
expect(sql).toContain("greater(");
});
it("should apply gte fallback when column not in WHERE", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
...baseOptions,
whereClauseFallback: {
time: { op: "gte", value: "2024-01-01" },
},
});
expect(sql).toContain("greaterOrEquals(");
});
it("should apply lt fallback when column not in WHERE", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
...baseOptions,
whereClauseFallback: {
time: { op: "lt", value: "2024-12-31" },
},
});
expect(sql).toContain("less(");
});
it("should apply lte fallback when column not in WHERE", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
...baseOptions,
whereClauseFallback: {
time: { op: "lte", value: "2024-12-31" },
},
});
expect(sql).toContain("lessOrEquals(");
});
it("should apply eq fallback when column not in WHERE", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
...baseOptions,
whereClauseFallback: {
status: { op: "eq", value: "completed" },
},
});
expect(sql).toContain("equals(");
expect(sql).toContain("status");
});
it("should apply neq fallback when column not in WHERE", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
...baseOptions,
whereClauseFallback: {
status: { op: "neq", value: "failed" },
},
});
expect(sql).toContain("notEquals(");
expect(sql).toContain("status");
});
});
describe("between fallback", () => {
it("should apply BETWEEN fallback when column not in WHERE", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs WHERE status = 'completed'", {
...baseOptions,
whereClauseFallback: {
time: { op: "between", low: "2024-01-01", high: "2024-12-31" },
},
});
expect(sql).toContain("time BETWEEN");
});
it("should apply BETWEEN fallback with Date values", () => {
const startDate = new Date("2024-01-01T00:00:00Z");
const endDate = new Date("2024-12-31T23:59:59Z");
const { sql, params } = compileTSQL("SELECT id FROM task_runs", {
...baseOptions,
whereClauseFallback: {
time: { op: "between", low: startDate, high: endDate },
},
});
expect(sql).toContain("time BETWEEN");
// The dates should be converted to ISO strings and parameterized
expect(Object.values(params).some((v) => typeof v === "string" && v.includes("2024"))).toBe(
true
);
});
});
describe("fallback NOT applied when column already filtered", () => {
it("should NOT apply fallback when column is in simple WHERE", () => {
const { sql } = compileTSQL("SELECT * FROM task_runs WHERE time > '2024-06-01'", {
...baseOptions,
whereClauseFallback: {
time: { op: "gte", value: "2024-01-01" },
},
});
// Should only have the user's time condition, not the fallback
// Count occurrences of time comparison - should be 1 (just the user's)
const timeMatches = sql.match(/greater\(.*?time/g) || [];
expect(timeMatches.length).toBe(1);
});
it("should NOT apply fallback when column is in BETWEEN expression", () => {
const { sql } = compileTSQL(
"SELECT * FROM task_runs WHERE time BETWEEN '2024-06-01' AND '2024-06-30'",
{
...baseOptions,
whereClauseFallback: {
time: { op: "gte", value: "2024-01-01" },
},
}
);
// Should have the user's BETWEEN, not the fallback gte
expect(sql).toContain("time BETWEEN");
expect(sql).not.toContain("greaterOrEquals(");
});
it("should NOT apply fallback when column is in AND expression", () => {
const { sql } = compileTSQL(
"SELECT * FROM task_runs WHERE status = 'completed' AND time > '2024-06-01'",
{
...baseOptions,
whereClauseFallback: {
time: { op: "gte", value: "2024-01-01" },
},
}
);
// Should only have one time comparison
const timeMatches = sql.match(/greater\(.*?time/g) || [];
expect(timeMatches.length).toBe(1);
});
it("should NOT apply fallback when column is in OR expression", () => {
const { sql } = compileTSQL(
"SELECT * FROM task_runs WHERE status = 'completed' OR time > '2024-06-01'",
{
...baseOptions,
whereClauseFallback: {
time: { op: "gte", value: "2024-01-01" },
},
}
);
// Fallback should not be applied since time is mentioned in OR
const timeGreaterMatches = sql.match(/greater\(.*?time/g) || [];
expect(timeGreaterMatches.length).toBe(1);
});
it("should NOT apply fallback when column is in qualified reference", () => {
const { sql } = compileTSQL("SELECT * FROM task_runs WHERE task_runs.time > '2024-06-01'", {
...baseOptions,
whereClauseFallback: {
time: { op: "gte", value: "2024-01-01" },
},
});
// Fallback should not be applied
const timeGreaterMatches = sql.match(/greater.*time/g) || [];
expect(timeGreaterMatches.length).toBe(1);
});
});
describe("multiple fallbacks", () => {
it("should apply multiple fallbacks for different unreferenced columns", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
...baseOptions,
whereClauseFallback: {
time: { op: "gte", value: "2024-01-01" },
status: { op: "eq", value: "completed" },
},
});
expect(sql).toContain("greaterOrEquals(");
expect(sql).toContain("time");
expect(sql).toContain("equals(");
expect(sql).toContain("status");
});
it("should only apply fallbacks for unreferenced columns when some are filtered", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs WHERE time > '2024-06-01'", {
...baseOptions,
whereClauseFallback: {
time: { op: "gte", value: "2024-01-01" }, // Should NOT be applied
status: { op: "eq", value: "completed" }, // Should be applied
},
});
// Status fallback should be applied
expect(sql).toContain("equals(");
expect(sql).toContain("status");
// Time should only have user's condition
const timeGreaterMatches = sql.match(/greater\(.*?time/g) || [];
expect(timeGreaterMatches.length).toBe(1);
});
});
describe("edge cases", () => {
it("should handle empty whereClauseFallback", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
...baseOptions,
whereClauseFallback: {},
});
// Should just compile normally without errors
expect(sql).toContain("SELECT");
expect(sql).toContain("FROM");
});
it("should handle undefined whereClauseFallback", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
...baseOptions,
// whereClauseFallback is undefined
});
// Should just compile normally without errors
expect(sql).toContain("SELECT");
expect(sql).toContain("FROM");
});
it("should work with UNION queries", () => {
const { sql } = compileTSQL(
"SELECT id FROM task_runs WHERE status = 'completed' UNION ALL SELECT id FROM task_runs WHERE status = 'failed'",
{
...baseOptions,
whereClauseFallback: {
time: { op: "gte", value: "2024-01-01" },
},
}
);
// Both queries in the UNION should have the time fallback
const timeMatches = sql.match(/greaterOrEquals\(.*?time/g) || [];
expect(timeMatches.length).toBe(2);
});
it("should handle numeric values in fallback", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
...baseOptions,
whereClauseFallback: {
id: { op: "gt", value: 100 },
},
});
expect(sql).toContain("greater(");
expect(sql).toContain("100");
});
});
});
describe("compileTSQL with enforcedWhereClause", () => {
describe("validation tests", () => {
it("should throw error when required tenant column is missing", () => {
expect(() =>
compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {}, // Missing organization_id
})
).toThrow("Table 'task_runs' requires 'organization_id' in enforcedWhereClause");
});
it("should throw error when organization_id is missing but other tenant columns are present", () => {
expect(() =>
compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
project_id: { op: "eq", value: "proj_123" },
environment_id: { op: "eq", value: "env_456" },
},
})
).toThrow("Table 'task_runs' requires 'organization_id' in enforcedWhereClause");
});
it("should work with non-tenant table and empty enforcedWhereClause", () => {
const { sql } = compileTSQL("SELECT id FROM reference_data", {
tableSchema: [nonTenantTableSchema],
enforcedWhereClause: {},
});
expect(sql).toContain("SELECT");
expect(sql).toContain("FROM");
});
it("should work with only organization_id (project and env are optional)", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
},
});
expect(sql).toContain("organization_id");
expect(sql).not.toContain("project_id");
expect(sql).not.toContain("environment_id");
});
});
describe("basic functionality", () => {
it("should apply single enforced condition", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
},
});
expect(sql).toContain("equals(");
expect(sql).toContain("organization_id");
});
it("should apply multiple enforced conditions", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
project_id: { op: "eq", value: "proj_456" },
environment_id: { op: "eq", value: "env_789" },
},
});
expect(sql).toContain("organization_id");
expect(sql).toContain("project_id");
expect(sql).toContain("environment_id");
});
it("should apply enforced condition even when user filters on same field", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs WHERE triggered_at > '2025-01-01'", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
triggered_at: { op: "gte", value: "2024-01-01" },
},
});
// Should have BOTH the user's condition AND the enforced condition
// User's condition: greater(triggered_at, '2025-01-01')
// Enforced condition: greaterOrEquals(triggered_at, '2024-01-01')
const triggeredAtMatches = sql.match(/triggered_at/g) || [];
expect(triggeredAtMatches.length).toBeGreaterThanOrEqual(2);
});
it("should apply different comparison operators", () => {
const { sql: sqlGt } = compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
time: { op: "gt", value: "2024-01-01" },
},
});
expect(sqlGt).toContain("greater(");
const { sql: sqlLt } = compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
time: { op: "lt", value: "2024-12-31" },
},
});
expect(sqlLt).toContain("less(");
const { sql: sqlNeq } = compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
status: { op: "neq", value: "deleted" },
},
});
expect(sqlNeq).toContain("notEquals(");
});
it("should apply BETWEEN condition", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
time: { op: "between", low: "2024-01-01", high: "2024-12-31" },
},
});
expect(sql).toContain("time BETWEEN");
});
it("should handle Date values in enforced conditions", () => {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const { sql, params: _params } = compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
triggered_at: { op: "gte", value: sevenDaysAgo },
},
});
expect(sql).toContain("triggered_at");
expect(sql).toContain("toDateTime64");
});
});
describe("enforcedWhereClause + whereClauseFallback interaction", () => {
it("should apply both enforced and fallback conditions when user doesn't filter", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
triggered_at: { op: "gte", value: "2024-01-01" },
},
whereClauseFallback: {
status: { op: "eq", value: "completed" },
},
});
// Should have both enforced (triggered_at) and fallback (status)
expect(sql).toContain("triggered_at");
expect(sql).toContain("status");
});
it("should apply enforced but not fallback when user filters on fallback column", () => {
const { sql, params } = compileTSQL("SELECT id FROM task_runs WHERE status = 'failed'", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
triggered_at: { op: "gte", value: "2024-01-01" },
},
whereClauseFallback: {
status: { op: "eq", value: "completed" },
},
});
// Enforced triggered_at should be applied
expect(sql).toContain("triggered_at");
// User's status = 'failed' should be there (as a parameter)
expect(Object.values(params)).toContain("failed");
// The fallback 'completed' should NOT be applied since user filtered on status
expect(Object.values(params)).not.toContain("completed");
});
it("should apply both enforced and fallback on same field (enforced always, fallback only if not filtered)", () => {
// User doesn't filter on triggered_at, so BOTH enforced AND fallback apply
const { sql } = compileTSQL("SELECT id FROM task_runs WHERE status = 'completed'", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
triggered_at: { op: "gte", value: "2024-06-01" }, // Enforced: last 6 months
},
whereClauseFallback: {
triggered_at: { op: "gte", value: "2024-01-01" }, // Fallback: last year
},
});
// Both should be applied (enforced at printer level, fallback at AST level)
const triggeredAtMatches = sql.match(/triggered_at/g) || [];
expect(triggeredAtMatches.length).toBeGreaterThanOrEqual(2);
});
it("should skip fallback but keep enforced when user filters on same field", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs WHERE triggered_at > '2025-01-01'", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
triggered_at: { op: "gte", value: "2024-06-01" }, // Enforced: always applied
},
whereClauseFallback: {
triggered_at: { op: "gte", value: "2024-01-01" }, // Fallback: skipped since user filtered
},
});
// User's condition + enforced should be present
// Fallback should NOT be applied since user filtered on triggered_at
// Count distinct triggered_at conditions
const triggeredAtMatches = sql.match(/triggered_at/g) || [];
// Should be 2: user's condition + enforced condition (NOT 3, no fallback)
expect(triggeredAtMatches.length).toBe(2);
});
});
describe("security tests", () => {
it("should apply enforced conditions to UNION queries", () => {
const { sql } = compileTSQL(
"SELECT id FROM task_runs WHERE status = 'completed' UNION ALL SELECT id FROM task_runs WHERE status = 'failed'",
{
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
triggered_at: { op: "gte", value: "2024-01-01" },
},
}
);
// Both parts of the UNION should have the enforced conditions
const orgMatches = sql.match(/organization_id/g) || [];
expect(orgMatches.length).toBe(2);
const triggeredAtMatches = sql.match(/triggered_at/g) || [];
expect(triggeredAtMatches.length).toBe(2);
});
it("should NOT be bypassable via OR clause", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs WHERE status = 'completed' OR 1=1", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
triggered_at: { op: "gte", value: "2024-01-01" },
},
});
// The enforced conditions should be ANDed with the entire user WHERE clause
// So the structure should be: (enforced AND enforced AND ...) AND (user_where)
expect(sql).toContain("organization_id");
expect(sql).toContain("triggered_at");
// The 1=1 should be within the user's OR clause, not affecting enforced conditions
});
it("should skip enforced conditions for columns that don't exist in table", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
nonexistent_column: { op: "eq", value: "test" },
},
});
// Should not contain nonexistent_column
expect(sql).not.toContain("nonexistent_column");
// Should still have organization_id
expect(sql).toContain("organization_id");
});
});
describe("edge cases", () => {
it("should handle empty enforced conditions for non-tenant table", () => {
const { sql } = compileTSQL("SELECT id FROM reference_data", {
tableSchema: [nonTenantTableSchema],
enforcedWhereClause: {},
});
expect(sql).toContain("SELECT");
expect(sql).not.toContain("WHERE"); // No WHERE clause needed
});
it("should properly format numeric values", () => {
const { sql } = compileTSQL("SELECT id FROM task_runs", {
tableSchema: [taskRunsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_123" },
},
});
// org_123 should be parameterized, not inlined
expect(sql).toContain("tsql_val_");
});
});
});
+606
View File
@@ -0,0 +1,606 @@
// TSQL - Type-Safe SQL Query Language for ClickHouse
// Originally derived from PostHog's HogQL (see NOTICE.md for attribution)
import type { ANTLRErrorListener, RecognitionException, Recognizer } from "antlr4ts";
import { CharStreams, CommonTokenStream } from "antlr4ts";
import type { Token } from "antlr4ts/Token";
import { TSQLLexer } from "./grammar/TSQLLexer.js";
import { TSQLParser } from "./grammar/TSQLParser.js";
import type {
And,
BetweenExpr,
Call,
CompareOperation,
Constant,
Expression,
Field,
Not,
Or,
SelectQuery,
SelectSetQuery,
Tuple,
} from "./query/ast.js";
import { CompareOperationOp } from "./query/ast.js";
import { SyntaxError as TSQLSyntaxError } from "./query/errors.js";
import { TSQLParseTreeConverter } from "./query/parser.js";
import { printToClickHouse, type PrintResult } from "./query/printer.js";
import {
createPrinterContext,
type QuerySettings,
type SimpleComparisonCondition,
type TimeRange,
type WhereClauseCondition,
} from "./query/printer_context.js";
import { createSchemaRegistry, type FieldMappings, type TableSchema } from "./query/schema.js";
/**
* Simple error listener that captures syntax errors
*/
class TSQLErrorListener implements ANTLRErrorListener<Token> {
public error: string | null = null;
syntaxError(
_recognizer: Recognizer<Token, any>,
_offendingSymbol: Token | undefined,
line: number,
charPositionInLine: number,
msg: string,
_e: RecognitionException | undefined
): void {
this.error = `Syntax error at line ${line}:${charPositionInLine}: ${msg}`;
}
}
// Re-export AST types
export * from "./query/ast.js";
// Re-export errors
export * from "./query/errors.js";
// Re-export escape utilities
export {
escapeClickHouseIdentifier,
escapeClickHouseString,
escapeTSQLIdentifier,
escapeTSQLString,
getClickHouseType,
} from "./query/escape.js";
// Re-export function definitions
export {
findTSQLAggregation,
findTSQLFunction,
getAllExposedFunctionNames,
TSQL_AGGREGATIONS,
TSQL_CLICKHOUSE_FUNCTIONS,
TSQL_COMPARISON_MAPPING,
type TSQLFunctionMeta,
} from "./query/functions.js";
// Re-export schema types and functions
export {
column,
createSchemaRegistry,
findColumn,
findTable,
getAllowedUserValues,
// Core column utilities
getCoreColumns,
getExternalValue,
getInternalValue,
getInternalValueFromMapping,
getInternalValueFromMappingCaseInsensitive,
// Value mapping utilities
getUserFriendlyValue,
getVirtualColumnExpression,
// Field mapping utilities (runtime dynamic mappings)
hasFieldMapping,
isValidUserValue,
// Virtual column utilities
isVirtualColumn,
// Error message sanitization
sanitizeErrorMessage,
validateFilterColumn,
validateGroupColumn,
validateSelectColumn,
validateSortColumn,
validateTable,
type ClickHouseType,
type ColumnSchema,
type FieldMappings,
type ColumnFormatType,
type OutputColumnMetadata,
type RequiredFilter,
type SchemaRegistry,
type TableSchema,
type TenantColumnConfig,
} from "./query/schema.js";
// Re-export printer context
export {
createPrinterContext,
DEFAULT_QUERY_SETTINGS,
PrinterContext,
type BetweenCondition,
type InCondition,
type PrinterContextOptions,
type QueryNotice,
type QuerySettings,
type SimpleComparisonCondition,
type TimeRange,
type WhereClauseCondition,
} from "./query/printer_context.js";
// Re-export time bucket utilities
export {
BUCKET_THRESHOLDS,
calculateTimeBucketInterval,
type BucketThreshold,
type TimeBucketInterval,
} from "./query/time_buckets.js";
// Re-export printer
export { ClickHousePrinter, printToClickHouse, type PrintResult } from "./query/printer.js";
// Re-export parser converter for advanced usage
export { TSQLParseTreeConverter } from "./query/parser.js";
// Re-export validator
export {
validateQuery,
type ValidationIssue,
type ValidationResult,
type ValidationSeverity,
} from "./query/validator.js";
// Re-export result transformation utilities
export {
createResultTransformer,
transformResults,
type TransformResultsOptions,
} from "./query/results.js";
/**
* Parse a TSQL SELECT query string into an AST
*
* @param query - The TSQL query string to parse
* @returns The parsed AST (SelectQuery or SelectSetQuery)
* @throws TSQLSyntaxError if the query is invalid
*
* @example
* ```typescript
* const ast = parseTSQLSelect("SELECT * FROM users WHERE id = 1");
* ```
*/
export function parseTSQLSelect(query: string): SelectQuery | SelectSetQuery {
const inputStream = CharStreams.fromString(query);
const lexer = new TSQLLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TSQLParser(tokenStream);
// Remove default error listeners and add custom one
parser.removeErrorListeners();
const errorListener = new TSQLErrorListener();
parser.addErrorListener(errorListener);
const parseTree = parser.select();
if (errorListener.error) {
throw new TSQLSyntaxError(errorListener.error);
}
const converter = new TSQLParseTreeConverter();
const ast = converter.visit(parseTree);
// Validate the result is a select query
if (typeof ast === "string" || !("expression_type" in ast)) {
throw new TSQLSyntaxError("Failed to parse SELECT query");
}
if (ast.expression_type !== "select_query" && ast.expression_type !== "select_set_query") {
throw new TSQLSyntaxError(`Expected SELECT query, got ${ast.expression_type}`);
}
return ast as SelectQuery | SelectSetQuery;
}
/**
* Parse a TSQL expression string into an AST
*
* @param expr - The TSQL expression string to parse
* @returns The parsed expression AST
* @throws TSQLSyntaxError if the expression is invalid
*
* @example
* ```typescript
* const ast = parseTSQLExpr("id = 1 AND name = 'test'");
* ```
*/
export function parseTSQLExpr(expr: string): Expression {
const inputStream = CharStreams.fromString(expr);
const lexer = new TSQLLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TSQLParser(tokenStream);
// Remove default error listeners and add custom one
parser.removeErrorListeners();
const errorListener = new TSQLErrorListener();
parser.addErrorListener(errorListener);
const parseTree = parser.columnExpr(0);
if (errorListener.error) {
throw new TSQLSyntaxError(errorListener.error);
}
const converter = new TSQLParseTreeConverter();
return converter.visit(parseTree) as Expression;
}
/**
* Check if a column is referenced in an expression (for WHERE clause detection).
* Recursively traverses And, Or, CompareOperation, BetweenExpr, and Field nodes.
*
* @param expr - The expression to search
* @param column - The column name to look for
* @returns true if the column is referenced in the expression
*/
export function isColumnReferencedInExpression(
expr: Expression | undefined,
column: string
): boolean {
if (!expr) return false;
const exprType = (expr as Expression).expression_type;
switch (exprType) {
case "and": {
const andExpr = expr as And;
return andExpr.exprs.some((e) => isColumnReferencedInExpression(e, column));
}
case "or": {
const orExpr = expr as Or;
return orExpr.exprs.some((e) => isColumnReferencedInExpression(e, column));
}
case "compare_operation": {
const compareExpr = expr as CompareOperation;
return (
isColumnReferencedInExpression(compareExpr.left, column) ||
isColumnReferencedInExpression(compareExpr.right, column)
);
}
case "between_expr": {
const betweenExpr = expr as BetweenExpr;
return isColumnReferencedInExpression(betweenExpr.expr, column);
}
case "field": {
const fieldExpr = expr as Field;
// Check if any part of the chain matches the column name
// Handles both unqualified (column) and qualified (table.column) references
return fieldExpr.chain.some((part) => part === column);
}
case "not": {
const notExpr = expr as Not;
return isColumnReferencedInExpression(notExpr.expr, column);
}
default:
return false;
}
}
/**
* Format a Date as a ClickHouse-compatible DateTime64 string.
* ClickHouse expects format: 'YYYY-MM-DD HH:MM:SS.mmm' (in UTC)
*/
function formatDateForClickHouse(date: Date): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const hours = String(date.getUTCHours()).padStart(2, "0");
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
const seconds = String(date.getUTCSeconds()).padStart(2, "0");
const ms = String(date.getUTCMilliseconds()).padStart(3, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}`;
}
/**
* Create an AST expression for a fallback value.
* Date values are wrapped in toDateTime64() for ClickHouse compatibility.
*/
function createValueExpression(value: Date | string | number): Expression {
if (value instanceof Date) {
// Wrap Date in toDateTime64(formatted_string, 3) for ClickHouse DateTime64(3) columns
return {
expression_type: "call",
name: "toDateTime64",
args: [
{ expression_type: "constant", value: formatDateForClickHouse(value) } as Constant,
{ expression_type: "constant", value: 3 } as Constant,
],
} as Call;
}
return { expression_type: "constant", value } as Constant;
}
/**
* Map fallback operator to CompareOperationOp
*/
function mapFallbackOpToCompareOp(op: SimpleComparisonCondition["op"]): CompareOperationOp {
switch (op) {
case "eq":
return CompareOperationOp.Eq;
case "neq":
return CompareOperationOp.NotEq;
case "gt":
return CompareOperationOp.Gt;
case "gte":
return CompareOperationOp.GtEq;
case "lt":
return CompareOperationOp.Lt;
case "lte":
return CompareOperationOp.LtEq;
}
}
/**
* Create an AST expression from a fallback condition
*
* @param column - The column name
* @param fallback - The fallback condition
* @returns The AST expression for the fallback condition
*/
export function createFallbackExpression(
column: string,
fallback: WhereClauseCondition
): Expression {
const fieldExpr: Field = {
expression_type: "field",
chain: [column],
};
if (fallback.op === "between") {
const betweenExpr: BetweenExpr = {
expression_type: "between_expr",
expr: fieldExpr,
low: createValueExpression(fallback.low),
high: createValueExpression(fallback.high),
};
return betweenExpr;
}
if (fallback.op === "in") {
// Create a tuple of values for the IN clause
const tupleExpr: Tuple = {
expression_type: "tuple",
exprs: fallback.values.map((value) => createValueExpression(value)),
};
const inExpr: CompareOperation = {
expression_type: "compare_operation",
left: fieldExpr,
right: tupleExpr,
op: CompareOperationOp.In,
};
return inExpr;
}
// Simple comparison
const compareExpr: CompareOperation = {
expression_type: "compare_operation",
left: fieldExpr,
right: createValueExpression(fallback.value),
op: mapFallbackOpToCompareOp(fallback.op),
};
return compareExpr;
}
/**
* Inject fallback WHERE conditions into a parsed AST.
* Only adds fallback conditions for columns not already referenced in the WHERE clause.
*
* @param ast - The parsed SELECT query AST
* @param fallbacks - The fallback conditions to potentially inject
* @returns The modified AST with fallback conditions injected
*/
export function injectFallbackConditions(
ast: SelectQuery | SelectSetQuery,
fallbacks: Record<string, WhereClauseCondition>
): SelectQuery | SelectSetQuery {
// Handle SelectSetQuery (UNION, etc.) - apply to each query in the set
if (ast.expression_type === "select_set_query") {
const setQuery = ast as SelectSetQuery;
// Process the initial select query
const modifiedInitial = injectFallbackConditions(
setQuery.initial_select_query,
fallbacks
) as SelectQuery;
// Process subsequent queries
const modifiedSubsequent = setQuery.subsequent_select_queries.map((sq) => ({
...sq,
select_query: injectFallbackConditions(sq.select_query, fallbacks) as SelectQuery,
}));
return {
...setQuery,
initial_select_query: modifiedInitial,
subsequent_select_queries: modifiedSubsequent,
};
}
// Handle SelectQuery
const selectQuery = ast as SelectQuery;
const existingWhere = selectQuery.where;
// Collect fallback expressions for columns not already in WHERE
const fallbackExprs: Expression[] = [];
for (const [column, fallback] of Object.entries(fallbacks)) {
if (!isColumnReferencedInExpression(existingWhere, column)) {
fallbackExprs.push(createFallbackExpression(column, fallback));
}
}
// If no fallbacks to add, return original AST
if (fallbackExprs.length === 0) {
return ast;
}
// Combine fallbacks with existing WHERE using AND
let newWhere: Expression;
if (!existingWhere) {
// No existing WHERE - just use fallbacks
if (fallbackExprs.length === 1) {
newWhere = fallbackExprs[0];
} else {
newWhere = {
expression_type: "and",
exprs: fallbackExprs,
} as And;
}
} else {
// Combine existing WHERE with fallbacks
newWhere = {
expression_type: "and",
exprs: [...fallbackExprs, existingWhere],
} as And;
}
return {
...selectQuery,
where: newWhere,
};
}
/**
* Options for compiling a TSQL query to ClickHouse SQL
*/
export interface CompileTSQLOptions {
/** Schema definitions for allowed tables and columns */
tableSchema: TableSchema[];
/**
* REQUIRED: Conditions always applied at the table level.
* Must include tenant columns (e.g., organization_id) for multi-tenant tables.
* Applied to every table reference including subqueries, CTEs, and JOINs.
*
* @example
* ```typescript
* {
* // Tenant isolation
* organization_id: { op: "eq", value: "org_123" },
* project_id: { op: "eq", value: "proj_456" },
* environment_id: { op: "eq", value: "env_789" },
* // Plan-based time limit
* triggered_at: { op: "gte", value: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
* }
* ```
*/
enforcedWhereClause: Record<string, WhereClauseCondition | undefined>;
/** Optional query settings */
settings?: Partial<QuerySettings>;
/**
* Runtime field mappings for dynamic value translation.
* Maps internal ClickHouse values to external user-facing values.
*
* @example
* ```typescript
* {
* project: { "cm12345": "my-project-ref" },
* }
* ```
*/
fieldMappings?: FieldMappings;
/**
* Fallback WHERE conditions to apply when the user hasn't filtered on a column.
* Key is the column name, value is the fallback condition.
* These are applied at the AST level (top-level query only).
*
* @example
* ```typescript
* // Apply time > 7 days ago if user doesn't filter on time
* whereClauseFallback: {
* time: { op: 'gte', value: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
* }
*
* // Apply time BETWEEN two dates if user doesn't filter on time
* whereClauseFallback: {
* time: { op: 'between', low: startDate, high: endDate }
* }
* ```
*/
whereClauseFallback?: Record<string, WhereClauseCondition>;
/**
* Time range for `timeBucket()` interval calculation.
* When provided, `timeBucket()` uses this to determine the appropriate bucket size
* based on the span of the time range.
*
* @example
* ```typescript
* {
* from: new Date('2024-01-01'),
* to: new Date('2024-01-08'),
* }
* ```
*/
timeRange?: TimeRange;
}
/**
* Compile a TSQL query string to ClickHouse SQL with parameters
*
* This function:
* 1. Parses the TSQL query into an AST
* 2. Validates tables and columns against the schema
* 3. Injects enforced WHERE clauses (tenant isolation + plan limits) at printer level
* 4. Optionally injects fallback WHERE conditions at AST level
* 5. Generates parameterized ClickHouse SQL
*
* @param query - The TSQL query string to compile
* @param options - Compilation options including enforcedWhereClause and schema
* @returns The compiled SQL and parameters
* @throws TSQLSyntaxError if the query is invalid
* @throws QueryError if tables/columns are not allowed or required tenant columns are missing
*
* @example
* ```typescript
* const { sql, params } = compileTSQL(
* "SELECT * FROM task_runs WHERE status = 'completed' LIMIT 100",
* {
* tableSchema: [taskRunsSchema],
* enforcedWhereClause: {
* organization_id: { op: "eq", value: "org_123" },
* project_id: { op: "eq", value: "proj_456" },
* environment_id: { op: "eq", value: "env_789" },
* triggered_at: { op: "gte", value: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
* },
* }
* );
* ```
*/
export function compileTSQL(query: string, options: CompileTSQLOptions): PrintResult {
// 1. Parse the TSQL query
let ast = parseTSQLSelect(query);
// 2. Inject fallback WHERE conditions if provided (applied at AST level - top-level query only)
if (options.whereClauseFallback && Object.keys(options.whereClauseFallback).length > 0) {
ast = injectFallbackConditions(ast, options.whereClauseFallback);
}
// 3. Create schema registry from table schemas
const schemaRegistry = createSchemaRegistry(options.tableSchema);
// 4. Strip undefined values from enforcedWhereClause
const enforcedWhereClause = Object.fromEntries(
Object.entries(options.enforcedWhereClause).filter(([_, value]) => value !== undefined)
) as Record<string, WhereClauseCondition>;
// 5. Create printer context with enforced WHERE clause and field mappings
const context = createPrinterContext({
schema: schemaRegistry,
settings: options.settings,
fieldMappings: options.fieldMappings,
enforcedWhereClause,
timeRange: options.timeRange,
});
// 6. Print the AST to ClickHouse SQL (enforced conditions applied at printer level)
return printToClickHouse(ast, context);
}
+668
View File
@@ -0,0 +1,668 @@
// TypeScript translation of posthog/hogql/ast.py
import type { TSQLContext } from "./context";
import type {
DatabaseField,
FieldOrTable,
LazyJoin,
LazyTable,
Table,
UnknownDatabaseField,
VirtualTable,
} from "./models";
import type { ConstantDataType, TSQLQuerySettings } from "./constants";
// Base types
export interface AST {
start?: number;
end?: number;
accept?(visitor: any): any;
}
export interface Type extends AST {
get_child?(name: string, context: TSQLContext): Type;
has_child?(name: string, context: TSQLContext): boolean;
resolve_constant_type?(context: TSQLContext): ConstantType;
resolve_column_constant_type?(name: string, context: TSQLContext): ConstantType;
}
export interface Expr extends AST {
type?: Type;
}
export interface ConstantType extends Type {
data_type: ConstantDataType;
nullable?: boolean;
print_type?(): string;
}
export interface UnknownType extends ConstantType {
data_type: "unknown";
}
export type Expression =
| CTE
| Alias
| ArithmeticOperation
| And
| Or
| CompareOperation
| Not
| BetweenExpr
| OrderExpr
| ArrayAccess
| Array
| Dict
| TupleAccess
| Tuple
| Lambda
| Constant
| Field
| Placeholder
| Call
| ExprCall
| JoinConstraint
| JoinExpr
| WindowFrameExpr
| WindowExpr
| WindowFunction
| LimitByExpr
| SelectQuery
| SelectSetQuery
| RatioExpr
| SampleExpr
| TSQLXTag;
export interface CTE extends Expr {
expression_type: "cte";
name: string;
expr: Expression;
cte_type: "column" | "subquery";
}
// Type system
export type TableOrSelectType =
| BaseTableType
| SelectSetQueryType
| SelectQueryType
| SelectQueryAliasType;
export interface FieldAliasType extends Type {
alias: string;
type: Type;
}
export interface BaseTableType extends Type {
resolve_database_table?(context: TSQLContext): Table;
}
export interface TableType extends BaseTableType {
table: Table;
}
export interface LazyJoinType extends BaseTableType {
table_type: TableOrSelectType;
field: string;
lazy_join: LazyJoin;
}
export interface LazyTableType extends BaseTableType {
table: LazyTable;
}
export interface TableAliasType extends BaseTableType {
alias: string;
table_type: TableType | LazyTableType;
}
export interface VirtualTableType extends BaseTableType {
table_type: TableOrSelectType;
field: string;
virtual_table: VirtualTable;
}
export interface SelectQueryType extends Type {
aliases: Record<string, FieldAliasType>;
columns: Record<string, Type>;
tables: Record<string, TableOrSelectType>;
ctes: Record<string, CTE>;
anonymous_tables: (SelectQueryType | SelectSetQueryType)[];
parent?: SelectQueryType | SelectSetQueryType;
is_lambda_type?: boolean;
}
export interface SelectSetQueryType extends Type {
types: (SelectQueryType | SelectSetQueryType)[];
}
export interface SelectViewType extends BaseTableType {
view_name: string;
alias: string;
select_query_type: SelectQueryType | SelectSetQueryType;
}
export interface SelectQueryAliasType extends Type {
alias: string;
select_query_type: SelectQueryType | SelectSetQueryType;
}
export interface IntegerType extends ConstantType {
data_type: "int";
}
export interface DecimalType extends ConstantType {
data_type: "unknown";
}
export interface FloatType extends ConstantType {
data_type: "float";
}
export interface StringType extends ConstantType {
data_type: "str";
}
export interface StringJSONType extends StringType {}
export interface StringArrayType extends StringType {}
export interface BooleanType extends ConstantType {
data_type: "bool";
}
export interface DateType extends ConstantType {
data_type: "date";
}
export interface DateTimeType extends ConstantType {
data_type: "datetime";
}
export interface IntervalType extends ConstantType {
data_type: "unknown";
}
export interface UUIDType extends ConstantType {
data_type: "uuid";
}
export interface ArrayType extends ConstantType {
data_type: "array";
item_type: ConstantType;
}
export interface TupleType extends ConstantType {
data_type: "tuple";
item_types: ConstantType[];
repeat?: boolean;
}
export interface CallType extends Type {
name: string;
arg_types: ConstantType[];
param_types?: ConstantType[];
return_type: ConstantType;
}
export interface AsteriskType extends Type {
table_type: TableOrSelectType;
}
export interface FieldTraverserType extends Type {
chain: (string | number)[];
table_type: TableOrSelectType;
}
export interface ExpressionFieldType extends Type {
name: string;
expr: Expression;
table_type: TableOrSelectType;
isolate_scope?: boolean;
}
export interface FieldType extends Type {
name: string;
table_type: TableOrSelectType;
}
export interface UnresolvedFieldType extends Type {
name: string;
}
export interface PropertyType extends Type {
chain: (string | number)[];
field_type: FieldType;
joined_subquery?: SelectQueryAliasType;
joined_subquery_field_name?: string;
}
export interface LambdaArgumentType extends Type {
name: string;
}
// Enums
export enum ArithmeticOperationOp {
Add = "+",
Sub = "-",
Mult = "*",
Div = "/",
Mod = "%",
}
export enum CompareOperationOp {
Eq = "==",
NotEq = "!=",
Gt = ">",
GtEq = ">=",
Lt = "<",
LtEq = "<=",
Like = "like",
ILike = "ilike",
NotLike = "not like",
NotILike = "not ilike",
In = "in",
GlobalIn = "global in",
NotIn = "not in",
GlobalNotIn = "global not in",
InCohort = "in cohort",
NotInCohort = "not in cohort",
Regex = "=~",
IRegex = "=~*",
NotRegex = "!~",
NotIRegex = "!~*",
}
export const NEGATED_COMPARE_OPS: CompareOperationOp[] = [
CompareOperationOp.NotEq,
CompareOperationOp.NotLike,
CompareOperationOp.NotILike,
CompareOperationOp.NotIn,
CompareOperationOp.GlobalNotIn,
CompareOperationOp.NotInCohort,
CompareOperationOp.NotRegex,
CompareOperationOp.NotIRegex,
];
export type SetOperator =
| "UNION ALL"
| "UNION DISTINCT"
| "INTERSECT"
| "INTERSECT DISTINCT"
| "EXCEPT";
export type ParseResult = Expression | Declaration | string;
// Declaration and Statement types
export interface Declaration extends AST {}
export interface VariableAssignment extends Declaration {
left: Expression;
right: Expression;
}
export interface VariableDeclaration extends Declaration {
name: string;
expr?: Expression;
}
export interface Statement extends Declaration {}
export interface ExprStatement extends Statement {
expr?: Expression;
}
export interface ReturnStatement extends Statement {
expr?: Expression;
}
export interface ThrowStatement extends Statement {
expr?: Expression;
}
export interface TryCatchStatement extends Statement {
try_stmt: Statement;
catches: [string | null, string | null, Statement][];
finally_stmt?: Statement;
}
export interface IfStatement extends Statement {
expr: Expression;
then: Statement;
else_?: Statement;
}
export interface WhileStatement extends Statement {
expr: Expression;
body: Statement;
}
export interface ForStatement extends Statement {
initializer?: VariableDeclaration | VariableAssignment | Expression;
condition?: Expression;
increment?: VariableDeclaration;
body: Statement;
}
export interface ForInStatement extends Statement {
keyVar?: string;
valueVar: string;
expr: Expression;
body: Statement;
}
export interface Function extends Statement {
name: string;
params: string[];
body: Statement;
}
export interface Block extends Statement {
declarations: Declaration[];
}
export interface Program extends AST {
declarations: Declaration[];
}
// Expression types
export interface Alias extends Expr {
expression_type: "alias";
alias: string;
expr: Expression;
hidden?: boolean;
from_asterisk?: boolean;
}
export interface ArithmeticOperation extends Expr {
expression_type: "arithmetic_operation";
left: Expression;
right: Expression;
op: ArithmeticOperationOp;
}
export interface And extends Expr {
expression_type: "and";
type?: ConstantType;
exprs: Expression[];
}
export interface Or extends Expr {
expression_type: "or";
exprs: Expression[];
type?: ConstantType;
}
export interface CompareOperation extends Expr {
expression_type: "compare_operation";
left: Expression;
right: Expression;
op: CompareOperationOp;
type?: ConstantType;
}
export interface Not extends Expr {
expression_type: "not";
expr: Expression;
type?: ConstantType;
}
export interface BetweenExpr extends Expr {
expression_type: "between_expr";
expr: Expression;
low: Expression;
high: Expression;
negated?: boolean;
type?: ConstantType;
}
export interface OrderExpr extends Expr {
expression_type: "order_expr";
expr: Expression;
order?: "ASC" | "DESC";
}
export interface ArrayAccess extends Expr {
expression_type: "array_access";
array: Expression;
property: Expression;
nullish?: boolean;
}
export interface Array extends Expr {
expression_type: "array";
exprs: Expression[];
}
export interface Dict extends Expr {
expression_type: "dict";
items: [Expression, Expression][];
}
export interface TupleAccess extends Expr {
expression_type: "tuple_access";
tuple: Expression;
index: number;
nullish?: boolean;
}
export interface Tuple extends Expr {
expression_type: "tuple";
exprs: Expression[];
}
export interface Lambda extends Expr {
expression_type: "lambda";
args: string[];
expr: Expression | Block;
}
export interface Constant extends Expr {
expression_type: "constant";
value: any;
}
export interface Field extends Expr {
expression_type: "field";
chain: (string | number)[];
from_asterisk?: boolean;
}
export interface Placeholder extends Expr {
expression_type: "placeholder";
expr: Expression;
// Computed properties
chain?: (string | number)[] | null;
field?: string | null;
}
export interface Call extends Expr {
expression_type: "call";
name: string;
args: Expression[];
params?: Expression[];
distinct?: boolean;
}
export interface ExprCall extends Expr {
expression_type: "expr_call";
expr: Expression;
args: Expression[];
}
export interface JoinConstraint extends Expr {
expression_type: "join_constraint";
expr: Expression;
constraint_type: "ON" | "USING";
}
export interface JoinExpr extends Expr {
expression_type: "join_expr";
type?: TableOrSelectType;
join_type?: string;
table?: SelectQuery | SelectSetQuery | Placeholder | TSQLXTag | Field;
table_args?: Expression[];
alias?: string;
table_final?: boolean;
constraint?: JoinConstraint;
next_join?: JoinExpr;
sample?: SampleExpr;
}
export interface WindowFrameExpr extends Expr {
expression_type: "window_frame_expr";
frame_type?: "CURRENT ROW" | "PRECEDING" | "FOLLOWING";
frame_value?: number;
}
export interface WindowExpr extends Expr {
expression_type: "window_expr";
partition_by?: Expression[];
order_by?: OrderExpr[];
frame_method?: "ROWS" | "RANGE";
frame_start?: WindowFrameExpr;
frame_end?: WindowFrameExpr;
}
export interface WindowFunction extends Expr {
expression_type: "window_function";
name: string;
args?: Expression[];
exprs?: Expression[];
over_expr?: WindowExpr;
over_identifier?: string;
}
export interface LimitByExpr extends Expr {
expression_type: "limit_by_expr";
n: Expression;
exprs: Expression[];
offset_value?: Expression;
}
export interface SelectQuery extends Expr {
expression_type: "select_query";
type?: SelectQueryType;
ctes?: Record<string, CTE>;
select: Expression[];
distinct?: boolean;
select_from?: JoinExpr;
array_join_op?: string;
array_join_list?: Expression[];
window_exprs?: Record<string, WindowExpr>;
where?: Expression;
prewhere?: Expression;
having?: Expression;
group_by?: Expression[];
order_by?: OrderExpr[];
limit?: Expression;
limit_by?: LimitByExpr;
limit_with_ties?: boolean;
offset?: Expression;
settings?: TSQLQuerySettings;
view_name?: string;
}
export interface SelectSetNode extends AST {
select_query: SelectQuery | SelectSetQuery;
set_operator: SetOperator;
}
export interface SelectSetQuery extends Expr {
expression_type: "select_set_query";
type?: SelectSetQueryType;
initial_select_query: SelectQuery | SelectSetQuery;
subsequent_select_queries: SelectSetNode[];
// Equivalent to select_queries() method
select_queries?(): (SelectQuery | SelectSetQuery)[];
}
// Add static method equivalent for SelectSetQuery.create_from_queries()
export namespace SelectSetQuery {
export function createFromQueries(
queries: (SelectQuery | SelectSetQuery)[],
set_operator: SetOperator
): SelectQuery | SelectSetQuery {
return createSelectSetQueryFromQueries(queries, set_operator);
}
}
export interface RatioExpr extends Expr {
expression_type: "ratio_expr";
left: Constant;
right?: Constant;
}
export interface SampleExpr extends Expr {
expression_type: "sample_expr";
sample_value: RatioExpr;
offset_value?: RatioExpr;
}
export interface TSQLXAttribute extends AST {
name: string;
value: any;
}
export interface TSQLXTag extends Expr {
expression_type: "tsqlx_tag";
kind: string;
attributes: TSQLXAttribute[];
// Equivalent to to_dict() method
to_dict?(): Record<string, any>;
}
// Helper function to create empty SelectQuery (equivalent to SelectQuery.empty())
export function createEmptySelectQuery(columns?: Record<string, FieldOrTable>): SelectQuery {
if (!columns) {
columns = { _: { name: "_" } as UnknownDatabaseField };
}
return {
expression_type: "select_query",
select: Object.entries(columns).map(([column, field]) => ({
expression_type: "alias" as const,
alias: column,
expr: {
expression_type: "constant",
value: (field as DatabaseField).default_value?.() ?? null,
} as Constant,
})),
where: { expression_type: "constant", value: false } as Constant,
};
}
// Add static method equivalent for SelectQuery.empty()
export namespace SelectQuery {
export function empty(columns?: Record<string, FieldOrTable>): SelectQuery {
return createEmptySelectQuery(columns);
}
}
// Helper function for SelectSetQuery.select_queries()
export function selectQueries(query: SelectSetQuery): (SelectQuery | SelectSetQuery)[] {
return [
query.initial_select_query,
...query.subsequent_select_queries.map((node) => node.select_query),
];
}
// Helper function to create SelectSetQuery from multiple queries
export function createSelectSetQueryFromQueries(
queries: (SelectQuery | SelectSetQuery)[],
set_operator: SetOperator
): SelectQuery | SelectSetQuery {
if (queries.length === 0) {
throw new Error("Cannot create a SelectSetQuery from an empty list of queries");
} else if (queries.length === 1) {
return queries[0];
}
return {
expression_type: "select_set_query",
initial_select_query: queries[0],
subsequent_select_queries: queries.slice(1).map((query) => ({
select_query: query,
set_operator,
})) as SelectSetNode[],
} as SelectSetQuery;
}
@@ -0,0 +1,71 @@
// TypeScript translation of posthog/hogql/constants.py
export type ConstantDataType =
| "int"
| "float"
| "str"
| "bool"
| "array"
| "tuple"
| "date"
| "datetime"
| "uuid"
| "unknown";
export type ConstantSupportedPrimitive = number | string | boolean | Date | null;
export type ConstantSupportedData =
| ConstantSupportedPrimitive
| ConstantSupportedPrimitive[]
| [ConstantSupportedPrimitive, ...ConstantSupportedPrimitive[]];
export const KEYWORDS = ["true", "false", "null"] as const;
export const RESERVED_KEYWORDS = [...KEYWORDS, "team_id"] as const;
export const DEFAULT_RETURNED_ROWS = 100;
export const MAX_SELECT_RETURNED_ROWS = 50000;
export const MAX_SELECT_RETENTION_LIMIT = 100000;
export const MAX_SELECT_HEATMAPS_LIMIT = 1000000;
export const MAX_SELECT_COHORT_CALCULATION_LIMIT = 1000000000;
export const MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY = 22 * 1024 * 1024 * 1024;
export const CSV_EXPORT_LIMIT = 300000;
export const CSV_EXPORT_BREAKDOWN_LIMIT_INITIAL = 512;
export const CSV_EXPORT_BREAKDOWN_LIMIT_LOW = 64;
export const BREAKDOWN_VALUES_LIMIT = 25;
export const BREAKDOWN_VALUES_LIMIT_FOR_COUNTRIES = 300;
export enum LimitContext {
QUERY = "query",
QUERY_ASYNC = "query_async",
EXPORT = "export",
COHORT_CALCULATION = "cohort_calculation",
HEATMAPS = "heatmaps",
SAVED_QUERY = "saved_query",
RETENTION = "retention",
}
// Settings applied at the SELECT level
export interface TSQLQuerySettings {
optimize_aggregation_in_order?: boolean;
date_time_output_format?: string;
date_time_input_format?: string;
join_algorithm?: string;
}
// Settings applied on top of all TSQL queries
export interface TSQLGlobalSettings extends TSQLQuerySettings {
readonly?: number;
max_execution_time?: number;
max_memory_usage?: number;
max_threads?: number;
allow_experimental_object_type?: boolean;
format_csv_allow_double_quotes?: boolean;
max_ast_elements?: number;
max_expanded_ast_elements?: number;
max_bytes_before_external_group_by?: number;
allow_experimental_analyzer?: boolean;
transform_null_in?: boolean;
optimize_min_equality_disjunction_chain_length?: number;
allow_experimental_join_condition?: boolean;
preferred_block_size_bytes?: number;
use_hive_partitioning?: number;
}
@@ -0,0 +1,56 @@
// TypeScript translation of posthog/hogql/context.py
import type { LimitContext } from "./constants";
import type { Database } from "./database";
import type { PropertySwapper } from "./property_types";
import type { TSQLTimings } from "./timings";
export interface TSQLNotice {
start?: number;
end?: number;
message: string;
fix?: string;
}
export interface TSQLQueryModifiers {
optimizeJoinedFilters?: boolean;
debug?: boolean;
timings?: boolean;
useMaterializedViews?: boolean;
formatCsvAllowDoubleQuotes?: boolean;
convertToProjectTimezone?: boolean;
usePreaggregatedTableTransforms?: boolean;
optimizeProjections?: boolean;
}
export interface TSQLFieldAccess {
input: string[];
type?: "run";
field?: string;
sql: string;
}
export interface Team {
id: number;
project_id: number;
}
export interface TSQLContext {
team_id?: number;
team?: Team;
database?: Database;
values: Record<string, any>;
within_non_tsql_query?: boolean;
enable_select_queries?: boolean;
limit_top_select?: boolean;
limit_context?: LimitContext;
output_format?: string | null;
globals?: Record<string, any>;
warnings: TSQLNotice[];
notices: TSQLNotice[];
errors: TSQLNotice[];
timings: TSQLTimings;
modifiers: TSQLQueryModifiers;
debug?: boolean;
property_swapper?: PropertySwapper;
}
@@ -0,0 +1,585 @@
// TypeScript translation of posthog/hogql/database/database.py
//
// NOTE: This implementation requires database/ORM access for:
// - serialize() method (needs DataWarehouseTable, DataWarehouseSavedQuery queries)
// - create_for() method (needs Team, DataWarehouseJoin, DataWarehouseSavedQuery queries)
// Adapt these methods to your database/ORM setup
import type { ConstantType } from "./ast";
import type { TSQLContext, TSQLQueryModifiers, Team } from "./context";
import type {
DatabaseField,
ExpressionField,
FieldOrTable,
FieldTraverser,
LazyJoin,
Table,
TableNode,
VirtualTable,
} from "./models";
import type { TSQLTimings } from "./timings";
import { QueryError, ResolutionError } from "./errors";
import { TSQLTimings as TSQLTimingsClass } from "./timings";
// Type definitions for schema serialization (adapt to your schema types)
export interface DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
}
export interface DatabaseSchemaSystemTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
}
export interface DatabaseSchemaDataWarehouseTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
format?: string;
url_pattern?: string;
schema?: DatabaseSchemaSchema;
source?: DatabaseSchemaSource;
row_count?: number;
}
export interface DatabaseSchemaViewTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
query: { query: string };
row_count?: number;
}
export interface DatabaseSchemaManagedViewTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
kind: string;
source_id?: string;
query: { query: string };
}
export interface DatabaseSchemaEndpointTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
query: { query: string };
row_count?: number;
status?: string;
}
export interface DatabaseSchemaField {
name: string;
tsql_value: string;
type: DatabaseSerializedFieldType;
schema_valid: boolean;
fields?: string[];
table?: string;
chain?: Array<string | number>;
id?: string;
}
export interface DatabaseSchemaSchema {
id: string;
name: string;
should_sync: boolean;
incremental: boolean;
status: string;
last_synced_at: string;
}
export interface DatabaseSchemaSource {
id: string;
status: string;
source_type: string;
prefix: string;
last_synced_at?: string | null;
}
export enum DatabaseSerializedFieldType {
STRING = "string",
INTEGER = "integer",
FLOAT = "float",
DECIMAL = "decimal",
BOOLEAN = "boolean",
DATE = "date",
DATETIME = "datetime",
UUID = "uuid",
ARRAY = "array",
JSON = "json",
TUPLE = "tuple",
UNKNOWN = "unknown",
EXPRESSION = "expression",
VIEW = "view",
LAZY_TABLE = "lazy_table",
VIRTUAL_TABLE = "virtual_table",
FIELD_TRAVERSER = "field_traverser",
}
export interface SerializedField {
key: string;
name: string;
type: DatabaseSerializedFieldType;
schema_valid: boolean;
fields?: string[];
table?: string;
chain?: Array<string | number>;
}
import { TableNodeImpl } from "./models";
export class Database {
// Users can query from the tables below
tables: TableNode;
private _warehouseTableNames: string[] = [];
private _warehouseSelfManagedTableNames: string[] = [];
private _viewTableNames: string[] = [];
private _coreTableNames: string[] = [];
private _timezone?: string | null;
private _weekStartDay?: string | null; // WeekStartDay enum
private _serializationErrors: Record<string, string> = {};
constructor(timezone?: string | null, weekStartDay?: string | null) {
// Initialize with root TableNode
this.tables = new TableNodeImpl("root");
this._timezone = timezone || null;
this._weekStartDay = weekStartDay || null;
}
getTimezone(): string {
return this._timezone || "UTC";
}
getWeekStartDay(): string {
return this._weekStartDay || "sunday"; // Adapt to your WeekStartDay enum
}
getSerializationErrors(): Record<string, string> {
/** Return any errors encountered during serialization. */
return { ...this._serializationErrors };
}
hasTable(tableName: string | string[]): boolean {
const path = typeof tableName === "string" ? tableName.split(".") : tableName;
return this.tables.has_child ? this.tables.has_child(path) : false;
}
getTableNode(tableName: string | string[]): TableNode {
let path: string[];
if (typeof tableName === "string") {
path = tableName.split(".");
} else {
path = tableName;
}
// Handle edge case where tableName is a list with a single string containing dots
if (path.length === 1 && path[0].includes(".")) {
path = path[0].split(".");
}
if (!this.tables.get_child) {
throw new ResolutionError(`TableNode.get_child not implemented`);
}
return this.tables.get_child(path);
}
getTable(tableName: string | string[]): Table {
try {
const node = this.getTableNode(tableName);
if (!node.get) {
throw new ResolutionError("TableNode.get not implemented");
}
const table = node.get();
if (!table || typeof table !== "object" || !("fields" in table)) {
throw new ResolutionError("Table is not set");
}
return table as Table;
} catch (e) {
const name = Array.isArray(tableName) ? tableName.join(".") : tableName;
if (e instanceof ResolutionError) {
throw new QueryError(`Unknown table \`${name}\`.`);
}
throw e;
}
}
getAllTableNames(): string[] {
const warehouseTableNames = this._warehouseTableNames.filter((x) => x.includes("."));
return [
...this._coreTableNames,
...warehouseTableNames,
...this._warehouseSelfManagedTableNames,
...this._viewTableNames,
];
}
// Core tables exposed via SQL editor autocomplete and data management
getCoreTableNames(): string[] {
return [...this._coreTableNames, ...this.getSystemTableNames()];
}
getSystemTableNames(): string[] {
const systemNode = this.tables.children["system"];
if (systemNode && systemNode.resolve_all_table_names) {
return ["query_log", ...systemNode.resolve_all_table_names()];
}
return ["query_log"];
}
getWarehouseTableNames(): string[] {
return [...this._warehouseTableNames, ...this._warehouseSelfManagedTableNames];
}
getViewNames(): string[] {
return this._viewTableNames;
}
addCoreTable(tableName: string, node: TableNode): void {
if (this.tables.add_child) {
this.tables.add_child(node);
}
this._coreTableNames.push(tableName);
}
private _addWarehouseTables(node: TableNode): void {
if (this.tables.merge_with) {
this.tables.merge_with(node);
}
if (node.resolve_all_table_names) {
const names = node.resolve_all_table_names();
this._warehouseTableNames.push(...names.sort());
}
}
private _addWarehouseSelfManagedTables(node: TableNode): void {
if (this.tables.merge_with) {
this.tables.merge_with(node);
}
if (node.resolve_all_table_names) {
const names = node.resolve_all_table_names();
this._warehouseSelfManagedTableNames.push(...names.sort());
}
}
private _addViews(node: TableNode): void {
if (this.tables.merge_with) {
this.tables.merge_with(node);
}
if (node.resolve_all_table_names) {
const names = node.resolve_all_table_names();
this._viewTableNames.push(...names.sort());
}
}
serialize(context: TSQLContext, includeOnly?: Set<string>): Record<string, DatabaseSchemaTable> {
// NOTE: This method requires database queries to fetch:
// - DataWarehouseTable objects
// - DataWarehouseSavedQuery objects
// - External data sources and schemas
//
// Adapt this to your database/ORM setup
const tables: Record<string, DatabaseSchemaTable> = {};
if (!context.team_id) {
throw new ResolutionError("Must provide team_id to serialize database");
}
// Core tables
const coreTableNames = this.getCoreTableNames();
for (const tableName of coreTableNames) {
if (includeOnly && !includeOnly.has(tableName)) {
continue;
}
let fieldInput: Record<string, FieldOrTable> = {};
const table = this.getTable(tableName);
if ("get_asterisk" in table && typeof table.get_asterisk === "function") {
fieldInput = table.get_asterisk() || {};
} else if ("fields" in table) {
fieldInput = table.fields;
}
const fields = serializeFields(fieldInput, context, tableName.split("."), undefined);
const fieldsDict: Record<string, DatabaseSchemaField> = {};
for (const field of fields) {
fieldsDict[field.name] = field;
}
tables[tableName] = {
fields: fieldsDict,
id: tableName,
name: tableName,
} as DatabaseSchemaTable;
}
// System tables
const systemTables = this.getSystemTableNames();
for (const tableKey of systemTables) {
if (includeOnly && !includeOnly.has(tableKey)) {
continue;
}
let systemFieldInput: Record<string, FieldOrTable> = {};
const table = this.getTable(tableKey);
if ("get_asterisk" in table && typeof table.get_asterisk === "function") {
systemFieldInput = table.get_asterisk() || {};
} else if ("fields" in table) {
systemFieldInput = table.fields;
}
const fields = serializeFields(systemFieldInput, context, tableKey.split("."), undefined);
const fieldsDict: Record<string, DatabaseSchemaField> = {};
for (const field of fields) {
fieldsDict[field.name] = field;
}
tables[tableKey] = {
fields: fieldsDict,
id: tableKey,
name: tableKey,
} as DatabaseSchemaSystemTable;
}
// NOTE: Data Warehouse Tables and Views processing requires database queries
// Implement based on your database/ORM setup:
// - Fetch DataWarehouseTable objects
// - Fetch DataWarehouseSavedQuery objects
// - Process and serialize them
return tables;
}
static createFor(
teamId?: number,
options?: {
team?: Team;
modifiers?: TSQLQueryModifiers;
timings?: TSQLTimings;
}
): Database {
// NOTE: This method requires extensive database/ORM access:
// - Team model queries
// - DataWarehouseTable queries
// - DataWarehouseSavedQuery queries
// - DataWarehouseJoin queries
// - GroupTypeMapping queries
// - Feature flag checks
//
// This is a skeleton structure - adapt to your setup
const timings = options?.timings || new TSQLTimingsClass();
const { team, modifiers: _modifiers } = options || {};
// Validate team/teamId
if (!teamId && !team) {
throw new Error("Either team_id or team must be provided");
}
if (team && teamId && team.id !== teamId) {
throw new Error("team_id and team must be the same");
}
// NOTE: Fetch team from database if not provided
// const fetchedTeam = team || await Team.findById(teamId);
// Create database instance
const database = timings.measure("database", () => {
// NOTE: Get timezone and week_start_day from team
// const timezone = fetchedTeam.timezone;
// const weekStartDay = fetchedTeam.week_start_day;
return new Database(undefined, undefined);
});
// NOTE: Apply modifiers, setup tables, etc.
// This requires extensive database access and table setup logic
return database;
}
}
// Helper functions
const TSQL_CHARACTERS_TO_BE_WRAPPED = ["@", "-", "!", "$", "+"];
function constantTypeToSerializedFieldType(
constantType: ConstantType
): DatabaseSerializedFieldType | null {
// Type checking for ConstantType subtypes
// NOTE: In TypeScript, we need to check properties rather than instanceof
// since these are interfaces, not classes
if ("data_type" in constantType) {
const dataType = constantType.data_type;
if (dataType === "str") {
return DatabaseSerializedFieldType.STRING;
}
if (dataType === "bool") {
return DatabaseSerializedFieldType.BOOLEAN;
}
if (dataType === "date") {
return DatabaseSerializedFieldType.DATE;
}
if (dataType === "datetime") {
return DatabaseSerializedFieldType.DATETIME;
}
if (dataType === "uuid") {
return DatabaseSerializedFieldType.STRING;
}
if (dataType === "array") {
return DatabaseSerializedFieldType.ARRAY;
}
if (dataType === "tuple") {
return DatabaseSerializedFieldType.JSON;
}
if (dataType === "int") {
return DatabaseSerializedFieldType.INTEGER;
}
if (dataType === "float") {
return DatabaseSerializedFieldType.FLOAT;
}
}
// Fallback: check print_type if available
if ("print_type" in constantType && typeof constantType.print_type === "function") {
const printed = constantType.print_type();
if (printed === "String" || printed === "JSON" || printed === "Array") {
return printed === "String"
? DatabaseSerializedFieldType.STRING
: printed === "JSON"
? DatabaseSerializedFieldType.JSON
: DatabaseSerializedFieldType.ARRAY;
}
if (printed === "Boolean") return DatabaseSerializedFieldType.BOOLEAN;
if (printed === "Date") return DatabaseSerializedFieldType.DATE;
if (printed === "DateTime") return DatabaseSerializedFieldType.DATETIME;
if (printed === "UUID") return DatabaseSerializedFieldType.STRING;
if (printed === "Integer") return DatabaseSerializedFieldType.INTEGER;
if (printed === "Float") return DatabaseSerializedFieldType.FLOAT;
if (printed === "Decimal") return DatabaseSerializedFieldType.DECIMAL;
}
return null;
}
export function serializeFields(
fieldInput: Record<string, FieldOrTable>,
context: TSQLContext,
tableChain: string[],
dbColumns?: Record<string, any> // DataWarehouseTableColumns
): DatabaseSchemaField[] {
// NOTE: This requires resolve_types_from_table from resolver
// Import as needed: import { resolveTypesFromTable } from '../resolver';
const fieldOutput: DatabaseSchemaField[] = [];
for (const [fieldKey, field] of Object.entries(fieldInput)) {
let schemaValid = true;
if (dbColumns) {
const column = dbColumns[fieldKey];
if (typeof column === "string") {
schemaValid = true;
} else if (column && typeof column === "object") {
schemaValid = column.valid !== false;
}
}
let tsqlValue: string;
if (TSQL_CHARACTERS_TO_BE_WRAPPED.some((char) => fieldKey.includes(char))) {
tsqlValue = `\`${fieldKey}\``;
} else {
tsqlValue = fieldKey;
}
if ("hidden" in field && field.hidden) {
continue;
}
if ("name" in field && "get_constant_type" in field) {
// DatabaseField
const dbField = field as DatabaseField;
let fieldType: DatabaseSerializedFieldType;
// Determine field type based on DatabaseField subclass
// NOTE: You'll need to check instanceof or use type guards
// For now, using a simplified approach
if (dbField.get_constant_type) {
const constantType = dbField.get_constant_type();
fieldType =
constantTypeToSerializedFieldType(constantType) || DatabaseSerializedFieldType.UNKNOWN;
} else {
fieldType = DatabaseSerializedFieldType.UNKNOWN;
}
fieldOutput.push({
name: fieldKey,
tsql_value: tsqlValue,
type: fieldType,
schema_valid: schemaValid,
});
} else if ("expr" in field) {
// ExpressionField
const _exprField = field as ExpressionField;
// NOTE: Requires resolve_types_from_table
// const resolvedExpr = resolveTypesFromTable(exprField.expr, tableChain, context, 'tsql');
// const constantType = resolvedExpr.type?.resolve_constant_type(context);
// const fieldType = constantTypeToSerializedFieldType(constantType) || DatabaseSerializedFieldType.EXPRESSION;
fieldOutput.push({
name: fieldKey,
tsql_value: tsqlValue,
type: DatabaseSerializedFieldType.EXPRESSION,
schema_valid: schemaValid,
});
} else if ("resolve_table" in field) {
// LazyJoin
const lazyJoin = field as LazyJoin;
if (lazyJoin.resolve_table) {
const resolvedTable = lazyJoin.resolve_table(context);
const type =
"id" in resolvedTable && resolvedTable.id
? DatabaseSerializedFieldType.VIEW
: DatabaseSerializedFieldType.LAZY_TABLE;
fieldOutput.push({
name: fieldKey,
tsql_value: tsqlValue,
type,
schema_valid: schemaValid,
table: resolvedTable.to_printed_tsql ? resolvedTable.to_printed_tsql() : fieldKey,
fields: "fields" in resolvedTable ? Object.keys(resolvedTable.fields) : [],
id: "id" in resolvedTable && resolvedTable.id ? String(resolvedTable.id) : fieldKey,
});
}
} else if ("fields" in field && !("resolve_table" in field)) {
// VirtualTable
const virtualTable = field as VirtualTable;
fieldOutput.push({
name: fieldKey,
tsql_value: tsqlValue,
type: DatabaseSerializedFieldType.VIRTUAL_TABLE,
schema_valid: schemaValid,
table: virtualTable.to_printed_tsql ? virtualTable.to_printed_tsql() : fieldKey,
fields: Object.keys(virtualTable.fields),
});
} else if ("chain" in field) {
// FieldTraverser
const traverser = field as FieldTraverser;
fieldOutput.push({
name: fieldKey,
tsql_value: tsqlValue,
type: DatabaseSerializedFieldType.FIELD_TRAVERSER,
schema_valid: schemaValid,
chain: traverser.chain,
});
}
}
return fieldOutput;
}
@@ -0,0 +1,61 @@
// TypeScript translation of posthog/hogql/errors.py
import type { Expr } from "./ast";
export class BaseTSQLError extends Error {
message: string;
start?: number;
end?: number;
constructor(
message: string,
options?: {
start?: number;
end?: number;
node?: Expr;
}
) {
super(message);
this.message = message;
if (options?.node && options.node.start !== undefined && options.node.end !== undefined) {
this.start = options.node.start;
this.end = options.node.end;
} else {
this.start = options?.start;
this.end = options?.end;
}
}
}
export class ExposedTSQLError extends BaseTSQLError {
/** An exception that can be exposed to the user. */
}
export class InternalTSQLError extends BaseTSQLError {
/** An internal exception in the TSQL engine. */
}
export class SyntaxError extends ExposedTSQLError {
/** The input does not conform to TSQL syntax. */
}
export class QueryError extends ExposedTSQLError {
/** The query is invalid, though correct syntactically. */
}
export class NotImplementedError extends InternalTSQLError {
/** This feature isn't implemented in TSQL (yet). */
}
export class ParsingError extends InternalTSQLError {
/** Parsing failed. */
}
export class ImpossibleASTError extends InternalTSQLError {
/** Parsing or resolution resulted in an impossible AST. */
}
export class ResolutionError extends InternalTSQLError {
/** Resolution of a table/field/expression failed. */
}
@@ -0,0 +1,241 @@
import { describe, it, expect } from "vitest";
import {
escapeClickHouseIdentifier,
escapeTSQLIdentifier,
escapeClickHouseString,
escapeTSQLString,
getClickHouseType,
SQLValueEscaper,
safeIdentifier,
} from "./escape.js";
import { QueryError } from "./errors.js";
describe("escapeClickHouseIdentifier", () => {
it("should pass through simple identifiers", () => {
expect(escapeClickHouseIdentifier("id")).toBe("id");
expect(escapeClickHouseIdentifier("user_name")).toBe("user_name");
expect(escapeClickHouseIdentifier("Column1")).toBe("Column1");
expect(escapeClickHouseIdentifier("_private")).toBe("_private");
});
it("should escape identifiers with special characters", () => {
expect(escapeClickHouseIdentifier("my column")).toBe("`my column`");
expect(escapeClickHouseIdentifier("table-name")).toBe("`table-name`");
expect(escapeClickHouseIdentifier("column.with.dots")).toBe("`column.with.dots`");
});
it("should escape identifiers starting with numbers", () => {
expect(escapeClickHouseIdentifier("1column")).toBe("`1column`");
expect(escapeClickHouseIdentifier("123")).toBe("`123`");
});
it("should escape backticks in identifiers", () => {
expect(escapeClickHouseIdentifier("column`name")).toBe("`column\\`name`");
});
it("should escape control characters", () => {
expect(escapeClickHouseIdentifier("col\nname")).toBe("`col\\nname`");
expect(escapeClickHouseIdentifier("col\tname")).toBe("`col\\tname`");
});
it("should throw for identifiers containing %", () => {
expect(() => escapeClickHouseIdentifier("column%name")).toThrow(QueryError);
});
});
describe("escapeTSQLIdentifier", () => {
it("should pass through simple identifiers", () => {
expect(escapeTSQLIdentifier("id")).toBe("id");
expect(escapeTSQLIdentifier("user_name")).toBe("user_name");
});
it("should allow dollar signs in identifiers", () => {
expect(escapeTSQLIdentifier("$property")).toBe("$property");
expect(escapeTSQLIdentifier("property$value")).toBe("property$value");
});
it("should handle numeric identifiers", () => {
expect(escapeTSQLIdentifier(0)).toBe("0");
expect(escapeTSQLIdentifier(123)).toBe("123");
});
it("should throw for identifiers containing %", () => {
expect(() => escapeTSQLIdentifier("column%name")).toThrow(QueryError);
});
});
describe("SQLValueEscaper", () => {
describe("ClickHouse dialect", () => {
const escaper = new SQLValueEscaper({ dialect: "clickhouse" });
it("should escape null", () => {
expect(escaper.visit(null)).toBe("NULL");
expect(escaper.visit(undefined)).toBe("NULL");
});
it("should escape booleans as numbers", () => {
expect(escaper.visit(true)).toBe("1");
expect(escaper.visit(false)).toBe("0");
});
it("should escape integers", () => {
expect(escaper.visit(0)).toBe("0");
expect(escaper.visit(42)).toBe("42");
expect(escaper.visit(-100)).toBe("-100");
});
it("should escape floats", () => {
expect(escaper.visit(3.14)).toBe("3.14");
expect(escaper.visit(-0.5)).toBe("-0.5");
});
it("should escape special floats", () => {
expect(escaper.visit(NaN)).toBe("NaN");
expect(escaper.visit(Infinity)).toBe("Inf");
expect(escaper.visit(-Infinity)).toBe("-Inf");
});
it("should escape strings with quotes", () => {
expect(escaper.visit("hello")).toBe("'hello'");
expect(escaper.visit("hello'world")).toBe("'hello\\'world'");
});
it("should escape strings with control characters", () => {
expect(escaper.visit("line1\nline2")).toBe("'line1\\nline2'");
expect(escaper.visit("col1\tcol2")).toBe("'col1\\tcol2'");
});
it("should escape arrays", () => {
expect(escaper.visit([1, 2, 3])).toBe("[1, 2, 3]");
expect(escaper.visit(["a", "b"])).toBe("['a', 'b']");
expect(escaper.visit(["hello", "world"])).toBe("['hello', 'world']");
});
it("should escape nested arrays", () => {
expect(
escaper.visit([
[1, 2],
[3, 4],
])
).toBe("[[1, 2], [3, 4]]");
});
it("should escape dates with toDateTime64 and default UTC timezone", () => {
const date = new Date("2024-01-15T10:30:00.500Z");
const result = escaper.visit(date);
expect(result).toBe("toDateTime64('2024-01-15 10:30:00.500000', 6, 'UTC')");
});
it("should escape dates with custom timezone", () => {
const escaperWithTz = new SQLValueEscaper({
dialect: "clickhouse",
timezone: "America/New_York",
});
const date = new Date("2024-01-15T10:30:00.500Z");
const result = escaperWithTz.visit(date);
expect(result).toBe("toDateTime64('2024-01-15 10:30:00.500000', 6, 'America/New_York')");
});
});
describe("TSQL dialect", () => {
const escaper = new SQLValueEscaper({ dialect: "tsql" });
it("should escape booleans as keywords", () => {
expect(escaper.visit(true)).toBe("true");
expect(escaper.visit(false)).toBe("false");
});
it("should escape dates with toDateTime and default UTC timezone", () => {
const date = new Date("2024-01-15T10:30:00.500Z");
const result = escaper.visit(date);
expect(result).toBe("toDateTime('2024-01-15 10:30:00.500000', 'UTC')");
});
it("should escape dates with custom timezone", () => {
const escaperWithTz = new SQLValueEscaper({
dialect: "tsql",
timezone: "Europe/London",
});
const date = new Date("2024-01-15T10:30:00.500Z");
const result = escaperWithTz.visit(date);
expect(result).toBe("toDateTime('2024-01-15 10:30:00.500000', 'Europe/London')");
});
});
});
describe("escapeClickHouseString", () => {
it("should escape string values", () => {
expect(escapeClickHouseString("test")).toBe("'test'");
});
it("should handle null", () => {
expect(escapeClickHouseString(null)).toBe("NULL");
});
it("should handle numbers", () => {
expect(escapeClickHouseString(42)).toBe("42");
});
});
describe("escapeTSQLString", () => {
it("should escape string values", () => {
expect(escapeTSQLString("test")).toBe("'test'");
});
it("should handle booleans differently from ClickHouse", () => {
expect(escapeTSQLString(true)).toBe("true");
expect(escapeTSQLString(false)).toBe("false");
});
});
describe("getClickHouseType", () => {
it("should return String for strings", () => {
expect(getClickHouseType("hello")).toBe("String");
});
it("should return UInt8 for booleans", () => {
expect(getClickHouseType(true)).toBe("UInt8");
expect(getClickHouseType(false)).toBe("UInt8");
});
it("should return Int32 for small integers", () => {
expect(getClickHouseType(42)).toBe("Int32");
expect(getClickHouseType(-100)).toBe("Int32");
});
it("should return Int64 for large integers", () => {
expect(getClickHouseType(3000000000)).toBe("Int64");
expect(getClickHouseType(-3000000000)).toBe("Int64");
});
it("should return Float64 for floats", () => {
expect(getClickHouseType(3.14)).toBe("Float64");
});
it("should return DateTime64(6) for dates", () => {
expect(getClickHouseType(new Date())).toBe("DateTime64(6)");
});
it("should return Array type for arrays", () => {
expect(getClickHouseType(["a", "b"])).toBe("Array(String)");
expect(getClickHouseType([1, 2])).toBe("Array(Int32)");
expect(getClickHouseType([])).toBe("Array(String)");
});
it("should return Nullable(String) for null", () => {
expect(getClickHouseType(null)).toBe("Nullable(String)");
expect(getClickHouseType(undefined)).toBe("Nullable(String)");
});
});
describe("safeIdentifier", () => {
it("should return identifier unchanged if no %", () => {
expect(safeIdentifier("column")).toBe("column");
expect(safeIdentifier("table_name")).toBe("table_name");
});
it("should remove % characters", () => {
expect(safeIdentifier("column%name")).toBe("columnname");
expect(safeIdentifier("%%test%%")).toBe("test");
});
});
+265
View File
@@ -0,0 +1,265 @@
// TypeScript port of posthog/hogql/escape_sql.py
// Keep this file in sync with the Python version
import { QueryError } from "./errors";
/**
* Character escape maps for ClickHouse string escaping
* Copied from clickhouse_driver.util.escape
*
* Note: In JavaScript, \a and \v are not recognized escape sequences like in Python.
* We use the actual ASCII codes: \x07 for bell (Python's \a) and \x0B for vertical tab.
*/
const escapeCharsMap: Record<string, string> = {
"\b": "\\b",
"\f": "\\f",
"\r": "\\r",
"\n": "\\n",
"\t": "\\t",
"\0": "\\0",
"\x07": "\\a", // Bell character (ASCII 7) - Python's \a
"\x0B": "\\v", // Vertical tab (ASCII 11) - use explicit code since JS \v may not work in all contexts
"\\": "\\\\",
};
const singlequoteEscapeCharsMap: Record<string, string> = {
...escapeCharsMap,
"'": "\\'",
};
const backquoteEscapeCharsMap: Record<string, string> = {
...escapeCharsMap,
"`": "\\`",
};
/**
* Sanitize an identifier by removing % characters
*/
export function safeIdentifier(identifier: string): string {
if (identifier.includes("%")) {
return identifier.replace(/%/g, "");
}
return identifier;
}
/**
* Escape a string value for use as a parameter in ClickHouse
* Copied from clickhouse_driver.util.escape_param
*/
export function escapeParamClickhouse(value: string): string {
const escaped = value
.split("")
.map((c) => singlequoteEscapeCharsMap[c] || c)
.join("");
return `'${escaped}'`;
}
/**
* Escape an identifier for use in TSQL/HogQL queries
* Adapted from clickhouse_driver.util.escape with support for $ in identifiers
*/
export function escapeTSQLIdentifier(identifier: string | number): string {
if (typeof identifier === "number") {
// In TSQL we allow integers as identifiers to access array elements
return String(identifier);
}
if (identifier.includes("%")) {
throw new QueryError(
`The TSQL identifier "${identifier}" is not permitted as it contains the "%" character`
);
}
// TSQL allows dollars in the identifier (same regex as frontend escapePropertyAsTSQLIdentifier)
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier)) {
return identifier;
}
const escaped = identifier
.split("")
.map((c) => backquoteEscapeCharsMap[c] || c)
.join("");
return `\`${escaped}\``;
}
/**
* Escape an identifier for use in ClickHouse queries
* Copied from clickhouse_driver.util.escape, adapted from single quotes to backquotes
*/
export function escapeClickHouseIdentifier(identifier: string): string {
if (identifier.includes("%")) {
throw new QueryError(
`The identifier "${identifier}" is not permitted as it contains the "%" character`
);
}
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
return identifier;
}
const escaped = identifier
.split("")
.map((c) => backquoteEscapeCharsMap[c] || c)
.join("");
return `\`${escaped}\``;
}
/**
* Type for values that can be escaped as SQL strings
*/
export type EscapableValue =
| null
| undefined
| string
| number
| boolean
| Date
| EscapableValue[]
| [EscapableValue, ...EscapableValue[]];
/**
* SQL Value Escaper class that handles different types of values
* Port of SQLValueEscaper from escape_sql.py
*/
export class SQLValueEscaper {
private timezone: string;
private dialect: "tsql" | "clickhouse";
constructor(options: { timezone?: string; dialect?: "tsql" | "clickhouse" } = {}) {
this.timezone = options.timezone || "UTC";
this.dialect = options.dialect || "clickhouse";
}
visit(value: EscapableValue): string {
if (value === null || value === undefined) {
return this.visitNull();
}
if (typeof value === "string") {
return this.visitString(value);
}
if (typeof value === "boolean") {
return this.visitBoolean(value);
}
if (typeof value === "number") {
if (Number.isInteger(value)) {
return this.visitInt(value);
}
return this.visitFloat(value);
}
if (value instanceof Date) {
return this.visitDateTime(value);
}
if (Array.isArray(value)) {
return this.visitArray(value);
}
throw new QueryError(`SQLValueEscaper cannot handle value of type ${typeof value}`);
}
private visitNull(): string {
return "NULL";
}
private visitString(value: string): string {
return escapeParamClickhouse(value);
}
private visitBoolean(value: boolean): string {
if (this.dialect === "clickhouse") {
return value ? "1" : "0";
}
return value ? "true" : "false";
}
private visitInt(value: number): string {
return String(value);
}
private visitFloat(value: number): string {
if (Number.isNaN(value)) {
return "NaN";
}
if (!Number.isFinite(value)) {
return value < 0 ? "-Inf" : "Inf";
}
return String(value);
}
private visitDateTime(value: Date): string {
// Format: YYYY-MM-DD HH:MM:SS.ffffff
const pad = (n: number, len: number = 2) => String(n).padStart(len, "0");
const year = value.getUTCFullYear();
const month = pad(value.getUTCMonth() + 1);
const day = pad(value.getUTCDate());
const hours = pad(value.getUTCHours());
const minutes = pad(value.getUTCMinutes());
const seconds = pad(value.getUTCSeconds());
const ms = pad(value.getUTCMilliseconds(), 3);
const datetimeString = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}000`;
if (this.dialect === "tsql") {
return `toDateTime(${this.visitString(datetimeString)}, ${this.visitString(this.timezone)})`;
}
return `toDateTime64(${this.visitString(datetimeString)}, 6, ${this.visitString(
this.timezone
)})`;
}
private visitArray(value: EscapableValue[]): string {
return `[${value.map((x) => this.visit(x)).join(", ")}]`;
}
}
/**
* Escape a value for use in a TSQL/HogQL query string
*/
export function escapeTSQLString(value: EscapableValue, timezone?: string): string {
return new SQLValueEscaper({ timezone, dialect: "tsql" }).visit(value);
}
/**
* Escape a value for use in a ClickHouse query string
*/
export function escapeClickHouseString(value: EscapableValue, timezone?: string): string {
return new SQLValueEscaper({ timezone, dialect: "clickhouse" }).visit(value);
}
/**
* Get the ClickHouse type string for a value
* Used when creating parameterized query placeholders like {param: Type}
*/
export function getClickHouseType(value: unknown): string {
if (value === null || value === undefined) {
return "Nullable(String)";
}
if (typeof value === "string") {
return "String";
}
if (typeof value === "boolean") {
return "UInt8";
}
if (typeof value === "number") {
if (Number.isInteger(value)) {
// Use Int64 for large integers, Int32 for smaller ones
if (value > 2147483647 || value < -2147483648) {
return "Int64";
}
return "Int32";
}
return "Float64";
}
if (value instanceof Date) {
return "DateTime64(6)";
}
if (Array.isArray(value)) {
if (value.length === 0) {
return "Array(String)";
}
const itemType = getClickHouseType(value[0]);
return `Array(${itemType})`;
}
// Default to String for unknown types
return "String";
}
@@ -0,0 +1,789 @@
// TypeScript port of posthog/hogql/functions/mapping.py and aggregations.py
// Keep this file in sync with the Python version
import { CompareOperationOp } from "./ast";
/**
* Metadata for a TSQL function
*/
export interface TSQLFunctionMeta {
/** The ClickHouse function name to use */
clickhouseName: string;
/** Minimum number of arguments */
minArgs: number;
/** Maximum number of arguments (undefined means unlimited) */
maxArgs?: number;
/** Minimum number of parameters (for parametric functions) */
minParams?: number;
/** Maximum number of parameters */
maxParams?: number;
/** Whether this is an aggregate function */
aggregate?: boolean;
/** Whether function is case-sensitive */
caseSensitive?: boolean;
/** Whether function is timezone-aware (will append timezone as last arg) */
tzAware?: boolean;
/** Whether the function uses placeholder arguments like {} */
usingPlaceholderArguments?: boolean;
}
/**
* Comparison function mappings from function names to CompareOperationOp
*/
export const TSQL_COMPARISON_MAPPING: Record<string, CompareOperationOp> = {
equals: CompareOperationOp.Eq,
notEquals: CompareOperationOp.NotEq,
less: CompareOperationOp.Lt,
greater: CompareOperationOp.Gt,
lessOrEquals: CompareOperationOp.LtEq,
greaterOrEquals: CompareOperationOp.GtEq,
like: CompareOperationOp.Like,
ilike: CompareOperationOp.ILike,
notLike: CompareOperationOp.NotLike,
notILike: CompareOperationOp.NotILike,
in: CompareOperationOp.In,
notIn: CompareOperationOp.NotIn,
};
/**
* ClickHouse functions available in TSQL
* Port of HOGQL_CLICKHOUSE_FUNCTIONS from mapping.py
*/
export const TSQL_CLICKHOUSE_FUNCTIONS: Record<string, TSQLFunctionMeta> = {
// Comparison
equals: { clickhouseName: "equals", minArgs: 2, maxArgs: 2 },
notEquals: { clickhouseName: "notEquals", minArgs: 2, maxArgs: 2 },
less: { clickhouseName: "less", minArgs: 2, maxArgs: 2 },
greater: { clickhouseName: "greater", minArgs: 2, maxArgs: 2 },
lessOrEquals: { clickhouseName: "lessOrEquals", minArgs: 2, maxArgs: 2 },
greaterOrEquals: { clickhouseName: "greaterOrEquals", minArgs: 2, maxArgs: 2 },
// Logical
and: { clickhouseName: "and", minArgs: 2 },
or: { clickhouseName: "or", minArgs: 2 },
xor: { clickhouseName: "xor", minArgs: 2 },
not: { clickhouseName: "not", minArgs: 1, maxArgs: 1, caseSensitive: false },
// Conditional
if: { clickhouseName: "if", minArgs: 3, maxArgs: 3, caseSensitive: false },
multiIf: { clickhouseName: "multiIf", minArgs: 3 },
// In
in: { clickhouseName: "in", minArgs: 2, maxArgs: 2 },
notIn: { clickhouseName: "notIn", minArgs: 2, maxArgs: 2 },
// Arithmetic
plus: { clickhouseName: "plus", minArgs: 2, maxArgs: 2 },
minus: { clickhouseName: "minus", minArgs: 2, maxArgs: 2 },
multiply: { clickhouseName: "multiply", minArgs: 2, maxArgs: 2 },
divide: { clickhouseName: "divide", minArgs: 2, maxArgs: 2 },
intDiv: { clickhouseName: "intDiv", minArgs: 2, maxArgs: 2 },
intDivOrZero: { clickhouseName: "intDivOrZero", minArgs: 2, maxArgs: 2 },
modulo: { clickhouseName: "modulo", minArgs: 2, maxArgs: 2 },
moduloOrZero: { clickhouseName: "moduloOrZero", minArgs: 2, maxArgs: 2 },
positiveModulo: { clickhouseName: "positiveModulo", minArgs: 2, maxArgs: 2 },
negate: { clickhouseName: "negate", minArgs: 1, maxArgs: 1 },
abs: { clickhouseName: "abs", minArgs: 1, maxArgs: 1 },
gcd: { clickhouseName: "gcd", minArgs: 2, maxArgs: 2 },
lcm: { clickhouseName: "lcm", minArgs: 2, maxArgs: 2 },
// Mathematical
exp: { clickhouseName: "exp", minArgs: 1, maxArgs: 1 },
log: { clickhouseName: "log", minArgs: 1, maxArgs: 1 },
ln: { clickhouseName: "log", minArgs: 1, maxArgs: 1 },
exp2: { clickhouseName: "exp2", minArgs: 1, maxArgs: 1 },
log2: { clickhouseName: "log2", minArgs: 1, maxArgs: 1 },
exp10: { clickhouseName: "exp10", minArgs: 1, maxArgs: 1 },
log10: { clickhouseName: "log10", minArgs: 1, maxArgs: 1 },
sqrt: { clickhouseName: "sqrt", minArgs: 1, maxArgs: 1 },
cbrt: { clickhouseName: "cbrt", minArgs: 1, maxArgs: 1 },
erf: { clickhouseName: "erf", minArgs: 1, maxArgs: 1 },
erfc: { clickhouseName: "erfc", minArgs: 1, maxArgs: 1 },
lgamma: { clickhouseName: "lgamma", minArgs: 1, maxArgs: 1 },
tgamma: { clickhouseName: "tgamma", minArgs: 1, maxArgs: 1 },
sin: { clickhouseName: "sin", minArgs: 1, maxArgs: 1 },
cos: { clickhouseName: "cos", minArgs: 1, maxArgs: 1 },
tan: { clickhouseName: "tan", minArgs: 1, maxArgs: 1 },
asin: { clickhouseName: "asin", minArgs: 1, maxArgs: 1 },
acos: { clickhouseName: "acos", minArgs: 1, maxArgs: 1 },
atan: { clickhouseName: "atan", minArgs: 1, maxArgs: 1 },
pow: { clickhouseName: "pow", minArgs: 2, maxArgs: 2 },
power: { clickhouseName: "power", minArgs: 2, maxArgs: 2 },
round: { clickhouseName: "round", minArgs: 1, maxArgs: 2 },
floor: { clickhouseName: "floor", minArgs: 1, maxArgs: 2 },
ceil: { clickhouseName: "ceil", minArgs: 1, maxArgs: 2 },
ceiling: { clickhouseName: "ceiling", minArgs: 1, maxArgs: 2 },
trunc: { clickhouseName: "trunc", minArgs: 1, maxArgs: 2 },
truncate: { clickhouseName: "truncate", minArgs: 1, maxArgs: 2 },
sign: { clickhouseName: "sign", minArgs: 1, maxArgs: 1 },
// String functions
empty: { clickhouseName: "empty", minArgs: 1, maxArgs: 1 },
notEmpty: { clickhouseName: "notEmpty", minArgs: 1, maxArgs: 1 },
length: { clickhouseName: "length", minArgs: 1, maxArgs: 1 },
lengthUTF8: { clickhouseName: "lengthUTF8", minArgs: 1, maxArgs: 1 },
char_length: { clickhouseName: "char_length", minArgs: 1, maxArgs: 1 },
character_length: { clickhouseName: "character_length", minArgs: 1, maxArgs: 1 },
lower: { clickhouseName: "lower", minArgs: 1, maxArgs: 1 },
upper: { clickhouseName: "upper", minArgs: 1, maxArgs: 1 },
lowerUTF8: { clickhouseName: "lowerUTF8", minArgs: 1, maxArgs: 1 },
upperUTF8: { clickhouseName: "upperUTF8", minArgs: 1, maxArgs: 1 },
reverse: { clickhouseName: "reverse", minArgs: 1, maxArgs: 1 },
reverseUTF8: { clickhouseName: "reverseUTF8", minArgs: 1, maxArgs: 1 },
concat: { clickhouseName: "concat", minArgs: 1 },
concatAssumeInjective: { clickhouseName: "concatAssumeInjective", minArgs: 1 },
substring: { clickhouseName: "substring", minArgs: 2, maxArgs: 3 },
substr: { clickhouseName: "substring", minArgs: 2, maxArgs: 3 },
mid: { clickhouseName: "substring", minArgs: 2, maxArgs: 3 },
substringUTF8: { clickhouseName: "substringUTF8", minArgs: 2, maxArgs: 3 },
appendTrailingCharIfAbsent: {
clickhouseName: "appendTrailingCharIfAbsent",
minArgs: 2,
maxArgs: 2,
},
convertCharset: { clickhouseName: "convertCharset", minArgs: 3, maxArgs: 3 },
base58Encode: { clickhouseName: "base58Encode", minArgs: 1, maxArgs: 1 },
base58Decode: { clickhouseName: "base58Decode", minArgs: 1, maxArgs: 1 },
base64Encode: { clickhouseName: "base64Encode", minArgs: 1, maxArgs: 1 },
base64Decode: { clickhouseName: "base64Decode", minArgs: 1, maxArgs: 1 },
tryBase64Decode: { clickhouseName: "tryBase64Decode", minArgs: 1, maxArgs: 1 },
endsWith: { clickhouseName: "endsWith", minArgs: 2, maxArgs: 2 },
startsWith: { clickhouseName: "startsWith", minArgs: 2, maxArgs: 2 },
trim: { clickhouseName: "trim", minArgs: 1, maxArgs: 2 },
trimLeft: { clickhouseName: "trimLeft", minArgs: 1, maxArgs: 2 },
trimRight: { clickhouseName: "trimRight", minArgs: 1, maxArgs: 2 },
ltrim: { clickhouseName: "trimLeft", minArgs: 1, maxArgs: 1 },
rtrim: { clickhouseName: "trimRight", minArgs: 1, maxArgs: 1 },
leftPad: { clickhouseName: "leftPad", minArgs: 2, maxArgs: 3 },
rightPad: { clickhouseName: "rightPad", minArgs: 2, maxArgs: 3 },
leftPadUTF8: { clickhouseName: "leftPadUTF8", minArgs: 2, maxArgs: 3 },
rightPadUTF8: { clickhouseName: "rightPadUTF8", minArgs: 2, maxArgs: 3 },
left: { clickhouseName: "left", minArgs: 2, maxArgs: 2 },
right: { clickhouseName: "right", minArgs: 2, maxArgs: 2 },
repeat: { clickhouseName: "repeat", minArgs: 2, maxArgs: 2 },
space: { clickhouseName: "space", minArgs: 1, maxArgs: 1 },
replace: { clickhouseName: "replace", minArgs: 3, maxArgs: 3 },
replaceOne: { clickhouseName: "replaceOne", minArgs: 3, maxArgs: 3 },
replaceAll: { clickhouseName: "replaceAll", minArgs: 3, maxArgs: 3 },
replaceRegexpOne: { clickhouseName: "replaceRegexpOne", minArgs: 3, maxArgs: 3 },
replaceRegexpAll: { clickhouseName: "replaceRegexpAll", minArgs: 3, maxArgs: 3 },
position: { clickhouseName: "position", minArgs: 2, maxArgs: 2 },
positionCaseInsensitive: { clickhouseName: "positionCaseInsensitive", minArgs: 2, maxArgs: 2 },
positionUTF8: { clickhouseName: "positionUTF8", minArgs: 2, maxArgs: 2 },
positionCaseInsensitiveUTF8: {
clickhouseName: "positionCaseInsensitiveUTF8",
minArgs: 2,
maxArgs: 2,
},
locate: { clickhouseName: "locate", minArgs: 2, maxArgs: 2 },
match: { clickhouseName: "match", minArgs: 2, maxArgs: 2 },
multiMatchAny: { clickhouseName: "multiMatchAny", minArgs: 2, maxArgs: 2 },
multiMatchAnyIndex: { clickhouseName: "multiMatchAnyIndex", minArgs: 2, maxArgs: 2 },
multiMatchAllIndices: { clickhouseName: "multiMatchAllIndices", minArgs: 2, maxArgs: 2 },
multiSearchFirstPosition: { clickhouseName: "multiSearchFirstPosition", minArgs: 2, maxArgs: 2 },
multiSearchFirstIndex: { clickhouseName: "multiSearchFirstIndex", minArgs: 2, maxArgs: 2 },
multiSearchAny: { clickhouseName: "multiSearchAny", minArgs: 2, maxArgs: 2 },
extract: { clickhouseName: "extract", minArgs: 2, maxArgs: 2 },
extractAll: { clickhouseName: "extractAll", minArgs: 2, maxArgs: 2 },
extractAllGroupsHorizontal: {
clickhouseName: "extractAllGroupsHorizontal",
minArgs: 2,
maxArgs: 2,
},
extractAllGroupsVertical: { clickhouseName: "extractAllGroupsVertical", minArgs: 2, maxArgs: 2 },
like: { clickhouseName: "like", minArgs: 2, maxArgs: 2 },
ilike: { clickhouseName: "ilike", minArgs: 2, maxArgs: 2 },
notLike: { clickhouseName: "notLike", minArgs: 2, maxArgs: 2 },
notILike: { clickhouseName: "notILike", minArgs: 2, maxArgs: 2 },
splitByChar: { clickhouseName: "splitByChar", minArgs: 2, maxArgs: 3 },
splitByString: { clickhouseName: "splitByString", minArgs: 2, maxArgs: 3 },
splitByRegexp: { clickhouseName: "splitByRegexp", minArgs: 2, maxArgs: 3 },
arrayStringConcat: { clickhouseName: "arrayStringConcat", minArgs: 1, maxArgs: 2 },
format: { clickhouseName: "format", minArgs: 1 },
coalesce: { clickhouseName: "coalesce", minArgs: 1 },
ifNull: { clickhouseName: "ifNull", minArgs: 2, maxArgs: 2 },
nullIf: { clickhouseName: "nullIf", minArgs: 2, maxArgs: 2 },
assumeNotNull: { clickhouseName: "assumeNotNull", minArgs: 1, maxArgs: 1 },
toNullable: { clickhouseName: "toNullable", minArgs: 1, maxArgs: 1 },
isNull: { clickhouseName: "isNull", minArgs: 1, maxArgs: 1 },
isNotNull: { clickhouseName: "isNotNull", minArgs: 1, maxArgs: 1 },
// Type conversions
toString: { clickhouseName: "toString", minArgs: 1, maxArgs: 1 },
toFixedString: { clickhouseName: "toFixedString", minArgs: 2, maxArgs: 2 },
toUInt8: { clickhouseName: "toUInt8", minArgs: 1, maxArgs: 1 },
toUInt16: { clickhouseName: "toUInt16", minArgs: 1, maxArgs: 1 },
toUInt32: { clickhouseName: "toUInt32", minArgs: 1, maxArgs: 1 },
toUInt64: { clickhouseName: "toUInt64", minArgs: 1, maxArgs: 1 },
toInt8: { clickhouseName: "toInt8", minArgs: 1, maxArgs: 1 },
toInt16: { clickhouseName: "toInt16", minArgs: 1, maxArgs: 1 },
toInt32: { clickhouseName: "toInt32", minArgs: 1, maxArgs: 1 },
toInt64: { clickhouseName: "toInt64", minArgs: 1, maxArgs: 1 },
toInt128: { clickhouseName: "toInt128", minArgs: 1, maxArgs: 1 },
toInt256: { clickhouseName: "toInt256", minArgs: 1, maxArgs: 1 },
toUInt128: { clickhouseName: "toUInt128", minArgs: 1, maxArgs: 1 },
toUInt256: { clickhouseName: "toUInt256", minArgs: 1, maxArgs: 1 },
toFloat32: { clickhouseName: "toFloat32", minArgs: 1, maxArgs: 1 },
toFloat64: { clickhouseName: "toFloat64", minArgs: 1, maxArgs: 1 },
toDecimal32: { clickhouseName: "toDecimal32", minArgs: 2, maxArgs: 2 },
toDecimal64: { clickhouseName: "toDecimal64", minArgs: 2, maxArgs: 2 },
toDecimal128: { clickhouseName: "toDecimal128", minArgs: 2, maxArgs: 2 },
toDecimal256: { clickhouseName: "toDecimal256", minArgs: 2, maxArgs: 2 },
toDate: { clickhouseName: "toDate", minArgs: 1, maxArgs: 2 },
toDateOrNull: { clickhouseName: "toDateOrNull", minArgs: 1, maxArgs: 2 },
toDateOrZero: { clickhouseName: "toDateOrZero", minArgs: 1, maxArgs: 2 },
toDate32: { clickhouseName: "toDate32", minArgs: 1, maxArgs: 2 },
toDate32OrNull: { clickhouseName: "toDate32OrNull", minArgs: 1, maxArgs: 2 },
toDate32OrZero: { clickhouseName: "toDate32OrZero", minArgs: 1, maxArgs: 2 },
toDateTime: { clickhouseName: "toDateTime", minArgs: 1, maxArgs: 2 },
toDateTimeOrNull: { clickhouseName: "toDateTimeOrNull", minArgs: 1, maxArgs: 2 },
toDateTimeOrZero: { clickhouseName: "toDateTimeOrZero", minArgs: 1, maxArgs: 2 },
toDateTime64: { clickhouseName: "toDateTime64", minArgs: 1, maxArgs: 3 },
toDateTime64OrNull: { clickhouseName: "toDateTime64OrNull", minArgs: 1, maxArgs: 3 },
toDateTime64OrZero: { clickhouseName: "toDateTime64OrZero", minArgs: 1, maxArgs: 3 },
toUUID: { clickhouseName: "toUUID", minArgs: 1, maxArgs: 1 },
toUUIDOrNull: { clickhouseName: "toUUIDOrNull", minArgs: 1, maxArgs: 1 },
toUUIDOrZero: { clickhouseName: "toUUIDOrZero", minArgs: 1, maxArgs: 1 },
toTypeName: { clickhouseName: "toTypeName", minArgs: 1, maxArgs: 1 },
// Date/time functions
now: { clickhouseName: "now", minArgs: 0, maxArgs: 1, tzAware: true },
now64: { clickhouseName: "now64", minArgs: 0, maxArgs: 2, tzAware: true },
today: { clickhouseName: "today", minArgs: 0, maxArgs: 0 },
yesterday: { clickhouseName: "yesterday", minArgs: 0, maxArgs: 0 },
toYear: { clickhouseName: "toYear", minArgs: 1, maxArgs: 1 },
toQuarter: { clickhouseName: "toQuarter", minArgs: 1, maxArgs: 1 },
toMonth: { clickhouseName: "toMonth", minArgs: 1, maxArgs: 1 },
toDayOfYear: { clickhouseName: "toDayOfYear", minArgs: 1, maxArgs: 1 },
toDayOfMonth: { clickhouseName: "toDayOfMonth", minArgs: 1, maxArgs: 1 },
toDayOfWeek: { clickhouseName: "toDayOfWeek", minArgs: 1, maxArgs: 3 },
toHour: { clickhouseName: "toHour", minArgs: 1, maxArgs: 1 },
toMinute: { clickhouseName: "toMinute", minArgs: 1, maxArgs: 1 },
toSecond: { clickhouseName: "toSecond", minArgs: 1, maxArgs: 1 },
toUnixTimestamp: { clickhouseName: "toUnixTimestamp", minArgs: 1, maxArgs: 2 },
toStartOfYear: { clickhouseName: "toStartOfYear", minArgs: 1, maxArgs: 1 },
toStartOfQuarter: { clickhouseName: "toStartOfQuarter", minArgs: 1, maxArgs: 1 },
toStartOfMonth: { clickhouseName: "toStartOfMonth", minArgs: 1, maxArgs: 1 },
toMonday: { clickhouseName: "toMonday", minArgs: 1, maxArgs: 1 },
toStartOfWeek: { clickhouseName: "toStartOfWeek", minArgs: 1, maxArgs: 2 },
toStartOfDay: { clickhouseName: "toStartOfDay", minArgs: 1, maxArgs: 1 },
toStartOfHour: { clickhouseName: "toStartOfHour", minArgs: 1, maxArgs: 1 },
toStartOfMinute: { clickhouseName: "toStartOfMinute", minArgs: 1, maxArgs: 1 },
toStartOfSecond: { clickhouseName: "toStartOfSecond", minArgs: 1, maxArgs: 1 },
toStartOfFiveMinutes: { clickhouseName: "toStartOfFiveMinutes", minArgs: 1, maxArgs: 1 },
toStartOfTenMinutes: { clickhouseName: "toStartOfTenMinutes", minArgs: 1, maxArgs: 1 },
toStartOfFifteenMinutes: { clickhouseName: "toStartOfFifteenMinutes", minArgs: 1, maxArgs: 1 },
toStartOfInterval: { clickhouseName: "toStartOfInterval", minArgs: 2, maxArgs: 4 },
toTime: { clickhouseName: "toTime", minArgs: 1, maxArgs: 2 },
toISOYear: { clickhouseName: "toISOYear", minArgs: 1, maxArgs: 1 },
toISOWeek: { clickhouseName: "toISOWeek", minArgs: 1, maxArgs: 1 },
toWeek: { clickhouseName: "toWeek", minArgs: 1, maxArgs: 3 },
toYearWeek: { clickhouseName: "toYearWeek", minArgs: 1, maxArgs: 3 },
date_add: { clickhouseName: "date_add", minArgs: 3, maxArgs: 3 },
date_diff: { clickhouseName: "date_diff", minArgs: 3, maxArgs: 4 },
date_sub: { clickhouseName: "date_sub", minArgs: 3, maxArgs: 3 },
date_trunc: { clickhouseName: "date_trunc", minArgs: 2, maxArgs: 3 },
dateDiff: { clickhouseName: "dateDiff", minArgs: 3, maxArgs: 4 },
dateAdd: { clickhouseName: "dateAdd", minArgs: 3, maxArgs: 3 },
dateSub: { clickhouseName: "dateSub", minArgs: 3, maxArgs: 3 },
dateTrunc: { clickhouseName: "dateTrunc", minArgs: 2, maxArgs: 3 },
addSeconds: { clickhouseName: "addSeconds", minArgs: 2, maxArgs: 2 },
addMinutes: { clickhouseName: "addMinutes", minArgs: 2, maxArgs: 2 },
addHours: { clickhouseName: "addHours", minArgs: 2, maxArgs: 2 },
addDays: { clickhouseName: "addDays", minArgs: 2, maxArgs: 2 },
addWeeks: { clickhouseName: "addWeeks", minArgs: 2, maxArgs: 2 },
addMonths: { clickhouseName: "addMonths", minArgs: 2, maxArgs: 2 },
addQuarters: { clickhouseName: "addQuarters", minArgs: 2, maxArgs: 2 },
addYears: { clickhouseName: "addYears", minArgs: 2, maxArgs: 2 },
subtractSeconds: { clickhouseName: "subtractSeconds", minArgs: 2, maxArgs: 2 },
subtractMinutes: { clickhouseName: "subtractMinutes", minArgs: 2, maxArgs: 2 },
subtractHours: { clickhouseName: "subtractHours", minArgs: 2, maxArgs: 2 },
subtractDays: { clickhouseName: "subtractDays", minArgs: 2, maxArgs: 2 },
subtractWeeks: { clickhouseName: "subtractWeeks", minArgs: 2, maxArgs: 2 },
subtractMonths: { clickhouseName: "subtractMonths", minArgs: 2, maxArgs: 2 },
subtractQuarters: { clickhouseName: "subtractQuarters", minArgs: 2, maxArgs: 2 },
subtractYears: { clickhouseName: "subtractYears", minArgs: 2, maxArgs: 2 },
toTimeZone: { clickhouseName: "toTimeZone", minArgs: 2, maxArgs: 2 },
formatDateTime: { clickhouseName: "formatDateTime", minArgs: 2, maxArgs: 3 },
parseDateTime: { clickhouseName: "parseDateTime", minArgs: 2, maxArgs: 3 },
parseDateTimeBestEffort: {
clickhouseName: "parseDateTimeBestEffort",
minArgs: 1,
maxArgs: 2,
tzAware: true,
},
parseDateTimeBestEffortOrNull: {
clickhouseName: "parseDateTimeBestEffortOrNull",
minArgs: 1,
maxArgs: 2,
tzAware: true,
},
parseDateTimeBestEffortOrZero: {
clickhouseName: "parseDateTimeBestEffortOrZero",
minArgs: 1,
maxArgs: 2,
tzAware: true,
},
parseDateTime64BestEffort: {
clickhouseName: "parseDateTime64BestEffort",
minArgs: 1,
maxArgs: 3,
tzAware: true,
},
parseDateTime64BestEffortOrNull: {
clickhouseName: "parseDateTime64BestEffortOrNull",
minArgs: 1,
maxArgs: 3,
tzAware: true,
},
parseDateTime64BestEffortOrZero: {
clickhouseName: "parseDateTime64BestEffortOrZero",
minArgs: 1,
maxArgs: 3,
tzAware: true,
},
// Interval functions
toIntervalSecond: { clickhouseName: "toIntervalSecond", minArgs: 1, maxArgs: 1 },
toIntervalMinute: { clickhouseName: "toIntervalMinute", minArgs: 1, maxArgs: 1 },
toIntervalHour: { clickhouseName: "toIntervalHour", minArgs: 1, maxArgs: 1 },
toIntervalDay: { clickhouseName: "toIntervalDay", minArgs: 1, maxArgs: 1 },
toIntervalWeek: { clickhouseName: "toIntervalWeek", minArgs: 1, maxArgs: 1 },
toIntervalMonth: { clickhouseName: "toIntervalMonth", minArgs: 1, maxArgs: 1 },
toIntervalQuarter: { clickhouseName: "toIntervalQuarter", minArgs: 1, maxArgs: 1 },
toIntervalYear: { clickhouseName: "toIntervalYear", minArgs: 1, maxArgs: 1 },
// Array functions
array: { clickhouseName: "array", minArgs: 0 },
range: { clickhouseName: "range", minArgs: 1, maxArgs: 3 },
arrayElement: { clickhouseName: "arrayElement", minArgs: 2, maxArgs: 2 },
has: { clickhouseName: "has", minArgs: 2, maxArgs: 2 },
hasAll: { clickhouseName: "hasAll", minArgs: 2, maxArgs: 2 },
hasAny: { clickhouseName: "hasAny", minArgs: 2, maxArgs: 2 },
hasSubstr: { clickhouseName: "hasSubstr", minArgs: 2, maxArgs: 2 },
indexOf: { clickhouseName: "indexOf", minArgs: 2, maxArgs: 2 },
arrayCount: { clickhouseName: "arrayCount", minArgs: 1, maxArgs: 2 },
countEqual: { clickhouseName: "countEqual", minArgs: 2, maxArgs: 2 },
arrayEnumerate: { clickhouseName: "arrayEnumerate", minArgs: 1, maxArgs: 1 },
arrayEnumerateDense: { clickhouseName: "arrayEnumerateDense", minArgs: 1 },
arrayEnumerateUniq: { clickhouseName: "arrayEnumerateUniq", minArgs: 1 },
arrayEnumerateUniqRanked: { clickhouseName: "arrayEnumerateUniqRanked", minArgs: 1 },
arrayPopBack: { clickhouseName: "arrayPopBack", minArgs: 1, maxArgs: 1 },
arrayPopFront: { clickhouseName: "arrayPopFront", minArgs: 1, maxArgs: 1 },
arrayPushBack: { clickhouseName: "arrayPushBack", minArgs: 2, maxArgs: 2 },
arrayPushFront: { clickhouseName: "arrayPushFront", minArgs: 2, maxArgs: 2 },
arrayResize: { clickhouseName: "arrayResize", minArgs: 2, maxArgs: 3 },
arraySlice: { clickhouseName: "arraySlice", minArgs: 2, maxArgs: 3 },
arraySort: { clickhouseName: "arraySort", minArgs: 1, maxArgs: 2 },
arrayPartialSort: { clickhouseName: "arrayPartialSort", minArgs: 2, maxArgs: 3 },
arrayReverseSort: { clickhouseName: "arrayReverseSort", minArgs: 1, maxArgs: 2 },
arrayPartialReverseSort: { clickhouseName: "arrayPartialReverseSort", minArgs: 2, maxArgs: 3 },
arrayShuffle: { clickhouseName: "arrayShuffle", minArgs: 1, maxArgs: 2 },
arrayUniq: { clickhouseName: "arrayUniq", minArgs: 1 },
arrayJoin: { clickhouseName: "arrayJoin", minArgs: 1, maxArgs: 1 },
arrayDifference: { clickhouseName: "arrayDifference", minArgs: 1, maxArgs: 1 },
arrayDistinct: { clickhouseName: "arrayDistinct", minArgs: 1, maxArgs: 1 },
arrayIntersect: { clickhouseName: "arrayIntersect", minArgs: 1 },
arrayReduce: { clickhouseName: "arrayReduce", minArgs: 2 },
arrayReverse: { clickhouseName: "arrayReverse", minArgs: 1, maxArgs: 1 },
arrayFlatten: { clickhouseName: "arrayFlatten", minArgs: 1, maxArgs: 1 },
arrayCompact: { clickhouseName: "arrayCompact", minArgs: 1, maxArgs: 1 },
arrayZip: { clickhouseName: "arrayZip", minArgs: 1 },
arrayMin: { clickhouseName: "arrayMin", minArgs: 1, maxArgs: 2 },
arrayMax: { clickhouseName: "arrayMax", minArgs: 1, maxArgs: 2 },
arraySum: { clickhouseName: "arraySum", minArgs: 1, maxArgs: 2 },
arrayAvg: { clickhouseName: "arrayAvg", minArgs: 1, maxArgs: 2 },
arrayCumSum: { clickhouseName: "arrayCumSum", minArgs: 1, maxArgs: 2 },
arrayCumSumNonNegative: { clickhouseName: "arrayCumSumNonNegative", minArgs: 1, maxArgs: 2 },
arrayProduct: { clickhouseName: "arrayProduct", minArgs: 1, maxArgs: 1 },
// JSON functions
JSONHas: { clickhouseName: "JSONHas", minArgs: 1 },
JSONLength: { clickhouseName: "JSONLength", minArgs: 1 },
JSONType: { clickhouseName: "JSONType", minArgs: 1 },
JSONExtractUInt: { clickhouseName: "JSONExtractUInt", minArgs: 1 },
JSONExtractInt: { clickhouseName: "JSONExtractInt", minArgs: 1 },
JSONExtractFloat: { clickhouseName: "JSONExtractFloat", minArgs: 1 },
JSONExtractBool: { clickhouseName: "JSONExtractBool", minArgs: 1 },
JSONExtractString: { clickhouseName: "JSONExtractString", minArgs: 1 },
JSONExtract: { clickhouseName: "JSONExtract", minArgs: 2 },
JSONExtractRaw: { clickhouseName: "JSONExtractRaw", minArgs: 1 },
JSONExtractArrayRaw: { clickhouseName: "JSONExtractArrayRaw", minArgs: 1 },
JSONExtractKeysAndValues: { clickhouseName: "JSONExtractKeysAndValues", minArgs: 2, maxArgs: 2 },
JSONExtractKeys: { clickhouseName: "JSONExtractKeys", minArgs: 1 },
toJSONString: { clickhouseName: "toJSONString", minArgs: 1, maxArgs: 1 },
// Tuple functions
tuple: { clickhouseName: "tuple", minArgs: 0 },
tupleElement: { clickhouseName: "tupleElement", minArgs: 2, maxArgs: 3 },
untuple: { clickhouseName: "untuple", minArgs: 1, maxArgs: 1 },
// Map functions
map: { clickhouseName: "map", minArgs: 0 },
mapFromArrays: { clickhouseName: "mapFromArrays", minArgs: 2, maxArgs: 2 },
mapContains: { clickhouseName: "mapContains", minArgs: 2, maxArgs: 2 },
mapKeys: { clickhouseName: "mapKeys", minArgs: 1, maxArgs: 1 },
mapValues: { clickhouseName: "mapValues", minArgs: 1, maxArgs: 1 },
// Hash functions
MD5: { clickhouseName: "MD5", minArgs: 1, maxArgs: 1 },
SHA1: { clickhouseName: "SHA1", minArgs: 1, maxArgs: 1 },
SHA224: { clickhouseName: "SHA224", minArgs: 1, maxArgs: 1 },
SHA256: { clickhouseName: "SHA256", minArgs: 1, maxArgs: 1 },
SHA384: { clickhouseName: "SHA384", minArgs: 1, maxArgs: 1 },
SHA512: { clickhouseName: "SHA512", minArgs: 1, maxArgs: 1 },
sipHash64: { clickhouseName: "sipHash64", minArgs: 1 },
sipHash128: { clickhouseName: "sipHash128", minArgs: 1 },
cityHash64: { clickhouseName: "cityHash64", minArgs: 1 },
intHash32: { clickhouseName: "intHash32", minArgs: 1, maxArgs: 1 },
intHash64: { clickhouseName: "intHash64", minArgs: 1, maxArgs: 1 },
farmHash64: { clickhouseName: "farmHash64", minArgs: 1 },
farmFingerprint64: { clickhouseName: "farmFingerprint64", minArgs: 1 },
xxHash32: { clickhouseName: "xxHash32", minArgs: 1 },
xxHash64: { clickhouseName: "xxHash64", minArgs: 1 },
murmurHash2_32: { clickhouseName: "murmurHash2_32", minArgs: 1 },
murmurHash2_64: { clickhouseName: "murmurHash2_64", minArgs: 1 },
murmurHash3_32: { clickhouseName: "murmurHash3_32", minArgs: 1 },
murmurHash3_64: { clickhouseName: "murmurHash3_64", minArgs: 1 },
murmurHash3_128: { clickhouseName: "murmurHash3_128", minArgs: 1 },
hex: { clickhouseName: "hex", minArgs: 1, maxArgs: 1 },
unhex: { clickhouseName: "unhex", minArgs: 1, maxArgs: 1 },
// URL functions
protocol: { clickhouseName: "protocol", minArgs: 1, maxArgs: 1 },
domain: { clickhouseName: "domain", minArgs: 1, maxArgs: 1 },
domainWithoutWWW: { clickhouseName: "domainWithoutWWW", minArgs: 1, maxArgs: 1 },
topLevelDomain: { clickhouseName: "topLevelDomain", minArgs: 1, maxArgs: 1 },
firstSignificantSubdomain: {
clickhouseName: "firstSignificantSubdomain",
minArgs: 1,
maxArgs: 1,
},
cutToFirstSignificantSubdomain: {
clickhouseName: "cutToFirstSignificantSubdomain",
minArgs: 1,
maxArgs: 1,
},
cutToFirstSignificantSubdomainWithWWW: {
clickhouseName: "cutToFirstSignificantSubdomainWithWWW",
minArgs: 1,
maxArgs: 1,
},
port: { clickhouseName: "port", minArgs: 1, maxArgs: 2 },
path: { clickhouseName: "path", minArgs: 1, maxArgs: 1 },
pathFull: { clickhouseName: "pathFull", minArgs: 1, maxArgs: 1 },
queryString: { clickhouseName: "queryString", minArgs: 1, maxArgs: 1 },
fragment: { clickhouseName: "fragment", minArgs: 1, maxArgs: 1 },
extractURLParameter: { clickhouseName: "extractURLParameter", minArgs: 2, maxArgs: 2 },
extractURLParameters: { clickhouseName: "extractURLParameters", minArgs: 1, maxArgs: 1 },
encodeURLComponent: { clickhouseName: "encodeURLComponent", minArgs: 1, maxArgs: 1 },
decodeURLComponent: { clickhouseName: "decodeURLComponent", minArgs: 1, maxArgs: 1 },
// UUID functions
generateUUIDv4: { clickhouseName: "generateUUIDv4", minArgs: 0, maxArgs: 0 },
UUIDStringToNum: { clickhouseName: "UUIDStringToNum", minArgs: 1, maxArgs: 1 },
UUIDNumToString: { clickhouseName: "UUIDNumToString", minArgs: 1, maxArgs: 1 },
// Other functions
isFinite: { clickhouseName: "isFinite", minArgs: 1, maxArgs: 1 },
isInfinite: { clickhouseName: "isInfinite", minArgs: 1, maxArgs: 1 },
ifNotFinite: { clickhouseName: "ifNotFinite", minArgs: 2, maxArgs: 2 },
isNaN: { clickhouseName: "isNaN", minArgs: 1, maxArgs: 1 },
bar: { clickhouseName: "bar", minArgs: 4, maxArgs: 4 },
transform: { clickhouseName: "transform", minArgs: 3, maxArgs: 4 },
formatReadableDecimalSize: {
clickhouseName: "formatReadableDecimalSize",
minArgs: 1,
maxArgs: 1,
},
formatReadableSize: { clickhouseName: "formatReadableSize", minArgs: 1, maxArgs: 1 },
formatReadableQuantity: { clickhouseName: "formatReadableQuantity", minArgs: 1, maxArgs: 1 },
formatReadableTimeDelta: { clickhouseName: "formatReadableTimeDelta", minArgs: 1, maxArgs: 2 },
least: { clickhouseName: "least", minArgs: 2, maxArgs: 2, caseSensitive: false },
greatest: { clickhouseName: "greatest", minArgs: 2, maxArgs: 2, caseSensitive: false },
min2: { clickhouseName: "min2", minArgs: 2, maxArgs: 2 },
max2: { clickhouseName: "max2", minArgs: 2, maxArgs: 2 },
runningDifference: { clickhouseName: "runningDifference", minArgs: 1, maxArgs: 1 },
runningDifferenceStartingWithFirstValue: {
clickhouseName: "runningDifferenceStartingWithFirstValue",
minArgs: 1,
maxArgs: 1,
},
neighbor: { clickhouseName: "neighbor", minArgs: 2, maxArgs: 3 },
// Window functions
rank: { clickhouseName: "rank", minArgs: 0, maxArgs: 0 },
dense_rank: { clickhouseName: "dense_rank", minArgs: 0, maxArgs: 0 },
row_number: { clickhouseName: "row_number", minArgs: 0, maxArgs: 0 },
first_value: { clickhouseName: "first_value", minArgs: 1, maxArgs: 1 },
last_value: { clickhouseName: "last_value", minArgs: 1, maxArgs: 1 },
nth_value: { clickhouseName: "nth_value", minArgs: 2, maxArgs: 2 },
lagInFrame: { clickhouseName: "lagInFrame", minArgs: 1, maxArgs: 3 },
leadInFrame: { clickhouseName: "leadInFrame", minArgs: 1, maxArgs: 3 },
lag: { clickhouseName: "lagInFrame", minArgs: 1, maxArgs: 3 },
lead: { clickhouseName: "leadInFrame", minArgs: 1, maxArgs: 3 },
};
/**
* Aggregate functions available in TSQL
* Port of HOGQL_AGGREGATIONS from aggregations.py
*/
export const TSQL_AGGREGATIONS: Record<string, TSQLFunctionMeta> = {
// Standard aggregate functions
count: { clickhouseName: "count", minArgs: 0, maxArgs: 1, aggregate: true, caseSensitive: false },
countIf: { clickhouseName: "countIf", minArgs: 1, maxArgs: 2, aggregate: true },
countDistinct: { clickhouseName: "countDistinct", minArgs: 1, maxArgs: 1, aggregate: true },
countDistinctIf: { clickhouseName: "countDistinctIf", minArgs: 1, maxArgs: 2, aggregate: true },
min: { clickhouseName: "min", minArgs: 1, maxArgs: 1, aggregate: true, caseSensitive: false },
minIf: { clickhouseName: "minIf", minArgs: 2, maxArgs: 2, aggregate: true },
max: { clickhouseName: "max", minArgs: 1, maxArgs: 1, aggregate: true, caseSensitive: false },
maxIf: { clickhouseName: "maxIf", minArgs: 2, maxArgs: 2, aggregate: true },
sum: { clickhouseName: "sum", minArgs: 1, maxArgs: 1, aggregate: true, caseSensitive: false },
sumIf: { clickhouseName: "sumIf", minArgs: 2, maxArgs: 2, aggregate: true },
avg: { clickhouseName: "avg", minArgs: 1, maxArgs: 1, aggregate: true, caseSensitive: false },
avgIf: { clickhouseName: "avgIf", minArgs: 2, maxArgs: 2, aggregate: true },
any: { clickhouseName: "any", minArgs: 1, maxArgs: 1, aggregate: true },
anyIf: { clickhouseName: "anyIf", minArgs: 2, maxArgs: 2, aggregate: true },
anyLast: { clickhouseName: "anyLast", minArgs: 1, maxArgs: 1, aggregate: true },
anyLastIf: { clickhouseName: "anyLastIf", minArgs: 2, maxArgs: 2, aggregate: true },
anyHeavy: { clickhouseName: "anyHeavy", minArgs: 1, maxArgs: 1, aggregate: true },
anyHeavyIf: { clickhouseName: "anyHeavyIf", minArgs: 2, maxArgs: 2, aggregate: true },
argMin: { clickhouseName: "argMin", minArgs: 2, maxArgs: 2, aggregate: true },
argMinIf: { clickhouseName: "argMinIf", minArgs: 3, maxArgs: 3, aggregate: true },
argMax: { clickhouseName: "argMax", minArgs: 2, maxArgs: 2, aggregate: true },
argMaxIf: { clickhouseName: "argMaxIf", minArgs: 3, maxArgs: 3, aggregate: true },
stddevPop: { clickhouseName: "stddevPop", minArgs: 1, maxArgs: 1, aggregate: true },
stddevSamp: { clickhouseName: "stddevSamp", minArgs: 1, maxArgs: 1, aggregate: true },
varPop: { clickhouseName: "varPop", minArgs: 1, maxArgs: 1, aggregate: true },
varSamp: { clickhouseName: "varSamp", minArgs: 1, maxArgs: 1, aggregate: true },
covarPop: { clickhouseName: "covarPop", minArgs: 2, maxArgs: 2, aggregate: true },
covarSamp: { clickhouseName: "covarSamp", minArgs: 2, maxArgs: 2, aggregate: true },
corr: { clickhouseName: "corr", minArgs: 2, maxArgs: 2, aggregate: true },
// Array aggregations
groupArray: { clickhouseName: "groupArray", minArgs: 1, maxArgs: 1, aggregate: true },
groupArrayIf: { clickhouseName: "groupArrayIf", minArgs: 2, maxArgs: 2, aggregate: true },
groupUniqArray: { clickhouseName: "groupUniqArray", minArgs: 1, maxArgs: 1, aggregate: true },
groupUniqArrayIf: { clickhouseName: "groupUniqArrayIf", minArgs: 2, maxArgs: 2, aggregate: true },
groupArrayInsertAt: {
clickhouseName: "groupArrayInsertAt",
minArgs: 2,
maxArgs: 2,
aggregate: true,
},
groupArrayMovingAvg: {
clickhouseName: "groupArrayMovingAvg",
minArgs: 1,
maxArgs: 1,
aggregate: true,
},
groupArrayMovingSum: {
clickhouseName: "groupArrayMovingSum",
minArgs: 1,
maxArgs: 1,
aggregate: true,
},
groupArraySample: {
clickhouseName: "groupArraySample",
minArgs: 1,
maxArgs: 1,
minParams: 1,
maxParams: 2,
aggregate: true,
},
array_agg: { clickhouseName: "groupArray", minArgs: 1, maxArgs: 1, aggregate: true },
// Bitmap aggregations
groupBitmap: { clickhouseName: "groupBitmap", minArgs: 1, maxArgs: 1, aggregate: true },
groupBitmapAnd: { clickhouseName: "groupBitmapAnd", minArgs: 1, maxArgs: 1, aggregate: true },
groupBitmapOr: { clickhouseName: "groupBitmapOr", minArgs: 1, maxArgs: 1, aggregate: true },
groupBitmapXor: { clickhouseName: "groupBitmapXor", minArgs: 1, maxArgs: 1, aggregate: true },
// Uniq functions
uniq: { clickhouseName: "uniq", minArgs: 1, aggregate: true },
uniqIf: { clickhouseName: "uniqIf", minArgs: 2, aggregate: true },
uniqExact: { clickhouseName: "uniqExact", minArgs: 1, aggregate: true },
uniqExactIf: { clickhouseName: "uniqExactIf", minArgs: 2, aggregate: true },
uniqHLL12: { clickhouseName: "uniqHLL12", minArgs: 1, aggregate: true },
uniqTheta: { clickhouseName: "uniqTheta", minArgs: 1, aggregate: true },
// Quantile functions
median: { clickhouseName: "median", minArgs: 1, maxArgs: 1, aggregate: true },
medianIf: { clickhouseName: "medianIf", minArgs: 2, maxArgs: 2, aggregate: true },
medianExact: { clickhouseName: "medianExact", minArgs: 1, maxArgs: 1, aggregate: true },
quantile: {
clickhouseName: "quantile",
minArgs: 1,
maxArgs: 1,
minParams: 1,
maxParams: 1,
aggregate: true,
},
quantileIf: {
clickhouseName: "quantileIf",
minArgs: 2,
maxArgs: 2,
minParams: 1,
maxParams: 1,
aggregate: true,
},
quantiles: { clickhouseName: "quantiles", minArgs: 1, aggregate: true },
// -Merge combinators for AggregatingMergeTree tables
quantilesMerge: {
clickhouseName: "quantilesMerge",
minArgs: 1,
maxArgs: 1,
minParams: 1,
aggregate: true,
},
quantileMerge: {
clickhouseName: "quantileMerge",
minArgs: 1,
maxArgs: 1,
minParams: 1,
maxParams: 1,
aggregate: true,
},
sumMerge: { clickhouseName: "sumMerge", minArgs: 1, maxArgs: 1, aggregate: true },
avgMerge: { clickhouseName: "avgMerge", minArgs: 1, maxArgs: 1, aggregate: true },
countMerge: { clickhouseName: "countMerge", minArgs: 1, maxArgs: 1, aggregate: true },
minMerge: { clickhouseName: "minMerge", minArgs: 1, maxArgs: 1, aggregate: true },
maxMerge: { clickhouseName: "maxMerge", minArgs: 1, maxArgs: 1, aggregate: true },
// Statistical functions
simpleLinearRegression: {
clickhouseName: "simpleLinearRegression",
minArgs: 2,
maxArgs: 2,
aggregate: true,
},
contingency: { clickhouseName: "contingency", minArgs: 2, maxArgs: 2, aggregate: true },
cramersV: { clickhouseName: "cramersV", minArgs: 2, maxArgs: 2, aggregate: true },
theilsU: { clickhouseName: "theilsU", minArgs: 2, maxArgs: 2, aggregate: true },
// Sum/Map variants
sumMap: { clickhouseName: "sumMap", minArgs: 1, maxArgs: 2, aggregate: true },
minMap: { clickhouseName: "minMap", minArgs: 1, maxArgs: 2, aggregate: true },
maxMap: { clickhouseName: "maxMap", minArgs: 1, maxArgs: 2, aggregate: true },
// TopK
topK: {
clickhouseName: "topK",
minArgs: 1,
maxArgs: 1,
minParams: 1,
maxParams: 1,
aggregate: true,
},
// Funnel
windowFunnel: { clickhouseName: "windowFunnel", minArgs: 1, maxArgs: 99, aggregate: true },
};
/**
* Build a lowercase lookup map from a functions record.
* Uses a null-prototype object to avoid Object.prototype pollution (e.g. "toString").
*/
function buildLowercaseMap(
functions: Record<string, TSQLFunctionMeta>
): Record<string, TSQLFunctionMeta> {
const map: Record<string, TSQLFunctionMeta> = Object.create(null);
for (const [key, value] of Object.entries(functions)) {
map[key.toLowerCase()] = value;
}
return map;
}
const FUNCTIONS_LOWERCASE = buildLowercaseMap(TSQL_CLICKHOUSE_FUNCTIONS);
const AGGREGATIONS_LOWERCASE = buildLowercaseMap(TSQL_AGGREGATIONS);
/**
* Find a function in the TSQL functions map.
* Supports case-insensitive lookup for non-case-sensitive functions.
*
* @param functions - The canonical functions record (exact-match lookup)
* @param lowercaseMap - Pre-computed lowercase lookup (null-prototype, safe from prototype pollution)
*/
function findFunction(
name: string,
functions: Record<string, TSQLFunctionMeta>,
lowercaseMap: Record<string, TSQLFunctionMeta>
): TSQLFunctionMeta | undefined {
if (Object.prototype.hasOwnProperty.call(functions, name)) {
return functions[name];
}
// Case-insensitive fallback using the pre-computed lowercase map
const lowerFunc = lowercaseMap[name.toLowerCase()];
if (lowerFunc === undefined) {
return undefined;
}
// If the function is case-sensitive, only the exact-match above should find it
if (lowerFunc.caseSensitive) {
return undefined;
}
return lowerFunc;
}
/**
* Find a TSQL aggregation function by name
*/
export function findTSQLAggregation(name: string): TSQLFunctionMeta | undefined {
return findFunction(name, TSQL_AGGREGATIONS, AGGREGATIONS_LOWERCASE);
}
/**
* Find a TSQL function by name
*/
export function findTSQLFunction(name: string): TSQLFunctionMeta | undefined {
return findFunction(name, TSQL_CLICKHOUSE_FUNCTIONS, FUNCTIONS_LOWERCASE);
}
/**
* Get all exposed function names (for autocomplete, suggestions, etc.)
*/
export function getAllExposedFunctionNames(): string[] {
const functionNames = Object.keys(TSQL_CLICKHOUSE_FUNCTIONS).filter(
(name) => !name.startsWith("_")
);
const aggregationNames = Object.keys(TSQL_AGGREGATIONS).filter((name) => !name.startsWith("_"));
return [...functionNames, ...aggregationNames];
}
/**
* Validate function arguments
*/
export function validateFunctionArgs(
args: unknown[],
minArgs: number,
maxArgs: number | undefined,
functionName: string,
options: {
functionTerm?: string;
argumentTerm?: string;
} = {}
): void {
const { functionTerm = "function", argumentTerm = "argument" } = options;
const tooFew = args.length < minArgs;
const tooMany = maxArgs !== undefined && args.length > maxArgs;
if (minArgs === maxArgs && (tooFew || tooMany)) {
throw new Error(
`${functionTerm.charAt(0).toUpperCase() + functionTerm.slice(1)} '${functionName}' expects ${minArgs} ${argumentTerm}${minArgs !== 1 ? "s" : ""}, found ${args.length}`
);
}
if (tooFew) {
throw new Error(
`${functionTerm.charAt(0).toUpperCase() + functionTerm.slice(1)} '${functionName}' expects at least ${minArgs} ${argumentTerm}${minArgs !== 1 ? "s" : ""}, found ${args.length}`
);
}
if (tooMany) {
throw new Error(
`${functionTerm.charAt(0).toUpperCase() + functionTerm.slice(1)} '${functionName}' expects at most ${maxArgs} ${argumentTerm}${maxArgs !== 1 ? "s" : ""}, found ${args.length}`
);
}
}
+253
View File
@@ -0,0 +1,253 @@
// TypeScript translation of posthog/hogql/database/models.py
import type { Expr, ConstantType } from "./ast";
import type { TSQLContext } from "./context";
export interface FieldOrTable {
hidden?: boolean;
}
export interface DatabaseField extends FieldOrTable {
name: string;
array?: boolean;
nullable?: boolean;
is_nullable?(): boolean;
get_constant_type?(): ConstantType;
default_value?(): any;
}
export interface IntegerDatabaseField extends DatabaseField {}
export interface FloatDatabaseField extends DatabaseField {}
export interface DecimalDatabaseField extends DatabaseField {}
export interface StringDatabaseField extends DatabaseField {}
export interface UnknownDatabaseField extends DatabaseField {}
export interface StringJSONDatabaseField extends DatabaseField {}
export interface StringArrayDatabaseField extends DatabaseField {}
export interface FloatArrayDatabaseField extends DatabaseField {}
export interface DateDatabaseField extends DatabaseField {}
export interface DateTimeDatabaseField extends DatabaseField {}
export interface BooleanDatabaseField extends DatabaseField {}
export interface UUIDDatabaseField extends DatabaseField {}
export interface ExpressionField extends DatabaseField {
expr: Expr;
isolate_scope?: boolean;
}
export interface FieldTraverser extends FieldOrTable {
chain: Array<string | number>;
}
export interface Table extends FieldOrTable {
fields: Record<string, FieldOrTable>;
has_field?(name: string | number): boolean;
get_field?(name: string | number): FieldOrTable;
to_printed_clickhouse?(context: TSQLContext): string;
to_printed_tsql?(): string;
avoid_asterisk_fields?(): string[];
get_asterisk?(): Record<string, FieldOrTable>;
}
export interface LazyJoin extends FieldOrTable {
join_function?(from_table: Table, to_table: Table, requesting_table: Table): Expr;
resolve_table?(context: TSQLContext): Table;
}
export interface LazyTable extends Table {}
export interface VirtualTable extends Table {}
export interface SavedQuery extends Table {
query: Expr;
}
export interface FunctionCallTable extends Table {
call_function?(context: TSQLContext): Expr;
}
export interface TableNode {
name: "root" | string;
table?: FieldOrTable | null;
children: Record<string, TableNode>;
get?(): FieldOrTable;
has_child?(path: string[]): boolean;
get_child?(path: string[]): TableNode;
add_child?(
child: TableNode,
options?: {
table_conflict_mode?: "override" | "ignore";
children_conflict_mode?: "override" | "merge" | "ignore";
}
): void;
merge_with?(
other: TableNode,
options?: {
table_conflict_mode?: "override" | "ignore";
children_conflict_mode?: "override" | "merge" | "ignore";
}
): void;
resolve_all_table_names?(): string[];
}
// Basic TableNode implementation class
export class TableNodeImpl implements TableNode {
name: "root" | string;
table?: FieldOrTable | null;
children: Record<string, TableNode>;
constructor(name: "root" | string = "root", table?: FieldOrTable | null) {
this.name = name;
this.table = table || null;
this.children = {};
}
get(): FieldOrTable {
if (this.table === null || this.table === undefined) {
throw new Error(`Table is not set at \`${this.name}\``);
}
return this.table;
}
has_child(path: string[]): boolean {
if (path.length === 0) {
return this.table !== null && this.table !== undefined;
}
const [first, ...restOfPath] = path;
if (!(first in this.children)) {
return false;
}
return this.children[first].has_child ? this.children[first].has_child!(restOfPath) : false;
}
get_child(path: string[]): TableNode {
if (path.length === 0) {
return this;
}
const [first, ...restOfPath] = path;
if (!(first in this.children)) {
throw new Error(`Unknown child \`${first}\` at \`${this.name}\`.`);
}
return this.children[first].get_child
? this.children[first].get_child!(restOfPath)
: this.children[first];
}
add_child(
child: TableNode,
options?: {
table_conflict_mode?: "override" | "ignore";
children_conflict_mode?: "override" | "merge" | "ignore";
}
): void {
const tableConflictMode = options?.table_conflict_mode || "ignore";
const childrenConflictMode = options?.children_conflict_mode || "merge";
if (child.name in this.children) {
if (childrenConflictMode === "override") {
this.children[child.name] = child;
} else if (childrenConflictMode === "merge") {
const existing = this.children[child.name];
if (existing.merge_with) {
existing.merge_with(child, {
table_conflict_mode: tableConflictMode,
children_conflict_mode: childrenConflictMode,
});
}
}
// ignore mode: do nothing
return;
}
this.children[child.name] = child;
}
merge_with(
other: TableNode,
options?: {
table_conflict_mode?: "override" | "ignore";
children_conflict_mode?: "override" | "merge" | "ignore";
}
): void {
const tableConflictMode = options?.table_conflict_mode || "ignore";
const childrenConflictMode = options?.children_conflict_mode || "merge";
if (other.table !== null && other.table !== undefined) {
if (this.table === null || this.table === undefined) {
this.table = other.table;
} else {
// Conflict - check conflict mode
if (tableConflictMode === "override") {
this.table = other.table;
}
// ignore mode: do nothing
}
}
for (const child of Object.values(other.children)) {
this.add_child(child, {
table_conflict_mode: tableConflictMode,
children_conflict_mode: childrenConflictMode,
});
}
}
resolve_all_table_names(): string[] {
const names: string[] = [];
if (this.table !== null && this.table !== undefined) {
names.push(this.name);
}
for (const child of Object.values(this.children)) {
const childNames = child.resolve_all_table_names ? child.resolve_all_table_names() : [];
// The root node should NOT include itself in the names
if (this.name === "root") {
names.push(...childNames);
} else {
names.push(...childNames.map((x) => `${this.name}.${x}`));
}
}
return names;
}
static createNestedForChain(chain: string[], table: Table): TableNode {
if (chain.length === 0) {
throw new Error("Chain must have at least one element");
}
const start = new TableNodeImpl(chain[0]);
let current: TableNode = start;
for (let i = 1; i < chain.length; i++) {
const child = new TableNodeImpl(chain[i]);
if (current.add_child) {
current.add_child(child);
} else {
current.children[child.name] = child;
}
current = child;
}
current.table = table;
return start;
}
}
export interface LazyTableToAdd {
lazy_table: LazyTable;
fields_accessed: Record<string, Array<string | number>>;
}
export interface LazyJoinToAdd {
from_table: string;
to_table: string;
lazy_join: LazyJoin;
lazy_join_type: any; // LazyJoinType from ast.ts
fields_accessed: Record<string, Array<string | number>>;
}
@@ -0,0 +1,69 @@
// TypeScript translation of posthog/hogql/parse_string.py
// Keep this file in sync with the Python version
import { SyntaxError } from "./errors";
function replaceCommonEscapeCharacters(text: string): string {
// copied from clickhouse_driver/util/escape.py
// Note: \a (bell) and \v (vertical tab) are not directly supported in JavaScript strings
// but we handle them as escape sequences that get replaced
text = text.replace(/\\b/g, "\b");
text = text.replace(/\\f/g, "\f");
text = text.replace(/\\r/g, "\r");
text = text.replace(/\\n/g, "\n");
text = text.replace(/\\t/g, "\t");
text = text.replace(/\\0/g, ""); // NUL characters are ignored
text = text.replace(/\\a/g, "\x07"); // Bell character (ASCII 7)
text = text.replace(/\\v/g, "\x0B"); // Vertical tab (ASCII 11)
text = text.replace(/\\\\/g, "\\");
return text;
}
export function parseStringLiteralText(text: string): string {
/** Converts a string received from antlr via ctx.getText() into a JavaScript string */
let result: string;
if (text.startsWith("'") && text.endsWith("'")) {
result = text.slice(1, -1);
result = result.replace(/''/g, "'");
result = result.replace(/\\'/g, "'");
} else if (text.startsWith('"') && text.endsWith('"')) {
result = text.slice(1, -1);
result = result.replace(/""/g, '"');
result = result.replace(/\\"/g, '"');
} else if (text.startsWith("`") && text.endsWith("`")) {
result = text.slice(1, -1);
result = result.replace(/``/g, "`");
result = result.replace(/\\`/g, "`");
} else if (text.startsWith("{") && text.endsWith("}")) {
result = text.slice(1, -1);
result = result.replace(/{{/g, "{");
result = result.replace(/\\{/g, "{");
} else {
throw new SyntaxError(
`Invalid string literal, must start and end with the same quote type: ${text}`
);
}
return replaceCommonEscapeCharacters(result);
}
export function parseStringLiteralCtx(ctx: { getText(): string }): string {
/** Converts a STRING_LITERAL received from antlr via ctx.getText() into a JavaScript string */
const text = ctx.getText();
return parseStringLiteralText(text);
}
export function parseStringTextCtx(
ctx: { getText(): string },
escapeQuotes: boolean = true
): string {
/** Converts a STRING_TEXT received from antlr via ctx.getText() into a JavaScript string */
let text = ctx.getText();
if (escapeQuotes) {
text = text.replace(/''/g, "'");
text = text.replace(/\\'/g, "'");
}
text = text.replace(/\\{/g, "{");
return replaceCommonEscapeCharacters(text);
}
@@ -0,0 +1,629 @@
import { describe, it, expect } from "vitest";
import { CharStreams, CommonTokenStream } from "antlr4ts";
import { TSQLLexer } from "../grammar/TSQLLexer.js";
import { TSQLParser } from "../grammar/TSQLParser.js";
import { TSQLParseTreeConverter } from "./parser.js";
import { ArithmeticOperationOp, CompareOperationOp } from "./ast.js";
import { SyntaxError } from "./errors.js";
/**
* Helper function to parse TSQL input and convert to AST
*/
function parseAndConvert(input: string) {
const inputStream = CharStreams.fromString(input);
const lexer = new TSQLLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TSQLParser(tokenStream);
const parseTree = parser.select();
const converter = new TSQLParseTreeConverter();
return converter.visit(parseTree);
}
describe("TSQLParseTreeConverter", () => {
describe("SELECT statements", () => {
it("should convert a simple SELECT statement", () => {
const ast = parseAndConvert("SELECT * FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
},
});
});
it("should convert SELECT with multiple columns", () => {
const ast = parseAndConvert("SELECT id, name, email FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{ expression_type: "field", chain: ["id"] },
{ expression_type: "field", chain: ["name"] },
{ expression_type: "field", chain: ["email"] },
],
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
},
});
});
it("should convert SELECT with DISTINCT", () => {
const ast = parseAndConvert("SELECT DISTINCT id FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
distinct: true,
select: [{ expression_type: "field", chain: ["id"] }],
});
});
it("should convert SELECT with WHERE clause", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id = 1");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
},
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Eq,
left: { expression_type: "field", chain: ["id"] },
right: { expression_type: "constant", value: 1 },
},
});
});
it("should convert SELECT with ORDER BY", () => {
const ast = parseAndConvert("SELECT * FROM users ORDER BY id DESC");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
order_by: [
{
expression_type: "order_expr",
order: "DESC",
expr: { expression_type: "field", chain: ["id"] },
},
],
});
});
it("should convert SELECT with LIMIT", () => {
const ast = parseAndConvert("SELECT * FROM users LIMIT 10");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
limit: { expression_type: "constant", value: 10 },
});
});
it("should convert SELECT with LIMIT and OFFSET", () => {
const ast = parseAndConvert("SELECT * FROM users LIMIT 10 OFFSET 5");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
limit: { expression_type: "constant", value: 10 },
offset: { expression_type: "constant", value: 5 },
});
});
it("should convert SELECT with GROUP BY", () => {
const ast = parseAndConvert("SELECT category, COUNT(*) FROM products GROUP BY category");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{ expression_type: "field", chain: ["category"] },
{ expression_type: "call", name: "COUNT" },
],
group_by: [{ expression_type: "field", chain: ["category"] }],
});
});
it("should convert SELECT with HAVING", () => {
const ast = parseAndConvert(
"SELECT category FROM products GROUP BY category HAVING COUNT(*) > 10"
);
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["category"] }],
group_by: [{ expression_type: "field", chain: ["category"] }],
having: {
expression_type: "compare_operation",
op: CompareOperationOp.Gt,
left: { expression_type: "call", name: "COUNT" },
right: { expression_type: "constant", value: 10 },
},
});
});
});
describe("expressions", () => {
it("should convert numeric constants", () => {
const ast = parseAndConvert("SELECT 42 FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "constant", value: 42 }],
});
});
it("should convert string constants", () => {
const ast = parseAndConvert("SELECT 'hello' FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "constant", value: "hello" }],
});
});
it("should convert boolean constants", () => {
const ast = parseAndConvert("SELECT true FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "constant", value: true }],
});
});
it("should convert arithmetic addition", () => {
const ast = parseAndConvert("SELECT 1 + 2 FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{
expression_type: "arithmetic_operation",
op: ArithmeticOperationOp.Add,
left: { expression_type: "constant", value: 1 },
right: { expression_type: "constant", value: 2 },
},
],
});
});
it("should convert arithmetic subtraction", () => {
const ast = parseAndConvert("SELECT 5 - 3 FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{
expression_type: "arithmetic_operation",
op: ArithmeticOperationOp.Sub,
left: { expression_type: "constant", value: 5 },
right: { expression_type: "constant", value: 3 },
},
],
});
});
it("should convert arithmetic multiplication", () => {
const ast = parseAndConvert("SELECT 2 * 3 FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{
expression_type: "arithmetic_operation",
op: ArithmeticOperationOp.Mult,
left: { expression_type: "constant", value: 2 },
right: { expression_type: "constant", value: 3 },
},
],
});
});
it("should convert arithmetic division", () => {
const ast = parseAndConvert("SELECT 10 / 2 FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{
expression_type: "arithmetic_operation",
op: ArithmeticOperationOp.Div,
left: { expression_type: "constant", value: 10 },
right: { expression_type: "constant", value: 2 },
},
],
});
});
it("should convert comparison equals", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id = 1");
expect(ast).toMatchObject({
expression_type: "select_query",
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Eq,
left: { expression_type: "field", chain: ["id"] },
right: { expression_type: "constant", value: 1 },
},
});
});
it("should convert comparison not equals", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id != 1");
expect(ast).toMatchObject({
expression_type: "select_query",
where: {
expression_type: "compare_operation",
op: CompareOperationOp.NotEq,
left: { expression_type: "field", chain: ["id"] },
right: { expression_type: "constant", value: 1 },
},
});
});
it("should convert comparison less than", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id < 10");
expect(ast).toMatchObject({
expression_type: "select_query",
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Lt,
left: { expression_type: "field", chain: ["id"] },
right: { expression_type: "constant", value: 10 },
},
});
});
it("should convert comparison greater than", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id > 5");
expect(ast).toMatchObject({
expression_type: "select_query",
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Gt,
left: { expression_type: "field", chain: ["id"] },
right: { expression_type: "constant", value: 5 },
},
});
});
it("should convert LIKE comparison", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE name LIKE '%john%'");
expect(ast).toMatchObject({
expression_type: "select_query",
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Like,
left: { expression_type: "field", chain: ["name"] },
right: { expression_type: "constant", value: "%john%" },
},
});
});
it("should convert IN comparison", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id IN (1, 2, 3)");
expect(ast).toMatchObject({
expression_type: "select_query",
where: {
expression_type: "compare_operation",
op: CompareOperationOp.In,
left: { expression_type: "field", chain: ["id"] },
right: {
expression_type: "tuple",
exprs: [
{ expression_type: "constant", value: 1 },
{ expression_type: "constant", value: 2 },
{ expression_type: "constant", value: 3 },
],
},
},
});
});
it("should convert function calls", () => {
const ast = parseAndConvert("SELECT COUNT(*) FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{
expression_type: "call",
name: "COUNT",
args: [{ expression_type: "field", chain: ["*"] }],
},
],
});
});
it("should convert nested field access", () => {
const ast = parseAndConvert("SELECT user.profile.name FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["user", "profile", "name"] }],
});
});
it("should convert aliased expressions", () => {
const ast = parseAndConvert("SELECT id AS user_id FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{
expression_type: "alias",
alias: "user_id",
expr: { expression_type: "field", chain: ["id"] },
},
],
});
});
});
describe("JOINs", () => {
it("should convert INNER JOIN", () => {
const ast = parseAndConvert(
"SELECT * FROM users INNER JOIN orders ON users.id = orders.user_id"
);
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
next_join: {
expression_type: "join_expr",
join_type: "INNER JOIN",
table: { expression_type: "field", chain: ["orders"] },
constraint: {
expression_type: "join_constraint",
constraint_type: "ON",
expr: {
expression_type: "compare_operation",
op: CompareOperationOp.Eq,
},
},
},
},
});
});
it("should convert LEFT JOIN", () => {
const ast = parseAndConvert(
"SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id"
);
expect(ast).toMatchObject({
expression_type: "select_query",
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
next_join: {
expression_type: "join_expr",
join_type: "LEFT JOIN",
table: { expression_type: "field", chain: ["orders"] },
constraint: { constraint_type: "ON" },
},
},
});
});
it("should convert CROSS JOIN", () => {
const ast = parseAndConvert("SELECT * FROM users CROSS JOIN orders");
expect(ast).toMatchObject({
expression_type: "select_query",
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
next_join: {
expression_type: "join_expr",
join_type: "CROSS JOIN",
table: { expression_type: "field", chain: ["orders"] },
},
},
});
});
});
describe("UNION queries", () => {
it("should convert UNION DISTINCT query", () => {
// Grammar supports UNION ALL, UNION DISTINCT, INTERSECT, INTERSECT DISTINCT, EXCEPT
// Bare UNION (without ALL/DISTINCT) is not supported
const ast = parseAndConvert("SELECT id FROM users UNION DISTINCT SELECT id FROM customers");
expect(ast).toMatchObject({
expression_type: "select_set_query",
initial_select_query: {
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["id"] }],
select_from: {
table: { expression_type: "field", chain: ["users"] },
},
},
subsequent_select_queries: [
{
set_operator: "UNION DISTINCT",
select_query: {
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["id"] }],
select_from: {
table: { expression_type: "field", chain: ["customers"] },
},
},
},
],
});
});
it("should convert UNION ALL query", () => {
const ast = parseAndConvert("SELECT id FROM users UNION ALL SELECT id FROM customers");
expect(ast).toMatchObject({
expression_type: "select_set_query",
initial_select_query: {
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["id"] }],
},
subsequent_select_queries: [
{
set_operator: "UNION ALL",
select_query: {
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["id"] }],
},
},
],
});
});
});
describe("WITH clauses (CTEs)", () => {
it("should convert SELECT with WITH clause", () => {
const ast = parseAndConvert(
"WITH recent_users AS (SELECT * FROM users WHERE created_at > '2024-01-01') SELECT * FROM recent_users"
);
expect(ast).toMatchObject({
expression_type: "select_query",
ctes: {
recent_users: {
expression_type: "cte",
name: "recent_users",
cte_type: "subquery",
expr: {
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
select_from: {
table: { expression_type: "field", chain: ["users"] },
},
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Gt,
},
},
},
},
select: [{ expression_type: "field", chain: ["*"] }],
select_from: {
table: { expression_type: "field", chain: ["recent_users"] },
},
});
});
});
describe("error handling", () => {
it("should preserve position information in errors", () => {
const input = "SELECT * FROM users WHERE invalid syntax";
const inputStream = CharStreams.fromString(input);
const lexer = new TSQLLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TSQLParser(tokenStream);
// This might not parse correctly, but if it does and we visit an error node,
// it should throw with position info
try {
const parseTree = parser.select();
const converter = new TSQLParseTreeConverter();
converter.visit(parseTree);
} catch (error) {
if (error instanceof SyntaxError) {
// Error should have position information if available
expect(error).toBeInstanceOf(SyntaxError);
}
}
});
});
describe("complex queries", () => {
it("should convert a complex query with multiple clauses", () => {
const ast = parseAndConvert(
"SELECT category, COUNT(*) as count " +
"FROM products " +
"WHERE price > 100 " +
"GROUP BY category " +
"HAVING COUNT(*) > 5 " +
"ORDER BY count DESC " +
"LIMIT 10"
);
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{ expression_type: "field", chain: ["category"] },
{
expression_type: "alias",
alias: "count",
expr: { expression_type: "call", name: "COUNT" },
},
],
select_from: {
table: { expression_type: "field", chain: ["products"] },
},
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Gt,
left: { expression_type: "field", chain: ["price"] },
right: { expression_type: "constant", value: 100 },
},
group_by: [{ expression_type: "field", chain: ["category"] }],
having: {
expression_type: "compare_operation",
op: CompareOperationOp.Gt,
left: { expression_type: "call", name: "COUNT" },
right: { expression_type: "constant", value: 5 },
},
order_by: [
{
expression_type: "order_expr",
order: "DESC",
expr: { expression_type: "field", chain: ["count"] },
},
],
limit: { expression_type: "constant", value: 10 },
});
});
it("should convert query with multiple JOINs", () => {
const ast = parseAndConvert(
"SELECT * FROM users " +
"INNER JOIN orders ON users.id = orders.user_id " +
"LEFT JOIN products ON orders.product_id = products.id"
);
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
next_join: {
expression_type: "join_expr",
join_type: "INNER JOIN",
table: { expression_type: "field", chain: ["orders"] },
constraint: { constraint_type: "ON" },
next_join: {
expression_type: "join_expr",
join_type: "LEFT JOIN",
table: { expression_type: "field", chain: ["products"] },
constraint: { constraint_type: "ON" },
},
},
},
});
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,293 @@
// TypeScript port of posthog/hogql/context.py
// Adapted for ClickHouse client's {param: Type} syntax
import { getClickHouseType } from "./escape";
import type { SchemaRegistry, FieldMappings } from "./schema";
/**
* Settings that control query execution behavior
*/
export interface QuerySettings {
/** Maximum number of rows to return */
maxRows?: number;
/** Timezone for date/time operations */
timezone?: string;
/** Whether to allow full table scans */
allowFullTableScans?: boolean;
/** Query timeout in seconds */
timeoutSeconds?: number;
}
/**
* A simple comparison condition (e.g., column > value)
*/
export interface SimpleComparisonCondition {
/** The comparison operator */
op: "eq" | "neq" | "gt" | "gte" | "lt" | "lte";
/** The value to compare against */
value: Date | string | number;
}
/**
* A between condition (e.g., column BETWEEN low AND high)
*/
export interface BetweenCondition {
/** The between operator */
op: "between";
/** The low bound of the range */
low: Date | string | number;
/** The high bound of the range */
high: Date | string | number;
}
/**
* An IN condition (e.g., column IN ('a', 'b', 'c'))
*/
export interface InCondition {
/** The in operator */
op: "in";
/** The values to check against */
values: (string | number)[];
}
/**
* A WHERE clause condition that can be either a simple comparison, a BETWEEN, or an IN.
* Used for both enforcedWhereClause (always applied) and whereClauseFallback (default when user doesn't filter).
*/
export type WhereClauseCondition = SimpleComparisonCondition | BetweenCondition | InCondition;
/**
* A time range used by `timeBucket()` to determine the appropriate bucket interval.
*/
export interface TimeRange {
/** Start of the time range (inclusive) */
from: Date;
/** End of the time range (inclusive) */
to: Date;
}
/**
* Default query settings
*/
export const DEFAULT_QUERY_SETTINGS: Required<QuerySettings> = {
maxRows: 10000,
timezone: "UTC",
allowFullTableScans: false,
timeoutSeconds: 60,
};
/**
* A warning or notice collected during query printing
*/
export interface QueryNotice {
code: string;
message: string;
start?: number;
end?: number;
}
/**
* Context for the TSQL to ClickHouse printer
*
* Holds:
* - Enforced WHERE conditions for tenant isolation and plan limits
* - Schema registry for table/column validation
* - Parameter accumulator for SQL injection safety
* - Query settings and execution options
* - Field mappings for runtime value translation
*/
export class PrinterContext {
/** Accumulated parameter values for parameterized query */
private values: Record<string, unknown> = {};
/** Counter for generating unique parameter names */
private paramCounter = 0;
/** Warnings collected during printing */
readonly warnings: QueryNotice[] = [];
/** Errors collected during printing */
readonly errors: QueryNotice[] = [];
/** Runtime field mappings for dynamic value translation */
readonly fieldMappings: FieldMappings;
/**
* Enforced WHERE conditions that are ALWAYS applied at the table level.
* Used for tenant isolation (org_id, project_id, env_id) and plan-based limits.
* Applied to every table reference including subqueries, CTEs, and JOINs.
*/
readonly enforcedWhereClause: Record<string, WhereClauseCondition>;
/**
* Time range for `timeBucket()` interval calculation.
* When provided, `timeBucket()` uses this to determine the appropriate bucket size.
*/
readonly timeRange?: TimeRange;
constructor(
/** Schema registry containing allowed tables and columns */
public readonly schema: SchemaRegistry,
/** Query execution settings */
public readonly settings: QuerySettings = {},
/** Runtime field mappings for dynamic value translation */
fieldMappings: FieldMappings = {},
/**
* Enforced WHERE conditions that are ALWAYS applied at the table level.
* Must include tenant columns (e.g., organization_id) for multi-tenant tables.
*/
enforcedWhereClause: Record<string, WhereClauseCondition> = {},
/** Time range for timeBucket() interval calculation */
timeRange?: TimeRange
) {
// Initialize with default settings
this.settings = { ...DEFAULT_QUERY_SETTINGS, ...settings };
this.fieldMappings = fieldMappings;
this.enforcedWhereClause = enforcedWhereClause;
this.timeRange = timeRange;
}
/**
* Get the timezone setting
*/
get timezone(): string {
return this.settings.timezone ?? DEFAULT_QUERY_SETTINGS.timezone;
}
/**
* Get the max rows setting
*/
get maxRows(): number {
return this.settings.maxRows ?? DEFAULT_QUERY_SETTINGS.maxRows;
}
/**
* Add a value to the parameter map and return a ClickHouse placeholder
*
* @param value The value to parameterize
* @returns A placeholder string like "{tsql_val_0: String}"
*/
addValue(value: unknown): string {
const key = `tsql_val_${this.paramCounter++}`;
this.values[key] = value;
const chType = getClickHouseType(value);
return `{${key}: ${chType}}`;
}
/**
* Add a value with a specific key (for named parameters)
*
* @param key The parameter name
* @param value The value
* @returns A placeholder string like "{key: Type}"
*/
addNamedValue(key: string, value: unknown): string {
this.values[key] = value;
const chType = getClickHouseType(value);
return `{${key}: ${chType}}`;
}
/**
* Get all accumulated parameter values
*/
getParams(): Record<string, unknown> {
return { ...this.values };
}
/**
* Add a warning notice
*/
addWarning(code: string, message: string, start?: number, end?: number): void {
this.warnings.push({ code, message, start, end });
}
/**
* Add an error notice
*/
addError(code: string, message: string, start?: number, end?: number): void {
this.errors.push({ code, message, start, end });
}
/**
* Check if any errors were collected
*/
hasErrors(): boolean {
return this.errors.length > 0;
}
/**
* Create a child context that shares the same parameter accumulator
* Useful for handling subqueries while keeping parameters unified
*/
createChildContext(): PrinterContext {
const child = new PrinterContext(
this.schema,
this.settings,
this.fieldMappings,
this.enforcedWhereClause,
this.timeRange
);
// Share the same values map so parameters are unified
child.values = this.values;
// Share the same counter reference via closure
// eslint-disable-next-line no-this-alias
const parentThis = this;
Object.defineProperty(child, "paramCounter", {
get() {
return parentThis.paramCounter;
},
set(v: number) {
parentThis.paramCounter = v;
},
});
return child;
}
}
/**
* Options for creating a printer context
*/
export interface PrinterContextOptions {
/** Schema registry containing allowed tables and columns */
schema: SchemaRegistry;
/** Query execution settings */
settings?: QuerySettings;
/**
* Runtime field mappings for dynamic value translation.
* Maps internal ClickHouse values to external user-facing values.
*/
fieldMappings?: FieldMappings;
/**
* REQUIRED: Conditions always applied at the table level.
* Must include tenant columns (e.g., organization_id) for multi-tenant tables.
* Applied to every table reference including subqueries, CTEs, and JOINs.
*
* @example
* ```typescript
* {
* organization_id: { op: "eq", value: "org_123" },
* project_id: { op: "eq", value: "proj_456" },
* triggered_at: { op: "gte", value: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
* }
* ```
*/
enforcedWhereClause: Record<string, WhereClauseCondition>;
/**
* Time range for `timeBucket()` interval calculation.
* When provided, `timeBucket()` uses this to determine the appropriate bucket size.
*/
timeRange?: TimeRange;
}
/**
* Create a new PrinterContext
*/
export function createPrinterContext(options: PrinterContextOptions): PrinterContext {
return new PrinterContext(
options.schema,
options.settings,
options.fieldMappings,
options.enforcedWhereClause,
options.timeRange
);
}
@@ -0,0 +1,720 @@
// TypeScript translation of posthog/hogql/transforms/property_types.py
import type {
AST,
Expr,
Field,
PropertyType,
FieldType,
BaseTableType,
VirtualTableType,
LazyJoinType,
LazyTableType,
Call,
Constant,
CallType,
DateTimeType,
} from "./ast";
import type { TSQLContext } from "./context";
import type { BooleanDatabaseField, DateTimeDatabaseField } from "./models";
// Helper function to escape TSQL identifiers
function escapeTSQLIdentifier(identifier: string | number): string {
if (typeof identifier === "number") {
return String(identifier);
}
if (identifier.includes("%")) {
throw new Error(
`The TSQL identifier "${identifier}" is not permitted as it contains the "%" character`
);
}
// TSQL allows dollars in the identifier
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier)) {
return identifier;
}
// Escape backticks and other special characters
const backquoteEscapeChars: Record<string, string> = {
"\b": "\\b",
"\f": "\\f",
"\r": "\\r",
"\n": "\\n",
"\t": "\\t",
"\0": "\\0",
a: "\\a",
"\v": "\\v",
"\\": "\\\\",
"`": "\\`",
};
return `\`${identifier
.split("")
.map((c) => backquoteEscapeChars[c] || c)
.join("")}\``;
}
// Visitor dispatcher - converts node type to visitor method name
// Matches Python's camel_case_pattern.sub("_", class_name).lower() logic
function getVisitorMethodName(node: AST): string {
// Get the constructor name or use a type guard to determine the type
const nodeType = (node as any).constructor?.name || detectNodeType(node);
if (!nodeType) {
return "visit_unknown";
}
// Convert CamelCase to snake_case (e.g., "PropertyType" -> "property_type")
const snakeCase = nodeType
.replace(/([A-Z])/g, "_$1")
.toLowerCase()
.replace(/^_/, "");
// Handle special cases (matching Python replacements)
const replacements: Record<string, string> = {
tsqlxtag: "tsqlx_tag",
tsqlxattribute: "tsqlx_attribute",
uuidtype: "uuid_type",
string_jsontype: "string_json_type",
};
return replacements[snakeCase] || snakeCase;
}
// Type detection helper (since we can't use instanceof with interfaces)
function detectNodeType(node: AST): string {
// Use property presence to detect node types
if ("chain" in node && "type" in node && !("name" in node)) {
return "Field";
}
if ("chain" in node && "field_type" in node) {
return "PropertyType";
}
if ("name" in node && "args" in node && !("expr" in node)) {
return "Call";
}
if ("value" in node && !("name" in node) && !("args" in node)) {
return "Constant";
}
// Add more type detection as needed
return "";
}
// Base visitor class - matches Python Visitor pattern
abstract class Visitor<T> {
visit(node: AST | null | undefined): T {
if (node === null || node === undefined) {
return node as T;
}
// Try using accept method if available (double dispatch)
if (node.accept) {
return node.accept(this) as T;
}
// Fallback: use dispatcher
const methodName = getVisitorMethodName(node);
const method = (this as any)[methodName];
if (method && typeof method === "function") {
return method.call(this, node) as T;
}
// Try visit_unknown as fallback
if ((this as any).visit_unknown) {
return (this as any).visit_unknown(node) as T;
}
throw new Error(`${this.constructor.name} has no method ${methodName} or visit_unknown`);
}
}
// TraversingVisitor - matches Python TraversingVisitor
class TraversingVisitor extends Visitor<void> {
visitPropertyType(node: PropertyType): void {
this.visit(node.field_type);
}
visitField(node: Field): void {
if (node.type) {
this.visit(node.type as any);
}
}
visitCall(node: Call): void {
for (const arg of node.args) {
this.visit(arg);
}
if (node.params) {
for (const param of node.params) {
this.visit(param);
}
}
}
visitConstant(node: Constant): void {
if (node.type) {
this.visit(node.type as any);
}
}
// Default handler for unknown types - traverse common properties
visit_unknown(node: AST): void {
// Traverse children based on common AST node properties
if ("expr" in node) {
this.visit((node as any).expr);
}
if ("exprs" in node) {
for (const expr of (node as any).exprs) {
this.visit(expr);
}
}
if ("left" in node && "right" in node) {
this.visit((node as any).left);
this.visit((node as any).right);
}
if ("args" in node) {
for (const arg of (node as any).args) {
this.visit(arg);
}
}
if ("type" in node) {
this.visit((node as any).type);
}
}
}
// CloningVisitor - matches Python CloningVisitor
class CloningVisitor extends Visitor<any> {
protected clearTypes: boolean;
protected clearLocations: boolean;
constructor(clearTypes: boolean = true, clearLocations: boolean = false) {
super();
this.clearTypes = clearTypes;
this.clearLocations = clearLocations;
}
visitField(node: Field): Field {
return {
...node,
type: this.clearTypes ? undefined : node.type ? this.visit(node.type as any) : node.type,
start: this.clearLocations ? undefined : node.start,
end: this.clearLocations ? undefined : node.end,
};
}
visitPropertyType(node: PropertyType): PropertyType {
return {
...node,
field_type: this.visit(node.field_type) as FieldType,
start: this.clearLocations ? undefined : node.start,
end: this.clearLocations ? undefined : node.end,
};
}
visitCall(node: Call): Call {
return {
...node,
args: node.args.map((arg) => this.visit(arg)),
params: node.params ? node.params.map((param) => this.visit(param)) : undefined,
start: this.clearLocations ? undefined : node.start,
end: this.clearLocations ? undefined : node.end,
type: this.clearTypes ? undefined : node.type,
};
}
visitConstant(node: Constant): Constant {
return {
...node,
start: this.clearLocations ? undefined : node.start,
end: this.clearLocations ? undefined : node.end,
type: this.clearTypes ? undefined : node.type,
};
}
// Default handler for unknown types - shallow clone
visit_unknown(node: AST): any {
const cloned: any = { ...node };
// Clone common properties
if ("expr" in node) {
cloned.expr = this.visit((node as any).expr);
}
if ("exprs" in node) {
cloned.exprs = (node as any).exprs.map((e: any) => this.visit(e));
}
if ("left" in node && "right" in node) {
cloned.left = this.visit((node as any).left);
cloned.right = this.visit((node as any).right);
}
if ("args" in node) {
cloned.args = (node as any).args.map((a: any) => this.visit(a));
}
if ("type" in node) {
cloned.type = this.clearTypes ? undefined : this.visit((node as any).type);
}
if (this.clearLocations) {
cloned.start = undefined;
cloned.end = undefined;
}
return cloned;
}
}
// PropertyFinder: Traverses AST to find all property references
class PropertyFinder extends TraversingVisitor {
context: TSQLContext;
personProperties: Set<string> = new Set();
eventProperties: Set<string> = new Set();
groupProperties: Map<number, Set<string>> = new Map();
foundTimestamps: boolean = false;
constructor(context: TSQLContext) {
super();
this.context = context;
}
visitPropertyType(node: PropertyType): void {
if (node.field_type.name === "properties" && node.chain.length === 1) {
const tableType = node.field_type.table_type;
if (this.isBaseTableType(tableType)) {
const table = tableType.resolve_database_table?.(this.context);
if (table) {
const tableName = table.to_printed_tsql?.() || "";
const propertyName = String(node.chain[0]);
if (tableName === "persons" || tableName === "raw_persons") {
this.personProperties.add(propertyName);
} else if (tableName === "groups") {
if (this.isLazyJoinType(tableType)) {
if (tableType.field.startsWith("group_")) {
const groupId = parseInt(tableType.field.split("_")[1], 10);
if (!this.groupProperties.has(groupId)) {
this.groupProperties.set(groupId, new Set());
}
this.groupProperties.get(groupId)!.add(propertyName);
}
} else if (this.isLazyTableType(tableType)) {
const globalGroupId = this.context.globals?.group_id;
if (typeof globalGroupId === "number") {
if (!this.groupProperties.has(globalGroupId)) {
this.groupProperties.set(globalGroupId, new Set());
}
this.groupProperties.get(globalGroupId)!.add(propertyName);
}
}
} else if (tableName === "events") {
if (this.isVirtualTableType(tableType) && tableType.field === "poe") {
this.personProperties.add(propertyName);
} else {
this.eventProperties.add(propertyName);
}
}
}
}
}
super.visitPropertyType(node);
}
visitField(node: Field): void {
super.visitField(node);
if (this.isFieldType(node.type)) {
const dbField = (node.type as any).resolve_database_field?.(this.context);
if (this.isDateTimeDatabaseField(dbField)) {
this.foundTimestamps = true;
}
}
}
private isBaseTableType(type: any): type is BaseTableType {
return type && typeof type.resolve_database_table === "function";
}
private isLazyJoinType(type: any): type is LazyJoinType {
return type && "lazy_join" in type && "field" in type;
}
private isLazyTableType(type: any): type is LazyTableType {
return type && "table" in type && !("lazy_join" in type);
}
private isVirtualTableType(type: any): type is VirtualTableType {
return type && "virtual_table" in type && "field" in type;
}
private isFieldType(type: any): type is FieldType {
return type && typeof type.resolve_database_field === "function";
}
private isDateTimeDatabaseField(field: any): field is DateTimeDatabaseField {
return field && "name" in field; // Simplified check
}
}
// PropertySwapper: Transforms property accesses with type conversions
export class PropertySwapper extends CloningVisitor {
timezone: string;
eventProperties: Map<string, string>;
personProperties: Map<string, string>;
groupProperties: Map<string, string>;
context: TSQLContext;
setTimeZones: boolean;
constructor(
timezone: string,
eventProperties: Map<string, string> | Record<string, string>,
personProperties: Map<string, string> | Record<string, string>,
groupProperties: Map<string, string> | Record<string, string>,
context: TSQLContext,
setTimeZones: boolean
) {
super(false); // Don't clear types
this.timezone = timezone;
this.eventProperties =
eventProperties instanceof Map ? eventProperties : new Map(Object.entries(eventProperties));
this.personProperties =
personProperties instanceof Map
? personProperties
: new Map(Object.entries(personProperties));
this.groupProperties =
groupProperties instanceof Map ? groupProperties : new Map(Object.entries(groupProperties));
this.context = context;
this.setTimeZones = setTimeZones;
}
visitField(node: Field): any {
if (this.isFieldType(node.type)) {
if (this.setTimeZones) {
const dbField = (node.type as any).resolve_database_field?.(this.context);
if (this.isDateTimeDatabaseField(dbField)) {
return this.createToTimeZoneCall(node);
}
}
if (this.isLazyJoinType(node.type.table_type)) {
const lazyJoinType = node.type.table_type;
const resolvedTable = lazyJoinType.lazy_join.resolve_table?.(this.context);
// Check if it's an S3Table-like table (has fields property)
if (resolvedTable && "fields" in resolvedTable) {
const field = node.chain[node.chain.length - 1];
const fieldType = resolvedTable.fields[String(field)];
let propType = "String";
if (this.isDateTimeDatabaseField(fieldType)) {
propType = "DateTime";
} else if (this.isBooleanDatabaseField(fieldType)) {
propType = "Boolean";
}
return this.fieldTypeToPropertyCall(node, propType);
}
}
}
const type = node.type;
if (
this.isPropertyType(type) &&
type.field_type.name === "properties" &&
type.chain.length === 1
) {
const propertyName = String(type.chain[0]);
const tableType = type.field_type.table_type;
if (this.isVirtualTableType(tableType) && tableType.field === "poe") {
if (this.personProperties.has(propertyName)) {
return this.convertStringPropertyToType(node, "person", propertyName);
}
} else if (this.isBaseTableType(tableType)) {
const table = tableType.resolve_database_table?.(this.context);
if (table) {
const tableName = table.to_printed_tsql?.() || "";
if (tableName === "persons" || tableName === "raw_persons") {
if (this.personProperties.has(propertyName)) {
return this.convertStringPropertyToType(node, "person", propertyName);
}
} else if (tableName === "groups") {
if (this.isLazyJoinType(tableType)) {
if (tableType.field.startsWith("group_")) {
const groupId = parseInt(tableType.field.split("_")[1], 10);
const groupKey = `${groupId}_${propertyName}`;
if (this.groupProperties.has(groupKey)) {
return this.convertStringPropertyToType(node, "group", groupKey);
}
}
} else if (this.isLazyTableType(tableType)) {
const globalGroupId = this.context.globals?.group_id;
if (typeof globalGroupId === "number") {
const groupKey = `${globalGroupId}_${propertyName}`;
if (this.groupProperties.has(groupKey)) {
return this.convertStringPropertyToType(node, "group", groupKey);
}
}
}
} else if (tableName === "events") {
if (this.eventProperties.has(propertyName)) {
return this.convertStringPropertyToType(node, "event", propertyName);
}
}
}
}
}
if (
this.isPropertyType(type) &&
type.field_type.name === "person_properties" &&
type.chain.length === 1
) {
const propertyName = String(type.chain[0]);
const tableType = type.field_type.table_type;
if (this.isBaseTableType(tableType)) {
const table = tableType.resolve_database_table?.(this.context);
if (table) {
const tableName = table.to_printed_tsql?.() || "";
if (tableName === "events") {
if (this.personProperties.has(propertyName)) {
return this.convertStringPropertyToType(node, "person", propertyName);
}
}
}
}
}
return super.visitField(node);
}
private convertStringPropertyToType(
node: Field,
propertyType: "event" | "person" | "group",
propertyName: string
): Expr {
let fieldTypeValue: string | undefined;
if (propertyType === "person") {
fieldTypeValue = this.personProperties.get(propertyName);
} else if (propertyType === "group") {
fieldTypeValue = this.groupProperties.get(propertyName);
} else {
fieldTypeValue = this.eventProperties.get(propertyName);
}
const fieldType = fieldTypeValue === "Numeric" ? "Float" : fieldTypeValue || "String";
this.addPropertyNotice(node, propertyType, fieldType);
return this.fieldTypeToPropertyCall(node, fieldType);
}
private fieldTypeToPropertyCall(node: Field, fieldType: string): Expr {
if (fieldType === "DateTime") {
return this.createToDateTimeCall(node);
}
if (fieldType === "Float") {
return this.createToFloatCall(node);
}
if (fieldType === "Boolean") {
return this.createToBoolCall(node);
}
return node;
}
private createToTimeZoneCall(node: Field): Call {
return {
expression_type: "call",
name: "toTimeZone",
args: [node, this.createConstant(this.timezone)],
type: {
name: "toTimeZone",
arg_types: [{ data_type: "datetime" } as DateTimeType],
return_type: { data_type: "datetime" } as DateTimeType,
} as CallType,
start: node.start,
end: node.end,
} as Call;
}
private createToDateTimeCall(node: Field): Call {
return {
expression_type: "call",
name: "toDateTime",
args: [node],
start: node.start,
end: node.end,
};
}
private createToFloatCall(node: Field): Call {
return {
expression_type: "call",
name: "toFloat",
args: [node],
start: node.start,
end: node.end,
};
}
private createToBoolCall(node: Field): Call {
return {
expression_type: "call",
name: "toBool",
args: [
{
name: "transform",
args: [
{
name: "toString",
args: [node],
start: node.start,
end: node.end,
} as Call,
this.createConstant(["true", "false"]),
this.createConstant([1, 0]),
this.createConstant(null),
],
start: node.start,
end: node.end,
} as Call,
],
start: node.start,
end: node.end,
} as Call;
}
private createConstant(value: any): Constant {
return {
expression_type: "constant",
value,
};
}
private addPropertyNotice(
node: Field,
propertyType: "event" | "person" | "group",
fieldType: string
): void {
let propertyName = String(node.chain[node.chain.length - 1]);
let materializedColumn: any = null; // MaterializedColumn type not yet defined
if (propertyType === "person") {
// if (this.context.modifiers.personsOnEventsMode !== "disabled") {
// materializedColumn = getMaterializedColumnForProperty('events', propertyName, 'person_properties');
// } else {
// materializedColumn = getMaterializedColumnForProperty('person', propertyName, 'properties');
// }
} else if (propertyType === "group") {
const nameParts = propertyName.split("_");
nameParts.shift();
propertyName = nameParts.join("_");
// materializedColumn = getMaterializedColumnForProperty('groups', propertyName, 'properties');
} else {
// materializedColumn = getMaterializedColumnForProperty('events', propertyName, 'properties');
}
let message = `${
propertyType.charAt(0).toUpperCase() + propertyType.slice(1)
} property '${propertyName}' is of type '${fieldType}'.`;
if (this.context.debug) {
if (materializedColumn !== null) {
message += " This property is materialized ⚡️.";
} else {
message += " This property is not materialized 🐢.";
}
}
this.addNotice(node, message);
}
private addNotice(node: Field, message: string): void {
if (node.start === undefined || node.end === undefined) {
return; // Don't add notices for nodes without location
}
// Only highlight the last part of the chain
const lastPart = node.chain[node.chain.length - 1];
const identifierLength = escapeTSQLIdentifier(lastPart).length;
this.context.notices.push({
start: Math.max(node.start, node.end - identifierLength),
end: node.end,
message,
});
}
private isFieldType(type: any): type is FieldType {
return type && typeof type.resolve_database_field === "function";
}
private isPropertyType(type: any): type is PropertyType {
return type && "field_type" in type && "chain" in type;
}
private isBaseTableType(type: any): type is BaseTableType {
return type && typeof type.resolve_database_table === "function";
}
private isLazyJoinType(type: any): type is LazyJoinType {
return type && "lazy_join" in type && "field" in type;
}
private isLazyTableType(type: any): type is LazyTableType {
return type && "table" in type && !("lazy_join" in type);
}
private isVirtualTableType(type: any): type is VirtualTableType {
return type && "virtual_table" in type && "field" in type;
}
private isDateTimeDatabaseField(field: any): field is DateTimeDatabaseField {
return field && "name" in field; // Simplified check
}
private isBooleanDatabaseField(field: any): field is BooleanDatabaseField {
return field && "name" in field; // Simplified check
}
}
// Main function to build property swapper
export function buildPropertySwapper(node: AST, context: TSQLContext): void {
if (!context || !context.team_id) {
return;
}
// NOTE: In TypeScript, you'll need to fetch the team from your database/ORM
// This is a placeholder - replace with your actual team fetching logic
// if (!context.team) {
// context.team = await Team.findById(context.team_id);
// }
if (!context.team) {
return;
}
// Find all properties
const propertyFinder = new PropertyFinder(context);
propertyFinder.visit(node);
// NOTE: In TypeScript, you'll need to query PropertyDefinition from your database
// This is a placeholder - replace with your actual property definition fetching logic
// const eventPropertyValues = await PropertyDefinition.find({
// project_id: context.team.project_id,
// name: { $in: Array.from(propertyFinder.eventProperties) },
// type: { $in: [null, 'event'] },
// }).select('name property_type');
// const eventProperties = new Map(
// eventPropertyValues.filter((p: any) => p.property_type).map((p: any) => [p.name, p.property_type])
// );
const eventProperties = new Map<string, string>();
const personProperties = new Map<string, string>();
const groupProperties = new Map<string, string>();
// TODO: Implement actual property definition fetching from database
// For now, these are empty maps
const timezone = (context.database as any)?._timezone || "UTC";
context.property_swapper = new PropertySwapper(
timezone,
eventProperties,
personProperties,
groupProperties,
context,
true
);
}
@@ -0,0 +1,231 @@
import { describe, it, expect } from "vitest";
import { transformResults, createResultTransformer } from "./results.js";
import { column, type TableSchema } from "./schema.js";
/**
* Test schema with valueMap
*/
const taskRunsSchema: TableSchema = {
name: "task_runs",
clickhouseName: "trigger_dev.task_runs_v2",
columns: {
id: { name: "id", ...column("String") },
status: {
name: "status",
...column("String"),
valueMap: {
COMPLETED_SUCCESSFULLY: "Completed",
COMPLETED_WITH_ERRORS: "Completed with errors",
SYSTEM_FAILURE: "System failure",
PENDING: "Pending",
EXECUTING: "Running",
FAILED: "Failed",
CANCELLED: "Cancelled",
},
},
environment_type: {
name: "environment_type",
...column("String"),
valueMap: {
DEVELOPMENT: "Development",
STAGING: "Staging",
PRODUCTION: "Production",
},
},
task_identifier: { name: "task_identifier", ...column("String") },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
/**
* Schema without valueMap
*/
const simpleSchema: TableSchema = {
name: "simple",
clickhouseName: "trigger_dev.simple",
columns: {
id: { name: "id", ...column("String") },
name: { name: "name", ...column("String") },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
describe("transformResults", () => {
it("should transform internal values to user-friendly values", () => {
const rows = [
{ id: "run_1", status: "COMPLETED_SUCCESSFULLY", task_identifier: "my-task" },
{ id: "run_2", status: "PENDING", task_identifier: "other-task" },
{ id: "run_3", status: "FAILED", task_identifier: "my-task" },
];
const transformed = transformResults(rows, [taskRunsSchema]);
expect(transformed[0].status).toBe("Completed");
expect(transformed[1].status).toBe("Pending");
expect(transformed[2].status).toBe("Failed");
});
it("should transform multiple columns with valueMaps", () => {
const rows = [
{ id: "run_1", status: "COMPLETED_SUCCESSFULLY", environment_type: "PRODUCTION" },
{ id: "run_2", status: "PENDING", environment_type: "DEVELOPMENT" },
];
const transformed = transformResults(rows, [taskRunsSchema]);
expect(transformed[0].status).toBe("Completed");
expect(transformed[0].environment_type).toBe("Production");
expect(transformed[1].status).toBe("Pending");
expect(transformed[1].environment_type).toBe("Development");
});
it("should not modify columns without valueMap", () => {
const rows = [{ id: "run_1", status: "COMPLETED_SUCCESSFULLY", task_identifier: "my-task" }];
const transformed = transformResults(rows, [taskRunsSchema]);
// id and task_identifier should be unchanged
expect(transformed[0].id).toBe("run_1");
expect(transformed[0].task_identifier).toBe("my-task");
});
it("should pass through values not in valueMap unchanged", () => {
const rows = [{ id: "run_1", status: "UNKNOWN_STATUS", task_identifier: "my-task" }];
const transformed = transformResults(rows, [taskRunsSchema]);
// UNKNOWN_STATUS is not in the valueMap, should be passed through
expect(transformed[0].status).toBe("UNKNOWN_STATUS");
});
it("should return original rows if no columns have valueMap", () => {
const rows = [
{ id: "run_1", name: "test" },
{ id: "run_2", name: "other" },
];
const transformed = transformResults(rows, [simpleSchema]);
// Should return the same array (reference equality)
expect(transformed).toBe(rows);
});
it("should handle empty rows array", () => {
const rows: Array<{ id: string; status: string }> = [];
const transformed = transformResults(rows, [taskRunsSchema]);
expect(transformed).toEqual([]);
});
it("should handle case-insensitive internal value matching", () => {
const rows = [
{ id: "run_1", status: "completed_successfully" },
{ id: "run_2", status: "COMPLETED_SUCCESSFULLY" },
{ id: "run_3", status: "Completed_Successfully" },
];
const transformed = transformResults(rows, [taskRunsSchema]);
// All should map to "Completed"
expect(transformed[0].status).toBe("Completed");
expect(transformed[1].status).toBe("Completed");
expect(transformed[2].status).toBe("Completed");
});
it("should preserve non-string column values", () => {
const rows = [{ id: "run_1", status: "COMPLETED_SUCCESSFULLY", count: 42, active: true }];
const transformed = transformResults(rows, [taskRunsSchema]);
expect(transformed[0].count).toBe(42);
expect(transformed[0].active).toBe(true);
expect(transformed[0].status).toBe("Completed");
});
it("should preserve row reference if no changes made", () => {
const rows = [{ id: "run_1", status: "UNKNOWN_STATUS" }];
const transformed = transformResults(rows, [taskRunsSchema]);
// The row has status that doesn't match any valueMap entry
// But the column does have a valueMap, so we still check it
// Since the value doesn't change, the row reference should be preserved
expect(transformed[0]).toBe(rows[0]);
});
it("should handle multiple table schemas", () => {
const anotherSchema: TableSchema = {
name: "events",
clickhouseName: "trigger_dev.events",
columns: {
id: { name: "id", ...column("String") },
event_type: {
name: "event_type",
...column("String"),
valueMap: {
TASK_START: "Started",
TASK_END: "Ended",
},
},
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
const rows = [
{ status: "COMPLETED_SUCCESSFULLY", event_type: "TASK_START" },
{ status: "PENDING", event_type: "TASK_END" },
];
const transformed = transformResults(rows, [taskRunsSchema, anotherSchema]);
expect(transformed[0].status).toBe("Completed");
expect(transformed[0].event_type).toBe("Started");
expect(transformed[1].status).toBe("Pending");
expect(transformed[1].event_type).toBe("Ended");
});
});
describe("createResultTransformer", () => {
it("should create a reusable transformer function", () => {
const transform = createResultTransformer([taskRunsSchema]);
const rows1 = [{ id: "1", status: "COMPLETED_SUCCESSFULLY" }];
const rows2 = [{ id: "2", status: "FAILED" }];
const transformed1 = transform(rows1);
const transformed2 = transform(rows2);
expect(transformed1[0].status).toBe("Completed");
expect(transformed2[0].status).toBe("Failed");
});
it("should return original rows if no valueMap columns exist", () => {
const transform = createResultTransformer([simpleSchema]);
const rows = [{ id: "1", name: "test" }];
const transformed = transform(rows);
expect(transformed).toBe(rows);
});
});
+200
View File
@@ -0,0 +1,200 @@
/**
* Result transformation utilities for TSQL queries
*
* Transforms query result values from internal ClickHouse values
* to user-friendly display names using the column valueMap or fieldMapping.
*/
import type { TableSchema, ColumnSchema, FieldMappings } from "./schema.js";
import { getUserFriendlyValue, hasFieldMapping, getExternalValue } from "./schema.js";
/**
* Options for transforming query results
*/
export interface TransformResultsOptions {
/**
* If true, transform values even if the column was aliased (e.g., SELECT status AS s)
* Default: false (aliased columns are not transformed since the user explicitly chose a different name)
*/
transformAliased?: boolean;
/**
* Runtime field mappings for dynamic value translation.
* Maps internal ClickHouse values to external user-facing values.
* Values not found in the mapping will be returned as null.
*/
fieldMappings?: FieldMappings;
}
/**
* Transform query result rows, mapping internal values to user-friendly display names
*
* This function iterates over result rows and transforms any column values that have
* a `valueMap` or `fieldMapping` defined in their schema, converting internal ClickHouse values
* (e.g., 'COMPLETED_SUCCESSFULLY') back to user-friendly display names (e.g., 'Completed').
*
* For columns with `fieldMapping`, values not found in the mapping will be returned as null.
*
* @param rows - Array of result rows to transform
* @param schema - Array of table schemas containing column definitions with valueMaps
* @param options - Optional transformation options
* @returns New array of rows with transformed values
*
* @example
* ```typescript
* const schema: TableSchema[] = [{
* name: "task_runs",
* clickhouseName: "trigger_dev.task_runs_v2",
* columns: {
* status: {
* name: "status",
* type: "String",
* valueMap: {
* "COMPLETED_SUCCESSFULLY": "Completed",
* "PENDING": "Pending",
* },
* },
* project_ref: {
* name: "project_ref",
* clickhouseName: "project_id",
* type: "String",
* fieldMapping: "project",
* },
* },
* tenantColumns: { organizationId: "organization_id", projectId: "project_id", environmentId: "environment_id" },
* }];
*
* const results = [{ status: "COMPLETED_SUCCESSFULLY", project_ref: "cm12345" }];
* const transformed = transformResults(results, schema, {
* fieldMappings: { project: { "cm12345": "my-project-ref" } },
* });
* // transformed = [{ status: "Completed", project_ref: "my-project-ref" }]
* ```
*/
export function transformResults<T extends Record<string, unknown>>(
rows: T[],
schema: TableSchema[],
options: TransformResultsOptions = {}
): T[] {
// Build a map of column names to their schemas (for columns that have transformations)
const columnTransformMaps = buildColumnTransformMaps(schema);
// If no columns have transformations, return the original rows unchanged
if (columnTransformMaps.size === 0) {
return rows;
}
// Transform each row
return rows.map((row) => transformRow(row, columnTransformMaps, options.fieldMappings));
}
/**
* Build a map of column names to their schemas for columns that have transformations
* (either valueMap or fieldMapping)
*/
function buildColumnTransformMaps(schema: TableSchema[]): Map<string, ColumnSchema> {
const columnMaps = new Map<string, ColumnSchema>();
for (const table of schema) {
for (const [columnName, columnSchema] of Object.entries(table.columns)) {
const hasValueMap = columnSchema.valueMap && Object.keys(columnSchema.valueMap).length > 0;
const hasFieldMap = hasFieldMapping(columnSchema);
if (hasValueMap || hasFieldMap) {
// Use the TSQL-exposed column name (not the ClickHouse name)
columnMaps.set(columnName, columnSchema);
}
}
}
return columnMaps;
}
/**
* Transform a single value using the column's valueMap or fieldMapping
* Returns the transformed value, or the original value if no transformation applies
* For fieldMapping, returns null if the value is not found in the mapping
*/
function transformSingleValue(
columnSchema: ColumnSchema,
value: string,
fieldMappings?: FieldMappings
): string | null {
// First try static valueMap (always returns the original if no match)
if (columnSchema.valueMap && Object.keys(columnSchema.valueMap).length > 0) {
return getUserFriendlyValue(columnSchema, value);
}
// Then try runtime fieldMapping (returns null if not found)
if (hasFieldMapping(columnSchema) && columnSchema.fieldMapping && fieldMappings) {
const externalValue = getExternalValue(fieldMappings, columnSchema.fieldMapping, value);
// For fieldMapping, return null if not found (per user requirement)
return externalValue;
}
return value;
}
/**
* Transform a single row's values using the column valueMaps and fieldMappings
*/
function transformRow<T extends Record<string, unknown>>(
row: T,
columnTransformMaps: Map<string, ColumnSchema>,
fieldMappings?: FieldMappings
): T {
const transformedRow: Record<string, unknown> = {};
let hasChanges = false;
for (const [key, value] of Object.entries(row)) {
const columnSchema = columnTransformMaps.get(key);
if (columnSchema && typeof value === "string") {
const transformedValue = transformSingleValue(columnSchema, value, fieldMappings);
transformedRow[key] = transformedValue;
if (transformedValue !== value) {
hasChanges = true;
}
} else {
transformedRow[key] = value;
}
}
// Return original row if no changes were made (preserves reference equality)
return hasChanges ? (transformedRow as T) : row;
}
/**
* Create a result transformer bound to a specific schema
*
* Useful when you need to transform multiple result sets with the same schema.
*
* @param schema - Array of table schemas
* @param options - Optional transformation options (including fieldMappings)
* @returns A function that transforms result rows
*
* @example
* ```typescript
* const transform = createResultTransformer(schema, {
* fieldMappings: { project: { "cm12345": "my-project-ref" } },
* });
*
* const results1 = await query1();
* const transformed1 = transform(results1);
*
* const results2 = await query2();
* const transformed2 = transform(results2);
* ```
*/
export function createResultTransformer(
schema: TableSchema[],
options: TransformResultsOptions = {}
): <T extends Record<string, unknown>>(rows: T[]) => T[] {
const columnTransformMaps = buildColumnTransformMaps(schema);
return <T extends Record<string, unknown>>(rows: T[]): T[] => {
if (columnTransformMaps.size === 0) {
return rows;
}
return rows.map((row) => transformRow(row, columnTransformMaps, options.fieldMappings));
};
}
@@ -0,0 +1,792 @@
import { describe, it, expect } from "vitest";
import {
column,
getUserFriendlyValue,
getInternalValue,
getAllowedUserValues,
isValidUserValue,
isVirtualColumn,
getVirtualColumnExpression,
hasFieldMapping,
getExternalValue,
getInternalValueFromMapping,
getInternalValueFromMappingCaseInsensitive,
sanitizeErrorMessage,
type ColumnSchema,
type FieldMappings,
type TableSchema,
} from "./schema.js";
describe("Value mapping helper functions", () => {
const columnWithValueMap: ColumnSchema = {
name: "status",
...column("String"),
valueMap: {
COMPLETED_SUCCESSFULLY: "Completed",
COMPLETED_WITH_ERRORS: "Completed with errors",
SYSTEM_FAILURE: "System failure",
PENDING: "Pending",
EXECUTING: "Running",
FAILED: "Failed",
},
};
const columnWithAllowedValues: ColumnSchema = {
name: "status",
...column("String"),
allowedValues: ["completed", "pending", "failed"],
};
const columnWithNoRestrictions: ColumnSchema = {
name: "task_identifier",
...column("String"),
};
describe("getUserFriendlyValue", () => {
it("should return user-friendly value for internal value", () => {
expect(getUserFriendlyValue(columnWithValueMap, "COMPLETED_SUCCESSFULLY")).toBe("Completed");
expect(getUserFriendlyValue(columnWithValueMap, "PENDING")).toBe("Pending");
expect(getUserFriendlyValue(columnWithValueMap, "EXECUTING")).toBe("Running");
});
it("should be case-insensitive for internal value lookup", () => {
expect(getUserFriendlyValue(columnWithValueMap, "completed_successfully")).toBe("Completed");
expect(getUserFriendlyValue(columnWithValueMap, "Completed_Successfully")).toBe("Completed");
expect(getUserFriendlyValue(columnWithValueMap, "COMPLETED_SUCCESSFULLY")).toBe("Completed");
});
it("should return original value if no mapping exists", () => {
expect(getUserFriendlyValue(columnWithValueMap, "UNKNOWN_STATUS")).toBe("UNKNOWN_STATUS");
});
it("should return original value if column has no valueMap", () => {
expect(getUserFriendlyValue(columnWithNoRestrictions, "any_value")).toBe("any_value");
});
});
describe("getInternalValue", () => {
it("should return internal value for user-friendly value", () => {
expect(getInternalValue(columnWithValueMap, "Completed")).toBe("COMPLETED_SUCCESSFULLY");
expect(getInternalValue(columnWithValueMap, "Pending")).toBe("PENDING");
expect(getInternalValue(columnWithValueMap, "Running")).toBe("EXECUTING");
});
it("should be case-insensitive for user-friendly value lookup", () => {
expect(getInternalValue(columnWithValueMap, "completed")).toBe("COMPLETED_SUCCESSFULLY");
expect(getInternalValue(columnWithValueMap, "COMPLETED")).toBe("COMPLETED_SUCCESSFULLY");
expect(getInternalValue(columnWithValueMap, "Completed")).toBe("COMPLETED_SUCCESSFULLY");
});
it("should return original value if no mapping exists", () => {
expect(getInternalValue(columnWithValueMap, "Unknown")).toBe("Unknown");
});
it("should return original value if column has no valueMap", () => {
expect(getInternalValue(columnWithNoRestrictions, "any_value")).toBe("any_value");
});
it("should handle multi-word user-friendly values", () => {
expect(getInternalValue(columnWithValueMap, "Completed with errors")).toBe(
"COMPLETED_WITH_ERRORS"
);
expect(getInternalValue(columnWithValueMap, "completed with errors")).toBe(
"COMPLETED_WITH_ERRORS"
);
expect(getInternalValue(columnWithValueMap, "System failure")).toBe("SYSTEM_FAILURE");
});
});
describe("getAllowedUserValues", () => {
it("should return user-friendly values from valueMap", () => {
const values = getAllowedUserValues(columnWithValueMap);
expect(values).toContain("Completed");
expect(values).toContain("Pending");
expect(values).toContain("Running");
expect(values).toContain("Failed");
expect(values).toContain("Completed with errors");
expect(values).toContain("System failure");
expect(values).toHaveLength(6);
});
it("should return allowedValues if no valueMap exists", () => {
const values = getAllowedUserValues(columnWithAllowedValues);
expect(values).toEqual(["completed", "pending", "failed"]);
});
it("should prefer valueMap over allowedValues", () => {
const columnWithBoth: ColumnSchema = {
name: "status",
...column("String"),
allowedValues: ["internal1", "internal2"],
valueMap: {
internal1: "User 1",
internal2: "User 2",
},
};
const values = getAllowedUserValues(columnWithBoth);
expect(values).toEqual(["User 1", "User 2"]);
});
it("should return empty array for column with no restrictions", () => {
const values = getAllowedUserValues(columnWithNoRestrictions);
expect(values).toEqual([]);
});
});
describe("isValidUserValue", () => {
it("should return true for valid user-friendly values", () => {
expect(isValidUserValue(columnWithValueMap, "Completed")).toBe(true);
expect(isValidUserValue(columnWithValueMap, "Pending")).toBe(true);
expect(isValidUserValue(columnWithValueMap, "Running")).toBe(true);
});
it("should be case-insensitive", () => {
expect(isValidUserValue(columnWithValueMap, "completed")).toBe(true);
expect(isValidUserValue(columnWithValueMap, "COMPLETED")).toBe(true);
expect(isValidUserValue(columnWithValueMap, "running")).toBe(true);
});
it("should return false for invalid values", () => {
expect(isValidUserValue(columnWithValueMap, "Unknown")).toBe(false);
expect(isValidUserValue(columnWithValueMap, "COMPLETED_SUCCESSFULLY")).toBe(false); // internal value, not user-friendly
});
it("should return true for any value if column has no restrictions", () => {
expect(isValidUserValue(columnWithNoRestrictions, "any_value")).toBe(true);
expect(isValidUserValue(columnWithNoRestrictions, "another")).toBe(true);
});
it("should validate against allowedValues if no valueMap", () => {
expect(isValidUserValue(columnWithAllowedValues, "completed")).toBe(true);
expect(isValidUserValue(columnWithAllowedValues, "COMPLETED")).toBe(true);
expect(isValidUserValue(columnWithAllowedValues, "unknown")).toBe(false);
});
it("should handle multi-word values", () => {
expect(isValidUserValue(columnWithValueMap, "Completed with errors")).toBe(true);
expect(isValidUserValue(columnWithValueMap, "completed with errors")).toBe(true);
expect(isValidUserValue(columnWithValueMap, "System failure")).toBe(true);
});
});
});
describe("Virtual column helper functions", () => {
const virtualColumn: ColumnSchema = {
name: "execution_duration",
...column("Nullable(Int64)"),
expression: "dateDiff('millisecond', started_at, completed_at)",
description: "Time between started_at and completed_at in milliseconds",
};
const regularColumn: ColumnSchema = {
name: "status",
...column("String"),
};
const columnWithEmptyExpression: ColumnSchema = {
name: "bad_column",
...column("String"),
expression: "",
};
describe("isVirtualColumn", () => {
it("should return true for columns with expression defined", () => {
expect(isVirtualColumn(virtualColumn)).toBe(true);
});
it("should return false for regular columns without expression", () => {
expect(isVirtualColumn(regularColumn)).toBe(false);
});
it("should return false for columns with empty expression", () => {
expect(isVirtualColumn(columnWithEmptyExpression)).toBe(false);
});
it("should return false for columns with undefined expression", () => {
const col: ColumnSchema = {
name: "test",
...column("String"),
expression: undefined,
};
expect(isVirtualColumn(col)).toBe(false);
});
});
describe("getVirtualColumnExpression", () => {
it("should return the expression for virtual columns", () => {
expect(getVirtualColumnExpression(virtualColumn)).toBe(
"dateDiff('millisecond', started_at, completed_at)"
);
});
it("should return undefined for regular columns", () => {
expect(getVirtualColumnExpression(regularColumn)).toBeUndefined();
});
it("should return undefined for columns with empty expression", () => {
expect(getVirtualColumnExpression(columnWithEmptyExpression)).toBeUndefined();
});
});
describe("virtual column schema definition", () => {
it("should allow defining virtual columns with all standard column options", () => {
const virtualWithOptions: ColumnSchema = {
name: "computed_value",
type: "Float64",
expression: "usage_duration_ms / 1000.0",
selectable: true,
filterable: true,
sortable: true,
groupable: false, // Might not want to group by computed values
description: "Usage duration in seconds",
};
expect(isVirtualColumn(virtualWithOptions)).toBe(true);
expect(virtualWithOptions.groupable).toBe(false);
expect(virtualWithOptions.selectable).toBe(true);
});
it("should support complex expressions with ClickHouse functions", () => {
const complexVirtual: ColumnSchema = {
name: "is_long_running",
...column("UInt8"),
expression:
"if(completed_at IS NOT NULL AND started_at IS NOT NULL, dateDiff('second', started_at, completed_at) > 60, 0)",
};
expect(isVirtualColumn(complexVirtual)).toBe(true);
expect(getVirtualColumnExpression(complexVirtual)).toContain("dateDiff");
expect(getVirtualColumnExpression(complexVirtual)).toContain("if(");
});
});
});
describe("Field mapping helper functions (runtime dynamic mappings)", () => {
const fieldMappings: FieldMappings = {
project: {
cm12345: "my-project-ref",
cm67890: "other-project",
cmABCDE: "Mixed-Case-Project",
},
environment: {
env123: "production",
env456: "staging",
},
};
const columnWithFieldMapping: ColumnSchema = {
name: "project_ref",
clickhouseName: "project_id",
...column("String"),
fieldMapping: "project",
};
const columnWithoutFieldMapping: ColumnSchema = {
name: "status",
...column("String"),
};
const columnWithEmptyFieldMapping: ColumnSchema = {
name: "test",
...column("String"),
fieldMapping: "",
};
describe("hasFieldMapping", () => {
it("should return true for columns with fieldMapping defined", () => {
expect(hasFieldMapping(columnWithFieldMapping)).toBe(true);
});
it("should return false for columns without fieldMapping", () => {
expect(hasFieldMapping(columnWithoutFieldMapping)).toBe(false);
});
it("should return false for columns with empty fieldMapping", () => {
expect(hasFieldMapping(columnWithEmptyFieldMapping)).toBe(false);
});
it("should return false for columns with undefined fieldMapping", () => {
const col: ColumnSchema = {
name: "test",
...column("String"),
fieldMapping: undefined,
};
expect(hasFieldMapping(col)).toBe(false);
});
});
describe("getExternalValue", () => {
it("should return external value for internal value", () => {
expect(getExternalValue(fieldMappings, "project", "cm12345")).toBe("my-project-ref");
expect(getExternalValue(fieldMappings, "project", "cm67890")).toBe("other-project");
expect(getExternalValue(fieldMappings, "environment", "env123")).toBe("production");
});
it("should return null if internal value is not found in mapping", () => {
expect(getExternalValue(fieldMappings, "project", "unknown_id")).toBeNull();
expect(getExternalValue(fieldMappings, "environment", "unknown_env")).toBeNull();
});
it("should return null if mapping name does not exist", () => {
expect(getExternalValue(fieldMappings, "nonexistent", "cm12345")).toBeNull();
});
it("should return null for empty mappings", () => {
expect(getExternalValue({}, "project", "cm12345")).toBeNull();
});
it("should be case-sensitive for internal values", () => {
// Internal IDs should be matched exactly
expect(getExternalValue(fieldMappings, "project", "CM12345")).toBeNull();
expect(getExternalValue(fieldMappings, "project", "cm12345")).toBe("my-project-ref");
});
});
describe("getInternalValueFromMapping", () => {
it("should return internal value for external value", () => {
expect(getInternalValueFromMapping(fieldMappings, "project", "my-project-ref")).toBe(
"cm12345"
);
expect(getInternalValueFromMapping(fieldMappings, "project", "other-project")).toBe(
"cm67890"
);
expect(getInternalValueFromMapping(fieldMappings, "environment", "production")).toBe(
"env123"
);
});
it("should return null if external value is not found", () => {
expect(getInternalValueFromMapping(fieldMappings, "project", "unknown-ref")).toBeNull();
});
it("should return null if mapping name does not exist", () => {
expect(
getInternalValueFromMapping(fieldMappings, "nonexistent", "my-project-ref")
).toBeNull();
});
it("should return null for empty mappings", () => {
expect(getInternalValueFromMapping({}, "project", "my-project-ref")).toBeNull();
});
it("should be case-sensitive for external values", () => {
// This is the case-sensitive version
expect(getInternalValueFromMapping(fieldMappings, "project", "MY-PROJECT-REF")).toBeNull();
expect(getInternalValueFromMapping(fieldMappings, "project", "my-project-ref")).toBe(
"cm12345"
);
});
});
describe("getInternalValueFromMappingCaseInsensitive", () => {
it("should return internal value for external value (case-insensitive)", () => {
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "project", "my-project-ref")
).toBe("cm12345");
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "project", "MY-PROJECT-REF")
).toBe("cm12345");
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "project", "My-Project-Ref")
).toBe("cm12345");
});
it("should return null if external value is not found (even case-insensitive)", () => {
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "project", "unknown-ref")
).toBeNull();
});
it("should return null if mapping name does not exist", () => {
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "nonexistent", "my-project-ref")
).toBeNull();
});
it("should return null for empty mappings", () => {
expect(
getInternalValueFromMappingCaseInsensitive({}, "project", "my-project-ref")
).toBeNull();
});
it("should handle mixed case external values correctly", () => {
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "project", "mixed-case-project")
).toBe("cmABCDE");
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "project", "MIXED-CASE-PROJECT")
).toBe("cmABCDE");
});
});
describe("field mapping column schema definition", () => {
it("should allow defining columns with fieldMapping", () => {
const col: ColumnSchema = {
name: "project_ref",
clickhouseName: "project_id",
type: "String",
fieldMapping: "project",
description: "Project reference (external identifier)",
};
expect(hasFieldMapping(col)).toBe(true);
expect(col.clickhouseName).toBe("project_id");
expect(col.fieldMapping).toBe("project");
});
it("should allow fieldMapping with all standard column options", () => {
const col: ColumnSchema = {
name: "project_ref",
clickhouseName: "project_id",
...column("String"),
fieldMapping: "project",
selectable: true,
filterable: true,
sortable: true,
groupable: true,
};
expect(hasFieldMapping(col)).toBe(true);
expect(col.selectable).toBe(true);
expect(col.filterable).toBe(true);
});
});
});
describe("Error message sanitization", () => {
// Test schema mimicking the real runs schema
const runsSchema: TableSchema = {
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
description: "Task runs table",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
columns: {
run_id: {
name: "run_id",
clickhouseName: "friendly_id",
...column("String"),
},
triggered_at: {
name: "triggered_at",
clickhouseName: "created_at",
...column("DateTime64"),
},
machine: {
name: "machine",
clickhouseName: "machine_preset",
...column("String"),
},
status: {
name: "status",
// No clickhouseName - same as name
...column("String"),
},
task_identifier: {
name: "task_identifier",
// No clickhouseName - same as name
...column("String"),
},
},
};
describe("sanitizeErrorMessage", () => {
it("should replace fully qualified table.column references", () => {
const error = "Missing column trigger_dev.task_runs_v2.friendly_id in query";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Missing column runs.run_id in query");
});
it("should replace standalone table names", () => {
const error = "Table trigger_dev.task_runs_v2 does not exist";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Table runs does not exist");
});
it("should replace standalone column names with different clickhouseName", () => {
const error = "Unknown identifier: friendly_id";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Unknown identifier: run_id");
});
it("should replace multiple occurrences in the same message", () => {
const error =
"Cannot compare friendly_id with created_at: incompatible types in trigger_dev.task_runs_v2";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Cannot compare run_id with triggered_at: incompatible types in runs");
});
it("should not replace column names that have no clickhouseName mapping", () => {
const error = "Invalid value for column status";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Invalid value for column status");
});
it("should handle error messages with quoted identifiers", () => {
const error = "Column 'machine_preset' is not of type String";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Column 'machine' is not of type String");
});
it("should handle error messages with backtick identifiers", () => {
const error = "Unknown column `friendly_id` in table";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Unknown column `run_id` in table");
});
it("should not replace partial matches within larger identifiers", () => {
const error = "Column my_friendly_id_column not found";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
// Should not replace "friendly_id" within "my_friendly_id_column"
expect(sanitized).toBe("Column my_friendly_id_column not found");
});
it("should return original message if no schemas provided", () => {
const error = "Some error with trigger_dev.task_runs_v2";
const sanitized = sanitizeErrorMessage(error, []);
expect(sanitized).toBe("Some error with trigger_dev.task_runs_v2");
});
it("should return original message if no matches found", () => {
const error = "Generic database error occurred";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Generic database error occurred");
});
it("should handle multiple tables", () => {
const eventsSchema: TableSchema = {
name: "events",
clickhouseName: "trigger_dev.task_events",
description: "Task events table",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
columns: {
event_id: {
name: "event_id",
clickhouseName: "internal_event_id",
...column("String"),
},
},
};
const error =
"Cannot join trigger_dev.task_runs_v2 with trigger_dev.task_events on internal_event_id";
const sanitized = sanitizeErrorMessage(error, [runsSchema, eventsSchema]);
expect(sanitized).toBe("Cannot join runs with events on event_id");
});
it("should handle real ClickHouse error format", () => {
const error =
"Unable to query clickhouse: Code: 47. DB::Exception: Missing columns: 'friendly_id' while processing query";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
// Should remove the "Unable to query clickhouse:" prefix
expect(sanitized).toBe(
"Code: 47. DB::Exception: Missing columns: 'run_id' while processing query"
);
});
it("should remove 'Unable to query clickhouse:' prefix", () => {
const error = "Unable to query clickhouse: Something went wrong";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Something went wrong");
expect(sanitized).not.toContain("Unable to query clickhouse");
});
it("should handle error with column in parentheses", () => {
const error = "Function count(friendly_id) expects different arguments";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Function count(run_id) expects different arguments");
});
it("should handle error with column after comma", () => {
const error = "SELECT friendly_id, created_at FROM table";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("SELECT run_id, triggered_at FROM table");
});
it("should NOT replace column names in 'Did you mean' error messages", () => {
// When a user types an internal ClickHouse column name, we show "Did you mean X?"
// The sanitizer should NOT replace the column name in this case, as it would
// turn "Unknown column 'created_at'. Did you mean 'triggered_at'?" into
// "Unknown column 'triggered_at'. Did you mean 'triggered_at'?" which is confusing
const error = 'Unknown column "created_at". Did you mean "triggered_at"?';
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
// Should preserve both the original column name AND the suggestion
expect(sanitized).toBe('Unknown column "created_at". Did you mean "triggered_at"?');
});
it("should prioritize longer matches (table.column before standalone column)", () => {
// This tests that we replace "trigger_dev.task_runs_v2.friendly_id" as a unit,
// not "trigger_dev.task_runs_v2" and then "friendly_id" separately
const error = "Error in trigger_dev.task_runs_v2.friendly_id";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Error in runs.run_id");
});
it("should remove tenant isolation filters from error messages", () => {
const error =
"Unknown identifier in scope SELECT run_id FROM runs WHERE ((organization_id = 'org123') AND (project_id = 'proj456') AND (environment_id = 'env789')) AND (status = 'Failed')";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).not.toContain("organization_id");
expect(sanitized).not.toContain("project_id");
expect(sanitized).not.toContain("environment_id");
expect(sanitized).toContain("status = 'Failed'");
});
it("should remove redundant column aliases like 'run_id AS run_id'", () => {
const error = "Error in SELECT run_id AS run_id, machine AS machine FROM runs";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Error in SELECT run_id, machine FROM runs");
});
it("should remove redundant table aliases like 'runs AS runs'", () => {
const error = "Error in SELECT * FROM runs AS runs WHERE status = 'Failed'";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).toBe("Error in SELECT * FROM runs WHERE status = 'Failed'");
});
it("should handle real error with tenant filters and aliases", () => {
const error =
"Unable to query clickhouse: Unknown expression identifier `triggered_ata` in scope SELECT run_id AS run_id, machine AS machine FROM runs AS runs WHERE ((organization_id = 'cm5qtzpb800007cp7h6ebwt2i') AND (project_id = 'cme2p1yep00007calt8ugarkr') AND (environment_id = 'cme2p1ygj00027caln51kyiwl')) AND (status = 'Complted') ORDER BY triggered_ata DESC LIMIT 100.";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
// Should not contain internal prefix
expect(sanitized).not.toContain("Unable to query clickhouse");
// Should not contain internal IDs
expect(sanitized).not.toContain("cm5qtzpb800007cp7h6ebwt2i");
expect(sanitized).not.toContain("cme2p1yep00007calt8ugarkr");
expect(sanitized).not.toContain("cme2p1ygj00027caln51kyiwl");
expect(sanitized).not.toContain("organization_id");
expect(sanitized).not.toContain("project_id");
expect(sanitized).not.toContain("environment_id");
// Should not have redundant aliases
expect(sanitized).not.toContain("run_id AS run_id");
expect(sanitized).not.toContain("machine AS machine");
expect(sanitized).not.toContain("runs AS runs");
// Should still have the user's query parts
expect(sanitized).toContain("status = 'Complted'");
expect(sanitized).toContain("triggered_ata");
expect(sanitized).toContain("LIMIT 100");
});
it("should remove required filters like engine = 'V2'", () => {
// Schema with required filters
const schemaWithRequiredFilters: TableSchema = {
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
description: "Task runs table",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
requiredFilters: [{ column: "engine", value: "V2" }],
columns: {
run_id: {
name: "run_id",
...column("String"),
},
},
};
const error =
"Error in SELECT run_id FROM runs WHERE ((organization_id = 'org1') AND (engine = 'V2')) AND (status = 'Failed')";
const sanitized = sanitizeErrorMessage(error, [schemaWithRequiredFilters]);
expect(sanitized).not.toContain("engine = 'V2'");
expect(sanitized).not.toContain("organization_id");
expect(sanitized).toContain("status = 'Failed'");
});
it("should handle project and environment field mappings in tenant columns", () => {
// The schema uses 'project' and 'environment' as column names with field mappings
const schemaWithFieldMappedTenants: TableSchema = {
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
description: "Task runs table",
tenantColumns: {
organizationId: "organization_id",
projectId: "project",
environmentId: "environment",
},
columns: {
run_id: {
name: "run_id",
...column("String"),
},
},
};
const error =
"Error WHERE ((organization_id = 'org1') AND (project = 'proj1') AND (environment = 'env1')) AND (status = 'ok')";
const sanitized = sanitizeErrorMessage(error, [schemaWithFieldMappedTenants]);
expect(sanitized).not.toContain("organization_id = 'org1'");
expect(sanitized).not.toContain("project = 'proj1'");
expect(sanitized).not.toContain("environment = 'env1'");
expect(sanitized).toContain("status = 'ok'");
});
it("should handle queries with only automatic WHERE filters (no user WHERE clause)", () => {
// When user writes: SELECT * FROM runs LIMIT 10
// The compiled query becomes: SELECT * FROM runs WHERE (org_id = '...') AND (proj_id = '...') AND (env_id = '...')
const error =
"Unable to query clickhouse: Some error in SELECT run_id FROM runs WHERE ((organization_id = 'org1') AND (project_id = 'proj1') AND (environment_id = 'env1')) LIMIT 10";
const sanitized = sanitizeErrorMessage(error, [runsSchema]);
expect(sanitized).not.toContain("Unable to query clickhouse");
expect(sanitized).not.toContain("organization_id");
expect(sanitized).not.toContain("project_id");
expect(sanitized).not.toContain("environment_id");
expect(sanitized).not.toContain("WHERE");
expect(sanitized).toContain("SELECT run_id FROM runs");
expect(sanitized).toContain("LIMIT 10");
});
it("should handle queries with only automatic filters including engine filter", () => {
const schemaWithEngine: TableSchema = {
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
description: "Task runs table",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
requiredFilters: [{ column: "engine", value: "V2" }],
columns: {
run_id: {
name: "run_id",
...column("String"),
},
},
};
const error =
"Error in SELECT * FROM runs WHERE ((organization_id = 'org1') AND (project_id = 'proj1') AND (environment_id = 'env1') AND (engine = 'V2')) ORDER BY run_id";
const sanitized = sanitizeErrorMessage(error, [schemaWithEngine]);
expect(sanitized).not.toContain("organization_id");
expect(sanitized).not.toContain("project_id");
expect(sanitized).not.toContain("environment_id");
expect(sanitized).not.toContain("engine");
expect(sanitized).not.toContain("WHERE");
expect(sanitized).toContain("SELECT * FROM runs");
expect(sanitized).toContain("ORDER BY run_id");
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,718 @@
/**
* Security Tests for TSQL
*
* These tests verify that the TSQL parser and printer correctly prevent:
* 1. Cross-tenant data access
* 2. SQL injection attacks
*/
import { describe, expect, it } from "vitest";
import { compileTSQL, type CompileTSQLOptions } from "../index.js";
import { column, type TableSchema } from "./schema.js";
/**
* Test schemas
*/
const taskRunsSchema: TableSchema = {
name: "task_runs",
clickhouseName: "trigger_dev.task_runs_v2",
columns: {
id: { name: "id", ...column("String") },
status: { name: "status", ...column("String") },
task_identifier: { name: "task_identifier", ...column("String") },
created_at: { name: "created_at", ...column("DateTime64") },
duration_ms: { name: "duration_ms", ...column("Nullable(UInt64)") },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
payload: { name: "payload", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
const taskEventsSchema: TableSchema = {
name: "task_events",
clickhouseName: "trigger_dev.task_events_v2",
columns: {
id: { name: "id", ...column("String") },
run_id: { name: "run_id", ...column("String") },
event_type: { name: "event_type", ...column("String") },
timestamp: { name: "timestamp", ...column("DateTime64") },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
const defaultOptions: CompileTSQLOptions = {
tableSchema: [taskRunsSchema, taskEventsSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_tenant1" },
project_id: { op: "eq", value: "proj_tenant1" },
environment_id: { op: "eq", value: "env_tenant1" },
},
};
function compile(query: string, options: Partial<CompileTSQLOptions> = {}) {
return compileTSQL(query, { ...defaultOptions, ...options });
}
describe("Cross-Tenant Security", () => {
describe("Tenant guards are always injected", () => {
it("should inject tenant guards on simple SELECT", () => {
const { sql, params } = compile("SELECT * FROM task_runs");
// Must contain all three tenant columns
expect(sql).toContain("organization_id");
expect(sql).toContain("project_id");
expect(sql).toContain("environment_id");
// Tenant values must be parameterized
expect(Object.values(params)).toContain("org_tenant1");
expect(Object.values(params)).toContain("proj_tenant1");
expect(Object.values(params)).toContain("env_tenant1");
});
it("should inject tenant guards even with user WHERE clause", () => {
const { sql, params } = compile("SELECT * FROM task_runs WHERE status = 'completed'");
// Must still have tenant guards
expect(sql).toContain("organization_id");
expect(sql).toContain("project_id");
expect(sql).toContain("environment_id");
expect(Object.values(params)).toContain("org_tenant1");
});
it("should inject tenant guards on all tables in JOIN", () => {
const { sql } = compile(`
SELECT r.id, e.event_type
FROM task_runs r
JOIN task_events e ON r.id = e.run_id
`);
// Both tables should have tenant guards
// Count occurrences of organization_id - should appear twice (once per table)
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
it("should inject tenant guards in subqueries", () => {
const { sql } = compile(`
SELECT * FROM task_runs
WHERE id IN (SELECT run_id FROM task_events WHERE event_type = 'completed')
`);
// Should have tenant guards in both main query and subquery
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
it("should inject tenant guards on UNION queries", () => {
const { sql } = compile(`
SELECT id, status FROM task_runs WHERE status = 'completed'
UNION ALL
SELECT id, status FROM task_runs WHERE status = 'failed'
`);
// Both sides of UNION should have tenant guards
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
});
describe("Cannot bypass tenant guards", () => {
it("should not allow OR clause to bypass tenant guard", () => {
const { sql, params } = compile("SELECT * FROM task_runs WHERE status = 'completed' OR 1=1");
// The tenant guards should be ANDed with the entire WHERE clause
// So even with OR 1=1, the tenant guard still applies
expect(sql).toContain("organization_id");
expect(Object.values(params)).toContain("org_tenant1");
// The structure should be: and(tenant_guards, or(user_conditions))
// The user's OR should be nested inside the outer AND with tenant guards
// This ensures tenant_guard AND (status='completed' OR 1=1)
// NOT: tenant_guard AND status='completed' OR 1=1 (which would bypass)
// Verify the OR is contained within an outer AND structure
// The tenant guards use and() and the user's OR uses or()
expect(sql).toContain("or(");
expect(sql).toContain("and(");
// The and() should wrap everything - find where tenant columns appear
// They should be at the same level as the user's condition, both inside and()
const whereClause = sql.substring(sql.indexOf("WHERE"));
expect(whereClause).toMatch(/and\([^)]*organization_id/);
});
it("should not allow accessing other tenant's data via explicit condition", () => {
const { sql: _sql, params } = compile(
"SELECT * FROM task_runs WHERE organization_id = 'org_other_tenant'"
);
// Even if user specifies a different org_id, our tenant guard should override
// The compiled SQL should still use our tenant's ID
expect(Object.values(params)).toContain("org_tenant1");
});
it("should not allow UNION with unguarded query", () => {
// This should be rejected or the second part should still be guarded
const { sql } = compile(`
SELECT id FROM task_runs
UNION ALL
SELECT id FROM task_runs
`);
// Both parts must have tenant guards
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
it("should not allow subquery to access other tenants", () => {
const { sql, params } = compile(`
SELECT * FROM task_runs
WHERE id IN (
SELECT run_id FROM task_events
)
`);
// Subquery must also have tenant guards
expect(Object.values(params)).toContain("org_tenant1");
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
});
describe("Table allowlisting", () => {
it("should reject queries to unknown tables", () => {
expect(() => {
compile("SELECT * FROM users");
}).toThrow();
});
it("should reject queries to system tables", () => {
expect(() => {
compile("SELECT * FROM system.tables");
}).toThrow();
});
it("should reject queries trying to use database prefix", () => {
expect(() => {
compile("SELECT * FROM other_database.task_runs");
}).toThrow();
});
});
});
describe("SQL Injection Prevention", () => {
describe("String value injection", () => {
it("should reject queries with stacked statements in strings", () => {
// The parser correctly rejects this at parse time
expect(() => {
compile("SELECT * FROM task_runs WHERE status = 'completed'; DROP TABLE task_runs; --'");
}).toThrow();
});
it("should parameterize malicious-looking string values", () => {
const { sql, params } = compile("SELECT * FROM task_runs WHERE status = 'DROP TABLE users'");
// The malicious payload should be in params, not in SQL
expect(sql).not.toContain("DROP TABLE");
expect(Object.values(params)).toContain("DROP TABLE users");
});
it("should handle quote escape attempts", () => {
const { sql: _sql, params } = compile(
"SELECT * FROM task_runs WHERE status = 'test''injection'"
);
// Should be safely parameterized
expect(Object.values(params).some((v) => typeof v === "string")).toBe(true);
});
it("should handle backslash escape attempts", () => {
const { sql, params: _params } = compile(
"SELECT * FROM task_runs WHERE status = 'test\\'injection'"
);
// Should be safely parameterized
expect(sql).not.toContain("injection'");
});
it("should handle unicode characters in strings", () => {
const { sql: _sql, params } = compile(
"SELECT * FROM task_runs WHERE status = 'test™injection'"
);
// Should be safely parameterized
expect(Object.values(params).some((v) => typeof v === "string")).toBe(true);
});
it("should handle null byte injection", () => {
const { sql: _sql, params } = compile(
"SELECT * FROM task_runs WHERE status = 'test\\0injection'"
);
expect(Object.values(params).some((v) => typeof v === "string")).toBe(true);
});
});
describe("Comment injection", () => {
it("should not allow -- comments to truncate query", () => {
// The parser should either reject this or handle it safely
const result = compile("SELECT * FROM task_runs WHERE status = 'completed'");
// Tenant guards must still be present
expect(result.sql).toContain("organization_id");
});
it("should not allow /* */ comments for injection", () => {
const result = compile("SELECT * FROM task_runs WHERE status = 'completed'");
// Tenant guards must still be present
expect(result.sql).toContain("organization_id");
});
});
describe("Identifier injection", () => {
it("should reject identifiers with backtick injection", () => {
expect(() => {
compile("SELECT * FROM task_runs WHERE `status`; DROP TABLE users; --` = 'test'");
}).toThrow();
});
it("should not expose column names that could be used for injection", () => {
// Column names in the output are validated identifiers from the schema
// Malicious column names would need to be in the schema first
const { sql } = compile("SELECT id, status FROM task_runs");
// Column names should be simple identifiers without injection
expect(sql).toContain("id");
expect(sql).toContain("status");
expect(sql).not.toContain(";");
});
it("should reject table names with special characters", () => {
expect(() => {
compile("SELECT * FROM `task_runs; DROP TABLE users`");
}).toThrow();
});
});
describe("Numeric injection", () => {
it("should handle numeric values safely", () => {
const { sql } = compile("SELECT * FROM task_runs WHERE duration_ms > 1000");
// Numbers should be safely inlined or parameterized
expect(sql).toContain("1000");
expect(sql).not.toContain(";");
});
it("should handle negative numbers safely", () => {
const { sql } = compile("SELECT * FROM task_runs WHERE duration_ms > -1");
expect(sql).not.toContain(";");
});
it("should handle floating point safely", () => {
const { sql } = compile("SELECT * FROM task_runs WHERE duration_ms > 1.5");
expect(sql).toContain("1.5");
});
});
describe("Function injection", () => {
it("should only allow known safe functions", () => {
// Unknown functions should be rejected
expect(() => {
compile("SELECT file('/etc/passwd') FROM task_runs");
}).toThrow();
});
it("should reject system functions", () => {
expect(() => {
compile("SELECT system.tables() FROM task_runs");
}).toThrow();
});
it("should allow known aggregate functions", () => {
const { sql } = compile("SELECT count(*), sum(duration_ms) FROM task_runs");
expect(sql).toContain("count(*)");
expect(sql).toContain("sum(duration_ms)");
});
});
describe("Stacked query prevention", () => {
it("should not allow semicolon to start new statement", () => {
expect(() => {
compile("SELECT * FROM task_runs; DELETE FROM task_runs");
}).toThrow();
});
it("should not allow multiple statements", () => {
expect(() => {
compile("SELECT * FROM task_runs; SELECT * FROM task_events");
}).toThrow();
});
});
describe("UNION-based injection", () => {
it("should apply tenant guards to all UNION parts", () => {
const { sql, params } = compile(`
SELECT id, status FROM task_runs WHERE status = 'a'
UNION ALL
SELECT id, status FROM task_runs WHERE status = 'b'
`);
// Both parts should have tenant guards
expect(Object.values(params)).toContain("org_tenant1");
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
});
});
describe("Parameter Safety", () => {
it("should use typed parameters", () => {
const { sql } = compile("SELECT * FROM task_runs WHERE status = 'test'");
// Parameters should have type annotations like {param: String}
expect(sql).toMatch(/\{tsql_\w+: \w+\}/);
});
it("should generate unique parameter names", () => {
const { params } = compile(`
SELECT * FROM task_runs
WHERE status = 'a' AND task_identifier = 'b' AND payload = 'c'
`);
// All parameter keys should be unique
const keys = Object.keys(params);
expect(new Set(keys).size).toBe(keys.length);
});
it("should not include raw values in SQL for strings", () => {
const { sql } = compile("SELECT * FROM task_runs WHERE status = 'user_provided_value'");
// The literal string should not appear in SQL
expect(sql).not.toContain("user_provided_value");
});
});
describe("Optional Tenant Filters", () => {
/**
* Helper to extract the WHERE clause from SQL for more precise testing.
* This is needed because SELECT * expansion includes all columns,
* but we only want to check what's in the WHERE clause for tenant filtering.
*/
function getWhereClause(sql: string): string {
const whereMatch = sql.match(/WHERE\s+(.*?)(?:\s+(?:ORDER|GROUP|LIMIT|$))/is);
return whereMatch ? whereMatch[1] : "";
}
describe("Organization ID is always required", () => {
it("should always inject organization guard even with optional project/env", () => {
const { sql, params } = compile("SELECT * FROM task_runs", {
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_tenant1" },
// project_id and environment_id omitted
},
});
const whereClause = getWhereClause(sql);
// Must contain organization_id in WHERE clause
expect(whereClause).toContain("organization_id");
expect(Object.values(params)).toContain("org_tenant1");
// Should NOT contain project_id or environment_id guards in WHERE clause
expect(whereClause).not.toContain("project_id");
expect(whereClause).not.toContain("environment_id");
});
});
describe("Project ID is optional", () => {
it("should inject org and project guards when project is provided", () => {
const { sql, params } = compile("SELECT * FROM task_runs", {
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_tenant1" },
project_id: { op: "eq", value: "proj_tenant1" },
// environment_id omitted
},
});
const whereClause = getWhereClause(sql);
// Must contain organization_id and project_id in WHERE clause
expect(whereClause).toContain("organization_id");
expect(whereClause).toContain("project_id");
expect(Object.values(params)).toContain("org_tenant1");
expect(Object.values(params)).toContain("proj_tenant1");
// Should NOT contain environment_id guard in WHERE clause
expect(whereClause).not.toContain("environment_id");
});
it("should allow querying across all projects when projectId is omitted", () => {
const { sql, params } = compile("SELECT * FROM task_runs", {
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_tenant1" },
// project_id and environment_id omitted
},
});
const whereClause = getWhereClause(sql);
// Only org guard should be present in WHERE clause
expect(whereClause).toContain("organization_id");
expect(Object.values(params)).toContain("org_tenant1");
expect(whereClause).not.toContain("project_id");
});
});
describe("Environment ID is optional", () => {
it("should inject org, project, and env guards when all provided", () => {
const { sql, params } = compile("SELECT * FROM task_runs");
const whereClause = getWhereClause(sql);
// All three should be present in WHERE clause (default options include all)
expect(whereClause).toContain("organization_id");
expect(whereClause).toContain("project_id");
expect(whereClause).toContain("environment_id");
expect(Object.values(params)).toContain("org_tenant1");
expect(Object.values(params)).toContain("proj_tenant1");
expect(Object.values(params)).toContain("env_tenant1");
});
it("should allow querying across all environments when environmentId is omitted", () => {
const { sql, params } = compile("SELECT * FROM task_runs", {
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_tenant1" },
project_id: { op: "eq", value: "proj_tenant1" },
// environment_id omitted
},
});
const whereClause = getWhereClause(sql);
// Org and project guards should be present in WHERE clause
expect(whereClause).toContain("organization_id");
expect(whereClause).toContain("project_id");
expect(Object.values(params)).toContain("org_tenant1");
expect(Object.values(params)).toContain("proj_tenant1");
// Environment guard should NOT be present in WHERE clause
expect(whereClause).not.toContain("environment_id");
});
});
describe("Cross-tenant security with optional filters", () => {
it("should still prevent cross-org access with org-only filter", () => {
const { sql: _sql, params } = compile(
"SELECT * FROM task_runs WHERE organization_id = 'org_other'",
{
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_tenant1" },
// project_id and environment_id omitted
},
}
);
// Our org guard should still be enforced
expect(Object.values(params)).toContain("org_tenant1");
});
it("should apply org guard to all tables in JOIN when using org-only filter", () => {
const { sql } = compile(
`
SELECT r.id, e.event_type
FROM task_runs r
JOIN task_events e ON r.id = e.run_id
`,
{
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_tenant1" },
// project_id and environment_id omitted
},
}
);
// Both tables should have org guards
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
// Project and environment should NOT appear
expect(sql).not.toContain("project_id");
expect(sql).not.toContain("environment_id");
});
it("should apply org guard to UNION queries when using org-only filter", () => {
const { sql } = compile(
`
SELECT id, status FROM task_runs WHERE status = 'completed'
UNION ALL
SELECT id, status FROM task_runs WHERE status = 'failed'
`,
{
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_tenant1" },
// project_id and environment_id omitted
},
}
);
// Both parts should have org guards
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
it("should apply org guard to subqueries when using org-only filter", () => {
const { sql, params } = compile(
`
SELECT * FROM task_runs
WHERE id IN (SELECT run_id FROM task_events)
`,
{
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_tenant1" },
// project_id and environment_id omitted
},
}
);
// Both main query and subquery should have org guards
expect(Object.values(params)).toContain("org_tenant1");
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
});
});
describe("Multi-join Tenant Guard Qualification", () => {
/**
* Security Test: Verifies that tenant guards are properly table-qualified in multi-join queries.
*
* The bug: createEnforcedGuard was building unqualified guard expressions like:
* organization_id = 'org_tenant1'
*
* In a multi-table join where both tables have the same column (organization_id),
* an unqualified reference could potentially bind to the wrong table during resolution,
* or be ambiguous. The guards should be qualified like:
* r.organization_id = 'org_tenant1' AND e.organization_id = 'org_tenant1'
*
* This ensures each table's guard binds to the correct table, not just any matching column.
*/
it("should qualify tenant guards with table alias in JOIN queries", () => {
const { sql } = compile(`
SELECT r.id, e.event_type
FROM task_runs r
JOIN task_events e ON r.id = e.run_id
`);
// The guards should be table-qualified to prevent binding to the wrong table
// Look for pattern like: r.organization_id and e.organization_id (with table alias prefix)
// The exact format in ClickHouse SQL is just "alias.column" after resolution
// Count qualified organization_id references (should have table prefixes)
// In the WHERE clause, we should see both r.organization_id and e.organization_id
const whereClause = sql.substring(sql.indexOf("WHERE"));
// Both tables should have their own qualified tenant guards
// The pattern should be: table_alias.organization_id for each table
expect(whereClause).toMatch(/\br\b[^,]*organization_id/);
expect(whereClause).toMatch(/\be\b[^,]*organization_id/);
});
it("should qualify tenant guards with table alias in LEFT JOIN queries", () => {
const { sql } = compile(`
SELECT r.id, e.event_type
FROM task_runs r
LEFT JOIN task_events e ON r.id = e.run_id
`);
const whereClause = sql.substring(sql.indexOf("WHERE"));
// Both tables should have qualified guards
expect(whereClause).toMatch(/\br\b[^,]*organization_id/);
expect(whereClause).toMatch(/\be\b[^,]*organization_id/);
});
it("should qualify tenant guards in multi-way JOIN queries", () => {
const { sql } = compile(`
SELECT r.id, e1.event_type, e2.event_type
FROM task_runs r
JOIN task_events e1 ON r.id = e1.run_id
JOIN task_events e2 ON r.id = e2.run_id
`);
const whereClause = sql.substring(sql.indexOf("WHERE"));
// All three table aliases should have qualified guards
expect(whereClause).toMatch(/\br\b[^,]*organization_id/);
expect(whereClause).toMatch(/\be1\b[^,]*organization_id/);
expect(whereClause).toMatch(/\be2\b[^,]*organization_id/);
});
it("should ensure guards cannot bind to wrong table by verifying separate qualifications", () => {
const { sql, params } = compile(`
SELECT r.id, e.event_type
FROM task_runs r
JOIN task_events e ON r.id = e.run_id
WHERE r.status = 'completed'
`);
// Count organization_id occurrences with different table prefixes
// This ensures each table gets its own guard, not shared/ambiguous references
const orgIdPattern = /(\w+)\.organization_id/g;
const matches = [...sql.matchAll(orgIdPattern)];
const tableAliases = matches.map((m) => m[1]);
// Should have at least 2 different table aliases for organization_id
// (one for task_runs alias 'r' and one for task_events alias 'e')
expect(tableAliases).toContain("r");
expect(tableAliases).toContain("e");
// Both should use the same tenant value (parameterized)
expect(Object.values(params)).toContain("org_tenant1");
});
});
describe("Edge Cases", () => {
it("should handle empty string values", () => {
const { params } = compile("SELECT * FROM task_runs WHERE status = ''");
expect(Object.values(params)).toContain("");
});
it("should handle very long strings", () => {
const longString = "a".repeat(10000);
const { params } = compile(`SELECT * FROM task_runs WHERE status = '${longString}'`);
expect(Object.values(params)).toContain(longString);
});
it("should handle strings with newlines", () => {
const { params } = compile("SELECT * FROM task_runs WHERE status = 'line1\nline2'");
// Should handle newlines safely
expect(Object.values(params).some((v) => typeof v === "string" && v.includes("\n"))).toBe(true);
});
it("should handle special SQL keywords in strings", () => {
const { sql, params } = compile("SELECT * FROM task_runs WHERE status = 'SELECT * FROM users'");
// The SQL keywords should be in params, not interpreted
expect(sql).not.toMatch(/SELECT \* FROM users/);
expect(Object.values(params)).toContain("SELECT * FROM users");
});
});
@@ -0,0 +1,181 @@
import { describe, it, expect } from "vitest";
import { calculateTimeBucketInterval, type TimeBucketInterval } from "./time_buckets.js";
/**
* Helper to create a Date range from a start date and a duration
*/
function makeRange(from: Date, durationMs: number): { from: Date; to: Date } {
return { from, to: new Date(from.getTime() + durationMs) };
}
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
describe("calculateTimeBucketInterval", () => {
describe("small ranges (seconds-level buckets)", () => {
it("should return 5 SECOND for a 1-minute range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 1 * MINUTE);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 5,
unit: "SECOND",
});
});
it("should return 5 SECOND for a 4-minute range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 4 * MINUTE);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 5,
unit: "SECOND",
});
});
it("should return 30 SECOND for a 10-minute range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 10 * MINUTE);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 30,
unit: "SECOND",
});
});
it("should return 30 SECOND for a 29-minute range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 29 * MINUTE);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 30,
unit: "SECOND",
});
});
});
describe("medium ranges (minute-level buckets)", () => {
it("should return 1 MINUTE for a 45-minute range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 45 * MINUTE);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "MINUTE",
});
});
it("should return 1 MINUTE for a 1-hour range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 1 * HOUR);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "MINUTE",
});
});
it("should return 5 MINUTE for a 3-hour range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 3 * HOUR);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 5,
unit: "MINUTE",
});
});
it("should return 15 MINUTE for a 12-hour range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 12 * HOUR);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 15,
unit: "MINUTE",
});
});
});
describe("large ranges (hour/day-level buckets)", () => {
it("should return 1 HOUR for a 2-day range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 2 * DAY);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "HOUR",
});
});
it("should return 6 HOUR for a 7-day range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 7 * DAY);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 6,
unit: "HOUR",
});
});
it("should return 1 DAY for a 30-day range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 30 * DAY);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "DAY",
});
});
it("should return 1 WEEK for a 90-day range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 90 * DAY);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "WEEK",
});
});
});
describe("very large ranges (month-level buckets)", () => {
it("should return 1 MONTH for a 365-day range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 365 * DAY);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "MONTH",
});
});
it("should return 1 MONTH for a 2-year range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 730 * DAY);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "MONTH",
});
});
});
describe("edge cases", () => {
it("should handle zero-length range (from === to)", () => {
const date = new Date("2024-01-01T00:00:00Z");
const result = calculateTimeBucketInterval(date, date);
// Zero range is under 5 minutes, so 5 SECOND
expect(result).toEqual<TimeBucketInterval>({ value: 5, unit: "SECOND" });
});
it("should handle reversed dates (to < from) using absolute difference", () => {
const from = new Date("2024-01-08T00:00:00Z");
const to = new Date("2024-01-01T00:00:00Z");
// 7 days reversed → same as 7 days forward
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 6,
unit: "HOUR",
});
});
it("should handle boundary exactly at 5 minutes", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 5 * MINUTE);
// Exactly 5 minutes is NOT under 5 minutes, so should be 30 SECOND
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 30,
unit: "SECOND",
});
});
it("should handle boundary exactly at 24 hours", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 24 * HOUR);
// Exactly 24 hours is NOT under 24 hours, so should be 1 HOUR
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "HOUR",
});
});
it("should handle very small range (1 second)", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 1 * SECOND);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 5,
unit: "SECOND",
});
});
});
});
@@ -0,0 +1,100 @@
/**
* Time bucket interval calculation for the `timeBucket()` TSQL function.
*
* Given a time range, determines the most appropriate bucket interval
* to produce a reasonable number of data points (~50-100 buckets).
*/
/**
* A time bucket interval with a numeric value and time unit.
* Used to generate ClickHouse `INTERVAL N UNIT` syntax.
*/
export interface TimeBucketInterval {
/** The numeric value of the interval (e.g., 5 for "5 MINUTE") */
value: number;
/** The time unit */
unit: "SECOND" | "MINUTE" | "HOUR" | "DAY" | "WEEK" | "MONTH";
}
/**
* A threshold mapping a maximum time range duration to a bucket interval.
*/
export interface BucketThreshold {
/** Maximum range duration in seconds for this threshold to apply */
maxRangeSeconds: number;
/** The bucket interval to use when the range is under maxRangeSeconds */
interval: TimeBucketInterval;
}
/**
* Default time bucket thresholds: each entry defines a maximum time range duration (in seconds)
* and the corresponding bucket interval to use.
*
* The intervals are chosen to produce roughly 50-100 data points for the given range.
* Entries are ordered from smallest to largest range.
*/
export const BUCKET_THRESHOLDS: BucketThreshold[] = [
// Under 5 minutes → 5 second buckets (max 60 buckets)
{ maxRangeSeconds: 5 * 60, interval: { value: 5, unit: "SECOND" } },
// Under 30 minutes → 30 second buckets (max 60 buckets)
{ maxRangeSeconds: 30 * 60, interval: { value: 30, unit: "SECOND" } },
// Under 2 hours → 1 minute buckets (max 120 buckets)
{ maxRangeSeconds: 2 * 60 * 60, interval: { value: 1, unit: "MINUTE" } },
// Under 6 hours → 5 minute buckets (max 72 buckets)
{ maxRangeSeconds: 6 * 60 * 60, interval: { value: 5, unit: "MINUTE" } },
// Under 24 hours → 15 minute buckets (max 96 buckets)
{ maxRangeSeconds: 24 * 60 * 60, interval: { value: 15, unit: "MINUTE" } },
// Under 3 days → 1 hour buckets (max 72 buckets)
{ maxRangeSeconds: 3 * 24 * 60 * 60, interval: { value: 1, unit: "HOUR" } },
// Under 14 days → 6 hour buckets (max 56 buckets)
{ maxRangeSeconds: 14 * 24 * 60 * 60, interval: { value: 6, unit: "HOUR" } },
// Under 60 days → 1 day buckets (max 60 buckets)
{ maxRangeSeconds: 60 * 24 * 60 * 60, interval: { value: 1, unit: "DAY" } },
// Under 365 days → 1 week buckets (max ~52 buckets)
{ maxRangeSeconds: 365 * 24 * 60 * 60, interval: { value: 1, unit: "WEEK" } },
];
/** Default interval for very large ranges (365+ days) */
const DEFAULT_LARGE_INTERVAL: TimeBucketInterval = { value: 1, unit: "MONTH" };
/**
* Calculate the most appropriate time bucket interval for a given time range.
*
* The interval is chosen to produce a reasonable number of data points (~50-100 buckets).
* For very small ranges (< 5 minutes), uses 5-second buckets.
* For very large ranges (> 365 days), uses 1-month buckets.
*
* @param from - Start of the time range
* @param to - End of the time range
* @returns The recommended bucket interval
*
* @example
* ```typescript
* // 1 hour range → 1 minute buckets
* calculateTimeBucketInterval(
* new Date("2024-01-01T00:00:00Z"),
* new Date("2024-01-01T01:00:00Z"),
* ); // { value: 1, unit: "MINUTE" }
*
* // 7 day range → 6 hour buckets
* calculateTimeBucketInterval(
* new Date("2024-01-01"),
* new Date("2024-01-08"),
* ); // { value: 6, unit: "HOUR" }
* ```
*/
export function calculateTimeBucketInterval(
from: Date,
to: Date,
thresholds?: BucketThreshold[]
): TimeBucketInterval {
const rangeSeconds = Math.abs(to.getTime() - from.getTime()) / 1000;
for (const threshold of thresholds ?? BUCKET_THRESHOLDS) {
if (rangeSeconds < threshold.maxRangeSeconds) {
return threshold.interval;
}
}
return DEFAULT_LARGE_INTERVAL;
}
+109
View File
@@ -0,0 +1,109 @@
// TypeScript translation of posthog/hogql/timings.py
/**
* Get performance counter in milliseconds (Node.js equivalent of perf_counter)
* Uses performance.now() which is available in:
* - Node.js 18+ (global)
* - Browser (global)
* - Node.js <18 via perf_hooks module
*/
function getPerformanceNow(): number {
// Check for global performance (Node.js 18+ or browser)
if (typeof globalThis !== "undefined" && "performance" in globalThis) {
const perf = (globalThis as any).performance;
if (perf && typeof perf.now === "function") {
return perf.now();
}
}
// Fallback to Date.now() if performance API is not available
// Note: This is less precise but works everywhere
return Date.now();
}
export interface QueryTiming {
key: string; // Key identifying the timing measurement
time: number; // Time in seconds
}
const TIMING_DECIMAL_PLACES = 3; // round to milliseconds
// Not thread safe.
// See trends_query_runner for an example of how to use for multithreaded queries
export class TSQLTimings {
// Completed time in seconds for different parts of the TSQL query
timings: Record<string, number> = {};
// Used for housekeeping
private _timingPointer: string;
private _timingStarts: Record<string, number> = {};
constructor(_timingPointer: string = ".") {
this._timingPointer = _timingPointer;
this._timingStarts[this._timingPointer] = this.perfCounter();
}
cloneForSubquery(seriesIndex: number): TSQLTimings {
return new TSQLTimings(`${this._timingPointer}/series_${seriesIndex}`);
}
clearTimings(): void {
this.timings = {};
}
/**
* Measure execution time of a function.
* Usage: timings.measure('operation', () => { ... });
*/
measure<T>(key: string, fn: () => T): T {
const lastKey = this._timingPointer;
const fullKey = `${this._timingPointer}/${key}`;
this._timingPointer = fullKey;
this._timingStarts[fullKey] = this.perfCounter();
try {
return fn();
} finally {
const duration = (this.perfCounter() - this._timingStarts[fullKey]) / 1000; // Convert to seconds
this.timings[fullKey] = (this.timings[fullKey] || 0.0) + duration;
delete this._timingStarts[fullKey];
this._timingPointer = lastKey;
}
}
/**
* Get performance counter in milliseconds (Node.js equivalent of perf_counter)
*/
private perfCounter(): number {
return getPerformanceNow();
}
toDict(): Record<string, number> {
const timings = { ...this.timings };
// Process in reverse order to handle nested timings correctly
const keys = Object.keys(this._timingStarts).reverse();
for (const key of keys) {
const start = this._timingStarts[key];
const elapsed = (this.perfCounter() - start) / 1000; // Convert to seconds
timings[key] = this.round((timings[key] || 0.0) + elapsed);
}
return timings;
}
toList(backOutStack: boolean = true): QueryTiming[] {
const timingDict = backOutStack ? this.toDict() : this.timings;
return Object.entries(timingDict).map(([key, time]) => ({
key: key,
time: this.round(time),
}));
}
/**
* Round to specified decimal places (milliseconds precision)
*/
private round(value: number): number {
return (
Math.round(value * Math.pow(10, TIMING_DECIMAL_PLACES)) / Math.pow(10, TIMING_DECIMAL_PLACES)
);
}
}
@@ -0,0 +1,128 @@
import { describe, it, expect } from "vitest";
import { validateQuery } from "./validator.js";
import { parseTSQLSelect } from "../index.js";
import { column, type TableSchema } from "./schema.js";
const runsSchema: TableSchema = {
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
columns: {
id: { name: "id", ...column("String") },
status: {
name: "status",
...column("String", {
allowedValues: ["PENDING", "COMPLETED", "FAILED"],
}),
},
task_id: { name: "task_id", ...column("String") },
created_at: { name: "created_at", ...column("DateTime64") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
function validateSQL(query: string, schema: TableSchema[] = [runsSchema]) {
const ast = parseTSQLSelect(query);
return validateQuery(ast, schema);
}
describe("validateQuery", () => {
describe("SELECT aliases", () => {
it("should allow ORDER BY to reference aliased columns", () => {
const result = validateSQL(
"SELECT status, count(*) as count FROM runs GROUP BY status ORDER BY count DESC"
);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should allow ORDER BY to reference multiple aliased columns", () => {
const result = validateSQL(
"SELECT status, count(*) as total, avg(created_at) as avg_time FROM runs GROUP BY status ORDER BY total DESC, avg_time ASC"
);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should report unknown columns that are not aliases as errors", () => {
const result = validateSQL(
"SELECT status, count(*) as count FROM runs GROUP BY status ORDER BY unknown_col DESC"
);
expect(result.valid).toBe(false); // unknown column is now an error
expect(result.issues).toHaveLength(1);
expect(result.issues[0].type).toBe("unknown_column");
expect(result.issues[0].severity).toBe("error");
expect(result.issues[0].columnName).toBe("unknown_col");
});
it("should allow ORDER BY to reference both aliases and real columns", () => {
const result = validateSQL(
"SELECT status, count(*) as count FROM runs GROUP BY status ORDER BY status ASC, count DESC"
);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should allow HAVING to reference implicit column names from aggregations", () => {
const result = validateSQL(
"SELECT COUNT(), status FROM runs GROUP BY status HAVING count > 20"
);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should allow HAVING to reference implicit names from multiple aggregations", () => {
const result = validateSQL(
"SELECT COUNT(), SUM(created_at), status FROM runs GROUP BY status HAVING count > 10 AND sum > 100"
);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should allow ORDER BY to reference implicit column names", () => {
const result = validateSQL(
"SELECT COUNT(), status FROM runs GROUP BY status ORDER BY count DESC"
);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
});
describe("column validation", () => {
it("should validate known columns", () => {
const result = validateSQL("SELECT id, status FROM runs LIMIT 10");
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should error on unknown columns", () => {
const result = validateSQL("SELECT id, unknown_column FROM runs LIMIT 10");
expect(result.valid).toBe(false); // unknown columns are now errors
expect(result.issues).toHaveLength(1);
expect(result.issues[0].type).toBe("unknown_column");
expect(result.issues[0].severity).toBe("error");
expect(result.issues[0].columnName).toBe("unknown_column");
});
});
describe("enum validation", () => {
it("should validate enum values", () => {
const result = validateSQL("SELECT id, status FROM runs WHERE status = 'COMPLETED' LIMIT 10");
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should error on invalid enum values", () => {
const result = validateSQL(
"SELECT id, status FROM runs WHERE status = 'INVALID_STATUS' LIMIT 10"
);
expect(result.valid).toBe(false);
expect(result.issues).toHaveLength(1);
expect(result.issues[0].type).toBe("invalid_enum_value");
expect(result.issues[0].invalidValue).toBe("INVALID_STATUS");
});
});
});
@@ -0,0 +1,596 @@
// Schema validation for TSQL queries
// Validates column names and enum values against the schema
import type {
SelectQuery,
SelectSetQuery,
Expression,
Field,
CompareOperation,
Constant,
And,
Or,
Not,
Alias,
OrderExpr,
Call,
JoinExpr,
BetweenExpr,
Array as ASTArray,
ArithmeticOperation,
} from "./ast.js";
import type { TableSchema, ColumnSchema } from "./schema.js";
import { getAllowedUserValues, getCoreColumns, isValidUserValue } from "./schema.js";
import { CompareOperationOp, ArithmeticOperationOp } from "./ast.js";
/**
* Severity of a validation issue
*/
export type ValidationSeverity = "error" | "warning" | "info";
/**
* A validation issue found in the query
*/
export interface ValidationIssue {
/** The error/warning message */
message: string;
/** Severity of the issue */
severity: ValidationSeverity;
/** The type of issue */
type: "unknown_column" | "unknown_table" | "invalid_enum_value" | "select_star";
/** Optional: the column name that caused the issue */
columnName?: string;
/** Optional: the table name that caused the issue */
tableName?: string;
/** Optional: the invalid value */
invalidValue?: string;
/** Optional: list of allowed values */
allowedValues?: string[];
/** Optional: suggested columns to use instead (for select_star) */
suggestedColumns?: string[];
}
/**
* Result of validating a query
*/
export interface ValidationResult {
/** Whether the query is valid */
valid: boolean;
/** List of issues found */
issues: ValidationIssue[];
}
/**
* Context for tracking tables and columns during validation
*/
interface ValidationContext {
/** Map of table aliases/names to their schemas */
tables: Map<string, TableSchema>;
/** The schema array for lookups */
schema: TableSchema[];
/** Accumulated issues */
issues: ValidationIssue[];
/** Set of column aliases defined in the SELECT clause */
selectAliases: Set<string>;
}
/**
* Validate a parsed TSQL query against a schema
*
* @param ast - The parsed query AST
* @param schema - Array of table schemas to validate against
* @returns Validation result with any issues found
*/
export function validateQuery(
ast: SelectQuery | SelectSetQuery,
schema: TableSchema[]
): ValidationResult {
const context: ValidationContext = {
tables: new Map(),
schema,
issues: [],
selectAliases: new Set(),
};
if (ast.expression_type === "select_set_query") {
validateSelectSetQuery(ast, context);
} else {
validateSelectQuery(ast, context);
}
return {
valid: context.issues.filter((i) => i.severity === "error").length === 0,
issues: context.issues,
};
}
/**
* Get the implicit column name for an expression without an explicit alias.
* This matches the naming used in the printer for result columns.
*
* @param expr - The SELECT expression
* @returns The implicit name, or null if no implicit name applies
*/
function getImplicitName(expr: Expression): string | null {
// Handle Call (function/aggregation) - use lowercase function name
if ((expr as Call).expression_type === "call") {
const call = expr as Call;
return call.name.toLowerCase();
}
// Handle ArithmeticOperation - use operator function name
if ((expr as ArithmeticOperation).expression_type === "arithmetic_operation") {
const arith = expr as ArithmeticOperation;
switch (arith.op) {
case ArithmeticOperationOp.Add:
return "plus";
case ArithmeticOperationOp.Sub:
return "minus";
case ArithmeticOperationOp.Mult:
return "multiply";
case ArithmeticOperationOp.Div:
return "divide";
case ArithmeticOperationOp.Mod:
return "modulo";
default:
return "expression";
}
}
// Handle Constant - use string representation
if ((expr as Constant).expression_type === "constant") {
const constant = expr as Constant;
if (constant.value === null) {
return "NULL";
}
if (typeof constant.value === "string") {
return `'${constant.value}'`;
}
return String(constant.value);
}
// Field expressions don't get implicit names (they use the column name directly)
return null;
}
/**
* Validate a SELECT SET query (UNION, INTERSECT, etc.)
*/
function validateSelectSetQuery(node: SelectSetQuery, context: ValidationContext): void {
if (node.initial_select_query.expression_type === "select_set_query") {
validateSelectSetQuery(node.initial_select_query, context);
} else {
validateSelectQuery(node.initial_select_query, context);
}
for (const subsequent of node.subsequent_select_queries) {
if (subsequent.select_query.expression_type === "select_set_query") {
validateSelectSetQuery(subsequent.select_query as SelectSetQuery, context);
} else {
validateSelectQuery(subsequent.select_query as SelectQuery, context);
}
}
}
/**
* Check if an expression is a SELECT * (asterisk)
*/
function isSelectStar(expr: Expression): boolean {
if ((expr as Field).expression_type !== "field") return false;
const field = expr as Field;
// SELECT * or SELECT table.*
return (
(field.chain.length === 1 && field.chain[0] === "*") ||
(field.chain.length === 2 && field.chain[1] === "*")
);
}
/**
* Validate a SELECT query
*/
function validateSelectQuery(node: SelectQuery, context: ValidationContext): void {
// Save parent aliases and create fresh set for this query
const parentAliases = context.selectAliases;
context.selectAliases = new Set();
// First, extract tables from FROM clause to build context
if (node.select_from) {
extractTablesFromJoin(node.select_from, context);
}
// Check for SELECT * and emit warning
if (node.select) {
const hasSelectStar = node.select.some(isSelectStar);
if (hasSelectStar) {
// Collect core columns from all tables in context
const coreColumns: string[] = [];
for (const tableSchema of context.tables.values()) {
const tableCoreColumns = getCoreColumns(tableSchema);
coreColumns.push(...tableCoreColumns);
}
// Build info message about SELECT * behavior
let suggestionMsg = "SELECT * doesn't return all columns.";
if (coreColumns.length > 0) {
suggestionMsg += `It will return: ${coreColumns.join(", ")}. `;
}
suggestionMsg += "Specify columns explicitly to include other columns.";
context.issues.push({
message: suggestionMsg,
severity: "info",
type: "select_star",
suggestedColumns: coreColumns.length > 0 ? coreColumns : undefined,
});
}
}
// Extract column aliases from SELECT clause before validation
// This allows ORDER BY and HAVING to reference aliased columns
if (node.select) {
for (const expr of node.select) {
if ((expr as Alias).expression_type === "alias") {
// Explicit alias: SELECT ... AS name (stored lowercase for case-insensitive lookup)
context.selectAliases.add((expr as Alias).alias.toLowerCase());
} else {
// Check for implicit aliases from expressions without AS
const implicitName = getImplicitName(expr);
if (implicitName) {
context.selectAliases.add(implicitName);
}
}
}
}
// Validate SELECT columns
if (node.select) {
for (const expr of node.select) {
validateExpression(expr, context);
}
}
// Validate WHERE clause
if (node.where) {
validateExpression(node.where, context);
}
// Validate GROUP BY
if (node.group_by) {
for (const expr of node.group_by) {
validateExpression(expr, context);
}
}
// Validate HAVING
if (node.having) {
validateExpression(node.having, context);
}
// Validate ORDER BY
if (node.order_by) {
for (const expr of node.order_by) {
validateExpression(expr, context);
}
}
// Restore parent aliases
context.selectAliases = parentAliases;
}
/**
* Extract table schemas from JOIN expressions
*/
function extractTablesFromJoin(node: JoinExpr, context: ValidationContext): void {
if (node.table) {
const tableExpr = node.table;
if ((tableExpr as Field).expression_type === "field") {
const field = tableExpr as Field;
const tableName = field.chain[0];
if (typeof tableName === "string") {
// Find the table schema
const tableSchema = context.schema.find(
(t) => t.name.toLowerCase() === tableName.toLowerCase()
);
if (tableSchema) {
// Register with alias if provided, otherwise use table name
const key = node.alias || tableName;
context.tables.set(key.toLowerCase(), tableSchema);
} else {
// Unknown table
context.issues.push({
message: `Unknown table "${tableName}". Available tables: ${
context.schema.map((t) => t.name).join(", ") || "(none)"
}`,
severity: "warning",
type: "unknown_table",
tableName,
});
}
}
} else if (
(tableExpr as SelectQuery).expression_type === "select_query" ||
(tableExpr as SelectSetQuery).expression_type === "select_set_query"
) {
// Subquery - validate it recursively
if ((tableExpr as SelectSetQuery).expression_type === "select_set_query") {
validateSelectSetQuery(tableExpr as SelectSetQuery, context);
} else {
validateSelectQuery(tableExpr as SelectQuery, context);
}
}
}
// Process next join in chain
if (node.next_join) {
extractTablesFromJoin(node.next_join, context);
}
}
/**
* Validate an expression and its children
*/
function validateExpression(expr: Expression, context: ValidationContext): void {
if (!expr || typeof expr !== "object") return;
const exprType = expr.expression_type;
switch (exprType) {
case "field":
validateField(expr as Field, context);
break;
case "compare_operation":
validateCompareOperation(expr as CompareOperation, context);
break;
case "and":
for (const e of (expr as And).exprs) {
validateExpression(e, context);
}
break;
case "or":
for (const e of (expr as Or).exprs) {
validateExpression(e, context);
}
break;
case "not":
validateExpression((expr as Not).expr, context);
break;
case "alias":
validateExpression((expr as Alias).expr, context);
break;
case "order_expr":
validateExpression((expr as OrderExpr).expr, context);
break;
case "call":
for (const arg of (expr as Call).args) {
validateExpression(arg, context);
}
break;
case "between_expr":
validateExpression((expr as BetweenExpr).expr, context);
validateExpression((expr as BetweenExpr).low, context);
validateExpression((expr as BetweenExpr).high, context);
break;
case "array":
for (const e of (expr as ASTArray).exprs) {
validateExpression(e, context);
}
break;
// Other expression types that we don't need to deeply validate
case "constant":
case "select_query":
case "select_set_query":
// Skip - constants don't need validation, subqueries are handled separately
break;
}
}
/**
* Validate a field reference
*/
function validateField(field: Field, context: ValidationContext): void {
const chain = field.chain;
if (chain.length === 0) return;
// Handle asterisk
if (chain[0] === "*") return;
if (chain.length === 2 && chain[1] === "*") return;
const firstPart = chain[0];
if (typeof firstPart !== "string") return;
// Case 1: Qualified reference like table.column
if (chain.length >= 2) {
const tableAlias = firstPart.toLowerCase();
const columnName = chain[1];
if (typeof columnName !== "string") return;
const tableSchema = context.tables.get(tableAlias);
if (tableSchema) {
// Check if column exists
if (!tableSchema.columns[columnName]) {
const availableColumns = Object.keys(tableSchema.columns).join(", ");
context.issues.push({
message: `Unknown column "${columnName}" on table "${tableAlias}". Available columns: ${availableColumns}`,
severity: "error",
type: "unknown_column",
columnName,
tableName: tableAlias,
});
}
}
return;
}
// Case 2: Unqualified reference - try to find in any table or SELECT alias
const columnName = firstPart;
// Check if it's a SELECT alias (e.g., from "count(*) as count")
// Case-insensitive: aliases are stored lowercase
if (context.selectAliases.has(columnName.toLowerCase())) {
return;
}
let found = false;
for (const tableSchema of context.tables.values()) {
if (tableSchema.columns[columnName]) {
found = true;
break;
}
}
if (!found && context.tables.size > 0) {
// Only report if we have tables to check against
const allColumns = new Set<string>();
for (const tableSchema of context.tables.values()) {
for (const col of Object.keys(tableSchema.columns)) {
allColumns.add(col);
}
}
context.issues.push({
message: `Unknown column "${columnName}". Available columns: ${Array.from(allColumns).join(
", "
)}`,
severity: "error",
type: "unknown_column",
columnName,
});
}
}
/**
* Validate a comparison operation, including enum value checks
*/
function validateCompareOperation(op: CompareOperation, context: ValidationContext): void {
// Validate both sides recursively
validateExpression(op.left, context);
validateExpression(op.right, context);
// Check for enum value validation
// We look for patterns like: column = 'value' or column IN ('value1', 'value2')
const columnInfo = extractColumnFromExpression(op.left, context);
if (!columnInfo) return;
const { columnSchema, columnName, tableName } = columnInfo;
// Only validate if the column has allowedValues or valueMap
const allowedValues = getAllowedUserValues(columnSchema);
if (allowedValues.length === 0) return;
// Check the comparison type
switch (op.op) {
case CompareOperationOp.Eq:
case CompareOperationOp.NotEq:
// Single value comparison
validateEnumValue(op.right, columnSchema, columnName, tableName, context);
break;
case CompareOperationOp.In:
case CompareOperationOp.NotIn:
case CompareOperationOp.GlobalIn:
case CompareOperationOp.GlobalNotIn:
// Array of values
if ((op.right as ASTArray).expression_type === "array") {
for (const elem of (op.right as ASTArray).exprs) {
validateEnumValue(elem, columnSchema, columnName, tableName, context);
}
}
break;
}
}
/**
* Extract column information from an expression if it's a simple column reference
*/
function extractColumnFromExpression(
expr: Expression,
context: ValidationContext
): { columnSchema: ColumnSchema; columnName: string; tableName?: string } | null {
if ((expr as Field).expression_type !== "field") return null;
const field = expr as Field;
const chain = field.chain;
if (chain.length === 0) return null;
const firstPart = chain[0];
if (typeof firstPart !== "string") return null;
// Qualified reference: table.column
if (chain.length >= 2) {
const tableAlias = firstPart.toLowerCase();
const columnName = chain[1];
if (typeof columnName !== "string") return null;
const tableSchema = context.tables.get(tableAlias);
if (!tableSchema) return null;
const columnSchema = tableSchema.columns[columnName];
if (!columnSchema) return null;
return { columnSchema, columnName, tableName: tableAlias };
}
// Unqualified reference
const columnName = firstPart;
for (const [tableName, tableSchema] of context.tables.entries()) {
const columnSchema = tableSchema.columns[columnName];
if (columnSchema) {
return { columnSchema, columnName, tableName };
}
}
return null;
}
/**
* Validate that a value matches the allowed enum values for a column
* Supports both allowedValues and valueMap, with case-insensitive matching
*/
function validateEnumValue(
expr: Expression,
columnSchema: ColumnSchema,
columnName: string,
tableName: string | undefined,
context: ValidationContext
): void {
if ((expr as Constant).expression_type !== "constant") return;
const constant = expr as Constant;
if (typeof constant.value !== "string") return;
const value = constant.value;
// Use isValidUserValue for case-insensitive validation against user-friendly values
if (!isValidUserValue(columnSchema, value)) {
const columnRef = tableName ? `${tableName}.${columnName}` : columnName;
// Show user-friendly values in the error message
const allowedValues = getAllowedUserValues(columnSchema);
context.issues.push({
message: `Invalid value "${value}" for column "${columnRef}". Allowed values: ${allowedValues.join(
", "
)}`,
severity: "error",
type: "invalid_enum_value",
columnName,
tableName,
invalidValue: value,
allowedValues,
});
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2019",
"lib": ["ES2019", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"module": "ESNext",
"moduleResolution": "node",
"moduleDetection": "force",
"verbatimModuleSyntax": false,
"types": ["vitest/globals"],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"preserveWatchOutput": true,
"skipLibCheck": true,
"noEmit": true,
"strict": true,
"paths": {
"@trigger.dev/core": ["../../packages/core/src/index"],
"@trigger.dev/core/*": ["../../packages/core/src/*"],
"@internal/clickhouse": ["../clickhouse/src/index"],
"@internal/clickhouse/*": ["../clickhouse/src/*"]
}
},
"exclude": ["node_modules"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["**/*.test.ts"],
globals: true,
isolate: true,
fileParallelism: false,
testTimeout: 60_000,
coverage: {
provider: "v8",
},
},
});