@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,3 +1,4 @@
1
+ import { sql } from "kysely";
1
2
  import {
2
3
  applyJoinFilters,
3
4
  normalizeFilters,
@@ -84,7 +85,7 @@ function buildFilterableCustomFieldJoins(sources) {
84
85
  }];
85
86
  });
86
87
  }
87
- const computeScore = (cfg, kind, entityIndex) => {
88
+ function computeCustomFieldScore(cfg, kind, entityIndex) {
88
89
  const listVisibleScore = cfg.listVisible === false ? 0 : 1;
89
90
  const formEditableScore = cfg.formEditable === false ? 0 : 1;
90
91
  const filterableScore = cfg.filterable ? 1 : 0;
@@ -111,11 +112,11 @@ const computeScore = (cfg, kind, entityIndex) => {
111
112
  const base = listVisibleScore * 16 + formEditableScore * 8 + filterableScore * 4 + kindScore + optionsBonus + dictionaryBonus;
112
113
  const penalty = typeof cfg.priority === "number" ? cfg.priority : 0;
113
114
  return { base, penalty, entityIndex };
114
- };
115
+ }
115
116
  class BasicQueryEngine {
116
- constructor(em, getKnexFn, resolveEncryptionService) {
117
+ constructor(em, getDbFn, resolveEncryptionService) {
117
118
  this.em = em;
118
- this.getKnexFn = getKnexFn;
119
+ this.getDbFn = getDbFn;
119
120
  this.resolveEncryptionService = resolveEncryptionService;
120
121
  this.columnCache = /* @__PURE__ */ new Map();
121
122
  this.tableCache = /* @__PURE__ */ new Map();
@@ -128,6 +129,12 @@ class BasicQueryEngine {
128
129
  return null;
129
130
  }
130
131
  }
132
+ getDb() {
133
+ if (this.getDbFn) return this.getDbFn();
134
+ const emAny = this.em;
135
+ if (typeof emAny?.getKysely === "function") return emAny.getKysely();
136
+ throw new Error("BasicQueryEngine requires an EntityManager exposing getKysely() (MikroORM v7)");
137
+ }
131
138
  async query(entity, opts = {}) {
132
139
  const ext = opts.extensions;
133
140
  let effectiveOpts = opts;
@@ -156,8 +163,8 @@ class BasicQueryEngine {
156
163
  const { extensions: _ext, ...coreOpts } = effectiveOpts;
157
164
  opts = coreOpts;
158
165
  const table = resolveEntityTableName(this.em, entity);
159
- const knex = this.getKnexFn ? this.getKnexFn() : this.em.getConnection().getKnex();
160
- let q = knex(table);
166
+ const db = this.getDb();
167
+ let q = db.selectFrom(table);
161
168
  const qualify = (col) => `${table}.${col}`;
162
169
  const orgScope = this.resolveOrganizationScope(opts);
163
170
  this.searchAliasSeq = 0;
@@ -171,10 +178,10 @@ class BasicQueryEngine {
171
178
  q = this.applyOrganizationScope(q, qualify("organization_id"), orgScope);
172
179
  }
173
180
  if (!skipAutoScope && await this.columnExists(table, "tenant_id")) {
174
- q = q.where(qualify("tenant_id"), opts.tenantId);
181
+ q = q.where(qualify("tenant_id"), "=", opts.tenantId);
175
182
  }
176
183
  if (!opts.withDeleted && await this.columnExists(table, "deleted_at")) {
177
- q = q.whereNull(qualify("deleted_at"));
184
+ q = q.where(qualify("deleted_at"), "is", null);
178
185
  }
179
186
  const normalizedFilters = normalizeFilters(opts.filters);
180
187
  const resolvedJoins = resolveJoins(
@@ -230,11 +237,11 @@ class BasicQueryEngine {
230
237
  }
231
238
  const recordIdColumn = qualify("id");
232
239
  const applyFilterOp = (builder, column, op, value, fieldName) => {
233
- if ((op === "like" || op === "ilike") && searchActive && typeof value === "string" && fieldName) {
240
+ if ((op === "like" || op === "ilike") && searchActive && typeof value === "string" && fieldName && typeof column === "string") {
234
241
  const tokens = tokenizeText(String(value), searchConfig);
235
242
  const hashes = tokens.hashes;
236
243
  if (hashes.length) {
237
- const applied = this.applySearchTokens(builder, {
244
+ const result = this.applySearchTokens(builder, {
238
245
  entity: String(entity),
239
246
  field: fieldName,
240
247
  hashes,
@@ -248,11 +255,11 @@ class BasicQueryEngine {
248
255
  field: fieldName,
249
256
  tokens: tokens.tokens,
250
257
  hashes,
251
- applied,
258
+ applied: result.applied,
252
259
  tenantId: opts.tenantId ?? null,
253
260
  organizationScope: orgScope
254
261
  });
255
- if (applied) return builder;
262
+ if (result.applied) return result.builder;
256
263
  } else {
257
264
  this.logSearchDebug("search:skip-empty-hashes", {
258
265
  entity: String(entity),
@@ -261,60 +268,21 @@ class BasicQueryEngine {
261
268
  });
262
269
  }
263
270
  }
264
- switch (op) {
265
- case "eq":
266
- if (value === null) builder.whereNull(column);
267
- else builder.where(column, value);
268
- break;
269
- case "ne":
270
- if (value === null) builder.whereNotNull(column);
271
- else builder.whereNot(column, value);
272
- break;
273
- case "gt":
274
- builder.where(column, ">", value);
275
- break;
276
- case "gte":
277
- builder.where(column, ">=", value);
278
- break;
279
- case "lt":
280
- builder.where(column, "<", value);
281
- break;
282
- case "lte":
283
- builder.where(column, "<=", value);
284
- break;
285
- case "in":
286
- builder.whereIn(column, Array.isArray(value) ? value : [value]);
287
- break;
288
- case "nin":
289
- builder.whereNotIn(column, Array.isArray(value) ? value : [value]);
290
- break;
291
- case "like":
292
- builder.where(column, "like", value);
293
- break;
294
- case "ilike":
295
- builder.where(column, "ilike", value);
296
- break;
297
- case "exists":
298
- value ? builder.whereNotNull(column) : builder.whereNull(column);
299
- break;
300
- default:
301
- break;
302
- }
303
- return builder;
271
+ return this.applyColumnOp(builder, column, op, value);
304
272
  };
305
273
  const applyJoinFilterOp = async (builder, filter, _qualified, join) => {
306
- if (!searchEnabled || !join.entityId) return false;
307
- if (!["eq", "like", "ilike"].includes(filter.op)) return false;
308
- if (typeof filter.value !== "string" || filter.value.trim().length === 0) return false;
274
+ if (!searchEnabled || !join.entityId) return { applied: false, builder };
275
+ if (!["like", "ilike"].includes(filter.op)) return { applied: false, builder };
276
+ if (typeof filter.value !== "string" || filter.value.trim().length === 0) return { applied: false, builder };
309
277
  let searchAvailable = joinSearchAvailability.get(join.entityId);
310
278
  if (searchAvailable === void 0) {
311
279
  searchAvailable = await this.hasSearchTokens(join.entityId, opts.tenantId ?? null, orgScope);
312
280
  joinSearchAvailability.set(join.entityId, searchAvailable);
313
281
  }
314
- if (!searchAvailable) return false;
282
+ if (!searchAvailable) return { applied: false, builder };
315
283
  const tokens = tokenizeText(String(filter.value), searchConfig);
316
- if (!tokens.hashes.length) return false;
317
- return this.applySearchTokens(builder, {
284
+ if (!tokens.hashes.length) return { applied: false, builder };
285
+ const result = this.applySearchTokens(builder, {
318
286
  entity: join.entityId,
319
287
  field: filter.column,
320
288
  hashes: tokens.hashes,
@@ -323,6 +291,7 @@ class BasicQueryEngine {
323
291
  organizationScope: orgScope,
324
292
  tokens: tokens.tokens
325
293
  });
294
+ return { applied: result.applied, builder: result.builder };
326
295
  };
327
296
  const regularBaseFilters = baseFilters.filter((f) => !f.orGroup);
328
297
  const orGroupFilters = baseFilters.filter((f) => f.orGroup);
@@ -348,7 +317,7 @@ class BasicQueryEngine {
348
317
  }
349
318
  qualified = qualify(column);
350
319
  }
351
- applyFilterOp(q, qualified, filter.op, filter.value, fieldName);
320
+ q = applyFilterOp(q, qualified, filter.op, filter.value, fieldName);
352
321
  }
353
322
  if (orGroupFilters.length > 0) {
354
323
  const groups = /* @__PURE__ */ new Map();
@@ -374,33 +343,27 @@ class BasicQueryEngine {
374
343
  if (resolved.length > 0) resolvedGroupFilters.push(resolved);
375
344
  }
376
345
  if (resolvedGroupFilters.length > 0) {
377
- q = q.where(function() {
378
- const applyConjunctiveGroup = (builder, conjuncts) => {
379
- for (const rf of conjuncts) {
380
- applyFilterOp(builder, rf.qualified, rf.op, rf.value, rf.fieldName);
381
- }
382
- };
383
- applyConjunctiveGroup(this, resolvedGroupFilters[0]);
384
- for (let gi = 1; gi < resolvedGroupFilters.length; gi++) {
385
- this.orWhere(function(nested) {
386
- applyConjunctiveGroup(nested, resolvedGroupFilters[gi]);
387
- });
388
- }
389
- });
346
+ q = q.where((eb) => eb.or(
347
+ resolvedGroupFilters.map((group) => eb.and(
348
+ group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value))
349
+ ))
350
+ ));
390
351
  }
391
352
  }
392
353
  const applyAliasScopes = async (builder, aliasName) => {
393
354
  const targetTable = aliasTables.get(aliasName);
394
- if (!targetTable) return;
355
+ if (!targetTable) return builder;
356
+ let next = builder;
395
357
  if (!skipAutoScope && orgScope && await this.columnExists(targetTable, "organization_id")) {
396
- this.applyOrganizationScope(builder, `${aliasName}.organization_id`, orgScope);
358
+ next = this.applyOrganizationScope(next, `${aliasName}.organization_id`, orgScope);
397
359
  }
398
360
  if (!skipAutoScope && opts.tenantId && await this.columnExists(targetTable, "tenant_id")) {
399
- builder.where(`${aliasName}.tenant_id`, opts.tenantId);
361
+ next = next.where(`${aliasName}.tenant_id`, "=", opts.tenantId);
400
362
  }
363
+ return next;
401
364
  };
402
- await applyJoinFilters({
403
- knex,
365
+ q = await applyJoinFilters({
366
+ db,
404
367
  baseTable: table,
405
368
  builder: q,
406
369
  joinMap,
@@ -408,22 +371,23 @@ class BasicQueryEngine {
408
371
  aliasTables,
409
372
  qualifyBase: (column) => qualify(column),
410
373
  applyAliasScope: (builder, alias) => applyAliasScopes(builder, alias),
411
- applyFilterOp,
374
+ applyFilterOp: (builder, column, op, value) => applyFilterOp(builder, column, op, value),
412
375
  applyJoinFilterOp,
413
376
  columnExists: (tbl, column) => this.columnExists(tbl, column)
414
377
  });
415
378
  if (opts.fields && opts.fields.length) {
416
379
  const cols = opts.fields.filter((f) => !f.startsWith("cf:"));
417
- if (cols.length) {
418
- const baseSelects = cols.map((c) => knex.raw("?? as ??", [qualify(c), c]));
419
- q = q.select(baseSelects);
380
+ for (const c of cols) {
381
+ q = q.select(sql.ref(qualify(c)).as(c));
420
382
  }
421
383
  } else {
422
- q = q.select(knex.raw("??.*", [table]));
384
+ q = q.select(sql`${sql.ref(table)}.*`.as("__all"));
423
385
  }
424
386
  const tenantId = opts.tenantId;
425
387
  const sanitize = (s) => s.replace(/[^a-zA-Z0-9_]/g, "_");
426
- const cfSources = this.configureCustomFieldSources(q, table, entity, knex, opts, qualify);
388
+ const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify);
389
+ q = cfSourcesResult.builder;
390
+ const cfSources = cfSourcesResult.sources;
427
391
  const entityIdToSource = /* @__PURE__ */ new Map();
428
392
  for (const source of cfSources) {
429
393
  entityIdToSource.set(String(source.entityId), source);
@@ -442,11 +406,10 @@ class BasicQueryEngine {
442
406
  const entityIdList = Array.from(entityIdToSource.keys());
443
407
  const entityOrder = /* @__PURE__ */ new Map();
444
408
  entityIdList.forEach((id, idx) => entityOrder.set(id, idx));
445
- const rows = await knex("custom_field_defs").select("key", "entity_id", "config_json", "kind").whereIn("entity_id", entityIdList).andWhere("is_active", true).modify((qb) => {
446
- qb.andWhere((inner) => {
447
- inner.where({ tenant_id: tenantId }).orWhereNull("tenant_id");
448
- });
449
- });
409
+ const rows = await db.selectFrom("custom_field_defs").select(["key", "entity_id", "config_json", "kind"]).where("entity_id", "in", entityIdList).where("is_active", "=", true).where((eb) => eb.or([
410
+ eb("tenant_id", "=", tenantId),
411
+ eb("tenant_id", "is", null)
412
+ ])).execute();
450
413
  const sorted = rows.map((row) => {
451
414
  const raw = row.config_json;
452
415
  let cfg = {};
@@ -478,7 +441,7 @@ class BasicQueryEngine {
478
441
  if (!source) continue;
479
442
  const cfg = row.config || {};
480
443
  const entityIndex = entityOrder.get(row.entityId) ?? Number.MAX_SAFE_INTEGER;
481
- const scores = computeScore(cfg, row.kind, entityIndex);
444
+ const scores = computeCustomFieldScore(cfg, row.kind, entityIndex);
482
445
  const existing = selectedSources.get(row.key);
483
446
  if (!existing || scores.base > existing.score || scores.base === existing.score && (scores.penalty < existing.penalty || scores.penalty === existing.penalty && scores.entityIndex < existing.entityIndex)) {
484
447
  selectedSources.set(row.key, { source, score: scores.base, penalty: scores.penalty, entityIndex: scores.entityIndex });
@@ -494,11 +457,10 @@ class BasicQueryEngine {
494
457
  }
495
458
  const unresolvedKeys = Array.from(cfKeys).filter((key) => !keySource.has(key));
496
459
  if (unresolvedKeys.length > 0 && entityIdToSource.size > 0) {
497
- const rows = await knex("custom_field_defs").select("key", "entity_id").whereIn("entity_id", Array.from(entityIdToSource.keys())).whereIn("key", unresolvedKeys).andWhere("is_active", true).modify((qb) => {
498
- qb.andWhere((inner) => {
499
- inner.where({ tenant_id: tenantId }).orWhereNull("tenant_id");
500
- });
501
- });
460
+ const rows = await db.selectFrom("custom_field_defs").select(["key", "entity_id"]).where("entity_id", "in", Array.from(entityIdToSource.keys())).where("key", "in", unresolvedKeys).where("is_active", "=", true).where((eb) => eb.or([
461
+ eb("tenant_id", "=", tenantId),
462
+ eb("tenant_id", "is", null)
463
+ ])).execute();
502
464
  for (const row of rows) {
503
465
  const source = entityIdToSource.get(String(row.entity_id));
504
466
  if (!source) continue;
@@ -518,33 +480,39 @@ class BasicQueryEngine {
518
480
  const keyAliasSafe = sanitize(key);
519
481
  const defAlias = `cfd_${sourceAliasSafe}_${keyAliasSafe}`;
520
482
  const valAlias = `cfv_${sourceAliasSafe}_${keyAliasSafe}`;
521
- q = q.leftJoin({ [defAlias]: "custom_field_defs" }, function() {
522
- this.on(`${defAlias}.entity_id`, "=", knex.raw("?", [entityIdForKey])).andOn(`${defAlias}.key`, "=", knex.raw("?", [key])).andOn(`${defAlias}.is_active`, "=", knex.raw("true")).andOn(knex.raw(`(${defAlias}.tenant_id = ? OR ${defAlias}.tenant_id IS NULL)`, [tenantId]));
523
- });
524
- q = q.leftJoin({ [valAlias]: "custom_field_values" }, function() {
525
- this.on(`${valAlias}.entity_id`, "=", knex.raw("?", [entityIdForKey])).andOn(`${valAlias}.field_key`, "=", knex.raw("?", [key])).andOn(`${valAlias}.record_id`, "=", recordIdExpr).andOn(knex.raw(`(${valAlias}.tenant_id = ? OR ${valAlias}.tenant_id IS NULL)`, [tenantId]));
526
- });
527
- const caseExpr = knex.raw(
528
- `CASE ${defAlias}.kind
529
- WHEN 'integer' THEN (${valAlias}.value_int)::text
530
- WHEN 'float' THEN (${valAlias}.value_float)::text
531
- WHEN 'boolean' THEN (${valAlias}.value_bool)::text
532
- WHEN 'multiline' THEN (${valAlias}.value_multiline)::text
533
- ELSE (${valAlias}.value_text)::text
534
- END`
483
+ q = q.leftJoin(
484
+ `custom_field_defs as ${defAlias}`,
485
+ (jb) => jb.on(`${defAlias}.entity_id`, "=", String(entityIdForKey)).on(`${defAlias}.key`, "=", key).on(`${defAlias}.is_active`, "=", true).on((eb) => eb.or([
486
+ eb(`${defAlias}.tenant_id`, "=", tenantId),
487
+ eb(`${defAlias}.tenant_id`, "is", null)
488
+ ]))
489
+ );
490
+ q = q.leftJoin(
491
+ `custom_field_values as ${valAlias}`,
492
+ (jb) => jb.on(`${valAlias}.entity_id`, "=", String(entityIdForKey)).on(`${valAlias}.field_key`, "=", key).onRef(`${valAlias}.record_id`, "=", recordIdExpr).on((eb) => eb.or([
493
+ eb(`${valAlias}.tenant_id`, "=", tenantId),
494
+ eb(`${valAlias}.tenant_id`, "is", null)
495
+ ]))
535
496
  );
497
+ const caseExpr = sql`CASE ${sql.ref(`${defAlias}.kind`)}
498
+ WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
499
+ WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
500
+ WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
501
+ WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
502
+ ELSE (${sql.ref(`${valAlias}.value_text`)})::text
503
+ END`;
536
504
  cfValueExprByKey[key] = caseExpr;
537
505
  const alias = sanitize(`cf:${key}`);
538
506
  if ((opts.fields || []).includes(`cf:${key}`) || opts.includeCustomFields === true || requestedCustomFieldKeys.length > 0 && requestedCustomFieldKeys.includes(key)) {
539
- const isMulti = knex.raw(`bool_or(coalesce((${defAlias}.config_json->>'multi')::boolean, false))`);
540
- const aggregatedArray = `array_remove(array_agg(DISTINCT ${caseExpr.toString()}), NULL)`;
541
- const expr = `CASE WHEN ${isMulti.toString()}
507
+ const multiAlias = `${alias}__is_multi`;
508
+ const isMultiExpr = sql`bool_or(coalesce((${sql.ref(`${defAlias}.config_json`)}->>'multi')::boolean, false))`;
509
+ const aggregatedArray = sql`array_remove(array_agg(DISTINCT ${caseExpr}), NULL)`;
510
+ const projExpr = sql`CASE WHEN ${isMultiExpr}
542
511
  THEN to_jsonb(${aggregatedArray})
543
- ELSE to_jsonb(max(${caseExpr.toString()}))
512
+ ELSE to_jsonb(max(${caseExpr}))
544
513
  END`;
545
- const multiAlias = `${alias}__is_multi`;
546
- q = q.select(knex.raw(`${expr} as ??`, [alias]));
547
- q = q.select(knex.raw(`${isMulti.toString()} as ??`, [multiAlias]));
514
+ q = q.select(projExpr.as(alias));
515
+ q = q.select(isMultiExpr.as(multiAlias));
548
516
  cfSelectedAliases.push(alias);
549
517
  cfJsonAliases.add(alias);
550
518
  cfMultiAliasByAlias.set(alias, multiAlias);
@@ -559,7 +527,7 @@ class BasicQueryEngine {
559
527
  const tokens = tokenizeText(String(f.value), searchConfig);
560
528
  const hashes = tokens.hashes;
561
529
  if (hashes.length) {
562
- const applied = this.applySearchTokens(q, {
530
+ const result = this.applySearchTokens(q, {
563
531
  entity: String(entity),
564
532
  field: f.field,
565
533
  hashes,
@@ -573,11 +541,14 @@ class BasicQueryEngine {
573
541
  field: f.field,
574
542
  tokens: tokens.tokens,
575
543
  hashes,
576
- applied,
544
+ applied: result.applied,
577
545
  tenantId: opts.tenantId ?? null,
578
546
  organizationScope: orgScope
579
547
  });
580
- if (applied) continue;
548
+ if (result.applied) {
549
+ q = result.builder;
550
+ continue;
551
+ }
581
552
  } else {
582
553
  this.logSearchDebug("search:cf-skip-empty-hashes", {
583
554
  entity: String(entity),
@@ -586,41 +557,7 @@ class BasicQueryEngine {
586
557
  });
587
558
  }
588
559
  }
589
- switch (f.op) {
590
- case "eq":
591
- q = q.where(expr, "=", f.value);
592
- break;
593
- case "ne":
594
- q = q.where(expr, "!=", f.value);
595
- break;
596
- case "gt":
597
- q = q.where(expr, ">", f.value);
598
- break;
599
- case "gte":
600
- q = q.where(expr, ">=", f.value);
601
- break;
602
- case "lt":
603
- q = q.where(expr, "<", f.value);
604
- break;
605
- case "lte":
606
- q = q.where(expr, "<=", f.value);
607
- break;
608
- case "in":
609
- q = q.whereIn(expr, f.value ?? []);
610
- break;
611
- case "nin":
612
- q = q.whereNotIn(expr, f.value ?? []);
613
- break;
614
- case "like":
615
- q = q.where(expr, "like", f.value);
616
- break;
617
- case "ilike":
618
- q = q.where(expr, "ilike", f.value);
619
- break;
620
- case "exists":
621
- f.value ? q = q.whereNotNull(expr) : q = q.whereNull(expr);
622
- break;
623
- }
560
+ q = this.applyColumnOp(q, expr, f.op, f.value);
624
561
  }
625
562
  if (opts.includeExtensions) {
626
563
  const { getModules } = await import("@open-mercato/shared/lib/i18n/server");
@@ -632,9 +569,10 @@ class BasicQueryEngine {
632
569
  const [, extName] = e.extension.split(":");
633
570
  const extTable = extName.endsWith("s") ? extName : `${extName}s`;
634
571
  const alias = `ext_${sanitize(extName)}`;
635
- q = q.leftJoin({ [alias]: extTable }, function() {
636
- this.on(`${alias}.${e.join.extensionKey}`, "=", knex.raw("??", [`${table}.${e.join.baseKey}`]));
637
- });
572
+ q = q.leftJoin(
573
+ `${extTable} as ${alias}`,
574
+ (jb) => jb.onRef(`${alias}.${e.join.extensionKey}`, "=", `${table}.${e.join.baseKey}`)
575
+ );
638
576
  }
639
577
  }
640
578
  for (const s of opts.sort || []) {
@@ -644,7 +582,7 @@ class BasicQueryEngine {
644
582
  if (!cfSelectedAliases.includes(alias)) {
645
583
  const expr = cfValueExprByKey[key];
646
584
  if (expr) {
647
- q = q.select(knex.raw(`max(${expr.toString()}) as ??`, [alias]));
585
+ q = q.select(sql`max(${expr})`.as(alias));
648
586
  cfSelectedAliases.push(alias);
649
587
  }
650
588
  }
@@ -657,16 +595,14 @@ class BasicQueryEngine {
657
595
  }
658
596
  const page = opts.page?.page ?? 1;
659
597
  const pageSize = opts.page?.pageSize ?? 20;
660
- if (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0) {
598
+ const hasJoinedAggregates = opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0;
599
+ if (hasJoinedAggregates) {
661
600
  q = q.groupBy(`${table}.id`);
662
601
  }
663
- const countClone = q.clone();
664
- if (typeof countClone.clearSelect === "function") countClone.clearSelect();
665
- if (typeof countClone.clearOrder === "function") countClone.clearOrder();
666
- if (typeof countClone.clearGroup === "function") countClone.clearGroup();
667
- const countRow = await countClone.countDistinct(`${table}.id as count`).first();
602
+ const countBuilder = hasJoinedAggregates ? q.clearSelect().clearOrderBy().clearGroupBy().select(sql`count(distinct ${sql.ref(`${table}.id`)})`.as("count")) : q.clearSelect().clearOrderBy().select(sql`count(distinct ${sql.ref(`${table}.id`)})`.as("count"));
603
+ const countRow = await countBuilder.executeTakeFirst();
668
604
  const total = Number(countRow?.count ?? 0);
669
- const items = await q.limit(pageSize).offset((page - 1) * pageSize);
605
+ const items = await q.limit(pageSize).offset((page - 1) * pageSize).execute();
670
606
  if (cfJsonAliases.size > 0) {
671
607
  for (const row of items) {
672
608
  for (const alias of cfJsonAliases) {
@@ -725,6 +661,62 @@ class BasicQueryEngine {
725
661
  }
726
662
  return queryResult;
727
663
  }
664
+ applyColumnOp(builder, column, op, value) {
665
+ switch (op) {
666
+ case "eq":
667
+ return value === null ? builder.where(column, "is", null) : builder.where(column, "=", value);
668
+ case "ne":
669
+ return value === null ? builder.where(column, "is not", null) : builder.where(column, "!=", value);
670
+ case "gt":
671
+ return builder.where(column, ">", value);
672
+ case "gte":
673
+ return builder.where(column, ">=", value);
674
+ case "lt":
675
+ return builder.where(column, "<", value);
676
+ case "lte":
677
+ return builder.where(column, "<=", value);
678
+ case "in":
679
+ return builder.where(column, "in", Array.isArray(value) ? value : [value]);
680
+ case "nin":
681
+ return builder.where(column, "not in", Array.isArray(value) ? value : [value]);
682
+ case "like":
683
+ return builder.where(column, "like", value);
684
+ case "ilike":
685
+ return builder.where(column, "ilike", value);
686
+ case "exists":
687
+ return value ? builder.where(column, "is not", null) : builder.where(column, "is", null);
688
+ default:
689
+ return builder;
690
+ }
691
+ }
692
+ buildColumnOpExpression(eb, column, op, value) {
693
+ switch (op) {
694
+ case "eq":
695
+ return value === null ? eb(column, "is", null) : eb(column, "=", value);
696
+ case "ne":
697
+ return value === null ? eb(column, "is not", null) : eb(column, "!=", value);
698
+ case "gt":
699
+ return eb(column, ">", value);
700
+ case "gte":
701
+ return eb(column, ">=", value);
702
+ case "lt":
703
+ return eb(column, "<", value);
704
+ case "lte":
705
+ return eb(column, "<=", value);
706
+ case "in":
707
+ return eb(column, "in", Array.isArray(value) ? value : [value]);
708
+ case "nin":
709
+ return eb(column, "not in", Array.isArray(value) ? value : [value]);
710
+ case "like":
711
+ return eb(column, "like", value);
712
+ case "ilike":
713
+ return eb(column, "ilike", value);
714
+ case "exists":
715
+ return value ? eb(column, "is not", null) : eb(column, "is", null);
716
+ default:
717
+ return eb.val(true);
718
+ }
719
+ }
728
720
  async resolveBaseColumn(table, field) {
729
721
  if (await this.columnExists(table, field)) return field;
730
722
  if (field === "organization_id" && await this.columnExists(table, "id")) return "id";
@@ -737,8 +729,8 @@ class BasicQueryEngine {
737
729
  if (cached === true) return true;
738
730
  this.columnCache.delete(key);
739
731
  }
740
- const knex = this.getKnexFn ? this.getKnexFn() : this.em.getConnection().getKnex();
741
- const exists = await knex("information_schema.columns").where({ table_name: table, column_name: column }).first();
732
+ const db = this.getDb();
733
+ const exists = await db.selectFrom("information_schema.columns").select(sql`1`.as("one")).where("table_name", "=", table).where("column_name", "=", column).limit(1).executeTakeFirst();
742
734
  const present = !!exists;
743
735
  if (present) this.columnCache.set(key, true);
744
736
  else this.columnCache.delete(key);
@@ -746,23 +738,23 @@ class BasicQueryEngine {
746
738
  }
747
739
  async tableExists(table) {
748
740
  if (this.tableCache.has(table)) return this.tableCache.get(table) ?? false;
749
- const knex = this.getKnexFn ? this.getKnexFn() : this.em.getConnection().getKnex();
750
- const exists = await knex("information_schema.tables").where({ table_name: table }).first();
741
+ const db = this.getDb();
742
+ const exists = await db.selectFrom("information_schema.tables").select(sql`1`.as("one")).where("table_name", "=", table).limit(1).executeTakeFirst();
751
743
  const present = !!exists;
752
744
  this.tableCache.set(table, present);
753
745
  return present;
754
746
  }
755
747
  async hasSearchTokens(entity, tenantId, orgScope) {
756
748
  try {
757
- const knex = this.getKnexFn ? this.getKnexFn() : this.em.getConnection().getKnex();
758
- const query = knex("search_tokens").select(1).where("entity_type", entity).limit(1);
749
+ const db = this.getDb();
750
+ let query = db.selectFrom("search_tokens").select(sql`1`.as("one")).where("entity_type", "=", entity).limit(1);
759
751
  if (tenantId !== void 0) {
760
- query.andWhereRaw("tenant_id is not distinct from ?", [tenantId]);
752
+ query = query.where(sql`tenant_id is not distinct from ${tenantId}`);
761
753
  }
762
754
  if (orgScope) {
763
- this.applyOrganizationScope(query, "search_tokens.organization_id", orgScope);
755
+ query = this.applyOrganizationScope(query, "search_tokens.organization_id", orgScope);
764
756
  }
765
- const row = await query.first();
757
+ const row = await query.executeTakeFirst();
766
758
  return !!row;
767
759
  } catch (err) {
768
760
  this.logSearchDebug("search:has-tokens-error", {
@@ -782,10 +774,9 @@ class BasicQueryEngine {
782
774
  tenantId: opts.tenantId ?? null,
783
775
  organizationScope: opts.organizationScope
784
776
  });
785
- return false;
777
+ return { applied: false, builder: q };
786
778
  }
787
779
  const alias = `st_${this.searchAliasSeq++}`;
788
- const combineWith = opts.combineWith === "or" ? "orWhereExists" : "whereExists";
789
780
  const engine = this;
790
781
  this.logSearchDebug("search:apply-search-tokens", {
791
782
  entity: opts.entity,
@@ -797,23 +788,30 @@ class BasicQueryEngine {
797
788
  organizationScope: opts.organizationScope,
798
789
  combineWith: opts.combineWith ?? "and"
799
790
  });
800
- q[combineWith](function() {
801
- this.select(1).from({ [alias]: "search_tokens" }).where(`${alias}.entity_type`, opts.entity).andWhere(`${alias}.field`, opts.field).andWhereRaw("?? = ??::text", [`${alias}.entity_id`, opts.recordIdColumn]).whereIn(`${alias}.token_hash`, opts.hashes).groupBy(`${alias}.entity_id`, `${alias}.field`).havingRaw(`count(distinct ${alias}.token_hash) >= ?`, [opts.hashes.length]);
791
+ const buildSub = (eb) => {
792
+ let sub = eb.selectFrom(`search_tokens as ${alias}`).select(sql`1`.as("one")).where(`${alias}.entity_type`, "=", opts.entity).where(`${alias}.field`, "=", opts.field).where(sql`${sql.ref(`${alias}.entity_id`)} = ${sql.ref(opts.recordIdColumn)}::text`).where(`${alias}.token_hash`, "in", opts.hashes).groupBy([`${alias}.entity_id`, `${alias}.field`]).having(sql`count(distinct ${sql.ref(`${alias}.token_hash`)}) >= ${opts.hashes.length}`);
802
793
  if (opts.tenantId !== void 0) {
803
- this.andWhereRaw(`${alias}.tenant_id is not distinct from ?`, [opts.tenantId ?? null]);
794
+ sub = sub.where(sql`${sql.ref(`${alias}.tenant_id`)} is not distinct from ${opts.tenantId ?? null}`);
804
795
  }
805
796
  if (opts.organizationScope) {
806
- engine.applyOrganizationScope(this, `${alias}.organization_id`, opts.organizationScope);
797
+ sub = engine.applyOrganizationScope(sub, `${alias}.organization_id`, opts.organizationScope);
807
798
  }
808
- });
809
- return true;
799
+ return sub;
800
+ };
801
+ const combiner = opts.combineWith === "or" ? "or" : "and";
802
+ if (combiner === "or") {
803
+ const next2 = q.where((eb) => eb.or([eb.exists(buildSub(eb))]));
804
+ return { applied: true, builder: next2 };
805
+ }
806
+ const next = q.where((eb) => eb.exists(buildSub(eb)));
807
+ return { applied: true, builder: next };
810
808
  }
811
809
  applyIndexDocFilter(q, opts) {
812
810
  if ((opts.op === "like" || opts.op === "ilike") && opts.searchActive && typeof opts.value === "string") {
813
811
  const tokens = tokenizeText(String(opts.value), opts.searchConfig);
814
812
  const hashes = tokens.hashes;
815
813
  if (hashes.length) {
816
- const applied = this.applySearchTokens(q, {
814
+ const result = this.applySearchTokens(q, {
817
815
  entity: opts.entity,
818
816
  field: opts.field,
819
817
  hashes,
@@ -827,11 +825,11 @@ class BasicQueryEngine {
827
825
  field: opts.field,
828
826
  tokens: tokens.tokens,
829
827
  hashes,
830
- applied,
828
+ applied: result.applied,
831
829
  tenantId: opts.tenantId ?? null,
832
830
  organizationScope: opts.organizationScope
833
831
  });
834
- if (applied) return q;
832
+ if (result.applied) return result.builder;
835
833
  } else {
836
834
  this.logSearchDebug("search:index-doc-skip-empty-hashes", {
837
835
  entity: opts.entity,
@@ -841,66 +839,71 @@ class BasicQueryEngine {
841
839
  }
842
840
  return q;
843
841
  }
844
- const knex = this.getKnexFn ? this.getKnexFn() : this.em.getConnection().getKnex();
845
842
  const alias = `ei_${this.searchAliasSeq++}`;
846
843
  const engine = this;
847
- return q.whereExists(function() {
848
- this.select(1).from({ [alias]: "entity_indexes" }).where(`${alias}.entity_type`, opts.entity).andWhereRaw("?? = ??::text", [`${alias}.entity_id`, opts.recordIdColumn]);
844
+ return q.where((eb) => eb.exists((() => {
845
+ let sub = eb.selectFrom(`entity_indexes as ${alias}`).select(sql`1`.as("one")).where(`${alias}.entity_type`, "=", opts.entity).where(sql`${sql.ref(`${alias}.entity_id`)} = ${sql.ref(opts.recordIdColumn)}::text`);
849
846
  if (opts.tenantId !== void 0) {
850
- this.andWhereRaw(`${alias}.tenant_id is not distinct from ?`, [opts.tenantId ?? null]);
847
+ sub = sub.where(sql`${sql.ref(`${alias}.tenant_id`)} is not distinct from ${opts.tenantId ?? null}`);
851
848
  }
852
849
  if (opts.organizationScope) {
853
- engine.applyOrganizationScope(this, `${alias}.organization_id`, opts.organizationScope);
850
+ sub = engine.applyOrganizationScope(sub, `${alias}.organization_id`, opts.organizationScope);
854
851
  }
855
852
  if (!opts.withDeleted) {
856
- this.whereNull(`${alias}.deleted_at`);
853
+ sub = sub.where(`${alias}.deleted_at`, "is", null);
857
854
  }
858
- const text = knex.raw(`(${alias}.doc ->> ?)`, [opts.field]);
855
+ const textExpr = sql`(${sql.ref(`${alias}.doc`)} ->> ${opts.field})`;
859
856
  switch (opts.op) {
860
857
  case "eq":
861
- this.where(text, "=", opts.value);
858
+ sub = sub.where(sql`${textExpr} = ${opts.value}`);
862
859
  break;
863
860
  case "ne":
864
- this.where(text, "!=", opts.value);
861
+ sub = sub.where(sql`${textExpr} <> ${opts.value}`);
865
862
  break;
866
863
  case "gt":
867
864
  case "gte":
868
865
  case "lt":
869
866
  case "lte": {
870
- const operator = opts.op === "gt" ? ">" : opts.op === "gte" ? ">=" : opts.op === "lt" ? "<" : "<=";
871
- this.where(text, operator, opts.value);
867
+ const operator = sql.raw(opts.op === "gt" ? ">" : opts.op === "gte" ? ">=" : opts.op === "lt" ? "<" : "<=");
868
+ sub = sub.where(sql`${textExpr} ${operator} ${opts.value}`);
872
869
  break;
873
870
  }
874
- case "in":
875
- this.whereIn(text, Array.isArray(opts.value) ? opts.value : [opts.value]);
871
+ case "in": {
872
+ const vals = Array.isArray(opts.value) ? opts.value : [opts.value];
873
+ sub = sub.where(sql`${textExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`);
876
874
  break;
877
- case "nin":
878
- this.whereNotIn(text, Array.isArray(opts.value) ? opts.value : [opts.value]);
875
+ }
876
+ case "nin": {
877
+ const vals = Array.isArray(opts.value) ? opts.value : [opts.value];
878
+ sub = sub.where(sql`${textExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`);
879
879
  break;
880
+ }
880
881
  case "like":
881
- this.where(text, "like", opts.value);
882
+ sub = sub.where(sql`${textExpr} like ${opts.value}`);
882
883
  break;
883
884
  case "ilike":
884
- this.where(text, "ilike", opts.value);
885
+ sub = sub.where(sql`${textExpr} ilike ${opts.value}`);
885
886
  break;
886
887
  case "exists":
887
- opts.value ? this.whereNotNull(text) : this.whereNull(text);
888
+ sub = opts.value ? sub.where(sql`${textExpr} is not null`) : sub.where(sql`${textExpr} is null`);
888
889
  break;
889
890
  default:
890
891
  break;
891
892
  }
892
- });
893
+ return sub;
894
+ })()));
893
895
  }
894
- configureCustomFieldSources(q, baseTable, baseEntity, knex, opts, qualify) {
896
+ configureCustomFieldSources(q, baseTable, baseEntity, db, opts, qualify) {
895
897
  const sources = [
896
898
  {
897
899
  entityId: baseEntity,
898
900
  alias: "base",
899
901
  table: baseTable,
900
- recordIdExpr: knex.raw("??::text", [`${baseTable}.id`])
902
+ recordIdExpr: sql`${sql.ref(`${baseTable}.id`)}::text`
901
903
  }
902
904
  ];
903
905
  const extras = opts.customFieldSources ?? [];
906
+ let next = q;
904
907
  extras.forEach((srcOpt, index) => {
905
908
  const joinTable = srcOpt.table ?? resolveEntityTableName(this.em, srcOpt.entityId);
906
909
  const alias = srcOpt.alias ?? `cfs_${index}`;
@@ -908,22 +911,17 @@ class BasicQueryEngine {
908
911
  if (!join) {
909
912
  throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`);
910
913
  }
911
- const joinArgs = { [alias]: joinTable };
912
- const joinCallback = function() {
913
- this.on(`${alias}.${join.toField}`, "=", qualify(join.fromField));
914
- };
915
- const joinType = join.type ?? "left";
916
- if (joinType === "inner") q.join(joinArgs, joinCallback);
917
- else q.leftJoin(joinArgs, joinCallback);
914
+ const joinFn = (join.type ?? "left") === "inner" ? "innerJoin" : "leftJoin";
915
+ next = next[joinFn](`${joinTable} as ${alias}`, (jb) => jb.onRef(`${alias}.${join.toField}`, "=", qualify(join.fromField)));
918
916
  const recordColumn = srcOpt.recordIdColumn ?? "id";
919
917
  sources.push({
920
918
  entityId: srcOpt.entityId,
921
919
  alias,
922
920
  table: joinTable,
923
- recordIdExpr: knex.raw("??::text", [`${alias}.${recordColumn}`])
921
+ recordIdExpr: sql`${sql.ref(`${alias}.${recordColumn}`)}::text`
924
922
  });
925
923
  });
926
- return sources;
924
+ return { builder: next, sources };
927
925
  }
928
926
  logSearchDebug(event, payload) {
929
927
  try {
@@ -947,20 +945,14 @@ class BasicQueryEngine {
947
945
  applyOrganizationScope(q, column, scope) {
948
946
  if (!scope) return q;
949
947
  if (scope.ids.length === 0 && !scope.includeNull) {
950
- return q.whereRaw("1 = 0");
951
- }
952
- return q.where((builder) => {
953
- let applied = false;
954
- if (scope.ids.length > 0) {
955
- builder.whereIn(column, scope.ids);
956
- applied = true;
957
- }
958
- if (scope.includeNull) {
959
- if (applied) builder.orWhereNull(column);
960
- else builder.whereNull(column);
961
- applied = true;
962
- }
963
- if (!applied) builder.whereRaw("1 = 0");
948
+ return q.where(sql`1 = 0`);
949
+ }
950
+ return q.where((eb) => {
951
+ const parts = [];
952
+ if (scope.ids.length > 0) parts.push(eb(column, "in", scope.ids));
953
+ if (scope.includeNull) parts.push(eb(column, "is", null));
954
+ if (parts.length === 1) return parts[0];
955
+ return eb.or(parts);
964
956
  });
965
957
  }
966
958
  }