@open-mercato/shared 0.6.8-develop.7029.1.a1bb3363af → 0.6.8-develop.7031.1.005201cd70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/crud/factory.js +1 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/openapi/crud.js +4 -1
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/count-cap.js +11 -0
- package/dist/lib/query/count-cap.js.map +7 -0
- package/dist/lib/query/engine.js +175 -25
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +22 -0
- package/src/lib/crud/factory.ts +1 -0
- package/src/lib/openapi/crud.ts +3 -0
- package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
- package/src/lib/query/__tests__/count-cap.test.ts +41 -0
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +154 -14
- package/src/lib/query/__tests__/engine.test.ts +131 -7
- package/src/lib/query/count-cap.ts +19 -0
- package/src/lib/query/engine.ts +271 -44
- package/src/lib/query/types.ts +15 -0
package/src/lib/query/engine.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QueryEngine, QueryOptions, QueryResult, QueryResultMeta, EncryptedSortRowCapWarning, QueryCustomFieldSource, QueryExtensionsConfig, Sort } from './types'
|
|
1
|
+
import type { QueryEngine, QueryOptions, QueryResult, QueryResultMeta, EncryptedSortRowCapWarning, ListCountCapWarning, QueryCustomFieldSource, QueryExtensionsConfig, Sort } from './types'
|
|
2
2
|
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import { type Kysely, sql, type RawBuilder } from 'kysely'
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from '../crud/custom-field-definition-index'
|
|
32
32
|
import { warnOnCiphertextLikeFallback } from './ciphertext-search-warning'
|
|
33
33
|
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from './encrypted-sort'
|
|
34
|
+
import { resolveListCountCap } from './count-cap'
|
|
34
35
|
import { mapWithConcurrency } from './bounded-decrypt'
|
|
35
36
|
import { createLogger } from '../logger'
|
|
36
37
|
|
|
@@ -93,6 +94,12 @@ type ResolvedCustomFieldSource = {
|
|
|
93
94
|
alias: string
|
|
94
95
|
table: string
|
|
95
96
|
recordIdExpr: RawBuilder<string>
|
|
97
|
+
/**
|
|
98
|
+
* The base→source join edge for joined sources (absent on the base source).
|
|
99
|
+
* The count projection uses it to correlate cf-value EXISTS subqueries
|
|
100
|
+
* without attaching the source join to the outer query.
|
|
101
|
+
*/
|
|
102
|
+
hop?: { fromField: string; toField: string; recordIdColumn: string; type: 'left' | 'inner' }
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
type ResultRow = Record<string, unknown>
|
|
@@ -649,11 +656,15 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
649
656
|
// Builds the fully-scoped query from a fresh root. `projection: 'full'` reproduces
|
|
650
657
|
// today's complete selection (base fields + CF projections + extension joins).
|
|
651
658
|
// `projection: 'sortKeys'` selects only `id` + the sort columns — the slim phase-1
|
|
652
|
-
// candidate scan used when `requiresPlaintextSort`.
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
|
|
659
|
+
// candidate scan used when `requiresPlaintextSort`. `projection: 'count'` carries
|
|
660
|
+
// scope + filters only: projection joins (CF defs/values, extensions) are omitted
|
|
661
|
+
// and cf filters are expressed as correlated EXISTS semi-joins, so nothing can
|
|
662
|
+
// multiply base rows and a LIMIT above the query is an enforceable bound.
|
|
663
|
+
// Re-running the WHERE/JOIN logic per projection is cheap: every `columnExists`
|
|
664
|
+
// check is memoized on `this.columnCache`, so later passes hit no extra DB calls.
|
|
665
|
+
const buildQuery = async (projection: 'full' | 'sortKeys' | 'count'): Promise<BuiltQuery> => {
|
|
656
666
|
const isSortKeysProjection = projection === 'sortKeys'
|
|
667
|
+
const isCountProjection = projection === 'count'
|
|
657
668
|
let q: AnyBuilder = db.selectFrom(table as any)
|
|
658
669
|
|
|
659
670
|
// Tenant/org/soft-delete scope
|
|
@@ -745,7 +756,10 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
745
756
|
})
|
|
746
757
|
|
|
747
758
|
// Selection (base columns only here; cf:* handled later)
|
|
748
|
-
if (
|
|
759
|
+
if (isCountProjection) {
|
|
760
|
+
// The caller owns the count query's SELECT (a constant inside the
|
|
761
|
+
// bounded subquery, or the aggregate itself when the cap is off).
|
|
762
|
+
} else if (isSortKeysProjection) {
|
|
749
763
|
q = q.select(sql.ref(qualify('id')).as('id'))
|
|
750
764
|
if (await this.columnExists(table, 'tenant_id')) {
|
|
751
765
|
q = q.select(sql.ref(qualify('tenant_id')).as('tenant_id'))
|
|
@@ -771,14 +785,14 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
771
785
|
}
|
|
772
786
|
|
|
773
787
|
// Resolve which custom fields to include
|
|
774
|
-
const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify)
|
|
788
|
+
const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify, !isCountProjection)
|
|
775
789
|
q = cfSourcesResult.builder
|
|
776
790
|
const cfSources = cfSourcesResult.sources
|
|
777
791
|
const entityIdToSource = new Map<string, ResolvedCustomFieldSource>()
|
|
778
792
|
for (const source of cfSources) {
|
|
779
793
|
entityIdToSource.set(String(source.entityId), source)
|
|
780
794
|
}
|
|
781
|
-
const requestedCustomFieldKeys = (
|
|
795
|
+
const requestedCustomFieldKeys = (projection === 'full' && Array.isArray(opts.includeCustomFields))
|
|
782
796
|
? opts.includeCustomFields.map((key) => String(key))
|
|
783
797
|
: []
|
|
784
798
|
const cfKeys = new Set<string>()
|
|
@@ -788,7 +802,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
788
802
|
// Output-only — never resolved for the slim sortKeys projection.
|
|
789
803
|
let resolvedCustomFieldDefinitions: ResolvedCustomFieldDefinitions | undefined
|
|
790
804
|
// Explicit in fields/filters
|
|
791
|
-
if (
|
|
805
|
+
if (projection === 'full') {
|
|
792
806
|
for (const f of (opts.fields || [])) {
|
|
793
807
|
if (typeof f === 'string' && f.startsWith('cf:')) cfKeys.add(f.slice(3))
|
|
794
808
|
}
|
|
@@ -796,7 +810,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
796
810
|
for (const f of cfFilters) {
|
|
797
811
|
if (typeof f.field === 'string' && f.field.startsWith('cf:')) cfKeys.add(f.field.slice(3))
|
|
798
812
|
}
|
|
799
|
-
if (
|
|
813
|
+
if (projection === 'full' && opts.includeCustomFields === true) {
|
|
800
814
|
if (entityIdToSource.size > 0) {
|
|
801
815
|
const entityIdList = Array.from(entityIdToSource.keys())
|
|
802
816
|
const entityOrder = new Map<string, number>()
|
|
@@ -923,6 +937,9 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
923
937
|
for (const key of cfKeys) {
|
|
924
938
|
const source = keySource.get(key)
|
|
925
939
|
if (!source) continue
|
|
940
|
+
// The count shape never joins defs/values — cf filters are applied as
|
|
941
|
+
// correlated EXISTS semi-joins below, so no join can multiply base rows.
|
|
942
|
+
if (isCountProjection) continue
|
|
926
943
|
const entityIdForKey = source.entityId
|
|
927
944
|
const recordIdExpr = source.recordIdExpr
|
|
928
945
|
const sourceAliasSafe = sanitize(source.alias || 'src')
|
|
@@ -976,13 +993,16 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
976
993
|
}
|
|
977
994
|
}
|
|
978
995
|
|
|
979
|
-
// Apply cf:* filters (on raw expressions
|
|
980
|
-
//
|
|
996
|
+
// Apply cf:* filters (on raw expressions; as EXISTS semi-joins for the count
|
|
997
|
+
// shape). OR-grouped ones are excluded here and combined with their
|
|
998
|
+
// disjunct's other leaves right below.
|
|
981
999
|
for (const f of regularCfFilters) {
|
|
982
1000
|
if (!f.field.startsWith('cf:')) continue
|
|
983
1001
|
const key = f.field.slice(3)
|
|
1002
|
+
const filterSource = keySource.get(key)
|
|
1003
|
+
if (!filterSource) continue
|
|
984
1004
|
const expr = cfValueExprByKey[key]
|
|
985
|
-
if (!expr) continue
|
|
1005
|
+
if (!isCountProjection && !expr) continue
|
|
986
1006
|
if ((f.op === 'like' || f.op === 'ilike') && searchActive && typeof f.value === 'string') {
|
|
987
1007
|
const tokens = tokenizeText(String(f.value), searchConfig)
|
|
988
1008
|
const hashes = tokens.hashes
|
|
@@ -1017,6 +1037,17 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1017
1037
|
})
|
|
1018
1038
|
}
|
|
1019
1039
|
}
|
|
1040
|
+
if (isCountProjection) {
|
|
1041
|
+
q = this.applyCfValueExistsFilter(q, {
|
|
1042
|
+
source: filterSource,
|
|
1043
|
+
qualify,
|
|
1044
|
+
tenantId: tenantId ?? null,
|
|
1045
|
+
key,
|
|
1046
|
+
op: f.op,
|
|
1047
|
+
value: f.value,
|
|
1048
|
+
})
|
|
1049
|
+
continue
|
|
1050
|
+
}
|
|
1020
1051
|
q = this.applyColumnOp(q, expr, f.op, f.value)
|
|
1021
1052
|
}
|
|
1022
1053
|
|
|
@@ -1031,15 +1062,35 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1031
1062
|
// `ilike` through the search-token index the way the ungrouped path does. On a
|
|
1032
1063
|
// field covered by an encryption map such a leaf therefore compares against
|
|
1033
1064
|
// ciphertext and will not match.
|
|
1065
|
+
//
|
|
1066
|
+
// The count shape never populates cfValueExprByKey (it joins no cf tables), so
|
|
1067
|
+
// its applicability test is key resolution itself — the same condition that
|
|
1068
|
+
// gates the full shape's expression map — and a cf leaf compiles to a
|
|
1069
|
+
// correlated EXISTS instead of a value-expression comparison. Dropping it
|
|
1070
|
+
// instead would narrow the OR and undercount relative to the display query.
|
|
1071
|
+
const cfLeafApplicable = (key: string): boolean =>
|
|
1072
|
+
isCountProjection ? keySource.has(key) : Boolean(cfValueExprByKey[key])
|
|
1034
1073
|
const applicableGroupFilters = resolvedGroupFilters
|
|
1035
|
-
.map((group) => group.filter((rf) => rf.kind !== 'cf' ||
|
|
1074
|
+
.map((group) => group.filter((rf) => rf.kind !== 'cf' || cfLeafApplicable(rf.key)))
|
|
1036
1075
|
.filter((group) => group.length > 0)
|
|
1037
1076
|
if (applicableGroupFilters.length > 0) {
|
|
1038
1077
|
q = q.where((eb: any) => {
|
|
1039
1078
|
const disjuncts = applicableGroupFilters.map((group) => {
|
|
1040
1079
|
const parts = group.map((rf) => {
|
|
1041
1080
|
if (rf.kind === 'column') return this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value)
|
|
1042
|
-
if (rf.kind === 'cf')
|
|
1081
|
+
if (rf.kind === 'cf') {
|
|
1082
|
+
if (isCountProjection) {
|
|
1083
|
+
return this.buildCfValueExistsExpression(eb, {
|
|
1084
|
+
source: keySource.get(rf.key)!,
|
|
1085
|
+
qualify,
|
|
1086
|
+
tenantId: tenantId ?? null,
|
|
1087
|
+
key: rf.key,
|
|
1088
|
+
op: rf.op,
|
|
1089
|
+
value: rf.value,
|
|
1090
|
+
})
|
|
1091
|
+
}
|
|
1092
|
+
return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
|
|
1093
|
+
}
|
|
1043
1094
|
return this.buildIndexDocOpExpression(eb, {
|
|
1044
1095
|
entity: String(entity),
|
|
1045
1096
|
field: rf.field,
|
|
@@ -1057,8 +1108,9 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1057
1108
|
})
|
|
1058
1109
|
}
|
|
1059
1110
|
|
|
1060
|
-
// Entity extensions joins (no selection yet; enables future filters/projections)
|
|
1061
|
-
|
|
1111
|
+
// Entity extensions joins (no selection yet; enables future filters/projections).
|
|
1112
|
+
// Projection-only, so the count shape omits them.
|
|
1113
|
+
if (opts.includeExtensions && !isCountProjection) {
|
|
1062
1114
|
const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
|
|
1063
1115
|
const allMods = getModules() as any[]
|
|
1064
1116
|
const allExts = allMods.flatMap((m) => (m as any).entityExtensions || [])
|
|
@@ -1085,7 +1137,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1085
1137
|
}
|
|
1086
1138
|
|
|
1087
1139
|
// Sorting: base fields and cf:* (use aggregated alias for cf)
|
|
1088
|
-
for (const s of resolvedSorts) {
|
|
1140
|
+
for (const s of isCountProjection ? [] : resolvedSorts) {
|
|
1089
1141
|
if (s.field.startsWith('cf:')) {
|
|
1090
1142
|
const key = s.field.slice(3)
|
|
1091
1143
|
const alias = sanitize(`cf:${key}`)
|
|
@@ -1103,8 +1155,12 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1103
1155
|
}
|
|
1104
1156
|
}
|
|
1105
1157
|
|
|
1106
|
-
// Deduplicate if we joined CFs or extensions by grouping on base id
|
|
1107
|
-
|
|
1158
|
+
// Deduplicate if we joined CFs or extensions by grouping on base id. The count
|
|
1159
|
+
// shape has neither, and must stay barrier-free for its LIMIT to bind.
|
|
1160
|
+
const hasJoinedAggregates = !isCountProjection && (
|
|
1161
|
+
(opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) ||
|
|
1162
|
+
Object.keys(cfValueExprByKey).length > 0
|
|
1163
|
+
)
|
|
1108
1164
|
if (hasJoinedAggregates) {
|
|
1109
1165
|
q = q.groupBy(`${table}.id`)
|
|
1110
1166
|
}
|
|
@@ -1124,22 +1180,35 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1124
1180
|
resolvedCustomFieldDefinitions,
|
|
1125
1181
|
} = await buildQuery('full')
|
|
1126
1182
|
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1183
|
+
// The count is built independently of the display query (the `'count'`
|
|
1184
|
+
// projection): scope + filters only, cf filters as correlated EXISTS
|
|
1185
|
+
// semi-joins, no projection joins. Nothing can multiply base rows, so
|
|
1186
|
+
// `count(*)` needs no DISTINCT (completing #2227) and — when the cap is
|
|
1187
|
+
// active — the LIMIT sits on a row-producing inner query with no
|
|
1188
|
+
// aggregate/sort barrier below it, so it actually bounds the scan.
|
|
1189
|
+
const countCap = resolveListCountCap()
|
|
1190
|
+
const { builder: countShape } = await buildQuery('count')
|
|
1191
|
+
let total: number
|
|
1192
|
+
let listCountCapWarning: ListCountCapWarning | undefined
|
|
1193
|
+
if (countCap !== null) {
|
|
1194
|
+
const probe = countShape.select(sql<number>`1`.as('one')).limit(countCap + 1)
|
|
1195
|
+
const countRow = await db
|
|
1196
|
+
.selectFrom(probe.as('om_count_probe') as any)
|
|
1197
|
+
.select(sql<string>`count(*)`.as('count'))
|
|
1198
|
+
.executeTakeFirst() as { count: unknown } | undefined
|
|
1199
|
+
const probed = Number((countRow as any)?.count ?? 0)
|
|
1200
|
+
if (probed > countCap) {
|
|
1201
|
+
total = countCap
|
|
1202
|
+
listCountCapWarning = { entity, cap: countCap }
|
|
1203
|
+
} else {
|
|
1204
|
+
total = probed
|
|
1205
|
+
}
|
|
1206
|
+
} else {
|
|
1207
|
+
const countRow = await countShape
|
|
1208
|
+
.select(sql<string>`count(*)`.as('count'))
|
|
1209
|
+
.executeTakeFirst() as { count: unknown } | undefined
|
|
1210
|
+
total = Number((countRow as any)?.count ?? 0)
|
|
1211
|
+
}
|
|
1143
1212
|
|
|
1144
1213
|
const svc = encryptionService
|
|
1145
1214
|
const decryptPayload =
|
|
@@ -1200,9 +1269,14 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1200
1269
|
const cap = resolveEncryptedSortMaxRows()
|
|
1201
1270
|
let qSort = (await buildQuery('sortKeys')).builder
|
|
1202
1271
|
if (cap !== null) {
|
|
1203
|
-
|
|
1272
|
+
// Probe one row past the cap: truncation is detected from the candidate
|
|
1273
|
+
// scan itself, not by comparing against `total` — which may itself be
|
|
1274
|
+
// capped (`OM_LIST_COUNT_CAP`) and would then never exceed the sort cap.
|
|
1275
|
+
qSort = qSort.limit(cap + 1).orderBy(qualify('id'), 'asc' as any)
|
|
1204
1276
|
}
|
|
1205
|
-
const
|
|
1277
|
+
const candidateRowsRaw = await qSort.execute() as ResultRow[]
|
|
1278
|
+
const sortTruncated = cap !== null && candidateRowsRaw.length > cap
|
|
1279
|
+
const candidateRows = sortTruncated && cap !== null ? candidateRowsRaw.slice(0, cap) : candidateRowsRaw
|
|
1206
1280
|
const decryptedCandidates = decryptPayload
|
|
1207
1281
|
? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow)
|
|
1208
1282
|
: candidateRows
|
|
@@ -1211,7 +1285,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1211
1285
|
.slice((page - 1) * pageSize, page * pageSize)
|
|
1212
1286
|
.map((row) => row.id)
|
|
1213
1287
|
|
|
1214
|
-
if (cap !== null
|
|
1288
|
+
if (sortTruncated && cap !== null) {
|
|
1215
1289
|
encryptedSortRowCapWarning = {
|
|
1216
1290
|
entity,
|
|
1217
1291
|
sortFields: resolvedSorts.map((s) => s.field),
|
|
@@ -1247,8 +1321,10 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1247
1321
|
|
|
1248
1322
|
let queryResult: QueryResult<T> = { items: pagedItems as unknown as T[], page, pageSize, total }
|
|
1249
1323
|
|
|
1250
|
-
if (encryptedSortRowCapWarning) {
|
|
1251
|
-
const meta: QueryResultMeta = {
|
|
1324
|
+
if (encryptedSortRowCapWarning || listCountCapWarning) {
|
|
1325
|
+
const meta: QueryResultMeta = {}
|
|
1326
|
+
if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning
|
|
1327
|
+
if (listCountCapWarning) meta.listCountCapWarning = listCountCapWarning
|
|
1252
1328
|
queryResult.meta = meta
|
|
1253
1329
|
}
|
|
1254
1330
|
|
|
@@ -1307,6 +1383,143 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1307
1383
|
}
|
|
1308
1384
|
}
|
|
1309
1385
|
|
|
1386
|
+
/**
|
|
1387
|
+
* Apply a `cf:*` filter as a correlated EXISTS semi-join over
|
|
1388
|
+
* `custom_field_values` (+ `custom_field_defs` for kind-based coercion) —
|
|
1389
|
+
* the count shape's equivalent of the projection path's leftJoin + WHERE.
|
|
1390
|
+
* A semi-join returns each base row at most once, so the count query needs
|
|
1391
|
+
* no DISTINCT or GROUP BY and stays boundable by an outer LIMIT.
|
|
1392
|
+
*
|
|
1393
|
+
* Predicates satisfied by the *absence* of a value row (`eq null`,
|
|
1394
|
+
* `exists: false`) become `NOT EXISTS(value) OR EXISTS(null value)`,
|
|
1395
|
+
* matching the leftJoin form where a missing row yields a NULL expression.
|
|
1396
|
+
*/
|
|
1397
|
+
private applyCfValueExistsFilter(
|
|
1398
|
+
q: AnyBuilder,
|
|
1399
|
+
opts: {
|
|
1400
|
+
source: ResolvedCustomFieldSource
|
|
1401
|
+
qualify: (column: string) => string
|
|
1402
|
+
tenantId: string | null
|
|
1403
|
+
key: string
|
|
1404
|
+
op: NormalizedFilter['op']
|
|
1405
|
+
value: unknown
|
|
1406
|
+
},
|
|
1407
|
+
): AnyBuilder {
|
|
1408
|
+
return q.where((eb: any) => this.buildCfValueExistsExpression(eb, opts))
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Expression-returning core of `applyCfValueExistsFilter`, so a cf leaf
|
|
1413
|
+
* inside an OR group can compile to an EXISTS predicate on the count shape
|
|
1414
|
+
* instead of being dropped for lacking a `cfValueExprByKey` entry.
|
|
1415
|
+
*/
|
|
1416
|
+
private buildCfValueExistsExpression(
|
|
1417
|
+
eb: any,
|
|
1418
|
+
opts: {
|
|
1419
|
+
source: ResolvedCustomFieldSource
|
|
1420
|
+
qualify: (column: string) => string
|
|
1421
|
+
tenantId: string | null
|
|
1422
|
+
key: string
|
|
1423
|
+
op: NormalizedFilter['op']
|
|
1424
|
+
value: unknown
|
|
1425
|
+
},
|
|
1426
|
+
): any {
|
|
1427
|
+
const { source, qualify, tenantId, key, op, value } = opts
|
|
1428
|
+
const seq = this.searchAliasSeq++
|
|
1429
|
+
const valAlias = `cfev_${seq}`
|
|
1430
|
+
const defAlias = `cfed_${seq}`
|
|
1431
|
+
const srcAlias = `cfes_${seq}`
|
|
1432
|
+
const caseExpr = sql<string | null>`CASE ${sql.ref(`${defAlias}.kind`)}
|
|
1433
|
+
WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
|
|
1434
|
+
WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
|
|
1435
|
+
WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
|
|
1436
|
+
WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
|
|
1437
|
+
ELSE (${sql.ref(`${valAlias}.value_text`)})::text
|
|
1438
|
+
END`
|
|
1439
|
+
|
|
1440
|
+
const buildSub = (eb: any): AnyBuilder => {
|
|
1441
|
+
let sub: AnyBuilder = eb
|
|
1442
|
+
.selectFrom(`custom_field_values as ${valAlias}`)
|
|
1443
|
+
.select(sql<number>`1`.as('one'))
|
|
1444
|
+
.leftJoin(`custom_field_defs as ${defAlias}`, (jb: any) =>
|
|
1445
|
+
jb.on(`${defAlias}.entity_id`, '=', String(source.entityId))
|
|
1446
|
+
.on(`${defAlias}.key`, '=', key)
|
|
1447
|
+
.on(`${defAlias}.is_active`, '=', true)
|
|
1448
|
+
.on((jeb: any) => jeb.or([
|
|
1449
|
+
jeb(`${defAlias}.tenant_id`, '=', tenantId),
|
|
1450
|
+
jeb(`${defAlias}.tenant_id`, 'is', null),
|
|
1451
|
+
])))
|
|
1452
|
+
.where(`${valAlias}.entity_id`, '=', String(source.entityId))
|
|
1453
|
+
.where(`${valAlias}.field_key`, '=', key)
|
|
1454
|
+
.where((web: any) => web.or([
|
|
1455
|
+
web(`${valAlias}.tenant_id`, '=', tenantId),
|
|
1456
|
+
web(`${valAlias}.tenant_id`, 'is', null),
|
|
1457
|
+
]))
|
|
1458
|
+
if (source.hop) {
|
|
1459
|
+
sub = sub
|
|
1460
|
+
.innerJoin(`${source.table} as ${srcAlias}`, (jb: any) =>
|
|
1461
|
+
jb.on(sql<boolean>`${sql.ref(`${valAlias}.record_id`)} = (${sql.ref(`${srcAlias}.${source.hop!.recordIdColumn}`)})::text`))
|
|
1462
|
+
.whereRef(`${srcAlias}.${source.hop.toField}`, '=', qualify(source.hop.fromField))
|
|
1463
|
+
} else {
|
|
1464
|
+
sub = sub.where(sql<boolean>`${sql.ref(`${valAlias}.record_id`)} = ${source.recordIdExpr}`)
|
|
1465
|
+
}
|
|
1466
|
+
return sub
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const absenceSatisfiable = (op === 'eq' && value === null) || (op === 'exists' && !value)
|
|
1470
|
+
if (absenceSatisfiable) {
|
|
1471
|
+
return eb.or([
|
|
1472
|
+
eb.not(eb.exists(buildSub(eb))),
|
|
1473
|
+
eb.exists(buildSub(eb).where(sql<boolean>`${caseExpr} is null`)),
|
|
1474
|
+
])
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
let predicate: RawBuilder<boolean> | null = null
|
|
1478
|
+
switch (op) {
|
|
1479
|
+
case 'eq':
|
|
1480
|
+
predicate = sql<boolean>`${caseExpr} = ${value}`
|
|
1481
|
+
break
|
|
1482
|
+
case 'ne':
|
|
1483
|
+
predicate = value === null
|
|
1484
|
+
? sql<boolean>`${caseExpr} is not null`
|
|
1485
|
+
: sql<boolean>`${caseExpr} != ${value}`
|
|
1486
|
+
break
|
|
1487
|
+
case 'gt':
|
|
1488
|
+
case 'gte':
|
|
1489
|
+
case 'lt':
|
|
1490
|
+
case 'lte': {
|
|
1491
|
+
const operator = sql.raw(op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<=')
|
|
1492
|
+
predicate = sql<boolean>`${caseExpr} ${operator} ${value}`
|
|
1493
|
+
break
|
|
1494
|
+
}
|
|
1495
|
+
case 'in': {
|
|
1496
|
+
const vals = Array.isArray(value) ? value : [value]
|
|
1497
|
+
predicate = sql<boolean>`${caseExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`
|
|
1498
|
+
break
|
|
1499
|
+
}
|
|
1500
|
+
case 'nin': {
|
|
1501
|
+
const vals = Array.isArray(value) ? value : [value]
|
|
1502
|
+
predicate = sql<boolean>`${caseExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`
|
|
1503
|
+
break
|
|
1504
|
+
}
|
|
1505
|
+
case 'like':
|
|
1506
|
+
predicate = sql<boolean>`${caseExpr} like ${value}`
|
|
1507
|
+
break
|
|
1508
|
+
case 'ilike':
|
|
1509
|
+
predicate = sql<boolean>`${caseExpr} ilike ${value}`
|
|
1510
|
+
break
|
|
1511
|
+
case 'exists':
|
|
1512
|
+
predicate = sql<boolean>`${caseExpr} is not null`
|
|
1513
|
+
break
|
|
1514
|
+
default:
|
|
1515
|
+
// Mirrors buildColumnOpExpression's unknown-op fallback: a neutral
|
|
1516
|
+
// predicate, so full and count shapes drop the same leaves.
|
|
1517
|
+
return eb.val(true)
|
|
1518
|
+
}
|
|
1519
|
+
const captured = predicate
|
|
1520
|
+
return eb.exists(buildSub(eb).where(captured))
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1310
1523
|
private buildColumnOpExpression(eb: any, column: string | RawBuilder<unknown>, op: string, value: unknown): any {
|
|
1311
1524
|
switch (op) {
|
|
1312
1525
|
case 'eq': return value === null ? eb(column, 'is', null) : eb(column, '=', value)
|
|
@@ -1554,6 +1767,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1554
1767
|
db: AnyDb,
|
|
1555
1768
|
opts: QueryOptions,
|
|
1556
1769
|
qualify: (column: string) => string,
|
|
1770
|
+
attachJoins: boolean = true,
|
|
1557
1771
|
): { builder: AnyBuilder; sources: ResolvedCustomFieldSource[] } {
|
|
1558
1772
|
const sources: ResolvedCustomFieldSource[] = [
|
|
1559
1773
|
{
|
|
@@ -1572,15 +1786,28 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1572
1786
|
if (!join) {
|
|
1573
1787
|
throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`)
|
|
1574
1788
|
}
|
|
1575
|
-
const
|
|
1576
|
-
|
|
1577
|
-
|
|
1789
|
+
const joinType: 'left' | 'inner' = (join.type ?? 'left') === 'inner' ? 'inner' : 'left'
|
|
1790
|
+
if (attachJoins) {
|
|
1791
|
+
const joinFn = joinType === 'inner' ? 'innerJoin' : 'leftJoin'
|
|
1792
|
+
next = (next as any)[joinFn](`${joinTable} as ${alias}`, (jb: any) =>
|
|
1793
|
+
jb.onRef(`${alias}.${join.toField}`, '=', qualify(join.fromField)))
|
|
1794
|
+
} else if (joinType === 'inner') {
|
|
1795
|
+
// The count projection carries no projection joins, but an inner-typed
|
|
1796
|
+
// source join restricts the result set — preserve that as a semi-join.
|
|
1797
|
+
next = next.where((eb: any) => eb.exists(
|
|
1798
|
+
eb
|
|
1799
|
+
.selectFrom(`${joinTable} as ${alias}`)
|
|
1800
|
+
.select(sql<number>`1`.as('one'))
|
|
1801
|
+
.whereRef(`${alias}.${join.toField}`, '=', qualify(join.fromField)),
|
|
1802
|
+
))
|
|
1803
|
+
}
|
|
1578
1804
|
const recordColumn = srcOpt.recordIdColumn ?? 'id'
|
|
1579
1805
|
sources.push({
|
|
1580
1806
|
entityId: srcOpt.entityId,
|
|
1581
1807
|
alias,
|
|
1582
1808
|
table: joinTable,
|
|
1583
1809
|
recordIdExpr: sql<string>`${sql.ref(`${alias}.${recordColumn}`)}::text`,
|
|
1810
|
+
hop: { fromField: join.fromField, toField: join.toField, recordIdColumn: recordColumn, type: joinType },
|
|
1584
1811
|
})
|
|
1585
1812
|
})
|
|
1586
1813
|
return { builder: next, sources }
|
package/src/lib/query/types.ts
CHANGED
|
@@ -151,12 +151,27 @@ export type EncryptedSortRowCapWarning = {
|
|
|
151
151
|
entity: EntityId
|
|
152
152
|
sortFields: string[]
|
|
153
153
|
maxRows: number
|
|
154
|
+
/**
|
|
155
|
+
* Exact when `meta.listCountCapWarning` is absent; a floor when it is
|
|
156
|
+
* present (the list total itself was bounded at the cap).
|
|
157
|
+
*/
|
|
154
158
|
totalMatched: number
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Present when the list COUNT was bounded at `cap` matching rows
|
|
163
|
+
* (`OM_LIST_COUNT_CAP`): `total` is a floor, not an exact value. Surfaced on
|
|
164
|
+
* CRUD list payloads as `totalIsCapped: true`.
|
|
165
|
+
*/
|
|
166
|
+
export type ListCountCapWarning = {
|
|
167
|
+
entity: EntityId
|
|
168
|
+
cap: number
|
|
169
|
+
}
|
|
170
|
+
|
|
157
171
|
export type QueryResultMeta = {
|
|
158
172
|
partialIndexWarning?: PartialIndexWarning
|
|
159
173
|
encryptedSortRowCapWarning?: EncryptedSortRowCapWarning
|
|
174
|
+
listCountCapWarning?: ListCountCapWarning
|
|
160
175
|
}
|
|
161
176
|
|
|
162
177
|
export type QueryResult<T = any> = {
|