From 9e01281af2d16ad0b23c2d08291e9267dd98c412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20Koskim=C3=A4ki?= Date: Mon, 22 Jul 2024 15:18:49 +0300 Subject: [PATCH] add reusable helpers recipe and implement missing expression features (#1085) * add reusable helpers recipe and implement missing expression features * force node 22.4.1 in CI because of an npm bug --- .github/workflows/test.yml | 12 +- site/docs/recipes/0001-reusable-helpers.md | 238 +++++++++++++++++++++ site/docs/recipes/0006-expressions.md | 42 ++-- src/expression/expression-builder.ts | 12 +- src/query-builder/select-query-builder.ts | 66 +++++- src/raw-builder/sql.ts | 16 +- test/typings/test-d/expression.test-d.ts | 21 +- test/typings/test-d/select.test-d.ts | 7 + 8 files changed, 377 insertions(+), 37 deletions(-) create mode 100644 site/docs/recipes/0001-reusable-helpers.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4a0a17600..405a26f89 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [18.x, 20.x, 22.x] + node-version: [18.x, 20.x, 22.4.1] steps: - uses: actions/checkout@v4 @@ -44,7 +44,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [18.x, 20.x, 22.x] + node-version: [18.x, 20.x, 22.4.1] steps: - uses: actions/checkout@v4 @@ -79,7 +79,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: 22.x + node-version: 22.4.1 cache: 'npm' - name: Use Deno ${{ matrix.deno-version }} @@ -114,7 +114,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: 22.x + node-version: 22.4.1 cache: 'npm' - name: Install dependencies @@ -141,7 +141,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: 22.x + node-version: 22.4.1 cache: 'npm' - name: Install dependencies @@ -163,7 +163,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: 22.x + node-version: 22.4.1 cache: 'npm' - name: Install dependencies diff --git a/site/docs/recipes/0001-reusable-helpers.md b/site/docs/recipes/0001-reusable-helpers.md new file mode 100644 index 000000000..2cce66949 --- /dev/null +++ b/site/docs/recipes/0001-reusable-helpers.md @@ -0,0 +1,238 @@ +# Reusable helpers + +:::info +[Here's](https://kyse.link/qm67s) a playground link containing all the code in this recipe. +::: + +Let's say you want to write the following query: + +```sql +SELECT id, first_name +FROM person +WHERE upper(last_name) = $1 +``` + +Kysely doesn't have a built-in `upper` function but there are at least three ways you could write this: + +```ts +const lastName = 'STALLONE' + +const persons = await db + .selectFrom('person') + .select(['id', 'first_name']) + // 1. `sql` template tag. This is the least type-safe option. + // You're providing the column name without any type-checking, + // and plugins won't affect it. + .where( + sql`upper(last_name)`, '=', lastName + ) + // 2. `sql` template tag with `ref`. Anything passed to `ref` + // gets type-checked against the accumulated query context. + .where(({ eb, ref }) => eb( + sql`upper(${ref('last_name')})`, '=', lastName + )) + // 3. The `fn` function helps you avoid missing parentheses/commas + // errors and uses refs as 1st class arguments. + .where(({ eb, fn }) => eb( + fn('upper', ['last_name']), '=', lastName + )) + .execute() +``` + +but each option could be more readable or type-safe. + +Fortunately Kysely allows you to easily create composable, reusable and type-safe helper functions: + +```ts +import { Expression, sql } from 'kysely' + +function upper(expr: Expression) { + return sql`upper(${expr})` +} + +function lower(expr: Expression) { + return sql`lower(${expr})` +} + +function concat(...exprs: Expression[]) { + return sql.join(exprs, sql`||`) +} +``` + +Using the `upper` helper, our query would look like this: + +```ts +const lastName = 'STALLONE' + +const persons = await db + .selectFrom('person') + .select(['id', 'first_name']) + .where(({ eb, ref }) => eb( + upper(ref('last_name')), '=', lastName + )) + .execute() +``` + +The recipe for helper functions is simple: take inputs as `Expression` instances where `T` is the type of the expression. For example `upper` takes in any `string` expression since it transforms strings to upper case. If you implemented the `round` function, it'd take in `Expression` since you can only round numbers. + +The helper functions should then use the inputs to create an output that's also an `Expression`. Everything you can create using the expression builder is an instance of `Expression`. So is the output of the `sql` template tag and all methods under the `sql` object. Same goes for `SelectQueryBuilder` and pretty much everything else in Kysely. Everything's an expression. + +See [this recipe](https://kysely.dev/docs/recipes/expressions) to learn more about expressions. + +So we've learned that everything's an expression and that expressions are composable. Let's put this idea to use: + +```ts +const persons = await db + .selectFrom('person') + .select(['id', 'first_name']) + .where(({ eb, ref, val }) => eb( + concat( + lower(ref('first_name')), + val(' '), + upper(ref('last_name')) + ), + '=', + 'sylvester STALLONE' + )) + .execute() +``` + +So far we've only used our helper functions in the first argument of `where` but you can use them anywhere: + +```ts +const persons = await db + .selectFrom('person') + .innerJoin('pet', (join) => join.on(eb => eb( + 'person.first_name', '=', lower(eb.ref('pet.name')) + ))) + .select(({ ref, val }) => [ + 'first_name', + // If you use a helper in `select`, you need to always provide an explicit + // name for it using the `as` method. + concat(ref('person.first_name'), val(' '), ref('pet.name')).as('name_with_pet') + ]) + .orderBy(({ ref }) => lower(ref('first_name'))) + .execute() +``` + +## Reusable helpers using `ExpressionBuilder` + +Here's an example of a helper function that uses the expression builder instead of raw SQL: + +```ts +import { Expression, expressionBuilder } from 'kysely' + +function idsOfPersonsThatHaveDogNamed(name: Expression) { + const eb = expressionBuilder() + + // A subquery that returns the identifiers of all persons + // that have a dog named `name`. + return eb + .selectFrom('pet') + .select('pet.owner_id') + .where('pet.species', '=', 'dog') + .where('pet.name', '=', name) +} +``` + +And here's how you could use it: + +```ts +const dogName = 'Doggo' + +const persons = await db + .selectFrom('person') + .selectAll('person') + .where((eb) => eb( + 'person.id', 'in', idsOfPersonsThatHaveDogNamed(eb.val(dogName)) + )) + .execute() +``` + +Note that `idsOfPersonsThatHaveDogNamed` doesn't execute a separate query but instead returns a subquery expression that's compiled as a part of the parent query: + +```sql +select + person.* +from + person +where + person.id in ( + select pet.owner_id + from pet + where pet.species = 'dog' + and pet.name = ? + ) +``` + +In all our examples we've used the following syntax: + +```ts +.where(eb => eb(left, operator, right)) +``` + +When the expression builder `eb` is used as a function, it creates a binary expression. All binary expressions with a comparison operator are represented as a `Expression`. You don't always need to return `eb(left, operator, right)` from the callback though. Since `Expressions` are composable and reusable, you can return any `Expression`. + +This means you can create helpers like this: + +```ts +function isOlderThan(age: Expression) { + return sql`age > ${age}` +} +``` + +```ts +const persons = await db + .selectFrom('person') + .select(['id', 'first_name']) + .where(({ val }) => isOlderThan(val(60))) + .execute() +``` + +## Dealing with nullable expressions + +If you want your helpers to work with nullable expressions (nullable columns etc.), you can do something like this: + +```ts +import { Expression } from 'kysely' + +// This function accepts both nullable and non-nullable string expressions. +function toInt(expr: Expression) { + // This returns `Expression` if `expr` is nullable + // and `Expression` otherwise. + return sql`(${expr})::integer` +} +``` + +## Passing select queries as expressions + +Let's say we have the following query: + +```ts +const expr: Expression<{ name: string }> = db + .selectFrom('pet') + .select('pet.name') +``` + +The expression type of our query is `Expression<{ name: string }>` but SQL allows you to use a query like that as an `Expression`. In other words, SQL allows you to use single-column record types like scalars. Most of the time Kysely is able to automatically handle this case but with helper functions you need to use `$asScalar()` to convert the type. Here's an example: + +```ts +const persons = await db + .selectFrom('person') + .select((eb) => [ + 'id', + 'first_name', + upper( + eb.selectFrom('pet') + .select('name') + .whereRef('person.id', '=', 'pet.owner_id') + .limit(1) + .$asScalar() // <-- This is needed + .$notNull() + ).as('pet_name') + ]) +``` + +The subquery is an `Expression<{ name: string }>` but our `upper` function only accepts `Expression`. That's why we need to call `$asScalar()`. `$asScalar()` has no effect on the generated SQL. It's simply a type-level helper. + +We also used `$notNull()` in the example because our simple `upper` function doesn't support nullable expressions. \ No newline at end of file diff --git a/site/docs/recipes/0006-expressions.md b/site/docs/recipes/0006-expressions.md index 3ac86cead..154eb88a6 100644 --- a/site/docs/recipes/0006-expressions.md +++ b/site/docs/recipes/0006-expressions.md @@ -6,9 +6,9 @@ An [`Expression`](https://kysely-org.github.io/kysely-apidoc/interfaces/Expre ## Expression builder -Expressions are usually built using an instance of [`ExpressionBuilder`](https://kysely-org.github.io/kysely-apidoc/interfaces/ExpressionBuilder.html). `DB` is the same database type you give to `Kysely` when you create an instance. `TB` is the union of all table names that are visible in the context. For example `ExpressionBuilder` means you can access `person` and `pet` tables and all their columns in the expression. +Expressions are usually built using an instance of [`ExpressionBuilder`](https://kysely-org.github.io/kysely-apidoc/interfaces/ExpressionBuilder.html). `DB` is the same database type you give to `Kysely` when you create an instance. `TB` is the union of all table names that are visible in the context. For example `ExpressionBuilder` means you can reference `person` and `pet` columns in the created expressions. -You can get an instance of the expression builder by using a callback: +You can get an instance of the expression builder using a callback: ```ts const person = await db @@ -28,7 +28,13 @@ const person = await db .as('pet_name'), // Select a boolean expression - eb('first_name', '=', 'Jennifer').as('is_jennifer') + eb('first_name', '=', 'Jennifer').as('is_jennifer'), + + // Select a static string value + eb.val('Some value').as('string_value'), + + // Select a literal value + eb.lit(42).as('literal_value'), ]) // You can also destructure the expression builder like this .where(({ and, or, eb, not, exists, selectFrom }) => or([ @@ -63,19 +69,21 @@ select limit 1 ) as "pet_name", - "first_name" = $1 as "is_jennifer" + "first_name" = $1 as "is_jennifer", + $2 as "string_value", + 42 as "literal_value" from "person" where ( ( - "first_name" = $2 - and "last_name" = $3 + "first_name" = $3 + and "last_name" = $4 ) or not exists ( select "pet.id" from "pet" where "pet"."owner_id" = "person"."id" - and "pet"."species" in ($4, $5) + and "pet"."species" in ($5, $6) ) ) ``` @@ -91,11 +99,17 @@ There's also a global function `expressionBuilder` you can use to create express ```ts import { expressionBuilder } from 'kysely' -// `eb1` has type `ExpressionBuilder` -const eb1 = expressionBuilder() +// `eb1` has type `ExpressionBuilder` which means there are no tables in the +// context. This variant should be used most of the time in helper functions since you +// shouldn't make assumptions about the calling context. +const eb1 = expressionBuilder() + +// `eb2` has type `ExpressionBuilder`. You can reference `person` columns +// directly in all expression builder methods. +const eb2 = expressionBuilder() // In this one you'd have access to tables `person` and `pet` and all their columns. -const eb2 = expressionBuilder() +const eb3 = expressionBuilder() let qb = query .selectFrom('person') @@ -141,7 +155,7 @@ const doggoPersons = await db .execute() ``` -The above helper is not very type-safe. The following code would compile, but fail at runtime: +However, the above helper is not very type-safe. The following code would compile, but fail at runtime: ```ts const bigFatFailure = await db @@ -160,7 +174,7 @@ in arbitrary expressions instead of just values. function hasDogNamed(name: Expression, ownerId: Expression) { // Create an expression builder without any tables in the context. // This way we make no assumptions about the calling context. - const eb = expressionBuilder() + const eb = expressionBuilder() return eb.exists( eb.selectFrom('pet') @@ -182,11 +196,13 @@ const doggoPersons = await db .execute() ``` +Learn more about reusable helper functions [here](https://kysely.dev/docs/recipes/reusable-helpers). + ## Conditional expressions In the following, we'll only cover `where` expressions. The same logic applies to `having`, `on`, `orderBy`, `groupBy` etc. -> This section should not be confused with conditional selections in `select` clauses, which is a whole 'nother topic we discuss in [this recipe](https://www.kysely.dev/docs/recipes/conditional-selects). +> This section should not be confused with conditional selections in `select` clauses, which is a whole 'nother topic we discuss in [this recipe](https://kysely.dev/docs/recipes/conditional-selects). Having a set of optional filters you want to combine using `and`, is the most basic and common use case of conditional `where` expressions. Since the `where`, `having` and other filter functions are additive, most of the time this is enough: diff --git a/src/expression/expression-builder.ts b/src/expression/expression-builder.ts index 318b0a8ec..b9c9ad584 100644 --- a/src/expression/expression-builder.ts +++ b/src/expression/expression-builder.ts @@ -194,9 +194,9 @@ export interface ExpressionBuilder { * ```ts * const result = await db.selectFrom('person') * .where(({ eb, exists, selectFrom }) => - * eb('first_name', '=', 'Jennifer').and( - * exists(selectFrom('pet').whereRef('owner_id', '=', 'person.id').select('pet.id')) - * ) + * eb('first_name', '=', 'Jennifer').and(exists( + * selectFrom('pet').whereRef('owner_id', '=', 'person.id').select('pet.id') + * )) * ) * .selectAll() * .execute() @@ -1379,10 +1379,10 @@ export function expressionBuilder( _: SelectQueryBuilder, ): ExpressionBuilder -export function expressionBuilder(): ExpressionBuilder< +export function expressionBuilder< DB, - TB -> + TB extends keyof DB = never, +>(): ExpressionBuilder export function expressionBuilder( _?: unknown, diff --git a/src/query-builder/select-query-builder.ts b/src/query-builder/select-query-builder.ts index da098a5ad..b61c2650d 100644 --- a/src/query-builder/select-query-builder.ts +++ b/src/query-builder/select-query-builder.ts @@ -243,7 +243,7 @@ export interface SelectQueryBuilder * import { sql } from 'kysely' * * const persons = await db.selectFrom('person') - * .select(({ eb, selectFrom, or }) => [ + * .select(({ eb, selectFrom, or, val, lit }) => [ * // Select a correlated subquery * selectFrom('pet') * .whereRef('person.id', '=', 'pet.owner_id') @@ -260,7 +260,13 @@ export interface SelectQueryBuilder * ]).as('is_jennifer_or_arnold'), * * // Select a raw sql expression - * sql`concat(first_name, ' ', last_name)`.as('full_name') + * sql`concat(first_name, ' ', last_name)`.as('full_name'). + * + * // Select a static string value + * val('Some value').as('string_value'), + * + * // Select a literal value + * lit(42).as('literal_value'), * ]) * .execute() * ``` @@ -277,7 +283,9 @@ export interface SelectQueryBuilder * limit $1 * ) as "pet_name", * ("first_name" = $2 or "first_name" = $3) as "jennifer_or_arnold", - * concat(first_name, ' ', last_name) as "full_name" + * concat(first_name, ' ', last_name) as "full_name", + * $4 as "string_value", + * 42 as "literal_value" * from "person" * ``` * @@ -1928,6 +1936,54 @@ export interface SelectQueryBuilder ? ExpressionWrapper : KyselyTypeError<'$asTuple() call failed: All selected columns must be provided as arguments'> + /** + * Plucks the value type of the output record. + * + * In SQL, any record type that only has one column can be used as a scalar. + * For example a query like this works: + * + * ```sql + * select + * id, + * first_name + * from + * person as p + * where + * -- This is ok since the query only selects one row + * -- and one column. + * (select name from pet where pet.owner_id = p.id limit 1) = 'Doggo' + * ``` + * + * In many cases Kysely handles this automatically and picks the correct + * scalar type instead of the record type, but sometimes you need to give + * Kysely a hint. + * + * One such case are custom helper functions that take `Expression` + * instances as inputs: + * + * ```ts + * function doStuff(expr: Expression) { + * ... + * } + * + * // Error! This is not ok because the expression type is + * // `{ first_name: string }` instead of `string`. + * doStuff(db.selectFrom('person').select('first_name')) + * + * // Ok! This is ok since we've plucked the `string` type of the + * // only column in the output type. + * doStuff(db.selectFrom('person').select('first_name').$asScalar()) + * ``` + * + * This function has absolutely no effect on the generated SQL. It's + * purely a type-level helper. + * + * This method returns an `ExpressionWrapper` instead of a `SelectQueryBuilder` + * since the return value should only be used as a part of an expression + * and never executed as the main query. + */ + $asScalar(): ExpressionWrapper + /** * Narrows (parts of) the output type of the query. * @@ -2561,6 +2617,10 @@ class SelectQueryBuilderImpl return new ExpressionWrapper(this.toOperationNode()) } + $asScalar(): ExpressionWrapper { + return new ExpressionWrapper(this.toOperationNode()) + } + withPlugin(plugin: KyselyPlugin): SelectQueryBuilder { return new SelectQueryBuilderImpl({ ...this.#props, diff --git a/src/raw-builder/sql.ts b/src/raw-builder/sql.ts index 3320d4e1b..b50b54ba8 100644 --- a/src/raw-builder/sql.ts +++ b/src/raw-builder/sql.ts @@ -230,7 +230,7 @@ export interface Sql { * select first_name from "public"."person" * ``` */ - table(tableReference: string): RawBuilder + table(tableReference: string): RawBuilder /** * This can be used to add arbitrary identifiers to SQL snippets. @@ -273,7 +273,7 @@ export interface Sql { * select "public"."person"."first_name" from "public"."person" * ``` */ - id(...ids: readonly string[]): RawBuilder + id(...ids: readonly string[]): RawBuilder /** * This can be used to add literal values to SQL snippets. @@ -383,10 +383,10 @@ export interface Sql { * BEFORE $1::varchar, (1 == 1)::varchar, (select * from "person")::varchar, false::varchar, "first_name" AFTER * ``` */ - join( + join( array: readonly unknown[], separator?: RawBuilder, - ): RawBuilder + ): RawBuilder } export const sql: Sql = Object.assign( @@ -421,14 +421,14 @@ export const sql: Sql = Object.assign( return this.val(value) }, - table(tableReference: string): RawBuilder { + table(tableReference: string): RawBuilder { return createRawBuilder({ queryId: createQueryId(), rawNode: RawNode.createWithChild(parseTable(tableReference)), }) }, - id(...ids: readonly string[]): RawBuilder { + id(...ids: readonly string[]): RawBuilder { const fragments = new Array(ids.length + 1).fill('.') fragments[0] = '' @@ -458,10 +458,10 @@ export const sql: Sql = Object.assign( }) }, - join( + join( array: readonly unknown[], separator: RawBuilder = sql`, `, - ): RawBuilder { + ): RawBuilder { const nodes = new Array(2 * array.length - 1) const sep = separator.toOperationNode() diff --git a/test/typings/test-d/expression.test-d.ts b/test/typings/test-d/expression.test-d.ts index 8c2180c4b..0b88fc81e 100644 --- a/test/typings/test-d/expression.test-d.ts +++ b/test/typings/test-d/expression.test-d.ts @@ -4,7 +4,13 @@ import { expectError, expectType, } from 'tsd' -import { Expression, ExpressionBuilder, Kysely, SqlBool } from '..' +import { + Expression, + ExpressionBuilder, + Kysely, + SqlBool, + expressionBuilder, +} from '..' import { Database } from '../shared' import { KyselyTypeError } from '../../../dist/cjs/util/type-error' @@ -260,3 +266,16 @@ function testExpressionBuilderTuple(db: Kysely) { .$asTuple('first_name', 'last_name', 'last_name'), ) } + +function testExpressionBuilderConstructor(db: Kysely) { + const eb1 = expressionBuilder() + expectType>(eb1) + + const eb2 = expressionBuilder() + expectType>(eb2) + + const eb3 = expressionBuilder( + db.selectFrom('action').innerJoin('pet', (join) => join.onTrue()), + ) + expectType>(eb3) +} diff --git a/test/typings/test-d/select.test-d.ts b/test/typings/test-d/select.test-d.ts index d3b0837e8..fd5997951 100644 --- a/test/typings/test-d/select.test-d.ts +++ b/test/typings/test-d/select.test-d.ts @@ -1,5 +1,6 @@ import { Expression, + ExpressionWrapper, Kysely, NotNull, RawBuilder, @@ -132,6 +133,12 @@ async function testSelectSingle(db: Kysely) { expectType(r17.callback_url) expectType(r17.queue_id) + + const expr1 = db.selectFrom('person').select('first_name').$asScalar() + expectType>(expr1) + + const expr2 = db.selectFrom('person').select('deleted_at').$asScalar() + expectType>(expr2) } async function testSelectAll(db: Kysely) {