@open-mercato/shared 0.4.2-canary-c02407ff85
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/build.mjs +101 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +7 -0
- package/dist/lib/api/crud.js +47 -0
- package/dist/lib/api/crud.js.map +7 -0
- package/dist/lib/api/scoped.js +140 -0
- package/dist/lib/api/scoped.js.map +7 -0
- package/dist/lib/auth/jwt.js +34 -0
- package/dist/lib/auth/jwt.js.map +7 -0
- package/dist/lib/auth/server.js +157 -0
- package/dist/lib/auth/server.js.map +7 -0
- package/dist/lib/boolean.js +22 -0
- package/dist/lib/boolean.js.map +7 -0
- package/dist/lib/bootstrap/appResolver.js +43 -0
- package/dist/lib/bootstrap/appResolver.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +108 -0
- package/dist/lib/bootstrap/dynamicLoader.js.map +7 -0
- package/dist/lib/bootstrap/factory.js +59 -0
- package/dist/lib/bootstrap/factory.js.map +7 -0
- package/dist/lib/bootstrap/index.js +11 -0
- package/dist/lib/bootstrap/index.js.map +7 -0
- package/dist/lib/bootstrap/types.js +1 -0
- package/dist/lib/bootstrap/types.js.map +7 -0
- package/dist/lib/cache/segments.js +36 -0
- package/dist/lib/cache/segments.js.map +7 -0
- package/dist/lib/cli/progress.js +46 -0
- package/dist/lib/cli/progress.js.map +7 -0
- package/dist/lib/commands/command-bus.js +285 -0
- package/dist/lib/commands/command-bus.js.map +7 -0
- package/dist/lib/commands/customFieldSnapshots.js +66 -0
- package/dist/lib/commands/customFieldSnapshots.js.map +7 -0
- package/dist/lib/commands/helpers.js +98 -0
- package/dist/lib/commands/helpers.js.map +7 -0
- package/dist/lib/commands/index.js +8 -0
- package/dist/lib/commands/index.js.map +7 -0
- package/dist/lib/commands/operationMetadata.js +32 -0
- package/dist/lib/commands/operationMetadata.js.map +7 -0
- package/dist/lib/commands/registry.js +43 -0
- package/dist/lib/commands/registry.js.map +7 -0
- package/dist/lib/commands/scope.js +44 -0
- package/dist/lib/commands/scope.js.map +7 -0
- package/dist/lib/commands/types.js +8 -0
- package/dist/lib/commands/types.js.map +7 -0
- package/dist/lib/crud/cache-stats.js +98 -0
- package/dist/lib/crud/cache-stats.js.map +7 -0
- package/dist/lib/crud/cache.js +175 -0
- package/dist/lib/crud/cache.js.map +7 -0
- package/dist/lib/crud/custom-fields-client.js +52 -0
- package/dist/lib/crud/custom-fields-client.js.map +7 -0
- package/dist/lib/crud/custom-fields.js +467 -0
- package/dist/lib/crud/custom-fields.js.map +7 -0
- package/dist/lib/crud/errors.js +24 -0
- package/dist/lib/crud/errors.js.map +7 -0
- package/dist/lib/crud/exporters.js +154 -0
- package/dist/lib/crud/exporters.js.map +7 -0
- package/dist/lib/crud/factory.js +1311 -0
- package/dist/lib/crud/factory.js.map +7 -0
- package/dist/lib/crud/types.js +1 -0
- package/dist/lib/crud/types.js.map +7 -0
- package/dist/lib/custom-fields/normalize.js +36 -0
- package/dist/lib/custom-fields/normalize.js.map +7 -0
- package/dist/lib/data/engine.js +396 -0
- package/dist/lib/data/engine.js.map +7 -0
- package/dist/lib/db/escapeLikePattern.js +5 -0
- package/dist/lib/db/escapeLikePattern.js.map +7 -0
- package/dist/lib/db/mikro.js +82 -0
- package/dist/lib/db/mikro.js.map +7 -0
- package/dist/lib/di/container.js +94 -0
- package/dist/lib/di/container.js.map +7 -0
- package/dist/lib/email/send.js +12 -0
- package/dist/lib/email/send.js.map +7 -0
- package/dist/lib/encryption/aes.js +58 -0
- package/dist/lib/encryption/aes.js.map +7 -0
- package/dist/lib/encryption/customFieldValues.js +49 -0
- package/dist/lib/encryption/customFieldValues.js.map +7 -0
- package/dist/lib/encryption/entityFields.js +26 -0
- package/dist/lib/encryption/entityFields.js.map +7 -0
- package/dist/lib/encryption/entityIds.js +80 -0
- package/dist/lib/encryption/entityIds.js.map +7 -0
- package/dist/lib/encryption/find.js +45 -0
- package/dist/lib/encryption/find.js.map +7 -0
- package/dist/lib/encryption/indexDoc.js +69 -0
- package/dist/lib/encryption/indexDoc.js.map +7 -0
- package/dist/lib/encryption/kms.js +282 -0
- package/dist/lib/encryption/kms.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +330 -0
- package/dist/lib/encryption/subscriber.js.map +7 -0
- package/dist/lib/encryption/tenantDataEncryptionService.js +252 -0
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +7 -0
- package/dist/lib/encryption/toggles.js +18 -0
- package/dist/lib/encryption/toggles.js.map +7 -0
- package/dist/lib/entities/naming.js +9 -0
- package/dist/lib/entities/naming.js.map +7 -0
- package/dist/lib/entities/system-entities.js +43 -0
- package/dist/lib/entities/system-entities.js.map +7 -0
- package/dist/lib/frontend/organizationEvents.js +41 -0
- package/dist/lib/frontend/organizationEvents.js.map +7 -0
- package/dist/lib/frontend/useOrganizationScope.js +32 -0
- package/dist/lib/frontend/useOrganizationScope.js.map +7 -0
- package/dist/lib/hotkeys/index.js +128 -0
- package/dist/lib/hotkeys/index.js.map +7 -0
- package/dist/lib/i18n/app-dictionaries.js +17 -0
- package/dist/lib/i18n/app-dictionaries.js.map +7 -0
- package/dist/lib/i18n/config.js +7 -0
- package/dist/lib/i18n/config.js.map +7 -0
- package/dist/lib/i18n/context.js +50 -0
- package/dist/lib/i18n/context.js.map +7 -0
- package/dist/lib/i18n/server.js +68 -0
- package/dist/lib/i18n/server.js.map +7 -0
- package/dist/lib/i18n/translate.js +45 -0
- package/dist/lib/i18n/translate.js.map +7 -0
- package/dist/lib/indexers/error-log.js +82 -0
- package/dist/lib/indexers/error-log.js.map +7 -0
- package/dist/lib/indexers/status-log.js +80 -0
- package/dist/lib/indexers/status-log.js.map +7 -0
- package/dist/lib/lib/auth/jwt.js +34 -0
- package/dist/lib/lib/auth/jwt.js.map +7 -0
- package/dist/lib/lib/auth/server.js +77 -0
- package/dist/lib/lib/auth/server.js.map +7 -0
- package/dist/lib/lib/email/send.js +12 -0
- package/dist/lib/lib/email/send.js.map +7 -0
- package/dist/lib/lib/i18n/config.js +7 -0
- package/dist/lib/lib/i18n/config.js.map +7 -0
- package/dist/lib/lib/i18n/context.js +31 -0
- package/dist/lib/lib/i18n/context.js.map +7 -0
- package/dist/lib/lib/utils.js +9 -0
- package/dist/lib/lib/utils.js.map +7 -0
- package/dist/lib/location/countries.js +68 -0
- package/dist/lib/location/countries.js.map +7 -0
- package/dist/lib/modules/index.js +6 -0
- package/dist/lib/modules/index.js.map +7 -0
- package/dist/lib/modules/registry.js +18 -0
- package/dist/lib/modules/registry.js.map +7 -0
- package/dist/lib/openapi/crud.js +137 -0
- package/dist/lib/openapi/crud.js.map +7 -0
- package/dist/lib/openapi/generator.js +1131 -0
- package/dist/lib/openapi/generator.js.map +7 -0
- package/dist/lib/openapi/index.js +10 -0
- package/dist/lib/openapi/index.js.map +7 -0
- package/dist/lib/openapi/sanitize.js +110 -0
- package/dist/lib/openapi/sanitize.js.map +7 -0
- package/dist/lib/openapi/types.js +1 -0
- package/dist/lib/openapi/types.js.map +7 -0
- package/dist/lib/profiler/index.js +258 -0
- package/dist/lib/profiler/index.js.map +7 -0
- package/dist/lib/query/engine.js +729 -0
- package/dist/lib/query/engine.js.map +7 -0
- package/dist/lib/query/join-utils.js +195 -0
- package/dist/lib/query/join-utils.js.map +7 -0
- package/dist/lib/query/types.js +9 -0
- package/dist/lib/query/types.js.map +7 -0
- package/dist/lib/search/config.js +32 -0
- package/dist/lib/search/config.js.map +7 -0
- package/dist/lib/search/tokenize.js +34 -0
- package/dist/lib/search/tokenize.js.map +7 -0
- package/dist/lib/slugify.js +24 -0
- package/dist/lib/slugify.js.map +7 -0
- package/dist/lib/testing/bootstrap.js +51 -0
- package/dist/lib/testing/bootstrap.js.map +7 -0
- package/dist/lib/testing/index.js +17 -0
- package/dist/lib/testing/index.js.map +7 -0
- package/dist/lib/testing/renderWithProviders.js +15 -0
- package/dist/lib/testing/renderWithProviders.js.map +7 -0
- package/dist/lib/url.js +12 -0
- package/dist/lib/url.js.map +7 -0
- package/dist/lib/utils.js +13 -0
- package/dist/lib/utils.js.map +7 -0
- package/dist/lib/version.js +7 -0
- package/dist/lib/version.js.map +7 -0
- package/dist/modules/dashboard/widgets.js +1 -0
- package/dist/modules/dashboard/widgets.js.map +7 -0
- package/dist/modules/dsl.js +30 -0
- package/dist/modules/dsl.js.map +7 -0
- package/dist/modules/entities/kinds.js +22 -0
- package/dist/modules/entities/kinds.js.map +7 -0
- package/dist/modules/entities/options.js +26 -0
- package/dist/modules/entities/options.js.map +7 -0
- package/dist/modules/entities/validation.js +102 -0
- package/dist/modules/entities/validation.js.map +7 -0
- package/dist/modules/entities/validators.js +88 -0
- package/dist/modules/entities/validators.js.map +7 -0
- package/dist/modules/entities.js +1 -0
- package/dist/modules/entities.js.map +7 -0
- package/dist/modules/navigation/sidebarPreferences.js +50 -0
- package/dist/modules/navigation/sidebarPreferences.js.map +7 -0
- package/dist/modules/perspectives/types.js +1 -0
- package/dist/modules/perspectives/types.js.map +7 -0
- package/dist/modules/registry.js +96 -0
- package/dist/modules/registry.js.map +7 -0
- package/dist/modules/search.js +15 -0
- package/dist/modules/search.js.map +7 -0
- package/dist/modules/vector.js +1 -0
- package/dist/modules/vector.js.map +7 -0
- package/dist/modules/widgets/injection-loader.js +180 -0
- package/dist/modules/widgets/injection-loader.js.map +7 -0
- package/dist/modules/widgets/injection.js +1 -0
- package/dist/modules/widgets/injection.js.map +7 -0
- package/dist/security/features.js +23 -0
- package/dist/security/features.js.map +7 -0
- package/dist/types/pg.d.js +1 -0
- package/dist/types/pg.d.js.map +7 -0
- package/dist/types/react-email.d.js +1 -0
- package/dist/types/react-email.d.js.map +7 -0
- package/dist/types/resend.d.js +1 -0
- package/dist/types/resend.d.js.map +7 -0
- package/jest.config.cjs +22 -0
- package/package.json +88 -0
- package/src/index.ts +0 -0
- package/src/lib/api/__tests__/scoped.test.ts +38 -0
- package/src/lib/api/crud.ts +59 -0
- package/src/lib/api/scoped.ts +239 -0
- package/src/lib/auth/jwt.ts +39 -0
- package/src/lib/auth/server.ts +199 -0
- package/src/lib/boolean.ts +17 -0
- package/src/lib/bootstrap/appResolver.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +177 -0
- package/src/lib/bootstrap/factory.ts +108 -0
- package/src/lib/bootstrap/index.ts +23 -0
- package/src/lib/bootstrap/types.ts +31 -0
- package/src/lib/cache/segments.ts +56 -0
- package/src/lib/cli/progress.ts +55 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +84 -0
- package/src/lib/commands/__tests__/helpers.test.ts +42 -0
- package/src/lib/commands/command-bus.ts +349 -0
- package/src/lib/commands/customFieldSnapshots.ts +86 -0
- package/src/lib/commands/helpers.ts +143 -0
- package/src/lib/commands/index.ts +4 -0
- package/src/lib/commands/operationMetadata.ts +40 -0
- package/src/lib/commands/registry.ts +46 -0
- package/src/lib/commands/scope.ts +59 -0
- package/src/lib/commands/types.ts +63 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +333 -0
- package/src/lib/crud/__tests__/custom-fields.test.ts +150 -0
- package/src/lib/crud/cache-stats.ts +127 -0
- package/src/lib/crud/cache.ts +205 -0
- package/src/lib/crud/custom-fields-client.ts +54 -0
- package/src/lib/crud/custom-fields.ts +607 -0
- package/src/lib/crud/errors.ts +23 -0
- package/src/lib/crud/exporters.ts +188 -0
- package/src/lib/crud/factory.ts +1622 -0
- package/src/lib/crud/types.ts +29 -0
- package/src/lib/custom-fields/normalize.ts +45 -0
- package/src/lib/data/engine.ts +562 -0
- package/src/lib/db/escapeLikePattern.ts +2 -0
- package/src/lib/db/mikro.ts +100 -0
- package/src/lib/di/container.ts +105 -0
- package/src/lib/email/send.ts +18 -0
- package/src/lib/encryption/__tests__/customFieldValues.test.ts +63 -0
- package/src/lib/encryption/__tests__/indexDoc.test.ts +115 -0
- package/src/lib/encryption/aes.ts +64 -0
- package/src/lib/encryption/customFieldValues.ts +67 -0
- package/src/lib/encryption/entityFields.ts +39 -0
- package/src/lib/encryption/entityIds.ts +107 -0
- package/src/lib/encryption/find.ts +81 -0
- package/src/lib/encryption/indexDoc.ts +104 -0
- package/src/lib/encryption/kms.ts +337 -0
- package/src/lib/encryption/subscriber.ts +416 -0
- package/src/lib/encryption/tenantDataEncryptionService.ts +313 -0
- package/src/lib/encryption/toggles.ts +15 -0
- package/src/lib/entities/naming.ts +6 -0
- package/src/lib/entities/system-entities.ts +43 -0
- package/src/lib/frontend/organizationEvents.ts +55 -0
- package/src/lib/frontend/useOrganizationScope.ts +30 -0
- package/src/lib/hotkeys/index.ts +168 -0
- package/src/lib/i18n/app-dictionaries.ts +18 -0
- package/src/lib/i18n/config.ts +4 -0
- package/src/lib/i18n/context.tsx +66 -0
- package/src/lib/i18n/server.ts +74 -0
- package/src/lib/i18n/translate.ts +54 -0
- package/src/lib/indexers/error-log.ts +106 -0
- package/src/lib/indexers/status-log.ts +119 -0
- package/src/lib/lib/auth/jwt.ts +39 -0
- package/src/lib/lib/auth/server.ts +94 -0
- package/src/lib/lib/email/send.ts +18 -0
- package/src/lib/lib/i18n/config.ts +4 -0
- package/src/lib/lib/i18n/context.tsx +38 -0
- package/src/lib/lib/utils.ts +6 -0
- package/src/lib/location/countries.ts +97 -0
- package/src/lib/modules/index.ts +1 -0
- package/src/lib/modules/registry.ts +18 -0
- package/src/lib/openapi/crud.ts +218 -0
- package/src/lib/openapi/generator.ts +1311 -0
- package/src/lib/openapi/index.ts +4 -0
- package/src/lib/openapi/sanitize.ts +137 -0
- package/src/lib/openapi/types.ts +79 -0
- package/src/lib/profiler/index.ts +371 -0
- package/src/lib/query/__tests__/engine.test.ts +274 -0
- package/src/lib/query/engine.ts +837 -0
- package/src/lib/query/join-utils.ts +238 -0
- package/src/lib/query/types.ts +121 -0
- package/src/lib/search/config.ts +49 -0
- package/src/lib/search/tokenize.ts +45 -0
- package/src/lib/slugify.ts +28 -0
- package/src/lib/testing/bootstrap.ts +124 -0
- package/src/lib/testing/index.ts +15 -0
- package/src/lib/testing/renderWithProviders.tsx +31 -0
- package/src/lib/url.ts +12 -0
- package/src/lib/utils.ts +17 -0
- package/src/lib/version.ts +5 -0
- package/src/modules/__tests__/dsl.test.ts +35 -0
- package/src/modules/__tests__/registry.test.ts +300 -0
- package/src/modules/dashboard/widgets.ts +57 -0
- package/src/modules/dsl.ts +32 -0
- package/src/modules/entities/__tests__/validation.test.ts +52 -0
- package/src/modules/entities/kinds.ts +20 -0
- package/src/modules/entities/options.ts +36 -0
- package/src/modules/entities/validation.ts +118 -0
- package/src/modules/entities/validators.ts +93 -0
- package/src/modules/entities.ts +102 -0
- package/src/modules/navigation/sidebarPreferences.ts +62 -0
- package/src/modules/perspectives/types.ts +40 -0
- package/src/modules/registry.ts +249 -0
- package/src/modules/search.ts +325 -0
- package/src/modules/vector.ts +122 -0
- package/src/modules/widgets/__tests__/injection.test.ts +48 -0
- package/src/modules/widgets/injection-loader.ts +235 -0
- package/src/modules/widgets/injection.ts +120 -0
- package/src/security/features.ts +22 -0
- package/src/types/pg.d.ts +2 -0
- package/src/types/react-email.d.ts +2 -0
- package/src/types/resend.d.ts +2 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import type { QueryEngine, QueryOptions, QueryResult, QueryCustomFieldSource } from './types'
|
|
2
|
+
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import type { Knex } from 'knex'
|
|
5
|
+
import {
|
|
6
|
+
applyJoinFilters,
|
|
7
|
+
normalizeFilters,
|
|
8
|
+
partitionFilters,
|
|
9
|
+
resolveJoins,
|
|
10
|
+
type BaseFilter,
|
|
11
|
+
type NormalizedFilter,
|
|
12
|
+
type ResolvedJoin,
|
|
13
|
+
} from './join-utils'
|
|
14
|
+
import { resolveSearchConfig } from '../search/config'
|
|
15
|
+
import { tokenizeText } from '../search/tokenize'
|
|
16
|
+
|
|
17
|
+
const entityTableCache = new Map<string, string>()
|
|
18
|
+
|
|
19
|
+
type EncryptionResolver = () => {
|
|
20
|
+
decryptEntityPayload?: (entityId: EntityId, payload: Record<string, unknown>, tenantId?: string | null, organizationId?: string | null) => Promise<Record<string, unknown>>
|
|
21
|
+
isEnabled?: () => boolean
|
|
22
|
+
} | null
|
|
23
|
+
|
|
24
|
+
type ResolvedCustomFieldSource = {
|
|
25
|
+
entityId: EntityId
|
|
26
|
+
alias: string
|
|
27
|
+
table: string
|
|
28
|
+
recordIdExpr: any
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ResultRow = Record<string, unknown>
|
|
32
|
+
|
|
33
|
+
const pluralizeBaseName = (name: string): string => {
|
|
34
|
+
if (!name) return name
|
|
35
|
+
if (name.endsWith('s')) return name
|
|
36
|
+
if (name.endsWith('y')) return `${name.slice(0, -1)}ies`
|
|
37
|
+
return `${name}s`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const toPascalCase = (value: string): string => {
|
|
41
|
+
return value
|
|
42
|
+
.split(/[_\s]+/)
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
45
|
+
.join('')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const candidateClassNames = (rawName: string): string[] => {
|
|
49
|
+
const base = toPascalCase(rawName)
|
|
50
|
+
const candidates = new Set<string>()
|
|
51
|
+
if (base) candidates.add(base)
|
|
52
|
+
if (base && !base.endsWith('Entity')) candidates.add(`${base}Entity`)
|
|
53
|
+
return Array.from(candidates)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveEntityTableName(em: EntityManager | undefined, entity: EntityId): string {
|
|
57
|
+
if (entityTableCache.has(entity)) {
|
|
58
|
+
return entityTableCache.get(entity)!
|
|
59
|
+
}
|
|
60
|
+
const parts = String(entity || '').split(':')
|
|
61
|
+
const rawName = (parts[1] && parts[1].trim().length > 0) ? parts[1] : (parts[0] || '').trim()
|
|
62
|
+
const metadata = (em as any)?.getMetadata?.()
|
|
63
|
+
|
|
64
|
+
if (metadata && rawName) {
|
|
65
|
+
const candidates = candidateClassNames(rawName)
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
try {
|
|
68
|
+
const meta = metadata.find?.(candidate)
|
|
69
|
+
if (meta?.tableName) {
|
|
70
|
+
const tableName = String(meta.tableName)
|
|
71
|
+
entityTableCache.set(entity, tableName)
|
|
72
|
+
return tableName
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fallback = pluralizeBaseName(rawName || '')
|
|
79
|
+
entityTableCache.set(entity, fallback)
|
|
80
|
+
return fallback
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
// Minimal default implementation placeholder.
|
|
85
|
+
// For now, only supports basic base-entity querying by table name inferred from EntityId ('<module>:<entity>' -> '<entities>') via convention.
|
|
86
|
+
// Extensions and custom fields will be added iteratively.
|
|
87
|
+
|
|
88
|
+
export class BasicQueryEngine implements QueryEngine {
|
|
89
|
+
private columnCache = new Map<string, boolean>()
|
|
90
|
+
private tableCache = new Map<string, boolean>()
|
|
91
|
+
private searchAliasSeq = 0
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
private em: EntityManager,
|
|
95
|
+
private getKnexFn?: () => any,
|
|
96
|
+
private resolveEncryptionService?: EncryptionResolver,
|
|
97
|
+
) {}
|
|
98
|
+
|
|
99
|
+
private getEncryptionService() {
|
|
100
|
+
try {
|
|
101
|
+
return this.resolveEncryptionService?.() ?? null
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async query<T = any>(entity: EntityId, opts: QueryOptions = {}): Promise<QueryResult<T>> {
|
|
108
|
+
// Heuristic: map '<module>:user' -> table 'users'
|
|
109
|
+
const table = resolveEntityTableName(this.em, entity)
|
|
110
|
+
const knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
|
|
111
|
+
|
|
112
|
+
let q = knex(table)
|
|
113
|
+
const qualify = (col: string) => `${table}.${col}`
|
|
114
|
+
const orgScope = this.resolveOrganizationScope(opts)
|
|
115
|
+
this.searchAliasSeq = 0
|
|
116
|
+
// Require tenant scope for all queries
|
|
117
|
+
if (!opts.tenantId) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
'QueryEngine: tenantId is now required for all queries (breaking change). ' +
|
|
120
|
+
'Please provide a tenantId in QueryOptions, e.g., query(entity, { tenantId: ... }). ' +
|
|
121
|
+
'See migration guide or documentation for details.'
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
// Optional organization filter (when present in schema)
|
|
125
|
+
if (orgScope && await this.columnExists(table, 'organization_id')) {
|
|
126
|
+
q = this.applyOrganizationScope(q, qualify('organization_id'), orgScope)
|
|
127
|
+
}
|
|
128
|
+
// Tenant guard (required) when present in schema
|
|
129
|
+
if (await this.columnExists(table, 'tenant_id')) {
|
|
130
|
+
q = q.where(qualify('tenant_id'), opts.tenantId)
|
|
131
|
+
}
|
|
132
|
+
// Default soft-delete guard: exclude rows with deleted_at when column exists
|
|
133
|
+
if (!opts.withDeleted && await this.columnExists(table, 'deleted_at')) {
|
|
134
|
+
q = q.whereNull(qualify('deleted_at'))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const normalizedFilters = normalizeFilters(opts.filters)
|
|
138
|
+
const resolvedJoins = resolveJoins(table, opts.joins, (entityId) => resolveEntityTableName(this.em, entityId as any))
|
|
139
|
+
const joinMap = new Map<string, ResolvedJoin>()
|
|
140
|
+
const aliasTables = new Map<string, string>()
|
|
141
|
+
aliasTables.set(table, table)
|
|
142
|
+
aliasTables.set('base', table)
|
|
143
|
+
for (const join of resolvedJoins) {
|
|
144
|
+
joinMap.set(join.alias, join)
|
|
145
|
+
aliasTables.set(join.alias, join.table)
|
|
146
|
+
}
|
|
147
|
+
const { baseFilters, joinFilters } = partitionFilters(table, normalizedFilters, joinMap)
|
|
148
|
+
const cfFilters = normalizedFilters.filter((filter) => String(filter.field).startsWith('cf:'))
|
|
149
|
+
const searchConfig = resolveSearchConfig()
|
|
150
|
+
const searchEnabled = searchConfig.enabled && await this.tableExists('search_tokens')
|
|
151
|
+
const hasSearchTokens = searchEnabled
|
|
152
|
+
? await this.hasSearchTokens(String(entity), opts.tenantId ?? null, orgScope)
|
|
153
|
+
: false
|
|
154
|
+
const searchActive = searchEnabled && hasSearchTokens
|
|
155
|
+
const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => filter.op === 'like' || filter.op === 'ilike')
|
|
156
|
+
if (searchFilters.length) {
|
|
157
|
+
const fields = searchFilters.map((filter) => String(filter.field))
|
|
158
|
+
this.logSearchDebug('search:init', {
|
|
159
|
+
entity: String(entity),
|
|
160
|
+
table,
|
|
161
|
+
tenantId: opts.tenantId ?? null,
|
|
162
|
+
organizationScope: orgScope,
|
|
163
|
+
fields,
|
|
164
|
+
searchEnabled,
|
|
165
|
+
hasSearchTokens,
|
|
166
|
+
searchActive,
|
|
167
|
+
searchConfig: {
|
|
168
|
+
enabled: searchConfig.enabled,
|
|
169
|
+
minTokenLength: searchConfig.minTokenLength,
|
|
170
|
+
enablePartials: searchConfig.enablePartials,
|
|
171
|
+
hashAlgorithm: searchConfig.hashAlgorithm,
|
|
172
|
+
blocklistedFields: searchConfig.blocklistedFields,
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
if (!searchEnabled) {
|
|
176
|
+
this.logSearchDebug('search:disabled', { entity: String(entity), table })
|
|
177
|
+
} else if (!hasSearchTokens) {
|
|
178
|
+
this.logSearchDebug('search:no-search-tokens', {
|
|
179
|
+
entity: String(entity),
|
|
180
|
+
table,
|
|
181
|
+
tenantId: opts.tenantId ?? null,
|
|
182
|
+
organizationScope: orgScope,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const recordIdColumn = qualify('id')
|
|
187
|
+
|
|
188
|
+
const applyFilterOp = (builder: any, column: string, op: any, value: any, fieldName?: string) => {
|
|
189
|
+
if (
|
|
190
|
+
(op === 'like' || op === 'ilike') &&
|
|
191
|
+
searchActive &&
|
|
192
|
+
typeof value === 'string' &&
|
|
193
|
+
fieldName
|
|
194
|
+
) {
|
|
195
|
+
const tokens = tokenizeText(String(value), searchConfig)
|
|
196
|
+
const hashes = tokens.hashes
|
|
197
|
+
if (hashes.length) {
|
|
198
|
+
const applied = this.applySearchTokens(builder, {
|
|
199
|
+
entity: String(entity),
|
|
200
|
+
field: fieldName,
|
|
201
|
+
hashes,
|
|
202
|
+
recordIdColumn,
|
|
203
|
+
tenantId: opts.tenantId ?? null,
|
|
204
|
+
organizationScope: orgScope,
|
|
205
|
+
tokens: tokens.tokens,
|
|
206
|
+
})
|
|
207
|
+
this.logSearchDebug('search:filter', {
|
|
208
|
+
entity: String(entity),
|
|
209
|
+
field: fieldName,
|
|
210
|
+
tokens: tokens.tokens,
|
|
211
|
+
hashes,
|
|
212
|
+
applied,
|
|
213
|
+
tenantId: opts.tenantId ?? null,
|
|
214
|
+
organizationScope: orgScope,
|
|
215
|
+
})
|
|
216
|
+
if (applied) return builder
|
|
217
|
+
} else {
|
|
218
|
+
this.logSearchDebug('search:skip-empty-hashes', {
|
|
219
|
+
entity: String(entity),
|
|
220
|
+
field: fieldName,
|
|
221
|
+
value,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
switch (op) {
|
|
226
|
+
case 'eq': builder.where(column, value); break
|
|
227
|
+
case 'ne': builder.whereNot(column, value); break
|
|
228
|
+
case 'gt': builder.where(column, '>', value); break
|
|
229
|
+
case 'gte': builder.where(column, '>=', value); break
|
|
230
|
+
case 'lt': builder.where(column, '<', value); break
|
|
231
|
+
case 'lte': builder.where(column, '<=', value); break
|
|
232
|
+
case 'in': builder.whereIn(column, Array.isArray(value) ? value : [value]); break
|
|
233
|
+
case 'nin': builder.whereNotIn(column, Array.isArray(value) ? value : [value]); break
|
|
234
|
+
case 'like': builder.where(column, 'like', value); break
|
|
235
|
+
case 'ilike': builder.where(column, 'ilike', value); break
|
|
236
|
+
case 'exists': value ? builder.whereNotNull(column) : builder.whereNull(column); break
|
|
237
|
+
default: break
|
|
238
|
+
}
|
|
239
|
+
return builder
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const filter of baseFilters) {
|
|
243
|
+
let qualified = filter.qualified ?? null
|
|
244
|
+
if (!qualified) {
|
|
245
|
+
const column = await this.resolveBaseColumn(table, String(filter.field))
|
|
246
|
+
if (!column) continue
|
|
247
|
+
qualified = qualify(column)
|
|
248
|
+
}
|
|
249
|
+
applyFilterOp(q, qualified, filter.op, filter.value, String(filter.field))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const applyAliasScopes = async (builder: any, aliasName: string) => {
|
|
253
|
+
const targetTable = aliasTables.get(aliasName)
|
|
254
|
+
if (!targetTable) return
|
|
255
|
+
if (orgScope && await this.columnExists(targetTable, 'organization_id')) {
|
|
256
|
+
this.applyOrganizationScope(builder, `${aliasName}.organization_id`, orgScope)
|
|
257
|
+
}
|
|
258
|
+
if (opts.tenantId && await this.columnExists(targetTable, 'tenant_id')) {
|
|
259
|
+
builder.where(`${aliasName}.tenant_id`, opts.tenantId)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
await applyJoinFilters({
|
|
263
|
+
knex,
|
|
264
|
+
baseTable: table,
|
|
265
|
+
builder: q,
|
|
266
|
+
joinMap,
|
|
267
|
+
joinFilters,
|
|
268
|
+
aliasTables,
|
|
269
|
+
qualifyBase: (column) => qualify(column),
|
|
270
|
+
applyAliasScope: (builder, alias) => applyAliasScopes(builder, alias),
|
|
271
|
+
applyFilterOp,
|
|
272
|
+
columnExists: (tbl, column) => this.columnExists(tbl, column),
|
|
273
|
+
})
|
|
274
|
+
// Selection (base columns only here; cf:* handled later)
|
|
275
|
+
if (opts.fields && opts.fields.length) {
|
|
276
|
+
const cols = opts.fields.filter((f) => !f.startsWith('cf:'))
|
|
277
|
+
if (cols.length) {
|
|
278
|
+
// Qualify and alias to base names to avoid ambiguity
|
|
279
|
+
const baseSelects = cols.map((c) => knex.raw('?? as ??', [qualify(c), c]))
|
|
280
|
+
q = q.select(baseSelects)
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
// Default to selecting only base table columns to avoid ambiguity when joining
|
|
284
|
+
q = q.select(knex.raw('??.*', [table]))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Resolve which custom fields to include
|
|
288
|
+
const tenantId = opts.tenantId
|
|
289
|
+
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
290
|
+
const cfSources = this.configureCustomFieldSources(q, table, entity, knex, opts, qualify)
|
|
291
|
+
const entityIdToSource = new Map<string, ResolvedCustomFieldSource>()
|
|
292
|
+
for (const source of cfSources) {
|
|
293
|
+
entityIdToSource.set(String(source.entityId), source)
|
|
294
|
+
}
|
|
295
|
+
const requestedCustomFieldKeys = Array.isArray(opts.includeCustomFields)
|
|
296
|
+
? opts.includeCustomFields.map((key) => String(key))
|
|
297
|
+
: []
|
|
298
|
+
const cfKeys = new Set<string>()
|
|
299
|
+
const keySource = new Map<string, ResolvedCustomFieldSource>()
|
|
300
|
+
// Explicit in fields/filters
|
|
301
|
+
for (const f of (opts.fields || [])) {
|
|
302
|
+
if (typeof f === 'string' && f.startsWith('cf:')) cfKeys.add(f.slice(3))
|
|
303
|
+
}
|
|
304
|
+
for (const f of cfFilters) {
|
|
305
|
+
if (typeof f.field === 'string' && f.field.startsWith('cf:')) cfKeys.add(f.field.slice(3))
|
|
306
|
+
}
|
|
307
|
+
if (opts.includeCustomFields === true) {
|
|
308
|
+
if (entityIdToSource.size > 0) {
|
|
309
|
+
const entityIdList = Array.from(entityIdToSource.keys())
|
|
310
|
+
const entityOrder = new Map<string, number>()
|
|
311
|
+
entityIdList.forEach((id, idx) => entityOrder.set(id, idx))
|
|
312
|
+
const rows = await knex('custom_field_defs')
|
|
313
|
+
.select('key', 'entity_id', 'config_json', 'kind')
|
|
314
|
+
.whereIn('entity_id', entityIdList)
|
|
315
|
+
.andWhere('is_active', true)
|
|
316
|
+
.modify((qb: any) => {
|
|
317
|
+
qb.andWhere((inner: any) => {
|
|
318
|
+
inner.where({ tenant_id: tenantId }).orWhereNull('tenant_id')
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
type CustomFieldDefinitionRow = {
|
|
322
|
+
key: string
|
|
323
|
+
entityId: string
|
|
324
|
+
kind: string
|
|
325
|
+
config: Record<string, unknown>
|
|
326
|
+
}
|
|
327
|
+
const sorted: CustomFieldDefinitionRow[] = rows.map((row: any) => {
|
|
328
|
+
const raw = row.config_json
|
|
329
|
+
let cfg: Record<string, any> = {}
|
|
330
|
+
if (raw && typeof raw === 'string') {
|
|
331
|
+
try { cfg = JSON.parse(raw) } catch { cfg = {} }
|
|
332
|
+
} else if (raw && typeof raw === 'object') {
|
|
333
|
+
cfg = raw
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
key: String(row.key),
|
|
337
|
+
entityId: String(row.entity_id),
|
|
338
|
+
kind: String(row.kind || ''),
|
|
339
|
+
config: cfg,
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
sorted.sort((a: CustomFieldDefinitionRow, b: CustomFieldDefinitionRow) => {
|
|
343
|
+
const ai = entityOrder.get(a.entityId) ?? Number.MAX_SAFE_INTEGER
|
|
344
|
+
const bi = entityOrder.get(b.entityId) ?? Number.MAX_SAFE_INTEGER
|
|
345
|
+
if (ai !== bi) return ai - bi
|
|
346
|
+
return a.key.localeCompare(b.key)
|
|
347
|
+
})
|
|
348
|
+
const selectedSources = new Map<string, { source: ResolvedCustomFieldSource; score: number; penalty: number; entityIndex: number }>()
|
|
349
|
+
for (const row of sorted) {
|
|
350
|
+
const source = entityIdToSource.get(row.entityId)
|
|
351
|
+
if (!source) continue
|
|
352
|
+
const cfg = row.config || {}
|
|
353
|
+
const entityIndex = entityOrder.get(row.entityId) ?? Number.MAX_SAFE_INTEGER
|
|
354
|
+
const scores = computeScore(cfg, row.kind, entityIndex)
|
|
355
|
+
const existing = selectedSources.get(row.key)
|
|
356
|
+
if (!existing || scores.base > existing.score || (scores.base === existing.score && (scores.penalty < existing.penalty || (scores.penalty === existing.penalty && scores.entityIndex < existing.entityIndex)))) {
|
|
357
|
+
selectedSources.set(row.key, { source, score: scores.base, penalty: scores.penalty, entityIndex: scores.entityIndex })
|
|
358
|
+
}
|
|
359
|
+
cfKeys.add(row.key)
|
|
360
|
+
}
|
|
361
|
+
for (const [key, entry] of selectedSources.entries()) {
|
|
362
|
+
keySource.set(key, entry.source)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} else if (requestedCustomFieldKeys.length > 0) {
|
|
366
|
+
for (const key of requestedCustomFieldKeys) cfKeys.add(key)
|
|
367
|
+
}
|
|
368
|
+
const unresolvedKeys = Array.from(cfKeys).filter((key) => !keySource.has(key))
|
|
369
|
+
if (unresolvedKeys.length > 0 && entityIdToSource.size > 0) {
|
|
370
|
+
const rows = await knex('custom_field_defs')
|
|
371
|
+
.select('key', 'entity_id')
|
|
372
|
+
.whereIn('entity_id', Array.from(entityIdToSource.keys()))
|
|
373
|
+
.whereIn('key', unresolvedKeys)
|
|
374
|
+
.andWhere('is_active', true)
|
|
375
|
+
.modify((qb: any) => {
|
|
376
|
+
qb.andWhere((inner: any) => {
|
|
377
|
+
inner.where({ tenant_id: tenantId }).orWhereNull('tenant_id')
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
for (const row of rows) {
|
|
381
|
+
const source = entityIdToSource.get(String(row.entity_id))
|
|
382
|
+
if (!source) continue
|
|
383
|
+
if (!keySource.has(row.key)) keySource.set(row.key, source)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const cfValueExprByKey: Record<string, any> = {}
|
|
388
|
+
const cfSelectedAliases: string[] = []
|
|
389
|
+
const cfJsonAliases = new Set<string>()
|
|
390
|
+
const cfMultiAliasByAlias = new Map<string, string>()
|
|
391
|
+
for (const key of cfKeys) {
|
|
392
|
+
const source = keySource.get(key)
|
|
393
|
+
if (!source) continue
|
|
394
|
+
const entityIdForKey = source.entityId
|
|
395
|
+
const recordIdExpr = source.recordIdExpr
|
|
396
|
+
const sourceAliasSafe = sanitize(source.alias || 'src')
|
|
397
|
+
const keyAliasSafe = sanitize(key)
|
|
398
|
+
const defAlias = `cfd_${sourceAliasSafe}_${keyAliasSafe}`
|
|
399
|
+
const valAlias = `cfv_${sourceAliasSafe}_${keyAliasSafe}`
|
|
400
|
+
// Join definitions for kind resolution
|
|
401
|
+
q = q.leftJoin({ [defAlias]: 'custom_field_defs' }, function (this: any) {
|
|
402
|
+
this.on(`${defAlias}.entity_id`, '=', knex.raw('?', [entityIdForKey]))
|
|
403
|
+
.andOn(`${defAlias}.key`, '=', knex.raw('?', [key]))
|
|
404
|
+
.andOn(`${defAlias}.is_active`, '=', knex.raw('true'))
|
|
405
|
+
.andOn(knex.raw(`(${defAlias}.tenant_id = ? OR ${defAlias}.tenant_id IS NULL)`, [tenantId]))
|
|
406
|
+
})
|
|
407
|
+
// Join values with record match
|
|
408
|
+
q = q.leftJoin({ [valAlias]: 'custom_field_values' }, function (this: any) {
|
|
409
|
+
this.on(`${valAlias}.entity_id`, '=', knex.raw('?', [entityIdForKey]))
|
|
410
|
+
.andOn(`${valAlias}.field_key`, '=', knex.raw('?', [key]))
|
|
411
|
+
.andOn(`${valAlias}.record_id`, '=', recordIdExpr)
|
|
412
|
+
.andOn(knex.raw(`(${valAlias}.tenant_id = ? OR ${valAlias}.tenant_id IS NULL)`, [tenantId]))
|
|
413
|
+
})
|
|
414
|
+
// Force a common SQL type across branches to avoid Postgres CASE type conflicts
|
|
415
|
+
const caseExpr = knex.raw(
|
|
416
|
+
`CASE ${defAlias}.kind
|
|
417
|
+
WHEN 'integer' THEN (${valAlias}.value_int)::text
|
|
418
|
+
WHEN 'float' THEN (${valAlias}.value_float)::text
|
|
419
|
+
WHEN 'boolean' THEN (${valAlias}.value_bool)::text
|
|
420
|
+
WHEN 'multiline' THEN (${valAlias}.value_multiline)::text
|
|
421
|
+
ELSE (${valAlias}.value_text)::text
|
|
422
|
+
END`
|
|
423
|
+
)
|
|
424
|
+
cfValueExprByKey[key] = caseExpr
|
|
425
|
+
const alias = sanitize(`cf:${key}`)
|
|
426
|
+
// Project as aggregated to avoid duplicates when multi values exist
|
|
427
|
+
if ((opts.fields || []).includes(`cf:${key}`) || opts.includeCustomFields === true || (requestedCustomFieldKeys.length > 0 && requestedCustomFieldKeys.includes(key))) {
|
|
428
|
+
// Use bool_or over config_json->>multi so it's valid under GROUP BY
|
|
429
|
+
const isMulti = knex.raw(`bool_or(coalesce((${defAlias}.config_json->>'multi')::boolean, false))`)
|
|
430
|
+
const aggregatedArray = `array_remove(array_agg(DISTINCT ${caseExpr.toString()}), NULL)`
|
|
431
|
+
const expr = `CASE WHEN ${isMulti.toString()}
|
|
432
|
+
THEN to_jsonb(${aggregatedArray})
|
|
433
|
+
ELSE to_jsonb(max(${caseExpr.toString()}))
|
|
434
|
+
END`
|
|
435
|
+
const multiAlias = `${alias}__is_multi`
|
|
436
|
+
q = q.select(knex.raw(`${expr} as ??`, [alias]))
|
|
437
|
+
q = q.select(knex.raw(`${isMulti.toString()} as ??`, [multiAlias]))
|
|
438
|
+
cfSelectedAliases.push(alias)
|
|
439
|
+
cfJsonAliases.add(alias)
|
|
440
|
+
cfMultiAliasByAlias.set(alias, multiAlias)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Apply cf:* filters (on raw expressions)
|
|
445
|
+
for (const f of cfFilters) {
|
|
446
|
+
if (!f.field.startsWith('cf:')) continue
|
|
447
|
+
const key = f.field.slice(3)
|
|
448
|
+
const expr = cfValueExprByKey[key]
|
|
449
|
+
if (!expr) continue
|
|
450
|
+
if ((f.op === 'like' || f.op === 'ilike') && searchActive && typeof f.value === 'string') {
|
|
451
|
+
const tokens = tokenizeText(String(f.value), searchConfig)
|
|
452
|
+
const hashes = tokens.hashes
|
|
453
|
+
if (hashes.length) {
|
|
454
|
+
const applied = this.applySearchTokens(q, {
|
|
455
|
+
entity: String(entity),
|
|
456
|
+
field: f.field,
|
|
457
|
+
hashes,
|
|
458
|
+
recordIdColumn,
|
|
459
|
+
tenantId: opts.tenantId ?? null,
|
|
460
|
+
organizationScope: orgScope,
|
|
461
|
+
tokens: tokens.tokens,
|
|
462
|
+
})
|
|
463
|
+
this.logSearchDebug('search:cf-filter', {
|
|
464
|
+
entity: String(entity),
|
|
465
|
+
field: f.field,
|
|
466
|
+
tokens: tokens.tokens,
|
|
467
|
+
hashes,
|
|
468
|
+
applied,
|
|
469
|
+
tenantId: opts.tenantId ?? null,
|
|
470
|
+
organizationScope: orgScope,
|
|
471
|
+
})
|
|
472
|
+
if (applied) continue
|
|
473
|
+
} else {
|
|
474
|
+
this.logSearchDebug('search:cf-skip-empty-hashes', {
|
|
475
|
+
entity: String(entity),
|
|
476
|
+
field: f.field,
|
|
477
|
+
value: f.value,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
switch (f.op) {
|
|
482
|
+
case 'eq': q = q.where(expr, '=', f.value); break
|
|
483
|
+
case 'ne': q = q.where(expr, '!=', f.value); break
|
|
484
|
+
case 'gt': q = q.where(expr, '>', f.value); break
|
|
485
|
+
case 'gte': q = q.where(expr, '>=', f.value); break
|
|
486
|
+
case 'lt': q = q.where(expr, '<', f.value); break
|
|
487
|
+
case 'lte': q = q.where(expr, '<=', f.value); break
|
|
488
|
+
case 'in': q = q.whereIn(expr as any, f.value ?? []); break
|
|
489
|
+
case 'nin': q = q.whereNotIn(expr as any, f.value ?? []); break
|
|
490
|
+
case 'like': q = q.where(expr, 'like', f.value); break
|
|
491
|
+
case 'ilike': q = q.where(expr, 'ilike', f.value); break
|
|
492
|
+
case 'exists': f.value ? q = q.whereNotNull(expr) : q = q.whereNull(expr); break
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Entity extensions joins (no selection yet; enables future filters/projections)
|
|
497
|
+
if (opts.includeExtensions) {
|
|
498
|
+
const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
|
|
499
|
+
const allMods = getModules() as any[]
|
|
500
|
+
const allExts = allMods.flatMap((m) => (m as any).entityExtensions || [])
|
|
501
|
+
const exts = allExts.filter((e: any) => e.base === entity)
|
|
502
|
+
const chosen = Array.isArray(opts.includeExtensions)
|
|
503
|
+
? exts.filter((e: any) => (opts.includeExtensions as string[]).includes(e.extension))
|
|
504
|
+
: exts
|
|
505
|
+
for (const e of chosen) {
|
|
506
|
+
const [, extName] = (e.extension as string).split(':')
|
|
507
|
+
const extTable = extName.endsWith('s') ? extName : `${extName}s`
|
|
508
|
+
const alias = `ext_${sanitize(extName)}`
|
|
509
|
+
q = q.leftJoin({ [alias]: extTable }, function (this: any) {
|
|
510
|
+
this.on(`${alias}.${e.join.extensionKey}`, '=', knex.raw('??', [`${table}.${e.join.baseKey}`]))
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Sorting: base fields and cf:* (use aggregated alias for cf)
|
|
516
|
+
for (const s of opts.sort || []) {
|
|
517
|
+
if (s.field.startsWith('cf:')) {
|
|
518
|
+
const key = s.field.slice(3)
|
|
519
|
+
const alias = sanitize(`cf:${key}`)
|
|
520
|
+
// Ensure included in projection to sort by
|
|
521
|
+
if (!cfSelectedAliases.includes(alias)) {
|
|
522
|
+
const expr = cfValueExprByKey[key]
|
|
523
|
+
if (expr) {
|
|
524
|
+
q = q.select(knex.raw(`max(${expr.toString()}) as ??`, [alias]))
|
|
525
|
+
cfSelectedAliases.push(alias)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
q = q.orderBy(alias, s.dir ?? 'asc')
|
|
529
|
+
} else {
|
|
530
|
+
const column = await this.resolveBaseColumn(table, s.field)
|
|
531
|
+
if (!column) continue
|
|
532
|
+
q = q.orderBy(qualify(column), s.dir ?? 'asc')
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Pagination
|
|
537
|
+
const page = opts.page?.page ?? 1
|
|
538
|
+
const pageSize = opts.page?.pageSize ?? 20
|
|
539
|
+
// Deduplicate if we joined CFs or extensions by grouping on base id
|
|
540
|
+
if ((opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) || Object.keys(cfValueExprByKey).length > 0) {
|
|
541
|
+
q = q.groupBy(`${table}.id`)
|
|
542
|
+
}
|
|
543
|
+
const countClone: any = q.clone()
|
|
544
|
+
if (typeof countClone.clearSelect === 'function') countClone.clearSelect()
|
|
545
|
+
if (typeof countClone.clearOrder === 'function') countClone.clearOrder()
|
|
546
|
+
if (typeof countClone.clearGroup === 'function') countClone.clearGroup()
|
|
547
|
+
const countRow = await countClone
|
|
548
|
+
.countDistinct(`${table}.id as count`)
|
|
549
|
+
.first()
|
|
550
|
+
const total = Number((countRow as any)?.count ?? 0)
|
|
551
|
+
const items = await q.limit(pageSize).offset((page - 1) * pageSize)
|
|
552
|
+
|
|
553
|
+
if (cfJsonAliases.size > 0) {
|
|
554
|
+
for (const row of items as any[]) {
|
|
555
|
+
for (const alias of cfJsonAliases) {
|
|
556
|
+
const multiAlias = cfMultiAliasByAlias.get(alias)
|
|
557
|
+
const isMulti = multiAlias ? Boolean(row[multiAlias]) : false
|
|
558
|
+
let raw = row[alias]
|
|
559
|
+
if (typeof raw === 'string') {
|
|
560
|
+
try { raw = JSON.parse(raw) } catch { /* ignore malformed json */ }
|
|
561
|
+
}
|
|
562
|
+
if (isMulti) {
|
|
563
|
+
if (raw == null) row[alias] = []
|
|
564
|
+
else if (Array.isArray(raw)) row[alias] = raw
|
|
565
|
+
else row[alias] = [raw]
|
|
566
|
+
} else {
|
|
567
|
+
if (Array.isArray(raw)) row[alias] = raw.length > 0 ? raw[0] : null
|
|
568
|
+
else row[alias] = raw
|
|
569
|
+
}
|
|
570
|
+
if (multiAlias) delete row[multiAlias]
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const svc = this.getEncryptionService()
|
|
576
|
+
const decryptPayload =
|
|
577
|
+
svc?.decryptEntityPayload?.bind(svc) as
|
|
578
|
+
| ((
|
|
579
|
+
entityId: EntityId,
|
|
580
|
+
payload: Record<string, unknown>,
|
|
581
|
+
tenantId: string | null,
|
|
582
|
+
organizationId: string | null,
|
|
583
|
+
) => Promise<Record<string, unknown>>)
|
|
584
|
+
| null
|
|
585
|
+
let decryptedItems = items
|
|
586
|
+
if (decryptPayload) {
|
|
587
|
+
const fallbackOrgId =
|
|
588
|
+
opts.organizationId
|
|
589
|
+
?? (Array.isArray(opts.organizationIds) && opts.organizationIds.length === 1 ? opts.organizationIds[0] : null)
|
|
590
|
+
decryptedItems = await Promise.all(
|
|
591
|
+
(items as any[]).map(async (item) => {
|
|
592
|
+
try {
|
|
593
|
+
const decrypted = await decryptPayload(
|
|
594
|
+
entity,
|
|
595
|
+
item,
|
|
596
|
+
item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
|
|
597
|
+
item?.organization_id ?? item?.organizationId ?? fallbackOrgId ?? null,
|
|
598
|
+
)
|
|
599
|
+
return { ...item, ...decrypted }
|
|
600
|
+
} catch (err) {
|
|
601
|
+
console.error('QueryEngine: error decrypting entity payload', err);
|
|
602
|
+
return item
|
|
603
|
+
}
|
|
604
|
+
})
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return { items: decryptedItems, page, pageSize, total }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private async resolveBaseColumn(table: string, field: string): Promise<string | null> {
|
|
612
|
+
if (await this.columnExists(table, field)) return field
|
|
613
|
+
if (field === 'organization_id' && await this.columnExists(table, 'id')) return 'id'
|
|
614
|
+
return null
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private async columnExists(table: string, column: string): Promise<boolean> {
|
|
618
|
+
const key = `${table}.${column}`
|
|
619
|
+
if (this.columnCache.has(key)) {
|
|
620
|
+
const cached = this.columnCache.get(key)
|
|
621
|
+
if (cached === true) return true
|
|
622
|
+
this.columnCache.delete(key)
|
|
623
|
+
}
|
|
624
|
+
const knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
|
|
625
|
+
const exists = await knex('information_schema.columns')
|
|
626
|
+
.where({ table_name: table, column_name: column })
|
|
627
|
+
.first()
|
|
628
|
+
const present = !!exists
|
|
629
|
+
if (present) this.columnCache.set(key, true)
|
|
630
|
+
else this.columnCache.delete(key)
|
|
631
|
+
return present
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private async tableExists(table: string): Promise<boolean> {
|
|
635
|
+
if (this.tableCache.has(table)) return this.tableCache.get(table) ?? false
|
|
636
|
+
const knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
|
|
637
|
+
const exists = await knex('information_schema.tables')
|
|
638
|
+
.where({ table_name: table })
|
|
639
|
+
.first()
|
|
640
|
+
const present = !!exists
|
|
641
|
+
this.tableCache.set(table, present)
|
|
642
|
+
return present
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private async hasSearchTokens(
|
|
646
|
+
entity: string,
|
|
647
|
+
tenantId: string | null,
|
|
648
|
+
orgScope?: { ids: string[]; includeNull: boolean } | null
|
|
649
|
+
): Promise<boolean> {
|
|
650
|
+
try {
|
|
651
|
+
const knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
|
|
652
|
+
const query = knex('search_tokens').select(1).where('entity_type', entity).limit(1)
|
|
653
|
+
if (tenantId !== undefined) {
|
|
654
|
+
query.andWhereRaw('tenant_id is not distinct from ?', [tenantId])
|
|
655
|
+
}
|
|
656
|
+
if (orgScope) {
|
|
657
|
+
this.applyOrganizationScope(query as any, 'search_tokens.organization_id', orgScope)
|
|
658
|
+
}
|
|
659
|
+
const row = await query.first()
|
|
660
|
+
return !!row
|
|
661
|
+
} catch (err) {
|
|
662
|
+
this.logSearchDebug('search:has-tokens-error', {
|
|
663
|
+
entity,
|
|
664
|
+
tenantId,
|
|
665
|
+
organizationScope: orgScope,
|
|
666
|
+
error: err instanceof Error ? err.message : String(err),
|
|
667
|
+
})
|
|
668
|
+
return false
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private applySearchTokens<TRecord extends ResultRow, TResult>(
|
|
673
|
+
q: Knex.QueryBuilder<TRecord, TResult>,
|
|
674
|
+
opts: {
|
|
675
|
+
entity: string
|
|
676
|
+
field: string
|
|
677
|
+
hashes: string[]
|
|
678
|
+
recordIdColumn: string
|
|
679
|
+
tenantId?: string | null
|
|
680
|
+
organizationScope?: { ids: string[]; includeNull: boolean } | null
|
|
681
|
+
combineWith?: 'and' | 'or'
|
|
682
|
+
tokens?: string[]
|
|
683
|
+
}
|
|
684
|
+
): boolean {
|
|
685
|
+
if (!opts.hashes.length) {
|
|
686
|
+
this.logSearchDebug('search:skip-no-hashes', {
|
|
687
|
+
entity: opts.entity,
|
|
688
|
+
field: opts.field,
|
|
689
|
+
tenantId: opts.tenantId ?? null,
|
|
690
|
+
organizationScope: opts.organizationScope,
|
|
691
|
+
})
|
|
692
|
+
return false
|
|
693
|
+
}
|
|
694
|
+
const alias = `st_${this.searchAliasSeq++}`
|
|
695
|
+
const combineWith = opts.combineWith === 'or' ? 'orWhereExists' : 'whereExists'
|
|
696
|
+
const engine = this
|
|
697
|
+
this.logSearchDebug('search:apply-search-tokens', {
|
|
698
|
+
entity: opts.entity,
|
|
699
|
+
field: opts.field,
|
|
700
|
+
alias,
|
|
701
|
+
tokenCount: opts.hashes.length,
|
|
702
|
+
tokens: opts.tokens,
|
|
703
|
+
tenantId: opts.tenantId ?? null,
|
|
704
|
+
organizationScope: opts.organizationScope,
|
|
705
|
+
combineWith: opts.combineWith ?? 'and',
|
|
706
|
+
})
|
|
707
|
+
;(q as any)[combineWith](function (this: Knex.QueryBuilder) {
|
|
708
|
+
this.select(1)
|
|
709
|
+
.from({ [alias]: 'search_tokens' })
|
|
710
|
+
.where(`${alias}.entity_type`, opts.entity)
|
|
711
|
+
.andWhere(`${alias}.field`, opts.field)
|
|
712
|
+
.andWhereRaw('?? = ??::text', [`${alias}.entity_id`, opts.recordIdColumn])
|
|
713
|
+
.whereIn(`${alias}.token_hash`, opts.hashes)
|
|
714
|
+
.groupBy(`${alias}.entity_id`, `${alias}.field`)
|
|
715
|
+
.havingRaw(`count(distinct ${alias}.token_hash) >= ?`, [opts.hashes.length])
|
|
716
|
+
if (opts.tenantId !== undefined) {
|
|
717
|
+
this.andWhereRaw(`${alias}.tenant_id is not distinct from ?`, [opts.tenantId ?? null])
|
|
718
|
+
}
|
|
719
|
+
if (opts.organizationScope) {
|
|
720
|
+
engine.applyOrganizationScope(this as any, `${alias}.organization_id`, opts.organizationScope)
|
|
721
|
+
}
|
|
722
|
+
})
|
|
723
|
+
return true
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private configureCustomFieldSources(
|
|
727
|
+
q: any,
|
|
728
|
+
baseTable: string,
|
|
729
|
+
baseEntity: EntityId,
|
|
730
|
+
knex: any,
|
|
731
|
+
opts: QueryOptions,
|
|
732
|
+
qualify: (column: string) => string
|
|
733
|
+
): ResolvedCustomFieldSource[] {
|
|
734
|
+
const sources: ResolvedCustomFieldSource[] = [
|
|
735
|
+
{
|
|
736
|
+
entityId: baseEntity,
|
|
737
|
+
alias: 'base',
|
|
738
|
+
table: baseTable,
|
|
739
|
+
recordIdExpr: knex.raw('??::text', [`${baseTable}.id`]),
|
|
740
|
+
},
|
|
741
|
+
]
|
|
742
|
+
const extras: QueryCustomFieldSource[] = opts.customFieldSources ?? []
|
|
743
|
+
extras.forEach((srcOpt, index) => {
|
|
744
|
+
const joinTable = srcOpt.table ?? resolveEntityTableName(this.em, srcOpt.entityId)
|
|
745
|
+
const alias = srcOpt.alias ?? `cfs_${index}`
|
|
746
|
+
const join = srcOpt.join
|
|
747
|
+
if (!join) {
|
|
748
|
+
throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`)
|
|
749
|
+
}
|
|
750
|
+
const joinArgs = { [alias]: joinTable }
|
|
751
|
+
const joinCallback = function (this: any) {
|
|
752
|
+
this.on(`${alias}.${join.toField}`, '=', qualify(join.fromField))
|
|
753
|
+
}
|
|
754
|
+
const joinType = join.type ?? 'left'
|
|
755
|
+
if (joinType === 'inner') q.join(joinArgs, joinCallback)
|
|
756
|
+
else q.leftJoin(joinArgs, joinCallback)
|
|
757
|
+
const recordColumn = srcOpt.recordIdColumn ?? 'id'
|
|
758
|
+
sources.push({
|
|
759
|
+
entityId: srcOpt.entityId,
|
|
760
|
+
alias,
|
|
761
|
+
table: joinTable,
|
|
762
|
+
recordIdExpr: knex.raw('??::text', [`${alias}.${recordColumn}`]),
|
|
763
|
+
})
|
|
764
|
+
})
|
|
765
|
+
return sources
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private logSearchDebug(event: string, payload: Record<string, unknown>) {
|
|
769
|
+
try {
|
|
770
|
+
console.info('[query:search]', event, JSON.stringify(payload))
|
|
771
|
+
} catch {
|
|
772
|
+
console.info('[query:search]', event, payload)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private resolveOrganizationScope(opts: QueryOptions): { ids: string[]; includeNull: boolean } | null {
|
|
777
|
+
if (opts.organizationIds !== undefined) {
|
|
778
|
+
const raw = (opts.organizationIds ?? []).map((id) => (typeof id === 'string' ? id.trim() : id))
|
|
779
|
+
const includeNull = raw.some((id) => id == null || id === '')
|
|
780
|
+
const ids = raw.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
781
|
+
return { ids: Array.from(new Set(ids)), includeNull }
|
|
782
|
+
}
|
|
783
|
+
if (typeof opts.organizationId === 'string' && opts.organizationId.trim().length > 0) {
|
|
784
|
+
return { ids: [opts.organizationId], includeNull: false }
|
|
785
|
+
}
|
|
786
|
+
return null
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
private applyOrganizationScope(q: any, column: string, scope: { ids: string[]; includeNull: boolean }): any {
|
|
790
|
+
if (!scope) return q
|
|
791
|
+
if (scope.ids.length === 0 && !scope.includeNull) {
|
|
792
|
+
return q.whereRaw('1 = 0')
|
|
793
|
+
}
|
|
794
|
+
return q.where((builder: any) => {
|
|
795
|
+
let applied = false
|
|
796
|
+
if (scope.ids.length > 0) {
|
|
797
|
+
builder.whereIn(column as any, scope.ids)
|
|
798
|
+
applied = true
|
|
799
|
+
}
|
|
800
|
+
if (scope.includeNull) {
|
|
801
|
+
if (applied) builder.orWhereNull(column)
|
|
802
|
+
else builder.whereNull(column)
|
|
803
|
+
applied = true
|
|
804
|
+
}
|
|
805
|
+
if (!applied) builder.whereRaw('1 = 0')
|
|
806
|
+
})
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
}
|
|
810
|
+
const computeScore = (cfg: Record<string, unknown>, kind: string, entityIndex: number) => {
|
|
811
|
+
const listVisibleScore = cfg.listVisible === false ? 0 : 1
|
|
812
|
+
const formEditableScore = cfg.formEditable === false ? 0 : 1
|
|
813
|
+
const filterableScore = cfg.filterable ? 1 : 0
|
|
814
|
+
const kindScore = (() => {
|
|
815
|
+
switch (kind) {
|
|
816
|
+
case 'dictionary':
|
|
817
|
+
return 8
|
|
818
|
+
case 'relation':
|
|
819
|
+
return 6
|
|
820
|
+
case 'select':
|
|
821
|
+
return 4
|
|
822
|
+
case 'multiline':
|
|
823
|
+
return 3
|
|
824
|
+
case 'boolean':
|
|
825
|
+
case 'integer':
|
|
826
|
+
case 'float':
|
|
827
|
+
return 2
|
|
828
|
+
default:
|
|
829
|
+
return 1
|
|
830
|
+
}
|
|
831
|
+
})()
|
|
832
|
+
const optionsBonus = Array.isArray(cfg.options) && cfg.options.length ? 2 : 0
|
|
833
|
+
const dictionaryBonus = typeof cfg.dictionaryId === 'string' && cfg.dictionaryId.trim().length ? 5 : 0
|
|
834
|
+
const base = (listVisibleScore * 16) + (formEditableScore * 8) + (filterableScore * 4) + kindScore + optionsBonus + dictionaryBonus
|
|
835
|
+
const penalty = typeof cfg.priority === 'number' ? cfg.priority : 0
|
|
836
|
+
return { base, penalty, entityIndex }
|
|
837
|
+
}
|