metal-orm 1.1.28 → 1.1.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metal-orm",
3
- "version": "1.1.28",
3
+ "version": "1.1.29",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { composeSchemaDialect } from '../schema-dialect-composer.js';
3
3
  import { PostgresSchemaDialect } from './postgres-schema-dialect.js';
4
+ import { SQLiteSchemaDialect } from './sqlite-schema-dialect.js';
4
5
  import type { TableDef } from '../../../schema/table.js';
5
6
  import type { ForeignKeyReference } from '../../../schema/column-types.js';
6
7
  import { createLiteralFormatter } from '../sql-writing.js';
@@ -42,9 +43,21 @@ describe('renderReference deferrable handling', () => {
42
43
  expect(sql).toContain('DEFERRABLE INITIALLY DEFERRED');
43
44
  });
44
45
 
46
+ it('SQLite dialect renders the deferrable clause', () => {
47
+ const dialect = new SQLiteSchemaDialect();
48
+ const sql = dialect.renderReference(deferrableReference, table);
49
+ expect(sql).toContain('DEFERRABLE INITIALLY DEFERRED');
50
+ });
51
+
45
52
  it('Postgres dialect skips the clause when the flag is missing', () => {
46
53
  const dialect = new PostgresSchemaDialect();
47
54
  const sql = dialect.renderReference({ table: 'parent', column: 'id' }, table);
48
55
  expect(sql).not.toContain('DEFERRABLE INITIALLY DEFERRED');
49
56
  });
57
+
58
+ it('SQLite dialect skips the clause when the flag is missing', () => {
59
+ const dialect = new SQLiteSchemaDialect();
60
+ const sql = dialect.renderReference({ table: 'parent', column: 'id' }, table);
61
+ expect(sql).not.toContain('DEFERRABLE INITIALLY DEFERRED');
62
+ });
50
63
  });
@@ -79,6 +79,7 @@ export const createSqliteSchemaDialect = (): SchemaDialect =>
79
79
  void table;
80
80
  return !!(column.autoIncrement && primaryKey.length === 1 && primaryKey[0] === column.name);
81
81
  },
