@open-mercato/shared 0.5.1-develop.2691.d8a0934b37 → 0.5.1-develop.2694.732417c5ec
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/api/crud.js +1 -1
- package/dist/lib/api/crud.js.map +2 -2
- package/dist/lib/auth/server.js +1 -1
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/data/engine.js +68 -27
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/mikro.js +18 -22
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/indexers/error-log.js +10 -12
- package/dist/lib/indexers/error-log.js.map +2 -2
- package/dist/lib/indexers/status-log.js +14 -16
- package/dist/lib/indexers/status-log.js.map +2 -2
- package/dist/lib/query/engine.js +220 -228
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/join-utils.js +28 -23
- package/dist/lib/query/join-utils.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/jest.config.cjs +4 -2
- package/package.json +1 -1
- package/src/lib/api/__tests__/crud.test.ts +5 -3
- package/src/lib/api/crud.ts +1 -1
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +10 -4
- package/src/lib/auth/server.ts +1 -1
- package/src/lib/bootstrap/types.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +27 -17
- package/src/lib/data/engine.ts +95 -47
- package/src/lib/db/mikro.ts +26 -25
- package/src/lib/indexers/error-log.ts +23 -23
- package/src/lib/indexers/status-log.ts +36 -33
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +253 -114
- package/src/lib/query/__tests__/engine.test.ts +206 -139
- package/src/lib/query/engine.ts +306 -263
- package/src/lib/query/join-utils.ts +38 -30
package/src/lib/query/engine.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { QueryEngine, QueryOptions, QueryResult, QueryCustomFieldSource, QueryExtensionsConfig } from './types'
|
|
2
2
|
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
-
import type
|
|
4
|
+
import { type Kysely, sql, type RawBuilder } from 'kysely'
|
|
5
5
|
import {
|
|
6
6
|
applyJoinFilters,
|
|
7
7
|
normalizeFilters,
|
|
@@ -15,6 +15,9 @@ import { resolveSearchConfig } from '../search/config'
|
|
|
15
15
|
import { tokenizeText } from '../search/tokenize'
|
|
16
16
|
import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from './query-extension-runner'
|
|
17
17
|
|
|
18
|
+
type AnyDb = Kysely<any>
|
|
19
|
+
type AnyBuilder = any
|
|
20
|
+
|
|
18
21
|
const entityTableCache = new Map<string, string>()
|
|
19
22
|
|
|
20
23
|
type EncryptionResolver = () => {
|
|
@@ -26,7 +29,7 @@ type ResolvedCustomFieldSource = {
|
|
|
26
29
|
entityId: EntityId
|
|
27
30
|
alias: string
|
|
28
31
|
table: string
|
|
29
|
-
recordIdExpr:
|
|
32
|
+
recordIdExpr: RawBuilder<string>
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
type ResultRow = Record<string, unknown>
|
|
@@ -131,40 +134,37 @@ function buildFilterableCustomFieldJoins(
|
|
|
131
134
|
})
|
|
132
135
|
}
|
|
133
136
|
|
|
134
|
-
|
|
135
|
-
// Minimal default implementation placeholder.
|
|
136
|
-
// For now, only supports basic base-entity querying by table name inferred from EntityId ('<module>:<entity>' -> '<entities>') via convention.
|
|
137
|
-
// Extensions and custom fields will be added iteratively.
|
|
138
|
-
|
|
139
|
-
const computeScore = (cfg: Record<string, unknown>, kind: string, entityIndex: number) => {
|
|
137
|
+
function computeCustomFieldScore(cfg: Record<string, unknown>, kind: string, entityIndex: number) {
|
|
140
138
|
const listVisibleScore = cfg.listVisible === false ? 0 : 1
|
|
141
139
|
const formEditableScore = cfg.formEditable === false ? 0 : 1
|
|
142
140
|
const filterableScore = cfg.filterable ? 1 : 0
|
|
143
141
|
const kindScore = (() => {
|
|
144
142
|
switch (kind) {
|
|
145
|
-
case 'dictionary':
|
|
146
|
-
|
|
147
|
-
case '
|
|
148
|
-
|
|
149
|
-
case 'select':
|
|
150
|
-
return 4
|
|
151
|
-
case 'multiline':
|
|
152
|
-
return 3
|
|
143
|
+
case 'dictionary': return 8
|
|
144
|
+
case 'relation': return 6
|
|
145
|
+
case 'select': return 4
|
|
146
|
+
case 'multiline': return 3
|
|
153
147
|
case 'boolean':
|
|
154
148
|
case 'integer':
|
|
155
|
-
case 'float':
|
|
156
|
-
|
|
157
|
-
default:
|
|
158
|
-
return 1
|
|
149
|
+
case 'float': return 2
|
|
150
|
+
default: return 1
|
|
159
151
|
}
|
|
160
152
|
})()
|
|
161
153
|
const optionsBonus = Array.isArray(cfg.options) && cfg.options.length ? 2 : 0
|
|
162
|
-
const dictionaryBonus = typeof cfg.dictionaryId === 'string' && cfg.dictionaryId.trim().length ? 5 : 0
|
|
154
|
+
const dictionaryBonus = typeof cfg.dictionaryId === 'string' && (cfg.dictionaryId as string).trim().length ? 5 : 0
|
|
163
155
|
const base = (listVisibleScore * 16) + (formEditableScore * 8) + (filterableScore * 4) + kindScore + optionsBonus + dictionaryBonus
|
|
164
156
|
const penalty = typeof cfg.priority === 'number' ? cfg.priority : 0
|
|
165
157
|
return { base, penalty, entityIndex }
|
|
166
158
|
}
|
|
167
159
|
|
|
160
|
+
/**
|
|
161
|
+
* BasicQueryEngine — Kysely-backed fallback query engine.
|
|
162
|
+
*
|
|
163
|
+
* Resolves base tables via MikroORM metadata, applies tenant/organization/
|
|
164
|
+
* deleted_at scoping, handles custom field (cf:*) selection and filtering,
|
|
165
|
+
* and performs entity-extension joins. Used as the fallback for
|
|
166
|
+
* {@link HybridQueryEngine} when the query index is unavailable or incomplete.
|
|
167
|
+
*/
|
|
168
168
|
export class BasicQueryEngine implements QueryEngine {
|
|
169
169
|
private columnCache = new Map<string, boolean>()
|
|
170
170
|
private tableCache = new Map<string, boolean>()
|
|
@@ -172,7 +172,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
172
172
|
|
|
173
173
|
constructor(
|
|
174
174
|
private em: EntityManager,
|
|
175
|
-
private
|
|
175
|
+
private getDbFn?: () => AnyDb,
|
|
176
176
|
private resolveEncryptionService?: EncryptionResolver,
|
|
177
177
|
) {}
|
|
178
178
|
|
|
@@ -184,6 +184,13 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
private getDb(): AnyDb {
|
|
188
|
+
if (this.getDbFn) return this.getDbFn()
|
|
189
|
+
const emAny = this.em as any
|
|
190
|
+
if (typeof emAny?.getKysely === 'function') return emAny.getKysely() as AnyDb
|
|
191
|
+
throw new Error('BasicQueryEngine requires an EntityManager exposing getKysely() (MikroORM v7)')
|
|
192
|
+
}
|
|
193
|
+
|
|
187
194
|
async query<T = any>(entity: EntityId, opts: QueryOptions = {}): Promise<QueryResult<T>> {
|
|
188
195
|
// --- UMES query extension: before-query pipeline ---
|
|
189
196
|
const ext = opts.extensions
|
|
@@ -215,9 +222,9 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
215
222
|
|
|
216
223
|
// Heuristic: map '<module>:user' -> table 'users'
|
|
217
224
|
const table = resolveEntityTableName(this.em, entity)
|
|
218
|
-
const
|
|
225
|
+
const db = this.getDb()
|
|
219
226
|
|
|
220
|
-
let q =
|
|
227
|
+
let q: AnyBuilder = db.selectFrom(table as any)
|
|
221
228
|
const qualify = (col: string) => `${table}.${col}`
|
|
222
229
|
const orgScope = this.resolveOrganizationScope(opts)
|
|
223
230
|
this.searchAliasSeq = 0
|
|
@@ -236,11 +243,11 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
236
243
|
}
|
|
237
244
|
// Tenant guard (required) when present in schema
|
|
238
245
|
if (!skipAutoScope && await this.columnExists(table, 'tenant_id')) {
|
|
239
|
-
q = q.where(qualify('tenant_id'), opts.tenantId)
|
|
246
|
+
q = q.where(qualify('tenant_id'), '=', opts.tenantId)
|
|
240
247
|
}
|
|
241
248
|
// Default soft-delete guard: exclude rows with deleted_at when column exists
|
|
242
249
|
if (!opts.withDeleted && await this.columnExists(table, 'deleted_at')) {
|
|
243
|
-
q = q.
|
|
250
|
+
q = q.where(qualify('deleted_at'), 'is', null)
|
|
244
251
|
}
|
|
245
252
|
|
|
246
253
|
const normalizedFilters = normalizeFilters(opts.filters)
|
|
@@ -299,17 +306,18 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
299
306
|
}
|
|
300
307
|
const recordIdColumn = qualify('id')
|
|
301
308
|
|
|
302
|
-
const applyFilterOp = (builder:
|
|
309
|
+
const applyFilterOp = (builder: AnyBuilder, column: string | RawBuilder<unknown>, op: string, value: unknown, fieldName?: string): AnyBuilder => {
|
|
303
310
|
if (
|
|
304
311
|
(op === 'like' || op === 'ilike') &&
|
|
305
312
|
searchActive &&
|
|
306
313
|
typeof value === 'string' &&
|
|
307
|
-
fieldName
|
|
314
|
+
fieldName &&
|
|
315
|
+
typeof column === 'string'
|
|
308
316
|
) {
|
|
309
317
|
const tokens = tokenizeText(String(value), searchConfig)
|
|
310
318
|
const hashes = tokens.hashes
|
|
311
319
|
if (hashes.length) {
|
|
312
|
-
const
|
|
320
|
+
const result = this.applySearchTokens(builder, {
|
|
313
321
|
entity: String(entity),
|
|
314
322
|
field: fieldName,
|
|
315
323
|
hashes,
|
|
@@ -323,11 +331,11 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
323
331
|
field: fieldName,
|
|
324
332
|
tokens: tokens.tokens,
|
|
325
333
|
hashes,
|
|
326
|
-
applied,
|
|
334
|
+
applied: result.applied,
|
|
327
335
|
tenantId: opts.tenantId ?? null,
|
|
328
336
|
organizationScope: orgScope,
|
|
329
337
|
})
|
|
330
|
-
if (applied) return builder
|
|
338
|
+
if (result.applied) return result.builder
|
|
331
339
|
} else {
|
|
332
340
|
this.logSearchDebug('search:skip-empty-hashes', {
|
|
333
341
|
entity: String(entity),
|
|
@@ -336,27 +344,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
336
344
|
})
|
|
337
345
|
}
|
|
338
346
|
}
|
|
339
|
-
|
|
340
|
-
case 'eq':
|
|
341
|
-
if (value === null) builder.whereNull(column)
|
|
342
|
-
else builder.where(column, value)
|
|
343
|
-
break
|
|
344
|
-
case 'ne':
|
|
345
|
-
if (value === null) builder.whereNotNull(column)
|
|
346
|
-
else builder.whereNot(column, value)
|
|
347
|
-
break
|
|
348
|
-
case 'gt': builder.where(column, '>', value); break
|
|
349
|
-
case 'gte': builder.where(column, '>=', value); break
|
|
350
|
-
case 'lt': builder.where(column, '<', value); break
|
|
351
|
-
case 'lte': builder.where(column, '<=', value); break
|
|
352
|
-
case 'in': builder.whereIn(column, Array.isArray(value) ? value : [value]); break
|
|
353
|
-
case 'nin': builder.whereNotIn(column, Array.isArray(value) ? value : [value]); break
|
|
354
|
-
case 'like': builder.where(column, 'like', value); break
|
|
355
|
-
case 'ilike': builder.where(column, 'ilike', value); break
|
|
356
|
-
case 'exists': value ? builder.whereNotNull(column) : builder.whereNull(column); break
|
|
357
|
-
default: break
|
|
358
|
-
}
|
|
359
|
-
return builder
|
|
347
|
+
return this.applyColumnOp(builder, column, op, value)
|
|
360
348
|
}
|
|
361
349
|
|
|
362
350
|
// `eq` is accepted alongside `like`/`ilike` so that filters against
|
|
@@ -369,26 +357,26 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
369
357
|
// callers needing strict equality on encrypted fields should filter on
|
|
370
358
|
// the deterministic `*_hash` column instead.
|
|
371
359
|
const applyJoinFilterOp = async (
|
|
372
|
-
builder:
|
|
360
|
+
builder: AnyBuilder,
|
|
373
361
|
filter: { column: string; op: string; value?: unknown },
|
|
374
362
|
_qualified: string,
|
|
375
363
|
join: ResolvedJoin,
|
|
376
|
-
): Promise<boolean> => {
|
|
377
|
-
if (!searchEnabled || !join.entityId) return false
|
|
378
|
-
if (!['
|
|
379
|
-
if (typeof filter.value !== 'string' || filter.value.trim().length === 0) return false
|
|
364
|
+
): Promise<{ applied: boolean; builder: AnyBuilder }> => {
|
|
365
|
+
if (!searchEnabled || !join.entityId) return { applied: false, builder }
|
|
366
|
+
if (!['like', 'ilike'].includes(filter.op)) return { applied: false, builder }
|
|
367
|
+
if (typeof filter.value !== 'string' || filter.value.trim().length === 0) return { applied: false, builder }
|
|
380
368
|
|
|
381
369
|
let searchAvailable = joinSearchAvailability.get(join.entityId)
|
|
382
370
|
if (searchAvailable === undefined) {
|
|
383
371
|
searchAvailable = await this.hasSearchTokens(join.entityId, opts.tenantId ?? null, orgScope)
|
|
384
372
|
joinSearchAvailability.set(join.entityId, searchAvailable)
|
|
385
373
|
}
|
|
386
|
-
if (!searchAvailable) return false
|
|
374
|
+
if (!searchAvailable) return { applied: false, builder }
|
|
387
375
|
|
|
388
376
|
const tokens = tokenizeText(String(filter.value), searchConfig)
|
|
389
|
-
if (!tokens.hashes.length) return false
|
|
377
|
+
if (!tokens.hashes.length) return { applied: false, builder }
|
|
390
378
|
|
|
391
|
-
|
|
379
|
+
const result = this.applySearchTokens(builder, {
|
|
392
380
|
entity: join.entityId,
|
|
393
381
|
field: filter.column,
|
|
394
382
|
hashes: tokens.hashes,
|
|
@@ -397,6 +385,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
397
385
|
organizationScope: orgScope,
|
|
398
386
|
tokens: tokens.tokens,
|
|
399
387
|
})
|
|
388
|
+
return { applied: result.applied, builder: result.builder }
|
|
400
389
|
}
|
|
401
390
|
|
|
402
391
|
const regularBaseFilters = baseFilters.filter((f) => !f.orGroup)
|
|
@@ -424,7 +413,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
424
413
|
}
|
|
425
414
|
qualified = qualify(column)
|
|
426
415
|
}
|
|
427
|
-
applyFilterOp(q, qualified, filter.op, filter.value, fieldName)
|
|
416
|
+
q = applyFilterOp(q, qualified, filter.op, filter.value, fieldName)
|
|
428
417
|
}
|
|
429
418
|
|
|
430
419
|
// OR-grouped filters: AND within each group (one $or disjunct), OR between groups.
|
|
@@ -452,34 +441,28 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
452
441
|
if (resolved.length > 0) resolvedGroupFilters.push(resolved)
|
|
453
442
|
}
|
|
454
443
|
if (resolvedGroupFilters.length > 0) {
|
|
455
|
-
q = q.where(
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
applyConjunctiveGroup(this, resolvedGroupFilters[0])
|
|
462
|
-
for (let gi = 1; gi < resolvedGroupFilters.length; gi++) {
|
|
463
|
-
this.orWhere(function (nested: any) {
|
|
464
|
-
applyConjunctiveGroup(nested, resolvedGroupFilters[gi])
|
|
465
|
-
})
|
|
466
|
-
}
|
|
467
|
-
})
|
|
444
|
+
q = q.where((eb: any) => eb.or(
|
|
445
|
+
resolvedGroupFilters.map((group) => eb.and(
|
|
446
|
+
group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value))
|
|
447
|
+
))
|
|
448
|
+
))
|
|
468
449
|
}
|
|
469
450
|
}
|
|
470
451
|
|
|
471
|
-
const applyAliasScopes = async (builder:
|
|
452
|
+
const applyAliasScopes = async (builder: AnyBuilder, aliasName: string): Promise<AnyBuilder> => {
|
|
472
453
|
const targetTable = aliasTables.get(aliasName)
|
|
473
|
-
if (!targetTable) return
|
|
454
|
+
if (!targetTable) return builder
|
|
455
|
+
let next = builder
|
|
474
456
|
if (!skipAutoScope && orgScope && await this.columnExists(targetTable, 'organization_id')) {
|
|
475
|
-
this.applyOrganizationScope(
|
|
457
|
+
next = this.applyOrganizationScope(next, `${aliasName}.organization_id`, orgScope)
|
|
476
458
|
}
|
|
477
459
|
if (!skipAutoScope && opts.tenantId && await this.columnExists(targetTable, 'tenant_id')) {
|
|
478
|
-
|
|
460
|
+
next = next.where(`${aliasName}.tenant_id`, '=', opts.tenantId)
|
|
479
461
|
}
|
|
462
|
+
return next
|
|
480
463
|
}
|
|
481
|
-
await applyJoinFilters({
|
|
482
|
-
|
|
464
|
+
q = await applyJoinFilters({
|
|
465
|
+
db,
|
|
483
466
|
baseTable: table,
|
|
484
467
|
builder: q,
|
|
485
468
|
joinMap,
|
|
@@ -487,27 +470,28 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
487
470
|
aliasTables,
|
|
488
471
|
qualifyBase: (column) => qualify(column),
|
|
489
472
|
applyAliasScope: (builder, alias) => applyAliasScopes(builder, alias),
|
|
490
|
-
applyFilterOp,
|
|
473
|
+
applyFilterOp: (builder, column, op, value) => applyFilterOp(builder, column, op, value),
|
|
491
474
|
applyJoinFilterOp,
|
|
492
475
|
columnExists: (tbl, column) => this.columnExists(tbl, column),
|
|
493
476
|
})
|
|
494
477
|
// Selection (base columns only here; cf:* handled later)
|
|
495
478
|
if (opts.fields && opts.fields.length) {
|
|
496
479
|
const cols = opts.fields.filter((f) => !f.startsWith('cf:'))
|
|
497
|
-
|
|
480
|
+
for (const c of cols) {
|
|
498
481
|
// Qualify and alias to base names to avoid ambiguity
|
|
499
|
-
|
|
500
|
-
q = q.select(baseSelects)
|
|
482
|
+
q = q.select(sql.ref(qualify(c)).as(c))
|
|
501
483
|
}
|
|
502
484
|
} else {
|
|
503
485
|
// Default to selecting only base table columns to avoid ambiguity when joining
|
|
504
|
-
q = q.select(
|
|
486
|
+
q = q.select(sql`${sql.ref(table)}.*`.as('__all'))
|
|
505
487
|
}
|
|
506
488
|
|
|
507
489
|
// Resolve which custom fields to include
|
|
508
490
|
const tenantId = opts.tenantId
|
|
509
491
|
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
510
|
-
const
|
|
492
|
+
const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify)
|
|
493
|
+
q = cfSourcesResult.builder
|
|
494
|
+
const cfSources = cfSourcesResult.sources
|
|
511
495
|
const entityIdToSource = new Map<string, ResolvedCustomFieldSource>()
|
|
512
496
|
for (const source of cfSources) {
|
|
513
497
|
entityIdToSource.set(String(source.entityId), source)
|
|
@@ -529,28 +513,29 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
529
513
|
const entityIdList = Array.from(entityIdToSource.keys())
|
|
530
514
|
const entityOrder = new Map<string, number>()
|
|
531
515
|
entityIdList.forEach((id, idx) => entityOrder.set(id, idx))
|
|
532
|
-
const rows = await
|
|
533
|
-
.
|
|
534
|
-
.
|
|
535
|
-
.
|
|
536
|
-
.
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
516
|
+
const rows = await db
|
|
517
|
+
.selectFrom('custom_field_defs' as any)
|
|
518
|
+
.select(['key' as any, 'entity_id' as any, 'config_json' as any, 'kind' as any])
|
|
519
|
+
.where('entity_id' as any, 'in', entityIdList)
|
|
520
|
+
.where('is_active' as any, '=', true)
|
|
521
|
+
.where((eb: any) => eb.or([
|
|
522
|
+
eb('tenant_id' as any, '=', tenantId),
|
|
523
|
+
eb('tenant_id' as any, 'is', null),
|
|
524
|
+
]))
|
|
525
|
+
.execute() as Array<{ key: string; entity_id: string; config_json: unknown; kind: string }>
|
|
541
526
|
type CustomFieldDefinitionRow = {
|
|
542
527
|
key: string
|
|
543
528
|
entityId: string
|
|
544
529
|
kind: string
|
|
545
530
|
config: Record<string, unknown>
|
|
546
531
|
}
|
|
547
|
-
const sorted: CustomFieldDefinitionRow[] = rows.map((row
|
|
532
|
+
const sorted: CustomFieldDefinitionRow[] = rows.map((row) => {
|
|
548
533
|
const raw = row.config_json
|
|
549
534
|
let cfg: Record<string, any> = {}
|
|
550
535
|
if (raw && typeof raw === 'string') {
|
|
551
536
|
try { cfg = JSON.parse(raw) } catch { cfg = {} }
|
|
552
537
|
} else if (raw && typeof raw === 'object') {
|
|
553
|
-
cfg = raw
|
|
538
|
+
cfg = raw as Record<string, any>
|
|
554
539
|
}
|
|
555
540
|
return {
|
|
556
541
|
key: String(row.key),
|
|
@@ -559,7 +544,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
559
544
|
config: cfg,
|
|
560
545
|
}
|
|
561
546
|
})
|
|
562
|
-
sorted.sort((a
|
|
547
|
+
sorted.sort((a, b) => {
|
|
563
548
|
const ai = entityOrder.get(a.entityId) ?? Number.MAX_SAFE_INTEGER
|
|
564
549
|
const bi = entityOrder.get(b.entityId) ?? Number.MAX_SAFE_INTEGER
|
|
565
550
|
if (ai !== bi) return ai - bi
|
|
@@ -571,7 +556,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
571
556
|
if (!source) continue
|
|
572
557
|
const cfg = row.config || {}
|
|
573
558
|
const entityIndex = entityOrder.get(row.entityId) ?? Number.MAX_SAFE_INTEGER
|
|
574
|
-
const scores =
|
|
559
|
+
const scores = computeCustomFieldScore(cfg, row.kind, entityIndex)
|
|
575
560
|
const existing = selectedSources.get(row.key)
|
|
576
561
|
if (!existing || scores.base > existing.score || (scores.base === existing.score && (scores.penalty < existing.penalty || (scores.penalty === existing.penalty && scores.entityIndex < existing.entityIndex)))) {
|
|
577
562
|
selectedSources.set(row.key, { source, score: scores.base, penalty: scores.penalty, entityIndex: scores.entityIndex })
|
|
@@ -587,16 +572,17 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
587
572
|
}
|
|
588
573
|
const unresolvedKeys = Array.from(cfKeys).filter((key) => !keySource.has(key))
|
|
589
574
|
if (unresolvedKeys.length > 0 && entityIdToSource.size > 0) {
|
|
590
|
-
const rows = await
|
|
591
|
-
.
|
|
592
|
-
.
|
|
593
|
-
.
|
|
594
|
-
.
|
|
595
|
-
.
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
575
|
+
const rows = await db
|
|
576
|
+
.selectFrom('custom_field_defs' as any)
|
|
577
|
+
.select(['key' as any, 'entity_id' as any])
|
|
578
|
+
.where('entity_id' as any, 'in', Array.from(entityIdToSource.keys()))
|
|
579
|
+
.where('key' as any, 'in', unresolvedKeys)
|
|
580
|
+
.where('is_active' as any, '=', true)
|
|
581
|
+
.where((eb: any) => eb.or([
|
|
582
|
+
eb('tenant_id' as any, '=', tenantId),
|
|
583
|
+
eb('tenant_id' as any, 'is', null),
|
|
584
|
+
]))
|
|
585
|
+
.execute() as Array<{ key: string; entity_id: string }>
|
|
600
586
|
for (const row of rows) {
|
|
601
587
|
const source = entityIdToSource.get(String(row.entity_id))
|
|
602
588
|
if (!source) continue
|
|
@@ -604,7 +590,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
604
590
|
}
|
|
605
591
|
}
|
|
606
592
|
|
|
607
|
-
const cfValueExprByKey: Record<string,
|
|
593
|
+
const cfValueExprByKey: Record<string, RawBuilder<string | null>> = {}
|
|
608
594
|
const cfSelectedAliases: string[] = []
|
|
609
595
|
const cfJsonAliases = new Set<string>()
|
|
610
596
|
const cfMultiAliasByAlias = new Map<string, string>()
|
|
@@ -618,43 +604,46 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
618
604
|
const defAlias = `cfd_${sourceAliasSafe}_${keyAliasSafe}`
|
|
619
605
|
const valAlias = `cfv_${sourceAliasSafe}_${keyAliasSafe}`
|
|
620
606
|
// Join definitions for kind resolution
|
|
621
|
-
q = q.leftJoin({
|
|
622
|
-
|
|
623
|
-
.
|
|
624
|
-
.
|
|
625
|
-
.
|
|
626
|
-
|
|
607
|
+
q = q.leftJoin(`custom_field_defs as ${defAlias}` as any, (jb: any) =>
|
|
608
|
+
jb.on(`${defAlias}.entity_id`, '=', String(entityIdForKey))
|
|
609
|
+
.on(`${defAlias}.key`, '=', key)
|
|
610
|
+
.on(`${defAlias}.is_active`, '=', true)
|
|
611
|
+
.on((eb: any) => eb.or([
|
|
612
|
+
eb(`${defAlias}.tenant_id`, '=', tenantId),
|
|
613
|
+
eb(`${defAlias}.tenant_id`, 'is', null),
|
|
614
|
+
]))
|
|
615
|
+
)
|
|
627
616
|
// Join values with record match
|
|
628
|
-
q = q.leftJoin({
|
|
629
|
-
|
|
630
|
-
.
|
|
631
|
-
.
|
|
632
|
-
.
|
|
633
|
-
|
|
617
|
+
q = q.leftJoin(`custom_field_values as ${valAlias}` as any, (jb: any) =>
|
|
618
|
+
jb.on(`${valAlias}.entity_id`, '=', String(entityIdForKey))
|
|
619
|
+
.on(`${valAlias}.field_key`, '=', key)
|
|
620
|
+
.onRef(`${valAlias}.record_id`, '=', recordIdExpr as any)
|
|
621
|
+
.on((eb: any) => eb.or([
|
|
622
|
+
eb(`${valAlias}.tenant_id`, '=', tenantId),
|
|
623
|
+
eb(`${valAlias}.tenant_id`, 'is', null),
|
|
624
|
+
]))
|
|
625
|
+
)
|
|
634
626
|
// Force a common SQL type across branches to avoid Postgres CASE type conflicts
|
|
635
|
-
const caseExpr =
|
|
636
|
-
|
|
637
|
-
WHEN '
|
|
638
|
-
WHEN '
|
|
639
|
-
WHEN '
|
|
640
|
-
|
|
641
|
-
ELSE (${valAlias}.value_text)::text
|
|
627
|
+
const caseExpr = sql<string | null>`CASE ${sql.ref(`${defAlias}.kind`)}
|
|
628
|
+
WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
|
|
629
|
+
WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
|
|
630
|
+
WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
|
|
631
|
+
WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
|
|
632
|
+
ELSE (${sql.ref(`${valAlias}.value_text`)})::text
|
|
642
633
|
END`
|
|
643
|
-
)
|
|
644
634
|
cfValueExprByKey[key] = caseExpr
|
|
645
635
|
const alias = sanitize(`cf:${key}`)
|
|
646
636
|
// Project as aggregated to avoid duplicates when multi values exist
|
|
647
637
|
if ((opts.fields || []).includes(`cf:${key}`) || opts.includeCustomFields === true || (requestedCustomFieldKeys.length > 0 && requestedCustomFieldKeys.includes(key))) {
|
|
648
|
-
|
|
649
|
-
const
|
|
650
|
-
const aggregatedArray =
|
|
651
|
-
const
|
|
638
|
+
const multiAlias = `${alias}__is_multi`
|
|
639
|
+
const isMultiExpr = sql<boolean>`bool_or(coalesce((${sql.ref(`${defAlias}.config_json`)}->>'multi')::boolean, false))`
|
|
640
|
+
const aggregatedArray = sql<unknown>`array_remove(array_agg(DISTINCT ${caseExpr}), NULL)`
|
|
641
|
+
const projExpr = sql<unknown>`CASE WHEN ${isMultiExpr}
|
|
652
642
|
THEN to_jsonb(${aggregatedArray})
|
|
653
|
-
ELSE to_jsonb(max(${caseExpr
|
|
643
|
+
ELSE to_jsonb(max(${caseExpr}))
|
|
654
644
|
END`
|
|
655
|
-
|
|
656
|
-
q = q.select(
|
|
657
|
-
q = q.select(knex.raw(`${isMulti.toString()} as ??`, [multiAlias]))
|
|
645
|
+
q = q.select(projExpr.as(alias))
|
|
646
|
+
q = q.select(isMultiExpr.as(multiAlias))
|
|
658
647
|
cfSelectedAliases.push(alias)
|
|
659
648
|
cfJsonAliases.add(alias)
|
|
660
649
|
cfMultiAliasByAlias.set(alias, multiAlias)
|
|
@@ -671,7 +660,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
671
660
|
const tokens = tokenizeText(String(f.value), searchConfig)
|
|
672
661
|
const hashes = tokens.hashes
|
|
673
662
|
if (hashes.length) {
|
|
674
|
-
const
|
|
663
|
+
const result = this.applySearchTokens(q, {
|
|
675
664
|
entity: String(entity),
|
|
676
665
|
field: f.field,
|
|
677
666
|
hashes,
|
|
@@ -685,11 +674,14 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
685
674
|
field: f.field,
|
|
686
675
|
tokens: tokens.tokens,
|
|
687
676
|
hashes,
|
|
688
|
-
applied,
|
|
677
|
+
applied: result.applied,
|
|
689
678
|
tenantId: opts.tenantId ?? null,
|
|
690
679
|
organizationScope: orgScope,
|
|
691
680
|
})
|
|
692
|
-
if (applied)
|
|
681
|
+
if (result.applied) {
|
|
682
|
+
q = result.builder
|
|
683
|
+
continue
|
|
684
|
+
}
|
|
693
685
|
} else {
|
|
694
686
|
this.logSearchDebug('search:cf-skip-empty-hashes', {
|
|
695
687
|
entity: String(entity),
|
|
@@ -698,19 +690,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
698
690
|
})
|
|
699
691
|
}
|
|
700
692
|
}
|
|
701
|
-
|
|
702
|
-
case 'eq': q = q.where(expr, '=', f.value); break
|
|
703
|
-
case 'ne': q = q.where(expr, '!=', f.value); break
|
|
704
|
-
case 'gt': q = q.where(expr, '>', f.value); break
|
|
705
|
-
case 'gte': q = q.where(expr, '>=', f.value); break
|
|
706
|
-
case 'lt': q = q.where(expr, '<', f.value); break
|
|
707
|
-
case 'lte': q = q.where(expr, '<=', f.value); break
|
|
708
|
-
case 'in': q = q.whereIn(expr as any, f.value ?? []); break
|
|
709
|
-
case 'nin': q = q.whereNotIn(expr as any, f.value ?? []); break
|
|
710
|
-
case 'like': q = q.where(expr, 'like', f.value); break
|
|
711
|
-
case 'ilike': q = q.where(expr, 'ilike', f.value); break
|
|
712
|
-
case 'exists': f.value ? q = q.whereNotNull(expr) : q = q.whereNull(expr); break
|
|
713
|
-
}
|
|
693
|
+
q = this.applyColumnOp(q, expr, f.op, f.value)
|
|
714
694
|
}
|
|
715
695
|
|
|
716
696
|
// Entity extensions joins (no selection yet; enables future filters/projections)
|
|
@@ -726,9 +706,9 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
726
706
|
const [, extName] = (e.extension as string).split(':')
|
|
727
707
|
const extTable = extName.endsWith('s') ? extName : `${extName}s`
|
|
728
708
|
const alias = `ext_${sanitize(extName)}`
|
|
729
|
-
q = q.leftJoin({
|
|
730
|
-
|
|
731
|
-
|
|
709
|
+
q = q.leftJoin(`${extTable} as ${alias}` as any, (jb: any) =>
|
|
710
|
+
jb.onRef(`${alias}.${e.join.extensionKey}`, '=', `${table}.${e.join.baseKey}`)
|
|
711
|
+
)
|
|
732
712
|
}
|
|
733
713
|
}
|
|
734
714
|
|
|
@@ -741,15 +721,15 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
741
721
|
if (!cfSelectedAliases.includes(alias)) {
|
|
742
722
|
const expr = cfValueExprByKey[key]
|
|
743
723
|
if (expr) {
|
|
744
|
-
q = q.select(
|
|
724
|
+
q = q.select(sql<string | null>`max(${expr})`.as(alias))
|
|
745
725
|
cfSelectedAliases.push(alias)
|
|
746
726
|
}
|
|
747
727
|
}
|
|
748
|
-
q = q.orderBy(alias, s.dir ?? 'asc')
|
|
728
|
+
q = q.orderBy(alias, (s.dir ?? 'asc') as any)
|
|
749
729
|
} else {
|
|
750
730
|
const column = await this.resolveBaseColumn(table, s.field)
|
|
751
731
|
if (!column) continue
|
|
752
|
-
q = q.orderBy(qualify(column), s.dir ?? 'asc')
|
|
732
|
+
q = q.orderBy(qualify(column), (s.dir ?? 'asc') as any)
|
|
753
733
|
}
|
|
754
734
|
}
|
|
755
735
|
|
|
@@ -757,21 +737,19 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
757
737
|
const page = opts.page?.page ?? 1
|
|
758
738
|
const pageSize = opts.page?.pageSize ?? 20
|
|
759
739
|
// Deduplicate if we joined CFs or extensions by grouping on base id
|
|
760
|
-
|
|
740
|
+
const hasJoinedAggregates = (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) || Object.keys(cfValueExprByKey).length > 0
|
|
741
|
+
if (hasJoinedAggregates) {
|
|
761
742
|
q = q.groupBy(`${table}.id`)
|
|
762
743
|
}
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
const countRow = await countClone
|
|
768
|
-
.countDistinct(`${table}.id as count`)
|
|
769
|
-
.first()
|
|
744
|
+
const countBuilder = hasJoinedAggregates
|
|
745
|
+
? q.clearSelect().clearOrderBy().clearGroupBy().select(sql<string>`count(distinct ${sql.ref(`${table}.id`)})`.as('count'))
|
|
746
|
+
: q.clearSelect().clearOrderBy().select(sql<string>`count(distinct ${sql.ref(`${table}.id`)})`.as('count'))
|
|
747
|
+
const countRow = await countBuilder.executeTakeFirst() as { count: unknown } | undefined
|
|
770
748
|
const total = Number((countRow as any)?.count ?? 0)
|
|
771
|
-
const items = await q.limit(pageSize).offset((page - 1) * pageSize)
|
|
749
|
+
const items = await q.limit(pageSize).offset((page - 1) * pageSize).execute() as any[]
|
|
772
750
|
|
|
773
751
|
if (cfJsonAliases.size > 0) {
|
|
774
|
-
for (const row of items
|
|
752
|
+
for (const row of items) {
|
|
775
753
|
for (const alias of cfJsonAliases) {
|
|
776
754
|
const multiAlias = cfMultiAliasByAlias.get(alias)
|
|
777
755
|
const isMulti = multiAlias ? Boolean(row[multiAlias]) : false
|
|
@@ -841,6 +819,58 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
841
819
|
return queryResult
|
|
842
820
|
}
|
|
843
821
|
|
|
822
|
+
private applyColumnOp(builder: AnyBuilder, column: string | RawBuilder<unknown>, op: string, value: unknown): AnyBuilder {
|
|
823
|
+
switch (op) {
|
|
824
|
+
case 'eq':
|
|
825
|
+
return value === null
|
|
826
|
+
? builder.where(column as any, 'is', null)
|
|
827
|
+
: builder.where(column as any, '=', value as any)
|
|
828
|
+
case 'ne':
|
|
829
|
+
return value === null
|
|
830
|
+
? builder.where(column as any, 'is not', null)
|
|
831
|
+
: builder.where(column as any, '!=', value as any)
|
|
832
|
+
case 'gt':
|
|
833
|
+
return builder.where(column as any, '>', value as any)
|
|
834
|
+
case 'gte':
|
|
835
|
+
return builder.where(column as any, '>=', value as any)
|
|
836
|
+
case 'lt':
|
|
837
|
+
return builder.where(column as any, '<', value as any)
|
|
838
|
+
case 'lte':
|
|
839
|
+
return builder.where(column as any, '<=', value as any)
|
|
840
|
+
case 'in':
|
|
841
|
+
return builder.where(column as any, 'in', Array.isArray(value) ? value : [value])
|
|
842
|
+
case 'nin':
|
|
843
|
+
return builder.where(column as any, 'not in', Array.isArray(value) ? value : [value])
|
|
844
|
+
case 'like':
|
|
845
|
+
return builder.where(column as any, 'like', value as any)
|
|
846
|
+
case 'ilike':
|
|
847
|
+
return builder.where(column as any, 'ilike', value as any)
|
|
848
|
+
case 'exists':
|
|
849
|
+
return value
|
|
850
|
+
? builder.where(column as any, 'is not', null)
|
|
851
|
+
: builder.where(column as any, 'is', null)
|
|
852
|
+
default:
|
|
853
|
+
return builder
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
private buildColumnOpExpression(eb: any, column: string, op: string, value: unknown): any {
|
|
858
|
+
switch (op) {
|
|
859
|
+
case 'eq': return value === null ? eb(column, 'is', null) : eb(column, '=', value)
|
|
860
|
+
case 'ne': return value === null ? eb(column, 'is not', null) : eb(column, '!=', value)
|
|
861
|
+
case 'gt': return eb(column, '>', value)
|
|
862
|
+
case 'gte': return eb(column, '>=', value)
|
|
863
|
+
case 'lt': return eb(column, '<', value)
|
|
864
|
+
case 'lte': return eb(column, '<=', value)
|
|
865
|
+
case 'in': return eb(column, 'in', Array.isArray(value) ? value : [value])
|
|
866
|
+
case 'nin': return eb(column, 'not in', Array.isArray(value) ? value : [value])
|
|
867
|
+
case 'like': return eb(column, 'like', value)
|
|
868
|
+
case 'ilike': return eb(column, 'ilike', value)
|
|
869
|
+
case 'exists': return value ? eb(column, 'is not', null) : eb(column, 'is', null)
|
|
870
|
+
default: return eb.val(true)
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
844
874
|
private async resolveBaseColumn(table: string, field: string): Promise<string | null> {
|
|
845
875
|
if (await this.columnExists(table, field)) return field
|
|
846
876
|
if (field === 'organization_id' && await this.columnExists(table, 'id')) return 'id'
|
|
@@ -854,10 +884,14 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
854
884
|
if (cached === true) return true
|
|
855
885
|
this.columnCache.delete(key)
|
|
856
886
|
}
|
|
857
|
-
const
|
|
858
|
-
const exists = await
|
|
859
|
-
.
|
|
860
|
-
.
|
|
887
|
+
const db = this.getDb()
|
|
888
|
+
const exists = await db
|
|
889
|
+
.selectFrom('information_schema.columns' as any)
|
|
890
|
+
.select(sql<number>`1`.as('one'))
|
|
891
|
+
.where('table_name' as any, '=', table)
|
|
892
|
+
.where('column_name' as any, '=', column)
|
|
893
|
+
.limit(1)
|
|
894
|
+
.executeTakeFirst()
|
|
861
895
|
const present = !!exists
|
|
862
896
|
if (present) this.columnCache.set(key, true)
|
|
863
897
|
else this.columnCache.delete(key)
|
|
@@ -866,10 +900,13 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
866
900
|
|
|
867
901
|
private async tableExists(table: string): Promise<boolean> {
|
|
868
902
|
if (this.tableCache.has(table)) return this.tableCache.get(table) ?? false
|
|
869
|
-
const
|
|
870
|
-
const exists = await
|
|
871
|
-
.
|
|
872
|
-
.
|
|
903
|
+
const db = this.getDb()
|
|
904
|
+
const exists = await db
|
|
905
|
+
.selectFrom('information_schema.tables' as any)
|
|
906
|
+
.select(sql<number>`1`.as('one'))
|
|
907
|
+
.where('table_name' as any, '=', table)
|
|
908
|
+
.limit(1)
|
|
909
|
+
.executeTakeFirst()
|
|
873
910
|
const present = !!exists
|
|
874
911
|
this.tableCache.set(table, present)
|
|
875
912
|
return present
|
|
@@ -881,15 +918,19 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
881
918
|
orgScope?: { ids: string[]; includeNull: boolean } | null
|
|
882
919
|
): Promise<boolean> {
|
|
883
920
|
try {
|
|
884
|
-
const
|
|
885
|
-
|
|
921
|
+
const db = this.getDb()
|
|
922
|
+
let query: AnyBuilder = db
|
|
923
|
+
.selectFrom('search_tokens' as any)
|
|
924
|
+
.select(sql<number>`1`.as('one'))
|
|
925
|
+
.where('entity_type' as any, '=', entity)
|
|
926
|
+
.limit(1)
|
|
886
927
|
if (tenantId !== undefined) {
|
|
887
|
-
query.
|
|
928
|
+
query = query.where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)
|
|
888
929
|
}
|
|
889
930
|
if (orgScope) {
|
|
890
|
-
this.applyOrganizationScope(query
|
|
931
|
+
query = this.applyOrganizationScope(query, 'search_tokens.organization_id', orgScope)
|
|
891
932
|
}
|
|
892
|
-
const row = await query.
|
|
933
|
+
const row = await query.executeTakeFirst()
|
|
893
934
|
return !!row
|
|
894
935
|
} catch (err) {
|
|
895
936
|
this.logSearchDebug('search:has-tokens-error', {
|
|
@@ -902,8 +943,8 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
902
943
|
}
|
|
903
944
|
}
|
|
904
945
|
|
|
905
|
-
private applySearchTokens
|
|
906
|
-
q:
|
|
946
|
+
private applySearchTokens(
|
|
947
|
+
q: AnyBuilder,
|
|
907
948
|
opts: {
|
|
908
949
|
entity: string
|
|
909
950
|
field: string
|
|
@@ -914,7 +955,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
914
955
|
combineWith?: 'and' | 'or'
|
|
915
956
|
tokens?: string[]
|
|
916
957
|
}
|
|
917
|
-
): boolean {
|
|
958
|
+
): { applied: boolean; builder: AnyBuilder } {
|
|
918
959
|
if (!opts.hashes.length) {
|
|
919
960
|
this.logSearchDebug('search:skip-no-hashes', {
|
|
920
961
|
entity: opts.entity,
|
|
@@ -922,10 +963,9 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
922
963
|
tenantId: opts.tenantId ?? null,
|
|
923
964
|
organizationScope: opts.organizationScope,
|
|
924
965
|
})
|
|
925
|
-
return false
|
|
966
|
+
return { applied: false, builder: q }
|
|
926
967
|
}
|
|
927
968
|
const alias = `st_${this.searchAliasSeq++}`
|
|
928
|
-
const combineWith = opts.combineWith === 'or' ? 'orWhereExists' : 'whereExists'
|
|
929
969
|
const engine = this
|
|
930
970
|
this.logSearchDebug('search:apply-search-tokens', {
|
|
931
971
|
entity: opts.entity,
|
|
@@ -937,27 +977,38 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
937
977
|
organizationScope: opts.organizationScope,
|
|
938
978
|
combineWith: opts.combineWith ?? 'and',
|
|
939
979
|
})
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
.
|
|
943
|
-
.
|
|
944
|
-
.
|
|
945
|
-
.
|
|
946
|
-
.
|
|
947
|
-
.
|
|
948
|
-
.
|
|
980
|
+
const buildSub = (eb: any) => {
|
|
981
|
+
let sub: AnyBuilder = eb
|
|
982
|
+
.selectFrom(`search_tokens as ${alias}`)
|
|
983
|
+
.select(sql<number>`1`.as('one'))
|
|
984
|
+
.where(`${alias}.entity_type`, '=', opts.entity)
|
|
985
|
+
.where(`${alias}.field`, '=', opts.field)
|
|
986
|
+
.where(sql<boolean>`${sql.ref(`${alias}.entity_id`)} = ${sql.ref(opts.recordIdColumn)}::text`)
|
|
987
|
+
.where(`${alias}.token_hash`, 'in', opts.hashes)
|
|
988
|
+
.groupBy([`${alias}.entity_id`, `${alias}.field`])
|
|
989
|
+
.having(sql<boolean>`count(distinct ${sql.ref(`${alias}.token_hash`)}) >= ${opts.hashes.length}`)
|
|
949
990
|
if (opts.tenantId !== undefined) {
|
|
950
|
-
|
|
991
|
+
sub = sub.where(sql<boolean>`${sql.ref(`${alias}.tenant_id`)} is not distinct from ${opts.tenantId ?? null}`)
|
|
951
992
|
}
|
|
952
993
|
if (opts.organizationScope) {
|
|
953
|
-
engine.applyOrganizationScope(
|
|
994
|
+
sub = engine.applyOrganizationScope(sub, `${alias}.organization_id`, opts.organizationScope)
|
|
954
995
|
}
|
|
955
|
-
|
|
956
|
-
|
|
996
|
+
return sub
|
|
997
|
+
}
|
|
998
|
+
const combiner = opts.combineWith === 'or' ? 'or' : 'and'
|
|
999
|
+
if (combiner === 'or') {
|
|
1000
|
+
// When OR combining, caller expects a raw predicate to include in eb.or([...]).
|
|
1001
|
+
// We keep the same semantics as the previous knex orWhereExists by mutating the outer builder with a WHERE EXISTS.
|
|
1002
|
+
// Return the mutated builder; callers that need per-predicate control should build the sub themselves.
|
|
1003
|
+
const next = q.where((eb: any) => eb.or([eb.exists(buildSub(eb))]))
|
|
1004
|
+
return { applied: true, builder: next }
|
|
1005
|
+
}
|
|
1006
|
+
const next = q.where((eb: any) => eb.exists(buildSub(eb)))
|
|
1007
|
+
return { applied: true, builder: next }
|
|
957
1008
|
}
|
|
958
1009
|
|
|
959
|
-
private applyIndexDocFilter
|
|
960
|
-
q:
|
|
1010
|
+
private applyIndexDocFilter(
|
|
1011
|
+
q: AnyBuilder,
|
|
961
1012
|
opts: {
|
|
962
1013
|
entity: string
|
|
963
1014
|
field: string
|
|
@@ -970,12 +1021,12 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
970
1021
|
searchActive: boolean
|
|
971
1022
|
searchConfig: ReturnType<typeof resolveSearchConfig>
|
|
972
1023
|
}
|
|
973
|
-
):
|
|
1024
|
+
): AnyBuilder {
|
|
974
1025
|
if ((opts.op === 'like' || opts.op === 'ilike') && opts.searchActive && typeof opts.value === 'string') {
|
|
975
1026
|
const tokens = tokenizeText(String(opts.value), opts.searchConfig)
|
|
976
1027
|
const hashes = tokens.hashes
|
|
977
1028
|
if (hashes.length) {
|
|
978
|
-
const
|
|
1029
|
+
const result = this.applySearchTokens(q, {
|
|
979
1030
|
entity: opts.entity,
|
|
980
1031
|
field: opts.field,
|
|
981
1032
|
hashes,
|
|
@@ -989,11 +1040,11 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
989
1040
|
field: opts.field,
|
|
990
1041
|
tokens: tokens.tokens,
|
|
991
1042
|
hashes,
|
|
992
|
-
applied,
|
|
1043
|
+
applied: result.applied,
|
|
993
1044
|
tenantId: opts.tenantId ?? null,
|
|
994
1045
|
organizationScope: opts.organizationScope,
|
|
995
1046
|
})
|
|
996
|
-
if (applied) return
|
|
1047
|
+
if (result.applied) return result.builder
|
|
997
1048
|
} else {
|
|
998
1049
|
this.logSearchDebug('search:index-doc-skip-empty-hashes', {
|
|
999
1050
|
entity: opts.entity,
|
|
@@ -1004,79 +1055,82 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1004
1055
|
return q
|
|
1005
1056
|
}
|
|
1006
1057
|
|
|
1007
|
-
const knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
|
|
1008
1058
|
const alias = `ei_${this.searchAliasSeq++}`
|
|
1009
1059
|
const engine = this
|
|
1010
|
-
return q.
|
|
1011
|
-
|
|
1012
|
-
.
|
|
1013
|
-
.
|
|
1014
|
-
.
|
|
1015
|
-
|
|
1060
|
+
return q.where((eb: any) => eb.exists((() => {
|
|
1061
|
+
let sub: AnyBuilder = eb
|
|
1062
|
+
.selectFrom(`entity_indexes as ${alias}`)
|
|
1063
|
+
.select(sql<number>`1`.as('one'))
|
|
1064
|
+
.where(`${alias}.entity_type`, '=', opts.entity)
|
|
1065
|
+
.where(sql<boolean>`${sql.ref(`${alias}.entity_id`)} = ${sql.ref(opts.recordIdColumn)}::text`)
|
|
1016
1066
|
if (opts.tenantId !== undefined) {
|
|
1017
|
-
|
|
1067
|
+
sub = sub.where(sql<boolean>`${sql.ref(`${alias}.tenant_id`)} is not distinct from ${opts.tenantId ?? null}`)
|
|
1018
1068
|
}
|
|
1019
1069
|
if (opts.organizationScope) {
|
|
1020
|
-
engine.applyOrganizationScope(
|
|
1070
|
+
sub = engine.applyOrganizationScope(sub, `${alias}.organization_id`, opts.organizationScope)
|
|
1021
1071
|
}
|
|
1022
1072
|
if (!opts.withDeleted) {
|
|
1023
|
-
|
|
1073
|
+
sub = sub.where(`${alias}.deleted_at`, 'is', null)
|
|
1024
1074
|
}
|
|
1025
1075
|
|
|
1026
|
-
const
|
|
1076
|
+
const textExpr = sql<string | null>`(${sql.ref(`${alias}.doc`)} ->> ${opts.field})`
|
|
1027
1077
|
switch (opts.op) {
|
|
1028
1078
|
case 'eq':
|
|
1029
|
-
|
|
1030
|
-
break
|
|
1079
|
+
sub = sub.where(sql<boolean>`${textExpr} = ${opts.value}`); break
|
|
1031
1080
|
case 'ne':
|
|
1032
|
-
|
|
1033
|
-
break
|
|
1081
|
+
sub = sub.where(sql<boolean>`${textExpr} <> ${opts.value}`); break
|
|
1034
1082
|
case 'gt':
|
|
1035
1083
|
case 'gte':
|
|
1036
1084
|
case 'lt':
|
|
1037
1085
|
case 'lte': {
|
|
1038
|
-
const operator = opts.op === 'gt' ? '>' : opts.op === 'gte' ? '>=' : opts.op === 'lt' ? '<' : '<='
|
|
1039
|
-
|
|
1086
|
+
const operator = sql.raw(opts.op === 'gt' ? '>' : opts.op === 'gte' ? '>=' : opts.op === 'lt' ? '<' : '<=')
|
|
1087
|
+
sub = sub.where(sql<boolean>`${textExpr} ${operator} ${opts.value}`)
|
|
1040
1088
|
break
|
|
1041
1089
|
}
|
|
1042
|
-
case 'in':
|
|
1043
|
-
|
|
1090
|
+
case 'in': {
|
|
1091
|
+
const vals = Array.isArray(opts.value) ? opts.value : [opts.value]
|
|
1092
|
+
sub = sub.where(sql<boolean>`${textExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`)
|
|
1044
1093
|
break
|
|
1045
|
-
|
|
1046
|
-
|
|
1094
|
+
}
|
|
1095
|
+
case 'nin': {
|
|
1096
|
+
const vals = Array.isArray(opts.value) ? opts.value : [opts.value]
|
|
1097
|
+
sub = sub.where(sql<boolean>`${textExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`)
|
|
1047
1098
|
break
|
|
1099
|
+
}
|
|
1048
1100
|
case 'like':
|
|
1049
|
-
|
|
1050
|
-
break
|
|
1101
|
+
sub = sub.where(sql<boolean>`${textExpr} like ${opts.value}`); break
|
|
1051
1102
|
case 'ilike':
|
|
1052
|
-
|
|
1053
|
-
break
|
|
1103
|
+
sub = sub.where(sql<boolean>`${textExpr} ilike ${opts.value}`); break
|
|
1054
1104
|
case 'exists':
|
|
1055
|
-
opts.value
|
|
1105
|
+
sub = opts.value
|
|
1106
|
+
? sub.where(sql<boolean>`${textExpr} is not null`)
|
|
1107
|
+
: sub.where(sql<boolean>`${textExpr} is null`)
|
|
1056
1108
|
break
|
|
1057
1109
|
default:
|
|
1058
1110
|
break
|
|
1059
1111
|
}
|
|
1060
|
-
|
|
1112
|
+
return sub
|
|
1113
|
+
})()))
|
|
1061
1114
|
}
|
|
1062
1115
|
|
|
1063
1116
|
private configureCustomFieldSources(
|
|
1064
|
-
q:
|
|
1117
|
+
q: AnyBuilder,
|
|
1065
1118
|
baseTable: string,
|
|
1066
1119
|
baseEntity: EntityId,
|
|
1067
|
-
|
|
1120
|
+
db: AnyDb,
|
|
1068
1121
|
opts: QueryOptions,
|
|
1069
|
-
qualify: (column: string) => string
|
|
1070
|
-
): ResolvedCustomFieldSource[] {
|
|
1122
|
+
qualify: (column: string) => string,
|
|
1123
|
+
): { builder: AnyBuilder; sources: ResolvedCustomFieldSource[] } {
|
|
1071
1124
|
const sources: ResolvedCustomFieldSource[] = [
|
|
1072
1125
|
{
|
|
1073
1126
|
entityId: baseEntity,
|
|
1074
1127
|
alias: 'base',
|
|
1075
1128
|
table: baseTable,
|
|
1076
|
-
recordIdExpr:
|
|
1129
|
+
recordIdExpr: sql<string>`${sql.ref(`${baseTable}.id`)}::text`,
|
|
1077
1130
|
},
|
|
1078
1131
|
]
|
|
1079
1132
|
const extras: QueryCustomFieldSource[] = opts.customFieldSources ?? []
|
|
1133
|
+
let next = q
|
|
1080
1134
|
extras.forEach((srcOpt, index) => {
|
|
1081
1135
|
const joinTable = srcOpt.table ?? resolveEntityTableName(this.em, srcOpt.entityId)
|
|
1082
1136
|
const alias = srcOpt.alias ?? `cfs_${index}`
|
|
@@ -1084,22 +1138,18 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1084
1138
|
if (!join) {
|
|
1085
1139
|
throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`)
|
|
1086
1140
|
}
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
}
|
|
1091
|
-
const joinType = join.type ?? 'left'
|
|
1092
|
-
if (joinType === 'inner') q.join(joinArgs, joinCallback)
|
|
1093
|
-
else q.leftJoin(joinArgs, joinCallback)
|
|
1141
|
+
const joinFn = (join.type ?? 'left') === 'inner' ? 'innerJoin' : 'leftJoin'
|
|
1142
|
+
next = (next as any)[joinFn](`${joinTable} as ${alias}`, (jb: any) =>
|
|
1143
|
+
jb.onRef(`${alias}.${join.toField}`, '=', qualify(join.fromField)))
|
|
1094
1144
|
const recordColumn = srcOpt.recordIdColumn ?? 'id'
|
|
1095
1145
|
sources.push({
|
|
1096
1146
|
entityId: srcOpt.entityId,
|
|
1097
1147
|
alias,
|
|
1098
1148
|
table: joinTable,
|
|
1099
|
-
recordIdExpr:
|
|
1149
|
+
recordIdExpr: sql<string>`${sql.ref(`${alias}.${recordColumn}`)}::text`,
|
|
1100
1150
|
})
|
|
1101
1151
|
})
|
|
1102
|
-
return sources
|
|
1152
|
+
return { builder: next, sources }
|
|
1103
1153
|
}
|
|
1104
1154
|
|
|
1105
1155
|
private logSearchDebug(event: string, payload: Record<string, unknown>) {
|
|
@@ -1123,24 +1173,17 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1123
1173
|
return null
|
|
1124
1174
|
}
|
|
1125
1175
|
|
|
1126
|
-
private applyOrganizationScope(q:
|
|
1176
|
+
private applyOrganizationScope(q: AnyBuilder, column: string, scope: { ids: string[]; includeNull: boolean }): AnyBuilder {
|
|
1127
1177
|
if (!scope) return q
|
|
1128
1178
|
if (scope.ids.length === 0 && !scope.includeNull) {
|
|
1129
|
-
return q.
|
|
1179
|
+
return q.where(sql<boolean>`1 = 0`)
|
|
1130
1180
|
}
|
|
1131
|
-
return q.where((
|
|
1132
|
-
|
|
1133
|
-
if (scope.ids.length > 0)
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
if (scope.includeNull) {
|
|
1138
|
-
if (applied) builder.orWhereNull(column)
|
|
1139
|
-
else builder.whereNull(column)
|
|
1140
|
-
applied = true
|
|
1141
|
-
}
|
|
1142
|
-
if (!applied) builder.whereRaw('1 = 0')
|
|
1181
|
+
return q.where((eb: any) => {
|
|
1182
|
+
const parts: any[] = []
|
|
1183
|
+
if (scope.ids.length > 0) parts.push(eb(column, 'in', scope.ids))
|
|
1184
|
+
if (scope.includeNull) parts.push(eb(column, 'is', null))
|
|
1185
|
+
if (parts.length === 1) return parts[0]
|
|
1186
|
+
return eb.or(parts)
|
|
1143
1187
|
})
|
|
1144
1188
|
}
|
|
1145
|
-
|
|
1146
1189
|
}
|