@open-mercato/shared 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51
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 +4 -1
- package/dist/lib/auth/jwt.js +6 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/mfaPendingAccess.js +42 -0
- package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
- package/dist/lib/auth/organizationAccess.js +7 -4
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/principal-service.js +1 -0
- package/dist/lib/auth/principal-service.js.map +7 -0
- package/dist/lib/auth/server.js +38 -7
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +6 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +24 -8
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +8 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/html/htmlToPlainText.js +16 -0
- package/dist/lib/html/htmlToPlainText.js.map +7 -0
- package/dist/lib/location/countries.js +12 -0
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +4 -1
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/count-cap.js +11 -0
- package/dist/lib/query/count-cap.js.map +7 -0
- package/dist/lib/query/engine.js +270 -34
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/queue/dispatchOrigin.js +20 -0
- package/dist/lib/queue/dispatchOrigin.js.map +7 -0
- package/dist/lib/search/config.js +1 -0
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/entityAccess.js +44 -0
- package/dist/lib/search/entityAccess.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +69 -15
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +15 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +10 -3
- package/src/lib/auth/__tests__/jwt.test.ts +13 -0
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
- package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
- package/src/lib/auth/__tests__/server.test.ts +104 -0
- package/src/lib/auth/jwt.ts +17 -0
- package/src/lib/auth/mfaPendingAccess.ts +70 -0
- package/src/lib/auth/organizationAccess.ts +11 -3
- package/src/lib/auth/principal-service.ts +110 -0
- package/src/lib/auth/server.ts +78 -8
- package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
- package/src/lib/commands/command-bus.ts +8 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +165 -0
- package/src/lib/crud/factory.ts +33 -7
- package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
- package/src/lib/data/engine.ts +7 -1
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
- package/src/lib/html/htmlToPlainText.ts +17 -0
- package/src/lib/location/__tests__/countries.test.ts +15 -0
- package/src/lib/location/countries.ts +17 -0
- package/src/lib/openapi/crud.ts +3 -0
- package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
- package/src/lib/query/__tests__/count-cap.test.ts +41 -0
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
- package/src/lib/query/__tests__/engine.test.ts +445 -7
- package/src/lib/query/count-cap.ts +19 -0
- package/src/lib/query/engine.ts +434 -54
- package/src/lib/query/types.ts +15 -0
- package/src/lib/queue/dispatchOrigin.ts +35 -0
- package/src/lib/search/config.ts +10 -0
- package/src/lib/search/entityAccess.ts +132 -0
- package/src/modules/events/__tests__/factory.test.ts +88 -0
- package/src/modules/events/factory.ts +111 -19
- package/src/modules/events/types.ts +17 -0
- package/src/modules/registry.ts +40 -0
- package/src/modules/widgets/component-registry.ts +14 -0
package/src/lib/query/engine.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QueryEngine, QueryOptions, QueryResult, QueryResultMeta, EncryptedSortRowCapWarning, QueryCustomFieldSource, QueryExtensionsConfig, Sort } from './types'
|
|
1
|
+
import type { QueryEngine, QueryOptions, QueryResult, QueryResultMeta, EncryptedSortRowCapWarning, ListCountCapWarning, QueryCustomFieldSource, QueryExtensionsConfig, Sort } from './types'
|
|
2
2
|
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import { type Kysely, sql, type RawBuilder } from 'kysely'
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
type SearchTokenProbeQueryBuilder,
|
|
21
21
|
} from '../search/availability'
|
|
22
22
|
import { tokenizeText } from '../search/tokenize'
|
|
23
|
+
import { fieldNameCandidates } from './encrypted-sort'
|
|
24
|
+
import { isTenantDataEncryptionEnabled } from '../encryption/toggles'
|
|
23
25
|
import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from './query-extension-runner'
|
|
24
26
|
import {
|
|
25
27
|
buildCustomFieldDefinitionIndexFromRows,
|
|
@@ -29,7 +31,9 @@ import {
|
|
|
29
31
|
} from '../crud/custom-field-definition-index'
|
|
30
32
|
import { warnOnCiphertextLikeFallback } from './ciphertext-search-warning'
|
|
31
33
|
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from './encrypted-sort'
|
|
34
|
+
import { resolveListCountCap } from './count-cap'
|
|
32
35
|
import { mapWithConcurrency } from './bounded-decrypt'
|
|
36
|
+
import { parseNumberWithDefault } from '../number'
|
|
33
37
|
import { createLogger } from '../logger'
|
|
34
38
|
|
|
35
39
|
const logger = createLogger('shared').child({ component: 'query' })
|
|
@@ -43,15 +47,112 @@ const entityTableCache = new Map<string, string>()
|
|
|
43
47
|
|
|
44
48
|
type EncryptionResolver = () => {
|
|
45
49
|
decryptEntityPayload?: (entityId: EntityId, payload: Record<string, unknown>, tenantId?: string | null, organizationId?: string | null) => Promise<Record<string, unknown>>
|
|
46
|
-
getEncryptedFieldNames?: (entityId: EntityId, tenantId?: string | null, organizationId?: string | null) => Promise<readonly string[]>
|
|
50
|
+
getEncryptedFieldNames?: (entityId: EntityId, tenantId?: string | null, organizationId?: string | null, options?: { ignoreRuntimeHealth?: boolean }) => Promise<readonly string[]>
|
|
47
51
|
isEnabled?: () => boolean
|
|
48
52
|
} | null
|
|
49
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Membership across name shapes: encryption maps may declare a field as `displayName` or
|
|
56
|
+
* `display_name` (TenantDataEncryptionService resolves both), while query filters carry real
|
|
57
|
+
* column names. Both sides expand through `fieldNameCandidates` at set-build and lookup time.
|
|
58
|
+
*/
|
|
59
|
+
export function isEncryptedLikeField(encrypted: ReadonlySet<string>, field: string): boolean {
|
|
60
|
+
return fieldNameCandidates(field).some((candidate) => encrypted.has(candidate))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The all-orgs union behind `getEncryptedFieldNames(..., organizationId: null)` is an UNCACHED
|
|
64
|
+
// `encryption_maps` read. Encryption maps change on deploys, not per request, so a short TTL
|
|
65
|
+
// removes the per-search round-trip without meaningfully delaying a map rollout.
|
|
66
|
+
const ENCRYPTED_LIKE_FIELDS_TTL_MS = 60_000
|
|
67
|
+
const ENCRYPTED_LIKE_FIELDS_CACHE_CAP = 500
|
|
68
|
+
const encryptedLikeFieldsCache = new Map<string, { at: number; fields: Set<string> }>()
|
|
69
|
+
|
|
70
|
+
export async function resolveEncryptedLikeFieldSet(
|
|
71
|
+
read: () => Promise<readonly string[]>,
|
|
72
|
+
entity: string,
|
|
73
|
+
tenantId: string | null,
|
|
74
|
+
): Promise<Set<string>> {
|
|
75
|
+
const key = `${entity}|${tenantId ?? ''}`
|
|
76
|
+
const hit = encryptedLikeFieldsCache.get(key)
|
|
77
|
+
if (hit && Date.now() - hit.at < ENCRYPTED_LIKE_FIELDS_TTL_MS) return hit.fields
|
|
78
|
+
const names = await read()
|
|
79
|
+
const fields = new Set<string>()
|
|
80
|
+
for (const name of names ?? []) {
|
|
81
|
+
for (const candidate of fieldNameCandidates(String(name))) fields.add(candidate)
|
|
82
|
+
}
|
|
83
|
+
if (encryptedLikeFieldsCache.size >= ENCRYPTED_LIKE_FIELDS_CACHE_CAP) encryptedLikeFieldsCache.clear()
|
|
84
|
+
encryptedLikeFieldsCache.set(key, { at: Date.now(), fields })
|
|
85
|
+
return fields
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Test-only: the TTL memo would otherwise leak state across specs. */
|
|
89
|
+
export function clearEncryptedLikeFieldsCache(): void {
|
|
90
|
+
encryptedLikeFieldsCache.clear()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Module-scoped on purpose: `createRequestContainer` builds a fresh `BasicQueryEngine`
|
|
94
|
+
// per request, so an instance field alone re-pays the `information_schema` probe on
|
|
95
|
+
// every request (#5605). Schema shape (does a table have this column?) is not
|
|
96
|
+
// per-request state — unlike `tenantEncryptionService` — so sharing the answer across
|
|
97
|
+
// requests is safe. One map per module instance rather than a true process singleton:
|
|
98
|
+
// standalone builds can duplicate this package, which for a memo is harmless (two
|
|
99
|
+
// caches, both correct), so nothing may be built on top of singleton semantics here.
|
|
100
|
+
//
|
|
101
|
+
// Bounded and TTL'd for two reasons. `columnExists` is reached with caller-supplied
|
|
102
|
+
// field names via `resolveBaseColumn` (sort fields and base filter keys arrive raw from
|
|
103
|
+
// the HTTP layer), so an unbounded map would grow monotonically on request input. And a
|
|
104
|
+
// cached `false` is consumed where the tenant/organization/soft-delete predicates are
|
|
105
|
+
// applied, so a schema change that adds one of those columns must not stay invisible
|
|
106
|
+
// until the process restarts — a migration applied against a running `yarn dev` or
|
|
107
|
+
// not-yet-recycled pods converges within the TTL instead. The TTL still removes
|
|
108
|
+
// essentially all of the traffic: a hot column is probed twelve times an hour rather
|
|
109
|
+
// than tens of thousands. Set OM_QUERY_COLUMN_EXISTS_CACHE_MS=0 to disable and probe
|
|
110
|
+
// per request again; OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES tunes the bound.
|
|
111
|
+
const COLUMN_EXISTS_CACHE_DEFAULT_TTL_MS = 300_000
|
|
112
|
+
const COLUMN_EXISTS_CACHE_DEFAULT_MAX_ENTRIES = 10_000
|
|
113
|
+
const columnExistsCache = new Map<string, { value: boolean; expiresAt: number }>()
|
|
114
|
+
|
|
115
|
+
function resolveColumnExistsCacheTtlMs(): number {
|
|
116
|
+
return parseNumberWithDefault(process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MS, COLUMN_EXISTS_CACHE_DEFAULT_TTL_MS, { integer: true, min: 0 })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveColumnExistsCacheMaxEntries(): number {
|
|
120
|
+
return parseNumberWithDefault(process.env.OM_QUERY_COLUMN_EXISTS_CACHE_MAX_ENTRIES, COLUMN_EXISTS_CACHE_DEFAULT_MAX_ENTRIES, { integer: true, min: 1 })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function storeColumnExists(key: string, value: boolean, ttlMs: number): void {
|
|
124
|
+
const maxEntries = resolveColumnExistsCacheMaxEntries()
|
|
125
|
+
if (columnExistsCache.size >= maxEntries) {
|
|
126
|
+
const now = Date.now()
|
|
127
|
+
for (const [entryKey, entry] of columnExistsCache) {
|
|
128
|
+
if (entry.expiresAt <= now) columnExistsCache.delete(entryKey)
|
|
129
|
+
}
|
|
130
|
+
if (columnExistsCache.size >= maxEntries) columnExistsCache.clear()
|
|
131
|
+
}
|
|
132
|
+
columnExistsCache.set(key, { value, expiresAt: Date.now() + ttlMs })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Test-only: the module-scoped memo would otherwise leak state across specs. */
|
|
136
|
+
export function clearColumnExistsCache(): void {
|
|
137
|
+
columnExistsCache.clear()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Test-only: entry count of the column-existence memo, for the cap regression test. */
|
|
141
|
+
export function columnExistsCacheSize(): number {
|
|
142
|
+
return columnExistsCache.size
|
|
143
|
+
}
|
|
144
|
+
|
|
50
145
|
type ResolvedCustomFieldSource = {
|
|
51
146
|
entityId: EntityId
|
|
52
147
|
alias: string
|
|
53
148
|
table: string
|
|
54
149
|
recordIdExpr: RawBuilder<string>
|
|
150
|
+
/**
|
|
151
|
+
* The base→source join edge for joined sources (absent on the base source).
|
|
152
|
+
* The count projection uses it to correlate cf-value EXISTS subqueries
|
|
153
|
+
* without attaching the source join to the outer query.
|
|
154
|
+
*/
|
|
155
|
+
hop?: { fromField: string; toField: string; recordIdColumn: string; type: 'left' | 'inner' }
|
|
55
156
|
}
|
|
56
157
|
|
|
57
158
|
type ResultRow = Record<string, unknown>
|
|
@@ -223,7 +324,6 @@ function computeCustomFieldScore(cfg: Record<string, unknown>, kind: string, ent
|
|
|
223
324
|
* {@link HybridQueryEngine} when the query index is unavailable or incomplete.
|
|
224
325
|
*/
|
|
225
326
|
export class BasicQueryEngine implements QueryEngine {
|
|
226
|
-
private columnCache = new Map<string, boolean>()
|
|
227
327
|
private searchAliasSeq = 0
|
|
228
328
|
private searchAvailabilityInstance: SearchTokenAvailability | null = null
|
|
229
329
|
|
|
@@ -345,6 +445,62 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
345
445
|
? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope)
|
|
346
446
|
: false
|
|
347
447
|
const searchActive = searchEnabled && hasSearchTokens
|
|
448
|
+
// Opt-in via OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS (default false: the pre-existing
|
|
449
|
+
// rewrite-everything behavior is kept). When enabled, base-column like/ilike is rerouted
|
|
450
|
+
// through search tokens ONLY for encrypted columns, where
|
|
451
|
+
// ILIKE against ciphertext cannot match. On a plaintext column SQL ILIKE is exact, and the token
|
|
452
|
+
// rewrite silently changes the result set: tokenization splits on non-alphanumerics and drops
|
|
453
|
+
// tokens shorter than minTokenLength, so a document-number search like "ZK 1/2026" degrades to
|
|
454
|
+
// the tokens {202, 2026} and matches every record from that year instead of the one document.
|
|
455
|
+
// `ignoreRuntimeHealth` asks the on-disk question -- a column holds ciphertext even while the
|
|
456
|
+
// KMS is down -- so an outage keeps encrypted columns on the token path (#4622).
|
|
457
|
+
// `organizationId: null` is deliberate, not an omission: the service then unions in every
|
|
458
|
+
// organization's map (`fetchAllOrganizationFieldNames`), so a field any org encrypts stays on
|
|
459
|
+
// the token path -- a wider set fails safe. Passing the request's org instead would silently
|
|
460
|
+
// break encrypted-column search for orgs without their own map. That union is an UNCACHED
|
|
461
|
+
// `encryption_maps` read, one extra round-trip per searched list request. `null` means
|
|
462
|
+
// the encryption service could not answer at all; keep the pre-existing rewrite-everything
|
|
463
|
+
// behavior then, because guessing "plaintext" would turn encrypted-column search into an
|
|
464
|
+
// ILIKE-on-ciphertext that matches nothing.
|
|
465
|
+
let encryptedLikeFields: Set<string> | null = null
|
|
466
|
+
if (
|
|
467
|
+
searchActive &&
|
|
468
|
+
searchConfig.useIlikeForNonEncryptedFields === true &&
|
|
469
|
+
searchFilters.some((filter) => !String(filter.field).startsWith('cf:'))
|
|
470
|
+
) {
|
|
471
|
+
try {
|
|
472
|
+
const service = this.getEncryptionService()
|
|
473
|
+
const readEncryptedFieldNames = service?.getEncryptedFieldNames?.bind(service)
|
|
474
|
+
if (readEncryptedFieldNames) {
|
|
475
|
+
encryptedLikeFields = await resolveEncryptedLikeFieldSet(
|
|
476
|
+
() => readEncryptedFieldNames(
|
|
477
|
+
String(entity),
|
|
478
|
+
opts.tenantId ?? null,
|
|
479
|
+
null,
|
|
480
|
+
{ ignoreRuntimeHealth: true },
|
|
481
|
+
),
|
|
482
|
+
String(entity),
|
|
483
|
+
opts.tenantId ?? null,
|
|
484
|
+
)
|
|
485
|
+
} else if (isTenantDataEncryptionEnabled()) {
|
|
486
|
+
// Encryption is on but the service is unreachable (a swallowed DI failure looks
|
|
487
|
+
// exactly like "no service"): treat the map as UNKNOWN and keep the token rewrite,
|
|
488
|
+
// rather than guessing "plaintext" and running ILIKE against ciphertext.
|
|
489
|
+
encryptedLikeFields = null
|
|
490
|
+
} else {
|
|
491
|
+
// Encryption disabled: nothing is ciphertext at rest, exact ILIKE is always right.
|
|
492
|
+
encryptedLikeFields = new Set()
|
|
493
|
+
}
|
|
494
|
+
} catch (err) {
|
|
495
|
+
// The fallback is safe (the old rewrite-everything behavior), but taking it silently
|
|
496
|
+
// would hide that the gate has stopped working.
|
|
497
|
+
logger.warn('search: encrypted-field map unavailable; keeping the token rewrite for all columns', {
|
|
498
|
+
entity: String(entity),
|
|
499
|
+
error: err instanceof Error ? err.message : String(err),
|
|
500
|
+
})
|
|
501
|
+
encryptedLikeFields = null
|
|
502
|
+
}
|
|
503
|
+
}
|
|
348
504
|
if (searchFilters.length) {
|
|
349
505
|
const fields = searchFilters.map((filter) => String(filter.field))
|
|
350
506
|
this.logSearchDebug('search:init', {
|
|
@@ -422,7 +578,13 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
422
578
|
searchActive &&
|
|
423
579
|
typeof value === 'string' &&
|
|
424
580
|
fieldName &&
|
|
425
|
-
typeof column === 'string'
|
|
581
|
+
typeof column === 'string' &&
|
|
582
|
+
// Plaintext columns keep exact SQL ILIKE -- see the encryptedLikeFields note above. cf:*
|
|
583
|
+
// filters never reach this path (they are applied by the custom-field branches), so this
|
|
584
|
+
// gate only decides base columns. Membership is tested across name-shape candidates --
|
|
585
|
+
// encryption maps may declare `displayName` while the filter carries the column name
|
|
586
|
+
// `display_name`, and a raw comparison would misread that ciphertext column as plaintext.
|
|
587
|
+
(encryptedLikeFields === null || isEncryptedLikeField(encryptedLikeFields, fieldName))
|
|
426
588
|
) {
|
|
427
589
|
const tokens = tokenizeText(String(value), searchConfig)
|
|
428
590
|
const hashes = tokens.hashes
|
|
@@ -546,11 +708,16 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
546
708
|
// Builds the fully-scoped query from a fresh root. `projection: 'full'` reproduces
|
|
547
709
|
// today's complete selection (base fields + CF projections + extension joins).
|
|
548
710
|
// `projection: 'sortKeys'` selects only `id` + the sort columns — the slim phase-1
|
|
549
|
-
// candidate scan used when `requiresPlaintextSort`.
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
|
|
711
|
+
// candidate scan used when `requiresPlaintextSort`. `projection: 'count'` carries
|
|
712
|
+
// scope + filters only: projection joins (CF defs/values, extensions) are omitted
|
|
713
|
+
// and cf filters are expressed as correlated EXISTS semi-joins, so nothing can
|
|
714
|
+
// multiply base rows and a LIMIT above the query is an enforceable bound.
|
|
715
|
+
// Re-running the WHERE/JOIN logic per projection is cheap: every `columnExists`
|
|
716
|
+
// check is memoized on the module-scoped `columnExistsCache`, so later passes
|
|
717
|
+
// hit no extra DB calls.
|
|
718
|
+
const buildQuery = async (projection: 'full' | 'sortKeys' | 'count'): Promise<BuiltQuery> => {
|
|
553
719
|
const isSortKeysProjection = projection === 'sortKeys'
|
|
720
|
+
const isCountProjection = projection === 'count'
|
|
554
721
|
let q: AnyBuilder = db.selectFrom(table as any)
|
|
555
722
|
|
|
556
723
|
// Tenant/org/soft-delete scope
|
|
@@ -642,7 +809,10 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
642
809
|
})
|
|
643
810
|
|
|
644
811
|
// Selection (base columns only here; cf:* handled later)
|
|
645
|
-
if (
|
|
812
|
+
if (isCountProjection) {
|
|
813
|
+
// The caller owns the count query's SELECT (a constant inside the
|
|
814
|
+
// bounded subquery, or the aggregate itself when the cap is off).
|
|
815
|
+
} else if (isSortKeysProjection) {
|
|
646
816
|
q = q.select(sql.ref(qualify('id')).as('id'))
|
|
647
817
|
if (await this.columnExists(table, 'tenant_id')) {
|
|
648
818
|
q = q.select(sql.ref(qualify('tenant_id')).as('tenant_id'))
|
|
@@ -668,14 +838,14 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
668
838
|
}
|
|
669
839
|
|
|
670
840
|
// Resolve which custom fields to include
|
|
671
|
-
const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify)
|
|
841
|
+
const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify, !isCountProjection)
|
|
672
842
|
q = cfSourcesResult.builder
|
|
673
843
|
const cfSources = cfSourcesResult.sources
|
|
674
844
|
const entityIdToSource = new Map<string, ResolvedCustomFieldSource>()
|
|
675
845
|
for (const source of cfSources) {
|
|
676
846
|
entityIdToSource.set(String(source.entityId), source)
|
|
677
847
|
}
|
|
678
|
-
const requestedCustomFieldKeys = (
|
|
848
|
+
const requestedCustomFieldKeys = (projection === 'full' && Array.isArray(opts.includeCustomFields))
|
|
679
849
|
? opts.includeCustomFields.map((key) => String(key))
|
|
680
850
|
: []
|
|
681
851
|
const cfKeys = new Set<string>()
|
|
@@ -685,7 +855,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
685
855
|
// Output-only — never resolved for the slim sortKeys projection.
|
|
686
856
|
let resolvedCustomFieldDefinitions: ResolvedCustomFieldDefinitions | undefined
|
|
687
857
|
// Explicit in fields/filters
|
|
688
|
-
if (
|
|
858
|
+
if (projection === 'full') {
|
|
689
859
|
for (const f of (opts.fields || [])) {
|
|
690
860
|
if (typeof f === 'string' && f.startsWith('cf:')) cfKeys.add(f.slice(3))
|
|
691
861
|
}
|
|
@@ -693,7 +863,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
693
863
|
for (const f of cfFilters) {
|
|
694
864
|
if (typeof f.field === 'string' && f.field.startsWith('cf:')) cfKeys.add(f.field.slice(3))
|
|
695
865
|
}
|
|
696
|
-
if (
|
|
866
|
+
if (projection === 'full' && opts.includeCustomFields === true) {
|
|
697
867
|
if (entityIdToSource.size > 0) {
|
|
698
868
|
const entityIdList = Array.from(entityIdToSource.keys())
|
|
699
869
|
const entityOrder = new Map<string, number>()
|
|
@@ -820,6 +990,9 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
820
990
|
for (const key of cfKeys) {
|
|
821
991
|
const source = keySource.get(key)
|
|
822
992
|
if (!source) continue
|
|
993
|
+
// The count shape never joins defs/values — cf filters are applied as
|
|
994
|
+
// correlated EXISTS semi-joins below, so no join can multiply base rows.
|
|
995
|
+
if (isCountProjection) continue
|
|
823
996
|
const entityIdForKey = source.entityId
|
|
824
997
|
const recordIdExpr = source.recordIdExpr
|
|
825
998
|
const sourceAliasSafe = sanitize(source.alias || 'src')
|
|
@@ -873,13 +1046,16 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
873
1046
|
}
|
|
874
1047
|
}
|
|
875
1048
|
|
|
876
|
-
// Apply cf:* filters (on raw expressions
|
|
877
|
-
//
|
|
1049
|
+
// Apply cf:* filters (on raw expressions; as EXISTS semi-joins for the count
|
|
1050
|
+
// shape). OR-grouped ones are excluded here and combined with their
|
|
1051
|
+
// disjunct's other leaves right below.
|
|
878
1052
|
for (const f of regularCfFilters) {
|
|
879
1053
|
if (!f.field.startsWith('cf:')) continue
|
|
880
1054
|
const key = f.field.slice(3)
|
|
1055
|
+
const filterSource = keySource.get(key)
|
|
1056
|
+
if (!filterSource) continue
|
|
881
1057
|
const expr = cfValueExprByKey[key]
|
|
882
|
-
if (!expr) continue
|
|
1058
|
+
if (!isCountProjection && !expr) continue
|
|
883
1059
|
if ((f.op === 'like' || f.op === 'ilike') && searchActive && typeof f.value === 'string') {
|
|
884
1060
|
const tokens = tokenizeText(String(f.value), searchConfig)
|
|
885
1061
|
const hashes = tokens.hashes
|
|
@@ -914,6 +1090,17 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
914
1090
|
})
|
|
915
1091
|
}
|
|
916
1092
|
}
|
|
1093
|
+
if (isCountProjection) {
|
|
1094
|
+
q = this.applyCfValueExistsFilter(q, {
|
|
1095
|
+
source: filterSource,
|
|
1096
|
+
qualify,
|
|
1097
|
+
tenantId: tenantId ?? null,
|
|
1098
|
+
key,
|
|
1099
|
+
op: f.op,
|
|
1100
|
+
value: f.value,
|
|
1101
|
+
})
|
|
1102
|
+
continue
|
|
1103
|
+
}
|
|
917
1104
|
q = this.applyColumnOp(q, expr, f.op, f.value)
|
|
918
1105
|
}
|
|
919
1106
|
|
|
@@ -928,15 +1115,35 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
928
1115
|
// `ilike` through the search-token index the way the ungrouped path does. On a
|
|
929
1116
|
// field covered by an encryption map such a leaf therefore compares against
|
|
930
1117
|
// ciphertext and will not match.
|
|
1118
|
+
//
|
|
1119
|
+
// The count shape never populates cfValueExprByKey (it joins no cf tables), so
|
|
1120
|
+
// its applicability test is key resolution itself — the same condition that
|
|
1121
|
+
// gates the full shape's expression map — and a cf leaf compiles to a
|
|
1122
|
+
// correlated EXISTS instead of a value-expression comparison. Dropping it
|
|
1123
|
+
// instead would narrow the OR and undercount relative to the display query.
|
|
1124
|
+
const cfLeafApplicable = (key: string): boolean =>
|
|
1125
|
+
isCountProjection ? keySource.has(key) : Boolean(cfValueExprByKey[key])
|
|
931
1126
|
const applicableGroupFilters = resolvedGroupFilters
|
|
932
|
-
.map((group) => group.filter((rf) => rf.kind !== 'cf' ||
|
|
1127
|
+
.map((group) => group.filter((rf) => rf.kind !== 'cf' || cfLeafApplicable(rf.key)))
|
|
933
1128
|
.filter((group) => group.length > 0)
|
|
934
1129
|
if (applicableGroupFilters.length > 0) {
|
|
935
1130
|
q = q.where((eb: any) => {
|
|
936
1131
|
const disjuncts = applicableGroupFilters.map((group) => {
|
|
937
1132
|
const parts = group.map((rf) => {
|
|
938
1133
|
if (rf.kind === 'column') return this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value)
|
|
939
|
-
if (rf.kind === 'cf')
|
|
1134
|
+
if (rf.kind === 'cf') {
|
|
1135
|
+
if (isCountProjection) {
|
|
1136
|
+
return this.buildCfValueExistsExpression(eb, {
|
|
1137
|
+
source: keySource.get(rf.key)!,
|
|
1138
|
+
qualify,
|
|
1139
|
+
tenantId: tenantId ?? null,
|
|
1140
|
+
key: rf.key,
|
|
1141
|
+
op: rf.op,
|
|
1142
|
+
value: rf.value,
|
|
1143
|
+
})
|
|
1144
|
+
}
|
|
1145
|
+
return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
|
|
1146
|
+
}
|
|
940
1147
|
return this.buildIndexDocOpExpression(eb, {
|
|
941
1148
|
entity: String(entity),
|
|
942
1149
|
field: rf.field,
|
|
@@ -954,8 +1161,9 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
954
1161
|
})
|
|
955
1162
|
}
|
|
956
1163
|
|
|
957
|
-
// Entity extensions joins (no selection yet; enables future filters/projections)
|
|
958
|
-
|
|
1164
|
+
// Entity extensions joins (no selection yet; enables future filters/projections).
|
|
1165
|
+
// Projection-only, so the count shape omits them.
|
|
1166
|
+
if (opts.includeExtensions && !isCountProjection) {
|
|
959
1167
|
const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
|
|
960
1168
|
const allMods = getModules() as any[]
|
|
961
1169
|
const allExts = allMods.flatMap((m) => (m as any).entityExtensions || [])
|
|
@@ -982,7 +1190,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
982
1190
|
}
|
|
983
1191
|
|
|
984
1192
|
// Sorting: base fields and cf:* (use aggregated alias for cf)
|
|
985
|
-
for (const s of resolvedSorts) {
|
|
1193
|
+
for (const s of isCountProjection ? [] : resolvedSorts) {
|
|
986
1194
|
if (s.field.startsWith('cf:')) {
|
|
987
1195
|
const key = s.field.slice(3)
|
|
988
1196
|
const alias = sanitize(`cf:${key}`)
|
|
@@ -1000,8 +1208,12 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1000
1208
|
}
|
|
1001
1209
|
}
|
|
1002
1210
|
|
|
1003
|
-
// Deduplicate if we joined CFs or extensions by grouping on base id
|
|
1004
|
-
|
|
1211
|
+
// Deduplicate if we joined CFs or extensions by grouping on base id. The count
|
|
1212
|
+
// shape has neither, and must stay barrier-free for its LIMIT to bind.
|
|
1213
|
+
const hasJoinedAggregates = !isCountProjection && (
|
|
1214
|
+
(opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) ||
|
|
1215
|
+
Object.keys(cfValueExprByKey).length > 0
|
|
1216
|
+
)
|
|
1005
1217
|
if (hasJoinedAggregates) {
|
|
1006
1218
|
q = q.groupBy(`${table}.id`)
|
|
1007
1219
|
}
|
|
@@ -1021,22 +1233,35 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1021
1233
|
resolvedCustomFieldDefinitions,
|
|
1022
1234
|
} = await buildQuery('full')
|
|
1023
1235
|
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1236
|
+
// The count is built independently of the display query (the `'count'`
|
|
1237
|
+
// projection): scope + filters only, cf filters as correlated EXISTS
|
|
1238
|
+
// semi-joins, no projection joins. Nothing can multiply base rows, so
|
|
1239
|
+
// `count(*)` needs no DISTINCT (completing #2227) and — when the cap is
|
|
1240
|
+
// active — the LIMIT sits on a row-producing inner query with no
|
|
1241
|
+
// aggregate/sort barrier below it, so it actually bounds the scan.
|
|
1242
|
+
const countCap = resolveListCountCap()
|
|
1243
|
+
const { builder: countShape } = await buildQuery('count')
|
|
1244
|
+
let total: number
|
|
1245
|
+
let listCountCapWarning: ListCountCapWarning | undefined
|
|
1246
|
+
if (countCap !== null) {
|
|
1247
|
+
const probe = countShape.select(sql<number>`1`.as('one')).limit(countCap + 1)
|
|
1248
|
+
const countRow = await db
|
|
1249
|
+
.selectFrom(probe.as('om_count_probe') as any)
|
|
1250
|
+
.select(sql<string>`count(*)`.as('count'))
|
|
1251
|
+
.executeTakeFirst() as { count: unknown } | undefined
|
|
1252
|
+
const probed = Number((countRow as any)?.count ?? 0)
|
|
1253
|
+
if (probed > countCap) {
|
|
1254
|
+
total = countCap
|
|
1255
|
+
listCountCapWarning = { entity, cap: countCap }
|
|
1256
|
+
} else {
|
|
1257
|
+
total = probed
|
|
1258
|
+
}
|
|
1259
|
+
} else {
|
|
1260
|
+
const countRow = await countShape
|
|
1261
|
+
.select(sql<string>`count(*)`.as('count'))
|
|
1262
|
+
.executeTakeFirst() as { count: unknown } | undefined
|
|
1263
|
+
total = Number((countRow as any)?.count ?? 0)
|
|
1264
|
+
}
|
|
1040
1265
|
|
|
1041
1266
|
const svc = encryptionService
|
|
1042
1267
|
const decryptPayload =
|
|
@@ -1097,9 +1322,14 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1097
1322
|
const cap = resolveEncryptedSortMaxRows()
|
|
1098
1323
|
let qSort = (await buildQuery('sortKeys')).builder
|
|
1099
1324
|
if (cap !== null) {
|
|
1100
|
-
|
|
1325
|
+
// Probe one row past the cap: truncation is detected from the candidate
|
|
1326
|
+
// scan itself, not by comparing against `total` — which may itself be
|
|
1327
|
+
// capped (`OM_LIST_COUNT_CAP`) and would then never exceed the sort cap.
|
|
1328
|
+
qSort = qSort.limit(cap + 1).orderBy(qualify('id'), 'asc' as any)
|
|
1101
1329
|
}
|
|
1102
|
-
const
|
|
1330
|
+
const candidateRowsRaw = await qSort.execute() as ResultRow[]
|
|
1331
|
+
const sortTruncated = cap !== null && candidateRowsRaw.length > cap
|
|
1332
|
+
const candidateRows = sortTruncated && cap !== null ? candidateRowsRaw.slice(0, cap) : candidateRowsRaw
|
|
1103
1333
|
const decryptedCandidates = decryptPayload
|
|
1104
1334
|
? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow)
|
|
1105
1335
|
: candidateRows
|
|
@@ -1108,7 +1338,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1108
1338
|
.slice((page - 1) * pageSize, page * pageSize)
|
|
1109
1339
|
.map((row) => row.id)
|
|
1110
1340
|
|
|
1111
|
-
if (cap !== null
|
|
1341
|
+
if (sortTruncated && cap !== null) {
|
|
1112
1342
|
encryptedSortRowCapWarning = {
|
|
1113
1343
|
entity,
|
|
1114
1344
|
sortFields: resolvedSorts.map((s) => s.field),
|
|
@@ -1144,8 +1374,10 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1144
1374
|
|
|
1145
1375
|
let queryResult: QueryResult<T> = { items: pagedItems as unknown as T[], page, pageSize, total }
|
|
1146
1376
|
|
|
1147
|
-
if (encryptedSortRowCapWarning) {
|
|
1148
|
-
const meta: QueryResultMeta = {
|
|
1377
|
+
if (encryptedSortRowCapWarning || listCountCapWarning) {
|
|
1378
|
+
const meta: QueryResultMeta = {}
|
|
1379
|
+
if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning
|
|
1380
|
+
if (listCountCapWarning) meta.listCountCapWarning = listCountCapWarning
|
|
1149
1381
|
queryResult.meta = meta
|
|
1150
1382
|
}
|
|
1151
1383
|
|
|
@@ -1204,6 +1436,143 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1204
1436
|
}
|
|
1205
1437
|
}
|
|
1206
1438
|
|
|
1439
|
+
/**
|
|
1440
|
+
* Apply a `cf:*` filter as a correlated EXISTS semi-join over
|
|
1441
|
+
* `custom_field_values` (+ `custom_field_defs` for kind-based coercion) —
|
|
1442
|
+
* the count shape's equivalent of the projection path's leftJoin + WHERE.
|
|
1443
|
+
* A semi-join returns each base row at most once, so the count query needs
|
|
1444
|
+
* no DISTINCT or GROUP BY and stays boundable by an outer LIMIT.
|
|
1445
|
+
*
|
|
1446
|
+
* Predicates satisfied by the *absence* of a value row (`eq null`,
|
|
1447
|
+
* `exists: false`) become `NOT EXISTS(value) OR EXISTS(null value)`,
|
|
1448
|
+
* matching the leftJoin form where a missing row yields a NULL expression.
|
|
1449
|
+
*/
|
|
1450
|
+
private applyCfValueExistsFilter(
|
|
1451
|
+
q: AnyBuilder,
|
|
1452
|
+
opts: {
|
|
1453
|
+
source: ResolvedCustomFieldSource
|
|
1454
|
+
qualify: (column: string) => string
|
|
1455
|
+
tenantId: string | null
|
|
1456
|
+
key: string
|
|
1457
|
+
op: NormalizedFilter['op']
|
|
1458
|
+
value: unknown
|
|
1459
|
+
},
|
|
1460
|
+
): AnyBuilder {
|
|
1461
|
+
return q.where((eb: any) => this.buildCfValueExistsExpression(eb, opts))
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Expression-returning core of `applyCfValueExistsFilter`, so a cf leaf
|
|
1466
|
+
* inside an OR group can compile to an EXISTS predicate on the count shape
|
|
1467
|
+
* instead of being dropped for lacking a `cfValueExprByKey` entry.
|
|
1468
|
+
*/
|
|
1469
|
+
private buildCfValueExistsExpression(
|
|
1470
|
+
eb: any,
|
|
1471
|
+
opts: {
|
|
1472
|
+
source: ResolvedCustomFieldSource
|
|
1473
|
+
qualify: (column: string) => string
|
|
1474
|
+
tenantId: string | null
|
|
1475
|
+
key: string
|
|
1476
|
+
op: NormalizedFilter['op']
|
|
1477
|
+
value: unknown
|
|
1478
|
+
},
|
|
1479
|
+
): any {
|
|
1480
|
+
const { source, qualify, tenantId, key, op, value } = opts
|
|
1481
|
+
const seq = this.searchAliasSeq++
|
|
1482
|
+
const valAlias = `cfev_${seq}`
|
|
1483
|
+
const defAlias = `cfed_${seq}`
|
|
1484
|
+
const srcAlias = `cfes_${seq}`
|
|
1485
|
+
const caseExpr = sql<string | null>`CASE ${sql.ref(`${defAlias}.kind`)}
|
|
1486
|
+
WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
|
|
1487
|
+
WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
|
|
1488
|
+
WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
|
|
1489
|
+
WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
|
|
1490
|
+
ELSE (${sql.ref(`${valAlias}.value_text`)})::text
|
|
1491
|
+
END`
|
|
1492
|
+
|
|
1493
|
+
const buildSub = (eb: any): AnyBuilder => {
|
|
1494
|
+
let sub: AnyBuilder = eb
|
|
1495
|
+
.selectFrom(`custom_field_values as ${valAlias}`)
|
|
1496
|
+
.select(sql<number>`1`.as('one'))
|
|
1497
|
+
.leftJoin(`custom_field_defs as ${defAlias}`, (jb: any) =>
|
|
1498
|
+
jb.on(`${defAlias}.entity_id`, '=', String(source.entityId))
|
|
1499
|
+
.on(`${defAlias}.key`, '=', key)
|
|
1500
|
+
.on(`${defAlias}.is_active`, '=', true)
|
|
1501
|
+
.on((jeb: any) => jeb.or([
|
|
1502
|
+
jeb(`${defAlias}.tenant_id`, '=', tenantId),
|
|
1503
|
+
jeb(`${defAlias}.tenant_id`, 'is', null),
|
|
1504
|
+
])))
|
|
1505
|
+
.where(`${valAlias}.entity_id`, '=', String(source.entityId))
|
|
1506
|
+
.where(`${valAlias}.field_key`, '=', key)
|
|
1507
|
+
.where((web: any) => web.or([
|
|
1508
|
+
web(`${valAlias}.tenant_id`, '=', tenantId),
|
|
1509
|
+
web(`${valAlias}.tenant_id`, 'is', null),
|
|
1510
|
+
]))
|
|
1511
|
+
if (source.hop) {
|
|
1512
|
+
sub = sub
|
|
1513
|
+
.innerJoin(`${source.table} as ${srcAlias}`, (jb: any) =>
|
|
1514
|
+
jb.on(sql<boolean>`${sql.ref(`${valAlias}.record_id`)} = (${sql.ref(`${srcAlias}.${source.hop!.recordIdColumn}`)})::text`))
|
|
1515
|
+
.whereRef(`${srcAlias}.${source.hop.toField}`, '=', qualify(source.hop.fromField))
|
|
1516
|
+
} else {
|
|
1517
|
+
sub = sub.where(sql<boolean>`${sql.ref(`${valAlias}.record_id`)} = ${source.recordIdExpr}`)
|
|
1518
|
+
}
|
|
1519
|
+
return sub
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const absenceSatisfiable = (op === 'eq' && value === null) || (op === 'exists' && !value)
|
|
1523
|
+
if (absenceSatisfiable) {
|
|
1524
|
+
return eb.or([
|
|
1525
|
+
eb.not(eb.exists(buildSub(eb))),
|
|
1526
|
+
eb.exists(buildSub(eb).where(sql<boolean>`${caseExpr} is null`)),
|
|
1527
|
+
])
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
let predicate: RawBuilder<boolean> | null = null
|
|
1531
|
+
switch (op) {
|
|
1532
|
+
case 'eq':
|
|
1533
|
+
predicate = sql<boolean>`${caseExpr} = ${value}`
|
|
1534
|
+
break
|
|
1535
|
+
case 'ne':
|
|
1536
|
+
predicate = value === null
|
|
1537
|
+
? sql<boolean>`${caseExpr} is not null`
|
|
1538
|
+
: sql<boolean>`${caseExpr} != ${value}`
|
|
1539
|
+
break
|
|
1540
|
+
case 'gt':
|
|
1541
|
+
case 'gte':
|
|
1542
|
+
case 'lt':
|
|
1543
|
+
case 'lte': {
|
|
1544
|
+
const operator = sql.raw(op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<=')
|
|
1545
|
+
predicate = sql<boolean>`${caseExpr} ${operator} ${value}`
|
|
1546
|
+
break
|
|
1547
|
+
}
|
|
1548
|
+
case 'in': {
|
|
1549
|
+
const vals = Array.isArray(value) ? value : [value]
|
|
1550
|
+
predicate = sql<boolean>`${caseExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`
|
|
1551
|
+
break
|
|
1552
|
+
}
|
|
1553
|
+
case 'nin': {
|
|
1554
|
+
const vals = Array.isArray(value) ? value : [value]
|
|
1555
|
+
predicate = sql<boolean>`${caseExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`
|
|
1556
|
+
break
|
|
1557
|
+
}
|
|
1558
|
+
case 'like':
|
|
1559
|
+
predicate = sql<boolean>`${caseExpr} like ${value}`
|
|
1560
|
+
break
|
|
1561
|
+
case 'ilike':
|
|
1562
|
+
predicate = sql<boolean>`${caseExpr} ilike ${value}`
|
|
1563
|
+
break
|
|
1564
|
+
case 'exists':
|
|
1565
|
+
predicate = sql<boolean>`${caseExpr} is not null`
|
|
1566
|
+
break
|
|
1567
|
+
default:
|
|
1568
|
+
// Mirrors buildColumnOpExpression's unknown-op fallback: a neutral
|
|
1569
|
+
// predicate, so full and count shapes drop the same leaves.
|
|
1570
|
+
return eb.val(true)
|
|
1571
|
+
}
|
|
1572
|
+
const captured = predicate
|
|
1573
|
+
return eb.exists(buildSub(eb).where(captured))
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1207
1576
|
private buildColumnOpExpression(eb: any, column: string | RawBuilder<unknown>, op: string, value: unknown): any {
|
|
1208
1577
|
switch (op) {
|
|
1209
1578
|
case 'eq': return value === null ? eb(column, 'is', null) : eb(column, '=', value)
|
|
@@ -1229,11 +1598,9 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1229
1598
|
|
|
1230
1599
|
private async columnExists(table: string, column: string): Promise<boolean> {
|
|
1231
1600
|
const key = `${table}.${column}`
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
this.columnCache.delete(key)
|
|
1236
|
-
}
|
|
1601
|
+
const ttlMs = resolveColumnExistsCacheTtlMs()
|
|
1602
|
+
const cached = columnExistsCache.get(key)
|
|
1603
|
+
if (cached && cached.expiresAt > Date.now()) return cached.value
|
|
1237
1604
|
const db = this.getDb()
|
|
1238
1605
|
const exists = await db
|
|
1239
1606
|
.selectFrom('information_schema.columns' as any)
|
|
@@ -1243,8 +1610,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1243
1610
|
.limit(1)
|
|
1244
1611
|
.executeTakeFirst()
|
|
1245
1612
|
const present = !!exists
|
|
1246
|
-
if (
|
|
1247
|
-
else this.columnCache.delete(key)
|
|
1613
|
+
if (ttlMs > 0) storeColumnExists(key, present, ttlMs)
|
|
1248
1614
|
return present
|
|
1249
1615
|
}
|
|
1250
1616
|
|
|
@@ -1451,6 +1817,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1451
1817
|
db: AnyDb,
|
|
1452
1818
|
opts: QueryOptions,
|
|
1453
1819
|
qualify: (column: string) => string,
|
|
1820
|
+
attachJoins: boolean = true,
|
|
1454
1821
|
): { builder: AnyBuilder; sources: ResolvedCustomFieldSource[] } {
|
|
1455
1822
|
const sources: ResolvedCustomFieldSource[] = [
|
|
1456
1823
|
{
|
|
@@ -1469,15 +1836,28 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1469
1836
|
if (!join) {
|
|
1470
1837
|
throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`)
|
|
1471
1838
|
}
|
|
1472
|
-
const
|
|
1473
|
-
|
|
1474
|
-
|
|
1839
|
+
const joinType: 'left' | 'inner' = (join.type ?? 'left') === 'inner' ? 'inner' : 'left'
|
|
1840
|
+
if (attachJoins) {
|
|
1841
|
+
const joinFn = joinType === 'inner' ? 'innerJoin' : 'leftJoin'
|
|
1842
|
+
next = (next as any)[joinFn](`${joinTable} as ${alias}`, (jb: any) =>
|
|
1843
|
+
jb.onRef(`${alias}.${join.toField}`, '=', qualify(join.fromField)))
|
|
1844
|
+
} else if (joinType === 'inner') {
|
|
1845
|
+
// The count projection carries no projection joins, but an inner-typed
|
|
1846
|
+
// source join restricts the result set — preserve that as a semi-join.
|
|
1847
|
+
next = next.where((eb: any) => eb.exists(
|
|
1848
|
+
eb
|
|
1849
|
+
.selectFrom(`${joinTable} as ${alias}`)
|
|
1850
|
+
.select(sql<number>`1`.as('one'))
|
|
1851
|
+
.whereRef(`${alias}.${join.toField}`, '=', qualify(join.fromField)),
|
|
1852
|
+
))
|
|
1853
|
+
}
|
|
1475
1854
|
const recordColumn = srcOpt.recordIdColumn ?? 'id'
|
|
1476
1855
|
sources.push({
|
|
1477
1856
|
entityId: srcOpt.entityId,
|
|
1478
1857
|
alias,
|
|
1479
1858
|
table: joinTable,
|
|
1480
1859
|
recordIdExpr: sql<string>`${sql.ref(`${alias}.${recordColumn}`)}::text`,
|
|
1860
|
+
hop: { fromField: join.fromField, toField: join.toField, recordIdColumn: recordColumn, type: joinType },
|
|
1481
1861
|
})
|
|
1482
1862
|
})
|
|
1483
1863
|
return { builder: next, sources }
|