@ganju/utils 0.0.15 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/constants.d.ts +26 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +107 -9
  4. package/dist/customCodeToken.d.ts +11 -0
  5. package/dist/customCodeToken.d.ts.map +1 -1
  6. package/dist/customCodeToken.js +41 -1
  7. package/dist/data.d.ts +13 -0
  8. package/dist/data.d.ts.map +1 -0
  9. package/dist/data.js +103 -0
  10. package/dist/dataApi.d.ts +578 -0
  11. package/dist/dataApi.d.ts.map +1 -0
  12. package/dist/dataApi.js +399 -0
  13. package/dist/dataCompiler.d.ts +68 -0
  14. package/dist/dataCompiler.d.ts.map +1 -0
  15. package/dist/dataCompiler.js +730 -0
  16. package/dist/dataCsv.d.ts +20 -0
  17. package/dist/dataCsv.d.ts.map +1 -0
  18. package/dist/dataCsv.js +102 -0
  19. package/dist/dataDdl.d.ts +29 -0
  20. package/dist/dataDdl.d.ts.map +1 -0
  21. package/dist/dataDdl.js +354 -0
  22. package/dist/dataEngine.d.ts +143 -0
  23. package/dist/dataEngine.d.ts.map +1 -0
  24. package/dist/dataEngine.js +2042 -0
  25. package/dist/dataIdentifiers.d.ts +8 -0
  26. package/dist/dataIdentifiers.d.ts.map +1 -0
  27. package/dist/dataIdentifiers.js +55 -0
  28. package/dist/dataImport.d.ts +79 -0
  29. package/dist/dataImport.d.ts.map +1 -0
  30. package/dist/dataImport.js +453 -0
  31. package/dist/dataQueries.d.ts +73 -0
  32. package/dist/dataQueries.d.ts.map +1 -0
  33. package/dist/dataQueries.js +513 -0
  34. package/dist/dataRpc.d.ts +150 -0
  35. package/dist/dataRpc.d.ts.map +1 -0
  36. package/dist/dataRpc.js +2 -0
  37. package/dist/dataTools.d.ts +40 -0
  38. package/dist/dataTools.d.ts.map +1 -0
  39. package/dist/dataTools.js +280 -0
  40. package/dist/dataTypes.d.ts +159 -0
  41. package/dist/dataTypes.d.ts.map +1 -0
  42. package/dist/dataTypes.js +160 -0
  43. package/dist/index.d.ts +394 -8
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +4 -0
  46. package/dist/oauth.d.ts +14 -0
  47. package/dist/oauth.d.ts.map +1 -1
  48. package/dist/oauth.js +42 -2
  49. package/dist/resourceRerank.d.ts +3 -2
  50. package/dist/resourceRerank.d.ts.map +1 -1
  51. package/dist/resourceRerank.js +5 -2
  52. package/dist/schema.d.ts +376 -4
  53. package/dist/schema.d.ts.map +1 -1
  54. package/dist/schema.js +268 -1
  55. package/dist/sdkConstants.d.ts +1 -0
  56. package/dist/sdkConstants.d.ts.map +1 -1
  57. package/dist/sdkConstants.js +2 -1
  58. package/package.json +17 -2
  59. package/src/constants.ts +121 -9
  60. package/src/customCodeToken.ts +74 -0
  61. package/src/data.ts +108 -0
  62. package/src/dataApi.ts +479 -0
  63. package/src/dataCompiler.ts +952 -0
  64. package/src/dataCsv.ts +99 -0
  65. package/src/dataDdl.ts +419 -0
  66. package/src/dataEngine.ts +2773 -0
  67. package/src/dataIdentifiers.ts +63 -0
  68. package/src/dataImport.ts +557 -0
  69. package/src/dataQueries.ts +730 -0
  70. package/src/dataRpc.ts +132 -0
  71. package/src/dataTools.ts +338 -0
  72. package/src/dataTypes.ts +318 -0
  73. package/src/index.ts +13 -1
  74. package/src/oauth.ts +42 -1
  75. package/src/resourceRerank.ts +5 -2
  76. package/src/schema.ts +315 -1
  77. package/src/sdkConstants.ts +1 -0
