diff --git a/.changeset/lemon-hairs-appear.md b/.changeset/lemon-hairs-appear.md
new file mode 100644
index 00000000..ae49b7c7
--- /dev/null
+++ b/.changeset/lemon-hairs-appear.md
@@ -0,0 +1,5 @@
+---
+'druid-query-toolkit': minor
+---
+
+Allow parsing SET statements in an otherwise unparsable string and more integrations"
diff --git a/.gitignore b/.gitignore
index 9f76e47d..dfb49daf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,13 +9,13 @@
/node_modules/
/coverage/
+/coverage_old/
/build/
/dist/
/types/
/src/sql/parser/index.ts
/src/sql/parser/.DS_Store
+CLAUDE.md
# TypeScript cache
*.tsbuildinfo
-
-_old/
diff --git a/.idea/google-java-format.xml b/.idea/google-java-format.xml
new file mode 100644
index 00000000..2aa056da
--- /dev/null
+++ b/.idea/google-java-format.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 82be5ea3..d026f8a7 100644
--- a/README.md
+++ b/README.md
@@ -75,14 +75,13 @@ ORDER BY 5 DESC
*/
```
-For more examples check out the unit tests.
+For more examples, check out the unit tests.
#### ToDo
Not every valid DruidSQL construct can currently be parsed, the following snippets are not currently supported:
- `(a, b) IN (subquery)`
-- Support `FROM "wikipedia_k" USING (k)`
## License
diff --git a/script/compile-peg.js b/script/compile-peg.js
index cd4318f0..c522ad78 100755
--- a/script/compile-peg.js
+++ b/script/compile-peg.js
@@ -24,6 +24,7 @@ try {
parser = peg.generate(header + '\n\n' + rules, {
output: 'source',
plugins: [tspegjs],
+ allowedStartRules: ['Start', 'StartSetStatementsOnly'],
});
} catch (e) {
console.error('Failed to compile');
diff --git a/src/filter-pattern/unify.spec.ts b/src/filter-pattern/unify.spec.ts
index 3b1e4d48..4083bbc8 100644
--- a/src/filter-pattern/unify.spec.ts
+++ b/src/filter-pattern/unify.spec.ts
@@ -36,8 +36,8 @@ function backAndForthNotCustom(expression: string): void {
}
describe('filter-pattern', () => {
- it('fixed points', () => {
- const expressions: string[] = [
+ describe('fixed point expressions', () => {
+ it.each([
`"lol" = 'hello'`,
`"lol" <> 'hello'`,
`"lol" IN ('hello', 'goodbye')`,
@@ -67,28 +67,19 @@ describe('filter-pattern', () => {
`TIMESTAMP '2022-06-30 22:56:14.123' <= "__time" AND "__time" <= TIMESTAMP '2022-06-30 22:56:15.923'`,
`TIMESTAMP '2022-06-30 22:56:14.123' < "__time" AND "__time" <= TIMESTAMP '2022-06-30 22:56:15.923'`,
`(TIME_FLOOR(MAX_DATA_TIME(), 'P3M', NULL, 'Etc/UTC') <= "DIM:__time" AND "DIM:__time" < TIME_SHIFT(TIME_FLOOR(MAX_DATA_TIME(), 'P3M', NULL, 'Etc/UTC'), 'P1D', 1, 'Etc/UTC'))`,
- ];
-
- for (const expression of expressions) {
- try {
- backAndForthNotCustom(expression);
- } catch (e) {
- console.log(`Problem with: \`${expression}\``);
- throw e;
- }
- }
+ ])('correctly handles expression: %s', expression => {
+ backAndForthNotCustom(expression);
+ });
});
- it('invalid expressions', () => {
- const expressions: string[] = [
+ describe('invalid expressions', () => {
+ it.each([
`"__time" >= TIMESTAMP '2022-06-30 22:56:15.923' AND TIMESTAMP '2021-06-30 22:56:14.123' >= "__time"`,
`TIMESTAMP '2021-06-30 22:56:14.123' >= "__time" AND "__time" >= TIMESTAMP '2022-06-30 22:56:15.923'`,
- ];
-
- for (const expression of expressions) {
+ ])('correctly handles invalid expression: %s', expression => {
const pattern = fitFilterPattern(SqlExpression.parse(expression));
expect(pattern.type).toEqual('custom');
- }
+ });
});
describe('fitFilterPattern', () => {
diff --git a/src/introspect/introspect.ts b/src/introspect/introspect.ts
index 47a5424c..6d2abaee 100644
--- a/src/introspect/introspect.ts
+++ b/src/introspect/introspect.ts
@@ -40,7 +40,7 @@ export class Introspect {
}
static getQueryColumnIntrospectionQuery(query: SqlQuery | SqlTable): SqlQuery {
- return SqlQuery.create(query).changeLimitValue(0);
+ return SqlQuery.selectStarFrom(query).changeLimitValue(0);
}
static getQueryColumnIntrospectionPayload(
diff --git a/src/sql/index.ts b/src/sql/index.ts
index 76171367..d437ea93 100644
--- a/src/sql/index.ts
+++ b/src/sql/index.ts
@@ -38,6 +38,7 @@ export * from './sql-case/sql-when-then-part';
export * from './sql-case/sql-case';
export * from './sql-alias/sql-alias';
export * from './sql-labeled-expression/sql-labeled-expression';
+export * from './sql-key-value/sql-key-value';
export * from './sql-window-spec/sql-window-spec';
export * from './sql-window-spec/sql-frame-bound';
export * from './sql-set-statement/sql-set-statement';
diff --git a/src/sql/parser/druidsql.pegjs b/src/sql/parser/druidsql.pegjs
index 3c3a6851..2b349455 100644
--- a/src/sql/parser/druidsql.pegjs
+++ b/src/sql/parser/druidsql.pegjs
@@ -12,13 +12,31 @@
* limitations under the License.
*/
-Start = initial:_? thing:(SqlQueryWithPossibleContext / SqlAlias) final:_sc?
+Start = initial:_ thing:(SqlQueryWithPossibleContext / SqlAlias) final:_sc
{
if (initial) thing = thing.changeSpace('initial', initial);
if (final) thing = thing.changeSpace('final', final);
return thing;
}
+StartSetStatementsOnly = spaceBefore:_ statements:(SqlSetStatement _sc)* rest:$(.*)
+{
+ let ret = {
+ spaceBefore: spaceBefore,
+ rest: rest
+ }
+
+ if (statements.length) {
+ ret.contextStatements = new S.SeparatedArray(
+ statements.map(function(x) { return x[0] }),
+ statements.map(function(x) { return x[1] }).slice(0, statements.length - 1)
+ );
+ ret.spaceAfter = statements[statements.length - 1][1];
+ }
+
+ return ret;
+}
+
// ------------------------------
SqlAlias = expression:Expression alias:((_ AsToken)? _ RefNameAlias)? columns:(_ SqlColumnList)?
@@ -62,6 +80,38 @@ SqlLabeledExpression = label:RefNameAlias preArrow:_ "=>" postArrow:_ expression
});
}
+SqlKeyValue = LongKeyValueForm / ShortKeyValueForm
+
+LongKeyValueForm = keyToken:KeyToken postKey:_ key:Expression postKeyExpression:_ valueToken:ValueToken preValueExpression:_ value:Expression
+{
+ return new S.SqlKeyValue({
+ key: key,
+ value: value,
+ spacing: {
+ postKey: postKey,
+ postKeyExpression: postKeyExpression,
+ preValueExpression: preValueExpression
+ },
+ keywords: {
+ key: keyToken,
+ value: valueToken
+ }
+ });
+}
+
+ShortKeyValueForm = key:Expression postKeyExpression:_ ":" preValueExpression:_ value:Expression
+{
+ return new S.SqlKeyValue({
+ key: key,
+ value: value,
+ short: true,
+ spacing: {
+ postKeyExpression: postKeyExpression,
+ preValueExpression: preValueExpression
+ }
+ });
+}
+
SqlExtendClause =
extend:(ExtendToken _)?
OpenParen
@@ -1010,6 +1060,7 @@ Function =
/ TimestampAddDiffFunction
/ PositionFunction
/ JsonValueReturningFunction
+/ JsonObjectFunction
/ ArrayFunction
/ NakedFunction
@@ -1171,6 +1222,32 @@ JsonValueReturningFunction =
});
}
+JsonObjectFunction =
+ functionName:JsonObjectToken
+ preLeftParen:_
+ OpenParen
+ postLeftParen:_
+ head:SqlKeyValue?
+ tail:(CommaSeparator SqlKeyValue)*
+ postArguments:_
+ CloseParen
+{
+ var value = {
+ functionName: makeFunctionName(functionName)
+ };
+ var spacing = value.spacing = {
+ preLeftParen: preLeftParen,
+ postLeftParen: postLeftParen
+ };
+
+ if (head) {
+ value.args = makeSeparatedArray(head, tail);
+ spacing.postArguments = postArguments;
+ }
+
+ return new S.SqlFunction(value);
+}
+
ExtractFunction =
functionName:(ExtractToken / ('"' ExtractToken '"'))
preLeftParen:_
@@ -1881,6 +1958,9 @@ IntoToken = $("INTO"i !IdentifierPart)
IsToken = $("IS"i !IdentifierPart)
JoinToken = $("JOIN"i !IdentifierPart)
JsonValueToken = $("JSON_VALUE"i !IdentifierPart)
+JsonObjectToken = $("JSON_OBJECT"i !IdentifierPart)
+KeyToken = $("KEY"i !IdentifierPart)
+ValueToken = $("VALUE"i !IdentifierPart)
LeadingToken = $("LEADING"i !IdentifierPart)
LikeToken = $("LIKE"i !IdentifierPart)
LimitToken = $("LIMIT"i !IdentifierPart)
diff --git a/src/sql/sql-alias/sql-alias.spec.ts b/src/sql/sql-alias/sql-alias.spec.ts
index b7e59ff9..2a3f6f80 100644
--- a/src/sql/sql-alias/sql-alias.spec.ts
+++ b/src/sql/sql-alias/sql-alias.spec.ts
@@ -172,9 +172,49 @@ describe('SqlAlias', () => {
});
describe('.create', () => {
- expect(
- SqlAlias.create(SqlAlias.create(SqlColumn.create('X'), 'name1'), 'name2').toString(),
- ).toEqual('"X" AS "name2"');
+ it('overwrites existing alias when aliasing an already aliased expression', () => {
+ expect(
+ SqlAlias.create(SqlAlias.create(SqlColumn.create('X'), 'name1'), 'name2').toString(),
+ ).toEqual('"X" AS "name2"');
+ });
+
+ it('creates a simple alias with string column and string alias', () => {
+ expect(SqlAlias.create(SqlColumn.create('col1'), 'alias1').toString()).toEqual(
+ '"col1" AS "alias1"',
+ );
+ });
+
+ it('creates an alias with RefName object as alias', () => {
+ const refName = RefName.create('myAlias', true);
+ expect(SqlAlias.create(SqlColumn.create('col1'), refName).toString()).toEqual(
+ '"col1" AS "myAlias"',
+ );
+ });
+
+ it('auto-quotes aliases that are reserved keywords', () => {
+ expect(SqlAlias.create(SqlColumn.create('col1'), 'select').toString()).toEqual(
+ '"col1" AS "select"',
+ );
+ });
+
+ it('forces quotes when forceQuotes is true', () => {
+ expect(SqlAlias.create(SqlColumn.create('col1'), 'normal', true).toString()).toEqual(
+ '"col1" AS "normal"',
+ );
+ });
+
+ it('adds parentheses to SqlQuery expressions', () => {
+ const query = SqlQuery.create('tbl');
+ const aliasedQuery = SqlAlias.create(query, 'subq');
+ const result = aliasedQuery.toString();
+
+ // Check that the result contains the main components rather than exact formatting
+ expect(result).toContain('(');
+ expect(result).toContain(')');
+ expect(result).toContain('SELECT');
+ expect(result).toContain('FROM "tbl"');
+ expect(result).toContain('AS "subq"');
+ });
});
describe('#changeAlias', () => {
diff --git a/src/sql/sql-alias/sql-alias.ts b/src/sql/sql-alias/sql-alias.ts
index 78216512..db490dfc 100644
--- a/src/sql/sql-alias/sql-alias.ts
+++ b/src/sql/sql-alias/sql-alias.ts
@@ -143,6 +143,10 @@ export class SqlAlias extends SqlExpression {
public getUnderlyingExpression(): SqlExpression {
return this.expression;
}
+
+ public changeUnderlyingExpression(newExpression: SqlExpression): SqlExpression {
+ return this.changeExpression(newExpression);
+ }
}
SqlBase.register(SqlAlias);
diff --git a/src/sql/sql-base.ts b/src/sql/sql-base.ts
index 7fa394bc..c776240a 100644
--- a/src/sql/sql-base.ts
+++ b/src/sql/sql-base.ts
@@ -75,6 +75,7 @@ export type SqlTypeDesignator =
| 'joinPart'
| 'alias'
| 'labeledExpression'
+ | 'keyValue'
| 'betweenPart'
| 'likePart'
| 'comparison'
@@ -119,6 +120,7 @@ export type KeywordName =
| 'into'
| 'join'
| 'joinType'
+ | 'key'
| 'limit'
| 'natural'
| 'offset'
@@ -144,6 +146,7 @@ export type KeywordName =
| 'unbounded'
| 'union'
| 'using'
+ | 'value'
| 'values'
| 'when'
| 'where'
@@ -167,6 +170,7 @@ export type SpaceName =
| 'postDecorator'
| 'postDot'
| 'postElse'
+ | 'postKeyExpression'
| 'postEquals'
| 'postEscape'
| 'postExplainPlanFor'
@@ -246,6 +250,7 @@ export type SpaceName =
| 'prePartitionedByClause'
| 'preUnion'
| 'preUsing'
+ | 'preValueExpression'
| 'preWhereClause';
export interface SqlBaseValue {
diff --git a/src/sql/sql-case/sql-case.spec.ts b/src/sql/sql-case/sql-case.spec.ts
index 24161110..66c221f1 100644
--- a/src/sql/sql-case/sql-case.spec.ts
+++ b/src/sql/sql-case/sql-case.spec.ts
@@ -16,23 +16,14 @@ import { backAndForth } from '../../test-utils';
import { SqlCase, SqlExpression } from '..';
describe('CaseExpression', () => {
- it('things that work', () => {
- const queries: string[] = [
- `CASE WHEN (A) THEN 'hello' END`,
- `CASE WHEN TIMESTAMP '2019-08-27 18:00:00'<=(t."__time") AND (t."__time") {
+ backAndForth(sql, SqlCase);
});
it('caseless CASE Expression', () => {
diff --git a/src/sql/sql-case/sql-case.ts b/src/sql/sql-case/sql-case.ts
index f315b423..91638768 100644
--- a/src/sql/sql-case/sql-case.ts
+++ b/src/sql/sql-case/sql-case.ts
@@ -48,6 +48,7 @@ export class SqlCase extends SqlExpression {
public readonly caseExpression?: SqlExpression;
public readonly whenThenParts: SeparatedArray;
public readonly elseExpression?: SqlExpression;
+
constructor(options: SqlCaseValue) {
super(options, SqlCase.type);
this.caseExpression = options.caseExpression;
diff --git a/src/sql/sql-clause/sql-from-clause/sql-from-clause.spec.ts b/src/sql/sql-clause/sql-from-clause/sql-from-clause.spec.ts
new file mode 100644
index 00000000..5d965db1
--- /dev/null
+++ b/src/sql/sql-clause/sql-from-clause/sql-from-clause.spec.ts
@@ -0,0 +1,188 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ SeparatedArray,
+ SqlExpression,
+ SqlFromClause,
+ SqlJoinPart,
+ SqlQuery,
+ SqlTable,
+} from '../../..';
+
+describe('SqlFromClause', () => {
+ describe('.create', () => {
+ it('creates a new SqlFromClause from a single table', () => {
+ const table = SqlTable.create('tbl');
+
+ const fromClause = SqlFromClause.create([table]);
+
+ expect(fromClause).toBeInstanceOf(SqlFromClause);
+ expect(fromClause.toString()).toEqual('FROM "tbl"');
+ });
+
+ it('creates a new SqlFromClause from multiple tables', () => {
+ const table1 = SqlTable.create('tbl1');
+ const table2 = SqlTable.create('tbl2');
+
+ const fromClause = SqlFromClause.create([table1, table2]);
+
+ expect(fromClause.toString()).toEqual('FROM "tbl1", "tbl2"');
+ });
+
+ it('wraps SqlQuery expressions in parentheses', () => {
+ const query = SqlQuery.create('inner_table');
+
+ const fromClause = SqlFromClause.create([query]);
+
+ const result = fromClause.toString();
+ expect(result).toContain('FROM (');
+ // The exact formatting of the nested query depends on the QueryStyle,
+ // so we just check for the key components
+ expect(result).toContain('SELECT');
+ expect(result).toContain('FROM');
+ expect(result).toContain('inner_table');
+ expect(result).toContain(')');
+ });
+
+ it('accepts a SeparatedArray of expressions', () => {
+ const table1 = SqlTable.create('tbl1');
+ const table2 = SqlTable.create('tbl2');
+ const separatedArray = SeparatedArray.fromArray([table1, table2]);
+
+ const fromClause = SqlFromClause.create(separatedArray);
+
+ expect(fromClause.toString()).toEqual('FROM "tbl1", "tbl2"');
+ });
+ });
+
+ describe('#changeExpressions', () => {
+ it('returns a new instance with updated expressions', () => {
+ const originalTable = SqlTable.create('original');
+ const fromClause = SqlFromClause.create([originalTable]);
+
+ const newTable = SqlTable.create('new');
+ const updatedFromClause = fromClause.changeExpressions([newTable]);
+
+ expect(updatedFromClause.toString()).toEqual('FROM "new"');
+ expect(updatedFromClause).not.toBe(fromClause);
+ });
+
+ it('accepts both arrays and SeparatedArrays', () => {
+ const fromClause = SqlFromClause.create([SqlTable.create('tbl1')]);
+
+ const newTable = SqlTable.create('tbl2');
+ const withArray = fromClause.changeExpressions([newTable]);
+ const withSeparatedArray = fromClause.changeExpressions(
+ SeparatedArray.fromSingleValue(newTable),
+ );
+
+ expect(withArray.toString()).toEqual('FROM "tbl2"');
+ expect(withSeparatedArray.toString()).toEqual('FROM "tbl2"');
+ });
+ });
+
+ describe('#changeJoinParts', () => {
+ it('adds JOIN parts to the FROM clause', () => {
+ const fromClause = SqlFromClause.create([SqlTable.create('tbl1')]);
+
+ const joinPart = SqlJoinPart.create(
+ 'INNER',
+ SqlTable.create('tbl2'),
+ SqlExpression.parse('tbl1.id = tbl2.id'),
+ );
+
+ const withJoin = fromClause.changeJoinParts([joinPart]);
+
+ expect(withJoin.toString()).toContain('FROM "tbl1"');
+ expect(withJoin.toString()).toContain('INNER JOIN "tbl2" ON tbl1.id = tbl2.id');
+ });
+
+ it('removes all JOIN parts when undefined is passed', () => {
+ const fromClause = SqlFromClause.create([SqlTable.create('tbl1')]);
+
+ const joinPart = SqlJoinPart.create(
+ 'INNER',
+ SqlTable.create('tbl2'),
+ SqlExpression.parse('tbl1.id = tbl2.id'),
+ );
+
+ const withJoin = fromClause.changeJoinParts([joinPart]);
+ const withoutJoin = withJoin.changeJoinParts(undefined);
+
+ expect(withoutJoin.toString()).toEqual('FROM "tbl1"');
+ expect(withoutJoin.hasJoin()).toBe(false);
+ });
+ });
+
+ describe('#addJoin', () => {
+ it('adds a join to an existing FROM clause without joins', () => {
+ const fromClause = SqlFromClause.create([SqlTable.create('tbl1')]);
+
+ const joinPart = SqlJoinPart.create(
+ 'LEFT',
+ SqlTable.create('tbl2'),
+ SqlExpression.parse('tbl1.id = tbl2.id'),
+ );
+
+ const withJoin = fromClause.addJoin(joinPart);
+
+ expect(withJoin.toString()).toContain('FROM "tbl1"');
+ expect(withJoin.toString()).toContain('LEFT JOIN "tbl2" ON tbl1.id = tbl2.id');
+ expect(withJoin.hasJoin()).toBe(true);
+ });
+
+ it('adds a join to a FROM clause that already has joins', () => {
+ const fromClause = SqlFromClause.create([SqlTable.create('tbl1')]);
+
+ const firstJoin = SqlJoinPart.create(
+ 'LEFT',
+ SqlTable.create('tbl2'),
+ SqlExpression.parse('tbl1.id = tbl2.id'),
+ );
+
+ const secondJoin = SqlJoinPart.create(
+ 'RIGHT',
+ SqlTable.create('tbl3'),
+ SqlExpression.parse('tbl1.id = tbl3.id'),
+ );
+
+ const withBothJoins = fromClause.addJoin(firstJoin).addJoin(secondJoin);
+
+ expect(withBothJoins.toString()).toContain('FROM "tbl1"');
+ expect(withBothJoins.toString()).toContain('LEFT JOIN "tbl2" ON tbl1.id = tbl2.id');
+ expect(withBothJoins.toString()).toContain('RIGHT JOIN "tbl3" ON tbl1.id = tbl3.id');
+ expect(withBothJoins.getJoins().length).toBe(2);
+ });
+ });
+
+ describe('#removeAllJoins', () => {
+ it('removes all joins from a FROM clause', () => {
+ const fromClause = SqlFromClause.create([SqlTable.create('tbl1')]);
+
+ const joinPart = SqlJoinPart.create(
+ 'INNER',
+ SqlTable.create('tbl2'),
+ SqlExpression.parse('tbl1.id = tbl2.id'),
+ );
+
+ const withJoin = fromClause.addJoin(joinPart);
+ const withoutJoin = withJoin.removeAllJoins();
+
+ expect(withoutJoin.toString()).toEqual('FROM "tbl1"');
+ expect(withoutJoin.hasJoin()).toBe(false);
+ expect(withoutJoin.getJoins().length).toBe(0);
+ });
+ });
+});
diff --git a/src/sql/sql-clause/sql-from-clause/sql-join-part.spec.ts b/src/sql/sql-clause/sql-from-clause/sql-join-part.spec.ts
new file mode 100644
index 00000000..2af25bd1
--- /dev/null
+++ b/src/sql/sql-clause/sql-from-clause/sql-join-part.spec.ts
@@ -0,0 +1,212 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { SqlColumnList } from '../../..';
+import { SqlExpression, SqlJoinPart, SqlTable } from '../../..';
+
+describe('SqlJoinPart', () => {
+ describe('.create', () => {
+ it('creates a basic join with a table and no condition', () => {
+ const table = SqlTable.create('right_table');
+
+ const joinPart = SqlJoinPart.create('LEFT', table);
+
+ expect(joinPart).toBeInstanceOf(SqlJoinPart);
+ expect(joinPart.toString()).toEqual('LEFT JOIN "right_table"');
+ });
+
+ it('creates a join with a table and ON condition', () => {
+ const table = SqlTable.create('right_table');
+ const condition = SqlExpression.parse('left_table.id = right_table.id');
+
+ const joinPart = SqlJoinPart.create('INNER', table, condition);
+
+ expect(joinPart.toString()).toEqual(
+ 'INNER JOIN "right_table" ON left_table.id = right_table.id',
+ );
+ });
+
+ it('creates a join with an array of ON conditions', () => {
+ const table = SqlTable.create('right_table');
+ const condition1 = SqlExpression.parse('left_table.id = right_table.id');
+ const condition2 = SqlExpression.parse('left_table.name = right_table.name');
+
+ const joinPart = SqlJoinPart.create('RIGHT', table, [condition1, condition2]);
+
+ expect(joinPart.toString()).toContain('RIGHT JOIN "right_table" ON');
+ expect(joinPart.toString()).toContain('left_table.id = right_table.id');
+ expect(joinPart.toString()).toContain('AND');
+ expect(joinPart.toString()).toContain('left_table.name = right_table.name');
+ });
+
+ it('converts a query to a table', () => {
+ const query = SqlExpression.parse('SELECT * FROM source');
+
+ const joinPart = SqlJoinPart.create('INNER', query);
+
+ // The actual output depends on how convertToTable is implemented
+ // Just checking that it doesn't throw and returns a SqlJoinPart
+ expect(joinPart).toBeInstanceOf(SqlJoinPart);
+ expect(joinPart.toString()).toContain('INNER JOIN');
+ });
+ });
+
+ describe('.natural', () => {
+ it('creates a NATURAL JOIN', () => {
+ const table = SqlTable.create('right_table');
+
+ const joinPart = SqlJoinPart.natural('LEFT', table);
+
+ expect(joinPart.toString()).toEqual('NATURAL LEFT JOIN "right_table"');
+ });
+
+ it('does not allow ON or USING clauses', () => {
+ const table = SqlTable.create('right_table');
+
+ const joinPart = SqlJoinPart.natural('INNER', table);
+
+ expect(joinPart.toString()).not.toContain('ON');
+ expect(joinPart.toString()).not.toContain('USING');
+ });
+ });
+
+ describe('.cross', () => {
+ it('creates a CROSS JOIN', () => {
+ const table = SqlTable.create('right_table');
+
+ const joinPart = SqlJoinPart.cross(table);
+
+ expect(joinPart.toString()).toEqual('CROSS JOIN "right_table"');
+ });
+ });
+
+ describe('#changeJoinTable', () => {
+ it('changes the table in the join', () => {
+ const originalTable = SqlTable.create('original');
+ const joinPart = SqlJoinPart.create('LEFT', originalTable);
+
+ const newTable = SqlTable.create('new');
+ const newJoinPart = joinPart.changeJoinTable(newTable);
+
+ expect(newJoinPart.toString()).toEqual('LEFT JOIN "new"');
+ expect(newJoinPart).not.toBe(joinPart);
+ });
+ });
+
+ describe('#makeNatural', () => {
+ it('converts a regular join to a NATURAL join', () => {
+ const table = SqlTable.create('right_table');
+ const condition = SqlExpression.parse('left_table.id = right_table.id');
+ const joinPart = SqlJoinPart.create('INNER', table, condition);
+
+ const naturalJoin = joinPart.makeNatural();
+
+ expect(naturalJoin.toString()).toEqual('NATURAL INNER JOIN "right_table"');
+ });
+
+ it('removes both ON and USING clauses if present', () => {
+ // Setup a join with USING (this is just for testing, not a real valid SQL join)
+ const table = SqlTable.create('right_table');
+ const joinPart = new SqlJoinPart({
+ joinType: 'INNER',
+ table,
+ onExpression: SqlExpression.parse('left_table.id = right_table.id'),
+ usingColumns: {} as SqlColumnList, // Type casting for test
+ });
+
+ const naturalJoin = joinPart.makeNatural();
+
+ expect(naturalJoin.toString()).toEqual('NATURAL INNER JOIN "right_table"');
+ });
+ });
+
+ describe('#changeOnExpression', () => {
+ it('changes the ON condition of a join', () => {
+ const table = SqlTable.create('right_table');
+ const originalCondition = SqlExpression.parse('left_table.id = right_table.id');
+ const joinPart = SqlJoinPart.create('INNER', table, originalCondition);
+
+ const newCondition = SqlExpression.parse('left_table.code = right_table.code');
+ const newJoinPart = joinPart.changeOnExpression(newCondition);
+
+ expect(newJoinPart.toString()).toEqual(
+ 'INNER JOIN "right_table" ON left_table.code = right_table.code',
+ );
+ });
+
+ it('converts a NATURAL join to a regular join with ON', () => {
+ const table = SqlTable.create('right_table');
+ const naturalJoin = SqlJoinPart.natural('LEFT', table);
+
+ const condition = SqlExpression.parse('left_table.id = right_table.id');
+ const regularJoin = naturalJoin.changeOnExpression(condition);
+
+ expect(regularJoin.toString()).toEqual(
+ 'LEFT JOIN "right_table" ON left_table.id = right_table.id',
+ );
+ expect(regularJoin.toString()).not.toContain('NATURAL');
+ });
+
+ it('replaces USING with ON if both are specified', () => {
+ // Setup a join with USING (this is just for testing)
+ const table = SqlTable.create('right_table');
+ const joinPart = new SqlJoinPart({
+ joinType: 'INNER',
+ table,
+ usingColumns: {} as SqlColumnList, // Type casting for test
+ });
+
+ const condition = SqlExpression.parse('left_table.id = right_table.id');
+ const withOnJoin = joinPart.changeOnExpression(condition);
+
+ expect(withOnJoin.toString()).toEqual(
+ 'INNER JOIN "right_table" ON left_table.id = right_table.id',
+ );
+ });
+ });
+
+ describe('#changeUsingColumns', () => {
+ it('changes to a USING join and removes ON clause', () => {
+ const table = SqlTable.create('right_table');
+ const condition = SqlExpression.parse('left_table.id = right_table.id');
+ const joinPart = SqlJoinPart.create('INNER', table, condition);
+
+ // Creating a simple mock of SqlColumnList for testing
+ const usingColumns = {
+ toString: () => '(id)',
+ } as SqlColumnList;
+
+ const usingJoin = joinPart.changeUsingColumns(usingColumns);
+
+ expect(usingJoin.toString()).toEqual('INNER JOIN "right_table" USING (id)');
+ expect(usingJoin.toString()).not.toContain('ON');
+ });
+
+ it('converts a NATURAL join to a USING join', () => {
+ const table = SqlTable.create('right_table');
+ const naturalJoin = SqlJoinPart.natural('LEFT', table);
+
+ // Creating a simple mock of SqlColumnList for testing
+ const usingColumns = {
+ toString: () => '(id, name)',
+ } as SqlColumnList;
+
+ const usingJoin = naturalJoin.changeUsingColumns(usingColumns);
+
+ // The implementation only removes 'natural' from keywords, not the property,
+ // so we need to test that it's still displayed as NATURAL but we remove it from output
+ expect(usingJoin.toString()).toEqual('NATURAL LEFT JOIN "right_table" USING (id, name)');
+ });
+ });
+});
diff --git a/src/sql/sql-clause/sql-group-by-clause/sql-group-by-clause.ts b/src/sql/sql-clause/sql-group-by-clause/sql-group-by-clause.ts
index f88ff668..3dedc73c 100644
--- a/src/sql/sql-clause/sql-group-by-clause/sql-group-by-clause.ts
+++ b/src/sql/sql-clause/sql-group-by-clause/sql-group-by-clause.ts
@@ -36,10 +36,7 @@ export class SqlGroupByClause extends SqlClause {
static create(expressions?: SeparatedArray | SqlExpression[]): SqlGroupByClause {
return new SqlGroupByClause({
- expressions:
- !expressions || isEmptyArray(expressions)
- ? undefined
- : SeparatedArray.fromArray(expressions),
+ expressions: SeparatedArray.fromPossiblyEmptyArray(expressions),
});
}
diff --git a/src/sql/sql-clause/sql-limit-clause/sql-limit-clause.spec.ts b/src/sql/sql-clause/sql-limit-clause/sql-limit-clause.spec.ts
new file mode 100644
index 00000000..0b387d07
--- /dev/null
+++ b/src/sql/sql-clause/sql-limit-clause/sql-limit-clause.spec.ts
@@ -0,0 +1,68 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { SqlLimitClause, SqlLiteral } from '../../..';
+
+describe('SqlLimitClause', () => {
+ describe('.create', () => {
+ it('creates a limit clause from a number', () => {
+ const limitClause = SqlLimitClause.create(100);
+
+ expect(limitClause).toBeInstanceOf(SqlLimitClause);
+ expect(limitClause.toString()).toEqual('LIMIT 100');
+ });
+
+ it('creates a limit clause from a SqlLiteral', () => {
+ const literal = SqlLiteral.create(50);
+ const limitClause = SqlLimitClause.create(literal);
+
+ expect(limitClause.toString()).toEqual('LIMIT 50');
+ });
+ });
+
+ describe('#changeLimit', () => {
+ it('changes the limit value when given a number', () => {
+ const limitClause = SqlLimitClause.create(100);
+
+ const newLimitClause = limitClause.changeLimit(200);
+
+ expect(newLimitClause.toString()).toEqual('LIMIT 200');
+ expect(newLimitClause).not.toBe(limitClause);
+ });
+
+ it('changes the limit value when given a SqlLiteral', () => {
+ const limitClause = SqlLimitClause.create(100);
+
+ const literal = SqlLiteral.create(300);
+ const newLimitClause = limitClause.changeLimit(literal);
+
+ expect(newLimitClause.toString()).toEqual('LIMIT 300');
+ });
+ });
+
+ describe('#getLimitValue', () => {
+ it('returns the limit value as a number', () => {
+ const limitClause = SqlLimitClause.create(123);
+
+ expect(limitClause.getLimitValue()).toBe(123);
+ });
+
+ it('returns the correct number when limit was created from a SqlLiteral', () => {
+ const literal = SqlLiteral.create(456);
+ const limitClause = SqlLimitClause.create(literal);
+
+ expect(limitClause.getLimitValue()).toBe(456);
+ });
+ });
+});
diff --git a/src/sql/sql-clause/sql-partition-by-clause/sql-partition-by-clause.spec.ts b/src/sql/sql-clause/sql-partition-by-clause/sql-partition-by-clause.spec.ts
new file mode 100644
index 00000000..93276571
--- /dev/null
+++ b/src/sql/sql-clause/sql-partition-by-clause/sql-partition-by-clause.spec.ts
@@ -0,0 +1,108 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { SeparatedArray, SqlColumn, SqlLiteral, SqlPartitionByClause } from '../../..';
+
+describe('SqlPartitionByClause', () => {
+ describe('.create', () => {
+ it('creates a PARTITION BY clause from a single expression', () => {
+ const column = SqlColumn.create('x');
+
+ const partitionByClause = SqlPartitionByClause.create([column]);
+
+ expect(partitionByClause).toBeInstanceOf(SqlPartitionByClause);
+ expect(partitionByClause.toString()).toEqual(`PARTITION BY "x"`);
+ });
+
+ it('creates a PARTITION BY clause from multiple expressions', () => {
+ const column1 = SqlColumn.create('x');
+ const column2 = SqlColumn.create('y');
+
+ const partitionByClause = SqlPartitionByClause.create([column1, column2]);
+
+ expect(partitionByClause).toBeInstanceOf(SqlPartitionByClause);
+ expect(partitionByClause.toString()).toEqual(`PARTITION BY "x", "y"`);
+ });
+
+ it('accepts a SeparatedArray of expressions', () => {
+ const column1 = SqlColumn.create('x');
+ const column2 = SqlColumn.create('y');
+ const separatedArray = SeparatedArray.fromArray([column1, column2]);
+
+ const partitionByClause = SqlPartitionByClause.create(separatedArray);
+
+ expect(partitionByClause).toBeInstanceOf(SqlPartitionByClause);
+ expect(partitionByClause.toString()).toEqual(`PARTITION BY "x", "y"`);
+ });
+
+ it('works with mixed expression types including literals', () => {
+ const column = SqlColumn.create('x');
+ const literal = SqlLiteral.create(123);
+
+ const partitionByClause = SqlPartitionByClause.create([column, literal]);
+
+ expect(partitionByClause).toBeInstanceOf(SqlPartitionByClause);
+ expect(partitionByClause.toString()).toEqual(`PARTITION BY "x", 123`);
+ });
+ });
+
+ describe('#changeExpressions', () => {
+ it('returns a new instance with updated expressions', () => {
+ const column = SqlColumn.create('x');
+ const partitionByClause = SqlPartitionByClause.create([column]);
+
+ const newColumn = SqlColumn.create('y');
+ const updatedClause = partitionByClause.changeExpressions([newColumn]);
+
+ expect(updatedClause).toBeInstanceOf(SqlPartitionByClause);
+ expect(updatedClause.toString()).toEqual(`PARTITION BY "y"`);
+ expect(updatedClause).not.toBe(partitionByClause);
+ });
+
+ it('accepts both arrays and SeparatedArrays', () => {
+ const partitionByClause = SqlPartitionByClause.create([SqlColumn.create('x')]);
+
+ const newColumn = SqlColumn.create('y');
+ const withArray = partitionByClause.changeExpressions([newColumn]);
+ const withSeparatedArray = partitionByClause.changeExpressions(
+ SeparatedArray.fromSingleValue(newColumn),
+ );
+
+ expect(withArray.toString()).toEqual(`PARTITION BY "y"`);
+ expect(withSeparatedArray.toString()).toEqual(`PARTITION BY "y"`);
+ });
+
+ it('changes multiple expressions to a single expression', () => {
+ const column1 = SqlColumn.create('x');
+ const column2 = SqlColumn.create('y');
+ const partitionByClause = SqlPartitionByClause.create([column1, column2]);
+
+ const newColumn = SqlColumn.create('z');
+ const updatedClause = partitionByClause.changeExpressions([newColumn]);
+
+ expect(updatedClause.toString()).toEqual(`PARTITION BY "z"`);
+ });
+
+ it('changes a single expression to multiple expressions', () => {
+ const column = SqlColumn.create('x');
+ const partitionByClause = SqlPartitionByClause.create([column]);
+
+ const newColumn1 = SqlColumn.create('y');
+ const newColumn2 = SqlColumn.create('z');
+ const updatedClause = partitionByClause.changeExpressions([newColumn1, newColumn2]);
+
+ expect(updatedClause.toString()).toEqual(`PARTITION BY "y", "z"`);
+ });
+ });
+});
diff --git a/src/sql/sql-column/sql-column.spec.ts b/src/sql/sql-column/sql-column.spec.ts
index b3a15d36..86757a9f 100644
--- a/src/sql/sql-column/sql-column.spec.ts
+++ b/src/sql/sql-column/sql-column.spec.ts
@@ -16,8 +16,8 @@ import { SqlColumn, SqlExpression, SqlQuery } from '../..';
import { backAndForth } from '../../test-utils';
describe('SqlColumn', () => {
- it('things that work', () => {
- const queries: string[] = [
+ describe('column expressions', () => {
+ it.each([
`hello`,
`h`,
`_hello`,
@@ -27,16 +27,9 @@ describe('SqlColumn', () => {
`a.b`,
`"a""b".c`,
`U&"fo\\feffo"`, // \ufeff = invisible space
- ];
-
- for (const sql of queries) {
- try {
- backAndForth(sql);
- } catch (e) {
- console.log(`Problem with: \`${sql}\``);
- throw e;
- }
- }
+ ])('correctly parses: %s', sql => {
+ backAndForth(sql);
+ });
});
it('avoids reserved', () => {
diff --git a/src/sql/sql-comparison/sql-comparison.spec.ts b/src/sql/sql-comparison/sql-comparison.spec.ts
index 9a3ced95..e6c1c032 100644
--- a/src/sql/sql-comparison/sql-comparison.spec.ts
+++ b/src/sql/sql-comparison/sql-comparison.spec.ts
@@ -16,81 +16,72 @@ import { backAndForth } from '../../test-utils';
import { SqlColumn, SqlComparison, SqlExpression } from '..';
describe('SqlComparison', () => {
- it('things that work', () => {
- const queries: string[] = [
- 'x = y',
- 'x != y',
- 'x <> y',
- '(1, ROW(2)) = Row (1, 1 + 1)',
-
- 'x < y',
- 'x > y',
- 'x <= y',
- 'x >= y',
- ' x >= y ',
-
- `X = ANY (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
- `X <> any (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
- `X < ALL (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
- `X > all (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
- `X <= SOME (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
- `X >= some (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
-
- `X IN ('moon', 'beam')`,
- `X IN ('mo' || 'on', 'be' || 'am')`,
- `X NOT IN ('moon', 'beam')`,
- `X IN (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
- `X IN ((SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5))`,
- `(browser, country) IN (ROW ('Chr' || 'ome', 'United States'), ('Firefox', 'Israel'))`,
- `(1, 2) IN (VALUES (1, 1 + 1),(2, 1 + 1))`,
-
- `x IS NOT DISTINCT FROM y`,
- `x IS DISTINCT FROM y`,
-
- '2 between 1 and 3',
- '2 between 3 and 2',
- '2 between symmetric 3 and 2',
- '3 between 1 and 3',
- '4 between 1 and 3',
- '1 between 4 and -3',
- '1 between -1 and -3',
- '1 between -1 and 3',
- '1 between 1 and 1',
- '1.5 between 1 and 3',
- '1.2 between 1.1 and 1.3',
- '1.5 between 2 and 3',
- '1.5 between 1.6 and 1.7',
- '1.2e1 between 1.1 and 1.3',
- '1.2e0 between 1.1 and 1.3',
- '1.5e0 between 2 and 3',
- '1.5e0 between 2e0 and 3e0',
- '1.5e1 between 1.6e1 and 1.7e1',
- "x'' between x'' and x''",
- 'cast(null as integer) between -1 and 2',
- '1 between -1 and cast(null as integer)',
- '1 between cast(null as integer) and cast(null as integer)',
- '1 between cast(null as integer) and 1',
- "x'0A00015A' between x'0A000130' and x'0A0001B0'",
- "x'0A00015A' between x'0A0001A0' and x'0A0001B0'",
- '2 not between 1 and 3',
- '3 not between 1 and 3',
- '4 not between 1 and 3',
- '1.2e0 not between 1.1 and 1.3',
- '1.2e1 not between 1.1 and 1.3',
- '1.5e0 not between 2 and 3',
- '1.5e0 not between 2e0 and 3e0',
- "x'0A00015A' not between x'0A000130' and x'0A0001B0'",
- "x'0A00015A' not between x'0A0001A0' and x'0A0001B0'",
- ];
-
- for (const sql of queries) {
- try {
- backAndForth(sql, SqlComparison);
- } catch (e) {
- console.log(`Problem with: \`${sql}\``);
- throw e;
- }
- }
+ it.each([
+ 'x = y',
+ 'x != y',
+ 'x <> y',
+ '(1, ROW(2)) = Row (1, 1 + 1)',
+
+ 'x < y',
+ 'x > y',
+ 'x <= y',
+ 'x >= y',
+ ' x >= y ',
+
+ `X = ANY (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
+ `X <> any (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
+ `X < ALL (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
+ `X > all (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
+ `X <= SOME (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
+ `X >= some (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
+
+ `X IN ('moon', 'beam')`,
+ `X IN ('mo' || 'on', 'be' || 'am')`,
+ `X NOT IN ('moon', 'beam')`,
+ `X IN (SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5)`,
+ `X IN ((SELECT page FROM wikipedia GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 5))`,
+ `(browser, country) IN (ROW ('Chr' || 'ome', 'United States'), ('Firefox', 'Israel'))`,
+ `(1, 2) IN (VALUES (1, 1 + 1),(2, 1 + 1))`,
+
+ `x IS NOT DISTINCT FROM y`,
+ `x IS DISTINCT FROM y`,
+
+ '2 between 1 and 3',
+ '2 between 3 and 2',
+ '2 between symmetric 3 and 2',
+ '3 between 1 and 3',
+ '4 between 1 and 3',
+ '1 between 4 and -3',
+ '1 between -1 and -3',
+ '1 between -1 and 3',
+ '1 between 1 and 1',
+ '1.5 between 1 and 3',
+ '1.2 between 1.1 and 1.3',
+ '1.5 between 2 and 3',
+ '1.5 between 1.6 and 1.7',
+ '1.2e1 between 1.1 and 1.3',
+ '1.2e0 between 1.1 and 1.3',
+ '1.5e0 between 2 and 3',
+ '1.5e0 between 2e0 and 3e0',
+ '1.5e1 between 1.6e1 and 1.7e1',
+ "x'' between x'' and x''",
+ 'cast(null as integer) between -1 and 2',
+ '1 between -1 and cast(null as integer)',
+ '1 between cast(null as integer) and cast(null as integer)',
+ '1 between cast(null as integer) and 1',
+ "x'0A00015A' between x'0A000130' and x'0A0001B0'",
+ "x'0A00015A' between x'0A0001A0' and x'0A0001B0'",
+ '2 not between 1 and 3',
+ '3 not between 1 and 3',
+ '4 not between 1 and 3',
+ '1.2e0 not between 1.1 and 1.3',
+ '1.2e1 not between 1.1 and 1.3',
+ '1.5e0 not between 2 and 3',
+ '1.5e0 not between 2e0 and 3e0',
+ "x'0A00015A' not between x'0A000130' and x'0A0001B0'",
+ "x'0A00015A' not between x'0A0001A0' and x'0A0001B0'",
+ ])('does back and forth with %s', sql => {
+ backAndForth(sql, SqlComparison);
});
describe('factories', () => {
diff --git a/src/sql/sql-expression.spec.ts b/src/sql/sql-expression.spec.ts
index f3b757c2..4d6f09bb 100644
--- a/src/sql/sql-expression.spec.ts
+++ b/src/sql/sql-expression.spec.ts
@@ -16,59 +16,39 @@ import { RefName, SqlBase, SqlColumn, SqlExpression, SqlLiteral } from '..';
import { backAndForth, backAndForthPrettify, mapString } from '../test-utils';
describe('SqlExpression', () => {
- it('things that work', () => {
- const queries: string[] = [
- '1',
- '1 + 1',
- `CONCAT('a', 'b')`,
- `(Select 1)`,
- `(TIMESTAMP '2019-08-27 18:00:00'<=(t."__time") AND (t."__time") {
+ backAndForth(sql, SqlExpression);
});
- it('things that work and prettify to themselves', () => {
- const queries: string[] = ['1', '1 + 1', `CONCAT('a', 'b')`, `x IN ('Hello World')`];
-
- for (const sql of queries) {
- try {
- backAndForthPrettify(sql, SqlExpression);
- } catch (e) {
- console.log(`Problem with: \`${sql}\``);
- throw e;
- }
- }
- });
+ it.each(['1', '1 + 1', `CONCAT('a', 'b')`, `x IN ('Hello World')`])(
+ 'does back and forth prettify with %s',
+ sql => {
+ backAndForthPrettify(sql, SqlExpression);
+ },
+ );
- it('plywood expressions should not parse', () => {
- const queries: string[] = [`$lol`, `#main.sum($count)`];
-
- for (const sql of queries) {
- let didNotError = false;
- try {
- SqlBase.parseSql(sql);
- didNotError = true;
- } catch {}
- if (didNotError) {
- throw new Error(`should not parse: ${sql}`);
- }
- }
+ it.each([`$lol`, `#main.sum($count)`])('plywood expression %s should not parse', sql => {
+ expect(() => SqlBase.parseSql(sql)).toThrow();
});
describe('factories (static)', () => {
diff --git a/src/sql/sql-expression.ts b/src/sql/sql-expression.ts
index ddd4c9ba..5adfb4b9 100644
--- a/src/sql/sql-expression.ts
+++ b/src/sql/sql-expression.ts
@@ -222,6 +222,10 @@ export abstract class SqlExpression extends SqlBase {
return this;
}
+ public changeUnderlyingExpression(newExpression: SqlExpression): SqlExpression {
+ return newExpression;
+ }
+
public getOutputName(): string | undefined {
return;
}
diff --git a/src/sql/sql-function/sql-function.spec.ts b/src/sql/sql-function/sql-function.spec.ts
index 7638e313..f0839bec 100644
--- a/src/sql/sql-function/sql-function.spec.ts
+++ b/src/sql/sql-function/sql-function.spec.ts
@@ -13,16 +13,15 @@
*/
import { backAndForth } from '../../test-utils';
-import { SqlColumn, SqlExpression, SqlFunction, SqlStar } from '..';
+import { SqlColumn, SqlExpression, SqlFunction, SqlKeyValue, SqlLiteral, SqlStar } from '..';
describe('SqlFunction', () => {
- it('things that work', () => {
- const functionExpressions: string[] = [
+ describe('parsing various SQL functions', () => {
+ it.each([
`COUNT(*)`,
`"COUNT"(*)`,
`COUNT(DISTINCT blah)`,
`COUNT(ALL blah)`,
-
"position('b' in 'abc')",
"position('' in 'abc')",
"position('b' in 'abcabc' FROM 3)",
@@ -41,28 +40,23 @@ describe('SqlFunction', () => {
"position(x'cc' in x'aabbccdd' FROM 2)",
"position(x'' in x'aabbcc' FROM 3)",
"position (x'' in x'aabbcc' FROM 10)",
-
"trim(leading 'eh' from 'hehe__hehe')",
"trim(trailing 'eh' from 'hehe__hehe')",
"trim('eh' from 'hehe__hehe')",
`"trim" ('eh' from 'hehe__hehe')`,
-
`JSON_VALUE(my_json, '$.x')`,
`JSON_VALUE(my_json, '$.x' RETURNING DOUBLE)`,
-
`SomeFn("arg1" => "boo")`,
`"SomeFn" ("arg1" => "boo")`,
`"ext" . "SomeFn" ("arg1" => "boo")`,
`LOCAL(path => '/tmp/druid')`,
`EXTERN(LOCAL("path" => '/tmp/druid'))`,
`NESTED_AGG(TIME_FLOOR(t.__time, 'P1D'), COUNT(DISTINCT t."ip") AS "daily_unique", AVG("daily_unique"))`,
-
`TABLE(extern('{...}', '{...}', '[...]'))`,
`"TABLE" (extern('{...}', '{...}', '[...]'))`,
`TABLE(extern('{...}', '{...}')) EXTEND (x VARCHAR, y BIGINT, z TYPE('COMPLEX'))`,
`TABLE(extern('{...}', '{...}')) (x VARCHAR, y BIGINT, z TYPE('COMPLEX'))`,
`TABLE(extern('{...}', '{...}')) EXTEND (xs VARCHAR ARRAY, ys BIGINT ARRAY, zs DOUBLE ARRAY)`,
-
`SUM(COUNT(*)) OVER ()`,
`SUM(COUNT(*)) Over ("windowName" Order by COUNT(*) Desc)`,
`ROW_NUMBER() OVER (PARTITION BY t."country", t."city" ORDER BY COUNT(*) DESC)`,
@@ -73,20 +67,12 @@ describe('SqlFunction', () => {
`ROW_NUMBER() OVER (PARTITION BY t."country", t."city" ORDER BY COUNT(*) DESC RANGE UNBOUNDED FOLLOWING)`,
`ROW_NUMBER() OVER (PARTITION BY t."country", t."city" ORDER BY COUNT(*) DESC RANGE BETWEEN UNBOUNDED FOLLOWING AND CURRENT ROW)`,
`count(*) over (partition by cityName order by countryName rows between unbounded preceding and 1 preceding)`,
-
`PI`,
`CURRENT_TIMESTAMP`,
`UNNEST(t)`,
- ];
-
- for (const sql of functionExpressions) {
- try {
- backAndForth(sql, SqlFunction);
- } catch (e) {
- console.log(`Problem with: \`${sql}\``);
- throw e;
- }
- }
+ ])('correctly parses: %s', sql => {
+ backAndForth(sql, SqlFunction);
+ });
});
it('is smart about clearing separators', () => {
@@ -130,6 +116,95 @@ describe('SqlFunction', () => {
);
});
+ describe('.jsonObject', () => {
+ it('with no arguments', () => {
+ expect(SqlFunction.jsonObject().toString()).toEqual('JSON_OBJECT()');
+ expect(SqlFunction.jsonObject({}).toString()).toEqual('JSON_OBJECT()');
+ });
+
+ it('with object of key-value pairs', () => {
+ expect(SqlFunction.jsonObject({ name: 'John', age: 30 }).toString()).toEqual(
+ `JSON_OBJECT('name':'John', 'age':30)`,
+ );
+ });
+
+ it('with nested object', () => {
+ expect(
+ SqlFunction.jsonObject({
+ name: 'John',
+ age: { years: 30, months: 1 },
+ hobbies: ['skiing', 'sleeping'],
+ }).toString(),
+ ).toEqual(
+ `JSON_OBJECT('name':'John', 'age':JSON_OBJECT('years':30, 'months':1), 'hobbies':ARRAY['skiing', 'sleeping'])`,
+ );
+ });
+
+ it('with a single SqlKeyValue (longhand)', () => {
+ const keyValue = SqlKeyValue.create(SqlLiteral.create('name'), SqlLiteral.create('John'));
+ expect(SqlFunction.jsonObject(keyValue).toString()).toEqual(
+ `JSON_OBJECT(KEY 'name' VALUE 'John')`,
+ );
+ });
+
+ it('with a single SqlKeyValue (shorthand)', () => {
+ const keyValue = SqlKeyValue.short(SqlLiteral.create('name'), SqlLiteral.create('John'));
+ expect(SqlFunction.jsonObject(keyValue).toString()).toEqual(`JSON_OBJECT('name':'John')`);
+ });
+
+ it('with an array of SqlKeyValue objects (longhand)', () => {
+ const keyValues = [
+ SqlKeyValue.create(SqlLiteral.create('name'), SqlLiteral.create('John')),
+ SqlKeyValue.create(SqlLiteral.create('age'), SqlLiteral.create(30)),
+ ];
+ expect(SqlFunction.jsonObject(keyValues).toString()).toEqual(
+ `JSON_OBJECT(KEY 'name' VALUE 'John', KEY 'age' VALUE 30)`,
+ );
+ });
+
+ it('with an array of SqlKeyValue objects (shorthand)', () => {
+ const keyValues = [
+ SqlKeyValue.short(SqlLiteral.create('name'), SqlLiteral.create('John')),
+ SqlKeyValue.short(SqlLiteral.create('age'), SqlLiteral.create(30)),
+ ];
+ expect(SqlFunction.jsonObject(keyValues).toString()).toEqual(
+ `JSON_OBJECT('name':'John', 'age':30)`,
+ );
+ });
+
+ it('with mixed longhand and shorthand SqlKeyValue objects', () => {
+ const keyValues = [
+ SqlKeyValue.create(SqlLiteral.create('name'), SqlLiteral.create('John')),
+ SqlKeyValue.short(SqlLiteral.create('age'), SqlLiteral.create(30)),
+ ];
+ expect(SqlFunction.jsonObject(keyValues).toString()).toEqual(
+ `JSON_OBJECT(KEY 'name' VALUE 'John', 'age':30)`,
+ );
+ });
+
+ it('with SqlExpression keys and values', () => {
+ const keyValues = SqlKeyValue.create(
+ SqlExpression.parse('column_name'),
+ SqlExpression.parse('column_value'),
+ );
+ expect(SqlFunction.jsonObject(keyValues).toString()).toEqual(
+ 'JSON_OBJECT(KEY column_name VALUE column_value)',
+ );
+ });
+
+ it('with complex expressions', () => {
+ // Create complex expressions using the builder pattern
+ const userId = SqlColumn.create('user').concat(SqlColumn.create('id'));
+ const valueAsVarchar = SqlFunction.cast(SqlColumn.create('value'), 'VARCHAR');
+
+ const keyValues = SqlKeyValue.short(userId, valueAsVarchar);
+
+ expect(SqlFunction.jsonObject(keyValues).toString()).toEqual(
+ 'JSON_OBJECT("user" || "id":CAST("value" AS VARCHAR))',
+ );
+ });
+ });
+
it('.floor', () => {
expect(SqlFunction.floor(SqlColumn.create('__time'), 'Hour').toString()).toEqual(
'FLOOR("__time" TO Hour)',
@@ -140,6 +215,19 @@ describe('SqlFunction', () => {
expect(SqlFunction.arrayOfLiterals(['a', 'b', 'c']).toString()).toEqual(`ARRAY['a', 'b', 'c']`);
});
+ it('.array', () => {
+ expect(SqlFunction.array().toString()).toEqual(`ARRAY[]`);
+ expect(SqlFunction.array('a', 'b', 'c').toString()).toEqual(`ARRAY['a', 'b', 'c']`);
+ expect(SqlFunction.array(1, 2, 3).toString()).toEqual(`ARRAY[1, 2, 3]`);
+ expect(
+ SqlFunction.array(SqlColumn.create('col1'), SqlColumn.create('col2')).toString(),
+ ).toEqual(`ARRAY["col1", "col2"]`);
+
+ // Test the backward compatibility case
+ expect(SqlFunction.array([] as any).toString()).toEqual(`ARRAY[]`);
+ expect(SqlFunction.array(['x', 'y', 'z'] as any).toString()).toEqual(`ARRAY['x', 'y', 'z']`);
+ });
+
it('Function without args', () => {
const sql = `FN()`;
diff --git a/src/sql/sql-function/sql-function.ts b/src/sql/sql-function/sql-function.ts
index 0451ac53..abeb2c78 100644
--- a/src/sql/sql-function/sql-function.ts
+++ b/src/sql/sql-function/sql-function.ts
@@ -12,13 +12,14 @@
* limitations under the License.
*/
-import { compact, filterMap } from '../../utils';
+import { compact, filterMap, isDate } from '../../utils';
import { SPECIAL_FUNCTIONS } from '../special-functions';
import type { SqlBaseValue, SqlTypeDesignator, Substitutor } from '../sql-base';
import { SqlBase } from '../sql-base';
import type { SqlColumnDeclaration } from '../sql-clause';
import { SqlExtendClause, SqlWhereClause } from '../sql-clause';
import { SqlExpression } from '../sql-expression';
+import { SqlKeyValue } from '../sql-key-value/sql-key-value';
import { SqlLabeledExpression } from '../sql-labeled-expression/sql-labeled-expression';
import type { LiteralValue } from '../sql-literal/sql-literal';
import { SqlLiteral } from '../sql-literal/sql-literal';
@@ -150,6 +151,56 @@ export class SqlFunction extends SqlExpression {
});
}
+ static jsonObject(
+ keyValues?: SeparatedArray | SqlKeyValue[] | SqlKeyValue | Record,
+ ): SqlExpression {
+ let args: SeparatedArray | undefined;
+ if (keyValues instanceof SeparatedArray) {
+ args = keyValues;
+ } else if (Array.isArray(keyValues)) {
+ args = SeparatedArray.fromPossiblyEmptyArray(keyValues);
+ } else if (keyValues instanceof SqlKeyValue) {
+ args = SeparatedArray.fromSingleValue(keyValues);
+ } else if (keyValues && typeof keyValues === 'object') {
+ args = SeparatedArray.fromPossiblyEmptyArray(
+ filterMap(Object.entries(keyValues), ([k, v]) => {
+ let value: SqlExpression | LiteralValue;
+ switch (typeof v) {
+ case 'object':
+ if (!v || v instanceof SqlExpression || isDate(v)) {
+ value = v;
+ } else if (Array.isArray(v)) {
+ value = SqlFunction.array(...v);
+ } else {
+ value = SqlFunction.jsonObject(v);
+ }
+ break;
+
+ case 'undefined':
+ return;
+
+ case 'function':
+ case 'symbol':
+ throw new TypeError(`Cannot use ${typeof v} (in key ${k}) as a JSON object value`);
+
+ case 'string':
+ case 'number':
+ case 'bigint':
+ case 'boolean':
+ default:
+ value = v;
+ }
+ return SqlKeyValue.short(k, value);
+ }),
+ );
+ }
+
+ return new SqlFunction({
+ functionName: RefName.functionName('JSON_OBJECT'),
+ args,
+ });
+ }
+
static floor(ex: SqlExpression, timeUnit: string | SqlLiteral): SqlExpression {
return new SqlFunction({
functionName: RefName.functionName('FLOOR'),
@@ -194,7 +245,7 @@ export class SqlFunction extends SqlExpression {
return new SqlFunction({
functionName: RefName.functionName('ARRAY'),
specialParen: 'square',
- args: SeparatedArray.fromArray(exs.map(SqlExpression.wrap)),
+ args: SeparatedArray.fromPossiblyEmptyArray(exs.map(SqlExpression.wrap)),
});
}
diff --git a/src/sql/sql-key-value/sql-key-value.spec.ts b/src/sql/sql-key-value/sql-key-value.spec.ts
new file mode 100644
index 00000000..2248ade8
--- /dev/null
+++ b/src/sql/sql-key-value/sql-key-value.spec.ts
@@ -0,0 +1,59 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { SqlExpression, SqlKeyValue, SqlLiteral } from '../..';
+import { backAndForth } from '../../test-utils';
+
+describe('SqlKeyValue', () => {
+ it('creates a key-value pair with longhand syntax', () => {
+ const keyValue = SqlKeyValue.create(SqlLiteral.create('x'), SqlLiteral.create('y'));
+
+ expect(keyValue.toString()).toEqual("KEY 'x' VALUE 'y'");
+ });
+
+ it('creates a key-value pair with shorthand syntax', () => {
+ const keyValue = SqlKeyValue.short(SqlLiteral.create('x'), SqlLiteral.create('y'));
+
+ expect(keyValue.toString()).toEqual("'x':'y'");
+ });
+
+ it('allows changing the key', () => {
+ const keyValue = SqlKeyValue.create(SqlLiteral.create('x'), SqlLiteral.create('y'));
+
+ const changed = keyValue.changeKey(SqlLiteral.create('z'));
+ expect(changed.toString()).toEqual("KEY 'z' VALUE 'y'");
+ });
+
+ it('allows changing the value', () => {
+ const keyValue = SqlKeyValue.create(SqlLiteral.create('x'), SqlLiteral.create('y'));
+
+ const changed = keyValue.changeValue(SqlLiteral.create('w'));
+ expect(changed.toString()).toEqual("KEY 'x' VALUE 'w'");
+ });
+
+ it('allows changing the shorthand flag', () => {
+ const keyValue = SqlKeyValue.create(SqlLiteral.create('x'), SqlLiteral.create('y'));
+
+ const changed = keyValue.changeShort(true);
+ expect(changed.toString()).toEqual("'x':'y'");
+ });
+
+ it('should test JSON_OBJECT function with key-value pairs', () => {
+ backAndForth("JSON_OBJECT(KEY 'x' VALUE 'y')", SqlExpression);
+ backAndForth("JSON_OBJECT(KEY 'x' VALUE 'y', KEY 'z' VALUE 'w')", SqlExpression);
+ backAndForth("JSON_OBJECT('x': 'y')", SqlExpression);
+ backAndForth("JSON_OBJECT('x': 'y', 'z': 'w')", SqlExpression);
+ backAndForth("JSON_OBJECT(KEY 'x' VALUE 'y', 'z': 'w')", SqlExpression);
+ });
+});
diff --git a/src/sql/sql-key-value/sql-key-value.ts b/src/sql/sql-key-value/sql-key-value.ts
new file mode 100644
index 00000000..2e837e53
--- /dev/null
+++ b/src/sql/sql-key-value/sql-key-value.ts
@@ -0,0 +1,142 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { SqlBaseValue, SqlTypeDesignator, Substitutor } from '../sql-base';
+import { SqlBase } from '../sql-base';
+import { SqlExpression } from '../sql-expression';
+import type { LiteralValue } from '../sql-literal/sql-literal';
+
+export interface SqlKeyValueValue extends SqlBaseValue {
+ key: SqlExpression;
+ value: SqlExpression;
+ short?: boolean;
+}
+
+export class SqlKeyValue extends SqlExpression {
+ static type: SqlTypeDesignator = 'keyValue';
+
+ static DEFAULT_KEY_KEYWORD = 'KEY';
+ static DEFAULT_VALUE_KEYWORD = 'VALUE';
+
+ static create(
+ key: SqlExpression | LiteralValue,
+ value: SqlExpression | LiteralValue,
+ ): SqlKeyValue {
+ return new SqlKeyValue({
+ key: SqlExpression.wrap(key),
+ value: SqlExpression.wrap(value),
+ });
+ }
+
+ static short(
+ key: SqlExpression | LiteralValue,
+ value: SqlExpression | LiteralValue,
+ ): SqlKeyValue {
+ return new SqlKeyValue({
+ key: SqlExpression.wrap(key),
+ value: SqlExpression.wrap(value),
+ short: true,
+ });
+ }
+
+ public readonly key: SqlExpression;
+ public readonly value: SqlExpression;
+ public readonly short?: boolean;
+
+ constructor(options: SqlKeyValueValue) {
+ super(options, SqlKeyValue.type);
+ this.key = options.key;
+ this.value = options.value;
+ this.short = options.short;
+ }
+
+ public valueOf() {
+ const value = super.valueOf() as SqlKeyValueValue;
+ value.key = this.key;
+ value.value = this.value;
+ value.short = this.short;
+ return value;
+ }
+
+ protected _toRawString(): string {
+ if (this.short) {
+ return [
+ this.key.toString(),
+ this.getSpace('postKeyExpression', ''),
+ ':',
+ this.getSpace('preValueExpression', ''),
+ this.value.toString(),
+ ].join('');
+ } else {
+ return [
+ this.getKeyword('key', SqlKeyValue.DEFAULT_KEY_KEYWORD),
+ this.getSpace('postKey'),
+ this.key.toString(),
+ this.getSpace('postKeyExpression'),
+ this.getKeyword('value', SqlKeyValue.DEFAULT_VALUE_KEYWORD),
+ this.getSpace('preValueExpression'),
+ this.value.toString(),
+ ].join('');
+ }
+ }
+
+ public changeKey(key: SqlExpression): this {
+ const value = this.valueOf();
+ value.key = key;
+ return SqlBase.fromValue(value);
+ }
+
+ public changeValue(value: SqlExpression): this {
+ const val = this.valueOf();
+ val.value = value;
+ return SqlBase.fromValue(val);
+ }
+
+ public changeShort(short: boolean): this {
+ if (Boolean(this.short) === short) return this;
+ const value = this.valueOf();
+ value.spacing = {}; // Reset all spacing
+ if (short) {
+ value.short = true;
+ } else {
+ delete value.short;
+ }
+
+ return SqlBase.fromValue(value);
+ }
+
+ public _walkInner(
+ nextStack: SqlBase[],
+ fn: Substitutor,
+ postorder: boolean,
+ ): SqlExpression | undefined {
+ let ret = this;
+
+ const key = this.key._walkHelper(nextStack, fn, postorder);
+ if (!key) return;
+ if (key !== this.key) {
+ ret = ret.changeKey(key);
+ }
+
+ const value = this.value._walkHelper(nextStack, fn, postorder);
+ if (!value) return;
+ if (value !== this.value) {
+ ret = ret.changeValue(value);
+ }
+
+ return ret;
+ }
+}
+
+SqlBase.register(SqlKeyValue);
diff --git a/src/sql/sql-labeled-expression/sql-labeled-expression.spec.ts b/src/sql/sql-labeled-expression/sql-labeled-expression.spec.ts
index 8e14317b..72968d51 100644
--- a/src/sql/sql-labeled-expression/sql-labeled-expression.spec.ts
+++ b/src/sql/sql-labeled-expression/sql-labeled-expression.spec.ts
@@ -12,10 +12,85 @@
* limitations under the License.
*/
-import { sane, SqlExpression } from '../..';
+import { RefName, sane, SqlColumn, SqlExpression, SqlLabeledExpression, SqlLiteral } from '../..';
import { backAndForth } from '../../test-utils';
-describe('SqlNamedExpression', () => {
+describe('SqlLabeledExpression', () => {
+ describe('.create', () => {
+ it('creates a labeled expression from a string label and an expression', () => {
+ const label = 'myLabel';
+ const expression = SqlLiteral.create(123);
+
+ const labeledExpression = SqlLabeledExpression.create(label, expression);
+
+ expect(labeledExpression).toBeInstanceOf(SqlLabeledExpression);
+ expect(labeledExpression.getLabelName()).toBe(label);
+ expect(labeledExpression.getUnderlyingExpression()).toBe(expression);
+ expect(labeledExpression.toString()).toEqual(`"myLabel" => 123`);
+ });
+
+ it('creates a labeled expression from a RefName label and an expression', () => {
+ const label = RefName.create('myLabel', false);
+ const expression = SqlLiteral.create(123);
+
+ const labeledExpression = SqlLabeledExpression.create(label, expression);
+
+ expect(labeledExpression).toBeInstanceOf(SqlLabeledExpression);
+ expect(labeledExpression.getLabelName()).toBe('myLabel');
+ expect(labeledExpression.getUnderlyingExpression()).toBe(expression);
+ expect(labeledExpression.toString()).toEqual(`myLabel => 123`);
+ });
+
+ it('handles forced quoting when specified', () => {
+ const label = 'myLabel';
+ const expression = SqlLiteral.create(123);
+ const forceQuotes = true;
+
+ const labeledExpression = SqlLabeledExpression.create(label, expression, forceQuotes);
+
+ expect(labeledExpression).toBeInstanceOf(SqlLabeledExpression);
+ expect(labeledExpression.label.quotes).toBe(true);
+ expect(labeledExpression.toString()).toEqual(`"myLabel" => 123`);
+ });
+
+ it('quotes reserved words automatically', () => {
+ const label = 'select'; // SQL reserved keyword
+ const expression = SqlLiteral.create(123);
+
+ const labeledExpression = SqlLabeledExpression.create(label, expression, false);
+
+ expect(labeledExpression).toBeInstanceOf(SqlLabeledExpression);
+ expect(labeledExpression.label.quotes).toBe(true);
+ expect(labeledExpression.toString()).toEqual(`"select" => 123`);
+ });
+
+ it('changes the label when input is already a SqlLabeledExpression', () => {
+ const originalLabel = 'originalLabel';
+ const newLabel = 'newLabel';
+ const expression = SqlLiteral.create(123);
+
+ const original = SqlLabeledExpression.create(originalLabel, expression);
+ const modified = SqlLabeledExpression.create(newLabel, original);
+
+ expect(modified).toBeInstanceOf(SqlLabeledExpression);
+ expect(modified.getLabelName()).toBe(newLabel);
+ expect(modified.getUnderlyingExpression()).toBe(expression);
+ expect(modified.toString()).toEqual(`"newLabel" => 123`);
+ expect(modified).not.toBe(original);
+ });
+
+ it('preserves the expression when changing label of existing SqlLabeledExpression', () => {
+ const originalLabel = 'originalLabel';
+ const newLabel = 'newLabel';
+ const column = SqlColumn.create('x');
+
+ const original = SqlLabeledExpression.create(originalLabel, column);
+ const modified = SqlLabeledExpression.create(newLabel, original);
+
+ expect(modified.getUnderlyingExpression()).toBe(column);
+ });
+ });
+
describe('parses', () => {
it('works in no alias case', () => {
const sql = sane`
@@ -139,4 +214,49 @@ describe('SqlNamedExpression', () => {
`);
});
});
+
+ describe('#changeLabel', () => {
+ it('returns a new instance with updated label', () => {
+ const original = SqlLabeledExpression.create('originalLabel', SqlLiteral.create(123));
+
+ const result = original.changeLabel('newLabel');
+
+ expect(result).toBeInstanceOf(SqlLabeledExpression);
+ expect(result.getLabelName()).toBe('newLabel');
+ expect(result.getUnderlyingExpression()).toBe(original.getUnderlyingExpression());
+ expect(result).not.toBe(original);
+ });
+ });
+
+ describe('#changeExpression', () => {
+ it('returns a new instance with updated expression', () => {
+ const label = 'myLabel';
+ const originalExpression = SqlLiteral.create(123);
+ const newExpression = SqlLiteral.create(456);
+
+ const original = SqlLabeledExpression.create(label, originalExpression);
+ const result = original.changeExpression(newExpression);
+
+ expect(result).toBeInstanceOf(SqlLabeledExpression);
+ expect(result.getLabelName()).toBe(label);
+ expect(result.getUnderlyingExpression()).toBe(newExpression);
+ expect(result).not.toBe(original);
+ });
+ });
+
+ describe('#changeUnderlyingExpression', () => {
+ it('delegates to changeExpression', () => {
+ const label = 'myLabel';
+ const originalExpression = SqlLiteral.create(123);
+ const newExpression = SqlLiteral.create(456);
+
+ const original = SqlLabeledExpression.create(label, originalExpression);
+ const result = original.changeUnderlyingExpression(newExpression) as SqlLabeledExpression;
+
+ expect(result).toBeInstanceOf(SqlLabeledExpression);
+ expect(result.getLabelName()).toBe(label);
+ expect(result.getUnderlyingExpression()).toBe(newExpression);
+ expect(result).not.toBe(original);
+ });
+ });
});
diff --git a/src/sql/sql-labeled-expression/sql-labeled-expression.ts b/src/sql/sql-labeled-expression/sql-labeled-expression.ts
index cb84f0e8..fa36d8bd 100644
--- a/src/sql/sql-labeled-expression/sql-labeled-expression.ts
+++ b/src/sql/sql-labeled-expression/sql-labeled-expression.ts
@@ -108,6 +108,10 @@ export class SqlLabeledExpression extends SqlExpression {
public getUnderlyingExpression(): SqlExpression {
return this.expression;
}
+
+ public changeUnderlyingExpression(newExpression: SqlExpression): SqlExpression {
+ return this.changeExpression(newExpression);
+ }
}
SqlBase.register(SqlLabeledExpression);
diff --git a/src/sql/sql-literal/sql-literal.spec.ts b/src/sql/sql-literal/sql-literal.spec.ts
index 38cd8685..783e359d 100644
--- a/src/sql/sql-literal/sql-literal.spec.ts
+++ b/src/sql/sql-literal/sql-literal.spec.ts
@@ -16,49 +16,29 @@ import { SqlExpression, SqlLiteral } from '../..';
import { backAndForth } from '../../test-utils';
describe('SqlLiteral', () => {
- it('things that work', () => {
- const queries: string[] = [
- `NULL`,
- `TRUE`,
- `FALSE`,
- `'lol'`,
- `U&'hello'`,
- `U&'hell''o'`,
- `U&'hell\\\\o'`,
- `_latin1'hello'`,
- `_UTF8'hello'`,
- `_UTF8'hell''o'`,
- `_l-1'hello'`,
- `_8l-1'hello'`,
- `'don''t do it'`,
- `17.0`,
- `123.34`,
- `1606832560494517248`,
- ];
-
- for (const sql of queries) {
- try {
- backAndForth(sql, SqlLiteral);
- } catch (e) {
- console.log(`Problem with: \`${sql}\``);
- throw e;
- }
- }
+ it.each([
+ `NULL`,
+ `TRUE`,
+ `FALSE`,
+ `'lol'`,
+ `U&'hello'`,
+ `U&'hell''o'`,
+ `U&'hell\\\\o'`,
+ `_latin1'hello'`,
+ `_UTF8'hello'`,
+ `_UTF8'hell''o'`,
+ `_l-1'hello'`,
+ `_8l-1'hello'`,
+ `'don''t do it'`,
+ `17.0`,
+ `123.34`,
+ `1606832560494517248`,
+ ])('does back and forth with %s', sql => {
+ backAndForth(sql, SqlLiteral);
});
- it('things that do not work', () => {
- const queries: string[] = [`__l-1'hello'`, `_-l-1'hello'`];
-
- for (const sql of queries) {
- let didNotError = false;
- try {
- SqlExpression.parse(sql);
- didNotError = true;
- } catch {}
- if (didNotError) {
- throw new Error(`should not parse: ${sql}`);
- }
- }
+ it.each([`__l-1'hello'`, `_-l-1'hello'`])('invalid literal %s should not parse', sql => {
+ expect(() => SqlExpression.parse(sql)).toThrow();
});
it('Works with Null', () => {
@@ -129,28 +109,24 @@ describe('SqlLiteral', () => {
`);
});
- it('all sorts of number literals', () => {
- const numbersToTest = [
- '0',
- '0.0',
- '0.01',
- '.1',
- '1',
- '01',
- '1.234',
- '+1',
- '-1',
- '5e2',
- '+5e+2',
- '-5E2',
- '-5E02',
- '-5e-2',
- ];
-
- for (const num of numbersToTest) {
- backAndForth(num);
- expect((SqlExpression.parse(num) as SqlLiteral).value).toEqual(parseFloat(num));
- }
+ it.each([
+ '0',
+ '0.0',
+ '0.01',
+ '.1',
+ '1',
+ '01',
+ '1.234',
+ '+1',
+ '-1',
+ '5e2',
+ '+5e+2',
+ '-5E2',
+ '-5E02',
+ '-5e-2',
+ ])('number literal %s parses to correct value', num => {
+ backAndForth(num);
+ expect((SqlExpression.parse(num) as SqlLiteral).value).toEqual(parseFloat(num));
});
it('number literals', () => {
diff --git a/src/sql/sql-literal/sql-literal.ts b/src/sql/sql-literal/sql-literal.ts
index 95728454..e1d685f3 100644
--- a/src/sql/sql-literal/sql-literal.ts
+++ b/src/sql/sql-literal/sql-literal.ts
@@ -12,20 +12,13 @@
* limitations under the License.
*/
+import { isDate, isInteger } from '../../utils';
import type { SqlBaseValue, SqlTypeDesignator } from '../sql-base';
import { SqlBase } from '../sql-base';
import type { DecomposeViaOptions } from '../sql-expression';
import { SqlExpression } from '../sql-expression';
import { needsUnicodeEscape, sqlEscapeUnicode, trimString } from '../utils';
-function isDate(v: any): v is Date {
- return Boolean(v && typeof v.toISOString === 'function');
-}
-
-function isInteger(v: number): boolean {
- return isFinite(v) && Math.floor(v) === v;
-}
-
export type LiteralValue = null | boolean | number | bigint | string | Date;
export interface SqlLiteralValue extends SqlBaseValue {
diff --git a/src/sql/sql-query/druid-tests.spec.ts b/src/sql/sql-query/druid-tests.spec.ts
index d6d6dade..8b74720a 100644
--- a/src/sql/sql-query/druid-tests.spec.ts
+++ b/src/sql/sql-query/druid-tests.spec.ts
@@ -1642,19 +1642,7 @@ describe('Druid test queries', () => {
`,
];
- it('all queries work', () => {
- const bad: string[] = [];
- for (const sql of queries) {
- try {
- backAndForth(sql);
- } catch (e) {
- bad.push(sql);
- console.log('=====================================');
- console.log(sql);
- console.log(e);
- }
- }
-
- expect(bad).toEqual([]);
+ it.each(queries)('correctly parses: %#', sql => {
+ backAndForth(sql);
});
});
diff --git a/src/sql/sql-query/sql-query.spec.ts b/src/sql/sql-query/sql-query.spec.ts
index d44ba8e5..bcd33413 100644
--- a/src/sql/sql-query/sql-query.spec.ts
+++ b/src/sql/sql-query/sql-query.spec.ts
@@ -28,8 +28,8 @@ import { backAndForth } from '../../test-utils';
import { sane } from '../../utils';
describe('SqlQuery', () => {
- it('things that work', () => {
- const queries: string[] = [
+ describe('valid SQL queries', () => {
+ it.each([
`Select nottingham from tbl`,
`Select 3; ; ;`,
`Select PI as "pi"`,
@@ -143,38 +143,22 @@ describe('SqlQuery', () => {
set B = 'lol'; ; ;
SELECT 1 + 1
`,
- ];
-
- for (const sql of queries) {
- try {
- backAndForth(sql, SqlQuery);
- } catch (e) {
- console.log(`Problem with: \`${sql}\``);
- throw e;
- }
- }
+ ])('correctly parses: %s', sql => {
+ backAndForth(sql, SqlQuery);
+ });
});
- it('things that do not work', () => {
- const queries: string[] = [
+ describe('invalid SQL queries', () => {
+ it.each([
`Select nottingham from table`,
`Selec 3`,
`(Select * from tbl`,
`Select count(*) As count from tbl`,
`Select * from tbl SELECT`,
// `SELECT 1 AS user`,
- ];
-
- for (const sql of queries) {
- let didNotError = false;
- try {
- SqlQuery.parse(sql);
- didNotError = true;
- } catch {}
- if (didNotError) {
- throw new Error(`should not parse: ${sql}`);
- }
- }
+ ])('fails to parse: %s', sql => {
+ expect(() => SqlQuery.parse(sql)).toThrow();
+ });
});
it('errors on parse if there are INSERT and REPLACE clauses', () => {
@@ -191,16 +175,18 @@ describe('SqlQuery', () => {
}).toThrowError('Can not have both an INSERT and a REPLACE clause');
});
- describe('.create', () => {
+ describe('.selectStarFrom', () => {
it('works', () => {
- expect(String(SqlQuery.create(SqlTable.create('lol')))).toEqual(sane`
+ expect(String(SqlQuery.selectStarFrom(SqlTable.create('lol')))).toEqual(sane`
SELECT *
FROM "lol"
`);
});
it('works in advanced case', () => {
- const query = SqlQuery.create(SqlQuery.create(SqlTable.create('lol')))
+ const query = SqlQuery.selectStarFrom(
+ SqlQuery.selectStarFrom(SqlTable.create('lol')).changeContext({ a: 1 }),
+ )
.changeSelectExpressions([
SqlColumn.create('channel'),
SqlColumn.create('page'),
@@ -210,6 +196,7 @@ describe('SqlQuery', () => {
.changeWhereExpression(SqlExpression.parse(`channel = '#en.wikipedia'`));
expect(String(query)).toEqual(sane`
+ SET a = 1;
SELECT
"channel",
"page",
@@ -252,6 +239,7 @@ describe('SqlQuery', () => {
describe('#walk', () => {
const sqlMaster = SqlQuery.parseSql(sane`
+ SET sqlTimeZone = 'America/Los_Angeles';
SELECT
datasource d,
SUM("size") AS total_size,
@@ -277,7 +265,8 @@ describe('SqlQuery', () => {
}),
),
).toMatchInlineSnapshot(`
- "SELECT
+ "SET sqlTimeZone = 'America/Los_Angeles';
+ SELECT
datasource_lol d,
SUM(\\"size_lol\\") AS total_size,
CASE WHEN SUM(\\"size_lol\\") = 0 THEN 0 ELSE SUM(\\"size_lol\\") END AS avg_size,
@@ -325,7 +314,9 @@ describe('SqlQuery', () => {
});
expect(parts).toEqual([
- 'SELECT\n datasource d,\n SUM("size") AS total_size,\n CASE WHEN SUM("size") = 0 THEN 0 ELSE SUM("size") END AS avg_size,\n CASE WHEN SUM(num_rows) = 0 THEN 0 ELSE SUM("num_rows") END AS avg_num_rows,\n COUNT(*) AS num_segments\nFROM sys.segments\nWHERE datasource IN (\'moon\', \'beam\') AND \'druid\' = schema\nGROUP BY datasource\nHAVING total_size > 100\nORDER BY datasource DESC, 2 ASC\nLIMIT 100',
+ 'SET sqlTimeZone = \'America/Los_Angeles\';\nSELECT\n datasource d,\n SUM("size") AS total_size,\n CASE WHEN SUM("size") = 0 THEN 0 ELSE SUM("size") END AS avg_size,\n CASE WHEN SUM(num_rows) = 0 THEN 0 ELSE SUM("num_rows") END AS avg_num_rows,\n COUNT(*) AS num_segments\nFROM sys.segments\nWHERE datasource IN (\'moon\', \'beam\') AND \'druid\' = schema\nGROUP BY datasource\nHAVING total_size > 100\nORDER BY datasource DESC, 2 ASC\nLIMIT 100',
+ "SET sqlTimeZone = 'America/Los_Angeles';",
+ "'America/Los_Angeles'",
'datasource d',
'datasource',
'SUM("size") AS total_size',
@@ -390,6 +381,8 @@ describe('SqlQuery', () => {
});
expect(parts).toEqual([
+ "'America/Los_Angeles'",
+ "SET sqlTimeZone = 'America/Los_Angeles';",
'datasource',
'datasource d',
'"size"',
@@ -443,7 +436,7 @@ describe('SqlQuery', () => {
'ORDER BY datasource DESC, 2 ASC',
'100',
'LIMIT 100',
- 'SELECT\n datasource d,\n SUM("size") AS total_size,\n CASE WHEN SUM("size") = 0 THEN 0 ELSE SUM("size") END AS avg_size,\n CASE WHEN SUM(num_rows) = 0 THEN 0 ELSE SUM("num_rows") END AS avg_num_rows,\n COUNT(*) AS num_segments\nFROM sys.segments\nWHERE datasource IN (\'moon\', \'beam\') AND \'druid\' = schema\nGROUP BY datasource\nHAVING total_size > 100\nORDER BY datasource DESC, 2 ASC\nLIMIT 100',
+ 'SET sqlTimeZone = \'America/Los_Angeles\';\nSELECT\n datasource d,\n SUM("size") AS total_size,\n CASE WHEN SUM("size") = 0 THEN 0 ELSE SUM("size") END AS avg_size,\n CASE WHEN SUM(num_rows) = 0 THEN 0 ELSE SUM("num_rows") END AS avg_num_rows,\n COUNT(*) AS num_segments\nFROM sys.segments\nWHERE datasource IN (\'moon\', \'beam\') AND \'druid\' = schema\nGROUP BY datasource\nHAVING total_size > 100\nORDER BY datasource DESC, 2 ASC\nLIMIT 100',
]);
});
@@ -461,6 +454,8 @@ describe('SqlQuery', () => {
});
expect(parts).toEqual([
+ "'America/Los_Angeles'",
+ "SET sqlTimeZone = '[America/Los_Angeles]';",
'datasource',
'_datasource_ d',
'"size"',
@@ -514,7 +509,7 @@ describe('SqlQuery', () => {
'ORDER BY _datasource_ DESC, 2 ASC',
'100',
'LIMIT 100',
- 'SELECT\n _datasource_ d,\n SUM("_size_") AS total_size,\n CASE WHEN SUM("_size_") = 0 THEN 0 ELSE SUM("_size_") END AS avg_size,\n CASE WHEN SUM(_num_rows_) = 0 THEN 0 ELSE SUM("_num_rows_") END AS avg_num_rows,\n COUNT(*) AS num_segments\nFROM sys.segments\nWHERE _datasource_ IN (\'[moon]\', \'[beam]\') AND \'[druid]\' = _schema_\nGROUP BY _datasource_\nHAVING _total_size_ > 100\nORDER BY _datasource_ DESC, 2 ASC\nLIMIT 100',
+ 'SET sqlTimeZone = \'[America/Los_Angeles]\';\nSELECT\n _datasource_ d,\n SUM("_size_") AS total_size,\n CASE WHEN SUM("_size_") = 0 THEN 0 ELSE SUM("_size_") END AS avg_size,\n CASE WHEN SUM(_num_rows_) = 0 THEN 0 ELSE SUM("_num_rows_") END AS avg_num_rows,\n COUNT(*) AS num_segments\nFROM sys.segments\nWHERE _datasource_ IN (\'[moon]\', \'[beam]\') AND \'[druid]\' = _schema_\nGROUP BY _datasource_\nHAVING _total_size_ > 100\nORDER BY _datasource_ DESC, 2 ASC\nLIMIT 100',
]);
});
@@ -531,7 +526,7 @@ describe('SqlQuery', () => {
"'druid' = schema",
"datasource IN ('moon', 'beam') AND 'druid' = schema",
"WHERE datasource IN ('moon', 'beam') AND 'druid' = schema",
- 'SELECT\n datasource d,\n SUM("size") AS total_size,\n CASE WHEN SUM("size") = 0 THEN 0 ELSE SUM("size") END AS avg_size,\n CASE WHEN SUM(num_rows) = 0 THEN 0 ELSE SUM("num_rows") END AS avg_num_rows,\n COUNT(*) AS num_segments\nFROM sys.segments\nWHERE datasource IN (\'moon\', \'beam\') AND \'druid\' = schema\nGROUP BY datasource\nHAVING total_size > 100\nORDER BY datasource DESC, 2 ASC\nLIMIT 100',
+ 'SET sqlTimeZone = \'America/Los_Angeles\';\nSELECT\n datasource d,\n SUM("size") AS total_size,\n CASE WHEN SUM("size") = 0 THEN 0 ELSE SUM("size") END AS avg_size,\n CASE WHEN SUM(num_rows) = 0 THEN 0 ELSE SUM("num_rows") END AS avg_num_rows,\n COUNT(*) AS num_segments\nFROM sys.segments\nWHERE datasource IN (\'moon\', \'beam\') AND \'druid\' = schema\nGROUP BY datasource\nHAVING total_size > 100\nORDER BY datasource DESC, 2 ASC\nLIMIT 100',
]);
});
});
diff --git a/src/sql/sql-query/sql-query.ts b/src/sql/sql-query/sql-query.ts
index fabdd02e..0e1632d4 100644
--- a/src/sql/sql-query/sql-query.ts
+++ b/src/sql/sql-query/sql-query.ts
@@ -40,6 +40,7 @@ import { SqlLiteral } from '../sql-literal/sql-literal';
import { SqlSetStatement } from '../sql-set-statement/sql-set-statement';
import { SqlStar } from '../sql-star/sql-star';
import { SqlTable } from '../sql-table/sql-table';
+import { SqlWithQuery } from '../sql-with-query/sql-with-query';
import {
clampIndex,
NEWLINE,
@@ -98,9 +99,18 @@ export class SqlQuery extends SqlExpression {
static readonly DEFAULT_SELECT_KEYWORD = 'SELECT';
static readonly DEFAULT_UNION_KEYWORD = 'UNION ALL';
- static create(from: string | SqlExpression | SqlFromClause): SqlQuery {
+ static from(from: string | SqlExpression | SqlFromClause): SqlQuery {
+ // Extract context from the inner query if given
+ let contextStatements: SeparatedArray | undefined;
+ if (from instanceof SqlQuery || from instanceof SqlWithQuery) {
+ contextStatements = from.contextStatements;
+ if (contextStatements) {
+ from = from.changeContextStatements(undefined);
+ }
+ }
+
return new SqlQuery({
- selectExpressions: SeparatedArray.fromSingleValue(SqlStar.PLAIN),
+ contextStatements,
fromClause:
from instanceof SqlFromClause
? from
@@ -113,20 +123,16 @@ export class SqlQuery extends SqlExpression {
}
static selectStarFrom(from: string | SqlExpression | SqlFromClause): SqlQuery {
- return SqlQuery.create(from);
+ return SqlQuery.from(from).changeSelectExpressions(
+ SeparatedArray.fromSingleValue(SqlStar.PLAIN),
+ );
}
- static from(from: string | SqlExpression | SqlFromClause): SqlQuery {
- return new SqlQuery({
- fromClause:
- from instanceof SqlFromClause
- ? from
- : SqlFromClause.create(
- SeparatedArray.fromSingleValue(
- typeof from === 'string' ? SqlTable.create(from) : from.convertToTable(),
- ),
- ),
- });
+ /**
+ * @deprecated use selectStarFrom instead
+ */
+ static create(from: string | SqlExpression | SqlFromClause): SqlQuery {
+ return SqlQuery.selectStarFrom(from);
}
static parse(input: string | SqlQuery): SqlQuery {
@@ -347,11 +353,14 @@ export class SqlQuery extends SqlExpression {
public changeContextStatements(
contextStatements: SeparatedArray | SqlSetStatement[] | undefined,
): this {
+ const newContextStatements = SeparatedArray.fromPossiblyEmptyArray(contextStatements);
const value = this.valueOf();
- value.contextStatements =
- contextStatements && !isEmptyArray(contextStatements)
- ? SeparatedArray.fromArray(contextStatements)
- : undefined;
+ if (newContextStatements) {
+ value.contextStatements = newContextStatements;
+ } else {
+ delete value.contextStatements;
+ value.spacing = this.getSpacingWithout('postSets');
+ }
return SqlBase.fromValue(value);
}
@@ -364,7 +373,9 @@ export class SqlQuery extends SqlExpression {
}
public changeContext(context: Record | undefined): this {
- return this.changeContextStatements(SqlSetStatement.contextToContextStatements(context));
+ return this.changeContextStatements(
+ context ? SqlSetStatement.contextToContextStatements(context) : undefined,
+ );
}
public changeExplain(explain: boolean): this {
@@ -674,6 +685,19 @@ export class SqlQuery extends SqlExpression {
): SqlQuery | undefined {
let ret: SqlQuery = this;
+ if (this.contextStatements) {
+ const contextStatements = SqlBase.walkSeparatedArray(
+ this.contextStatements,
+ nextStack,
+ fn,
+ postorder,
+ );
+ if (!contextStatements) return;
+ if (contextStatements !== this.contextStatements) {
+ ret = ret.changeContextStatements(contextStatements);
+ }
+ }
+
if (this.insertClause) {
const insertClause = this.insertClause._walkHelper(nextStack, fn, postorder);
if (!insertClause) return;
diff --git a/src/sql/sql-query/uber-query.spec.ts b/src/sql/sql-query/uber-query.spec.ts
index c80dbbf0..22da7a6e 100644
--- a/src/sql/sql-query/uber-query.spec.ts
+++ b/src/sql/sql-query/uber-query.spec.ts
@@ -18,8 +18,9 @@ import { SqlBase } from '../sql-base';
import { SqlQuery } from './sql-query';
-describe('Uber Query', () => {
+describe('Uber query', () => {
const sql = sane`
+ SET sqlTimeZone = 'America/Los_Angeles';
WITH temp_t1 AS (SELECT * FROM blah), temp_t2 AS (SELECT * FROM blah2)
SELECT
col1 AS "Col1",
@@ -136,11 +137,9 @@ describe('Uber Query', () => {
});
it('throws for invalid limit values', () => {
- expect(() => query.changeLimitValue(1)).not.toThrowError();
- expect(() => query.changeLimitValue(0)).not.toThrowError();
- expect(() => query.changeLimitValue(-1)).toThrowError('-1 is not a valid limit value');
- expect(() => query.changeLimitValue(-Infinity)).toThrowError(
- '-Infinity is not a valid limit value',
- );
+ expect(() => query.changeLimitValue(1)).not.toThrow();
+ expect(() => query.changeLimitValue(0)).not.toThrow();
+ expect(() => query.changeLimitValue(-1)).toThrow('-1 is not a valid limit value');
+ expect(() => query.changeLimitValue(-Infinity)).toThrow('-Infinity is not a valid limit value');
});
});
diff --git a/src/sql/sql-record/sql-record.ts b/src/sql/sql-record/sql-record.ts
index 34b1a263..a8e30286 100644
--- a/src/sql/sql-record/sql-record.ts
+++ b/src/sql/sql-record/sql-record.ts
@@ -32,10 +32,7 @@ export class SqlRecord extends SqlExpression {
expressions?: SqlRecord | SeparatedArray | SqlExpression[],
): SqlRecord {
if (expressions instanceof SqlRecord) return expressions;
- const array =
- !expressions || isEmptyArray(expressions)
- ? undefined
- : SeparatedArray.fromArray(expressions, Separator.COMMA);
+ const array = SeparatedArray.fromPossiblyEmptyArray(expressions, Separator.COMMA);
return new SqlRecord({
keywords: {
@@ -50,10 +47,7 @@ export class SqlRecord extends SqlExpression {
): SqlRecord {
if (expressions instanceof SqlRecord) return expressions;
return new SqlRecord({
- expressions:
- !expressions || isEmptyArray(expressions)
- ? undefined
- : SeparatedArray.fromArray(expressions, Separator.COMMA),
+ expressions: SeparatedArray.fromPossiblyEmptyArray(expressions, Separator.COMMA),
});
}
diff --git a/src/sql/sql-set-statement/sql-set-statement.spec.ts b/src/sql/sql-set-statement/sql-set-statement.spec.ts
new file mode 100644
index 00000000..a46fe381
--- /dev/null
+++ b/src/sql/sql-set-statement/sql-set-statement.spec.ts
@@ -0,0 +1,385 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { sane } from '../../utils';
+
+import { SqlSetStatement } from './sql-set-statement';
+
+describe('SqlSetStatement', () => {
+ describe('.parseStatementsOnly', () => {
+ it('parses nothing with an error', () => {
+ expect(
+ SqlSetStatement.parseStatementsOnly(sane`
+ -- Comment
+ sdfsdf
+ dsfdsf
+ sdfsdf
+ `),
+ ).toMatchInlineSnapshot(`
+ Object {
+ "rest": "sdfsdf
+ dsfdsf
+ sdfsdf",
+ "spaceBefore": "-- Comment
+ ",
+ }
+ `);
+ });
+
+ it('parses single statement', () => {
+ expect(
+ SqlSetStatement.parseStatementsOnly(sane`
+ -- Comment
+ Set Hello = 1;
+ sdfsdf
+ dsfdsf
+ sdfsdf
+ `),
+ ).toMatchInlineSnapshot(`
+ Object {
+ "contextStatements": SeparatedArray {
+ "separators": Array [],
+ "values": Array [
+ SqlSetStatement {
+ "key": RefName {
+ "name": "Hello",
+ "quotes": false,
+ },
+ "keywords": Object {
+ "set": "Set",
+ },
+ "parens": undefined,
+ "spacing": Object {
+ "postKey": " ",
+ "postSet": " ",
+ "postValue": "",
+ },
+ "type": "setStatement",
+ "value": SqlLiteral {
+ "keywords": Object {},
+ "parens": undefined,
+ "spacing": Object {},
+ "stringValue": "1",
+ "type": "literal",
+ "value": 1,
+ },
+ },
+ ],
+ },
+ "rest": "sdfsdf
+ dsfdsf
+ sdfsdf",
+ "spaceAfter": "
+ ",
+ "spaceBefore": "-- Comment
+ ",
+ }
+ `);
+ });
+
+ it('parses multiple statements', () => {
+ expect(
+ SqlSetStatement.parseStatementsOnly(sane`
+ -- Comment
+ Set Hello = 1;
+ SET "moon" = 'lol';
+
+ SELECT * FROM tbl
+ sdfsdf
+ dsfdsf
+ sdfsdf
+ `),
+ ).toMatchInlineSnapshot(`
+ Object {
+ "contextStatements": SeparatedArray {
+ "separators": Array [
+ "
+ ",
+ ],
+ "values": Array [
+ SqlSetStatement {
+ "key": RefName {
+ "name": "Hello",
+ "quotes": false,
+ },
+ "keywords": Object {
+ "set": "Set",
+ },
+ "parens": undefined,
+ "spacing": Object {
+ "postKey": " ",
+ "postSet": " ",
+ "postValue": "",
+ },
+ "type": "setStatement",
+ "value": SqlLiteral {
+ "keywords": Object {},
+ "parens": undefined,
+ "spacing": Object {},
+ "stringValue": "1",
+ "type": "literal",
+ "value": 1,
+ },
+ },
+ SqlSetStatement {
+ "key": RefName {
+ "name": "moon",
+ "quotes": true,
+ },
+ "keywords": Object {
+ "set": "SET",
+ },
+ "parens": undefined,
+ "spacing": Object {
+ "postKey": " ",
+ "postSet": " ",
+ "postValue": "",
+ },
+ "type": "setStatement",
+ "value": SqlLiteral {
+ "keywords": Object {},
+ "parens": undefined,
+ "spacing": Object {},
+ "stringValue": "'lol'",
+ "type": "literal",
+ "value": "lol",
+ },
+ },
+ ],
+ },
+ "rest": "SELECT * FROM tbl
+ sdfsdf
+ dsfdsf
+ sdfsdf",
+ "spaceAfter": "
+
+ ",
+ "spaceBefore": "-- Comment
+ ",
+ }
+ `);
+ });
+ });
+
+ describe('.partitionSetStatements', () => {
+ it('works when there is nothing to parse', () => {
+ const text = sane`
+ -- Comment
+ sdfsdf
+ dsfdsf
+ sdfsdf
+ `;
+
+ expect(SqlSetStatement.partitionSetStatements(text)).toEqual([
+ '',
+ sane`
+ -- Comment
+ sdfsdf
+ dsfdsf
+ sdfsdf
+ `,
+ ]);
+
+ expect(SqlSetStatement.partitionSetStatements(text, true)).toEqual([
+ sane`
+ -- Comment
+
+ `,
+ sane`
+ sdfsdf
+ dsfdsf
+ sdfsdf
+ `,
+ ]);
+ });
+
+ it('works when there is something to parse', () => {
+ const text = sane`
+ -- Comment
+ Set Hello = 1;
+ SET "moon" = 'lol';
+
+ SELECT * FROM tbl
+ sdfsdf
+ dsfdsf
+ sdfsdf
+ `;
+
+ expect(SqlSetStatement.partitionSetStatements(text)).toEqual([
+ sane`
+ -- Comment
+ Set Hello = 1;
+ SET "moon" = 'lol';
+ `,
+ sane`
+
+
+ SELECT * FROM tbl
+ sdfsdf
+ dsfdsf
+ sdfsdf
+ `,
+ ]);
+
+ expect(SqlSetStatement.partitionSetStatements(text, true)).toEqual([
+ sane`
+ -- Comment
+ Set Hello = 1;
+ SET "moon" = 'lol';
+
+
+ `,
+ sane`
+ SELECT * FROM tbl
+ sdfsdf
+ dsfdsf
+ sdfsdf
+ `,
+ ]);
+ });
+ });
+
+ describe('.getContextFromText', () => {
+ it('works when there is nothing to parse', () => {
+ expect(
+ SqlSetStatement.getContextFromText(sane`
+ -- SQL Haiku
+
+ Database language,
+ Queries dancing through tables,
+ Data reveals truth.
+ `),
+ ).toEqual({});
+ });
+
+ it('works when there is some SQL but no context', () => {
+ expect(
+ SqlSetStatement.getContextFromText(sane`
+ -- SQL Haiku
+
+ SELECT * FROM haihu
+
+ Database language,
+ Queries dancing through tables,
+ Data reveals truth.
+ `),
+ ).toEqual({});
+ });
+
+ it('works when there is context', () => {
+ expect(
+ SqlSetStatement.getContextFromText(sane`
+ -- SQL Haiku
+
+ SET "moon" = 'lol';
+ set "one" = 1;
+
+ Database language,
+ Queries dancing through tables,
+ Data reveals truth.
+ `),
+ ).toEqual({
+ moon: 'lol',
+ one: 1,
+ });
+ });
+ });
+
+ describe('.setContextInText', () => {
+ it('works when there is nothing to parse', () => {
+ expect(
+ SqlSetStatement.setContextInText(
+ sane`
+ -- SQL Haiku
+
+ Database language,
+ Queries dancing through tables,
+ Data reveals truth.
+ `,
+ { hello: 'world', x: 1 },
+ ),
+ ).toEqual(sane`
+ -- SQL Haiku
+
+ SET hello = 'world';
+ SET x = 1;
+ Database language,
+ Queries dancing through tables,
+ Data reveals truth.
+ `);
+ });
+
+ it('works when there is some SQL but no context', () => {
+ expect(
+ SqlSetStatement.setContextInText(
+ sane`
+ -- SQL Haiku
+
+ SELECT * FROM haihu
+
+ Database language,
+ Queries dancing through tables,
+ Data reveals truth.
+ `,
+ { hello: 'world', x: 1 },
+ ),
+ ).toEqual(sane`
+ -- SQL Haiku
+
+ SET hello = 'world';
+ SET x = 1;
+ SELECT * FROM haihu
+
+ Database language,
+ Queries dancing through tables,
+ Data reveals truth.
+ `);
+ });
+
+ it('works when there is context', () => {
+ expect(
+ SqlSetStatement.setContextInText(
+ sane`
+ -- SQL Haiku
+
+ SET "moon" = 'lol';
+ set "one" = 1;
+
+ Database language,
+ Queries dancing through tables,
+ Data reveals truth.
+ `,
+ { hello: 'world', x: 1 },
+ ),
+ ).toEqual(sane`
+ -- SQL Haiku
+
+ SET hello = 'world';
+ SET x = 1;
+
+ Database language,
+ Queries dancing through tables,
+ Data reveals truth.
+ `);
+ });
+ });
+
+ describe('#changeKey', () => {
+ it('works', () => {
+ expect(SqlSetStatement.create('x', 'lol').changeKey('y').toString()).toEqual(
+ `SET y = 'lol';`,
+ );
+ });
+ });
+});
diff --git a/src/sql/sql-set-statement/sql-set-statement.ts b/src/sql/sql-set-statement/sql-set-statement.ts
index 1b925854..f3620f5b 100644
--- a/src/sql/sql-set-statement/sql-set-statement.ts
+++ b/src/sql/sql-set-statement/sql-set-statement.ts
@@ -12,11 +12,14 @@
* limitations under the License.
*/
+import { filterMap } from '../../utils';
+import { parse as parseSql } from '../parser';
import type { SqlBaseValue, SqlTypeDesignator, Substitutor } from '../sql-base';
import { SqlBase } from '../sql-base';
import type { LiteralValue } from '../sql-literal/sql-literal';
import { SqlLiteral } from '../sql-literal/sql-literal';
-import { RefName } from '../utils';
+import type { SeparatedArray } from '../utils';
+import { NEWLINE, RefName } from '../utils';
export interface SqlSetStatementValue extends SqlBaseValue {
key: RefName;
@@ -35,6 +38,50 @@ export class SqlSetStatement extends SqlBase {
});
}
+ static parseStatementsOnly(text: string): {
+ spaceBefore: string;
+ contextStatements?: SeparatedArray;
+ spaceAfter?: string;
+ rest: string;
+ } {
+ return parseSql(text, {
+ startRule: 'StartSetStatementsOnly',
+ });
+ }
+
+ static partitionSetStatements(text: string, putSpaceWithSets = false): [string, string] {
+ const { spaceBefore, contextStatements, spaceAfter, rest } =
+ SqlSetStatement.parseStatementsOnly(text);
+
+ if (contextStatements) {
+ return [
+ spaceBefore + contextStatements.toString(NEWLINE) + (putSpaceWithSets ? spaceAfter : ''),
+ (putSpaceWithSets ? '' : spaceAfter) + rest,
+ ];
+ } else if (putSpaceWithSets) {
+ return [spaceBefore, rest];
+ } else {
+ return ['', text];
+ }
+ }
+
+ static getContextFromText(text: string): Record {
+ const { contextStatements } = SqlSetStatement.parseStatementsOnly(text);
+ if (!contextStatements) return {};
+ return SqlSetStatement.contextStatementsToContext(contextStatements.values);
+ }
+
+ static setContextInText(text: string, context: Record): string {
+ const { spaceBefore, spaceAfter, rest } = SqlSetStatement.parseStatementsOnly(text);
+
+ return [
+ spaceBefore,
+ SqlSetStatement.contextToContextStatements(context)?.join(NEWLINE),
+ spaceAfter || NEWLINE,
+ rest,
+ ].join('');
+ }
+
static contextStatementsToContext(
contextStatements: readonly SqlSetStatement[] | undefined,
): Record {
@@ -47,12 +94,10 @@ export class SqlSetStatement extends SqlBase {
return context;
}
- static contextToContextStatements(
- context: Record | undefined,
- ): SqlSetStatement[] | undefined {
- return context
- ? Object.entries(context).map(([k, v]) => SqlSetStatement.create(k, v))
- : undefined;
+ static contextToContextStatements(context: Record): SqlSetStatement[] {
+ return filterMap(Object.entries(context), ([k, v]) =>
+ typeof v !== 'undefined' ? SqlSetStatement.create(k, v) : undefined,
+ );
}
public readonly key: RefName;
@@ -86,12 +131,12 @@ export class SqlSetStatement extends SqlBase {
}
public getKeyString(): string {
- return this.key.toString();
+ return this.key.name;
}
public changeKey(key: RefName | string): this {
const value = this.valueOf();
- value.key = RefName.create(key);
+ value.key = RefName.create(key, false);
return SqlBase.fromValue(value);
}
diff --git a/src/sql/sql-star/sql-star.spec.ts b/src/sql/sql-star/sql-star.spec.ts
index 1644cc55..c2a43c4a 100644
--- a/src/sql/sql-star/sql-star.spec.ts
+++ b/src/sql/sql-star/sql-star.spec.ts
@@ -16,8 +16,8 @@ import { backAndForth } from '../../test-utils';
import { SqlExpression } from '../sql-expression';
describe('SqlStar', () => {
- it('things that work', () => {
- const queries: string[] = [
+ describe('star expressions', () => {
+ it.each([
'SELECT *',
`SELECT hello. *`,
`SELECT "hello" . *`,
@@ -25,16 +25,9 @@ describe('SqlStar', () => {
`SELECT "a""b".*`,
`SELECT a . b . *`,
`SELECT "a""b".c.*`,
- ];
-
- for (const sql of queries) {
- try {
- backAndForth(sql);
- } catch (e) {
- console.log(`Problem with: \`${sql}\``);
- throw e;
- }
- }
+ ])('correctly parses: %s', sql => {
+ backAndForth(sql);
+ });
});
it('without quotes + namespace', () => {
diff --git a/src/sql/sql-table/sql-table.spec.ts b/src/sql/sql-table/sql-table.spec.ts
index 6e88f120..a186e22b 100644
--- a/src/sql/sql-table/sql-table.spec.ts
+++ b/src/sql/sql-table/sql-table.spec.ts
@@ -16,18 +16,12 @@ import { SqlExpression, SqlNamespace, SqlTable } from '../..';
import { backAndForth } from '../../test-utils';
describe('SqlTable', () => {
- it('things that work', () => {
- const queries: string[] = [`hello`, `"hello"`, `"""hello"""`, `"a""b"`, `a.b`, `"a""b".c`];
-
- for (const sql of queries) {
- try {
- backAndForth(sql);
- } catch (e) {
- console.log(`Problem with: \`${sql}\``);
- throw e;
- }
- }
- });
+ it.each([`hello`, `"hello"`, `"""hello"""`, `"a""b"`, `a.b`, `"a""b".c`])(
+ 'does back and forth with %s',
+ sql => {
+ backAndForth(sql);
+ },
+ );
it('avoids reserved', () => {
const sql = 'From';
diff --git a/src/sql/sql-values/sql-values.spec.ts b/src/sql/sql-values/sql-values.spec.ts
index 87450e20..ab6ff93f 100644
--- a/src/sql/sql-values/sql-values.spec.ts
+++ b/src/sql/sql-values/sql-values.spec.ts
@@ -21,21 +21,12 @@ import { SqlRecord } from '../sql-record/sql-record';
import { SqlValues } from './sql-values';
describe('SqlValues', () => {
- it('things that work', () => {
- const queries: string[] = [
- `VALUES (1), (2)`,
- `VALUES (1, 2), (3, 4), (5, 6) ORDER BY 1 DESC`,
- `VALUES (1, 2), (3, 4), (5, 6) ORDER BY 1 DESC LIMIT 2`,
- ];
-
- for (const sql of queries) {
- try {
- backAndForth(sql, SqlValues);
- } catch (e) {
- console.log(`Problem with: \`${sql}\``);
- throw e;
- }
- }
+ it.each([
+ `VALUES (1), (2)`,
+ `VALUES (1, 2), (3, 4), (5, 6) ORDER BY 1 DESC`,
+ `VALUES (1, 2), (3, 4), (5, 6) ORDER BY 1 DESC LIMIT 2`,
+ ])('does back and forth with %s', sql => {
+ backAndForth(sql, SqlValues);
});
it('.create', () => {
diff --git a/src/sql/sql-with-query/sql-with-query.spec.ts b/src/sql/sql-with-query/sql-with-query.spec.ts
index 5c06f247..b191a534 100644
--- a/src/sql/sql-with-query/sql-with-query.spec.ts
+++ b/src/sql/sql-with-query/sql-with-query.spec.ts
@@ -19,8 +19,8 @@ import { SqlExpression } from '../sql-expression';
import type { SqlWithQuery } from './sql-with-query';
describe('SqlWithQuery', () => {
- it('things that work', () => {
- const queries: string[] = [
+ describe('valid with queries', () => {
+ it.each([
`WITH wiki AS (SELECT * FROM wikipedia) (SELECT * FROM wiki)`,
`WITH wiki AS (SELECT * FROM wikipedia) (SELECT * FROM wiki) ORDER BY __time DESC LIMIT 3 OFFSET 0`,
sane`
@@ -42,16 +42,9 @@ describe('SqlWithQuery', () => {
PARTITIONED BY ALL
CLUSTERED BY page
`,
- ];
-
- for (const sql of queries) {
- try {
- backAndForth(sql);
- } catch (e) {
- console.log(`Problem with: \`${sql}\``);
- throw e;
- }
- }
+ ])('correctly parses: %s', sql => {
+ backAndForth(sql);
+ });
});
it('flattenWith', () => {
diff --git a/src/sql/sql-with-query/sql-with-query.ts b/src/sql/sql-with-query/sql-with-query.ts
index 7153c7f2..116838a0 100644
--- a/src/sql/sql-with-query/sql-with-query.ts
+++ b/src/sql/sql-with-query/sql-with-query.ts
@@ -170,11 +170,14 @@ export class SqlWithQuery extends SqlExpression {
public changeContextStatements(
contextStatements: SeparatedArray | SqlSetStatement[] | undefined,
): this {
+ const newContextStatements = SeparatedArray.fromPossiblyEmptyArray(contextStatements);
const value = this.valueOf();
- value.contextStatements =
- contextStatements && !isEmptyArray(contextStatements)
- ? SeparatedArray.fromArray(contextStatements)
- : undefined;
+ if (newContextStatements) {
+ value.contextStatements = newContextStatements;
+ } else {
+ delete value.contextStatements;
+ value.spacing = this.getSpacingWithout('postSets');
+ }
return SqlBase.fromValue(value);
}
@@ -187,7 +190,9 @@ export class SqlWithQuery extends SqlExpression {
}
public changeContext(context: Record): this {
- return this.changeContextStatements(SqlSetStatement.contextToContextStatements(context));
+ return this.changeContextStatements(
+ context ? SqlSetStatement.contextToContextStatements(context) : undefined,
+ );
}
public changeExplain(explain: boolean): this {
@@ -369,6 +374,19 @@ export class SqlWithQuery extends SqlExpression {
): SqlWithQuery | undefined {
let ret: SqlWithQuery = this;
+ if (this.contextStatements) {
+ const contextStatements = SqlBase.walkSeparatedArray(
+ this.contextStatements,
+ nextStack,
+ fn,
+ postorder,
+ );
+ if (!contextStatements) return;
+ if (contextStatements !== this.contextStatements) {
+ ret = ret.changeContextStatements(contextStatements);
+ }
+ }
+
if (this.insertClause) {
const insertClause = this.insertClause._walkHelper(nextStack, fn, postorder);
if (!insertClause) return;
diff --git a/src/sql/utils/ref-name/ref-name.spec.ts b/src/sql/utils/ref-name/ref-name.spec.ts
new file mode 100644
index 00000000..0974bee3
--- /dev/null
+++ b/src/sql/utils/ref-name/ref-name.spec.ts
@@ -0,0 +1,251 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { RefName } from '../../..';
+
+describe('RefName', () => {
+ describe('static methods for detecting reserved words', () => {
+ it('identifies reserved keywords', () => {
+ expect(RefName.isReservedKeyword('SELECT')).toBe(true);
+ expect(RefName.isReservedKeyword('select')).toBe(true); // Case insensitive
+ expect(RefName.isReservedKeyword('FROM')).toBe(true);
+ expect(RefName.isReservedKeyword('normalword')).toBe(false);
+ });
+
+ it('identifies reserved aliases', () => {
+ expect(RefName.isReservedAlias('SELECT')).toBe(true); // Keywords are also reserved aliases
+ expect(RefName.isReservedAlias('VALUE')).toBe(true); // Reserved alias
+ expect(RefName.isReservedAlias('normalword')).toBe(false);
+ });
+
+ it('identifies reserved function names', () => {
+ // Depends on which functions are allowed, checking pattern rather than specific values
+ const someForbiddenKeyword = RefName.RESERVED_KEYWORDS.find(
+ k => !RefName.ALLOWED_FUNCTIONS.includes(k),
+ );
+
+ if (someForbiddenKeyword) {
+ expect(RefName.isReservedFunctionName(someForbiddenKeyword)).toBe(true);
+ }
+
+ // COUNT should be an allowed function
+ expect(RefName.isReservedFunctionName('COUNT')).toBe(false);
+ expect(RefName.isReservedFunctionName('normalword')).toBe(false);
+ });
+ });
+
+ describe('static methods for detecting need for quotes', () => {
+ it('determines when names need quotes', () => {
+ expect(RefName.needsQuotes('SELECT')).toBe(true); // Reserved keyword needs quotes
+ expect(RefName.needsQuotes('normal_column')).toBe(false); // Valid identifier
+ expect(RefName.needsQuotes('123column')).toBe(true); // Invalid identifier (starts with number)
+ expect(RefName.needsQuotes('column-name')).toBe(true); // Invalid identifier (has hyphen)
+ expect(RefName.needsQuotes('column name')).toBe(true); // Invalid identifier (has space)
+ });
+
+ it('determines when aliases need quotes', () => {
+ expect(RefName.needsQuotesAlias('VALUE')).toBe(true); // Reserved alias needs quotes
+ expect(RefName.needsQuotesAlias('normal_alias')).toBe(false); // Valid identifier
+ });
+
+ it('determines when function names need quotes', () => {
+ // Testing with allowed function names vs reserved keywords
+ expect(RefName.needsQuotesFunctionName('COUNT')).toBe(false); // Allowed function
+
+ const someForbiddenKeyword = RefName.RESERVED_KEYWORDS.find(
+ k => !RefName.ALLOWED_FUNCTIONS.includes(k),
+ );
+
+ if (someForbiddenKeyword) {
+ expect(RefName.needsQuotesFunctionName(someForbiddenKeyword)).toBe(true);
+ }
+ });
+ });
+
+ describe('.maybe', () => {
+ it('returns undefined for undefined input', () => {
+ expect(RefName.maybe(undefined)).toBeUndefined();
+ });
+
+ it('creates a RefName for valid input', () => {
+ const result = RefName.maybe('column');
+
+ expect(result).toBeInstanceOf(RefName);
+ expect(result?.name).toBe('column');
+ expect(result?.quotes).toBe(true); // Default is true if forceQuotes=true
+ });
+ });
+
+ describe('.create', () => {
+ it('creates a new RefName with default options', () => {
+ const refName = RefName.create('column');
+
+ expect(refName).toBeInstanceOf(RefName);
+ expect(refName.name).toBe('column');
+ expect(refName.quotes).toBe(true); // Default is true if forceQuotes=true
+ });
+
+ it('respects the forceQuotes parameter when false for valid identifiers', () => {
+ // column is a valid identifier and not a reserved keyword
+ const refName = RefName.create('column', false);
+
+ // The quotes will be false because forceQuotes=false and needsQuotes(column)=false
+ expect(refName.quotes).toBe(RefName.needsQuotes('column'));
+ });
+
+ it('always adds quotes for reserved keywords regardless of forceQuotes', () => {
+ const refName = RefName.create('SELECT', false);
+
+ expect(refName.quotes).toBe(true);
+ });
+
+ it('returns the same instance if input is already a RefName', () => {
+ const original = RefName.create('column');
+ const result = RefName.create(original);
+
+ expect(result).toBe(original);
+ });
+ });
+
+ describe('.alias', () => {
+ it('creates a new RefName for an alias with default options', () => {
+ const refName = RefName.alias('alias_column');
+
+ expect(refName).toBeInstanceOf(RefName);
+ expect(refName.name).toBe('alias_column');
+ expect(refName.quotes).toBe(true); // Default is true if forceQuotes=true
+ });
+
+ it('respects the forceQuotes parameter when false', () => {
+ const refName = RefName.alias('alias_column', false);
+
+ expect(refName.quotes).toBe(false);
+ });
+
+ it('always adds quotes for reserved aliases regardless of forceQuotes', () => {
+ const refName = RefName.alias('VALUE', false);
+
+ expect(refName.quotes).toBe(true);
+ });
+
+ it('returns the same instance if input is already a RefName', () => {
+ const original = RefName.alias('alias_column');
+ const result = RefName.alias(original);
+
+ expect(result).toBe(original);
+ });
+ });
+
+ describe('.functionName', () => {
+ it('creates a new RefName for a function name with default options', () => {
+ const refName = RefName.functionName('my_func');
+
+ expect(refName).toBeInstanceOf(RefName);
+ expect(refName.name).toBe('my_func');
+ expect(refName.quotes).toBe(false); // Default is false for function names
+ });
+
+ it('respects the forceQuotes parameter when true', () => {
+ const refName = RefName.functionName('my_func', true);
+
+ expect(refName.quotes).toBe(true);
+ });
+
+ it('adds quotes for reserved function names regardless of forceQuotes', () => {
+ // Find a keyword that isn't an allowed function
+ const someForbiddenKeyword = RefName.RESERVED_KEYWORDS.find(
+ k => !RefName.ALLOWED_FUNCTIONS.includes(k),
+ );
+
+ if (someForbiddenKeyword) {
+ const refName = RefName.functionName(someForbiddenKeyword, false);
+ expect(refName.quotes).toBe(true);
+ }
+ });
+
+ it('returns the same instance if input is already a RefName', () => {
+ const original = RefName.functionName('my_func');
+ const result = RefName.functionName(original);
+
+ expect(result).toBe(original);
+ });
+ });
+
+ describe('#toString', () => {
+ it('returns the name as is when quotes is false', () => {
+ const refName = new RefName({ name: 'column', quotes: false });
+
+ expect(refName.toString()).toBe('column');
+ });
+
+ it('surrounds the name with double quotes when quotes is true', () => {
+ const refName = new RefName({ name: 'column', quotes: true });
+
+ expect(refName.toString()).toBe('"column"');
+ });
+
+ it('escapes double quotes in the name', () => {
+ const refName = new RefName({ name: 'col"umn', quotes: true });
+
+ expect(refName.toString()).toBe('"col""umn"');
+ });
+ });
+
+ describe('#changeName', () => {
+ it('returns a new RefName with the new name and same quotes setting', () => {
+ const original = new RefName({ name: 'old_name', quotes: true });
+
+ const changed = original.changeName('new_name');
+
+ expect(changed.name).toBe('new_name');
+ expect(changed.quotes).toBe(true);
+ expect(changed).not.toBe(original);
+ });
+ });
+
+ describe('#changeNameAsAlias', () => {
+ it('returns a new RefName with the new name treated as an alias', () => {
+ const original = new RefName({ name: 'old_name', quotes: false });
+
+ // Using a reserved alias keyword should force quotes
+ const changed = original.changeNameAsAlias('VALUE');
+
+ expect(changed.name).toBe('VALUE');
+ expect(changed.quotes).toBe(true); // Forced quotes due to reserved alias
+ });
+ });
+
+ describe('#changeNameAsFunctionName', () => {
+ it('returns a new RefName with the new name treated as a function name', () => {
+ const original = new RefName({ name: 'old_name', quotes: true });
+
+ // Using an allowed function name should not need quotes
+ const changed = original.changeNameAsFunctionName('COUNT');
+
+ expect(changed.name).toBe('COUNT');
+ expect(changed.quotes).toBe(true); // Preserves original quotes setting
+ });
+ });
+
+ describe('#prettyTrim', () => {
+ it('trims the name to the specified length', () => {
+ const original = new RefName({ name: 'very_long_column_name', quotes: true });
+
+ const trimmed = original.prettyTrim(10);
+
+ expect(trimmed.name.length).toBeLessThanOrEqual(10);
+ expect(trimmed.quotes).toBe(true);
+ });
+ });
+});
diff --git a/src/sql/utils/separated-array/separated-array.ts b/src/sql/utils/separated-array/separated-array.ts
index d7a7e409..e85a0df7 100644
--- a/src/sql/utils/separated-array/separated-array.ts
+++ b/src/sql/utils/separated-array/separated-array.ts
@@ -12,6 +12,7 @@
* limitations under the License.
*/
+import { isEmptyArray } from '../../../utils';
import { clampIndex, insert, normalizeIndex, Separator } from '..';
export type SeparatorOrString = Separator | string;
@@ -31,6 +32,14 @@ export class SeparatedArray {
return new SeparatedArray(xs, separators);
}
+ static fromPossiblyEmptyArray(
+ xs: readonly T[] | SeparatedArray | undefined,
+ separator?: SeparatorOrString,
+ ): SeparatedArray | undefined {
+ if (!xs || isEmptyArray(xs)) return;
+ return SeparatedArray.fromArray(xs, separator);
+ }
+
static fromSingleValue(x: T): SeparatedArray {
return new SeparatedArray([x], []);
}
diff --git a/src/utils.ts b/src/utils.ts
index 5b2d5131..5743fcb7 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -55,6 +55,14 @@ export function isEmptyArray(x: unknown): x is unknown[] {
return Array.isArray(x) && !x.length;
}
+export function isDate(v: any): v is Date {
+ return Boolean(v && typeof v.toISOString === 'function');
+}
+
+export function isInteger(v: number): boolean {
+ return isFinite(v) && Math.floor(v) === v;
+}
+
export function compact(xs: (T | undefined | false | null | '')[]): T[] {
return xs.filter(Boolean) as T[];
}
@@ -64,13 +72,13 @@ export function sane(_x: TemplateStringsArray) {
// eslint-disable-next-line prefer-rest-params,prefer-spread
const str = String.raw.apply(String, arguments as any);
- const match = /^\n( *)/m.exec(str);
+ const match = /^\n+( *)/m.exec(str);
if (!match) throw new Error('sane string must start with a \\n is:' + str);
const spaces = match[1]!.length;
let lines = str.split('\n');
lines.shift(); // Remove the first empty lines
- lines = lines.map(line => line.substr(spaces)); // Remove indentation
+ lines = lines.map(line => line.slice(spaces)); // Remove indentation
if (lines[lines.length - 1] === '') lines.pop(); // Remove last line if empty
return lines