@ganju/utils 0.0.15 → 0.0.16
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.
- package/dist/constants.d.ts +23 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +95 -8
- package/dist/data.d.ts +9 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +78 -0
- package/dist/dataApi.d.ts +511 -0
- package/dist/dataApi.d.ts.map +1 -0
- package/dist/dataApi.js +355 -0
- package/dist/dataCompiler.d.ts +68 -0
- package/dist/dataCompiler.d.ts.map +1 -0
- package/dist/dataCompiler.js +726 -0
- package/dist/dataDdl.d.ts +29 -0
- package/dist/dataDdl.d.ts.map +1 -0
- package/dist/dataDdl.js +354 -0
- package/dist/dataEngine.d.ts +143 -0
- package/dist/dataEngine.d.ts.map +1 -0
- package/dist/dataEngine.js +2022 -0
- package/dist/dataIdentifiers.d.ts +8 -0
- package/dist/dataIdentifiers.d.ts.map +1 -0
- package/dist/dataIdentifiers.js +55 -0
- package/dist/dataImport.d.ts +77 -0
- package/dist/dataImport.d.ts.map +1 -0
- package/dist/dataImport.js +450 -0
- package/dist/dataRpc.d.ts +131 -0
- package/dist/dataRpc.d.ts.map +1 -0
- package/dist/dataRpc.js +2 -0
- package/dist/dataTypes.d.ts +157 -0
- package/dist/dataTypes.d.ts.map +1 -0
- package/dist/dataTypes.js +160 -0
- package/dist/index.d.ts +308 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/oauth.d.ts +14 -0
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +42 -2
- package/dist/resourceRerank.d.ts +3 -2
- package/dist/resourceRerank.d.ts.map +1 -1
- package/dist/resourceRerank.js +5 -2
- package/dist/schema.d.ts +285 -4
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +203 -0
- package/package.json +17 -2
- package/src/constants.ts +108 -8
- package/src/data.ts +74 -0
- package/src/dataApi.ts +428 -0
- package/src/dataCompiler.ts +948 -0
- package/src/dataDdl.ts +419 -0
- package/src/dataEngine.ts +2753 -0
- package/src/dataIdentifiers.ts +63 -0
- package/src/dataImport.ts +554 -0
- package/src/dataRpc.ts +121 -0
- package/src/dataTypes.ts +312 -0
- package/src/index.ts +4 -0
- package/src/oauth.ts +42 -1
- package/src/resourceRerank.ts +5 -2
- package/src/schema.ts +246 -0
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
|
+
};
|