@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.
Files changed (34) hide show
  1. package/dist/lib/api/crud.js +1 -1
  2. package/dist/lib/api/crud.js.map +2 -2
  3. package/dist/lib/auth/server.js +1 -1
  4. package/dist/lib/auth/server.js.map +2 -2
  5. package/dist/lib/data/engine.js +68 -27
  6. package/dist/lib/data/engine.js.map +2 -2
  7. package/dist/lib/db/mikro.js +18 -22
  8. package/dist/lib/db/mikro.js.map +2 -2
  9. package/dist/lib/indexers/error-log.js +10 -12
  10. package/dist/lib/indexers/error-log.js.map +2 -2
  11. package/dist/lib/indexers/status-log.js +14 -16
  12. package/dist/lib/indexers/status-log.js.map +2 -2
  13. package/dist/lib/query/engine.js +220 -228
  14. package/dist/lib/query/engine.js.map +3 -3
  15. package/dist/lib/query/join-utils.js +28 -23
  16. package/dist/lib/query/join-utils.js.map +2 -2
  17. package/dist/lib/version.js +1 -1
  18. package/dist/lib/version.js.map +1 -1
  19. package/jest.config.cjs +4 -2
  20. package/package.json +1 -1
  21. package/src/lib/api/__tests__/crud.test.ts +5 -3
  22. package/src/lib/api/crud.ts +1 -1
  23. package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +10 -4
  24. package/src/lib/auth/server.ts +1 -1
  25. package/src/lib/bootstrap/types.ts +2 -2
  26. package/src/lib/crud/__tests__/crud-factory.test.ts +27 -17
  27. package/src/lib/data/engine.ts +95 -47
  28. package/src/lib/db/mikro.ts +26 -25
  29. package/src/lib/indexers/error-log.ts +23 -23
  30. package/src/lib/indexers/status-log.ts +36 -33
  31. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +253 -114
  32. package/src/lib/query/__tests__/engine.test.ts +206 -139
  33. package/src/lib/query/engine.ts +306 -263
  34. package/src/lib/query/join-utils.ts +38 -30
@@ -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 { Knex } from 'knex'
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: any
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
- return 8
147
- case 'relation':
148
- return 6
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
- return 2
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 getKnexFn?: () => any,
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 knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
225
+ const db = this.getDb()
219
226
 
