@open-mercato/shared 0.6.8-develop.7099.1.7ba2771d06 → 0.7.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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -4
- package/dist/lib/auth/jwt.js +0 -6
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/organizationAccess.js +4 -7
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/server.js +7 -38
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +1 -6
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +8 -24
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +2 -8
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/location/countries.js +0 -12
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +1 -4
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/engine.js +34 -270
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/search/config.js +0 -1
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +15 -69
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +0 -15
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +3 -10
- package/src/lib/auth/__tests__/jwt.test.ts +0 -13
- package/src/lib/auth/__tests__/organizationAccess.test.ts +1 -36
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +0 -324
- package/src/lib/auth/__tests__/server.test.ts +0 -104
- package/src/lib/auth/jwt.ts +0 -17
- package/src/lib/auth/organizationAccess.ts +3 -11
- package/src/lib/auth/server.ts +8 -78
- package/src/lib/commands/__tests__/command-bus.test.ts +0 -31
- package/src/lib/commands/command-bus.ts +1 -8
- package/src/lib/crud/__tests__/crud-factory.test.ts +0 -165
- package/src/lib/crud/factory.ts +7 -33
- package/src/lib/data/__tests__/engine.event-validation.test.ts +1 -9
- package/src/lib/data/engine.ts +1 -7
- package/src/lib/location/__tests__/countries.test.ts +0 -15
- package/src/lib/location/countries.ts +0 -17
- package/src/lib/openapi/crud.ts +0 -3
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +15 -162
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +1 -11
- package/src/lib/query/__tests__/engine.test.ts +7 -445
- package/src/lib/query/engine.ts +54 -434
- package/src/lib/query/types.ts +0 -15
- package/src/lib/search/config.ts +0 -10
- package/src/modules/events/factory.ts +19 -111
- package/src/modules/events/types.ts +0 -17
- package/src/modules/registry.ts +0 -40
- package/src/modules/widgets/component-registry.ts +0 -14
- package/dist/lib/auth/mfaPendingAccess.js +0 -42
- package/dist/lib/auth/mfaPendingAccess.js.map +0 -7
- package/dist/lib/auth/principal-service.js +0 -1
- package/dist/lib/auth/principal-service.js.map +0 -7
- package/dist/lib/html/htmlToPlainText.js +0 -16
- package/dist/lib/html/htmlToPlainText.js.map +0 -7
- package/dist/lib/query/count-cap.js +0 -11
- package/dist/lib/query/count-cap.js.map +0 -7
- package/dist/lib/queue/dispatchOrigin.js +0 -20
- package/dist/lib/queue/dispatchOrigin.js.map +0 -7
- package/dist/lib/search/entityAccess.js +0 -44
- package/dist/lib/search/entityAccess.js.map +0 -7
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +0 -69
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +0 -67
- package/src/lib/auth/mfaPendingAccess.ts +0 -70
- package/src/lib/auth/principal-service.ts +0 -110
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +0 -59
- package/src/lib/html/htmlToPlainText.ts +0 -17
- package/src/lib/query/__tests__/count-cap-plan.test.ts +0 -240
- package/src/lib/query/__tests__/count-cap.test.ts +0 -41
- package/src/lib/query/count-cap.ts +0 -19
- package/src/lib/queue/dispatchOrigin.ts +0 -35
- package/src/lib/search/entityAccess.ts +0 -132
- package/src/modules/events/__tests__/factory.test.ts +0 -88
package/dist/lib/query/engine.js
CHANGED
|
@@ -11,8 +11,6 @@ import {
|
|
|
11
11
|
isSearchFilterOp
|
|
12
12
|
} from "../search/availability.js";
|
|
13
13
|
import { tokenizeText } from "../search/tokenize.js";
|
|
14
|
-
import { fieldNameCandidates } from "./encrypted-sort.js";
|
|
15
|
-
import { isTenantDataEncryptionEnabled } from "../encryption/toggles.js";
|
|
16
14
|
import { runBeforeQueryPipeline, runAfterQueryPipeline } from "./query-extension-runner.js";
|
|
17
15
|
import {
|
|
18
16
|
buildCustomFieldDefinitionIndexFromRows,
|
|
@@ -20,61 +18,11 @@ import {
|
|
|
20
18
|
} from "../crud/custom-field-definition-index.js";
|
|
21
19
|
import { warnOnCiphertextLikeFallback } from "./ciphertext-search-warning.js";
|
|
22
20
|
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from "./encrypted-sort.js";
|
|
23
|
-
import { resolveListCountCap } from "./count-cap.js";
|
|
24
21
|
import { mapWithConcurrency } from "./bounded-decrypt.js";
|
|
25
|
-
import { parseNumberWithDefault } from "../number.js";
|
|
26
22
|
import { createLogger } from "../logger/index.js";
|
|
27
23
|
const logger = createLogger("shared").child({ component: "query" });
|
|
28
24
|
const DECRYPT_CONCURRENCY = 8;
|
|
29
25
|
const entityTableCache = /* @__PURE__ */ new Map();
|
|
30
|
-
function isEncryptedLikeField(encrypted, field) {
|
|
31
|
-
return fieldNameCandidates(field).some((candidate) => encrypted.has(candidate));
|
|
32
|
-
}
|
|
33
|
-
const ENCRYPTED_LIKE_FIELDS_TTL_MS = 6e4;
|
|
34
|
-
const ENCRYPTED_LIKE_FIELDS_CACHE_CAP = 500;
|
|
35
|
-
const encryptedLikeFieldsCache = /* @__PURE__ */ new Map();
|
|
36
|
-
async function resolveEncryptedLikeFieldSet(read, entity, tenantId) {
|
|
37
|
-
const key = `${entity}|${tenantId ?? ""}`;
|
|
38
|
-
const hit = encryptedLikeFieldsCache.get(key);
|
|
39
|
-
if (hit && Date.now() - hit.at < ENCRYPTED_LIKE_FIELDS_TTL_MS) return hit.fields;
|
|
40
|
-
const names = await read();
|
|
41
|
-
const fields = /* @__PURE__ */ new Set();
|
|
42
|
-
for (const name of names ?? []) {
|
|
43
|
-
for (const candidate of fieldNameCandidates(String(name))) fields.add(candidate);
|
|
44
|
-
}
|
|
45
|
-
if (encryptedLikeFieldsCache.size >= ENCRYPTED_LIKE_FIELDS_CACHE_CAP) encryptedLikeFieldsCache.clear();
|
|
46
|
-
encryptedLikeFieldsCache.set(key, { at: Date.now(), fields });
|
|
47
|
-
return fields;
|
|
48
|
-
}
|
|
49
|
-
function clearEncryptedLikeFieldsCache() {
|
|
50
|
-
encryptedLikeFieldsCache.clear();
|
|
51
|
-
}
|
|
52
|
-
const COLUMN_EXISTS_CACHE_DEFAULT_TTL_MS = 3e5;
|
|
53
|
-
const COLUMN_EXISTS_CACHE_DEFAULT_MAX_ENTRIES = 1e4;
|
|
54
|
-
const columnExistsCache = /* @__PURE__ */ new Map();
|
|
55
|
-
function resolveColumnExistsCacheTtlMs() {
|
|
56
|
-
return parseNumberWithDefault(process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS, COLUMN_EXISTS_CACHE_DEFAULT_TTL_MS, { integer: true, min: 0 });
|
|
57
|
-
}
|
|
58
|
-
function resolveColumnExistsCacheMaxEntries() {
|
|
59
|
-
return parseNumberWithDefault(process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES, COLUMN_EXISTS_CACHE_DEFAULT_MAX_ENTRIES, { integer: true, min: 1 });
|
|
60
|
-
}
|
|
61
|
-
function storeColumnExists(key, value, ttlMs) {
|
|
62
|
-
const maxEntries = resolveColumnExistsCacheMaxEntries();
|
|
63
|
-
if (columnExistsCache.size >= maxEntries) {
|
|
64
|
-
const now = Date.now();
|
|
65
|
-
for (const [entryKey, entry] of columnExistsCache) {
|
|
66
|
-
if (entry.expiresAt <= now) columnExistsCache.delete(entryKey);
|
|
67
|
-
}
|
|
68
|
-
if (columnExistsCache.size >= maxEntries) columnExistsCache.clear();
|
|
69
|
-
}
|
|
70
|
-
columnExistsCache.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
71
|
-
}
|
|
72
|
-
function clearColumnExistsCache() {
|
|
73
|
-
columnExistsCache.clear();
|
|
74
|
-
}
|
|
75
|
-
function columnExistsCacheSize() {
|
|
76
|
-
return columnExistsCache.size;
|
|
77
|
-
}
|
|
78
26
|
const ENTITY_ID_PATTERN = /^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$/;
|
|
79
27
|
const isValidEntityIdShape = (value) => ENTITY_ID_PATTERN.test(value);
|
|
80
28
|
const pluralizeBaseName = (name) => {
|
|
@@ -191,6 +139,7 @@ class BasicQueryEngine {
|
|
|
191
139
|
this.em = em;
|
|
192
140
|
this.getDbFn = getDbFn;
|
|
193
141
|
this.resolveEncryptionService = resolveEncryptionService;
|
|
142
|
+
this.columnCache = /* @__PURE__ */ new Map();
|
|
194
143
|
this.searchAliasSeq = 0;
|
|
195
144
|
this.searchAvailabilityInstance = null;
|
|
196
145
|
}
|
|
@@ -283,35 +232,6 @@ class BasicQueryEngine {
|
|
|
283
232
|
const searchEnabled = !skipAutoScope && await this.searchAvailability().staticEnabled();
|
|
284
233
|
const hasSearchTokens = searchEnabled && searchFilters.length ? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope) : false;
|
|
285
234
|
const searchActive = searchEnabled && hasSearchTokens;
|
|
286
|
-
let encryptedLikeFields = null;
|
|
287
|
-
if (searchActive && searchConfig.useIlikeForNonEncryptedFields === true && searchFilters.some((filter) => !String(filter.field).startsWith("cf:"))) {
|
|
288
|
-
try {
|
|
289
|
-
const service = this.getEncryptionService();
|
|
290
|
-
const readEncryptedFieldNames = service?.getEncryptedFieldNames?.bind(service);
|
|
291
|
-
if (readEncryptedFieldNames) {
|
|
292
|
-
encryptedLikeFields = await resolveEncryptedLikeFieldSet(
|
|
293
|
-
() => readEncryptedFieldNames(
|
|
294
|
-
String(entity),
|
|
295
|
-
opts.tenantId ?? null,
|
|
296
|
-
null,
|
|
297
|
-
{ ignoreRuntimeHealth: true }
|
|
298
|
-
),
|
|
299
|
-
String(entity),
|
|
300
|
-
opts.tenantId ?? null
|
|
301
|
-
);
|
|
302
|
-
} else if (isTenantDataEncryptionEnabled()) {
|
|
303
|
-
encryptedLikeFields = null;
|
|
304
|
-
} else {
|
|
305
|
-
encryptedLikeFields = /* @__PURE__ */ new Set();
|
|
306
|
-
}
|
|
307
|
-
} catch (err) {
|
|
308
|
-
logger.warn("search: encrypted-field map unavailable; keeping the token rewrite for all columns", {
|
|
309
|
-
entity: String(entity),
|
|
310
|
-
error: err instanceof Error ? err.message : String(err)
|
|
311
|
-
});
|
|
312
|
-
encryptedLikeFields = null;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
235
|
if (searchFilters.length) {
|
|
316
236
|
const fields = searchFilters.map((filter) => String(filter.field));
|
|
317
237
|
this.logSearchDebug("search:init", {
|
|
@@ -373,12 +293,7 @@ class BasicQueryEngine {
|
|
|
373
293
|
}
|
|
374
294
|
const recordIdColumn = qualify("id");
|
|
375
295
|
const applyFilterOp = (builder, column, op, value, fieldName) => {
|
|
376
|
-
if ((op === "like" || op === "ilike") && searchActive && typeof value === "string" && fieldName && typeof column === "string"
|
|
377
|
-
// filters never reach this path (they are applied by the custom-field branches), so this
|
|
378
|
-
// gate only decides base columns. Membership is tested across name-shape candidates --
|
|
379
|
-
// encryption maps may declare `displayName` while the filter carries the column name
|
|
380
|
-
// `display_name`, and a raw comparison would misread that ciphertext column as plaintext.
|
|
381
|
-
(encryptedLikeFields === null || isEncryptedLikeField(encryptedLikeFields, fieldName))) {
|
|
296
|
+
if ((op === "like" || op === "ilike") && searchActive && typeof value === "string" && fieldName && typeof column === "string") {
|
|
382
297
|
const tokens = tokenizeText(String(value), searchConfig);
|
|
383
298
|
const hashes = tokens.hashes;
|
|
384
299
|
if (hashes.length) {
|
|
@@ -467,7 +382,6 @@ class BasicQueryEngine {
|
|
|
467
382
|
const sanitize = (s) => s.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
468
383
|
const buildQuery = async (projection) => {
|
|
469
384
|
const isSortKeysProjection = projection === "sortKeys";
|
|
470
|
-
const isCountProjection = projection === "count";
|
|
471
385
|
let q = db.selectFrom(table);
|
|
472
386
|
if (!skipAutoScope && orgScope && await this.columnExists(table, "organization_id")) {
|
|
473
387
|
q = this.applyOrganizationScope(q, qualify("organization_id"), orgScope);
|
|
@@ -541,8 +455,7 @@ class BasicQueryEngine {
|
|
|
541
455
|
applyJoinFilterOp,
|
|
542
456
|
columnExists: (tbl, column) => this.columnExists(tbl, column)
|
|
543
457
|
});
|
|
544
|
-
if (
|
|
545
|
-
} else if (isSortKeysProjection) {
|
|
458
|
+
if (isSortKeysProjection) {
|
|
546
459
|
q = q.select(sql.ref(qualify("id")).as("id"));
|
|
547
460
|
if (await this.columnExists(table, "tenant_id")) {
|
|
548
461
|
q = q.select(sql.ref(qualify("tenant_id")).as("tenant_id"));
|
|
@@ -564,18 +477,18 @@ class BasicQueryEngine {
|
|
|
564
477
|
} else {
|
|
565
478
|
q = q.select(sql`${sql.ref(table)}.*`.as("__all"));
|
|
566
479
|
}
|
|
567
|
-
const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify
|
|
480
|
+
const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify);
|
|
568
481
|
q = cfSourcesResult.builder;
|
|
569
482
|
const cfSources = cfSourcesResult.sources;
|
|
570
483
|
const entityIdToSource = /* @__PURE__ */ new Map();
|
|
571
484
|
for (const source of cfSources) {
|
|
572
485
|
entityIdToSource.set(String(source.entityId), source);
|
|
573
486
|
}
|
|
574
|
-
const requestedCustomFieldKeys =
|
|
487
|
+
const requestedCustomFieldKeys = !isSortKeysProjection && Array.isArray(opts.includeCustomFields) ? opts.includeCustomFields.map((key) => String(key)) : [];
|
|
575
488
|
const cfKeys = /* @__PURE__ */ new Set();
|
|
576
489
|
const keySource = /* @__PURE__ */ new Map();
|
|
577
490
|
let resolvedCustomFieldDefinitions2;
|
|
578
|
-
if (
|
|
491
|
+
if (!isSortKeysProjection) {
|
|
579
492
|
for (const f of opts.fields || []) {
|
|
580
493
|
if (typeof f === "string" && f.startsWith("cf:")) cfKeys.add(f.slice(3));
|
|
581
494
|
}
|
|
@@ -583,7 +496,7 @@ class BasicQueryEngine {
|
|
|
583
496
|
for (const f of cfFilters) {
|
|
584
497
|
if (typeof f.field === "string" && f.field.startsWith("cf:")) cfKeys.add(f.field.slice(3));
|
|
585
498
|
}
|
|
586
|
-
if (
|
|
499
|
+
if (!isSortKeysProjection && opts.includeCustomFields === true) {
|
|
587
500
|
if (entityIdToSource.size > 0) {
|
|
588
501
|
const entityIdList = Array.from(entityIdToSource.keys());
|
|
589
502
|
const entityOrder = /* @__PURE__ */ new Map();
|
|
@@ -682,7 +595,6 @@ class BasicQueryEngine {
|
|
|
682
595
|
for (const key of cfKeys) {
|
|
683
596
|
const source = keySource.get(key);
|
|
684
597
|
if (!source) continue;
|
|
685
|
-
if (isCountProjection) continue;
|
|
686
598
|
const entityIdForKey = source.entityId;
|
|
687
599
|
const recordIdExpr = source.recordIdExpr;
|
|
688
600
|
const sourceAliasSafe = sanitize(source.alias || "src");
|
|
@@ -730,10 +642,8 @@ class BasicQueryEngine {
|
|
|
730
642
|
for (const f of regularCfFilters) {
|
|
731
643
|
if (!f.field.startsWith("cf:")) continue;
|
|
732
644
|
const key = f.field.slice(3);
|
|
733
|
-
const filterSource = keySource.get(key);
|
|
734
|
-
if (!filterSource) continue;
|
|
735
645
|
const expr = cfValueExprByKey[key];
|
|
736
|
-
if (!
|
|
646
|
+
if (!expr) continue;
|
|
737
647
|
if ((f.op === "like" || f.op === "ilike") && searchActive && typeof f.value === "string") {
|
|
738
648
|
const tokens = tokenizeText(String(f.value), searchConfig);
|
|
739
649
|
const hashes = tokens.hashes;
|
|
@@ -768,39 +678,15 @@ class BasicQueryEngine {
|
|
|
768
678
|
});
|
|
769
679
|
}
|
|
770
680
|
}
|
|
771
|
-
if (isCountProjection) {
|
|
772
|
-
q = this.applyCfValueExistsFilter(q, {
|
|
773
|
-
source: filterSource,
|
|
774
|
-
qualify,
|
|
775
|
-
tenantId: tenantId ?? null,
|
|
776
|
-
key,
|
|
777
|
-
op: f.op,
|
|
778
|
-
value: f.value
|
|
779
|
-
});
|
|
780
|
-
continue;
|
|
781
|
-
}
|
|
782
681
|
q = this.applyColumnOp(q, expr, f.op, f.value);
|
|
783
682
|
}
|
|
784
|
-
const
|
|
785
|
-
const applicableGroupFilters = resolvedGroupFilters.map((group) => group.filter((rf) => rf.kind !== "cf" || cfLeafApplicable(rf.key))).filter((group) => group.length > 0);
|
|
683
|
+
const applicableGroupFilters = resolvedGroupFilters.map((group) => group.filter((rf) => rf.kind !== "cf" || Boolean(cfValueExprByKey[rf.key]))).filter((group) => group.length > 0);
|
|
786
684
|
if (applicableGroupFilters.length > 0) {
|
|
787
685
|
q = q.where((eb) => {
|
|
788
686
|
const disjuncts = applicableGroupFilters.map((group) => {
|
|
789
687
|
const parts = group.map((rf) => {
|
|
790
688
|
if (rf.kind === "column") return this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value);
|
|
791
|
-
if (rf.kind === "cf")
|
|
792
|
-
if (isCountProjection) {
|
|
793
|
-
return this.buildCfValueExistsExpression(eb, {
|
|
794
|
-
source: keySource.get(rf.key),
|
|
795
|
-
qualify,
|
|
796
|
-
tenantId: tenantId ?? null,
|
|
797
|
-
key: rf.key,
|
|
798
|
-
op: rf.op,
|
|
799
|
-
value: rf.value
|
|
800
|
-
});
|
|
801
|
-
}
|
|
802
|
-
return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value);
|
|
803
|
-
}
|
|
689
|
+
if (rf.kind === "cf") return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value);
|
|
804
690
|
return this.buildIndexDocOpExpression(eb, {
|
|
805
691
|
entity: String(entity),
|
|
806
692
|
field: rf.field,
|
|
@@ -817,7 +703,7 @@ class BasicQueryEngine {
|
|
|
817
703
|
return disjuncts.length === 1 ? disjuncts[0] : eb.or(disjuncts);
|
|
818
704
|
});
|
|
819
705
|
}
|
|
820
|
-
if (opts.includeExtensions
|
|
706
|
+
if (opts.includeExtensions) {
|
|
821
707
|
const { getModules } = await import("@open-mercato/shared/lib/i18n/server");
|
|
822
708
|
const allMods = getModules();
|
|
823
709
|
const allExts = allMods.flatMap((m) => m.entityExtensions || []);
|
|
@@ -834,7 +720,7 @@ class BasicQueryEngine {
|
|
|
834
720
|
);
|
|
835
721
|
}
|
|
836
722
|
}
|
|
837
|
-
for (const s of
|
|
723
|
+
for (const s of resolvedSorts) {
|
|
838
724
|
if (s.field.startsWith("cf:")) {
|
|
839
725
|
const key = s.field.slice(3);
|
|
840
726
|
const alias = sanitize(`cf:${key}`);
|
|
@@ -850,7 +736,7 @@ class BasicQueryEngine {
|
|
|
850
736
|
if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), s.dir ?? "asc");
|
|
851
737
|
}
|
|
852
738
|
}
|
|
853
|
-
const hasJoinedAggregates2 =
|
|
739
|
+
const hasJoinedAggregates2 = opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0;
|
|
854
740
|
if (hasJoinedAggregates2) {
|
|
855
741
|
q = q.groupBy(`${table}.id`);
|
|
856
742
|
}
|
|
@@ -865,24 +751,11 @@ class BasicQueryEngine {
|
|
|
865
751
|
cfMultiAliasByAlias,
|
|
866
752
|
resolvedCustomFieldDefinitions
|
|
867
753
|
} = await buildQuery("full");
|
|
868
|
-
const
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
const probe = countShape.select(sql`1`.as("one")).limit(countCap + 1);
|
|
874
|
-
const countRow = await db.selectFrom(probe.as("om_count_probe")).select(sql`count(*)`.as("count")).executeTakeFirst();
|
|
875
|
-
const probed = Number(countRow?.count ?? 0);
|
|
876
|
-
if (probed > countCap) {
|
|
877
|
-
total = countCap;
|
|
878
|
-
listCountCapWarning = { entity, cap: countCap };
|
|
879
|
-
} else {
|
|
880
|
-
total = probed;
|
|
881
|
-
}
|
|
882
|
-
} else {
|
|
883
|
-
const countRow = await countShape.select(sql`count(*)`.as("count")).executeTakeFirst();
|
|
884
|
-
total = Number(countRow?.count ?? 0);
|
|
885
|
-
}
|
|
754
|
+
const mayMultiplyBaseRows = hasJoinedAggregates || Array.isArray(opts.joins) && opts.joins.length > 0 || Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0;
|
|
755
|
+
const countExpr = mayMultiplyBaseRows ? sql`count(distinct ${sql.ref(`${table}.id`)})` : sql`count(*)`;
|
|
756
|
+
const countBuilder = hasJoinedAggregates ? qFull.clearSelect().clearOrderBy().clearGroupBy().select(countExpr.as("count")) : qFull.clearSelect().clearOrderBy().select(countExpr.as("count"));
|
|
757
|
+
const countRow = await countBuilder.executeTakeFirst();
|
|
758
|
+
const total = Number(countRow?.count ?? 0);
|
|
886
759
|
const svc = encryptionService;
|
|
887
760
|
const decryptPayload = svc?.decryptEntityPayload?.bind(svc);
|
|
888
761
|
const decryptRow = async (item) => {
|
|
@@ -931,15 +804,13 @@ class BasicQueryEngine {
|
|
|
931
804
|
const cap = resolveEncryptedSortMaxRows();
|
|
932
805
|
let qSort = (await buildQuery("sortKeys")).builder;
|
|
933
806
|
if (cap !== null) {
|
|
934
|
-
qSort = qSort.limit(cap
|
|
807
|
+
qSort = qSort.limit(cap).orderBy(qualify("id"), "asc");
|
|
935
808
|
}
|
|
936
|
-
const
|
|
937
|
-
const sortTruncated = cap !== null && candidateRowsRaw.length > cap;
|
|
938
|
-
const candidateRows = sortTruncated && cap !== null ? candidateRowsRaw.slice(0, cap) : candidateRowsRaw;
|
|
809
|
+
const candidateRows = await qSort.execute();
|
|
939
810
|
const decryptedCandidates = decryptPayload ? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow) : candidateRows;
|
|
940
811
|
const orderedCandidates = sortRowsInMemory(decryptedCandidates, resolvedSorts);
|
|
941
812
|
const pageIds = orderedCandidates.slice((page - 1) * pageSize, page * pageSize).map((row) => row.id);
|
|
942
|
-
if (
|
|
813
|
+
if (cap !== null && total > cap) {
|
|
943
814
|
encryptedSortRowCapWarning = {
|
|
944
815
|
entity,
|
|
945
816
|
sortFields: resolvedSorts.map((s) => s.field),
|
|
@@ -963,10 +834,8 @@ class BasicQueryEngine {
|
|
|
963
834
|
pagedItems = decryptPayload ? await mapWithConcurrency(items, DECRYPT_CONCURRENCY, decryptRow) : items;
|
|
964
835
|
}
|
|
965
836
|
let queryResult = { items: pagedItems, page, pageSize, total };
|
|
966
|
-
if (encryptedSortRowCapWarning
|
|
967
|
-
const meta = {};
|
|
968
|
-
if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning;
|
|
969
|
-
if (listCountCapWarning) meta.listCountCapWarning = listCountCapWarning;
|
|
837
|
+
if (encryptedSortRowCapWarning) {
|
|
838
|
+
const meta = { encryptedSortRowCapWarning };
|
|
970
839
|
queryResult.meta = meta;
|
|
971
840
|
}
|
|
972
841
|
if (ext && extensionCtx) {
|
|
@@ -1011,101 +880,6 @@ class BasicQueryEngine {
|
|
|
1011
880
|
return builder;
|
|
1012
881
|
}
|
|
1013
882
|
}
|
|
1014
|
-
/**
|
|
1015
|
-
* Apply a `cf:*` filter as a correlated EXISTS semi-join over
|
|
1016
|
-
* `custom_field_values` (+ `custom_field_defs` for kind-based coercion) —
|
|
1017
|
-
* the count shape's equivalent of the projection path's leftJoin + WHERE.
|
|
1018
|
-
* A semi-join returns each base row at most once, so the count query needs
|
|
1019
|
-
* no DISTINCT or GROUP BY and stays boundable by an outer LIMIT.
|
|
1020
|
-
*
|
|
1021
|
-
* Predicates satisfied by the *absence* of a value row (`eq null`,
|
|
1022
|
-
* `exists: false`) become `NOT EXISTS(value) OR EXISTS(null value)`,
|
|
1023
|
-
* matching the leftJoin form where a missing row yields a NULL expression.
|
|
1024
|
-
*/
|
|
1025
|
-
applyCfValueExistsFilter(q, opts) {
|
|
1026
|
-
return q.where((eb) => this.buildCfValueExistsExpression(eb, opts));
|
|
1027
|
-
}
|
|
1028
|
-
/**
|
|
1029
|
-
* Expression-returning core of `applyCfValueExistsFilter`, so a cf leaf
|
|
1030
|
-
* inside an OR group can compile to an EXISTS predicate on the count shape
|
|
1031
|
-
* instead of being dropped for lacking a `cfValueExprByKey` entry.
|
|
1032
|
-
*/
|
|
1033
|
-
buildCfValueExistsExpression(eb, opts) {
|
|
1034
|
-
const { source, qualify, tenantId, key, op, value } = opts;
|
|
1035
|
-
const seq = this.searchAliasSeq++;
|
|
1036
|
-
const valAlias = `cfev_${seq}`;
|
|
1037
|
-
const defAlias = `cfed_${seq}`;
|
|
1038
|
-
const srcAlias = `cfes_${seq}`;
|
|
1039
|
-
const caseExpr = sql`CASE ${sql.ref(`${defAlias}.kind`)}
|
|
1040
|
-
WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
|
|
1041
|
-
WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
|
|
1042
|
-
WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
|
|
1043
|
-
WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
|
|
1044
|
-
ELSE (${sql.ref(`${valAlias}.value_text`)})::text
|
|
1045
|
-
END`;
|
|
1046
|
-
const buildSub = (eb2) => {
|
|
1047
|
-
let sub = eb2.selectFrom(`custom_field_values as ${valAlias}`).select(sql`1`.as("one")).leftJoin(`custom_field_defs as ${defAlias}`, (jb) => jb.on(`${defAlias}.entity_id`, "=", String(source.entityId)).on(`${defAlias}.key`, "=", key).on(`${defAlias}.is_active`, "=", true).on((jeb) => jeb.or([
|
|
1048
|
-
jeb(`${defAlias}.tenant_id`, "=", tenantId),
|
|
1049
|
-
jeb(`${defAlias}.tenant_id`, "is", null)
|
|
1050
|
-
]))).where(`${valAlias}.entity_id`, "=", String(source.entityId)).where(`${valAlias}.field_key`, "=", key).where((web) => web.or([
|
|
1051
|
-
web(`${valAlias}.tenant_id`, "=", tenantId),
|
|
1052
|
-
web(`${valAlias}.tenant_id`, "is", null)
|
|
1053
|
-
]));
|
|
1054
|
-
if (source.hop) {
|
|
1055
|
-
sub = sub.innerJoin(`${source.table} as ${srcAlias}`, (jb) => jb.on(sql`${sql.ref(`${valAlias}.record_id`)} = (${sql.ref(`${srcAlias}.${source.hop.recordIdColumn}`)})::text`)).whereRef(`${srcAlias}.${source.hop.toField}`, "=", qualify(source.hop.fromField));
|
|
1056
|
-
} else {
|
|
1057
|
-
sub = sub.where(sql`${sql.ref(`${valAlias}.record_id`)} = ${source.recordIdExpr}`);
|
|
1058
|
-
}
|
|
1059
|
-
return sub;
|
|
1060
|
-
};
|
|
1061
|
-
const absenceSatisfiable = op === "eq" && value === null || op === "exists" && !value;
|
|
1062
|
-
if (absenceSatisfiable) {
|
|
1063
|
-
return eb.or([
|
|
1064
|
-
eb.not(eb.exists(buildSub(eb))),
|
|
1065
|
-
eb.exists(buildSub(eb).where(sql`${caseExpr} is null`))
|
|
1066
|
-
]);
|
|
1067
|
-
}
|
|
1068
|
-
let predicate = null;
|
|
1069
|
-
switch (op) {
|
|
1070
|
-
case "eq":
|
|
1071
|
-
predicate = sql`${caseExpr} = ${value}`;
|
|
1072
|
-
break;
|
|
1073
|
-
case "ne":
|
|
1074
|
-
predicate = value === null ? sql`${caseExpr} is not null` : sql`${caseExpr} != ${value}`;
|
|
1075
|
-
break;
|
|
1076
|
-
case "gt":
|
|
1077
|
-
case "gte":
|
|
1078
|
-
case "lt":
|
|
1079
|
-
case "lte": {
|
|
1080
|
-
const operator = sql.raw(op === "gt" ? ">" : op === "gte" ? ">=" : op === "lt" ? "<" : "<=");
|
|
1081
|
-
predicate = sql`${caseExpr} ${operator} ${value}`;
|
|
1082
|
-
break;
|
|
1083
|
-
}
|
|
1084
|
-
case "in": {
|
|
1085
|
-
const vals = Array.isArray(value) ? value : [value];
|
|
1086
|
-
predicate = sql`${caseExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`;
|
|
1087
|
-
break;
|
|
1088
|
-
}
|
|
1089
|
-
case "nin": {
|
|
1090
|
-
const vals = Array.isArray(value) ? value : [value];
|
|
1091
|
-
predicate = sql`${caseExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`;
|
|
1092
|
-
break;
|
|
1093
|
-
}
|
|
1094
|
-
case "like":
|
|
1095
|
-
predicate = sql`${caseExpr} like ${value}`;
|
|
1096
|
-
break;
|
|
1097
|
-
case "ilike":
|
|
1098
|
-
predicate = sql`${caseExpr} ilike ${value}`;
|
|
1099
|
-
break;
|
|
1100
|
-
case "exists":
|
|
1101
|
-
predicate = sql`${caseExpr} is not null`;
|
|
1102
|
-
break;
|
|
1103
|
-
default:
|
|
1104
|
-
return eb.val(true);
|
|
1105
|
-
}
|
|
1106
|
-
const captured = predicate;
|
|
1107
|
-
return eb.exists(buildSub(eb).where(captured));
|
|
1108
|
-
}
|
|
1109
883
|
buildColumnOpExpression(eb, column, op, value) {
|
|
1110
884
|
switch (op) {
|
|
1111
885
|
case "eq":
|
|
@@ -1141,13 +915,16 @@ class BasicQueryEngine {
|
|
|
1141
915
|
}
|
|
1142
916
|
async columnExists(table, column) {
|
|
1143
917
|
const key = `${table}.${column}`;
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
918
|
+
if (this.columnCache.has(key)) {
|
|
919
|
+
const cached = this.columnCache.get(key);
|
|
920
|
+
if (cached === true) return true;
|
|
921
|
+
this.columnCache.delete(key);
|
|
922
|
+
}
|
|
1147
923
|
const db = this.getDb();
|
|
1148
924
|
const exists = await db.selectFrom("information_schema.columns").select(sql`1`.as("one")).where("table_name", "=", table).where("column_name", "=", column).limit(1).executeTakeFirst();
|
|
1149
925
|
const present = !!exists;
|
|
1150
|
-
if (
|
|
926
|
+
if (present) this.columnCache.set(key, true);
|
|
927
|
+
else this.columnCache.delete(key);
|
|
1151
928
|
return present;
|
|
1152
929
|
}
|
|
1153
930
|
applySearchTokens(q, opts) {
|
|
@@ -1284,7 +1061,7 @@ class BasicQueryEngine {
|
|
|
1284
1061
|
return sub;
|
|
1285
1062
|
})());
|
|
1286
1063
|
}
|
|
1287
|
-
configureCustomFieldSources(q, baseTable, baseEntity, db, opts, qualify
|
|
1064
|
+
configureCustomFieldSources(q, baseTable, baseEntity, db, opts, qualify) {
|
|
1288
1065
|
const sources = [
|
|
1289
1066
|
{
|
|
1290
1067
|
entityId: baseEntity,
|
|
@@ -1302,22 +1079,14 @@ class BasicQueryEngine {
|
|
|
1302
1079
|
if (!join) {
|
|
1303
1080
|
throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`);
|
|
1304
1081
|
}
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1307
|
-
const joinFn = joinType === "inner" ? "innerJoin" : "leftJoin";
|
|
1308
|
-
next = next[joinFn](`${joinTable} as ${alias}`, (jb) => jb.onRef(`${alias}.${join.toField}`, "=", qualify(join.fromField)));
|
|
1309
|
-
} else if (joinType === "inner") {
|
|
1310
|
-
next = next.where((eb) => eb.exists(
|
|
1311
|
-
eb.selectFrom(`${joinTable} as ${alias}`).select(sql`1`.as("one")).whereRef(`${alias}.${join.toField}`, "=", qualify(join.fromField))
|
|
1312
|
-
));
|
|
1313
|
-
}
|
|
1082
|
+
const joinFn = (join.type ?? "left") === "inner" ? "innerJoin" : "leftJoin";
|
|
1083
|
+
next = next[joinFn](`${joinTable} as ${alias}`, (jb) => jb.onRef(`${alias}.${join.toField}`, "=", qualify(join.fromField)));
|
|
1314
1084
|
const recordColumn = srcOpt.recordIdColumn ?? "id";
|
|
1315
1085
|
sources.push({
|
|
1316
1086
|
entityId: srcOpt.entityId,
|
|
1317
1087
|
alias,
|
|
1318
1088
|
table: joinTable,
|
|
1319
|
-
recordIdExpr: sql`${sql.ref(`${alias}.${recordColumn}`)}::text
|
|
1320
|
-
hop: { fromField: join.fromField, toField: join.toField, recordIdColumn: recordColumn, type: joinType }
|
|
1089
|
+
recordIdExpr: sql`${sql.ref(`${alias}.${recordColumn}`)}::text`
|
|
1321
1090
|
});
|
|
1322
1091
|
});
|
|
1323
1092
|
return { builder: next, sources };
|
|
@@ -1357,12 +1126,7 @@ class BasicQueryEngine {
|
|
|
1357
1126
|
export {
|
|
1358
1127
|
BasicQueryEngine,
|
|
1359
1128
|
ENTITY_ID_PATTERN,
|
|
1360
|
-
clearColumnExistsCache,
|
|
1361
|
-
clearEncryptedLikeFieldsCache,
|
|
1362
|
-
columnExistsCacheSize,
|
|
1363
|
-
isEncryptedLikeField,
|
|
1364
1129
|
isValidEntityIdShape,
|
|
1365
|
-
resolveEncryptedLikeFieldSet,
|
|
1366
1130
|
resolveEntityTableName,
|
|
1367
1131
|
resolveRegisteredEntityTableName
|
|
1368
1132
|
};
|