|
| 1 | +import { QueryResult } from '../../driver/database-connection.js' |
| 2 | +import { RootOperationNode } from '../../query-compiler/query-compiler.js' |
| 3 | +import { |
| 4 | + KyselyPlugin, |
| 5 | + PluginTransformQueryArgs, |
| 6 | + PluginTransformResultArgs, |
| 7 | +} from '../kysely-plugin.js' |
| 8 | +import { UnknownRow } from '../../util/type-utils.js' |
| 9 | +import { HandleEmptyInListsTransformer } from './handle-empty-in-lists-transformer.js' |
| 10 | +import { HandleEmptyInListsOptions } from './handle-empty-in-lists.js' |
| 11 | + |
| 12 | +/** |
| 13 | + * A plugin that allows handling `in ()` and `not in ()` expressions. |
| 14 | + * |
| 15 | + * These expressions are invalid SQL syntax for many databases, and result in runtime |
| 16 | + * database errors. |
| 17 | + * |
| 18 | + * The workarounds used by other libraries always involve modifying the query under |
| 19 | + * the hood, which is not aligned with Kysely's philosophy of WYSIWYG. We recommend manually checking |
| 20 | + * for empty arrays before passing them as arguments to `in` and `not in` expressions |
| 21 | + * instead, but understand that this can be cumbersome. Hence we're going with an |
| 22 | + * opt-in approach where you can choose if and how to handle these cases. We do |
| 23 | + * not want to make this the default behavior, as it can lead to unexpected behavior. |
| 24 | + * Use it at your own risk. Test it. Make sure it works as expected for you. |
| 25 | + * |
| 26 | + * Using this plugin also allows you to throw an error (thus avoiding unnecessary |
| 27 | + * requests to the database) or print a warning in these cases. |
| 28 | + * |
| 29 | + * ### Examples |
| 30 | + * |
| 31 | + * The following strategy replaces the `in`/`not in` expression with a noncontingent |
| 32 | + * expression. A contradiction (falsy) `1 = 0` for `in`, and a tautology (truthy) `1 = 1` for `not in`), |
| 33 | + * similarily to how {@link https://github.com/knex/knex/blob/176151d8048b2a7feeb89a3d649a5580786d4f4e/docs/src/guide/query-builder.md#L1763 | Knex.js}, |
| 34 | + * {@link https://github.com/prisma/prisma-engines/blob/99168c54187178484dae45d9478aa40cfd1866d2/quaint/src/visitor.rs#L804-L823 | PrismaORM}, |
| 35 | + * {@link https://github.com/laravel/framework/blob/8.x/src/Illuminate/Database/Query/Grammars/Grammar.php#L284-L291 | Laravel}, |
| 36 | + * {@link https://docs.sqlalchemy.org/en/13/core/engines.html#sqlalchemy.create_engine.params.empty_in_strategy | SQLAlchemy} |
| 37 | + * handle this. |
| 38 | + * |
| 39 | + * ```ts |
| 40 | + * import Sqlite from 'better-sqlite3' |
| 41 | + * import { |
| 42 | + * HandleEmptyInListsPlugin, |
| 43 | + * Kysely, |
| 44 | + * replaceWithNoncontingentExpression, |
| 45 | + * SqliteDialect, |
| 46 | + * } from 'kysely' |
| 47 | + * import type { Database } from 'type-editor' // imaginary module |
| 48 | + * |
| 49 | + * const db = new Kysely<Database>({ |
| 50 | + * dialect: new SqliteDialect({ |
| 51 | + * database: new Sqlite(':memory:'), |
| 52 | + * }), |
| 53 | + * plugins: [ |
| 54 | + * new HandleEmptyInListsPlugin({ |
| 55 | + * strategy: replaceWithNoncontingentExpression |
| 56 | + * }) |
| 57 | + * ], |
| 58 | + * }) |
| 59 | + * |
| 60 | + * const results = await db |
| 61 | + * .selectFrom('person') |
| 62 | + * .where('id', 'in', []) |
| 63 | + * .where('first_name', 'not in', []) |
| 64 | + * .selectAll() |
| 65 | + * .execute() |
| 66 | + * ``` |
| 67 | + * |
| 68 | + * The generated SQL (SQLite): |
| 69 | + * |
| 70 | + * ```sql |
| 71 | + * select * from "person" where 1 = 0 and 1 = 1 |
| 72 | + * ``` |
| 73 | + * |
| 74 | + * The following strategy does the following: |
| 75 | + * |
| 76 | + * When `in`, pushes a `null` value into the empty list resulting in `in (null)`, |
| 77 | + * similiarly to how {@link https://github.com/typeorm/typeorm/blob/0280cdc451c35ef73c830eb1191c95d34f6ce06e/src/query-builder/QueryBuilder.ts#L919-L922 | TypeORM} |
| 78 | + * and {@link https://github.com/sequelize/sequelize/blob/0f2891c6897e12bf9bf56df344aae5b698f58c7d/packages/core/src/abstract-dialect/where-sql-builder.ts#L368-L379 | Sequelize} |
| 79 | + * handle `in ()`. `in (null)` is logically the equivalent of `= null`, which returns |
| 80 | + * `null`, which is a falsy expression in most SQL databases. We recommend NOT |
| 81 | + * using this strategy if you plan to use `in` in `select`, `returning`, or `output` |
| 82 | + * clauses, as the return type differs from the `SqlBool` default type for comparisons. |
| 83 | + * |
| 84 | + * When `not in`, casts the left operand as `char` and pushes a unique value into |
| 85 | + * the empty list resulting in `cast({{lhs}} as char) not in ({{VALUE}})`. Casting |
| 86 | + * is required to avoid database errors with non-string values. |
| 87 | + * |
| 88 | + * ```ts |
| 89 | + * import Sqlite from 'better-sqlite3' |
| 90 | + * import { |
| 91 | + * HandleEmptyInListsPlugin, |
| 92 | + * Kysely, |
| 93 | + * pushValueIntoList, |
| 94 | + * SqliteDialect |
| 95 | + * } from 'kysely' |
| 96 | + * import type { Database } from 'type-editor' // imaginary module |
| 97 | + * |
| 98 | + * const db = new Kysely<Database>({ |
| 99 | + * dialect: new SqliteDialect({ |
| 100 | + * database: new Sqlite(':memory:'), |
| 101 | + * }), |
| 102 | + * plugins: [ |
| 103 | + * new HandleEmptyInListsPlugin({ |
| 104 | + * strategy: pushValueIntoList('__kysely_no_values_were_provided__') // choose a unique value for not in. has to be something with zero chance being in the data. |
| 105 | + * }) |
| 106 | + * ], |
| 107 | + * }) |
| 108 | + * |
| 109 | + * const results = await db |
| 110 | + * .selectFrom('person') |
| 111 | + * .where('id', 'in', []) |
| 112 | + * .where('first_name', 'not in', []) |
| 113 | + * .selectAll() |
| 114 | + * .execute() |
| 115 | + * ``` |
| 116 | + * |
| 117 | + * The generated SQL (SQLite): |
| 118 | + * |
| 119 | + * ```sql |
| 120 | + * select * from "person" where "id" in (null) and cast("first_name" as char) not in ('__kysely_no_values_were_provided__') |
| 121 | + * ``` |
| 122 | + * |
| 123 | + * The following custom strategy throws an error when an empty list is encountered |
| 124 | + * to avoid unnecessary requests to the database: |
| 125 | + * |
| 126 | + * ```ts |
| 127 | + * import Sqlite from 'better-sqlite3' |
| 128 | + * import { |
| 129 | + * HandleEmptyInListsPlugin, |
| 130 | + * Kysely, |
| 131 | + * SqliteDialect |
| 132 | + * } from 'kysely' |
| 133 | + * import type { Database } from 'type-editor' // imaginary module |
| 134 | + * |
| 135 | + * const db = new Kysely<Database>({ |
| 136 | + * dialect: new SqliteDialect({ |
| 137 | + * database: new Sqlite(':memory:'), |
| 138 | + * }), |
| 139 | + * plugins: [ |
| 140 | + * new HandleEmptyInListsPlugin({ |
| 141 | + * strategy: () => { |
| 142 | + * throw new Error('Empty in/not-in is not allowed') |
| 143 | + * } |
| 144 | + * }) |
| 145 | + * ], |
| 146 | + * }) |
| 147 | + * |
| 148 | + * const results = await db |
| 149 | + * .selectFrom('person') |
| 150 | + * .where('id', 'in', []) |
| 151 | + * .selectAll() |
| 152 | + * .execute() // throws an error with 'Empty in/not-in is not allowed' message! |
| 153 | + * ``` |
| 154 | + */ |
| 155 | +export class HandleEmptyInListsPlugin implements KyselyPlugin { |
| 156 | + readonly #transformer: HandleEmptyInListsTransformer |
| 157 | + |
| 158 | + constructor(readonly opt: HandleEmptyInListsOptions) { |
| 159 | + this.#transformer = new HandleEmptyInListsTransformer(opt.strategy) |
| 160 | + } |
| 161 | + |
| 162 | + transformQuery(args: PluginTransformQueryArgs): RootOperationNode { |
| 163 | + return this.#transformer.transformNode(args.node) |
| 164 | + } |
| 165 | + |
| 166 | + async transformResult( |
| 167 | + args: PluginTransformResultArgs, |
| 168 | + ): Promise<QueryResult<UnknownRow>> { |
| 169 | + return args.result |
| 170 | + } |
| 171 | +} |
0 commit comments