220
- let q = knex(table)
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.whereNull(qualify('deleted_at'))
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: any, column: string, op: any, value: any, fieldName?: string) => {
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 applied = this.applySearchTokens(builder, {
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
- switch (op) {
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: any,
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 (!['eq', 'like', 'ilike'].includes(filter.op)) return false
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
- return this.applySearchTokens(builder, {
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(function (this: any) {
456
- const applyConjunctiveGroup = (builder: any, conjuncts: (typeof resolvedGroupFilters)[0]) => {
457
- for (const rf of conjuncts) {
458
- applyFilterOp(builder, rf.qualified, rf.op, rf.value, rf.fieldName)
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: any, aliasName: string) => {
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(builder, `${aliasName}.organization_id`, orgScope)
457
+ next = this.applyOrganizationScope(next, `${aliasName}.organization_id`, orgScope)
476
458
  }
477
459
  if (!skipAutoScope && opts.tenantId && await this.columnExists(targetTable, 'tenant_id')) {
478
- builder.where(`${aliasName}.tenant_id`, opts.tenantId)
460
+ next = next.where(`${aliasName}.tenant_id`, '=', opts.tenantId)
479
461
  }
462
+ return next
480
463
  }
481
- await applyJoinFilters({
482
- knex,
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
- if (cols.length) {
480
+ for (const c of cols) {
498
481
  // Qualify and alias to base names to avoid ambiguity
499
- const baseSelects = cols.map((c) => knex.raw('?? as ??', [qualify(c), c]))
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(knex.raw('??.*', [table]))
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 cfSources = this.configureCustomFieldSources(q, table, entity, knex, opts, qualify)
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 knex('custom_field_defs')
533
- .select('key', 'entity_id', 'config_json', 'kind')
534
- .whereIn('entity_id', entityIdList)
535
- .andWhere('is_active', true)
536
- .modify((qb: any) => {
537
- qb.andWhere((inner: any) => {
538
- inner.where({ tenant_id: tenantId }).orWhereNull('tenant_id')
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: any) => {
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: CustomFieldDefinitionRow, b: CustomFieldDefinitionRow) => {
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 = computeScore(cfg, row.kind, entityIndex)
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 knex('custom_field_defs')
591
- .select('key', 'entity_id')
592
- .whereIn('entity_id', Array.from(entityIdToSource.keys()))
593
- .whereIn('key', unresolvedKeys)
594
- .andWhere('is_active', true)
595
- .modify((qb: any) => {
596
- qb.andWhere((inner: any) => {
597
- inner.where({ tenant_id: tenantId }).orWhereNull('tenant_id')
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, any> = {}
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({ [defAlias]: 'custom_field_defs' }, function (this: any) {
622
- this.on(`${defAlias}.entity_id`, '=', knex.raw('?', [entityIdForKey]))
623
- .andOn(`${defAlias}.key`, '=', knex.raw('?', [key]))
624
- .andOn(`${defAlias}.is_active`, '=', knex.raw('true'))
625
- .andOn(knex.raw(`(${defAlias}.tenant_id = ? OR ${defAlias}.tenant_id IS NULL)`, [tenantId]))
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({ [valAlias]: 'custom_field_values' }, function (this: any) {
629
- this.on(`${valAlias}.entity_id`, '=', knex.raw('?', [entityIdForKey]))
630
- .andOn(`${valAlias}.field_key`, '=', knex.raw('?', [key]))
631
- .andOn(`${valAlias}.record_id`, '=', recordIdExpr)
632
- .andOn(knex.raw(`(${valAlias}.tenant_id = ? OR ${valAlias}.tenant_id IS NULL)`, [tenantId]))
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 = knex.raw(
636
- `CASE ${defAlias}.kind
637
- WHEN 'integer' THEN (${valAlias}.value_int)::text
638
- WHEN 'float' THEN (${valAlias}.value_float)::text
639
- WHEN 'boolean' THEN (${valAlias}.value_bool)::text
640
- WHEN 'multiline' THEN (${valAlias}.value_multiline)::text
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
- // Use bool_or over config_json->>multi so it's valid under GROUP BY
649
- const isMulti = knex.raw(`bool_or(coalesce((${defAlias}.config_json->>'multi')::boolean, false))`)
650
- const aggregatedArray = `array_remove(array_agg(DISTINCT ${caseExpr.toString()}), NULL)`
651
- const expr = `CASE WHEN ${isMulti.toString()}
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.toString()}))
643
+ ELSE to_jsonb(max(${caseExpr}))
654
644
  END`
655
- const multiAlias = `${alias}__is_multi`
656
- q = q.select(knex.raw(`${expr} as ??`, [alias]))
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 applied = this.applySearchTokens(q, {
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) continue
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
- switch (f.op) {
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({ [alias]: extTable }, function (this: any) {
730
- this.on(`${alias}.${e.join.extensionKey}`, '=', knex.raw('??', [`${table}.${e.join.baseKey}`]))
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(knex.raw(`max(${expr.toString()}) as ??`, [alias]))
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
- if ((opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) || Object.keys(cfValueExprByKey).length > 0) {
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 countClone: any = q.clone()
764
- if (typeof countClone.clearSelect === 'function') countClone.clearSelect()
765
- if (typeof countClone.clearOrder === 'function') countClone.clearOrder()
766
- if (typeof countClone.clearGroup === 'function') countClone.clearGroup()
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 as any[]) {
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 knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
858
- const exists = await knex('information_schema.columns')
859
- .where({ table_name: table, column_name: column })
860
- .first()
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 knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
870
- const exists = await knex('information_schema.tables')
871
- .where({ table_name: table })
872
- .first()
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 knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
885
- const query = knex('search_tokens').select(1).where('entity_type', entity).limit(1)
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.andWhereRaw('tenant_id is not distinct from ?', [tenantId])
928
+ query = query.where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)
888
929
  }
889
930
  if (orgScope) {
890
- this.applyOrganizationScope(query as any, 'search_tokens.organization_id', orgScope)
931
+ query = this.applyOrganizationScope(query, 'search_tokens.organization_id', orgScope)
891
932
  }
892
- const row = await query.first()
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<TRecord extends ResultRow, TResult>(
906
- q: Knex.QueryBuilder<TRecord, TResult>,
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
- ;(q as any)[combineWith](function (this: Knex.QueryBuilder) {
941
- this.select(1)
942
- .from({ [alias]: 'search_tokens' })
943
- .where(`${alias}.entity_type`, opts.entity)
944
- .andWhere(`${alias}.field`, opts.field)
945
- .andWhereRaw('?? = ??::text', [`${alias}.entity_id`, opts.recordIdColumn])
946
- .whereIn(`${alias}.token_hash`, opts.hashes)
947
- .groupBy(`${alias}.entity_id`, `${alias}.field`)
948
- .havingRaw(`count(distinct ${alias}.token_hash) >= ?`, [opts.hashes.length])
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
- this.andWhereRaw(`${alias}.tenant_id is not distinct from ?`, [opts.tenantId ?? null])
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(this as any, `${alias}.organization_id`, opts.organizationScope)
994
+ sub = engine.applyOrganizationScope(sub, `${alias}.organization_id`, opts.organizationScope)
954
995
  }
955
- })
956
- return true
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<TRecord extends ResultRow, TResult>(
960
- q: Knex.QueryBuilder<TRecord, TResult>,
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
- ): Knex.QueryBuilder<TRecord, TResult> {
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 applied = this.applySearchTokens(q, {
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 q
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.whereExists(function (this: Knex.QueryBuilder) {
1011
- this.select(1)
1012
- .from({ [alias]: 'entity_indexes' })
1013
- .where(`${alias}.entity_type`, opts.entity)
1014
- .andWhereRaw('?? = ??::text', [`${alias}.entity_id`, opts.recordIdColumn])
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
- this.andWhereRaw(`${alias}.tenant_id is not distinct from ?`, [opts.tenantId ?? null])
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(this as any, `${alias}.organization_id`, opts.organizationScope)
1070
+ sub = engine.applyOrganizationScope(sub, `${alias}.organization_id`, opts.organizationScope)
1021
1071
  }
1022
1072
  if (!opts.withDeleted) {
1023
- this.whereNull(`${alias}.deleted_at`)
1073
+ sub = sub.where(`${alias}.deleted_at`, 'is', null)
1024
1074
  }
1025
1075
 
1026
- const text = knex.raw(`(${alias}.doc ->> ?)`, [opts.field])
1076
+ const textExpr = sql<string | null>`(${sql.ref(`${alias}.doc`)} ->> ${opts.field})`
1027
1077
  switch (opts.op) {
1028
1078
  case 'eq':
1029
- this.where(text, '=', opts.value as Knex.Value)
1030
- break
1079
+ sub = sub.where(sql<boolean>`${textExpr} = ${opts.value}`); break
1031
1080
  case 'ne':
1032
- this.where(text, '!=', opts.value as Knex.Value)
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
- this.where(text, operator, opts.value as Knex.Value)
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
- this.whereIn(text as any, Array.isArray(opts.value) ? opts.value : [opts.value])
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
- case 'nin':
1046
- this.whereNotIn(text as any, Array.isArray(opts.value) ? opts.value : [opts.value])
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
- this.where(text, 'like', opts.value as Knex.Value)
1050
- break
1101
+ sub = sub.where(sql<boolean>`${textExpr} like ${opts.value}`); break
1051
1102
  case 'ilike':
1052
- this.where(text, 'ilike', opts.value as Knex.Value)
1053
- break
1103
+ sub = sub.where(sql<boolean>`${textExpr} ilike ${opts.value}`); break
1054
1104
  case 'exists':
1055
- opts.value ? this.whereNotNull(text as any) : this.whereNull(text as any)
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: any,
1117
+ q: AnyBuilder,
1065
1118
  baseTable: string,
1066
1119
  baseEntity: EntityId,
1067
- knex: any,
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: knex.raw('??::text', [`${baseTable}.id`]),
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 joinArgs = { [alias]: joinTable }
1088
- const joinCallback = function (this: any) {
1089
- this.on(`${alias}.${join.toField}`, '=', qualify(join.fromField))
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: knex.raw('??::text', [`${alias}.${recordColumn}`]),
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: any, column: string, scope: { ids: string[]; includeNull: boolean }): any {
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.whereRaw('1 = 0')
1179
+ return q.where(sql<boolean>`1 = 0`)
1130
1180
  }
1131
- return q.where((builder: any) => {
1132
- let applied = false
1133
- if (scope.ids.length > 0) {
1134
- builder.whereIn(column as any, scope.ids)
1135
- applied = true
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
  }