@open-mercato/core 0.5.1-develop.2691.d8a0934b37 → 0.5.1-develop.2699.f8b50c8046
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/dist/modules/api_keys/data/entities.js +1 -1
- package/dist/modules/api_keys/data/entities.js.map +1 -1
- package/dist/modules/api_keys/services/apiKeyService.js +5 -5
- package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
- package/dist/modules/attachments/api/library/[id]/route.js +1 -1
- package/dist/modules/attachments/api/library/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/library/route.js +7 -9
- package/dist/modules/attachments/api/library/route.js.map +2 -2
- package/dist/modules/attachments/api/partitions/route.js +3 -3
- package/dist/modules/attachments/api/partitions/route.js.map +2 -2
- package/dist/modules/attachments/api/route.js +6 -5
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/api/transfer/route.js +1 -1
- package/dist/modules/attachments/api/transfer/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +2 -1
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/lib/ocrQueue.js +1 -1
- package/dist/modules/attachments/lib/ocrQueue.js.map +2 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/export/route.js.map +2 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/route.js.map +2 -2
- package/dist/modules/audit_logs/data/entities.js +1 -1
- package/dist/modules/audit_logs/data/entities.js.map +1 -1
- package/dist/modules/audit_logs/services/actionLogService.js +77 -70
- package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
- package/dist/modules/auth/api/roles/acl/route.js +1 -1
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/users/acl/route.js +2 -2
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/api/users/resend-invite/route.js +1 -1
- package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
- package/dist/modules/auth/cli.js +12 -6
- package/dist/modules/auth/cli.js.map +2 -2
- package/dist/modules/auth/commands/users.js +1 -1
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/data/entities.js +1 -1
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/lib/setup-app.js +3 -3
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/auth/services/authService.js +2 -2
- package/dist/modules/auth/services/authService.js.map +2 -2
- package/dist/modules/business_rules/api/rules/route.js +3 -3
- package/dist/modules/business_rules/api/rules/route.js.map +2 -2
- package/dist/modules/business_rules/api/sets/[id]/members/route.js +7 -4
- package/dist/modules/business_rules/api/sets/[id]/members/route.js.map +2 -2
- package/dist/modules/business_rules/api/sets/route.js +3 -3
- package/dist/modules/business_rules/api/sets/route.js.map +2 -2
- package/dist/modules/business_rules/cli.js +1 -1
- package/dist/modules/business_rules/cli.js.map +2 -2
- package/dist/modules/business_rules/data/entities.js +2 -9
- package/dist/modules/business_rules/data/entities.js.map +2 -2
- package/dist/modules/business_rules/lib/rule-engine.js +1 -1
- package/dist/modules/business_rules/lib/rule-engine.js.map +2 -2
- package/dist/modules/catalog/api/option-schemas/route.js +0 -1
- package/dist/modules/catalog/api/option-schemas/route.js.map +2 -2
- package/dist/modules/catalog/data/entities.js +2 -11
- package/dist/modules/catalog/data/entities.js.map +2 -2
- package/dist/modules/configs/data/entities.js +2 -1
- package/dist/modules/configs/data/entities.js.map +2 -2
- package/dist/modules/currencies/commands/fetch-configs.js +3 -3
- package/dist/modules/currencies/commands/fetch-configs.js.map +2 -2
- package/dist/modules/currencies/data/entities.js +1 -1
- package/dist/modules/currencies/data/entities.js.map +2 -2
- package/dist/modules/customer_accounts/api/signup.js +1 -1
- package/dist/modules/customer_accounts/api/signup.js.map +2 -2
- package/dist/modules/customer_accounts/data/entities.js +1 -1
- package/dist/modules/customer_accounts/data/entities.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerInvitationService.js +1 -1
- package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerSessionService.js +1 -1
- package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerTokenService.js +12 -7
- package/dist/modules/customer_accounts/services/customerTokenService.js.map +2 -2
- package/dist/modules/customers/api/interactions/conflicts/route.js +19 -17
- package/dist/modules/customers/api/interactions/conflicts/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/counts/route.js +7 -6
- package/dist/modules/customers/api/interactions/counts/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/route.js +28 -42
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/customers/api/utils.js +29 -24
- package/dist/modules/customers/api/utils.js.map +2 -2
- package/dist/modules/customers/cli.js +45 -40
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/commands/dictionaries.js +1 -1
- package/dist/modules/customers/commands/dictionaries.js.map +2 -2
- package/dist/modules/customers/commands/tags.js +1 -1
- package/dist/modules/customers/commands/tags.js.map +2 -2
- package/dist/modules/customers/data/entities.js +2 -12
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/lib/interactionProjection.js +18 -15
- package/dist/modules/customers/lib/interactionProjection.js.map +2 -2
- package/dist/modules/customers/lib/personCompanyLinkTable.js +6 -8
- package/dist/modules/customers/lib/personCompanyLinkTable.js.map +2 -2
- package/dist/modules/dashboards/api/roles/widgets/route.js +1 -1
- package/dist/modules/dashboards/api/roles/widgets/route.js.map +2 -2
- package/dist/modules/dashboards/api/users/widgets/route.js +1 -1
- package/dist/modules/dashboards/api/users/widgets/route.js.map +2 -2
- package/dist/modules/dashboards/data/entities.js +1 -1
- package/dist/modules/dashboards/data/entities.js.map +1 -1
- package/dist/modules/data_sync/api/mappings/route.js +1 -1
- package/dist/modules/data_sync/api/mappings/route.js.map +2 -2
- package/dist/modules/data_sync/data/entities.js +2 -1
- package/dist/modules/data_sync/data/entities.js.map +2 -2
- package/dist/modules/data_sync/lib/id-mapping.js +1 -1
- package/dist/modules/data_sync/lib/id-mapping.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-run-service.js +1 -1
- package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
- package/dist/modules/dictionaries/commands/factory.js +1 -1
- package/dist/modules/dictionaries/commands/factory.js.map +2 -2
- package/dist/modules/dictionaries/data/entities.js +2 -9
- package/dist/modules/dictionaries/data/entities.js.map +2 -2
- package/dist/modules/directory/commands/organizations.js +4 -4
- package/dist/modules/directory/commands/organizations.js.map +2 -2
- package/dist/modules/directory/data/entities.js +2 -1
- package/dist/modules/directory/data/entities.js.map +2 -2
- package/dist/modules/entities/api/definitions.js +2 -2
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/api/encryption.js +2 -2
- package/dist/modules/entities/api/encryption.js.map +2 -2
- package/dist/modules/entities/api/relations/options.js +2 -2
- package/dist/modules/entities/api/relations/options.js.map +2 -2
- package/dist/modules/entities/cli.js +4 -4
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/entities/data/entities.js +1 -1
- package/dist/modules/entities/data/entities.js.map +2 -2
- package/dist/modules/entities/lib/field-definitions.js +2 -2
- package/dist/modules/entities/lib/field-definitions.js.map +2 -2
- package/dist/modules/entities/lib/register.js +1 -1
- package/dist/modules/entities/lib/register.js.map +2 -2
- package/dist/modules/feature_toggles/data/entities.js +2 -9
- package/dist/modules/feature_toggles/data/entities.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/counts/route.js +3 -6
- package/dist/modules/inbox_ops/api/proposals/counts/route.js.map +2 -2
- package/dist/modules/inbox_ops/data/entities.js +2 -8
- package/dist/modules/inbox_ops/data/entities.js.map +2 -2
- package/dist/modules/inbox_ops/lib/messagesIntegration.js +6 -6
- package/dist/modules/inbox_ops/lib/messagesIntegration.js.map +2 -2
- package/dist/modules/integrations/data/entities.js +2 -1
- package/dist/modules/integrations/data/entities.js.map +2 -2
- package/dist/modules/integrations/lib/credentials-service.js +1 -1
- package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
- package/dist/modules/integrations/lib/log-service.js +1 -1
- package/dist/modules/integrations/lib/log-service.js.map +2 -2
- package/dist/modules/integrations/lib/state-service.js +1 -1
- package/dist/modules/integrations/lib/state-service.js.map +2 -2
- package/dist/modules/messages/api/route.js +90 -93
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/api/unread-count/route.js +8 -7
- package/dist/modules/messages/api/unread-count/route.js.map +2 -2
- package/dist/modules/messages/commands/confirmations.js +1 -1
- package/dist/modules/messages/commands/confirmations.js.map +2 -2
- package/dist/modules/messages/commands/messages.js +3 -3
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/data/entities.js +2 -1
- package/dist/modules/messages/data/entities.js.map +2 -2
- package/dist/modules/messages/lib/email-sender.js +1 -1
- package/dist/modules/messages/lib/email-sender.js.map +2 -2
- package/dist/modules/messages/lib/searchLookup.js +8 -8
- package/dist/modules/messages/lib/searchLookup.js.map +2 -2
- package/dist/modules/messages/lib/tokenConsumption.js +9 -4
- package/dist/modules/messages/lib/tokenConsumption.js.map +2 -2
- package/dist/modules/notifications/data/entities.js +2 -1
- package/dist/modules/notifications/data/entities.js.map +2 -2
- package/dist/modules/notifications/lib/notificationRecipients.js +15 -5
- package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
- package/dist/modules/notifications/lib/notificationService.js +39 -34
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/notifications/workers/create-notification.worker.js +14 -13
- package/dist/modules/notifications/workers/create-notification.worker.js.map +2 -2
- package/dist/modules/payment_gateways/api/transactions/route.js +2 -2
- package/dist/modules/payment_gateways/api/transactions/route.js.map +2 -2
- package/dist/modules/payment_gateways/data/entities.js +2 -1
- package/dist/modules/payment_gateways/data/entities.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +1 -1
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/webhook-utils.js +2 -2
- package/dist/modules/payment_gateways/lib/webhook-utils.js.map +2 -2
- package/dist/modules/perspectives/data/entities.js +1 -1
- package/dist/modules/perspectives/data/entities.js.map +2 -2
- package/dist/modules/planner/data/entities.js +1 -1
- package/dist/modules/planner/data/entities.js.map +2 -2
- package/dist/modules/progress/data/entities.js +2 -1
- package/dist/modules/progress/data/entities.js.map +2 -2
- package/dist/modules/progress/lib/progressServiceImpl.js +1 -1
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/query_index/api/status.js +66 -57
- package/dist/modules/query_index/api/status.js.map +2 -2
- package/dist/modules/query_index/cli.js +39 -24
- package/dist/modules/query_index/cli.js.map +2 -2
- package/dist/modules/query_index/data/entities.js +1 -1
- package/dist/modules/query_index/data/entities.js.map +2 -2
- package/dist/modules/query_index/di.js +25 -13
- package/dist/modules/query_index/di.js.map +2 -2
- package/dist/modules/query_index/lib/batch.js +31 -33
- package/dist/modules/query_index/lib/batch.js.map +2 -2
- package/dist/modules/query_index/lib/coverage.js +63 -50
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +592 -588
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/query_index/lib/indexer.js +74 -47
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/query_index/lib/jobs.js +37 -24
- package/dist/modules/query_index/lib/jobs.js.map +2 -2
- package/dist/modules/query_index/lib/purge.js +19 -11
- package/dist/modules/query_index/lib/purge.js.map +2 -2
- package/dist/modules/query_index/lib/reindexer.js +47 -44
- package/dist/modules/query_index/lib/reindexer.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +47 -25
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/query_index/lib/stale.js +14 -12
- package/dist/modules/query_index/lib/stale.js.map +2 -2
- package/dist/modules/query_index/lib/subscriber-scope.js +2 -2
- package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
- package/dist/modules/query_index/subscribers/delete_one.js +3 -2
- package/dist/modules/query_index/subscribers/delete_one.js.map +2 -2
- package/dist/modules/resources/commands/tag-assignments.js +1 -1
- package/dist/modules/resources/commands/tag-assignments.js.map +2 -2
- package/dist/modules/resources/commands/tags.js +1 -1
- package/dist/modules/resources/commands/tags.js.map +2 -2
- package/dist/modules/resources/data/entities.js +2 -1
- package/dist/modules/resources/data/entities.js.map +2 -2
- package/dist/modules/sales/commands/documentAddresses.js +2 -2
- package/dist/modules/sales/commands/documentAddresses.js.map +2 -2
- package/dist/modules/sales/commands/notes.js.map +2 -2
- package/dist/modules/sales/commands/tags.js +1 -1
- package/dist/modules/sales/commands/tags.js.map +2 -2
- package/dist/modules/sales/data/enrichers.js +9 -8
- package/dist/modules/sales/data/enrichers.js.map +2 -2
- package/dist/modules/sales/data/entities.js +2 -11
- package/dist/modules/sales/data/entities.js.map +2 -2
- package/dist/modules/shipping_carriers/data/entities.js +2 -1
- package/dist/modules/shipping_carriers/data/entities.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipping-service.js +1 -1
- package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/webhook-utils.js +2 -2
- package/dist/modules/shipping_carriers/lib/webhook-utils.js.map +2 -2
- package/dist/modules/staff/data/entities.js +1 -1
- package/dist/modules/staff/data/entities.js.map +2 -2
- package/dist/modules/translations/api/[entityType]/[entityId]/route.js +3 -5
- package/dist/modules/translations/api/[entityType]/[entityId]/route.js.map +2 -2
- package/dist/modules/translations/api/context.js +2 -2
- package/dist/modules/translations/api/context.js.map +2 -2
- package/dist/modules/translations/commands/translations.js +46 -39
- package/dist/modules/translations/commands/translations.js.map +2 -2
- package/dist/modules/translations/components/TranslationManager.js +19 -10
- package/dist/modules/translations/components/TranslationManager.js.map +2 -2
- package/dist/modules/translations/data/entities.js +1 -1
- package/dist/modules/translations/data/entities.js.map +2 -2
- package/dist/modules/translations/lib/apply.js +4 -4
- package/dist/modules/translations/lib/apply.js.map +2 -2
- package/dist/modules/translations/lib/batch.js +3 -2
- package/dist/modules/translations/lib/batch.js.map +2 -2
- package/dist/modules/translations/subscribers/cleanup.js +3 -5
- package/dist/modules/translations/subscribers/cleanup.js.map +2 -2
- package/dist/modules/workflows/api/definitions/route.js +1 -1
- package/dist/modules/workflows/api/definitions/route.js.map +2 -2
- package/dist/modules/workflows/cli.js +5 -5
- package/dist/modules/workflows/cli.js.map +2 -2
- package/dist/modules/workflows/data/entities.js +2 -1
- package/dist/modules/workflows/data/entities.js.map +2 -2
- package/dist/modules/workflows/lib/event-logger.js +2 -2
- package/dist/modules/workflows/lib/event-logger.js.map +2 -2
- package/dist/modules/workflows/lib/seeds.js +16 -1
- package/dist/modules/workflows/lib/seeds.js.map +2 -2
- package/dist/modules/workflows/lib/step-handler.js +3 -3
- package/dist/modules/workflows/lib/step-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-handler.js +1 -1
- package/dist/modules/workflows/lib/task-handler.js.map +2 -2
- package/dist/modules/workflows/lib/transition-handler.js +1 -1
- package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/jest.config.cjs +4 -2
- package/package.json +3 -3
- package/src/modules/api_keys/data/entities.ts +1 -1
- package/src/modules/api_keys/services/apiKeyService.ts +5 -5
- package/src/modules/attachments/api/library/[id]/route.ts +1 -1
- package/src/modules/attachments/api/library/route.ts +10 -12
- package/src/modules/attachments/api/partitions/route.ts +3 -3
- package/src/modules/attachments/api/route.ts +10 -8
- package/src/modules/attachments/api/transfer/route.ts +1 -1
- package/src/modules/attachments/data/entities.ts +2 -1
- package/src/modules/attachments/lib/ocrQueue.ts +1 -1
- package/src/modules/audit_logs/api/audit-logs/actions/export/route.ts +4 -4
- package/src/modules/audit_logs/api/audit-logs/actions/route.ts +4 -4
- package/src/modules/audit_logs/data/entities.ts +1 -1
- package/src/modules/audit_logs/services/actionLogService.ts +96 -87
- package/src/modules/auth/api/roles/acl/route.ts +1 -1
- package/src/modules/auth/api/users/acl/route.ts +2 -2
- package/src/modules/auth/api/users/resend-invite/route.ts +1 -1
- package/src/modules/auth/cli.ts +46 -40
- package/src/modules/auth/commands/users.ts +1 -1
- package/src/modules/auth/data/entities.ts +1 -1
- package/src/modules/auth/lib/setup-app.ts +3 -3
- package/src/modules/auth/services/authService.ts +2 -2
- package/src/modules/business_rules/api/rules/route.ts +3 -3
- package/src/modules/business_rules/api/sets/[id]/members/route.ts +7 -4
- package/src/modules/business_rules/api/sets/route.ts +3 -3
- package/src/modules/business_rules/cli.ts +1 -1
- package/src/modules/business_rules/data/entities.ts +2 -9
- package/src/modules/business_rules/lib/rule-engine.ts +1 -1
- package/src/modules/catalog/api/option-schemas/route.ts +0 -1
- package/src/modules/catalog/data/entities.ts +2 -11
- package/src/modules/configs/data/entities.ts +2 -1
- package/src/modules/currencies/commands/fetch-configs.ts +3 -3
- package/src/modules/currencies/data/entities.ts +1 -1
- package/src/modules/customer_accounts/api/signup.ts +1 -1
- package/src/modules/customer_accounts/data/entities.ts +1 -1
- package/src/modules/customer_accounts/services/customerInvitationService.ts +1 -1
- package/src/modules/customer_accounts/services/customerSessionService.ts +1 -1
- package/src/modules/customer_accounts/services/customerTokenService.ts +26 -15
- package/src/modules/customers/api/interactions/conflicts/route.ts +26 -23
- package/src/modules/customers/api/interactions/counts/route.ts +13 -11
- package/src/modules/customers/api/interactions/route.ts +32 -44
- package/src/modules/customers/api/utils.ts +45 -37
- package/src/modules/customers/cli.ts +88 -67
- package/src/modules/customers/commands/dictionaries.ts +1 -1
- package/src/modules/customers/commands/tags.ts +1 -1
- package/src/modules/customers/data/entities.ts +2 -12
- package/src/modules/customers/lib/interactionProjection.ts +36 -25
- package/src/modules/customers/lib/personCompanyLinkTable.ts +13 -18
- package/src/modules/dashboards/api/roles/widgets/route.ts +1 -1
- package/src/modules/dashboards/api/users/widgets/route.ts +1 -1
- package/src/modules/dashboards/data/entities.ts +1 -1
- package/src/modules/data_sync/api/mappings/route.ts +1 -1
- package/src/modules/data_sync/data/entities.ts +2 -1
- package/src/modules/data_sync/lib/id-mapping.ts +1 -1
- package/src/modules/data_sync/lib/sync-run-service.ts +1 -1
- package/src/modules/dictionaries/commands/factory.ts +1 -1
- package/src/modules/dictionaries/data/entities.ts +2 -9
- package/src/modules/directory/commands/organizations.ts +4 -4
- package/src/modules/directory/data/entities.ts +2 -1
- package/src/modules/entities/api/definitions.ts +2 -2
- package/src/modules/entities/api/encryption.ts +2 -2
- package/src/modules/entities/api/relations/options.ts +8 -3
- package/src/modules/entities/cli.ts +4 -4
- package/src/modules/entities/data/entities.ts +1 -1
- package/src/modules/entities/lib/field-definitions.ts +2 -2
- package/src/modules/entities/lib/register.ts +1 -1
- package/src/modules/feature_toggles/data/entities.ts +2 -9
- package/src/modules/inbox_ops/api/proposals/counts/route.ts +10 -10
- package/src/modules/inbox_ops/data/entities.ts +2 -8
- package/src/modules/inbox_ops/lib/messagesIntegration.ts +12 -11
- package/src/modules/integrations/data/entities.ts +2 -1
- package/src/modules/integrations/lib/credentials-service.ts +1 -1
- package/src/modules/integrations/lib/log-service.ts +1 -1
- package/src/modules/integrations/lib/state-service.ts +1 -1
- package/src/modules/messages/api/route.ts +134 -123
- package/src/modules/messages/api/unread-count/route.ts +19 -16
- package/src/modules/messages/commands/confirmations.ts +1 -1
- package/src/modules/messages/commands/messages.ts +3 -3
- package/src/modules/messages/data/entities.ts +2 -1
- package/src/modules/messages/lib/email-sender.ts +1 -1
- package/src/modules/messages/lib/searchLookup.ts +16 -13
- package/src/modules/messages/lib/tokenConsumption.ts +16 -8
- package/src/modules/notifications/data/entities.ts +2 -1
- package/src/modules/notifications/lib/notificationRecipients.ts +42 -26
- package/src/modules/notifications/lib/notificationService.ts +53 -42
- package/src/modules/notifications/workers/create-notification.worker.ts +20 -17
- package/src/modules/payment_gateways/api/transactions/route.ts +2 -2
- package/src/modules/payment_gateways/data/entities.ts +2 -1
- package/src/modules/payment_gateways/lib/gateway-service.ts +1 -1
- package/src/modules/payment_gateways/lib/webhook-utils.ts +2 -2
- package/src/modules/perspectives/data/entities.ts +1 -1
- package/src/modules/planner/data/entities.ts +1 -1
- package/src/modules/progress/data/entities.ts +2 -1
- package/src/modules/progress/lib/progressServiceImpl.ts +1 -1
- package/src/modules/query_index/api/status.ts +85 -71
- package/src/modules/query_index/cli.ts +51 -31
- package/src/modules/query_index/data/entities.ts +1 -1
- package/src/modules/query_index/di.ts +41 -16
- package/src/modules/query_index/lib/batch.ts +68 -55
- package/src/modules/query_index/lib/coverage.ts +115 -88
- package/src/modules/query_index/lib/engine.ts +1036 -1096
- package/src/modules/query_index/lib/indexer.ts +115 -79
- package/src/modules/query_index/lib/jobs.ts +51 -31
- package/src/modules/query_index/lib/purge.ts +25 -19
- package/src/modules/query_index/lib/reindexer.ts +97 -84
- package/src/modules/query_index/lib/search-tokens.ts +67 -36
- package/src/modules/query_index/lib/stale.ts +14 -17
- package/src/modules/query_index/lib/subscriber-scope.ts +6 -5
- package/src/modules/query_index/subscribers/delete_one.ts +9 -6
- package/src/modules/resources/commands/tag-assignments.ts +1 -1
- package/src/modules/resources/commands/tags.ts +1 -1
- package/src/modules/resources/data/entities.ts +2 -1
- package/src/modules/sales/commands/documentAddresses.ts +2 -2
- package/src/modules/sales/commands/notes.ts +1 -1
- package/src/modules/sales/commands/tags.ts +1 -1
- package/src/modules/sales/data/enrichers.ts +17 -13
- package/src/modules/sales/data/entities.ts +2 -11
- package/src/modules/shipping_carriers/data/entities.ts +2 -1
- package/src/modules/shipping_carriers/lib/shipping-service.ts +1 -1
- package/src/modules/shipping_carriers/lib/webhook-utils.ts +2 -2
- package/src/modules/staff/data/entities.ts +1 -1
- package/src/modules/translations/api/[entityType]/[entityId]/route.ts +14 -11
- package/src/modules/translations/api/context.ts +4 -4
- package/src/modules/translations/commands/translations.ts +116 -81
- package/src/modules/translations/components/TranslationManager.tsx +23 -14
- package/src/modules/translations/data/entities.ts +1 -1
- package/src/modules/translations/i18n/de.json +1 -0
- package/src/modules/translations/i18n/en.json +1 -0
- package/src/modules/translations/i18n/es.json +1 -0
- package/src/modules/translations/i18n/pl.json +1 -0
- package/src/modules/translations/lib/apply.ts +6 -6
- package/src/modules/translations/lib/batch.ts +9 -7
- package/src/modules/translations/subscribers/cleanup.ts +10 -11
- package/src/modules/workflows/api/definitions/route.ts +1 -1
- package/src/modules/workflows/cli.ts +5 -5
- package/src/modules/workflows/data/entities.ts +2 -1
- package/src/modules/workflows/lib/event-logger.ts +2 -2
- package/src/modules/workflows/lib/seeds.ts +16 -1
- package/src/modules/workflows/lib/step-handler.ts +3 -3
- package/src/modules/workflows/lib/task-handler.ts +1 -1
- package/src/modules/workflows/lib/transition-handler.ts +1 -1
- package/src/modules/workflows/lib/workflow-executor.ts +2 -2
|
@@ -3,7 +3,7 @@ import { SortDir } from '@open-mercato/shared/lib/query/types'
|
|
|
3
3
|
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
4
4
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
5
5
|
import { BasicQueryEngine, resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
|
|
6
|
-
import type
|
|
6
|
+
import { type Kysely, sql, type RawBuilder } from 'kysely'
|
|
7
7
|
import type { EventBus } from '@open-mercato/events'
|
|
8
8
|
import { readCoverageSnapshot, refreshCoverageSnapshot } from './coverage'
|
|
9
9
|
import { createProfiler, shouldEnableProfiler, type Profiler } from '@open-mercato/shared/lib/profiler'
|
|
@@ -58,20 +58,17 @@ function resolveBooleanEnv(names: readonly string[], defaultValue: boolean): boo
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
function resolveDebugVerbosity(): boolean {
|
|
61
|
-
// Check explicit OM_QUERY_INDEX_DEBUG flag first
|
|
62
61
|
const queryIndexDebug = process.env.OM_QUERY_INDEX_DEBUG
|
|
63
62
|
if (queryIndexDebug !== undefined) {
|
|
64
63
|
return parseBooleanToken(queryIndexDebug) ?? false
|
|
65
64
|
}
|
|
66
|
-
// Fall back to log level or NODE_ENV
|
|
67
65
|
const level = (process.env.LOG_VERBOSITY ?? process.env.LOG_LEVEL ?? '').toLowerCase()
|
|
68
66
|
if (['debug', 'trace', 'silly'].includes(level)) return true
|
|
69
|
-
// Default to false (don't spam logs in development)
|
|
70
67
|
return false
|
|
71
68
|
}
|
|
72
69
|
|
|
73
|
-
type
|
|
74
|
-
type
|
|
70
|
+
type AnyDb = Kysely<any>
|
|
71
|
+
type AnyBuilder = any
|
|
75
72
|
type NormalizedFilter = { field: string; op: FilterOp; value?: unknown }
|
|
76
73
|
type IndexDocSource = { alias: string; entityId: EntityId; recordIdColumn: string }
|
|
77
74
|
type PreparedCustomFieldSource = {
|
|
@@ -143,8 +140,13 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
143
140
|
}
|
|
144
141
|
}
|
|
145
142
|
|
|
143
|
+
private getDb(): AnyDb {
|
|
144
|
+
const emAny = this.em as any
|
|
145
|
+
if (typeof emAny.getKysely === 'function') return emAny.getKysely() as AnyDb
|
|
146
|
+
throw new Error('HybridQueryEngine requires an EntityManager exposing getKysely() (MikroORM v7)')
|
|
147
|
+
}
|
|
148
|
+
|
|
146
149
|
async query<T = unknown>(entity: EntityId, opts: QueryOptions = {}): Promise<QueryResult<T>> {
|
|
147
|
-
// --- UMES query extension: before-query pipeline ---
|
|
148
150
|
const ext: QueryExtensionsConfig | undefined = opts.extensions
|
|
149
151
|
let hybridExtCtx: QueryExtensionContext | null = null
|
|
150
152
|
const noopDi = { resolve: <R = unknown>(_name: string): R => { throw new Error('No DI context') } }
|
|
@@ -167,7 +169,6 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
167
169
|
}
|
|
168
170
|
opts = beforeResult.query
|
|
169
171
|
}
|
|
170
|
-
// Strip extensions so fallback to BasicQueryEngine doesn't double-execute
|
|
171
172
|
const { extensions: _stripExt, ...coreOpts } = opts
|
|
172
173
|
opts = coreOpts
|
|
173
174
|
|
|
@@ -217,8 +218,8 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
217
218
|
}
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
const
|
|
221
|
-
profiler.mark('query:
|
|
221
|
+
const db = this.getDb()
|
|
222
|
+
profiler.mark('query:db_ready')
|
|
222
223
|
const baseTable = resolveEntityTableName(this.em, entity)
|
|
223
224
|
profiler.mark('query:base_table_resolved')
|
|
224
225
|
const searchConfig = resolveSearchConfig()
|
|
@@ -343,688 +344,583 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
343
344
|
}
|
|
344
345
|
|
|
345
346
|
const qualify = (col: string) => `b.${col}`
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const resolvedJoinsConfig = resolveJoins(
|
|
352
|
-
baseTable,
|
|
353
|
-
[...(opts.joins ?? []), ...buildFilterableCustomFieldJoins(opts.customFieldSources)],
|
|
354
|
-
(entityId) => resolveEntityTableName(this.em, entityId as any),
|
|
355
|
-
)
|
|
356
|
-
const joinMap = new Map<string, ResolvedJoin>()
|
|
357
|
-
const aliasTables = new Map<string, string>()
|
|
358
|
-
aliasTables.set('b', baseTable)
|
|
359
|
-
aliasTables.set('base', baseTable)
|
|
360
|
-
aliasTables.set(baseTable, baseTable)
|
|
361
|
-
for (const join of resolvedJoinsConfig) {
|
|
362
|
-
joinMap.set(join.alias, join)
|
|
363
|
-
aliasTables.set(join.alias, join.table)
|
|
364
|
-
}
|
|
365
|
-
const { baseFilters, joinFilters } = partitionFilters(baseTable, normalizedFilters, joinMap)
|
|
347
|
+
const columns = await this.getBaseColumnsForEntity(entity)
|
|
348
|
+
const hasOrganizationColumn = await this.columnExists(baseTable, 'organization_id')
|
|
349
|
+
const hasTenantColumn = await this.columnExists(baseTable, 'tenant_id')
|
|
350
|
+
const hasDeletedColumn = await this.columnExists(baseTable, 'deleted_at')
|
|
366
351
|
|
|
367
|
-
|
|
352
|
+
if (!opts.tenantId) throw new Error('QueryEngine: tenantId is required')
|
|
368
353
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
354
|
+
const resolvedJoinsConfig = resolveJoins(
|
|
355
|
+
baseTable,
|
|
356
|
+
[...(opts.joins ?? []), ...buildFilterableCustomFieldJoins(opts.customFieldSources)],
|
|
357
|
+
(entityId) => resolveEntityTableName(this.em, entityId as any),
|
|
358
|
+
)
|
|
359
|
+
const joinMap = new Map<string, ResolvedJoin>()
|
|
360
|
+
const aliasTables = new Map<string, string>()
|
|
361
|
+
aliasTables.set('b', baseTable)
|
|
362
|
+
aliasTables.set('base', baseTable)
|
|
363
|
+
aliasTables.set(baseTable, baseTable)
|
|
364
|
+
for (const join of resolvedJoinsConfig) {
|
|
365
|
+
joinMap.set(join.alias, join)
|
|
366
|
+
aliasTables.set(join.alias, join.table)
|
|
367
|
+
}
|
|
368
|
+
const { baseFilters, joinFilters } = partitionFilters(baseTable, normalizedFilters, joinMap)
|
|
378
369
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (optimizedCountBuilder) optimizedCountBuilder = optimizedCountBuilder.where(qualify('tenant_id'), opts.tenantId)
|
|
386
|
-
}
|
|
387
|
-
if (!opts.withDeleted && hasDeletedColumn) {
|
|
388
|
-
builder = builder.whereNull(qualify('deleted_at'))
|
|
389
|
-
if (optimizedCountBuilder) optimizedCountBuilder = optimizedCountBuilder.whereNull(qualify('deleted_at'))
|
|
390
|
-
}
|
|
370
|
+
const searchRuntimeBase = {
|
|
371
|
+
enabled: false,
|
|
372
|
+
config: searchConfig,
|
|
373
|
+
organizationScope: orgScope,
|
|
374
|
+
tenantId: opts.tenantId ?? null,
|
|
375
|
+
}
|
|
391
376
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
baseJoinParts.push(`ei.tenant_id = ${qualify('tenant_id')}`)
|
|
401
|
-
baseJoinParts.push('ei.tenant_id is not null')
|
|
402
|
-
}
|
|
403
|
-
if (!opts.withDeleted) baseJoinParts.push(`ei.deleted_at is null`)
|
|
404
|
-
builder = builder.leftJoin({ ei: 'entity_indexes' }, knex.raw(baseJoinParts.join(' AND ')))
|
|
405
|
-
|
|
406
|
-
const columns = await this.getBaseColumnsForEntity(entity)
|
|
407
|
-
const indexSources: IndexDocSource[] = [{ alias: 'ei', entityId: entity, recordIdColumn: 'b.id' }]
|
|
408
|
-
|
|
409
|
-
const shouldAttachCustomSources = Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0 && (wantsCf || searchEnabled)
|
|
410
|
-
if (shouldAttachCustomSources) {
|
|
411
|
-
const prepared = this.prepareCustomFieldSources(knex, builder, opts.customFieldSources ?? [], qualify)
|
|
412
|
-
builder = prepared.builder
|
|
413
|
-
for (const source of prepared.sources) {
|
|
414
|
-
const fragments: string[] = []
|
|
415
|
-
fragments.push(`${source.indexAlias}.entity_type = ${knex.raw('?', [source.entityId]).toString()}`)
|
|
416
|
-
fragments.push(`${source.indexAlias}.entity_id = (${knex.raw('??::text', [`${source.alias}.${source.recordIdColumn}`]).toString()})`)
|
|
417
|
-
const orgExpr = source.organizationField
|
|
418
|
-
? knex.raw('??', [`${source.alias}.${source.organizationField}`]).toString()
|
|
419
|
-
: (columns.has('organization_id') ? qualify('organization_id') : null)
|
|
420
|
-
if (orgExpr) {
|
|
421
|
-
fragments.push(`${source.indexAlias}.organization_id = ${orgExpr}`)
|
|
422
|
-
fragments.push(`${source.indexAlias}.organization_id is not null`)
|
|
377
|
+
// Prepare index sources for JSONB custom-field access.
|
|
378
|
+
const indexSources: IndexDocSource[] = [{ alias: 'ei', entityId: entity, recordIdColumn: 'b.id' }]
|
|
379
|
+
let preparedCfSources: PreparedCustomFieldSource[] = []
|
|
380
|
+
const shouldAttachCustomSources = Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0 && (wantsCf || searchEnabled)
|
|
381
|
+
if (shouldAttachCustomSources) {
|
|
382
|
+
preparedCfSources = this.prepareCustomFieldSources(opts.customFieldSources ?? [])
|
|
383
|
+
for (const source of preparedCfSources) {
|
|
384
|
+
indexSources.push({ alias: source.indexAlias, entityId: source.entityId, recordIdColumn: `${source.alias}.${source.recordIdColumn}` })
|
|
423
385
|
}
|
|
424
|
-
const tenantExpr = source.tenantField
|
|
425
|
-
? knex.raw('??', [`${source.alias}.${source.tenantField}`]).toString()
|
|
426
|
-
: (columns.has('tenant_id') ? qualify('tenant_id') : null)
|
|
427
|
-
if (tenantExpr) {
|
|
428
|
-
fragments.push(`${source.indexAlias}.tenant_id = ${tenantExpr}`)
|
|
429
|
-
fragments.push(`${source.indexAlias}.tenant_id is not null`)
|
|
430
|
-
}
|
|
431
|
-
if (!opts.withDeleted) fragments.push(`${source.indexAlias}.deleted_at is null`)
|
|
432
|
-
builder = builder.leftJoin({ [source.indexAlias]: 'entity_indexes' }, knex.raw(fragments.join(' AND ')))
|
|
433
|
-
indexSources.push({ alias: source.indexAlias, entityId: source.entityId, recordIdColumn: `${source.alias}.${source.recordIdColumn}` })
|
|
434
386
|
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if (debugEnabled) {
|
|
438
|
-
this.debug('query:index-sources', {
|
|
439
|
-
entity,
|
|
440
|
-
sources: indexSources.map((src) => ({ alias: src.alias, entity: src.entityId })),
|
|
441
|
-
})
|
|
442
|
-
}
|
|
443
387
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const searchFilters = normalizeFilters(opts.filters).filter((filter) => filter.op === 'like' || filter.op === 'ilike')
|
|
456
|
-
if (searchFilters.length) {
|
|
457
|
-
this.logSearchDebug('search:init', {
|
|
458
|
-
entity,
|
|
459
|
-
baseTable,
|
|
460
|
-
tenantId: opts.tenantId ?? null,
|
|
461
|
-
organizationScope: orgScope,
|
|
462
|
-
fields: searchFilters.map((filter) => String(filter.field)),
|
|
463
|
-
searchEnabled,
|
|
464
|
-
hasSearchTokens,
|
|
465
|
-
searchSources,
|
|
466
|
-
searchConfig: {
|
|
467
|
-
enabled: searchConfig.enabled,
|
|
468
|
-
minTokenLength: searchConfig.minTokenLength,
|
|
469
|
-
enablePartials: searchConfig.enablePartials,
|
|
470
|
-
hashAlgorithm: searchConfig.hashAlgorithm,
|
|
471
|
-
blocklistedFields: searchConfig.blocklistedFields,
|
|
472
|
-
},
|
|
473
|
-
})
|
|
474
|
-
if (!searchEnabled) {
|
|
475
|
-
this.logSearchDebug('search:disabled', { entity, baseTable })
|
|
476
|
-
} else if (!hasSearchTokens) {
|
|
477
|
-
this.logSearchDebug('search:no-search-tokens', {
|
|
388
|
+
const searchSources: SearchTokenSource[] = indexSources
|
|
389
|
+
.map((src) => ({ entity: String(src.entityId), recordIdColumn: src.recordIdColumn }))
|
|
390
|
+
.filter((src) => src.recordIdColumn && src.entity)
|
|
391
|
+
const hasSearchTokens = searchEnabled && searchSources.length
|
|
392
|
+
? await this.searchSourcesHaveTokens(searchSources, opts.tenantId ?? null, orgScope)
|
|
393
|
+
: false
|
|
394
|
+
const searchRuntime: SearchRuntime = { ...searchRuntimeBase, searchSources, enabled: searchEnabled && hasSearchTokens }
|
|
395
|
+
const joinSearchAvailability = new Map<string, boolean>()
|
|
396
|
+
const searchFilters = normalizeFilters(opts.filters).filter((filter) => filter.op === 'like' || filter.op === 'ilike')
|
|
397
|
+
if (searchFilters.length) {
|
|
398
|
+
this.logSearchDebug('search:init', {
|
|
478
399
|
entity,
|
|
479
400
|
baseTable,
|
|
480
401
|
tenantId: opts.tenantId ?? null,
|
|
481
402
|
organizationScope: orgScope,
|
|
403
|
+
fields: searchFilters.map((filter) => String(filter.field)),
|
|
404
|
+
searchEnabled,
|
|
405
|
+
hasSearchTokens,
|
|
406
|
+
searchSources,
|
|
407
|
+
searchConfig: {
|
|
408
|
+
enabled: searchConfig.enabled,
|
|
409
|
+
minTokenLength: searchConfig.minTokenLength,
|
|
410
|
+
enablePartials: searchConfig.enablePartials,
|
|
411
|
+
hashAlgorithm: searchConfig.hashAlgorithm,
|
|
412
|
+
blocklistedFields: searchConfig.blocklistedFields,
|
|
413
|
+
},
|
|
414
|
+
})
|
|
415
|
+
if (!searchEnabled) this.logSearchDebug('search:disabled', { entity, baseTable })
|
|
416
|
+
else if (!hasSearchTokens) this.logSearchDebug('search:no-search-tokens', {
|
|
417
|
+
entity, baseTable,
|
|
418
|
+
tenantId: opts.tenantId ?? null,
|
|
419
|
+
organizationScope: orgScope,
|
|
482
420
|
searchSources,
|
|
483
421
|
})
|
|
484
422
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
)
|
|
489
|
-
if (hasNonBaseSearchSource) {
|
|
490
|
-
optimizedCountBuilder = null
|
|
491
|
-
}
|
|
423
|
+
const hasNonBaseSearchSource = searchSources.some(
|
|
424
|
+
(src) => src.entity !== String(entity) || src.recordIdColumn !== 'b.id'
|
|
425
|
+
)
|
|
492
426
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const sourceTable = source.table ?? resolveEntityTableName(this.em, targetEntity)
|
|
505
|
-
try {
|
|
506
|
-
const gap = await profiler.measure(
|
|
507
|
-
'resolve_coverage_gap',
|
|
508
|
-
() => this.resolveCoverageGap(targetEntity, opts, coverageScope, sourceTable),
|
|
509
|
-
(value) => (value
|
|
510
|
-
? {
|
|
511
|
-
entity: targetEntity,
|
|
512
|
-
scope: value.scope,
|
|
513
|
-
baseCount: value.stats?.baseCount ?? null,
|
|
514
|
-
indexedCount: value.stats?.indexedCount ?? null,
|
|
515
|
-
}
|
|
516
|
-
: { entity: targetEntity, scope: null })
|
|
517
|
-
)
|
|
518
|
-
if (!gap) continue
|
|
519
|
-
if (!opts.skipAutoReindex) {
|
|
520
|
-
this.scheduleAutoReindex(targetEntity, opts, gap.stats, coverageScope?.organizationId ?? null)
|
|
521
|
-
}
|
|
522
|
-
partialIndexWarning = {
|
|
523
|
-
entity: targetEntity,
|
|
524
|
-
entityLabel: this.resolveEntityLabel(targetEntity),
|
|
525
|
-
baseCount: gap.stats?.baseCount ?? null,
|
|
526
|
-
indexedCount: gap.stats?.indexedCount ?? null,
|
|
527
|
-
scope: gap.stats ? gap.scope : undefined,
|
|
427
|
+
// Additional partial-coverage checks for customFieldSources
|
|
428
|
+
if (!partialIndexWarning && Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0 && this.isForcePartialIndexEnabled()) {
|
|
429
|
+
const seen = new Set<string>([entity])
|
|
430
|
+
for (const source of opts.customFieldSources) {
|
|
431
|
+
const targetEntity = source?.entityId ? String(source.entityId) : null
|
|
432
|
+
if (!targetEntity || seen.has(targetEntity)) continue
|
|
433
|
+
seen.add(targetEntity)
|
|
434
|
+
const sourceHasCustomFields = await this.entityHasActiveCustomFields(targetEntity, opts.tenantId ?? null)
|
|
435
|
+
if (!sourceHasCustomFields) {
|
|
436
|
+
if (debugEnabled) this.debug('query:coverage:skip-no-custom-fields', { entity: targetEntity })
|
|
437
|
+
continue
|
|
528
438
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
439
|
+
const sourceTable = source.table ?? resolveEntityTableName(this.em, targetEntity)
|
|
440
|
+
try {
|
|
441
|
+
const gap = await profiler.measure(
|
|
442
|
+
'resolve_coverage_gap',
|
|
443
|
+
() => this.resolveCoverageGap(targetEntity, opts, coverageScope, sourceTable),
|
|
444
|
+
(value) => (value
|
|
445
|
+
? {
|
|
446
|
+
entity: targetEntity, scope: value.scope,
|
|
447
|
+
baseCount: value.stats?.baseCount ?? null,
|
|
448
|
+
indexedCount: value.stats?.indexedCount ?? null,
|
|
449
|
+
}
|
|
450
|
+
: { entity: targetEntity, scope: null })
|
|
451
|
+
)
|
|
452
|
+
if (!gap) continue
|
|
453
|
+
if (!opts.skipAutoReindex) {
|
|
454
|
+
this.scheduleAutoReindex(targetEntity, opts, gap.stats, coverageScope?.organizationId ?? null)
|
|
455
|
+
}
|
|
456
|
+
partialIndexWarning = {
|
|
457
|
+
entity: targetEntity,
|
|
458
|
+
entityLabel: this.resolveEntityLabel(targetEntity),
|
|
459
|
+
baseCount: gap.stats?.baseCount ?? null,
|
|
460
|
+
indexedCount: gap.stats?.indexedCount ?? null,
|
|
461
|
+
scope: gap.stats ? gap.scope : undefined,
|
|
462
|
+
}
|
|
463
|
+
if (debugEnabled) {
|
|
464
|
+
if (gap.stats) this.debug('query:partial-coverage:forced', { entity: targetEntity, baseCount: gap.stats.baseCount, indexedCount: gap.stats.indexedCount, scope: gap.scope })
|
|
465
|
+
else this.debug('query:partial-coverage:forced', { entity: targetEntity })
|
|
466
|
+
}
|
|
467
|
+
break
|
|
468
|
+
} catch (err) {
|
|
469
|
+
if (debugEnabled) this.debug('query:partial-coverage:check-failed', { entity: targetEntity, error: err instanceof Error ? err.message : err })
|
|
532
470
|
}
|
|
533
|
-
break
|
|
534
|
-
} catch (err) {
|
|
535
|
-
if (debugEnabled) this.debug('query:partial-coverage:check-failed', { entity: targetEntity, error: err instanceof Error ? err.message : err })
|
|
536
471
|
}
|
|
537
472
|
}
|
|
538
|
-
}
|
|
539
473
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
entity,
|
|
559
|
-
baseCount: globalBase,
|
|
560
|
-
|
|
561
|
-
scope: 'global',
|
|
562
|
-
})
|
|
563
|
-
}
|
|
564
|
-
partialIndexWarning = {
|
|
565
|
-
entity,
|
|
566
|
-
entityLabel: this.resolveEntityLabel(entity),
|
|
567
|
-
baseCount: globalBase,
|
|
568
|
-
indexedCount: globalIndexed,
|
|
569
|
-
scope: 'global',
|
|
474
|
+
if (
|
|
475
|
+
!partialIndexWarning &&
|
|
476
|
+
wantsCf &&
|
|
477
|
+
entityHasActiveCustomFields &&
|
|
478
|
+
this.isForcePartialIndexEnabled() &&
|
|
479
|
+
opts.tenantId
|
|
480
|
+
) {
|
|
481
|
+
try {
|
|
482
|
+
await this.indexCoverageStats(entity, opts, coverageScope)
|
|
483
|
+
const globalStats = await this.indexCoverageStats(entity, opts, coverageScope)
|
|
484
|
+
if (globalStats) {
|
|
485
|
+
const globalBase = globalStats.baseCount
|
|
486
|
+
const globalIndexed = globalStats.indexedCount
|
|
487
|
+
const globalGap = (globalBase > 0 && globalIndexed < globalBase) || globalIndexed > globalBase
|
|
488
|
+
if (globalGap) {
|
|
489
|
+
console.warn('[HybridQueryEngine] Partial index coverage detected at global scope; forcing query index usage due to FORCE_QUERY_INDEX_ON_PARTIAL_INDEXES:', { entity, baseCount: globalBase, indexedCount: globalIndexed, scope: 'global' })
|
|
490
|
+
if (debugEnabled) this.debug('query:partial-coverage:forced', { entity, baseCount: globalBase, indexedCount: globalIndexed, scope: 'global' })
|
|
491
|
+
partialIndexWarning = {
|
|
492
|
+
entity, entityLabel: this.resolveEntityLabel(entity),
|
|
493
|
+
baseCount: globalBase, indexedCount: globalIndexed, scope: 'global',
|
|
494
|
+
}
|
|
570
495
|
}
|
|
571
496
|
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
if (debugEnabled) {
|
|
575
|
-
this.debug('query:partial-coverage:global-check-failed', {
|
|
576
|
-
entity,
|
|
577
|
-
error: err instanceof Error ? err.message : err,
|
|
578
|
-
})
|
|
497
|
+
} catch (err) {
|
|
498
|
+
if (debugEnabled) this.debug('query:partial-coverage:global-check-failed', { entity, error: err instanceof Error ? err.message : err })
|
|
579
499
|
}
|
|
580
500
|
}
|
|
581
|
-
}
|
|
582
501
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
502
|
+
const resolveBaseColumn = (field: string): string | null => {
|
|
503
|
+
if (columns.has(field)) return field
|
|
504
|
+
if (field === 'organization_id' && columns.has('id')) return 'id'
|
|
505
|
+
return null
|
|
506
|
+
}
|
|
588
507
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
filter.op,
|
|
595
|
-
filter.value,
|
|
596
|
-
indexSources,
|
|
597
|
-
searchRuntime
|
|
598
|
-
)
|
|
599
|
-
}
|
|
508
|
+
// ────────────────────────────────────────────────────────────────
|
|
509
|
+
// Build a reusable "applyQueryShape" function that applies every
|
|
510
|
+
// WHERE/JOIN/scope to a fresh SelectQueryBuilder. We use this in
|
|
511
|
+
// place of knex's `.clone()` for producing count + data queries.
|
|
512
|
+
// ────────────────────────────────────────────────────────────────
|
|
600
513
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const fieldName = String(filter.field)
|
|
606
|
-
const baseField = resolveBaseColumn(fieldName)
|
|
607
|
-
if (!baseField) {
|
|
608
|
-
builder = this.applyIndexDocFilterFromAlias(
|
|
609
|
-
knex,
|
|
610
|
-
builder,
|
|
611
|
-
'ei',
|
|
612
|
-
entity,
|
|
613
|
-
fieldName,
|
|
614
|
-
filter.op,
|
|
615
|
-
filter.value,
|
|
616
|
-
'b.id',
|
|
617
|
-
searchRuntime,
|
|
618
|
-
)
|
|
619
|
-
if (optimizedCountBuilder) {
|
|
620
|
-
optimizedCountBuilder = this.applyIndexDocFilterFromAlias(
|
|
621
|
-
knex,
|
|
622
|
-
optimizedCountBuilder,
|
|
623
|
-
'ei',
|
|
624
|
-
entity,
|
|
625
|
-
fieldName,
|
|
626
|
-
filter.op,
|
|
627
|
-
filter.value,
|
|
628
|
-
'b.id',
|
|
629
|
-
searchRuntime,
|
|
630
|
-
)
|
|
514
|
+
const applyBaseScope = (q: AnyBuilder): AnyBuilder => {
|
|
515
|
+
let next = q
|
|
516
|
+
if (orgScope && hasOrganizationColumn) {
|
|
517
|
+
next = this.applyOrganizationScope(next, qualify('organization_id'), orgScope)
|
|
631
518
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
519
|
+
if (hasTenantColumn) {
|
|
520
|
+
next = next.where(qualify('tenant_id'), '=', opts.tenantId)
|
|
521
|
+
}
|
|
522
|
+
if (!opts.withDeleted && hasDeletedColumn) {
|
|
523
|
+
next = next.where(qualify('deleted_at'), 'is', null)
|
|
524
|
+
}
|
|
525
|
+
return next
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const applyEntityIndexesJoin = (q: AnyBuilder): AnyBuilder => {
|
|
529
|
+
return q.leftJoin('entity_indexes as ei', (jb: any) => {
|
|
530
|
+
let jc = jb
|
|
531
|
+
.on('ei.entity_type', '=', String(entity))
|
|
532
|
+
.onRef('ei.entity_id', '=', sql<string>`(${sql.ref(qualify('id'))}::text)`)
|
|
533
|
+
if (hasOrganizationColumn) {
|
|
534
|
+
jc = jc
|
|
535
|
+
.onRef('ei.organization_id', '=', qualify('organization_id'))
|
|
536
|
+
.on('ei.organization_id', 'is not', null)
|
|
537
|
+
}
|
|
538
|
+
if (hasTenantColumn) {
|
|
539
|
+
jc = jc
|
|
540
|
+
.onRef('ei.tenant_id', '=', qualify('tenant_id'))
|
|
541
|
+
.on('ei.tenant_id', 'is not', null)
|
|
542
|
+
}
|
|
543
|
+
if (!opts.withDeleted) {
|
|
544
|
+
jc = jc.on('ei.deleted_at', 'is', null)
|
|
545
|
+
}
|
|
546
|
+
return jc
|
|
649
547
|
})
|
|
650
548
|
}
|
|
651
|
-
}
|
|
652
549
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
conditionBuilder,
|
|
674
|
-
'ei',
|
|
675
|
-
entity,
|
|
676
|
-
fieldName,
|
|
677
|
-
filter.op,
|
|
678
|
-
filter.value,
|
|
679
|
-
'b.id',
|
|
680
|
-
searchRuntime,
|
|
681
|
-
)
|
|
682
|
-
return
|
|
683
|
-
}
|
|
684
|
-
this.applyColumnFilter(conditionBuilder, qualify(baseField), filter, {
|
|
685
|
-
...searchRuntime,
|
|
686
|
-
knex,
|
|
687
|
-
entity,
|
|
688
|
-
field: fieldName,
|
|
689
|
-
recordIdColumn: 'b.id',
|
|
690
|
-
})
|
|
550
|
+
const applyCustomFieldSourceJoins = (q: AnyBuilder): AnyBuilder => {
|
|
551
|
+
let next = q
|
|
552
|
+
for (const source of preparedCfSources) {
|
|
553
|
+
const join = (opts.customFieldSources ?? []).find((s) => s && (s.alias ?? undefined) === source.alias)?.join
|
|
554
|
+
if (!join) continue
|
|
555
|
+
const joinType = (join.type ?? 'left') === 'inner' ? 'innerJoin' : 'leftJoin'
|
|
556
|
+
next = (next as any)[joinType](`${source.table} as ${source.alias}`, (jb: any) =>
|
|
557
|
+
jb.onRef(`${source.alias}.${join.toField}`, '=', qualify(join.fromField)))
|
|
558
|
+
// Index join for source
|
|
559
|
+
next = next.leftJoin(`entity_indexes as ${source.indexAlias}`, (jb: any) => {
|
|
560
|
+
let jc = jb
|
|
561
|
+
.on(`${source.indexAlias}.entity_type`, '=', String(source.entityId))
|
|
562
|
+
.onRef(`${source.indexAlias}.entity_id`, '=', sql<string>`(${sql.ref(`${source.alias}.${source.recordIdColumn}`)}::text)`)
|
|
563
|
+
const orgRef = source.organizationField
|
|
564
|
+
? `${source.alias}.${source.organizationField}`
|
|
565
|
+
: (columns.has('organization_id') ? qualify('organization_id') : null)
|
|
566
|
+
if (orgRef) {
|
|
567
|
+
jc = jc
|
|
568
|
+
.onRef(`${source.indexAlias}.organization_id`, '=', orgRef)
|
|
569
|
+
.on(`${source.indexAlias}.organization_id`, 'is not', null)
|
|
691
570
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
571
|
+
const tenantRef = source.tenantField
|
|
572
|
+
? `${source.alias}.${source.tenantField}`
|
|
573
|
+
: (columns.has('tenant_id') ? qualify('tenant_id') : null)
|
|
574
|
+
if (tenantRef) {
|
|
575
|
+
jc = jc
|
|
576
|
+
.onRef(`${source.indexAlias}.tenant_id`, '=', tenantRef)
|
|
577
|
+
.on(`${source.indexAlias}.tenant_id`, 'is not', null)
|
|
695
578
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
})
|
|
579
|
+
if (!opts.withDeleted) jc = jc.on(`${source.indexAlias}.deleted_at`, 'is', null)
|
|
580
|
+
return jc
|
|
699
581
|
})
|
|
700
|
-
}
|
|
582
|
+
}
|
|
583
|
+
return next
|
|
701
584
|
}
|
|
702
|
-
return next
|
|
703
|
-
}
|
|
704
585
|
|
|
705
|
-
|
|
706
|
-
|
|
586
|
+
const applyCfFilters = (q: AnyBuilder): AnyBuilder => {
|
|
587
|
+
let next = q
|
|
588
|
+
for (const filter of cfFilters) {
|
|
589
|
+
next = this.applyCfFilterAcrossSources(
|
|
590
|
+
next, filter.field, filter.op, filter.value, indexSources, searchRuntime,
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
return next
|
|
594
|
+
}
|
|
707
595
|
|
|
708
|
-
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
596
|
+
const regularBaseFilters = baseFilters.filter((filter) => !filter.orGroup)
|
|
597
|
+
const orGroupFilters = baseFilters.filter((filter) => filter.orGroup)
|
|
598
|
+
|
|
599
|
+
const applyRegularBaseFilters = (q: AnyBuilder): AnyBuilder => {
|
|
600
|
+
let next = q
|
|
601
|
+
for (const filter of regularBaseFilters) {
|
|
602
|
+
const fieldName = String(filter.field)
|
|
603
|
+
const baseField = resolveBaseColumn(fieldName)
|
|
604
|
+
if (!baseField) {
|
|
605
|
+
next = this.applyIndexDocFilterFromAlias(
|
|
606
|
+
next, 'ei', entity, fieldName, filter.op, filter.value, 'b.id', searchRuntime,
|
|
607
|
+
)
|
|
608
|
+
continue
|
|
609
|
+
}
|
|
610
|
+
const column = qualify(baseField)
|
|
611
|
+
next = this.applyColumnFilter(next, column, filter, {
|
|
612
|
+
...searchRuntime,
|
|
613
|
+
entity, field: fieldName, recordIdColumn: 'b.id',
|
|
614
|
+
})
|
|
615
|
+
}
|
|
616
|
+
return next
|
|
713
617
|
}
|
|
714
|
-
|
|
715
|
-
|
|
618
|
+
|
|
619
|
+
const applyOrGroupedBaseFilters = (q: AnyBuilder): AnyBuilder => {
|
|
620
|
+
if (orGroupFilters.length === 0) return q
|
|
621
|
+
const groups = new Map<string, BaseFilter[]>()
|
|
622
|
+
for (const filter of orGroupFilters) {
|
|
623
|
+
if (!filter.orGroup) continue
|
|
624
|
+
const existing = groups.get(filter.orGroup) ?? []
|
|
625
|
+
existing.push(filter)
|
|
626
|
+
groups.set(filter.orGroup, existing)
|
|
627
|
+
}
|
|
628
|
+
let next = q
|
|
629
|
+
for (const [, groupFilters] of groups) {
|
|
630
|
+
if (!groupFilters.length) continue
|
|
631
|
+
next = next.where((eb: any) => eb.or(
|
|
632
|
+
groupFilters.map((filter) => this.buildBaseFilterExpression(eb, filter, resolveBaseColumn, qualify, entity, searchRuntime))
|
|
633
|
+
))
|
|
634
|
+
}
|
|
635
|
+
return next
|
|
716
636
|
}
|
|
717
|
-
|
|
718
|
-
|
|
637
|
+
|
|
638
|
+
const applyAliasScopes = async (target: AnyBuilder, aliasName: string): Promise<AnyBuilder> => {
|
|
639
|
+
let next = target
|
|
640
|
+
const tableName = aliasTables.get(aliasName)
|
|
641
|
+
if (!tableName) return next
|
|
642
|
+
if (orgScope && await this.columnExists(tableName, 'organization_id')) {
|
|
643
|
+
next = this.applyOrganizationScope(next, `${aliasName}.organization_id`, orgScope)
|
|
644
|
+
}
|
|
645
|
+
if (opts.tenantId && await this.columnExists(tableName, 'tenant_id')) {
|
|
646
|
+
next = next.where(`${aliasName}.tenant_id`, '=', opts.tenantId)
|
|
647
|
+
}
|
|
648
|
+
if (!opts.withDeleted && await this.columnExists(tableName, 'deleted_at')) {
|
|
649
|
+
next = next.where(`${aliasName}.deleted_at`, 'is', null)
|
|
650
|
+
}
|
|
651
|
+
return next
|
|
719
652
|
}
|
|
720
|
-
}
|
|
721
653
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
target.where(column, value as
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
target.
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
break
|
|
654
|
+
const applyJoinFilterOpFn = (target: AnyBuilder, column: string, op: FilterOp, value?: unknown): AnyBuilder => {
|
|
655
|
+
switch (op) {
|
|
656
|
+
case 'eq': return target.where(column, '=', value as any)
|
|
657
|
+
case 'ne': return target.where(column, '!=', value as any)
|
|
658
|
+
case 'gt': return target.where(column, '>', value as any)
|
|
659
|
+
case 'gte': return target.where(column, '>=', value as any)
|
|
660
|
+
case 'lt': return target.where(column, '<', value as any)
|
|
661
|
+
case 'lte': return target.where(column, '<=', value as any)
|
|
662
|
+
case 'in': return target.where(column, 'in', this.toArray(value))
|
|
663
|
+
case 'nin': return target.where(column, 'not in', this.toArray(value))
|
|
664
|
+
case 'like': return target.where(column, 'like', value as any)
|
|
665
|
+
case 'ilike': return target.where(column, 'ilike', value as any)
|
|
666
|
+
case 'exists': return value ? target.where(column, 'is not', null) : target.where(column, 'is', null)
|
|
667
|
+
default: return target
|
|
737
668
|
}
|
|
738
|
-
case 'in':
|
|
739
|
-
target.whereIn(column, this.toArray(value) as readonly Knex.Value[])
|
|
740
|
-
break
|
|
741
|
-
case 'nin':
|
|
742
|
-
target.whereNotIn(column, this.toArray(value) as readonly Knex.Value[])
|
|
743
|
-
break
|
|
744
|
-
case 'like':
|
|
745
|
-
target.where(column, 'like', value as Knex.Value)
|
|
746
|
-
break
|
|
747
|
-
case 'ilike':
|
|
748
|
-
target.where(column, 'ilike', value as Knex.Value)
|
|
749
|
-
break
|
|
750
|
-
case 'exists':
|
|
751
|
-
value ? target.whereNotNull(column) : target.whereNull(column)
|
|
752
|
-
break
|
|
753
669
|
}
|
|
754
|
-
}
|
|
755
670
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
if (typeof filter.value !== 'string' || filter.value.trim().length === 0) return false
|
|
773
|
-
|
|
774
|
-
let searchAvailable = joinSearchAvailability.get(join.entityId)
|
|
775
|
-
if (searchAvailable === undefined) {
|
|
776
|
-
searchAvailable = await this.hasSearchTokens(String(join.entityId), opts.tenantId ?? null, orgScope)
|
|
777
|
-
joinSearchAvailability.set(join.entityId, searchAvailable)
|
|
778
|
-
}
|
|
779
|
-
if (!searchAvailable) return false
|
|
780
|
-
|
|
781
|
-
const tokens = tokenizeText(String(filter.value), searchConfig)
|
|
782
|
-
if (!tokens.hashes.length) return false
|
|
783
|
-
|
|
784
|
-
return this.applySearchTokens(target, {
|
|
785
|
-
knex,
|
|
786
|
-
entity: String(join.entityId),
|
|
787
|
-
field: filter.column,
|
|
788
|
-
hashes: tokens.hashes,
|
|
789
|
-
recordIdColumn: `${join.alias}.id`,
|
|
790
|
-
tenantId: opts.tenantId ?? null,
|
|
791
|
-
organizationScope: orgScope,
|
|
792
|
-
})
|
|
793
|
-
}
|
|
671
|
+
const applyJoinSearchFilterOp = async (
|
|
672
|
+
target: AnyBuilder,
|
|
673
|
+
filter: { column: string; op: FilterOp; value?: unknown },
|
|
674
|
+
_qualified: string,
|
|
675
|
+
join: ResolvedJoin,
|
|
676
|
+
): Promise<boolean> => {
|
|
677
|
+
if (!searchEnabled || !join.entityId) return false
|
|
678
|
+
if (!['like', 'ilike'].includes(filter.op)) return false
|
|
679
|
+
if (typeof filter.value !== 'string' || filter.value.trim().length === 0) return false
|
|
680
|
+
|
|
681
|
+
let searchAvailable = joinSearchAvailability.get(join.entityId)
|
|
682
|
+
if (searchAvailable === undefined) {
|
|
683
|
+
searchAvailable = await this.hasSearchTokens(String(join.entityId), opts.tenantId ?? null, orgScope)
|
|
684
|
+
joinSearchAvailability.set(join.entityId, searchAvailable)
|
|
685
|
+
}
|
|
686
|
+
if (!searchAvailable) return false
|
|
794
687
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
baseTable,
|
|
798
|
-
builder,
|
|
799
|
-
joinMap,
|
|
800
|
-
joinFilters,
|
|
801
|
-
aliasTables,
|
|
802
|
-
qualifyBase: (column) => qualify(column),
|
|
803
|
-
applyAliasScope: (target, alias) => applyAliasScopes(target, alias),
|
|
804
|
-
applyFilterOp: (target, column, op, value) => applyJoinFilterOp(target as ResultBuilder, column, op, value),
|
|
805
|
-
applyJoinFilterOp: (target, filter, qualified, join) =>
|
|
806
|
-
applyJoinSearchFilterOp(target as ResultBuilder, filter, qualified, join),
|
|
807
|
-
columnExists: (tbl, column) => this.columnExists(tbl, column),
|
|
808
|
-
}) as ResultBuilder
|
|
809
|
-
|
|
810
|
-
if (optimizedCountBuilder) {
|
|
811
|
-
await applyJoinFilters({
|
|
812
|
-
knex,
|
|
813
|
-
baseTable,
|
|
814
|
-
builder: optimizedCountBuilder,
|
|
815
|
-
joinMap,
|
|
816
|
-
joinFilters,
|
|
817
|
-
aliasTables,
|
|
818
|
-
qualifyBase: (column) => qualify(column),
|
|
819
|
-
applyAliasScope: (target, alias) => applyAliasScopes(target, alias),
|
|
820
|
-
applyFilterOp: (target, column, op, value) => applyJoinFilterOp(target as ResultBuilder, column, op, value),
|
|
821
|
-
applyJoinFilterOp: (target, filter, qualified, join) =>
|
|
822
|
-
applyJoinSearchFilterOp(target as ResultBuilder, filter, qualified, join),
|
|
823
|
-
columnExists: (tbl, column) => this.columnExists(tbl, column),
|
|
824
|
-
})
|
|
825
|
-
}
|
|
688
|
+
const tokens = tokenizeText(String(filter.value), searchConfig)
|
|
689
|
+
if (!tokens.hashes.length) return false
|
|
826
690
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
691
|
+
return this.applySearchTokens(target, {
|
|
692
|
+
entity: String(join.entityId),
|
|
693
|
+
field: filter.column,
|
|
694
|
+
hashes: tokens.hashes,
|
|
695
|
+
recordIdColumn: `${join.alias}.id`,
|
|
696
|
+
tenantId: opts.tenantId ?? null,
|
|
697
|
+
organizationScope: orgScope,
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const applyQueryShape = async (q: AnyBuilder): Promise<AnyBuilder> => {
|
|
702
|
+
let next = applyBaseScope(q)
|
|
703
|
+
next = applyEntityIndexesJoin(next)
|
|
704
|
+
next = applyCustomFieldSourceJoins(next)
|
|
705
|
+
next = applyCfFilters(next)
|
|
706
|
+
next = applyRegularBaseFilters(next)
|
|
707
|
+
next = applyOrGroupedBaseFilters(next)
|
|
708
|
+
// applyJoinFilters is the shared helper that handles `joinFilters` (ALIAS:col -> value).
|
|
709
|
+
next = await applyJoinFilters({
|
|
710
|
+
db,
|
|
711
|
+
baseTable,
|
|
712
|
+
builder: next,
|
|
713
|
+
joinMap,
|
|
714
|
+
joinFilters,
|
|
715
|
+
aliasTables,
|
|
716
|
+
qualifyBase: (column) => qualify(column),
|
|
717
|
+
applyAliasScope: async (target: any, alias: string) => applyAliasScopes(target as AnyBuilder, alias),
|
|
718
|
+
applyFilterOp: (target, column, op, value) => applyJoinFilterOpFn(target as AnyBuilder, column, op, value),
|
|
719
|
+
applyJoinFilterOp: async (target, filter, qualified, join) => {
|
|
720
|
+
const applied = await applyJoinSearchFilterOp(target as AnyBuilder, filter, qualified, join)
|
|
721
|
+
return { applied, builder: target }
|
|
722
|
+
},
|
|
723
|
+
columnExists: (tbl, column) => this.columnExists(tbl, column),
|
|
724
|
+
})
|
|
725
|
+
return next
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const hasCustomFieldFilters = cfFilters.length > 0
|
|
729
|
+
const canOptimizeCount = !hasCustomFieldFilters && !hasNonBaseSearchSource
|
|
730
|
+
|
|
731
|
+
// Selection (for data query)
|
|
732
|
+
const selectFieldSet = new Set<string>((opts.fields && opts.fields.length) ? opts.fields.map(String) : Array.from(columns.keys()))
|
|
733
|
+
if (opts.includeCustomFields === true) {
|
|
734
|
+
const entityIds = Array.from(new Set(indexSources.map((src) => String(src.entityId))))
|
|
735
|
+
try {
|
|
736
|
+
const resolvedKeys = await this.resolveAvailableCustomFieldKeys(entityIds, opts.tenantId ?? null)
|
|
737
|
+
resolvedKeys.forEach((key) => selectFieldSet.add(`cf:${key}`))
|
|
738
|
+
if (this.isDebugVerbosity()) this.debug('query:cf:resolved-keys', { entity, keys: resolvedKeys })
|
|
739
|
+
} catch (err) {
|
|
740
|
+
console.warn('[HybridQueryEngine] Failed to resolve custom field keys for', entity, err)
|
|
836
741
|
}
|
|
837
|
-
}
|
|
838
|
-
|
|
742
|
+
} else if (Array.isArray(opts.includeCustomFields)) {
|
|
743
|
+
opts.includeCustomFields.map((key) => String(key)).forEach((key) => selectFieldSet.add(`cf:${key}`))
|
|
839
744
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
745
|
+
const selectFields = Array.from(selectFieldSet)
|
|
746
|
+
|
|
747
|
+
const applySelection = (q: AnyBuilder): AnyBuilder => {
|
|
748
|
+
let next = q
|
|
749
|
+
for (const field of selectFields) {
|
|
750
|
+
const fieldName = String(field)
|
|
751
|
+
if (fieldName.startsWith('cf:')) {
|
|
752
|
+
const alias = this.sanitize(fieldName)
|
|
753
|
+
const jsonExpr = this.buildCfJsonExprSql(fieldName, indexSources)
|
|
754
|
+
const exprRaw = jsonExpr ?? sql`NULL::jsonb`
|
|
755
|
+
next = next.select(exprRaw.as(alias))
|
|
756
|
+
} else if (columns.has(fieldName)) {
|
|
757
|
+
next = next.select(`${qualify(fieldName)} as ${fieldName}`)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return next
|
|
855
761
|
}
|
|
856
|
-
}
|
|
857
762
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
763
|
+
const applySort = (q: AnyBuilder): AnyBuilder => {
|
|
764
|
+
let next = q
|
|
765
|
+
for (const s of opts.sort || []) {
|
|
766
|
+
const fieldName = String(s.field)
|
|
767
|
+
if (fieldName.startsWith('cf:')) {
|
|
768
|
+
const textExpr = this.buildCfTextExprSql(fieldName, indexSources)
|
|
769
|
+
if (textExpr) {
|
|
770
|
+
const direction = sql.raw(String(s.dir ?? SortDir.Asc))
|
|
771
|
+
next = next.orderBy(sql`${textExpr} ${direction}`)
|
|
772
|
+
}
|
|
773
|
+
} else {
|
|
774
|
+
const baseField = resolveBaseColumn(fieldName)
|
|
775
|
+
if (!baseField) continue
|
|
776
|
+
next = next.orderBy(qualify(baseField), s.dir ?? SortDir.Asc)
|
|
777
|
+
}
|
|
865
778
|
}
|
|
866
|
-
|
|
867
|
-
const baseField = resolveBaseColumn(fieldName)
|
|
868
|
-
if (!baseField) continue
|
|
869
|
-
builder = builder.orderBy(qualify(baseField), sort.dir ?? SortDir.Asc)
|
|
779
|
+
return next
|
|
870
780
|
}
|
|
871
|
-
}
|
|
872
781
|
|
|
873
|
-
|
|
874
|
-
|
|
782
|
+
const page = opts.page?.page ?? 1
|
|
783
|
+
const pageSize = opts.page?.pageSize ?? 20
|
|
784
|
+
const sqlDebugEnabled = this.isSqlDebugEnabled()
|
|
785
|
+
|
|
786
|
+
let total: number
|
|
787
|
+
|
|
788
|
+
if (canOptimizeCount) {
|
|
789
|
+
// Optimized count: apply only base-scope + regular filters + or-group filters (no index joins).
|
|
790
|
+
const optimizedRoot = db.selectFrom(`${baseTable} as b` as any)
|
|
791
|
+
let countCore = applyBaseScope(optimizedRoot)
|
|
792
|
+
countCore = applyRegularBaseFilters(countCore)
|
|
793
|
+
countCore = applyOrGroupedBaseFilters(countCore)
|
|
794
|
+
// joinFilters still need to be re-applied in the optimized path
|
|
795
|
+
countCore = await applyJoinFilters({
|
|
796
|
+
db,
|
|
797
|
+
baseTable,
|
|
798
|
+
builder: countCore,
|
|
799
|
+
joinMap,
|
|
800
|
+
joinFilters,
|
|
801
|
+
aliasTables,
|
|
802
|
+
qualifyBase: (column) => qualify(column),
|
|
803
|
+
applyAliasScope: async (target: any, alias: string) => applyAliasScopes(target as AnyBuilder, alias),
|
|
804
|
+
applyFilterOp: (target, column, op, value) => applyJoinFilterOpFn(target as AnyBuilder, column, op, value),
|
|
805
|
+
applyJoinFilterOp: async (target, filter, qualified, join) => {
|
|
806
|
+
const applied = await applyJoinSearchFilterOp(target as AnyBuilder, filter, qualified, join)
|
|
807
|
+
return { applied, builder: target }
|
|
808
|
+
},
|
|
809
|
+
columnExists: (tbl, column) => this.columnExists(tbl, column),
|
|
810
|
+
})
|
|
811
|
+
const sub = countCore.select(sql.ref(qualify('id')).as('id')).groupBy(qualify('id')).as('sq')
|
|
812
|
+
const countQuery = db.selectFrom(sub as any).select(sql<string>`count(*)`.as('count'))
|
|
813
|
+
if (debugEnabled && sqlDebugEnabled) {
|
|
814
|
+
const compiled = countQuery.compile()
|
|
815
|
+
this.debug('query:sql:count', { entity, sql: compiled.sql, bindings: compiled.parameters })
|
|
816
|
+
}
|
|
817
|
+
const countRow = await this.captureSqlTiming(
|
|
818
|
+
'query:sql:count', entity,
|
|
819
|
+
() => countQuery.executeTakeFirst(),
|
|
820
|
+
{ optimized: true }, profiler,
|
|
821
|
+
)
|
|
822
|
+
total = this.parseCount(countRow)
|
|
823
|
+
} else {
|
|
824
|
+
const countRoot = db.selectFrom(`${baseTable} as b` as any)
|
|
825
|
+
const countBuilder = (await applyQueryShape(countRoot))
|
|
826
|
+
.select(sql<string>`count(distinct ${sql.ref(qualify('id'))})`.as('count'))
|
|
827
|
+
if (debugEnabled && sqlDebugEnabled) {
|
|
828
|
+
const compiled = countBuilder.compile()
|
|
829
|
+
this.debug('query:sql:count', { entity, sql: compiled.sql, bindings: compiled.parameters })
|
|
830
|
+
}
|
|
831
|
+
const countRow = await this.captureSqlTiming(
|
|
832
|
+
'query:sql:count', entity,
|
|
833
|
+
() => countBuilder.executeTakeFirst(),
|
|
834
|
+
{ optimized: false }, profiler,
|
|
835
|
+
)
|
|
836
|
+
total = this.parseCount(countRow)
|
|
837
|
+
}
|
|
875
838
|
|
|
876
|
-
|
|
877
|
-
|
|
839
|
+
const dataRoot = db.selectFrom(`${baseTable} as b` as any)
|
|
840
|
+
let dataBuilder = await applyQueryShape(dataRoot)
|
|
841
|
+
dataBuilder = applySelection(dataBuilder)
|
|
842
|
+
dataBuilder = applySort(dataBuilder)
|
|
843
|
+
dataBuilder = dataBuilder.limit(pageSize).offset((page - 1) * pageSize)
|
|
878
844
|
|
|
879
|
-
if (optimizedCountBuilder) {
|
|
880
|
-
const countSource = optimizedCountBuilder.clone().clearSelect().clearOrder().select(knex.raw(`${qualify('id')} as id`)).groupBy(qualify('id'))
|
|
881
|
-
const countQuery = knex.from(countSource.as('sq')).count({ count: knex.raw('*') })
|
|
882
|
-
if (debugEnabled && sqlDebugEnabled) {
|
|
883
|
-
const { sql, bindings } = countQuery.clone().toSQL()
|
|
884
|
-
this.debug('query:sql:count', { entity, sql, bindings })
|
|
885
|
-
}
|
|
886
|
-
const countRow = await this.captureSqlTiming(
|
|
887
|
-
'query:sql:count',
|
|
888
|
-
entity,
|
|
889
|
-
() => countQuery.first(),
|
|
890
|
-
{ optimized: true },
|
|
891
|
-
profiler
|
|
892
|
-
)
|
|
893
|
-
total = this.parseCount(countRow)
|
|
894
|
-
} else {
|
|
895
|
-
const countBuilder = builder.clone().clearSelect().clearOrder().countDistinct(`${qualify('id')} as count`)
|
|
896
845
|
if (debugEnabled && sqlDebugEnabled) {
|
|
897
|
-
const
|
|
898
|
-
this.debug('query:sql:
|
|
899
|
-
}
|
|
900
|
-
const
|
|
901
|
-
'query:sql:
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
{ optimized: false },
|
|
905
|
-
profiler
|
|
906
|
-
)
|
|
907
|
-
total = this.parseCount(countRow)
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
const dataBuilder = builder.clone().limit(pageSize).offset((page - 1) * pageSize)
|
|
911
|
-
|
|
912
|
-
if (debugEnabled && sqlDebugEnabled) {
|
|
913
|
-
const { sql, bindings } = dataBuilder.clone().toSQL()
|
|
914
|
-
this.debug('query:sql:data', { entity, sql, bindings, page, pageSize })
|
|
915
|
-
}
|
|
916
|
-
const itemsRaw = await this.captureSqlTiming(
|
|
917
|
-
'query:sql:data',
|
|
918
|
-
entity,
|
|
919
|
-
() => dataBuilder,
|
|
920
|
-
{ page, pageSize },
|
|
921
|
-
profiler
|
|
922
|
-
)
|
|
923
|
-
if (debugEnabled) this.debug('query:complete', { entity, total, items: Array.isArray(itemsRaw) ? itemsRaw.length : 0 })
|
|
924
|
-
|
|
925
|
-
let items = itemsRaw as any[]
|
|
926
|
-
const encSvc = this.getEncryptionService()
|
|
927
|
-
const dekKeyCache = new Map<string | null, string | null>()
|
|
928
|
-
if (encSvc?.decryptEntityPayload) {
|
|
929
|
-
const decrypt = encSvc.decryptEntityPayload.bind(encSvc) as (
|
|
930
|
-
entityId: EntityId,
|
|
931
|
-
payload: Record<string, unknown>,
|
|
932
|
-
tenantId: string | null,
|
|
933
|
-
organizationId: string | null,
|
|
934
|
-
) => Promise<Record<string, unknown>>
|
|
935
|
-
items = await Promise.all(
|
|
936
|
-
items.map(async (item) => {
|
|
937
|
-
try {
|
|
938
|
-
const decrypted = await decrypt(
|
|
939
|
-
entity,
|
|
940
|
-
item,
|
|
941
|
-
item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
|
|
942
|
-
item?.organization_id ?? item?.organizationId ?? null,
|
|
943
|
-
)
|
|
944
|
-
return { ...item, ...decrypted }
|
|
945
|
-
} catch (err) {
|
|
946
|
-
console.error('Error decrypting entity payload', err);
|
|
947
|
-
return item
|
|
948
|
-
}
|
|
949
|
-
})
|
|
950
|
-
)
|
|
951
|
-
}
|
|
952
|
-
if (encSvc) {
|
|
953
|
-
items = await Promise.all(
|
|
954
|
-
items.map(async (item) => {
|
|
955
|
-
try {
|
|
956
|
-
return await decryptIndexDocCustomFields(
|
|
957
|
-
item,
|
|
958
|
-
{
|
|
959
|
-
tenantId: item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
|
|
960
|
-
organizationId: item?.organization_id ?? item?.organizationId ?? null,
|
|
961
|
-
},
|
|
962
|
-
encSvc as any,
|
|
963
|
-
dekKeyCache,
|
|
964
|
-
)
|
|
965
|
-
} catch {
|
|
966
|
-
return item
|
|
967
|
-
}
|
|
968
|
-
}),
|
|
846
|
+
const compiled = dataBuilder.compile()
|
|
847
|
+
this.debug('query:sql:data', { entity, sql: compiled.sql, bindings: compiled.parameters, page, pageSize })
|
|
848
|
+
}
|
|
849
|
+
const itemsRaw = await this.captureSqlTiming(
|
|
850
|
+
'query:sql:data', entity,
|
|
851
|
+
() => dataBuilder.execute(),
|
|
852
|
+
{ page, pageSize }, profiler,
|
|
969
853
|
)
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
854
|
+
if (debugEnabled) this.debug('query:complete', { entity, total, items: Array.isArray(itemsRaw) ? itemsRaw.length : 0 })
|
|
855
|
+
|
|
856
|
+
let items = itemsRaw as any[]
|
|
857
|
+
const encSvc = this.getEncryptionService()
|
|
858
|
+
const dekKeyCache = new Map<string | null, string | null>()
|
|
859
|
+
if (encSvc?.decryptEntityPayload) {
|
|
860
|
+
const decrypt = encSvc.decryptEntityPayload.bind(encSvc) as (
|
|
861
|
+
entityId: EntityId, payload: Record<string, unknown>, tenantId: string | null, organizationId: string | null,
|
|
862
|
+
) => Promise<Record<string, unknown>>
|
|
863
|
+
items = await Promise.all(
|
|
864
|
+
items.map(async (item) => {
|
|
865
|
+
try {
|
|
866
|
+
const decrypted = await decrypt(
|
|
867
|
+
entity, item,
|
|
868
|
+
item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
|
|
869
|
+
item?.organization_id ?? item?.organizationId ?? null,
|
|
870
|
+
)
|
|
871
|
+
return { ...item, ...decrypted }
|
|
872
|
+
} catch (err) {
|
|
873
|
+
console.error('Error decrypting entity payload', err)
|
|
874
|
+
return item
|
|
875
|
+
}
|
|
876
|
+
})
|
|
877
|
+
)
|
|
878
|
+
}
|
|
879
|
+
if (encSvc) {
|
|
880
|
+
items = await Promise.all(
|
|
881
|
+
items.map(async (item) => {
|
|
882
|
+
try {
|
|
883
|
+
return await decryptIndexDocCustomFields(
|
|
884
|
+
item,
|
|
885
|
+
{
|
|
886
|
+
tenantId: item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
|
|
887
|
+
organizationId: item?.organization_id ?? item?.organizationId ?? null,
|
|
888
|
+
},
|
|
889
|
+
encSvc as any, dekKeyCache,
|
|
890
|
+
)
|
|
891
|
+
} catch { return item }
|
|
892
|
+
}),
|
|
893
|
+
)
|
|
894
|
+
}
|
|
980
895
|
|
|
981
|
-
|
|
982
|
-
result:
|
|
983
|
-
|
|
984
|
-
page,
|
|
985
|
-
pageSize,
|
|
986
|
-
itemCount: Array.isArray(items) ? items.length : undefined,
|
|
987
|
-
partialIndexWarning: partialIndexWarning ? true : false,
|
|
988
|
-
})
|
|
989
|
-
return result
|
|
990
|
-
} catch (err) {
|
|
991
|
-
finishProfile({ result: 'error', error: err instanceof Error ? err.message : String(err) })
|
|
992
|
-
throw err
|
|
993
|
-
}
|
|
994
|
-
}
|
|
896
|
+
const typedItems = items as unknown as T[]
|
|
897
|
+
let result: QueryResult<T> = { items: typedItems, page, pageSize, total }
|
|
898
|
+
if (partialIndexWarning) result.meta = { partialIndexWarning }
|
|
995
899
|
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
900
|
+
result = await applyAfterExtensions(result)
|
|
901
|
+
finishProfile({
|
|
902
|
+
result: 'ok', total, page, pageSize,
|
|
903
|
+
itemCount: Array.isArray(items) ? items.length : undefined,
|
|
904
|
+
partialIndexWarning: partialIndexWarning ? true : false,
|
|
905
|
+
})
|
|
906
|
+
return result
|
|
907
|
+
} catch (err) {
|
|
908
|
+
finishProfile({ result: 'error', error: err instanceof Error ? err.message : String(err) })
|
|
909
|
+
throw err
|
|
1001
910
|
}
|
|
1002
|
-
throw new Error('HybridQueryEngine requires a SQL connection that exposes getKnex()')
|
|
1003
911
|
}
|
|
1004
912
|
|
|
1005
913
|
private prepareCustomFieldSources(
|
|
1006
|
-
knex: Knex,
|
|
1007
|
-
builder: ResultBuilder,
|
|
1008
914
|
sources: QueryCustomFieldSource[],
|
|
1009
|
-
|
|
1010
|
-
): { builder: ResultBuilder; sources: PreparedCustomFieldSource[] } {
|
|
1011
|
-
let current = builder
|
|
915
|
+
): PreparedCustomFieldSource[] {
|
|
1012
916
|
const prepared: PreparedCustomFieldSource[] = []
|
|
1013
917
|
sources.forEach((source, index) => {
|
|
1014
918
|
if (!source) return
|
|
1015
919
|
const joinTable = source.table ?? resolveEntityTableName(this.em, source.entityId)
|
|
1016
920
|
const alias = source.alias ?? `cfs_${index}`
|
|
1017
|
-
|
|
1018
|
-
if (!join) {
|
|
921
|
+
if (!source.join) {
|
|
1019
922
|
throw new Error(`QueryEngine: customFieldSources entry for ${String(source.entityId)} requires a join configuration`)
|
|
1020
923
|
}
|
|
1021
|
-
const joinArgs = { [alias]: joinTable }
|
|
1022
|
-
const joinCallback = function (this: Knex.JoinClause) {
|
|
1023
|
-
this.on(`${alias}.${join.toField}`, '=', qualify(join.fromField))
|
|
1024
|
-
}
|
|
1025
|
-
current = (join.type ?? 'left') === 'inner'
|
|
1026
|
-
? current.join(joinArgs, joinCallback)
|
|
1027
|
-
: current.leftJoin(joinArgs, joinCallback)
|
|
1028
924
|
prepared.push({
|
|
1029
925
|
alias,
|
|
1030
926
|
indexAlias: `ei_${alias}`,
|
|
@@ -1035,23 +931,36 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1035
931
|
table: joinTable,
|
|
1036
932
|
})
|
|
1037
933
|
})
|
|
1038
|
-
return
|
|
934
|
+
return prepared
|
|
1039
935
|
}
|
|
1040
936
|
|
|
1041
937
|
private async isCustomEntity(entity: string): Promise<boolean> {
|
|
1042
938
|
try {
|
|
1043
|
-
const
|
|
1044
|
-
const row = await
|
|
939
|
+
const db = this.getDb() as any
|
|
940
|
+
const row = await db
|
|
941
|
+
.selectFrom('custom_entities')
|
|
942
|
+
.select('id')
|
|
943
|
+
.where('entity_id', '=', entity)
|
|
944
|
+
.where('is_active', '=', true)
|
|
945
|
+
.executeTakeFirst()
|
|
1045
946
|
return !!row
|
|
1046
947
|
} catch {
|
|
1047
948
|
return false
|
|
1048
949
|
}
|
|
1049
950
|
}
|
|
1050
951
|
|
|
1051
|
-
|
|
1052
|
-
|
|
952
|
+
/**
|
|
953
|
+
* Adds a WHERE EXISTS / OR WHERE EXISTS subquery that matches
|
|
954
|
+
* `search_tokens` for the supplied (entity, field) against the
|
|
955
|
+
* provided record id column.
|
|
956
|
+
*
|
|
957
|
+
* Returns true when the sub-query was applied (i.e. tokens were
|
|
958
|
+
* non-empty). Caller is responsible for the calling context
|
|
959
|
+
* (direct where vs. inside `eb.or([...])`).
|
|
960
|
+
*/
|
|
961
|
+
private applySearchTokens(
|
|
962
|
+
q: AnyBuilder,
|
|
1053
963
|
opts: {
|
|
1054
|
-
knex: Knex
|
|
1055
964
|
entity: string
|
|
1056
965
|
field: string
|
|
1057
966
|
hashes: string[]
|
|
@@ -1063,244 +972,288 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1063
972
|
): boolean {
|
|
1064
973
|
if (!opts.hashes.length) {
|
|
1065
974
|
this.logSearchDebug('search:skip-no-hashes', {
|
|
1066
|
-
entity: opts.entity,
|
|
1067
|
-
|
|
1068
|
-
tenantId: opts.tenantId ?? null,
|
|
1069
|
-
organizationScope: opts.organizationScope,
|
|
975
|
+
entity: opts.entity, field: opts.field,
|
|
976
|
+
tenantId: opts.tenantId ?? null, organizationScope: opts.organizationScope,
|
|
1070
977
|
})
|
|
1071
978
|
return false
|
|
1072
979
|
}
|
|
1073
980
|
const alias = `st_${this.searchAliasSeq++}`
|
|
1074
|
-
const combineWith = opts.combineWith === 'or' ? 'orWhereExists' : 'whereExists'
|
|
1075
|
-
const engine = this
|
|
1076
981
|
this.logSearchDebug('search:apply-search-tokens', {
|
|
1077
|
-
entity: opts.entity,
|
|
1078
|
-
field: opts.field,
|
|
1079
|
-
alias,
|
|
982
|
+
entity: opts.entity, field: opts.field, alias,
|
|
1080
983
|
tokenCount: opts.hashes.length,
|
|
1081
984
|
tenantId: opts.tenantId ?? null,
|
|
1082
985
|
organizationScope: opts.organizationScope,
|
|
1083
986
|
combineWith: opts.combineWith ?? 'and',
|
|
1084
987
|
})
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
.
|
|
1090
|
-
.
|
|
1091
|
-
.
|
|
1092
|
-
.
|
|
1093
|
-
.
|
|
988
|
+
|
|
989
|
+
const engine = this
|
|
990
|
+
const buildSub = (eb: any) => {
|
|
991
|
+
let sub = eb
|
|
992
|
+
.selectFrom(`search_tokens as ${alias}`)
|
|
993
|
+
.select(sql<number>`1`.as('one'))
|
|
994
|
+
.where(`${alias}.entity_type`, '=', opts.entity)
|
|
995
|
+
.where(`${alias}.field`, '=', opts.field)
|
|
996
|
+
.where(sql<boolean>`${sql.ref(`${alias}.entity_id`)} = ${sql.ref(opts.recordIdColumn)}::text`)
|
|
997
|
+
.where(`${alias}.token_hash`, 'in', opts.hashes)
|
|
998
|
+
.groupBy([`${alias}.entity_id`, `${alias}.field`])
|
|
999
|
+
.having(sql<boolean>`count(distinct ${sql.ref(`${alias}.token_hash`)}) >= ${opts.hashes.length}`)
|
|
1094
1000
|
if (opts.tenantId !== undefined) {
|
|
1095
|
-
|
|
1001
|
+
sub = sub.where(sql<boolean>`${sql.ref(`${alias}.tenant_id`)} is not distinct from ${opts.tenantId ?? null}`)
|
|
1096
1002
|
}
|
|
1097
1003
|
if (opts.organizationScope) {
|
|
1098
|
-
engine.applyOrganizationScope(
|
|
1004
|
+
sub = engine.applyOrganizationScope(sub, `${alias}.organization_id`, opts.organizationScope)
|
|
1099
1005
|
}
|
|
1100
|
-
|
|
1006
|
+
return sub
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (opts.combineWith === 'or') {
|
|
1010
|
+
// When called inside an .or([...]) array the caller supplied `eb`.
|
|
1011
|
+
// `q` is the ExpressionBuilder callable (eb) itself in that case.
|
|
1012
|
+
// We return the expression node rather than mutating q.
|
|
1013
|
+
;(q as any).__pendingOrExists = buildSub(q)
|
|
1014
|
+
return true
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Default: append WHERE EXISTS (...) to the outer builder.
|
|
1018
|
+
;(q as any).__applied = true
|
|
1019
|
+
const built = buildSub(q)
|
|
1020
|
+
// If q is a Kysely builder (has .where), use eb => eb.exists(sub)
|
|
1021
|
+
if (typeof q.where === 'function') {
|
|
1022
|
+
;(q as any) = q.where((eb: any) => eb.exists(built))
|
|
1023
|
+
}
|
|
1101
1024
|
return true
|
|
1102
1025
|
}
|
|
1103
1026
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1027
|
+
/** SQL fragment for `cf:<key>` (or legacy bare key) as JSON across a single alias. */
|
|
1028
|
+
private jsonbSqlAlias(alias: string, key: string): RawBuilder<unknown> {
|
|
1106
1029
|
if (key.startsWith('cf:')) {
|
|
1107
1030
|
const bare = key.slice(3)
|
|
1108
|
-
return
|
|
1031
|
+
return sql`coalesce(${sql.ref(alias + '.doc')} -> ${key}, ${sql.ref(alias + '.doc')} -> ${bare})`
|
|
1109
1032
|
}
|
|
1110
|
-
return
|
|
1033
|
+
return sql`${sql.ref(alias + '.doc')} -> ${key}`
|
|
1111
1034
|
}
|
|
1112
|
-
|
|
1035
|
+
|
|
1036
|
+
/** SQL fragment for `cf:<key>` (or legacy bare key) as text across a single alias. */
|
|
1037
|
+
private cfTextExprAlias(alias: string, key: string): RawBuilder<string | null> {
|
|
1113
1038
|
if (key.startsWith('cf:')) {
|
|
1114
1039
|
const bare = key.slice(3)
|
|
1115
|
-
return
|
|
1040
|
+
return sql<string | null>`coalesce((${sql.ref(alias + '.doc')} ->> ${key}), (${sql.ref(alias + '.doc')} ->> ${bare}))`
|
|
1116
1041
|
}
|
|
1117
|
-
return
|
|
1042
|
+
return sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${key})`
|
|
1118
1043
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
return {
|
|
1044
|
+
|
|
1045
|
+
/** Build JSON/text SQL expressions across multiple index alias sources (coalesce over them). */
|
|
1046
|
+
private buildCfJsonExprSql(key: string, sources: IndexDocSource[]): RawBuilder<unknown> | null {
|
|
1047
|
+
if (!sources.length) return null
|
|
1048
|
+
const parts = sources.map((src) => this.jsonbSqlAlias(src.alias, key))
|
|
1049
|
+
if (parts.length === 1) return parts[0]
|
|
1050
|
+
return sql`coalesce(${sql.join(parts, sql`, `)})`
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
private buildCfTextExprSql(key: string, sources: IndexDocSource[]): RawBuilder<string | null> | null {
|
|
1054
|
+
if (!sources.length) return null
|
|
1055
|
+
const parts = sources.map((src) => this.cfTextExprAlias(src.alias, key))
|
|
1056
|
+
if (parts.length === 1) return parts[0]
|
|
1057
|
+
return sql<string | null>`coalesce(${sql.join(parts, sql`, `)})`
|
|
1126
1058
|
}
|
|
1127
1059
|
|
|
1128
1060
|
private applyCfFilterAcrossSources(
|
|
1129
|
-
|
|
1130
|
-
builder: ResultBuilder,
|
|
1061
|
+
builder: AnyBuilder,
|
|
1131
1062
|
key: string,
|
|
1132
1063
|
op: FilterOp,
|
|
1133
1064
|
value: unknown,
|
|
1134
1065
|
sources: IndexDocSource[],
|
|
1135
1066
|
search?: SearchRuntime
|
|
1136
|
-
):
|
|
1067
|
+
): AnyBuilder {
|
|
1137
1068
|
if (!sources.length) return builder
|
|
1138
1069
|
if ((op === 'like' || op === 'ilike') && search?.enabled && typeof value === 'string') {
|
|
1139
1070
|
const tokens = tokenizeText(String(value), search.config)
|
|
1140
1071
|
const hashes = tokens.hashes
|
|
1141
1072
|
if (hashes.length) {
|
|
1142
|
-
|
|
1143
|
-
if (sources.length) {
|
|
1144
|
-
builder = builder.where((qb) => {
|
|
1145
|
-
sources.forEach((source, idx) => {
|
|
1146
|
-
const ok = this.applySearchTokens(qb as any, {
|
|
1147
|
-
knex,
|
|
1148
|
-
entity: source.entityId,
|
|
1149
|
-
field: key,
|
|
1150
|
-
hashes,
|
|
1151
|
-
recordIdColumn: `${source.alias}.entity_id`,
|
|
1152
|
-
tenantId: search.tenantId ?? null,
|
|
1153
|
-
organizationScope: search.organizationScope ?? null,
|
|
1154
|
-
combineWith: idx === 0 ? 'and' : 'or',
|
|
1155
|
-
})
|
|
1156
|
-
if (ok) applied = true
|
|
1157
|
-
})
|
|
1158
|
-
})
|
|
1159
|
-
}
|
|
1073
|
+
const applied = this.applyMultiSourceSearchExists(builder, sources, key, hashes, search)
|
|
1160
1074
|
this.logSearchDebug('search:cf-filter-across', {
|
|
1161
1075
|
entity: sources.map((src) => src.entityId),
|
|
1162
|
-
field: key,
|
|
1163
|
-
|
|
1164
|
-
hashes,
|
|
1165
|
-
applied,
|
|
1166
|
-
tenantId: search.tenantId ?? null,
|
|
1167
|
-
organizationScope: search.organizationScope,
|
|
1076
|
+
field: key, tokens: tokens.tokens, hashes, applied,
|
|
1077
|
+
tenantId: search.tenantId ?? null, organizationScope: search.organizationScope,
|
|
1168
1078
|
})
|
|
1169
|
-
if (applied) return builder
|
|
1079
|
+
if (applied.builder !== builder) return applied.builder
|
|
1170
1080
|
} else {
|
|
1171
1081
|
this.logSearchDebug('search:cf-skip-empty-hashes', {
|
|
1172
|
-
entity: sources.map((src) => src.entityId),
|
|
1173
|
-
field: key,
|
|
1174
|
-
value,
|
|
1082
|
+
entity: sources.map((src) => src.entityId), field: key, value,
|
|
1175
1083
|
})
|
|
1176
1084
|
}
|
|
1177
1085
|
return builder
|
|
1178
1086
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1087
|
+
|
|
1088
|
+
const textExpr = this.buildCfTextExprSql(key, sources)
|
|
1089
|
+
const jsonExpr = this.buildCfJsonExprSql(key, sources)
|
|
1090
|
+
if (!textExpr || !jsonExpr) return builder
|
|
1091
|
+
|
|
1092
|
+
const arrContains = (val: unknown) => sql<boolean>`${jsonExpr} @> ${JSON.stringify([val])}::jsonb`
|
|
1093
|
+
|
|
1183
1094
|
switch (op) {
|
|
1184
1095
|
case 'eq':
|
|
1185
|
-
return builder.where((
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1096
|
+
return builder.where((eb: any) => eb.or([
|
|
1097
|
+
sql<boolean>`${textExpr} = ${value}`,
|
|
1098
|
+
arrContains(value),
|
|
1099
|
+
]))
|
|
1189
1100
|
case 'ne':
|
|
1190
|
-
return builder.
|
|
1101
|
+
return builder.where(sql<boolean>`${textExpr} <> ${value}`)
|
|
1191
1102
|
case 'in': {
|
|
1192
1103
|
const values = this.toArray(value)
|
|
1193
|
-
return builder.where((
|
|
1194
|
-
values.
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1104
|
+
return builder.where((eb: any) => eb.or(
|
|
1105
|
+
values.flatMap((val) => [
|
|
1106
|
+
sql<boolean>`${textExpr} = ${val}`,
|
|
1107
|
+
arrContains(val),
|
|
1108
|
+
])
|
|
1109
|
+
))
|
|
1199
1110
|
}
|
|
1200
1111
|
case 'nin': {
|
|
1201
|
-
const values = this.toArray(value)
|
|
1202
|
-
return builder.
|
|
1112
|
+
const values = this.toArray(value)
|
|
1113
|
+
return builder.where(sql<boolean>`${textExpr} not in (${sql.join(values.map((v) => sql`${v}`), sql`, `)})`)
|
|
1203
1114
|
}
|
|
1204
1115
|
case 'like':
|
|
1205
|
-
return builder.where(textExpr
|
|
1116
|
+
return builder.where(sql<boolean>`${textExpr} like ${value}`)
|
|
1206
1117
|
case 'ilike':
|
|
1207
|
-
return builder.where(textExpr
|
|
1118
|
+
return builder.where(sql<boolean>`${textExpr} ilike ${value}`)
|
|
1208
1119
|
case 'exists':
|
|
1209
1120
|
return value
|
|
1210
|
-
? builder.
|
|
1211
|
-
: builder.
|
|
1121
|
+
? builder.where(sql<boolean>`${textExpr} is not null`)
|
|
1122
|
+
: builder.where(sql<boolean>`${textExpr} is null`)
|
|
1212
1123
|
case 'gt':
|
|
1213
1124
|
case 'gte':
|
|
1214
1125
|
case 'lt':
|
|
1215
1126
|
case 'lte': {
|
|
1216
|
-
const operator = op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<='
|
|
1217
|
-
return builder.where(textExpr
|
|
1127
|
+
const operator = sql.raw(op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<=')
|
|
1128
|
+
return builder.where(sql<boolean>`${textExpr} ${operator} ${value}`)
|
|
1218
1129
|
}
|
|
1219
1130
|
default:
|
|
1220
1131
|
return builder
|
|
1221
1132
|
}
|
|
1222
1133
|
}
|
|
1223
1134
|
|
|
1135
|
+
/** Apply a search-token EXISTS subquery across multiple sources (OR-joined). */
|
|
1136
|
+
private applyMultiSourceSearchExists(
|
|
1137
|
+
builder: AnyBuilder,
|
|
1138
|
+
sources: IndexDocSource[],
|
|
1139
|
+
key: string,
|
|
1140
|
+
hashes: string[],
|
|
1141
|
+
search: SearchRuntime,
|
|
1142
|
+
): { builder: AnyBuilder; applied: boolean } {
|
|
1143
|
+
if (!sources.length || !hashes.length) return { builder, applied: false }
|
|
1144
|
+
const next = builder.where((eb: any) => eb.or(
|
|
1145
|
+
sources.map((source) =>
|
|
1146
|
+
eb.exists(this.buildSearchTokensSub(eb, {
|
|
1147
|
+
entity: String(source.entityId),
|
|
1148
|
+
field: key, hashes,
|
|
1149
|
+
recordIdColumn: `${source.alias}.entity_id`,
|
|
1150
|
+
tenantId: search.tenantId ?? null,
|
|
1151
|
+
organizationScope: search.organizationScope ?? null,
|
|
1152
|
+
}))
|
|
1153
|
+
)
|
|
1154
|
+
))
|
|
1155
|
+
return { builder: next, applied: true }
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Construct a search-token EXISTS subquery using the given ExpressionBuilder. */
|
|
1159
|
+
private buildSearchTokensSub(
|
|
1160
|
+
eb: any,
|
|
1161
|
+
opts: {
|
|
1162
|
+
entity: string
|
|
1163
|
+
field: string
|
|
1164
|
+
hashes: string[]
|
|
1165
|
+
recordIdColumn: string
|
|
1166
|
+
tenantId?: string | null
|
|
1167
|
+
organizationScope?: { ids: string[]; includeNull: boolean } | null
|
|
1168
|
+
}
|
|
1169
|
+
): any {
|
|
1170
|
+
const alias = `st_${this.searchAliasSeq++}`
|
|
1171
|
+
let sub = eb
|
|
1172
|
+
.selectFrom(`search_tokens as ${alias}`)
|
|
1173
|
+
.select(sql<number>`1`.as('one'))
|
|
1174
|
+
.where(`${alias}.entity_type`, '=', opts.entity)
|
|
1175
|
+
.where(`${alias}.field`, '=', opts.field)
|
|
1176
|
+
.where(sql<boolean>`${sql.ref(`${alias}.entity_id`)} = ${sql.ref(opts.recordIdColumn)}::text`)
|
|
1177
|
+
.where(`${alias}.token_hash`, 'in', opts.hashes)
|
|
1178
|
+
.groupBy([`${alias}.entity_id`, `${alias}.field`])
|
|
1179
|
+
.having(sql<boolean>`count(distinct ${sql.ref(`${alias}.token_hash`)}) >= ${opts.hashes.length}`)
|
|
1180
|
+
if (opts.tenantId !== undefined) {
|
|
1181
|
+
sub = sub.where(sql<boolean>`${sql.ref(`${alias}.tenant_id`)} is not distinct from ${opts.tenantId ?? null}`)
|
|
1182
|
+
}
|
|
1183
|
+
if (opts.organizationScope) {
|
|
1184
|
+
sub = this.applyOrganizationScope(sub, `${alias}.organization_id`, opts.organizationScope)
|
|
1185
|
+
}
|
|
1186
|
+
return sub
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1224
1189
|
private applyCfFilterFromAlias(
|
|
1225
|
-
|
|
1226
|
-
q: ResultBuilder,
|
|
1190
|
+
q: AnyBuilder,
|
|
1227
1191
|
alias: string,
|
|
1228
1192
|
entityType: string,
|
|
1229
1193
|
key: string,
|
|
1230
1194
|
op: FilterOp,
|
|
1231
1195
|
value: unknown,
|
|
1232
1196
|
search?: SearchRuntime
|
|
1233
|
-
):
|
|
1234
|
-
const
|
|
1235
|
-
const arrExpr =
|
|
1236
|
-
const arrContains = (val: unknown) =>
|
|
1197
|
+
): AnyBuilder {
|
|
1198
|
+
const textExpr = this.cfTextExprAlias(alias, key)
|
|
1199
|
+
const arrExpr = sql<unknown>`(${sql.ref(alias + '.doc')} -> ${key})`
|
|
1200
|
+
const arrContains = (val: unknown) => sql<boolean>`${arrExpr} @> ${JSON.stringify([val])}::jsonb`
|
|
1201
|
+
|
|
1237
1202
|
if ((op === 'like' || op === 'ilike') && search?.enabled && typeof value === 'string') {
|
|
1238
1203
|
const tokens = tokenizeText(String(value), search.config)
|
|
1239
1204
|
const hashes = tokens.hashes
|
|
1240
1205
|
if (hashes.length) {
|
|
1241
|
-
const applied = this.
|
|
1242
|
-
|
|
1243
|
-
entity: entityType,
|
|
1244
|
-
field: key,
|
|
1245
|
-
hashes,
|
|
1206
|
+
const applied = q.where((eb: any) => eb.exists(this.buildSearchTokensSub(eb, {
|
|
1207
|
+
entity: entityType, field: key, hashes,
|
|
1246
1208
|
recordIdColumn: `${alias}.entity_id`,
|
|
1247
1209
|
tenantId: search.tenantId ?? null,
|
|
1248
1210
|
organizationScope: search.organizationScope ?? null,
|
|
1249
|
-
})
|
|
1211
|
+
})))
|
|
1250
1212
|
this.logSearchDebug('search:cf-filter', {
|
|
1251
|
-
entity: entityType,
|
|
1252
|
-
|
|
1253
|
-
tokens: tokens.tokens,
|
|
1254
|
-
hashes,
|
|
1255
|
-
applied,
|
|
1256
|
-
tenantId: search.tenantId ?? null,
|
|
1257
|
-
organizationScope: search.organizationScope,
|
|
1213
|
+
entity: entityType, field: key, tokens: tokens.tokens, hashes, applied: true,
|
|
1214
|
+
tenantId: search.tenantId ?? null, organizationScope: search.organizationScope,
|
|
1258
1215
|
})
|
|
1259
|
-
|
|
1216
|
+
return applied
|
|
1260
1217
|
} else {
|
|
1261
|
-
this.logSearchDebug('search:cf-skip-empty-hashes', {
|
|
1262
|
-
entity: entityType,
|
|
1263
|
-
field: key,
|
|
1264
|
-
value,
|
|
1265
|
-
})
|
|
1218
|
+
this.logSearchDebug('search:cf-skip-empty-hashes', { entity: entityType, field: key, value })
|
|
1266
1219
|
}
|
|
1267
1220
|
return q
|
|
1268
1221
|
}
|
|
1269
1222
|
switch (op) {
|
|
1270
1223
|
case 'eq':
|
|
1271
|
-
return q.where((
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1224
|
+
return q.where((eb: any) => eb.or([
|
|
1225
|
+
sql<boolean>`${textExpr} = ${value}`,
|
|
1226
|
+
arrContains(value),
|
|
1227
|
+
]))
|
|
1275
1228
|
case 'ne':
|
|
1276
|
-
return q.
|
|
1229
|
+
return q.where(sql<boolean>`${textExpr} <> ${value}`)
|
|
1277
1230
|
case 'in': {
|
|
1278
1231
|
const vals = this.toArray(value)
|
|
1279
|
-
return q.where((
|
|
1280
|
-
vals.
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1232
|
+
return q.where((eb: any) => eb.or(
|
|
1233
|
+
vals.flatMap((val) => [
|
|
1234
|
+
sql<boolean>`${textExpr} = ${val}`,
|
|
1235
|
+
arrContains(val),
|
|
1236
|
+
])
|
|
1237
|
+
))
|
|
1285
1238
|
}
|
|
1286
1239
|
case 'nin': {
|
|
1287
|
-
const vals = this.toArray(value)
|
|
1288
|
-
return q.
|
|
1240
|
+
const vals = this.toArray(value)
|
|
1241
|
+
return q.where(sql<boolean>`${textExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`)
|
|
1289
1242
|
}
|
|
1290
1243
|
case 'like':
|
|
1291
|
-
return q.where(
|
|
1244
|
+
return q.where(sql<boolean>`${textExpr} like ${value}`)
|
|
1292
1245
|
case 'ilike':
|
|
1293
|
-
return q.where(
|
|
1246
|
+
return q.where(sql<boolean>`${textExpr} ilike ${value}`)
|
|
1294
1247
|
case 'exists':
|
|
1295
1248
|
return value
|
|
1296
|
-
? q.
|
|
1297
|
-
: q.
|
|
1249
|
+
? q.where(sql<boolean>`${textExpr} is not null`)
|
|
1250
|
+
: q.where(sql<boolean>`${textExpr} is null`)
|
|
1298
1251
|
case 'gt':
|
|
1299
1252
|
case 'gte':
|
|
1300
1253
|
case 'lt':
|
|
1301
1254
|
case 'lte': {
|
|
1302
|
-
const operator = op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<='
|
|
1303
|
-
return q.where(
|
|
1255
|
+
const operator = sql.raw(op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<=')
|
|
1256
|
+
return q.where(sql<boolean>`${textExpr} ${operator} ${value}`)
|
|
1304
1257
|
}
|
|
1305
1258
|
default:
|
|
1306
1259
|
return q
|
|
@@ -1308,8 +1261,7 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1308
1261
|
}
|
|
1309
1262
|
|
|
1310
1263
|
private applyIndexDocFilterFromAlias(
|
|
1311
|
-
|
|
1312
|
-
q: ResultBuilder,
|
|
1264
|
+
q: AnyBuilder,
|
|
1313
1265
|
alias: string,
|
|
1314
1266
|
entityType: string,
|
|
1315
1267
|
key: string,
|
|
@@ -1317,83 +1269,148 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1317
1269
|
value: unknown,
|
|
1318
1270
|
recordIdColumn: string,
|
|
1319
1271
|
search?: SearchRuntime,
|
|
1320
|
-
):
|
|
1321
|
-
const
|
|
1272
|
+
): AnyBuilder {
|
|
1273
|
+
const textExpr = sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${key})`
|
|
1322
1274
|
if ((op === 'like' || op === 'ilike') && search?.enabled && typeof value === 'string') {
|
|
1323
1275
|
const tokens = tokenizeText(String(value), search.config)
|
|
1324
1276
|
const hashes = tokens.hashes
|
|
1325
1277
|
if (hashes.length) {
|
|
1326
|
-
const applied = this.
|
|
1327
|
-
|
|
1328
|
-
entity: entityType,
|
|
1329
|
-
field: key,
|
|
1330
|
-
hashes,
|
|
1331
|
-
recordIdColumn,
|
|
1278
|
+
const applied = q.where((eb: any) => eb.exists(this.buildSearchTokensSub(eb, {
|
|
1279
|
+
entity: entityType, field: key, hashes, recordIdColumn,
|
|
1332
1280
|
tenantId: search.tenantId ?? null,
|
|
1333
1281
|
organizationScope: search.organizationScope ?? null,
|
|
1334
|
-
})
|
|
1282
|
+
})))
|
|
1335
1283
|
this.logSearchDebug('search:index-doc-filter', {
|
|
1336
|
-
entity: entityType,
|
|
1337
|
-
|
|
1338
|
-
tokens: tokens.tokens,
|
|
1339
|
-
hashes,
|
|
1340
|
-
applied,
|
|
1341
|
-
tenantId: search.tenantId ?? null,
|
|
1342
|
-
organizationScope: search.organizationScope,
|
|
1284
|
+
entity: entityType, field: key, tokens: tokens.tokens, hashes, applied: true,
|
|
1285
|
+
tenantId: search.tenantId ?? null, organizationScope: search.organizationScope,
|
|
1343
1286
|
})
|
|
1344
|
-
|
|
1287
|
+
return applied
|
|
1345
1288
|
} else {
|
|
1346
|
-
this.logSearchDebug('search:index-doc-skip-empty-hashes', {
|
|
1347
|
-
entity: entityType,
|
|
1348
|
-
field: key,
|
|
1349
|
-
value,
|
|
1350
|
-
})
|
|
1289
|
+
this.logSearchDebug('search:index-doc-skip-empty-hashes', { entity: entityType, field: key, value })
|
|
1351
1290
|
}
|
|
1352
1291
|
return q
|
|
1353
1292
|
}
|
|
1354
1293
|
switch (op) {
|
|
1355
1294
|
case 'eq':
|
|
1356
|
-
return q.where(
|
|
1295
|
+
return q.where(sql<boolean>`${textExpr} = ${value}`)
|
|
1357
1296
|
case 'ne':
|
|
1358
|
-
return q.where(
|
|
1359
|
-
case 'in':
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1297
|
+
return q.where(sql<boolean>`${textExpr} <> ${value}`)
|
|
1298
|
+
case 'in': {
|
|
1299
|
+
const vals = this.toArray(value)
|
|
1300
|
+
return q.where(sql<boolean>`${textExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`)
|
|
1301
|
+
}
|
|
1302
|
+
case 'nin': {
|
|
1303
|
+
const vals = this.toArray(value)
|
|
1304
|
+
return q.where(sql<boolean>`${textExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`)
|
|
1305
|
+
}
|
|
1363
1306
|
case 'like':
|
|
1364
|
-
return q.where(
|
|
1307
|
+
return q.where(sql<boolean>`${textExpr} like ${value}`)
|
|
1365
1308
|
case 'ilike':
|
|
1366
|
-
return q.where(
|
|
1309
|
+
return q.where(sql<boolean>`${textExpr} ilike ${value}`)
|
|
1367
1310
|
case 'exists':
|
|
1368
1311
|
return value
|
|
1369
|
-
? q.
|
|
1370
|
-
: q.
|
|
1312
|
+
? q.where(sql<boolean>`${textExpr} is not null`)
|
|
1313
|
+
: q.where(sql<boolean>`${textExpr} is null`)
|
|
1371
1314
|
case 'gt':
|
|
1372
1315
|
case 'gte':
|
|
1373
1316
|
case 'lt':
|
|
1374
1317
|
case 'lte': {
|
|
1375
|
-
const operator = op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<='
|
|
1376
|
-
return q.where(
|
|
1318
|
+
const operator = sql.raw(op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<=')
|
|
1319
|
+
return q.where(sql<boolean>`${textExpr} ${operator} ${value}`)
|
|
1377
1320
|
}
|
|
1378
1321
|
default:
|
|
1379
1322
|
return q
|
|
1380
1323
|
}
|
|
1381
1324
|
}
|
|
1382
1325
|
|
|
1326
|
+
/**
|
|
1327
|
+
* Build a single OR-group base filter expression as a Kysely predicate
|
|
1328
|
+
* (no side effects on the outer builder).
|
|
1329
|
+
*/
|
|
1330
|
+
private buildBaseFilterExpression(
|
|
1331
|
+
eb: any,
|
|
1332
|
+
filter: BaseFilter,
|
|
1333
|
+
resolveBaseColumn: (field: string) => string | null,
|
|
1334
|
+
qualify: (col: string) => string,
|
|
1335
|
+
entity: EntityId,
|
|
1336
|
+
searchRuntime: SearchRuntime,
|
|
1337
|
+
): any {
|
|
1338
|
+
const fieldName = String(filter.field)
|
|
1339
|
+
const baseField = resolveBaseColumn(fieldName)
|
|
1340
|
+
if (!baseField) {
|
|
1341
|
+
// Doc-based filter via `ei` alias — returned as EXISTS where possible
|
|
1342
|
+
return this.buildIndexDocFilterExpression(eb, 'ei', entity, fieldName, filter.op, filter.value, 'b.id', searchRuntime)
|
|
1343
|
+
}
|
|
1344
|
+
return this.buildColumnFilterExpression(eb, qualify(baseField), filter.op, filter.value)
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
private buildColumnFilterExpression(
|
|
1348
|
+
eb: any,
|
|
1349
|
+
column: string,
|
|
1350
|
+
op: FilterOp,
|
|
1351
|
+
value: unknown,
|
|
1352
|
+
): any {
|
|
1353
|
+
switch (op) {
|
|
1354
|
+
case 'eq': return eb(column, '=', value)
|
|
1355
|
+
case 'ne': return eb(column, '!=', value)
|
|
1356
|
+
case 'gt': return eb(column, '>', value)
|
|
1357
|
+
case 'gte': return eb(column, '>=', value)
|
|
1358
|
+
case 'lt': return eb(column, '<', value)
|
|
1359
|
+
case 'lte': return eb(column, '<=', value)
|
|
1360
|
+
case 'in': return eb(column, 'in', this.toArray(value))
|
|
1361
|
+
case 'nin': return eb(column, 'not in', this.toArray(value))
|
|
1362
|
+
case 'like': return eb(column, 'like', value)
|
|
1363
|
+
case 'ilike': return eb(column, 'ilike', value)
|
|
1364
|
+
case 'exists': return eb(column, value ? 'is not' : 'is', null)
|
|
1365
|
+
default: return sql<boolean>`true`
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
private buildIndexDocFilterExpression(
|
|
1370
|
+
eb: any,
|
|
1371
|
+
alias: string,
|
|
1372
|
+
_entity: EntityId,
|
|
1373
|
+
key: string,
|
|
1374
|
+
op: FilterOp,
|
|
1375
|
+
value: unknown,
|
|
1376
|
+
_recordIdColumn: string,
|
|
1377
|
+
_search?: SearchRuntime,
|
|
1378
|
+
): any {
|
|
1379
|
+
const textExpr = sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${key})`
|
|
1380
|
+
switch (op) {
|
|
1381
|
+
case 'eq': return sql<boolean>`${textExpr} = ${value}`
|
|
1382
|
+
case 'ne': return sql<boolean>`${textExpr} <> ${value}`
|
|
1383
|
+
case 'gt':
|
|
1384
|
+
case 'gte':
|
|
1385
|
+
case 'lt':
|
|
1386
|
+
case 'lte': {
|
|
1387
|
+
const operator = sql.raw(op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<=')
|
|
1388
|
+
return sql<boolean>`${textExpr} ${operator} ${value}`
|
|
1389
|
+
}
|
|
1390
|
+
case 'like': return sql<boolean>`${textExpr} like ${value}`
|
|
1391
|
+
case 'ilike': return sql<boolean>`${textExpr} ilike ${value}`
|
|
1392
|
+
case 'in': {
|
|
1393
|
+
const vals = this.toArray(value)
|
|
1394
|
+
return sql<boolean>`${textExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`
|
|
1395
|
+
}
|
|
1396
|
+
case 'nin': {
|
|
1397
|
+
const vals = this.toArray(value)
|
|
1398
|
+
return sql<boolean>`${textExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`
|
|
1399
|
+
}
|
|
1400
|
+
case 'exists':
|
|
1401
|
+
return value ? sql<boolean>`${textExpr} is not null` : sql<boolean>`${textExpr} is null`
|
|
1402
|
+
default:
|
|
1403
|
+
return sql<boolean>`true`
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1383
1407
|
private async queryCustomEntity<T = unknown>(entity: string, opts: QueryOptions = {}): Promise<QueryResult<T>> {
|
|
1384
|
-
const
|
|
1408
|
+
const db = this.getDb() as any
|
|
1385
1409
|
const alias = 'ce'
|
|
1386
|
-
let q = knex({ [alias]: 'custom_entities_storage' }).where(`${alias}.entity_type`, entity)
|
|
1387
1410
|
|
|
1388
1411
|
const orgScope = this.resolveOrganizationScope(opts)
|
|
1389
|
-
|
|
1390
|
-
// Require tenant scope; custom entities are tenant-scoped only
|
|
1391
1412
|
if (!opts.tenantId) throw new Error('QueryEngine: tenantId is required')
|
|
1392
|
-
|
|
1393
|
-
if (orgScope) {
|
|
1394
|
-
q = this.applyOrganizationScope(q, `${alias}.organization_id`, orgScope)
|
|
1395
|
-
}
|
|
1396
|
-
if (!opts.withDeleted) q = q.whereNull(`${alias}.deleted_at`)
|
|
1413
|
+
|
|
1397
1414
|
const searchConfig = resolveSearchConfig()
|
|
1398
1415
|
const searchEnabled = searchConfig.enabled && await this.tableExists('search_tokens')
|
|
1399
1416
|
const hasSearchTokens = searchEnabled
|
|
@@ -1408,31 +1425,33 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1408
1425
|
|
|
1409
1426
|
const normalizedFilters = normalizeFilters(opts.filters)
|
|
1410
1427
|
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
if (
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1428
|
+
const applyScope = (q: AnyBuilder): AnyBuilder => {
|
|
1429
|
+
let next = q
|
|
1430
|
+
.where(`${alias}.entity_type`, '=', entity)
|
|
1431
|
+
.where(`${alias}.tenant_id`, '=', opts.tenantId)
|
|
1432
|
+
if (orgScope) {
|
|
1433
|
+
next = this.applyOrganizationScope(next, `${alias}.organization_id`, orgScope)
|
|
1434
|
+
}
|
|
1435
|
+
if (!opts.withDeleted) next = next.where(`${alias}.deleted_at`, 'is', null)
|
|
1436
|
+
for (const filter of normalizedFilters) {
|
|
1437
|
+
if (filter.field.startsWith('cf:')) {
|
|
1438
|
+
next = this.applyCfFilterFromAlias(next, alias, entity, filter.field, filter.op, filter.value, searchRuntime)
|
|
1439
|
+
continue
|
|
1440
|
+
}
|
|
1441
|
+
const column = this.resolveCustomEntityColumn(alias, String(filter.field))
|
|
1442
|
+
if (column) {
|
|
1443
|
+
next = this.applyColumnFilter(next, column, filter, {
|
|
1444
|
+
...searchRuntime, entity, field: String(filter.field), recordIdColumn: `${alias}.entity_id`,
|
|
1445
|
+
})
|
|
1446
|
+
continue
|
|
1447
|
+
}
|
|
1448
|
+
// Unknown field → filter on doc JSON text
|
|
1449
|
+
const docExpr = sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${String(filter.field)})`
|
|
1450
|
+
next = this.applyColumnFilter(next, docExpr, filter, {
|
|
1451
|
+
...searchRuntime, entity, field: String(filter.field), recordIdColumn: `${alias}.entity_id`,
|
|
1425
1452
|
})
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
const docExpr = knex.raw(`(${alias}.doc ->> ?)`, [String(filter.field)])
|
|
1429
|
-
q = this.applyColumnFilter(q, docExpr, filter, {
|
|
1430
|
-
...searchRuntime,
|
|
1431
|
-
knex,
|
|
1432
|
-
entity,
|
|
1433
|
-
field: String(filter.field),
|
|
1434
|
-
recordIdColumn: `${alias}.entity_id`,
|
|
1435
|
-
})
|
|
1453
|
+
}
|
|
1454
|
+
return next
|
|
1436
1455
|
}
|
|
1437
1456
|
|
|
1438
1457
|
// Determine CFs and l10n keys to include
|
|
@@ -1447,93 +1466,92 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1447
1466
|
}
|
|
1448
1467
|
if (opts.includeCustomFields === true) {
|
|
1449
1468
|
try {
|
|
1450
|
-
const rows = await
|
|
1469
|
+
const rows = await db
|
|
1470
|
+
.selectFrom('custom_field_defs')
|
|
1451
1471
|
.select('key')
|
|
1452
|
-
.where(
|
|
1453
|
-
.
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
// if (opts.organizationId != null) qb.andWhere((b: any) => b.where({ organization_id: opts.organizationId }).orWhereNull('organization_id'))
|
|
1457
|
-
// else qb.whereNull('organization_id')
|
|
1458
|
-
})
|
|
1472
|
+
.where('entity_id', '=', entity)
|
|
1473
|
+
.where('is_active', '=', true)
|
|
1474
|
+
.where('tenant_id', '=', opts.tenantId)
|
|
1475
|
+
.execute() as Array<{ key: unknown }>
|
|
1459
1476
|
for (const row of rows) {
|
|
1460
|
-
const key =
|
|
1461
|
-
if (typeof key === 'string')
|
|
1462
|
-
|
|
1463
|
-
} else if (key != null) {
|
|
1464
|
-
cfKeys.add(String(key))
|
|
1465
|
-
}
|
|
1477
|
+
const key = row.key
|
|
1478
|
+
if (typeof key === 'string') cfKeys.add(key)
|
|
1479
|
+
else if (key != null) cfKeys.add(String(key))
|
|
1466
1480
|
}
|
|
1467
1481
|
} catch {
|
|
1468
|
-
// ignore
|
|
1482
|
+
// ignore
|
|
1469
1483
|
}
|
|
1470
1484
|
} else if (Array.isArray(opts.includeCustomFields)) {
|
|
1471
1485
|
for (const k of opts.includeCustomFields) cfKeys.add(k)
|
|
1472
1486
|
}
|
|
1473
1487
|
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
const
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1488
|
+
const applySelection = (q: AnyBuilder): AnyBuilder => {
|
|
1489
|
+
let next = q
|
|
1490
|
+
const requested = (opts.fields && opts.fields.length) ? opts.fields : ['id']
|
|
1491
|
+
for (const field of requested) {
|
|
1492
|
+
const f = String(field)
|
|
1493
|
+
if (f.startsWith('cf:')) {
|
|
1494
|
+
const aliasName = this.sanitize(f)
|
|
1495
|
+
next = next.select(this.jsonbSqlAlias(alias, f).as(aliasName))
|
|
1496
|
+
} else if (f === 'id') {
|
|
1497
|
+
next = next.select(`${alias}.entity_id as id`)
|
|
1498
|
+
} else if (f === 'created_at' || f === 'updated_at' || f === 'deleted_at') {
|
|
1499
|
+
next = next.select(`${alias}.${f} as ${f}`)
|
|
1500
|
+
} else {
|
|
1501
|
+
const expr = sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${f})`
|
|
1502
|
+
next = next.select(expr.as(f))
|
|
1503
|
+
}
|
|
1490
1504
|
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
q = q.select({ [aliasName]: expr })
|
|
1498
|
-
cfSelectedAliases.push(aliasName)
|
|
1505
|
+
// Ensure CF fields for sort / includeCustomFields are selected
|
|
1506
|
+
for (const key of cfKeys) {
|
|
1507
|
+
const aliasName = this.sanitize(`cf:${key}`)
|
|
1508
|
+
next = next.select(this.jsonbSqlAlias(alias, `cf:${key}`).as(aliasName))
|
|
1509
|
+
}
|
|
1510
|
+
return next
|
|
1499
1511
|
}
|
|
1500
1512
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1513
|
+
const applySort = (q: AnyBuilder): AnyBuilder => {
|
|
1514
|
+
let next = q
|
|
1515
|
+
for (const s of opts.sort || []) {
|
|
1516
|
+
if (s.field.startsWith('cf:')) {
|
|
1517
|
+
const key = s.field.slice(3)
|
|
1518
|
+
const aliasName = this.sanitize(`cf:${key}`)
|
|
1519
|
+
next = next.orderBy(aliasName, s.dir ?? SortDir.Asc)
|
|
1520
|
+
} else if (s.field === 'id') {
|
|
1521
|
+
next = next.orderBy(`${alias}.entity_id`, s.dir ?? SortDir.Asc)
|
|
1522
|
+
} else if (s.field === 'created_at' || s.field === 'updated_at' || s.field === 'deleted_at') {
|
|
1523
|
+
next = next.orderBy(`${alias}.${s.field}`, s.dir ?? SortDir.Asc)
|
|
1524
|
+
} else {
|
|
1525
|
+
const direction = sql.raw(String(s.dir ?? SortDir.Asc))
|
|
1526
|
+
next = next.orderBy(sql`(${sql.ref(alias + '.doc')} ->> ${s.field}) ${direction}`)
|
|
1510
1527
|
}
|
|
1511
|
-
q = q.orderBy(aliasName, s.dir ?? SortDir.Asc)
|
|
1512
|
-
} else if (s.field === 'id') {
|
|
1513
|
-
q = q.orderBy(`${alias}.entity_id`, s.dir ?? SortDir.Asc)
|
|
1514
|
-
} else if (s.field === 'created_at' || s.field === 'updated_at' || s.field === 'deleted_at') {
|
|
1515
|
-
q = q.orderBy(`${alias}.${s.field}`, s.dir ?? SortDir.Asc)
|
|
1516
|
-
} else {
|
|
1517
|
-
const direction = s.dir ?? SortDir.Asc
|
|
1518
|
-
q = q.orderByRaw(`(${alias}.doc ->> ?) ${direction}`, [s.field])
|
|
1519
1528
|
}
|
|
1529
|
+
return next
|
|
1520
1530
|
}
|
|
1521
1531
|
|
|
1522
|
-
// Pagination + totals
|
|
1523
1532
|
const page = opts.page?.page ?? 1
|
|
1524
1533
|
const pageSize = opts.page?.pageSize ?? 20
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
const countRow = await
|
|
1534
|
+
|
|
1535
|
+
const root = db.selectFrom(`custom_entities_storage as ${alias}`)
|
|
1536
|
+
const countQuery = applyScope(root).select(sql<string>`count(distinct ${sql.ref(`${alias}.entity_id`)})`.as('count'))
|
|
1537
|
+
const countRow = await countQuery.executeTakeFirst()
|
|
1529
1538
|
const total = this.parseCount(countRow)
|
|
1530
|
-
|
|
1539
|
+
|
|
1540
|
+
let dataQuery = applyScope(db.selectFrom(`custom_entities_storage as ${alias}`))
|
|
1541
|
+
dataQuery = applySelection(dataQuery)
|
|
1542
|
+
dataQuery = applySort(dataQuery)
|
|
1543
|
+
dataQuery = dataQuery.limit(pageSize).offset((page - 1) * pageSize)
|
|
1544
|
+
const items = await dataQuery.execute()
|
|
1531
1545
|
return { items, page, pageSize, total }
|
|
1532
1546
|
}
|
|
1533
1547
|
|
|
1534
1548
|
private async tableExists(table: string): Promise<boolean> {
|
|
1535
|
-
const
|
|
1536
|
-
const exists = await
|
|
1549
|
+
const db = this.getDb() as any
|
|
1550
|
+
const exists = await db
|
|
1551
|
+
.selectFrom('information_schema.tables')
|
|
1552
|
+
.select(sql<number>`1`.as('one'))
|
|
1553
|
+
.where('table_name', '=', table)
|
|
1554
|
+
.executeTakeFirst()
|
|
1537
1555
|
return !!exists
|
|
1538
1556
|
}
|
|
1539
1557
|
|
|
@@ -1543,21 +1561,22 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1543
1561
|
orgScope?: { ids: string[]; includeNull: boolean } | null
|
|
1544
1562
|
): Promise<boolean> {
|
|
1545
1563
|
try {
|
|
1546
|
-
const
|
|
1547
|
-
|
|
1564
|
+
const db = this.getDb() as any
|
|
1565
|
+
let query = db
|
|
1566
|
+
.selectFrom('search_tokens')
|
|
1567
|
+
.select(sql<number>`1`.as('one'))
|
|
1568
|
+
.where('entity_type', '=', entity)
|
|
1548
1569
|
if (tenantId !== undefined) {
|
|
1549
|
-
query.
|
|
1570
|
+
query = query.where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)
|
|
1550
1571
|
}
|
|
1551
1572
|
if (orgScope) {
|
|
1552
|
-
this.applyOrganizationScope(query
|
|
1573
|
+
query = this.applyOrganizationScope(query, 'search_tokens.organization_id', orgScope)
|
|
1553
1574
|
}
|
|
1554
|
-
const row = await query.
|
|
1575
|
+
const row = await query.limit(1).executeTakeFirst()
|
|
1555
1576
|
return !!row
|
|
1556
1577
|
} catch (err) {
|
|
1557
1578
|
this.logSearchDebug('search:has-tokens-error', {
|
|
1558
|
-
entity,
|
|
1559
|
-
tenantId,
|
|
1560
|
-
organizationScope: orgScope,
|
|
1579
|
+
entity, tenantId, organizationScope: orgScope,
|
|
1561
1580
|
error: err instanceof Error ? err.message : String(err),
|
|
1562
1581
|
})
|
|
1563
1582
|
return false
|
|
@@ -1572,11 +1591,8 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1572
1591
|
for (const source of sources) {
|
|
1573
1592
|
const ok = await this.hasSearchTokens(source.entity, tenantId, orgScope)
|
|
1574
1593
|
this.logSearchDebug('search:source-has-tokens', {
|
|
1575
|
-
entity: source.entity,
|
|
1576
|
-
|
|
1577
|
-
tenantId,
|
|
1578
|
-
organizationScope: orgScope,
|
|
1579
|
-
hasTokens: ok,
|
|
1594
|
+
entity: source.entity, recordIdColumn: source.recordIdColumn,
|
|
1595
|
+
tenantId, organizationScope: orgScope, hasTokens: ok,
|
|
1580
1596
|
})
|
|
1581
1597
|
if (ok) return true
|
|
1582
1598
|
}
|
|
@@ -1588,23 +1604,22 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1588
1604
|
const cacheKey = this.customFieldKeysCacheKey(entityIds, tenantId)
|
|
1589
1605
|
const now = Date.now()
|
|
1590
1606
|
const cached = this.customFieldKeysCache.get(cacheKey)
|
|
1591
|
-
if (cached && cached.expiresAt > now)
|
|
1592
|
-
return cached.value.slice()
|
|
1593
|
-
}
|
|
1607
|
+
if (cached && cached.expiresAt > now) return cached.value.slice()
|
|
1594
1608
|
|
|
1595
|
-
const
|
|
1596
|
-
const rows = await
|
|
1609
|
+
const db = this.getDb() as any
|
|
1610
|
+
const rows = await db
|
|
1611
|
+
.selectFrom('custom_field_defs')
|
|
1597
1612
|
.select('key')
|
|
1598
|
-
.
|
|
1599
|
-
.
|
|
1600
|
-
.
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
}
|
|
1613
|
+
.where('entity_id', 'in', entityIds)
|
|
1614
|
+
.where('is_active', '=', true)
|
|
1615
|
+
.where((eb: any) => eb.or([
|
|
1616
|
+
eb('tenant_id', '=', tenantId),
|
|
1617
|
+
eb('tenant_id', 'is', null),
|
|
1618
|
+
]))
|
|
1619
|
+
.execute() as Array<{ key: unknown }>
|
|
1605
1620
|
const keys = new Set<string>()
|
|
1606
|
-
for (const row of rows
|
|
1607
|
-
const key =
|
|
1621
|
+
for (const row of rows) {
|
|
1622
|
+
const key = row.key
|
|
1608
1623
|
if (typeof key === 'string' && key.trim().length) keys.add(key.trim())
|
|
1609
1624
|
else if (key != null) keys.add(String(key))
|
|
1610
1625
|
}
|
|
@@ -1622,8 +1637,7 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1622
1637
|
} catch (err) {
|
|
1623
1638
|
if (this.isDebugVerbosity()) {
|
|
1624
1639
|
this.debug('query:cf:check-error', {
|
|
1625
|
-
entity: entityId,
|
|
1626
|
-
tenantId: tenantId ?? null,
|
|
1640
|
+
entity: entityId, tenantId: tenantId ?? null,
|
|
1627
1641
|
error: err instanceof Error ? err.message : err,
|
|
1628
1642
|
})
|
|
1629
1643
|
}
|
|
@@ -1650,17 +1664,22 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1650
1664
|
}
|
|
1651
1665
|
|
|
1652
1666
|
private async indexAnyRows(entity: string): Promise<boolean> {
|
|
1653
|
-
const
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
.select(1)
|
|
1657
|
-
.where('entity_type', entity)
|
|
1667
|
+
const db = this.getDb() as any
|
|
1668
|
+
const coverage = await db
|
|
1669
|
+
.selectFrom('entity_index_coverage')
|
|
1670
|
+
.select(sql<number>`1`.as('one'))
|
|
1671
|
+
.where('entity_type', '=', entity)
|
|
1658
1672
|
.where('indexed_count', '>', 0)
|
|
1659
|
-
.
|
|
1673
|
+
.executeTakeFirst()
|
|
1660
1674
|
if (coverage) return true
|
|
1661
|
-
const exists = await
|
|
1675
|
+
const exists = await db
|
|
1676
|
+
.selectFrom('entity_indexes')
|
|
1677
|
+
.select('entity_id')
|
|
1678
|
+
.where('entity_type', '=', entity)
|
|
1679
|
+
.executeTakeFirst()
|
|
1662
1680
|
return !!exists
|
|
1663
1681
|
}
|
|
1682
|
+
|
|
1664
1683
|
private async getStoredCoverageSnapshot(
|
|
1665
1684
|
entity: string,
|
|
1666
1685
|
tenantId: string | null,
|
|
@@ -1669,32 +1688,20 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1669
1688
|
): Promise<{ baseCount: number; indexedCount: number } | null> {
|
|
1670
1689
|
try {
|
|
1671
1690
|
if (!this.isCoverageOptimizationEnabled()) {
|
|
1672
|
-
await refreshCoverageSnapshot(
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
entityType: entity,
|
|
1676
|
-
tenantId,
|
|
1677
|
-
organizationId,
|
|
1678
|
-
withDeleted,
|
|
1679
|
-
},
|
|
1680
|
-
)
|
|
1691
|
+
await refreshCoverageSnapshot(this.em, {
|
|
1692
|
+
entityType: entity, tenantId, organizationId, withDeleted,
|
|
1693
|
+
})
|
|
1681
1694
|
}
|
|
1682
|
-
const
|
|
1683
|
-
const row = await readCoverageSnapshot(
|
|
1684
|
-
entityType: entity,
|
|
1685
|
-
tenantId,
|
|
1686
|
-
organizationId,
|
|
1687
|
-
withDeleted,
|
|
1695
|
+
const db = this.getDb()
|
|
1696
|
+
const row = await readCoverageSnapshot(db as any, {
|
|
1697
|
+
entityType: entity, tenantId, organizationId, withDeleted,
|
|
1688
1698
|
})
|
|
1689
1699
|
if (!row) return null
|
|
1690
1700
|
return { baseCount: row.baseCount, indexedCount: row.indexedCount }
|
|
1691
1701
|
} catch (err) {
|
|
1692
1702
|
if (this.isDebugVerbosity()) {
|
|
1693
1703
|
this.debug('coverage:snapshot:read-error', {
|
|
1694
|
-
entity,
|
|
1695
|
-
tenantId,
|
|
1696
|
-
organizationId,
|
|
1697
|
-
withDeleted,
|
|
1704
|
+
entity, tenantId, organizationId, withDeleted,
|
|
1698
1705
|
error: err instanceof Error ? err.message : err,
|
|
1699
1706
|
})
|
|
1700
1707
|
}
|
|
@@ -1709,7 +1716,6 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1709
1716
|
organizationIdOverride?: string | null
|
|
1710
1717
|
) {
|
|
1711
1718
|
if (!this.isAutoReindexEnabled()) return
|
|
1712
|
-
|
|
1713
1719
|
const bus = this.resolveEventBus()
|
|
1714
1720
|
if (!bus) return
|
|
1715
1721
|
const payload = {
|
|
@@ -1719,27 +1725,19 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1719
1725
|
force: false,
|
|
1720
1726
|
}
|
|
1721
1727
|
const context = stats
|
|
1722
|
-
? {
|
|
1723
|
-
entity,
|
|
1724
|
-
tenantId: payload.tenantId,
|
|
1725
|
-
organizationId: payload.organizationId,
|
|
1726
|
-
baseCount: stats.baseCount,
|
|
1727
|
-
indexedCount: stats.indexedCount,
|
|
1728
|
-
}
|
|
1728
|
+
? { entity, tenantId: payload.tenantId, organizationId: payload.organizationId, baseCount: stats.baseCount, indexedCount: stats.indexedCount }
|
|
1729
1729
|
: { entity, tenantId: payload.tenantId, organizationId: payload.organizationId }
|
|
1730
1730
|
|
|
1731
|
-
void Promise.resolve()
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
}
|
|
1742
|
-
})
|
|
1731
|
+
void Promise.resolve().then(async () => {
|
|
1732
|
+
try {
|
|
1733
|
+
await bus.emitEvent('query_index.reindex', payload, { persistent: true })
|
|
1734
|
+
if (this.isDebugVerbosity()) this.debug('query:auto-reindex:scheduled', context)
|
|
1735
|
+
} catch (err) {
|
|
1736
|
+
console.warn('[HybridQueryEngine] Failed to schedule auto reindex:', {
|
|
1737
|
+
...context, error: err instanceof Error ? err.message : err,
|
|
1738
|
+
})
|
|
1739
|
+
}
|
|
1740
|
+
})
|
|
1743
1741
|
}
|
|
1744
1742
|
|
|
1745
1743
|
private scheduleCoverageRefresh(
|
|
@@ -1750,12 +1748,7 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1750
1748
|
): void {
|
|
1751
1749
|
const bus = this.resolveEventBus()
|
|
1752
1750
|
if (!bus) return
|
|
1753
|
-
const key = [
|
|
1754
|
-
entity,
|
|
1755
|
-
tenantId ?? '__tenant__',
|
|
1756
|
-
organizationId ?? '__org__',
|
|
1757
|
-
withDeleted ? '1' : '0',
|
|
1758
|
-
].join('|')
|
|
1751
|
+
const key = [entity, tenantId ?? '__tenant__', organizationId ?? '__org__', withDeleted ? '1' : '0'].join('|')
|
|
1759
1752
|
if (this.pendingCoverageRefreshKeys.has(key)) return
|
|
1760
1753
|
this.pendingCoverageRefreshKeys.add(key)
|
|
1761
1754
|
void Promise.resolve()
|
|
@@ -1763,34 +1756,24 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1763
1756
|
try {
|
|
1764
1757
|
await bus.emitEvent('query_index.coverage.refresh', {
|
|
1765
1758
|
entityType: entity,
|
|
1766
|
-
tenantId: tenantId ?? null,
|
|
1767
|
-
|
|
1768
|
-
withDeleted,
|
|
1769
|
-
delayMs: 0,
|
|
1759
|
+
tenantId: tenantId ?? null, organizationId: organizationId ?? null,
|
|
1760
|
+
withDeleted, delayMs: 0,
|
|
1770
1761
|
})
|
|
1771
1762
|
if (this.isDebugVerbosity()) {
|
|
1772
1763
|
this.debug('coverage:refresh:scheduled', {
|
|
1773
|
-
entity,
|
|
1774
|
-
tenantId: tenantId ?? null,
|
|
1775
|
-
organizationId: organizationId ?? null,
|
|
1776
|
-
withDeleted,
|
|
1764
|
+
entity, tenantId: tenantId ?? null, organizationId: organizationId ?? null, withDeleted,
|
|
1777
1765
|
})
|
|
1778
1766
|
}
|
|
1779
1767
|
} catch (err) {
|
|
1780
1768
|
if (this.isDebugVerbosity()) {
|
|
1781
1769
|
this.debug('coverage:refresh:failed', {
|
|
1782
|
-
entity,
|
|
1783
|
-
tenantId: tenantId ?? null,
|
|
1784
|
-
organizationId: organizationId ?? null,
|
|
1785
|
-
withDeleted,
|
|
1770
|
+
entity, tenantId: tenantId ?? null, organizationId: organizationId ?? null, withDeleted,
|
|
1786
1771
|
error: err instanceof Error ? err.message : err,
|
|
1787
1772
|
})
|
|
1788
1773
|
}
|
|
1789
1774
|
}
|
|
1790
1775
|
})
|
|
1791
|
-
.finally(() => {
|
|
1792
|
-
this.pendingCoverageRefreshKeys.delete(key)
|
|
1793
|
-
})
|
|
1776
|
+
.finally(() => { this.pendingCoverageRefreshKeys.delete(key) })
|
|
1794
1777
|
}
|
|
1795
1778
|
|
|
1796
1779
|
private resolveEventBus(): Pick<EventBus, 'emitEvent'> | null {
|
|
@@ -1805,17 +1788,8 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1805
1788
|
|
|
1806
1789
|
private isAutoReindexEnabled(): boolean {
|
|
1807
1790
|
if (this.autoReindexEnabled != null) return this.autoReindexEnabled
|
|
1808
|
-
const raw = (
|
|
1809
|
-
|
|
1810
|
-
process.env.QUERY_INDEX_AUTO_REINDEX ??
|
|
1811
|
-
''
|
|
1812
|
-
)
|
|
1813
|
-
.trim()
|
|
1814
|
-
.toLowerCase()
|
|
1815
|
-
if (!raw) {
|
|
1816
|
-
this.autoReindexEnabled = true
|
|
1817
|
-
return true
|
|
1818
|
-
}
|
|
1791
|
+
const raw = (process.env.SCHEDULE_AUTO_REINDEX ?? process.env.QUERY_INDEX_AUTO_REINDEX ?? '').trim().toLowerCase()
|
|
1792
|
+
if (!raw) { this.autoReindexEnabled = true; return true }
|
|
1819
1793
|
const parsed = parseBooleanToken(raw)
|
|
1820
1794
|
this.autoReindexEnabled = parsed === null ? true : parsed
|
|
1821
1795
|
return this.autoReindexEnabled
|
|
@@ -1824,10 +1798,7 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1824
1798
|
private isCoverageOptimizationEnabled(): boolean {
|
|
1825
1799
|
if (this.coverageOptimizationEnabled != null) return this.coverageOptimizationEnabled
|
|
1826
1800
|
const raw = (process.env.OPTIMIZE_INDEX_COVERAGE_STATS ?? '').trim().toLowerCase()
|
|
1827
|
-
if (!raw) {
|
|
1828
|
-
this.coverageOptimizationEnabled = false
|
|
1829
|
-
return false
|
|
1830
|
-
}
|
|
1801
|
+
if (!raw) { this.coverageOptimizationEnabled = false; return false }
|
|
1831
1802
|
this.coverageOptimizationEnabled = parseBooleanToken(raw) === true
|
|
1832
1803
|
return this.coverageOptimizationEnabled
|
|
1833
1804
|
}
|
|
@@ -1839,10 +1810,13 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1839
1810
|
if (cached === true) return true
|
|
1840
1811
|
this.columnCache.delete(key)
|
|
1841
1812
|
}
|
|
1842
|
-
const
|
|
1843
|
-
const exists = await
|
|
1844
|
-
.
|
|
1845
|
-
.
|
|
1813
|
+
const db = this.getDb() as any
|
|
1814
|
+
const exists = await db
|
|
1815
|
+
.selectFrom('information_schema.columns')
|
|
1816
|
+
.select(sql<number>`1`.as('one'))
|
|
1817
|
+
.where('table_name', '=', table)
|
|
1818
|
+
.where('column_name', '=', column)
|
|
1819
|
+
.executeTakeFirst()
|
|
1846
1820
|
const present = !!exists
|
|
1847
1821
|
if (present) this.columnCache.set(key, true)
|
|
1848
1822
|
else this.columnCache.delete(key)
|
|
@@ -1850,11 +1824,13 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1850
1824
|
}
|
|
1851
1825
|
|
|
1852
1826
|
private async getBaseColumnsForEntity(entity: string): Promise<Map<string, string>> {
|
|
1853
|
-
const
|
|
1827
|
+
const db = this.getDb() as any
|
|
1854
1828
|
const table = resolveEntityTableName(this.em, entity)
|
|
1855
|
-
const rows = await
|
|
1856
|
-
.
|
|
1857
|
-
.
|
|
1829
|
+
const rows = await db
|
|
1830
|
+
.selectFrom('information_schema.columns')
|
|
1831
|
+
.select(['column_name', 'data_type'])
|
|
1832
|
+
.where('table_name', '=', table)
|
|
1833
|
+
.execute() as Array<{ column_name: string; data_type: string }>
|
|
1858
1834
|
const map = new Map<string, string>()
|
|
1859
1835
|
for (const r of rows) map.set(r.column_name, r.data_type)
|
|
1860
1836
|
return map
|
|
@@ -1889,26 +1865,20 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1889
1865
|
return null
|
|
1890
1866
|
}
|
|
1891
1867
|
|
|
1892
|
-
private applyOrganizationScope
|
|
1893
|
-
q:
|
|
1868
|
+
private applyOrganizationScope(
|
|
1869
|
+
q: AnyBuilder,
|
|
1894
1870
|
column: string,
|
|
1895
1871
|
scope: { ids: string[]; includeNull: boolean }
|
|
1896
|
-
):
|
|
1872
|
+
): AnyBuilder {
|
|
1897
1873
|
if (scope.ids.length === 0 && !scope.includeNull) {
|
|
1898
|
-
return q.
|
|
1899
|
-
}
|
|
1900
|
-
return q.where((
|
|
1901
|
-
|
|
1902
|
-
if (scope.ids.length > 0)
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
if (scope.includeNull) {
|
|
1907
|
-
if (applied) builder.orWhereNull(column)
|
|
1908
|
-
else builder.whereNull(column)
|
|
1909
|
-
} else if (!applied) {
|
|
1910
|
-
builder.whereRaw('1 = 0')
|
|
1911
|
-
}
|
|
1874
|
+
return q.where(sql<boolean>`1 = 0`)
|
|
1875
|
+
}
|
|
1876
|
+
return q.where((eb: any) => {
|
|
1877
|
+
const parts: any[] = []
|
|
1878
|
+
if (scope.ids.length > 0) parts.push(eb(column, 'in', scope.ids))
|
|
1879
|
+
if (scope.includeNull) parts.push(eb(column, 'is', null))
|
|
1880
|
+
if (parts.length === 1) return parts[0]
|
|
1881
|
+
return eb.or(parts)
|
|
1912
1882
|
})
|
|
1913
1883
|
}
|
|
1914
1884
|
|
|
@@ -1918,8 +1888,7 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1918
1888
|
if (Array.isArray(filters)) {
|
|
1919
1889
|
return (filters as Filter[]).map((filter) => ({
|
|
1920
1890
|
field: normalizeField(String(filter.field)),
|
|
1921
|
-
op: filter.op,
|
|
1922
|
-
value: filter.value,
|
|
1891
|
+
op: filter.op, value: filter.value,
|
|
1923
1892
|
}))
|
|
1924
1893
|
}
|
|
1925
1894
|
const out: NormalizedFilter[] = []
|
|
@@ -1955,12 +1924,8 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1955
1924
|
}
|
|
1956
1925
|
|
|
1957
1926
|
private toArray(value: unknown): readonly unknown[] {
|
|
1958
|
-
if (Array.isArray(value))
|
|
1959
|
-
|
|
1960
|
-
}
|
|
1961
|
-
if (value === undefined) {
|
|
1962
|
-
return []
|
|
1963
|
-
}
|
|
1927
|
+
if (Array.isArray(value)) return value
|
|
1928
|
+
if (value === undefined) return []
|
|
1964
1929
|
return [value]
|
|
1965
1930
|
}
|
|
1966
1931
|
|
|
@@ -1972,6 +1937,7 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1972
1937
|
const parsed = Number(value)
|
|
1973
1938
|
return Number.isNaN(parsed) ? 0 : parsed
|
|
1974
1939
|
}
|
|
1940
|
+
if (typeof value === 'bigint') return Number(value)
|
|
1975
1941
|
}
|
|
1976
1942
|
return 0
|
|
1977
1943
|
}
|
|
@@ -1985,12 +1951,12 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1985
1951
|
}
|
|
1986
1952
|
}
|
|
1987
1953
|
|
|
1988
|
-
private applyColumnFilter
|
|
1989
|
-
q:
|
|
1990
|
-
column: string |
|
|
1954
|
+
private applyColumnFilter(
|
|
1955
|
+
q: AnyBuilder,
|
|
1956
|
+
column: string | RawBuilder<unknown>,
|
|
1991
1957
|
filter: NormalizedFilter,
|
|
1992
|
-
search?: SearchRuntime & {
|
|
1993
|
-
):
|
|
1958
|
+
search?: SearchRuntime & { entity: string; field: string; recordIdColumn?: string },
|
|
1959
|
+
): AnyBuilder {
|
|
1994
1960
|
if (
|
|
1995
1961
|
(filter.op === 'like' || filter.op === 'ilike') &&
|
|
1996
1962
|
search?.enabled &&
|
|
@@ -2003,71 +1969,53 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2003
1969
|
? search.searchSources
|
|
2004
1970
|
: [{ entity: search.entity, recordIdColumn: search.recordIdColumn ?? '' }]
|
|
2005
1971
|
).filter((src) => src.recordIdColumn && src.entity)
|
|
2006
|
-
let applied = false
|
|
2007
1972
|
if (sources.length) {
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
entity: src.entity,
|
|
2013
|
-
field: search.field,
|
|
2014
|
-
hashes,
|
|
1973
|
+
const engine = this
|
|
1974
|
+
q = q.where((eb: any) => eb.or(
|
|
1975
|
+
sources.map((src) =>
|
|
1976
|
+
eb.exists(engine.buildSearchTokensSub(eb, {
|
|
1977
|
+
entity: src.entity, field: search.field, hashes,
|
|
2015
1978
|
recordIdColumn: src.recordIdColumn,
|
|
2016
1979
|
tenantId: search.tenantId ?? null,
|
|
2017
1980
|
organizationScope: search.organizationScope ?? null,
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
1981
|
+
})))
|
|
1982
|
+
))
|
|
1983
|
+
this.logSearchDebug('search:filter', {
|
|
1984
|
+
entity: search.entity, field: search.field, tokens: tokens.tokens, hashes,
|
|
1985
|
+
applied: true, tenantId: search.tenantId ?? null,
|
|
1986
|
+
organizationScope: search.organizationScope,
|
|
1987
|
+
sources: sources.map((src) => ({ entity: src.entity, recordIdColumn: src.recordIdColumn })),
|
|
2022
1988
|
})
|
|
1989
|
+
return q
|
|
2023
1990
|
}
|
|
2024
|
-
this.logSearchDebug('search:filter', {
|
|
2025
|
-
entity: search.entity,
|
|
2026
|
-
field: search.field,
|
|
2027
|
-
tokens: tokens.tokens,
|
|
2028
|
-
hashes,
|
|
2029
|
-
applied,
|
|
2030
|
-
tenantId: search.tenantId ?? null,
|
|
2031
|
-
organizationScope: search.organizationScope,
|
|
2032
|
-
sources: sources.map((src) => ({ entity: src.entity, recordIdColumn: src.recordIdColumn })),
|
|
2033
|
-
})
|
|
2034
|
-
if (applied) return q
|
|
2035
1991
|
} else {
|
|
2036
1992
|
this.logSearchDebug('search:skip-empty-hashes', {
|
|
2037
|
-
entity: search.entity,
|
|
2038
|
-
field: search.field,
|
|
2039
|
-
value: filter.value,
|
|
1993
|
+
entity: search.entity, field: search.field, value: filter.value,
|
|
2040
1994
|
})
|
|
2041
1995
|
}
|
|
2042
1996
|
return q
|
|
2043
1997
|
}
|
|
2044
|
-
const col = column
|
|
1998
|
+
const col: any = column
|
|
2045
1999
|
switch (filter.op) {
|
|
2046
|
-
case 'eq':
|
|
2047
|
-
|
|
2048
|
-
case 'ne':
|
|
2049
|
-
return q.whereNot(col, filter.value as Knex.Value)
|
|
2000
|
+
case 'eq': return q.where(col, '=', filter.value as any)
|
|
2001
|
+
case 'ne': return q.where(col, '!=', filter.value as any)
|
|
2050
2002
|
case 'gt':
|
|
2051
2003
|
case 'gte':
|
|
2052
2004
|
case 'lt':
|
|
2053
2005
|
case 'lte': {
|
|
2054
2006
|
const operator = filter.op === 'gt' ? '>' : filter.op === 'gte' ? '>=' : filter.op === 'lt' ? '<' : '<='
|
|
2055
|
-
return q.where(col, operator, filter.value as
|
|
2056
|
-
}
|
|
2057
|
-
case 'in': {
|
|
2058
|
-
const values = this.toArray(filter.value) as readonly Knex.Value[]
|
|
2059
|
-
return q.whereIn(col, values)
|
|
2060
|
-
}
|
|
2061
|
-
case 'nin': {
|
|
2062
|
-
const values = this.toArray(filter.value) as readonly Knex.Value[]
|
|
2063
|
-
return q.whereNotIn(col, values)
|
|
2007
|
+
return q.where(col, operator, filter.value as any)
|
|
2064
2008
|
}
|
|
2009
|
+
case 'in':
|
|
2010
|
+
return q.where(col, 'in', this.toArray(filter.value))
|
|
2011
|
+
case 'nin':
|
|
2012
|
+
return q.where(col, 'not in', this.toArray(filter.value))
|
|
2065
2013
|
case 'like':
|
|
2066
|
-
return q.where(col, 'like', filter.value as
|
|
2014
|
+
return q.where(col, 'like', filter.value as any)
|
|
2067
2015
|
case 'ilike':
|
|
2068
|
-
return q.where(col, 'ilike', filter.value as
|
|
2016
|
+
return q.where(col, 'ilike', filter.value as any)
|
|
2069
2017
|
case 'exists':
|
|
2070
|
-
return filter.value ? q.
|
|
2018
|
+
return filter.value ? q.where(col, 'is not', null) : q.where(col, 'is', null)
|
|
2071
2019
|
default:
|
|
2072
2020
|
return q
|
|
2073
2021
|
}
|
|
@@ -2120,10 +2068,7 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2120
2068
|
const baseCount = snapshot.baseCount
|
|
2121
2069
|
const indexCount = snapshot.indexedCount
|
|
2122
2070
|
const hasGap = baseCount > 0 && indexCount < baseCount
|
|
2123
|
-
if (hasGap || indexCount > baseCount) {
|
|
2124
|
-
return { stats: snapshot, scope: 'scoped' }
|
|
2125
|
-
}
|
|
2126
|
-
|
|
2071
|
+
if (hasGap || indexCount > baseCount) return { stats: snapshot, scope: 'scoped' }
|
|
2127
2072
|
return null
|
|
2128
2073
|
}
|
|
2129
2074
|
|
|
@@ -2146,18 +2091,13 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
2146
2091
|
): Promise<TResult> {
|
|
2147
2092
|
const shouldDebug = this.isSqlDebugEnabled() && this.isDebugVerbosity()
|
|
2148
2093
|
const shouldProfile = profiler?.enabled === true
|
|
2149
|
-
if (!shouldDebug && !shouldProfile)
|
|
2150
|
-
return Promise.resolve(execute())
|
|
2151
|
-
}
|
|
2094
|
+
if (!shouldDebug && !shouldProfile) return Promise.resolve(execute())
|
|
2152
2095
|
const startedAt = process.hrtime.bigint()
|
|
2153
2096
|
try {
|
|
2154
2097
|
return await Promise.resolve(execute())
|
|
2155
2098
|
} finally {
|
|
2156
2099
|
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000
|
|
2157
|
-
const context: Record<string, unknown> = {
|
|
2158
|
-
entity,
|
|
2159
|
-
durationMs: Math.round(elapsedMs * 1000) / 1000,
|
|
2160
|
-
}
|
|
2100
|
+
const context: Record<string, unknown> = { entity, durationMs: Math.round(elapsedMs * 1000) / 1000 }
|
|
2161
2101
|
if (extra) Object.assign(context, extra)
|
|
2162
2102
|
if (shouldProfile) profiler!.record(label, context.durationMs as number, extra)
|
|
2163
2103
|
if (shouldDebug) this.debug(`${label}:timing`, context)
|