@open-mercato/shared 0.10.1-develop.7576.1.21d5b50435 → 0.10.1-develop.7577.1.477f2486e5
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 +4 -4
- package/dist/lib/auth/server.js +1 -1
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/dynamicLoader.js +46 -6
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +4 -3
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js +6 -0
- package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js.map +7 -0
- package/dist/lib/bootstrap/next-subpath-resolve-hook.js +62 -0
- package/dist/lib/bootstrap/next-subpath-resolve-hook.js.map +7 -0
- 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/commands/command-bus.js +45 -13
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/crud/exporters.js +1 -1
- package/dist/lib/crud/exporters.js.map +2 -2
- package/dist/lib/crud/ids.js +13 -1
- package/dist/lib/crud/ids.js.map +2 -2
- package/dist/lib/db/advisoryLock.js +172 -0
- package/dist/lib/db/advisoryLock.js.map +7 -0
- package/dist/lib/db/pg-errors.js +14 -0
- package/dist/lib/db/pg-errors.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/number.js +6 -0
- package/dist/lib/number.js.map +2 -2
- package/dist/lib/openapi/generator.js +84 -67
- package/dist/lib/openapi/generator.js.map +2 -2
- package/dist/lib/query/engine.js +45 -10
- 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/url.js +24 -0
- package/dist/lib/url.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +7 -3
- package/dist/lib/webhooks/body.js.map +2 -2
- package/dist/lib/webhooks/index.js +2 -0
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/modules/integrations/types.js.map +2 -2
- package/dist/modules/notifications/safeHref.js +20 -0
- package/dist/modules/notifications/safeHref.js.map +7 -0
- package/dist/modules/notifications/types.js +4 -0
- package/dist/modules/notifications/types.js.map +3 -3
- package/dist/modules/payment_gateways/types.js +14 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/search.js.map +2 -2
- package/dist/modules/widgets/injection-loader.js +14 -3
- package/dist/modules/widgets/injection-loader.js.map +2 -2
- package/package.json +9 -2
- package/src/lib/__tests__/number.test.ts +24 -1
- package/src/lib/__tests__/url.cross-site.test.ts +140 -0
- package/src/lib/auth/__tests__/server.test.ts +14 -0
- package/src/lib/auth/server.ts +1 -1
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +141 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/factory.test.ts +18 -6
- package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +174 -0
- package/src/lib/bootstrap/dynamicLoader.ts +63 -5
- package/src/lib/bootstrap/factory.ts +8 -3
- package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +3 -0
- package/src/lib/bootstrap/next-subpath-resolve-hook.ts +130 -0
- package/src/lib/bootstrap/types.ts +5 -1
- 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/commands/__tests__/command-bus.test.ts +100 -1
- package/src/lib/commands/command-bus.ts +42 -10
- package/src/lib/commands/command-interceptor.ts +4 -0
- package/src/lib/commands/types.ts +14 -0
- package/src/lib/crud/__tests__/exporters.test.ts +18 -0
- package/src/lib/crud/__tests__/ids.test.ts +34 -1
- package/src/lib/crud/exporters.ts +1 -1
- package/src/lib/crud/ids.ts +24 -0
- package/src/lib/db/__tests__/advisoryLock.integration.test.ts +343 -0
- package/src/lib/db/__tests__/advisoryLock.test.ts +564 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +68 -1
- package/src/lib/db/advisoryLock.ts +271 -0
- package/src/lib/db/pg-errors.ts +35 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +23 -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/number.ts +13 -0
- package/src/lib/openapi/__tests__/optional-path-params.test.ts +102 -0
- package/src/lib/openapi/generator.ts +106 -77
- package/src/lib/query/__tests__/empty-ids-filter.pg.test.ts +89 -0
- package/src/lib/query/__tests__/engine.empty-in-filter.test.ts +79 -0
- package/src/lib/query/__tests__/overlap.test.ts +219 -0
- package/src/lib/query/engine.ts +58 -8
- 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/lib/url.ts +30 -0
- package/src/lib/webhooks/__tests__/body.test.ts +48 -0
- package/src/lib/webhooks/body.ts +11 -4
- package/src/lib/webhooks/index.ts +1 -0
- package/src/modules/integrations/types.ts +6 -0
- package/src/modules/notifications/safeHref.ts +19 -0
- package/src/modules/notifications/types.ts +7 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +44 -0
- package/src/modules/payment_gateways/types.ts +52 -6
- package/src/modules/registry.ts +5 -0
- package/src/modules/search.ts +39 -0
- package/src/modules/widgets/__tests__/injection-loader.partitioned-registration.test.ts +78 -0
- package/src/modules/widgets/injection-loader.ts +33 -3
|
@@ -15,6 +15,12 @@ type PathParamInfo = {
|
|
|
15
15
|
optional?: boolean
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
type PathVariant = {
|
|
19
|
+
path: string
|
|
20
|
+
params: PathParamInfo[]
|
|
21
|
+
omittedParam?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
type ParameterLocation = 'query' | 'path' | 'header'
|
|
19
25
|
|
|
20
26
|
export type JsonSchema = Record<string, unknown>
|
|
@@ -119,6 +125,20 @@ function normalizePath(path: string): { path: string; params: PathParamInfo[] }
|
|
|
119
125
|
return { path: '/' + normalized, params }
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
function expandOptionalPathParams(normalized: { path: string; params: PathParamInfo[] }): PathVariant[] {
|
|
129
|
+
const required = normalized.params.filter((param) => !param.optional)
|
|
130
|
+
const optionalParam = normalized.params.find((param) => param.optional)
|
|
131
|
+
const fullVariant: PathVariant = {
|
|
132
|
+
path: normalized.path,
|
|
133
|
+
params: normalized.params.map((param) => ({ ...param, optional: false })),
|
|
134
|
+
}
|
|
135
|
+
if (!optionalParam) return [fullVariant]
|
|
136
|
+
const placeholder = `/{${optionalParam.name}}`
|
|
137
|
+
if (!normalized.path.endsWith(placeholder)) return [fullVariant]
|
|
138
|
+
const basePath = normalized.path.slice(0, -placeholder.length) || '/'
|
|
139
|
+
return [fullVariant, { path: basePath, params: required, omittedParam: optionalParam.name }]
|
|
140
|
+
}
|
|
141
|
+
|
|
122
142
|
function unwrap(schema?: ZodTypeAny): {
|
|
123
143
|
schema: ZodTypeAny | undefined
|
|
124
144
|
optional: boolean
|
|
@@ -681,14 +701,14 @@ function buildParameters(
|
|
|
681
701
|
|
|
682
702
|
if (location === 'path' && pathParamNames && pathParamNames.length) {
|
|
683
703
|
const merged = mergePathParamSchemas(schema, pathParamNames)
|
|
684
|
-
for (const { name, schema: paramSchema
|
|
704
|
+
for (const { name, schema: paramSchema } of merged) {
|
|
685
705
|
const jsonSchema = zodToJsonSchema(paramSchema)
|
|
686
706
|
const example = generateExample(paramSchema)
|
|
687
707
|
const description = extractZodDescription(paramSchema)
|
|
688
708
|
params.push({
|
|
689
709
|
name,
|
|
690
710
|
in: 'path',
|
|
691
|
-
required:
|
|
711
|
+
required: true,
|
|
692
712
|
schema: jsonSchema ?? { type: 'string' },
|
|
693
713
|
example,
|
|
694
714
|
...(description ? { description } : {}),
|
|
@@ -738,7 +758,7 @@ function buildParameters(
|
|
|
738
758
|
}
|
|
739
759
|
|
|
740
760
|
function mergePathParamSchemas(schema: ZodTypeAny | undefined, params: PathParamInfo[]) {
|
|
741
|
-
const merged: Array<{ name: string; schema: ZodTypeAny | undefined
|
|
761
|
+
const merged: Array<{ name: string; schema: ZodTypeAny | undefined }> = []
|
|
742
762
|
const map: Record<string, ZodTypeAny> = {}
|
|
743
763
|
if (schema) {
|
|
744
764
|
const { schema: unwrapped } = unwrap(schema)
|
|
@@ -753,7 +773,6 @@ function mergePathParamSchemas(schema: ZodTypeAny | undefined, params: PathParam
|
|
|
753
773
|
merged.push({
|
|
754
774
|
name: param.name,
|
|
755
775
|
schema: map[param.name],
|
|
756
|
-
optional: !!param.optional,
|
|
757
776
|
})
|
|
758
777
|
}
|
|
759
778
|
return merged
|
|
@@ -1051,6 +1070,7 @@ export function buildOpenApiDocument(modules: Module[], options: OpenApiDocument
|
|
|
1051
1070
|
|
|
1052
1071
|
const includeAccessControlMetadata = options.includeAccessControlMetadata !== false
|
|
1053
1072
|
const tags = new Map<string, string | undefined>()
|
|
1073
|
+
const derivedOperations = new Set<string>()
|
|
1054
1074
|
|
|
1055
1075
|
for (const moduleEntry of modules) {
|
|
1056
1076
|
const defaultTag = moduleEntry.info?.title ?? toTitle(moduleEntry.id)
|
|
@@ -1060,85 +1080,94 @@ export function buildOpenApiDocument(modules: Module[], options: OpenApiDocument
|
|
|
1060
1080
|
for (const api of apis) {
|
|
1061
1081
|
const routeDoc = collectRouteDoc(api, moduleEntry.id)
|
|
1062
1082
|
const moduleTag = routeDoc?.tag ?? defaultTag
|
|
1063
|
-
const normalized = normalizePath((api as any).path ?? (api as any).path ?? '')
|
|
1064
|
-
const pathKey = normalized.path
|
|
1065
|
-
if (!doc.paths[pathKey]) doc.paths[pathKey] = {}
|
|
1066
1083
|
const availableMethods: HttpMethod[] =
|
|
1067
1084
|
'handlers' in api
|
|
1068
1085
|
? HTTP_METHODS.filter((method) => typeof (api as ModuleApiRouteFile).handlers?.[method] === 'function')
|
|
1069
1086
|
: [api.method as HttpMethod]
|
|
1070
1087
|
|
|
1071
|
-
for (const
|
|
1072
|
-
const
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
: undefined
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1088
|
+
for (const normalized of expandOptionalPathParams(normalizePath((api as any).path ?? ''))) {
|
|
1089
|
+
const pathKey = normalized.path
|
|
1090
|
+
if (!doc.paths[pathKey]) doc.paths[pathKey] = {}
|
|
1091
|
+
|
|
1092
|
+
for (const method of availableMethods) {
|
|
1093
|
+
const methodLower = method.toLowerCase() as Lowercase<HttpMethod>
|
|
1094
|
+
const operationKey = `${methodLower} ${pathKey}`
|
|
1095
|
+
const existing = doc.paths[pathKey][methodLower]
|
|
1096
|
+
if (existing && (normalized.omittedParam || !derivedOperations.has(operationKey))) continue
|
|
1097
|
+
if (normalized.omittedParam) derivedOperations.add(operationKey)
|
|
1098
|
+
else derivedOperations.delete(operationKey)
|
|
1099
|
+
|
|
1100
|
+
const metadata = 'handlers' in api ? (api as ModuleApiRouteFile).metadata?.[method] : undefined
|
|
1101
|
+
const methodDoc = routeDoc?.methods?.[method]
|
|
1102
|
+
const summary = methodDoc?.summary ?? routeDoc?.summary ?? `${method} ${pathKey}`
|
|
1103
|
+
const baseDescription = methodDoc?.description ?? routeDoc?.description
|
|
1104
|
+
const meta = metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : undefined
|
|
1105
|
+
const requireFeatures = Array.isArray(meta?.['requireFeatures'])
|
|
1106
|
+
? (meta!['requireFeatures'] as string[])
|
|
1107
|
+
: undefined
|
|
1108
|
+
const requireRoles = Array.isArray(meta?.['requireRoles'])
|
|
1109
|
+
? (meta!['requireRoles'] as string[])
|
|
1110
|
+
: undefined
|
|
1111
|
+
const requireAuth = meta?.['requireAuth'] === true
|
|
1112
|
+
const descriptionParts: string[] = []
|
|
1113
|
+
if (baseDescription) descriptionParts.push(baseDescription)
|
|
1114
|
+
if (includeAccessControlMetadata && Array.isArray(requireFeatures) && requireFeatures.length) {
|
|
1115
|
+
descriptionParts.push(`Requires features: ${requireFeatures.join(', ')}`)
|
|
1116
|
+
}
|
|
1117
|
+
if (includeAccessControlMetadata && Array.isArray(requireRoles) && requireRoles.length) {
|
|
1118
|
+
descriptionParts.push(`Requires roles: ${requireRoles.join(', ')}`)
|
|
1119
|
+
}
|
|
1096
1120
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1121
|
+
const querySchema = methodDoc?.query
|
|
1122
|
+
const pathSchema = methodDoc?.pathParams ?? routeDoc?.pathParams
|
|
1123
|
+
const headerSchema = methodDoc?.headers
|
|
1124
|
+
const requestBody = methodDoc?.requestBody
|
|
1125
|
+
const examples = collectExamples(querySchema, requestBody?.schema, pathSchema, headerSchema, metadata)
|
|
1126
|
+
const curlSample = buildCurlSample(
|
|
1127
|
+
method,
|
|
1128
|
+
pathKey,
|
|
1129
|
+
normalized.params,
|
|
1130
|
+
examples,
|
|
1131
|
+
options.baseUrlForExamples ?? 'https://api.open-mercato.local',
|
|
1132
|
+
metadata,
|
|
1133
|
+
requestBody
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
doc.paths[pathKey][methodLower] = {
|
|
1137
|
+
operationId: methodDoc?.operationId
|
|
1138
|
+
? normalized.omittedParam
|
|
1139
|
+
? `${methodDoc.operationId}_without_${normalizeOperationIdSegment(normalized.omittedParam)}`
|
|
1140
|
+
: methodDoc.operationId
|
|
1141
|
+
: resolveOperationId(moduleEntry.id, pathKey, method),
|
|
1142
|
+
summary,
|
|
1143
|
+
description: descriptionParts.length ? descriptionParts.join('\n\n') : undefined,
|
|
1144
|
+
tags: methodDoc?.tags ?? (moduleTag ? [moduleTag] : undefined),
|
|
1145
|
+
deprecated: methodDoc?.deprecated,
|
|
1146
|
+
externalDocs: methodDoc?.externalDocs,
|
|
1147
|
+
parameters: [
|
|
1148
|
+
...buildParameters(pathSchema, 'path', normalized.params),
|
|
1149
|
+
...buildParameters(querySchema, 'query'),
|
|
1150
|
+
...buildParameters(headerSchema, 'header'),
|
|
1151
|
+
].filter(Boolean),
|
|
1152
|
+
requestBody: buildRequestBody(requestBody),
|
|
1153
|
+
responses: buildResponses(method, methodDoc?.responses, methodDoc?.errors, metadata),
|
|
1154
|
+
security: buildSecurity(metadata, methodDoc, options.defaultSecurity),
|
|
1155
|
+
'x-codeSamples': methodDoc?.codeSamples ?? [
|
|
1156
|
+
{
|
|
1157
|
+
lang: 'curl',
|
|
1158
|
+
label: 'cURL',
|
|
1159
|
+
source: curlSample,
|
|
1160
|
+
},
|
|
1161
|
+
],
|
|
1162
|
+
...(includeAccessControlMetadata && Array.isArray(requireFeatures) && requireFeatures.length
|
|
1163
|
+
? { 'x-require-features': requireFeatures }
|
|
1164
|
+
: {}),
|
|
1165
|
+
...(includeAccessControlMetadata && Array.isArray(requireRoles) && requireRoles.length
|
|
1166
|
+
? { 'x-require-roles': requireRoles }
|
|
1167
|
+
: {}),
|
|
1168
|
+
...(requireAuth ? { 'x-require-auth': true } : {}),
|
|
1169
|
+
...(methodDoc?.extensions ?? {}),
|
|
1170
|
+
}
|
|
1142
1171
|
}
|
|
1143
1172
|
}
|
|
1144
1173
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end proof for #7502 against a real PostgreSQL: a `?ids=` filter that
|
|
3
|
+
* leaves no valid id (`{ id: { $in: [] } }`, the shape `mergeIdFilter` produces
|
|
4
|
+
* for an unparseable or disjoint id list) must answer an empty page, not a
|
|
5
|
+
* 42601 syntax error from Kysely compiling `in ()`.
|
|
6
|
+
*
|
|
7
|
+
* Gated on OM_COUNT_CAP_PG_URL (a throwaway database — the suite creates and
|
|
8
|
+
* drops its own table). Example:
|
|
9
|
+
* docker run --rm -d -p 54329:5432 -e POSTGRES_PASSWORD=t postgres:16
|
|
10
|
+
* OM_COUNT_CAP_PG_URL=postgres://postgres:t@localhost:54329/postgres yarn jest empty-ids-filter.pg
|
|
11
|
+
*/
|
|
12
|
+
import { Kysely, PostgresDialect, sql } from 'kysely'
|
|
13
|
+
import { BasicQueryEngine } from '../engine'
|
|
14
|
+
|
|
15
|
+
const PG_URL = process.env.OM_COUNT_CAP_PG_URL
|
|
16
|
+
const maybe = PG_URL ? describe : describe.skip
|
|
17
|
+
|
|
18
|
+
const TENANT = '11111111-1111-4111-8111-111111111111'
|
|
19
|
+
const ORG = '22222222-2222-4222-8222-222222222222'
|
|
20
|
+
const ENTITY = 'capcheck:om_emptyids_order'
|
|
21
|
+
const TABLE = 'om_emptyids_orders'
|
|
22
|
+
const ID_A = '33333333-3333-4333-8333-000000000001'
|
|
23
|
+
const ID_B = '33333333-3333-4333-8333-000000000002'
|
|
24
|
+
|
|
25
|
+
maybe('empty $in/$nin filter against PostgreSQL', () => {
|
|
26
|
+
jest.setTimeout(30_000)
|
|
27
|
+
let db: Kysely<any>
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
31
|
+
const { Pool } = require('pg')
|
|
32
|
+
db = new Kysely<any>({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: PG_URL }) }) })
|
|
33
|
+
await sql`drop table if exists om_emptyids_orders`.execute(db)
|
|
34
|
+
await sql`create table om_emptyids_orders (
|
|
35
|
+
id uuid primary key,
|
|
36
|
+
tenant_id uuid not null,
|
|
37
|
+
organization_id uuid not null,
|
|
38
|
+
deleted_at timestamptz,
|
|
39
|
+
status text not null
|
|
40
|
+
)`.execute(db)
|
|
41
|
+
await sql`insert into om_emptyids_orders (id, tenant_id, organization_id, deleted_at, status) values
|
|
42
|
+
(${ID_A}::uuid, ${TENANT}::uuid, ${ORG}::uuid, null, 'open'),
|
|
43
|
+
(${ID_B}::uuid, ${TENANT}::uuid, ${ORG}::uuid, null, 'closed')`.execute(db)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await sql`drop table if exists om_emptyids_orders`.execute(db)
|
|
48
|
+
await db.destroy()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const makeEngine = () => new BasicQueryEngine({} as any, () => db as any)
|
|
52
|
+
const baseOpts = {
|
|
53
|
+
tenantId: TENANT,
|
|
54
|
+
organizationId: ORG,
|
|
55
|
+
fields: ['id'],
|
|
56
|
+
page: { page: 1, pageSize: 10 },
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('?ids= with no valid id returns an empty page instead of a 500 (AND path)', async () => {
|
|
60
|
+
const result = await makeEngine().query(ENTITY, {
|
|
61
|
+
...baseOpts,
|
|
62
|
+
filters: { id: { $in: [] } },
|
|
63
|
+
})
|
|
64
|
+
expect(result.items).toEqual([])
|
|
65
|
+
expect(result.total).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('a disjoint ?id=&?ids= intersection inside an $or group still matches the other disjunct', async () => {
|
|
69
|
+
const result = await makeEngine().query(ENTITY, {
|
|
70
|
+
...baseOpts,
|
|
71
|
+
filters: {
|
|
72
|
+
$or: [
|
|
73
|
+
{ id: { $in: [] } },
|
|
74
|
+
{ status: { $eq: 'closed' } },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
expect(result.items.map((row: any) => row.id)).toEqual([ID_B])
|
|
79
|
+
expect(result.total).toBe(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('an empty $nin excludes nothing, matching every row', async () => {
|
|
83
|
+
const result = await makeEngine().query(ENTITY, {
|
|
84
|
+
...baseOpts,
|
|
85
|
+
filters: { id: { $nin: [] } },
|
|
86
|
+
})
|
|
87
|
+
expect(result.total).toBe(2)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #7502: `mergeIdFilter` deliberately produces `{ $in: [] }` for a `?ids=` param
|
|
3
|
+
* that left no valid UUID (or a disjoint `?id=`+`?ids=` intersection) — the
|
|
4
|
+
* correct "match nothing" semantics. Both engine where-builders used to pass
|
|
5
|
+
* that empty array straight to Kysely's `.where(column, 'in', [])`, which
|
|
6
|
+
* compiles to invalid SQL (`in ()`) and makes Postgres answer 42601/500
|
|
7
|
+
* instead of an empty page. These tests pin the guard directly on the two
|
|
8
|
+
* private builder methods — no real SQL execution needed to prove the shape
|
|
9
|
+
* changed; `count-cap-plan.test.ts`-style Postgres-gated tests cover the
|
|
10
|
+
* end-to-end compiled-SQL behavior.
|
|
11
|
+
*/
|
|
12
|
+
import { BasicQueryEngine } from '../engine'
|
|
13
|
+
|
|
14
|
+
describe('applyColumnOp empty $in/$nin guard (#7502)', () => {
|
|
15
|
+
const engine = new BasicQueryEngine({} as any, () => ({}) as any)
|
|
16
|
+
|
|
17
|
+
function callApplyColumnOp(op: string, value: unknown) {
|
|
18
|
+
const calls: any[] = []
|
|
19
|
+
const builder: any = {
|
|
20
|
+
where: (...args: any[]) => {
|
|
21
|
+
calls.push(args)
|
|
22
|
+
return builder
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
;(engine as any).applyColumnOp(builder, 'id', op, value)
|
|
26
|
+
return calls
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('empty $in does not reach Kysely as an empty in-list', () => {
|
|
30
|
+
const calls = callApplyColumnOp('in', [])
|
|
31
|
+
expect(calls).toHaveLength(1)
|
|
32
|
+
// Pre-fix this called builder.where('id', 'in', []) -- three arguments, the
|
|
33
|
+
// shape Kysely compiles to invalid SQL. Post-fix it is a single predicate arg.
|
|
34
|
+
expect(calls[0]).not.toEqual(['id', 'in', []])
|
|
35
|
+
expect(calls[0]).toHaveLength(1)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('non-empty $in keeps the original in-list predicate', () => {
|
|
39
|
+
const calls = callApplyColumnOp('in', ['a'])
|
|
40
|
+
expect(calls[0]).toEqual(['id', 'in', ['a']])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('empty $nin does not reach Kysely as an empty not-in-list', () => {
|
|
44
|
+
const calls = callApplyColumnOp('nin', [])
|
|
45
|
+
expect(calls[0]).toHaveLength(1)
|
|
46
|
+
expect(calls[0]).not.toEqual(['id', 'not in', []])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('non-empty $nin keeps the original not-in-list predicate', () => {
|
|
50
|
+
const calls = callApplyColumnOp('nin', ['a'])
|
|
51
|
+
expect(calls[0]).toEqual(['id', 'not in', ['a']])
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('buildColumnOpExpression empty $in/$nin guard (#7502, OR-group path)', () => {
|
|
56
|
+
const engine = new BasicQueryEngine({} as any, () => ({}) as any)
|
|
57
|
+
|
|
58
|
+
function callBuildColumnOpExpression(op: string, value: unknown) {
|
|
59
|
+
const eb: any = (...args: any[]) => ({ kind: 'cmp', args })
|
|
60
|
+
eb.val = (v: any) => ({ kind: 'val', value: v })
|
|
61
|
+
return (engine as any).buildColumnOpExpression(eb, 'id', op, value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('empty $in compiles to a never-match literal, not an empty in-list comparison', () => {
|
|
65
|
+
expect(callBuildColumnOpExpression('in', [])).toEqual({ kind: 'val', value: false })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('non-empty $in keeps the original in-list comparison', () => {
|
|
69
|
+
expect(callBuildColumnOpExpression('in', ['a'])).toEqual({ kind: 'cmp', args: ['id', 'in', ['a']] })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('empty $nin compiles to an always-match literal, not an empty not-in-list comparison', () => {
|
|
73
|
+
expect(callBuildColumnOpExpression('nin', [])).toEqual({ kind: 'val', value: true })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('non-empty $nin keeps the original not-in-list comparison', () => {
|
|
77
|
+
expect(callBuildColumnOpExpression('nin', ['a'])).toEqual({ kind: 'cmp', args: ['id', 'not in', ['a']] })
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -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
|
+
})
|