@cosmicdrift/kumiko-framework 0.238.0 → 0.240.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/package.json +3 -3
- package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +381 -0
- package/src/db/event-store-executor-read.ts +186 -34
- package/src/engine/__tests__/engine.test.ts +77 -28
- package/src/engine/__tests__/screen.test.ts +455 -0
- package/src/engine/boot-validator/screens.ts +111 -1
- package/src/engine/entity-handlers.ts +7 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/registry-facade.ts +3 -3
- package/src/engine/registry-state.ts +13 -2
- package/src/engine/registry-validate.ts +47 -14
- package/src/engine/registry.ts +2 -2
- package/src/engine/screen-helpers.ts +5 -0
- package/src/engine/types/index.ts +1 -0
- package/src/i18n/required-surface-keys.ts +20 -1
- package/src/ui-types/index.ts +6 -1
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
|
-
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto";
|
|
2
|
+
import { collectPiiSubjectFields, computeBlindIndex, configuredBlindIndexKey } from "../crypto";
|
|
3
|
+
import { escapeLikePattern } from "../crypto/ciphertext-pattern";
|
|
3
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql";
|
|
4
5
|
import { coerceRow, extractTableInfo } from "../db/query";
|
|
5
6
|
import { buildOwnershipClause, normalizeAccessEntry, shiftParams } from "../engine/ownership";
|
|
6
|
-
import type { EntityId } from "../engine/types";
|
|
7
|
+
import type { EntityDefinition, EntityId } from "../engine/types";
|
|
7
8
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
8
9
|
import { UnprocessableError } from "../errors";
|
|
9
10
|
import { getStreamVersion } from "../event-store";
|
|
11
|
+
import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta";
|
|
10
12
|
import { rehydrateCompoundTypes } from "./compound-types";
|
|
11
13
|
import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor";
|
|
14
|
+
import { collectEncryptedFieldNames } from "./entity-field-encryption";
|
|
12
15
|
import type { EventStoreExecutor } from "./event-store-executor";
|
|
13
16
|
import { buildFilterWhere, type ExecutorContext, type Table } from "./event-store-executor-context";
|
|
14
|
-
import { toSnakeCase } from "./table-builder";
|
|
17
|
+
import { buildEntityTable, toSnakeCase } from "./table-builder";
|
|
18
|
+
import type { TenantDb, TenantDbMode } from "./tenant-db";
|
|
15
19
|
|
|
16
20
|
// The two read verbs (list/detail) of the event-store-executor. Split out
|
|
17
21
|
// of event-store-executor.ts (#1005, Welle 2) — behavior-preserving
|
|
@@ -183,9 +187,9 @@ function applyScreenFilter(
|
|
|
183
187
|
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
184
188
|
}
|
|
185
189
|
} else {
|
|
186
|
-
// Blind-
|
|
187
|
-
// in bun-db/query.ts —
|
|
188
|
-
//
|
|
190
|
+
// Blind-index OR rewrite (#818), lock-step with buildWhereClause
|
|
191
|
+
// in bun-db/query.ts — equality on lookupable fields matches the
|
|
192
|
+
// plaintext arm OR the HMAC arm.
|
|
189
193
|
const bidxKey = configuredBlindIndexKey();
|
|
190
194
|
if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
|
|
191
195
|
params.push(value, computeBlindIndex(bidxKey, value));
|
|
@@ -200,6 +204,102 @@ function applyScreenFilter(
|
|
|
200
204
|
}
|
|
201
205
|
}
|
|
202
206
|
|
|
207
|
+
// fw#2660 — above this many target-row matches, drop the reference clause
|
|
208
|
+
// entirely instead of truncating: an arbitrary 200-row slice of "which
|
|
209
|
+
// targets match" would silently hide rows a full-cardinality match would
|
|
210
|
+
// have found. Native text search on the entity's own fields still applies.
|
|
211
|
+
const MAX_REFERENCE_SEARCH_IDS = 200;
|
|
212
|
+
|
|
213
|
+
function physicalColumnName(table: Table, field: string): string {
|
|
214
|
+
return (table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
type ReferenceSearchDescriptor = {
|
|
218
|
+
readonly ownColumn: string;
|
|
219
|
+
readonly targetEntityName: string;
|
|
220
|
+
readonly labelField: string;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Registry-declared `searchable` reference fields, plus the implicit
|
|
224
|
+
// tenantId→tenant.name row-meta reference (fw#2660) — both resolve a text
|
|
225
|
+
// search term against a target row's label instead of the raw FK column.
|
|
226
|
+
function collectReferenceSearchDescriptors(
|
|
227
|
+
table: Table,
|
|
228
|
+
referenceSearch: NonNullable<Parameters<EventStoreExecutor["list"]>[3]>["referenceSearch"],
|
|
229
|
+
dbMode: TenantDbMode,
|
|
230
|
+
): readonly ReferenceSearchDescriptor[] {
|
|
231
|
+
if (!referenceSearch) return [];
|
|
232
|
+
const descriptors: ReferenceSearchDescriptor[] = referenceSearch.fields.map((f) => ({
|
|
233
|
+
ownColumn: f.fieldName,
|
|
234
|
+
targetEntityName: f.targetEntityName,
|
|
235
|
+
labelField: f.labelField,
|
|
236
|
+
}));
|
|
237
|
+
for (const [ownColumn, ref] of Object.entries(LIST_ROW_META_REFERENCES)) {
|
|
238
|
+
if (table[ownColumn] === undefined) continue;
|
|
239
|
+
// In "tenant" mode the outer tenant filter already pins tenantId to one
|
|
240
|
+
// of exactly two values, so a by-name lookup here can never narrow that further.
|
|
241
|
+
if (ownColumn === "tenantId" && dbMode === "tenant") continue;
|
|
242
|
+
descriptors.push({ ownColumn, targetEntityName: ref.refEntity, labelField: ref.refLabelField });
|
|
243
|
+
}
|
|
244
|
+
return descriptors;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Tenant-scoped ILIKE lookup against one reference target — never raw/
|
|
248
|
+
// unscoped SQL, a match in a foreign tenant must never surface a row here
|
|
249
|
+
// (fw#2660's hard constraint). Mirrors the main table's own tenant filter
|
|
250
|
+
// (see the `table["tenantId"]` check further down in list()).
|
|
251
|
+
async function resolveReferenceMatches(
|
|
252
|
+
descriptor: ReferenceSearchDescriptor,
|
|
253
|
+
searchTerm: string,
|
|
254
|
+
resolveEntity: (entityName: string) => EntityDefinition | undefined,
|
|
255
|
+
db: TenantDb,
|
|
256
|
+
): Promise<{ readonly ownColumn: string; readonly ids: readonly string[] } | undefined> {
|
|
257
|
+
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
258
|
+
if (targetEntity === undefined) {
|
|
259
|
+
// skip: unknown target entity — registry inconsistency the boot-validator
|
|
260
|
+
// should have caught; fail open (no clause) rather than 500 the request
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
if (targetEntity.fields[descriptor.labelField] === undefined) {
|
|
264
|
+
// skip: labelField isn't a real (declared) column on the target entity —
|
|
265
|
+
// row-meta references (e.g. tenant.name) always resolve here since
|
|
266
|
+
// `name` is a declared field on the tenant entity, not an id/row-meta column
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
if (
|
|
270
|
+
collectEncryptedFieldNames(targetEntity).has(descriptor.labelField) ||
|
|
271
|
+
collectPiiSubjectFields(targetEntity).includes(descriptor.labelField)
|
|
272
|
+
) {
|
|
273
|
+
// skip: encrypted/PII labelField — ILIKE can never match ciphertext,
|
|
274
|
+
// emitting the query would just be a silent, permanent non-match
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
|
|
279
|
+
const targetTableName = String(
|
|
280
|
+
(targetTable as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL],
|
|
281
|
+
);
|
|
282
|
+
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
283
|
+
const subParams: unknown[] = [`%${escapeLikePattern(searchTerm)}%`];
|
|
284
|
+
let tenantClause = "";
|
|
285
|
+
if (targetTable["tenantId"] !== undefined && db.mode === "tenant") {
|
|
286
|
+
subParams.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
287
|
+
tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
|
|
288
|
+
}
|
|
289
|
+
// ::text cast covers a non-text labelField (e.g. a number/select column
|
|
290
|
+
// used as label) — Postgres has no ILIKE for those types otherwise.
|
|
291
|
+
const sql =
|
|
292
|
+
`SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
|
|
293
|
+
`LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
|
|
294
|
+
const rows = await executeRawQueryRead<{ id: string }>(db.raw, sql, subParams);
|
|
295
|
+
if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
|
|
296
|
+
// skip: no match, or over the cap — drop the clause rather than
|
|
297
|
+
// truncate to an arbitrary slice of matching targets
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
|
|
301
|
+
}
|
|
302
|
+
|
|
203
303
|
export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
|
|
204
304
|
const {
|
|
205
305
|
table,
|
|
@@ -231,12 +331,35 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
231
331
|
}
|
|
232
332
|
|
|
233
333
|
let filterIds: EntityId[] | undefined;
|
|
334
|
+
// fw#2660 — true once a searchable-reference or row-meta-reference
|
|
335
|
+
// clause contributed rows, so the total-count fast-path below knows
|
|
336
|
+
// `filterIds.length` alone would undercount.
|
|
337
|
+
let referenceClauseActive = false;
|
|
338
|
+
// Build the WHERE clause as raw SQL — ownership produces a
|
|
339
|
+
// parameterised fragment that we splice in alongside simple WhereObject
|
|
340
|
+
// conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
|
|
341
|
+
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
342
|
+
const whereSql: string[] = [];
|
|
343
|
+
const params: unknown[] = [];
|
|
344
|
+
const physicalCol = (field: string): string => physicalColumnName(table, field);
|
|
345
|
+
const colSql = (field: string): string => `"${physicalCol(field)}"`;
|
|
346
|
+
|
|
347
|
+
// Field-level read access gates filtering and sorting the same way it
|
|
348
|
+
// gates the projected row: a caller who cannot read a field must not be
|
|
349
|
+
// able to probe its values through result counts or ordering (fw#2629).
|
|
350
|
+
// Hoisted above the search block (fw#2660) — a searchable-reference
|
|
351
|
+
// clause is the same probe surface as a filter/sort and must be gated
|
|
352
|
+
// the same way, before the reference-match query even runs.
|
|
353
|
+
const fieldReadClause = (field: string) =>
|
|
354
|
+
buildOwnershipClause(user, normalizeAccessEntry(entity.fields[field]?.access?.read), table);
|
|
355
|
+
|
|
234
356
|
// Build-Time options.searchAdapter gewinnt; runtime-Override ist
|
|
235
357
|
// Fallback für die defaultEntityQueryHandler-Pipe (die nutzt den
|
|
236
358
|
// ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
|
|
237
359
|
// beim Definition-Time noch keinen Server-Context hat).
|
|
238
360
|
const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
|
|
239
361
|
if (payload.search) {
|
|
362
|
+
const searchTerm = payload.search;
|
|
240
363
|
// #2032 — a search term with no adapter wired must fail loud, not
|
|
241
364
|
// silently return the unfiltered list dressed up as a search result.
|
|
242
365
|
if (!effectiveSearchAdapter) {
|
|
@@ -251,29 +374,63 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
251
374
|
// SYSTEM_TENANT_ID; everything else under the session tenant — db.mode
|
|
252
375
|
// must not invent a second predicate (kumiko-framework#2412).
|
|
253
376
|
const searchTenantId = streamTenantFor(user);
|
|
254
|
-
const results = await effectiveSearchAdapter.search(searchTenantId,
|
|
377
|
+
const results = await effectiveSearchAdapter.search(searchTenantId, searchTerm, {
|
|
255
378
|
filterType: entityName,
|
|
256
379
|
});
|
|
257
380
|
filterIds = results.map((r) => r.entityId);
|
|
258
|
-
|
|
381
|
+
|
|
382
|
+
// fw#2660 — union in reference-column label matches: a searchable
|
|
383
|
+
// reference field (or the implicit tenantId→tenant.name row-meta
|
|
384
|
+
// reference) whose target row's labelField matches the search term
|
|
385
|
+
// also counts as a hit on this entity, not just a raw text-field hit.
|
|
386
|
+
const referenceSearch = runtimeOptions?.referenceSearch;
|
|
387
|
+
// fw#2629 parity: a caller who cannot read the reference column itself
|
|
388
|
+
// must not be able to infer its value by matching the target's label —
|
|
389
|
+
// same probe surface as filtering/sorting on it directly.
|
|
390
|
+
const descriptors = collectReferenceSearchDescriptors(
|
|
391
|
+
table,
|
|
392
|
+
referenceSearch,
|
|
393
|
+
db.mode,
|
|
394
|
+
).filter((d) => fieldReadClause(d.ownColumn).kind === "pass");
|
|
395
|
+
const referenceMatches = referenceSearch
|
|
396
|
+
? (
|
|
397
|
+
await Promise.all(
|
|
398
|
+
descriptors.map((d) =>
|
|
399
|
+
resolveReferenceMatches(d, searchTerm, referenceSearch.resolveEntity, db),
|
|
400
|
+
),
|
|
401
|
+
)
|
|
402
|
+
).filter(
|
|
403
|
+
(m): m is { readonly ownColumn: string; readonly ids: readonly string[] } =>
|
|
404
|
+
m !== undefined,
|
|
405
|
+
)
|
|
406
|
+
: [];
|
|
407
|
+
|
|
408
|
+
if (filterIds.length === 0 && referenceMatches.length === 0) {
|
|
259
409
|
return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
|
|
260
410
|
}
|
|
411
|
+
|
|
412
|
+
const orParts: string[] = [];
|
|
413
|
+
if (filterIds.length > 0) {
|
|
414
|
+
const placeholders = filterIds.map((id) => {
|
|
415
|
+
params.push(id);
|
|
416
|
+
return `$${params.length}`;
|
|
417
|
+
});
|
|
418
|
+
orParts.push(`${colSql("id")} IN (${placeholders.join(", ")})`);
|
|
419
|
+
}
|
|
420
|
+
for (const match of referenceMatches) {
|
|
421
|
+
const placeholders = match.ids.map((id) => {
|
|
422
|
+
params.push(id);
|
|
423
|
+
return `$${params.length}`;
|
|
424
|
+
});
|
|
425
|
+
orParts.push(`${colSql(match.ownColumn)} IN (${placeholders.join(", ")})`);
|
|
426
|
+
referenceClauseActive = true;
|
|
427
|
+
}
|
|
428
|
+
// Parenthesized as one whereSql entry — whereSql is joined with
|
|
429
|
+
// " AND " below, an unparenthesized OR here would leak past the
|
|
430
|
+
// tenant filter that follows.
|
|
431
|
+
whereSql.push(`(${orParts.join(" OR ")})`);
|
|
261
432
|
}
|
|
262
433
|
|
|
263
|
-
// Build the WHERE clause as raw SQL — ownership produces a
|
|
264
|
-
// parameterised fragment that we splice in alongside simple WhereObject
|
|
265
|
-
// conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
|
|
266
|
-
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
267
|
-
const whereSql: string[] = [];
|
|
268
|
-
const params: unknown[] = [];
|
|
269
|
-
const physicalCol = (field: string): string =>
|
|
270
|
-
(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field);
|
|
271
|
-
const colSql = (field: string): string => `"${physicalCol(field)}"`;
|
|
272
|
-
// Field-level read access gates filtering and sorting the same way it
|
|
273
|
-
// gates the projected row: a caller who cannot read a field must not be
|
|
274
|
-
// able to probe its values through result counts or ordering (fw#2629).
|
|
275
|
-
const fieldReadClause = (field: string) =>
|
|
276
|
-
buildOwnershipClause(user, normalizeAccessEntry(entity.fields[field]?.access?.read), table);
|
|
277
434
|
const sortField =
|
|
278
435
|
payload.sort && table[payload.sort] && fieldReadClause(payload.sort).kind === "pass"
|
|
279
436
|
? payload.sort
|
|
@@ -305,13 +462,6 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
305
462
|
);
|
|
306
463
|
}
|
|
307
464
|
}
|
|
308
|
-
if (filterIds) {
|
|
309
|
-
const placeholders = filterIds.map((id) => {
|
|
310
|
-
params.push(id);
|
|
311
|
-
return `$${params.length}`;
|
|
312
|
-
});
|
|
313
|
-
whereSql.push(`${colSql("id")} IN (${placeholders.join(", ")})`);
|
|
314
|
-
}
|
|
315
465
|
if (ownership.kind === "sql") {
|
|
316
466
|
const shifted = shiftParams(
|
|
317
467
|
{ sqlText: ownership.sqlText, params: ownership.params },
|
|
@@ -397,13 +547,15 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
397
547
|
sortText === undefined ? encodeCursor(cursorId) : encodeKeysetCursor(sortText, cursorId);
|
|
398
548
|
}
|
|
399
549
|
|
|
400
|
-
// total: extra COUNT(*) —
|
|
401
|
-
// Postgres
|
|
402
|
-
//
|
|
403
|
-
//
|
|
550
|
+
// total: extra COUNT(*) — only when explicitly requested (pager UI).
|
|
551
|
+
// Postgres cost is O(table-scan) without a filter, with a filter as
|
|
552
|
+
// expensive as the corresponding WHERE — cheap enough on indexed columns.
|
|
553
|
+
// On the search path, `total = filterIds.length` needs no extra query —
|
|
554
|
+
// unless a reference match contributed additional rows (fw#2660), in
|
|
555
|
+
// which case the raw filterIds length alone would undercount.
|
|
404
556
|
let total: number | undefined;
|
|
405
557
|
if (totalCount) {
|
|
406
|
-
if (filterIds) {
|
|
558
|
+
if (filterIds && !referenceClauseActive) {
|
|
407
559
|
total = filterIds.length;
|
|
408
560
|
} else {
|
|
409
561
|
const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
|
|
@@ -621,6 +621,83 @@ describe("createRegistry", () => {
|
|
|
621
621
|
expect(registry.getSearchableFields("user")).toEqual(["email", "lastName"]);
|
|
622
622
|
expect(registry.getSearchableFields("nonexistent")).toEqual([]);
|
|
623
623
|
});
|
|
624
|
+
|
|
625
|
+
test("returns searchable reference fields for entity, without leaking them into getSearchableFields (fw#2660)", () => {
|
|
626
|
+
const feature = defineFeature("crm", (r) => {
|
|
627
|
+
r.entity(
|
|
628
|
+
"customer",
|
|
629
|
+
createEntity({ table: "Customers", fields: { name: createTextField() } }),
|
|
630
|
+
);
|
|
631
|
+
r.entity(
|
|
632
|
+
"order",
|
|
633
|
+
createEntity({
|
|
634
|
+
table: "Orders",
|
|
635
|
+
fields: {
|
|
636
|
+
note: createTextField({ searchable: true }),
|
|
637
|
+
customerId: {
|
|
638
|
+
type: "reference",
|
|
639
|
+
entity: "customer",
|
|
640
|
+
labelField: "name",
|
|
641
|
+
searchable: true,
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
}),
|
|
645
|
+
);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
const registry = createRegistry([feature]);
|
|
649
|
+
// Meili invariant: the search-index build reads getSearchableFields, and
|
|
650
|
+
// a reference field is never text-indexed there — it's resolved via a
|
|
651
|
+
// live DB lookup at query time instead (getSearchableReferences).
|
|
652
|
+
expect(registry.getSearchableFields("order")).toEqual(["note"]);
|
|
653
|
+
expect(registry.getSearchableReferences("order")).toEqual([
|
|
654
|
+
{ fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
|
|
655
|
+
]);
|
|
656
|
+
expect(registry.getSearchableReferences("nonexistent")).toEqual([]);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test("throws at boot when a searchable reference field has no explicit labelField (fw#2660)", () => {
|
|
660
|
+
const feature = defineFeature("crm", (r) => {
|
|
661
|
+
r.entity(
|
|
662
|
+
"customer",
|
|
663
|
+
createEntity({ table: "Customers", fields: { name: createTextField() } }),
|
|
664
|
+
);
|
|
665
|
+
r.entity(
|
|
666
|
+
"order",
|
|
667
|
+
createEntity({
|
|
668
|
+
table: "Orders",
|
|
669
|
+
fields: {
|
|
670
|
+
customerId: { type: "reference", entity: "customer", searchable: true },
|
|
671
|
+
},
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
expect(() => createRegistry([feature])).toThrow(/labelField/);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("throws at boot when searchable is combined with multiple on a reference field (fw#2660)", () => {
|
|
680
|
+
const feature = defineFeature("crm", (r) => {
|
|
681
|
+
r.entity("tag", createEntity({ table: "Tags", fields: { name: createTextField() } }));
|
|
682
|
+
r.entity(
|
|
683
|
+
"post",
|
|
684
|
+
createEntity({
|
|
685
|
+
table: "Posts",
|
|
686
|
+
fields: {
|
|
687
|
+
tagIds: {
|
|
688
|
+
type: "reference",
|
|
689
|
+
entity: "tag",
|
|
690
|
+
labelField: "name",
|
|
691
|
+
searchable: true,
|
|
692
|
+
multiple: true,
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
}),
|
|
696
|
+
);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
expect(() => createRegistry([feature])).toThrow(/multiple/);
|
|
700
|
+
});
|
|
624
701
|
});
|
|
625
702
|
|
|
626
703
|
// --- Access ---
|
|
@@ -1597,7 +1674,6 @@ describe("r.relation()", () => {
|
|
|
1597
1674
|
type: "belongsTo",
|
|
1598
1675
|
target: "department",
|
|
1599
1676
|
foreignKey: "departmentId",
|
|
1600
|
-
searchInclude: ["name"],
|
|
1601
1677
|
});
|
|
1602
1678
|
});
|
|
1603
1679
|
|
|
@@ -1613,7 +1689,6 @@ describe("r.relation()", () => {
|
|
|
1613
1689
|
type: "manyToMany",
|
|
1614
1690
|
target: "role",
|
|
1615
1691
|
through: { table: "UserRoles", sourceKey: "userId", targetKey: "roleId" },
|
|
1616
|
-
searchInclude: ["name"],
|
|
1617
1692
|
});
|
|
1618
1693
|
});
|
|
1619
1694
|
|
|
@@ -1648,7 +1723,6 @@ describe("registry relations", () => {
|
|
|
1648
1723
|
type: "manyToMany",
|
|
1649
1724
|
target: "role",
|
|
1650
1725
|
through: { table: "UserRoles", sourceKey: "userId", targetKey: "roleId" },
|
|
1651
|
-
searchInclude: ["name"],
|
|
1652
1726
|
});
|
|
1653
1727
|
});
|
|
1654
1728
|
|
|
@@ -1657,31 +1731,6 @@ describe("registry relations", () => {
|
|
|
1657
1731
|
expect(rels["roles"]).toBeDefined();
|
|
1658
1732
|
});
|
|
1659
1733
|
|
|
1660
|
-
test("getSearchIncludes returns fields to index from relations", () => {
|
|
1661
|
-
const feature = defineFeature("test", (r) => {
|
|
1662
|
-
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
1663
|
-
r.entity("role", createEntity({ table: "Roles", fields: { name: createTextField() } }));
|
|
1664
|
-
r.entity("department", createEntity({ table: "Depts", fields: { name: createTextField() } }));
|
|
1665
|
-
r.relation("user", "roles", {
|
|
1666
|
-
type: "manyToMany",
|
|
1667
|
-
target: "role",
|
|
1668
|
-
through: { table: "UserRoles", sourceKey: "userId", targetKey: "roleId" },
|
|
1669
|
-
searchInclude: ["name"],
|
|
1670
|
-
});
|
|
1671
|
-
r.relation("user", "department", {
|
|
1672
|
-
type: "belongsTo",
|
|
1673
|
-
target: "department",
|
|
1674
|
-
foreignKey: "departmentId",
|
|
1675
|
-
searchInclude: ["name"],
|
|
1676
|
-
});
|
|
1677
|
-
});
|
|
1678
|
-
|
|
1679
|
-
const registry = createRegistry([feature]);
|
|
1680
|
-
const includes = registry.getSearchIncludes("user");
|
|
1681
|
-
expect(includes.get("roles")).toEqual(["name"]);
|
|
1682
|
-
expect(includes.get("department")).toEqual(["name"]);
|
|
1683
|
-
});
|
|
1684
|
-
|
|
1685
1734
|
test("throws on relation to non-existent entity", () => {
|
|
1686
1735
|
const feature = defineFeature("test", (r) => {
|
|
1687
1736
|
r.entity("user", createEntity({ table: "Users", fields: {} }));
|