Skip to content

Commit f1ed2ce

Browse files
committed
addressing review comments, cleaning up text search casing
1 parent 0785139 commit f1ed2ce

File tree

14 files changed

+1302
-1241
lines changed

14 files changed

+1302
-1241
lines changed

packages/common-helpers/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
export * from './is-plain-object';
22
export * from './lower-case-first';
33
export * from './param-case';
4-
export * from './promise-utils';
54
export * from './sleep';
65
export * from './tiny-invariant';
76
export * from './upper-case-first';

packages/common-helpers/src/promise-utils.ts

Lines changed: 0 additions & 10 deletions
This file was deleted.

packages/language/res/stdlib.zmodel

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,10 @@ function future(): Any {
123123
} @@@expressionContext([AccessPolicy])
124124

125125
/**
126-
* If the field value contains the search string. By default, the search is case-sensitive,
127-
* but you can override the behavior with the "caseInSensitive" argument.
126+
* Checks if the field value contains the search string. By default, the search is case-sensitive, and
127+
* "LIKE" operator is used to match. If `caseInSensitive` is true, "ILIKE" operator is used if
128+
* supported, otherwise it still falls back to "LIKE" and delivers whatever the database's
129+
* behavior is.
128130
*/
129131
function contains(field: String, search: String, caseInSensitive: Boolean?): Boolean {
130132
} @@@expressionContext([AccessPolicy, ValidationRule])
@@ -136,15 +138,21 @@ function search(field: String, search: String): Boolean {
136138
} @@@expressionContext([AccessPolicy])
137139

138140
/**
139-
* If the field value starts with the search string
141+
* Checks the field value starts with the search string. By default, the search is case-sensitive, and
142+
* "LIKE" operator is used to match. If `caseInSensitive` is true, "ILIKE" operator is used if
143+
* supported, otherwise it still falls back to "LIKE" and delivers whatever the database's
144+
* behavior is.
140145
*/
141-
function startsWith(field: String, search: String): Boolean {
146+
function startsWith(field: String, search: String, caseInSensitive: Boolean?): Boolean {
142147
} @@@expressionContext([AccessPolicy, ValidationRule])
143148

144149
/**
145-
* If the field value ends with the search string
150+
* Checks if the field value ends with the search string. By default, the search is case-sensitive, and
151+
* "LIKE" operator is used to match. If `caseInSensitive` is true, "ILIKE" operator is used if
152+
* supported, otherwise it still falls back to "LIKE" and delivers whatever the database's
153+
* behavior is.
146154
*/
147-
function endsWith(field: String, search: String): Boolean {
155+
function endsWith(field: String, search: String, caseInSensitive: Boolean?): Boolean {
148156
} @@@expressionContext([AccessPolicy, ValidationRule])
149157

150158
/**

packages/runtime/src/client/crud/dialects/base-dialect.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,5 +1264,10 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
12641264
*/
12651265
abstract getFieldSqlType(fieldDef: FieldDef): string;
12661266

1267+
/*
1268+
* Gets the string casing behavior for the dialect.
1269+
*/
1270+
abstract getStringCasingBehavior(): { supportsILike: boolean; likeCaseSensitive: boolean };
1271+
12671272
// #endregion
12681273
}

packages/runtime/src/client/crud/dialects/postgresql.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
6363
}
6464

6565
override transformOutput(value: unknown, type: BuiltinType) {
66+
if (value === null || value === undefined) {
67+
return value;
68+
}
6669
return match(type)
6770
.with('DateTime', () => this.transformOutputDate(value))
6871
.with('Bytes', () => this.transformOutputBytes(value))
@@ -96,9 +99,12 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
9699
private transformOutputDate(value: unknown) {
97100
if (typeof value === 'string') {
98101
return new Date(value);
99-
} else if (value instanceof Date) {
100-
value.setTime(value.getTime() - value.getTimezoneOffset() * 60 * 1000);
101-
return value;
102+
} else if (value instanceof Date && this.options.fixPostgresTimezone !== false) {
103+
// SPECIAL NOTES:
104+
// node-pg has a terrible quirk that it returns the date value in local timezone
105+
// as a `Date` object although for `DateTime` field the data in DB is stored in UTC
106+
// see: https://github.com/brianc/node-postgres/issues/429
107+
return new Date(value.getTime() - value.getTimezoneOffset() * 60 * 1000);
102108
} else {
103109
return value;
104110
}
@@ -460,4 +466,9 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
460466

461467
return result;
462468
}
469+
470+
override getStringCasingBehavior() {
471+
// Postgres `LIKE` is case-sensitive, `ILIKE` is case-insensitive
472+
return { supportsILike: true, likeCaseSensitive: true };
473+
}
463474
}

