@open-mercato/shared 0.6.8-develop.6924.1.a8d208fcdc → 0.6.8-develop.6930.1.1e5976efc3
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/dist/lib/commands/command-bus.js +19 -0
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/query/engine.js +36 -23
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/commands/__tests__/command-bus.test.ts +143 -0
- package/src/lib/commands/command-bus.ts +24 -0
- package/src/lib/commands/command-interceptor.ts +9 -1
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +94 -0
- package/src/lib/query/engine.ts +64 -32
|
@@ -124,6 +124,149 @@ describe('CommandBus', () => {
|
|
|
124
124
|
expect(execute).toHaveBeenCalledTimes(1)
|
|
125
125
|
})
|
|
126
126
|
|
|
127
|
+
it('merges command interceptor beforeExecute returned metadata.logContext into logged context with correct precedence', async () => {
|
|
128
|
+
const logMock = jest.fn(async () => ({ id: 'log-entry' }))
|
|
129
|
+
registerCommand({
|
|
130
|
+
id: 'test.command.interceptor-context',
|
|
131
|
+
execute: jest.fn(async () => ({ ok: true })),
|
|
132
|
+
buildLog: jest.fn(() => ({
|
|
133
|
+
actionLabel: 'Test',
|
|
134
|
+
resourceKind: 'test',
|
|
135
|
+
resourceId: '123',
|
|
136
|
+
context: {
|
|
137
|
+
original: 'buildlog-original-value',
|
|
138
|
+
interceptorOverridden: 'buildlog-takes-precedence',
|
|
139
|
+
},
|
|
140
|
+
})),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
registerCommandInterceptors([
|
|
144
|
+
{
|
|
145
|
+
moduleId: 'test-module',
|
|
146
|
+
interceptors: [
|
|
147
|
+
{
|
|
148
|
+
id: 'test-interceptor-priority-2',
|
|
149
|
+
targetCommand: 'test.command.*',
|
|
150
|
+
priority: 60, // runs second
|
|
151
|
+
beforeExecute: async () => ({
|
|
152
|
+
ok: true,
|
|
153
|
+
metadata: {
|
|
154
|
+
logContext: {
|
|
155
|
+
ip: '127.0.0.1',
|
|
156
|
+
requestId: 'req-second',
|
|
157
|
+
interceptorOverlap: 'second-wins',
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: 'test-interceptor-priority-1',
|
|
164
|
+
targetCommand: 'test.command.*',
|
|
165
|
+
priority: 40, // runs first
|
|
166
|
+
beforeExecute: async () => ({
|
|
167
|
+
ok: true,
|
|
168
|
+
metadata: {
|
|
169
|
+
logContext: {
|
|
170
|
+
requestId: 'req-first',
|
|
171
|
+
interceptorOverlap: 'first-loss',
|
|
172
|
+
baseOverridden: 'interceptor-wins-over-base',
|
|
173
|
+
interceptorOverridden: 'interceptor-loss-to-buildlog',
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
|
|
183
|
+
container.register({ actionLogService: asValue({ log: logMock }) })
|
|
184
|
+
|
|
185
|
+
const bus = new CommandBus()
|
|
186
|
+
const ctx = {
|
|
187
|
+
container,
|
|
188
|
+
auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: null },
|
|
189
|
+
organizationScope: null,
|
|
190
|
+
selectedOrganizationId: null,
|
|
191
|
+
organizationIds: null,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await bus.execute('test.command.interceptor-context', {
|
|
195
|
+
input: {},
|
|
196
|
+
ctx,
|
|
197
|
+
metadata: {
|
|
198
|
+
context: {
|
|
199
|
+
original: 'base-original-value', // will be overridden by buildLog
|
|
200
|
+
baseOverridden: 'base-original-to-be-overridden',
|
|
201
|
+
untouched: 'base-untouched',
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
expect(logMock).toHaveBeenCalledWith(
|
|
207
|
+
expect.objectContaining({
|
|
208
|
+
context: {
|
|
209
|
+
untouched: 'base-untouched',
|
|
210
|
+
baseOverridden: 'interceptor-wins-over-base',
|
|
211
|
+
requestId: 'req-second',
|
|
212
|
+
interceptorOverlap: 'second-wins',
|
|
213
|
+
ip: '127.0.0.1',
|
|
214
|
+
original: 'buildlog-original-value',
|
|
215
|
+
interceptorOverridden: 'buildlog-takes-precedence',
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('does not promote a generic interceptor metadata.context key into the logged context', async () => {
|
|
222
|
+
const logMock = jest.fn(async () => ({ id: 'log-entry' }))
|
|
223
|
+
registerCommand({
|
|
224
|
+
id: 'test.command.private-metadata',
|
|
225
|
+
execute: jest.fn(async () => ({ ok: true })),
|
|
226
|
+
buildLog: jest.fn(() => ({
|
|
227
|
+
actionLabel: 'Test',
|
|
228
|
+
resourceKind: 'test',
|
|
229
|
+
resourceId: '123',
|
|
230
|
+
})),
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
registerCommandInterceptors([
|
|
234
|
+
{
|
|
235
|
+
moduleId: 'test-module',
|
|
236
|
+
interceptors: [
|
|
237
|
+
{
|
|
238
|
+
id: 'test-interceptor-private-metadata',
|
|
239
|
+
targetCommand: 'test.command.*',
|
|
240
|
+
beforeExecute: async () => ({
|
|
241
|
+
ok: true,
|
|
242
|
+
// `context` here is the interceptor's own after-hook state, not audit input.
|
|
243
|
+
metadata: { context: { internalHandle: 'must-stay-private' } },
|
|
244
|
+
}),
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
])
|
|
249
|
+
|
|
250
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
|
|
251
|
+
container.register({ actionLogService: asValue({ log: logMock }) })
|
|
252
|
+
|
|
253
|
+
await new CommandBus().execute('test.command.private-metadata', {
|
|
254
|
+
input: {},
|
|
255
|
+
ctx: {
|
|
256
|
+
container,
|
|
257
|
+
auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: null },
|
|
258
|
+
organizationScope: null,
|
|
259
|
+
selectedOrganizationId: null,
|
|
260
|
+
organizationIds: null,
|
|
261
|
+
},
|
|
262
|
+
metadata: { context: { untouched: 'base-untouched' } },
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
expect(logMock).toHaveBeenCalledWith(
|
|
266
|
+
expect.objectContaining({ context: { untouched: 'base-untouched' } })
|
|
267
|
+
)
|
|
268
|
+
})
|
|
269
|
+
|
|
127
270
|
describe('interceptor rejections', () => {
|
|
128
271
|
const blockingInterceptor = (result: Record<string, unknown>): CommandInterceptor => ({
|
|
129
272
|
id: 'test.block',
|
|
@@ -267,6 +267,30 @@ export class CommandBus {
|
|
|
267
267
|
const snapshotsWithAfter = { ...snapshots, after: afterSnapshot }
|
|
268
268
|
const logMeta = await this.buildLog(handler, effectiveOptions, result, snapshotsWithAfter)
|
|
269
269
|
let mergedMeta = this.mergeMetadata(effectiveOptions.metadata, logMeta)
|
|
270
|
+
// Interceptors opt into audit-log enrichment with a reserved `logContext` key rather
|
|
271
|
+
// than the generic `context` one, so the metadata an interceptor already passes to its
|
|
272
|
+
// own afterExecute hook is never silently promoted into audit storage.
|
|
273
|
+
// Map iteration order is interceptor priority order (see collectMatching), so a
|
|
274
|
+
// later-priority interceptor overrides an earlier one on key collisions.
|
|
275
|
+
let interceptorContextMerged: Record<string, unknown> = {}
|
|
276
|
+
for (const meta of interceptorMetadata.values()) {
|
|
277
|
+
const logContextRecord = asRecord(asRecord(meta)?.logContext)
|
|
278
|
+
if (!logContextRecord) continue
|
|
279
|
+
interceptorContextMerged = {
|
|
280
|
+
...interceptorContextMerged,
|
|
281
|
+
...logContextRecord,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const baseContext = asRecord(effectiveOptions.metadata?.context) ?? {}
|
|
285
|
+
const logMetaContext = asRecord(logMeta?.context) ?? {}
|
|
286
|
+
if (Object.keys(interceptorContextMerged).length > 0 || Object.keys(baseContext).length > 0 || Object.keys(logMetaContext).length > 0) {
|
|
287
|
+
mergedMeta = mergedMeta ?? {}
|
|
288
|
+
mergedMeta.context = {
|
|
289
|
+
...baseContext,
|
|
290
|
+
...interceptorContextMerged,
|
|
291
|
+
...logMetaContext,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
270
294
|
const undoable = this.isUndoable(handler)
|
|
271
295
|
if (undoable) {
|
|
272
296
|
mergedMeta = mergedMeta ?? {}
|
|
@@ -77,7 +77,15 @@ export interface CommandInterceptorBeforeResult {
|
|
|
77
77
|
body?: Record<string, unknown>
|
|
78
78
|
/** Modified input — shallow-merged into command input if ok:true */
|
|
79
79
|
modifiedInput?: Record<string, unknown>
|
|
80
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Metadata passed to the corresponding after hook.
|
|
82
|
+
*
|
|
83
|
+
* Reserved key — `logContext`: a record here (e.g. `{ logContext: { ip: '...' } }`) is
|
|
84
|
+
* shallow-merged into the persisted `ActionLog.context_json`. Every other key stays
|
|
85
|
+
* private to the after hook. Contributed keys take precedence over
|
|
86
|
+
* `options.metadata.context` but yield to `buildLog().context` on conflicts; when two
|
|
87
|
+
* interceptors contribute the same key, the higher `priority` (later-running) one wins.
|
|
88
|
+
*/
|
|
81
89
|
metadata?: Record<string, unknown>
|
|
82
90
|
}
|
|
83
91
|
|
|
@@ -484,3 +484,97 @@ describe('BasicQueryEngine — multi-field $or grouping', () => {
|
|
|
484
484
|
expect(hasNullTenant).toBe(true)
|
|
485
485
|
})
|
|
486
486
|
})
|
|
487
|
+
|
|
488
|
+
describe('BasicQueryEngine — custom-field leaves inside an $or group (#5039)', () => {
|
|
489
|
+
const customFieldDb = () => createFakeKysely({
|
|
490
|
+
scheduled_jobs: [],
|
|
491
|
+
custom_field_defs: [
|
|
492
|
+
{ key: 'priority', entity_id: 'scheduler:scheduled_job', is_active: true, tenant_id: null, kind: 'text' },
|
|
493
|
+
],
|
|
494
|
+
custom_field_values: [],
|
|
495
|
+
'information_schema.columns': [
|
|
496
|
+
{ table_name: 'scheduled_jobs', column_name: 'organization_id' },
|
|
497
|
+
{ table_name: 'scheduled_jobs', column_name: 'tenant_id' },
|
|
498
|
+
{ table_name: 'scheduled_jobs', column_name: 'scope_type' },
|
|
499
|
+
],
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
test('two cf:* values joined by $or become one OR of two disjuncts, not two ANDed filters', async () => {
|
|
503
|
+
const fakeDb = customFieldDb()
|
|
504
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
505
|
+
await engine.query('scheduler:scheduled_job', {
|
|
506
|
+
tenantId: 't1',
|
|
507
|
+
fields: ['id', 'cf:priority'],
|
|
508
|
+
omitAutomaticTenantOrgScope: true,
|
|
509
|
+
filters: {
|
|
510
|
+
$or: [
|
|
511
|
+
{ 'cf:priority': { $eq: 'high' } },
|
|
512
|
+
{ 'cf:priority': { $eq: 'low' } },
|
|
513
|
+
],
|
|
514
|
+
},
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
|
|
518
|
+
expect(baseCall).toBeTruthy()
|
|
519
|
+
|
|
520
|
+
// Before the fix each value was applied as its own `.where()`, so the query asked
|
|
521
|
+
// for priority = 'high' AND priority = 'low' and could never match a row.
|
|
522
|
+
const orEntry = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'or')
|
|
523
|
+
expect(orEntry).toBeTruthy()
|
|
524
|
+
const orParts: Node[] = orEntry[1]
|
|
525
|
+
expect(orParts.length).toBe(2)
|
|
526
|
+
|
|
527
|
+
const values = orParts.flatMap((part) => flattenCmps(part)).map((cmp) => cmp.value)
|
|
528
|
+
expect(values).toEqual(expect.arrayContaining(['high', 'low']))
|
|
529
|
+
|
|
530
|
+
const standaloneCfFilters = baseCall._ops.wheres.filter(
|
|
531
|
+
(w: any) => Array.isArray(w) && w.length === 3 && (w[2] === 'high' || w[2] === 'low'),
|
|
532
|
+
)
|
|
533
|
+
expect(standaloneCfFilters).toHaveLength(0)
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
test('a base column OR a cf:* value unites both legs in the same disjunction', async () => {
|
|
537
|
+
const fakeDb = customFieldDb()
|
|
538
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
539
|
+
await engine.query('scheduler:scheduled_job', {
|
|
540
|
+
tenantId: 't1',
|
|
541
|
+
fields: ['id', 'cf:priority'],
|
|
542
|
+
omitAutomaticTenantOrgScope: true,
|
|
543
|
+
filters: {
|
|
544
|
+
$or: [
|
|
545
|
+
{ scope_type: { $eq: 'system' } },
|
|
546
|
+
{ 'cf:priority': { $eq: 'high' } },
|
|
547
|
+
],
|
|
548
|
+
},
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
|
|
552
|
+
const orEntry = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'or')
|
|
553
|
+
expect(orEntry).toBeTruthy()
|
|
554
|
+
const orParts: Node[] = orEntry[1]
|
|
555
|
+
expect(orParts.length).toBe(2)
|
|
556
|
+
|
|
557
|
+
const cmps = orParts.flatMap((part) => flattenCmps(part))
|
|
558
|
+
expect(cmps.some((cmp) => String(cmp.column).endsWith('scope_type') && cmp.value === 'system')).toBe(true)
|
|
559
|
+
expect(cmps.some((cmp) => cmp.value === 'high')).toBe(true)
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
test('an ungrouped cf:* filter still applies as its own AND predicate', async () => {
|
|
563
|
+
const fakeDb = customFieldDb()
|
|
564
|
+
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
565
|
+
await engine.query('scheduler:scheduled_job', {
|
|
566
|
+
tenantId: 't1',
|
|
567
|
+
fields: ['id', 'cf:priority'],
|
|
568
|
+
omitAutomaticTenantOrgScope: true,
|
|
569
|
+
filters: { 'cf:priority': { $eq: 'high' } },
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
const baseCall = fakeDb._calls.find((b: any) => b._ops.table === 'scheduled_jobs')
|
|
573
|
+
const orEntry = baseCall._ops.wheres.find((w: any) => Array.isArray(w) && w[0] === 'or')
|
|
574
|
+
expect(orEntry).toBeFalsy()
|
|
575
|
+
const directFilter = baseCall._ops.wheres.some(
|
|
576
|
+
(w: any) => Array.isArray(w) && w.length === 3 && w[1] === '=' && w[2] === 'high',
|
|
577
|
+
)
|
|
578
|
+
expect(directFilter).toBe(true)
|
|
579
|
+
})
|
|
580
|
+
})
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -316,6 +316,10 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
316
316
|
}
|
|
317
317
|
const { baseFilters, joinFilters } = partitionFilters(table, normalizedFilters, joinMap)
|
|
318
318
|
const cfFilters = normalizedFilters.filter((filter) => String(filter.field).startsWith('cf:'))
|
|
319
|
+
// Custom-field leaves carrying an orGroup belong to an OR disjunct; applying them
|
|
320
|
+
// one `.where()` at a time would AND them onto every disjunct (#5039).
|
|
321
|
+
const regularCfFilters = cfFilters.filter((filter) => !filter.orGroup)
|
|
322
|
+
const orGroupCfFilters = cfFilters.filter((filter) => filter.orGroup)
|
|
319
323
|
const searchConfig = resolveSearchConfig()
|
|
320
324
|
const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => isSearchFilterOp(filter.op))
|
|
321
325
|
// Callers that opt out of automatic tenant/org scoping own the full
|
|
@@ -576,51 +580,41 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
576
580
|
}
|
|
577
581
|
|
|
578
582
|
// OR-grouped filters: AND within each group (one $or disjunct), OR between groups.
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
583
|
+
// Resolution happens here (it needs async column lookups); the WHERE itself is
|
|
584
|
+
// applied further down, once the cf:* value expressions exist — an OR group may
|
|
585
|
+
// contain custom-field leaves whose SQL is only available then (#5039).
|
|
586
|
+
type ResolvedOrClause =
|
|
587
|
+
| { kind: 'column'; qualified: string; op: NormalizedFilter['op']; value: unknown }
|
|
588
|
+
| { kind: 'doc'; field: string; op: NormalizedFilter['op']; value: unknown }
|
|
589
|
+
| { kind: 'cf'; key: string; op: NormalizedFilter['op']; value: unknown }
|
|
590
|
+
const resolvedGroupFilters: ResolvedOrClause[][] = []
|
|
591
|
+
if (orGroupFilters.length > 0 || orGroupCfFilters.length > 0) {
|
|
592
|
+
const groups = new Map<string, NormalizedFilter[]>()
|
|
593
|
+
for (const f of [...orGroupFilters, ...orGroupCfFilters]) {
|
|
582
594
|
const group = groups.get(f.orGroup!) ?? []
|
|
583
|
-
group.push(f)
|
|
595
|
+
group.push(f as NormalizedFilter)
|
|
584
596
|
groups.set(f.orGroup!, group)
|
|
585
597
|
}
|
|
586
|
-
type ResolvedOrClause =
|
|
587
|
-
| { kind: 'column'; qualified: string; op: NormalizedFilter['op']; value: unknown }
|
|
588
|
-
| { kind: 'doc'; field: string; op: NormalizedFilter['op']; value: unknown }
|
|
589
|
-
const resolvedGroupFilters: ResolvedOrClause[][] = []
|
|
590
598
|
for (const [, groupFilters] of groups) {
|
|
591
599
|
const resolved: ResolvedOrClause[] = []
|
|
592
600
|
for (const filter of groupFilters) {
|
|
593
|
-
const
|
|
601
|
+
const field = String(filter.field)
|
|
602
|
+
if (field.startsWith('cf:')) {
|
|
603
|
+
resolved.push({ kind: 'cf', key: field.slice(3), op: filter.op, value: filter.value })
|
|
604
|
+
continue
|
|
605
|
+
}
|
|
606
|
+
const column = await this.resolveBaseColumn(table, field)
|
|
594
607
|
if (column) {
|
|
595
608
|
resolved.push({ kind: 'column', qualified: qualify(column), op: filter.op, value: filter.value })
|
|
596
609
|
} else {
|
|
597
610
|
// Field is not a base column — for custom-entity records it lives in
|
|
598
611
|
// entity_indexes.doc. Build an EXISTS sub-filter so `$or` searches
|
|
599
612
|
// across doc fields resolve instead of being silently dropped (#3229).
|
|
600
|
-
resolved.push({ kind: 'doc', field
|
|
613
|
+
resolved.push({ kind: 'doc', field, op: filter.op, value: filter.value })
|
|
601
614
|
}
|
|
602
615
|
}
|
|
603
616
|
if (resolved.length > 0) resolvedGroupFilters.push(resolved)
|
|
604
617
|
}
|
|
605
|
-
if (resolvedGroupFilters.length > 0) {
|
|
606
|
-
q = q.where((eb: any) => eb.or(
|
|
607
|
-
resolvedGroupFilters.map((group) => {
|
|
608
|
-
const parts = group.map((rf) => rf.kind === 'column'
|
|
609
|
-
? this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value)
|
|
610
|
-
: this.buildIndexDocOpExpression(eb, {
|
|
611
|
-
entity: String(entity),
|
|
612
|
-
field: rf.field,
|
|
613
|
-
op: rf.op,
|
|
614
|
-
value: rf.value,
|
|
615
|
-
recordIdColumn,
|
|
616
|
-
tenantId: opts.tenantId ?? null,
|
|
617
|
-
organizationScope: orgScope,
|
|
618
|
-
withDeleted: opts.withDeleted === true,
|
|
619
|
-
}))
|
|
620
|
-
return parts.length === 1 ? parts[0] : eb.and(parts)
|
|
621
|
-
})
|
|
622
|
-
))
|
|
623
|
-
}
|
|
624
618
|
}
|
|
625
619
|
|
|
626
620
|
q = await applyJoinFilters({
|
|
@@ -869,8 +863,9 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
869
863
|
}
|
|
870
864
|
}
|
|
871
865
|
|
|
872
|
-
// Apply cf:* filters (on raw expressions)
|
|
873
|
-
|
|
866
|
+
// Apply cf:* filters (on raw expressions). OR-grouped ones are excluded here and
|
|
867
|
+
// combined with their disjunct's other leaves right below.
|
|
868
|
+
for (const f of regularCfFilters) {
|
|
874
869
|
if (!f.field.startsWith('cf:')) continue
|
|
875
870
|
const key = f.field.slice(3)
|
|
876
871
|
const expr = cfValueExprByKey[key]
|
|
@@ -912,6 +907,43 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
912
907
|
q = this.applyColumnOp(q, expr, f.op, f.value)
|
|
913
908
|
}
|
|
914
909
|
|
|
910
|
+
// OR groups are applied here, after the cf:* value expressions exist, so a
|
|
911
|
+
// disjunct mixing base/doc and custom-field leaves is united rather than
|
|
912
|
+
// intersected. A cf leaf whose key resolved no value expression yields no
|
|
913
|
+
// predicate and is dropped; a disjunct left empty by that is dropped too,
|
|
914
|
+
// because an empty AND would read as TRUE and widen the result.
|
|
915
|
+
//
|
|
916
|
+
// Known limitation, shared with the `doc` clause kind above: a leaf inside an OR
|
|
917
|
+
// group compares against the stored value directly and does not route `like` /
|
|
918
|
+
// `ilike` through the search-token index the way the ungrouped path does. On a
|
|
919
|
+
// field covered by an encryption map such a leaf therefore compares against
|
|
920
|
+
// ciphertext and will not match.
|
|
921
|
+
const applicableGroupFilters = resolvedGroupFilters
|
|
922
|
+
.map((group) => group.filter((rf) => rf.kind !== 'cf' || Boolean(cfValueExprByKey[rf.key])))
|
|
923
|
+
.filter((group) => group.length > 0)
|
|
924
|
+
if (applicableGroupFilters.length > 0) {
|
|
925
|
+
q = q.where((eb: any) => {
|
|
926
|
+
const disjuncts = applicableGroupFilters.map((group) => {
|
|
927
|
+
const parts = group.map((rf) => {
|
|
928
|
+
if (rf.kind === 'column') return this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value)
|
|
929
|
+
if (rf.kind === 'cf') return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
|
|
930
|
+
return this.buildIndexDocOpExpression(eb, {
|
|
931
|
+
entity: String(entity),
|
|
932
|
+
field: rf.field,
|
|
933
|
+
op: rf.op,
|
|
934
|
+
value: rf.value,
|
|
935
|
+
recordIdColumn,
|
|
936
|
+
tenantId: opts.tenantId ?? null,
|
|
937
|
+
organizationScope: orgScope,
|
|
938
|
+
withDeleted: opts.withDeleted === true,
|
|
939
|
+
})
|
|
940
|
+
})
|
|
941
|
+
return parts.length === 1 ? parts[0] : eb.and(parts)
|
|
942
|
+
})
|
|
943
|
+
return disjuncts.length === 1 ? disjuncts[0] : eb.or(disjuncts)
|
|
944
|
+
})
|
|
945
|
+
}
|
|
946
|
+
|
|
915
947
|
// Entity extensions joins (no selection yet; enables future filters/projections)
|
|
916
948
|
if (opts.includeExtensions) {
|
|
917
949
|
const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
|
|
@@ -1154,7 +1186,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1154
1186
|
}
|
|
1155
1187
|
}
|
|
1156
1188
|
|
|
1157
|
-
private buildColumnOpExpression(eb: any, column: string
|
|
1189
|
+
private buildColumnOpExpression(eb: any, column: string | RawBuilder<unknown>, op: string, value: unknown): any {
|
|
1158
1190
|
switch (op) {
|
|
1159
1191
|
case 'eq': return value === null ? eb(column, 'is', null) : eb(column, '=', value)
|
|
1160
1192
|
case 'ne': return value === null ? eb(column, 'is not', null) : eb(column, '!=', value)
|