turbine-orm 0.40.1 → 0.41.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/README.md +22 -4
- package/dist/cjs/cli/config.js +3 -0
- package/dist/cjs/cli/index.js +179 -0
- package/dist/cjs/cli/prisma-report.js +216 -0
- package/dist/cjs/cli/prisma-resolve.js +335 -0
- package/dist/cjs/cli/prisma-schema.js +484 -0
- package/dist/cjs/client.js +1 -0
- package/dist/cjs/generate.js +279 -22
- package/dist/cjs/index.js +3 -2
- package/dist/cjs/introspect.js +203 -26
- package/dist/cjs/mssql.js +9 -10
- package/dist/cjs/mysql.js +3 -9
- package/dist/cjs/powdb-introspect.js +5 -10
- package/dist/cjs/powql.js +13 -0
- package/dist/cjs/prisma-compat.js +1147 -0
- package/dist/cjs/query/aggregates.js +67 -7
- package/dist/cjs/query/builder.js +388 -17
- package/dist/cjs/query/compound-unique.js +0 -0
- package/dist/cjs/query/relations.js +7 -5
- package/dist/cjs/query/warn-registry.js +98 -0
- package/dist/cjs/query/writes.js +13 -5
- package/dist/cjs/schema.js +47 -0
- package/dist/cjs/sqlite.js +4 -9
- package/dist/cli/config.d.ts +26 -0
- package/dist/cli/config.js +3 -0
- package/dist/cli/index.d.ts +11 -0
- package/dist/cli/index.js +180 -1
- package/dist/cli/prisma-report.d.ts +19 -0
- package/dist/cli/prisma-report.js +211 -0
- package/dist/cli/prisma-resolve.d.ts +87 -0
- package/dist/cli/prisma-resolve.js +330 -0
- package/dist/cli/prisma-schema.d.ts +116 -0
- package/dist/cli/prisma-schema.js +479 -0
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +18 -2
- package/dist/client.js +1 -0
- package/dist/generate.d.ts +80 -1
- package/dist/generate.js +277 -25
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/introspect.d.ts +92 -2
- package/dist/introspect.js +198 -26
- package/dist/mssql.js +10 -11
- package/dist/mysql.js +4 -10
- package/dist/powdb-introspect.js +5 -10
- package/dist/powql.js +13 -0
- package/dist/prisma-compat.d.ts +281 -0
- package/dist/prisma-compat.js +1143 -0
- package/dist/query/aggregates.js +67 -7
- package/dist/query/builder.d.ts +77 -4
- package/dist/query/builder.js +390 -19
- package/dist/query/compound-unique.d.ts +49 -0
- package/dist/query/compound-unique.js +0 -0
- package/dist/query/deferred.d.ts +18 -0
- package/dist/query/relations.js +7 -5
- package/dist/query/types.d.ts +70 -9
- package/dist/query/warn-registry.d.ts +57 -0
- package/dist/query/warn-registry.js +92 -0
- package/dist/query/writes.js +13 -5
- package/dist/schema.d.ts +75 -0
- package/dist/schema.js +46 -0
- package/dist/sqlite.js +5 -10
- package/package.json +6 -1
package/dist/introspect.d.ts
CHANGED
|
@@ -8,13 +8,54 @@
|
|
|
8
8
|
* This is the foundation of `npx turbine generate`.
|
|
9
9
|
*/
|
|
10
10
|
import { type Dialect } from './dialect.js';
|
|
11
|
-
import { type ColumnMetadata, type ReferentialAction, type RelationDef, type SchemaMetadata } from './schema.js';
|
|
11
|
+
import { type ColumnMetadata, type IndexMetadata, type ReferentialAction, type RelationDef, type SchemaMetadata } from './schema.js';
|
|
12
12
|
/**
|
|
13
13
|
* Map a `pg_constraint.confdeltype` / `confupdtype` character to a
|
|
14
14
|
* {@link ReferentialAction}. Postgres encodes: `a` = NO ACTION, `r` = RESTRICT,
|
|
15
15
|
* `c` = CASCADE, `n` = SET NULL, `d` = SET DEFAULT.
|
|
16
16
|
*/
|
|
17
17
|
export declare function pgConfActionToReferential(ch: string): ReferentialAction;
|
|
18
|
+
/**
|
|
19
|
+
* Migration-bookkeeping tables that introspection drops by default: Turbine's
|
|
20
|
+
* own `_turbine_migrations` / `_turbine_metrics` and Prisma's
|
|
21
|
+
* `_prisma_migrations`. These are almost never meant to be surfaced as typed
|
|
22
|
+
* accessors, and a fresh migrate-from-Prisma introspection would otherwise emit
|
|
23
|
+
* a `PrismaMigrations` entity plus stray FK-derived relations on neighbours.
|
|
24
|
+
*
|
|
25
|
+
* A table named here is dropped UNLESS it is explicitly listed in
|
|
26
|
+
* `options.include` (`include` is the escape hatch, no separate flag), and
|
|
27
|
+
* naming a default-excluded table restores its old generated output byte for
|
|
28
|
+
* byte. The list is deliberately tight (exactly these three); leading-
|
|
29
|
+
* underscore tables are legitimate user tables and are never blanket-excluded.
|
|
30
|
+
*/
|
|
31
|
+
export declare const DEFAULT_EXCLUDED_TABLES: readonly ["_turbine_migrations", "_prisma_migrations", "_turbine_metrics"];
|
|
32
|
+
/** The include / exclude filters shared by every introspector's table selection. */
|
|
33
|
+
export interface TableFilterOptions {
|
|
34
|
+
/** Tables to include (empty/undefined = all). Applied first. */
|
|
35
|
+
include?: string[];
|
|
36
|
+
/** Tables the user asked to exclude. Applied after include. */
|
|
37
|
+
exclude?: string[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The single authority for turning a raw list of candidate table names into the
|
|
41
|
+
* introspected set, shared by the Postgres catalog reader and every engine
|
|
42
|
+
* introspector (SQLite / MySQL / MSSQL / PowDB) so all surfaces agree.
|
|
43
|
+
*
|
|
44
|
+
* Order of operations:
|
|
45
|
+
* 1. `include` filter: when non-empty, keep only the named tables.
|
|
46
|
+
* 2. user `exclude`: drop anything the caller listed.
|
|
47
|
+
* 3. {@link DEFAULT_EXCLUDED_TABLES}: drop migration bookkeeping tables,
|
|
48
|
+
* EXCEPT any that the caller explicitly named in `include` (the escape
|
|
49
|
+
* hatch that restores the pre-0.41 output for those tables).
|
|
50
|
+
*/
|
|
51
|
+
export declare function applyTableFilters(names: string[], options?: TableFilterOptions): string[];
|
|
52
|
+
/**
|
|
53
|
+
* The subset of {@link DEFAULT_EXCLUDED_TABLES} that were present in `names` but
|
|
54
|
+
* dropped by {@link applyTableFilters} (i.e. not re-added via `include`). Pure
|
|
55
|
+
* helper so the CLI can report "skipped internal table X" without re-deriving
|
|
56
|
+
* the filtering rule.
|
|
57
|
+
*/
|
|
58
|
+
export declare function defaultExcludedTablesPresent(names: string[], options?: TableFilterOptions): string[];
|
|
18
59
|
export interface IntrospectOptions {
|
|
19
60
|
/** Postgres connection string */
|
|
20
61
|
connectionString: string;
|
|
@@ -31,6 +72,23 @@ export interface IntrospectOptions {
|
|
|
31
72
|
* the generated `findUnique`-family accessor types.
|
|
32
73
|
*/
|
|
33
74
|
includeViews?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Opt OUT of the unique-FK → `hasOne` flip (F2). By default (`false`)
|
|
77
|
+
* introspection emits a to-one (`hasOne`) relation on the parent side when a
|
|
78
|
+
* child's foreign-key column set is EXACTLY covered by a UNIQUE constraint or
|
|
79
|
+
* a non-partial, non-expression UNIQUE index, matching Prisma one-to-one
|
|
80
|
+
* introspection. Set to `true` to keep the pre-0.41 behavior where every such
|
|
81
|
+
* relation was emitted as `hasMany` (a to-many array). See
|
|
82
|
+
* {@link detectUniqueForeignKeySets}.
|
|
83
|
+
*/
|
|
84
|
+
legacyToManyUniques?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Called with any {@link DEFAULT_EXCLUDED_TABLES} that were present in the
|
|
87
|
+
* database but dropped from this run (F12), so the CLI can print a
|
|
88
|
+
* "skipped internal table X (add it to include to keep it)" note. Not invoked
|
|
89
|
+
* when the set is empty. Postgres path only for now.
|
|
90
|
+
*/
|
|
91
|
+
onDefaultTableExclusion?: (tables: string[]) => void;
|
|
34
92
|
/**
|
|
35
93
|
* Dialect whose {@link Dialect.introspector} drives the catalog reads.
|
|
36
94
|
* Defaults to {@link postgresDialect}. Engines plug their own introspector
|
|
@@ -81,6 +139,30 @@ export declare function relationNameFromColumn(column: string): string;
|
|
|
81
139
|
* payload), so the legacy-first naming keeps it instead of renaming.
|
|
82
140
|
*/
|
|
83
141
|
export declare function isUnknownTsType(tsType: string): boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Parse the column list of a PLAIN unique index from its `pg_indexes.indexdef`,
|
|
144
|
+
* returning `null` for anything that does NOT guarantee at-most-one child row:
|
|
145
|
+
*
|
|
146
|
+
* - a PARTIAL index (has a `WHERE` clause): only unique within the predicate;
|
|
147
|
+
* - an EXPRESSION index (`lower(email)`, `(a || b)`): the uniqueness is on the
|
|
148
|
+
* expression, not the raw FK column set.
|
|
149
|
+
*
|
|
150
|
+
* Anchors on the `USING <method> (` clause the same way
|
|
151
|
+
* {@link describeIndexDefMismatch} does, so a partial index's `WHERE (...)`
|
|
152
|
+
* parentheses are never mistaken for the column list. Every column token must be
|
|
153
|
+
* a bare or double-quoted identifier; anything else (a function call, an
|
|
154
|
+
* operator expression) fails the check and yields `null`.
|
|
155
|
+
*/
|
|
156
|
+
export declare function parsePlainUniqueIndexColumns(indexdef: string): string[] | null;
|
|
157
|
+
/**
|
|
158
|
+
* Assemble, per table, every column set that EXACTLY guarantees at-most-one row:
|
|
159
|
+
* the primary key, every UNIQUE constraint, and every PLAIN (non-partial,
|
|
160
|
+
* non-expression) UNIQUE index. Consumed by
|
|
161
|
+
* {@link buildRelationsFromForeignKeys} to flip a child relation whose FK column
|
|
162
|
+
* set matches one of these sets from `hasMany` to `hasOne` (F2, Prisma
|
|
163
|
+
* one-to-one parity).
|
|
164
|
+
*/
|
|
165
|
+
export declare function detectUniqueForeignKeySets(pkByTable: Map<string, string[]>, uniqueByTable: Map<string, string[][]>, indexesByTable: Map<string, IndexMetadata[]>): Map<string, string[][]>;
|
|
84
166
|
/**
|
|
85
167
|
* Build the belongsTo/hasMany relation maps for every table from its foreign
|
|
86
168
|
* keys. Naming rules (LEGACY-FIRST — a relation name that previously worked at
|
|
@@ -111,11 +193,19 @@ export declare function isUnknownTsType(tsType: string): boolean;
|
|
|
111
193
|
* guarantee relations never shadow concrete-typed scalar columns.
|
|
112
194
|
* @param unknownTypedFieldsByTable subset of the column fields whose tsType is
|
|
113
195
|
* `unknown` (json/jsonb) — legacy shadows of these are preserved (rule 2).
|
|
196
|
+
* @param uniqueSetsByTable when provided (F2), the child-table column sets that
|
|
197
|
+
* guarantee at-most-one row (PK + unique constraints + plain unique indexes,
|
|
198
|
+
* from {@link detectUniqueForeignKeySets}). A reverse relation whose FK column
|
|
199
|
+
* set EXACTLY matches one of the child's unique sets is emitted as `hasOne`
|
|
200
|
+
* (to-one) instead of `hasMany`, and named with the SINGULAR of the child
|
|
201
|
+
* table (falling back to the legacy plural name on collision). Omit it (the
|
|
202
|
+
* engine introspectors and `defineSchema` path do) to keep every reverse
|
|
203
|
+
* relation `hasMany`.
|
|
114
204
|
*/
|
|
115
205
|
export declare function buildRelationsFromForeignKeys(foreignKeys: ForeignKeyEntry[], columnFieldsByTable: Map<string, Set<string>>, fkActions?: Map<string, {
|
|
116
206
|
onDelete: ReferentialAction;
|
|
117
207
|
onUpdate: ReferentialAction;
|
|
118
|
-
}>, unknownTypedFieldsByTable?: Map<string, Set<string
|
|
208
|
+
}>, unknownTypedFieldsByTable?: Map<string, Set<string>>, uniqueSetsByTable?: Map<string, string[][]>): Map<string, Record<string, RelationDef>>;
|
|
119
209
|
/**
|
|
120
210
|
* Conservative auto-`manyToMany` detection over pure junction tables, shared
|
|
121
211
|
* by the Postgres introspector, the engine introspectors (SQLite / MySQL /
|
package/dist/introspect.js
CHANGED
|
@@ -176,6 +176,61 @@ const SQL_ENUMS = `
|
|
|
176
176
|
ORDER BY t.typname, e.enumsortorder
|
|
177
177
|
`;
|
|
178
178
|
// ---------------------------------------------------------------------------
|
|
179
|
+
// Default table exclusions (F12)
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
/**
|
|
182
|
+
* Migration-bookkeeping tables that introspection drops by default: Turbine's
|
|
183
|
+
* own `_turbine_migrations` / `_turbine_metrics` and Prisma's
|
|
184
|
+
* `_prisma_migrations`. These are almost never meant to be surfaced as typed
|
|
185
|
+
* accessors, and a fresh migrate-from-Prisma introspection would otherwise emit
|
|
186
|
+
* a `PrismaMigrations` entity plus stray FK-derived relations on neighbours.
|
|
187
|
+
*
|
|
188
|
+
* A table named here is dropped UNLESS it is explicitly listed in
|
|
189
|
+
* `options.include` (`include` is the escape hatch, no separate flag), and
|
|
190
|
+
* naming a default-excluded table restores its old generated output byte for
|
|
191
|
+
* byte. The list is deliberately tight (exactly these three); leading-
|
|
192
|
+
* underscore tables are legitimate user tables and are never blanket-excluded.
|
|
193
|
+
*/
|
|
194
|
+
export const DEFAULT_EXCLUDED_TABLES = ['_turbine_migrations', '_prisma_migrations', '_turbine_metrics'];
|
|
195
|
+
/**
|
|
196
|
+
* The single authority for turning a raw list of candidate table names into the
|
|
197
|
+
* introspected set, shared by the Postgres catalog reader and every engine
|
|
198
|
+
* introspector (SQLite / MySQL / MSSQL / PowDB) so all surfaces agree.
|
|
199
|
+
*
|
|
200
|
+
* Order of operations:
|
|
201
|
+
* 1. `include` filter: when non-empty, keep only the named tables.
|
|
202
|
+
* 2. user `exclude`: drop anything the caller listed.
|
|
203
|
+
* 3. {@link DEFAULT_EXCLUDED_TABLES}: drop migration bookkeeping tables,
|
|
204
|
+
* EXCEPT any that the caller explicitly named in `include` (the escape
|
|
205
|
+
* hatch that restores the pre-0.41 output for those tables).
|
|
206
|
+
*/
|
|
207
|
+
export function applyTableFilters(names, options = {}) {
|
|
208
|
+
let result = names;
|
|
209
|
+
const includeSet = options.include?.length ? new Set(options.include) : null;
|
|
210
|
+
if (includeSet) {
|
|
211
|
+
result = result.filter((t) => includeSet.has(t));
|
|
212
|
+
}
|
|
213
|
+
if (options.exclude?.length) {
|
|
214
|
+
const excludeSet = new Set(options.exclude);
|
|
215
|
+
result = result.filter((t) => !excludeSet.has(t));
|
|
216
|
+
}
|
|
217
|
+
// Default exclusions never override an explicit include.
|
|
218
|
+
const defaults = new Set(DEFAULT_EXCLUDED_TABLES);
|
|
219
|
+
result = result.filter((t) => !defaults.has(t) || (includeSet?.has(t) ?? false));
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The subset of {@link DEFAULT_EXCLUDED_TABLES} that were present in `names` but
|
|
224
|
+
* dropped by {@link applyTableFilters} (i.e. not re-added via `include`). Pure
|
|
225
|
+
* helper so the CLI can report "skipped internal table X" without re-deriving
|
|
226
|
+
* the filtering rule.
|
|
227
|
+
*/
|
|
228
|
+
export function defaultExcludedTablesPresent(names, options = {}) {
|
|
229
|
+
const includeSet = options.include?.length ? new Set(options.include) : null;
|
|
230
|
+
const present = new Set(names);
|
|
231
|
+
return DEFAULT_EXCLUDED_TABLES.filter((t) => present.has(t) && !(includeSet?.has(t) ?? false));
|
|
232
|
+
}
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
179
234
|
// Main introspection function
|
|
180
235
|
// ---------------------------------------------------------------------------
|
|
181
236
|
/**
|
|
@@ -243,16 +298,18 @@ export async function introspectPostgresCatalog(options) {
|
|
|
243
298
|
onUpdate: pgConfActionToReferential(row.confupdtype),
|
|
244
299
|
});
|
|
245
300
|
}
|
|
246
|
-
// Filter tables by include/exclude
|
|
247
|
-
// candidates so
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
301
|
+
// Filter tables by include/exclude + default bookkeeping-table exclusions
|
|
302
|
+
// (F12). Views/matviews join the base tables as candidates so the filters
|
|
303
|
+
// apply uniformly.
|
|
304
|
+
const candidateTables = [
|
|
305
|
+
...tablesResult.rows.map((r) => r.table_name),
|
|
306
|
+
...viewNameSet,
|
|
307
|
+
];
|
|
308
|
+
const tableNames = applyTableFilters(candidateTables, options);
|
|
309
|
+
if (options.onDefaultTableExclusion) {
|
|
310
|
+
const skipped = defaultExcludedTablesPresent(candidateTables, options);
|
|
311
|
+
if (skipped.length > 0)
|
|
312
|
+
options.onDefaultTableExclusion(skipped);
|
|
256
313
|
}
|
|
257
314
|
const tableSet = new Set(tableNames);
|
|
258
315
|
// ----- Group columns by table -----
|
|
@@ -407,7 +464,13 @@ export async function introspectPostgresCatalog(options) {
|
|
|
407
464
|
// main, so only genuine json/jsonb columns qualify as historical shadows.
|
|
408
465
|
unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
|
|
409
466
|
}
|
|
410
|
-
|
|
467
|
+
// F2: unless the caller opts out, detect child FK column sets that a unique
|
|
468
|
+
// constraint / plain unique index exactly covers, so the reverse relation is
|
|
469
|
+
// emitted as a one-to-one (`hasOne`) instead of `hasMany`.
|
|
470
|
+
const uniqueSetsByTable = options.legacyToManyUniques
|
|
471
|
+
? undefined
|
|
472
|
+
: detectUniqueForeignKeySets(pkByTable, uniqueByTable, indexesByTable);
|
|
473
|
+
const relationsByTable = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable, uniqueSetsByTable);
|
|
411
474
|
// ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
|
|
412
475
|
//
|
|
413
476
|
// Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
|
|
@@ -537,6 +600,92 @@ function upperFirst(s) {
|
|
|
537
600
|
export function isUnknownTsType(tsType) {
|
|
538
601
|
return tsType === 'unknown' || tsType === 'unknown | null';
|
|
539
602
|
}
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// Unique-foreign-key detection for one-to-one relations (F2)
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
/** True when two column lists cover the same set (order-insensitive, no dupes). */
|
|
607
|
+
function columnSetsEqual(a, b) {
|
|
608
|
+
if (a.length !== b.length)
|
|
609
|
+
return false;
|
|
610
|
+
const bs = new Set(b);
|
|
611
|
+
return a.every((c) => bs.has(c));
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Parse the column list of a PLAIN unique index from its `pg_indexes.indexdef`,
|
|
615
|
+
* returning `null` for anything that does NOT guarantee at-most-one child row:
|
|
616
|
+
*
|
|
617
|
+
* - a PARTIAL index (has a `WHERE` clause): only unique within the predicate;
|
|
618
|
+
* - an EXPRESSION index (`lower(email)`, `(a || b)`): the uniqueness is on the
|
|
619
|
+
* expression, not the raw FK column set.
|
|
620
|
+
*
|
|
621
|
+
* Anchors on the `USING <method> (` clause the same way
|
|
622
|
+
* {@link describeIndexDefMismatch} does, so a partial index's `WHERE (...)`
|
|
623
|
+
* parentheses are never mistaken for the column list. Every column token must be
|
|
624
|
+
* a bare or double-quoted identifier; anything else (a function call, an
|
|
625
|
+
* operator expression) fails the check and yields `null`.
|
|
626
|
+
*/
|
|
627
|
+
export function parsePlainUniqueIndexColumns(indexdef) {
|
|
628
|
+
// Partial index: uniqueness is scoped to the WHERE predicate.
|
|
629
|
+
if (/\bWHERE\b/i.test(indexdef))
|
|
630
|
+
return null;
|
|
631
|
+
const paren = indexdef.match(/USING\s+\w+\s*\(([^)]*)\)/i);
|
|
632
|
+
if (!paren)
|
|
633
|
+
return null;
|
|
634
|
+
const tokens = paren[1].split(',').map((c) => c
|
|
635
|
+
.trim()
|
|
636
|
+
.replace(/\s+(ASC|DESC|NULLS\s+(FIRST|LAST))\b/gi, '')
|
|
637
|
+
.trim());
|
|
638
|
+
const columns = [];
|
|
639
|
+
for (const token of tokens) {
|
|
640
|
+
if (token.length === 0)
|
|
641
|
+
return null;
|
|
642
|
+
if (/^"(?:[^"]|"")*"$/.test(token)) {
|
|
643
|
+
// Quoted identifier: unquote and unescape doubled quotes.
|
|
644
|
+
columns.push(token.slice(1, -1).replace(/""/g, '"'));
|
|
645
|
+
}
|
|
646
|
+
else if (/^[A-Za-z_][A-Za-z0-9_$]*$/.test(token)) {
|
|
647
|
+
columns.push(token);
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
// Expression column (function call, operator, cast, and the like): never a plain FK.
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return columns.length > 0 ? columns : null;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Assemble, per table, every column set that EXACTLY guarantees at-most-one row:
|
|
658
|
+
* the primary key, every UNIQUE constraint, and every PLAIN (non-partial,
|
|
659
|
+
* non-expression) UNIQUE index. Consumed by
|
|
660
|
+
* {@link buildRelationsFromForeignKeys} to flip a child relation whose FK column
|
|
661
|
+
* set matches one of these sets from `hasMany` to `hasOne` (F2, Prisma
|
|
662
|
+
* one-to-one parity).
|
|
663
|
+
*/
|
|
664
|
+
export function detectUniqueForeignKeySets(pkByTable, uniqueByTable, indexesByTable) {
|
|
665
|
+
const result = new Map();
|
|
666
|
+
const add = (table, cols) => {
|
|
667
|
+
if (cols.length === 0)
|
|
668
|
+
return;
|
|
669
|
+
if (!result.has(table))
|
|
670
|
+
result.set(table, []);
|
|
671
|
+
result.get(table).push(cols);
|
|
672
|
+
};
|
|
673
|
+
for (const [table, pk] of pkByTable)
|
|
674
|
+
add(table, pk);
|
|
675
|
+
for (const [table, sets] of uniqueByTable)
|
|
676
|
+
for (const cols of sets)
|
|
677
|
+
add(table, cols);
|
|
678
|
+
for (const [table, indexes] of indexesByTable) {
|
|
679
|
+
for (const idx of indexes) {
|
|
680
|
+
if (!idx.unique)
|
|
681
|
+
continue;
|
|
682
|
+
const cols = parsePlainUniqueIndexColumns(idx.definition);
|
|
683
|
+
if (cols)
|
|
684
|
+
add(table, cols);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return result;
|
|
688
|
+
}
|
|
540
689
|
/**
|
|
541
690
|
* Resolve a derived relation name against the names already taken on the
|
|
542
691
|
* table (scalar column fields + previously assigned relations). On collision,
|
|
@@ -583,8 +732,16 @@ function resolveRelationNameCollision(candidate, taken, table, source) {
|
|
|
583
732
|
* guarantee relations never shadow concrete-typed scalar columns.
|
|
584
733
|
* @param unknownTypedFieldsByTable subset of the column fields whose tsType is
|
|
585
734
|
* `unknown` (json/jsonb) — legacy shadows of these are preserved (rule 2).
|
|
735
|
+
* @param uniqueSetsByTable when provided (F2), the child-table column sets that
|
|
736
|
+
* guarantee at-most-one row (PK + unique constraints + plain unique indexes,
|
|
737
|
+
* from {@link detectUniqueForeignKeySets}). A reverse relation whose FK column
|
|
738
|
+
* set EXACTLY matches one of the child's unique sets is emitted as `hasOne`
|
|
739
|
+
* (to-one) instead of `hasMany`, and named with the SINGULAR of the child
|
|
740
|
+
* table (falling back to the legacy plural name on collision). Omit it (the
|
|
741
|
+
* engine introspectors and `defineSchema` path do) to keep every reverse
|
|
742
|
+
* relation `hasMany`.
|
|
586
743
|
*/
|
|
587
|
-
export function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable) {
|
|
744
|
+
export function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable, uniqueSetsByTable) {
|
|
588
745
|
// Count FKs per (source, target) pair for disambiguation.
|
|
589
746
|
const fkCounts = new Map();
|
|
590
747
|
for (const fk of foreignKeys) {
|
|
@@ -673,26 +830,41 @@ export function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable,
|
|
|
673
830
|
referenceKey,
|
|
674
831
|
...actionFields,
|
|
675
832
|
};
|
|
676
|
-
// ---
|
|
677
|
-
// e.g. posts.user_id → users.id creates users.posts (hasMany)
|
|
678
|
-
|
|
833
|
+
// --- reverse relation on the target (parent) table ---
|
|
834
|
+
// e.g. posts.user_id → users.id creates users.posts (hasMany), UNLESS the
|
|
835
|
+
// child's FK column set is exactly covered by a unique constraint / plain
|
|
836
|
+
// unique index (F2), then it is a one-to-one, emitted as `hasOne` and
|
|
837
|
+
// named with the SINGULAR of the child table.
|
|
838
|
+
const isUniqueFk = (uniqueSetsByTable?.get(fk.sourceTable) ?? []).some((set) => columnSetsEqual(set, fk.sourceColumns));
|
|
839
|
+
const disambSuffix = needsDisambiguation
|
|
840
|
+
? singleColumn
|
|
841
|
+
? `By${upperFirst(relationNameFromColumn(fk.sourceColumns[0]))}`
|
|
842
|
+
: `By${upperFirst(snakeToCamel(constraintBase))}`
|
|
843
|
+
: '';
|
|
844
|
+
const legacyReverse = needsDisambiguation
|
|
679
845
|
? singleColumn
|
|
680
846
|
? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
|
|
681
847
|
: snakeToCamel(`${fk.sourceTable}_by_${constraintBase}`)
|
|
682
848
|
: snakeToCamel(fk.sourceTable);
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
849
|
+
const modernReverse = needsDisambiguation ? `${snakeToCamel(fk.sourceTable)}${disambSuffix}` : null;
|
|
850
|
+
let reverseName;
|
|
851
|
+
const reverseType = isUniqueFk ? 'hasOne' : 'hasMany';
|
|
852
|
+
if (isUniqueFk) {
|
|
853
|
+
// Prefer the singular child-table name; fall back to the legacy plural
|
|
854
|
+
// (which stays byte-stable for any app that was on the pre-flip shape).
|
|
855
|
+
const singularReverse = `${singularize(snakeToCamel(fk.sourceTable))}${disambSuffix}`;
|
|
856
|
+
reverseName = resolveName(singularReverse, legacyReverse, fk.targetTable, `FK ${fk.constraintName}`);
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
reverseName = resolveName(legacyReverse, modernReverse, fk.targetTable, `FK ${fk.constraintName}`);
|
|
860
|
+
}
|
|
861
|
+
takenFor(fk.targetTable).add(reverseName);
|
|
862
|
+
assignedFor(fk.targetTable).add(reverseName);
|
|
691
863
|
if (!relationsByTable.has(fk.targetTable))
|
|
692
864
|
relationsByTable.set(fk.targetTable, {});
|
|
693
|
-
relationsByTable.get(fk.targetTable)[
|
|
694
|
-
type:
|
|
695
|
-
name:
|
|
865
|
+
relationsByTable.get(fk.targetTable)[reverseName] = {
|
|
866
|
+
type: reverseType,
|
|
867
|
+
name: reverseName,
|
|
696
868
|
from: fk.targetTable,
|
|
697
869
|
to: fk.sourceTable,
|
|
698
870
|
foreignKey,
|
package/dist/mssql.js
CHANGED
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
import { TurbineClient } from './client.js';
|
|
93
93
|
import { postgresDialect, } from './dialect.js';
|
|
94
94
|
import { ConnectionError, RelationError, UnsupportedFeatureError, ValidationError } from './errors.js';
|
|
95
|
-
import { deriveEngineRelations } from './introspect.js';
|
|
95
|
+
import { applyTableFilters, deriveEngineRelations } from './introspect.js';
|
|
96
96
|
import importOptionalPeer from './optional-peer-import.cjs';
|
|
97
97
|
import { camelToSnake, isDateType, normalizeKeyColumns, snakeToCamel, } from './schema.js';
|
|
98
98
|
// ---------------------------------------------------------------------------
|
|
@@ -549,7 +549,12 @@ export const mssqlDialect = {
|
|
|
549
549
|
.map((row) => `(${row.map(() => this.paramPlaceholder(++n)).join(', ')})`)
|
|
550
550
|
.join(', ');
|
|
551
551
|
const out = mssqlOutput(input.returning, 'INSERTED');
|
|
552
|
-
//
|
|
552
|
+
// SQL Server has no single-statement skip-duplicates form (no ON CONFLICT /
|
|
553
|
+
// INSERT IGNORE), so silently dropping the flag would let duplicate rows
|
|
554
|
+
// through against the caller's intent. Refuse loudly instead.
|
|
555
|
+
if (input.skipDuplicates) {
|
|
556
|
+
throw new UnsupportedFeatureError('createMany({ skipDuplicates: true })', 'mssql', 'SQL Server has no ON CONFLICT DO NOTHING equivalent, pre-filter conflicting rows or use a MERGE.');
|
|
557
|
+
}
|
|
553
558
|
return {
|
|
554
559
|
sql: `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES ${placeholders}`,
|
|
555
560
|
params: input.rowValues.flat(),
|
|
@@ -923,15 +928,9 @@ function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, colum
|
|
|
923
928
|
*/
|
|
924
929
|
export async function introspectMssqlWith(exec, schema = 'dbo', options = {}) {
|
|
925
930
|
// ----- Tables -----
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
tableNames = tableNames.filter((t) => inc.has(t));
|
|
930
|
-
}
|
|
931
|
-
if (options.exclude?.length) {
|
|
932
|
-
const exc = new Set(options.exclude);
|
|
933
|
-
tableNames = tableNames.filter((t) => !exc.has(t));
|
|
934
|
-
}
|
|
931
|
+
const candidateTables = (await exec("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @p1 AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME", [schema])).map((r) => String(r.TABLE_NAME));
|
|
932
|
+
// include / exclude + default bookkeeping-table exclusions (F12).
|
|
933
|
+
const tableNames = applyTableFilters(candidateTables, options);
|
|
935
934
|
const tableSet = new Set(tableNames);
|
|
936
935
|
// ----- Identity columns (mark hasDefault) -----
|
|
937
936
|
const identityRows = await exec(`SELECT t.name AS TABLE_NAME, c.name AS COLUMN_NAME
|
package/dist/mysql.js
CHANGED
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
import { TurbineClient } from './client.js';
|
|
66
66
|
import { postgresDialect, } from './dialect.js';
|
|
67
67
|
import { ConnectionError, UnsupportedFeatureError } from './errors.js';
|
|
68
|
-
import { deriveEngineRelations } from './introspect.js';
|
|
68
|
+
import { applyTableFilters, deriveEngineRelations } from './introspect.js';
|
|
69
69
|
import importOptionalPeer from './optional-peer-import.cjs';
|
|
70
70
|
import { isDateType, snakeToCamel, } from './schema.js';
|
|
71
71
|
// ---------------------------------------------------------------------------
|
|
@@ -585,15 +585,9 @@ function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, colum
|
|
|
585
585
|
*/
|
|
586
586
|
export async function introspectMysqlWith(exec, schema, options = {}) {
|
|
587
587
|
// ----- Tables -----
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
tableNames = tableNames.filter((t) => inc.has(t));
|
|
592
|
-
}
|
|
593
|
-
if (options.exclude?.length) {
|
|
594
|
-
const exc = new Set(options.exclude);
|
|
595
|
-
tableNames = tableNames.filter((t) => !exc.has(t));
|
|
596
|
-
}
|
|
588
|
+
const candidateTables = (await exec("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = :p1 AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME", [schema])).map((r) => String(r.TABLE_NAME));
|
|
589
|
+
// include / exclude + default bookkeeping-table exclusions (F12).
|
|
590
|
+
const tableNames = applyTableFilters(candidateTables, options);
|
|
597
591
|
const tableSet = new Set(tableNames);
|
|
598
592
|
// ----- Columns -----
|
|
599
593
|
const columnRows = (await exec(`SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY,
|
package/dist/powdb-introspect.js
CHANGED
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
* funnel to construct a PowDB client instead of a `pg` client for `powdb://`.
|
|
55
55
|
*/
|
|
56
56
|
import { ValidationError } from './errors.js';
|
|
57
|
+
import { applyTableFilters } from './introspect.js';
|
|
57
58
|
import { quotePowqlIdent, requireCapability } from './powdb.js';
|
|
58
59
|
import { snakeToCamel } from './schema.js';
|
|
59
60
|
/** Coerce a wire cell to string (legacy wire cells are strings; native cells may be typed). */
|
|
@@ -107,24 +108,18 @@ export async function introspectPowdbDatabase(exec, options = {}) {
|
|
|
107
108
|
}
|
|
108
109
|
// ----- Types (one row per table, columns `name`, `columns`) -----
|
|
109
110
|
const schemaRows = (await exec('schema')).rows;
|
|
110
|
-
|
|
111
|
+
const candidateTables = schemaRows.map((r) => asString(r.name)).filter((n) => n.length > 0);
|
|
111
112
|
// A mis-shaped `exec` (e.g. the raw client's positional `string[][]` rows
|
|
112
113
|
// passed straight through) yields rows whose `name` cell is `undefined`, so
|
|
113
114
|
// every table filters out and the schema comes back silently empty. Refuse
|
|
114
115
|
// that instead of losing data: real rows must carry a `name`.
|
|
115
|
-
if (schemaRows.length > 0 &&
|
|
116
|
+
if (schemaRows.length > 0 && candidateTables.length === 0) {
|
|
116
117
|
throw new ValidationError(`[turbine] PowDB introspection: the \`schema\` statement returned ${schemaRows.length} row(s) but none carried a ` +
|
|
117
118
|
'`name` cell. The `exec` you supplied likely returns POSITIONAL rows (string[][]) rather than records keyed by ' +
|
|
118
119
|
'column name; zip `columns` with each row (see introspectPowdbDatabase docs).');
|
|
119
120
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
tableNames = tableNames.filter((t) => inc.has(t));
|
|
123
|
-
}
|
|
124
|
-
if (options.exclude?.length) {
|
|
125
|
-
const exc = new Set(options.exclude);
|
|
126
|
-
tableNames = tableNames.filter((t) => !exc.has(t));
|
|
127
|
-
}
|
|
121
|
+
// include / exclude + default bookkeeping-table exclusions (F12).
|
|
122
|
+
const tableNames = applyTableFilters(candidateTables, options);
|
|
128
123
|
const tables = {};
|
|
129
124
|
for (const tableName of tableNames) {
|
|
130
125
|
// `describe` needs the table name in bare-identifier position → quote it so
|
package/dist/powql.js
CHANGED
|
@@ -38,6 +38,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
38
38
|
import { NotFoundError, ReadOnlyError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
|
|
39
39
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
|
|
40
40
|
import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
|
|
41
|
+
import { expandCompoundUniqueWhere } from './query/compound-unique.js';
|
|
41
42
|
import { isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/filters.js';
|
|
42
43
|
import { escapeLike } from './query/utils.js';
|
|
43
44
|
import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
|
|
@@ -1007,6 +1008,13 @@ export class PowqlInterface {
|
|
|
1007
1008
|
.filter((line) => line.length > 0);
|
|
1008
1009
|
}
|
|
1009
1010
|
async findUnique(args) {
|
|
1011
|
+
// Prisma compound-unique selector → column conjunction (engine parity with
|
|
1012
|
+
// the SQL findUnique family; pure metadata, so this is a one-line adoption).
|
|
1013
|
+
if (args.where) {
|
|
1014
|
+
const expanded = expandCompoundUniqueWhere(this.meta, args.where);
|
|
1015
|
+
if (expanded !== args.where)
|
|
1016
|
+
args = { ...args, where: expanded };
|
|
1017
|
+
}
|
|
1010
1018
|
return this.withMiddleware('findUnique', args, async () => {
|
|
1011
1019
|
const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
1012
1020
|
if (!rows.length)
|
|
@@ -1736,6 +1744,11 @@ export class PowqlInterface {
|
|
|
1736
1744
|
}
|
|
1737
1745
|
async createMany(args) {
|
|
1738
1746
|
return this.withMiddleware('createMany', args, async () => {
|
|
1747
|
+
if (args.skipDuplicates) {
|
|
1748
|
+
// PowQL's `insert … returning` has no conflict clause, so there is no
|
|
1749
|
+
// faithful skip-duplicates form; refuse rather than silently insert.
|
|
1750
|
+
throw new UnsupportedFeatureError('createMany({ skipDuplicates: true })', 'powdb', 'PowQL insert has no ON CONFLICT DO NOTHING equivalent, filter duplicates before inserting.');
|
|
1751
|
+
}
|
|
1739
1752
|
const inputs = args.data.map((d) => this.applyPkDefault(d));
|
|
1740
1753
|
if (!inputs.length)
|
|
1741
1754
|
return [];
|