@genesislcap/mock-server 15.54.0 → 15.55.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 +126 -12
- package/dist/db/schema.js +29 -14
- package/dist/db/store.d.ts +11 -1
- package/dist/db/store.js +71 -47
- package/dist/db/views.d.ts +42 -0
- package/dist/db/views.js +193 -0
- package/dist/index.d.ts +1 -1
- package/dist/server.js +136 -15
- package/dist/types.d.ts +26 -5
- package/package.json +1 -1
- package/src/db/schema.ts +32 -15
- package/src/db/store.ts +85 -60
- package/src/db/views.ts +269 -0
- package/src/index.ts +5 -0
- package/src/server.ts +171 -15
- package/src/types.ts +93 -15
package/src/db/store.ts
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
|
|
4
4
|
import type { Row, SequenceFormat, TableDef, ViewDef } from '../types.ts';
|
|
5
5
|
import { RECORD_ID, RecordClock, recordKey, TIMESTAMP, withoutRecordFields } from './identity.ts';
|
|
6
|
+
import {
|
|
7
|
+
materializeEntries,
|
|
8
|
+
planView,
|
|
9
|
+
type PlannedAntiJoin,
|
|
10
|
+
type PlannedJoin,
|
|
11
|
+
type ViewEntry,
|
|
12
|
+
} from './views.ts';
|
|
13
|
+
|
|
14
|
+
// What a view is materialized from: the base rows, and a join's or
|
|
15
|
+
// anti-join's rows by its planned entry (undefined: the table as it is).
|
|
16
|
+
// Either left out reads the tables as they are.
|
|
17
|
+
export interface ViewRowsSource {
|
|
18
|
+
base?: Row[];
|
|
19
|
+
rowsOf?: (entry: PlannedJoin | PlannedAntiJoin) => Row[] | undefined;
|
|
20
|
+
}
|
|
6
21
|
|
|
7
22
|
type Pk = string;
|
|
8
23
|
|
|
@@ -117,6 +132,10 @@ export class Store {
|
|
|
117
132
|
// MAX_REMOVED_PER_KEY records and a table its last MAX_REMOVED_KEYS keys,
|
|
118
133
|
// for handlers that delete without ever broadcasting.
|
|
119
134
|
private removedRows = new Map<string, Map<Pk, Row[]>>();
|
|
135
|
+
// Per table, each record a modify changed since a broadcast last announced
|
|
136
|
+
// it, as it was before the first such modify (by RECORD_ID) — see
|
|
137
|
+
// takePreviousRecord. Bounded like removedRows.
|
|
138
|
+
private previousRows = new Map<string, Map<string, Row>>();
|
|
120
139
|
readonly views = new Map<string, ViewDef>();
|
|
121
140
|
|
|
122
141
|
registerTable(tableName: string, definition: TableDef): this {
|
|
@@ -152,6 +171,7 @@ export class Store {
|
|
|
152
171
|
this.tables.set(tableName, table);
|
|
153
172
|
this.tableConfig.set(tableName, config);
|
|
154
173
|
this.removedRows.delete(tableName);
|
|
174
|
+
this.previousRows.delete(tableName);
|
|
155
175
|
this.seedRows.set(
|
|
156
176
|
tableName,
|
|
157
177
|
rows.map((row) => ({ ...row })),
|
|
@@ -199,7 +219,9 @@ export class Store {
|
|
|
199
219
|
return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
|
|
200
220
|
}
|
|
201
221
|
|
|
222
|
+
// Throws on a join model GSF would refuse to compile (see planView).
|
|
202
223
|
registerView(viewName: string, definition: ViewDef): this {
|
|
224
|
+
planView(viewName, definition);
|
|
203
225
|
this.views.set(viewName, definition);
|
|
204
226
|
return this;
|
|
205
227
|
}
|
|
@@ -252,6 +274,19 @@ export class Store {
|
|
|
252
274
|
if (removed.size > MAX_REMOVED_KEYS) removed.delete(removed.keys().next().value!);
|
|
253
275
|
}
|
|
254
276
|
|
|
277
|
+
private notePrevious(tableName: string, row: Row): void {
|
|
278
|
+
const id = recordKey(row);
|
|
279
|
+
if (id === undefined) return;
|
|
280
|
+
let previous = this.previousRows.get(tableName);
|
|
281
|
+
if (!previous) {
|
|
282
|
+
previous = new Map();
|
|
283
|
+
this.previousRows.set(tableName, previous);
|
|
284
|
+
}
|
|
285
|
+
if (previous.has(id)) return;
|
|
286
|
+
previous.set(id, row);
|
|
287
|
+
if (previous.size > MAX_REMOVED_KEYS) previous.delete(previous.keys().next().value!);
|
|
288
|
+
}
|
|
289
|
+
|
|
255
290
|
// A supplied value inside a generator's own range must advance its counter
|
|
256
291
|
// too. Without this, inserting e.g. 'TR002' by hand leaves the counter at
|
|
257
292
|
// 2, and the next generated insert mints 'TR002' again and silently
|
|
@@ -387,6 +422,7 @@ export class Store {
|
|
|
387
422
|
const config = this.tableConfig.get(tableName);
|
|
388
423
|
if (!table || !config || !table.has(pk)) return undefined;
|
|
389
424
|
const existing = table.get(pk)!;
|
|
425
|
+
this.notePrevious(tableName, existing);
|
|
390
426
|
const updated = { ...existing, ...patch };
|
|
391
427
|
updated[RECORD_ID] = existing[RECORD_ID];
|
|
392
428
|
updated[TIMESTAMP] = this.clock.next();
|
|
@@ -426,6 +462,18 @@ export class Store {
|
|
|
426
462
|
return records;
|
|
427
463
|
}
|
|
428
464
|
|
|
465
|
+
// Reads and clears how a record was before the modifies no broadcast has
|
|
466
|
+
// announced yet changed it (undefined when none did): with the removed
|
|
467
|
+
// records, what a live change to a joined table is diffed against.
|
|
468
|
+
takePreviousRecord(tableName: string, row: Row): Row | undefined {
|
|
469
|
+
const id = recordKey(row);
|
|
470
|
+
const previous = this.previousRows.get(tableName);
|
|
471
|
+
if (id === undefined || !previous) return undefined;
|
|
472
|
+
const record = previous.get(id);
|
|
473
|
+
previous.delete(id);
|
|
474
|
+
return record;
|
|
475
|
+
}
|
|
476
|
+
|
|
429
477
|
// The same, for a row of a table or view given by its (base) pk fields —
|
|
430
478
|
// what a public broadcast(resource, 'DELETE', pkStub) names.
|
|
431
479
|
takeRemovedRecordsFor(resourceName: string, row: Row): Row[] {
|
|
@@ -433,69 +481,42 @@ export class Store {
|
|
|
433
481
|
return located ? this.takeRemovedRows(located.tableName, located.key) : [];
|
|
434
482
|
}
|
|
435
483
|
|
|
436
|
-
// Materializes a view
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
484
|
+
// Materializes a view (see the README's "Views: the join model"): base
|
|
485
|
+
// rows minus anti-joined ones, joined in order — OUTER keeps a row with no
|
|
486
|
+
// match, INNER drops it — plus the select aliases and derived fields.
|
|
487
|
+
// `tables` substitutes the rows of any table the view reads (its base or a
|
|
488
|
+
// joined one) — e.g. a table's earlier state, to rebuild the view as it
|
|
489
|
+
// was.
|
|
441
490
|
getViewRows(viewName: string, tables: Record<string, Row[]> = {}): Row[] {
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
return this.
|
|
445
|
-
|
|
446
|
-
|
|
491
|
+
const rowsOf = (tableName: string) =>
|
|
492
|
+
Object.hasOwn(tables, tableName) ? tables[tableName] : this.getAllRows(tableName);
|
|
493
|
+
return this.getViewRowsFrom(viewName, {
|
|
494
|
+
base: rowsOf(this.viewOf(viewName).base),
|
|
495
|
+
rowsOf: (entry) => rowsOf(entry.table),
|
|
496
|
+
});
|
|
447
497
|
}
|
|
448
498
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
!joinRows.some(
|
|
457
|
-
(joinRow) =>
|
|
458
|
-
joinRow[antiJoin.on[1]] === row[antiJoin.on[0]] &&
|
|
459
|
-
(antiJoin.where?.(joinRow, row) ?? true),
|
|
460
|
-
),
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
for (const join of view.joins ?? []) {
|
|
465
|
-
const joinRows = getRows(join.table);
|
|
466
|
-
// A `where` predicate sees the base row, so an index keyed on the `on`
|
|
467
|
-
// field alone can't answer it — scan per base row instead.
|
|
468
|
-
const joinIndex = join.where ? undefined : new Map<unknown, Row>();
|
|
469
|
-
if (joinIndex) {
|
|
470
|
-
for (const row of joinRows) {
|
|
471
|
-
joinIndex.set(row[join.on[1]], row);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
rows = rows.map((row) => {
|
|
475
|
-
const match = joinIndex
|
|
476
|
-
? joinIndex.get(row[join.on[0]])
|
|
477
|
-
: joinRows.find(
|
|
478
|
-
(joinRow) => joinRow[join.on[1]] === row[join.on[0]] && join.where!(joinRow, row),
|
|
479
|
-
);
|
|
480
|
-
const merged = { ...row };
|
|
481
|
-
for (const [alias, sourceField] of Object.entries(join.select ?? {})) {
|
|
482
|
-
merged[alias] = match?.[sourceField];
|
|
483
|
-
}
|
|
484
|
-
return merged;
|
|
485
|
-
});
|
|
486
|
-
}
|
|
499
|
+
// The same, with the base rows given apart from the joined ones — e.g. one
|
|
500
|
+
// base row, to build that row's view row when the base table is also joined
|
|
501
|
+
// — and a join's or anti-join's rows given by its planned entry (undefined:
|
|
502
|
+
// the table as it is).
|
|
503
|
+
getViewRowsFrom(viewName: string, source: ViewRowsSource): Row[] {
|
|
504
|
+
return this.getViewEntriesFrom(viewName, source).map((entry) => entry.row);
|
|
505
|
+
}
|
|
487
506
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
507
|
+
// The same, each row with the row every join matched (in join order).
|
|
508
|
+
getViewEntriesFrom(viewName: string, source: ViewRowsSource): ViewEntry[] {
|
|
509
|
+
const view = this.viewOf(viewName);
|
|
510
|
+
return materializeEntries(viewName, view, {
|
|
511
|
+
base: source.base ?? this.getAllRows(view.base),
|
|
512
|
+
rowsOf: (entry) => source.rowsOf?.(entry) ?? this.getAllRows(entry.table),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
497
515
|
|
|
498
|
-
|
|
516
|
+
private viewOf(viewName: string): ViewDef {
|
|
517
|
+
const view = this.views.get(viewName);
|
|
518
|
+
if (!view) throw new Error(`Unknown view: ${viewName}`);
|
|
519
|
+
return view;
|
|
499
520
|
}
|
|
500
521
|
|
|
501
522
|
// Resolves a queryable resource (table or view) by name to its current rows.
|
|
@@ -516,9 +537,13 @@ export class Store {
|
|
|
516
537
|
const live = this.getResourceRows(resourceName);
|
|
517
538
|
if (live.length > 0) return live.map(withoutRecordFields);
|
|
518
539
|
const view = this.views.get(resourceName);
|
|
540
|
+
const seedOf = (tableName: string) => this.seedRows.get(tableName) ?? [];
|
|
519
541
|
const seed = view
|
|
520
|
-
? this.
|
|
521
|
-
|
|
542
|
+
? this.getViewRowsFrom(resourceName, {
|
|
543
|
+
base: seedOf(view.base),
|
|
544
|
+
rowsOf: (entry) => seedOf(entry.table),
|
|
545
|
+
})
|
|
546
|
+
: seedOf(resourceName);
|
|
522
547
|
return seed.map(withoutRecordFields);
|
|
523
548
|
}
|
|
524
549
|
|
package/src/db/views.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// The view join model, as GSF 8.15.x views behave (genesis-pal
|
|
2
|
+
// view/join/*.kt, ViewStructureReaderImpl.kt; genesis-codegen
|
|
3
|
+
// ViewRepoConfigGenerator.kt): a base table, joins that each start from the
|
|
4
|
+
// base or an earlier join, matched on one or more key links, INNER or OUTER,
|
|
5
|
+
// plus derived fields. A ViewDef is planned into the form the store
|
|
6
|
+
// materializes from, the schema types from and live propagation reads.
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
AntiJoinDef,
|
|
10
|
+
JoinDef,
|
|
11
|
+
JoinFieldLink,
|
|
12
|
+
JoinLink,
|
|
13
|
+
Row,
|
|
14
|
+
ViewDef,
|
|
15
|
+
ViewRowSources,
|
|
16
|
+
} from '../types.ts';
|
|
17
|
+
|
|
18
|
+
// A field link reads its source field from the table `fromAlias` names, or,
|
|
19
|
+
// with none, from the view row built so far (see linksOf).
|
|
20
|
+
export type PlannedLink =
|
|
21
|
+
| { kind: 'field'; fromAlias: string | undefined; from: string; to: string }
|
|
22
|
+
| { kind: 'value'; to: string; value: unknown };
|
|
23
|
+
|
|
24
|
+
export interface PlannedJoin {
|
|
25
|
+
def: JoinDef;
|
|
26
|
+
table: string;
|
|
27
|
+
// The name `tables` holds its row under, and joins and links read it by.
|
|
28
|
+
// Undefined for a join with no alias whose table name an earlier table of
|
|
29
|
+
// the view already goes by (see planView).
|
|
30
|
+
alias: string | undefined;
|
|
31
|
+
// The table the join starts from: its own `fromAlias`, else the base.
|
|
32
|
+
fromAlias: string;
|
|
33
|
+
links: PlannedLink[];
|
|
34
|
+
inner: boolean;
|
|
35
|
+
backwardsJoin: boolean;
|
|
36
|
+
// Whether its columns keep their own nullability: true when the join is
|
|
37
|
+
// reached from the base through INNER joins only. Any other join's columns
|
|
38
|
+
// are nullable, as an OUTER join (or anything under one) may find nothing
|
|
39
|
+
// (ViewStructureReaderImpl.kt transformOutputFields / getInnerJoinAliases,
|
|
40
|
+
// GSF v8.15.29).
|
|
41
|
+
alwaysMatched: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PlannedAntiJoin {
|
|
45
|
+
def: AntiJoinDef;
|
|
46
|
+
table: string;
|
|
47
|
+
links: PlannedLink[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ViewPlan {
|
|
51
|
+
baseAlias: string;
|
|
52
|
+
joins: PlannedJoin[];
|
|
53
|
+
antiJoins: PlannedAntiJoin[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isFieldPair = (on: JoinDef['on']): on is JoinFieldLink =>
|
|
57
|
+
on.length === 2 && typeof on[0] === 'string' && typeof on[1] === 'string';
|
|
58
|
+
|
|
59
|
+
// A pair, and an object link with no `fromAlias`, read from the join's own
|
|
60
|
+
// `fromAlias`. A join with none reads them from the view row built so far —
|
|
61
|
+
// the base row's columns, overlaid by the selects of the joins before it —
|
|
62
|
+
// as joins always have, so one written before `fromAlias` existed can still
|
|
63
|
+
// key on an earlier join's selected column. For a GSF view that is the base
|
|
64
|
+
// row's field (an alias never repeats a base column the view exposes); name
|
|
65
|
+
// `fromAlias` to read one table's own row.
|
|
66
|
+
function linksOf(on: JoinDef['on'], fromAlias: string | undefined): PlannedLink[] {
|
|
67
|
+
const links: JoinLink[] = isFieldPair(on) ? [on] : on;
|
|
68
|
+
return links.map((link): PlannedLink => {
|
|
69
|
+
if (Array.isArray(link)) return { kind: 'field', fromAlias, from: link[0], to: link[1] };
|
|
70
|
+
if ('value' in link) return { kind: 'value', to: link.to, value: link.value };
|
|
71
|
+
return { kind: 'field', fromAlias: link.fromAlias ?? fromAlias, from: link.from, to: link.to };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The plan of a view. Planned on every use (it costs next to nothing), so a
|
|
76
|
+
// definition changed in place takes effect, as it always has. Throws on a
|
|
77
|
+
// definition GSF would refuse to compile: a join with no `on` link, an alias
|
|
78
|
+
// given twice, or a join (or one of its links) reading from a table that is
|
|
79
|
+
// neither the base nor an earlier join.
|
|
80
|
+
//
|
|
81
|
+
// Before aliases existed, a table could be joined twice (or the base table
|
|
82
|
+
// joined to itself) with no alias, and such configs keep working: a join
|
|
83
|
+
// with no alias whose table name an earlier table already goes by (one
|
|
84
|
+
// without an explicit alias either) is joined as before, but goes by no name
|
|
85
|
+
// — the name keeps meaning the earlier table. Give it an `alias` to read it.
|
|
86
|
+
export function planView(viewName: string, view: ViewDef): ViewPlan {
|
|
87
|
+
const fail = (message: string): never => {
|
|
88
|
+
throw new Error(`[mock-server] View ${viewName}: ${message}`);
|
|
89
|
+
};
|
|
90
|
+
const baseAlias = view.baseAlias ?? view.base;
|
|
91
|
+
// Every name so far: whether it was given explicitly, and whether a row
|
|
92
|
+
// under it always exists when the view row does.
|
|
93
|
+
const names = new Map<string, { explicit: boolean; alwaysMatched: boolean }>([
|
|
94
|
+
[baseAlias, { explicit: view.baseAlias !== undefined, alwaysMatched: true }],
|
|
95
|
+
]);
|
|
96
|
+
const checkSource = (label: string, alias: string | undefined) => {
|
|
97
|
+
if (alias !== undefined && !names.has(alias)) {
|
|
98
|
+
fail(`${label} reads from '${alias}', which is neither the base nor an earlier join`);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const joins = (view.joins ?? []).map((def, index): PlannedJoin => {
|
|
102
|
+
const name = def.alias ?? def.table;
|
|
103
|
+
const fromAlias = def.fromAlias ?? baseAlias;
|
|
104
|
+
const label = `join ${index + 1} (${name})`;
|
|
105
|
+
checkSource(label, fromAlias);
|
|
106
|
+
const links = linksOf(def.on, def.fromAlias);
|
|
107
|
+
if (links.length === 0) fail(`${label} has no 'on' link`);
|
|
108
|
+
for (const link of links) if (link.kind === 'field') checkSource(label, link.fromAlias);
|
|
109
|
+
const taken = names.get(name);
|
|
110
|
+
if (taken && (taken.explicit || def.alias !== undefined)) {
|
|
111
|
+
fail(`${label}: alias '${name}' is already used; give each table its own alias`);
|
|
112
|
+
}
|
|
113
|
+
const inner = def.type === 'INNER';
|
|
114
|
+
const alwaysMatched = inner && names.get(fromAlias)!.alwaysMatched;
|
|
115
|
+
if (!taken) names.set(name, { explicit: def.alias !== undefined, alwaysMatched });
|
|
116
|
+
return {
|
|
117
|
+
def,
|
|
118
|
+
table: def.table,
|
|
119
|
+
alias: taken ? undefined : name,
|
|
120
|
+
fromAlias,
|
|
121
|
+
links,
|
|
122
|
+
inner,
|
|
123
|
+
backwardsJoin: def.backwardsJoin === true,
|
|
124
|
+
alwaysMatched,
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
const antiJoins = (view.antiJoins ?? []).map((def, index): PlannedAntiJoin => {
|
|
128
|
+
const label = `anti-join ${index + 1} (${def.table})`;
|
|
129
|
+
const links = linksOf(def.on, undefined);
|
|
130
|
+
if (links.length === 0) fail(`${label} has no 'on' link`);
|
|
131
|
+
for (const link of links) {
|
|
132
|
+
if (link.kind === 'field' && link.fromAlias !== undefined && link.fromAlias !== baseAlias) {
|
|
133
|
+
fail(`${label} can only read from the base table`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { def, table: def.table, links };
|
|
137
|
+
});
|
|
138
|
+
return { baseAlias, joins, antiJoins };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- materialization ---------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
type Where = ((joinRow: Row, viewRow: Row, sources: ViewRowSources) => boolean) | undefined;
|
|
144
|
+
|
|
145
|
+
const KEY_SEPARATOR = '\u0000';
|
|
146
|
+
|
|
147
|
+
// One index key for a list of values, or undefined when one is null or
|
|
148
|
+
// missing: GSF builds no join request from a null key, so it matches nothing.
|
|
149
|
+
// Typed, so 1 and '1' differ, as === tells them apart.
|
|
150
|
+
function keyOf(values: unknown[]): string | undefined {
|
|
151
|
+
if (values.some((value) => value === undefined || value === null)) return undefined;
|
|
152
|
+
return values.map((value) => `${typeof value}:${String(value)}`).join(KEY_SEPARATOR);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Finds a join's match for one view row: the joined rows whose fields equal
|
|
156
|
+
// the source fields (an index over them, built once), narrowed by the
|
|
157
|
+
// constants and the `where` predicate. GSF only lets a dataserver read a view
|
|
158
|
+
// whose joins are one-to-one (their links cover a unique index), so a valid
|
|
159
|
+
// view has at most one match; when there are more, the last one in table
|
|
160
|
+
// order wins — with a `where`, the first that passes — as before this model.
|
|
161
|
+
function matcher(
|
|
162
|
+
links: PlannedLink[],
|
|
163
|
+
where: Where,
|
|
164
|
+
rows: Row[],
|
|
165
|
+
): (viewRow: Row, sources: ViewRowSources) => Row | undefined {
|
|
166
|
+
const fieldLinks = links.flatMap((link) => (link.kind === 'field' ? [link] : []));
|
|
167
|
+
const valueLinks = links.flatMap((link) => (link.kind === 'value' ? [link] : []));
|
|
168
|
+
let index: Map<string, Row[]> | undefined;
|
|
169
|
+
const candidatesFor = (viewRow: Row, sources: ViewRowSources): Row[] => {
|
|
170
|
+
if (fieldLinks.length === 0) return rows;
|
|
171
|
+
if (!index) {
|
|
172
|
+
index = new Map();
|
|
173
|
+
for (const row of rows) {
|
|
174
|
+
const key = keyOf(fieldLinks.map((link) => row[link.to]));
|
|
175
|
+
if (key === undefined) continue;
|
|
176
|
+
const bucket = index.get(key);
|
|
177
|
+
if (bucket) bucket.push(row);
|
|
178
|
+
else index.set(key, [row]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const key = keyOf(
|
|
182
|
+
fieldLinks.map((link) =>
|
|
183
|
+
link.fromAlias === undefined
|
|
184
|
+
? viewRow[link.from]
|
|
185
|
+
: sources.tables[link.fromAlias]?.[link.from],
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
return key === undefined ? [] : (index.get(key) ?? []);
|
|
189
|
+
};
|
|
190
|
+
const constantsHold = (row: Row) => valueLinks.every((link) => row[link.to] === link.value);
|
|
191
|
+
return (viewRow, sources) => {
|
|
192
|
+
const candidates = candidatesFor(viewRow, sources);
|
|
193
|
+
if (where) {
|
|
194
|
+
return candidates.find((row) => constantsHold(row) && where(row, viewRow, sources));
|
|
195
|
+
}
|
|
196
|
+
return candidates.findLast(constantsHold);
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Where a materialization reads its rows: the base table's, and each join's
|
|
201
|
+
// and anti-join's by its planned entry (so one alias of a table can read a
|
|
202
|
+
// different state of it than another).
|
|
203
|
+
export interface ViewRowSource {
|
|
204
|
+
base: Row[];
|
|
205
|
+
rowsOf: (entry: PlannedJoin | PlannedAntiJoin) => Row[];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A view row, with the row each planned join matched (undefined for none),
|
|
209
|
+
// in plan order.
|
|
210
|
+
export interface ViewEntry {
|
|
211
|
+
row: Row;
|
|
212
|
+
matches: Array<Row | undefined>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The view's rows: each base row without an anti-joined match, joined in
|
|
216
|
+
// order (an INNER join with no match drops the row; a join whose source
|
|
217
|
+
// found nothing finds nothing either), plus the base `select` aliases, each
|
|
218
|
+
// join's `select` and the derived fields.
|
|
219
|
+
export function materializeEntries(
|
|
220
|
+
viewName: string,
|
|
221
|
+
view: ViewDef,
|
|
222
|
+
source: ViewRowSource,
|
|
223
|
+
): ViewEntry[] {
|
|
224
|
+
const plan = planView(viewName, view);
|
|
225
|
+
const antiJoins = plan.antiJoins.map((antiJoin) =>
|
|
226
|
+
matcher(antiJoin.links, antiJoin.def.where, source.rowsOf(antiJoin)),
|
|
227
|
+
);
|
|
228
|
+
const joins = plan.joins.map((join) => matcher(join.links, join.def.where, source.rowsOf(join)));
|
|
229
|
+
const entries: ViewEntry[] = [];
|
|
230
|
+
for (const base of source.base) {
|
|
231
|
+
const sources: ViewRowSources = { tables: { [plan.baseAlias]: base } };
|
|
232
|
+
if (antiJoins.some((match) => match(base, sources))) continue;
|
|
233
|
+
const row: Row = { ...base };
|
|
234
|
+
for (const [name, field] of Object.entries(view.select ?? {})) row[name] = base[field];
|
|
235
|
+
const matches: Array<Row | undefined> = [];
|
|
236
|
+
let dropped = false;
|
|
237
|
+
for (const [index, join] of plan.joins.entries()) {
|
|
238
|
+
// `where` sees the view row so far, as it always has: the base row's
|
|
239
|
+
// columns and the selects before this join.
|
|
240
|
+
const match = sources.tables[join.fromAlias] ? joins[index](row, sources) : undefined;
|
|
241
|
+
if (!match && join.inner) {
|
|
242
|
+
dropped = true;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
matches.push(match);
|
|
246
|
+
if (join.alias !== undefined) sources.tables[join.alias] = match;
|
|
247
|
+
for (const [name, field] of Object.entries(join.def.select ?? {})) row[name] = match?.[field];
|
|
248
|
+
}
|
|
249
|
+
if (dropped) continue;
|
|
250
|
+
if (!view.derived) {
|
|
251
|
+
entries.push({ row, matches });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const derived: Row = { ...row };
|
|
255
|
+
for (const [name, compute] of Object.entries(view.derived)) {
|
|
256
|
+
derived[name] = compute(row, sources);
|
|
257
|
+
}
|
|
258
|
+
entries.push({ row: derived, matches });
|
|
259
|
+
}
|
|
260
|
+
return entries;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The planned joins and anti-joins that read a table.
|
|
264
|
+
export function entriesReading(
|
|
265
|
+
plan: ViewPlan,
|
|
266
|
+
tableName: string,
|
|
267
|
+
): Array<PlannedJoin | PlannedAntiJoin> {
|
|
268
|
+
return [...plan.joins, ...plan.antiJoins].filter((entry) => entry.table === tableName);
|
|
269
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,10 @@ export type {
|
|
|
33
33
|
HttpRouteDef,
|
|
34
34
|
IndexDef,
|
|
35
35
|
JoinDef,
|
|
36
|
+
JoinFieldLink,
|
|
37
|
+
JoinLink,
|
|
38
|
+
JoinType,
|
|
39
|
+
JoinValueLink,
|
|
36
40
|
MockServerConfig,
|
|
37
41
|
OnMessageCtx,
|
|
38
42
|
OnMessageHook,
|
|
@@ -48,4 +52,5 @@ export type {
|
|
|
48
52
|
SsoIdentityProvider,
|
|
49
53
|
TableDef,
|
|
50
54
|
ViewDef,
|
|
55
|
+
ViewRowSources,
|
|
51
56
|
} from './types.ts';
|