packages/runtime/src/client/crud/dialects/sqlite.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ export class SqliteCrudDialect<Schema extends SchemaDef> extends BaseCrudDialect
5858
}
5959

6060
override transformOutput(value: unknown, type: BuiltinType) {
61-
if (this.schema.typeDefs && type in this.schema.typeDefs) {
61+
if (value === null || value === undefined) {
62+
return value;
63+
} else if (this.schema.typeDefs && type in this.schema.typeDefs) {
6264
// typed JSON field
6365
return this.transformOutputJson(value);
6466
} else {
@@ -114,9 +116,15 @@ export class SqliteCrudDialect<Schema extends SchemaDef> extends BaseCrudDialect
114116
}
115117

116118
private transformOutputJson(value: unknown) {
117-
// better-sqlite3 returns JSON as string
118-
invariant(typeof value === 'string', 'Expected string, got ' + typeof value);
119-
return JSON.parse(value as string);
119+
// better-sqlite3 typically returns JSON as string; be tolerant
120+
if (typeof value === 'string') {
121+
try {
122+
return JSON.parse(value);
123+
} catch (e) {
124+
throw new QueryError('Invalid JSON returned', e);
125+
}
126+
}
127+
return value;
120128
}
121129

122130
override buildRelationSelection(
@@ -400,4 +408,9 @@ export class SqliteCrudDialect<Schema extends SchemaDef> extends BaseCrudDialect
400408
.otherwise(() => 'text')
401409
);
402410
}
411+
412+
override getStringCasingBehavior() {
413+
// SQLite `LIKE` is case-insensitive, and there is no `ILIKE`
414+
return { supportsILike: false, likeCaseSensitive: false };
415+
}
403416
}

packages/runtime/src/client/functions.ts

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,65 @@
11
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';
33
import { match } from 'ts-pattern';
44
import type { ZModelFunction, ZModelFunctionContext } from './options';
55

66
// TODO: migrate default value generation functions to here too
77

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');
199

2010
export const search: ZModelFunction<any> = (_eb: ExpressionBuilder<any, any>, _args: Expression<any>[]) => {
2111
throw new Error(`"search" function is not implemented yet`);
2212
};
2313

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;
2625
if (!field) {
2726
throw new Error('"field" parameter is required');
2827
}
2928
if (!search) {
3029
throw new Error('"search" parameter is required');
3130
}
32-
const searchExpr = eb.fn('CONCAT', [sql`CAST(${search} as text)`, sql.lit('%')]);
33-
return eb(field, 'like', searchExpr);
34-
};
3531

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';
4354
}
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);
4663
};
4764

4865
export const has: ZModelFunction<any> = (eb, args) => {
@@ -122,3 +139,12 @@ function processCasing(casing: Expression<any>, result: string, model: string) {
122139
});
123140
return result;
124141
}
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+
}

packages/runtime/src/client/options.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ export type ClientOptions<Schema extends SchemaDef> = {
6262
* Logging configuration.
6363
*/
6464
log?: KyselyConfig['log'];
65+
66+
/**
67+
* Whether to automatically fix timezone for `DateTime` fields returned by node-pg. Defaults
68+
* to `true`.
69+
*
70+
* Node-pg has a terrible quirk that it interprets the date value as local timezone (as a
71+
* `Date` object) although for `DateTime` field the data in DB is stored in UTC.
72+
* @see https://github.com/brianc/node-postgres/issues/429
73+
*/
74+
fixPostgresTimezone?: boolean;
6575
} & (HasComputedFields<Schema> extends true
6676
? {
6777
/**

0 commit comments

Comments
 (0)