|
1 | 1 | import { invariant, lowerCaseFirst, upperCaseFirst } from '@zenstackhq/common-helpers'; |
2 | | -import { sql, ValueNode, type Expression, type ExpressionBuilder } from 'kysely'; |
| 2 | +import { sql, ValueNode, type BinaryOperator, type Expression, type ExpressionBuilder } from 'kysely'; |
3 | 3 | import { match } from 'ts-pattern'; |
4 | 4 | import type { ZModelFunction, ZModelFunctionContext } from './options'; |
5 | 5 |
|
6 | 6 | // TODO: migrate default value generation functions to here too |
7 | 7 |
|
8 | | -export const contains: ZModelFunction<any> = (eb, args) => { |
9 | | - const [field, search, caseInsensitive = false] = args; |
10 | | - if (!field) { |
11 | | - throw new Error('"field" parameter is required'); |
12 | | - } |
13 | | - if (!search) { |
14 | | - throw new Error('"search" parameter is required'); |
15 | | - } |
16 | | - const searchExpr = eb.fn('CONCAT', [sql.lit('%'), sql`CAST(${search} as text)`, sql.lit('%')]); |
17 | | - return eb(field, caseInsensitive ? 'ilike' : 'like', searchExpr); |
18 | | -}; |
| 8 | +export const contains: ZModelFunction<any> = (eb, args, context) => textMatch(eb, args, context, 'contains'); |
19 | 9 |
|
20 | 10 | export const search: ZModelFunction<any> = (_eb: ExpressionBuilder<any, any>, _args: Expression<any>[]) => { |
21 | 11 | throw new Error(`"search" function is not implemented yet`); |
22 | 12 | }; |
23 | 13 |
|
24 | | -export const startsWith: ZModelFunction<any> = (eb, args) => { |
25 | | - const [field, search] = args; |
| 14 | +export const startsWith: ZModelFunction<any> = (eb, args, context) => textMatch(eb, args, context, 'startsWith'); |
| 15 | + |
| 16 | +export const endsWith: ZModelFunction<any> = (eb, args, context) => textMatch(eb, args, context, 'endsWith'); |
| 17 | + |
| 18 | +const textMatch = ( |
| 19 | + eb: ExpressionBuilder<any, any>, |
| 20 | + args: Expression<any>[], |
| 21 | + { dialect }: ZModelFunctionContext<any>, |
| 22 | + method: 'contains' | 'startsWith' | 'endsWith', |
| 23 | +) => { |
| 24 | + const [field, search, caseInsensitive = undefined] = args; |
26 | 25 | if (!field) { |
27 | 26 | throw new Error('"field" parameter is required'); |
28 | 27 | } |
29 | 28 | if (!search) { |
30 | 29 | throw new Error('"search" parameter is required'); |
31 | 30 | } |
32 | | - const searchExpr = eb.fn('CONCAT', [sql`CAST(${search} as text)`, sql.lit('%')]); |
33 | | - return eb(field, 'like', searchExpr); |
34 | | -}; |
35 | 31 |
|
36 | | -export const endsWith: ZModelFunction<any> = (eb, args) => { |
37 | | - const [field, search] = args; |
38 | | - if (!field) { |
39 | | - throw new Error('"field" parameter is required'); |
40 | | - } |
41 | | - if (!search) { |
42 | | - throw new Error('"search" parameter is required'); |
| 32 | + const casingBehavior = dialect.getStringCasingBehavior(); |
| 33 | + const caseInsensitiveValue = readBoolean(caseInsensitive, false); |
| 34 | + let op: BinaryOperator; |
| 35 | + let fieldExpr = field; |
| 36 | + let searchExpr = search; |
| 37 | + |
| 38 | + if (caseInsensitiveValue) { |
| 39 | + // case-insensitive search |
| 40 | + if (casingBehavior.supportsILike) { |
| 41 | + // use ILIKE if supported |
| 42 | + op = 'ilike'; |
| 43 | + } else { |
| 44 | + // otherwise change both sides to lower case |
| 45 | + op = 'like'; |
| 46 | + if (casingBehavior.likeCaseSensitive === true) { |
| 47 | + fieldExpr = eb.fn('LOWER', [fieldExpr]); |
| 48 | + searchExpr = eb.fn('LOWER', [searchExpr]); |
| 49 | + } |
| 50 | + } |
| 51 | + } else { |
| 52 | + // case-sensitive search, just use LIKE and deliver whatever the database's behavior is |
| 53 | + op = 'like'; |
43 | 54 | } |
44 | | - const searchExpr = eb.fn('CONCAT', [sql.lit('%'), sql`CAST(${search} as text)`]); |
45 | | - return eb(field, 'like', searchExpr); |
| 55 | + |
| 56 | + searchExpr = match(method) |
| 57 | + .with('contains', () => eb.fn('CONCAT', [sql.lit('%'), sql`CAST(${searchExpr} as text)`, sql.lit('%')])) |
| 58 | + .with('startsWith', () => eb.fn('CONCAT', [sql`CAST(${searchExpr} as text)`, sql.lit('%')])) |
| 59 | + .with('endsWith', () => eb.fn('CONCAT', [sql.lit('%'), sql`CAST(${searchExpr} as text)`])) |
| 60 | + .exhaustive(); |
| 61 | + |
| 62 | + return eb(fieldExpr, op, searchExpr); |
46 | 63 | }; |
47 | 64 |
|
48 | 65 | export const has: ZModelFunction<any> = (eb, args) => { |
@@ -122,3 +139,12 @@ function processCasing(casing: Expression<any>, result: string, model: string) { |
122 | 139 | }); |
123 | 140 | return result; |
124 | 141 | } |
| 142 | + |
| 143 | +function readBoolean(expr: Expression<any> | undefined, defaultValue: boolean) { |
| 144 | + if (expr === undefined) { |
| 145 | + return defaultValue; |
| 146 | + } |
| 147 | + const opNode = expr.toOperationNode(); |
| 148 | + invariant(ValueNode.is(opNode), 'expression must be a literal value'); |
| 149 | + return !!opNode.value; |
| 150 | +} |
0 commit comments