82
+ renderReferenceSuffix: ref => ref.deferrable ? 'DEFERRABLE INITIALLY DEFERRED' : undefined,
82
83
  renderIndex(table, index, services) {
83
84
  const name = index.name || deriveIndexName(table, index);
84
85
  const columns = renderIndexColumns(services, index.columns);
@@ -0,0 +1,321 @@
1
+ export interface SqliteForeignKeyModifier {
2
+ column: string;
3
+ name?: string;
4
+ deferrable: boolean;
5
+ }
6
+
7
+ const isIdentifierChar = (value: string): boolean => /[A-Za-z0-9_]/.test(value);
8
+
9
+ const keywordAt = (text: string, position: number, keyword: string): boolean => {
10
+ if (position < 0 || position + keyword.length > text.length) return false;
11
+ if (position > 0 && isIdentifierChar(text[position - 1])) return false;
12
+ if (position + keyword.length < text.length && isIdentifierChar(text[position + keyword.length])) {
13
+ return false;
14
+ }
15
+ return text.slice(position, position + keyword.length).toUpperCase() === keyword.toUpperCase();
16
+ };
17
+
18
+ const skipWhitespace = (text: string, position: number): number => {
19
+ let pos = position;
20
+ while (pos < text.length && /\s/.test(text[pos])) pos += 1;
21
+ return pos;
22
+ };
23
+
24
+ const readIdentifier = (text: string, position: number): { value: string; next: number } | undefined => {
25
+ let pos = skipWhitespace(text, position);
26
+ if (pos >= text.length) return undefined;
27
+
28
+ const quote = text[pos];
29
+ if (quote === '"' || quote === '`' || quote === '[') {
30
+ const close = quote === '[' ? ']' : quote;
31
+ pos += 1;
32
+ let value = '';
33
+ while (pos < text.length) {
34
+ const current = text[pos++];
35
+ if (current === close) {
36
+ if (pos < text.length && text[pos] === close) {
37
+ value += close;
38
+ pos += 1;
39
+ continue;
40
+ }
41
+ return { value, next: pos };
42
+ }
43
+ value += current;
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ const start = pos;
49
+ while (
50
+ pos < text.length
51
+ && !/\s/.test(text[pos])
52
+ && text[pos] !== '('
53
+ && text[pos] !== ')'
54
+ && text[pos] !== ','
55
+ ) {
56
+ pos += 1;
57
+ }
58
+ if (pos === start) return undefined;
59
+ return { value: text.slice(start, pos), next: pos };
60
+ };
61
+
62
+ const splitTableBody = (createSql: string): string[] => {
63
+ const open = createSql.indexOf('(');
64
+ if (open < 0) return [];
65
+
66
+ let depth = 1;
67
+ let quote = '';
68
+ let bracket = false;
69
+ let lineComment = false;
70
+ let blockComment = false;
71
+ let close = -1;
72
+
73
+ for (let i = open + 1; i < createSql.length; i += 1) {
74
+ const current = createSql[i];
75
+ const next = createSql[i + 1] ?? '';
76
+
77
+ if (lineComment) {
78
+ if (current === '\n' || current === '\r') lineComment = false;
79
+ continue;
80
+ }
81
+ if (blockComment) {
82
+ if (current === '*' && next === '/') {
83
+ blockComment = false;
84
+ i += 1;
85
+ }
86
+ continue;
87
+ }
88
+ if (quote) {
89
+ if (current === quote) {
90
+ if (next === quote) i += 1;
91
+ else quote = '';
92
+ }
93
+ continue;
94
+ }
95
+ if (bracket) {
96
+ if (current === ']') {
97
+ if (next === ']') i += 1;
98
+ else bracket = false;
99
+ }
100
+ continue;
101
+ }
102
+ if (current === '-' && next === '-') {
103
+ lineComment = true;
104
+ i += 1;
105
+ continue;
106
+ }
107
+ if (current === '/' && next === '*') {
108
+ blockComment = true;
109
+ i += 1;
110
+ continue;
111
+ }
112
+ if (current === '\'' || current === '"' || current === '`') {
113
+ quote = current;
114
+ continue;
115
+ }
116
+ if (current === '[') {
117
+ bracket = true;
118
+ continue;
119
+ }
120
+ if (current === '(') depth += 1;
121
+ else if (current === ')') {
122
+ depth -= 1;
123
+ if (depth === 0) {
124
+ close = i;
125
+ break;
126
+ }
127
+ }
128
+ }
129
+
130
+ if (close < 0) return [];
131
+ const body = createSql.slice(open + 1, close);
132
+ const segments: string[] = [];
133
+ let start = 0;
134
+ depth = 0;
135
+ quote = '';
136
+ bracket = false;
137
+ lineComment = false;
138
+ blockComment = false;
139
+
140
+ for (let i = 0; i < body.length; i += 1) {
141
+ const current = body[i];
142
+ const next = body[i + 1] ?? '';
143
+
144
+ if (lineComment) {
145
+ if (current === '\n' || current === '\r') lineComment = false;
146
+ continue;
147
+ }
148
+ if (blockComment) {
149
+ if (current === '*' && next === '/') {
150
+ blockComment = false;
151
+ i += 1;
152
+ }
153
+ continue;
154
+ }
155
+ if (quote) {
156
+ if (current === quote) {
157
+ if (next === quote) i += 1;
158
+ else quote = '';
159
+ }
160
+ continue;
161
+ }
162
+ if (bracket) {
163
+ if (current === ']') {
164
+ if (next === ']') i += 1;
165
+ else bracket = false;
166
+ }
167
+ continue;
168
+ }
169
+ if (current === '-' && next === '-') {
170
+ lineComment = true;
171
+ i += 1;
172
+ continue;
173
+ }
174
+ if (current === '/' && next === '*') {
175
+ blockComment = true;
176
+ i += 1;
177
+ continue;
178
+ }
179
+ if (current === '\'' || current === '"' || current === '`') {
180
+ quote = current;
181
+ continue;
182
+ }
183
+ if (current === '[') {
184
+ bracket = true;
185
+ continue;
186
+ }
187
+ if (current === '(') depth += 1;
188
+ else if (current === ')' && depth > 0) depth -= 1;
189
+ else if (current === ',' && depth === 0) {
190
+ segments.push(body.slice(start, i).trim());
191
+ start = i + 1;
192
+ }
193
+ }
194
+
195
+ segments.push(body.slice(start).trim());
196
+ return segments.filter(Boolean);
197
+ };
198
+
199
+ const findTopLevelKeyword = (text: string, keyword: string, start = 0): number => {
200
+ let depth = 0;
201
+ let quote = '';
202
+ let bracket = false;
203
+
204
+ for (let i = start; i < text.length; i += 1) {
205
+ const current = text[i];
206
+ const next = text[i + 1] ?? '';
207
+ if (quote) {
208
+ if (current === quote) {
209
+ if (next === quote) i += 1;
210
+ else quote = '';
211
+ }
212
+ continue;
213
+ }
214
+ if (bracket) {
215
+ if (current === ']') {
216
+ if (next === ']') i += 1;
217
+ else bracket = false;
218
+ }
219
+ continue;
220
+ }
221
+ if (current === '\'' || current === '"' || current === '`') {
222
+ quote = current;
223
+ continue;
224
+ }
225
+ if (current === '[') {
226
+ bracket = true;
227
+ continue;
228
+ }
229
+ if (current === '(') {
230
+ depth += 1;
231
+ continue;
232
+ }
233
+ if (current === ')') {
234
+ if (depth > 0) depth -= 1;
235
+ continue;
236
+ }
237
+ if (depth === 0 && keywordAt(text, i, keyword)) return i;
238
+ }
239
+ return -1;
240
+ };
241
+
242
+ const isInitiallyDeferred = (segment: string, start: number): boolean => {
243
+ const deferrablePos = findTopLevelKeyword(segment, 'DEFERRABLE', start);
244
+ if (deferrablePos < 0) return false;
245
+
246
+ const before = segment.slice(start, deferrablePos).trimEnd();
247
+ if (/\bNOT$/i.test(before)) return false;
248
+
249
+ return /^DEFERRABLE\s+INITIALLY\s+DEFERRED\b/i.test(segment.slice(deferrablePos));
250
+ };
251
+
252
+ const parseTableForeignKey = (segment: string): SqliteForeignKeyModifier | undefined => {
253
+ let pos = skipWhitespace(segment, 0);
254
+ let name: string | undefined;
255
+
256
+ if (keywordAt(segment, pos, 'CONSTRAINT')) {
257
+ pos += 'CONSTRAINT'.length;
258
+ const parsedName = readIdentifier(segment, pos);
259
+ if (!parsedName) return undefined;
260
+ name = parsedName.value;
261
+ pos = skipWhitespace(segment, parsedName.next);
262
+ }
263
+
264
+ if (!keywordAt(segment, pos, 'FOREIGN')) return undefined;
265
+ pos += 'FOREIGN'.length;
266
+ pos = skipWhitespace(segment, pos);
267
+ if (!keywordAt(segment, pos, 'KEY')) return undefined;
268
+ pos += 'KEY'.length;
269
+ pos = skipWhitespace(segment, pos);
270
+ if (segment[pos] !== '(') return undefined;
271
+ pos += 1;
272
+
273
+ const source = readIdentifier(segment, pos);
274
+ if (!source) return undefined;
275
+ pos = skipWhitespace(segment, source.next);
276
+ if (segment[pos] === ',') return undefined;
277
+
278
+ const referencesPos = findTopLevelKeyword(segment, 'REFERENCES', pos);
279
+ if (referencesPos < 0) return undefined;
280
+
281
+ return {
282
+ column: source.value,
283
+ ...(name ? { name } : {}),
284
+ deferrable: isInitiallyDeferred(segment, referencesPos)
285
+ };
286
+ };
287
+
288
+ const parseInlineForeignKey = (segment: string): SqliteForeignKeyModifier | undefined => {
289
+ const source = readIdentifier(segment, 0);
290
+ if (!source) return undefined;
291
+
292
+ const referencesPos = findTopLevelKeyword(segment, 'REFERENCES', source.next);
293
+ if (referencesPos < 0) return undefined;
294
+
295
+ let name: string | undefined;
296
+ const constraintPos = findTopLevelKeyword(segment, 'CONSTRAINT', source.next);
297
+ if (constraintPos >= 0 && constraintPos < referencesPos) {
298
+ const parsedName = readIdentifier(segment, constraintPos + 'CONSTRAINT'.length);
299
+ if (parsedName) name = parsedName.value;
300
+ }
301
+
302
+ return {
303
+ column: source.value,
304
+ ...(name ? { name } : {}),
305
+ deferrable: isInitiallyDeferred(segment, referencesPos)
306
+ };
307
+ };
308
+
309
+ export const parseSqliteForeignKeyModifiers = (createSql: string): SqliteForeignKeyModifier[] => {
310
+ const result: SqliteForeignKeyModifier[] = [];
311
+ for (const segment of splitTableBody(createSql)) {
312
+ const tableConstraint = parseTableForeignKey(segment);
313
+ if (tableConstraint) {
314
+ result.push(tableConstraint);
315
+ continue;
316
+ }
317
+ const inlineConstraint = parseInlineForeignKey(segment);
318
+ if (inlineConstraint) result.push(inlineConstraint);
319
+ }
320
+ return result;
321
+ };
@@ -3,6 +3,7 @@ import { shouldIncludeTable, shouldIncludeView, queryRows } from './utils.js';
3
3
  import { DatabaseSchema, DatabaseTable, DatabaseIndex, DatabaseColumn, DatabaseView } from '../schema-types.js';
4
4
  import type { IntrospectContext } from './context.js';
5
5
  import { runSelectNode } from './run-select.js';
6
+ import { parseSqliteForeignKeyModifiers } from './sqlite-foreign-key-ddl.js';
6
7
  import type { SelectQueryNode, TableNode } from '../../ast/query.js';
7
8
  import type { ColumnNode } from '../../ast/expression-nodes.js';
8
9
  import { eq, notLike, and, valueToOperand } from '../../ast/expression-builders.js';
@@ -11,6 +12,7 @@ import type { ReferentialAction } from '../../../schema/column-types.js';
11
12
 
12
13
  type SqliteTableRow = {
13
14
  name: string;
15
+ sql: string | null;
14
16
  };
15
17
 
16
18
  type SqliteTableInfoRow = {
@@ -73,7 +75,7 @@ const buildPragmaQuery = (
73
75
  columnAliases: string[]
74
76
  ): SelectQueryNode => ({
75
77
  type: 'SelectQuery',
76
- from: fnTable(name, [valueToOperand(table)], alias, { columnAliases }),
78
+ from: fnTable(name, [valueToOperand(table)], alias),
77
79
  columns: columnAliases.map(column => columnNode(alias, column)),
78
80
  joins: []
79
81
  });
@@ -151,7 +153,10 @@ export const sqliteIntrospector: SchemaIntrospector = {
151
153
  const tablesQuery: SelectQueryNode = {
152
154
  type: 'SelectQuery',
153
155
  from: { type: 'Table', name: 'sqlite_master' } as TableNode,
154
- columns: [columnNode(alias, 'name')],
156
+ columns: [
157
+ columnNode(alias, 'name'),
158
+ columnNode(alias, 'sql')
159
+ ],
155
160
  joins: [],
156
161
  where: and(
157
162
  eq(columnNode(alias, 'type'), 'table'),
@@ -230,6 +235,15 @@ export const sqliteIntrospector: SchemaIntrospector = {
230
235
  }
231
236
  });
232
237
 
238
+ if (row.sql) {
239
+ for (const modifier of parseSqliteForeignKeyModifiers(row.sql)) {
240
+ const reference = tableEntry.columns.find(column => column.name === modifier.column)?.references;
241
+ if (!reference) continue;
242
+ if (modifier.name) reference.name = modifier.name;
243
+ reference.deferrable = modifier.deferrable;
244
+ }
245
+ }
246
+
233
247
  for (const idx of indexList) {
234
248
  if (!idx.name) continue;
235
249
  const indexColumns = await runPragma<SqliteIndexInfoRow>(
@@ -1,5 +1,5 @@
1
1
  import type { TableDef } from '../../schema/table.js';
2
- import type { ColumnDef } from '../../schema/column-types.js';
2
+ import type { ColumnDef, ForeignKeyReference } from '../../schema/column-types.js';
3
3
  import type { DbExecutor } from '../execution/db-executor.js';
4
4
  import type { SchemaDialect } from './schema-dialect.js';
5
5
  import { deriveIndexName } from './naming-strategy.js';
@@ -54,6 +54,22 @@ const normalizeDefault = (value: unknown): string | undefined => {
54
54
  return String(value).trim();
55
55
  };
56
56
 
57
+ const normalizeReferenceAction = (value: string | undefined): string =>
58
+ (value || 'NO ACTION').toUpperCase().replace(/\s+/g, ' ').trim();
59
+
60
+ const sameReference = (
61
+ expected: ForeignKeyReference | undefined,
62
+ actual: ForeignKeyReference | undefined
63
+ ): boolean => {
64
+ if (!expected || !actual) return expected === actual;
65
+ return expected.table === actual.table
66
+ && expected.column === actual.column
67
+ && expected.name === actual.name
68
+ && normalizeReferenceAction(expected.onDelete) === normalizeReferenceAction(actual.onDelete)
69
+ && normalizeReferenceAction(expected.onUpdate) === normalizeReferenceAction(actual.onUpdate)
70
+ && !!expected.deferrable === !!actual.deferrable;
71
+ };
72
+
57
73
  const diffColumn = (
58
74
  expected: ColumnDef,
59
75
  actual: DatabaseColumn,
@@ -69,7 +85,8 @@ const diffColumn = (
69
85
  typeChanged: expectedType !== actualType,
70
86
  nullabilityChanged: !!expected.notNull !== !!actual.notNull,
71
87
  defaultChanged: expectedDefault !== actualDefault,
72
- autoIncrementChanged: !!expected.autoIncrement !== !!actual.autoIncrement
88
+ autoIncrementChanged: !!expected.autoIncrement !== !!actual.autoIncrement,
89
+ referenceChanged: !sameReference(expected.references, actual.references)
73
90
  };
74
91
  };
75
92
 
@@ -121,6 +138,13 @@ export const diffSchema = (
121
138
  const expectedColumn = table.columns[columnName];
122
139
  const actualColumn = actualColumns.get(columnName)!;
123
140
  const columnDiff = diffColumn(expectedColumn, actualColumn, dialect);
141
+
142
+ if (columnDiff.referenceChanged) {
143
+ plan.warnings.push(
144
+ `Foreign key definition on ${key}.${columnName} differs from the expected schema; manual constraint migration is required.`
145
+ );
146
+ }
147
+
124
148
  const shouldAlter =
125
149
  columnDiff.typeChanged
126
150
  || columnDiff.nullabilityChanged
@@ -50,7 +50,12 @@ export const renderColumnDefinition = (
50
50
  parts.push(`CHECK (${col.check})`);
51
51
  }
52
52
  if (col.references) {
53
- parts.push(dialect.renderReference(col.references, table));
53
+ const referenceSql = dialect.renderReference(col.references, table);
54
+ parts.push(
55
+ col.references.name
56
+ ? `CONSTRAINT ${dialect.quoteIdentifier(col.references.name)} ${referenceSql}`
57
+ : referenceSql
58
+ );
54
59
  }
55
60
 
56
61
  return { sql: parts.join(' '), inlinePrimary: !!(options.includePrimary && col.primary) };
@@ -7,6 +7,7 @@ export interface ColumnDiff {
7
7
  nullabilityChanged?: boolean;
8
8
  defaultChanged?: boolean;
9
9
  autoIncrementChanged?: boolean;
10
+ referenceChanged?: boolean;
10
11
  }
11
12
 
12
13
  /** Represents a column in the database schema. */