From e6154461b8d3db8c23a8da0cd675f79d2a4c57b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20Koskim=C3=A4ki?= Date: Sun, 23 Jul 2023 10:11:53 +0300 Subject: [PATCH] Add tuple support --- src/expression/expression-builder.ts | 205 ++++++++++++++++++ src/index.ts | 1 + .../operation-node-transformer.ts | 9 + src/operation-node/operation-node-visitor.ts | 3 + src/operation-node/operation-node.ts | 1 + src/operation-node/tuple-node.ts | 23 ++ src/parser/tuple-parser.ts | 73 +++++++ src/parser/value-parser.ts | 2 +- src/query-builder/select-query-builder.ts | 86 ++++++++ src/query-compiler/default-query-compiler.ts | 9 +- test/node/src/select.test.ts | 34 --- test/node/src/where.test.ts | 91 ++++++++ test/typings/test-d/expression.test-d.ts | 47 ++++ 13 files changed, 548 insertions(+), 36 deletions(-) create mode 100644 src/operation-node/tuple-node.ts create mode 100644 src/parser/tuple-parser.ts diff --git a/src/expression/expression-builder.ts b/src/expression/expression-builder.ts index c541a8a1c..6855a19e2 100644 --- a/src/expression/expression-builder.ts +++ b/src/expression/expression-builder.ts @@ -65,6 +65,17 @@ import { JSONPathBuilder } from '../query-builder/json-path-builder.js' import { OperandExpression } from '../parser/expression-parser.js' import { BinaryOperationNode } from '../operation-node/binary-operation-node.js' import { AndNode } from '../operation-node/and-node.js' +import { + RefTuple2, + RefTuple3, + RefTuple4, + RefTuple5, + ValTuple2, + ValTuple3, + ValTuple4, + ValTuple5, +} from '../parser/tuple-parser.js' +import { TupleNode } from '../operation-node/tuple-node.js' export interface ExpressionBuilder { /** @@ -425,6 +436,184 @@ export interface ExpressionBuilder { value: VE ): ExpressionWrapper> + /** + * Creates a tuple expression. + * + * This creates a tuple using column references by default. See {@link valTuple} + * if you need to create value tuples. + * + * ### Examples + * + * ```ts + * db.selectFrom('person') + * .selectAll('person') + * .where(({ eb, tuple, valTuple }) => eb( + * tuple('first_name', 'last_name'), + * 'in', + * [ + * valTuple('Jennifer', 'Aniston'), + * valTuple('Sylvester', 'Stallone') + * ] + * )) + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * select + * "person".* + * from + * "person" + * where + * ("first_name", "last_name") + * in + * ( + * ($1, $2), + * ($3, $4) + * ) + * ``` + * + * In the next example a reference tuple is compared to a subquery. Note that + * in this case you need to use the {@link @SelectQueryBuilder.$asTuple | $asTuple} + * function: + * + * ```ts + * db.selectFrom('person') + * .selectAll('person') + * .where(({ eb, tuple, selectFrom }) => eb( + * tuple('first_name', 'last_name'), + * 'in', + * selectFrom('pet') + * .select(['name', 'species']) + * .where('species', '!=', 'cat') + * .$asTuple('name', 'species') + * )) + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * select + * "person".* + * from + * "person" + * where + * ("first_name", "last_name") + * in + * ( + * select "name", "species" + * from "pet" + * where "species" != $1 + * ) + * ``` + */ + tuple< + R1 extends ReferenceExpression, + R2 extends ReferenceExpression + >( + value1: R1, + value2: R2 + ): ExpressionWrapper> + + tuple< + R1 extends ReferenceExpression, + R2 extends ReferenceExpression, + R3 extends ReferenceExpression + >( + value1: R1, + value2: R2, + value3: R3 + ): ExpressionWrapper> + + tuple< + R1 extends ReferenceExpression, + R2 extends ReferenceExpression, + R3 extends ReferenceExpression, + R4 extends ReferenceExpression + >( + value1: R1, + value2: R2, + value3: R3, + value4: R4 + ): ExpressionWrapper> + + tuple< + R1 extends ReferenceExpression, + R2 extends ReferenceExpression, + R3 extends ReferenceExpression, + R4 extends ReferenceExpression, + R5 extends ReferenceExpression + >( + value1: R1, + value2: R2, + value3: R3, + value4: R4, + value5: R5 + ): ExpressionWrapper> + + /** + * Creates a value tuple expression. + * + * This creates a tuple using values by default. See {@link tuple} if you need to create + * tuples using column references. + * + * ### Examples + * + * ```ts + * db.selectFrom('person') + * .selectAll('person') + * .where(({ eb, tuple, valTuple }) => eb( + * tuple('first_name', 'last_name'), + * 'in', + * [ + * valTuple('Jennifer', 'Aniston'), + * valTuple('Sylvester', 'Stallone') + * ] + * )) + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * select + * "person".* + * from + * "person" + * where + * ("first_name", "last_name") + * in + * ( + * ($1, $2), + * ($3, $4) + * ) + * ``` + */ + valTuple( + value1: V1, + value2: V2 + ): ExpressionWrapper> + + valTuple( + value1: V1, + value2: V2, + value3: V3 + ): ExpressionWrapper> + + valTuple( + value1: V1, + value2: V2, + value3: V3, + value4: V4 + ): ExpressionWrapper> + + valTuple( + value1: V1, + value2: V2, + value3: V3, + value4: V4, + value5: V5 + ): ExpressionWrapper> + /** * Returns a literal value expression. * @@ -886,6 +1075,22 @@ export function createExpressionBuilder( return new ExpressionWrapper(parseValueExpressionOrList(value)) }, + tuple( + ...values: ReadonlyArray> + ): ExpressionWrapper { + return new ExpressionWrapper( + TupleNode.create(values.map(parseReferenceExpression)) + ) + }, + + valTuple( + ...values: ReadonlyArray + ): ExpressionWrapper { + return new ExpressionWrapper( + TupleNode.create(values.map(parseValueExpression)) + ) + }, + lit( value: VE ): ExpressionWrapper { diff --git a/src/index.ts b/src/index.ts index 0cc1d911e..1ce029264 100644 --- a/src/index.ts +++ b/src/index.ts @@ -189,6 +189,7 @@ export * from './operation-node/json-reference-node.js' export * from './operation-node/json-path-leg-node.js' export * from './operation-node/json-path-node.js' export * from './operation-node/json-operator-chain-node.js' +export * from './operation-node/tuple-node.js' export * from './util/column-type.js' export * from './util/compilable.js' diff --git a/src/operation-node/operation-node-transformer.ts b/src/operation-node/operation-node-transformer.ts index 7443af1a2..f10c6bb9a 100644 --- a/src/operation-node/operation-node-transformer.ts +++ b/src/operation-node/operation-node-transformer.ts @@ -86,6 +86,7 @@ import { JSONReferenceNode } from './json-reference-node.js' import { JSONPathNode } from './json-path-node.js' import { JSONPathLegNode } from './json-path-leg-node.js' import { JSONOperatorChainNode } from './json-operator-chain-node.js' +import { TupleNode } from './tuple-node.js' /** * Transforms an operation node tree into another one. @@ -206,6 +207,7 @@ export class OperationNodeTransformer { JSONPathNode: this.transformJSONPath.bind(this), JSONPathLegNode: this.transformJSONPathLeg.bind(this), JSONOperatorChainNode: this.transformJSONOperatorChain.bind(this), + TupleNode: this.transformTuple.bind(this), }) transformNode(node: T): T { @@ -963,6 +965,13 @@ export class OperationNodeTransformer { }) } + protected transformTuple(node: TupleNode): TupleNode { + return requireAllProps({ + kind: 'TupleNode', + values: this.transformNodeList(node.values), + }) + } + protected transformDataType(node: DataTypeNode): DataTypeNode { // An Object.freezed leaf node. No need to clone. return node diff --git a/src/operation-node/operation-node-visitor.ts b/src/operation-node/operation-node-visitor.ts index 922fac8df..23ec54091 100644 --- a/src/operation-node/operation-node-visitor.ts +++ b/src/operation-node/operation-node-visitor.ts @@ -88,6 +88,7 @@ import { JSONReferenceNode } from './json-reference-node.js' import { JSONPathNode } from './json-path-node.js' import { JSONPathLegNode } from './json-path-leg-node.js' import { JSONOperatorChainNode } from './json-operator-chain-node.js' +import { TupleNode } from './tuple-node.js' export abstract class OperationNodeVisitor { protected readonly nodeStack: OperationNode[] = [] @@ -183,6 +184,7 @@ export abstract class OperationNodeVisitor { JSONPathNode: this.visitJSONPath.bind(this), JSONPathLegNode: this.visitJSONPathLeg.bind(this), JSONOperatorChainNode: this.visitJSONOperatorChain.bind(this), + TupleNode: this.visitTuple.bind(this), }) protected readonly visitNode = (node: OperationNode): void => { @@ -286,4 +288,5 @@ export abstract class OperationNodeVisitor { protected abstract visitJSONPath(node: JSONPathNode): void protected abstract visitJSONPathLeg(node: JSONPathLegNode): void protected abstract visitJSONOperatorChain(node: JSONOperatorChainNode): void + protected abstract visitTuple(node: TupleNode): void } diff --git a/src/operation-node/operation-node.ts b/src/operation-node/operation-node.ts index 3f459f4b5..d9511bf41 100644 --- a/src/operation-node/operation-node.ts +++ b/src/operation-node/operation-node.ts @@ -84,6 +84,7 @@ export type OperationNodeKind = | 'JSONPathNode' | 'JSONPathLegNode' | 'JSONOperatorChainNode' + | 'TupleNode' export interface OperationNode { readonly kind: OperationNodeKind diff --git a/src/operation-node/tuple-node.ts b/src/operation-node/tuple-node.ts new file mode 100644 index 000000000..bb4ae261f --- /dev/null +++ b/src/operation-node/tuple-node.ts @@ -0,0 +1,23 @@ +import { freeze } from '../util/object-utils.js' +import { OperationNode } from './operation-node.js' + +export interface TupleNode extends OperationNode { + readonly kind: 'TupleNode' + readonly values: ReadonlyArray +} + +/** + * @internal + */ +export const TupleNode = freeze({ + is(node: OperationNode): node is TupleNode { + return node.kind === 'TupleNode' + }, + + create(values: ReadonlyArray): TupleNode { + return freeze({ + kind: 'TupleNode', + values: freeze(values), + }) + }, +}) diff --git a/src/parser/tuple-parser.ts b/src/parser/tuple-parser.ts new file mode 100644 index 000000000..7283241c8 --- /dev/null +++ b/src/parser/tuple-parser.ts @@ -0,0 +1,73 @@ +import { ExtractTypeFromReferenceExpression } from './reference-parser.js' +import { ExtractTypeFromValueExpression } from './value-parser.js' + +export type RefTuple2 = [R1] extends [unknown] + ? [ + ExtractTypeFromReferenceExpression, + ExtractTypeFromReferenceExpression + ] + : never + +export type RefTuple3 = [R1] extends [ + unknown +] + ? [ + ExtractTypeFromReferenceExpression, + ExtractTypeFromReferenceExpression, + ExtractTypeFromReferenceExpression + ] + : never + +export type RefTuple4 = [R1] extends [ + unknown +] + ? [ + ExtractTypeFromReferenceExpression, + ExtractTypeFromReferenceExpression, + ExtractTypeFromReferenceExpression, + ExtractTypeFromReferenceExpression + ] + : never + +export type RefTuple5 = [ + R1 +] extends [unknown] + ? [ + ExtractTypeFromReferenceExpression, + ExtractTypeFromReferenceExpression, + ExtractTypeFromReferenceExpression, + ExtractTypeFromReferenceExpression, + ExtractTypeFromReferenceExpression + ] + : never + +export type ValTuple2 = [V1] extends [unknown] + ? [ExtractTypeFromValueExpression, ExtractTypeFromValueExpression] + : never + +export type ValTuple3 = V1 extends any + ? [ + ExtractTypeFromValueExpression, + ExtractTypeFromValueExpression, + ExtractTypeFromValueExpression + ] + : never + +export type ValTuple4 = [V1] extends [unknown] + ? [ + ExtractTypeFromValueExpression, + ExtractTypeFromValueExpression, + ExtractTypeFromValueExpression, + ExtractTypeFromValueExpression + ] + : never + +export type ValTuple5 = [V1] extends [unknown] + ? [ + ExtractTypeFromValueExpression, + ExtractTypeFromValueExpression, + ExtractTypeFromValueExpression, + ExtractTypeFromValueExpression, + ExtractTypeFromValueExpression + ] + : never diff --git a/src/parser/value-parser.ts b/src/parser/value-parser.ts index 245940ad1..830639515 100644 --- a/src/parser/value-parser.ts +++ b/src/parser/value-parser.ts @@ -30,7 +30,7 @@ export type ExtractTypeFromValueExpressionOrList = VE extends ReadonlyArray< ? ExtractTypeFromValueExpression : ExtractTypeFromValueExpression -type ExtractTypeFromValueExpression = VE extends SelectQueryBuilder< +export type ExtractTypeFromValueExpression = VE extends SelectQueryBuilder< any, any, Record diff --git a/src/query-builder/select-query-builder.ts b/src/query-builder/select-query-builder.ts index 1ad8e6616..186f81ab8 100644 --- a/src/query-builder/select-query-builder.ts +++ b/src/query-builder/select-query-builder.ts @@ -71,6 +71,7 @@ import { KyselyTypeError } from '../util/type-error.js' import { Selectable } from '../util/column-type.js' import { Streamable } from '../util/streamable.js' import { ExpressionOrFactory } from '../parser/expression-parser.js' +import { ExpressionWrapper } from '../expression/expression-wrapper.js' export interface SelectQueryBuilder extends WhereInterface, @@ -1414,6 +1415,87 @@ export interface SelectQueryBuilder */ $castTo(): SelectQueryBuilder + /** + * Changes the output type from an object to a tuple. + * + * This doesn't affect the generated SQL in any way. This function is + * just a necessary evil when you need to convert a query's output + * record type to a tuple type. Typescript doesn't currently offer + * tools to do this automatically (without insane hackery). + * + * The returned object can no longer be executed. It can only be used + * as a subquery. + * + * ### Examples + * + * ```ts + * const result = await db + * .selectFrom('person') + * .selectAll('person') + * .where(({ eb, tuple, selectFrom }) => eb( + * tuple('first_name', 'last_name'), + * 'in', + * selectFrom('pet') + * .select(['name', 'species']) + * .where('pet.species', '!=', 'cat') + * .$asTuple('name', 'species') + * )) + * ``` + * + * The generated SQL(PostgreSQL): + * + * ```sql + * select + * "person".* + * from + * "person" + * where + * ("first_name", "last_name") + * in + * ( + * select "name", "species" + * from "pet" + * where "pet"."species" != $1 + * ) + * ``` + */ + $asTuple( + key1: K1, + key2: K2 + ): ExpressionWrapper + + $asTuple( + key1: K1, + key2: K2, + key3: K3 + ): ExpressionWrapper + + $asTuple< + K1 extends keyof O, + K2 extends keyof O, + K3 extends keyof O, + K4 extends keyof O + >( + key1: K1, + key2: K2, + key3: K3, + key4: K4 + ): ExpressionWrapper + + $asTuple< + K1 extends keyof O, + K2 extends keyof O, + K3 extends keyof O, + K4 extends keyof O, + K5 extends keyof O + >( + key1: K1, + key2: K2, + key3: K3, + key4: K4, + key5: K5 + ): ExpressionWrapper + /** * Narrows (parts of) the output type of the query. * @@ -1966,6 +2048,10 @@ class SelectQueryBuilderImpl return new SelectQueryBuilderImpl(this.#props) as unknown as any } + $asTuple(): ExpressionWrapper { + return new ExpressionWrapper(this.toOperationNode()) + } + withPlugin(plugin: KyselyPlugin): SelectQueryBuilder { return new SelectQueryBuilderImpl({ ...this.#props, diff --git a/src/query-compiler/default-query-compiler.ts b/src/query-compiler/default-query-compiler.ts index 42b9f0b2c..5af5442a6 100644 --- a/src/query-compiler/default-query-compiler.ts +++ b/src/query-compiler/default-query-compiler.ts @@ -100,6 +100,7 @@ import { JSONReferenceNode } from '../operation-node/json-reference-node.js' import { JSONPathNode } from '../operation-node/json-path-node.js' import { JSONPathLegNode } from '../operation-node/json-path-leg-node.js' import { JSONOperatorChainNode } from '../operation-node/json-operator-chain-node.js' +import { TupleNode } from '../operation-node/tuple-node.js' export class DefaultQueryCompiler extends OperationNodeVisitor @@ -447,6 +448,12 @@ export class DefaultQueryCompiler this.append(')') } + protected override visitTuple(node: TupleNode): void { + this.append('(') + this.compileList(node.values) + this.append(')') + } + protected override visitPrimitiveValueList( node: PrimitiveValueListNode ): void { @@ -944,7 +951,7 @@ export class DefaultQueryCompiler if (!node.materialized) { this.append('not ') } - + this.append('materialized ') } diff --git a/test/node/src/select.test.ts b/test/node/src/select.test.ts index 5f0b15ae1..28b567b86 100644 --- a/test/node/src/select.test.ts +++ b/test/node/src/select.test.ts @@ -847,39 +847,5 @@ for (const dialect of DIALECTS) { }) } } - - it.skip('perf', async () => { - const ids = Array.from({ length: 100 }).map(() => - Math.round(Math.random() * 1000) - ) - - function test() { - return ctx.db - .updateTable('person') - .set({ - first_name: 'foo', - last_name: 'bar', - id: 100, - gender: 'other', - }) - .where('id', 'in', ids) - .compile() - } - - // Warmup - for (let i = 0; i < 1000; ++i) { - test() - } - - const time = Date.now() - const N = 100000 - - for (let i = 0; i < N; ++i) { - test() - } - - const endTime = Date.now() - console.log((endTime - time) / N) - }) }) } diff --git a/test/node/src/where.test.ts b/test/node/src/where.test.ts index b585a6935..0c06b55e7 100644 --- a/test/node/src/where.test.ts +++ b/test/node/src/where.test.ts @@ -454,6 +454,97 @@ for (const dialect of DIALECTS) { ]) }) + it('a `where in` query with tuples', async () => { + const query = ctx.db + .selectFrom('person') + .selectAll() + .where((eb) => + eb(eb.tuple('first_name', 'last_name'), 'in', [ + eb.valTuple('Jennifer', 'Aniston'), + eb.valTuple('Sylvester', 'Stallone'), + ]) + ) + .orderBy('first_name asc') + + testSql(query, dialect, { + postgres: { + sql: 'select * from "person" where ("first_name", "last_name") in (($1, $2), ($3, $4)) order by "first_name" asc', + parameters: ['Jennifer', 'Aniston', 'Sylvester', 'Stallone'], + }, + mysql: { + sql: 'select * from `person` where (`first_name`, `last_name`) in ((?, ?), (?, ?)) order by `first_name` asc', + parameters: ['Jennifer', 'Aniston', 'Sylvester', 'Stallone'], + }, + sqlite: { + sql: 'select * from "person" where ("first_name", "last_name") in ((?, ?), (?, ?)) order by "first_name" asc', + parameters: ['Jennifer', 'Aniston', 'Sylvester', 'Stallone'], + }, + }) + + const persons = await query.execute() + expect(persons).to.have.length(2) + expect(persons).to.containSubset([ + { + first_name: 'Jennifer', + last_name: 'Aniston', + gender: 'female', + }, + { + first_name: 'Sylvester', + last_name: 'Stallone', + gender: 'male', + }, + ]) + }) + + it('a `where in` query with tuples and a subquery', async () => { + const query = ctx.db + .selectFrom('person') + .selectAll() + .where((eb) => + eb( + eb.tuple('first_name', 'last_name'), + 'in', + eb + .selectFrom('person as p2') + .select(['p2.first_name', 'p2.last_name']) + .where('first_name', 'in', ['Arnold', 'Sylvester']) + .$asTuple('first_name', 'last_name') + ) + ) + .orderBy('first_name asc') + + testSql(query, dialect, { + postgres: { + sql: 'select * from "person" where ("first_name", "last_name") in (select "p2"."first_name", "p2"."last_name" from "person" as "p2" where "first_name" in ($1, $2)) order by "first_name" asc', + parameters: ['Arnold', 'Sylvester'], + }, + mysql: { + sql: 'select * from `person` where (`first_name`, `last_name`) in (select `p2`.`first_name`, `p2`.`last_name` from `person` as `p2` where `first_name` in (?, ?)) order by `first_name` asc', + parameters: ['Arnold', 'Sylvester'], + }, + sqlite: { + sql: 'select * from "person" where ("first_name", "last_name") in (select "p2"."first_name", "p2"."last_name" from "person" as "p2" where "first_name" in (?, ?)) order by "first_name" asc', + parameters: ['Arnold', 'Sylvester'], + }, + }) + + const persons = await query.execute() + expect(persons).to.have.length(2) + expect(persons).to.containSubset([ + { + first_name: 'Arnold', + last_name: 'Schwarzenegger', + gender: 'male', + }, + { + first_name: 'Sylvester', + last_name: 'Stallone', + gender: 'male', + }, + ]) + }) + it('two where expressions', async () => { const query = ctx.db .selectFrom('person') diff --git a/test/typings/test-d/expression.test-d.ts b/test/typings/test-d/expression.test-d.ts index 6af2e4854..b5d8b467c 100644 --- a/test/typings/test-d/expression.test-d.ts +++ b/test/typings/test-d/expression.test-d.ts @@ -143,3 +143,50 @@ function testExpressionBuilder(eb: ExpressionBuilder) { expectError(eb.betweenSymmetric('age', 'wrong type', 2)) expectError(eb.betweenSymmetric('age', 1, 'wrong type')) } + +function testExpressionBuilderTuple(db: Kysely) { + db.selectFrom('person') + .selectAll() + .where(({ eb, tuple, valTuple }) => + eb(tuple('first_name', 'last_name'), 'in', [ + valTuple('Jennifer', 'Aniston'), + valTuple('Sylvester', 'Stallone'), + ]) + ) + + db.selectFrom('person') + .selectAll() + .where(({ eb, tuple, selectFrom }) => + eb( + tuple('first_name', 'last_name'), + 'in', + selectFrom('person') + .select(['first_name', 'last_name']) + .$asTuple('first_name', 'last_name') + ) + ) + + // Wrong tuple type + expectError( + db + .selectFrom('person') + .where(({ eb, tuple, valTuple }) => + eb(tuple('first_name', 'last_name'), 'in', [ + valTuple('Jennifer', 'Aniston'), + valTuple('Sylvester', 1), + ]) + ) + ) + + // Wrong tuple length + expectError( + db + .selectFrom('person') + .where(({ eb, tuple, valTuple }) => + eb(tuple('first_name', 'last_name'), 'in', [ + valTuple('Jennifer', 'Aniston', 'Extra'), + valTuple('Sylvester', 'Stallone'), + ]) + ) + ) +}