@open-mercato/shared 0.9.1-develop.7474.1.51db10479d → 0.9.1-develop.7475.1.28ca93ed97
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/AGENTS.md +1 -1
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js +33 -0
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +7 -0
- package/dist/lib/catalog-visibility/hashEffectiveScope.js +9 -0
- package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +7 -0
- package/dist/lib/catalog-visibility/index.js +4 -0
- package/dist/lib/catalog-visibility/index.js.map +2 -2
- package/dist/lib/localization/index.js +9 -1
- package/dist/lib/localization/index.js.map +2 -2
- package/dist/lib/localization/translatable-fields.js +26 -0
- package/dist/lib/localization/translatable-fields.js.map +2 -2
- package/dist/lib/query/engine.js +29 -2
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/join-utils.js +1 -1
- package/dist/lib/query/join-utils.js.map +2 -2
- package/dist/lib/query/overlap.js +58 -0
- package/dist/lib/query/overlap.js.map +7 -0
- package/dist/lib/query/types.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/search.js.map +2 -2
- package/package.json +2 -2
- package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +121 -0
- package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +41 -0
- package/src/lib/catalog-visibility/hashEffectiveScope.ts +8 -0
- package/src/lib/catalog-visibility/index.ts +2 -0
- package/src/lib/localization/__tests__/translatable-fields.test.ts +17 -0
- package/src/lib/localization/index.ts +8 -1
- package/src/lib/localization/translatable-fields.ts +46 -0
- package/src/lib/query/__tests__/overlap.test.ts +219 -0
- package/src/lib/query/engine.ts +33 -2
- package/src/lib/query/join-utils.ts +1 -1
- package/src/lib/query/overlap.ts +106 -0
- package/src/lib/query/types.ts +13 -1
- package/src/modules/search.ts +39 -0
|
@@ -23,3 +23,49 @@ export function getTranslatableFields(entityType: string): string[] | undefined
|
|
|
23
23
|
export function getTranslatableFieldsRegistry(): TranslatableFieldsRegistry {
|
|
24
24
|
return { ...getGlobal() }
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
export type TranslatableFieldExpansion = {
|
|
28
|
+
key: string
|
|
29
|
+
label?: string
|
|
30
|
+
baseValue?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type TranslatableFieldExpander = (record: Record<string, unknown>) => TranslatableFieldExpansion[]
|
|
34
|
+
|
|
35
|
+
type TranslatableFieldExpanderRegistry = Record<string, TranslatableFieldExpander>
|
|
36
|
+
|
|
37
|
+
const EXPANDER_GLOBAL_KEY = '__openMercatoTranslatableFieldExpanders__'
|
|
38
|
+
|
|
39
|
+
type ExpanderGlobalHost = { [EXPANDER_GLOBAL_KEY]?: TranslatableFieldExpanderRegistry }
|
|
40
|
+
|
|
41
|
+
function getExpanderGlobal(): TranslatableFieldExpanderRegistry {
|
|
42
|
+
return (globalThis as ExpanderGlobalHost)[EXPANDER_GLOBAL_KEY] ?? {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function registerTranslatableFieldExpander(entityType: string, expander: TranslatableFieldExpander): void {
|
|
46
|
+
const host = globalThis as ExpanderGlobalHost
|
|
47
|
+
host[EXPANDER_GLOBAL_KEY] = { ...getExpanderGlobal(), [entityType]: expander }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getTranslatableFieldExpander(entityType: string): TranslatableFieldExpander | undefined {
|
|
51
|
+
return getExpanderGlobal()[entityType]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type TranslatableEntityListPathRegistry = Record<string, string>
|
|
55
|
+
|
|
56
|
+
const LIST_PATH_GLOBAL_KEY = '__openMercatoTranslatableEntityListPaths__'
|
|
57
|
+
|
|
58
|
+
type ListPathGlobalHost = { [LIST_PATH_GLOBAL_KEY]?: TranslatableEntityListPathRegistry }
|
|
59
|
+
|
|
60
|
+
function getListPathGlobal(): TranslatableEntityListPathRegistry {
|
|
61
|
+
return (globalThis as ListPathGlobalHost)[LIST_PATH_GLOBAL_KEY] ?? {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function registerTranslatableEntityListPath(entityType: string, listPath: string): void {
|
|
65
|
+
const host = globalThis as ListPathGlobalHost
|
|
66
|
+
host[LIST_PATH_GLOBAL_KEY] = { ...getListPathGlobal(), [entityType]: listPath }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getTranslatableEntityListPath(entityType: string): string | undefined {
|
|
70
|
+
return getListPathGlobal()[entityType]
|
|
71
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import {
|
|
3
|
+
DummyDriver,
|
|
4
|
+
Kysely,
|
|
5
|
+
PostgresAdapter,
|
|
6
|
+
PostgresIntrospector,
|
|
7
|
+
PostgresQueryCompiler,
|
|
8
|
+
sql,
|
|
9
|
+
type RawBuilder,
|
|
10
|
+
} from 'kysely'
|
|
11
|
+
import { BasicQueryEngine } from '../engine'
|
|
12
|
+
import { normalizeFilters } from '../join-utils'
|
|
13
|
+
import {
|
|
14
|
+
buildArrayColumnNoOverlapPredicate,
|
|
15
|
+
buildArrayColumnOverlapPredicate,
|
|
16
|
+
buildJsonbNoOverlapPredicate,
|
|
17
|
+
buildJsonbOverlapPredicate,
|
|
18
|
+
buildScalarOverlapPredicate,
|
|
19
|
+
jsonbDocKeyExpression,
|
|
20
|
+
normalizeOverlapValues,
|
|
21
|
+
} from '../overlap'
|
|
22
|
+
import type { FilterOp } from '../types'
|
|
23
|
+
|
|
24
|
+
function makeCompilingDb(): Kysely<any> {
|
|
25
|
+
return new Kysely<any>({
|
|
26
|
+
dialect: {
|
|
27
|
+
createAdapter: () => new PostgresAdapter(),
|
|
28
|
+
createDriver: () => new DummyDriver(),
|
|
29
|
+
createIntrospector: (db) => new PostgresIntrospector(db),
|
|
30
|
+
createQueryCompiler: () => new PostgresQueryCompiler(),
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const db = makeCompilingDb()
|
|
36
|
+
|
|
37
|
+
function compileWhere(predicate: RawBuilder<boolean>): { sql: string; parameters: readonly unknown[] } {
|
|
38
|
+
const compiled = db.selectFrom('entity_indexes as ei').select('ei.id').where(predicate).compile()
|
|
39
|
+
return { sql: compiled.sql, parameters: compiled.parameters }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type EngineInternals = {
|
|
43
|
+
applyColumnOp: (builder: unknown, column: string, op: string, value: unknown) => { compile: () => { sql: string; parameters: readonly unknown[] } }
|
|
44
|
+
buildColumnOpExpression: (eb: unknown, column: string, op: string, value: unknown) => RawBuilder<boolean>
|
|
45
|
+
buildIndexDocOpExpression: (eb: unknown, opts: {
|
|
46
|
+
entity: string
|
|
47
|
+
field: string
|
|
48
|
+
op: FilterOp
|
|
49
|
+
value: unknown
|
|
50
|
+
recordIdColumn: string
|
|
51
|
+
tenantId?: string | null
|
|
52
|
+
withDeleted: boolean
|
|
53
|
+
}) => RawBuilder<boolean>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const engine = new BasicQueryEngine({} as EntityManager, () => db) as unknown as EngineInternals
|
|
57
|
+
|
|
58
|
+
describe('normalizeOverlapValues', () => {
|
|
59
|
+
it('flattens, stringifies, drops nullish and de-duplicates', () => {
|
|
60
|
+
expect(normalizeOverlapValues(['a', 'b', 'a', null, undefined, 3])).toEqual(['a', 'b', '3'])
|
|
61
|
+
expect(normalizeOverlapValues('a')).toEqual(['a'])
|
|
62
|
+
expect(normalizeOverlapValues(undefined)).toEqual([])
|
|
63
|
+
expect(normalizeOverlapValues(null)).toEqual([])
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('overlap predicate builders', () => {
|
|
68
|
+
it('compiles a jsonb has-any-of with the values bound as one array parameter', () => {
|
|
69
|
+
const compiled = compileWhere(buildJsonbOverlapPredicate('ei.doc', 'scope_keys', ['cat:1', 'tag:2']))
|
|
70
|
+
expect(compiled.sql).toContain(`("ei"."doc" -> 'scope_keys') ?| $1::text[]`)
|
|
71
|
+
expect(compiled.parameters).toEqual([['cat:1', 'tag:2']])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('never interpolates values into the SQL text', () => {
|
|
75
|
+
const hostile = "x'); drop table entity_indexes; --"
|
|
76
|
+
const compiled = compileWhere(buildJsonbOverlapPredicate('ei.doc', 'scope_keys', [hostile]))
|
|
77
|
+
expect(compiled.sql).not.toContain('drop table')
|
|
78
|
+
expect(compiled.parameters).toEqual([[hostile]])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('binds a key that is not a plain identifier instead of inlining it', () => {
|
|
82
|
+
const key = "scope'keys"
|
|
83
|
+
const expression = db.selectFrom('entity_indexes as ei').select(jsonbDocKeyExpression('ei.doc', key).as('value')).compile()
|
|
84
|
+
expect(expression.sql).toContain('("ei"."doc" -> $1)')
|
|
85
|
+
expect(expression.parameters).toEqual([key])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it.each([
|
|
89
|
+
['jsonb', () => buildJsonbOverlapPredicate('ei.doc', 'scope_keys', [])],
|
|
90
|
+
['array column', () => buildArrayColumnOverlapPredicate('ei.tags', [])],
|
|
91
|
+
['scalar', () => buildScalarOverlapPredicate(sql`ei.value`, [null, undefined])],
|
|
92
|
+
])('compiles an empty %s value list to false (matches nothing)', (_label, build) => {
|
|
93
|
+
const compiled = compileWhere(build())
|
|
94
|
+
expect(compiled.sql).toMatch(/where false$/)
|
|
95
|
+
expect(compiled.parameters).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('compiles array-column overlap to && with a bound array', () => {
|
|
99
|
+
const compiled = compileWhere(buildArrayColumnOverlapPredicate('ei.tags', ['a', 'b']))
|
|
100
|
+
expect(compiled.sql).toContain('"ei"."tags" && $1')
|
|
101
|
+
expect(compiled.parameters).toEqual([['a', 'b']])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('compiles scalar overlap to an IN list of bound values', () => {
|
|
105
|
+
const compiled = compileWhere(buildScalarOverlapPredicate(sql`ei.value`, ['a', 'b']))
|
|
106
|
+
expect(compiled.sql).toContain('ei.value in ($1, $2)')
|
|
107
|
+
expect(compiled.parameters).toEqual(['a', 'b'])
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('BasicQueryEngine overlap', () => {
|
|
112
|
+
it('normalizes $overlap from the object filter syntax', () => {
|
|
113
|
+
const filters = normalizeFilters({ scope_keys: { $overlap: ['cat:1'] } })
|
|
114
|
+
expect(filters).toEqual([expect.objectContaining({ field: 'scope_keys', op: 'overlap', value: ['cat:1'] })])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('applies && on a base column', () => {
|
|
118
|
+
const builder = db.selectFrom('catalog_products as b').select('b.id')
|
|
119
|
+
const compiled = engine.applyColumnOp(builder, 'b.tags', 'overlap', ['x', 'y']).compile()
|
|
120
|
+
expect(compiled.sql).toContain('"b"."tags" && $1')
|
|
121
|
+
expect(compiled.parameters).toEqual([['x', 'y']])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('compiles an index-document overlap inside the entity_indexes EXISTS', () => {
|
|
125
|
+
const compiled = db
|
|
126
|
+
.selectFrom('catalog_products as b')
|
|
127
|
+
.select('b.id')
|
|
128
|
+
.where((eb) => engine.buildIndexDocOpExpression(eb, {
|
|
129
|
+
entity: 'catalog:catalog_product',
|
|
130
|
+
field: 'scope_keys',
|
|
131
|
+
op: 'overlap',
|
|
132
|
+
value: ['cat:1'],
|
|
133
|
+
recordIdColumn: 'b.id',
|
|
134
|
+
tenantId: 'tenant-1',
|
|
135
|
+
withDeleted: false,
|
|
136
|
+
}))
|
|
137
|
+
.compile()
|
|
138
|
+
expect(compiled.sql).toMatch(/exists \(select 1 as "one" from "entity_indexes" as "ei_\d+"/)
|
|
139
|
+
expect(compiled.sql).toMatch(/\("ei_\d+"\."doc" -> 'scope_keys'\) \?\| \$\d+::text\[\]/)
|
|
140
|
+
expect(compiled.parameters).toContainEqual(['cat:1'])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('leaves existing operators unchanged', () => {
|
|
144
|
+
const compiled = db
|
|
145
|
+
.selectFrom('catalog_products as b')
|
|
146
|
+
.select('b.id')
|
|
147
|
+
.where((eb) => eb.and([
|
|
148
|
+
engine.buildColumnOpExpression(eb, 'b.sku', 'in', ['a', 'b']),
|
|
149
|
+
engine.buildColumnOpExpression(eb, 'b.title', 'ilike', '%x%'),
|
|
150
|
+
engine.buildColumnOpExpression(eb, 'b.deleted_at', 'exists', false),
|
|
151
|
+
]))
|
|
152
|
+
.compile()
|
|
153
|
+
expect(compiled.sql).toContain('"b"."sku" in ($1, $2)')
|
|
154
|
+
expect(compiled.sql).toContain('"b"."title" ilike $3')
|
|
155
|
+
expect(compiled.sql).toContain('"b"."deleted_at" is null')
|
|
156
|
+
expect(compiled.sql).not.toContain('&&')
|
|
157
|
+
expect(compiled.sql).not.toContain('?|')
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('noverlap predicate builders', () => {
|
|
162
|
+
it('compiles a jsonb has-none-of that requires a present array', () => {
|
|
163
|
+
const compiled = compileWhere(buildJsonbNoOverlapPredicate('ei.doc', 'scope_keys', ['cat:1', 'tag:2']))
|
|
164
|
+
expect(compiled.sql).toContain(
|
|
165
|
+
`(jsonb_typeof(("ei"."doc" -> 'scope_keys')) = 'array' and not (("ei"."doc" -> 'scope_keys') ?| $1::text[]))`,
|
|
166
|
+
)
|
|
167
|
+
expect(compiled.parameters).toEqual([['cat:1', 'tag:2']])
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('keeps only the array check for an empty jsonb value list', () => {
|
|
171
|
+
const compiled = compileWhere(buildJsonbNoOverlapPredicate('ei.doc', 'scope_keys', []))
|
|
172
|
+
expect(compiled.sql).toMatch(/where jsonb_typeof\(\("ei"\."doc" -> 'scope_keys'\)\) = 'array'$/)
|
|
173
|
+
expect(compiled.parameters).toEqual([])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('compiles array-column has-none-of to not && and an empty list to is not null', () => {
|
|
177
|
+
const compiled = compileWhere(buildArrayColumnNoOverlapPredicate('ei.tags', ['a']))
|
|
178
|
+
expect(compiled.sql).toContain('not ("ei"."tags" && $1)')
|
|
179
|
+
expect(compiled.parameters).toEqual([['a']])
|
|
180
|
+
expect(compileWhere(buildArrayColumnNoOverlapPredicate('ei.tags', [])).sql).toContain('"ei"."tags" is not null')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('BasicQueryEngine noverlap', () => {
|
|
185
|
+
it('normalizes $noverlap from the object filter syntax', () => {
|
|
186
|
+
const filters = normalizeFilters({ scope_keys: { $noverlap: ['cat:1'] } })
|
|
187
|
+
expect(filters).toEqual([expect.objectContaining({ field: 'scope_keys', op: 'noverlap', value: ['cat:1'] })])
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('compiles an index-document noverlap inside the entity_indexes EXISTS', () => {
|
|
191
|
+
const compiled = db
|
|
192
|
+
.selectFrom('catalog_products as b')
|
|
193
|
+
.select('b.id')
|
|
194
|
+
.where((eb) => engine.buildIndexDocOpExpression(eb, {
|
|
195
|
+
entity: 'catalog:catalog_product',
|
|
196
|
+
field: 'scope_keys',
|
|
197
|
+
op: 'noverlap',
|
|
198
|
+
value: ['cat:1'],
|
|
199
|
+
recordIdColumn: 'b.id',
|
|
200
|
+
tenantId: 'tenant-1',
|
|
201
|
+
withDeleted: false,
|
|
202
|
+
}))
|
|
203
|
+
.compile()
|
|
204
|
+
expect(compiled.sql).toMatch(/exists \(select 1 as "one" from "entity_indexes" as "ei_\d+"/)
|
|
205
|
+
expect(compiled.sql).toMatch(/not \(\("ei_\d+"\."doc" -> 'scope_keys'\) \?\| \$\d+::text\[\]\)/)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('applies not && on a base column', () => {
|
|
209
|
+
const builder = db.selectFrom('catalog_products as b').select('b.id')
|
|
210
|
+
const compiled = engine.applyColumnOp(builder, 'b.tags', 'noverlap', ['x']).compile()
|
|
211
|
+
expect(compiled.sql).toContain('not ("b"."tags" && $1)')
|
|
212
|
+
const expression = db
|
|
213
|
+
.selectFrom('catalog_products as b')
|
|
214
|
+
.select('b.id')
|
|
215
|
+
.where((eb) => engine.buildColumnOpExpression(eb, 'b.tags', 'noverlap', ['x']))
|
|
216
|
+
.compile()
|
|
217
|
+
expect(expression.sql).toContain('not ("b"."tags" && $1)')
|
|
218
|
+
})
|
|
219
|
+
})
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -38,6 +38,13 @@ import { resolveListCountCap } from './count-cap'
|
|
|
38
38
|
import { mapWithConcurrency } from './bounded-decrypt'
|
|
39
39
|
import { parseNumberWithDefault } from '../number'
|
|
40
40
|
import { createLogger } from '../logger'
|
|
41
|
+
import {
|
|
42
|
+
buildArrayColumnNoOverlapPredicate,
|
|
43
|
+
buildArrayColumnOverlapPredicate,
|
|
44
|
+
buildJsonbNoOverlapPredicate,
|
|
45
|
+
buildJsonbOverlapPredicate,
|
|
46
|
+
buildScalarOverlapPredicate,
|
|
47
|
+
} from './overlap'
|
|
41
48
|
|
|
42
49
|
const logger = createLogger('shared').child({ component: 'query' })
|
|
43
50
|
|
|
@@ -1216,7 +1223,11 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1216
1223
|
})
|
|
1217
1224
|
continue
|
|
1218
1225
|
}
|
|
1219
|
-
q =
|
|
1226
|
+
q = f.op === 'overlap'
|
|
1227
|
+
? q.where(buildScalarOverlapPredicate(expr, f.value))
|
|
1228
|
+
: f.op === 'noverlap'
|
|
1229
|
+
? q.where(sql<boolean>`false`)
|
|
1230
|
+
: this.applyColumnOp(q, expr, f.op, f.value)
|
|
1220
1231
|
}
|
|
1221
1232
|
|
|
1222
1233
|
// OR groups are applied here, after the cf:* value expressions exist, so a
|
|
@@ -1263,7 +1274,10 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1263
1274
|
value: rf.value,
|
|
1264
1275
|
})
|
|
1265
1276
|
}
|
|
1266
|
-
|
|
1277
|
+
if (rf.op === 'noverlap') return sql<boolean>`false`
|
|
1278
|
+
return rf.op === 'overlap'
|
|
1279
|
+
? buildScalarOverlapPredicate(cfValueExprByKey[rf.key], rf.value)
|
|
1280
|
+
: this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value)
|
|
1267
1281
|
}
|
|
1268
1282
|
return this.buildIndexDocOpExpression(eb, {
|
|
1269
1283
|
entity: String(entity),
|
|
@@ -1601,6 +1615,10 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1601
1615
|
return value
|
|
1602
1616
|
? builder.where(column as any, 'is not', null)
|
|
1603
1617
|
: builder.where(column as any, 'is', null)
|
|
1618
|
+
case 'overlap':
|
|
1619
|
+
return builder.where(buildArrayColumnOverlapPredicate(column, value))
|
|
1620
|
+
case 'noverlap':
|
|
1621
|
+
return builder.where(buildArrayColumnNoOverlapPredicate(column, value))
|
|
1604
1622
|
default:
|
|
1605
1623
|
return builder
|
|
1606
1624
|
}
|
|
@@ -1734,6 +1752,11 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1734
1752
|
case 'exists':
|
|
1735
1753
|
predicate = sql<boolean>`${caseExpr} is not null`
|
|
1736
1754
|
break
|
|
1755
|
+
case 'overlap':
|
|
1756
|
+
predicate = buildScalarOverlapPredicate(caseExpr, value)
|
|
1757
|
+
break
|
|
1758
|
+
case 'noverlap':
|
|
1759
|
+
return sql<boolean>`false`
|
|
1737
1760
|
default:
|
|
1738
1761
|
// Mirrors buildColumnOpExpression's unknown-op fallback: a neutral
|
|
1739
1762
|
// predicate, so full and count shapes drop the same leaves.
|
|
@@ -1756,6 +1779,8 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1756
1779
|
case 'like': return eb(column, 'like', value)
|
|
1757
1780
|
case 'ilike': return eb(column, 'ilike', value)
|
|
1758
1781
|
case 'exists': return value ? eb(column, 'is not', null) : eb(column, 'is', null)
|
|
1782
|
+
case 'overlap': return buildArrayColumnOverlapPredicate(column, value)
|
|
1783
|
+
case 'noverlap': return buildArrayColumnNoOverlapPredicate(column, value)
|
|
1759
1784
|
default: return eb.val(true)
|
|
1760
1785
|
}
|
|
1761
1786
|
}
|
|
@@ -1973,6 +1998,12 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1973
1998
|
? sub.where(sql<boolean>`${textExpr} is not null`)
|
|
1974
1999
|
: sub.where(sql<boolean>`${textExpr} is null`)
|
|
1975
2000
|
break
|
|
2001
|
+
case 'overlap':
|
|
2002
|
+
sub = sub.where(buildJsonbOverlapPredicate(`${alias}.doc`, opts.field, opts.value))
|
|
2003
|
+
break
|
|
2004
|
+
case 'noverlap':
|
|
2005
|
+
sub = sub.where(buildJsonbNoOverlapPredicate(`${alias}.doc`, opts.field, opts.value))
|
|
2006
|
+
break
|
|
1976
2007
|
default:
|
|
1977
2008
|
break
|
|
1978
2009
|
}
|
|
@@ -7,7 +7,7 @@ type AnyBuilder = any
|
|
|
7
7
|
|
|
8
8
|
export type NormalizedFilter = { field: string; op: FilterOp; value?: unknown; orGroup?: string; qualified?: string | null }
|
|
9
9
|
|
|
10
|
-
const VALID_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists'] as const
|
|
10
|
+
const VALID_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists', 'overlap', 'noverlap'] as const
|
|
11
11
|
|
|
12
12
|
const normalizeField = (key: string) => (key.startsWith('cf_') ? `cf:${key.slice(3)}` : key)
|
|
13
13
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { sql, type RawBuilder } from 'kysely'
|
|
2
|
+
|
|
3
|
+
const LITERAL_SAFE_DOC_KEY = /^[A-Za-z0-9_:.-]{1,128}$/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes the right-hand side of an `overlap` filter into a de-duplicated list of strings.
|
|
7
|
+
*
|
|
8
|
+
* `null`/`undefined` entries are dropped; a scalar is treated as a one-element list. An empty
|
|
9
|
+
* result means the filter matches nothing — every `overlap` builder compiles it to `false`
|
|
10
|
+
* rather than dropping the predicate, so a caller that computes an empty inclusion set can
|
|
11
|
+
* never widen a query by accident.
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeOverlapValues(value: unknown): string[] {
|
|
14
|
+
const list: readonly unknown[] = Array.isArray(value)
|
|
15
|
+
? value
|
|
16
|
+
: value === undefined || value === null
|
|
17
|
+
? []
|
|
18
|
+
: [value]
|
|
19
|
+
const out: string[] = []
|
|
20
|
+
const seen = new Set<string>()
|
|
21
|
+
for (const entry of list) {
|
|
22
|
+
if (entry === undefined || entry === null) continue
|
|
23
|
+
const normalized = String(entry)
|
|
24
|
+
if (seen.has(normalized)) continue
|
|
25
|
+
seen.add(normalized)
|
|
26
|
+
out.push(normalized)
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `(<docColumn> -> '<key>')` — the jsonb sub-document of an index document.
|
|
33
|
+
*
|
|
34
|
+
* The key is emitted as an escaped SQL literal when it is a plain identifier so the expression is
|
|
35
|
+
* textually identical to a GIN expression index such as `((doc -> 'scope_keys'))` and the planner
|
|
36
|
+
* can match it. Any other key falls back to a bound parameter (still correct, just not indexable).
|
|
37
|
+
*/
|
|
38
|
+
export function jsonbDocKeyExpression(docColumnRef: string, key: string): RawBuilder<unknown> {
|
|
39
|
+
const keyExpr = LITERAL_SAFE_DOC_KEY.test(key) ? sql.lit(key) : sql`${key}`
|
|
40
|
+
return sql`(${sql.ref(docColumnRef)} -> ${keyExpr})`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* jsonb "has any of" over a string-array key of an index document:
|
|
45
|
+
* `(<docColumn> -> '<key>') ?| $n::text[]`. The values are bound as one array parameter.
|
|
46
|
+
* A missing key or a JSON `null` evaluates to SQL NULL, i.e. the row does not match.
|
|
47
|
+
*/
|
|
48
|
+
export function buildJsonbOverlapPredicate(docColumnRef: string, key: string, value: unknown): RawBuilder<boolean> {
|
|
49
|
+
const values = normalizeOverlapValues(value)
|
|
50
|
+
if (!values.length) return sql<boolean>`false`
|
|
51
|
+
return sql<boolean>`${jsonbDocKeyExpression(docColumnRef, key)} ?| ${values}::text[]`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* jsonb "has none of" over a string-array key of an index document (`noverlap`):
|
|
56
|
+
* `(jsonb_typeof(<expr>) = 'array' and not (<expr> ?| $n::text[]))`.
|
|
57
|
+
*
|
|
58
|
+
* Only a document whose key holds a JSON array can match: a missing key, a JSON `null` or a
|
|
59
|
+
* scalar never satisfies it, so an unindexed document can never slip through an exclusion.
|
|
60
|
+
* An empty value list excludes nothing (only the array check remains).
|
|
61
|
+
*/
|
|
62
|
+
export function buildJsonbNoOverlapPredicate(docColumnRef: string, key: string, value: unknown): RawBuilder<boolean> {
|
|
63
|
+
const values = normalizeOverlapValues(value)
|
|
64
|
+
const keyExpr = jsonbDocKeyExpression(docColumnRef, key)
|
|
65
|
+
const isArray = sql<boolean>`jsonb_typeof(${keyExpr}) = 'array'`
|
|
66
|
+
if (!values.length) return isArray
|
|
67
|
+
return sql<boolean>`(${isArray} and not (${keyExpr} ?| ${values}::text[]))`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Postgres array overlap for array-typed columns: `<column> && $n`. The bound array takes its
|
|
72
|
+
* element type from the column, so `text[]`, `varchar[]` and `uuid[]` columns all work.
|
|
73
|
+
*/
|
|
74
|
+
export function buildArrayColumnOverlapPredicate(
|
|
75
|
+
column: string | RawBuilder<unknown>,
|
|
76
|
+
value: unknown,
|
|
77
|
+
): RawBuilder<boolean> {
|
|
78
|
+
const values = normalizeOverlapValues(value)
|
|
79
|
+
if (!values.length) return sql<boolean>`false`
|
|
80
|
+
const columnExpr = typeof column === 'string' ? sql.ref(column) : column
|
|
81
|
+
return sql<boolean>`${columnExpr} && ${values}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Postgres array "has none of" for array-typed columns (`noverlap`): `not (<column> && $n)`.
|
|
86
|
+
* A NULL column never matches; an empty value list excludes nothing (`<column> is not null`).
|
|
87
|
+
*/
|
|
88
|
+
export function buildArrayColumnNoOverlapPredicate(
|
|
89
|
+
column: string | RawBuilder<unknown>,
|
|
90
|
+
value: unknown,
|
|
91
|
+
): RawBuilder<boolean> {
|
|
92
|
+
const values = normalizeOverlapValues(value)
|
|
93
|
+
const columnExpr = typeof column === 'string' ? sql.ref(column) : column
|
|
94
|
+
if (!values.length) return sql<boolean>`${columnExpr} is not null`
|
|
95
|
+
return sql<boolean>`not (${columnExpr} && ${values})`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `overlap` over a scalar expression evaluated once per stored value (custom-field value rows):
|
|
100
|
+
* the record matches when any of its values is one of the requested ones — `<expr> in (...)`.
|
|
101
|
+
*/
|
|
102
|
+
export function buildScalarOverlapPredicate(expression: RawBuilder<unknown>, value: unknown): RawBuilder<boolean> {
|
|
103
|
+
const values = normalizeOverlapValues(value)
|
|
104
|
+
if (!values.length) return sql<boolean>`false`
|
|
105
|
+
return sql<boolean>`${expression} in (${sql.join(values.map((entry) => sql`${entry}`), sql`, `)})`
|
|
106
|
+
}
|
package/src/lib/query/types.ts
CHANGED
|
@@ -2,7 +2,17 @@ import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
|
2
2
|
import type { Profiler } from '../profiler'
|
|
3
3
|
import type { ResolvedCustomFieldDefinitions } from '../crud/custom-field-definition-index'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `overlap` (additive, 2026-10-05): "has any of" over a multi-valued field. An index-document
|
|
7
|
+
* string array compiles to jsonb `?|`, a `cf:*` key to any-value-in-set, and an array-typed base
|
|
8
|
+
* column to `&&`. An empty value list matches nothing.
|
|
9
|
+
*
|
|
10
|
+
* `noverlap` (additive, 2026-10-05): "has none of" — the negation of `overlap` on an index-document
|
|
11
|
+
* string array or an array-typed base column. Only a present array can match (a missing/null key or
|
|
12
|
+
* NULL column never does); an empty value list excludes nothing. Unsupported on `cf:*` keys, where
|
|
13
|
+
* it compiles to `false`.
|
|
14
|
+
*/
|
|
15
|
+
export type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'ilike' | 'exists' | 'overlap' | 'noverlap'
|
|
6
16
|
|
|
7
17
|
export enum SortDir {
|
|
8
18
|
Asc = 'asc',
|
|
@@ -34,6 +44,8 @@ export type WhereOps<T> = {
|
|
|
34
44
|
$like?: T extends string ? string : never
|
|
35
45
|
$ilike?: T extends string ? string : never
|
|
36
46
|
$exists?: boolean
|
|
47
|
+
$overlap?: T extends readonly (infer Element)[] ? Element[] : T[]
|
|
48
|
+
$noverlap?: T extends readonly (infer Element)[] ? Element[] : T[]
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
// A field filter can be a direct value (equals) or ops object
|
package/src/modules/search.ts
CHANGED
|
@@ -94,6 +94,39 @@ export type SearchOptions = {
|
|
|
94
94
|
offset?: number
|
|
95
95
|
/** How to combine results: 'or' merges all, 'and' requires match in all strategies */
|
|
96
96
|
combineMode?: 'or' | 'and'
|
|
97
|
+
/**
|
|
98
|
+
* Restricts results to records whose query-index row (`entity_indexes`) matches this predicate,
|
|
99
|
+
* evaluated inside the strategy's ranking query so a restrictive filter never starves the result
|
|
100
|
+
* set (additive, 2026-10-06). Only strategies with `supportsIndexDocFilter: true` receive a
|
|
101
|
+
* filtered search; any other strategy returns no results for it rather than unfiltered ones.
|
|
102
|
+
*/
|
|
103
|
+
indexDocFilter?: SearchIndexDocFilter
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* One condition over a record's query-index document (`entity_indexes.doc`), with the query
|
|
108
|
+
* engine's index-document semantics:
|
|
109
|
+
* - `exists`: the key is present and not JSON `null`;
|
|
110
|
+
* - `eq`: the key's text value equals `value` (booleans and numbers compare by their JSON text);
|
|
111
|
+
* - `overlap`: the key holds an array sharing at least one entry with `values`; empty `values`
|
|
112
|
+
* matches nothing;
|
|
113
|
+
* - `noverlap`: the key holds an array sharing no entry with `values`; a missing key never matches;
|
|
114
|
+
* - `recordIdNotIn`: the record id is none of `values`.
|
|
115
|
+
*/
|
|
116
|
+
export type SearchIndexDocCondition =
|
|
117
|
+
| { op: 'exists'; key: string }
|
|
118
|
+
| { op: 'eq'; key: string; value: string | number | boolean }
|
|
119
|
+
| { op: 'overlap'; key: string; values: string[] }
|
|
120
|
+
| { op: 'noverlap'; key: string; values: string[] }
|
|
121
|
+
| { op: 'recordIdNotIn'; values: string[] }
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A predicate over a record's `entity_indexes` row in disjunctive normal form: the record matches
|
|
125
|
+
* when its index row is not deleted and every condition of at least one `anyOf` branch holds.
|
|
126
|
+
* An empty `anyOf` matches nothing; an empty branch matches every indexed record.
|
|
127
|
+
*/
|
|
128
|
+
export type SearchIndexDocFilter = {
|
|
129
|
+
anyOf: SearchIndexDocCondition[][]
|
|
97
130
|
}
|
|
98
131
|
|
|
99
132
|
// =============================================================================
|
|
@@ -144,6 +177,12 @@ export interface SearchStrategy {
|
|
|
144
177
|
/** Priority for result merging (higher = more prominent in results) */
|
|
145
178
|
readonly priority: number
|
|
146
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Whether `search()` applies `SearchOptions.indexDocFilter` inside its ranking query. Absent or
|
|
182
|
+
* `false` means the strategy is skipped for filtered searches (fail closed).
|
|
183
|
+
*/
|
|
184
|
+
readonly supportsIndexDocFilter?: boolean
|
|
185
|
+
|
|
147
186
|
/** Check if strategy is available and configured */
|
|
148
187
|
isAvailable(): Promise<boolean>
|
|
149
188
|
|