@drzl/analyzer 1.3.0 → 1.4.0

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/index.cjs CHANGED
@@ -52,6 +52,197 @@ var SchemaAnalyzer = class {
52
52
  }
53
53
  return table[Symbol.for(key)];
54
54
  }
55
+ /**
56
+ * Drizzle keys the Columns object by TypeScript property name, but every other piece of
57
+ * metadata (foreign keys, indexes, composite primary keys) reports the *database* column
58
+ * name. We report the TS name, since that is what a generated schema has to spell, so
59
+ * anything coming from that other metadata has to be translated back.
60
+ *
61
+ * Falls through to the input when a name is unknown, which keeps raw SQL expressions and
62
+ * columns belonging to another table readable rather than dropping them.
63
+ */
64
+ dbToTsNames(columnsObj) {
65
+ const map = /* @__PURE__ */ new Map();
66
+ for (const [tsName, col] of Object.entries(columnsObj ?? {})) {
67
+ const dbName = col?.name;
68
+ if (typeof dbName === "string") map.set(dbName, tsName);
69
+ }
70
+ return (dbName) => {
71
+ const raw = typeof dbName === "string" ? dbName : dbName?.name ?? String(dbName);
72
+ return map.get(raw) ?? raw;
73
+ };
74
+ }
75
+ /**
76
+ * Entries from a table's third argument, the extra-config callback.
77
+ *
78
+ * Drizzle invokes this callback with the table's ExtraConfigColumns, NOT with the table.
79
+ * Passing the table throws, and because the whole block used to sit under a bare `catch {}`
80
+ * the throw was swallowed and every index, unique index, composite primary key, check
81
+ * constraint and table-level foreign key silently vanished from the analysis.
82
+ *
83
+ * Both shapes are accepted: modern Drizzle returns an array, older versions an object.
84
+ */
85
+ extraConfigEntries(tbl, issues, tableName) {
86
+ const builder = this.getSymbol(tbl, "drizzle:ExtraConfigBuilder");
87
+ if (typeof builder !== "function") return [];
88
+ const cols = this.getSymbol(tbl, "drizzle:ExtraConfigColumns") ?? tbl;
89
+ try {
90
+ const built = builder(cols);
91
+ if (!built) return [];
92
+ return Array.isArray(built) ? built : Object.values(built);
93
+ } catch (e) {
94
+ issues.push({
95
+ code: "DRZL_ANL_EXTRACONFIG",
96
+ level: "warn",
97
+ message: `Could not evaluate the extra-config callback for table "${tableName}": ${e.message}`,
98
+ hint: "Indexes, composite keys, checks and table-level foreign keys will be missing for this table."
99
+ });
100
+ return [];
101
+ }
102
+ }
103
+ /**
104
+ * Foreign keys declared inline with `.references()`.
105
+ *
106
+ * Drizzle stores these per dialect under `drizzle:PgInlineForeignKeys`,
107
+ * `drizzle:MySqlInlineForeignKeys` and `drizzle:SQLiteInlineForeignKeys`. Matching the
108
+ * suffix rather than listing the three keeps new dialects working without a change here.
109
+ * SingleStore has no entry because it does not support foreign keys at all, which is why
110
+ * `.references()` is not even a function there.
111
+ */
112
+ inlineForeignKeys(tbl) {
113
+ try {
114
+ for (const s of Object.getOwnPropertySymbols(tbl)) {
115
+ if (/InlineForeignKeys$/.test(s.description ?? "")) {
116
+ const v = tbl[s];
117
+ if (Array.isArray(v)) return v;
118
+ }
119
+ }
120
+ } catch {
121
+ }
122
+ return [];
123
+ }
124
+ /**
125
+ * Normalise one foreign key, inline or table-level, into a common shape.
126
+ *
127
+ * `.reference()` yields the resolved columns on both. The referential actions do not live
128
+ * in the same place: a built ForeignKey exposes `onDelete`/`onUpdate` as strings, while an
129
+ * unbuilt ForeignKeyBuilder exposes them as the chainable setter functions and keeps the
130
+ * values in `_onDelete`/`_onUpdate`. Reading the wrong one yields a function where a string
131
+ * is expected, so check the type rather than the property's presence.
132
+ *
133
+ * Postgres reports 'no action' where MySQL and SQLite report nothing, for identical schemas.
134
+ * Since 'no action' *is* the default, it is normalised away so the same schema analyses the
135
+ * same across dialects.
136
+ */
137
+ readForeignKey(fk, toTs) {
138
+ let ref;
139
+ try {
140
+ ref = fk?.reference?.();
141
+ } catch {
142
+ return void 0;
143
+ }
144
+ if (!ref?.foreignTable) return void 0;
145
+ const action = (v, fallback) => {
146
+ const raw = typeof v === "string" ? v : typeof fallback === "string" ? fallback : void 0;
147
+ return raw && raw.toLowerCase() !== "no action" ? raw : void 0;
148
+ };
149
+ const foreignColumnsObj = this.getSymbol(ref.foreignTable, "drizzle:Columns") ?? {};
150
+ const toForeignTs = this.dbToTsNames(foreignColumnsObj);
151
+ return {
152
+ columns: (ref.columns ?? []).map((c) => toTs(c?.name)),
153
+ foreignTable: this.getSymbol(ref.foreignTable, "drizzle:Name") ?? "unknown",
154
+ foreignColumns: (ref.foreignColumns ?? []).map((c) => toForeignTs(c?.name)),
155
+ onDelete: action(fk?.onDelete, fk?._onDelete),
156
+ onUpdate: action(fk?.onUpdate, fk?._onUpdate),
157
+ name: typeof fk?.getName === "function" ? void 0 : fk?.name
158
+ };
159
+ }
160
+ /**
161
+ * Render a Drizzle SQL template back into readable text.
162
+ *
163
+ * A `sql` tagged template is stored as alternating chunks: literal fragments holding a
164
+ * string array, and column references. `String()` on that array yields "[object Object]",
165
+ * so a check constraint's expression has to be assembled rather than stringified.
166
+ * Anything unrecognised becomes `?`, which is honest about the gap without inventing SQL.
167
+ */
168
+ renderSql(value, toTs) {
169
+ const chunks = value?.queryChunks;
170
+ if (!Array.isArray(chunks)) return String(value ?? "");
171
+ return chunks.map((c) => {
172
+ if (Array.isArray(c?.value)) return c.value.join("");
173
+ if (typeof c?.name === "string") return toTs(c.name);
174
+ if (c?.queryChunks) return this.renderSql(c, toTs);
175
+ return "?";
176
+ }).join("").trim();
177
+ }
178
+ /** A value produced by Drizzle's `relations()` helper: a source table plus a callback. */
179
+ isRelationsObject(val) {
180
+ return !!val && typeof val === "object" && typeof val.config === "function" && !!this.getSymbol(val.table, "drizzle:Columns");
181
+ }
182
+ /**
183
+ * Read the relations declared by `relations(table, ({ one, many }) => ...)`.
184
+ *
185
+ * The previous implementation looked for `val.config.relations`. That property does not
186
+ * exist: `config` is a *function*, so the expression was always undefined and the branch
187
+ * never ran. Nothing failed loudly, relations simply came back empty forever.
188
+ *
189
+ * `config` cannot just be read, it has to be invoked with the builder Drizzle would pass.
190
+ * Rather than depend on drizzle-orm to obtain the real builder, which this package
191
+ * deliberately does not do anywhere else, a stand-in supplies the only two functions a
192
+ * user's callback can call. `relations()` wraps that callback and calls `.withFieldName()`
193
+ * on each returned value, so the stand-in results must carry that method or the call throws.
194
+ */
195
+ readRelationsObject(val, exportName, issues) {
196
+ const from = this.getSymbol(val.table, "drizzle:Name") ?? exportName;
197
+ const make = (kind) => (table, cfg) => ({
198
+ kind,
199
+ referencedTable: table,
200
+ cfg,
201
+ withFieldName(n) {
202
+ this.fieldName = n;
203
+ return this;
204
+ }
205
+ });
206
+ try {
207
+ const built = val.config({ one: make("one"), many: make("many") });
208
+ const out = [];
209
+ for (const rel of Object.values(built ?? {})) {
210
+ const to = this.getSymbol(rel?.referencedTable, "drizzle:Name");
211
+ if (to) out.push({ kind: rel.kind, from, to });
212
+ }
213
+ return out;
214
+ } catch (e) {
215
+ issues.push({
216
+ code: "DRZL_ANL_RELATIONS",
217
+ level: "warn",
218
+ message: `Could not read the relations declared in "${exportName}": ${e.message}`,
219
+ hint: "Relations for this table will be missing from the analysis."
220
+ });
221
+ return [];
222
+ }
223
+ }
224
+ /**
225
+ * Infer many-to-many links through a join table.
226
+ *
227
+ * A join table is taken to be one whose every column participates in a foreign key, and
228
+ * which points at exactly two distinct tables. Requiring *all* columns to be foreign keys
229
+ * is deliberate: a table carrying its own data is a real entity, not plumbing, and calling
230
+ * it a join table would invent a relation the author never declared.
231
+ */
232
+ inferManyToMany(tables) {
233
+ const out = [];
234
+ for (const t of tables) {
235
+ const fks = t.foreignKeys ?? [];
236
+ if (fks.length < 2) continue;
237
+ const fkCols = new Set(fks.flatMap((f) => f.columns));
238
+ if (!t.columns.every((c) => fkCols.has(c.name))) continue;
239
+ const targets = [...new Set(fks.map((f) => f.foreignTable))];
240
+ if (targets.length !== 2) continue;
241
+ out.push({ kind: "manyToMany", from: targets[0], to: targets[1], via: t.name });
242
+ out.push({ kind: "manyToMany", from: targets[1], to: targets[0], via: t.name });
243
+ }
244
+ return out;
245
+ }
55
246
  mapColumnType(column) {
56
247
  const ctor = column?.constructor?.name ?? "";
57
248
  switch (ctor) {
@@ -177,12 +368,13 @@ var SchemaAnalyzer = class {
177
368
  return { tsType: "unknown", dbType: "UNKNOWN" };
178
369
  }
179
370
  }
180
- analyzeTable(tsName, tbl) {
371
+ analyzeTable(tsName, tbl, issues = []) {
181
372
  const columnsObj = this.getSymbol(tbl, "drizzle:Columns") ?? {};
182
373
  const columns = [];
183
374
  const unique = [];
184
375
  const indexes = [];
185
376
  const checks = [];
377
+ const foreignKeys = [];
186
378
  const pkCols = [];
187
379
  const uniqueGroups = /* @__PURE__ */ new Map();
188
380
  for (const [colName, col] of Object.entries(columnsObj)) {
@@ -195,13 +387,7 @@ var SchemaAnalyzer = class {
195
387
  const nullable = !col?.notNull && !col?.config?.notNull;
196
388
  const isGenerated = !!(col?.autoIncrement || col?.isGenerated);
197
389
  const hasDefault = col?.default !== void 0 || col?.config?.default !== void 0 || isGenerated;
198
- const ref = col?.references;
199
- const references = ref ? {
200
- table: ref.table ?? "unknown",
201
- column: ref.column ?? "id",
202
- onDelete: ref.onDelete,
203
- onUpdate: ref.onUpdate
204
- } : void 0;
390
+ const references = void 0;
205
391
  const isUnique = !!(col?.isUnique || col?.config?.isUnique);
206
392
  const isPk = !!(col?.primary || col?.config?.primaryKey);
207
393
  if (isPk) pkCols.push(colName);
@@ -226,10 +412,11 @@ var SchemaAnalyzer = class {
226
412
  }
227
413
  const name = this.getSymbol(tbl, "drizzle:Name") || tsName;
228
414
  const schema = this.getSymbol(tbl, "drizzle:Schema");
415
+ const toTs = this.dbToTsNames(columnsObj);
229
416
  try {
230
- const pkDef = tbl[Symbol.for("drizzle:PrimaryKey")];
417
+ const pkDef = this.getSymbol(tbl, "drizzle:PrimaryKey");
231
418
  if (pkDef && Array.isArray(pkDef.columns)) {
232
- const cols = pkDef.columns.map((c) => c?.name ?? String(c)).filter(Boolean);
419
+ const cols = pkDef.columns.map((c) => toTs(c?.name)).filter(Boolean);
233
420
  if (cols.length) {
234
421
  pkCols.splice(0, pkCols.length, ...cols);
235
422
  }
@@ -237,36 +424,50 @@ var SchemaAnalyzer = class {
237
424
  } catch {
238
425
  }
239
426
  try {
240
- const idxDef = tbl[Symbol.for("drizzle:Indexes")];
427
+ const idxDef = this.getSymbol(tbl, "drizzle:Indexes");
241
428
  if (Array.isArray(idxDef)) {
242
429
  for (const i of idxDef) {
243
- const cols = (i?.columns ?? []).map((c) => c?.name ?? String(c)).filter(Boolean);
430
+ const cols = (i?.columns ?? []).map((c) => toTs(c?.name)).filter(Boolean);
244
431
  if (cols.length) indexes.push({ columns: cols, name: i?.name });
245
432
  if (i?.unique && cols.length) unique.push({ columns: cols });
246
433
  }
247
434
  }
248
435
  } catch {
249
436
  }
250
- try {
251
- const builder = tbl[Symbol.for("drizzle:ExtraConfigBuilder")];
252
- if (typeof builder === "function") {
253
- const built = builder(tbl);
254
- const entries = Array.isArray(built) ? built.map((v, i) => [v?.config?.name ?? v?.name ?? `idx_${i}`, v]) : Object.entries(built ?? {});
255
- for (const [key, val] of entries) {
256
- const cfg = val?.config ?? val;
257
- const cols = (cfg?.columns ?? []).map((c) => c?.name ?? String(c)).filter(Boolean);
258
- const uniqueFlag = !!(cfg?.unique || /unique/i.test(val?.constructor?.name ?? "") || /unique/i.test(key));
259
- const idxName = cfg?.name ?? val?.name ?? key;
260
- const expr = cfg?.where || cfg?.expression;
261
- if (expr && !cols.length) {
262
- checks.push({ name: idxName, expression: String(expr) });
263
- } else if (cols.length) {
264
- indexes.push({ columns: cols, name: idxName });
265
- if (uniqueFlag) unique.push({ columns: cols });
266
- }
267
- }
437
+ for (const entry of this.extraConfigEntries(tbl, issues, name)) {
438
+ if (typeof entry?.reference === "function") {
439
+ const fk = this.readForeignKey(entry, toTs);
440
+ if (fk) foreignKeys.push(fk);
441
+ continue;
268
442
  }
269
- } catch {
443
+ if (entry?.value?.queryChunks && entry?.name !== void 0) {
444
+ checks.push({ name: entry.name, expression: this.renderSql(entry.value, toTs) });
445
+ continue;
446
+ }
447
+ const cfg = entry?.config ?? entry ?? {};
448
+ const cols = (cfg.columns ?? []).map((c) => toTs(c?.name)).filter(Boolean);
449
+ if (!cols.length) continue;
450
+ if (cfg.unique === void 0) {
451
+ pkCols.splice(0, pkCols.length, ...cols);
452
+ continue;
453
+ }
454
+ indexes.push({ columns: cols, name: cfg.name });
455
+ if (cfg.unique) unique.push({ columns: cols, name: cfg.name });
456
+ }
457
+ for (const fk of this.inlineForeignKeys(tbl)) {
458
+ const read = this.readForeignKey(fk, toTs);
459
+ if (read) foreignKeys.push(read);
460
+ }
461
+ for (const fk of foreignKeys) {
462
+ if (fk.columns.length !== 1 || fk.foreignColumns.length !== 1) continue;
463
+ const col = columns.find((c) => c.name === fk.columns[0]);
464
+ if (!col) continue;
465
+ col.references = {
466
+ table: fk.foreignTable,
467
+ column: fk.foreignColumns[0],
468
+ onDelete: fk.onDelete,
469
+ onUpdate: fk.onUpdate
470
+ };
270
471
  }
271
472
  return {
272
473
  name,
@@ -280,6 +481,7 @@ var SchemaAnalyzer = class {
280
481
  ],
281
482
  indexes: [...pkCols.length ? [{ columns: pkCols }] : [], ...indexes],
282
483
  checks,
484
+ foreignKeys,
283
485
  meta: {}
284
486
  };
285
487
  }
@@ -315,39 +517,29 @@ var SchemaAnalyzer = class {
315
517
  const tables = [];
316
518
  const relations = [];
317
519
  const enums = [];
520
+ const columnEnums = [];
318
521
  for (const [name, val] of Object.entries(exportsObj)) {
319
522
  try {
320
523
  const cols = this.getSymbol(val, "drizzle:Columns");
321
524
  if (cols && typeof cols === "object") {
322
- const table = this.analyzeTable(name, val);
525
+ const table = this.analyzeTable(name, val, issues);
323
526
  tables.push(table);
324
- if (opts.includeRelations) {
325
- for (const col of table.columns) {
326
- if (col.references) {
327
- relations.push({
328
- kind: "many",
329
- from: table.name,
330
- to: col.references.table
331
- });
332
- }
333
- const enumVals = cols[col.name]?.enumValues;
334
- if (enumVals && enumVals.length) {
335
- const enumName = `${table.name}_${col.name}_enum`;
336
- if (!enums.find((e) => e.name === enumName))
337
- enums.push({ name: enumName, values: enumVals });
338
- }
527
+ for (const col of table.columns) {
528
+ const enumVals = cols[col.name]?.enumValues;
529
+ if (enumVals && enumVals.length) {
530
+ columnEnums.push({ name: `${table.name}_${col.name}_enum`, values: enumVals });
339
531
  }
340
532
  }
341
- } else if (val?.config?.relations) {
342
- const cfg = val.config.relations;
343
- const base = name.replace(/Relations$/, "");
344
- for (const entry of Object.values(cfg)) {
345
- const target = entry?.referencedTable;
346
- const targetName = target ? this.getSymbol(target, "drizzle:Name") : void 0;
347
- if (targetName) {
348
- relations.push({ kind: "many", from: base, to: targetName });
533
+ if (opts.includeRelations) {
534
+ for (const fk of table.foreignKeys ?? []) {
535
+ relations.push({ kind: "one", from: table.name, to: fk.foreignTable });
536
+ relations.push({ kind: "many", from: fk.foreignTable, to: table.name });
349
537
  }
350
538
  }
539
+ } else if (this.isRelationsObject(val)) {
540
+ if (opts.includeRelations) {
541
+ relations.push(...this.readRelationsObject(val, name, issues));
542
+ }
351
543
  } else {
352
544
  const ev = val?.enumValues;
353
545
  if (Array.isArray(ev) && ev.every((x) => typeof x === "string")) {
@@ -369,6 +561,12 @@ var SchemaAnalyzer = class {
369
561
  });
370
562
  }
371
563
  }
564
+ for (const candidate of columnEnums) {
565
+ const key = JSON.stringify(candidate.values);
566
+ if (enums.some((e) => JSON.stringify(e.values) === key)) continue;
567
+ if (enums.some((e) => e.name === candidate.name)) continue;
568
+ enums.push(candidate);
569
+ }
372
570
  let dialect = "unknown";
373
571
  const ctorNames = /* @__PURE__ */ new Set();
374
572
  for (const [_, val] of Object.entries(exportsObj)) {
@@ -392,6 +590,9 @@ var SchemaAnalyzer = class {
392
590
  );
393
591
  if (looksSqlite) dialect = "sqlite";
394
592
  }
593
+ if (opts.includeRelations) {
594
+ relations.push(...this.inferManyToMany(tables));
595
+ }
395
596
  if (opts.includeRelations && opts.includeHeuristicRelations) {
396
597
  const tableNames = new Set(tables.map((t) => t.name));
397
598
  const findTarget = (base) => {
@@ -402,19 +603,27 @@ var SchemaAnalyzer = class {
402
603
  };
403
604
  for (const t of tables) {
404
605
  for (const c of t.columns) {
606
+ if (c.references) continue;
405
607
  if (c.name.endsWith("Id")) {
406
608
  const base = c.name.slice(0, -2);
407
609
  const target = findTarget(base);
408
- if (target) relations.push({ kind: "many", from: t.name, to: target });
610
+ if (target) relations.push({ kind: "one", from: t.name, to: target });
409
611
  }
410
612
  }
411
613
  }
412
614
  }
615
+ const seen = /* @__PURE__ */ new Set();
616
+ const deduped = relations.filter((r) => {
617
+ const key = `${r.kind}|${r.from}|${r.to}|${r.via ?? ""}`;
618
+ if (seen.has(key)) return false;
619
+ seen.add(key);
620
+ return true;
621
+ });
413
622
  return {
414
623
  dialect,
415
624
  tables,
416
625
  enums,
417
- relations,
626
+ relations: deduped,
418
627
  issues
419
628
  };
420
629
  }
package/dist/index.d.cts CHANGED
@@ -44,6 +44,21 @@ interface Check {
44
44
  name?: string;
45
45
  expression?: string;
46
46
  }
47
+ /**
48
+ * A foreign key as declared, which may span several columns. Single-column keys are also
49
+ * mirrored onto `Column.references` for convenience; composite ones exist only here, because
50
+ * they cannot be attributed to any one column.
51
+ *
52
+ * Column names are TypeScript property names, matching `Column.name`.
53
+ */
54
+ interface ForeignKey {
55
+ name?: string;
56
+ columns: string[];
57
+ foreignTable: string;
58
+ foreignColumns: string[];
59
+ onDelete?: string;
60
+ onUpdate?: string;
61
+ }
47
62
  interface Table {
48
63
  name: string;
49
64
  tsName: string;
@@ -53,6 +68,7 @@ interface Table {
53
68
  unique: Key[];
54
69
  indexes: Index[];
55
70
  checks?: Check[];
71
+ foreignKeys?: ForeignKey[];
56
72
  meta?: Record<string, unknown>;
57
73
  }
58
74
  interface Enum {
@@ -76,9 +92,88 @@ declare class SchemaAnalyzer {
76
92
  private readonly schemaPath;
77
93
  constructor(schemaPath: string);
78
94
  private getSymbol;
95
+ /**
96
+ * Drizzle keys the Columns object by TypeScript property name, but every other piece of
97
+ * metadata (foreign keys, indexes, composite primary keys) reports the *database* column
98
+ * name. We report the TS name, since that is what a generated schema has to spell, so
99
+ * anything coming from that other metadata has to be translated back.
100
+ *
101
+ * Falls through to the input when a name is unknown, which keeps raw SQL expressions and
102
+ * columns belonging to another table readable rather than dropping them.
103
+ */
104
+ private dbToTsNames;
105
+ /**
106
+ * Entries from a table's third argument, the extra-config callback.
107
+ *
108
+ * Drizzle invokes this callback with the table's ExtraConfigColumns, NOT with the table.
109
+ * Passing the table throws, and because the whole block used to sit under a bare `catch {}`
110
+ * the throw was swallowed and every index, unique index, composite primary key, check
111
+ * constraint and table-level foreign key silently vanished from the analysis.
112
+ *
113
+ * Both shapes are accepted: modern Drizzle returns an array, older versions an object.
114
+ */
115
+ private extraConfigEntries;
116
+ /**
117
+ * Foreign keys declared inline with `.references()`.
118
+ *
119
+ * Drizzle stores these per dialect under `drizzle:PgInlineForeignKeys`,
120
+ * `drizzle:MySqlInlineForeignKeys` and `drizzle:SQLiteInlineForeignKeys`. Matching the
121
+ * suffix rather than listing the three keeps new dialects working without a change here.
122
+ * SingleStore has no entry because it does not support foreign keys at all, which is why
123
+ * `.references()` is not even a function there.
124
+ */
125
+ private inlineForeignKeys;
126
+ /**
127
+ * Normalise one foreign key, inline or table-level, into a common shape.
128
+ *
129
+ * `.reference()` yields the resolved columns on both. The referential actions do not live
130
+ * in the same place: a built ForeignKey exposes `onDelete`/`onUpdate` as strings, while an
131
+ * unbuilt ForeignKeyBuilder exposes them as the chainable setter functions and keeps the
132
+ * values in `_onDelete`/`_onUpdate`. Reading the wrong one yields a function where a string
133
+ * is expected, so check the type rather than the property's presence.
134
+ *
135
+ * Postgres reports 'no action' where MySQL and SQLite report nothing, for identical schemas.
136
+ * Since 'no action' *is* the default, it is normalised away so the same schema analyses the
137
+ * same across dialects.
138
+ */
139
+ private readForeignKey;
140
+ /**
141
+ * Render a Drizzle SQL template back into readable text.
142
+ *
143
+ * A `sql` tagged template is stored as alternating chunks: literal fragments holding a
144
+ * string array, and column references. `String()` on that array yields "[object Object]",
145
+ * so a check constraint's expression has to be assembled rather than stringified.
146
+ * Anything unrecognised becomes `?`, which is honest about the gap without inventing SQL.
147
+ */
148
+ private renderSql;
149
+ /** A value produced by Drizzle's `relations()` helper: a source table plus a callback. */
150
+ private isRelationsObject;
151
+ /**
152
+ * Read the relations declared by `relations(table, ({ one, many }) => ...)`.
153
+ *
154
+ * The previous implementation looked for `val.config.relations`. That property does not
155
+ * exist: `config` is a *function*, so the expression was always undefined and the branch
156
+ * never ran. Nothing failed loudly, relations simply came back empty forever.
157
+ *
158
+ * `config` cannot just be read, it has to be invoked with the builder Drizzle would pass.
159
+ * Rather than depend on drizzle-orm to obtain the real builder, which this package
160
+ * deliberately does not do anywhere else, a stand-in supplies the only two functions a
161
+ * user's callback can call. `relations()` wraps that callback and calls `.withFieldName()`
162
+ * on each returned value, so the stand-in results must carry that method or the call throws.
163
+ */
164
+ private readRelationsObject;
165
+ /**
166
+ * Infer many-to-many links through a join table.
167
+ *
168
+ * A join table is taken to be one whose every column participates in a foreign key, and
169
+ * which points at exactly two distinct tables. Requiring *all* columns to be foreign keys
170
+ * is deliberate: a table carrying its own data is a real entity, not plumbing, and calling
171
+ * it a join table would invent a relation the author never declared.
172
+ */
173
+ private inferManyToMany;
79
174
  private mapColumnType;
80
175
  private analyzeTable;
81
176
  analyze(opts?: AnalyzeOptions): Promise<Analysis>;
82
177
  }
83
178
 
84
- export { type Analysis, type AnalyzeOptions, type Check, type Column, type ColumnRef, type Dialect, type Enum, type Index, type Issue, type Key, type Relation, SchemaAnalyzer, type Table, SchemaAnalyzer as default };
179
+ export { type Analysis, type AnalyzeOptions, type Check, type Column, type ColumnRef, type Dialect, type Enum, type ForeignKey, type Index, type Issue, type Key, type Relation, SchemaAnalyzer, type Table, SchemaAnalyzer as default };
package/dist/index.d.ts CHANGED
@@ -44,6 +44,21 @@ interface Check {
44
44
  name?: string;
45
45
  expression?: string;
46
46
  }
47
+ /**
48
+ * A foreign key as declared, which may span several columns. Single-column keys are also
49
+ * mirrored onto `Column.references` for convenience; composite ones exist only here, because
50
+ * they cannot be attributed to any one column.
51
+ *
52
+ * Column names are TypeScript property names, matching `Column.name`.
53
+ */
54
+ interface ForeignKey {
55
+ name?: string;
56
+ columns: string[];
57
+ foreignTable: string;
58
+ foreignColumns: string[];
59
+ onDelete?: string;
60
+ onUpdate?: string;
61
+ }
47
62
  interface Table {
48
63
  name: string;
49
64
  tsName: string;
@@ -53,6 +68,7 @@ interface Table {
53
68
  unique: Key[];
54
69
  indexes: Index[];
55
70
  checks?: Check[];
71
+ foreignKeys?: ForeignKey[];
56
72
  meta?: Record<string, unknown>;
57
73
  }
58
74
  interface Enum {
@@ -76,9 +92,88 @@ declare class SchemaAnalyzer {
76
92
  private readonly schemaPath;
77
93
  constructor(schemaPath: string);
78
94
  private getSymbol;
95
+ /**
96
+ * Drizzle keys the Columns object by TypeScript property name, but every other piece of
97
+ * metadata (foreign keys, indexes, composite primary keys) reports the *database* column
98
+ * name. We report the TS name, since that is what a generated schema has to spell, so
99
+ * anything coming from that other metadata has to be translated back.
100
+ *
101
+ * Falls through to the input when a name is unknown, which keeps raw SQL expressions and
102
+ * columns belonging to another table readable rather than dropping them.
103
+ */
104
+ private dbToTsNames;
105
+ /**
106
+ * Entries from a table's third argument, the extra-config callback.
107
+ *
108
+ * Drizzle invokes this callback with the table's ExtraConfigColumns, NOT with the table.
109
+ * Passing the table throws, and because the whole block used to sit under a bare `catch {}`
110
+ * the throw was swallowed and every index, unique index, composite primary key, check
111
+ * constraint and table-level foreign key silently vanished from the analysis.
112
+ *
113
+ * Both shapes are accepted: modern Drizzle returns an array, older versions an object.
114
+ */
115
+ private extraConfigEntries;
116
+ /**
117
+ * Foreign keys declared inline with `.references()`.
118
+ *
119
+ * Drizzle stores these per dialect under `drizzle:PgInlineForeignKeys`,
120
+ * `drizzle:MySqlInlineForeignKeys` and `drizzle:SQLiteInlineForeignKeys`. Matching the
121
+ * suffix rather than listing the three keeps new dialects working without a change here.
122
+ * SingleStore has no entry because it does not support foreign keys at all, which is why
123
+ * `.references()` is not even a function there.
124
+ */
125
+ private inlineForeignKeys;
126
+ /**
127
+ * Normalise one foreign key, inline or table-level, into a common shape.
128
+ *
129
+ * `.reference()` yields the resolved columns on both. The referential actions do not live
130
+ * in the same place: a built ForeignKey exposes `onDelete`/`onUpdate` as strings, while an
131
+ * unbuilt ForeignKeyBuilder exposes them as the chainable setter functions and keeps the
132
+ * values in `_onDelete`/`_onUpdate`. Reading the wrong one yields a function where a string
133
+ * is expected, so check the type rather than the property's presence.
134
+ *
135
+ * Postgres reports 'no action' where MySQL and SQLite report nothing, for identical schemas.
136
+ * Since 'no action' *is* the default, it is normalised away so the same schema analyses the
137
+ * same across dialects.
138
+ */
139
+ private readForeignKey;
140
+ /**
141
+ * Render a Drizzle SQL template back into readable text.
142
+ *
143
+ * A `sql` tagged template is stored as alternating chunks: literal fragments holding a
144
+ * string array, and column references. `String()` on that array yields "[object Object]",
145
+ * so a check constraint's expression has to be assembled rather than stringified.
146
+ * Anything unrecognised becomes `?`, which is honest about the gap without inventing SQL.
147
+ */
148
+ private renderSql;
149
+ /** A value produced by Drizzle's `relations()` helper: a source table plus a callback. */
150
+ private isRelationsObject;
151
+ /**
152
+ * Read the relations declared by `relations(table, ({ one, many }) => ...)`.
153
+ *
154
+ * The previous implementation looked for `val.config.relations`. That property does not
155
+ * exist: `config` is a *function*, so the expression was always undefined and the branch
156
+ * never ran. Nothing failed loudly, relations simply came back empty forever.
157
+ *
158
+ * `config` cannot just be read, it has to be invoked with the builder Drizzle would pass.
159
+ * Rather than depend on drizzle-orm to obtain the real builder, which this package
160
+ * deliberately does not do anywhere else, a stand-in supplies the only two functions a
161
+ * user's callback can call. `relations()` wraps that callback and calls `.withFieldName()`
162
+ * on each returned value, so the stand-in results must carry that method or the call throws.
163
+ */
164
+ private readRelationsObject;
165
+ /**
166
+ * Infer many-to-many links through a join table.
167
+ *
168
+ * A join table is taken to be one whose every column participates in a foreign key, and
169
+ * which points at exactly two distinct tables. Requiring *all* columns to be foreign keys
170
+ * is deliberate: a table carrying its own data is a real entity, not plumbing, and calling
171
+ * it a join table would invent a relation the author never declared.
172
+ */
173
+ private inferManyToMany;
79
174
  private mapColumnType;
80
175
  private analyzeTable;
81
176
  analyze(opts?: AnalyzeOptions): Promise<Analysis>;
82
177
  }
83
178
 
84
- export { type Analysis, type AnalyzeOptions, type Check, type Column, type ColumnRef, type Dialect, type Enum, type Index, type Issue, type Key, type Relation, SchemaAnalyzer, type Table, SchemaAnalyzer as default };
179
+ export { type Analysis, type AnalyzeOptions, type Check, type Column, type ColumnRef, type Dialect, type Enum, type ForeignKey, type Index, type Issue, type Key, type Relation, SchemaAnalyzer, type Table, SchemaAnalyzer as default };
package/dist/index.js CHANGED
@@ -16,6 +16,197 @@ var SchemaAnalyzer = class {
16
16
  }
17
17
  return table[Symbol.for(key)];
18
18
  }
19
+ /**
20
+ * Drizzle keys the Columns object by TypeScript property name, but every other piece of
21
+ * metadata (foreign keys, indexes, composite primary keys) reports the *database* column
22
+ * name. We report the TS name, since that is what a generated schema has to spell, so
23
+ * anything coming from that other metadata has to be translated back.
24
+ *
25
+ * Falls through to the input when a name is unknown, which keeps raw SQL expressions and
26
+ * columns belonging to another table readable rather than dropping them.
27
+ */
28
+ dbToTsNames(columnsObj) {
29
+ const map = /* @__PURE__ */ new Map();
30
+ for (const [tsName, col] of Object.entries(columnsObj ?? {})) {
31
+ const dbName = col?.name;
32
+ if (typeof dbName === "string") map.set(dbName, tsName);
33
+ }
34
+ return (dbName) => {
35
+ const raw = typeof dbName === "string" ? dbName : dbName?.name ?? String(dbName);
36
+ return map.get(raw) ?? raw;
37
+ };
38
+ }
39
+ /**
40
+ * Entries from a table's third argument, the extra-config callback.
41
+ *
42
+ * Drizzle invokes this callback with the table's ExtraConfigColumns, NOT with the table.
43
+ * Passing the table throws, and because the whole block used to sit under a bare `catch {}`
44
+ * the throw was swallowed and every index, unique index, composite primary key, check
45
+ * constraint and table-level foreign key silently vanished from the analysis.
46
+ *
47
+ * Both shapes are accepted: modern Drizzle returns an array, older versions an object.
48
+ */
49
+ extraConfigEntries(tbl, issues, tableName) {
50
+ const builder = this.getSymbol(tbl, "drizzle:ExtraConfigBuilder");
51
+ if (typeof builder !== "function") return [];
52
+ const cols = this.getSymbol(tbl, "drizzle:ExtraConfigColumns") ?? tbl;
53
+ try {
54
+ const built = builder(cols);
55
+ if (!built) return [];
56
+ return Array.isArray(built) ? built : Object.values(built);
57
+ } catch (e) {
58
+ issues.push({
59
+ code: "DRZL_ANL_EXTRACONFIG",
60
+ level: "warn",
61
+ message: `Could not evaluate the extra-config callback for table "${tableName}": ${e.message}`,
62
+ hint: "Indexes, composite keys, checks and table-level foreign keys will be missing for this table."
63
+ });
64
+ return [];
65
+ }
66
+ }
67
+ /**
68
+ * Foreign keys declared inline with `.references()`.
69
+ *
70
+ * Drizzle stores these per dialect under `drizzle:PgInlineForeignKeys`,
71
+ * `drizzle:MySqlInlineForeignKeys` and `drizzle:SQLiteInlineForeignKeys`. Matching the
72
+ * suffix rather than listing the three keeps new dialects working without a change here.
73
+ * SingleStore has no entry because it does not support foreign keys at all, which is why
74
+ * `.references()` is not even a function there.
75
+ */
76
+ inlineForeignKeys(tbl) {
77
+ try {
78
+ for (const s of Object.getOwnPropertySymbols(tbl)) {
79
+ if (/InlineForeignKeys$/.test(s.description ?? "")) {
80
+ const v = tbl[s];
81
+ if (Array.isArray(v)) return v;
82
+ }
83
+ }
84
+ } catch {
85
+ }
86
+ return [];
87
+ }
88
+ /**
89
+ * Normalise one foreign key, inline or table-level, into a common shape.
90
+ *
91
+ * `.reference()` yields the resolved columns on both. The referential actions do not live
92
+ * in the same place: a built ForeignKey exposes `onDelete`/`onUpdate` as strings, while an
93
+ * unbuilt ForeignKeyBuilder exposes them as the chainable setter functions and keeps the
94
+ * values in `_onDelete`/`_onUpdate`. Reading the wrong one yields a function where a string
95
+ * is expected, so check the type rather than the property's presence.
96
+ *
97
+ * Postgres reports 'no action' where MySQL and SQLite report nothing, for identical schemas.
98
+ * Since 'no action' *is* the default, it is normalised away so the same schema analyses the
99
+ * same across dialects.
100
+ */
101
+ readForeignKey(fk, toTs) {
102
+ let ref;
103
+ try {
104
+ ref = fk?.reference?.();
105
+ } catch {
106
+ return void 0;
107
+ }
108
+ if (!ref?.foreignTable) return void 0;
109
+ const action = (v, fallback) => {
110
+ const raw = typeof v === "string" ? v : typeof fallback === "string" ? fallback : void 0;
111
+ return raw && raw.toLowerCase() !== "no action" ? raw : void 0;
112
+ };
113
+ const foreignColumnsObj = this.getSymbol(ref.foreignTable, "drizzle:Columns") ?? {};
114
+ const toForeignTs = this.dbToTsNames(foreignColumnsObj);
115
+ return {
116
+ columns: (ref.columns ?? []).map((c) => toTs(c?.name)),
117
+ foreignTable: this.getSymbol(ref.foreignTable, "drizzle:Name") ?? "unknown",
118
+ foreignColumns: (ref.foreignColumns ?? []).map((c) => toForeignTs(c?.name)),
119
+ onDelete: action(fk?.onDelete, fk?._onDelete),
120
+ onUpdate: action(fk?.onUpdate, fk?._onUpdate),
121
+ name: typeof fk?.getName === "function" ? void 0 : fk?.name
122
+ };
123
+ }
124
+ /**
125
+ * Render a Drizzle SQL template back into readable text.
126
+ *
127
+ * A `sql` tagged template is stored as alternating chunks: literal fragments holding a
128
+ * string array, and column references. `String()` on that array yields "[object Object]",
129
+ * so a check constraint's expression has to be assembled rather than stringified.
130
+ * Anything unrecognised becomes `?`, which is honest about the gap without inventing SQL.
131
+ */
132
+ renderSql(value, toTs) {
133
+ const chunks = value?.queryChunks;
134
+ if (!Array.isArray(chunks)) return String(value ?? "");
135
+ return chunks.map((c) => {
136
+ if (Array.isArray(c?.value)) return c.value.join("");
137
+ if (typeof c?.name === "string") return toTs(c.name);
138
+ if (c?.queryChunks) return this.renderSql(c, toTs);
139
+ return "?";
140
+ }).join("").trim();
141
+ }
142
+ /** A value produced by Drizzle's `relations()` helper: a source table plus a callback. */
143
+ isRelationsObject(val) {
144
+ return !!val && typeof val === "object" && typeof val.config === "function" && !!this.getSymbol(val.table, "drizzle:Columns");
145
+ }
146
+ /**
147
+ * Read the relations declared by `relations(table, ({ one, many }) => ...)`.
148
+ *
149
+ * The previous implementation looked for `val.config.relations`. That property does not
150
+ * exist: `config` is a *function*, so the expression was always undefined and the branch
151
+ * never ran. Nothing failed loudly, relations simply came back empty forever.
152
+ *
153
+ * `config` cannot just be read, it has to be invoked with the builder Drizzle would pass.
154
+ * Rather than depend on drizzle-orm to obtain the real builder, which this package
155
+ * deliberately does not do anywhere else, a stand-in supplies the only two functions a
156
+ * user's callback can call. `relations()` wraps that callback and calls `.withFieldName()`
157
+ * on each returned value, so the stand-in results must carry that method or the call throws.
158
+ */
159
+ readRelationsObject(val, exportName, issues) {
160
+ const from = this.getSymbol(val.table, "drizzle:Name") ?? exportName;
161
+ const make = (kind) => (table, cfg) => ({
162
+ kind,
163
+ referencedTable: table,
164
+ cfg,
165
+ withFieldName(n) {
166
+ this.fieldName = n;
167
+ return this;
168
+ }
169
+ });
170
+ try {
171
+ const built = val.config({ one: make("one"), many: make("many") });
172
+ const out = [];
173
+ for (const rel of Object.values(built ?? {})) {
174
+ const to = this.getSymbol(rel?.referencedTable, "drizzle:Name");
175
+ if (to) out.push({ kind: rel.kind, from, to });
176
+ }
177
+ return out;
178
+ } catch (e) {
179
+ issues.push({
180
+ code: "DRZL_ANL_RELATIONS",
181
+ level: "warn",
182
+ message: `Could not read the relations declared in "${exportName}": ${e.message}`,
183
+ hint: "Relations for this table will be missing from the analysis."
184
+ });
185
+ return [];
186
+ }
187
+ }
188
+ /**
189
+ * Infer many-to-many links through a join table.
190
+ *
191
+ * A join table is taken to be one whose every column participates in a foreign key, and
192
+ * which points at exactly two distinct tables. Requiring *all* columns to be foreign keys
193
+ * is deliberate: a table carrying its own data is a real entity, not plumbing, and calling
194
+ * it a join table would invent a relation the author never declared.
195
+ */
196
+ inferManyToMany(tables) {
197
+ const out = [];
198
+ for (const t of tables) {
199
+ const fks = t.foreignKeys ?? [];
200
+ if (fks.length < 2) continue;
201
+ const fkCols = new Set(fks.flatMap((f) => f.columns));
202
+ if (!t.columns.every((c) => fkCols.has(c.name))) continue;
203
+ const targets = [...new Set(fks.map((f) => f.foreignTable))];
204
+ if (targets.length !== 2) continue;
205
+ out.push({ kind: "manyToMany", from: targets[0], to: targets[1], via: t.name });
206
+ out.push({ kind: "manyToMany", from: targets[1], to: targets[0], via: t.name });
207
+ }
208
+ return out;
209
+ }
19
210
  mapColumnType(column) {
20
211
  const ctor = column?.constructor?.name ?? "";
21
212
  switch (ctor) {
@@ -141,12 +332,13 @@ var SchemaAnalyzer = class {
141
332
  return { tsType: "unknown", dbType: "UNKNOWN" };
142
333
  }
143
334
  }
144
- analyzeTable(tsName, tbl) {
335
+ analyzeTable(tsName, tbl, issues = []) {
145
336
  const columnsObj = this.getSymbol(tbl, "drizzle:Columns") ?? {};
146
337
  const columns = [];
147
338
  const unique = [];
148
339
  const indexes = [];
149
340
  const checks = [];
341
+ const foreignKeys = [];
150
342
  const pkCols = [];
151
343
  const uniqueGroups = /* @__PURE__ */ new Map();
152
344
  for (const [colName, col] of Object.entries(columnsObj)) {
@@ -159,13 +351,7 @@ var SchemaAnalyzer = class {
159
351
  const nullable = !col?.notNull && !col?.config?.notNull;
160
352
  const isGenerated = !!(col?.autoIncrement || col?.isGenerated);
161
353
  const hasDefault = col?.default !== void 0 || col?.config?.default !== void 0 || isGenerated;
162
- const ref = col?.references;
163
- const references = ref ? {
164
- table: ref.table ?? "unknown",
165
- column: ref.column ?? "id",
166
- onDelete: ref.onDelete,
167
- onUpdate: ref.onUpdate
168
- } : void 0;
354
+ const references = void 0;
169
355
  const isUnique = !!(col?.isUnique || col?.config?.isUnique);
170
356
  const isPk = !!(col?.primary || col?.config?.primaryKey);
171
357
  if (isPk) pkCols.push(colName);
@@ -190,10 +376,11 @@ var SchemaAnalyzer = class {
190
376
  }
191
377
  const name = this.getSymbol(tbl, "drizzle:Name") || tsName;
192
378
  const schema = this.getSymbol(tbl, "drizzle:Schema");
379
+ const toTs = this.dbToTsNames(columnsObj);
193
380
  try {
194
- const pkDef = tbl[Symbol.for("drizzle:PrimaryKey")];
381
+ const pkDef = this.getSymbol(tbl, "drizzle:PrimaryKey");
195
382
  if (pkDef && Array.isArray(pkDef.columns)) {
196
- const cols = pkDef.columns.map((c) => c?.name ?? String(c)).filter(Boolean);
383
+ const cols = pkDef.columns.map((c) => toTs(c?.name)).filter(Boolean);
197
384
  if (cols.length) {
198
385
  pkCols.splice(0, pkCols.length, ...cols);
199
386
  }
@@ -201,36 +388,50 @@ var SchemaAnalyzer = class {
201
388
  } catch {
202
389
  }
203
390
  try {
204
- const idxDef = tbl[Symbol.for("drizzle:Indexes")];
391
+ const idxDef = this.getSymbol(tbl, "drizzle:Indexes");
205
392
  if (Array.isArray(idxDef)) {
206
393
  for (const i of idxDef) {
207
- const cols = (i?.columns ?? []).map((c) => c?.name ?? String(c)).filter(Boolean);
394
+ const cols = (i?.columns ?? []).map((c) => toTs(c?.name)).filter(Boolean);
208
395
  if (cols.length) indexes.push({ columns: cols, name: i?.name });
209
396
  if (i?.unique && cols.length) unique.push({ columns: cols });
210
397
  }
211
398
  }
212
399
  } catch {
213
400
  }
214
- try {
215
- const builder = tbl[Symbol.for("drizzle:ExtraConfigBuilder")];
216
- if (typeof builder === "function") {
217
- const built = builder(tbl);
218
- const entries = Array.isArray(built) ? built.map((v, i) => [v?.config?.name ?? v?.name ?? `idx_${i}`, v]) : Object.entries(built ?? {});
219
- for (const [key, val] of entries) {
220
- const cfg = val?.config ?? val;
221
- const cols = (cfg?.columns ?? []).map((c) => c?.name ?? String(c)).filter(Boolean);
222
- const uniqueFlag = !!(cfg?.unique || /unique/i.test(val?.constructor?.name ?? "") || /unique/i.test(key));
223
- const idxName = cfg?.name ?? val?.name ?? key;
224
- const expr = cfg?.where || cfg?.expression;
225
- if (expr && !cols.length) {
226
- checks.push({ name: idxName, expression: String(expr) });
227
- } else if (cols.length) {
228
- indexes.push({ columns: cols, name: idxName });
229
- if (uniqueFlag) unique.push({ columns: cols });
230
- }
231
- }
401
+ for (const entry of this.extraConfigEntries(tbl, issues, name)) {
402
+ if (typeof entry?.reference === "function") {
403
+ const fk = this.readForeignKey(entry, toTs);
404
+ if (fk) foreignKeys.push(fk);
405
+ continue;
232
406
  }
233
- } catch {
407
+ if (entry?.value?.queryChunks && entry?.name !== void 0) {
408
+ checks.push({ name: entry.name, expression: this.renderSql(entry.value, toTs) });
409
+ continue;
410
+ }
411
+ const cfg = entry?.config ?? entry ?? {};
412
+ const cols = (cfg.columns ?? []).map((c) => toTs(c?.name)).filter(Boolean);
413
+ if (!cols.length) continue;
414
+ if (cfg.unique === void 0) {
415
+ pkCols.splice(0, pkCols.length, ...cols);
416
+ continue;
417
+ }
418
+ indexes.push({ columns: cols, name: cfg.name });
419
+ if (cfg.unique) unique.push({ columns: cols, name: cfg.name });
420
+ }
421
+ for (const fk of this.inlineForeignKeys(tbl)) {
422
+ const read = this.readForeignKey(fk, toTs);
423
+ if (read) foreignKeys.push(read);
424
+ }
425
+ for (const fk of foreignKeys) {
426
+ if (fk.columns.length !== 1 || fk.foreignColumns.length !== 1) continue;
427
+ const col = columns.find((c) => c.name === fk.columns[0]);
428
+ if (!col) continue;
429
+ col.references = {
430
+ table: fk.foreignTable,
431
+ column: fk.foreignColumns[0],
432
+ onDelete: fk.onDelete,
433
+ onUpdate: fk.onUpdate
434
+ };
234
435
  }
235
436
  return {
236
437
  name,
@@ -244,6 +445,7 @@ var SchemaAnalyzer = class {
244
445
  ],
245
446
  indexes: [...pkCols.length ? [{ columns: pkCols }] : [], ...indexes],
246
447
  checks,
448
+ foreignKeys,
247
449
  meta: {}
248
450
  };
249
451
  }
@@ -279,39 +481,29 @@ var SchemaAnalyzer = class {
279
481
  const tables = [];
280
482
  const relations = [];
281
483
  const enums = [];
484
+ const columnEnums = [];
282
485
  for (const [name, val] of Object.entries(exportsObj)) {
283
486
  try {
284
487
  const cols = this.getSymbol(val, "drizzle:Columns");
285
488
  if (cols && typeof cols === "object") {
286
- const table = this.analyzeTable(name, val);
489
+ const table = this.analyzeTable(name, val, issues);
287
490
  tables.push(table);
288
- if (opts.includeRelations) {
289
- for (const col of table.columns) {
290
- if (col.references) {
291
- relations.push({
292
- kind: "many",
293
- from: table.name,
294
- to: col.references.table
295
- });
296
- }
297
- const enumVals = cols[col.name]?.enumValues;
298
- if (enumVals && enumVals.length) {
299
- const enumName = `${table.name}_${col.name}_enum`;
300
- if (!enums.find((e) => e.name === enumName))
301
- enums.push({ name: enumName, values: enumVals });
302
- }
491
+ for (const col of table.columns) {
492
+ const enumVals = cols[col.name]?.enumValues;
493
+ if (enumVals && enumVals.length) {
494
+ columnEnums.push({ name: `${table.name}_${col.name}_enum`, values: enumVals });
303
495
  }
304
496
  }
305
- } else if (val?.config?.relations) {
306
- const cfg = val.config.relations;
307
- const base = name.replace(/Relations$/, "");
308
- for (const entry of Object.values(cfg)) {
309
- const target = entry?.referencedTable;
310
- const targetName = target ? this.getSymbol(target, "drizzle:Name") : void 0;
311
- if (targetName) {
312
- relations.push({ kind: "many", from: base, to: targetName });
497
+ if (opts.includeRelations) {
498
+ for (const fk of table.foreignKeys ?? []) {
499
+ relations.push({ kind: "one", from: table.name, to: fk.foreignTable });
500
+ relations.push({ kind: "many", from: fk.foreignTable, to: table.name });
313
501
  }
314
502
  }
503
+ } else if (this.isRelationsObject(val)) {
504
+ if (opts.includeRelations) {
505
+ relations.push(...this.readRelationsObject(val, name, issues));
506
+ }
315
507
  } else {
316
508
  const ev = val?.enumValues;
317
509
  if (Array.isArray(ev) && ev.every((x) => typeof x === "string")) {
@@ -333,6 +525,12 @@ var SchemaAnalyzer = class {
333
525
  });
334
526
  }
335
527
  }
528
+ for (const candidate of columnEnums) {
529
+ const key = JSON.stringify(candidate.values);
530
+ if (enums.some((e) => JSON.stringify(e.values) === key)) continue;
531
+ if (enums.some((e) => e.name === candidate.name)) continue;
532
+ enums.push(candidate);
533
+ }
336
534
  let dialect = "unknown";
337
535
  const ctorNames = /* @__PURE__ */ new Set();
338
536
  for (const [_, val] of Object.entries(exportsObj)) {
@@ -356,6 +554,9 @@ var SchemaAnalyzer = class {
356
554
  );
357
555
  if (looksSqlite) dialect = "sqlite";
358
556
  }
557
+ if (opts.includeRelations) {
558
+ relations.push(...this.inferManyToMany(tables));
559
+ }
359
560
  if (opts.includeRelations && opts.includeHeuristicRelations) {
360
561
  const tableNames = new Set(tables.map((t) => t.name));
361
562
  const findTarget = (base) => {
@@ -366,19 +567,27 @@ var SchemaAnalyzer = class {
366
567
  };
367
568
  for (const t of tables) {
368
569
  for (const c of t.columns) {
570
+ if (c.references) continue;
369
571
  if (c.name.endsWith("Id")) {
370
572
  const base = c.name.slice(0, -2);
371
573
  const target = findTarget(base);
372
- if (target) relations.push({ kind: "many", from: t.name, to: target });
574
+ if (target) relations.push({ kind: "one", from: t.name, to: target });
373
575
  }
374
576
  }
375
577
  }
376
578
  }
579
+ const seen = /* @__PURE__ */ new Set();
580
+ const deduped = relations.filter((r) => {
581
+ const key = `${r.kind}|${r.from}|${r.to}|${r.via ?? ""}`;
582
+ if (seen.has(key)) return false;
583
+ seen.add(key);
584
+ return true;
585
+ });
377
586
  return {
378
587
  dialect,
379
588
  tables,
380
589
  enums,
381
- relations,
590
+ relations: deduped,
382
591
  issues
383
592
  };
384
593
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drzl/analyzer",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -21,6 +21,7 @@
21
21
  "jiti": "^2.5.1"
22
22
  },
23
23
  "devDependencies": {
24
+ "drizzle-orm": "^0.45.2",
24
25
  "tsup": "^8.5.0",
25
26
  "typescript": "^5.9.2"
26
27
  },