@clivly/core 0.9.7 → 0.10.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 +17 -10
- package/dist/builder.cjs +599 -0
- package/dist/builder.d.cts +336 -0
- package/dist/builder.d.mts +336 -0
- package/dist/builder.mjs +589 -0
- package/dist/drizzle.d.cts +1 -1
- package/dist/drizzle.d.mts +1 -1
- package/dist/{entity-config-25v_udeY.cjs → entity-config-C5jBmG2C.cjs} +83 -68
- package/dist/{entity-config-buOIVtMM.d.cts → entity-config-DCa785OE.d.cts} +27 -1
- package/dist/{entity-config-buOIVtMM.d.mts → entity-config-DCa785OE.d.mts} +27 -1
- package/dist/{entity-config-DwGXbUo_.mjs → entity-config-DpHPMFIS.mjs} +71 -38
- package/dist/entity-config.cjs +1 -1
- package/dist/entity-config.d.cts +1 -1
- package/dist/entity-config.d.mts +1 -1
- package/dist/entity-config.mjs +1 -1
- package/dist/entity-heuristics.cjs +34 -1
- package/dist/entity-heuristics.d.cts +8 -2
- package/dist/entity-heuristics.d.mts +8 -2
- package/dist/entity-heuristics.mjs +34 -2
- package/dist/index.cjs +136 -199
- package/dist/index.d.cts +135 -89
- package/dist/index.d.mts +135 -89
- package/dist/index.mjs +124 -188
- package/dist/mapping-form.d.cts +1 -1
- package/dist/mapping-form.d.mts +1 -1
- package/dist/mappings-config.cjs +2 -16
- package/dist/mappings-config.d.cts +1 -1
- package/dist/mappings-config.d.mts +1 -1
- package/dist/mappings-config.mjs +1 -15
- package/dist/{projection-OJYCYmS_.cjs → projection-DFgCoyZl.cjs} +0 -6
- package/dist/{projection-BXw_iiw5.mjs → projection-SCNrLJMV.mjs} +1 -1
- package/dist/schema-meta-Ac7938mT.mjs +46 -0
- package/dist/schema-meta-Cgi0MnHh.cjs +69 -0
- package/dist/sync-engine.cjs +1 -1
- package/dist/sync-engine.d.cts +1 -1
- package/dist/sync-engine.d.mts +1 -1
- package/dist/sync-engine.mjs +1 -1
- package/dist/types.cjs +11 -0
- package/dist/types.d.cts +28 -2
- package/dist/types.d.mts +28 -2
- package/dist/types.mjs +9 -1
- package/dist/{view-compiler-u3GGRgnv.mjs → view-compiler-CLvZ0wJW.mjs} +1 -1
- package/dist/{view-compiler-D30HNRnA.cjs → view-compiler-DP4Fbj7-.cjs} +1 -1
- package/dist/view-compiler.cjs +1 -1
- package/dist/view-compiler.d.cts +1 -1
- package/dist/view-compiler.d.mts +1 -1
- package/dist/view-compiler.mjs +1 -1
- package/package.json +11 -1
package/dist/builder.mjs
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { n as PROJECTION_KEY_PATTERN, t as BASE_REF } from "./projection-SCNrLJMV.mjs";
|
|
2
|
+
import { i as primaryKeyColumns, n as coversUniqueKey, r as hasRichMeta } from "./schema-meta-Ac7938mT.mjs";
|
|
3
|
+
//#region src/path-compiler.ts
|
|
4
|
+
/**
|
|
5
|
+
* Compiles picked paths into a `ProjectionMapping` whose joins are in
|
|
6
|
+
* dependency order.
|
|
7
|
+
*
|
|
8
|
+
* This is the last step of the derived-object builder: the user has browsed
|
|
9
|
+
* outward from a starting list and picked fields, and this turns those picks
|
|
10
|
+
* into the existing projection contract — unchanged, so validation, view
|
|
11
|
+
* compilation, paging and sync all consume it as they already do.
|
|
12
|
+
*
|
|
13
|
+
* Four properties carry the weight:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Joins dedupe by edge path, not by table.** Ten fields reached through
|
|
16
|
+
* the same chain of relationships produce one join. Two chains that land on
|
|
17
|
+
* the same table through *different* foreign keys stay two joins, because
|
|
18
|
+
* they are two genuinely different relationships. The ordered edge path is
|
|
19
|
+
* the join's identity.
|
|
20
|
+
*
|
|
21
|
+
* 2. **Dependency order.** Joins are emitted shortest-prefix-first, so a
|
|
22
|
+
* join's ON condition only ever references the base table or a join already
|
|
23
|
+
* declared above it. `validateProjection`'s forward-reference check is the
|
|
24
|
+
* external proof of this, and SQL requires it.
|
|
25
|
+
*
|
|
26
|
+
* 3. **No raw table name is ever an alias.** Every join key is derived from
|
|
27
|
+
* the full edge path — including which FK column each hop followed — and
|
|
28
|
+
* uniquified deterministically. A table-derived alias would silently
|
|
29
|
+
* collapse two different relationships into one.
|
|
30
|
+
*
|
|
31
|
+
* 4. **Collections never compile.** A path that crosses a to-many edge
|
|
32
|
+
* resolves to many rows, not a value. Emitting one as a join would fan the
|
|
33
|
+
* output out and quietly duplicate or collapse mirror rows at sync time.
|
|
34
|
+
* Asked to compile one, this throws rather than emitting something
|
|
35
|
+
* plausible.
|
|
36
|
+
*
|
|
37
|
+
* No table, column, or relationship is ever named in this file — the only
|
|
38
|
+
* inputs are the schema, the starting table, and the user's picks.
|
|
39
|
+
*/
|
|
40
|
+
/** The reserved binding name; the view always projects identity as `id`. */
|
|
41
|
+
const RESERVED_FIELD = "id";
|
|
42
|
+
/**
|
|
43
|
+
* Thrown when picks cannot compile to a valid projection.
|
|
44
|
+
*
|
|
45
|
+
* A distinct class so a builder UI can tell "this cannot be expressed" apart
|
|
46
|
+
* from a genuine bug, and so callers can catch it narrowly. Failing loudly is
|
|
47
|
+
* the point: the alternative — emitting a plausible-looking mapping — is the
|
|
48
|
+
* defect class this whole body of work exists to close.
|
|
49
|
+
*/
|
|
50
|
+
var PathCompileError = class extends Error {
|
|
51
|
+
constructor(message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "PathCompileError";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Reduce an arbitrary identifier to the projection key charset.
|
|
58
|
+
*
|
|
59
|
+
* Table and column names come from the host's schema and are not constrained
|
|
60
|
+
* to `PROJECTION_KEY_PATTERN` — they may carry capitals, spaces, or leading
|
|
61
|
+
* digits. Sanitizing is lossy on purpose (two different names can reduce to
|
|
62
|
+
* the same token), which is exactly why the caller disambiguates afterwards
|
|
63
|
+
* rather than trusting this to be injective.
|
|
64
|
+
*/
|
|
65
|
+
function sanitize(part) {
|
|
66
|
+
return part.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A key candidate for the join reached by `edges`.
|
|
70
|
+
*
|
|
71
|
+
* Every hop contributes both the FK column it followed and the table it landed
|
|
72
|
+
* on. Carrying the column is what keeps two FKs into the same table apart:
|
|
73
|
+
* without it, both hops would reduce to the destination table's name and the
|
|
74
|
+
* two relationships would collide into one alias.
|
|
75
|
+
*/
|
|
76
|
+
function keyCandidate(edges) {
|
|
77
|
+
const joined = edges.flatMap((edge) => [sanitize(edge.fromColumn), sanitize(edge.toTable)]).filter(Boolean).join("_");
|
|
78
|
+
return PROJECTION_KEY_PATTERN.test(joined) ? joined : `j_${joined}`;
|
|
79
|
+
}
|
|
80
|
+
/** The ordered prefixes of a path, longest last. Each prefix is one join. */
|
|
81
|
+
function prefixesOf(edges) {
|
|
82
|
+
return edges.map((_, index) => edges.slice(0, index + 1));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Identity of a join: the full ordered edge path, including direction and both
|
|
86
|
+
* column halves of every hop.
|
|
87
|
+
*
|
|
88
|
+
* This is the deduplication key and it is exact — unlike the *display* key,
|
|
89
|
+
* nothing here is sanitized or lossy, so two distinct relationships can never
|
|
90
|
+
* be conflated into one join.
|
|
91
|
+
*/
|
|
92
|
+
function prefixIdentity(edges) {
|
|
93
|
+
return edges.map((e) => `${e.direction}:${e.fromTable}.${e.fromColumn}->${e.toTable}.${e.toColumn}`).join("|");
|
|
94
|
+
}
|
|
95
|
+
/** Assigns each distinct edge path a unique, deterministic key. */
|
|
96
|
+
var JoinKeyAllocator = class {
|
|
97
|
+
byIdentity = /* @__PURE__ */ new Map();
|
|
98
|
+
taken = /* @__PURE__ */ new Set([BASE_REF]);
|
|
99
|
+
/**
|
|
100
|
+
* The key for this edge path, allocating one on first sight.
|
|
101
|
+
*
|
|
102
|
+
* Collisions are resolved by a counter rather than by anything derived from
|
|
103
|
+
* call order beyond first-sight order, which is itself deterministic
|
|
104
|
+
* because joins are visited in a fixed sorted order. The same picks
|
|
105
|
+
* therefore always produce the same keys.
|
|
106
|
+
*/
|
|
107
|
+
keyFor(edges) {
|
|
108
|
+
const identity = prefixIdentity(edges);
|
|
109
|
+
const existing = this.byIdentity.get(identity);
|
|
110
|
+
if (existing) return existing;
|
|
111
|
+
const candidate = keyCandidate(edges);
|
|
112
|
+
let key = candidate;
|
|
113
|
+
let suffix = 2;
|
|
114
|
+
while (this.taken.has(key)) {
|
|
115
|
+
key = `${candidate}_${suffix}`;
|
|
116
|
+
suffix += 1;
|
|
117
|
+
}
|
|
118
|
+
this.taken.add(key);
|
|
119
|
+
this.byIdentity.set(identity, key);
|
|
120
|
+
return key;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
/** The ref an edge path resolves to: `base` for the root, else a join key. */
|
|
124
|
+
function refTableFor(edges, allocator) {
|
|
125
|
+
return edges.length === 0 ? BASE_REF : allocator.keyFor(edges);
|
|
126
|
+
}
|
|
127
|
+
function buildJoin(edges, allocator) {
|
|
128
|
+
const edge = edges.at(-1);
|
|
129
|
+
if (!edge) throw new PathCompileError("cannot build a join from an empty path");
|
|
130
|
+
const parentRef = refTableFor(edges.slice(0, -1), allocator);
|
|
131
|
+
const key = allocator.keyFor(edges);
|
|
132
|
+
const left = {
|
|
133
|
+
table: parentRef,
|
|
134
|
+
column: edge.fromColumn
|
|
135
|
+
};
|
|
136
|
+
const right = {
|
|
137
|
+
table: key,
|
|
138
|
+
column: edge.toColumn
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
key,
|
|
142
|
+
table: edge.toTable,
|
|
143
|
+
type: "left",
|
|
144
|
+
on: [{
|
|
145
|
+
left,
|
|
146
|
+
op: "eq",
|
|
147
|
+
right
|
|
148
|
+
}]
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function assertCompilable(field, startTable) {
|
|
152
|
+
const path = field.path;
|
|
153
|
+
if (!path || path.edges.length === 0) return;
|
|
154
|
+
if (path.isCollection) throw new PathCompileError(`field "${field.name}" is reached through a collection (a to-one relationship is required); a collection is many rows, not a value, so it must be started as its own list rather than compiled into a join`);
|
|
155
|
+
const first = path.edges[0];
|
|
156
|
+
if (first && first.fromTable !== startTable) throw new PathCompileError(`field "${field.name}" follows a path starting at "${first.fromTable}", not the starting table "${startTable}"`);
|
|
157
|
+
}
|
|
158
|
+
function validatePicks(options) {
|
|
159
|
+
const { pickedFields, startTable } = options;
|
|
160
|
+
if (pickedFields.length === 0) throw new PathCompileError("at least one field must be picked");
|
|
161
|
+
const seen = /* @__PURE__ */ new Set();
|
|
162
|
+
for (const field of pickedFields) {
|
|
163
|
+
if (field.name === RESERVED_FIELD) throw new PathCompileError(`"${RESERVED_FIELD}" is a reserved field name — the view always projects identity as "${RESERVED_FIELD}"`);
|
|
164
|
+
if (seen.has(field.name)) throw new PathCompileError(`duplicate field name "${field.name}"`);
|
|
165
|
+
seen.add(field.name);
|
|
166
|
+
assertCompilable(field, startTable);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The starting table's single-column primary key.
|
|
171
|
+
*
|
|
172
|
+
* Fails closed on absent or composite keys rather than guessing a column: an
|
|
173
|
+
* identity that is not provably unique per row is the failure mode that
|
|
174
|
+
* silently duplicates or collapses mirror rows at sync time.
|
|
175
|
+
*/
|
|
176
|
+
function basePrimaryKey(schema, startTable) {
|
|
177
|
+
const table = schema.find((t) => t.name === startTable);
|
|
178
|
+
if (!table) throw new PathCompileError(`starting table "${startTable}" is not in the discovered schema`);
|
|
179
|
+
const pk = primaryKeyColumns(table);
|
|
180
|
+
const [column] = pk;
|
|
181
|
+
if (!column) throw new PathCompileError(`"${startTable}" has no discovered single-column primary key, which a derived object needs to identify its rows`);
|
|
182
|
+
if (pk.length > 1) throw new PathCompileError(`"${startTable}" has a composite primary key (${pk.join(", ")}), which cannot be used as a derived object's identity`);
|
|
183
|
+
return column;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Every distinct edge-path prefix used by at least one picked field, ordered
|
|
187
|
+
* shortest first.
|
|
188
|
+
*
|
|
189
|
+
* Ordering by depth is what puts joins in dependency order: a prefix of length
|
|
190
|
+
* n can only reference the base table or its own length n-1 prefix, both of
|
|
191
|
+
* which sort strictly earlier. Ties within a depth break on the exact path
|
|
192
|
+
* identity, so the order is total and stable across runs.
|
|
193
|
+
*/
|
|
194
|
+
function requiredPrefixes(pickedFields) {
|
|
195
|
+
const byIdentity = /* @__PURE__ */ new Map();
|
|
196
|
+
for (const field of pickedFields) for (const prefix of prefixesOf(field.path?.edges ?? [])) {
|
|
197
|
+
const identity = prefixIdentity(prefix);
|
|
198
|
+
if (!byIdentity.has(identity)) byIdentity.set(identity, prefix);
|
|
199
|
+
}
|
|
200
|
+
return [...byIdentity.entries()].sort(([leftId, left], [rightId, right]) => {
|
|
201
|
+
const byDepth = left.length - right.length;
|
|
202
|
+
if (byDepth !== 0) return byDepth;
|
|
203
|
+
if (leftId === rightId) return 0;
|
|
204
|
+
return leftId < rightId ? -1 : 1;
|
|
205
|
+
}).map(([, prefix]) => prefix);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Compile picked fields into a projection mapping.
|
|
209
|
+
*
|
|
210
|
+
* Throws `PathCompileError` when the picks cannot be expressed — a collection
|
|
211
|
+
* path, a missing primary key, a reserved or duplicated field name. Every
|
|
212
|
+
* mapping it returns satisfies `projectionSchema` and, given a schema whose
|
|
213
|
+
* tables carry rich metadata, `validateProjection`.
|
|
214
|
+
*/
|
|
215
|
+
function compilePaths(options) {
|
|
216
|
+
const { entityKey, pickedFields, schema, startTable } = options;
|
|
217
|
+
validatePicks(options);
|
|
218
|
+
const identityColumn = basePrimaryKey(schema, startTable);
|
|
219
|
+
const allocator = new JoinKeyAllocator();
|
|
220
|
+
const joins = requiredPrefixes(pickedFields).map((prefix) => buildJoin(prefix, allocator));
|
|
221
|
+
const fieldBindings = {};
|
|
222
|
+
for (const field of pickedFields) {
|
|
223
|
+
const table = refTableFor(field.path?.edges ?? [], allocator);
|
|
224
|
+
fieldBindings[field.name] = { from: {
|
|
225
|
+
table,
|
|
226
|
+
column: field.column
|
|
227
|
+
} };
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
version: 1,
|
|
231
|
+
kind: "projection",
|
|
232
|
+
entityKey,
|
|
233
|
+
baseTable: startTable,
|
|
234
|
+
joins,
|
|
235
|
+
fieldBindings,
|
|
236
|
+
identity: {
|
|
237
|
+
kind: "base_pk",
|
|
238
|
+
column: identityColumn
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/relationship-graph.ts
|
|
244
|
+
const NO_RICH_META_REASON$1 = { code: "no-rich-meta" };
|
|
245
|
+
/** Every edge whose `fromTable` is `table`, in either direction. */
|
|
246
|
+
function edgesFrom(graph, table) {
|
|
247
|
+
return graph.edges.filter((edge) => edge.fromTable === table);
|
|
248
|
+
}
|
|
249
|
+
function childToParentCardinality(parentTable, parentColumn) {
|
|
250
|
+
return coversUniqueKey(parentTable, [parentColumn]) ? "to-one" : "to-many";
|
|
251
|
+
}
|
|
252
|
+
function parentToChildCardinality(childTable, childFkColumn) {
|
|
253
|
+
return coversUniqueKey(childTable, [childFkColumn]) ? "to-one" : "to-many";
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Build the bidirectional relationship graph for a discovered schema.
|
|
257
|
+
*
|
|
258
|
+
* No table, column, or relationship is ever named in this function — the
|
|
259
|
+
* only input is the schema itself.
|
|
260
|
+
*/
|
|
261
|
+
function buildRelationshipGraph(schema) {
|
|
262
|
+
const tablesByName = new Map(schema.map((table) => [table.name, table]));
|
|
263
|
+
const nodes = schema.map((table) => hasRichMeta(table) ? {
|
|
264
|
+
table: table.name,
|
|
265
|
+
offerable: true
|
|
266
|
+
} : {
|
|
267
|
+
table: table.name,
|
|
268
|
+
offerable: false,
|
|
269
|
+
reason: NO_RICH_META_REASON$1
|
|
270
|
+
});
|
|
271
|
+
const edges = [];
|
|
272
|
+
for (const table of schema) {
|
|
273
|
+
if (!hasRichMeta(table)) continue;
|
|
274
|
+
for (const column of table.columnsMeta) {
|
|
275
|
+
const ref = column.references;
|
|
276
|
+
if (!ref) continue;
|
|
277
|
+
const parentTable = tablesByName.get(ref.table);
|
|
278
|
+
if (!(parentTable && hasRichMeta(parentTable))) continue;
|
|
279
|
+
edges.push({
|
|
280
|
+
fromTable: table.name,
|
|
281
|
+
fromColumn: column.name,
|
|
282
|
+
toTable: parentTable.name,
|
|
283
|
+
toColumn: ref.column,
|
|
284
|
+
direction: "child-to-parent",
|
|
285
|
+
cardinality: childToParentCardinality(parentTable, ref.column)
|
|
286
|
+
});
|
|
287
|
+
edges.push({
|
|
288
|
+
fromTable: parentTable.name,
|
|
289
|
+
fromColumn: ref.column,
|
|
290
|
+
toTable: table.name,
|
|
291
|
+
toColumn: column.name,
|
|
292
|
+
direction: "parent-to-child",
|
|
293
|
+
cardinality: parentToChildCardinality(table, column.name)
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
nodes,
|
|
299
|
+
edges
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/path-enumeration.ts
|
|
304
|
+
/**
|
|
305
|
+
* Breadth-first enumeration of the paths reachable from a starting table.
|
|
306
|
+
*
|
|
307
|
+
* This is the "browse outward" engine behind the derived-object builder: the
|
|
308
|
+
* user picks a starting list and we show them what else is reachable, without
|
|
309
|
+
* ever naming a join, a base table, or an ON condition.
|
|
310
|
+
*
|
|
311
|
+
* Three properties matter enough to be spelled out:
|
|
312
|
+
*
|
|
313
|
+
* 1. **Cycles terminate.** A table already on a path is not revisited, so a
|
|
314
|
+
* self-referencing table yields its one-hop neighbours and stops. Parallel
|
|
315
|
+
* edges are still distinct: edge identity is the FK column pair, not the
|
|
316
|
+
* table pair, so two FKs into the same table stay two relationships.
|
|
317
|
+
*
|
|
318
|
+
* 2. **To-many is sticky.** Once a path crosses a to-many edge, everything at
|
|
319
|
+
* or beyond it is a *collection* — a list of rows, not a single value. Those
|
|
320
|
+
* paths are not pickable columns and must never be compiled into joins; the
|
|
321
|
+
* UI shows them with a "start a list of X" action instead.
|
|
322
|
+
*
|
|
323
|
+
* 3. **Truncation is reported, never silent.** Hitting a budget is part of the
|
|
324
|
+
* return value so the UI can say deeper paths are hidden and offer a
|
|
325
|
+
* narrower search, rather than quietly showing a partial answer as if it
|
|
326
|
+
* were the whole one.
|
|
327
|
+
*
|
|
328
|
+
* No table, column, or relationship is ever named here — the only inputs are
|
|
329
|
+
* the graph and the starting table.
|
|
330
|
+
*/
|
|
331
|
+
/** Spec-mandated: how many edges deep a path may go. */
|
|
332
|
+
const MAX_HOPS = 3;
|
|
333
|
+
/** Spec-mandated: how many candidate paths may be considered before stopping. */
|
|
334
|
+
const MAX_CANDIDATES = 200;
|
|
335
|
+
/** Spec-mandated: how many ranked results may be displayed. */
|
|
336
|
+
const MAX_RESULTS = 50;
|
|
337
|
+
/**
|
|
338
|
+
* `direction` is part of the key, not decoration. A column that is both a PK
|
|
339
|
+
* and a self-FK (`t.id` referencing `t.id`) emits two genuinely different
|
|
340
|
+
* edges — one child-to-parent, one parent-to-child — whose table/column
|
|
341
|
+
* halves are identical. Without `direction` they collapse to one key, which
|
|
342
|
+
* makes two distinct paths indistinguishable to the comparator and leaves
|
|
343
|
+
* `Array.prototype.sort` free to order them either way.
|
|
344
|
+
*/
|
|
345
|
+
function edgeKey(edge) {
|
|
346
|
+
return `${edge.direction}:${edge.fromTable}.${edge.fromColumn}->${edge.toTable}.${edge.toColumn}`;
|
|
347
|
+
}
|
|
348
|
+
function pathKey(edges) {
|
|
349
|
+
return edges.map(edgeKey).join("|");
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Does the path's destination carry a column named exactly `field`?
|
|
353
|
+
*
|
|
354
|
+
* Only an exact match counts. A fuzzy match here would reorder results for
|
|
355
|
+
* reasons a user cannot see, and the ordering is load-bearing downstream.
|
|
356
|
+
*/
|
|
357
|
+
function matchesField(path, field, columnsByTable) {
|
|
358
|
+
if (!field) return false;
|
|
359
|
+
const columns = columnsByTable?.get(path.toTable);
|
|
360
|
+
return columns ? columns.includes(field) : false;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Total order over paths: hop count, then exact field match, then the stable
|
|
364
|
+
* key. The key tiebreak is what makes the order total — without it, equal-rank
|
|
365
|
+
* paths would keep their input order, which is stable per run but would drift
|
|
366
|
+
* the moment edge construction changed.
|
|
367
|
+
*
|
|
368
|
+
* Returns 0 on equal keys rather than an arbitrary 1. A comparator that
|
|
369
|
+
* answers 1 for both (a, b) and (b, a) is *inconsistent*, and
|
|
370
|
+
* `Array.prototype.sort` is explicitly allowed to order such elements however
|
|
371
|
+
* it likes — which would make Task 5's ordering invariant flake rather than
|
|
372
|
+
* fail cleanly. Keys are unique in practice now that `edgeKey` carries
|
|
373
|
+
* `direction`, so this is defence against the class, not a live collision.
|
|
374
|
+
*/
|
|
375
|
+
function comparePaths(left, right, field, columnsByTable) {
|
|
376
|
+
const byHops = left.edges.length - right.edges.length;
|
|
377
|
+
if (byHops !== 0) return byHops;
|
|
378
|
+
const leftMatch = matchesField(left, field, columnsByTable);
|
|
379
|
+
if (leftMatch !== matchesField(right, field, columnsByTable)) return leftMatch ? -1 : 1;
|
|
380
|
+
if (left.key === right.key) return 0;
|
|
381
|
+
return left.key < right.key ? -1 : 1;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Should this edge be followed from `path`?
|
|
385
|
+
*
|
|
386
|
+
* A self-referencing FK is a real relationship a user browses to (a row's
|
|
387
|
+
* parent row), so it is offered as a one-hop path even though it lands back
|
|
388
|
+
* on the starting table. It is never extended: the destination is already on
|
|
389
|
+
* the path, so the next level rejects it and the cycle terminates.
|
|
390
|
+
*/
|
|
391
|
+
function canFollow(entry, edge) {
|
|
392
|
+
const isSelfEdgeFromStart = entry.path.edges.length === 0 && edge.toTable === edge.fromTable;
|
|
393
|
+
return !entry.visited.has(edge.toTable) || isSelfEdgeFromStart;
|
|
394
|
+
}
|
|
395
|
+
function extend(entry, edge) {
|
|
396
|
+
const edges = [...entry.path.edges, edge];
|
|
397
|
+
return {
|
|
398
|
+
path: {
|
|
399
|
+
edges,
|
|
400
|
+
toTable: edge.toTable,
|
|
401
|
+
isCollection: entry.path.isCollection || edge.cardinality === "to-many",
|
|
402
|
+
key: pathKey(edges)
|
|
403
|
+
},
|
|
404
|
+
visited: /* @__PURE__ */ new Set([...entry.visited, edge.toTable])
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Expand one breadth-first level, appending to `candidates`.
|
|
409
|
+
*
|
|
410
|
+
* Returns the next frontier, or `undefined` when the candidate budget ran out
|
|
411
|
+
* mid-level — the caller treats that as truncation and stops.
|
|
412
|
+
*/
|
|
413
|
+
function expandLevel(graph, frontier, candidates, maxCandidates) {
|
|
414
|
+
const next = [];
|
|
415
|
+
for (const entry of frontier) for (const edge of edgesFrom(graph, entry.path.toTable)) {
|
|
416
|
+
if (!canFollow(entry, edge)) continue;
|
|
417
|
+
if (candidates.length >= maxCandidates) return;
|
|
418
|
+
const extended = extend(entry, edge);
|
|
419
|
+
candidates.push(extended.path);
|
|
420
|
+
next.push(extended);
|
|
421
|
+
}
|
|
422
|
+
return next;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Enumerate every path reachable from `startTable`, ranked and budgeted.
|
|
426
|
+
*
|
|
427
|
+
* Breadth-first, so the candidate budget always cuts the deepest frontier
|
|
428
|
+
* rather than an arbitrary slice of a half-explored level.
|
|
429
|
+
*
|
|
430
|
+
* Note the order of operations: ranking happens *after* the candidate budget
|
|
431
|
+
* has already stopped the walk. A path that would have ranked highly but sits
|
|
432
|
+
* beyond the 200th candidate is therefore never seen, and no amount of
|
|
433
|
+
* ranking recovers it. That is inherent to bounding the search rather than a
|
|
434
|
+
* defect — and it is precisely why truncation is reported, so the UI can
|
|
435
|
+
* offer a narrower search instead of implying the list is complete.
|
|
436
|
+
*/
|
|
437
|
+
function enumeratePaths(graph, startTable, options = {}) {
|
|
438
|
+
const maxHops = options.maxHops ?? 3;
|
|
439
|
+
const maxCandidates = options.maxCandidates ?? 200;
|
|
440
|
+
const maxResults = options.maxResults ?? 50;
|
|
441
|
+
const candidates = [];
|
|
442
|
+
let truncatedBy;
|
|
443
|
+
let frontier = [{
|
|
444
|
+
path: {
|
|
445
|
+
edges: [],
|
|
446
|
+
toTable: startTable,
|
|
447
|
+
isCollection: false,
|
|
448
|
+
key: ""
|
|
449
|
+
},
|
|
450
|
+
visited: /* @__PURE__ */ new Set([startTable])
|
|
451
|
+
}];
|
|
452
|
+
for (let hop = 0; hop < maxHops && frontier.length > 0; hop += 1) {
|
|
453
|
+
const next = expandLevel(graph, frontier, candidates, maxCandidates);
|
|
454
|
+
if (!next) {
|
|
455
|
+
truncatedBy = "candidate-budget";
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
frontier = next;
|
|
459
|
+
}
|
|
460
|
+
const columnsByTable = options.columnsByTable;
|
|
461
|
+
const ranked = [...candidates].sort((left, right) => comparePaths(left, right, options.field, columnsByTable));
|
|
462
|
+
const paths = ranked.slice(0, maxResults);
|
|
463
|
+
if (!truncatedBy && ranked.length > maxResults) truncatedBy = "result-limit";
|
|
464
|
+
return {
|
|
465
|
+
paths,
|
|
466
|
+
truncated: Boolean(truncatedBy),
|
|
467
|
+
truncatedBy,
|
|
468
|
+
candidatesConsidered: candidates.length
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region src/starting-record-change.ts
|
|
473
|
+
/** Does this table have a column of exactly this name? */
|
|
474
|
+
function hasColumn(schema, table, column) {
|
|
475
|
+
const found = schema.find((entry) => entry.name === table);
|
|
476
|
+
return found ? found.columns.includes(column) : false;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The route a pick would have from the new starting record, or `null` when it
|
|
480
|
+
* has none.
|
|
481
|
+
*
|
|
482
|
+
* Returns `{ path: undefined }` — deliberately distinct from `null` — when the
|
|
483
|
+
* field is a column of the new starting record itself. That is a survivor
|
|
484
|
+
* reached by no route, which is exactly what `PickedField.path` being absent
|
|
485
|
+
* means to `compilePaths`.
|
|
486
|
+
*/
|
|
487
|
+
function repoint(schema, paths, toTable, pick) {
|
|
488
|
+
if (hasColumn(schema, toTable, pick.column)) return { path: void 0 };
|
|
489
|
+
const destination = pick.path?.toTable ?? void 0;
|
|
490
|
+
const candidates = paths.filter((path) => !path.isCollection);
|
|
491
|
+
if (destination !== void 0) {
|
|
492
|
+
const match = candidates.find((path) => path.toTable === destination && hasColumn(schema, path.toTable, pick.column));
|
|
493
|
+
return match ? { path: match } : null;
|
|
494
|
+
}
|
|
495
|
+
const match = candidates.find((path) => hasColumn(schema, path.toTable, pick.column));
|
|
496
|
+
return match ? { path: match } : null;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Identify the connection a pick travelled over, for counting.
|
|
500
|
+
*
|
|
501
|
+
* The path key, not the destination table: two FKs from one table into the
|
|
502
|
+
* same table — a ticket's assignee and its reviewer — are two genuinely
|
|
503
|
+
* different connections that both end at `users`. Counting by destination
|
|
504
|
+
* would collapse them into one and under-report what the user loses.
|
|
505
|
+
*
|
|
506
|
+
* `null` means the pick was a column of the starting record, which is not a
|
|
507
|
+
* connection.
|
|
508
|
+
*/
|
|
509
|
+
function connectionOf(pick) {
|
|
510
|
+
if (!pick.path || pick.path.edges.length === 0) return null;
|
|
511
|
+
return pick.path.key;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Classify what changing the starting record does to a set of picked fields.
|
|
515
|
+
*
|
|
516
|
+
* Pure and total: an unknown `to` table simply has no reachable paths, so
|
|
517
|
+
* every pick that is not one of its own columns is reported removed. It never
|
|
518
|
+
* throws, because its caller is a confirmation dialog that must be able to
|
|
519
|
+
* render an answer for any proposed change, including a nonsensical one.
|
|
520
|
+
*/
|
|
521
|
+
function classifyStartingRecordChange(options) {
|
|
522
|
+
const { from, picks, schema, to } = options;
|
|
523
|
+
if (from === to) return {
|
|
524
|
+
keptFields: [...picks],
|
|
525
|
+
removedConnectionCount: 0,
|
|
526
|
+
removedFields: []
|
|
527
|
+
};
|
|
528
|
+
const { paths } = enumeratePaths(buildRelationshipGraph(schema), to);
|
|
529
|
+
const keptFields = [];
|
|
530
|
+
const removedFields = [];
|
|
531
|
+
const removedConnections = /* @__PURE__ */ new Set();
|
|
532
|
+
for (const pick of picks) {
|
|
533
|
+
const repointed = repoint(schema, paths, to, pick);
|
|
534
|
+
if (repointed) {
|
|
535
|
+
keptFields.push({
|
|
536
|
+
column: pick.column,
|
|
537
|
+
name: pick.name,
|
|
538
|
+
...repointed.path ? { path: repointed.path } : {}
|
|
539
|
+
});
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
removedFields.push(pick);
|
|
543
|
+
const connection = connectionOf(pick);
|
|
544
|
+
if (connection !== null) removedConnections.add(connection);
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
keptFields,
|
|
548
|
+
removedConnectionCount: removedConnections.size,
|
|
549
|
+
removedFields
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
//#endregion
|
|
553
|
+
//#region src/starting-record-eligibility.ts
|
|
554
|
+
const NO_RICH_META_REASON = { code: "no-rich-meta" };
|
|
555
|
+
const COMPOSITE_PK_REASON = { code: "composite-pk" };
|
|
556
|
+
const NO_PRIMARY_KEY_REASON = { code: "no-primary-key" };
|
|
557
|
+
/**
|
|
558
|
+
* List every table in the discovered schema with its starting-record
|
|
559
|
+
* eligibility, in schema order.
|
|
560
|
+
*
|
|
561
|
+
* No table, column, or relationship is ever named in this function — the
|
|
562
|
+
* only input is the schema itself.
|
|
563
|
+
*/
|
|
564
|
+
function listStartingRecordOptions(schema) {
|
|
565
|
+
return schema.map((table) => {
|
|
566
|
+
if (!hasRichMeta(table)) return {
|
|
567
|
+
table: table.name,
|
|
568
|
+
offerable: false,
|
|
569
|
+
reason: NO_RICH_META_REASON
|
|
570
|
+
};
|
|
571
|
+
const pk = primaryKeyColumns(table);
|
|
572
|
+
if (pk.length > 1) return {
|
|
573
|
+
table: table.name,
|
|
574
|
+
offerable: false,
|
|
575
|
+
reason: COMPOSITE_PK_REASON
|
|
576
|
+
};
|
|
577
|
+
if (pk.length === 0) return {
|
|
578
|
+
table: table.name,
|
|
579
|
+
offerable: false,
|
|
580
|
+
reason: NO_PRIMARY_KEY_REASON
|
|
581
|
+
};
|
|
582
|
+
return {
|
|
583
|
+
table: table.name,
|
|
584
|
+
offerable: true
|
|
585
|
+
};
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
//#endregion
|
|
589
|
+
export { MAX_CANDIDATES, MAX_HOPS, MAX_RESULTS, PathCompileError, buildRelationshipGraph, classifyStartingRecordChange, compilePaths, edgesFrom, enumeratePaths, listStartingRecordOptions };
|
package/dist/drizzle.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { m as DiscoveredSchemaTable, p as DiscoveredColumnMeta } from "./entity-config-
|
|
1
|
+
import { m as DiscoveredSchemaTable, p as DiscoveredColumnMeta } from "./entity-config-DCa785OE.cjs";
|
|
2
2
|
import { Table } from "drizzle-orm";
|
|
3
3
|
//#region src/drizzle.d.ts
|
|
4
4
|
/**
|
package/dist/drizzle.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { m as DiscoveredSchemaTable, p as DiscoveredColumnMeta } from "./entity-config-
|
|
1
|
+
import { m as DiscoveredSchemaTable, p as DiscoveredColumnMeta } from "./entity-config-DCa785OE.mjs";
|
|
2
2
|
import { Table } from "drizzle-orm";
|
|
3
3
|
//#region src/drizzle.d.ts
|
|
4
4
|
/**
|