package/src/dataCsv.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Reading a CSV file into rows of text, with no parser package behind it:
3
+ * the CLI's import and the owner's data-admin tool read files the same way.
4
+ *
5
+ * RFC 4180 plus the two things spreadsheets actually write: a byte-order mark
6
+ * in front, and `;` between fields where the decimal mark is a comma (Excel in
7
+ * Spanish). The separator is read from the header line, so the same file
8
+ * exported from either locale imports the same way.
9
+ *
10
+ * Every cell comes back as text. Deciding what "28.000" means is the shared
11
+ * import code's job, so the CLI and the dashboard decide it the same way.
12
+ */
13
+
14
+ export interface ParsedCsv {
15
+ headers: string[];
16
+ rows: string[][];
17
+ // The file line each row starts on, counting the header as line 1, since a
18
+ // quoted cell can span lines and blank lines are skipped.
19
+ lines: number[];
20
+ }
21
+
22
+ const SEPARATORS = [',', ';', '\t'] as const;
23
+
24
+ // The separator that appears most in the first line, outside quotes.
25
+ export const detectSeparator = (text: string): string => {
26
+ const counts = new Map<string, number>(SEPARATORS.map(s => [s, 0]));
27
+ let quoted = false;
28
+ for (const char of text) {
29
+ if (char === '"') quoted = !quoted;
30
+ else if (!quoted && (char === '\n' || char === '\r')) break;
31
+ else if (!quoted && counts.has(char))
32
+ counts.set(char, counts.get(char)! + 1);
33
+ }
34
+ let best = ',';
35
+ for (const [separator, count] of counts) {
36
+ if (count > counts.get(best)!) best = separator;
37
+ }
38
+ return best;
39
+ };
40
+
41
+ export const parseCsv = (input: string, separator?: string): ParsedCsv => {
42
+ const text = input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
43
+ const sep = separator ?? detectSeparator(text);
44
+ const records: { cells: string[]; line: number }[] = [];
45
+ let cells: string[] = [];
46
+ let cell = '';
47
+ let quoted = false;
48
+ let line = 1;
49
+ let startLine = 1;
50
+
51
+ const endCell = () => {
52
+ cells.push(cell);
53
+ cell = '';
54
+ };
55
+ const endRecord = () => {
56
+ endCell();
57
+ // A blank line, or one of nothing but separators, is not a row.
58
+ if (cells.some(c => c.trim() !== '')) {
59
+ records.push({ cells, line: startLine });
60
+ }
61
+ cells = [];
62
+ };
63
+
64
+ for (let i = 0; i < text.length; i++) {
65
+ const char = text[i];
66
+ if (quoted) {
67
+ if (char === '"') {
68
+ if (text[i + 1] === '"') {
69
+ cell += '"';
70
+ i++;
71
+ } else quoted = false;
72
+ } else {
73
+ if (char === '\n') line++;
74
+ cell += char;
75
+ }
76
+ continue;
77
+ }
78
+ if (char === '"' && cell === '') {
79
+ quoted = true;
80
+ } else if (char === sep) {
81
+ endCell();
82
+ } else if (char === '\n' || char === '\r') {
83
+ if (char === '\r' && text[i + 1] === '\n') i++;
84
+ endRecord();
85
+ line++;
86
+ startLine = line;
87
+ } else {
88
+ cell += char;
89
+ }
90
+ }
91
+ if (cell !== '' || cells.length) endRecord();
92
+
93
+ const [header, ...body] = records;
94
+ return {
95
+ headers: (header?.cells ?? []).map(h => h.trim()),
96
+ rows: body.map(r => r.cells),
97
+ lines: body.map(r => r.line)
98
+ };
99
+ };
package/src/dataDdl.ts ADDED
@@ -0,0 +1,419 @@
1
+ // The SQL a project's database runs to build and change its tables. Pure
2
+ // string building: every name in here passed identifierIssue or is one of
3
+ // ours, and every owner-supplied value is a literal escaped by sqlLiteral.
4
+ import { quoteIdentifier as q } from './dataIdentifiers';
5
+ import type {
6
+ DataColumn,
7
+ DataColumnType,
8
+ DataDefaultValue,
9
+ DataTable
10
+ } from './dataTypes';
11
+
12
+ export const DATA_NOW_SQL = "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')";
13
+
14
+ export const TEXT_LIKE_TYPES: ReadonlySet<DataColumnType> = new Set([
15
+ 'text',
16
+ 'long_text',
17
+ 'email',
18
+ 'url',
19
+ 'phone',
20
+ 'file'
21
+ ]);
22
+ export const NUMERIC_TYPES: ReadonlySet<DataColumnType> = new Set([
23
+ 'integer',
24
+ 'decimal'
25
+ ]);
26
+ // Columns full-text search can cover.
27
+ export const SEARCHABLE_TYPES: ReadonlySet<DataColumnType> = new Set([
28
+ 'text',
29
+ 'long_text',
30
+ 'email',
31
+ 'url',
32
+ 'phone',
33
+ 'enum'
34
+ ]);
35
+ // Columns a unique index makes sense on.
36
+ export const UNIQUE_TYPES: ReadonlySet<DataColumnType> = new Set([
37
+ 'text',
38
+ 'email',
39
+ 'url',
40
+ 'phone',
41
+ 'integer',
42
+ 'decimal',
43
+ 'date',
44
+ 'datetime',
45
+ 'enum',
46
+ 'relation'
47
+ ]);
48
+
49
+ // Literal for a value the owner typed (a default, an enum value). Strings are
50
+ // single-quoted with quotes doubled; nothing else reaches SQL unescaped.
51
+ export const sqlLiteral = (value: DataDefaultValue): string => {
52
+ if (value === null) return 'NULL';
53
+ if (typeof value === 'boolean') return value ? '1' : '0';
54
+ if (typeof value === 'number') {
55
+ if (!Number.isFinite(value)) throw new Error('not a finite number');
56
+ return String(value);
57
+ }
58
+ return `'${String(value).replace(/'/g, "''")}'`;
59
+ };
60
+
61
+ // Names derived from stable ids, so renaming a table or column never leaves
62
+ // an index or constraint behind under the old name.
63
+ export const indexName = (table: Pick<DataTable, 'id'>, column: DataColumn) =>
64
+ `ix_${table.id}_${column.id}`;
65
+ export const uniqueIndexName = (
66
+ table: Pick<DataTable, 'id'>,
67
+ column: DataColumn
68
+ ) => `ux_${table.id}_${column.id}`;
69
+ export const participantIndexName = (table: Pick<DataTable, 'id'>) =>
70
+ `ix_${table.id}_participant`;
71
+ export const ftsTableName = (table: Pick<DataTable, 'name'>) =>
72
+ `${table.name}__fts`;
73
+ export const triggerPrefix = (table: Pick<DataTable, 'id'>) =>
74
+ `tr_${table.id}_`;
75
+ export const checkConstraintName = (column: Pick<DataColumn, 'id'>) =>
76
+ `chk_${column.id}`;
77
+ export const minConstraintName = (column: Pick<DataColumn, 'id'>) =>
78
+ `min_${column.id}`;
79
+
80
+ // The default as SQLite stores it: booleans as 0/1, JSON as its text.
81
+ export const storedDefault = (
82
+ column: Pick<DataColumn, 'type'>,
83
+ value: DataDefaultValue
84
+ ): DataDefaultValue => {
85
+ if (value === null) return null;
86
+ if (column.type === 'boolean') return value ? 1 : 0;
87
+ if (column.type === 'json') return JSON.stringify(value);
88
+ return value;
89
+ };
90
+
91
+ const typeCheck = (column: DataColumn): { affinity: string; check: string } => {
92
+ const c = q(column.name);
93
+ switch (column.type) {
94
+ case 'integer':
95
+ case 'relation':
96
+ return { affinity: 'INTEGER', check: `typeof(${c}) = 'integer'` };
97
+ case 'decimal':
98
+ return {
99
+ affinity: 'REAL',
100
+ check: `typeof(${c}) IN ('real', 'integer')`
101
+ };
102
+ case 'boolean':
103
+ return { affinity: 'INTEGER', check: `${c} IN (0, 1)` };
104
+ case 'date':
105
+ return {
106
+ affinity: 'TEXT',
107
+ check: `typeof(${c}) = 'text' AND date(${c}) = ${c}`
108
+ };
109
+ case 'datetime':
110
+ return {
111
+ affinity: 'TEXT',
112
+ check: `typeof(${c}) = 'text' AND strftime('%Y-%m-%dT%H:%M:%SZ', ${c}) = ${c}`
113
+ };
114
+ case 'enum':
115
+ return {
116
+ affinity: 'TEXT',
117
+ check: `${c} IN (${(column.values ?? []).map(sqlLiteral).join(', ')})`
118
+ };
119
+ case 'json':
120
+ return { affinity: 'TEXT', check: `json_valid(${c})` };
121
+ default:
122
+ return { affinity: 'TEXT', check: `typeof(${c}) = 'text'` };
123
+ }
124
+ };
125
+
126
+ const ON_DELETE_SQL = {
127
+ restrict: 'RESTRICT',
128
+ cascade: 'CASCADE',
129
+ set_null: 'SET NULL'
130
+ } as const;
131
+
132
+ export const columnDefinitionSql = (column: DataColumn): string => {
133
+ const c = q(column.name);
134
+ const { affinity, check } = typeCheck(column);
135
+ const parts = [
136
+ c,
137
+ affinity,
138
+ `CONSTRAINT ${q(checkConstraintName(column))} CHECK (${c} IS NULL OR (${check}))`
139
+ ];
140
+ if (column.min !== null && NUMERIC_TYPES.has(column.type)) {
141
+ parts.push(
142
+ `CONSTRAINT ${q(minConstraintName(column))} CHECK (${c} IS NULL OR ${c} >= ${sqlLiteral(column.min)})`
143
+ );
144
+ }
145
+ if (column.type === 'relation' && column.table) {
146
+ parts.push(
147
+ `REFERENCES ${q(column.table)}(id) ON DELETE ${ON_DELETE_SQL[column.onDelete ?? 'restrict']}`
148
+ );
149
+ }
150
+ if (column.required) parts.push('NOT NULL');
151
+ if (column.default !== undefined && column.default !== null) {
152
+ parts.push(`DEFAULT ${sqlLiteral(storedDefault(column, column.default))}`);
153
+ }
154
+ return parts.join(' ');
155
+ };
156
+
157
+ // Every table has the same system columns around the owner's. The hidden
158
+ // participant_id is always there, so turning customer scoping on is an index,
159
+ // not a table change.
160
+ export const createTableSql = (
161
+ name: string,
162
+ columns: readonly DataColumn[]
163
+ ): string =>
164
+ [
165
+ `CREATE TABLE ${q(name)} (`,
166
+ [
167
+ 'id INTEGER PRIMARY KEY',
168
+ ...columns.map(columnDefinitionSql),
169
+ `created_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL})`,
170
+ `updated_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL})`,
171
+ 'version INTEGER NOT NULL DEFAULT 1',
172
+ 'created_by TEXT',
173
+ 'updated_by TEXT',
174
+ 'participant_id TEXT'
175
+ ].join(',\n '),
176
+ ')'
177
+ ].join('\n');
178
+
179
+ export interface NamedStatement {
180
+ name: string;
181
+ sql: string;
182
+ }
183
+
184
+ // The indexes a table should have: every relation and file column (SQLite
185
+ // doesn't index the child side of a foreign key), every unique column (as a
186
+ // unique index, so the column can still be dropped with ALTER TABLE) and
187
+ // every filterable one.
188
+ export const desiredIndexes = (table: DataTable): NamedStatement[] => {
189
+ const out: NamedStatement[] = [];
190
+ for (const column of table.columns) {
191
+ if (column.unique) {
192
+ const name = uniqueIndexName(table, column);
193
+ out.push({
194
+ name,
195
+ sql: `CREATE UNIQUE INDEX ${q(name)} ON ${q(table.name)}(${q(column.name)})`
196
+ });
197
+ } else if (
198
+ column.type === 'relation' ||
199
+ column.type === 'file' ||
200
+ column.filterable
201
+ ) {
202
+ const name = indexName(table, column);
203
+ out.push({
204
+ name,
205
+ sql: `CREATE INDEX ${q(name)} ON ${q(table.name)}(${q(column.name)})`
206
+ });
207
+ }
208
+ }
209
+ if (table.customerScoped) {
210
+ const name = participantIndexName(table);
211
+ out.push({
212
+ name,
213
+ sql: `CREATE INDEX ${q(name)} ON ${q(table.name)}(participant_id)`
214
+ });
215
+ }
216
+ return out;
217
+ };
218
+
219
+ // Whether a filter or sort on this column is served by an index.
220
+ export const isIndexedColumn = (column: DataColumn): boolean =>
221
+ column.unique ||
222
+ column.filterable ||
223
+ column.type === 'relation' ||
224
+ column.type === 'file';
225
+
226
+ export const searchableColumns = (table: DataTable): DataColumn[] =>
227
+ table.columns.filter(
228
+ column => column.searchable && SEARCHABLE_TYPES.has(column.type)
229
+ );
230
+
231
+ // The search table: an external-content FTS5 table over the searchable
232
+ // columns, accent-insensitive ("jalapeno" finds "jalapeño"). Null when the
233
+ // table has nothing searchable.
234
+ export const ftsCreateSql = (table: DataTable): string | null => {
235
+ const columns = searchableColumns(table);
236
+ if (columns.length === 0) return null;
237
+ return `CREATE VIRTUAL TABLE ${q(ftsTableName(table))} USING fts5(${columns
238
+ .map(c => q(c.name))
239
+ .join(
240
+ ', '
241
+ )}, content=${sqlLiteral(table.name)}, content_rowid='id', tokenize='unicode61 remove_diacritics 2')`;
242
+ };
243
+
244
+ const ftsTriggers = (table: DataTable): NamedStatement[] => {
245
+ const columns = searchableColumns(table);
246
+ if (columns.length === 0) return [];
247
+ const fts = q(ftsTableName(table));
248
+ const t = q(table.name);
249
+ const list = columns.map(c => q(c.name)).join(', ');
250
+ const newValues = columns.map(c => `new.${q(c.name)}`).join(', ');
251
+ const oldValues = columns.map(c => `old.${q(c.name)}`).join(', ');
252
+ const prefix = triggerPrefix(table);
253
+ return [
254
+ {
255
+ name: `${prefix}fts_ai`,
256
+ sql: `CREATE TRIGGER ${q(`${prefix}fts_ai`)} AFTER INSERT ON ${t} BEGIN INSERT INTO ${fts}(rowid, ${list}) VALUES (new.id, ${newValues}); END`
257
+ },
258
+ {
259
+ name: `${prefix}fts_ad`,
260
+ sql: `CREATE TRIGGER ${q(`${prefix}fts_ad`)} AFTER DELETE ON ${t} BEGIN INSERT INTO ${fts}(${fts}, rowid, ${list}) VALUES ('delete', old.id, ${oldValues}); END`
261
+ },
262
+ {
263
+ // Only when a searchable column changes, so a stock decrement doesn't
264
+ // rewrite the search index.
265
+ name: `${prefix}fts_au`,
266
+ sql: `CREATE TRIGGER ${q(`${prefix}fts_au`)} AFTER UPDATE OF ${list} ON ${t} BEGIN INSERT INTO ${fts}(${fts}, rowid, ${list}) VALUES ('delete', old.id, ${oldValues}); INSERT INTO ${fts}(rowid, ${list}) VALUES (new.id, ${newValues}); END`
267
+ }
268
+ ];
269
+ };
270
+
271
+ // json_object takes at most 127 arguments, so wide tables are built in
272
+ // pieces and merged. json_patch drops keys whose value is null, which is what
273
+ // leaves only the changed columns.
274
+ const changedColumnsJson = (
275
+ columns: readonly DataColumn[],
276
+ value: (column: string) => string
277
+ ): string => {
278
+ let out = "'{}'";
279
+ for (let i = 0; i < columns.length; i += 50) {
280
+ const pairs = columns
281
+ .slice(i, i + 50)
282
+ .map(c => `${sqlLiteral(c.name)}, ${value(q(c.name))}`)
283
+ .join(', ');
284
+ out = `json_patch(${out}, json_object(${pairs}))`;
285
+ }
286
+ return out;
287
+ };
288
+
289
+ // Row history: who changed which columns, old → new. Triggers rather than
290
+ // code, so cascades and SET NULL are recorded too. The actor comes from
291
+ // _ganju_context, which every write sets first.
292
+ const historyTriggers = (table: DataTable): NamedStatement[] => {
293
+ if (!table.history) return [];
294
+ const t = q(table.name);
295
+ const prefix = triggerPrefix(table);
296
+ const columns = table.columns;
297
+ const log = (op: string, rowId: string, changes: string) =>
298
+ `INSERT INTO _ganju_row_log (table_id, row_id, op, actor, at, changes) VALUES (${table.id}, ${rowId}, '${op}', (SELECT actor FROM _ganju_context WHERE id = 1), ${DATA_NOW_SQL}, ${changes})`;
299
+ const changed = columns.length
300
+ ? columns.map(c => `old.${q(c.name)} IS NOT new.${q(c.name)}`).join(' OR ')
301
+ : '0';
302
+ return [
303
+ {
304
+ name: `${prefix}log_ai`,
305
+ sql: `CREATE TRIGGER ${q(`${prefix}log_ai`)} AFTER INSERT ON ${t} BEGIN ${log(
306
+ 'insert',
307
+ 'new.id',
308
+ changedColumnsJson(
309
+ columns,
310
+ c => `CASE WHEN new.${c} IS NOT NULL THEN json_array(NULL, new.${c}) END`
311
+ )
312
+ )}; END`
313
+ },
314
+ {
315
+ name: `${prefix}log_au`,
316
+ sql: `CREATE TRIGGER ${q(`${prefix}log_au`)} AFTER UPDATE ON ${t} WHEN ${changed} BEGIN ${log(
317
+ 'update',
318
+ 'new.id',
319
+ changedColumnsJson(
320
+ columns,
321
+ c =>
322
+ `CASE WHEN old.${c} IS NOT new.${c} THEN json_array(old.${c}, new.${c}) END`
323
+ )
324
+ )}; END`
325
+ },
326
+ {
327
+ name: `${prefix}log_ad`,
328
+ sql: `CREATE TRIGGER ${q(`${prefix}log_ad`)} AFTER DELETE ON ${t} BEGIN ${log(
329
+ 'delete',
330
+ 'old.id',
331
+ changedColumnsJson(
332
+ columns,
333
+ c => `CASE WHEN old.${c} IS NOT NULL THEN json_array(old.${c}, NULL) END`
334
+ )
335
+ )}; END`
336
+ }
337
+ ];
338
+ };
339
+
340
+ export const desiredTriggers = (table: DataTable): NamedStatement[] => [
341
+ ...ftsTriggers(table),
342
+ ...historyTriggers(table)
343
+ ];
344
+
345
+ // Type changes
346
+
347
+ // SQL that turns `src` (the old column's value) into what `to` stores, or
348
+ // NULL when it can't. The rows where it yields NULL from a non-null value
349
+ // are the ones a type change lists and refuses on.
350
+ export const conversionSql = (
351
+ fromType: DataColumnType,
352
+ to: DataColumn,
353
+ src: string
354
+ ): string => {
355
+ if (fromType === to.type) return src;
356
+ switch (to.type) {
357
+ case 'text':
358
+ case 'long_text':
359
+ case 'email':
360
+ case 'url':
361
+ case 'phone':
362
+ case 'file':
363
+ return fromType === 'boolean'
364
+ ? `CASE ${src} WHEN 1 THEN 'true' WHEN 0 THEN 'false' END`
365
+ : `CAST(${src} AS TEXT)`;
366
+ case 'integer':
367
+ case 'relation':
368
+ return `CASE WHEN typeof(${src}) = 'integer' THEN ${src} WHEN typeof(${src}) = 'real' AND ${src} = CAST(${src} AS INTEGER) THEN CAST(${src} AS INTEGER) WHEN typeof(${src}) = 'text' AND trim(${src}) <> '' AND CAST(CAST(trim(${src}) AS INTEGER) AS TEXT) = ltrim(trim(${src}), '+') THEN CAST(trim(${src}) AS INTEGER) END`;
369
+ case 'decimal':
370
+ return `CASE WHEN typeof(${src}) IN ('integer', 'real') THEN ${src} WHEN typeof(${src}) = 'text' AND trim(${src}) <> '' AND trim(${src}) NOT GLOB '*[^0-9.eE+-]*' AND trim(${src}) GLOB '*[0-9]*' THEN CAST(trim(${src}) AS REAL) END`;
371
+ case 'boolean':
372
+ return `CASE WHEN typeof(${src}) IN ('integer', 'real') AND ${src} IN (0, 1) THEN CAST(${src} AS INTEGER) WHEN typeof(${src}) = 'text' AND lower(trim(${src})) IN ('true', '1', 'yes', 'si', 'sí') THEN 1 WHEN typeof(${src}) = 'text' AND lower(trim(${src})) IN ('false', '0', 'no') THEN 0 END`;
373
+ case 'date':
374
+ return `CASE WHEN typeof(${src}) = 'text' THEN date(${src}) END`;
375
+ case 'datetime':
376
+ return `CASE WHEN typeof(${src}) = 'text' THEN strftime('%Y-%m-%dT%H:%M:%SZ', ${src}) END`;
377
+ case 'enum':
378
+ return `CASE lower(trim(CAST(${src} AS TEXT))) ${(to.values ?? [])
379
+ .map(v => `WHEN ${sqlLiteral(v.toLowerCase())} THEN ${sqlLiteral(v)}`)
380
+ .join(' ')} END`;
381
+ case 'json':
382
+ return fromType === 'boolean'
383
+ ? `CASE ${src} WHEN 1 THEN 'true' WHEN 0 THEN 'false' END`
384
+ : `CASE WHEN typeof(${src}) = 'text' AND json_valid(${src}) THEN ${src} ELSE json_quote(${src}) END`;
385
+ }
386
+ };
387
+
388
+ // The condition that marks a row whose value can't be kept after the change:
389
+ // it doesn't convert, or breaks one of the new column's rules.
390
+ export const invalidAfterConversionSql = (
391
+ to: DataColumn,
392
+ src: string,
393
+ converted: string
394
+ ): string => {
395
+ const parts = [`(${src} IS NOT NULL AND (${converted}) IS NULL)`];
396
+ if (to.required) parts.push(`(${converted}) IS NULL`);
397
+ if (to.min !== null && NUMERIC_TYPES.has(to.type)) {
398
+ parts.push(`(${converted}) < ${sqlLiteral(to.min)}`);
399
+ }
400
+ if (to.type === 'enum') {
401
+ parts.push(
402
+ `((${converted}) IS NOT NULL AND (${converted}) NOT IN (${(to.values ?? []).map(sqlLiteral).join(', ')}))`
403
+ );
404
+ }
405
+ if (to.type === 'email') {
406
+ parts.push(`(${converted}) NOT LIKE '%_@_%._%'`);
407
+ }
408
+ if (to.type === 'url') {
409
+ parts.push(
410
+ `NOT ((${converted}) LIKE 'http://%' OR (${converted}) LIKE 'https://%')`
411
+ );
412
+ }
413
+ if (to.type === 'relation' && to.table) {
414
+ parts.push(
415
+ `((${converted}) IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${q(to.table)} WHERE id = (${converted})))`
416
+ );
417
+ }
418
+ return parts.join(' OR ');
419
+ };