From 03f6e307b3bb704ede7425fc6a71104a8d5c5268 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 06:02:02 +0000 Subject: [PATCH] [sync] fix(core): normalize numeric formula field values Synced from teableio/teable-ee@a13a75a Co-authored-by: Boris Co-authored-by: Jocky-Teable Co-authored-by: Pengap Co-authored-by: Uno Co-authored-by: nichenqin --- .../src/configs/env.validation.schema.spec.ts | 5 +- .../src/configs/env.validation.schema.ts | 1 - .../listeners/record-history.listener.ts | 21 +- .../aggregation/aggregation.service.spec.ts | 51 + .../aggregation/aggregation.service.ts | 52 +- .../base-sql-executor.service.ts | 280 +--- .../features/base/base-duplicate.service.ts | 73 +- .../src/features/base/base-export.service.ts | 64 +- .../base-import-csv.processor.spec.ts | 92 ++ .../base-import-csv.processor.ts | 125 +- .../base-import-junction.processor.ts | 57 +- .../features/base/base-import.service.spec.ts | 155 +- .../src/features/base/base-import.service.ts | 157 +- .../base/base-query/base-query.service.ts | 9 +- .../src/features/base/base.controller.ts | 4 +- .../src/features/base/base.service.spec.ts | 76 + .../src/features/base/base.service.ts | 93 +- .../features/base/db-connection.service.ts | 50 +- .../src/features/calculation/batch.service.ts | 178 ++- .../field-calculation.service.spec.ts | 7 +- .../calculation/field-calculation.service.ts | 30 +- .../src/features/calculation/link.service.ts | 91 +- .../calculation/system-field.service.ts | 12 +- .../src/features/canary/canary.service.ts | 4 +- .../database-view/database-view.service.ts | 14 +- .../field-converting-link.service.ts | 6 +- .../field-converting.service.ts | 50 +- .../field-calculate/field-creating.service.ts | 86 +- .../field-supplement.service.ts | 55 +- .../field-duplicate.service.ts | 424 +++-- .../src/features/field/field.service.spec.ts | 28 + .../src/features/field/field.service.ts | 108 +- .../open-api/field-open-api-v2.service.ts | 43 +- .../field/open-api/field-open-api.service.ts | 85 +- .../src/features/graph/graph.service.ts | 29 +- .../features/health/health.controller.test.ts | 9 +- .../src/features/health/health.controller.ts | 9 +- .../open-api/import-open-api-v2.service.ts | 4 +- .../features/integrity/foreign-key.service.ts | 24 +- .../integrity/integrity-v2.service.ts | 8 +- .../features/integrity/link-field.service.ts | 19 +- .../integrity/link-integrity.service.ts | 34 +- .../integrity/unique-index.service.ts | 10 +- .../notification/notification.service.ts | 167 +- .../computed-dependency-collector.service.ts | 32 +- .../services/computed-orchestrator.service.ts | 8 +- .../services/link-cascade-resolver.ts | 19 +- .../record-computed-update.service.ts | 16 +- .../record-open-api-v2.service.spec.ts | 47 +- .../open-api/record-open-api-v2.service.ts | 133 +- .../open-api/record-open-api.service.spec.ts | 14 +- .../open-api/record-open-api.service.ts | 9 +- .../record-modify/record-create.service.ts | 2 +- .../record-modify.shared.service.ts | 6 +- .../features/record/record-query.service.ts | 10 +- .../features/record/record.service.spec.ts | 72 +- .../src/features/record/record.service.ts | 231 ++- .../open-api/admin-open-api.controller.ts | 15 +- .../setting/open-api/admin-open-api.module.ts | 2 + .../open-api/admin-open-api.service.ts | 25 +- .../src/features/share/share.service.ts | 11 +- .../space/data-db-baseline.service.ts | 60 +- .../space/data-db-binding.service.spec.ts | 256 ++- .../features/space/data-db-binding.service.ts | 164 +- .../features/space/data-db-internal-schema.ts | 34 + .../space/data-db-migration.service.spec.ts | 259 +++ .../space/data-db-migration.service.ts | 317 ++++ .../space/data-db-preflight.service.spec.ts | 159 +- .../space/data-db-preflight.service.ts | 195 ++- .../table-open-api-v2.service.spec.ts | 93 +- .../open-api/table-open-api-v2.service.ts | 88 +- .../open-api/table-open-api.service.spec.ts | 195 ++- .../table/open-api/table-open-api.service.ts | 119 +- ...able-mutation-cache-invalidator.service.ts | 5 +- .../features/table/table-duplicate.service.ts | 97 +- .../src/features/table/table-index.service.ts | 45 +- .../src/features/table/table.service.spec.ts | 52 + .../src/features/table/table.service.ts | 43 +- .../listener/table-trash.listener.spec.ts | 25 +- .../trash/listener/table-trash.listener.ts | 64 +- .../src/features/trash/trash.controller.ts | 3 +- .../src/features/trash/trash.service.ts | 112 +- .../features/trash/v2-record-trash.service.ts | 2 +- .../trash/v2-table-trash.service.spec.ts | 36 +- .../features/trash/v2-table-trash.service.ts | 53 +- .../undo-redo/open-api/undo-redo.service.ts | 8 +- .../operations/delete-fields.operation.ts | 24 +- .../operations/delete-records.operation.ts | 67 +- .../operations/delete-trash-routing.spec.ts | 32 +- .../operations/delete-view.operation.ts | 19 +- .../stack/undo-redo-operation.service.ts | 12 +- .../src/features/user/user.service.ts | 17 + .../features/v2/v2-container.service.spec.ts | 62 +- .../src/features/v2/v2-container.service.ts | 66 +- .../v2/v2-execution-context.factory.ts | 5 +- .../v2/v2-field-delete-compat.service.spec.ts | 59 +- .../v2/v2-field-delete-compat.service.ts | 5 +- .../features/v2/v2-record-history.service.ts | 9 +- .../v2/v2-view-compat.service.spec.ts | 141 +- .../src/features/v2/v2-view-compat.service.ts | 76 +- .../src/features/v2/v2.controller.ts | 16 +- .../view/open-api/view-open-api-v2.service.ts | 4 +- .../view/open-api/view-open-api.service.ts | 45 +- .../view-data-safety-limit.service.spec.ts | 159 ++ .../view/view-data-safety-limit.service.ts | 178 +++ .../src/features/view/view.module.ts | 3 +- .../src/features/view/view.service.ts | 73 +- .../filter/global-exception.filter.spec.ts | 155 +- .../src/filter/global-exception.filter.ts | 126 +- .../src/global/byodb-routing.guard.spec.ts | 55 + .../data-db-client-manager.service.spec.ts | 228 ++- .../global/data-db-client-manager.service.ts | 298 ++-- .../data-db-runtime-cache.service.spec.ts | 55 + .../global/data-db-runtime-cache.service.ts | 137 ++ .../src/global/data-db-runtime-error.spec.ts | 66 + .../src/global/data-db-runtime-error.ts | 207 +++ .../global/database-router.service.spec.ts | 89 ++ .../src/global/database-router.service.ts | 200 ++- .../src/global/global.module.ts | 6 + .../readonly/record-readonly.service.ts | 26 +- .../src/tracing-db-context.spec.ts | 94 ++ apps/nestjs-backend/src/tracing-db-context.ts | 149 ++ apps/nestjs-backend/src/tracing.ts | 22 + apps/nestjs-backend/src/types/cls.ts | 13 +- .../src/types/i18n.generated.ts | 66 + .../test/base-sql-executor.e2e-spec.ts | 6 +- .../byodb-space-storage-placement.e2e-spec.ts | 1403 +++++++++++++++++ .../test/dual-db-split.e2e-spec.ts | 4 +- .../test/large-table-operations.e2e-spec.ts | 32 +- .../test/table-trash.e2e-spec.ts | 14 +- .../app/blocks/space/NoSpacesPlaceholder.tsx | 34 +- .../data-db/ByodbSpaceCreateSection.spec.tsx | 152 ++ .../space/data-db/ByodbSpaceCreateSection.tsx | 215 +++ .../data-db/create-space-data-db.spec.ts | 41 + .../space/data-db/create-space-data-db.ts | 38 + .../app/blocks/space/data-db/index.ts | 3 + .../space/data-db/useByodbSpaceCreate.tsx | 89 ++ .../blocks/space/space-side-bar/SpaceList.tsx | 34 +- .../space/space-side-bar/SpaceSwitcher.tsx | 35 +- .../blocks/trash/components/TableTrash.tsx | 2 +- .../field-setting/FieldSetting.spec.tsx | 184 ++- .../components/field-setting/FieldSetting.tsx | 13 +- .../notifications/NotificationIcon.tsx | 3 +- .../notifications/NotificationItem.tsx | 6 +- .../notifications/NotificationsManage.tsx | 52 +- .../LinkNotification.tsx | 2 +- apps/nextjs-app/src/lib/database-url.spec.ts | 6 - apps/nextjs-app/src/lib/database-url.ts | 1 - .../common-i18n/src/locales/de/common.json | 8 +- packages/common-i18n/src/locales/de/sdk.json | 25 + .../common-i18n/src/locales/en/common.json | 8 +- packages/common-i18n/src/locales/en/sdk.json | 25 + .../common-i18n/src/locales/en/space.json | 62 +- .../common-i18n/src/locales/en/table.json | 4 + .../common-i18n/src/locales/es/common.json | 8 +- packages/common-i18n/src/locales/es/sdk.json | 25 + .../common-i18n/src/locales/fr/common.json | 8 +- packages/common-i18n/src/locales/fr/sdk.json | 25 + .../common-i18n/src/locales/it/common.json | 8 +- packages/common-i18n/src/locales/it/sdk.json | 25 + .../common-i18n/src/locales/ja/common.json | 8 +- packages/common-i18n/src/locales/ja/sdk.json | 25 + .../common-i18n/src/locales/ru/common.json | 8 +- packages/common-i18n/src/locales/ru/sdk.json | 25 + .../common-i18n/src/locales/tr/common.json | 8 +- packages/common-i18n/src/locales/tr/sdk.json | 25 + .../common-i18n/src/locales/uk/common.json | 8 +- packages/common-i18n/src/locales/uk/sdk.json | 25 + .../common-i18n/src/locales/zh/common.json | 8 +- packages/common-i18n/src/locales/zh/sdk.json | 25 + .../common-i18n/src/locales/zh/space.json | 61 + .../common-i18n/src/locales/zh/table.json | 4 + packages/core/src/formula/visitor.spec.ts | 15 + packages/core/src/formula/visitor.ts | 24 + .../models/notification/notification.enum.ts | 1 + packages/db-data-prisma/package.json | 2 +- .../migration.sql | 13 +- packages/db-data-prisma/prisma/schema.prisma | 2 +- .../scripts/run-prisma-command.mjs | 5 +- packages/db-data-prisma/src/database-url.ts | 19 +- .../migration.sql | 13 + .../prisma/postgres/schema.prisma | 1 + packages/db-main-prisma/src/database-url.ts | 11 +- packages/openapi/src/admin/setting/get.ts | 9 +- .../openapi/src/admin/setting/key.enum.ts | 1 - packages/openapi/src/admin/setting/update.ts | 26 - packages/openapi/src/base/import.ts | 2 +- packages/openapi/src/notification/index.ts | 1 + .../notification/send-admin-notification.ts | 26 + packages/openapi/src/space/create.ts | 3 +- packages/openapi/src/space/data-db.spec.ts | 24 + packages/openapi/src/space/data-db.ts | 105 ++ packages/openapi/src/trash/restore.ts | 9 +- .../components/expand-record/Modal.spec.tsx | 33 + .../src/components/expand-record/Modal.tsx | 3 +- .../sdk/src/context/app/queryClient.spec.ts | 76 + packages/sdk/src/context/app/queryClient.tsx | 29 +- .../repositories/PostgresTableRepository.ts | 8 +- .../PostgresTableRowLimitPlugin.ts | 3 +- .../visitors/TableWhereVisitor.spec.ts | 22 +- .../visitors/TableWhereVisitor.ts | 4 + .../commands/CreateRecordHandler.db.spec.ts | 108 ++ .../record/computed/ComputedFieldUpdater.ts | 8 +- .../computed/UpdateFromSelectBuilder.ts | 31 +- .../__tests__/UpdateFromSelectBuilder.spec.ts | 55 +- .../strategies/HybridWithOutboxStrategy.ts | 2 +- .../ComputedUpdatePollingService.spec.ts | 37 + .../worker/ComputedUpdatePollingService.ts | 16 +- .../computed/worker/ComputedUpdateWorker.ts | 2 +- .../ComputedFieldSelectExpressionVisitor.ts | 11 + .../ComputedTableRecordQueryBuilder.ts | 4 +- .../computed/FieldReferenceSqlVisitor.ts | 13 +- .../computed/SameTableBatchQueryBuilder.ts | 33 +- ...sTableRecordQueryRepository.pglite.spec.ts | 51 + .../PostgresTableRecordQueryRepository.ts | 10 +- ...stgresTableRecordRepository.delete.spec.ts | 6 +- .../PostgresTableRecordRepository.ts | 2 +- ...stgresTableRecordRepository.update.spec.ts | 141 +- .../PostgresTableSchemaRepository.spec.ts | 111 ++ .../PostgresTableSchemaRepository.ts | 26 +- .../src/schema/rules/core/ISchemaRule.ts | 9 +- .../rules/core/SchemaStatementAccessPolicy.ts | 81 + .../src/schema/rules/core/index.ts | 7 + .../schema/rules/field/ColumnExistsRule.ts | 8 +- .../rules/field/ColumnUniqueConstraintRule.ts | 3 +- .../rules/field/FieldSchemaRulesFactory.ts | 7 +- .../src/schema/rules/field/FkColumnRule.ts | 9 +- .../src/schema/rules/field/ForeignKeyRule.ts | 27 +- .../rules/field/GeneratedColumnMetaRule.ts | 14 +- .../schema/rules/field/JunctionTableRule.ts | 58 +- .../schema/rules/field/LinkValueColumnRule.ts | 8 +- .../rules/field/NotNullConstraintRule.ts | 5 +- .../src/schema/rules/field/OrderColumnRule.ts | 8 +- .../rules/field/SchemaRules.pglite.spec.ts | 38 +- .../rules/field/SelectOptionsMetaRule.ts | 94 +- .../schema/rules/helpers/StatementBuilders.ts | 197 +-- .../src/schema/rules/helpers/index.ts | 2 + .../src/schema/rules/index.ts | 4 + .../schema/rules/table/SystemTableRules.ts | 95 +- .../visitors/FieldTypeConversionVisitor.ts | 115 +- ...tgresTableSchemaFieldCreateVisitor.spec.ts | 5 + .../PostgresTableSchemaFieldCreateVisitor.ts | 42 +- .../visitors/TableSchemaUpdateVisitor.ts | 54 +- .../FieldTypeConversionVisitor.spec.ts | 41 +- .../src/shared/db.spec.ts | 31 +- .../src/shared/db.ts | 16 +- .../src/shared/undoCapture.ts | 10 +- .../src/shared/undoCaptureGlobalsSql.ts | 12 +- .../src/benchmarkTableDataSafetyLimits.ts | 8 + .../benchmark-node/src/create-table.bench.ts | 5 +- .../src/get-table-by-id.bench.ts | 5 +- packages/v2/container-node/src/index.ts | 3 +- .../RecordsBatchCreatedRealtimeProjection.ts | 2 + .../projections/runRealtimeTasks.ts | 1 + .../FieldUpdateSideEffectService.spec.ts | 18 +- .../services/FieldUpdateSideEffectService.ts | 4 +- .../services/TableCreationService.spec.ts | 37 +- .../services/TableCreationService.ts | 4 +- ...ableDataSafetyLimitFieldOperationPlugin.ts | 16 +- ...ataSafetyLimitTableOperationPlugin.spec.ts | 69 +- ...ableDataSafetyLimitTableOperationPlugin.ts | 114 +- ...DataSafetyLimitViewOperationPlugin.spec.ts | 218 +++ ...TableDataSafetyLimitViewOperationPlugin.ts | 204 +++ .../services/TableUpdateFlow.spec.ts | 26 + .../application/services/TableUpdateFlow.ts | 54 +- .../services/ViewOperationPluginRunner.ts | 161 ++ .../core/src/commands/CreateTableHandler.ts | 1 + .../core/src/commands/CreateTablesHandler.ts | 1 + .../core/src/commands/DeleteTableHandler.ts | 40 +- .../src/commands/DuplicateTableHandler.ts | 1 + .../v2/core/src/commands/ImportCsvHandler.ts | 1 + .../ImportDotTeaStructureHandler.spec.ts | 73 +- .../commands/ImportDotTeaStructureHandler.ts | 29 +- .../src/commands/ImportRecordsHandler.spec.ts | 4 +- .../core/src/commands/ImportRecordsHandler.ts | 3 +- .../v2/core/src/di/registerCoreServices.ts | 22 + .../src/di/registerViewOperationPlugin.ts | 68 + packages/v2/core/src/domain/table/Table.ts | 13 +- packages/v2/core/src/index.ts | 4 + .../v2/core/src/ports/TableOperationPlugin.ts | 4 + packages/v2/core/src/ports/TableRepository.ts | 2 +- .../core/src/ports/TableSchemaRepository.ts | 5 +- .../v2/core/src/ports/ViewOperationPlugin.ts | 92 ++ packages/v2/core/src/ports/tokens.ts | 2 + packages/v2/e2e/src/deleteTable.e2e.spec.ts | 88 ++ .../longText/conversion/to-date.spec.ts | 21 +- .../src/FormulaSqlPgExpressionBuilder.ts | 11 +- .../src/LookupArrayNormalization.spec.ts | 7 + .../v2/formula-sql-pg/src/PgSqlHelpers.ts | 8 +- .../__snapshots__/ArrayFunctions.spec.ts.snap | 68 +- .../BinaryOperators.spec.ts.snap | 48 +- .../__snapshots__/DateFunctions.spec.ts.snap | 400 ++--- .../IfBranchNormalization.spec.ts.snap | 4 +- .../LogicalFunctions.spec.ts.snap | 88 +- .../NumericFunctions.spec.ts.snap | 150 +- .../__snapshots__/TextFunctions.spec.ts.snap | 84 +- packages/v2/formula-sql-pg/vitest.config.ts | 1 + scripts/db-migrate.mjs | 2 +- 298 files changed, 13486 insertions(+), 2894 deletions(-) create mode 100644 apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.spec.ts create mode 100644 apps/nestjs-backend/src/features/space/data-db-internal-schema.ts create mode 100644 apps/nestjs-backend/src/features/space/data-db-migration.service.spec.ts create mode 100644 apps/nestjs-backend/src/features/space/data-db-migration.service.ts create mode 100644 apps/nestjs-backend/src/features/view/view-data-safety-limit.service.spec.ts create mode 100644 apps/nestjs-backend/src/features/view/view-data-safety-limit.service.ts create mode 100644 apps/nestjs-backend/src/global/byodb-routing.guard.spec.ts create mode 100644 apps/nestjs-backend/src/global/data-db-runtime-cache.service.spec.ts create mode 100644 apps/nestjs-backend/src/global/data-db-runtime-cache.service.ts create mode 100644 apps/nestjs-backend/src/global/data-db-runtime-error.spec.ts create mode 100644 apps/nestjs-backend/src/global/data-db-runtime-error.ts create mode 100644 apps/nestjs-backend/src/global/database-router.service.spec.ts create mode 100644 apps/nestjs-backend/src/tracing-db-context.spec.ts create mode 100644 apps/nestjs-backend/src/tracing-db-context.ts create mode 100644 apps/nestjs-backend/test/byodb-space-storage-placement.e2e-spec.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/space/data-db/ByodbSpaceCreateSection.spec.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/space/data-db/ByodbSpaceCreateSection.tsx create mode 100644 apps/nextjs-app/src/features/app/blocks/space/data-db/create-space-data-db.spec.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/space/data-db/create-space-data-db.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/space/data-db/index.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/space/data-db/useByodbSpaceCreate.tsx create mode 100644 packages/db-main-prisma/prisma/postgres/migrations/20260507075100_add_data_db_internal_schema/migration.sql create mode 100644 packages/openapi/src/notification/send-admin-notification.ts create mode 100644 packages/sdk/src/components/expand-record/Modal.spec.tsx create mode 100644 packages/v2/adapter-table-repository-postgres/src/schema/rules/core/SchemaStatementAccessPolicy.ts create mode 100644 packages/v2/benchmark-node/src/benchmarkTableDataSafetyLimits.ts create mode 100644 packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.spec.ts create mode 100644 packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.ts create mode 100644 packages/v2/core/src/application/services/ViewOperationPluginRunner.ts create mode 100644 packages/v2/core/src/di/registerViewOperationPlugin.ts create mode 100644 packages/v2/core/src/ports/ViewOperationPlugin.ts diff --git a/apps/nestjs-backend/src/configs/env.validation.schema.spec.ts b/apps/nestjs-backend/src/configs/env.validation.schema.spec.ts index 37fe17fecd..34828c88e1 100644 --- a/apps/nestjs-backend/src/configs/env.validation.schema.spec.ts +++ b/apps/nestjs-backend/src/configs/env.validation.schema.spec.ts @@ -18,19 +18,16 @@ describe('envValidationSchema', () => { expect(value.PRISMA_DATABASE_URL).toContain('/teable'); }); - it('accepts split meta/data env without the legacy alias', () => { + it('accepts split meta env without the legacy alias', () => { const { error, value } = envValidationSchema.validate( createEnv({ PRISMA_META_DATABASE_URL: 'postgresql://teable:teable@127.0.0.1:5432/teable-meta?schema=public', - PRISMA_DATA_DATABASE_URL: - 'postgresql://teable:teable@127.0.0.1:5432/teable-data?schema=public', }) ); expect(error).toBeUndefined(); expect(value.PRISMA_META_DATABASE_URL).toContain('/teable-meta'); - expect(value.PRISMA_DATA_DATABASE_URL).toContain('/teable-data'); }); it('accepts DATABASE_URL as the last-resort meta fallback', () => { diff --git a/apps/nestjs-backend/src/configs/env.validation.schema.ts b/apps/nestjs-backend/src/configs/env.validation.schema.ts index acbadb547c..f17902d90e 100644 --- a/apps/nestjs-backend/src/configs/env.validation.schema.ts +++ b/apps/nestjs-backend/src/configs/env.validation.schema.ts @@ -15,7 +15,6 @@ export const envValidationSchema = Joi.object({ // database_url PRISMA_DATABASE_URL: Joi.string(), PRISMA_META_DATABASE_URL: Joi.string(), - PRISMA_DATA_DATABASE_URL: Joi.string(), DATABASE_URL: Joi.string(), STORAGE_PREFIX: Joi.string().uri().optional(), diff --git a/apps/nestjs-backend/src/event-emitter/listeners/record-history.listener.ts b/apps/nestjs-backend/src/event-emitter/listeners/record-history.listener.ts index 4ccc0afa82..6a05805db3 100644 --- a/apps/nestjs-backend/src/event-emitter/listeners/record-history.listener.ts +++ b/apps/nestjs-backend/src/event-emitter/listeners/record-history.listener.ts @@ -3,15 +3,12 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import type { ISelectFieldOptions } from '@teable/core'; import { FieldType, generateRecordHistoryId } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import type { Field } from '@teable/db-main-prisma'; -import { Knex } from 'knex'; import { isEqual, isObject, isString } from 'lodash'; -import { InjectModel } from 'nest-knexjs'; import { BaseConfig, IBaseConfig } from '../../configs/base.config'; import { DataLoaderService } from '../../features/data-loader/data-loader.service'; import { rawField2FieldObj } from '../../features/field/model/factory'; -import { DATA_KNEX } from '../../global/knex/knex.module'; +import { DatabaseRouter } from '../../global/database-router.service'; import { EventEmitterService } from '../event-emitter.service'; import { Events, RecordUpdateEvent } from '../events'; @@ -21,10 +18,9 @@ const SELECT_FIELD_TYPE_SET = new Set([FieldType.SingleSelect, FieldType.Multipl @Injectable() export class RecordHistoryListener { constructor( - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly eventEmitterService: EventEmitterService, @BaseConfig() private readonly baseConfig: IBaseConfig, - @InjectModel(DATA_KNEX) private readonly knex: Knex, private readonly dataLoaderService: DataLoaderService ) {} @@ -129,9 +125,16 @@ export class RecordHistoryListener { }); if (recordHistoryList.length) { - const query = this.knex.insert(recordHistoryList).into('record_history').toQuery(); - - await this.dataPrismaService.txClient().$executeRawUnsafe(query); + const dataKnex = await this.databaseRouter.dataKnexForTable(tableId); + const dataDbUrl = await this.databaseRouter.getDataDatabaseUrlForTable(tableId); + const dataDbInternalSchema = new URL(dataDbUrl).searchParams.get('schema') || 'public'; + const query = dataKnex + .withSchema(dataDbInternalSchema) + .insert(recordHistoryList) + .into('record_history') + .toQuery(); + + await this.databaseRouter.executeDataPrismaForTable(tableId, query); } } diff --git a/apps/nestjs-backend/src/features/aggregation/aggregation.service.spec.ts b/apps/nestjs-backend/src/features/aggregation/aggregation.service.spec.ts index f2d68948f7..23632dc9d5 100644 --- a/apps/nestjs-backend/src/features/aggregation/aggregation.service.spec.ts +++ b/apps/nestjs-backend/src/features/aggregation/aggregation.service.spec.ts @@ -18,4 +18,55 @@ describe('AggregateService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + it('should execute row count SQL through the table scoped data database client', async () => { + const queryRaw = vi.fn().mockResolvedValue([{ count: 7 }]); + const databaseRouter = { + queryDataPrismaForTable: queryRaw, + }; + const recordPermissionService = { + wrapView: vi.fn().mockResolvedValue({ + builder: {}, + }), + }; + const recordQueryBuilder = { + createRecordAggregateBuilder: vi.fn().mockResolvedValue({ + qb: { + toQuery: () => 'SELECT COUNT(*)::int AS count FROM "bse1"."tbl1"', + }, + alias: 'tbl1', + selectionMap: {}, + }), + }; + const service = new AggregationService( + {} as never, + {} as never, + {} as never, + databaseRouter as never, + { queryBuilder: vi.fn().mockReturnValue({}) } as never, + {} as never, + {} as never, + { get: vi.fn().mockReturnValue('usr1') } as never, + recordPermissionService as never, + recordQueryBuilder as never + ); + + const serviceInternals = service as unknown as { + fetchStatisticsParams: () => Promise; + getDbTableName: () => Promise; + }; + vi.spyOn(serviceInternals, 'fetchStatisticsParams').mockResolvedValue({ + statisticsData: {}, + fieldInstanceMap: {}, + }); + vi.spyOn(serviceInternals, 'getDbTableName').mockResolvedValue('bse1.tbl1'); + + const result = await service.performRowCount('tbl1', { viewId: 'viw1' }); + + expect(result.rowCount).toBe(7); + expect(queryRaw).toHaveBeenCalledWith( + 'tbl1', + 'SELECT COUNT(*)::int AS count FROM "bse1"."tbl1"' + ); + }); }); diff --git a/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts b/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts index 5e971ca7c6..c6f1d8ec10 100644 --- a/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts +++ b/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts @@ -12,7 +12,6 @@ import { ViewType, } from '@teable/core'; import type { IGridColumnMeta, IFilter, IGroup, ISortItem } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import type { Prisma } from '@teable/db-main-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import { StatisticsFunc } from '@teable/openapi'; @@ -41,6 +40,8 @@ import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.confi import { CustomHttpException } from '../../custom.exception'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; +import type { IDataPrismaQueryExecutor } from '../../global/database-router.service'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import type { IClsStore } from '../../types/cls'; import { convertValueToStringify, string2Hash } from '../../utils'; @@ -65,6 +66,7 @@ type IStatisticsData = { // so this is undefined unless the caller is paginating. sort?: ISortItem[]; }; + /** * Version 2 implementation of the aggregation service * This is a placeholder implementation that will be developed in the future @@ -77,7 +79,7 @@ export class AggregationService implements IAggregationService { private readonly recordService: RecordService, private readonly tableIndexService: TableIndexService, private readonly prisma: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, @InjectModel(DATA_KNEX) private readonly knex: Knex, @InjectDbProvider() private readonly dbProvider: IDbProvider, @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, @@ -85,6 +87,22 @@ export class AggregationService implements IAggregationService { private readonly recordPermissionService: RecordPermissionService, @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder ) {} + + private async queryDataPrisma( + tableId: string, + query: string, + ...values: unknown[] + ): Promise { + return await this.databaseRouter.queryDataPrismaForTable(tableId, query, ...values); + } + + private async withDataPrismaTransaction( + tableId: string, + fn: (prisma: IDataPrismaQueryExecutor) => Promise + ): Promise { + return await this.databaseRouter.dataPrismaTransactionForTable(tableId, fn); + } + /** * Perform aggregation operations on table data * @param params - Parameters for aggregation including tableId, field IDs, view settings, and search @@ -127,7 +145,7 @@ export class AggregationService implements IAggregationService { const isPaginated = take !== undefined; const baseSort = isPaginated ? [...(groupBy ?? []), ...(resolvedSort ?? [])] : undefined; const defaultOrderField = isPaginated - ? await this.recordService.getBasicOrderIndexField(dbTableName, withView?.viewId) + ? await this.recordService.getBasicOrderIndexField(tableId, dbTableName, withView?.viewId) : undefined; const rawAggregationData = await this.handleAggregation({ @@ -325,7 +343,7 @@ export class AggregationService implements IAggregationService { const aggSql = qb.toQuery(); this.logger.debug('handleAggregation aggSql: %s', aggSql); - return this.dataPrismaService.$queryRawUnsafe<{ [field: string]: unknown }[]>(aggSql); + return this.queryDataPrisma<{ [field: string]: unknown }[]>(tableId, aggSql); } /** * Perform grouped aggregation operations @@ -621,7 +639,7 @@ export class AggregationService implements IAggregationService { const rawQuery = qb.toQuery(); this.logger.debug('handleRowCount raw query: %s', rawQuery); - return await this.dataPrismaService.$queryRawUnsafe<{ count: number }[]>(rawQuery); + return await this.queryDataPrisma<{ count: number }[]>(tableId, rawQuery); } private async fetchStatisticsParams(params: { @@ -874,7 +892,7 @@ export class AggregationService implements IAggregationService { const sql = queryBuilder.toQuery(); - const result = await this.dataPrismaService.$queryRawUnsafe<{ count: number }[] | null>(sql); + const result = await this.queryDataPrisma<{ count: number }[] | null>(tableId, sql); return { count: result ? Number(result[0]?.count) : 0, @@ -941,7 +959,11 @@ export class AggregationService implements IAggregationService { Object.values(fieldInstanceMap).map((f) => [f.id, `"${f.dbFieldName}"`]) ); - const basicSortIndex = await this.recordService.getBasicOrderIndexField(dbTableName, viewId); + const basicSortIndex = await this.recordService.getBasicOrderIndexField( + tableId, + dbTableName, + viewId + ); const filterQuery = (qb: Knex.QueryBuilder) => { this.dbProvider @@ -993,7 +1015,7 @@ export class AggregationService implements IAggregationService { this.logger.debug('getRecordIndexBySearchOrder sql: %s', sql); try { - return await this.dataPrismaService.$tx(async (prisma) => { + return await this.withDataPrismaTransaction(tableId, async (prisma) => { const result = await prisma.$queryRawUnsafe<{ __id: string; fieldId: string }[]>(sql); // no result found @@ -1035,9 +1057,7 @@ export class AggregationService implements IAggregationService { this.logger.debug('getRecordIndexBySearchOrder indexSql: %s', indexSql); const indexResult = // eslint-disable-next-line @typescript-eslint/naming-convention - await this.dataPrismaService.$queryRawUnsafe<{ row_num: number; __id: string }[]>( - indexSql - ); + await prisma.$queryRawUnsafe<{ row_num: number; __id: string }[]>(indexSql); if (indexResult?.length === 0) { return null; @@ -1102,7 +1122,7 @@ export class AggregationService implements IAggregationService { this.logger.debug('getRecordIndex sql: %s', sql); // eslint-disable-next-line @typescript-eslint/naming-convention - const result = await this.dataPrismaService.$queryRawUnsafe<{ row_num: number }[]>(sql); + const result = await this.queryDataPrisma<{ row_num: number }[]>(tableId, sql); if (!result?.length) { return null; @@ -1227,11 +1247,9 @@ export class AggregationService implements IAggregationService { endField: endField as DateFieldDto, dbTableName: viewCte || dbTableName, }); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe< - { date: Date | string; count: number; ids: string[] | string }[] - >(queryBuilder.toQuery()); + const result = await this.queryDataPrisma< + { date: Date | string; count: number; ids: string[] | string }[] + >(tableId, queryBuilder.toQuery()); const countMap = result.reduce( (map, item) => { diff --git a/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts b/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts index 41afa3a79d..d8f21106e4 100644 --- a/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts +++ b/apps/nestjs-backend/src/features/base-sql-executor/base-sql-executor.service.ts @@ -2,26 +2,24 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { IDsn } from '@teable/core'; import { DriverClient, HttpErrorCode, parseDsn } from '@teable/core'; -import { Prisma, PrismaService, PrismaClient, getDatabaseUrl } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; +import { Prisma, PrismaService, getDatabaseUrl } from '@teable/db-main-prisma'; import { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; import { CustomHttpException } from '../../custom.exception'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex'; -import { BASE_READ_ONLY_ROLE_PREFIX, BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME } from './const'; +import { BASE_READ_ONLY_ROLE_PREFIX } from './const'; import { checkTableAccess, validateRoleOperations } from './utils'; @Injectable() export class BaseSqlExecutorService { - private db?: PrismaClient; private readonly dsn: IDsn; readonly driver: DriverClient; - private hasPgReadAllDataRole?: boolean; private readonly logger = new Logger(BaseSqlExecutorService.name); constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly configService: ConfigService, @InjectModel(DATA_KNEX) private readonly knex: Knex ) { @@ -32,214 +30,93 @@ export class BaseSqlExecutorService { private getDatabaseUrl() { return ( this.configService.get('PRISMA_DATABASE_URL_FOR_SQL_EXECUTOR') || - this.configService.get('PRISMA_DATA_DATABASE_URL') || - getDatabaseUrl('data') + getDatabaseUrl('meta') ); } - private getDisablePreSqlExecutorCheck() { - return this.configService.get('DISABLE_PRE_SQL_EXECUTOR_CHECK') === 'true'; - } - - private async getReadOnlyDatabaseConnectionConfig(): Promise { - if (!this.hasPgReadAllDataRole) { - return; - } - const isExistReadOnlyRole = await this.roleExits(BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME); - if (!isExistReadOnlyRole) { - await this.dataPrismaService.$tx(async (prisma) => { - try { - await prisma.$executeRawUnsafe( - this.knex - .raw( - `CREATE ROLE ?? WITH LOGIN PASSWORD ? NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION`, - [BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME, this.dsn.pass] - ) - .toQuery() - ); - await prisma.$executeRawUnsafe( - this.knex - .raw(`GRANT pg_read_all_data TO ??`, [BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME]) - .toQuery() - ); - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - (error?.meta?.code === '42710' || - error?.meta?.code === '23505' || - error?.meta?.code === 'XX000') - ) { - this.logger.warn( - `read only role ${BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME} already exists or concurrent update detected, error code: ${error?.meta?.code}` - ); - return; - } - throw error; - } - }); - } - return `postgresql://${BASE_SCHEMA_TABLE_READ_ONLY_ROLE_NAME}:${this.dsn.pass}@${this.dsn.host}:${this.dsn.port}/${this.dsn.db}${ - this.dsn.params - ? `?${Object.entries(this.dsn.params) - .map(([key, value]) => `${key}=${value}`) - .join('&')}` - : '' - }`; - } - - async onModuleInit() { - if (this.driver !== DriverClient.Pg) { - return; - } - if (this.getDisablePreSqlExecutorCheck()) { - return; - } - // if pg_read_all_data role not exist, no need to create read only role - this.hasPgReadAllDataRole = await this.roleExits('pg_read_all_data'); - if (!this.hasPgReadAllDataRole) { - return; - } - this.db = await this.createConnection(); - } - - async onModuleDestroy() { - await this.db?.$disconnect(); - } - - private async createConnection(): Promise { - if (this.db) { - return this.db; - } - const connectionConfig = await this.getReadOnlyDatabaseConnectionConfig(); - if (!connectionConfig) { - return; - } - const connection = new PrismaClient({ - datasources: { - db: { - url: connectionConfig, - }, - }, - }); - await connection.$connect(); - - // validate connection - try { - await connection.$queryRawUnsafe('SELECT 1'); - return connection; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - await connection.$disconnect(); - throw new CustomHttpException( - `database connection failed: ${error.message}`, - HttpErrorCode.VALIDATION_ERROR, - { - localization: { - i18nKey: 'httpErrors.baseSqlExecutor.databaseConnectionFailed', - context: { - message: error.message, - }, - }, - } - ); - } - } - private getReadOnlyRoleName(baseId: string) { return `${BASE_READ_ONLY_ROLE_PREFIX}${baseId}`; } + private async dataPrismaForBase(baseId: string) { + return await this.databaseRouter.dataPrismaExecutorForBase(baseId); + } + async createReadOnlyRole(baseId: string) { const roleName = this.getReadOnlyRoleName(baseId); - await this.dataPrismaService - .txClient() - .$executeRawUnsafe( - this.knex - .raw( - `CREATE ROLE ?? WITH NOLOGIN NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION`, - [roleName] - ) - .toQuery() - ); - await this.dataPrismaService - .txClient() - .$executeRawUnsafe( - this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [baseId, roleName]).toQuery() - ); - await this.dataPrismaService - .txClient() - .$executeRawUnsafe( - this.knex.raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [baseId, roleName]).toQuery() - ); - await this.dataPrismaService - .txClient() - .$executeRawUnsafe( - this.knex - .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [ - baseId, - roleName, - ]) - .toQuery() - ); + const dataPrisma = await this.dataPrismaForBase(baseId); + await dataPrisma.$executeRawUnsafe( + this.knex + .raw( + `CREATE ROLE ?? WITH NOLOGIN NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION`, + [roleName] + ) + .toQuery() + ); + await dataPrisma.$executeRawUnsafe( + this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [baseId, roleName]).toQuery() + ); + await dataPrisma.$executeRawUnsafe( + this.knex.raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [baseId, roleName]).toQuery() + ); + await dataPrisma.$executeRawUnsafe( + this.knex + .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [ + baseId, + roleName, + ]) + .toQuery() + ); } async dropReadOnlyRole(baseId: string) { const roleName = this.getReadOnlyRoleName(baseId); - await this.dataPrismaService - .txClient() - .$executeRawUnsafe( - this.knex.raw(`REVOKE USAGE ON SCHEMA ?? FROM ??`, [baseId, roleName]).toQuery() - ); - await this.dataPrismaService - .txClient() - .$executeRawUnsafe( - this.knex - .raw(`REVOKE SELECT ON ALL TABLES IN SCHEMA ?? FROM ??`, [baseId, roleName]) - .toQuery() - ); - await this.dataPrismaService - .txClient() - .$executeRawUnsafe( - this.knex - .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? REVOKE ALL ON TABLES FROM ??`, [ - baseId, - roleName, - ]) - .toQuery() - ); - await this.dataPrismaService - .txClient() - .$executeRawUnsafe(this.knex.raw(`DROP ROLE IF EXISTS ??`, [roleName]).toQuery()); + const dataPrisma = await this.dataPrismaForBase(baseId); + await dataPrisma.$executeRawUnsafe( + this.knex.raw(`REVOKE USAGE ON SCHEMA ?? FROM ??`, [baseId, roleName]).toQuery() + ); + await dataPrisma.$executeRawUnsafe( + this.knex + .raw(`REVOKE SELECT ON ALL TABLES IN SCHEMA ?? FROM ??`, [baseId, roleName]) + .toQuery() + ); + await dataPrisma.$executeRawUnsafe( + this.knex + .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? REVOKE ALL ON TABLES FROM ??`, [ + baseId, + roleName, + ]) + .toQuery() + ); + await dataPrisma.$executeRawUnsafe( + this.knex.raw(`DROP ROLE IF EXISTS ??`, [roleName]).toQuery() + ); } async grantReadOnlyRole(baseId: string) { const roleName = this.getReadOnlyRoleName(baseId); - await this.dataPrismaService - .txClient() - .$executeRawUnsafe( - this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [baseId, roleName]).toQuery() - ); - await this.dataPrismaService - .txClient() - .$executeRawUnsafe( - this.knex.raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [baseId, roleName]).toQuery() - ); - await this.dataPrismaService - .txClient() - .$executeRawUnsafe( - this.knex - .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [ - baseId, - roleName, - ]) - .toQuery() - ); + const dataPrisma = await this.dataPrismaForBase(baseId); + await dataPrisma.$executeRawUnsafe( + this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [baseId, roleName]).toQuery() + ); + await dataPrisma.$executeRawUnsafe( + this.knex.raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [baseId, roleName]).toQuery() + ); + await dataPrisma.$executeRawUnsafe( + this.knex + .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [ + baseId, + roleName, + ]) + .toQuery() + ); } - private async roleExits(role: string): Promise { - const roleExists = await this.dataPrismaService.$queryRaw< - { count: bigint }[] - >`SELECT count(*) FROM pg_roles WHERE rolname=${role}`; + private async roleExits(role: string, baseId?: string): Promise { + const dataPrisma = baseId ? await this.dataPrismaForBase(baseId) : this.prismaService; + const roleExists = await dataPrisma.$queryRawUnsafe<{ count: bigint }[]>( + this.knex.raw('SELECT count(*) FROM pg_roles WHERE rolname = ?', [role]).toQuery() + ); return Boolean(roleExists[0].count); } @@ -248,7 +125,7 @@ export class BaseSqlExecutorService { return; } const roleName = this.getReadOnlyRoleName(baseId); - if (!(await this.roleExits(roleName))) { + if (!(await this.roleExits(roleName, baseId))) { try { await this.createReadOnlyRole(baseId); } catch (error) { @@ -279,8 +156,11 @@ export class BaseSqlExecutorService { await prisma.$executeRawUnsafe(this.knex.raw(`RESET ROLE`).toQuery()); } - private async readonlyExecuteSql(sql: string) { - return this.db?.$queryRawUnsafe(sql); + private async readonlyExecuteSql(baseId: string, sql: string) { + return this.databaseRouter.dataPrismaTransactionForBase(baseId, async (prisma) => { + await prisma.$executeRawUnsafe('SET TRANSACTION READ ONLY'); + return await prisma.$queryRawUnsafe(sql); + }); } /** @@ -318,7 +198,7 @@ export class BaseSqlExecutorService { }); // 3. read only role check table access, only pg and pg version > 14 support try { - await this.readonlyExecuteSql(sql); + await this.readonlyExecuteSql(baseId, sql); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { throw new CustomHttpException( @@ -346,7 +226,7 @@ export class BaseSqlExecutorService { ) { await this.safeCheckSql(baseId, sql, opts); await this.roleCheckAndCreate(baseId); - return this.dataPrismaService.$tx(async (prisma) => { + return this.databaseRouter.dataPrismaTransactionForBase(baseId, async (prisma) => { try { await this.setRole(prisma, baseId); return await prisma.$queryRawUnsafe(sql); diff --git a/apps/nestjs-backend/src/features/base/base-duplicate.service.ts b/apps/nestjs-backend/src/features/base/base-duplicate.service.ts index a3dc155fea..7147ecdcd3 100644 --- a/apps/nestjs-backend/src/features/base/base-duplicate.service.ts +++ b/apps/nestjs-backend/src/features/base/base-duplicate.service.ts @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Injectable, Logger } from '@nestjs/common'; import type { ILinkFieldOptions } from '@teable/core'; -import { FieldType } from '@teable/core'; +import { FieldType, HttpErrorCode } from '@teable/core'; import { PrismaService, ProvisionState } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { BaseDuplicateMode, CreateRecordAction, @@ -14,10 +13,12 @@ import { Knex } from 'knex'; import { groupBy } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; +import { CustomHttpException } from '../../custom.exception'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; import { EventEmitterService } from '../../event-emitter/event-emitter.service'; import { Events } from '../../event-emitter/events'; +import { DataDbClientManager } from '../../global/data-db-client-manager.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import type { IClsStore } from '../../types/cls'; import { createFieldInstanceByRaw } from '../field/model/factory'; @@ -29,13 +30,21 @@ import { mergeLinkFieldTableMaps } from './utils'; type DuplicatedBase = Awaited>['base']; +type IDataPrismaExecutor = { + $executeRawUnsafe(query: string, ...values: unknown[]): Promise; + $queryRawUnsafe(query: string, ...values: unknown[]): Promise; +}; + +type IDataPrismaScopedClient = IDataPrismaExecutor & { + txClient?: () => IDataPrismaExecutor; +}; + @Injectable() export class BaseDuplicateService { private logger = new Logger(BaseDuplicateService.name); constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, private readonly tableDuplicateService: TableDuplicateService, private readonly baseExportService: BaseExportService, private readonly baseImportService: BaseImportService, @@ -43,9 +52,14 @@ export class BaseDuplicateService { @InjectModel(DATA_KNEX) private readonly knex: Knex, private readonly persistedComputedBackfillService: PersistedComputedBackfillService, private readonly cls: ClsService, - private readonly eventEmitterService: EventEmitterService + private readonly eventEmitterService: EventEmitterService, + private readonly dataDbClientManager: DataDbClientManager ) {} + private getDataPrismaExecutor(prisma: IDataPrismaScopedClient): IDataPrismaExecutor { + return prisma.txClient?.() ?? prisma; + } + async duplicateBase( duplicateBaseRo: IDuplicateBaseRo, allowCrossBase: boolean = true, @@ -106,6 +120,7 @@ export class BaseDuplicateService { let recordsLength = 0; if (withRecords) { + await this.assertSameDataDatabaseForRecordCopy(fromBaseId, base.id); await prisma.base.update({ where: { id: base.id }, data: { @@ -115,13 +130,15 @@ export class BaseDuplicateService { }); recordsLength = await this.duplicateTableData( + base.id, tableIdMap, fieldIdMap, viewIdMap, mergedLinkFieldTableMap ); - await this.duplicateAttachments(tableIdMap, fieldIdMap); + await this.duplicateAttachments(base.id, tableIdMap, fieldIdMap); await this.duplicateLinkJunction( + base.id, tableIdMap, fieldIdMap, allowCrossBase, @@ -190,6 +207,22 @@ export class BaseDuplicateService { .map((f) => f.id); } + private async assertSameDataDatabaseForRecordCopy(sourceBaseId: string, targetBaseId: string) { + const [source, target] = await Promise.all([ + this.dataDbClientManager.getDataDatabaseForBase(sourceBaseId, { useTransaction: true }), + this.dataDbClientManager.getDataDatabaseForBase(targetBaseId, { useTransaction: true }), + ]); + + if (source.cacheKey === target.cacheKey) { + return; + } + + throw new CustomHttpException( + 'Duplicating records across different space data databases is not supported yet', + HttpErrorCode.VALIDATION_ERROR + ); + } + private async duplicateStructure( fromBaseId: string, spaceId: string, @@ -283,7 +316,9 @@ export class BaseDuplicateService { structure, baseId, undefined, - duplicateMode + duplicateMode, + undefined, + { useTransaction: true } ); return { base: newBase, tableIdMap, fieldIdMap, viewIdMap, ...rest }; @@ -552,6 +587,7 @@ export class BaseDuplicateService { } private async duplicateTableData( + targetBaseId: string, tableIdMap: Record, fieldIdMap: Record, viewIdMap: Record, @@ -560,7 +596,11 @@ export class BaseDuplicateService { { dbFieldName: string; selfKeyName: string; isMultipleCellValue: boolean }[] > ): Promise { - const prisma = this.dataPrismaService.txClient(); + const prisma = this.getDataPrismaExecutor( + (await this.dataDbClientManager.dataPrismaForBase(targetBaseId, { + useTransaction: true, + })) as IDataPrismaScopedClient + ); const metaPrisma = this.prismaService.txClient(); const tableId2DbTableNameMap: Record = {}; const allTableId = Object.keys(tableIdMap).concat(Object.values(tableIdMap)); @@ -643,7 +683,8 @@ export class BaseDuplicateService { newDbTableName, viewIdMap, fieldIdMap, - crossBaseLinkFieldTableMap[tableId] || [] + crossBaseLinkFieldTableMap[tableId] || [], + prisma ); } catch (error) { this.logger.error( @@ -678,28 +719,42 @@ export class BaseDuplicateService { } private async duplicateAttachments( + targetBaseId: string, tableIdMap: Record, fieldIdMap: Record ) { + const dataPrisma = this.getDataPrismaExecutor( + (await this.dataDbClientManager.dataPrismaForBase(targetBaseId, { + useTransaction: true, + })) as IDataPrismaScopedClient + ); for (const [sourceTableId, targetTableId] of Object.entries(tableIdMap)) { await this.tableDuplicateService.duplicateAttachments( sourceTableId, targetTableId, - fieldIdMap + fieldIdMap, + dataPrisma ); } } private async duplicateLinkJunction( + targetBaseId: string, tableIdMap: Record, fieldIdMap: Record, allowCrossBase: boolean = true, disconnectedLinkFieldIds?: string[] ) { + const dataPrisma = this.getDataPrismaExecutor( + (await this.dataDbClientManager.dataPrismaForBase(targetBaseId, { + useTransaction: true, + })) as IDataPrismaScopedClient + ); await this.tableDuplicateService.duplicateLinkJunction( tableIdMap, fieldIdMap, allowCrossBase, + dataPrisma, disconnectedLinkFieldIds ); } diff --git a/apps/nestjs-backend/src/features/base/base-export.service.ts b/apps/nestjs-backend/src/features/base/base-export.service.ts index d612305081..b11201b6ef 100644 --- a/apps/nestjs-backend/src/features/base/base-export.service.ts +++ b/apps/nestjs-backend/src/features/base/base-export.service.ts @@ -12,7 +12,6 @@ import type { import { FieldType, getRandomString, ViewType, isLinkLookupOptions } from '@teable/core'; import type { Field, View, TableMeta, Base } from '@teable/db-main-prisma'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { PluginPosition, UploadType } from '@teable/openapi'; import type { BaseNodeResourceType, IBaseJson } from '@teable/openapi'; import archiver from 'archiver'; @@ -27,6 +26,7 @@ import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; import { EventEmitterService } from '../../event-emitter/event-emitter.service'; import { Events } from '../../event-emitter/events'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import type { IClsStore } from '../../types/cls'; import type { I18nPath } from '../../types/i18n.generated'; @@ -68,7 +68,6 @@ export class BaseExportService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, private readonly cls: ClsService, private readonly notificationService: NotificationService, private readonly eventEmitterService: EventEmitterService, @@ -76,7 +75,8 @@ export class BaseExportService { @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter, @StorageConfig() private readonly storageConfig: IStorageConfig, - @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig + @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, + private readonly databaseRouter: DatabaseRouter ) {} private captureExportError( @@ -253,7 +253,14 @@ export class BaseExportService { name: exportFileName, }, }; - this.notifyExportResult(baseId, message, previewUrl); + this.notifyExportResult(baseId, message, { + status: 'success', + previewUrl, + attachment: { + name: exportFileName, + path, + }, + }); } catch (e) { this.captureExportError(e, { stage: 'processExport', @@ -269,7 +276,10 @@ export class BaseExportService { errorMessage: e.message, }, }; - this.notifyExportResult(baseId, message); + this.notifyExportResult(baseId, message, { + status: 'failed', + errorMessage: e.message, + }); } } } @@ -378,19 +388,20 @@ export class BaseExportService { ); } - const linkFieldInstances = fieldRaws + const linkFieldRaws = fieldRaws .filter(({ type, isLookup }) => type === FieldType.Link && !isLookup) - .filter(({ id }) => !crossBaseRelativeFieldIds.has(id)) - .map((f) => createFieldInstanceByRaw(f)); + .filter(({ id }) => !crossBaseRelativeFieldIds.has(id)); // 5. export junction csv for link fields const junctionTableName = [] as string[]; - for (const linkField of linkFieldInstances) { + for (const linkFieldRaw of linkFieldRaws) { + const linkField = createFieldInstanceByRaw(linkFieldRaw); const { options } = linkField; const { fkHostTableName, selfKeyName, foreignKeyName } = options as ILinkFieldOptions; if (fkHostTableName.includes('junction_') && !junctionTableName.includes(fkHostTableName)) { await this.appendJunctionCsv( 'tables', + linkFieldRaw.tableId, fkHostTableName, selfKeyName, foreignKeyName, @@ -566,7 +577,9 @@ export class BaseExportService { ) { const { dbTableName, id } = tableRaw; const csvStream = new PassThrough(); - const prisma = this.dataPrismaService.txClient(); + const prisma = await this.databaseRouter.dataPrismaExecutorForTable(id, { + useTransaction: true, + }); const columnInfoQuery = this.dbProvider.columnInfo(dbTableName); const columnInfo = await prisma.$queryRawUnsafe<{ name: string }[]>(columnInfoQuery); @@ -610,6 +623,7 @@ export class BaseExportService { // 2. write csv content while (hasMoreData) { const csvChunk = await this.getCsvChunk( + prisma, dbTableName, offset, crossBaseRelativeFields, @@ -706,13 +720,16 @@ export class BaseExportService { private async appendJunctionCsv( filePath: string, + tableId: string, fkHostTableName: string, selfKeyName: string, foreignKeyName: string, archive: archiver.Archiver ) { const csvStream = new PassThrough(); - const prisma = this.dataPrismaService.txClient(); + const prisma = await this.databaseRouter.dataPrismaExecutorForTable(tableId, { + useTransaction: true, + }); const columnInfoQuery = this.dbProvider.columnInfo(fkHostTableName); const columnInfo = await prisma.$queryRawUnsafe<{ name: string }[]>(columnInfoQuery); @@ -750,6 +767,7 @@ export class BaseExportService { // 2. write csv content while (hasMoreData) { const csvChunk = await this.getJunctionChunk( + prisma, fkHostTableName, offset, [selfKeyName, foreignKeyName], @@ -769,12 +787,13 @@ export class BaseExportService { } private async getCsvChunk( + prisma: { $queryRawUnsafe(query: string, ...values: unknown[]): Promise }, dbTableName: string, offset: number, crossBaseRelativeFields: Field[], excludeFieldNames: string[] ) { - const rawRecords = await this.getChunkRecords(dbTableName, offset); + const rawRecords = await this.getChunkRecords(prisma, dbTableName, offset); // 1. clear unless fields const records = rawRecords.map((record) => omit(record, excludeFieldNames)); // 2. convert to csv value @@ -784,12 +803,12 @@ export class BaseExportService { } private async getJunctionChunk( + prisma: { $queryRawUnsafe(query: string, ...values: unknown[]): Promise }, fkHostTableName: string, offset: number, convertFields: [string, string], excludeFieldNames: string[] ) { - const prisma = this.dataPrismaService.txClient(); const recordsQuery = await this.knex(fkHostTableName) .select('*') .limit(BaseExportService.CSV_CHUNK) @@ -814,8 +833,11 @@ export class BaseExportService { }); } - private async getChunkRecords(dbTableName: string, offset: number) { - const prisma = this.dataPrismaService.txClient(); + private async getChunkRecords( + prisma: { $queryRawUnsafe(query: string, ...values: unknown[]): Promise }, + dbTableName: string, + offset: number + ) { const recordsQuery = await this.knex(dbTableName) .select('*') .limit(BaseExportService.CSV_CHUNK) @@ -1483,11 +1505,19 @@ export class BaseExportService { private async notifyExportResult( baseId: string, message: string | ILocalization, - previewUrl?: string + result?: { + status: 'success' | 'failed'; + previewUrl?: string; + attachment?: { name: string; path: string }; + errorMessage?: string; + } ) { const userId = this.cls.get('user.id'); await this.eventEmitterService.emit(Events.BASE_EXPORT_COMPLETE, { - previewUrl, + status: result?.status, + previewUrl: result?.previewUrl, + attachment: result?.attachment, + errorMessage: result?.errorMessage, }); await this.notificationService.sendExportBaseResultNotify({ baseId: baseId, diff --git a/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.spec.ts b/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.spec.ts new file mode 100644 index 0000000000..8d41623670 --- /dev/null +++ b/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.spec.ts @@ -0,0 +1,92 @@ +import Knex from 'knex'; +import { vi } from 'vitest'; + +import { BaseImportCsvQueueProcessor } from './base-import-csv.processor'; + +describe('BaseImportCsvQueueProcessor', () => { + it('writes imported record history into the routed data DB internal schema', async () => { + const executedSql: string[] = []; + const dataKnex = Knex({ client: 'pg' }); + const dataPrisma = { + $queryRawUnsafe: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ name: '__id' }, { name: 'fldText' }]), + $executeRawUnsafe: vi.fn(async (sql: string) => { + executedSql.push(sql); + return 1; + }), + }; + const prismaService = { + tableMeta: { + findUniqueOrThrow: vi.fn().mockResolvedValue({ dbTableName: 'bse_data.tbl_imported' }), + }, + txClient: vi.fn().mockReturnValue({ + attachmentsTable: { + createMany: vi.fn().mockResolvedValue(undefined), + }, + }), + }; + const dataDbClientManager = { + dataPrismaForBase: vi.fn().mockResolvedValue(dataPrisma), + dataKnexForBase: vi.fn().mockResolvedValue(dataKnex), + getDataDatabaseForBase: vi.fn().mockResolvedValue({ + url: 'postgresql://user:pass@example.test:5432/data?schema=teable_internal', + }), + }; + const processor = Object.create(BaseImportCsvQueueProcessor.prototype) as { + handleChunk: ( + results: Record[], + config: { + baseId: string; + tableId: string; + userId: string; + fieldIdMap: Record; + viewIdMap: Record; + fkMap: Record; + attachmentsFields: { dbFieldName: string; id: string }[]; + notNullFieldMap: Map; + fieldDbNameMap: Map; + }, + excludeDbFieldNames: string[] + ) => Promise; + prismaService: typeof prismaService; + dbProvider: { + getForeignKeysInfo: ReturnType; + columnInfo: ReturnType; + }; + dataDbClientManager: typeof dataDbClientManager; + }; + + processor.prismaService = prismaService; + processor.dbProvider = { + getForeignKeysInfo: vi.fn().mockReturnValue('SELECT * FROM foreign_keys'), + columnInfo: vi.fn().mockReturnValue('SELECT * FROM columns'), + }; + processor.dataDbClientManager = dataDbClientManager; + + await processor.handleChunk( + [{ __id: 'recImported', fldText: 'Imported value' }], + { + baseId: 'bseImport', + tableId: 'tblImport', + userId: 'usrImport', + fieldIdMap: {}, + viewIdMap: {}, + fkMap: {}, + attachmentsFields: [], + notNullFieldMap: new Map(), + fieldDbNameMap: new Map([['fldText', 'fldMappedText']]), + }, + [] + ); + + expect(executedSql.some((sql) => sql.includes('"bse_data"."tbl_imported"'))).toBe(true); + expect(executedSql.some((sql) => sql.includes('"teable_internal"."record_history"'))).toBe( + true + ); + expect(executedSql.some((sql) => sql.includes('insert into "record_history"'))).toBe(false); + + await dataKnex.destroy(); + }); +}); diff --git a/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.ts b/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.ts index 9ac8a7e7a0..c1c4a01424 100644 --- a/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.ts +++ b/apps/nestjs-backend/src/features/base/base-import-processor/base-import-csv.processor.ts @@ -2,22 +2,24 @@ import { InjectQueue, OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; import { Injectable, Logger } from '@nestjs/common'; import type { IAttachmentCellValue, ILinkFieldOptions } from '@teable/core'; -import { DbFieldType, FieldType, generateAttachmentId } from '@teable/core'; +import { + DbFieldType, + FieldType, + generateAttachmentId, + generateRecordHistoryId, +} from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import type { IBaseJson, ImportBaseRo } from '@teable/openapi'; import { CreateRecordAction, UploadType } from '@teable/openapi'; import { Queue, Job } from 'bullmq'; import * as csvParser from 'csv-parser'; -import { Knex } from 'knex'; -import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; import * as unzipper from 'unzipper'; import { InjectDbProvider } from '../../../db-provider/db.provider'; import { IDbProvider } from '../../../db-provider/db.provider.interface'; import { EventEmitterService } from '../../../event-emitter/event-emitter.service'; import { Events } from '../../../event-emitter/events'; -import { DATA_KNEX } from '../../../global/knex/knex.module'; +import { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import type { IClsStore } from '../../../types/cls'; import StorageAdapter from '../../attachments/plugins/adapter'; import { InjectStorageAdapter } from '../../attachments/plugins/storage'; @@ -25,6 +27,17 @@ import { PersistedComputedBackfillService } from '../../record/computed/services import { BatchProcessor } from '../BatchProcessor.class'; import { EXCLUDE_SYSTEM_FIELDS } from '../constant'; import { BaseImportJunctionCsvQueueProcessor } from './base-import-junction.processor'; + +type IDataPrismaExecutor = { + $queryRawUnsafe(query: string, ...values: unknown[]): Promise; + $executeRawUnsafe(query: string, ...values: unknown[]): Promise; +}; + +type IDataPrismaScopedClient = IDataPrismaExecutor & { + $tx?: (fn: (prisma: IDataPrismaExecutor) => Promise) => Promise; + $transaction?: (fn: (prisma: IDataPrismaExecutor) => Promise) => Promise; +}; + interface IBaseImportCsvJob { path: string; userId: string; @@ -55,15 +68,14 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, private readonly baseImportJunctionCsvQueueProcessor: BaseImportJunctionCsvQueueProcessor, private readonly persistedComputedBackfillService: PersistedComputedBackfillService, - @InjectModel(DATA_KNEX) private readonly knex: Knex, @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter, @InjectQueue(BASE_IMPORT_CSV_QUEUE) public readonly queue: Queue, @InjectDbProvider() private readonly dbProvider: IDbProvider, private readonly cls: ClsService, - private readonly eventEmitterService: EventEmitterService + private readonly eventEmitterService: EventEmitterService, + private readonly dataDbClientManager: DataDbClientManager ) { super(); } @@ -89,7 +101,7 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { } private async handleBaseImportCsv(job: Job): Promise { - const { path, userId, tableIdMap, fieldIdMap, viewIdMap, structure, fkMap } = job.data; + const { path, userId, baseId, tableIdMap, fieldIdMap, viewIdMap, structure, fkMap } = job.data; const csvStream = await this.storageAdapter.downloadFile( StorageAdapter.getBucket(UploadType.Import), path @@ -163,12 +175,16 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { }); } }); + const fieldDbNameMap = new Map( + table?.fields?.map(({ dbFieldName, id }) => [dbFieldName, fieldIdMap[id] ?? id]) ?? [] + ); const batchProcessor = new BatchProcessor>(async (chunk) => { totalRecordsCount += chunk.length; await this.handleChunk( chunk, { + baseId, tableId: tableIdMap[tableId], userId, fieldIdMap, @@ -176,6 +192,7 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { fkMap, attachmentsFields, notNullFieldMap, + fieldDbNameMap, }, excludeDbFieldNames ); @@ -248,9 +265,29 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { ); } + private async dataTransaction( + dataPrisma: IDataPrismaScopedClient, + fn: (prisma: IDataPrismaExecutor) => Promise + ) { + if (dataPrisma.$tx) { + return await dataPrisma.$tx(fn); + } + + if (dataPrisma.$transaction) { + return await dataPrisma.$transaction(fn); + } + + return await fn(dataPrisma); + } + + private getDataDbInternalSchema(dataDbUrl: string) { + return new URL(dataDbUrl).searchParams.get('schema') || 'public'; + } + private async handleChunk( results: Record[], config: { + baseId: string; tableId: string; userId: string; fieldIdMap: Record; @@ -258,10 +295,20 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { fkMap: Record; attachmentsFields: { dbFieldName: string; id: string }[]; notNullFieldMap: Map; + fieldDbNameMap: Map; }, excludeDbFieldNames: string[] ) { - const { tableId, userId, fieldIdMap, attachmentsFields, fkMap, notNullFieldMap } = config; + const { + baseId, + tableId, + userId, + fieldIdMap, + attachmentsFields, + fkMap, + notNullFieldMap, + fieldDbNameMap, + } = config; const { dbTableName } = await this.prismaService.tableMeta.findUniqueOrThrow({ where: { id: tableId }, select: { @@ -285,8 +332,24 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { recordId: string; fieldId: string; }[]; - - await this.dataPrismaService.$tx(async (prisma) => { + const recordHistoryList: { + id: string; + table_id: string; + record_id: string; + field_id: string; + before: string; + after: string; + created_by: string; + }[] = []; + + const dataPrisma = (await this.dataDbClientManager.dataPrismaForBase( + baseId + )) as IDataPrismaScopedClient; + const dataKnex = await this.dataDbClientManager.dataKnexForBase(baseId); + const dataDb = await this.dataDbClientManager.getDataDatabaseForBase(baseId); + const dataDbInternalSchema = this.getDataDbInternalSchema(dataDb.url); + + await this.dataTransaction(dataPrisma, async (prisma) => { // delete foreign keys if(exist) then duplicate table data const foreignKeysInfoSql = this.dbProvider.getForeignKeysInfo(dbTableName); const foreignKeysInfo = await prisma.$queryRawUnsafe< @@ -305,7 +368,7 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { allForeignKeyInfos.push(...newForeignKeyInfos); for (const { constraint_name, column_name, dbTableName } of allForeignKeyInfos) { - const dropForeignKeyQuery = this.knex.schema + const dropForeignKeyQuery = dataKnex.schema .alterTable(dbTableName, (table) => { table.dropForeign(column_name, constraint_name); }) @@ -373,6 +436,24 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { }); }); } + + if (key.startsWith('__') && !key.startsWith('__fk_')) { + return; + } + + const sourceFieldId = key.startsWith('__fk_') ? key.slice(5) : key; + const fieldId = fieldIdMap[sourceFieldId] ?? fieldDbNameMap.get(key) ?? sourceFieldId; + if (fieldId && value !== '' && value != null) { + recordHistoryList.push({ + id: generateRecordHistoryId(), + table_id: tableId, + record_id: res['__id'] as string, + field_id: fieldId, + before: JSON.stringify({ data: null }), + after: JSON.stringify({ data: value }), + created_by: userId, + }); + } }); // default value set @@ -389,7 +470,7 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { .filter((name) => name.startsWith('__row_')); for (const name of lackingColumns) { - const sql = this.knex.schema + const sql = dataKnex.schema .alterTable(dbTableName, (table) => { table.double(name); }) @@ -398,8 +479,17 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { } } - const sql = this.knex.table(dbTableName).insert(recordsToInsert).toQuery(); + const sql = dataKnex.table(dbTableName).insert(recordsToInsert).toQuery(); await prisma.$executeRawUnsafe(sql); + + if (recordHistoryList.length) { + const historySql = dataKnex + .withSchema(dataDbInternalSchema) + .insert(recordHistoryList) + .into('record_history') + .toQuery(); + await prisma.$executeRawUnsafe(historySql); + } }); // restore foreign keys with NOT VALID @@ -412,7 +502,7 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { referenced_column_name: referencedColumnName, } of allForeignKeyInfos) { const [schema, tableName] = dbTableName.split('.'); - const addForeignKeyQuery = this.knex + const addForeignKeyQuery = dataKnex .raw( 'ALTER TABLE ??.?? ADD CONSTRAINT ?? FOREIGN KEY (??) REFERENCES ??.??(??) NOT VALID', [ @@ -426,7 +516,7 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { ] ) .toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(addForeignKeyQuery); + await dataPrisma.$executeRawUnsafe(addForeignKeyQuery); } await this.updateAttachmentTable(userId, attachmentsTableData); @@ -479,6 +569,7 @@ export class BaseImportCsvQueueProcessor extends WorkerHost { await this.baseImportJunctionCsvQueueProcessor.queue.add( 'import_base_junction_csv', { + baseId: job.data.baseId, tableIdMap, fieldIdMap, path, diff --git a/apps/nestjs-backend/src/features/base/base-import-processor/base-import-junction.processor.ts b/apps/nestjs-backend/src/features/base/base-import-processor/base-import-junction.processor.ts index a4ea016ad3..a0e6baafd8 100644 --- a/apps/nestjs-backend/src/features/base/base-import-processor/base-import-junction.processor.ts +++ b/apps/nestjs-backend/src/features/base/base-import-processor/base-import-junction.processor.ts @@ -8,26 +8,34 @@ import { import type { ILinkFieldOptions } from '@teable/core'; import { FieldType } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import type { IBaseJson } from '@teable/openapi'; import { UploadType } from '@teable/openapi'; import type { Job } from 'bullmq'; import { Queue } from 'bullmq'; import * as csvParser from 'csv-parser'; -import { Knex } from 'knex'; -import { InjectModel } from 'nest-knexjs'; import * as unzipper from 'unzipper'; import { InjectDbProvider } from '../../../db-provider/db.provider'; import { IDbProvider } from '../../../db-provider/db.provider.interface'; -import { DATA_KNEX } from '../../../global/knex/knex.module'; +import { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import StorageAdapter from '../../attachments/plugins/adapter'; import { InjectStorageAdapter } from '../../attachments/plugins/storage'; import { createFieldInstanceByRaw } from '../../field/model/factory'; import { PersistedComputedBackfillService } from '../../record/computed/services/persisted-computed-backfill.service'; import { BatchProcessor } from '../BatchProcessor.class'; +type IDataPrismaExecutor = { + $queryRawUnsafe(query: string, ...values: unknown[]): Promise; + $executeRawUnsafe(query: string, ...values: unknown[]): Promise; +}; + +type IDataPrismaScopedClient = IDataPrismaExecutor & { + $tx?: (fn: (prisma: IDataPrismaExecutor) => Promise) => Promise; + $transaction?: (fn: (prisma: IDataPrismaExecutor) => Promise) => Promise; +}; + interface IBaseImportJunctionCsvJob { path: string; + baseId: string; tableIdMap: Record; fieldIdMap: Record; structure: IBaseJson; @@ -43,13 +51,12 @@ export class BaseImportJunctionCsvQueueProcessor extends WorkerHost { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, private readonly persistedComputedBackfillService: PersistedComputedBackfillService, - @InjectModel(DATA_KNEX) private readonly knex: Knex, @InjectStorageAdapter() private readonly storageAdapter: StorageAdapter, @InjectQueue(BASE_IMPORT_JUNCTION_CSV_QUEUE) public readonly queue: Queue, - @InjectDbProvider() private readonly dbProvider: IDbProvider + @InjectDbProvider() private readonly dbProvider: IDbProvider, + private readonly dataDbClientManager: DataDbClientManager ) { super(); } @@ -63,10 +70,10 @@ export class BaseImportJunctionCsvQueueProcessor extends WorkerHost { this.processedJobs.add(jobId); - const { path, tableIdMap, fieldIdMap, structure } = job.data; + const { path, baseId, tableIdMap, fieldIdMap, structure } = job.data; try { - await this.importJunctionChunk(path, fieldIdMap, structure); + await this.importJunctionChunk(path, baseId, fieldIdMap, structure); await this.persistedComputedBackfillService.recomputeForTables(Object.values(tableIdMap)); } catch (error) { this.logger.error( @@ -78,6 +85,7 @@ export class BaseImportJunctionCsvQueueProcessor extends WorkerHost { private async importJunctionChunk( path: string, + baseId: string, fieldIdMap: Record, structure: IBaseJson ) { @@ -173,7 +181,7 @@ export class BaseImportJunctionCsvQueueProcessor extends WorkerHost { } = junctionInfo; const batchProcessor = new BatchProcessor>((chunk) => - this.handleJunctionChunk(chunk, targetFkHostTableName) + this.handleJunctionChunk(baseId, chunk, targetFkHostTableName) ); entry @@ -216,7 +224,23 @@ export class BaseImportJunctionCsvQueueProcessor extends WorkerHost { }); } + private async dataTransaction( + dataPrisma: IDataPrismaScopedClient, + fn: (prisma: IDataPrismaExecutor) => Promise + ) { + if (dataPrisma.$tx) { + return await dataPrisma.$tx(fn); + } + + if (dataPrisma.$transaction) { + return await dataPrisma.$transaction(fn); + } + + return await fn(dataPrisma); + } + private async handleJunctionChunk( + baseId: string, results: Record[], targetFkHostTableName: string ) { @@ -229,7 +253,12 @@ export class BaseImportJunctionCsvQueueProcessor extends WorkerHost { dbTableName: string; }[]; - await this.dataPrismaService.$tx(async (prisma) => { + const dataPrisma = (await this.dataDbClientManager.dataPrismaForBase( + baseId + )) as IDataPrismaScopedClient; + const dataKnex = await this.dataDbClientManager.dataKnexForBase(baseId); + + await this.dataTransaction(dataPrisma, async (prisma) => { // delete foreign keys if(exist) then duplicate table data const foreignKeysInfoSql = this.dbProvider.getForeignKeysInfo(targetFkHostTableName); const foreignKeysInfo = await prisma.$queryRawUnsafe< @@ -248,7 +277,7 @@ export class BaseImportJunctionCsvQueueProcessor extends WorkerHost { allForeignKeyInfos.push(...newForeignKeyInfos); for (const { constraint_name, column_name, dbTableName } of allForeignKeyInfos) { - const dropForeignKeyQuery = this.knex.schema + const dropForeignKeyQuery = dataKnex.schema .alterTable(dbTableName, (table) => { table.dropForeign(column_name, constraint_name); }) @@ -257,7 +286,7 @@ export class BaseImportJunctionCsvQueueProcessor extends WorkerHost { await prisma.$executeRawUnsafe(dropForeignKeyQuery); } - const sql = this.knex.table(targetFkHostTableName).insert(results).toQuery(); + const sql = dataKnex.table(targetFkHostTableName).insert(results).toQuery(); try { await prisma.$executeRawUnsafe(sql); } catch (error) { @@ -289,7 +318,7 @@ export class BaseImportJunctionCsvQueueProcessor extends WorkerHost { referenced_column_name: referencedColumnName, } of allForeignKeyInfos) { const [schema, tableName] = dbTableName.split('.'); - const addForeignKeyQuery = this.knex + const addForeignKeyQuery = dataKnex .raw( 'ALTER TABLE ??.?? ADD CONSTRAINT ?? FOREIGN KEY (??) REFERENCES ??.??(??) NOT VALID', [ diff --git a/apps/nestjs-backend/src/features/base/base-import.service.spec.ts b/apps/nestjs-backend/src/features/base/base-import.service.spec.ts index f7c73c91d4..45deeffa89 100644 --- a/apps/nestjs-backend/src/features/base/base-import.service.spec.ts +++ b/apps/nestjs-backend/src/features/base/base-import.service.spec.ts @@ -1,7 +1,11 @@ +import type { Readable } from 'stream'; import { DbFieldType, FieldType } from '@teable/core'; +import type { IBaseJson, ImportBaseRo } from '@teable/openapi'; import type { RestoreRecordInput } from '@teable/v2-core'; +import archiver from 'archiver'; +import { vi } from 'vitest'; -import { BaseImportService } from './base-import.service'; +import { BaseImportService, formatBaseImportError } from './base-import.service'; interface IRestoreRecordInputBuilder { toRestoreRecordInput( @@ -26,6 +30,15 @@ interface IRestoreRecordInputBuilder { ): RestoreRecordInput; } +interface IProcessStructureService { + processStructure( + zipStream: Readable, + importBaseRo: Pick, + onProgress?: (...args: unknown[]) => void + ): Promise; + createBaseStructure: ReturnType; +} + const dbTableName = 'bse_test.tbl_test'; const jsonColumnName = 'json_col'; const textColumnName = 'text_col'; @@ -34,7 +47,147 @@ const textCellValue = 'plain text'; const createService = () => Object.create(BaseImportService.prototype) as IRestoreRecordInputBuilder; +const createZipStream = (structure: IBaseJson) => { + const archive = archiver('zip', { zlib: { level: 0 } }); + archive.append(JSON.stringify(structure), { name: 'structure.json' }); + void archive.finalize(); + return archive; +}; + describe('BaseImportService', () => { + describe('formatBaseImportError', () => { + it('falls back when an Error has an empty message', () => { + expect(formatBaseImportError(new Error(''), 'Unknown import error')).toBe( + 'Unknown import error' + ); + }); + + it('uses domain error code and details when message is empty', () => { + expect( + formatBaseImportError( + { + code: 'dottea.parse_failed', + message: '', + details: { file: 'structure.json' }, + tags: ['unexpected'], + }, + 'Failed to import dottea structure' + ) + ).toBe('Failed to import dottea structure: dottea.parse_failed - {"file":"structure.json"}'); + }); + + it('uses network error code with a specific fallback when message is empty', () => { + expect( + formatBaseImportError({ code: 'ECONNREFUSED', message: '' }, 'Failed to connect data DB') + ).toBe('Failed to connect data DB: ECONNREFUSED'); + }); + }); + + describe('processStructure', () => { + it('passes transaction-aware data DB routing into structure creation', async () => { + const service = Object.create(BaseImportService.prototype) as IProcessStructureService; + const structure = { + id: 'bseSource', + name: 'Source base', + tables: [], + plugins: {}, + folders: [], + nodes: [], + } as unknown as IBaseJson; + const expectedResult = { + base: { id: 'bseImported' }, + tableIdMap: {}, + fieldIdMap: {}, + viewIdMap: {}, + fkMap: {}, + structure, + }; + + service.createBaseStructure = vi.fn().mockResolvedValue(expectedResult); + + await expect( + service.processStructure(createZipStream(structure), { spaceId: 'spcImport' }) + ).resolves.toBe(expectedResult); + + expect(service.createBaseStructure).toHaveBeenCalledWith( + 'spcImport', + structure, + undefined, + undefined, + undefined, + undefined, + { useTransaction: true } + ); + }); + + it('creates imported base schemas through the space routed data client', async () => { + const createdBase = { + id: 'bseImported', + name: 'Imported base', + spaceId: 'spcImport', + order: 1, + }; + const baseCreate = vi.fn().mockResolvedValue(createdBase); + const baseUpdate = vi.fn().mockResolvedValue({ + ...createdBase, + name: 'Imported base', + }); + const routedExecute = vi.fn().mockResolvedValue(0); + const fallbackExecute = vi.fn().mockResolvedValue(0); + const service = Object.create(BaseImportService.prototype) as { + getMaxOrder: ReturnType; + createBase: ( + spaceId: string, + name: string, + icon: string | undefined, + routingOptions: { useTransaction: true } + ) => Promise; + cls: unknown; + prismaService: unknown; + dbProvider: unknown; + dataDbClientManager: unknown; + dataPrismaService: unknown; + }; + + service.getMaxOrder = vi.fn().mockResolvedValue(0); + service.cls = { get: vi.fn().mockReturnValue('usrImport') }; + service.prismaService = { + txClient: vi.fn().mockReturnValue({ + base: { + create: baseCreate, + update: baseUpdate, + }, + }), + }; + service.dbProvider = { + createSchema: vi.fn().mockReturnValue(['CREATE SCHEMA "bseImported"']), + }; + service.dataDbClientManager = { + dataPrismaForSpace: vi.fn().mockResolvedValue({ + txClient: vi.fn().mockReturnValue({ + $executeRawUnsafe: routedExecute, + }), + }), + }; + service.dataPrismaService = { + $executeRawUnsafe: fallbackExecute, + }; + + await expect( + service.createBase('spcImport', 'Imported base', 'icon', { useTransaction: true }) + ).resolves.toMatchObject({ + id: 'bseImported', + name: 'Imported base', + }); + + expect(service.dataDbClientManager.dataPrismaForSpace).toHaveBeenCalledWith('spcImport', { + useTransaction: true, + }); + expect(routedExecute).toHaveBeenCalledWith('CREATE SCHEMA "bseImported"'); + expect(fallbackExecute).not.toHaveBeenCalled(); + }); + }); + describe('toRestoreRecordInput', () => { it('serializes JSON extra column values for v2 dottea row restore', () => { const service = createService(); diff --git a/apps/nestjs-backend/src/features/base/base-import.service.ts b/apps/nestjs-backend/src/features/base/base-import.service.ts index 6fb3ffa51b..0209bae2a5 100644 --- a/apps/nestjs-backend/src/features/base/base-import.service.ts +++ b/apps/nestjs-backend/src/features/base/base-import.service.ts @@ -19,7 +19,6 @@ import { ViewType, } from '@teable/core'; import { PrismaService, ProvisionState } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import type { ICreateBaseVo, IBaseJson, @@ -56,6 +55,7 @@ import { type RestoreRecordsStreamResult, type UpdateManyStreamBatchInput, } from '@teable/v2-core'; +import type { DependencyContainer } from '@teable/v2-di'; import * as csvParser from 'csv-parser'; import { Knex } from 'knex'; @@ -68,6 +68,8 @@ import * as unzipper from 'unzipper'; import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; +import { DataDbClientManager } from '../../global/data-db-client-manager.service'; +import type { IDataDbRoutingOptions } from '../../global/data-db-client-manager.service'; import type { IClsStore } from '../../types/cls'; import StorageAdapter from '../attachments/plugins/adapter'; import { InjectStorageAdapter } from '../attachments/plugins/storage'; @@ -98,16 +100,72 @@ export type BaseImportProgressCallback = ( detail?: string ) => void; +type IDataPrismaExecutor = { + $executeRawUnsafe(query: string, ...values: unknown[]): Promise; +}; + +type IDataPrismaScopedClient = IDataPrismaExecutor & { + txClient?: () => IDataPrismaExecutor; +}; + const tableDataImportBatchSize = 100; const linkFieldImportBatchSize = 25; +const stringifyErrorDetails = (details: unknown): string | undefined => { + if (details === undefined || details === null) { + return undefined; + } + if (typeof details === 'string') { + return details.trim() || undefined; + } + try { + return JSON.stringify(details); + } catch { + return String(details); + } +}; + +const formatBaseImportObjectError = (error: object, fallback: string): string => { + const candidate = error as { + code?: unknown; + details?: unknown; + message?: unknown; + name?: unknown; + }; + const message = typeof candidate.message === 'string' ? candidate.message.trim() : ''; + const code = typeof candidate.code === 'string' ? candidate.code.trim() : ''; + const details = stringifyErrorDetails(candidate.details); + + if (message) { + return code ? `${message} (${code})` : message; + } + if (code) { + return details ? `${fallback}: ${code} - ${details}` : `${fallback}: ${code}`; + } + if (error instanceof Error && typeof candidate.name === 'string' && candidate.name !== 'Error') { + return `${fallback}: ${candidate.name}`; + } + return fallback; +}; + +export const formatBaseImportError = (error: unknown, fallback = 'Import failed'): string => { + if (typeof error === 'string') { + return error.trim() || fallback; + } + + if (error && typeof error === 'object') { + return formatBaseImportObjectError(error, fallback); + } + + return fallback; +}; + @Injectable() export class BaseImportService { private logger = new Logger(BaseImportService.name); constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, private readonly cls: ClsService, private readonly tableService: TableService, private readonly fieldDuplicateService: FieldDuplicateService, @@ -119,7 +177,8 @@ export class BaseImportService { @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, private readonly eventEmitter: EventEmitter2, private readonly v2ContainerService: V2ContainerService, - private readonly v2ContextFactory: V2ExecutionContextFactory + private readonly v2ContextFactory: V2ExecutionContextFactory, + private readonly dataDbClientManager: DataDbClientManager ) {} private async getMaxOrder(spaceId: string) { @@ -130,7 +189,12 @@ export class BaseImportService { return spaceAggregate._max.order || 0; } - private async createBase(spaceId: string, name: string, icon?: string) { + private async createBase( + spaceId: string, + name: string, + icon?: string, + routingOptions?: IDataDbRoutingOptions + ) { const userId = this.cls.get('user.id'); const order = (await this.getMaxOrder(spaceId)) + 1; @@ -156,10 +220,15 @@ export class BaseImportService { try { const sqlList = this.dbProvider.createSchema(base.id); if (sqlList) { + const scopedDataPrisma = (await this.dataDbClientManager.dataPrismaForSpace( + spaceId, + routingOptions + )) as IDataPrismaScopedClient; + const dataPrisma = scopedDataPrisma.txClient?.() ?? scopedDataPrisma; for (const sql of sqlList) { // Keep schema creation visible to the subsequent data-plane DDL/insert steps even when // import structure creation is wrapped in an outer shared meta transaction. - await this.dataPrismaService.$executeRawUnsafe(sql); + await dataPrisma.$executeRawUnsafe(sql); } } @@ -303,7 +372,14 @@ export class BaseImportService { ); const structure = await this.readDotTeaStructure(structureStream); onProgress?.('creating_base', structure.name); - const container = await this.v2ContainerService.getContainer(); + let container: DependencyContainer; + try { + container = await this.v2ContainerService.getContainerForSpace(spaceId); + } catch (error) { + throw new Error( + formatBaseImportError(error, `Failed to connect space data database for ${spaceId}`) + ); + } const commandBus = container.resolve(v2CoreTokens.commandBus); const queryBus = container.resolve(v2CoreTokens.queryBus); const tableRecordRepository = container.resolve( @@ -311,7 +387,7 @@ export class BaseImportService { ); const unitOfWork = container.resolve(v2CoreTokens.unitOfWork); const db = container.resolve>(v2PostgresDbTokens.db); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const base = await this.createBaseV2(db, spaceId, structure.name, structure.icon || undefined); const dotTeaStream = await this.storageAdapter.downloadFile( @@ -326,7 +402,7 @@ export class BaseImportService { }); if (commandResult.isErr()) { - throw new Error(commandResult.error.message); + throw new Error(formatBaseImportError(commandResult.error, 'Invalid dottea import command')); } const result = await commandBus.execute< @@ -335,7 +411,7 @@ export class BaseImportService { >(context, commandResult.value); if (result.isErr()) { - throw new Error(result.error.message); + throw new Error(formatBaseImportError(result.error, 'Failed to import dottea structure')); } const { tableIdMap, fieldIdMap, viewIdMap } = result.value; @@ -1174,7 +1250,9 @@ export class BaseImportService { enqueueDeferredComputedUpdates: true, }); if (commandResult.isErr()) { - throw new Error(commandResult.error.message); + throw new Error( + formatBaseImportError(commandResult.error, 'Invalid table data import command') + ); } const result = await commandBus.execute< @@ -1182,7 +1260,7 @@ export class BaseImportService { RestoreRecordsStreamResult >(context, commandResult.value); if (result.isErr()) { - throw new Error(result.error.message); + throw new Error(formatBaseImportError(result.error, 'Failed to import table data')); } for await (const event of result.value) { @@ -1199,7 +1277,7 @@ export class BaseImportService { } if (event.id === 'error') { - throw new Error(event.message); + throw new Error(formatBaseImportError(event.message, 'Failed to import table data')); } onProgress?.({ @@ -1465,7 +1543,9 @@ export class BaseImportService { for await (const batchResult of this.createTableLinkFieldUpdateBatchStream(entry, config)) { if (batchResult.isErr()) { - throw new Error(batchResult.error.message); + throw new Error( + formatBaseImportError(batchResult.error, 'Invalid link field import batch') + ); } const result = await unitOfWork.withTransaction(context, async (transactionContext) => @@ -1476,7 +1556,7 @@ export class BaseImportService { }) ); if (result.isErr()) { - throw new Error(result.error.message); + throw new Error(formatBaseImportError(result.error, 'Failed to import link fields')); } onLinkBatchUpdated(result.value.totalUpdated); @@ -1843,7 +1923,8 @@ export class BaseImportService { undefined, undefined, undefined, - onProgress + onProgress, + { useTransaction: true } ); resolve(result); } catch (error) { @@ -1919,7 +2000,8 @@ export class BaseImportService { baseId?: string, skipCreateBaseNodes?: boolean, duplicateMode: BaseDuplicateMode = BaseDuplicateMode.Normal, - onProgress?: BaseImportProgressCallback + onProgress?: BaseImportProgressCallback, + routingOptions?: IDataDbRoutingOptions ) { const { name, icon, tables, plugins, folders } = structure; @@ -1937,7 +2019,7 @@ export class BaseImportService { spaceId: true, }, }) - : await this.createBase(spaceId, name, icon || undefined); + : await this.createBase(spaceId, name, icon || undefined, routingOptions); this.logger.log(`base-duplicate-service: Duplicate base successfully`); // update base icon and name (skip when copying into an existing base) @@ -1970,7 +2052,8 @@ export class BaseImportService { ({ tableIdMap, fieldIdMap, viewIdMap, fkMap } = await this.createTables( newBase.id, effectiveTables as IBaseJson['tables'], - onProgress + onProgress, + routingOptions )); } finally { this.cls.set('skipFieldComputation', false); @@ -2036,7 +2119,8 @@ export class BaseImportService { private async createTables( baseId: string, tables: IBaseJson['tables'], - onProgress?: BaseImportProgressCallback + onProgress?: BaseImportProgressCallback, + routingOptions?: IDataDbRoutingOptions ) { const tableIdMap: Record = {}; // Build a name lookup: oldTableId → tableName @@ -2060,7 +2144,8 @@ export class BaseImportService { tables, tableIdMap, tableNameMap, - onProgress + onProgress, + routingOptions ); this.logger.log(`base-duplicate-service: Duplicate table fields successfully`); @@ -2076,7 +2161,8 @@ export class BaseImportService { tables: IBaseJson['tables'], tableIdMap: Record, tableNameMap?: Record, - onProgress?: BaseImportProgressCallback + onProgress?: BaseImportProgressCallback, + routingOptions?: IDataDbRoutingOptions ) { const fieldMap: Record = {}; const fkMap: Record = {}; @@ -2149,13 +2235,17 @@ export class BaseImportService { }; emitFieldProgress('creating_common_fields', commonFields); - await this.fieldDuplicateService.createCommonFields(commonFields, fieldMap); + await this.fieldDuplicateService.createCommonFields(commonFields, fieldMap, routingOptions); emitFieldProgress('creating_button_fields', buttonFields); - await this.fieldDuplicateService.createButtonFields(buttonFields, fieldMap); + await this.fieldDuplicateService.createButtonFields(buttonFields, fieldMap, routingOptions); emitFieldProgress('creating_formula_fields', primaryFormulaFields); - await this.fieldDuplicateService.createTmpPrimaryFormulaFields(primaryFormulaFields, fieldMap); + await this.fieldDuplicateService.createTmpPrimaryFormulaFields( + primaryFormulaFields, + fieldMap, + routingOptions + ); // main fix formula dbField type await this.fieldDuplicateService.repairPrimaryFormulaFields(primaryFormulaFields, fieldMap); @@ -2166,14 +2256,27 @@ export class BaseImportService { emitFieldProgress('creating_primary_dependency_fields', primaryDependencyFields); await this.fieldDuplicateService.bootstrapPrimaryDependencyFields( primaryDependencyFields, - fieldMap + fieldMap, + routingOptions ); emitFieldProgress('creating_link_fields', linkFields); - await this.fieldDuplicateService.createLinkFields(linkFields, tableIdMap, fieldMap, fkMap); + await this.fieldDuplicateService.createLinkFields( + linkFields, + tableIdMap, + fieldMap, + fkMap, + routingOptions + ); emitFieldProgress('creating_lookup_fields', dependencyFields); - await this.fieldDuplicateService.createDependencyFields(dependencyFields, tableIdMap, fieldMap); + await this.fieldDuplicateService.createDependencyFields( + dependencyFields, + tableIdMap, + fieldMap, + 'base', + routingOptions + ); // fix formula expression' field map await this.fieldDuplicateService.repairPrimaryFormulaFields(primaryFormulaFields, fieldMap); diff --git a/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts b/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts index 69f69adbf0..d16e267a4e 100644 --- a/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts +++ b/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common'; import type { IAttachmentCellValue } from '@teable/core'; import { CellFormat, FieldType, HttpErrorCode } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { BaseQueryColumnType, BaseQueryJoinType } from '@teable/openapi'; import type { IBaseQueryJoin, IBaseQuery, IBaseQueryVo, IBaseQueryColumn } from '@teable/openapi'; import { Knex } from 'knex'; @@ -11,6 +10,7 @@ import { ClsService } from 'nestjs-cls'; import { CustomHttpException } from '../../../custom.exception'; import { InjectDbProvider } from '../../../db-provider/db.provider'; import { IDbProvider } from '../../../db-provider/db.provider.interface'; +import { DatabaseRouter } from '../../../global/database-router.service'; import { DATA_KNEX } from '../../../global/knex/knex.module'; import type { IClsStore } from '../../../types/cls'; import { FieldService } from '../../field/field.service'; @@ -37,7 +37,7 @@ export class BaseQueryService { private readonly fieldService: FieldService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly cls: ClsService, private readonly recordService: RecordService ) {} @@ -137,9 +137,8 @@ export class BaseQueryService { const { queryBuilder, fieldMap } = await this.parseBaseQuery(baseId, baseQuery, 0); const query = queryBuilder.toQuery(); this.logger.log('baseQuery SQL: ', query); - const rows = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ [key in string]: unknown }[]>(query) + const rows = await this.databaseRouter + .queryDataPrismaForBase<{ [key in string]: unknown }[]>(baseId, query) .catch((e) => { this.logger.error(e); throw new CustomHttpException('Query failed', HttpErrorCode.VALIDATION_ERROR, { diff --git a/apps/nestjs-backend/src/features/base/base.controller.ts b/apps/nestjs-backend/src/features/base/base.controller.ts index f6251fe69b..6b091e6466 100644 --- a/apps/nestjs-backend/src/features/base/base.controller.ts +++ b/apps/nestjs-backend/src/features/base/base.controller.ts @@ -82,7 +82,7 @@ import { V2IndicatorInterceptor } from '../canary/interceptors/v2-indicator.inte import { CollaboratorService } from '../collaborator/collaborator.service'; import { InvitationService } from '../invitation/invitation.service'; import { BaseExportService } from './base-export.service'; -import { BaseImportService } from './base-import.service'; +import { BaseImportService, formatBaseImportError } from './base-import.service'; import { BaseService } from './base.service'; import { DbConnectionService } from './db-connection.service'; @@ -176,7 +176,7 @@ export class BaseController { } catch (error) { sendEvent({ type: 'error', - message: error instanceof Error ? error.message : 'Unknown import error', + message: formatBaseImportError(error, 'Unknown import error'), }); } finally { clearInterval(heartbeat); diff --git a/apps/nestjs-backend/src/features/base/base.service.spec.ts b/apps/nestjs-backend/src/features/base/base.service.spec.ts index eb168ec6e2..dd6c0a6d12 100644 --- a/apps/nestjs-backend/src/features/base/base.service.spec.ts +++ b/apps/nestjs-backend/src/features/base/base.service.spec.ts @@ -145,4 +145,80 @@ describe('BaseService', () => { expect(result.v2Status).toEqual({ useV2: true, reason: 'space_feature' }); }); }); + + describe('dropBase', () => { + it('runs base-level data DDL through the routed BYODB data client', async () => { + const defaultDataPrisma = { + $executeRawUnsafe: vi.fn(), + }; + const routedTxClient = { + $executeRawUnsafe: vi.fn().mockResolvedValue(1), + }; + const routedDataPrisma = { + txClient: vi.fn().mockReturnValue(routedTxClient), + $executeRawUnsafe: vi.fn(), + }; + const dataDbClientManager = { + dataPrismaForBase: vi.fn().mockResolvedValue(routedDataPrisma), + }; + const dbProvider = { + dropSchema: vi.fn().mockReturnValue('DROP SCHEMA "bse1" CASCADE'), + }; + const tableOpenApiService = { + dropTables: vi.fn(), + }; + const { service } = { + service: new BaseService( + {} as never, + defaultDataPrisma as never, + dataDbClientManager as never, + {} as never, + {} as never, + {} as never, + {} as never, + tableOpenApiService as never, + {} as never, + {} as never, + {} as never, + dbProvider as never, + {} as never + ), + }; + + await service.dropBase('bse1', ['tbl1']); + + expect(dataDbClientManager.dataPrismaForBase).toHaveBeenCalledWith('bse1', { + useTransaction: true, + }); + expect(routedTxClient.$executeRawUnsafe).toHaveBeenCalledWith('DROP SCHEMA "bse1" CASCADE'); + expect(routedDataPrisma.$executeRawUnsafe).not.toHaveBeenCalled(); + expect(defaultDataPrisma.$executeRawUnsafe).not.toHaveBeenCalled(); + expect(tableOpenApiService.dropTables).not.toHaveBeenCalled(); + }); + + it('falls back to table-level routed drops when the provider has no base schema DDL', async () => { + const tableOpenApiService = { + dropTables: vi.fn().mockResolvedValue(undefined), + }; + const service = new BaseService( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + tableOpenApiService as never, + {} as never, + {} as never, + {} as never, + { dropSchema: vi.fn().mockReturnValue('') } as never, + {} as never + ); + + await service.dropBase('bse1', ['tbl1', 'tbl2']); + + expect(tableOpenApiService.dropTables).toHaveBeenCalledWith(['tbl1', 'tbl2']); + }); + }); }); diff --git a/apps/nestjs-backend/src/features/base/base.service.ts b/apps/nestjs-backend/src/features/base/base.service.ts index 375082eb31..4c4f604472 100644 --- a/apps/nestjs-backend/src/features/base/base.service.ts +++ b/apps/nestjs-backend/src/features/base/base.service.ts @@ -7,7 +7,6 @@ import { Role, generateTemplateId, } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { PrismaService, ProvisionState } from '@teable/db-main-prisma'; import type { IBaseErdVo, @@ -50,13 +49,20 @@ import { TableOpenApiService } from '../table/open-api/table-open-api.service'; import { BaseDuplicateService } from './base-duplicate.service'; import { replaceDefaultUrl } from './utils'; +type IDataPrismaExecutor = { + $executeRawUnsafe(query: string, ...values: unknown[]): PromiseLike; +}; + +type IDataPrismaScopedClient = IDataPrismaExecutor & { + txClient?: () => IDataPrismaExecutor; +}; + @Injectable() export class BaseService { private logger = new Logger(BaseService.name); constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, private readonly dataDbClientManager: DataDbClientManager, private readonly cls: ClsService, private readonly collaboratorService: CollaboratorService, @@ -70,6 +76,10 @@ export class BaseService { @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig ) {} + private getDataPrismaExecutor(prisma: IDataPrismaScopedClient): IDataPrismaExecutor { + return prisma.txClient?.() ?? prisma; + } + private async getRoleByBaseId(baseId: string, spaceId: string) { const userId = this.cls.get('user.id'); const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id); @@ -252,7 +262,9 @@ export class BaseService { try { const sqlList = this.dbProvider.createSchema(base.id); if (sqlList) { - const dataPrisma = await this.dataDbClientManager.dataPrismaForSpace(spaceId); + const dataPrisma = await this.dataDbClientManager.dataPrismaForSpace(spaceId, { + useTransaction: true, + }); for (const sql of sqlList) { await dataPrisma.$executeRawUnsafe(sql); } @@ -571,7 +583,9 @@ export class BaseService { await this.dropBase(baseId, tableIds); await this.tableOpenApiService.cleanReferenceFieldIds(tableIds); - await this.tableOpenApiService.cleanTablesRelatedData(baseId, tableIds); + await this.tableOpenApiService.cleanTablesRelatedData(baseId, tableIds, { + useTransaction: true, + }); await this.cleanBaseRelatedData(baseId); }, { @@ -580,25 +594,38 @@ export class BaseService { ); } - private async permanentEmptyBaseRelatedData(baseId: string) { - return await this.prismaService.$tx( - async (prisma) => { - const tables = await prisma.tableMeta.findMany({ - where: { baseId }, - select: { id: true }, - }); - const tableIds = tables.map(({ id }) => id); + private async permanentEmptyBaseRelatedData( + baseId: string, + options: { + transaction?: 'current'; + emitRuntimeEvents?: boolean; + syncButtonField?: boolean; + } = {} + ) { + const remove = async () => { + const prisma = this.prismaService.txClient(); + const tables = await prisma.tableMeta.findMany({ + where: { baseId }, + select: { id: true }, + }); + const tableIds = tables.map(({ id }) => id); - await this.dropBaseTable(tableIds); - await this.tableOpenApiService.cleanReferenceFieldIds(tableIds); - await this.tableOpenApiService.cleanTablesRelatedData(baseId, tableIds); - await this.cleanBaseRelatedDataWithoutBase(baseId); - await this.cleanRelativeNodesData(baseId); - }, - { - timeout: this.thresholdConfig.bigTransactionTimeout, - } - ); + await this.dropBaseTable(tableIds); + await this.tableOpenApiService.cleanReferenceFieldIds(tableIds); + await this.tableOpenApiService.cleanTablesRelatedData(baseId, tableIds, { + useTransaction: true, + }); + await this.cleanBaseRelatedDataWithoutBase(baseId); + await this.cleanRelativeNodesData(baseId); + }; + + if (options.transaction === 'current') { + return await remove(); + } + + return await this.prismaService.$tx(remove, { + timeout: this.thresholdConfig.bigTransactionTimeout, + }); } private async cleanBaseRelatedDataWithoutBase(baseId: string) { @@ -639,7 +666,10 @@ export class BaseService { async dropBase(baseId: string, tableIds: string[]) { const sql = this.dbProvider.dropSchema(baseId); if (sql) { - return await this.dataPrismaService.$executeRawUnsafe(sql); + const scopedDataPrisma = await this.dataDbClientManager.dataPrismaForBase(baseId, { + useTransaction: true, + }); + return await this.getDataPrismaExecutor(scopedDataPrisma).$executeRawUnsafe(sql); } await this.tableOpenApiService.dropTables(tableIds); } @@ -863,7 +893,11 @@ export class BaseService { if (existedBaseId) { // delete some related data - await this.cleanTemplateRelatedData(existedBaseId); + await this.cleanTemplateRelatedData(existedBaseId, { + transaction: 'current', + emitRuntimeEvents: false, + syncButtonField: false, + }); } const { @@ -890,8 +924,15 @@ export class BaseService { }; } - async cleanTemplateRelatedData(baseId: string) { - await this.permanentEmptyBaseRelatedData(baseId); + async cleanTemplateRelatedData( + baseId: string, + options: { + transaction?: 'current'; + emitRuntimeEvents?: boolean; + syncButtonField?: boolean; + } = {} + ) { + await this.permanentEmptyBaseRelatedData(baseId, options); } /** diff --git a/apps/nestjs-backend/src/features/base/db-connection.service.ts b/apps/nestjs-backend/src/features/base/db-connection.service.ts index 7ec2ac6efd..3dedd78688 100644 --- a/apps/nestjs-backend/src/features/base/db-connection.service.ts +++ b/apps/nestjs-backend/src/features/base/db-connection.service.ts @@ -3,8 +3,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { IDsn } from '@teable/core'; import { DriverClient, HttpErrorCode, parseDsn } from '@teable/core'; -import { PrismaService, getDatabaseUrl } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; +import { PrismaService } from '@teable/db-main-prisma'; import type { IDbConnectionVo } from '@teable/openapi'; import { Knex } from 'knex'; import { nanoid } from 'nanoid'; @@ -13,6 +12,7 @@ import { BaseConfig, type IBaseConfig } from '../../configs/base.config'; import { CustomHttpException } from '../../custom.exception'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex'; @Injectable() @@ -21,7 +21,7 @@ export class DbConnectionService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly configService: ConfigService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel(DATA_KNEX) private readonly knex: Knex, @@ -84,12 +84,16 @@ export class DbConnectionService { ); }); + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForBase(baseId, { + useTransaction: true, + }); + // Revoke permissions from the role for the schema - await this.dataPrismaService.$executeRawUnsafe( + await dataPrisma.$executeRawUnsafe( this.knex.raw('REVOKE USAGE ON SCHEMA ?? FROM ??', [schemaName, readOnlyRole]).toQuery() ); - await this.dataPrismaService.$executeRawUnsafe( + await dataPrisma.$executeRawUnsafe( this.knex .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? REVOKE ALL ON TABLES FROM ??`, [ schemaName, @@ -99,7 +103,7 @@ export class DbConnectionService { ); // Revoke permissions from the role for the tables in schema - await this.dataPrismaService.$executeRawUnsafe( + await dataPrisma.$executeRawUnsafe( this.knex .raw('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA ?? FROM ??', [ schemaName, @@ -109,7 +113,7 @@ export class DbConnectionService { ); // drop the role - await this.dataPrismaService.$executeRawUnsafe( + await dataPrisma.$executeRawUnsafe( this.knex.raw('DROP ROLE IF EXISTS ??', [readOnlyRole]).toQuery() ); @@ -120,15 +124,17 @@ export class DbConnectionService { }); } - private async roleExits(role: string): Promise { - const roleExists = await this.dataPrismaService.$queryRaw< + private async roleExits(baseId: string, role: string): Promise { + const dataPrisma = await this.databaseRouter.dataPrismaForBase(baseId); + const roleExists = await dataPrisma.$queryRaw< { count: bigint }[] >`SELECT count(*) FROM pg_roles WHERE rolname=${role}`; return Boolean(roleExists[0].count); } - private async getConnectionCount(role: string): Promise { - const roleExists = await this.dataPrismaService.$queryRaw< + private async getConnectionCount(baseId: string, role: string): Promise { + const dataPrisma = await this.databaseRouter.dataPrismaForBase(baseId); + const roleExists = await dataPrisma.$queryRaw< { count: bigint }[] >`SELECT COUNT(*) FROM pg_stat_activity WHERE usename=${role}`; return Number(roleExists[0].count); @@ -159,7 +165,7 @@ export class DbConnectionService { } // Check if the read-only role already exists - if (!(await this.roleExits(readOnlyRole))) { + if (!(await this.roleExits(baseId, readOnlyRole))) { throw new CustomHttpException('Role does not exist', HttpErrorCode.INTERNAL_SERVER_ERROR, { localization: { i18nKey: 'httpErrors.dbConnection.roleNotExist', @@ -170,10 +176,9 @@ export class DbConnectionService { }); } - const currentConnections = await this.getConnectionCount(readOnlyRole); + const currentConnections = await this.getConnectionCount(baseId, readOnlyRole); - const databaseUrl = - this.configService.get('PRISMA_DATA_DATABASE_URL') ?? getDatabaseUrl('data'); + const databaseUrl = await this.databaseRouter.getDataDatabaseUrlForBase(baseId); const { db } = parseDsn(databaseUrl); // Construct the DSN for the read-only role @@ -225,8 +230,7 @@ export class DbConnectionService { const { hostname: dbHostProxy, port: dbPortProxy } = new URL( `https://${publicDatabaseProxy}` ); - const databaseUrl = - this.configService.get('PRISMA_DATA_DATABASE_URL') ?? getDatabaseUrl('data'); + const databaseUrl = await this.databaseRouter.getDataDatabaseUrlForBase(baseId); const { db } = parseDsn(databaseUrl); return this.prismaService.$tx(async (prisma) => { @@ -254,8 +258,12 @@ export class DbConnectionService { data: { schemaPass: password }, }); + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForBase(baseId, { + useTransaction: true, + }); + // Create a read-only role - await this.dataPrismaService.$executeRawUnsafe( + await dataPrisma.$executeRawUnsafe( this.knex .raw( `CREATE ROLE ?? WITH LOGIN PASSWORD ? NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION CONNECTION LIMIT ?`, @@ -264,17 +272,17 @@ export class DbConnectionService { .toQuery() ); - await this.dataPrismaService.$executeRawUnsafe( + await dataPrisma.$executeRawUnsafe( this.knex.raw(`GRANT USAGE ON SCHEMA ?? TO ??`, [schemaName, readOnlyRole]).toQuery() ); - await this.dataPrismaService.$executeRawUnsafe( + await dataPrisma.$executeRawUnsafe( this.knex .raw(`GRANT SELECT ON ALL TABLES IN SCHEMA ?? TO ??`, [schemaName, readOnlyRole]) .toQuery() ); - await this.dataPrismaService.$executeRawUnsafe( + await dataPrisma.$executeRawUnsafe( this.knex .raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA ?? GRANT SELECT ON TABLES TO ??`, [ schemaName, diff --git a/apps/nestjs-backend/src/features/calculation/batch.service.ts b/apps/nestjs-backend/src/features/calculation/batch.service.ts index 487a836760..c8d6a21be5 100644 --- a/apps/nestjs-backend/src/features/calculation/batch.service.ts +++ b/apps/nestjs-backend/src/features/calculation/batch.service.ts @@ -3,7 +3,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { HttpErrorCode, IdPrefix, RecordOpBuilder, FieldType } from '@teable/core'; import type { IOtOperation, IRecord, TableDomain } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { Knex } from 'knex'; import { groupBy, isEmpty, keyBy } from 'lodash'; import { customAlphabet } from 'nanoid'; @@ -15,6 +14,7 @@ import { CustomHttpException } from '../../custom.exception'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; import { DATA_KNEX } from '../../global/knex/knex.module'; +import { DatabaseRouter } from '../../global/database-router.service'; import type { IRawOp, IRawOpMap } from '../../share-db/interface'; import { RawOpType } from '../../share-db/interface'; import type { IClsStore } from '../../types/cls'; @@ -41,7 +41,7 @@ export class BatchService { constructor( private readonly cls: ClsService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, @InjectModel(DATA_KNEX) private readonly knex: Knex, @InjectDbProvider() private readonly dbProvider: IDbProvider, @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, @@ -108,6 +108,7 @@ export class BatchService { opsPair: [recordId: string, IOtOperation[]][] ) { const raw = await this.fetchRawData( + tableId, dbTableName, opsPair.map(([recordId]) => recordId) ); @@ -134,7 +135,7 @@ export class BatchService { const opsData = this.buildRecordOpsData(opsPair, versionGroup); if (!opsData.length) return; - await this.executeUpdateRecords(dbTableName, fieldMap, opsData); + await this.executeUpdateRecords(tableId, dbTableName, fieldMap, opsData); const opDataList = opsPair.map(([recordId, ops]) => { return { docId: recordId, version: versionGroup[recordId].__version, data: ops }; @@ -225,18 +226,18 @@ export class BatchService { } // @Timing() - private async fetchRawData(dbTableName: string, recordIds: string[]) { + private async fetchRawData(tableId: string, dbTableName: string, recordIds: string[]) { const querySql = this.knex(dbTableName) .whereIn('__id', recordIds) .select('__id', '__version', '__last_modified_time', '__last_modified_by') .toQuery(); - return this.dataPrismaService.txClient().$queryRawUnsafe< + return this.databaseRouter.queryDataPrismaForTable< { __version: number; __id: string; }[] - >(querySql); + >(tableId, querySql, { useTransaction: true }); } private buildRecordOpsData( @@ -282,6 +283,7 @@ export class BatchService { @Timing() private async executeUpdateRecords( + tableId: string, dbTableName: string, fieldMap: { [fieldId: string]: IFieldInstance }, opsData: IOpsData[] @@ -294,7 +296,7 @@ export class BatchService { // group by fieldIds before apply for (const groupKey in opsDataGroup) { - await this.executeUpdateRecordsInner(dbTableName, fieldMap, opsDataGroup[groupKey]); + await this.executeUpdateRecordsInner(tableId, dbTableName, fieldMap, opsDataGroup[groupKey]); } } @@ -302,7 +304,8 @@ export class BatchService { dbTableName: string, idFieldName: string, schemas: { schemaType: SchemaType; dbFieldName: string }[], - data: { id: string; values: { [key: string]: unknown } }[] + data: { id: string; values: { [key: string]: unknown } }[], + routingTableId?: string ) { const tempTableName = `temp_` + customAlphabet('abcdefghijklmnopqrstuvwxyz', 10)(); // 1.create temporary table structure @@ -328,83 +331,98 @@ export class BatchService { const validDbFieldNames = schemas.map((s) => s.dbFieldName).filter((f) => !f.startsWith('__')); - await this.dataPrismaService.$tx(async (tx) => { - // temp table should in one transaction - await tx.$executeRawUnsafe(createTempTableSql); - // 2.initialize temporary table data - await tx.$executeRawUnsafe(insertTempTableSql); - // 3.update data - await handleDBValidationErrors({ - fn: async () => { - await tx.$executeRawUnsafe(updateRecordSql); - }, - handleUniqueError: async () => { - const tables = await this.prismaService.tableMeta.findMany({ - where: { dbTableName }, - select: { id: true, name: true }, - }); - const table = tables[0]; - const fieldRaws = await this.prismaService.field.findMany({ - where: { - tableId: table.id, - dbFieldName: { in: validDbFieldNames }, - unique: true, - deletedTime: null, - }, - select: { id: true, name: true }, - }); - - throw new CustomHttpException( - `Fields ${fieldRaws.map((f) => f.id).join(', ')} unique validation failed`, - HttpErrorCode.VALIDATION_ERROR, - { - localization: { - i18nKey: 'httpErrors.custom.fieldValueDuplicate', - context: { - tableName: table.name, - fieldName: fieldRaws.map((f) => f.name).join(', '), - }, + const resolvedRoutingTableId = + routingTableId ?? + ( + await this.prismaService.txClient().tableMeta.findFirstOrThrow({ + where: { dbTableName, deletedTime: null }, + select: { id: true }, + }) + ).id; + + await this.databaseRouter.dataPrismaTransactionForTable( + resolvedRoutingTableId, + async (tx) => { + // temp table should in one transaction + await tx.$executeRawUnsafe(createTempTableSql); + // 2.initialize temporary table data + await tx.$executeRawUnsafe(insertTempTableSql); + // 3.update data + await handleDBValidationErrors({ + fn: async () => { + await tx.$executeRawUnsafe(updateRecordSql); + }, + handleUniqueError: async () => { + const tables = await this.prismaService.tableMeta.findMany({ + where: { dbTableName }, + select: { id: true, name: true }, + }); + const table = tables[0]; + const fieldRaws = await this.prismaService.field.findMany({ + where: { + tableId: table.id, + dbFieldName: { in: validDbFieldNames }, + unique: true, + deletedTime: null, }, - } - ); - }, - handleNotNullError: async () => { - const tables = await this.prismaService.tableMeta.findMany({ - where: { dbTableName }, - select: { id: true, name: true }, - }); - const table = tables[0]; - const fieldRaws = await this.prismaService.field.findMany({ - where: { - tableId: table.id, - dbFieldName: { in: validDbFieldNames }, - notNull: true, - deletedTime: null, - }, - select: { id: true, name: true }, - }); - - throw new CustomHttpException( - `Fields ${fieldRaws.map((f) => f.id).join(', ')} not null validation failed`, - HttpErrorCode.VALIDATION_ERROR, - { - localization: { - i18nKey: 'httpErrors.custom.fieldValueNotNull', - context: { - tableName: table.name, - fieldName: fieldRaws.map((f) => f.name).join(', '), + select: { id: true, name: true }, + }); + + throw new CustomHttpException( + `Fields ${fieldRaws.map((f) => f.id).join(', ')} unique validation failed`, + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.custom.fieldValueDuplicate', + context: { + tableName: table.name, + fieldName: fieldRaws.map((f) => f.name).join(', '), + }, }, + } + ); + }, + handleNotNullError: async () => { + const tables = await this.prismaService.tableMeta.findMany({ + where: { dbTableName }, + select: { id: true, name: true }, + }); + const table = tables[0]; + const fieldRaws = await this.prismaService.field.findMany({ + where: { + tableId: table.id, + dbFieldName: { in: validDbFieldNames }, + notNull: true, + deletedTime: null, }, - } - ); - }, - }); - // 4.delete temporary table - await tx.$executeRawUnsafe(dropTempTableSql); - }); + select: { id: true, name: true }, + }); + + throw new CustomHttpException( + `Fields ${fieldRaws.map((f) => f.id).join(', ')} not null validation failed`, + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.custom.fieldValueNotNull', + context: { + tableName: table.name, + fieldName: fieldRaws.map((f) => f.name).join(', '), + }, + }, + } + ); + }, + }); + // 4.delete temporary table + await tx.$executeRawUnsafe(dropTempTableSql); + }, + undefined, + { useTransaction: true } + ); } private async executeUpdateRecordsInner( + tableId: string, dbTableName: string, fieldMap: { [fieldId: string]: IFieldInstance }, opsData: IOpsData[] @@ -451,7 +469,7 @@ export class BatchService { { dbFieldName: '__version', schemaType: SchemaType.Integer }, ]; - await this.batchUpdateDB(dbTableName, '__id', schemas, data); + await this.batchUpdateDB(dbTableName, '__id', schemas, data, tableId); } @Timing() diff --git a/apps/nestjs-backend/src/features/calculation/field-calculation.service.spec.ts b/apps/nestjs-backend/src/features/calculation/field-calculation.service.spec.ts index 14859b4cdd..072331df6c 100644 --- a/apps/nestjs-backend/src/features/calculation/field-calculation.service.spec.ts +++ b/apps/nestjs-backend/src/features/calculation/field-calculation.service.spec.ts @@ -29,7 +29,7 @@ describe('FieldCalculationService', () => { txClient: () => ({ $queryRawUnsafe: metaQueryRawUnsafe }), } as never, { - txClient: () => ({ $queryRawUnsafe: dataQueryRawUnsafe }), + queryDataPrismaForBase: dataQueryRawUnsafe, } as never, {} as never, {} as never, @@ -38,7 +38,10 @@ describe('FieldCalculationService', () => { ); await expect(service.getRowCount('bseTest.projects')).resolves.toBe(7); - expect(dataQueryRawUnsafe).toHaveBeenCalledTimes(1); + expect(dataQueryRawUnsafe).toHaveBeenCalledWith( + 'bseTest', + 'select count(*) as "count" from "bseTest"."projects"' + ); expect(metaQueryRawUnsafe).not.toHaveBeenCalled(); await knex.destroy(); }); diff --git a/apps/nestjs-backend/src/features/calculation/field-calculation.service.ts b/apps/nestjs-backend/src/features/calculation/field-calculation.service.ts index 65b9b3fcd7..7553d2fb4f 100644 --- a/apps/nestjs-backend/src/features/calculation/field-calculation.service.ts +++ b/apps/nestjs-backend/src/features/calculation/field-calculation.service.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; import { FieldType, type IRecord } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import { Knex } from 'knex'; import { uniq } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { concatMap, lastValueFrom, map, range, toArray } from 'rxjs'; import { ThresholdConfig, IThresholdConfig } from '../../configs/threshold.config'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import { Timing } from '../../utils/timing'; import type { IFieldInstance, IFieldMap } from '../field/model/factory'; @@ -35,7 +35,7 @@ export interface ITopoOrdersContext { export class FieldCalculationService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly referenceService: ReferenceService, @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder, @InjectModel(DATA_KNEX) private readonly knex: Knex, @@ -114,9 +114,14 @@ export class FieldCalculationService { .limit(chunkSize) .offset(page * chunkSize) .toQuery(); - return this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ [dbFieldName: string]: unknown }[]>(query); + return this.databaseRouter.queryDataPrismaForTable<{ [dbFieldName: string]: unknown }[]>( + tableId, + query + ); + } + + private getBaseIdFromDbTableName(dbTableName: string) { + return dbTableName.split('.')[0]; } async getRecordsBatchByFields( @@ -130,11 +135,11 @@ export class FieldCalculationService { } = {}; const chunkSize = this.thresholdConfig.calcChunkSize; for (const dbTableName in dbTableName2fields) { + const tableId = dbTableName2tableId[dbTableName]; // deduplication is needed - const rowCount = await this.getRowCount(dbTableName); + const rowCount = await this.getRowCount(dbTableName, tableId); const totalPages = Math.ceil(rowCount / chunkSize); const fields = dbTableName2fields[dbTableName]; - const tableId = dbTableName2tableId[dbTableName]; const records = await lastValueFrom( range(0, totalPages).pipe( @@ -152,11 +157,14 @@ export class FieldCalculationService { } @Timing() - async getRowCount(dbTableName: string) { + async getRowCount(dbTableName: string, tableId?: string) { const query = this.knex.count('*', { as: 'count' }).from(dbTableName).toQuery(); - const [{ count }] = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ count: bigint }[]>(query); + const [{ count }] = tableId + ? await this.databaseRouter.queryDataPrismaForTable<{ count: bigint }[]>(tableId, query) + : await this.databaseRouter.queryDataPrismaForBase<{ count: bigint }[]>( + this.getBaseIdFromDbTableName(dbTableName), + query + ); return Number(count); } diff --git a/apps/nestjs-backend/src/features/calculation/link.service.ts b/apps/nestjs-backend/src/features/calculation/link.service.ts index 6505060cb6..5a6715cbba 100644 --- a/apps/nestjs-backend/src/features/calculation/link.service.ts +++ b/apps/nestjs-backend/src/features/calculation/link.service.ts @@ -3,7 +3,6 @@ import { Injectable, Logger } from '@nestjs/common'; import type { ILinkCellValue, ILinkFieldOptions, IRecord, TableDomain } from '@teable/core'; import { FieldType, HttpErrorCode, Relationship } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import type { Field } from '@teable/db-main-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import { Knex } from 'knex'; @@ -12,6 +11,7 @@ import { InjectModel } from 'nest-knexjs'; import { CustomHttpException } from '../../custom.exception'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import { Timing } from '../../utils/timing'; import type { IFieldInstance, IFieldMap } from '../field/model/factory'; @@ -60,13 +60,28 @@ export class LinkService { private logger = new Logger(LinkService.name); constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly batchService: BatchService, @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder, @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel(DATA_KNEX) private readonly knex: Knex ) {} + private async executeForTable(tableId: string, query: string) { + return await this.databaseRouter.executeDataPrismaForTable(tableId, query, { + useTransaction: true, + }); + } + + private async queryForTable(tableId: string, query: string, ...values: unknown[]) { + return await this.databaseRouter.queryDataPrismaForTable( + tableId, + query, + { useTransaction: true }, + ...values + ); + } + private validateLinkCell(cell: ILinkCellContext) { if (!Array.isArray(cell.newValue)) { return cell; @@ -614,9 +629,7 @@ export class LinkService { .whereNotNull(foreignKeyName) .toQuery(); - return this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ id: string; foreignId: string }[]>(query); + return this.queryForTable<{ id: string; foreignId: string }[]>(options.foreignTableId, query); } async getAllForeignKeys(options: ILinkFieldOptions) { @@ -631,9 +644,7 @@ export class LinkService { .whereNotNull(foreignKeyName) .toQuery(); - return this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ id: string; foreignId: string }[]>(query); + return this.queryForTable<{ id: string; foreignId: string }[]>(options.foreignTableId, query); } private async getJoinedForeignKeys(linkRecordIds: string[], options: ILinkFieldOptions) { @@ -653,9 +664,7 @@ export class LinkService { .whereNotNull(foreignKeyName) .toQuery(); - return this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ id: string; foreignId: string }[]>(query); + return this.queryForTable<{ id: string; foreignId: string }[]>(options.foreignTableId, query); } /** @@ -1001,9 +1010,10 @@ export class LinkService { const nativeQuery = qb.whereIn('__id', recordIds).toQuery(); this.logger.debug(`Fetch records with query: ${nativeQuery}`); - const recordRaw = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ [dbTableName: string]: unknown }[]>(nativeQuery); + const recordRaw = await this.queryForTable<{ [dbTableName: string]: unknown }[]>( + tableId, + nativeQuery + ); recordRaw.forEach((record) => { const recordId = record.__id as string; @@ -1207,7 +1217,7 @@ export class LinkService { .whereIn(selfKeyName, recordIdsToDeleteAll) .delete() .toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(deleteAllQuery); + await this.executeForTable(field.options.foreignTableId, deleteAllQuery); // Re-insert all records in correct order const reinsertData = toDeleteAndReinsert.flatMap(([recordId, newKeys]) => @@ -1227,7 +1237,7 @@ export class LinkService { if (reinsertData.length) { const reinsertQuery = this.knex(fkHostTableName).insert(reinsertData).toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(reinsertQuery); + await this.executeForTable(field.options.foreignTableId, reinsertQuery); } } @@ -1237,7 +1247,7 @@ export class LinkService { .whereIn([selfKeyName, foreignKeyName], toDelete) .delete() .toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(query); + await this.executeForTable(field.options.foreignTableId, query); } // Handle regular additions @@ -1259,6 +1269,7 @@ export class LinkService { // Get current max order for this source record if field has order column if (field.getHasOrderColumn()) { currentMaxOrder = await this.getMaxOrderForTarget( + field.options.foreignTableId, fkHostTableName, selfKeyName, sourceRecordId, @@ -1284,7 +1295,7 @@ export class LinkService { } const query = this.knex(fkHostTableName).insert(insertData).toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(query); + await this.executeForTable(field.options.foreignTableId, query); } } @@ -1292,6 +1303,7 @@ export class LinkService { * Get the maximum order value for a specific target record in a link relationship */ private async getMaxOrderForTarget( + routingTableId: string, tableName: string, foreignKeyColumn: string, targetRecordId: string, @@ -1303,9 +1315,10 @@ export class LinkService { .first() .toQuery(); - const maxOrderResult = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ maxOrder: unknown }[]>(maxOrderQuery); + const maxOrderResult = await this.queryForTable<{ maxOrder: unknown }[]>( + routingTableId, + maxOrderQuery + ); const raw = maxOrderResult[0]?.maxOrder as unknown; // Coerce aggregate results safely into number; default to 0 return raw == null ? 0 : Number(raw); @@ -1343,7 +1356,7 @@ export class LinkService { .update(updateFields) .whereIn([selfKeyName, foreignKeyName], toDelete) .toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(query); + await this.executeForTable(field.options.foreignTableId, query); } if (toAdd.length) { @@ -1370,6 +1383,7 @@ export class LinkService { // Get current max order for this target record if field has order column if (field.getHasOrderColumn()) { currentMaxOrder = await this.getMaxOrderForTarget( + field.options.foreignTableId, fkHostTableName, foreignKeyName, foreignRecordId, @@ -1393,7 +1407,13 @@ export class LinkService { } } - await this.batchService.batchUpdateDB(fkHostTableName, selfKeyName, dbFields, updateData); + await this.batchService.batchUpdateDB( + fkHostTableName, + selfKeyName, + dbFields, + updateData, + field.options.foreignTableId + ); } } @@ -1422,7 +1442,7 @@ export class LinkService { .forUpdate() .toQuery(); - await this.dataPrismaService.txClient().$queryRawUnsafe(lockQuery); + await this.queryForTable(tableId, lockQuery); } private async saveForeignKeyForOneMany( @@ -1462,7 +1482,7 @@ export class LinkService { .update(clearFields) .where(selfKeyName, recordId) .toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(clearQuery); + await this.executeForTable(field.options.foreignTableId, clearQuery); // Re-establish all links with correct order const dbFields = [ @@ -1485,7 +1505,8 @@ export class LinkService { fkHostTableName, foreignKeyName, dbFields, - updateData + updateData, + field.options.foreignTableId ); } else { // Handle regular add/remove operations @@ -1504,7 +1525,7 @@ export class LinkService { .update(updateFields) .whereIn([selfKeyName, foreignKeyName], deleteConditions) .toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(query); + await this.executeForTable(field.options.foreignTableId, query); } // Add new links and update order for all current links @@ -1516,6 +1537,7 @@ export class LinkService { if (toAdd.length > 0) { // Get the current maximum order value for this target record const currentMaxOrder = await this.getMaxOrderForTarget( + field.options.foreignTableId, fkHostTableName, selfKeyName, recordId, @@ -1541,7 +1563,8 @@ export class LinkService { fkHostTableName, foreignKeyName, dbFields, - addData + addData, + field.options.foreignTableId ); } } else { @@ -1563,7 +1586,8 @@ export class LinkService { fkHostTableName, foreignKeyName, dbFields, - addData + addData, + field.options.foreignTableId ); } } @@ -1603,7 +1627,7 @@ export class LinkService { .update(updateFields) .whereIn([selfKeyName, foreignKeyName], toDelete) .toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(query); + await this.executeForTable(field.options.foreignTableId, query); } if (toAdd.length) { @@ -1627,7 +1651,8 @@ export class LinkService { id: foreignRecordId, values, }; - }) + }), + field.options.foreignTableId ); } } @@ -1912,9 +1937,7 @@ export class LinkService { .whereNotNull(foreignKeyName) .toQuery(); - return this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ id: string; foreignId: string }[]>(query); + return this.queryForTable<{ id: string; foreignId: string }[]>(options.foreignTableId, query); } async getRelatedLinkFieldRaws(tableId: string) { diff --git a/apps/nestjs-backend/src/features/calculation/system-field.service.ts b/apps/nestjs-backend/src/features/calculation/system-field.service.ts index ff63f70a0d..33817f1398 100644 --- a/apps/nestjs-backend/src/features/calculation/system-field.service.ts +++ b/apps/nestjs-backend/src/features/calculation/system-field.service.ts @@ -4,10 +4,10 @@ import { Injectable } from '@nestjs/common'; import type { LastModifiedByFieldCore, LastModifiedTimeFieldCore } from '@teable/core'; import { FieldKeyType, TableDomain, FieldType } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import type { IClsStore } from '../../types/cls'; import { Timing } from '../../utils/timing'; @@ -18,11 +18,12 @@ export class SystemFieldService { constructor( private readonly cls: ClsService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, @InjectModel(DATA_KNEX) private readonly knex: Knex ) {} private async updateSystemField( + tableId: string, dbTableName: string, recordIds: string[], userId: string, @@ -38,7 +39,7 @@ export class SystemFieldService { .whereIn('__id', recordIds) .toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(nativeQuery); + await this.databaseRouter.executeDataPrismaForTable(tableId, nativeQuery); } @Timing() @@ -78,6 +79,7 @@ export class SystemFieldService { const trackedLastModifiedByColumnUpdates: Record = {}; await this.updateSystemField( + table.id, dbTableName, records.map((r) => r.id), user.id, @@ -160,7 +162,7 @@ export class SystemFieldService { }) .whereIn('__id', recordIds) .toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(nativeQuery); + await this.databaseRouter.executeDataPrismaForTable(table.id, nativeQuery); } // Persist tracked Last Modified By columns that are not generated from the system column @@ -174,7 +176,7 @@ export class SystemFieldService { }) .whereIn('__id', recordIds) .toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(nativeQuery); + await this.databaseRouter.executeDataPrismaForTable(table.id, nativeQuery); } } diff --git a/apps/nestjs-backend/src/features/canary/canary.service.ts b/apps/nestjs-backend/src/features/canary/canary.service.ts index 9ef17a6a1b..50d223b6ba 100644 --- a/apps/nestjs-backend/src/features/canary/canary.service.ts +++ b/apps/nestjs-backend/src/features/canary/canary.service.ts @@ -2,12 +2,12 @@ import { Injectable } from '@nestjs/common'; import type { ICanaryConfig, V2Feature } from '@teable/openapi'; import { SettingKey } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; -import type { IClsStore, V2Reason } from '../../types/cls'; +import type { IClsStore, IV2Reason } from '../../types/cls'; import { SettingService } from '../setting/setting.service'; export interface IV2Decision { useV2: boolean; - reason: V2Reason; + reason: IV2Reason; } export interface IBaseV2DecisionContext { diff --git a/apps/nestjs-backend/src/features/database-view/database-view.service.ts b/apps/nestjs-backend/src/features/database-view/database-view.service.ts index 3af92c46e0..963551718d 100644 --- a/apps/nestjs-backend/src/features/database-view/database-view.service.ts +++ b/apps/nestjs-backend/src/features/database-view/database-view.service.ts @@ -1,9 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import type { TableDomain } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; +import { DatabaseRouter } from '../../global/database-router.service'; import { ReferenceService } from '../calculation/reference.service'; import { InjectRecordQueryBuilder, IRecordQueryBuilder } from '../record/query-builder'; import type { IDatabaseView } from './database-view.interface'; @@ -18,7 +18,7 @@ export class DatabaseViewService implements IDatabaseView { @InjectRecordQueryBuilder() private readonly recordQueryBuilderService: IRecordQueryBuilder, private readonly prisma: PrismaService, - private readonly dataPrisma: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly referenceService: ReferenceService ) {} @@ -29,7 +29,7 @@ export class DatabaseViewService implements IDatabaseView { const sqls = this.dbProvider.createDatabaseView(table, qb, { materialized: true }); const viewName = this.dbProvider.generateDatabaseViewName(table.id); - await this.dataPrisma.$tx(async (tx) => { + await this.databaseRouter.dataPrismaTransactionForTable(table.id, async (tx) => { for (const sql of sqls) { await tx.$executeRawUnsafe(sql); } @@ -64,7 +64,7 @@ export class DatabaseViewService implements IDatabaseView { }); const sqls = this.dbProvider.recreateDatabaseView(table, qb); - await this.dataPrisma.$tx(async (tx) => { + await this.databaseRouter.dataPrismaTransactionForTable(table.id, async (tx) => { for (const sql of sqls) { await tx.$executeRawUnsafe(sql); } @@ -83,7 +83,7 @@ export class DatabaseViewService implements IDatabaseView { public async refreshView(tableId: string) { const sql = this.dbProvider.refreshDatabaseView(tableId, { concurrently: true }); if (sql) { - await this.dataPrisma.$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql); } } @@ -93,14 +93,14 @@ export class DatabaseViewService implements IDatabaseView { for (const tableId of tableIds) { const sql = this.dbProvider.refreshDatabaseView(tableId, { concurrently: true }); if (sql) { - await this.dataPrisma.$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql); } } } private async dropDataView(tableId: string) { const sqls = this.dbProvider.dropDatabaseView(tableId); - await this.dataPrisma.$tx(async (tx) => { + await this.databaseRouter.dataPrismaTransactionForTable(tableId, async (tx) => { for (const sql of sqls) { await tx.$executeRawUnsafe(sql); } diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-converting-link.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-converting-link.service.ts index d831a03918..b4863fabb8 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-converting-link.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-converting-link.service.ts @@ -9,13 +9,13 @@ import { PRIMARY_SUPPORTED_TYPES, HttpErrorCode, } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import { isEqual } from 'lodash'; import { CustomHttpException } from '../../../custom.exception'; import { InjectDbProvider } from '../../../db-provider/db.provider'; import { IDbProvider } from '../../../db-provider/db.provider.interface'; import { DropColumnOperationType } from '../../../db-provider/drop-database-column-query/drop-database-column-field-visitor.interface'; +import { DatabaseRouter } from '../../../global/database-router.service'; import { FieldCalculationService } from '../../calculation/field-calculation.service'; import type { IOpsMap } from '../../calculation/utils/compose-maps'; import { TableDomainQueryService } from '../../table-domain/table-domain-query.service'; @@ -37,7 +37,7 @@ const isLink = (field: IFieldInstance): field is LinkFieldDto => export class FieldConvertingLinkService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly fieldDeletingService: FieldDeletingService, private readonly fieldCreatingService: FieldCreatingService, private readonly fieldSupplementService: FieldSupplementService, @@ -199,7 +199,7 @@ export class FieldConvertingLinkService { ); // Execute all queries (FK/junction creation, order columns, etc.) for (const query of createColumnQueries) { - await this.dataPrismaService.txClient().$executeRawUnsafe(query); + await this.databaseRouter.executeDataPrismaForTable(tableId, query, { useTransaction: true }); } } diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts index 20a2e90f9e..62cf3eaa93 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts @@ -22,12 +22,12 @@ import { RecordOpBuilder, } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { Knex } from 'knex'; import { difference, intersection, isEmpty, isEqual, keyBy, set, uniq } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { CustomHttpException } from '../../../custom.exception'; import { DATA_KNEX } from '../../../global/knex/knex.module'; +import { DatabaseRouter } from '../../../global/database-router.service'; import { handleDBValidationErrors } from '../../../utils/db-validation-error'; import { majorFieldKeysChanged, @@ -69,7 +69,7 @@ export class FieldConvertingService { private readonly fieldService: FieldService, private readonly batchService: BatchService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly fieldConvertingLinkService: FieldConvertingLinkService, private readonly fieldSupplementService: FieldSupplementService, private readonly fieldCalculationService: FieldCalculationService, @@ -620,11 +620,9 @@ export class FieldConvertingService { .toSQL() .toNative(); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe< - { __id: string; [dbFieldName: string]: string }[] - >(nativeSql.sql, ...nativeSql.bindings); + const result = await this.databaseRouter.queryDataPrismaForTable< + { __id: string; [dbFieldName: string]: string }[] + >(tableId, nativeSql.sql, { useTransaction: true }, ...nativeSql.bindings); for (const row of result) { const oldCellValue = field.convertDBValue2CellValue(row[field.dbFieldName]) as string[]; @@ -674,11 +672,9 @@ export class FieldConvertingService { .toSQL() .toNative(); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe< - { __id: string; [dbFieldName: string]: string }[] - >(nativeSql.sql, ...nativeSql.bindings); + const result = await this.databaseRouter.queryDataPrismaForTable< + { __id: string; [dbFieldName: string]: string }[] + >(tableId, nativeSql.sql, { useTransaction: true }, ...nativeSql.bindings); for (const row of result) { let oldCellValue = field.convertDBValue2CellValue(row[field.dbFieldName]) as string; @@ -770,11 +766,9 @@ export class FieldConvertingService { .toSQL() .toNative(); - const result = await this.prismaService - .txClient() - .$queryRawUnsafe< - { __id: string; [dbFieldName: string]: string }[] - >(nativeSql.sql, ...nativeSql.bindings); + const result = await this.databaseRouter.queryDataPrismaForTable< + { __id: string; [dbFieldName: string]: string }[] + >(tableId, nativeSql.sql, { useTransaction: true }, ...nativeSql.bindings); for (const row of result) { let oldCellValue = field.convertDBValue2CellValue(row[dbFieldName]) as number; @@ -821,9 +815,9 @@ export class FieldConvertingService { const opsMap: { [recordId: string]: IOtOperation[] } = {}; const nativeSql = this.knex(dbTableName).select('__id', dbFieldName).whereNotNull(dbFieldName); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ __id: string; [dbFieldName: string]: string }[]>(nativeSql.toQuery()); + const result = await this.databaseRouter.queryDataPrismaForTable< + { __id: string; [dbFieldName: string]: string }[] + >(tableId, nativeSql.toQuery(), { useTransaction: true }); for (const row of result) { const oldCellValue = field.convertDBValue2CellValue(row[dbFieldName]); @@ -870,9 +864,9 @@ export class FieldConvertingService { .select('__id', field.dbFieldName) .whereNotNull(field.dbFieldName); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ __id: string; [dbFieldName: string]: string }[]>(nativeSql.toQuery()); + const result = await this.databaseRouter.queryDataPrismaForTable< + { __id: string; [dbFieldName: string]: string }[] + >(tableId, nativeSql.toQuery(), { useTransaction: true }); for (const row of result) { const oldCellValue = field.convertDBValue2CellValue(row[field.dbFieldName]); opsMap[row.__id] = [ @@ -1510,7 +1504,10 @@ export class FieldConvertingService { .toQuery(); await handleDBValidationErrors({ - fn: () => this.dataPrismaService.txClient().$executeRawUnsafe(fieldValidationQuery), + fn: () => + this.databaseRouter.executeDataPrismaForTable(tableId, fieldValidationQuery, { + useTransaction: true, + }), handleUniqueError: () => { throw new CustomHttpException( `Field ${oldField.id} unique validation failed`, @@ -1553,6 +1550,7 @@ export class FieldConvertingService { } const matchedIndexes = await this.fieldService.findUniqueIndexesForField( + tableId, dbTableName, dbFieldName ); @@ -1571,7 +1569,9 @@ export class FieldConvertingService { .map(({ sql }) => sql); for (const sql of executeSqls) { - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql, { + useTransaction: true, + }); } } diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-creating.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-creating.service.ts index f09058a736..a371f2b662 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-creating.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-creating.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import type { IColumn, IColumnMeta } from '@teable/core'; import { FieldType } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; +import type { IDataDbRoutingOptions } from '../../../global/data-db-client-manager.service'; import { ViewService } from '../../view/view.service'; import { FieldService } from '../field.service'; import type { IFieldInstance } from '../model/factory'; @@ -23,14 +24,15 @@ export class FieldCreatingService { tableId: string, field: IFieldInstance, initViewColumnMap?: Record, - isSymmetricField?: boolean + isSymmetricField?: boolean, + routingOptions?: IDataDbRoutingOptions ) { const fieldId = field.id; await this.fieldSupplementService.createReference(field); await this.fieldSupplementService.createFieldTaskReference(tableId, field); - const dbTableName = await this.fieldService.getDbTableName(tableId); + const dbTableName = await this.fieldService.getDbTableName(tableId, routingOptions); await this.fieldService.batchCreateFields(tableId, dbTableName, [field], isSymmetricField); @@ -45,11 +47,12 @@ export class FieldCreatingService { tableId: string, fieldInstances: IFieldInstance[], initViewColumnMapList?: Array | undefined>, - isSymmetricField?: boolean + isSymmetricField?: boolean, + routingOptions?: IDataDbRoutingOptions ) { if (!fieldInstances.length) return; - const dbTableName = await this.fieldService.getDbTableName(tableId); + const dbTableName = await this.fieldService.getDbTableName(tableId, routingOptions); for (const field of fieldInstances) { await this.fieldSupplementService.createReference(field); @@ -77,9 +80,10 @@ export class FieldCreatingService { async createFields( tableId: string, fieldInstances: IFieldInstance[], - initViewColumnMap?: Record + initViewColumnMap?: Record, + routingOptions?: IDataDbRoutingOptions ) { - const dbTableName = await this.fieldService.getDbTableName(tableId); + const dbTableName = await this.fieldService.getDbTableName(tableId, routingOptions); for (const field of fieldInstances) { await this.fieldSupplementService.createReference(field); @@ -97,14 +101,21 @@ export class FieldCreatingService { async alterCreateFieldsInExistingTable( tableId: string, - fields: Array<{ field: IFieldInstance; columnMeta?: Record }> + fields: Array<{ field: IFieldInstance; columnMeta?: Record }>, + routingOptions?: IDataDbRoutingOptions ) { if (!fields.length) return [] as { tableId: string; field: IFieldInstance }[]; const baseFieldInstances = fields.map(({ field }) => field); const initViewColumnMapList = fields.map(({ columnMeta }) => columnMeta); - await this.createFieldItemsBatch(tableId, baseFieldInstances, initViewColumnMapList); + await this.createFieldItemsBatch( + tableId, + baseFieldInstances, + initViewColumnMapList, + undefined, + routingOptions + ); const created: { tableId: string; field: IFieldInstance }[] = baseFieldInstances.map( (field) => ({ @@ -124,44 +135,64 @@ export class FieldCreatingService { if (!linkField.options.symmetricFieldId) continue; const symmetricField = await this.fieldSupplementService.generateSymmetricField( tableId, - linkField + linkField, + routingOptions ); const foreignTableId = linkField.options.foreignTableId; - await this.createFieldItemsBatch(foreignTableId, [symmetricField], undefined, true); + await this.createFieldItemsBatch( + foreignTableId, + [symmetricField], + undefined, + true, + routingOptions + ); created.push({ tableId: foreignTableId, field: symmetricField }); } return created; } - async alterCreateField(tableId: string, field: IFieldInstance, columnMeta?: IColumnMeta) { + async alterCreateField( + tableId: string, + field: IFieldInstance, + columnMeta?: IColumnMeta, + routingOptions?: IDataDbRoutingOptions + ) { const newFields: { tableId: string; field: IFieldInstance }[] = []; if (field.type === FieldType.Link && !field.isLookup) { // Foreign key creation is now handled by the visitor in createFieldItem - await this.createFieldItem(tableId, field, columnMeta); + await this.createFieldItem(tableId, field, columnMeta, undefined, routingOptions); newFields.push({ tableId, field }); if (field.options.symmetricFieldId) { const symmetricField = await this.fieldSupplementService.generateSymmetricField( tableId, - field + field, + routingOptions ); - await this.createFieldItem(field.options.foreignTableId, symmetricField, columnMeta, true); + await this.createFieldItem( + field.options.foreignTableId, + symmetricField, + columnMeta, + true, + routingOptions + ); newFields.push({ tableId: field.options.foreignTableId, field: symmetricField }); } return newFields; } - await this.createFieldItem(tableId, field, columnMeta); + await this.createFieldItem(tableId, field, columnMeta, undefined, routingOptions); return [{ tableId, field: field }]; } async alterCreateFields( tableId: string, fieldInstances: IFieldInstance[], - columnMeta?: IColumnMeta + columnMeta?: IColumnMeta, + routingOptions?: IDataDbRoutingOptions ) { const newFields: { tableId: string; field: IFieldInstance }[] = fieldInstances.map((field) => ({ tableId, @@ -170,7 +201,7 @@ export class FieldCreatingService { const primaryField = fieldInstances.find((field) => field.isPrimary)!; - await this.createFieldItem(tableId, primaryField, columnMeta); + await this.createFieldItem(tableId, primaryField, columnMeta, undefined, routingOptions); const linkFields = fieldInstances.filter( (field) => field.type === FieldType.Link && !field.isLookup @@ -180,7 +211,13 @@ export class FieldCreatingService { const initViewColumnMapList = columnMeta ? linkFields.map(() => columnMeta as unknown as Record) : undefined; - await this.createFieldItemsBatch(tableId, linkFields, initViewColumnMapList); + await this.createFieldItemsBatch( + tableId, + linkFields, + initViewColumnMapList, + undefined, + routingOptions + ); // Generate and create symmetric fields one-by-one to avoid duplicate // dbFieldName collisions when multiple links target the same foreign table. @@ -188,10 +225,17 @@ export class FieldCreatingService { if (!field.options.symmetricFieldId) continue; const symmetricField = await this.fieldSupplementService.generateSymmetricField( tableId, - field + field, + routingOptions ); const foreignTableId = field.options.foreignTableId; - await this.createFieldItemsBatch(foreignTableId, [symmetricField], undefined, true); + await this.createFieldItemsBatch( + foreignTableId, + [symmetricField], + undefined, + true, + routingOptions + ); newFields.push({ tableId: foreignTableId, field: symmetricField }); } } @@ -201,7 +245,7 @@ export class FieldCreatingService { (linkFields.length ? !linkFields.map(({ id }) => id).includes(id) : true) && !isPrimary ); - await this.createFields(tableId, otherFields, columnMeta); + await this.createFields(tableId, otherFields, columnMeta, routingOptions); return newFields; } } diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts index 7f3e7eeaea..5627e504c8 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.ts @@ -58,7 +58,6 @@ import type { INumberFieldOptions, } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { Knex } from 'knex'; import { uniq, keyBy, mergeWith } from 'lodash'; import { InjectModel } from 'nest-knexjs'; @@ -67,6 +66,8 @@ import { fromZodError } from 'zod-validation-error'; import { CustomHttpException } from '../../../custom.exception'; import { InjectDbProvider } from '../../../db-provider/db.provider'; import { IDbProvider } from '../../../db-provider/db.provider.interface'; +import { DatabaseRouter } from '../../../global/database-router.service'; +import type { IDataDbRoutingOptions } from '../../../global/data-db-client-manager.service'; import { DATA_KNEX } from '../../../global/knex/knex.module'; import { extractFieldReferences } from '../../../utils'; import { @@ -93,7 +94,7 @@ export class FieldSupplementService { constructor( private readonly fieldService: FieldService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly referenceService: ReferenceService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel(DATA_KNEX) private readonly knex: Knex @@ -1730,14 +1731,20 @@ export class FieldSupplementService { * prepare properties for computed field to make sure it's valid * this method do not do any db update */ - async prepareCreateField(tableId: string, fieldRo: IFieldRo, batchFieldVos?: IFieldVo[]) { + async prepareCreateField( + tableId: string, + fieldRo: IFieldRo, + batchFieldVos?: IFieldVo[], + routingOptions?: IDataDbRoutingOptions + ) { const field = (await this.prepareCreateFieldInner(tableId, fieldRo, batchFieldVos)) as IFieldVo; const fieldId = field.id || generateFieldId(); const fieldName = await this.uniqFieldName(tableId, field.name); const dbFieldName = - fieldRo.dbFieldName ?? (await this.fieldService.generateDbFieldName(tableId, fieldName)); + fieldRo.dbFieldName ?? + (await this.fieldService.generateDbFieldName(tableId, fieldName, routingOptions)); if (fieldRo.dbFieldName) { const existField = await this.prismaService.txClient().field.findFirst({ @@ -1832,7 +1839,12 @@ export class FieldSupplementService { } } - async prepareCreateFields(tableId: string, fieldRos: IFieldRo[], batchFieldVos?: IFieldVo[]) { + async prepareCreateFields( + tableId: string, + fieldRos: IFieldRo[], + batchFieldVos?: IFieldVo[], + routingOptions?: IDataDbRoutingOptions + ) { // throw error when dbFieldName is duplicated const fieldRoDbFieldNames = fieldRos .map((field) => field.dbFieldName) @@ -1869,7 +1881,11 @@ export class FieldSupplementService { fields.map((field) => field.name) ); - const dbFieldNames = await this.fieldService.generateDbFieldNames(tableId, uniqFieldNames); + const dbFieldNames = await this.fieldService.generateDbFieldNames( + tableId, + uniqFieldNames, + routingOptions + ); const fieldVos = fieldRos.map((fieldRo, index) => { const field = fields[index]; @@ -1953,7 +1969,11 @@ export class FieldSupplementService { }); } - async generateSymmetricField(tableId: string, field: LinkFieldDto) { + async generateSymmetricField( + tableId: string, + field: LinkFieldDto, + routingOptions?: IDataDbRoutingOptions + ) { if (!field.options.symmetricFieldId) { throw new CustomHttpException( 'symmetricFieldId is required', @@ -1984,7 +2004,8 @@ export class FieldSupplementService { const isMultipleCellValue = isMultiValueLink(relationship) || undefined; const dbFieldName = await this.fieldService.generateDbFieldName( field.options.foreignTableId, - fieldName + fieldName, + routingOptions ); return createFieldInstanceByVo({ @@ -2013,26 +2034,28 @@ export class FieldSupplementService { async cleanForeignKey(options: ILinkFieldOptions) { const { fkHostTableName, relationship, selfKeyName, foreignKeyName, isOneWay } = options; + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForTable( + options.foreignTableId, + { + useTransaction: true, + } + ); const dropTable = async (tableName: string) => { // Use provider to generate dialect-correct DROP TABLE SQL const sql = this.dbProvider.dropTable(tableName); - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await dataPrisma.$executeRawUnsafe(sql); }; const dropColumn = async (tableName: string, columnName: string) => { const sqls = this.dbProvider.dropColumnAndIndex(tableName, columnName, `index_${columnName}`); for (const sql of sqls) { - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await dataPrisma.$executeRawUnsafe(sql); } // Drop the associated order column if it exists const orderColumn = `${columnName}_order`; - const exists = await this.dbProvider.checkColumnExist( - tableName, - orderColumn, - this.dataPrismaService.txClient() - ); + const exists = await this.dbProvider.checkColumnExist(tableName, orderColumn, dataPrisma); if (exists) { const dropOrderSqls = this.dbProvider.dropColumnAndIndex( tableName, @@ -2040,7 +2063,7 @@ export class FieldSupplementService { `index_${orderColumn}` ); for (const sql of dropOrderSqls) { - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await dataPrisma.$executeRawUnsafe(sql); } } }; diff --git a/apps/nestjs-backend/src/features/field/field-duplicate/field-duplicate.service.ts b/apps/nestjs-backend/src/features/field/field-duplicate/field-duplicate.service.ts index 5ba84f247e..22f45640c6 100644 --- a/apps/nestjs-backend/src/features/field/field-duplicate/field-duplicate.service.ts +++ b/apps/nestjs-backend/src/features/field/field-duplicate/field-duplicate.service.ts @@ -18,7 +18,6 @@ import { isLinkLookupOptions, } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import type { IBaseJson, IFieldJson, IFieldWithTableIdJson } from '@teable/openapi'; import { Knex } from 'knex'; import { pick, get } from 'lodash'; @@ -26,6 +25,8 @@ import { InjectModel } from 'nest-knexjs'; import { CustomHttpException } from '../../../custom.exception'; import { InjectDbProvider } from '../../../db-provider/db.provider'; import { IDbProvider } from '../../../db-provider/db.provider.interface'; +import type { IDataDbRoutingOptions } from '../../../global/data-db-client-manager.service'; +import { DatabaseRouter } from '../../../global/database-router.service'; import { DATA_KNEX } from '../../../global/knex/knex.module'; import { extractFieldReferences } from '../../../utils'; import { DEFAULT_EXPRESSION } from '../../base/constant'; @@ -42,7 +43,7 @@ export class FieldDuplicateService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly fieldOpenApiService: FieldOpenApiService, private readonly linkFieldQueryService: LinkFieldQueryService, @InjectModel(DATA_KNEX) private readonly knex: Knex, @@ -50,7 +51,11 @@ export class FieldDuplicateService { private readonly tableDomainQueryService: TableDomainQueryService ) {} - async createCommonFields(fields: IFieldWithTableIdJson[], fieldMap: Record) { + async createCommonFields( + fields: IFieldWithTableIdJson[], + fieldMap: Record, + routingOptions?: IDataDbRoutingOptions + ) { const byTable = new Map(); for (const field of fields) { const list = byTable.get(field.targetTableId) ?? []; @@ -69,23 +74,38 @@ export class FieldDuplicateService { }) ); - const newFieldVos = await this.fieldOpenApiService.createFieldsByRo(targetTableId, fieldRos); + const newFieldVos = await this.fieldOpenApiService.createFieldsByRo( + targetTableId, + fieldRos, + routingOptions + ); for (let index = 0; index < tableFields.length; index++) { const original = tableFields[index]; const newFieldVo = newFieldVos[index]; - await this.replenishmentConstraint(newFieldVo.id, targetTableId, original.order, { - notNull: original.notNull, - unique: original.unique, - dbFieldName: newFieldVo.dbFieldName, - isPrimary: original.isPrimary, - }); + await this.replenishmentConstraint( + newFieldVo.id, + targetTableId, + original.order, + { + notNull: original.notNull, + unique: original.unique, + dbFieldName: newFieldVo.dbFieldName, + isPrimary: original.isPrimary, + }, + undefined, + routingOptions + ); fieldMap[original.id] = newFieldVo.id; } } } - async createButtonFields(fields: IFieldWithTableIdJson[], fieldMap: Record) { + async createButtonFields( + fields: IFieldWithTableIdJson[], + fieldMap: Record, + routingOptions?: IDataDbRoutingOptions + ) { const newFields = fields.map((field) => { const { options } = field; return { @@ -96,12 +116,13 @@ export class FieldDuplicateService { }, }; }) as IFieldWithTableIdJson[]; - return await this.createCommonFields(newFields, fieldMap); + return await this.createCommonFields(newFields, fieldMap, routingOptions); } async createTmpPrimaryFormulaFields( primaryFormulaFields: IFieldWithTableIdJson[], - fieldMap: Record + fieldMap: Record, + routingOptions?: IDataDbRoutingOptions ) { const byTable = new Map(); for (const field of primaryFormulaFields) { @@ -124,7 +145,11 @@ export class FieldDuplicateService { }) ); - const newFields = await this.fieldOpenApiService.createFieldsByRo(targetTableId, fieldRos); + const newFields = await this.fieldOpenApiService.createFieldsByRo( + targetTableId, + fieldRos, + routingOptions + ); for (let index = 0; index < tableFields.length; index++) { const original = tableFields[index]; @@ -140,12 +165,19 @@ export class FieldDuplicateService { }); } - await this.replenishmentConstraint(newField.id, targetTableId, original.order, { - notNull: original.notNull, - unique: original.unique, - dbFieldName: original.dbFieldName, - isPrimary: original.isPrimary, - }); + await this.replenishmentConstraint( + newField.id, + targetTableId, + original.order, + { + notNull: original.notNull, + unique: original.unique, + dbFieldName: original.dbFieldName, + isPrimary: original.isPrimary, + }, + undefined, + routingOptions + ); fieldMap[original.id] = newField.id; if (original.hasError) { @@ -223,7 +255,9 @@ export class FieldDuplicateService { this.logger.debug( "Executing SQL to modify primary formula field's column: " + alterTableQuery ); - await this.dataPrismaService.txClient().$executeRawUnsafe(alterTableQuery); + await this.databaseRouter.executeDataPrismaForTable(targetTableId, alterTableQuery, { + useTransaction: true, + }); } await this.prismaService.txClient().field.update({ where: { @@ -281,7 +315,8 @@ export class FieldDuplicateService { linkFields: IFieldWithTableIdJson[], tableIdMap: Record, fieldMap: Record, - fkMap: Record + fkMap: Record, + routingOptions?: IDataDbRoutingOptions ) { const selfLinkFields = linkFields.filter( ({ options, sourceTableId }) => @@ -310,19 +345,34 @@ export class FieldDuplicateService { ({ id }) => ![...selfLinkFields, ...crossBaseLinkFields].map(({ id }) => id).includes(id) ); - await this.createSelfLinkFields(selfLinkFields, fieldMap, fkMap); + await this.createSelfLinkFields(selfLinkFields, fieldMap, fkMap, routingOptions); // deal with cross base link fields - await this.createCommonLinkFields(crossBaseLinkFields, tableIdMap, fieldMap, fkMap, true); + await this.createCommonLinkFields( + crossBaseLinkFields, + tableIdMap, + fieldMap, + fkMap, + true, + routingOptions + ); - await this.createCommonLinkFields(commonLinkFields, tableIdMap, fieldMap, fkMap); + await this.createCommonLinkFields( + commonLinkFields, + tableIdMap, + fieldMap, + fkMap, + false, + routingOptions + ); } // eslint-disable-next-line sonarjs/cognitive-complexity async createSelfLinkFields( fields: IFieldWithTableIdJson[], fieldMap: Record, - fkMap: Record + fkMap: Record, + routingOptions?: IDataDbRoutingOptions ) { const twoWaySelfLinkFields = fields.filter( ({ options }) => !(options as ILinkFieldOptions).isOneWay @@ -366,7 +416,11 @@ export class FieldDuplicateService { }) ); - const newFieldVos = await this.fieldOpenApiService.createFieldsByRo(targetTableId, fieldRos); + const newFieldVos = await this.fieldOpenApiService.createFieldsByRo( + targetTableId, + fieldRos, + routingOptions + ); const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({ where: { @@ -438,7 +492,11 @@ export class FieldDuplicateService { }; }); - const newFieldVos = await this.fieldOpenApiService.createFieldsByRo(targetTableId, fieldRos); + const newFieldVos = await this.fieldOpenApiService.createFieldsByRo( + targetTableId, + fieldRos, + routingOptions + ); const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({ where: { @@ -483,7 +541,8 @@ export class FieldDuplicateService { tableIdMap: Record, fieldMap: Record, fkMap: Record, - allowCrossBase: boolean = false + allowCrossBase: boolean = false, + routingOptions?: IDataDbRoutingOptions ) { const oneWayFields = fields.filter(({ options }) => (options as ILinkFieldOptions).isOneWay); const twoWayFields = fields.filter(({ options }) => !(options as ILinkFieldOptions).isOneWay); @@ -513,7 +572,11 @@ export class FieldDuplicateService { } ); - const newFieldVos = await this.fieldOpenApiService.createFieldsByRo(targetTableId, fieldRos); + const newFieldVos = await this.fieldOpenApiService.createFieldsByRo( + targetTableId, + fieldRos, + routingOptions + ); const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({ where: { @@ -592,7 +655,11 @@ export class FieldDuplicateService { }; }); - const newFieldVos = await this.fieldOpenApiService.createFieldsByRo(targetTableId, fieldRos); + const newFieldVos = await this.fieldOpenApiService.createFieldsByRo( + targetTableId, + fieldRos, + routingOptions + ); const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({ where: { @@ -677,10 +744,13 @@ export class FieldDuplicateService { }); if (genDbFieldName !== dbFieldName) { + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForTable(targetTableId, { + useTransaction: true, + }); const exists = await this.dbProvider.checkColumnExist( resolvedDbTableName, genDbFieldName, - this.dataPrismaService.txClient() + dataPrisma ); if (exists) { // Debug logging for rename operation to diagnose failures @@ -700,7 +770,9 @@ export class FieldDuplicateService { for (const sql of alterTableSql) { // eslint-disable-next-line no-console console.log('[repairSymmetricField] executing SQL', sql); - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(targetTableId, sql, { + useTransaction: true, + }); } } } @@ -820,7 +892,8 @@ export class FieldDuplicateService { dependFields: IFieldWithTableIdJson[], tableIdMap: Record, fieldMap: Record, - scope: 'base' | 'table' = 'base' + scope: 'base' | 'table' = 'base', + routingOptions?: IDataDbRoutingOptions ): Promise { if (!dependFields.length) return; @@ -847,7 +920,9 @@ export class FieldDuplicateService { curField, tableIdMap, fieldMap, - scope + scope, + false, + routingOptions ); continue; } @@ -861,7 +936,8 @@ export class FieldDuplicateService { tableIdMap, fieldMap, scope, - true + true, + routingOptions ); } else if (!countMap[curField.id] || countMap[curField.id] < maxCount) { dependFields.push(curField); @@ -891,7 +967,8 @@ export class FieldDuplicateService { async bootstrapPrimaryDependencyFields( fields: IFieldWithTableIdJson[], - sourceToTargetFieldMap: Record + sourceToTargetFieldMap: Record, + routingOptions?: IDataDbRoutingOptions ) { for (const field of fields) { if (!field.isPrimary || !field.aiConfig || field.isLookup) continue; @@ -909,13 +986,18 @@ export class FieldDuplicateService { order, } = field; - const newField = await this.fieldOpenApiService.createField(targetTableId, { - type, - dbFieldName, - description, - options, - name, - }); + const newField = await this.fieldOpenApiService.createField( + targetTableId, + { + type, + dbFieldName, + description, + options, + name, + }, + undefined, + routingOptions + ); await this.replenishmentConstraint(newField.id, targetTableId, order, { notNull, @@ -935,7 +1017,8 @@ export class FieldDuplicateService { tableIdMap: Record, sourceToTargetFieldMap: Record, scope: 'base' | 'table' = 'base', - hasError = false + hasError = false, + routingOptions?: IDataDbRoutingOptions ) { const hasFieldError = Boolean(field.hasError); const isAiConfig = field.aiConfig && !field.isLookup; @@ -948,7 +1031,12 @@ export class FieldDuplicateService { if (shouldConvertErroredComputed) { // During base import, persist errored computed fields as plain text so users keep the data. - await this.duplicateErroredComputedFieldAsText(targetTableId, field, sourceToTargetFieldMap); + await this.duplicateErroredComputedFieldAsText( + targetTableId, + field, + sourceToTargetFieldMap, + routingOptions + ); return; } @@ -968,14 +1056,16 @@ export class FieldDuplicateService { targetTableId, field, tableIdMap, - sourceToTargetFieldMap + sourceToTargetFieldMap, + routingOptions ); break; case isAiConfig: await this.duplicateFieldAiConfig( targetTableId, field as unknown as IFieldInstance, - sourceToTargetFieldMap + sourceToTargetFieldMap, + routingOptions ); break; case isRollup: @@ -984,7 +1074,8 @@ export class FieldDuplicateService { targetTableId, field, tableIdMap, - sourceToTargetFieldMap + sourceToTargetFieldMap, + routingOptions ); break; case isConditionalRollup: @@ -993,7 +1084,8 @@ export class FieldDuplicateService { targetTableId, field, tableIdMap, - sourceToTargetFieldMap + sourceToTargetFieldMap, + routingOptions ); break; case isFormula: @@ -1001,7 +1093,8 @@ export class FieldDuplicateService { targetTableId, field, sourceToTargetFieldMap, - hasError || hasFieldError + hasError || hasFieldError, + routingOptions ); } } @@ -1009,7 +1102,8 @@ export class FieldDuplicateService { private async duplicateErroredComputedFieldAsText( targetTableId: string, field: IFieldWithTableIdJson, - sourceToTargetFieldMap: Record + sourceToTargetFieldMap: Record, + routingOptions?: IDataDbRoutingOptions ) { const { id, name, description, dbFieldName, order, notNull, unique, isPrimary } = field; @@ -1023,7 +1117,12 @@ export class FieldDuplicateService { createFieldRo.dbFieldName = dbFieldName; } - const newField = await this.fieldOpenApiService.createField(targetTableId, createFieldRo); + const newField = await this.fieldOpenApiService.createField( + targetTableId, + createFieldRo, + undefined, + routingOptions + ); await this.replenishmentConstraint(newField.id, targetTableId, order, { notNull, @@ -1040,7 +1139,8 @@ export class FieldDuplicateService { targetTableId: string, field: IFieldWithTableIdJson, tableIdMap: Record, - sourceToTargetFieldMap: Record + sourceToTargetFieldMap: Record, + routingOptions?: IDataDbRoutingOptions ) { const { dbFieldName, @@ -1101,23 +1201,28 @@ export class FieldDuplicateService { const effectiveLookupFieldId = hasError ? mockFieldId : (mappedLookupFieldId as string); - newField = await this.fieldOpenApiService.createField(targetTableId, { - type: (hasError ? mockType : lookupFieldType) as FieldType, - dbFieldName, - description, - isLookup: true, - isConditionalLookup: true, - name, - options, - lookupOptions: { - baseId: remappedLookupOptions?.baseId ?? conditionalOptions?.baseId, - foreignTableId: remappedLookupOptions?.foreignTableId ?? mappedForeignTableId, - lookupFieldId: effectiveLookupFieldId, - filter: remappedLookupOptions?.filter ?? conditionalOptions?.filter ?? null, - sort: remappedLookupOptions?.sort ?? conditionalOptions?.sort ?? undefined, - limit: remappedLookupOptions?.limit ?? conditionalOptions?.limit ?? undefined, + newField = await this.fieldOpenApiService.createField( + targetTableId, + { + type: (hasError ? mockType : lookupFieldType) as FieldType, + dbFieldName, + description, + isLookup: true, + isConditionalLookup: true, + name, + options, + lookupOptions: { + baseId: remappedLookupOptions?.baseId ?? conditionalOptions?.baseId, + foreignTableId: remappedLookupOptions?.foreignTableId ?? mappedForeignTableId, + lookupFieldId: effectiveLookupFieldId, + filter: remappedLookupOptions?.filter ?? conditionalOptions?.filter ?? null, + sort: remappedLookupOptions?.sort ?? conditionalOptions?.sort ?? undefined, + limit: remappedLookupOptions?.limit ?? conditionalOptions?.limit ?? undefined, + }, }, - }); + undefined, + routingOptions + ); if (hasError) { await this.prismaService.txClient().field.update({ @@ -1148,25 +1253,30 @@ export class FieldDuplicateService { const { foreignTableId, linkFieldId, lookupFieldId } = lookupOptionsRo; const isSelfLink = foreignTableId === sourceTableId; - newField = await this.fieldOpenApiService.createField(targetTableId, { - type: (hasError ? mockType : lookupFieldType) as FieldType, - dbFieldName, - description, - isLookup: true, - lookupOptions: { - foreignTableId: - (isSelfLink ? targetTableId : tableIdMap[foreignTableId]) || foreignTableId, - linkFieldId: sourceToTargetFieldMap[linkFieldId], - lookupFieldId: isSelfLink - ? hasError - ? mockFieldId - : sourceToTargetFieldMap[lookupFieldId] - : hasError - ? mockFieldId - : sourceToTargetFieldMap[lookupFieldId] || lookupFieldId, + newField = await this.fieldOpenApiService.createField( + targetTableId, + { + type: (hasError ? mockType : lookupFieldType) as FieldType, + dbFieldName, + description, + isLookup: true, + lookupOptions: { + foreignTableId: + (isSelfLink ? targetTableId : tableIdMap[foreignTableId]) || foreignTableId, + linkFieldId: sourceToTargetFieldMap[linkFieldId], + lookupFieldId: isSelfLink + ? hasError + ? mockFieldId + : sourceToTargetFieldMap[lookupFieldId] + : hasError + ? mockFieldId + : sourceToTargetFieldMap[lookupFieldId] || lookupFieldId, + }, + name, }, - name, - }); + undefined, + routingOptions + ); if (hasError) { await this.prismaService.txClient().field.update({ @@ -1199,7 +1309,8 @@ export class FieldDuplicateService { targetTableId: string, fieldInstance: IFieldWithTableIdJson, tableIdMap: Record, - sourceToTargetFieldMap: Record + sourceToTargetFieldMap: Record, + routingOptions?: IDataDbRoutingOptions ) { const { dbFieldName, @@ -1221,25 +1332,31 @@ export class FieldDuplicateService { const isSelfLink = foreignTableId === sourceTableId; const mockFieldId = Object.values(sourceToTargetFieldMap)[0]; - const newField = await this.fieldOpenApiService.createField(targetTableId, { - type: FieldType.Rollup, - dbFieldName, - description, - lookupOptions: { - // foreignTableId may are cross base table id, so we need to use tableIdMap to get the target table id - foreignTableId: (isSelfLink ? targetTableId : tableIdMap[foreignTableId]) || foreignTableId, - linkFieldId: sourceToTargetFieldMap[linkFieldId], - lookupFieldId: isSelfLink - ? hasError - ? mockFieldId - : sourceToTargetFieldMap[lookupFieldId] - : hasError - ? mockFieldId - : sourceToTargetFieldMap[lookupFieldId] || lookupFieldId, + const newField = await this.fieldOpenApiService.createField( + targetTableId, + { + type: FieldType.Rollup, + dbFieldName, + description, + lookupOptions: { + // foreignTableId may are cross base table id, so we need to use tableIdMap to get the target table id + foreignTableId: + (isSelfLink ? targetTableId : tableIdMap[foreignTableId]) || foreignTableId, + linkFieldId: sourceToTargetFieldMap[linkFieldId], + lookupFieldId: isSelfLink + ? hasError + ? mockFieldId + : sourceToTargetFieldMap[lookupFieldId] + : hasError + ? mockFieldId + : sourceToTargetFieldMap[lookupFieldId] || lookupFieldId, + }, + options, + name, }, - options, - name, - }); + undefined, + routingOptions + ); await this.replenishmentConstraint(newField.id, targetTableId, fieldInstance.order, { notNull, unique, @@ -1270,7 +1387,8 @@ export class FieldDuplicateService { targetTableId: string, fieldInstance: IFieldWithTableIdJson, tableIdMap: Record, - sourceToTargetFieldMap: Record + sourceToTargetFieldMap: Record, + routingOptions?: IDataDbRoutingOptions ) { const { dbFieldName, @@ -1302,13 +1420,18 @@ export class FieldDuplicateService { false ) as IConditionalRollupFieldOptions; - const newField = await this.fieldOpenApiService.createField(targetTableId, { - type: FieldType.ConditionalRollup, - dbFieldName, - description, - options: remappedOptions, - name, - }); + const newField = await this.fieldOpenApiService.createField( + targetTableId, + { + type: FieldType.ConditionalRollup, + dbFieldName, + description, + options: remappedOptions, + name, + }, + undefined, + routingOptions + ); await this.replenishmentConstraint(newField.id, targetTableId, fieldInstance.order, { notNull, @@ -1335,7 +1458,8 @@ export class FieldDuplicateService { targetTableId: string, fieldInstance: IFieldWithTableIdJson, sourceToTargetFieldMap: Record, - hasError: boolean = false + hasError: boolean = false, + routingOptions?: IDataDbRoutingOptions ) { const { type, @@ -1353,20 +1477,25 @@ export class FieldDuplicateService { } = fieldInstance; const { expression } = options as IFormulaFieldOptions; const newExpression = replaceStringByMap(expression, { sourceToTargetFieldMap }); - const newField = await this.fieldOpenApiService.createField(targetTableId, { - type, - dbFieldName, - description, - options: { - ...options, - expression: hasError - ? DEFAULT_EXPRESSION - : newExpression - ? JSON.parse(newExpression) - : undefined, + const newField = await this.fieldOpenApiService.createField( + targetTableId, + { + type, + dbFieldName, + description, + options: { + ...options, + expression: hasError + ? DEFAULT_EXPRESSION + : newExpression + ? JSON.parse(newExpression) + : undefined, + }, + name, }, - name, - }); + undefined, + routingOptions + ); await this.replenishmentConstraint(newField.id, targetTableId, fieldInstance.order, { notNull, unique, @@ -1426,7 +1555,9 @@ export class FieldDuplicateService { ); for (const alterTableQuery of modifyColumnSql) { - await this.dataPrismaService.txClient().$executeRawUnsafe(alterTableQuery); + await this.databaseRouter.executeDataPrismaForTable(targetTableId, alterTableQuery, { + useTransaction: true, + }); } await this.prismaService.txClient().field.update({ @@ -1464,7 +1595,8 @@ export class FieldDuplicateService { private async duplicateFieldAiConfig( targetTableId: string, fieldInstance: IFieldInstance, - sourceToTargetFieldMap: Record + sourceToTargetFieldMap: Record, + routingOptions?: IDataDbRoutingOptions ) { if (!fieldInstance.aiConfig) return; @@ -1472,14 +1604,19 @@ export class FieldDuplicateService { fieldInstance; const aiConfig = this.mapAiConfigForDuplicate(fieldInstance.aiConfig, sourceToTargetFieldMap); - const newField = await this.fieldOpenApiService.createField(targetTableId, { - type, - dbFieldName, - description, - options, - aiConfig, - name, - }); + const newField = await this.fieldOpenApiService.createField( + targetTableId, + { + type, + dbFieldName, + description, + options, + aiConfig, + name, + }, + undefined, + routingOptions + ); await this.replenishmentConstraint(newField.id, targetTableId, 1, { notNull, @@ -1520,7 +1657,8 @@ export class FieldDuplicateService { dbFieldName, isPrimary, }: { notNull?: boolean; unique?: boolean; dbFieldName: string; isPrimary?: boolean }, - dbTableName?: string + dbTableName?: string, + routingOptions?: IDataDbRoutingOptions ) { await this.prismaService.txClient().field.update({ where: { @@ -1574,7 +1712,11 @@ export class FieldDuplicateService { .toSQL(); for (const sql of fieldValidationSqls) { - await this.dataPrismaService.txClient().$executeRawUnsafe(sql.sql); + await this.databaseRouter.executeDataPrismaForTable( + targetTableId, + sql.sql, + routingOptions ?? { useTransaction: true } + ); } } } diff --git a/apps/nestjs-backend/src/features/field/field.service.spec.ts b/apps/nestjs-backend/src/features/field/field.service.spec.ts index 79635f98f0..c367a66542 100644 --- a/apps/nestjs-backend/src/features/field/field.service.spec.ts +++ b/apps/nestjs-backend/src/features/field/field.service.spec.ts @@ -3,6 +3,7 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; import { CellValueType, DbFieldType, FieldType, OpName } from '@teable/core'; import type { IFieldVo, INumberFormatting, ISetFieldPropertyOpContext } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; import { GlobalModule } from '../../global/global.module'; import { FieldModule } from './field.module'; import { FieldService } from './field.service'; @@ -23,6 +24,33 @@ describe('FieldService', () => { expect(service).toBeDefined(); }); + it('reads table metadata from the active transaction when routing asks for it', async () => { + const txFindUnique = vi.fn().mockResolvedValue({ dbTableName: 'bse_test.tbl_tx' }); + const rootFindUnique = vi.fn(); + const service = Object.create(FieldService.prototype) as FieldService; + Object.assign(service, { + prismaService: { + txClient: vi.fn(() => ({ + tableMeta: { + findUnique: txFindUnique, + }, + })), + tableMeta: { + findUnique: rootFindUnique, + }, + } as unknown as PrismaService, + }); + + await expect(service.getDbTableName('tbl_tx', { useTransaction: true })).resolves.toBe( + 'bse_test.tbl_tx' + ); + expect(txFindUnique).toHaveBeenCalledWith({ + where: { id: 'tbl_tx' }, + select: { dbTableName: true }, + }); + expect(rootFindUnique).not.toHaveBeenCalled(); + }); + describe('applyFieldPropertyOpsAndCreateInstance', () => { it('should apply field property operations and return field instance', () => { // Create a mock field VO diff --git a/apps/nestjs-backend/src/features/field/field.service.ts b/apps/nestjs-backend/src/features/field/field.service.ts index 4b9ec2ffe4..4fd74ed933 100644 --- a/apps/nestjs-backend/src/features/field/field.service.ts +++ b/apps/nestjs-backend/src/features/field/field.service.ts @@ -22,7 +22,6 @@ import type { } from '@teable/core'; import type { Field as RawField, Prisma } from '@teable/db-main-prisma'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { instanceToPlain } from 'class-transformer'; import { Knex } from 'knex'; import { keyBy, sortBy, omit } from 'lodash'; @@ -32,6 +31,8 @@ import { CustomHttpException } from '../../custom.exception'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; import { DropColumnOperationType } from '../../db-provider/drop-database-column-query/drop-database-column-field-visitor.interface'; +import type { IDataDbRoutingOptions } from '../../global/data-db-client-manager.service'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import type { IReadonlyAdapterService } from '../../share-db/interface'; import { RawOpType } from '../../share-db/interface'; @@ -64,7 +65,7 @@ export class FieldService implements IReadonlyAdapterService { constructor( private readonly batchService: BatchService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly dataLoaderService: DataLoaderService, private readonly cls: ClsService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @@ -83,13 +84,19 @@ export class FieldService implements IReadonlyAdapterService { this.dataLoaderService.field.invalidateTables(ids); } - async generateDbFieldName(tableId: string, name: string): Promise { + async generateDbFieldName( + tableId: string, + name: string, + routingOptions?: IDataDbRoutingOptions + ): Promise { let dbFieldName = convertNameToValidCharacter(name, 40); - const query = this.dbProvider.columnInfo(await this.getDbTableName(tableId)); - const columns = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ name: string }[]>(query); + const query = this.dbProvider.columnInfo(await this.getDbTableName(tableId, routingOptions)); + const columns = await this.databaseRouter.queryDataPrismaForTable<{ name: string }[]>( + tableId, + query, + routingOptions + ); // fallback logic if (columns.some((column) => column.name === dbFieldName)) { dbFieldName += new Date().getTime(); @@ -97,11 +104,17 @@ export class FieldService implements IReadonlyAdapterService { return dbFieldName; } - async generateDbFieldNames(tableId: string, names: string[]) { - const query = this.dbProvider.columnInfo(await this.getDbTableName(tableId)); - const columns = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ name: string }[]>(query); + async generateDbFieldNames( + tableId: string, + names: string[], + routingOptions?: IDataDbRoutingOptions + ) { + const query = this.dbProvider.columnInfo(await this.getDbTableName(tableId, routingOptions)); + const columns = await this.databaseRouter.queryDataPrismaForTable<{ name: string }[]>( + tableId, + query, + routingOptions + ); return names .map((name) => convertNameToValidCharacter(name, 40)) .map((dbFieldName) => { @@ -463,7 +476,9 @@ export class FieldService implements IReadonlyAdapterService { // Execute all queries (main table alteration + any additional queries like junction tables) for (const query of alterTableQueries) { this.logger.debug(`Executing alter table query: ${query}`); - await this.dataPrismaService.txClient().$executeRawUnsafe(query); + await this.databaseRouter.executeDataPrismaForTable(tableId, query, { + useTransaction: true, + }); } if (unique) { @@ -487,7 +502,9 @@ export class FieldService implements IReadonlyAdapterService { }); }) .toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(fieldValidationQuery); + await this.databaseRouter.executeDataPrismaForTable(tableId, fieldValidationQuery, { + useTransaction: true, + }); } if (notNull) { @@ -537,7 +554,7 @@ export class FieldService implements IReadonlyAdapterService { ); for (const alterTableQuery of alterTableSql) { - await this.dataPrismaService.txClient().$executeRawUnsafe(alterTableQuery); + await this.databaseRouter.executeDataPrismaForTable(tableId, alterTableQuery); } } } @@ -575,9 +592,10 @@ export class FieldService implements IReadonlyAdapterService { // Link fields in Teable maintain a persisted display column on the host table; skipping // the physical rename causes mismatches during computed updates (e.g., UPDATE ... FROM ...). const columnInfoQuery = this.dbProvider.columnInfo(table.dbTableName); - const columns = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ name: string }[]>(columnInfoQuery); + const columns = await this.databaseRouter.queryDataPrismaForTable<{ name: string }[]>( + table.id, + columnInfoQuery + ); const columnNames = new Set(columns.map((column) => column.name)); if (columnNames.has(newDbFieldName)) { @@ -601,7 +619,7 @@ export class FieldService implements IReadonlyAdapterService { ); for (const alterTableQuery of alterTableSql) { - await this.dataPrismaService.txClient().$executeRawUnsafe(alterTableQuery); + await this.databaseRouter.executeDataPrismaForTable(table.id, alterTableQuery); } } @@ -666,11 +684,11 @@ export class FieldService implements IReadonlyAdapterService { await handleDBValidationErrors({ fn: async () => { if (resetFieldQuery) { - await this.dataPrismaService.txClient().$executeRawUnsafe(resetFieldQuery); + await this.databaseRouter.executeDataPrismaForTable(tableId, resetFieldQuery); } for (const alterTableQuery of modifyColumnSql) { - await this.dataPrismaService.txClient().$executeRawUnsafe(alterTableQuery); + await this.databaseRouter.executeDataPrismaForTable(tableId, alterTableQuery); } }, handleUniqueError: () => { @@ -700,11 +718,22 @@ export class FieldService implements IReadonlyAdapterService { }); } - async findUniqueIndexesForField(dbTableName: string, dbFieldName: string) { + async findUniqueIndexesForField( + tableIdOrDbTableName: string, + dbTableNameOrDbFieldName: string, + maybeDbFieldName?: string + ) { + const tableId = maybeDbFieldName ? tableIdOrDbTableName : undefined; + const dbTableName = maybeDbFieldName ? dbTableNameOrDbFieldName : tableIdOrDbTableName; + const dbFieldName = maybeDbFieldName ?? dbTableNameOrDbFieldName; const indexesQuery = this.dbProvider.getTableIndexes(dbTableName); - const indexes = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ name: string; columns: string; isUnique: boolean }[]>(indexesQuery); + const indexes = tableId + ? await this.databaseRouter.queryDataPrismaForTable< + { name: string; columns: string; isUnique: boolean }[] + >(tableId, indexesQuery) + : await this.databaseRouter.queryDataPrismaForBase< + { name: string; columns: string; isUnique: boolean }[] + >(dbTableName.split('.')[0], indexesQuery); return indexes .filter((index) => { @@ -729,7 +758,7 @@ export class FieldService implements IReadonlyAdapterService { dbFieldName: true, type: true, isLookup: true, - table: { select: { dbTableName: true, name: true } }, + table: { select: { id: true, dbTableName: true, name: true } }, }, }); @@ -747,7 +776,7 @@ export class FieldService implements IReadonlyAdapterService { } const dbTableName = table.dbTableName; - const matchedIndexes = await this.findUniqueIndexesForField(dbTableName, dbFieldName); + const matchedIndexes = await this.findUniqueIndexesForField(table.id, dbTableName, dbFieldName); const fieldValidationSqls = this.knex.schema .alterTable(dbTableName, (table) => { @@ -772,7 +801,7 @@ export class FieldService implements IReadonlyAdapterService { await handleDBValidationErrors({ fn: () => { return Promise.all( - executeSqls.map((sql) => this.dataPrismaService.txClient().$executeRawUnsafe(sql)) + executeSqls.map((sql) => this.databaseRouter.executeDataPrismaForTable(table.id, sql)) ); }, handleUniqueError: () => { @@ -891,7 +920,18 @@ export class FieldService implements IReadonlyAdapterService { return fields.map((field) => createFieldInstanceByVo(field)); } - async getDbTableName(tableId: string) { + async getDbTableName(tableId: string, routingOptions?: IDataDbRoutingOptions) { + if (routingOptions?.useTransaction) { + const tableMeta = await this.prismaService.txClient().tableMeta.findUnique({ + where: { id: tableId }, + select: { dbTableName: true }, + }); + if (!tableMeta) { + throw new NotFoundException(`Table not found: ${tableId}`); + } + return tableMeta.dbTableName; + } + const [tableMeta] = await this.dataLoaderService.table.loadByIds([tableId]); if (!tableMeta) { throw new NotFoundException(`Table not found: ${tableId}`); @@ -1064,7 +1104,7 @@ export class FieldService implements IReadonlyAdapterService { tableDomain ); for (const sql of sqls) { - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql); } } catch (e) { this.logger.warn( @@ -1499,7 +1539,7 @@ export class FieldService implements IReadonlyAdapterService { tableDomain ); for (const sql of modifyColumnSql) { - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql); } return; } @@ -1520,7 +1560,7 @@ export class FieldService implements IReadonlyAdapterService { tableDomain ); for (const sql of modifyColumnSql) { - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql); } return; } @@ -1557,7 +1597,7 @@ export class FieldService implements IReadonlyAdapterService { // Execute the column modification for (const sql of modifyColumnSql) { - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql); } } @@ -1649,7 +1689,7 @@ export class FieldService implements IReadonlyAdapterService { // Execute the column modification for (const sql of modifyColumnSql) { - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(dependentTableId, sql); } } } catch (error) { diff --git a/apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.ts b/apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.ts index 7c63670231..b4a64c50f5 100644 --- a/apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.ts +++ b/apps/nestjs-backend/src/features/field/open-api/field-open-api-v2.service.ts @@ -481,7 +481,7 @@ export class FieldOpenApiV2Service { fieldId: string, context?: IExecutionContext ): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const tableQueryService = container.resolve(v2CoreTokens.tableQueryService); const tableMapper = container.resolve(v2CoreTokens.tableMapper); const tableIdResult = TableId.create(tableId); @@ -489,7 +489,7 @@ export class FieldOpenApiV2Service { throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST); } - const queryContext = context ?? (await this.v2ContextFactory.createContext()); + const queryContext = context ?? (await this.v2ContextFactory.createContext(container)); const tableResult = await tableQueryService.getById(queryContext, tableIdResult.value); if (tableResult.isErr()) { const errMsg = tableResult.error.message ?? 'Table not found'; @@ -667,10 +667,10 @@ export class FieldOpenApiV2Service { context: IExecutionContext; table: Table; }> { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); const tableQueryService = container.resolve(v2CoreTokens.tableQueryService); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const tableIdResult = TableId.create(tableId); if (tableIdResult.isErr()) { throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST); @@ -768,7 +768,8 @@ export class FieldOpenApiV2Service { } async getField(tableId: string, fieldId: string): Promise { - const context = await this.v2ContextFactory.createContext(); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.v2ContextFactory.createContext(container); return this.getFieldFromV2(tableId, fieldId, context); } @@ -1444,10 +1445,10 @@ export class FieldOpenApiV2Service { duplicateFieldRo: IDuplicateFieldRo, _windowId?: string ): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); const tableQueryService = container.resolve(v2CoreTokens.tableQueryService); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const tableIdResult = TableId.create(tableId); if (tableIdResult.isErr()) { @@ -1493,10 +1494,10 @@ export class FieldOpenApiV2Service { } async deleteField(tableId: string, fieldId: string): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); const tableQueryService = container.resolve(v2CoreTokens.tableQueryService); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const tableIdResult = TableId.create(tableId); if (tableIdResult.isErr()) { throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST); @@ -1548,10 +1549,10 @@ export class FieldOpenApiV2Service { } async deleteFields(tableId: string, fieldIds: string[]): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); const tableQueryService = container.resolve(v2CoreTokens.tableQueryService); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const tableIdResult = TableId.create(tableId); if (tableIdResult.isErr()) { throw new HttpException('Invalid table id', HttpStatus.BAD_REQUEST); @@ -1614,9 +1615,9 @@ export class FieldOpenApiV2Service { } async updateField(tableId: string, fieldId: string, updateFieldRo: IUpdateFieldRo) { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const currentField = await this.getFieldFromV2(tableId, fieldId, context); const v2Input = { @@ -1656,9 +1657,9 @@ export class FieldOpenApiV2Service { convertFieldRo: IConvertFieldRo, executionOptions?: ConvertFieldExecutionOptions ) { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const shouldTrackUndoContext = executionOptions?.emitOperation !== false && Boolean(context.windowId && context.actorId); if (executionOptions?.undoRedoMode) { @@ -1738,13 +1739,13 @@ export class FieldOpenApiV2Service { direction: 'old' | 'new', undoRedoMode: 'undo' | 'redo' ): Promise { - const container = await this.v2ContainerService.getContainer(); - const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); - context.undoRedo = { mode: undoRedoMode }; - delete context.windowId; - for (const [tableId, opsByRecordId] of Object.entries(modifiedOps)) { + const container = await this.v2ContainerService.getContainerForTable(tableId); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const context = await this.v2ContextFactory.createContext(container); + context.undoRedo = { mode: undoRedoMode }; + delete context.windowId; + for (const [recordId, ops] of Object.entries(opsByRecordId)) { const fields: Record = {}; for (const op of ops) { diff --git a/apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts b/apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts index 3769b8277d..3424493d7a 100644 --- a/apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts +++ b/apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts @@ -53,11 +53,12 @@ import { Knex } from 'knex'; import { groupBy, isEqual, omit, pick } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { ThresholdConfig, IThresholdConfig } from '../../../configs/threshold.config'; import { FieldReferenceCompatibilityException } from '../../../db-provider/filter-query/cell-value-filter.abstract'; import { EventEmitterService } from '../../../event-emitter/event-emitter.service'; import { Events } from '../../../event-emitter/events'; +import { IDataDbRoutingOptions } from '../../../global/data-db-client-manager.service'; +import { DatabaseRouter } from '../../../global/database-router.service'; import type { IClsStore } from '../../../types/cls'; import { Timing } from '../../../utils/timing'; import { FieldCalculationService } from '../../calculation/field-calculation.service'; @@ -109,13 +110,17 @@ export type ILegacyDeleteFieldsPayloadSnapshot = { records: Awaited> | undefined; }; +type CreateFieldsOptions = { + restoreViewOrder?: boolean; +}; + @Injectable() export class FieldOpenApiService { private logger = new Logger(FieldOpenApiService.name); constructor( private readonly graphService: GraphService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly fieldService: FieldService, private readonly viewService: ViewService, private readonly viewOpenApiService: ViewOpenApiService, @@ -1149,7 +1154,9 @@ export class FieldOpenApiService { @Timing() async createFields( tableId: string, - fields: (IFieldVo & { columnMeta?: IColumnMeta; references?: string[] })[] + fields: (IFieldVo & { columnMeta?: IColumnMeta; references?: string[] })[], + routingOptions?: IDataDbRoutingOptions, + options: CreateFieldsOptions = {} ) { if (!fields.length) return; @@ -1183,6 +1190,7 @@ export class FieldOpenApiService { set.add(fieldId); }; + const columnMetaByFieldId = new Map(fields.map((field) => [field.id, field.columnMeta])); const createPayload = orderedFields.map((field) => { const { columnMeta, references, ...fieldVo } = field; if (references?.length) { @@ -1191,7 +1199,10 @@ export class FieldOpenApiService { return { field: createFieldInstanceByVo(fieldVo), - columnMeta: columnMeta as unknown as Record, + columnMeta: (options.restoreViewOrder ? undefined : columnMeta) as unknown as Record< + string, + IColumn + >, }; }); @@ -1200,7 +1211,8 @@ export class FieldOpenApiService { async () => { const createResult = await this.fieldCreatingService.alterCreateFieldsInExistingTable( tableId, - createPayload + createPayload, + routingOptions ); created.push(...createResult); @@ -1215,6 +1227,19 @@ export class FieldOpenApiService { await this.restoreReference(Array.from(referencesToRestore)); } + if (options.restoreViewOrder) { + for (const { tableId: tid, field } of createResult) { + const columnMeta = columnMetaByFieldId.get(field.id); + if (columnMeta) { + await this.viewService.initViewColumnMeta( + tid, + [field.id], + [columnMeta as unknown as Record] + ); + } + } + } + const skipComputation = this.cls.get('skipFieldComputation'); if (!skipComputation) { @@ -1253,15 +1278,24 @@ export class FieldOpenApiService { // Recreate search indexes after schema changes (outside tx boundaries) for (const { tableId: tid, field } of createdFields) { - await this.tableIndexService.createSearchFieldSingleIndex(tid, field); + await this.tableIndexService.createSearchFieldSingleIndex(tid, field, routingOptions); } } @Timing() - async createFieldsByRo(tableId: string, fieldRos: IFieldRo[]): Promise { + async createFieldsByRo( + tableId: string, + fieldRos: IFieldRo[], + routingOptions?: IDataDbRoutingOptions + ): Promise { if (!fieldRos.length) return []; - const fieldVos = await this.fieldSupplementService.prepareCreateFields(tableId, fieldRos); - await this.createFields(tableId, fieldVos); + const fieldVos = await this.fieldSupplementService.prepareCreateFields( + tableId, + fieldRos, + undefined, + routingOptions + ); + await this.createFields(tableId, fieldVos, routingOptions); return fieldVos; } @@ -1327,8 +1361,18 @@ export class FieldOpenApiService { } @Timing() - async createField(tableId: string, fieldRo: IFieldRo, windowId?: string) { - const fieldVo = await this.fieldSupplementService.prepareCreateField(tableId, fieldRo); + async createField( + tableId: string, + fieldRo: IFieldRo, + windowId?: string, + routingOptions?: IDataDbRoutingOptions + ) { + const fieldVo = await this.fieldSupplementService.prepareCreateField( + tableId, + fieldRo, + undefined, + routingOptions + ); const fieldInstance = createFieldInstanceByVo(fieldVo); const columnMeta = fieldRo.order && { [fieldRo.order.viewId]: { order: fieldRo.order.orderIndex }, @@ -1344,7 +1388,8 @@ export class FieldOpenApiService { created = await this.fieldCreatingService.alterCreateField( tableId, fieldInstance, - columnMeta + columnMeta, + routingOptions ); for (const { tableId: tid, field } of created) { let entry = sourceEntries.find((s) => s.tableId === tid); @@ -1367,7 +1412,7 @@ export class FieldOpenApiService { ); for (const { tableId: tid, field } of newFields) { - await this.tableIndexService.createSearchFieldSingleIndex(tid, field); + await this.tableIndexService.createSearchFieldSingleIndex(tid, field, routingOptions); } const referenceMap = await this.getFieldReferenceMap([fieldVo.id]); @@ -2147,9 +2192,11 @@ export class FieldOpenApiService { }); const query = qb.toQuery(); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ count: number }[]>(query); + const result = await this.databaseRouter.queryDataPrismaForTable<{ count: number }[]>( + tableId, + query, + { useTransaction: true } + ); return Number(result[0].count); } @@ -2189,9 +2236,9 @@ export class FieldOpenApiService { .limit(chunkSize) .offset(page * chunkSize) .toQuery(); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ __id: string; [key: string]: string }[]>(query); + const result = await this.databaseRouter.queryDataPrismaForTable< + { __id: string; [key: string]: string }[] + >(tableId, query, { useTransaction: true }); this.logger.debug('getFieldRecords: ', result); return result.map((item) => ({ id: item.__id, diff --git a/apps/nestjs-backend/src/features/graph/graph.service.ts b/apps/nestjs-backend/src/features/graph/graph.service.ts index 3ff3e549be..63e0bf5844 100644 --- a/apps/nestjs-backend/src/features/graph/graph.service.ts +++ b/apps/nestjs-backend/src/features/graph/graph.service.ts @@ -3,7 +3,6 @@ import type { IFieldRo, ILinkFieldOptions, IConvertFieldRo } from '@teable/core' import { FieldType, Relationship, isLinkLookupOptions } from '@teable/core'; import type { Field, TableMeta } from '@teable/db-main-prisma'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import type { IGraphEdge, IGraphNode, @@ -18,6 +17,7 @@ import { Knex } from 'knex'; import { groupBy, keyBy, uniq } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import { majorFieldKeysChanged } from '../../utils/major-field-keys-changed'; import { Timing } from '../../utils/timing'; @@ -61,7 +61,7 @@ export class GraphService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly fieldService: FieldService, private readonly referenceService: ReferenceService, private readonly fieldSupplementService: FieldSupplementService, @@ -197,7 +197,8 @@ export class GraphService { field.id, [field.id], { [field.id]: field }, - { [field.id]: tableMap[tableId].dbTableName } + { [field.id]: tableMap[tableId].dbTableName }, + { [field.id]: tableId } ); const estimateTime = field.isComputed ? this.getEstimateTime(updateCellCount) : 200; return { @@ -423,7 +424,8 @@ export class GraphService { fieldId, topoFieldIds, fieldMap, - fieldId2DbTableName + fieldId2DbTableName, + fieldId2TableId ); const resetLinkFieldLookupFieldIds = @@ -462,7 +464,8 @@ export class GraphService { hostFieldId: string, fieldIds: string[], fieldMap: IFieldMap, - fieldId2DbTableName: Record + fieldId2DbTableName: Record, + fieldId2TableId: Record ): Promise { const queries = fieldIds .map((fieldId) => @@ -473,8 +476,17 @@ export class GraphService { let total = 0; for (const { fieldId, fieldName, query } of queries) { try { - const [{ count }] = - await this.dataPrismaService.$queryRawUnsafe<{ count: bigint }[]>(query); + const tableId = fieldId2TableId[fieldId]; + if (!tableId) { + this.logger.warn( + `Skip affected cell count for field=${fieldId} name="${fieldName}" because table id is missing` + ); + continue; + } + const [{ count }] = await this.databaseRouter.queryDataPrismaForTable<{ count: bigint }[]>( + tableId, + query + ); total += Number(count); } catch (error) { if (this.shouldSkipAffectedCountError(error)) { @@ -615,7 +627,8 @@ export class GraphService { fieldId, allFieldIds, fieldMap, - fieldId2DbTableName + fieldId2DbTableName, + fieldId2TableId ); return { diff --git a/apps/nestjs-backend/src/features/health/health.controller.test.ts b/apps/nestjs-backend/src/features/health/health.controller.test.ts index 0025b31b50..9d3d70e386 100644 --- a/apps/nestjs-backend/src/features/health/health.controller.test.ts +++ b/apps/nestjs-backend/src/features/health/health.controller.test.ts @@ -1,7 +1,6 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; import { HealthCheckService, PrismaHealthIndicator } from '@nestjs/terminus'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HealthController } from './health.controller'; @@ -15,7 +14,6 @@ describe('HealthController', () => { pingCheck: vi.fn(), }; const metaPrisma = {}; - const dataPrisma = {}; beforeEach(async () => { health.check.mockReset(); @@ -29,7 +27,6 @@ describe('HealthController', () => { { provide: HealthCheckService, useValue: health }, { provide: PrismaHealthIndicator, useValue: db }, { provide: PrismaService, useValue: metaPrisma }, - { provide: DataPrismaService, useValue: dataPrisma }, ], }).compile(); @@ -40,18 +37,16 @@ describe('HealthController', () => { expect(controller).toBeDefined(); }); - it('checks both meta and data databases', async () => { + it('checks the meta database without assuming a global data database', async () => { await controller.check(); expect(health.check).toHaveBeenCalledTimes(1); const indicators = health.check.mock.calls[0][0] as Array<() => Promise>; - expect(indicators).toHaveLength(2); + expect(indicators).toHaveLength(1); await indicators[0](); - await indicators[1](); expect(db.pingCheck).toHaveBeenNthCalledWith(1, 'metaDatabase', metaPrisma); - expect(db.pingCheck).toHaveBeenNthCalledWith(2, 'dataDatabase', dataPrisma); }); }); diff --git a/apps/nestjs-backend/src/features/health/health.controller.ts b/apps/nestjs-backend/src/features/health/health.controller.ts index 3063c86d33..5b23119ecc 100644 --- a/apps/nestjs-backend/src/features/health/health.controller.ts +++ b/apps/nestjs-backend/src/features/health/health.controller.ts @@ -1,6 +1,5 @@ import { Controller, Get, Logger } from '@nestjs/common'; import { HealthCheck, HealthCheckService, PrismaHealthIndicator } from '@nestjs/terminus'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import { Public } from '../auth/decorators/public.decorator'; @@ -11,18 +10,14 @@ export class HealthController { constructor( private readonly health: HealthCheckService, private readonly db: PrismaHealthIndicator, - private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService + private readonly prismaService: PrismaService ) {} @Get() @HealthCheck() check() { try { - return this.health.check([ - () => this.db.pingCheck('metaDatabase', this.prismaService), - () => this.db.pingCheck('dataDatabase', this.dataPrismaService), - ]); + return this.health.check([() => this.db.pingCheck('metaDatabase', this.prismaService)]); } catch (error) { this.logger.error(error); throw error; diff --git a/apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts b/apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts index c48712d70b..1b8bf1abf6 100644 --- a/apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts +++ b/apps/nestjs-backend/src/features/import/open-api/import-open-api-v2.service.ts @@ -120,10 +120,10 @@ export class ImportOpenApiV2Service { maxRowCount?: number, projection?: string[] ): Promise<{ totalImported: number }> { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const { attachmentUrl, fileType, insertConfig } = importOptions; const { sourceColumnMap, sourceWorkSheetKey, excludeFirstRow } = insertConfig; diff --git a/apps/nestjs-backend/src/features/integrity/foreign-key.service.ts b/apps/nestjs-backend/src/features/integrity/foreign-key.service.ts index 8e4064a618..77687fdee5 100644 --- a/apps/nestjs-backend/src/features/integrity/foreign-key.service.ts +++ b/apps/nestjs-backend/src/features/integrity/foreign-key.service.ts @@ -1,10 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { FieldType, type ILinkFieldOptions } from '@teable/core'; import { Prisma, PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { IntegrityIssueType, type IIntegrityIssue } from '@teable/openapi'; import { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto'; @@ -14,7 +14,7 @@ export class ForeignKeyIntegrityService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, @InjectModel(DATA_KNEX) private readonly knex: Knex ) {} @@ -43,6 +43,7 @@ export class ForeignKeyIntegrityService { field, referencedTableName: selfTableName, isSelfReference: true, + routingTableId: tableId, }); issues.push(...selfIssues); } @@ -56,6 +57,7 @@ export class ForeignKeyIntegrityService { field, referencedTableName: foreignTableName, isSelfReference: false, + routingTableId: tableId, }); issues.push(...foreignIssues); } @@ -70,6 +72,7 @@ export class ForeignKeyIntegrityService { field, referencedTableName, isSelfReference, + routingTableId, }: { fkHostTableName: string; targetTableName: string; @@ -77,6 +80,7 @@ export class ForeignKeyIntegrityService { field: { id: string; name: string }; referencedTableName: string; isSelfReference: boolean; + routingTableId: string; }): Promise { const issues: IIntegrityIssue[] = []; @@ -88,9 +92,11 @@ export class ForeignKeyIntegrityService { .toQuery(); try { - const invalidRefs = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ count: bigint }[]>(invalidQuery); + const invalidRefs = await this.databaseRouter.queryDataPrismaForTable<{ count: bigint }[]>( + routingTableId, + invalidQuery, + { useTransaction: true } + ); const refCount = Number(invalidRefs[0]?.count || 0); if (refCount > 0) { @@ -140,6 +146,7 @@ export class ForeignKeyIntegrityService { fkHostTableName, targetTableName: table.dbTableName, keyName: selfKeyName, + routingTableId: tableId, }); totalFixed += selfDeleted; } @@ -150,6 +157,7 @@ export class ForeignKeyIntegrityService { fkHostTableName, targetTableName: foreignTable.dbTableName, keyName: foreignKeyName, + routingTableId: tableId, }); totalFixed += foreignDeleted; } @@ -167,10 +175,12 @@ export class ForeignKeyIntegrityService { fkHostTableName, targetTableName, keyName, + routingTableId, }: { fkHostTableName: string; targetTableName: string; keyName: string; + routingTableId: string; }) { if (!fkHostTableName.split('.')[1].startsWith('junction_')) { throw new Error(`fkHostTableName: ${fkHostTableName} is not a junction table`); @@ -185,6 +195,8 @@ export class ForeignKeyIntegrityService { ) .delete() .toQuery(); - return await this.dataPrismaService.txClient().$executeRawUnsafe(deleteQuery); + return await this.databaseRouter.executeDataPrismaForTable(routingTableId, deleteQuery, { + useTransaction: true, + }); } } diff --git a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts index dd5225bc9b..bd04c3082e 100644 --- a/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts +++ b/apps/nestjs-backend/src/features/integrity/integrity-v2.service.ts @@ -226,9 +226,9 @@ export class IntegrityV2Service { throw new HttpException(parsedTableId.error.message, HttpStatus.BAD_REQUEST); } - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const tableRepository = container.resolve(v2CoreTokens.tableRepository); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const tableResult = await tableRepository.findOne( context, TableByIdSpec.create(parsedTableId.value) @@ -270,10 +270,10 @@ export class IntegrityV2Service { throw new HttpException(parsedBaseId.error.message, HttpStatus.BAD_REQUEST); } - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForBase(baseId); const tableRepository = container.resolve(v2CoreTokens.tableRepository); const baseRepository = container.resolve(v2CoreTokens.baseRepository); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const baseResult = await baseRepository.findOne(context, parsedBaseId.value); if (baseResult.isErr()) { diff --git a/apps/nestjs-backend/src/features/integrity/link-field.service.ts b/apps/nestjs-backend/src/features/integrity/link-field.service.ts index 9fab615a99..b7875178a6 100644 --- a/apps/nestjs-backend/src/features/integrity/link-field.service.ts +++ b/apps/nestjs-backend/src/features/integrity/link-field.service.ts @@ -1,10 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { FieldType, type ILinkFieldOptions } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { Prisma, PrismaService } from '@teable/db-main-prisma'; import { IntegrityIssueType, type IIntegrityIssue } from '@teable/openapi'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; +import { DatabaseRouter } from '../../global/database-router.service'; import { createFieldInstanceByRaw } from '../field/model/factory'; import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto'; @@ -14,7 +14,7 @@ export class LinkFieldIntegrityService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, @InjectDbProvider() private readonly dbProvider: IDbProvider ) {} @@ -31,6 +31,7 @@ export class LinkFieldIntegrityService { foreignKeyName, linkDbFieldName: field.dbFieldName, isMultiValue: Boolean(field.isMultipleCellValue), + routingTableId: tableId, }); if (inconsistentRecords.length > 0) { @@ -53,13 +54,15 @@ export class LinkFieldIntegrityService { foreignKeyName: string; linkDbFieldName: string; isMultiValue: boolean; + routingTableId: string; }) { + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForTable(params.routingTableId); // Some symmetric link fields may not persist a JSON column (depending on // creation path). If the link JSON column does not exist, skip comparison. const linkColumnExists = await this.dbProvider.checkColumnExist( params.dbTableName, params.linkDbFieldName, - this.dataPrismaService + dataPrisma ); if (!linkColumnExists) { @@ -68,7 +71,7 @@ export class LinkFieldIntegrityService { const query = this.dbProvider.integrityQuery().checkLinks(params); try { - return await this.dataPrismaService.$queryRawUnsafe<{ id: string }[]>(query); + return await dataPrisma.$queryRawUnsafe<{ id: string }[]>(query); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2010') { this.logger.warn( @@ -90,12 +93,14 @@ export class LinkFieldIntegrityService { foreignKeyName: string; linkDbFieldName: string; isMultiValue: boolean; + routingTableId: string; }) { + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForTable(params.routingTableId); // If display column does not exist (link fields are virtual by design), skip update const linkColumnExists = await this.dbProvider.checkColumnExist( params.dbTableName, params.linkDbFieldName, - this.dataPrismaService + dataPrisma ); if (!linkColumnExists) { @@ -103,7 +108,7 @@ export class LinkFieldIntegrityService { } const query = this.dbProvider.integrityQuery().fixLinks(params); - return await this.dataPrismaService.$executeRawUnsafe(query); + return await dataPrisma.$executeRawUnsafe(query); } private async checkAndFix(params: { @@ -115,6 +120,7 @@ export class LinkFieldIntegrityService { linkDbFieldName: string; isMultiValue: boolean; selfKeyName: string; + routingTableId: string; }) { try { const inconsistentRecords = await this.checkLinks(params); @@ -174,6 +180,7 @@ export class LinkFieldIntegrityService { linkDbFieldName: linkField.dbFieldName, isMultiValue: Boolean(linkField.isMultipleCellValue), selfKeyName, + routingTableId: tableId, }); totalFixed += linksFixed; diff --git a/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts b/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts index 8e2f5246d0..858d53754c 100644 --- a/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts +++ b/apps/nestjs-backend/src/features/integrity/link-integrity.service.ts @@ -19,12 +19,12 @@ import type { } from '@teable/core'; import type { Field } from '@teable/db-main-prisma'; import { Prisma, PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { IntegrityIssueType, type IIntegrityCheckVo, type IIntegrityIssue } from '@teable/openapi'; import { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import { LinkFieldQueryService } from '../field/field-calculate/link-field-query.service'; import { FieldService } from '../field/field.service'; @@ -42,7 +42,7 @@ export class LinkIntegrityService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly foreignKeyIntegrityService: ForeignKeyIntegrityService, private readonly linkFieldIntegrityService: LinkFieldIntegrityService, private readonly uniqueIndexService: UniqueIndexService, @@ -348,9 +348,10 @@ export class LinkIntegrityService { let canCheckLinks = false; const tableExistsSql = this.dbProvider.checkTableExist(options.fkHostTableName); - const tableExists = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ exists: boolean }[]>(tableExistsSql); + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForTable(table.id, { + useTransaction: true, + }); + const tableExists = await dataPrisma.$queryRawUnsafe<{ exists: boolean }[]>(tableExistsSql); const hostTableExists = tableExists[0].exists; if (!hostTableExists) { @@ -363,13 +364,13 @@ export class LinkIntegrityService { const selfKeyExists = await this.dbProvider.checkColumnExist( options.fkHostTableName, options.selfKeyName, - this.dataPrismaService.txClient() + dataPrisma ); const foreignKeyExists = await this.dbProvider.checkColumnExist( options.fkHostTableName, options.foreignKeyName, - this.dataPrismaService.txClient() + dataPrisma ); if (!selfKeyExists) { @@ -434,7 +435,9 @@ export class LinkIntegrityService { async checkEmptyString(tableId: string): Promise { const prisma = this.prismaService.txClient(); - const dataPrisma = this.dataPrismaService.txClient(); + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForTable(tableId, { + useTransaction: true, + }); const fields = await prisma.field.findMany({ where: { tableId, @@ -481,7 +484,6 @@ export class LinkIntegrityService { issueType?: IntegrityIssueType ): Promise { const prisma = this.prismaService.txClient(); - const dataPrisma = this.dataPrismaService.txClient(); const fieldRaw = await prisma.field.findFirst({ where: { id: fieldId, type: FieldType.Link, isLookup: null, deletedTime: null }, }); @@ -491,6 +493,9 @@ export class LinkIntegrityService { } const linkField = createFieldInstanceByRaw(fieldRaw) as LinkFieldDto; + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForTable(fieldRaw.tableId, { + useTransaction: true, + }); const options = linkField.options; const tableMeta = await prisma.tableMeta.findFirst({ where: { id: fieldRaw.tableId, deletedTime: null }, @@ -661,6 +666,7 @@ export class LinkIntegrityService { } await this.backfillForeignKeysFromLinkColumn({ + routingTableId: fieldRaw.tableId, dbTableName: tableMeta.dbTableName, linkDbFieldName: linkField.dbFieldName, fkHostTableName: options.fkHostTableName, @@ -685,6 +691,7 @@ export class LinkIntegrityService { foreignKeyName: string; relationship: Relationship; isOneWay?: boolean; + routingTableId: string; }) { const { dbTableName, @@ -694,8 +701,11 @@ export class LinkIntegrityService { foreignKeyName, relationship, isOneWay, + routingTableId, } = params; - const dataPrisma = this.dataPrismaService.txClient(); + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForTable(routingTableId, { + useTransaction: true, + }); const linkColumnExists = await this.dbProvider.checkColumnExist( dbTableName, @@ -1224,10 +1234,12 @@ export class LinkIntegrityService { async fixEmptyString(fieldId: string, tableId?: string): Promise { const prisma = this.prismaService.txClient(); - const dataPrisma = this.dataPrismaService.txClient(); if (!tableId) { return; } + const dataPrisma = await this.databaseRouter.dataPrismaExecutorForTable(tableId, { + useTransaction: true, + }); const { dbTableName } = await prisma.tableMeta.findFirstOrThrow({ where: { id: tableId, deletedTime: null }, diff --git a/apps/nestjs-backend/src/features/integrity/unique-index.service.ts b/apps/nestjs-backend/src/features/integrity/unique-index.service.ts index ebbcc5366c..8a8f3df2b5 100644 --- a/apps/nestjs-backend/src/features/integrity/unique-index.service.ts +++ b/apps/nestjs-backend/src/features/integrity/unique-index.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; import { IdPrefix } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { IntegrityIssueType, type IIntegrityIssue } from '@teable/openapi'; import { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import { FieldService } from '../field/field.service'; @@ -12,7 +12,7 @@ import { FieldService } from '../field/field.service'; export class UniqueIndexService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, @InjectModel(DATA_KNEX) private readonly knex: Knex, private readonly fieldService: FieldService ) {} @@ -26,7 +26,8 @@ export class UniqueIndexService { const colId = '__id'; const idUniqueIndexExists = - (await this.fieldService.findUniqueIndexesForField(table.dbTableName, colId)).length > 0; + (await this.fieldService.findUniqueIndexesForField(table.id, table.dbTableName, colId)) + .length > 0; if (!idUniqueIndexExists) { issues.push({ @@ -43,6 +44,7 @@ export class UniqueIndexService { for (const field of uniqueFields) { const indexNames = await this.fieldService.findUniqueIndexesForField( + table.id, table.dbTableName, field.dbFieldName ); @@ -98,7 +100,7 @@ export class UniqueIndexService { if (!sql) { return; } - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql, { useTransaction: true }); return { type: IntegrityIssueType.UniqueIndexNotFound, diff --git a/apps/nestjs-backend/src/features/notification/notification.service.ts b/apps/nestjs-backend/src/features/notification/notification.service.ts index 11f30c8314..715beec867 100644 --- a/apps/nestjs-backend/src/features/notification/notification.service.ts +++ b/apps/nestjs-backend/src/features/notification/notification.service.ts @@ -36,6 +36,11 @@ type INotifyEmailConfig = { buttonText?: string | ILocalization; }; +function toArray(value?: T | T[]): T[] { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + const notificationListLimit = 10; const notificationListSelect = { @@ -63,6 +68,7 @@ export class NotificationService { [NotificationTypeEnum.CollaboratorMultiRowTag]: MailType.CollaboratorMultiRowTag, [NotificationTypeEnum.Comment]: MailType.Common, [NotificationTypeEnum.ExportBase]: MailType.ExportBase, + [NotificationTypeEnum.AdminNotice]: MailType.System, }; constructor( private readonly prismaService: PrismaService, @@ -304,91 +310,118 @@ export class NotificationService { async sendCommonNotify( params: { - path: string; + path?: string; fromUserId?: string; - toUserId: string; + toUserId?: string | string[]; + toEmail?: string | string[]; message: string | ILocalization; severity?: NotificationSeverityEnum; emailConfig?: INotifyEmailConfig; }, type = NotificationTypeEnum.System - ) { - const { toUserId, emailConfig, path, fromUserId = SYSTEM_USER_ID } = params; - const notifyId = generateNotificationId(); - const toUser = await this.userService.getUserById(toUserId); - if (!toUser) { - return; + ): Promise<{ + sentCount: number; + invalidUserIds?: string[]; + invalidEmails?: string[]; + }> { + const { emailConfig, path = '', fromUserId = SYSTEM_USER_ID } = params; + const ids = toArray(params.toUserId); + const emails = toArray(params.toEmail); + + const toUsers = await this.userService.getUsersByIdsOrEmails({ ids, emails }); + + const invalidUserIds = ids.length + ? ids.filter((id) => !toUsers.some((u) => u.id === id)) + : undefined; + const invalidEmails = emails.length + ? emails.filter((e) => !toUsers.some((u) => u.email.toLowerCase() === e.toLowerCase())) + : undefined; + + if (toUsers.length === 0) { + return { sentCount: 0, invalidUserIds, invalidEmails }; } const severity = params.severity ?? this.getNotificationSeverity(type); const messageI18n = this.getMessageI18n(params.message); - const data: Prisma.NotificationCreateInput = { - id: notifyId, - fromUserId: fromUserId, - toUserId, - type, - urlPath: path, - createdBy: fromUserId, - message: this.getMessage(params.message, 'en'), - messageI18n, - severity, - }; - const notifyData = await this.createNotify(data); - - const unreadCount = (await this.unreadCount(toUser.id)).unreadCount; + const messageEn = this.getMessage(params.message, 'en'); const rawUsers = await this.prismaService.user.findMany({ select: { id: true, name: true, avatar: true }, where: { id: fromUserId }, }); const fromUserSets = keyBy(rawUsers, 'id'); + const notifyIcon = this.generateNotifyIcon(type, fromUserId, fromUserSets); - const systemNotifyIcon = this.generateNotifyIcon( - notifyData.type as NotificationTypeEnum, + const createdTime = new Date(); + const notifyRecords = toUsers.map((toUser) => ({ + id: generateNotificationId(), fromUserId, - fromUserSets - ); - - const socketNotification = { - notification: { - id: notifyData.id, - message: notifyData.message, - messageI18n: notifyData.messageI18n, - notifyType: type, - url: path, - notifyIcon: systemNotifyIcon, - severity, - isRead: false, - createdTime: notifyData.createdTime.toISOString(), - }, - unreadCount: unreadCount, - }; - - this.sendNotifyBySocket(toUser.id, socketNotification); + toUserId: toUser.id, + type, + urlPath: path, + createdBy: fromUserId, + message: messageEn, + messageI18n, + severity, + createdTime, + })); - if (emailConfig && toUser.notifyMeta && toUser.notifyMeta.email) { - const lang = this.getUserLang(toUser.lang); - const emailOptions = await this.mailSenderService.commonEmailOptions({ - ...emailConfig, - title: this.getMessage(emailConfig.title, lang), - message: this.getMessage(emailConfig.message, lang), - to: toUserId, - buttonUrl: emailConfig.buttonUrl || this.mailConfig.origin + path, - buttonText: emailConfig.buttonText - ? this.getMessage(emailConfig.buttonText, lang) - : this.i18n.t('common.email.templates.notify.buttonText'), - }); - this.mailSenderService.sendMail( - { - to: toUser.email, - ...emailOptions, + const toUserIdList = toUsers.map((u) => u.id); + const unreadCounts = await this.prismaService.notification.groupBy({ + by: ['toUserId'], + where: { toUserId: { in: toUserIdList }, isRead: false }, + _count: { _all: true }, + }); + const unreadCountMap = new Map(unreadCounts.map((r) => [r.toUserId, r._count._all])); + + await this.prismaService.notification.createMany({ data: notifyRecords }); + + const notifyById = keyBy(notifyRecords, 'toUserId'); + for (const toUser of toUsers) { + const record = notifyById[toUser.id]; + const unreadCount = (unreadCountMap.get(toUser.id) ?? 0) + 1; + + this.sendNotifyBySocket(toUser.id, { + notification: { + id: record.id, + message: messageEn, + messageI18n, + notifyType: type, + url: path, + notifyIcon: notifyIcon, + severity, + isRead: false, + createdTime: createdTime.toISOString(), }, - { - type: this.mailTypeMap[type], - transporterName: MailTransporterType.Notify, - } - ); + unreadCount, + }); + + if (emailConfig && toUser.notifyMeta && toUser.notifyMeta.email) { + const lang = this.getUserLang(toUser.lang); + const emailOptions = await this.mailSenderService.commonEmailOptions({ + ...emailConfig, + title: this.getMessage(emailConfig.title, lang), + message: this.getMessage(emailConfig.message, lang), + to: toUser.id, + buttonUrl: emailConfig.buttonUrl || this.mailConfig.origin + path, + buttonText: emailConfig.buttonText + ? this.getMessage(emailConfig.buttonText, lang) + : this.i18n.t('common.email.templates.notify.buttonText'), + }); + this.mailSenderService.sendMail( + { + to: toUser.email, + ...emailOptions, + }, + { + type: this.mailTypeMap[type], + transporterName: MailTransporterType.Notify, + } + ); + } } + + return { sentCount: toUsers.length, invalidUserIds, invalidEmails }; } async sendImportResultNotify(params: { @@ -574,7 +607,7 @@ export class NotificationService { id: v.id, notifyIcon: notifyIcon, notifyType: v.type as NotificationTypeEnum, - url: this.mailConfig.origin + v.urlPath, + url: v.urlPath ? this.mailConfig.origin + v.urlPath : '', message: v.message, messageI18n: v.messageI18n, severity: this.getNotificationSeverity(v.type as NotificationTypeEnum, v.severity), @@ -594,6 +627,7 @@ export class NotificationService { switch (notifyType) { case NotificationTypeEnum.System: case NotificationTypeEnum.ExportBase: + case NotificationTypeEnum.AdminNotice: return { iconUrl: `${origin}/images/favicon/favicon.svg` }; case NotificationTypeEnum.Comment: case NotificationTypeEnum.CollaboratorCellTag: @@ -628,6 +662,7 @@ export class NotificationService { case NotificationTypeEnum.CollaboratorMultiRowTag: case NotificationTypeEnum.ExportBase: case NotificationTypeEnum.System: + case NotificationTypeEnum.AdminNotice: return NotificationSeverityEnum.Info; default: throw assertNever(notifyType); @@ -655,6 +690,8 @@ export class NotificationService { const { downloadUrl } = urlMeta || {}; return downloadUrl as string; } + case NotificationTypeEnum.AdminNotice: + return ''; default: throw assertNever(notifyType); } diff --git a/apps/nestjs-backend/src/features/record/computed/services/computed-dependency-collector.service.ts b/apps/nestjs-backend/src/features/record/computed/services/computed-dependency-collector.service.ts index 66754071fe..172a0c4f3a 100644 --- a/apps/nestjs-backend/src/features/record/computed/services/computed-dependency-collector.service.ts +++ b/apps/nestjs-backend/src/features/record/computed/services/computed-dependency-collector.service.ts @@ -16,11 +16,11 @@ import type { } from '@teable/core'; import { DbFieldType, DriverClient, FieldType, isFieldReferenceValue } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; import { InjectDbProvider } from '../../../../db-provider/db.provider'; import { IDbProvider } from '../../../../db-provider/db.provider.interface'; +import { DatabaseRouter } from '../../../../global/database-router.service'; import { CUSTOM_KNEX, DATA_KNEX } from '../../../../global/knex/knex.module'; import { Timing } from '../../../../utils/timing'; import type { ICellContext } from '../../../calculation/utils/changes'; @@ -76,7 +76,7 @@ export class ComputedDependencyCollectorService { private logger = new Logger(ComputedDependencyCollectorService.name); constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly tableDomainQueryService: TableDomainQueryService, @InjectModel(CUSTOM_KNEX) private readonly knex: Knex, @InjectModel(DATA_KNEX) private readonly dataKnex: Knex, @@ -155,9 +155,11 @@ export class ComputedDependencyCollectorService { const qb = (schema ? this.dataKnex.withSchema(schema) : this.dataKnex) .select('__id') .from(table); - const rows = await this.dataPrismaService - .txClient() - .$queryRawUnsafe>(qb.toQuery()); + const rows = await this.databaseRouter.queryDataPrismaForTable>( + tableId, + qb.toQuery(), + { useTransaction: true } + ); return rows.map((r) => r.__id).filter(Boolean); } @@ -870,9 +872,9 @@ export class ComputedDependencyCollectorService { const sql = queryBuilder.toQuery(); this.logger.debug(`Conditional Rollup Impacted Records SQL: ${sql}`); - const rows = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ id?: string; __id?: string }[]>(sql); + const rows = await this.databaseRouter.queryDataPrismaForTable< + { id?: string; __id?: string }[] + >(edge.tableId, sql, { useTransaction: true }); const ids = new Set(); for (const row of rows) { @@ -915,9 +917,11 @@ export class ComputedDependencyCollectorService { `${baseIdAlias}.__id` ); - const baseRows = await this.dataPrismaService - .txClient() - .$queryRawUnsafe[]>(baseRowsQuery.toQuery()); + const baseRows = await this.databaseRouter.queryDataPrismaForTable[]>( + edge.foreignTableId, + baseRowsQuery.toQuery(), + { useTransaction: true } + ); const baseRowById = new Map>(); for (const row of baseRows) { const id = row['__id']; @@ -1043,9 +1047,9 @@ export class ComputedDependencyCollectorService { const postQuery = postQueryBuilder.toQuery(); this.logger.debug('postQuery %s', postQuery); - const postRows = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ id?: string; __id?: string }[]>(postQuery); + const postRows = await this.databaseRouter.queryDataPrismaForTable< + { id?: string; __id?: string }[] + >(edge.tableId, postQuery, { useTransaction: true }); for (const row of postRows) { const id = row.id || row.__id; diff --git a/apps/nestjs-backend/src/features/record/computed/services/computed-orchestrator.service.ts b/apps/nestjs-backend/src/features/record/computed/services/computed-orchestrator.service.ts index ab50385c0d..8682f8edbc 100644 --- a/apps/nestjs-backend/src/features/record/computed/services/computed-orchestrator.service.ts +++ b/apps/nestjs-backend/src/features/record/computed/services/computed-orchestrator.service.ts @@ -2,11 +2,11 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { FieldType } from '@teable/core'; import type { TableDomain, LastModifiedByFieldCore, LastModifiedTimeFieldCore } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import { ClsService } from 'nestjs-cls'; import { InjectDbProvider } from '../../../../db-provider/db.provider'; import { IDbProvider } from '../../../../db-provider/db.provider.interface'; +import { DatabaseRouter } from '../../../../global/database-router.service'; import type { IClsStore } from '../../../../types/cls'; import { Timing } from '../../../../utils/timing'; import type { ICellContext } from '../../../calculation/utils/changes'; @@ -25,7 +25,7 @@ export class ComputedOrchestratorService { private readonly collector: ComputedDependencyCollectorService, private readonly evaluator: ComputedEvaluatorService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly tableDomainQueryService: TableDomainQueryService, private readonly cls: ClsService, @InjectDbProvider() private readonly dbProvider: IDbProvider @@ -441,7 +441,9 @@ export class ComputedOrchestratorService { recordIds: target.recordIds, }); if (sql) { - await this.dataPrismaService.txClient().$queryRawUnsafe(sql); + await this.databaseRouter.queryDataPrismaForTable(target.tableId, sql, { + useTransaction: true, + }); } } } diff --git a/apps/nestjs-backend/src/features/record/computed/services/link-cascade-resolver.ts b/apps/nestjs-backend/src/features/record/computed/services/link-cascade-resolver.ts index 84c19663bf..8c3fa0bbb2 100644 --- a/apps/nestjs-backend/src/features/record/computed/services/link-cascade-resolver.ts +++ b/apps/nestjs-backend/src/features/record/computed/services/link-cascade-resolver.ts @@ -1,8 +1,8 @@ /* eslint-disable sonarjs/cognitive-complexity */ /* eslint-disable @typescript-eslint/naming-convention */ import { Injectable } from '@nestjs/common'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { chunk } from 'lodash'; +import { DatabaseRouter } from '../../../../global/database-router.service'; import { Timing } from '../../../../utils/timing'; export interface ILinkEdge { @@ -36,7 +36,7 @@ const IN_CHUNK = 500; @Injectable() export class LinkCascadeResolver { - constructor(private readonly dataPrismaService: DataPrismaService) {} + constructor(private readonly databaseRouter: DatabaseRouter) {} /** * Iterative BFS over link edges using only frontier ids; avoids full edge table scans and keeps @@ -186,9 +186,12 @@ from ${fkTableRef} where ${srcCol} in (${placeholders}) and ${srcCol} is not null and ${dstCol} is not null`; - return await this.dataPrismaService - .txClient() - .$queryRawUnsafe>(sql, ...srcIds); + return await this.databaseRouter.queryDataPrismaForTable>( + edge.foreignTableId, + sql, + { useTransaction: true }, + ...srcIds + ); } private async fetchEdgeTargetsBatched( @@ -211,7 +214,11 @@ where ${srcCol} in (${placeholders}) from ${fkTableRef} where ${srcCol} is not null and ${dstCol} is not null`; - return this.dataPrismaService.txClient().$queryRawUnsafe>(sql); + return this.databaseRouter.queryDataPrismaForTable>( + edge.foreignTableId, + sql, + { useTransaction: true } + ); } private quoteIdentifier(identifier: string): string { diff --git a/apps/nestjs-backend/src/features/record/computed/services/record-computed-update.service.ts b/apps/nestjs-backend/src/features/record/computed/services/record-computed-update.service.ts index 7cee267569..b87500440c 100644 --- a/apps/nestjs-backend/src/features/record/computed/services/record-computed-update.service.ts +++ b/apps/nestjs-backend/src/features/record/computed/services/record-computed-update.service.ts @@ -2,12 +2,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { FieldType } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import { Knex } from 'knex'; import { match } from 'ts-pattern'; import { InjectDbProvider } from '../../../../db-provider/db.provider'; import { IDbProvider } from '../../../../db-provider/db.provider.interface'; +import { DatabaseRouter } from '../../../../global/database-router.service'; import { retryOnDeadlock } from '../../../../utils/retry-decorator'; import { Timing } from '../../../../utils/timing'; import { AUTO_NUMBER_FIELD_NAME } from '../../../field/constant'; @@ -20,7 +20,7 @@ export class RecordComputedUpdateService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, @InjectDbProvider() private readonly dbProvider: IDbProvider ) {} @@ -115,7 +115,7 @@ export class RecordComputedUpdateService { } @Timing() - private async lockRestrictRecords(dbTableName: string, recordIds?: string[]) { + private async lockRestrictRecords(tableId: string, dbTableName: string, recordIds?: string[]) { if (!recordIds?.length) { return; } @@ -130,7 +130,7 @@ export class RecordComputedUpdateService { if (!sql) { return; } - await this.dataPrismaService.txClient().$queryRawUnsafe(sql); + await this.databaseRouter.queryDataPrismaForTable(tableId, sql, { useTransaction: true }); } @retryOnDeadlock() @@ -159,7 +159,7 @@ export class RecordComputedUpdateService { // Acquire row-level locks in a deterministic order to avoid deadlocks when multiple // computed updates touch the same set of records concurrently. - await this.lockRestrictRecords(dbTableName, restrictRecordIds); + await this.lockRestrictRecords(tableId, dbTableName, restrictRecordIds); const sql = this.dbProvider.updateFromSelectSql({ dbTableName, @@ -171,9 +171,9 @@ export class RecordComputedUpdateService { }); this.logger.debug('updateFromSelect SQL:', sql); try { - return await this.dataPrismaService - .txClient() - .$queryRawUnsafe>>(sql); + return await this.databaseRouter.queryDataPrismaForTable< + Array<{ __id: string; __version: number } & Record> + >(tableId, sql, { useTransaction: true }); } catch (error) { this.handleRawQueryError(error, sql, tableId, fields); } diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.spec.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.spec.ts index 3bc7978e4f..89db78a19c 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.spec.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.spec.ts @@ -30,8 +30,12 @@ describe('RecordOpenApiV2Service', () => { const resolve = vi.fn(); const getContainer = vi.fn(); const clsGet = vi.fn(); + const clsSet = vi.fn(); + const clsRunWith = vi.fn(); const cacheDel = vi.fn(); const cacheSetDetail = vi.fn(); + const getDataDatabaseForTable = vi.fn(); + const dataPrismaForTable = vi.fn(); let service: RecordOpenApiV2Service; @@ -138,6 +142,9 @@ describe('RecordOpenApiV2Service', () => { getContainer.mockResolvedValue({ resolve }); createContext.mockResolvedValue({}); clsGet.mockImplementation((key: string) => { + if (key == null) { + return {}; + } if (key === 'user.id') { return `usr${'h'.repeat(16)}`; } @@ -146,8 +153,14 @@ describe('RecordOpenApiV2Service', () => { } return undefined; }); + clsRunWith.mockImplementation((_store, fn: () => unknown) => fn()); getReadQuerySource.mockResolvedValue(undefined); getFieldsByQuery.mockResolvedValue([]); + getDataDatabaseForTable.mockResolvedValue({ + cacheKey: 'meta-fallback', + url: 'postgresql://meta', + isMetaFallback: true, + }); commandExecute.mockResolvedValue({ isErr: () => false, value: UpdateRecordsResult.create(2, []), @@ -169,15 +182,16 @@ describe('RecordOpenApiV2Service', () => { { data: { id: 'rec2222222222222222', fields: {} } }, ]); service = new RecordOpenApiV2Service( - { getContainer } as never, + { getContainerForTable: getContainer } as never, { createContext } as never, { getDocIdsByQuery, getSnapshotBulkWithPermission } as never, {} as never, - { get: clsGet } as never, + { get: clsGet, set: clsSet, runWith: clsRunWith } as never, { del: cacheDel, setDetail: cacheSetDetail } as never, { getFieldsByQuery } as never, { getReadQuerySource } as never, - {} as never + {} as never, + { getDataDatabaseForTable, dataPrismaForTable } as never ); }); @@ -266,6 +280,33 @@ describe('RecordOpenApiV2Service', () => { ]); }); + it('runs legacy snapshot compatibility reads against the table data client for BYODB tables', async () => { + const tableId = `tbl${'c'.repeat(16)}`; + const dataPrisma = { $queryRawUnsafe: vi.fn() }; + getDataDatabaseForTable.mockResolvedValue({ + cacheKey: 'ddc-byodb', + url: 'postgresql://byodb', + isMetaFallback: false, + }); + dataPrismaForTable.mockResolvedValue(dataPrisma); + + const result = await service.getRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + skip: 0, + take: 2, + }); + + expect(result.records).toEqual([ + { id: 'rec1111111111111111', fields: {} }, + { id: 'rec2222222222222222', fields: {} }, + ]); + expect(dataPrismaForTable).toHaveBeenCalledWith(tableId); + expect(clsRunWith).toHaveBeenCalled(); + expect(clsSet).toHaveBeenCalledWith('dataTx.client', dataPrisma); + expect(clsSet).toHaveBeenLastCalledWith('dataTx.client', undefined); + expect(getSnapshotBulkWithPermission).toHaveBeenCalledTimes(1); + }); + it('formats sorted top-level system datetime fields in the final OpenAPI response', async () => { execute.mockResolvedValue({ isErr: () => false, diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts index 360a5b5950..10e284553d 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api-v2.service.ts @@ -48,6 +48,7 @@ import { executeListTableRecordsEndpoint, } from '@teable/v2-contract-http-implementation/handlers'; import { v2CoreTokens } from '@teable/v2-core'; +import type { DependencyContainer } from '@teable/v2-di'; import { ClearStreamCommand, DeleteByRangeStreamCommand, @@ -73,6 +74,7 @@ import { ClsService } from 'nestjs-cls'; import { CacheService } from '../../../cache/cache.service'; import type { ICacheStore } from '../../../cache/types'; import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception'; +import { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import type { IClsStore } from '../../../types/cls'; import { AggregationService } from '../../aggregation/aggregation.service'; import { FieldService } from '../../field/field.service'; @@ -114,7 +116,8 @@ export class RecordOpenApiV2Service { private readonly cacheService: CacheService, private readonly fieldService: FieldService, private readonly recordPermissionService: RecordPermissionService, - private readonly aggregationService: AggregationService + private readonly aggregationService: AggregationService, + private readonly dataDbClientManager: DataDbClientManager ) {} private throwV2Error( @@ -218,7 +221,8 @@ export class RecordOpenApiV2Service { ); } - const context = await this.createV2ReadContext(tableId, query); + const container = await this.v2ContainerService.getContainerForTable(tableId); + const context = await this.createV2ReadContext(tableId, query, container); const enabledFieldIds = ( context as IExecutionContext & { recordReadQuerySource?: { enabledFieldIds?: string[] }; @@ -247,10 +251,9 @@ export class RecordOpenApiV2Service { })); const normalizedGroupBy = effectiveQuery.groupBy?.map((item) => item.fieldId); const queryExtra = this.shouldLoadQueryExtra(effectiveQuery) - ? await this.getQueryExtra(tableId, effectiveQuery) + ? await this.withTableDataClient(tableId, () => this.getQueryExtra(tableId, effectiveQuery)) : undefined; - const container = await this.v2ContainerService.getContainer(); const queryBus = container.resolve(v2CoreTokens.queryBus); const pageResult = await this.executeListRecordsEndpoint( { @@ -287,13 +290,15 @@ export class RecordOpenApiV2Service { } const recordIds = orderedRecords.map((record) => record.id); - const snapshots = await this.recordService.getSnapshotBulkWithPermission( - tableId, - recordIds, - snapshotProjection, - requestedFieldKeyType, - query.cellFormat, - true + const snapshots = await this.withTableDataClient(tableId, () => + this.recordService.getSnapshotBulkWithPermission( + tableId, + recordIds, + snapshotProjection, + requestedFieldKeyType, + query.cellFormat, + true + ) ); if (snapshots.length !== recordIds.length) { @@ -323,6 +328,27 @@ export class RecordOpenApiV2Service { : { records: normalizedRecords }; } + private async withTableDataClient(tableId: string, fn: () => Promise): Promise { + const resolvedDataDb = await this.dataDbClientManager.getDataDatabaseForTable(tableId); + if (resolvedDataDb.isMetaFallback) { + return fn(); + } + + const dataPrisma = await this.dataDbClientManager.dataPrismaForTable(tableId); + const cls = this.cls as unknown as ClsService<{ dataTx: { client?: unknown } }>; + const store = cls.get(); + const previousClient = cls.get('dataTx.client'); + + return cls.runWith(store, async () => { + cls.set('dataTx.client', dataPrisma); + try { + return await fn(); + } finally { + cls.set('dataTx.client', previousClient); + } + }); + } + private async formatSystemDatetimeFields( tableId: string, records: IRecord[], @@ -496,9 +522,10 @@ export class RecordOpenApiV2Service { private async createV2ReadContext( tableId: string, - query: Pick + query: Pick, + container: DependencyContainer ): Promise { - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const readSource = await this.recordPermissionService.getReadQuerySource(tableId, { viewId: query.viewId, keepPrimaryKey: Boolean(query.filterLinkCellSelected), @@ -576,9 +603,9 @@ export class RecordOpenApiV2Service { const fields = updateRecordRo.record.fields ?? {}; const hasFields = Object.keys(fields).length > 0; - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); if (hasFields || (hasOrder && order)) { // Convert v1 input format to v2 format @@ -645,9 +672,9 @@ export class RecordOpenApiV2Service { 'record.update.request.typecast': updateRecordsRo.typecast ?? false, }); - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const updateResult = await executeUpdateRecordsEndpoint( context, { @@ -684,9 +711,9 @@ export class RecordOpenApiV2Service { createRecordsRo: ICreateRecordsRo, _isAiInternal?: string ): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); // Preserve v1's default typecast behavior (false) to ensure proper validation const records = createRecordsRo.records; @@ -718,9 +745,9 @@ export class RecordOpenApiV2Service { } async formSubmit(tableId: string, formSubmitRo: IFormSubmitRo): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const result = await executeSubmitRecordEndpoint( context, @@ -755,9 +782,9 @@ export class RecordOpenApiV2Service { allowRecordExpansion?: boolean; } ): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); ( context as IExecutionContext & { [V2_RECORD_PASTE_AUDIT_CONTEXT_KEY]?: boolean; @@ -829,9 +856,9 @@ export class RecordOpenApiV2Service { allowRecordExpansion?: boolean; } ): Promise> { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); ( context as IExecutionContext & { [V2_RECORD_PASTE_AUDIT_CONTEXT_KEY]?: boolean; @@ -1097,9 +1124,9 @@ export class RecordOpenApiV2Service { } async clear(tableId: string, rangesRo: IRangesRo): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const rangeQuery = await this.normalizeRangeQuery(tableId, rangesRo); const normalizedFilter = await this.normalizeFilterForV2(tableId, rangeQuery.filter); @@ -1140,9 +1167,9 @@ export class RecordOpenApiV2Service { tableId: string, rangesRo: IRangesRo ): Promise> { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const rangeQuery = await this.normalizeRangeQuery(tableId, rangesRo); const normalizedFilter = await this.normalizeFilterForV2(tableId, rangeQuery.filter); @@ -1268,9 +1295,9 @@ export class RecordOpenApiV2Service { rangesRo: IRangesRo, _windowId?: string ): Promise<{ ids: string[] }> { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const rangeQuery = await this.normalizeRangeQuery(tableId, rangesRo); const sortWithGroupFallback = this.mergeGroupByIntoSort(rangeQuery.groupBy, rangeQuery.orderBy); @@ -1315,9 +1342,9 @@ export class RecordOpenApiV2Service { tableId: string, rangesRo: IRangesRo ): Promise> { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const rangeQuery = await this.normalizeRangeQuery(tableId, rangesRo); const sortWithGroupFallback = this.mergeGroupByIntoSort(rangeQuery.groupBy, rangeQuery.orderBy); @@ -1364,9 +1391,9 @@ export class RecordOpenApiV2Service { tableId: string, rangesRo: IRangesRo ): Promise> { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const rangeQuery = await this.normalizeRangeQuery(tableId, rangesRo); const sortWithGroupFallback = this.mergeGroupByIntoSort(rangeQuery.groupBy, rangeQuery.orderBy); @@ -1414,19 +1441,27 @@ export class RecordOpenApiV2Service { recordIds: string[], _windowId?: string ): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const queryBus = container.resolve(v2CoreTokens.queryBus); + const context = await this.v2ContextFactory.createContext(container); - // Query records before deletion to return them in V1 format - const recordSnapshots = await this.recordService.getSnapshotBulkWithPermission( - tableId, - recordIds, - undefined, - FieldKeyType.Id, - undefined, - true - ); + const recordsBeforeDelete: IRecord[] = []; + for (let index = 0; index < recordIds.length; index += 1000) { + const selectedRecordIds = recordIds.slice(index, index + 1000); + const page = await this.executeListRecordsEndpoint( + { + tableId, + fieldKeyType: FieldKeyType.Id, + selectedRecordIds, + limit: selectedRecordIds.length, + ignoreViewQuery: true, + }, + context, + queryBus + ); + recordsBeforeDelete.push(...(page.records as IRecord[])); + } const v2Input = { tableId, @@ -1440,7 +1475,7 @@ export class RecordOpenApiV2Service { // Return records that were deleted (V1 format) return { - records: recordSnapshots.map((snapshot) => snapshot.data as IRecord), + records: recordsBeforeDelete, }; } @@ -1961,9 +1996,9 @@ export class RecordOpenApiV2Service { recordId: string, order?: IRecordInsertOrderRo ): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const result = await executeDuplicateRecordEndpoint( context, diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.spec.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.spec.ts index 1e5375e466..41a934ce7d 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.spec.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.spec.ts @@ -5,10 +5,12 @@ const createService = ({ prismaService = {}, dataPrismaService = {}, recordService = {}, + dataDbClientManager, }: { prismaService?: unknown; dataPrismaService?: unknown; recordService?: unknown; + dataDbClientManager?: unknown; } = {}) => new RecordOpenApiService( prismaService as never, @@ -21,7 +23,10 @@ const createService = ({ {} as never, {} as never, {} as never, - {} as never + {} as never, + (dataDbClientManager ?? { + dataPrismaForTable: vi.fn().mockResolvedValue(dataPrismaService), + }) as never ); describe('RecordOpenApiService', () => { @@ -50,6 +55,9 @@ describe('RecordOpenApiService', () => { avatar: null, }, ]); + const dataPrismaForTable = vi.fn().mockResolvedValue({ + recordHistory: { findMany: dataRecordHistoryFindMany }, + }); const service = createService({ prismaService: { @@ -59,6 +67,9 @@ describe('RecordOpenApiService', () => { dataPrismaService: { recordHistory: { findMany: dataRecordHistoryFindMany }, }, + dataDbClientManager: { + dataPrismaForTable, + }, }); const result = await service.getRecordHistory( @@ -82,6 +93,7 @@ describe('RecordOpenApiService', () => { orderBy: { createdTime: 'desc' }, }) ); + expect(dataPrismaForTable).toHaveBeenCalledWith('tbl1'); expect(metaRecordHistoryFindMany).not.toHaveBeenCalled(); expect(userFindMany).toHaveBeenCalledWith({ where: { id: { in: ['usr1'] } }, diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts index d49fc3c3f8..242f060766 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts @@ -8,7 +8,6 @@ import type { IMakeOptional, } from '@teable/core'; import { FieldKeyType, FieldType, HttpErrorCode, ViewType } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import { CreateRecordAction, @@ -32,6 +31,7 @@ import { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.co import { CustomHttpException } from '../../../custom.exception'; import { EventEmitterService } from '../../../event-emitter/event-emitter.service'; import { Events } from '../../../event-emitter/events'; +import { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import type { IClsStore } from '../../../types/cls'; import { retryOnDeadlock } from '../../../utils/retry-decorator'; import { AttachmentsService } from '../../attachments/attachments.service'; @@ -49,7 +49,6 @@ import type { IUpdateRecordsInternalRo } from '../type'; export class RecordOpenApiService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, private readonly recordService: RecordService, private readonly attachmentsService: AttachmentsService, private readonly recordModifyService: RecordModifyService, @@ -58,7 +57,8 @@ export class RecordOpenApiService { private readonly tableDomainQueryService: TableDomainQueryService, private readonly fieldService: FieldService, private readonly cls: ClsService, - private readonly eventEmitterService: EventEmitterService + private readonly eventEmitterService: EventEmitterService, + private readonly dataDbClientManager: DataDbClientManager ) {} @retryOnDeadlock() @@ -231,7 +231,8 @@ export class RecordOpenApiService { dateFilter['lte'] = new Date(endDate); } - const list = await this.dataPrismaService.recordHistory.findMany({ + const dataPrisma = await this.dataDbClientManager.dataPrismaForTable(tableId); + const list = await dataPrisma.recordHistory.findMany({ where: { tableId, ...(recordId ? { recordId } : {}), diff --git a/apps/nestjs-backend/src/features/record/record-modify/record-create.service.ts b/apps/nestjs-backend/src/features/record/record-modify/record-create.service.ts index a9207210af..84a62f2da1 100644 --- a/apps/nestjs-backend/src/features/record/record-modify/record-create.service.ts +++ b/apps/nestjs-backend/src/features/record/record-modify/record-create.service.ts @@ -112,7 +112,7 @@ export class RecordCreateService { const typecastRecords = await this.shared.validateFieldsAndTypecast< IMakeOptional >(table, records, fieldKeyType, typecast); - await this.recordService.createRecordsOnlySql(table, typecastRecords); + await this.recordService.createRecordsOnlySql(table, typecastRecords, fieldKeyType); } private buildProjectionByTable( diff --git a/apps/nestjs-backend/src/features/record/record-modify/record-modify.shared.service.ts b/apps/nestjs-backend/src/features/record/record-modify/record-modify.shared.service.ts index 1793a112e0..4d917edfdf 100644 --- a/apps/nestjs-backend/src/features/record/record-modify/record-modify.shared.service.ts +++ b/apps/nestjs-backend/src/features/record/record-modify/record-modify.shared.service.ts @@ -273,7 +273,11 @@ export class RecordModifySharedService { tableId: table.id, dbTableName, itemLength: recordCount, - indexField: await this.viewService.getOrCreateViewIndexField(dbTableName, orderRo.viewId), + indexField: await this.viewService.getOrCreateViewIndexFieldForTable( + table.id, + dbTableName, + orderRo.viewId + ), orderRo, update: async (result) => { indexes = result; diff --git a/apps/nestjs-backend/src/features/record/record-query.service.ts b/apps/nestjs-backend/src/features/record/record-query.service.ts index 4ec81bc200..d3ebb6a012 100644 --- a/apps/nestjs-backend/src/features/record/record-query.service.ts +++ b/apps/nestjs-backend/src/features/record/record-query.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { TableDomain, type IRecord } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; +import { DatabaseRouter } from '../../global/database-router.service'; import { Timing } from '../../utils/timing'; import type { IFieldInstance } from '../field/model/factory'; import { fieldCore2FieldInstance } from '../field/model/factory'; @@ -17,7 +17,7 @@ export class RecordQueryService { private readonly logger = new Logger(RecordQueryService.name); constructor( - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder ) {} @@ -59,9 +59,9 @@ export class RecordQueryService { this.logger.debug(`Querying records: ${sql}`); - const rawRecords = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ [key: string]: unknown }[]>(sql); + const rawRecords = await this.databaseRouter.queryDataPrismaForTable< + { [key: string]: unknown }[] + >(table.id, sql); const fields = table.fieldList.map((f) => fieldCore2FieldInstance(f)); diff --git a/apps/nestjs-backend/src/features/record/record.service.spec.ts b/apps/nestjs-backend/src/features/record/record.service.spec.ts index a5a3a1ee4d..16fe4e29d1 100644 --- a/apps/nestjs-backend/src/features/record/record.service.spec.ts +++ b/apps/nestjs-backend/src/features/record/record.service.spec.ts @@ -1,21 +1,67 @@ -import type { TestingModule } from '@nestjs/testing'; -import { Test } from '@nestjs/testing'; -import { GlobalModule } from '../../global/global.module'; -import { RecordModule } from './record.module'; +import { FieldKeyType, FieldType } from '@teable/core'; +import Knex from 'knex'; +import { vi } from 'vitest'; import { RecordService } from './record.service'; describe('RecordService', () => { - let service: RecordService; + it('writes SQL-only created record history into the routed data DB internal schema', async () => { + const dataKnex = Knex({ client: 'pg' }); + const executedSql: string[] = []; + const service = Object.create(RecordService.prototype) as RecordService & { + creditCheck: ReturnType; + getFieldsByProjection: ReturnType; + getWritableCreatedTimeFieldNames: ReturnType; + cls: { get: ReturnType }; + dbProvider: { batchInsertSql: ReturnType }; + databaseRouter: { + executeDataPrismaForTable: ReturnType; + dataKnexForTable: ReturnType; + getDataDatabaseUrlForTable: ReturnType; + }; + }; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [GlobalModule, RecordModule], - }).compile(); + service.cls = { + get: vi.fn((key: string) => + key === 'user' ? { id: 'usrImport', name: 'User', email: 'user@example.com' } : undefined + ), + }; + service.creditCheck = vi.fn().mockResolvedValue(undefined); + service.getFieldsByProjection = vi.fn().mockResolvedValue([ + { + id: 'fldText', + name: 'Text', + type: FieldType.SingleLineText, + dbFieldName: 'fld_text', + convertCellValue2DBValue: vi.fn((value) => value), + }, + ]); + service.getWritableCreatedTimeFieldNames = vi.fn().mockResolvedValue(new Set()); + service.dbProvider = { + batchInsertSql: vi.fn().mockReturnValue('insert into "bse_data"."tbl_imported" values (...)'), + }; + service.databaseRouter = { + executeDataPrismaForTable: vi.fn(async (_tableId: string, sql: string) => { + executedSql.push(sql); + return 1; + }), + dataKnexForTable: vi.fn().mockResolvedValue(dataKnex), + getDataDatabaseUrlForTable: vi + .fn() + .mockResolvedValue('postgresql://user:pass@example.test:5432/data?schema=teable_internal'), + }; - service = module.get(RecordService); - }); + await service.createRecordsOnlySql( + { id: 'tblImport', dbTableName: 'bse_data.tbl_imported' } as never, + [{ fields: { fldText: 'Imported value' } }], + FieldKeyType.Id + ); + + expect(executedSql[0]).toContain('"bse_data"."tbl_imported"'); + expect(executedSql.some((sql) => sql.includes('"teable_internal"."record_history"'))).toBe( + true + ); + expect(executedSql.some((sql) => sql.includes('insert into "record_history"'))).toBe(false); - it('should be defined', () => { - expect(service).toBeDefined(); + await dataKnex.destroy(); }); }); diff --git a/apps/nestjs-backend/src/features/record/record.service.ts b/apps/nestjs-backend/src/features/record/record.service.ts index 29940997cb..ae05a2d969 100644 --- a/apps/nestjs-backend/src/features/record/record.service.ts +++ b/apps/nestjs-backend/src/features/record/record.service.ts @@ -28,6 +28,7 @@ import { DriverClient, FieldKeyType, FieldType, + generateRecordHistoryId, generateRecordId, HttpErrorCode, identify, @@ -44,7 +45,6 @@ import { TableDomain, } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import type { CreateRecordAction, ICreateRecordsRo, @@ -70,6 +70,7 @@ import { CustomHttpException } from '../../custom.exception'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; import { Events } from '../../event-emitter/events'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import { RawOpType } from '../../share-db/interface'; import type { IClsStore } from '../../types/cls'; @@ -124,7 +125,7 @@ export class RecordService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly batchService: BatchService, private readonly cls: ClsService, private readonly cacheService: CacheService, @@ -147,7 +148,24 @@ export class RecordService { return field.dbFieldName; } + private getBaseIdFromDbTableName(dbTableName: string) { + return this.dbProvider.splitTableName(dbTableName)[0]; + } + + private async queryDataTableByPhysicalName( + dbTableName: string, + query: string, + ...values: unknown[] + ) { + return this.databaseRouter.queryDataPrismaForBase( + this.getBaseIdFromDbTableName(dbTableName), + query, + ...values + ); + } + private async getWritableCreatedTimeFieldNames( + tableId: string, dbTableName: string, fields: readonly FieldCore[] ): Promise> { @@ -184,9 +202,11 @@ export class RecordService { .toSQL() .toNative(); - const rows = await this.dataPrismaService - .txClient() - .$queryRawUnsafe(sqlNative.sql, ...sqlNative.bindings); + const rows = await this.databaseRouter.queryDataPrismaForTable( + tableId, + sqlNative.sql, + ...sqlNative.bindings + ); const columnStateMap = new Map(rows.map((row) => [row.column_name, row.is_generated])); return new Set( @@ -221,12 +241,20 @@ export class RecordService { }, {}); } - async getAllRecordCount(dbTableName: string) { + async getAllRecordCount(dbTableName: string, tableId?: string) { const sqlNative = this.knex(dbTableName).count({ count: '*' }).toSQL().toNative(); - const queryResult = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ count?: number }[]>(sqlNative.sql, ...sqlNative.bindings); + const queryResult = tableId + ? await this.databaseRouter.queryDataPrismaForTable<{ count?: number }[]>( + tableId, + sqlNative.sql, + ...sqlNative.bindings + ) + : await this.queryDataTableByPhysicalName<{ count?: number }[]>( + dbTableName, + sqlNative.sql, + ...sqlNative.bindings + ); return Number(queryResult[0]?.count ?? 0); } @@ -279,7 +307,6 @@ export class RecordService { private async getLinkCellIds(tableId: string, field: IFieldInstance, recordId: string) { const prisma = this.prismaService.txClient(); - const dataPrisma = this.dataPrismaService.txClient(); const { dbTableName } = await prisma.tableMeta.findFirstOrThrow({ where: { id: tableId }, select: { dbTableName: true }, @@ -296,7 +323,9 @@ export class RecordService { ); const sql = queryBuilder.where('__id', recordId).toQuery(); - const result = await dataPrisma.$queryRawUnsafe<{ id: string; [key: string]: unknown }[]>(sql); + const result = await this.databaseRouter.queryDataPrismaForTable< + { id: string; [key: string]: unknown }[] + >(tableId, sql); return result .map((item) => { return field.convertDBValue2CellValue(item[field.dbFieldName]) as @@ -765,7 +794,7 @@ export class RecordService { }; } - async getBasicOrderIndexField(dbTableName: string, viewId: string | undefined) { + async getBasicOrderIndexField(tableId: string, dbTableName: string, viewId: string | undefined) { if (!viewId) { return '__auto_number'; } @@ -773,7 +802,7 @@ export class RecordService { const exists = await this.dbProvider.checkColumnExist( dbTableName, columnName, - this.dataPrismaService.txClient() + await this.databaseRouter.dataPrismaExecutorForTable(tableId) ); if (exists) { @@ -825,7 +854,7 @@ export class RecordService { enabledFieldIds, } = await this.prepareQuery(tableId, query); - const basicSortIndex = await this.getBasicOrderIndexField(dbTableName, query.viewId); + const basicSortIndex = await this.getBasicOrderIndexField(tableId, dbTableName, query.viewId); const restrictRecordIds = query.selectedRecordIds && !query.filterLinkCellCandidate @@ -1110,12 +1139,14 @@ export class RecordService { return record.fields[fieldId]; } - async getMaxRecordOrder(dbTableName: string) { + async getMaxRecordOrder(tableId: string, dbTableName: string) { const sqlNative = this.knex(dbTableName).max('__auto_number', { as: 'max' }).toSQL().toNative(); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ max?: number }[]>(sqlNative.sql, ...sqlNative.bindings); + const result = await this.databaseRouter.queryDataPrismaForTable<{ max?: number }[]>( + tableId, + sqlNative.sql, + ...sqlNative.bindings + ); return Number(result[0]?.max ?? 0) + 1; } @@ -1127,9 +1158,9 @@ export class RecordService { .select('__id as id', '__version as version') .whereIn('__id', recordIds) .toQuery(); - const recordRaw = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ id: string; version: number }[]>(nativeQuery); + const recordRaw = await this.databaseRouter.queryDataPrismaForTable< + { id: string; version: number }[] + >(tableId, nativeQuery); if (recordIds.length !== recordRaw.length) { throw new CustomHttpException( @@ -1158,11 +1189,12 @@ export class RecordService { await this.batchDel(tableId, recordIds); } - private async getViewIndexColumns(dbTableName: string) { + private async getViewIndexColumns(tableId: string, dbTableName: string) { const columnInfoQuery = this.dbProvider.columnInfo(dbTableName); - const columns = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ name: string }[]>(columnInfoQuery); + const columns = await this.databaseRouter.queryDataPrismaForTable<{ name: string }[]>( + tableId, + columnInfoQuery + ); return columns .filter((column) => column.name.startsWith(ROW_ORDER_FIELD_PREFIX)) .map((column) => column.name); @@ -1175,7 +1207,7 @@ export class RecordService { viewId?: string ): Promise[] | undefined> { const dbTableName = table.dbTableName; - const allViewIndexColumns = await this.getViewIndexColumns(dbTableName); + const allViewIndexColumns = await this.getViewIndexColumns(table.id, dbTableName); const viewIndexColumns = viewId ? (() => { const viewIndexColumns = allViewIndexColumns.filter((column) => column.endsWith(viewId)); @@ -1203,9 +1235,10 @@ export class RecordService { .select('__id') .whereIn('__id', recordIds) .toQuery(); - const indexValues = await this.dataPrismaService - .txClient() - .$queryRawUnsafe[]>(indexQuery); + const indexValues = await this.databaseRouter.queryDataPrismaForTable[]>( + table.id, + indexQuery + ); const indexMap = indexValues.reduce>>((map, cur) => { const id = cur.__id; @@ -1225,7 +1258,7 @@ export class RecordService { }[] ) { const dbTableName = await this.getDbTableName(tableId); - const viewIndexColumns = await this.getViewIndexColumns(dbTableName); + const viewIndexColumns = await this.getViewIndexColumns(tableId, dbTableName); if (!viewIndexColumns.length) { return; } @@ -1251,7 +1284,7 @@ export class RecordService { .filter(Boolean) as string[]; for (const sql of updateRecordSqls) { - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql); } } @@ -1277,7 +1310,8 @@ export class RecordService { table: TableDomain, records: { fields: Record; - }[] + }[], + fieldKeyType: FieldKeyType = FieldKeyType.Id ) { const user = this.cls.get('user'); const userId = user.id; @@ -1285,6 +1319,7 @@ export class RecordService { const dbTableName = table.dbTableName; const fields = await this.getFieldsByProjection(table.id); const writableCreatedTimeFieldNames = await this.getWritableCreatedTimeFieldNames( + table.id, dbTableName, fields ); @@ -1300,19 +1335,40 @@ export class RecordService { ) as IFieldInstance[]; const fieldInstanceMap = fields.reduce( (map, curField) => { - map[curField.id] = curField; + map[curField[fieldKeyType]] = curField; return map; }, {} as Record ); + const recordHistoryList: { + id: string; + table_id: string; + record_id: string; + field_id: string; + before: string; + after: string; + created_by: string; + }[] = []; const newRecords = records.map((record) => { const createdTime = writableCreatedTimeFieldNames.size > 0 ? new Date().toISOString() : undefined; const fieldsValues: Record = {}; + const recordId = generateRecordId(); Object.entries(record.fields).forEach(([fieldId, value]) => { const fieldInstance = fieldInstanceMap[fieldId]; fieldsValues[fieldInstance.dbFieldName] = fieldInstance.convertCellValue2DBValue(value); + if (value !== '' && value != null) { + recordHistoryList.push({ + id: generateRecordHistoryId(), + table_id: table.id, + record_id: recordId, + field_id: fieldInstance.id, + before: JSON.stringify({ data: null }), + after: JSON.stringify({ data: value }), + created_by: userId, + }); + } }); if (auditUserValue && createdByFields.length) { createdByFields.forEach((field) => { @@ -1327,7 +1383,7 @@ export class RecordService { } }); return removeUndefined({ - __id: generateRecordId(), + __id: recordId, __created_by: userId, __created_time: createdTime, __version: 1, @@ -1335,7 +1391,18 @@ export class RecordService { }); }); const sql = this.dbProvider.batchInsertSql(dbTableName, newRecords); - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(table.id, sql); + if (recordHistoryList.length) { + const dataKnex = await this.databaseRouter.dataKnexForTable(table.id); + const dataDbUrl = await this.databaseRouter.getDataDatabaseUrlForTable(table.id); + const dataDbInternalSchema = new URL(dataDbUrl).searchParams.get('schema') || 'public'; + const historySql = dataKnex + .withSchema(dataDbInternalSchema) + .insert(recordHistoryList) + .into('record_history') + .toQuery(); + await this.databaseRouter.executeDataPrismaForTable(table.id, historySql); + } } async creditCheck(tableId: string) { @@ -1348,7 +1415,7 @@ export class RecordService { select: { dbTableName: true, base: { select: { space: { select: { credit: true } } } } }, }); - const rowCount = await this.getAllRecordCount(table.dbTableName); + const rowCount = await this.getAllRecordCount(table.dbTableName, tableId); const maxRowCount = table.base.space.credit == null @@ -1372,11 +1439,12 @@ export class RecordService { } } - private async getAllViewIndexesField(dbTableName: string) { + private async getAllViewIndexesField(tableId: string, dbTableName: string) { const query = this.dbProvider.columnInfo(dbTableName); - const columns = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ name: string }[]>(query); + const columns = await this.databaseRouter.queryDataPrismaForTable<{ name: string }[]>( + tableId, + query + ); return columns .filter((column) => column.name.startsWith(ROW_ORDER_FIELD_PREFIX)) .map((column) => column.name) @@ -1423,8 +1491,9 @@ export class RecordService { await this.creditCheck(table.id); const { dbTableName, name: tableName } = table; - const maxRecordOrder = await this.getMaxRecordOrder(dbTableName); + const maxRecordOrder = await this.getMaxRecordOrder(table.id, dbTableName); const writableCreatedTimeFieldNames = await this.getWritableCreatedTimeFieldNames( + table.id, dbTableName, fields ); @@ -1434,7 +1503,7 @@ export class RecordService { select: { id: true }, }); - const allViewIndexes = await this.getAllViewIndexesField(dbTableName); + const allViewIndexes = await this.getAllViewIndexesField(table.id, dbTableName); const validationFields = fields .filter((f) => !f.isComputed) @@ -1554,7 +1623,7 @@ export class RecordService { ); await handleDBValidationErrors({ - fn: () => this.dataPrismaService.txClient().$executeRawUnsafe(sql), + fn: () => this.databaseRouter.executeDataPrismaForTable(table.id, sql), handleUniqueError: () => { throw new CustomHttpException( `Fields ${validationFields.map((f) => f.id).join(', ')} unique validation failed`, @@ -1594,7 +1663,7 @@ export class RecordService { const dbTableName = await this.getDbTableName(tableId); const nativeQuery = this.knex(dbTableName).whereIn('__id', recordIds).del().toQuery(); - await this.dataPrismaService.txClient().$executeRawUnsafe(nativeQuery); + await this.databaseRouter.executeDataPrismaForTable(tableId, nativeQuery); } public async getFieldsByProjection( @@ -1825,11 +1894,9 @@ export class RecordService { let result: ({ [fieldName: string]: unknown } & IVisualTableDefaultField)[]; try { - result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe< - ({ [fieldName: string]: unknown } & IVisualTableDefaultField)[] - >(nativeQuery); + result = await this.databaseRouter.queryDataPrismaForTable< + ({ [fieldName: string]: unknown } & IVisualTableDefaultField)[] + >(tableId, nativeQuery); } catch (error) { this.handleRawQueryError(error, nativeQuery, { tableId, @@ -2008,9 +2075,11 @@ export class RecordService { this.logger.debug('getRecordsQuery: %s', sqlDebug); let result: { __id: string }[]; try { - result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ __id: string }[]>(sqlNative.sql, ...sqlNative.bindings); + result = await this.databaseRouter.queryDataPrismaForTable<{ __id: string }[]>( + tableId, + sqlNative.sql, + ...sqlNative.bindings + ); } catch (error) { this.handleRawQueryError(error, sqlNative.sql, { tableId, @@ -2226,9 +2295,9 @@ export class RecordService { this.logger.debug('getSearchHitIndex query: %s', searchQuery); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ __id: string; fieldId: string }[]>(searchQuery); + const result = await this.databaseRouter.queryDataPrismaForTable< + { __id: string; fieldId: string }[] + >(tableId, searchQuery); if (!result.length) { return null; @@ -2304,9 +2373,9 @@ export class RecordService { this.logger.debug('getRecordsFields query: %s', sql); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<(Pick & Pick)[]>(sql); + const result = await this.databaseRouter.queryDataPrismaForTable< + (Pick & Pick)[] + >(tableId, sql); return result.map((record) => { return { @@ -2349,9 +2418,10 @@ export class RecordService { const querySql = queryBuilder.toQuery(); - return this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ id: string; title: string }[]>(querySql); + return this.databaseRouter.queryDataPrismaForTable<{ id: string; title: string }[]>( + tableId, + querySql + ); } async getRecordsHeadWithIds(tableId: string, recordIds: string[]) { @@ -2364,9 +2434,9 @@ export class RecordService { const querySql = queryBuilder.toQuery(); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ id: string; title: unknown }[]>(querySql); + const result = await this.databaseRouter.queryDataPrismaForTable< + { id: string; title: unknown }[] + >(tableId, querySql); return result.map((r) => ({ id: r.id, @@ -2387,9 +2457,10 @@ export class RecordService { true ); queryBuilder.whereIn(`${alias}.__id`, recordIds); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery()); + const result = await this.databaseRouter.queryDataPrismaForTable<{ __id: string }[]>( + tableId, + queryBuilder.toQuery() + ); return result.map((r) => r.__id); } @@ -2599,9 +2670,10 @@ export class RecordService { const rowCountSql = qb.count({ count: '*' }); const sql = rowCountSql.toQuery(); this.logger.debug('getRowCountSql: %s', sql); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ count?: number }[]>(sql); + const result = await this.databaseRouter.queryDataPrismaForTable<{ count?: number }[]>( + tableId, + sql + ); return Number(result[0].count); } @@ -2711,9 +2783,9 @@ export class RecordService { ); try { - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ [key: string]: unknown; __c: number }[]>(groupSql); + const result = await this.databaseRouter.queryDataPrismaForTable< + { [key: string]: unknown; __c: number }[] + >(tableId, groupSql); const pointsResult = await this.groupDbCollection2GroupPoints( result, groupFields, @@ -2750,9 +2822,10 @@ export class RecordService { const dbTableName = await this.getDbTableName(tableId); const queryBuilder = this.knex(dbTableName).select('__id').where('__id', recordId).limit(1); - const result = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ __id: string }[]>(queryBuilder.toQuery()); + const result = await this.databaseRouter.queryDataPrismaForTable<{ __id: string }[]>( + tableId, + queryBuilder.toQuery() + ); const isDeleted = result.length === 0; @@ -2840,9 +2913,9 @@ export class RecordService { ); const collaboratorIdsQuery = collaboratorsQueryBuilder.distinct('user_id').toQuery(); - const collaboratorIds = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ user_id: string | null }[]>(collaboratorIdsQuery); + const collaboratorIds = await this.databaseRouter.queryDataPrismaForTable< + { user_id: string | null }[] + >(tableId, collaboratorIdsQuery); const userIds = Array.from( new Set( collaboratorIds diff --git a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.controller.ts b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.controller.ts index 0a324a717b..9c91ecdf46 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.controller.ts @@ -1,5 +1,11 @@ -import { Controller, Delete, Get, Param, Patch, Post, Query, Res } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Res } from '@nestjs/common'; +import { + adminSendNotificationRoSchema, + type IAdminSendNotificationRo, + type IAdminSendNotificationVo, +} from '@teable/openapi'; import { Response } from 'express'; +import { ZodValidationPipe } from '../../../zod.validation.pipe'; import { Permissions } from '../../auth/decorators/permissions.decorator'; import { AdminOpenApiService } from './admin-open-api.service'; @@ -37,4 +43,11 @@ export class AdminOpenApiController { async deletePerformanceCache(@Query('key') key?: string) { return await this.adminService.deletePerformanceCache(key); } + + @Post('notification') + async sendNotification( + @Body(new ZodValidationPipe(adminSendNotificationRoSchema)) ro: IAdminSendNotificationRo + ): Promise { + return this.adminService.sendAdminNotification(ro); + } } diff --git a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.module.ts b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.module.ts index be3569725e..0b12050c72 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.module.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.module.ts @@ -3,6 +3,7 @@ import { MulterModule } from '@nestjs/platform-express'; import multer from 'multer'; import { AttachmentsCropModule } from '../../attachments/attachments-crop.module'; import { StorageModule } from '../../attachments/plugins/storage.module'; +import { NotificationModule } from '../../notification/notification.module'; import { AdminOpenApiController } from './admin-open-api.controller'; import { AdminOpenApiService } from './admin-open-api.service'; @@ -13,6 +14,7 @@ import { AdminOpenApiService } from './admin-open-api.service'; storage: multer.diskStorage({}), }), StorageModule, + NotificationModule, ], controllers: [AdminOpenApiController], exports: [AdminOpenApiService], diff --git a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts index 6c0f57752f..8d62291c4e 100644 --- a/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts +++ b/apps/nestjs-backend/src/features/setting/open-api/admin-open-api.service.ts @@ -7,15 +7,20 @@ import { InternalServerErrorException, Logger, } from '@nestjs/common'; +import { NotificationSeverityEnum, NotificationTypeEnum } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; +import type { IAdminSendNotificationRo } from '@teable/openapi'; import { PluginStatus, UploadType } from '@teable/openapi'; import { Response } from 'express'; import { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; +import { ClsService } from 'nestjs-cls'; import { PerformanceCacheService } from '../../../performance-cache'; +import type { IClsStore } from '../../../types/cls'; import { Timing } from '../../../utils/timing'; import { AttachmentsCropQueueProcessor } from '../../attachments/attachments-crop.processor'; import StorageAdapter from '../../attachments/plugins/adapter'; +import { NotificationService } from '../../notification/notification.service'; @Injectable() export class AdminOpenApiService { @@ -24,7 +29,9 @@ export class AdminOpenApiService { private readonly prismaService: PrismaService, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex, private readonly attachmentsCropQueueProcessor: AttachmentsCropQueueProcessor, - private readonly performanceCacheService: PerformanceCacheService + private readonly performanceCacheService: PerformanceCacheService, + private readonly notificationService: NotificationService, + private readonly cls: ClsService ) {} async publishPlugin(pluginId: string) { @@ -178,4 +185,20 @@ export class AdminOpenApiService { // eslint-disable-next-line @typescript-eslint/no-explicit-any await this.performanceCacheService.del(key as any); } + + async sendAdminNotification(ro: IAdminSendNotificationRo) { + const fromUserId = this.cls.get('user.id'); + const { message, severity, userIds, emails } = ro; + + return this.notificationService.sendCommonNotify( + { + fromUserId, + toUserId: userIds, + toEmail: emails, + message, + severity, + }, + NotificationTypeEnum.AdminNotice + ); + } } diff --git a/apps/nestjs-backend/src/features/share/share.service.ts b/apps/nestjs-backend/src/features/share/share.service.ts index e532d50090..85af6b4789 100644 --- a/apps/nestjs-backend/src/features/share/share.service.ts +++ b/apps/nestjs-backend/src/features/share/share.service.ts @@ -3,7 +3,6 @@ import { Injectable } from '@nestjs/common'; import type { IFilter, IFieldVo, IViewVo, ILinkFieldOptions, StatisticsFunc } from '@teable/core'; import { CellFormat, FieldKeyType, FieldType, HttpErrorCode, ViewType } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { ShareViewLinkRecordsType, PluginPosition } from '@teable/openapi'; import type { IShareViewCalendarDailyCollectionRo, @@ -29,6 +28,7 @@ import { ClsService } from 'nestjs-cls'; import { CustomHttpException } from '../../custom.exception'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import type { IClsStore } from '../../types/cls'; import { convertViewVoAttachmentUrl } from '../../utils/convert-view-vo-attachment-url'; @@ -55,7 +55,7 @@ export interface IJwtShareInfo { export class ShareService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly fieldService: FieldService, private readonly recordService: RecordService, @InjectAggregationService() private readonly aggregationService: IAggregationService, @@ -470,9 +470,10 @@ export class ShareService { queryBuilder.whereNotNull(dbFieldName); this.dbProvider.filterQuery(queryBuilder, fieldMap, filter).appendQueryBuilder(); const nativeQuery = queryBuilder.toQuery(); - const rows = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ user_id: string | null }[]>(nativeQuery); + const rows = await this.databaseRouter.queryDataPrismaForTable<{ user_id: string | null }[]>( + tableId, + nativeQuery + ); return Array.from( new Set( diff --git a/apps/nestjs-backend/src/features/space/data-db-baseline.service.ts b/apps/nestjs-backend/src/features/space/data-db-baseline.service.ts index 4d193c1ac0..6777ea8ac4 100644 --- a/apps/nestjs-backend/src/features/space/data-db-baseline.service.ts +++ b/apps/nestjs-backend/src/features/space/data-db-baseline.service.ts @@ -1,60 +1,12 @@ -import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; -import { Inject, Injectable, Optional } from '@nestjs/common'; -import { - IDataDbPreflightClientFactory, - DATA_DB_PREFLIGHT_CLIENT_FACTORY, - dataDbKnexClientFactory, -} from './data-db-preflight.service'; - -export const dataDbBaselineSqlToken = Symbol('DATA_DB_BASELINE_SQL'); - -const getBaselineSqlPath = () => { - const candidates = [ - join( - process.cwd(), - 'community/packages/db-data-prisma/prisma/migrations/20260421000000_init_data_db_baseline/migration.sql' - ), - join( - process.cwd(), - '../../community/packages/db-data-prisma/prisma/migrations/20260421000000_init_data_db_baseline/migration.sql' - ), - ]; - const found = candidates.find((path) => existsSync(path)); - if (!found) { - throw new Error('Data DB baseline SQL migration not found'); - } - return found; -}; - -const readBaselineSql = () => readFileSync(getBaselineSqlPath(), 'utf8'); +import { Injectable } from '@nestjs/common'; +import { DataDbMigrationService } from './data-db-migration.service'; @Injectable() export class DataDbBaselineService { - private readonly clientFactory: IDataDbPreflightClientFactory; - - constructor( - @Optional() - @Inject(dataDbBaselineSqlToken) - private readonly baselineSql?: string, - @Optional() - @Inject(DATA_DB_PREFLIGHT_CLIENT_FACTORY) - clientFactory?: IDataDbPreflightClientFactory - ) { - this.clientFactory = clientFactory ?? dataDbKnexClientFactory; - } - - async initialize(url: string) { - const sql = this.baselineSql ?? readBaselineSql(); - if (!sql.trim()) { - return; - } + constructor(private readonly migrationService: DataDbMigrationService) {} - const client = this.clientFactory(url); - try { - await client.raw(sql); - } finally { - await client.destroy().catch(() => undefined); - } + async initialize(url: string, internalSchema?: string) { + await this.migrationService.migrate(url, internalSchema); + return this.migrationService.getLatestSchemaVersion(); } } diff --git a/apps/nestjs-backend/src/features/space/data-db-binding.service.spec.ts b/apps/nestjs-backend/src/features/space/data-db-binding.service.spec.ts index f20f9e4b9c..973228fb76 100644 --- a/apps/nestjs-backend/src/features/space/data-db-binding.service.spec.ts +++ b/apps/nestjs-backend/src/features/space/data-db-binding.service.spec.ts @@ -1,9 +1,13 @@ +/* eslint-disable sonarjs/no-duplicate-string */ import { HttpErrorCode } from '@teable/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DataDbBindingService } from './data-db-binding.service'; +import { encryptDataDbUrl } from './data-db-url-secret'; const dataUrl = 'postgresql://teable:secret@example.com:5432/teable_data'; const initializeEmptyTargetMode = 'initialize-empty'; +const internalSchema = 'teable_meta_test'; +const schemaVersion = '20260421000000_init_data_db_baseline'; const capabilities = { createSchema: true, createTable: true, @@ -18,13 +22,22 @@ describe('DataDbBindingService', () => { const txClient = { dataDbConnection: { upsert: vi.fn(), + update: vi.fn(), }, spaceDataDbBinding: { create: vi.fn(), + updateMany: vi.fn(), }, }; const prismaService = { $tx: vi.fn(async (fn: (client: typeof txClient) => Promise) => fn(txClient)), + dataDbConnection: { + update: vi.fn(), + }, + spaceDataDbBinding: { + findUnique: vi.fn(), + updateMany: vi.fn(), + }, }; const preflightService = { preflight: vi.fn(), @@ -32,13 +45,45 @@ describe('DataDbBindingService', () => { const baselineService = { initialize: vi.fn(), }; + const dataDbClientManager = { + invalidateConnection: vi.fn(), + }; + const dataDbMigrationService = { + ensureConnectionMigrated: vi.fn(), + }; + const byodbBinding = { + mode: 'byodb', + state: 'ready', + dataDbConnection: { + id: 'dcnxxx', + provider: 'postgres', + encryptedUrl: encryptDataDbUrl(dataUrl), + urlFingerprint: 'dbfp_old', + displayHost: 'example.com:5432', + displayDatabase: 'teable_data', + internalSchema, + schemaVersion, + status: 'ready', + capabilities, + lastValidatedAt: new Date('2026-05-06T00:00:00.000Z'), + lastError: null, + createdBy: 'usrxxx', + }, + }; beforeEach(() => { txClient.dataDbConnection.upsert.mockReset().mockResolvedValue({ id: 'dcnxxx' }); + txClient.dataDbConnection.update.mockReset(); txClient.spaceDataDbBinding.create.mockReset(); + txClient.spaceDataDbBinding.updateMany.mockReset(); prismaService.$tx.mockClear(); preflightService.preflight.mockReset(); - baselineService.initialize.mockReset(); + baselineService.initialize.mockReset().mockResolvedValue(schemaVersion); + dataDbClientManager.invalidateConnection.mockReset(); + dataDbMigrationService.ensureConnectionMigrated.mockReset().mockResolvedValue([]); + prismaService.dataDbConnection.update.mockReset(); + prismaService.spaceDataDbBinding.findUnique.mockReset().mockResolvedValue(byodbBinding); + prismaService.spaceDataDbBinding.updateMany.mockReset(); }); it('creates an encrypted connection and BYODB binding after successful preflight', async () => { @@ -52,27 +97,35 @@ describe('DataDbBindingService', () => { const service = new DataDbBindingService( prismaService as never, preflightService as never, - baselineService as never + baselineService as never, + dataDbClientManager as never ); await service.createBindingForNewSpace('spcxxx', 'usrxxx', { mode: 'byodb', url: dataUrl, targetMode: initializeEmptyTargetMode, + internalSchema, }); expect(preflightService.preflight).toHaveBeenCalledWith({ url: dataUrl, targetMode: initializeEmptyTargetMode, + internalSchema, }); - expect(baselineService.initialize).toHaveBeenCalledWith(dataUrl); + expect(baselineService.initialize).toHaveBeenCalledWith(dataUrl, internalSchema); expect(txClient.dataDbConnection.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ urlFingerprint: expect.stringMatching(/^dbfp_/) }), create: expect.objectContaining({ encryptedUrl: expect.not.stringContaining('secret'), + internalSchema, + schemaVersion, status: 'ready', }), + update: expect.objectContaining({ + schemaVersion, + }), }) ); expect(txClient.spaceDataDbBinding.create).toHaveBeenCalledWith({ @@ -84,6 +137,85 @@ describe('DataDbBindingService', () => { createdBy: 'usrxxx', }, }); + expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith('dcnxxx'); + }); + + it('generates an internal schema for new BYODB spaces when one is not provided', async () => { + preflightService.preflight.mockResolvedValue({ + ok: true, + provider: 'postgres', + classification: 'empty', + capabilities, + errors: [], + }); + const service = new DataDbBindingService( + prismaService as never, + preflightService as never, + baselineService as never, + dataDbClientManager as never + ); + + await service.createBindingForNewSpace('spcxxx', 'usrxxx', { + mode: 'byodb', + url: dataUrl, + targetMode: initializeEmptyTargetMode, + }); + + const generatedInternalSchema = expect.stringMatching(/^teable_[a-f0-9]{16}$/); + expect(preflightService.preflight).toHaveBeenCalledWith({ + url: dataUrl, + targetMode: initializeEmptyTargetMode, + internalSchema: generatedInternalSchema, + }); + expect(baselineService.initialize).toHaveBeenCalledWith(dataUrl, generatedInternalSchema); + expect(txClient.dataDbConnection.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ internalSchema: generatedInternalSchema }), + }) + ); + }); + + it('reuses the same data DB connection for multiple spaces with the same URL', async () => { + preflightService.preflight.mockResolvedValue({ + ok: true, + provider: 'postgres', + classification: 'teable-managed-compatible', + capabilities, + errors: [], + }); + const service = new DataDbBindingService( + prismaService as never, + preflightService as never, + baselineService as never, + dataDbClientManager as never + ); + + const dataDb = { + mode: 'byodb' as const, + url: dataUrl, + targetMode: initializeEmptyTargetMode, + internalSchema, + }; + await service.createBindingForNewSpace('spcxxx1', 'usrxxx', dataDb); + await service.createBindingForNewSpace('spcxxx2', 'usrxxx', dataDb); + + expect(txClient.dataDbConnection.upsert).toHaveBeenCalledTimes(2); + expect(txClient.dataDbConnection.upsert.mock.calls[0]?.[0].where).toEqual( + txClient.dataDbConnection.upsert.mock.calls[1]?.[0].where + ); + expect(txClient.spaceDataDbBinding.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + spaceId: 'spcxxx1', + dataDbConnectionId: 'dcnxxx', + }), + }); + expect(txClient.spaceDataDbBinding.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + spaceId: 'spcxxx2', + dataDbConnectionId: 'dcnxxx', + }), + }); + expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledTimes(2); }); it('rejects BYODB space creation when preflight fails', async () => { @@ -97,7 +229,8 @@ describe('DataDbBindingService', () => { const service = new DataDbBindingService( prismaService as never, preflightService as never, - baselineService as never + baselineService as never, + dataDbClientManager as never ); await expect( @@ -105,9 +238,124 @@ describe('DataDbBindingService', () => { mode: 'byodb', url: dataUrl, targetMode: initializeEmptyTargetMode, + internalSchema, }) ).rejects.toMatchObject({ code: HttpErrorCode.CONFLICT }); expect(baselineService.initialize).not.toHaveBeenCalled(); expect(prismaService.$tx).not.toHaveBeenCalled(); }); + + it('retests an existing BYODB binding without exposing encrypted URL material', async () => { + preflightService.preflight.mockResolvedValue({ + ok: true, + provider: 'postgres', + classification: 'teable-managed-compatible', + capabilities, + errors: [], + }); + const service = new DataDbBindingService( + prismaService as never, + preflightService as never, + baselineService as never, + dataDbClientManager as never, + dataDbMigrationService as never + ); + + await service.retestBinding('spcxxx'); + + expect(preflightService.preflight).toHaveBeenCalledWith({ + url: dataUrl, + targetMode: initializeEmptyTargetMode, + internalSchema, + }); + expect(txClient.dataDbConnection.upsert).not.toHaveBeenCalled(); + expect(txClient.dataDbConnection.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'dcnxxx' }, + data: expect.objectContaining({ + status: 'ready', + lastError: null, + }), + }) + ); + }); + + it('retries migration for an existing BYODB binding', async () => { + const service = new DataDbBindingService( + prismaService as never, + preflightService as never, + baselineService as never, + dataDbClientManager as never, + dataDbMigrationService as never + ); + + await service.retryMigrationForSpace('spcxxx'); + + expect(dataDbMigrationService.ensureConnectionMigrated).toHaveBeenCalledWith({ + connectionId: 'dcnxxx', + internalSchema, + url: dataUrl, + }); + expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith('dcnxxx'); + }); + + it('updates credentials for the same BYODB database identity', async () => { + const updatedUrl = 'postgresql://teable:new-secret@example.com:5432/teable_data'; + preflightService.preflight.mockResolvedValue({ + ok: true, + provider: 'postgres', + classification: 'teable-managed-compatible', + capabilities, + errors: [], + }); + const service = new DataDbBindingService( + prismaService as never, + preflightService as never, + baselineService as never, + dataDbClientManager as never, + dataDbMigrationService as never + ); + + await service.updateBindingForSpace('spcxxx', 'usrxxx', { + url: updatedUrl, + targetMode: initializeEmptyTargetMode, + }); + + expect(preflightService.preflight).toHaveBeenCalledWith({ + url: updatedUrl, + targetMode: initializeEmptyTargetMode, + internalSchema, + }); + expect(baselineService.initialize).toHaveBeenCalledWith(updatedUrl, internalSchema); + expect(prismaService.dataDbConnection.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'dcnxxx' }, + data: expect.objectContaining({ + encryptedUrl: expect.not.stringContaining('new-secret'), + schemaVersion, + status: 'ready', + }), + }) + ); + expect(dataDbClientManager.invalidateConnection).toHaveBeenCalledWith('dcnxxx'); + }); + + it('rejects credential updates that would move the space to a different data DB', async () => { + const service = new DataDbBindingService( + prismaService as never, + preflightService as never, + baselineService as never, + dataDbClientManager as never, + dataDbMigrationService as never + ); + + await expect( + service.updateBindingForSpace('spcxxx', 'usrxxx', { + url: 'postgresql://teable:secret@other.example.com:5432/teable_data', + targetMode: initializeEmptyTargetMode, + internalSchema, + }) + ).rejects.toMatchObject({ code: HttpErrorCode.VALIDATION_ERROR }); + expect(baselineService.initialize).not.toHaveBeenCalled(); + }); }); diff --git a/apps/nestjs-backend/src/features/space/data-db-binding.service.ts b/apps/nestjs-backend/src/features/space/data-db-binding.service.ts index 2612f6d04c..ab925a52a3 100644 --- a/apps/nestjs-backend/src/features/space/data-db-binding.service.ts +++ b/apps/nestjs-backend/src/features/space/data-db-binding.service.ts @@ -1,15 +1,18 @@ import { Injectable } from '@nestjs/common'; import { HttpErrorCode } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import type { ICreateSpaceRo, IDataDbPreflightVo } from '@teable/openapi'; +import type { ICreateSpaceRo, IDataDbPreflightRo, IDataDbPreflightVo } from '@teable/openapi'; import { CustomHttpException } from '../../custom.exception'; +import { DataDbClientManager } from '../../global/data-db-client-manager.service'; import { DataDbBaselineService } from './data-db-baseline.service'; +import { resolveDataDbInternalSchema } from './data-db-internal-schema'; +import { DataDbMigrationService } from './data-db-migration.service'; import { DataDbPreflightService, - fingerprintDatabaseUrl, + fingerprintDataDbConnection, getDatabaseUrlDisplayParts, } from './data-db-preflight.service'; -import { encryptDataDbUrl } from './data-db-url-secret'; +import { decryptDataDbUrl, encryptDataDbUrl } from './data-db-url-secret'; type IDataDbCreateOptions = NonNullable; type IPreparedDataDbBinding = { @@ -17,9 +20,14 @@ type IPreparedDataDbBinding = { urlFingerprint: string; displayHost: string; displayDatabase: string; + internalSchema: string; + schemaVersion: string | null; capabilities: IDataDbPreflightVo['capabilities']; }; +const initializeEmptyTargetMode = 'initialize-empty'; +const dataDbUrlRequiredError = 'Data database URL is required'; + const buildPreflightErrorMessage = (preflight: IDataDbPreflightVo) => { const errorCodes = preflight.errors.map((error) => error.code).join(', '); return errorCodes @@ -32,7 +40,9 @@ export class DataDbBindingService { constructor( private readonly prismaService: PrismaService, private readonly preflightService: DataDbPreflightService, - private readonly baselineService: DataDbBaselineService + private readonly baselineService: DataDbBaselineService, + private readonly dataDbClientManager: DataDbClientManager, + private readonly dataDbMigrationService?: DataDbMigrationService ) {} async createBindingForNewSpace( @@ -51,7 +61,7 @@ export class DataDbBindingService { return null; } - if (dataDb.targetMode && dataDb.targetMode !== 'initialize-empty') { + if (dataDb.targetMode && dataDb.targetMode !== initializeEmptyTargetMode) { throw new CustomHttpException( 'Only initialize-empty BYODB target mode is supported for new spaces', HttpErrorCode.VALIDATION_ERROR @@ -59,15 +69,14 @@ export class DataDbBindingService { } if (!dataDb.url) { - throw new CustomHttpException( - 'Data database URL is required', - HttpErrorCode.VALIDATION_ERROR - ); + throw new CustomHttpException(dataDbUrlRequiredError, HttpErrorCode.VALIDATION_ERROR); } + const internalSchema = resolveDataDbInternalSchema(dataDb.internalSchema, dataDb.url); const preflight = await this.preflightService.preflight({ url: dataDb.url, - targetMode: dataDb.targetMode ?? 'initialize-empty', + targetMode: dataDb.targetMode ?? initializeEmptyTargetMode, + internalSchema, }); if (!preflight.ok) { throw new CustomHttpException(buildPreflightErrorMessage(preflight), HttpErrorCode.CONFLICT, { @@ -75,14 +84,16 @@ export class DataDbBindingService { }); } - await this.baselineService.initialize(dataDb.url); + const schemaVersion = await this.baselineService.initialize(dataDb.url, internalSchema); const { displayHost, displayDatabase } = getDatabaseUrlDisplayParts(dataDb.url); return { encryptedUrl: encryptDataDbUrl(dataDb.url), - urlFingerprint: fingerprintDatabaseUrl(dataDb.url), + urlFingerprint: fingerprintDataDbConnection(dataDb.url, internalSchema), displayHost, displayDatabase, + internalSchema, + schemaVersion, capabilities: preflight.capabilities, }; } @@ -96,6 +107,7 @@ export class DataDbBindingService { return; } + let connectionId: string | undefined; await this.prismaService.$tx(async (prisma) => { const connection = await prisma.dataDbConnection.upsert({ where: { urlFingerprint: prepared.urlFingerprint }, @@ -105,7 +117,9 @@ export class DataDbBindingService { urlFingerprint: prepared.urlFingerprint, displayHost: prepared.displayHost, displayDatabase: prepared.displayDatabase, + internalSchema: prepared.internalSchema, status: 'ready', + schemaVersion: prepared.schemaVersion, capabilities: prepared.capabilities, lastValidatedAt: new Date(), createdBy, @@ -114,13 +128,16 @@ export class DataDbBindingService { encryptedUrl: prepared.encryptedUrl, displayHost: prepared.displayHost, displayDatabase: prepared.displayDatabase, + internalSchema: prepared.internalSchema, status: 'ready', + schemaVersion: prepared.schemaVersion, capabilities: prepared.capabilities, lastValidatedAt: new Date(), lastError: null, }, select: { id: true }, }); + connectionId = connection.id; await prisma.spaceDataDbBinding.create({ data: { @@ -132,5 +149,128 @@ export class DataDbBindingService { }, }); }); + if (connectionId) { + await this.dataDbClientManager.invalidateConnection(connectionId); + } + } + + async retestBinding(spaceId: string) { + const binding = await this.getByodbBinding(spaceId); + const connection = binding.dataDbConnection; + const url = decryptDataDbUrl(connection.encryptedUrl); + const preflight = await this.preflightService.preflight({ + url, + targetMode: initializeEmptyTargetMode, + internalSchema: connection.internalSchema, + }); + + await this.prismaService.$tx(async (prisma) => { + await prisma.dataDbConnection.update({ + where: { id: connection.id }, + data: { + status: preflight.ok ? 'ready' : 'error', + capabilities: preflight.capabilities, + lastValidatedAt: new Date(), + lastError: preflight.ok ? null : buildPreflightErrorMessage(preflight), + }, + }); + await prisma.spaceDataDbBinding.updateMany({ + where: { dataDbConnectionId: connection.id, mode: 'byodb' }, + data: { state: preflight.ok ? 'ready' : 'error' }, + }); + }); + + return preflight; + } + + async retryMigrationForSpace(spaceId: string) { + if (!this.dataDbMigrationService) { + throw new CustomHttpException( + 'Data database migration service is unavailable', + HttpErrorCode.CONFLICT + ); + } + + const binding = await this.getByodbBinding(spaceId); + const connection = binding.dataDbConnection; + const applied = await this.dataDbMigrationService.ensureConnectionMigrated({ + connectionId: connection.id, + internalSchema: connection.internalSchema, + url: decryptDataDbUrl(connection.encryptedUrl), + }); + await this.dataDbClientManager.invalidateConnection(connection.id); + return applied; + } + + async updateBindingForSpace(spaceId: string, updatedBy: string, dataDb: IDataDbPreflightRo) { + if (!dataDb.url) { + throw new CustomHttpException(dataDbUrlRequiredError, HttpErrorCode.VALIDATION_ERROR); + } + + const binding = await this.getByodbBinding(spaceId); + const current = binding.dataDbConnection; + const internalSchema = resolveDataDbInternalSchema( + dataDb.internalSchema ?? current.internalSchema, + dataDb.url + ); + const nextDisplayParts = getDatabaseUrlDisplayParts(dataDb.url); + + if ( + current.internalSchema !== internalSchema || + current.displayHost !== nextDisplayParts.displayHost || + current.displayDatabase !== nextDisplayParts.displayDatabase + ) { + throw new CustomHttpException( + 'Changing the BYODB database or internal schema is not supported yet', + HttpErrorCode.VALIDATION_ERROR + ); + } + + const preflight = await this.preflightService.preflight({ + url: dataDb.url, + targetMode: initializeEmptyTargetMode, + internalSchema, + }); + if (!preflight.ok) { + throw new CustomHttpException(buildPreflightErrorMessage(preflight), HttpErrorCode.CONFLICT, { + preflight, + }); + } + + const schemaVersion = await this.baselineService.initialize(dataDb.url, internalSchema); + await this.prismaService.dataDbConnection.update({ + where: { id: current.id }, + data: { + encryptedUrl: encryptDataDbUrl(dataDb.url), + urlFingerprint: fingerprintDataDbConnection(dataDb.url, internalSchema), + status: 'ready', + schemaVersion, + capabilities: preflight.capabilities, + lastValidatedAt: new Date(), + lastError: null, + createdBy: current.createdBy ?? updatedBy, + }, + }); + await this.prismaService.spaceDataDbBinding.updateMany({ + where: { dataDbConnectionId: current.id, mode: 'byodb' }, + data: { state: 'ready' }, + }); + await this.dataDbClientManager.invalidateConnection(current.id); + } + + private async getByodbBinding(spaceId: string) { + const binding = await this.prismaService.spaceDataDbBinding.findUnique({ + where: { spaceId }, + include: { dataDbConnection: true }, + }); + if (binding?.mode !== 'byodb' || !binding.dataDbConnection?.encryptedUrl) { + throw new CustomHttpException( + 'BYODB data database binding was not found', + HttpErrorCode.NOT_FOUND + ); + } + return binding as typeof binding & { + dataDbConnection: NonNullable; + }; } } diff --git a/apps/nestjs-backend/src/features/space/data-db-internal-schema.ts b/apps/nestjs-backend/src/features/space/data-db-internal-schema.ts new file mode 100644 index 0000000000..412b8fc032 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/data-db-internal-schema.ts @@ -0,0 +1,34 @@ +import { createHash } from 'crypto'; + +const postgresIdentifierPattern = /^[a-z_]\w*$/i; +const byodbInternalSchemaPrefix = + process.env.BYODB_DATA_DB_INTERNAL_SCHEMA_PREFIX?.trim() || 'teable'; + +const getDataDbIdentity = (url: string) => { + const parsed = new URL(url); + const database = parsed.pathname.replace(/^\//, ''); + return `${parsed.hostname}:${parsed.port}/${database}`; +}; + +export const generateDataDbInternalSchema = (url: string) => { + const digest = createHash('sha256').update(getDataDbIdentity(url)).digest('hex').slice(0, 16); + return `${byodbInternalSchemaPrefix}_${digest}`; +}; + +export const resolveDataDbInternalSchema = (internalSchema: string | undefined, url: string) => { + const resolved = internalSchema?.trim() || generateDataDbInternalSchema(url); + if (!postgresIdentifierPattern.test(resolved)) { + throw new Error('Invalid data database internal schema name'); + } + return resolved; +}; + +export const quoteDataDbIdentifier = (identifier: string) => + `"${identifier.replaceAll('"', '""')}"`; + +export const withDataDbInternalSchemaParam = (url: string, internalSchema: string) => { + const parsed = new URL(url); + parsed.searchParams.set('schema', internalSchema); + parsed.searchParams.set('options', `-c search_path=${internalSchema}`); + return parsed.toString(); +}; diff --git a/apps/nestjs-backend/src/features/space/data-db-migration.service.spec.ts b/apps/nestjs-backend/src/features/space/data-db-migration.service.spec.ts new file mode 100644 index 0000000000..f194606a1b --- /dev/null +++ b/apps/nestjs-backend/src/features/space/data-db-migration.service.spec.ts @@ -0,0 +1,259 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { createHash } from 'crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { DataDbBaselineService } from './data-db-baseline.service'; +import { DataDbMigrationService, type IDataDbMigration } from './data-db-migration.service'; +import type { IDataDbPreflightClient } from './data-db-preflight.service'; +import { encryptDataDbUrl } from './data-db-url-secret'; + +class FakeMigrationClient implements IDataDbPreflightClient { + readonly calls: Array<{ bindings?: unknown[]; sql: string }> = []; + readonly executedMigrationSql: string[] = []; + + constructor(private readonly applied = new Map()) {} + + async raw(sql: string, bindings?: unknown[]) { + this.calls.push({ sql, bindings }); + + if (sql.includes('SELECT "id", "checksum" FROM "__teable_data_schema_migrations"')) { + return { + rows: Array.from(this.applied).map(([id, checksum]) => ({ id, checksum })) as T[], + }; + } + + if (sql.includes('INSERT INTO "__teable_data_schema_migrations"')) { + this.applied.set(String(bindings?.[0]), String(bindings?.[1])); + return { rows: [] as T[] }; + } + + if (sql.includes('"fixture_table"')) { + this.executedMigrationSql.push(sql); + } + + return { rows: [] as T[] }; + } + + async destroy() { + return undefined; + } +} + +const migrations: IDataDbMigration[] = [ + { + id: '20260421000000_init_data_db_baseline', + sql: 'CREATE TABLE "fixture_table" ("id" TEXT PRIMARY KEY);', + }, + { + id: '20260513000000_add_fixture_column', + sql: 'ALTER TABLE "fixture_table" ADD COLUMN IF NOT EXISTS "name" TEXT;', + }, +]; + +const checksumSql = (sql: string) => createHash('sha256').update(sql).digest('hex'); + +describe('DataDbMigrationService', () => { + it('creates the internal schema, locks, runs pending migrations, and records them', async () => { + const client = new FakeMigrationClient(); + const service = new DataDbMigrationService(migrations, () => client); + + await expect( + service.migrate('postgresql://teable:secret@example.com:5432/data', 'teable_test') + ).resolves.toEqual(migrations.map((migration) => migration.id)); + + expect(client.calls.map((call) => call.sql)).toEqual( + expect.arrayContaining([ + 'SET statement_timeout TO 300000', + 'SET lock_timeout TO 30000', + 'CREATE SCHEMA IF NOT EXISTS "teable_test"', + 'SET search_path TO "teable_test"', + 'SELECT pg_advisory_lock(hashtext(?))', + 'SELECT pg_advisory_unlock(hashtext(?))', + ]) + ); + expect(client.executedMigrationSql).toEqual(migrations.map((migration) => migration.sql)); + expect(client.calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sql: expect.stringContaining('INSERT INTO "__teable_data_schema_migrations"'), + bindings: [migrations[0].id, expect.any(String)], + }), + expect.objectContaining({ + sql: expect.stringContaining('INSERT INTO "__teable_data_schema_migrations"'), + bindings: [migrations[1].id, expect.any(String)], + }), + ]) + ); + }); + + it('skips already applied migrations', async () => { + const client = new FakeMigrationClient( + new Map([[migrations[0].id, checksumSql(migrations[0].sql)]]) + ); + const service = new DataDbMigrationService(migrations, () => client); + + await expect( + service.migrate('postgresql://teable:secret@example.com:5432/data', 'teable_test') + ).resolves.toEqual([migrations[1].id]); + + expect(client.calls).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sql: expect.stringContaining('INSERT INTO "__teable_data_schema_migrations"'), + bindings: [migrations[0].id, expect.any(String)], + }), + ]) + ); + }); + + it('marks the connection and bindings ready after a successful ensure', async () => { + const client = new FakeMigrationClient(); + const prismaService = { + dataDbConnection: { + findUnique: vi.fn().mockResolvedValue({ status: 'ready', schemaVersion: null }), + update: vi.fn().mockResolvedValue({}), + }, + spaceDataDbBinding: { + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + }; + const service = new DataDbMigrationService(migrations, () => client, prismaService as never); + + await expect( + service.ensureConnectionMigrated({ + connectionId: 'dcnxxx', + internalSchema: 'teable_test', + url: 'postgresql://teable:secret@example.com:5432/data', + }) + ).resolves.toEqual(migrations.map((migration) => migration.id)); + + expect(prismaService.dataDbConnection.update).toHaveBeenCalledWith({ + where: { id: 'dcnxxx' }, + data: { status: 'migrating' }, + }); + expect(prismaService.dataDbConnection.update).toHaveBeenCalledWith({ + where: { id: 'dcnxxx' }, + data: expect.objectContaining({ + status: 'ready', + schemaVersion: migrations[1].id, + lastError: null, + }), + }); + expect(prismaService.spaceDataDbBinding.updateMany).toHaveBeenLastCalledWith({ + where: { dataDbConnectionId: 'dcnxxx', mode: 'byodb' }, + data: { state: 'ready' }, + }); + }); + + it('stores a visible error when ensure fails', async () => { + const client = new FakeMigrationClient(); + const prismaService = { + dataDbConnection: { + findUnique: vi.fn().mockResolvedValue({ status: 'ready', schemaVersion: null }), + update: vi.fn().mockResolvedValue({}), + }, + spaceDataDbBinding: { + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + }; + const brokenClient: IDataDbPreflightClient = { + destroy: () => client.destroy(), + raw: async (sql: string, bindings?: unknown[]) => { + if (sql === 'SELECT broken') { + throw new Error('ECONNREFUSED'); + } + return await client.raw(sql, bindings); + }, + }; + const service = new DataDbMigrationService( + [{ id: migrations[0].id, sql: 'SELECT broken' }], + () => brokenClient, + prismaService as never + ); + + await expect( + service.ensureConnectionMigrated({ + connectionId: 'dcnxxx', + internalSchema: 'teable_test', + url: 'postgresql://teable:secret@example.com:5432/data', + }) + ).rejects.toThrow('ECONNREFUSED'); + + expect(prismaService.dataDbConnection.update).toHaveBeenLastCalledWith({ + where: { id: 'dcnxxx' }, + data: { + status: 'error', + lastError: 'ECONNREFUSED', + }, + }); + expect(prismaService.spaceDataDbBinding.updateMany).toHaveBeenLastCalledWith({ + where: { dataDbConnectionId: 'dcnxxx', mode: 'byodb' }, + data: { state: 'error' }, + }); + }); + + it('scans existing connections that need schema upgrades', async () => { + const dataUrl = 'postgresql://teable:secret@example.com:5432/data'; + const client = new FakeMigrationClient(); + const prismaService = { + dataDbConnection: { + findMany: vi.fn().mockResolvedValue([ + { + id: 'dcnxxx', + encryptedUrl: encryptDataDbUrl(dataUrl), + internalSchema: 'teable_test', + }, + ]), + findUnique: vi.fn().mockResolvedValue({ status: 'ready', schemaVersion: null }), + update: vi.fn().mockResolvedValue({}), + }, + spaceDataDbBinding: { + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + }; + const service = new DataDbMigrationService(migrations, () => client, prismaService as never); + + await service.migrateExistingConnections(); + + expect(prismaService.dataDbConnection.findMany).toHaveBeenCalledWith({ + where: { + status: { not: 'disabled' }, + OR: [ + { schemaVersion: null }, + { schemaVersion: { not: migrations[1].id } }, + { status: { in: ['migrating', 'error'] } }, + ], + }, + select: { + id: true, + encryptedUrl: true, + internalSchema: true, + }, + }); + expect(prismaService.dataDbConnection.update).toHaveBeenCalledWith({ + where: { id: 'dcnxxx' }, + data: expect.objectContaining({ + status: 'ready', + schemaVersion: migrations[1].id, + }), + }); + }); +}); + +describe('DataDbBaselineService', () => { + it('delegates baseline initialization to the migration service', async () => { + const migrationService = { + migrate: vi.fn().mockResolvedValue([]), + getLatestSchemaVersion: vi.fn().mockReturnValue(migrations[1].id), + }; + const service = new DataDbBaselineService(migrationService as never); + + await expect( + service.initialize('postgresql://teable:secret@example.com:5432/data', 'teable_test') + ).resolves.toBe(migrations[1].id); + + expect(migrationService.migrate).toHaveBeenCalledWith( + 'postgresql://teable:secret@example.com:5432/data', + 'teable_test' + ); + }); +}); diff --git a/apps/nestjs-backend/src/features/space/data-db-migration.service.ts b/apps/nestjs-backend/src/features/space/data-db-migration.service.ts new file mode 100644 index 0000000000..d38e22dcf2 --- /dev/null +++ b/apps/nestjs-backend/src/features/space/data-db-migration.service.ts @@ -0,0 +1,317 @@ +import { createHash } from 'crypto'; +import { existsSync, readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; +import type { OnApplicationBootstrap } from '@nestjs/common'; +import { PrismaService, type DataDbConnection } from '@teable/db-main-prisma'; +import { quoteDataDbIdentifier, resolveDataDbInternalSchema } from './data-db-internal-schema'; +import { + DATA_DB_PREFLIGHT_CLIENT_FACTORY, + dataDbKnexClientFactory, + type IDataDbPreflightClient, + type IDataDbPreflightClientFactory, +} from './data-db-preflight.service'; +import { decryptDataDbUrl } from './data-db-url-secret'; + +export interface IDataDbMigration { + id: string; + sql: string; +} + +export const dataDbMigrationsToken = Symbol('DATA_DB_MIGRATIONS'); + +export const DATA_DB_MIGRATION_TABLE = '__teable_data_schema_migrations'; + +const migrationsRootCandidates = [ + join(process.cwd(), 'community/packages/db-data-prisma/prisma/migrations'), + join(process.cwd(), '../../community/packages/db-data-prisma/prisma/migrations'), +]; +const defaultMigrationStatementTimeoutMs = 300_000; +const defaultMigrationLockTimeoutMs = 30_000; + +const readPositiveIntEnv = (key: string, fallback: number) => { + const value = Number(process.env[key]); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +}; + +const getMigrationsRoot = () => { + const found = migrationsRootCandidates.find((path) => existsSync(path)); + if (!found) { + throw new Error('Data DB migrations directory not found'); + } + return found; +}; + +const readDataDbMigrations = (): IDataDbMigration[] => + readdirSync(getMigrationsRoot(), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const sqlPath = join(getMigrationsRoot(), entry.name, 'migration.sql'); + return existsSync(sqlPath) + ? { + id: entry.name, + sql: readFileSync(sqlPath, 'utf8'), + } + : null; + }) + .filter((migration): migration is IDataDbMigration => Boolean(migration?.sql.trim())) + .sort((left, right) => left.id.localeCompare(right.id)); + +const checksumSql = (sql: string) => createHash('sha256').update(sql).digest('hex'); + +@Injectable() +export class DataDbMigrationService implements OnApplicationBootstrap { + private readonly logger = new Logger(DataDbMigrationService.name); + private readonly clientFactory: IDataDbPreflightClientFactory; + private readonly runningConnections = new Map>(); + + constructor( + @Optional() + @Inject(dataDbMigrationsToken) + private readonly migrations?: IDataDbMigration[], + @Optional() + @Inject(DATA_DB_PREFLIGHT_CLIENT_FACTORY) + clientFactory?: IDataDbPreflightClientFactory, + @Optional() + private readonly prismaService?: PrismaService + ) { + this.clientFactory = clientFactory ?? dataDbKnexClientFactory; + } + + async onApplicationBootstrap() { + if (!this.prismaService) { + return; + } + + void this.migrateExistingConnections().catch((error) => { + this.logger.error(`Failed to scan data database migrations: ${formatMigrationError(error)}`); + }); + } + + async migrate(url: string, internalSchema?: string) { + const migrations = this.resolveMigrations(); + if (migrations.length === 0) { + return []; + } + + const client = this.clientFactory(url); + const resolvedInternalSchema = resolveDataDbInternalSchema(internalSchema, url); + const quotedInternalSchema = quoteDataDbIdentifier(resolvedInternalSchema); + const lockKey = `teable:data-db-migration:${resolvedInternalSchema}`; + + try { + await client.raw( + `SET statement_timeout TO ${readPositiveIntEnv( + 'BYODB_DATA_DB_MIGRATION_STATEMENT_TIMEOUT_MS', + defaultMigrationStatementTimeoutMs + )}` + ); + await client.raw( + `SET lock_timeout TO ${readPositiveIntEnv( + 'BYODB_DATA_DB_MIGRATION_LOCK_TIMEOUT_MS', + defaultMigrationLockTimeoutMs + )}` + ); + await client.raw(`CREATE SCHEMA IF NOT EXISTS ${quotedInternalSchema}`); + await client.raw(`SET search_path TO ${quotedInternalSchema}`); + await client.raw('SELECT pg_advisory_lock(hashtext(?))', [lockKey]); + + try { + await this.ensureMigrationTable(client); + const applied = await this.getAppliedMigrations(client); + const appliedNow: string[] = []; + + for (const migration of migrations) { + const checksum = checksumSql(migration.sql); + const appliedChecksum = applied.get(migration.id); + if (appliedChecksum) { + if (appliedChecksum !== checksum) { + throw new Error(`Data DB migration ${migration.id} checksum mismatch`); + } + continue; + } + await client.raw(migration.sql); + await this.recordMigration(client, migration, checksum); + appliedNow.push(migration.id); + } + + return appliedNow; + } finally { + await client + .raw('SELECT pg_advisory_unlock(hashtext(?))', [lockKey]) + .catch(() => undefined); + } + } finally { + await client.destroy().catch(() => undefined); + } + } + + async ensureConnectionMigrated(input: { + connectionId: string; + internalSchema: string; + url: string; + }) { + const existing = this.runningConnections.get(input.connectionId); + if (existing) { + return await existing; + } + + const promise = this.migrateConnection(input).finally(() => { + this.runningConnections.delete(input.connectionId); + }); + this.runningConnections.set(input.connectionId, promise); + return await promise; + } + + async migrateExistingConnections() { + if (!this.prismaService) { + return; + } + + const latestSchemaVersion = this.getLatestSchemaVersion(); + if (!latestSchemaVersion) { + return; + } + + const connections = await this.prismaService.dataDbConnection.findMany({ + where: { + status: { + not: 'disabled', + }, + OR: [ + { schemaVersion: null }, + { schemaVersion: { not: latestSchemaVersion } }, + { status: { in: ['migrating', 'error'] } }, + ], + }, + select: { + id: true, + encryptedUrl: true, + internalSchema: true, + }, + }); + + for (const connection of connections) { + await this.ensureConnectionMigrated({ + connectionId: connection.id, + internalSchema: connection.internalSchema, + url: decryptDataDbUrl(connection.encryptedUrl), + }).catch((error) => { + this.logger.warn( + `Failed to migrate data database connection ${connection.id}: ${formatMigrationError(error)}` + ); + }); + } + } + + private async migrateConnection(input: { + connectionId: string; + internalSchema: string; + url: string; + }) { + const latestSchemaVersion = this.getLatestSchemaVersion(); + if (!latestSchemaVersion) { + return []; + } + + const current = await this.prismaService?.dataDbConnection.findUnique({ + where: { id: input.connectionId }, + select: { status: true, schemaVersion: true }, + }); + + if (current?.status === 'ready' && current.schemaVersion === latestSchemaVersion) { + return []; + } + + await this.updateConnectionState(input.connectionId, 'migrating'); + + try { + const applied = await this.migrate(input.url, input.internalSchema); + await this.prismaService?.dataDbConnection.update({ + where: { id: input.connectionId }, + data: { + status: 'ready', + schemaVersion: latestSchemaVersion, + lastValidatedAt: new Date(), + lastError: null, + }, + }); + await this.prismaService?.spaceDataDbBinding.updateMany({ + where: { dataDbConnectionId: input.connectionId, mode: 'byodb' }, + data: { state: 'ready' }, + }); + return applied; + } catch (error) { + const message = formatMigrationError(error); + await this.prismaService?.dataDbConnection.update({ + where: { id: input.connectionId }, + data: { + status: 'error', + lastError: message, + }, + }); + await this.prismaService?.spaceDataDbBinding.updateMany({ + where: { dataDbConnectionId: input.connectionId, mode: 'byodb' }, + data: { state: 'error' }, + }); + throw error; + } + } + + private async updateConnectionState(connectionId: DataDbConnection['id'], state: 'migrating') { + await this.prismaService?.dataDbConnection.update({ + where: { id: connectionId }, + data: { status: state }, + }); + await this.prismaService?.spaceDataDbBinding.updateMany({ + where: { dataDbConnectionId: connectionId, mode: 'byodb' }, + data: { state }, + }); + } + + private resolveMigrations() { + return this.migrations ?? readDataDbMigrations(); + } + + getLatestSchemaVersion() { + return this.resolveMigrations().at(-1)?.id ?? null; + } + + private async ensureMigrationTable(client: IDataDbPreflightClient) { + await client.raw(` + CREATE TABLE IF NOT EXISTS "${DATA_DB_MIGRATION_TABLE}" ( + "id" TEXT PRIMARY KEY, + "checksum" TEXT NOT NULL, + "applied_at" TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + } + + private async getAppliedMigrations(client: IDataDbPreflightClient) { + const result = await client.raw<{ id: string; checksum: string }>( + `SELECT "id", "checksum" FROM "${DATA_DB_MIGRATION_TABLE}" ORDER BY "id"` + ); + const rows = Array.isArray(result) ? result : result.rows ?? []; + return new Map(rows.map((row) => [row.id, row.checksum])); + } + + private async recordMigration( + client: IDataDbPreflightClient, + migration: IDataDbMigration, + checksum: string + ) { + await client.raw( + ` + INSERT INTO "${DATA_DB_MIGRATION_TABLE}" ("id", "checksum") + VALUES (?, ?) + ON CONFLICT ("id") DO NOTHING + `, + [migration.id, checksum] + ); + } +} + +const formatMigrationError = (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + return message.length > 2000 ? `${message.slice(0, 1997)}...` : message; +}; diff --git a/apps/nestjs-backend/src/features/space/data-db-preflight.service.spec.ts b/apps/nestjs-backend/src/features/space/data-db-preflight.service.spec.ts index 79ab4f9b74..8e9941f77a 100644 --- a/apps/nestjs-backend/src/features/space/data-db-preflight.service.spec.ts +++ b/apps/nestjs-backend/src/features/space/data-db-preflight.service.spec.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable sonarjs/no-duplicate-string */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IDataDbPreflightClient } from './data-db-preflight.service'; import { @@ -10,9 +13,11 @@ type IFakeDbState = { schemas?: string[]; tables?: Array<{ table_schema: string; table_name: string }>; functions?: string[]; + databases?: string[]; failCreateSchema?: boolean; failConnect?: boolean; failMessage?: string; + failCode?: string; }; class FakePreflightClient implements IDataDbPreflightClient { @@ -20,7 +25,9 @@ class FakePreflightClient implements IDataDbPreflightClient { async raw(sql: string): Promise<{ rows: T[] }> { if (this.state.failConnect) { - throw new Error(this.state.failMessage ?? 'connection failed'); + const error = new Error(this.state.failMessage ?? 'connection failed'); + Object.assign(error, { code: this.state.failCode }); + throw error; } if (sql.includes('CREATE SCHEMA') && this.state.failCreateSchema) { throw new Error('permission denied for database'); @@ -37,6 +44,13 @@ class FakePreflightClient implements IDataDbPreflightClient { if (sql.includes('pg_stat_activity')) { return { rows: [{ count: '0' }] as T[] }; } + if (sql.includes('pg_database')) { + return { + rows: (this.state.databases ?? ['postgres', 'teable_data']).map((datname) => ({ + datname, + })) as T[], + }; + } if (sql.includes('information_schema.schemata')) { return { rows: (this.state.schemas ?? ['public']).map((schema_name) => ({ schema_name })) as T[], @@ -59,6 +73,7 @@ class FakePreflightClient implements IDataDbPreflightClient { } const DATA_URL = 'postgresql://teable:secret@example.com:5432/teable_data'; +const internalSchema = 'teable_meta_test'; const BASELINE_TABLES = [ 'computed_update_outbox', 'computed_update_outbox_seed', @@ -69,6 +84,7 @@ const BASELINE_TABLES = [ 'record_trash', '__undo_log', ]; +const DATA_SCHEMA_MIGRATION_TABLE = '__teable_data_schema_migrations'; const createService = (state: IFakeDbState) => new DataDbPreflightService(undefined, () => new FakePreflightClient(state)); @@ -113,26 +129,50 @@ describe('DataDbPreflightService', () => { it('classifies a compatible Teable data database', async () => { const result = await createService({ - schemas: ['public', 'bseabc'], - tables: BASELINE_TABLES.map((table_name) => ({ table_schema: 'public', table_name })), + schemas: ['public', internalSchema, 'bseabc'], + tables: [...BASELINE_TABLES, DATA_SCHEMA_MIGRATION_TABLE].map((table_name) => ({ + table_schema: internalSchema, + table_name, + })), functions: ['__teable_capture_undo_row'], }).preflight({ url: DATA_URL, targetMode: 'adopt-existing', + internalSchema, }); - expect(result.ok).toBe(false); + expect(result.ok).toBe(true); + expect(result.classification).toBe('teable-managed-compatible'); + expect(result.errors).toEqual([]); + }); + + it('allows the internal data schema migration history table in Teable-managed schemas', async () => { + const result = await createService({ + schemas: ['public', internalSchema], + tables: [...BASELINE_TABLES, DATA_SCHEMA_MIGRATION_TABLE].map((table_name) => ({ + table_schema: internalSchema, + table_name, + })), + functions: ['__teable_capture_undo_row'], + }).preflight({ + url: DATA_URL, + targetMode: 'initialize-empty', + internalSchema, + }); + + expect(result.ok).toBe(true); expect(result.classification).toBe('teable-managed-compatible'); expect(result.errors).toEqual([]); }); it('rejects a partial Teable data database as incompatible', async () => { const result = await createService({ - schemas: ['public'], - tables: [{ table_schema: 'public', table_name: 'record_history' }], + schemas: ['public', internalSchema], + tables: [{ table_schema: internalSchema, table_name: 'record_history' }], }).preflight({ url: DATA_URL, targetMode: 'initialize-empty', + internalSchema, }); expect(result.ok).toBe(false); @@ -140,7 +180,7 @@ describe('DataDbPreflightService', () => { expect(result.errors.map((error) => error.code)).toContain('INCOMPATIBLE_TEABLE_DATABASE'); }); - it('rejects non-empty unknown databases', async () => { + it('allows non-empty public schemas because BYODB uses Teable internal schemas', async () => { const result = await createService({ schemas: ['public'], tables: [{ table_schema: 'public', table_name: 'customer_table' }], @@ -149,6 +189,39 @@ describe('DataDbPreflightService', () => { targetMode: 'initialize-empty', }); + expect(result.ok).toBe(true); + expect(result.classification).toBe('empty'); + expect(result.errors).toEqual([]); + }); + + it('allows other base schemas while initializing an empty internal schema', async () => { + const result = await createService({ + schemas: ['public', internalSchema, 'bse_existing_base'], + tables: [ + { table_schema: 'bse_existing_base', table_name: 'sheet_table' }, + { table_schema: 'public', table_name: 'customer_table' }, + ], + }).preflight({ + url: DATA_URL, + targetMode: 'initialize-empty', + internalSchema, + }); + + expect(result.ok).toBe(true); + expect(result.classification).toBe('empty'); + expect(result.errors).toEqual([]); + }); + + it('rejects unknown objects inside the Teable internal schema', async () => { + const result = await createService({ + schemas: ['public', internalSchema], + tables: [{ table_schema: internalSchema, table_name: 'customer_table' }], + }).preflight({ + url: DATA_URL, + targetMode: 'initialize-empty', + internalSchema, + }); + expect(result.ok).toBe(false); expect(result.classification).toBe('non-empty-unknown'); expect(result.errors.map((error) => error.code)).toContain('NON_EMPTY_UNKNOWN_DATABASE'); @@ -181,6 +254,74 @@ describe('DataDbPreflightService', () => { expect(result.errors.map((error) => error.code)).toContain('CONNECTION_FAILED'); }); + it('returns a specific error when an IPv6 address is unreachable', async () => { + const result = await createService({ + failConnect: true, + failCode: 'ENETUNREACH', + failMessage: 'connect ENETUNREACH 2406:da1c:4c7:f800:9d0b:f8d1:c668:930:5432', + }).preflight({ + url: DATA_URL, + targetMode: 'initialize-empty', + }); + + expect(result.errors.map((error) => error.code)).toContain('IPV6_NETWORK_UNREACHABLE'); + expect(result.errors[0]?.remediation).toContain('IPv4-reachable'); + }); + + it('returns database choices when the URL database does not exist', async () => { + const requestedUrl = 'postgresql://teable:secret@example.com:5432/missing_db'; + const clients: Record = { + [requestedUrl]: { + failConnect: true, + failCode: '3D000', + failMessage: 'database "missing_db" does not exist', + }, + ['postgresql://teable:secret@example.com:5432/postgres']: { + databases: ['postgres', 'teable_data'], + }, + }; + const service = new DataDbPreflightService( + undefined, + (url) => new FakePreflightClient(clients[url] ?? {}) + ); + + const result = await service.preflight({ + url: requestedUrl, + targetMode: 'initialize-empty', + }); + + expect(result.ok).toBe(false); + expect(result.displayDatabase).toBe('missing_db'); + expect(result.serverVersion).toBe('14.12'); + expect(result.availableDatabases).toEqual(['postgres', 'teable_data']); + expect(result.errors.map((error) => error.code)).toContain('CONNECTION_FAILED'); + expect(JSON.stringify(result)).not.toContain('secret'); + }); + + it('returns database choices when the URL omits the database name', async () => { + const requestedUrl = 'postgresql://teable:secret@example.com:5432'; + const clients: Record = { + ['postgresql://teable:secret@example.com:5432/postgres']: { + databases: ['postgres', 'teable_data'], + }, + }; + const service = new DataDbPreflightService( + undefined, + (url) => new FakePreflightClient(clients[url] ?? {}) + ); + + const result = await service.preflight({ + url: requestedUrl, + targetMode: 'initialize-empty', + }); + + expect(result.ok).toBe(false); + expect(result.displayDatabase).toBe(''); + expect(result.availableDatabases).toEqual(['postgres', 'teable_data']); + expect(result.requiresDatabaseSelection).toBe(true); + expect(result.errors).toEqual([]); + }); + it('rejects unsupported database URL drivers', async () => { const result = await createService({}).preflight({ url: 'mysql://teable:secret@example.com:3306/teable_data', @@ -215,6 +356,8 @@ describe('DataDbPreflightService', () => { provider: 'postgres', displayHost: 'example.com:5432', displayDatabase: 'teable_data', + internalSchema, + schemaVersion: '20260421000000_init_data_db_baseline', lastValidatedAt: new Date('2026-05-06T00:00:00.000Z'), lastError: null, encryptedUrl: 'encrypted-secret', @@ -240,6 +383,8 @@ describe('DataDbPreflightService', () => { provider: 'postgres', displayHost: 'example.com:5432', displayDatabase: 'teable_data', + internalSchema, + schemaVersion: '20260421000000_init_data_db_baseline', lastValidatedAt: '2026-05-06T00:00:00.000Z', }); expect(JSON.stringify(summary)).not.toContain('encrypted-secret'); diff --git a/apps/nestjs-backend/src/features/space/data-db-preflight.service.ts b/apps/nestjs-backend/src/features/space/data-db-preflight.service.ts index 9f8d7d7e7d..195f6dc361 100644 --- a/apps/nestjs-backend/src/features/space/data-db-preflight.service.ts +++ b/apps/nestjs-backend/src/features/space/data-db-preflight.service.ts @@ -1,16 +1,19 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable sonarjs/no-duplicate-string */ +import { createHash } from 'crypto'; +import { promises as dns } from 'dns'; +import { isIP } from 'net'; import { Inject, Injectable, Optional } from '@nestjs/common'; import { parseDsn } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; import type { IDataDbConnectionSummaryVo, IDataDbPreflightRo, IDataDbPreflightVo, } from '@teable/openapi'; -import { PrismaService } from '@teable/db-main-prisma'; -import { createHash } from 'crypto'; -import { promises as dns } from 'dns'; -import { isIP } from 'net'; import type { Knex } from 'knex'; import createKnex from 'knex'; +import { resolveDataDbInternalSchema } from './data-db-internal-schema'; type IPreflightCapabilities = IDataDbPreflightVo['capabilities']; type IPreflightClassification = IDataDbPreflightVo['classification']; @@ -35,8 +38,14 @@ const DATA_PLANE_TABLES = [ '__undo_log', ]; +const DATA_SCHEMA_MIGRATION_TABLE = '__teable_data_schema_migrations'; const DATA_PLANE_FUNCTIONS = ['__teable_capture_undo_row']; -const ALLOWED_PUBLIC_TABLES = new Set([...DATA_PLANE_TABLES, '_prisma_migrations']); +const ALLOWED_INTERNAL_TABLES = new Set([ + ...DATA_PLANE_TABLES, + DATA_SCHEMA_MIGRATION_TABLE, + '_prisma_migrations', +]); +const MAINTENANCE_DATABASE_CANDIDATES = ['postgres', 'template1']; const DEFAULT_CAPABILITIES: IPreflightCapabilities = { createSchema: false, createTable: false, @@ -53,6 +62,14 @@ const PRIVATE_NETWORK_ERROR: IPreflightError = { remediation: 'Set TEABLE_SSRF_PROTECTION_DISABLED=true only in trusted self-hosted deployments.', }; +const IPV6_NETWORK_UNREACHABLE_ERROR: IPreflightError = { + code: 'IPV6_NETWORK_UNREACHABLE', + message: + 'The database host resolved to an IPv6 address, but this Teable deployment cannot reach IPv6 networks.', + remediation: + 'Use an IPv4-reachable database endpoint or connection pooler, or enable IPv6 outbound networking for this Teable deployment.', +}; + const normalizeRawRows = (result: { rows?: T[] } | T[]): T[] => { if (Array.isArray(result)) { return result; @@ -72,14 +89,24 @@ export const fingerprintDatabaseUrl = (url: string): string => { return `dbfp_${createHash('sha256').update(url).digest('hex')}`; }; +export const fingerprintDataDbConnection = (url: string, internalSchema: string): string => { + return `dbfp_${createHash('sha256').update(`${url}\n${internalSchema}`).digest('hex')}`; +}; + export const getDatabaseUrlDisplayParts = (url: string) => { const parsed = new URL(url); return { displayHost: parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname, - displayDatabase: parsed.pathname.replace(/^\//, ''), + displayDatabase: decodeURIComponent(parsed.pathname.replace(/^\//, '')), }; }; +export const replaceDatabaseUrlDatabase = (url: string, database: string): string => { + const parsed = new URL(url); + parsed.pathname = `/${encodeURIComponent(database)}`; + return parsed.toString(); +}; + const isPrivateNetworkAllowed = () => process.env.TEABLE_SSRF_PROTECTION_DISABLED === 'true'; const isPrivateIp = (address: string): boolean => { @@ -130,6 +157,7 @@ export class DataDbPreflightService { async preflight(input: IDataDbPreflightRo): Promise { const errors: IPreflightError[] = []; + let internalSchema = ''; let maskedUrl: string | undefined; let urlFingerprint: string | undefined; let displayHost: string | undefined; @@ -137,8 +165,9 @@ export class DataDbPreflightService { try { parseDsn(input.url); + internalSchema = resolveDataDbInternalSchema(input.internalSchema, input.url); maskedUrl = maskDatabaseUrl(input.url); - urlFingerprint = fingerprintDatabaseUrl(input.url); + urlFingerprint = fingerprintDataDbConnection(input.url, internalSchema); const displayParts = getDatabaseUrlDisplayParts(input.url); displayHost = displayParts.displayHost; displayDatabase = displayParts.displayDatabase; @@ -154,6 +183,7 @@ export class DataDbPreflightService { }, ], classification: 'non-empty-unknown', + internalSchema, }); } @@ -167,14 +197,32 @@ export class DataDbPreflightService { displayHost, displayDatabase, classification: 'non-empty-unknown', + internalSchema, + }); + } + + if (!displayDatabase) { + const databaseResult = await this.inspectServerDatabases(input.url); + return this.buildResult({ + errors, + maskedUrl, + urlFingerprint, + displayHost, + displayDatabase, + serverVersion: databaseResult?.serverVersion, + availableDatabases: databaseResult?.availableDatabases, + requiresDatabaseSelection: true, + classification: 'non-empty-unknown', + internalSchema, }); } const client = this.clientFactory(input.url); try { const serverVersion = await this.getServerVersion(client); + const availableDatabases = await this.listAvailableDatabases(client).catch(() => undefined); const capabilities = await this.detectCapabilities(client, errors); - const classification = await this.classifyTarget(client, errors); + const classification = await this.classifyTarget(client, errors, internalSchema); return this.buildResult({ errors, @@ -183,14 +231,23 @@ export class DataDbPreflightService { displayHost, displayDatabase, serverVersion, + availableDatabases, capabilities, classification, + internalSchema, }); } catch (error) { + const missingDatabaseResult = this.isMissingDatabaseError(error) + ? await this.inspectServerDatabases(input.url) + : undefined; errors.push({ - code: 'CONNECTION_FAILED', - message: this.sanitizeErrorMessage(error, input.url), - remediation: 'Verify host, port, database name, credentials, and SSL settings.', + ...(this.isIpv6NetworkUnreachableError(error) + ? IPV6_NETWORK_UNREACHABLE_ERROR + : { + code: 'CONNECTION_FAILED', + message: this.sanitizeErrorMessage(error, input.url), + remediation: 'Verify host, port, database name, credentials, and SSL settings.', + }), }); return this.buildResult({ errors, @@ -198,7 +255,10 @@ export class DataDbPreflightService { urlFingerprint, displayHost, displayDatabase, + serverVersion: missingDatabaseResult?.serverVersion, + availableDatabases: missingDatabaseResult?.availableDatabases, classification: 'non-empty-unknown', + internalSchema, }); } finally { await client.destroy().catch(() => undefined); @@ -218,6 +278,8 @@ export class DataDbPreflightService { provider: binding.dataDbConnection.provider, displayHost: binding.dataDbConnection.displayHost ?? undefined, displayDatabase: binding.dataDbConnection.displayDatabase ?? undefined, + internalSchema: binding.dataDbConnection.internalSchema ?? undefined, + schemaVersion: binding.dataDbConnection.schemaVersion ?? null, lastValidatedAt: binding.dataDbConnection.lastValidatedAt?.toISOString(), lastError: binding.dataDbConnection.lastError ?? undefined, capabilities: binding.dataDbConnection.capabilities as @@ -238,28 +300,39 @@ export class DataDbPreflightService { urlFingerprint, displayHost, displayDatabase, + internalSchema, serverVersion, capabilities = DEFAULT_CAPABILITIES, classification, + availableDatabases, + requiresDatabaseSelection, }: { errors: IPreflightError[]; maskedUrl?: string; urlFingerprint?: string; displayHost?: string; displayDatabase?: string; + internalSchema?: string; serverVersion?: string; + availableDatabases?: string[]; + requiresDatabaseSelection?: boolean; capabilities?: IPreflightCapabilities; classification: IPreflightClassification; }): IDataDbPreflightVo { + const isUsableForNewSpace = + classification === 'empty' || classification === 'teable-managed-compatible'; return { - ok: errors.length === 0 && classification === 'empty', + ok: errors.length === 0 && isUsableForNewSpace, provider: 'postgres', maskedUrl, urlFingerprint, displayHost, displayDatabase, + internalSchema, serverVersion, classification, + availableDatabases, + requiresDatabaseSelection, capabilities, errors, }; @@ -271,6 +344,16 @@ export class DataDbPreflightService { return withoutRawUrl.replace(/:[^:@/]+@/g, ':***@'); } + private isIpv6NetworkUnreachableError(error: unknown) { + const code = + error && typeof error === 'object' ? (error as { code?: unknown }).code : undefined; + const message = error instanceof Error ? error.message : String(error); + return ( + (code === 'ENETUNREACH' || message.includes('ENETUNREACH')) && + /\b(?:[a-f0-9]{1,4}:){2,}[a-f0-9]{1,4}\b/i.test(message) + ); + } + private async validateNetwork(url: string): Promise { if (isPrivateNetworkAllowed()) { return null; @@ -294,6 +377,53 @@ export class DataDbPreflightService { return rows[0]?.server_version; } + private async listAvailableDatabases(client: IDataDbPreflightClient) { + const rows = normalizeRawRows<{ datname: string }>( + await client.raw(` + SELECT datname + FROM pg_database + WHERE datallowconn = true + AND datistemplate = false + ORDER BY datname ASC + `) + ); + return rows.map((row) => row.datname).filter(Boolean); + } + + private isMissingDatabaseError(error: unknown) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === '3D000') { + return true; + } + + const message = error instanceof Error ? error.message : String(error); + return /database ".+" does not exist/i.test(message); + } + + private getMaintenanceDatabaseUrls(url: string) { + const parsed = new URL(url); + const requestedDatabase = decodeURIComponent(parsed.pathname.replace(/^\//, '')); + const username = parsed.username ? decodeURIComponent(parsed.username) : undefined; + const candidates = [...MAINTENANCE_DATABASE_CANDIDATES, username].filter( + (database): database is string => Boolean(database && database !== requestedDatabase) + ); + return [...new Set(candidates)].map((database) => replaceDatabaseUrlDatabase(url, database)); + } + + private async inspectServerDatabases(url: string) { + for (const maintenanceUrl of this.getMaintenanceDatabaseUrls(url)) { + const client = this.clientFactory(maintenanceUrl); + try { + const serverVersion = await this.getServerVersion(client).catch(() => undefined); + const availableDatabases = await this.listAvailableDatabases(client).catch(() => []); + return { serverVersion, availableDatabases }; + } catch { + // Try the next well-known maintenance database. + } finally { + await client.destroy().catch(() => undefined); + } + } + } + private async detectCapabilities( client: IDataDbPreflightClient, errors: IPreflightError[] @@ -373,15 +503,10 @@ export class DataDbPreflightService { private async classifyTarget( client: IDataDbPreflightClient, - errors: IPreflightError[] + errors: IPreflightError[], + internalSchema: string ): Promise { - const [schemaRows, tableRows, functionRows] = await Promise.all([ - client.raw<{ schema_name: string }>(` - SELECT schema_name - FROM information_schema.schemata - WHERE schema_name NOT IN ('information_schema', 'pg_catalog', 'pg_toast') - AND schema_name NOT LIKE 'pg_%' - `), + const [tableRows, functionRows] = await Promise.all([ client.raw<{ table_schema: string; table_name: string }>(` SELECT table_schema, table_name FROM information_schema.tables @@ -392,40 +517,34 @@ export class DataDbPreflightService { client.raw<{ routine_name: string }>(` SELECT routine_name FROM information_schema.routines - WHERE routine_schema = 'public' + WHERE routine_schema = '${internalSchema}' `), ]); - const schemas = normalizeRawRows(schemaRows).map((row) => row.schema_name); const tables = normalizeRawRows(tableRows); const functions = normalizeRawRows(functionRows).map((row) => row.routine_name); - const bseSchemas = schemas.filter((schema) => schema.startsWith('bse')); - const publicTables = tables - .filter((table) => table.table_schema === 'public') + const internalTables = tables + .filter((table) => table.table_schema === internalSchema) .map((table) => table.table_name); - const unknownTables = tables.filter((table) => { - if (table.table_schema.startsWith('bse')) { - return false; - } - return table.table_schema !== 'public' || !ALLOWED_PUBLIC_TABLES.has(table.table_name); - }); - const managedTables = publicTables.filter((table) => DATA_PLANE_TABLES.includes(table)); + const unknownInternalTables = internalTables.filter( + (table) => !ALLOWED_INTERNAL_TABLES.has(table) + ); + const managedTables = internalTables.filter((table) => DATA_PLANE_TABLES.includes(table)); const managedFunctions = functions.filter((name) => DATA_PLANE_FUNCTIONS.includes(name)); - const hasManagedObjects = - bseSchemas.length > 0 || managedTables.length > 0 || managedFunctions.length > 0; + const hasManagedObjects = managedTables.length > 0 || managedFunctions.length > 0; const hasAllBaselineObjects = DATA_PLANE_TABLES.every((table) => managedTables.includes(table)) && DATA_PLANE_FUNCTIONS.every((func) => managedFunctions.includes(func)); - if (!hasManagedObjects && unknownTables.length === 0) { + if (!hasManagedObjects && unknownInternalTables.length === 0) { return 'empty'; } - if (unknownTables.length > 0) { + if (unknownInternalTables.length > 0) { errors.push({ code: 'NON_EMPTY_UNKNOWN_DATABASE', - message: 'The target database contains objects that are not managed by Teable', - remediation: 'Use an empty database or run a dedicated migration/adopt flow.', + message: `The ${internalSchema} schema already contains objects outside Teable management`, + remediation: `Use a database without a conflicting ${internalSchema} schema, or remove the unknown objects from that schema.`, }); return 'non-empty-unknown'; } diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.spec.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.spec.ts index 45ad79f0a5..7d38386fa9 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.spec.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.spec.ts @@ -5,11 +5,13 @@ const { executeCreateTableEndpoint, executeDeleteTableEndpoint, executeDuplicateTableEndpoint, + executeListTableRecordsEndpoint, executeRestoreTableEndpoint, } = vi.hoisted(() => ({ executeCreateTableEndpoint: vi.fn(), executeDeleteTableEndpoint: vi.fn(), executeDuplicateTableEndpoint: vi.fn(), + executeListTableRecordsEndpoint: vi.fn(), executeRestoreTableEndpoint: vi.fn(), })); @@ -17,6 +19,7 @@ vi.mock('@teable/v2-contract-http-implementation/handlers', () => ({ executeCreateTableEndpoint, executeDeleteTableEndpoint, executeDuplicateTableEndpoint, + executeListTableRecordsEndpoint, executeRestoreTableEndpoint, })); @@ -55,12 +58,14 @@ describe('TableOpenApiV2Service.createTable', () => { tableService?: Record; fieldOpenApiService?: Record; viewService?: Record; - recordService?: Record; prismaService?: Record; dbProvider?: Record; }) => new TableOpenApiV2Service( { + getContainerForBase: vi.fn().mockResolvedValue({ + resolve: vi.fn().mockReturnValue({}), + }), getContainer: vi.fn().mockResolvedValue({ resolve: vi.fn().mockReturnValue({}), }), @@ -71,7 +76,6 @@ describe('TableOpenApiV2Service.createTable', () => { (overrides?.tableService ?? {}) as never, (overrides?.fieldOpenApiService ?? {}) as never, (overrides?.viewService ?? {}) as never, - (overrides?.recordService ?? {}) as never, (overrides?.prismaService ?? {}) as never, { generateDbTableName: vi @@ -164,6 +168,33 @@ describe('TableOpenApiV2Service.createTable', () => { }); const recordIds = Array.from({ length: 1001 }, (_, index) => `rec${index + 1}`); + executeListTableRecordsEndpoint + .mockResolvedValueOnce({ + status: 200, + body: { + ok: true, + data: { + records: recordIds.slice(0, 1000).map((recordId) => ({ + id: recordId, + fields: {}, + })), + pagination: { hasMore: true }, + }, + }, + }) + .mockResolvedValueOnce({ + status: 200, + body: { + ok: true, + data: { + records: recordIds.slice(1000).map((recordId) => ({ + id: recordId, + fields: {}, + })), + pagination: { hasMore: false }, + }, + }, + }); const tableService = { getTableMeta: vi.fn().mockResolvedValue({ id: 'tblTest', @@ -190,27 +221,10 @@ describe('TableOpenApiV2Service.createTable', () => { }, ]), }; - const recordService = { - getDocIdsByQuery: vi - .fn() - .mockResolvedValueOnce({ ids: recordIds.slice(0, 1000) }) - .mockResolvedValueOnce({ ids: recordIds.slice(1000) }), - getSnapshotBulkWithPermission: vi.fn().mockResolvedValue( - [...recordIds].reverse().map((recordId) => ({ - data: { - id: recordId, - name: recordId, - fields: {}, - }, - })) - ), - }; - const service = createService({ tableService, fieldOpenApiService, viewService, - recordService, }); const result = await service.createTable('bseTest', { @@ -222,16 +236,32 @@ describe('TableOpenApiV2Service.createTable', () => { })), }); - expect(recordService.getDocIdsByQuery).toHaveBeenNthCalledWith(1, 'tblTest', { - viewId: 'viwDefault', - skip: 0, - take: 1000, - }); - expect(recordService.getDocIdsByQuery).toHaveBeenNthCalledWith(2, 'tblTest', { - viewId: 'viwDefault', - skip: 1000, - take: 1, - }); + expect(executeListTableRecordsEndpoint).toHaveBeenNthCalledWith( + 1, + {}, + { + tableId: 'tblTest', + viewId: 'viwDefault', + fieldKeyType: 'name', + cellFormat: 'json', + limit: 1000, + offset: 0, + }, + {} + ); + expect(executeListTableRecordsEndpoint).toHaveBeenNthCalledWith( + 2, + {}, + { + tableId: 'tblTest', + viewId: 'viwDefault', + fieldKeyType: 'name', + cellFormat: 'json', + limit: 1, + offset: 1000, + }, + {} + ); expect(result.records).toHaveLength(1001); expect(result.records[0]?.id).toBe('rec1'); expect(result.records[1000]?.id).toBe('rec1001'); @@ -247,12 +277,14 @@ describe('TableOpenApiV2Service.duplicateTable', () => { tableService?: Record; fieldOpenApiService?: Record; viewService?: Record; - recordService?: Record; prismaService?: Record; dbProvider?: Record; }) => new TableOpenApiV2Service( { + getContainerForBase: vi.fn().mockResolvedValue({ + resolve: vi.fn().mockReturnValue({}), + }), getContainer: vi.fn().mockResolvedValue({ resolve: vi.fn().mockReturnValue({}), }), @@ -263,7 +295,6 @@ describe('TableOpenApiV2Service.duplicateTable', () => { (overrides?.tableService ?? {}) as never, (overrides?.fieldOpenApiService ?? {}) as never, (overrides?.viewService ?? {}) as never, - (overrides?.recordService ?? {}) as never, (overrides?.prismaService ?? {}) as never, { generateDbTableName: vi diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.ts index d815271c14..452c0398d1 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api-v2.service.ts @@ -13,15 +13,15 @@ import { executeCreateTableEndpoint, executeDeleteTableEndpoint, executeDuplicateTableEndpoint, + executeListTableRecordsEndpoint, executeRestoreTableEndpoint, } from '@teable/v2-contract-http-implementation/handlers'; import { v2CoreTokens } from '@teable/v2-core'; -import type { ICommandBus } from '@teable/v2-core'; +import type { ICommandBus, IExecutionContext, IQueryBus } from '@teable/v2-core'; import { CustomHttpException, getDefaultCodeByStatus } from '../../../custom.exception'; import { InjectDbProvider } from '../../../db-provider/db.provider'; import { IDbProvider } from '../../../db-provider/db.provider.interface'; import { FieldOpenApiService } from '../../field/open-api/field-open-api.service'; -import { RecordService } from '../../record/record.service'; import { V2ContainerService } from '../../v2/v2-container.service'; import { V2ExecutionContextFactory } from '../../v2/v2-execution-context.factory'; import { ViewService } from '../../view/view.service'; @@ -38,7 +38,6 @@ export class TableOpenApiV2Service { private readonly tableService: TableService, private readonly fieldOpenApiService: FieldOpenApiService, private readonly viewService: ViewService, - private readonly recordService: RecordService, private readonly prismaService: PrismaService, @InjectDbProvider() private readonly dbProvider: IDbProvider ) {} @@ -60,9 +59,9 @@ export class TableOpenApiV2Service { } async createTable(baseId: string, createTableRo: ICreateTableWithDefault): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForBase(baseId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const normalizedCreateTableRo = await this.normalizeLegacyCreateTableRo(baseId, createTableRo); const result = await executeCreateTableEndpoint( context, @@ -74,7 +73,9 @@ export class TableOpenApiV2Service { return await this.buildLegacyCreateTableResponse( baseId, normalizedCreateTableRo, - result.body.data.table.id + result.body.data.table.id, + context, + container.resolve(v2CoreTokens.queryBus) ); } @@ -90,9 +91,9 @@ export class TableOpenApiV2Service { tableId: string, mode: 'soft' | 'permanent' = 'soft' ): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForBase(baseId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const result = await executeDeleteTableEndpoint( context, @@ -116,9 +117,9 @@ export class TableOpenApiV2Service { } async restoreTable(baseId: string, tableId: string): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForBase(baseId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const result = await executeRestoreTableEndpoint( context, @@ -145,9 +146,9 @@ export class TableOpenApiV2Service { tableId: string, duplicateTableRo: IDuplicateTableRo ): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForBase(baseId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const result = await executeDuplicateTableEndpoint( context, { @@ -185,14 +186,16 @@ export class TableOpenApiV2Service { private async buildLegacyCreateTableResponse( baseId: string, createTableRo: ICreateTableWithDefault, - tableId: string + tableId: string, + context: IExecutionContext, + queryBus: IQueryBus ): Promise { const table = await this.tableService.getTableMeta(baseId, tableId); const fields = await this.fieldOpenApiService.getFields(tableId, { filterHidden: false, }); const views = await this.viewService.getViews(tableId); - const records = await this.getCreatedRecords(table, createTableRo); + const records = await this.getCreatedRecords(table, createTableRo, context, queryBus); return { ...table, @@ -224,41 +227,46 @@ export class TableOpenApiV2Service { private async getCreatedRecords( table: ITableVo, - createTableRo: ICreateTableWithDefault + createTableRo: ICreateTableWithDefault, + context: IExecutionContext, + queryBus: IQueryBus ): Promise { const total = createTableRo.records?.length ?? 0; if (total === 0) { return []; } - const recordIds: string[] = []; - for (let skip = 0; skip < total; skip += 1000) { - const take = Math.min(1000, total - skip); - const { ids } = await this.recordService.getDocIdsByQuery(table.id, { - viewId: table.defaultViewId, - skip, - take, - }); - recordIds.push(...ids); - } + const records: IRecord[] = []; + for (let offset = 0; offset < total; offset += 1000) { + const limit = Math.min(1000, total - offset); + const result = await executeListTableRecordsEndpoint( + context, + { + tableId: table.id, + viewId: table.defaultViewId, + fieldKeyType: createTableRo.fieldKeyType ?? FieldKeyType.Name, + cellFormat: CellFormat.Json, + limit, + offset, + }, + queryBus + ); - if (recordIds.length === 0) { - return []; - } + if (result.status === 200 && result.body.ok) { + records.push(...(result.body.data.records as IRecord[])); + continue; + } - const snapshots = await this.recordService.getSnapshotBulkWithPermission( - table.id, - recordIds, - undefined, - createTableRo.fieldKeyType ?? FieldKeyType.Name, - CellFormat.Json - ); - const recordById = new Map( - snapshots.map((snapshot) => [snapshot.data.id, snapshot.data] as const) - ); + if (!result.body.ok) { + this.throwV2Error(result.body.error, result.status); + } + + throw new HttpException(internalServerError, HttpStatus.INTERNAL_SERVER_ERROR); + } - return recordIds - .map((recordId) => recordById.get(recordId)) + const recordById = new Map(records.map((record) => [record.id, record] as const)); + return records + .map((record) => recordById.get(record.id)) .filter((record): record is IRecord => record != null); } diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.spec.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.spec.ts index ff15974285..b2cdb973a9 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.spec.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.spec.ts @@ -133,16 +133,94 @@ describe('TableOpenApiService.prepareFields', () => { } ).prepareFields('tblTest', [nameFieldRo, linkFieldRo, lookupFieldRo, rollupFieldRo]); - expect(fieldSupplementService.prepareCreateFields).toHaveBeenCalledWith('tblTest', [ - nameFieldRo, - linkFieldRo, - ]); + expect(fieldSupplementService.prepareCreateFields).toHaveBeenCalledWith( + 'tblTest', + [nameFieldRo, linkFieldRo], + undefined, + { useTransaction: true } + ); expect(fieldSupplementService.prepareCreateField).toHaveBeenCalledTimes(2); expect(fields).toHaveLength(4); }); }); describe('TableOpenApiService.createTable', () => { + it('records legacy table creation schema operations in meta prisma', async () => { + const upsert = vi.fn().mockResolvedValue(undefined); + const prismaService = { + txClient: vi.fn().mockReturnValue({ + schemaOperation: { upsert }, + }), + }; + const cls = { + get: vi.fn().mockReturnValue('usrTest'), + }; + + const service = new TableOpenApiService( + prismaService as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + cls as never, + {} as never, + {} as never + ); + + await ( + service as unknown as { + completeTableCreateSchemaOperation: ( + baseId: string, + tableId: string, + recordCount: number + ) => Promise; + } + ).completeTableCreateSchemaOperation('bseTest', 'tblTest', 2); + + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { idempotencyKey: 'table.create:table:tblTest' }, + create: expect.objectContaining({ + type: 'table.create', + status: 'ready', + phase: 'ready', + resourceType: 'table', + resourceId: 'tblTest', + baseId: 'bseTest', + tableId: 'tblTest', + idempotencyKey: 'table.create:table:tblTest', + payload: { recordCount: 2 }, + createdBy: 'usrTest', + lastModifiedBy: 'usrTest', + }), + update: expect.objectContaining({ + type: 'table.create', + status: 'ready', + phase: 'ready', + resourceType: 'table', + resourceId: 'tblTest', + baseId: 'bseTest', + tableId: 'tblTest', + payload: { recordCount: 2 }, + lockedAt: null, + lockedBy: null, + lastError: null, + lastModifiedBy: 'usrTest', + }), + }) + ); + }); + it('drops the data table when metadata transaction rolls back after physical creation', async () => { const projectsTable = 'bseTest.projects'; const createError = new Error('field create failed'); @@ -176,8 +254,10 @@ describe('TableOpenApiService.createTable', () => { const prismaService = { $tx: vi.fn(async (fn: () => Promise) => fn()), }; - const dataPrismaService = { - $executeRawUnsafe: executeRawUnsafe, + const databaseRouter = { + executeDataPrismaForBase: vi.fn(async (_baseId: string, sql: string) => + executeRawUnsafe(sql) + ), }; const dbProvider = { dropTable: vi.fn().mockReturnValue('drop table "bseTest"."projects"'), @@ -185,7 +265,7 @@ describe('TableOpenApiService.createTable', () => { const service = new TableOpenApiService( prismaService as never, - dataPrismaService as never, + databaseRouter as never, {} as never, {} as never, {} as never, @@ -237,13 +317,13 @@ describe('TableOpenApiService.cleanTablesRelatedData', () => { const prismaService = { txClient: vi.fn().mockReturnValue(metaTxClient), }; - const dataPrismaService = { - txClient: vi.fn().mockReturnValue(dataTxClient), + const databaseRouter = { + dataPrismaForBase: vi.fn().mockResolvedValue(dataTxClient), }; const service = new TableOpenApiService( prismaService as never, - dataPrismaService as never, + databaseRouter as never, {} as never, {} as never, {} as never, @@ -282,6 +362,67 @@ describe('TableOpenApiService.cleanTablesRelatedData', () => { expect(dataTxClient.recordTrash.deleteMany).toHaveBeenCalledWith({ where: { tableId: { in: ['tblA', 'tblB'] } }, }); + expect(databaseRouter.dataPrismaForBase).toHaveBeenCalledWith('bseTest', undefined); + }); + + it('uses the routed data transaction client when requested', async () => { + const metaTxClient = { + field: { deleteMany: vi.fn().mockResolvedValue(undefined) }, + view: { deleteMany: vi.fn().mockResolvedValue(undefined) }, + attachmentsTable: { deleteMany: vi.fn().mockResolvedValue(undefined) }, + ops: { deleteMany: vi.fn().mockResolvedValue(undefined) }, + tableMeta: { deleteMany: vi.fn().mockResolvedValue(undefined) }, + trash: { deleteMany: vi.fn().mockResolvedValue(undefined) }, + }; + const dataTxClient = { + recordHistory: { deleteMany: vi.fn().mockResolvedValue(undefined) }, + tableTrash: { deleteMany: vi.fn().mockResolvedValue(undefined) }, + recordTrash: { deleteMany: vi.fn().mockResolvedValue(undefined) }, + }; + const dataRootClient = { + txClient: vi.fn().mockReturnValue(dataTxClient), + recordHistory: { deleteMany: vi.fn() }, + tableTrash: { deleteMany: vi.fn() }, + recordTrash: { deleteMany: vi.fn() }, + }; + const prismaService = { + txClient: vi.fn().mockReturnValue(metaTxClient), + }; + const databaseRouter = { + dataPrismaForBase: vi.fn().mockResolvedValue(dataRootClient), + }; + + const service = new TableOpenApiService( + prismaService as never, + databaseRouter as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never + ); + + await service.cleanTablesRelatedData('bseTest', ['tblA'], { useTransaction: true }); + + expect(databaseRouter.dataPrismaForBase).toHaveBeenCalledWith('bseTest', { + useTransaction: true, + }); + expect(dataRootClient.txClient).toHaveBeenCalled(); + expect(dataTxClient.recordHistory.deleteMany).toHaveBeenCalledWith({ + where: { tableId: { in: ['tblA'] } }, + }); + expect(dataRootClient.recordHistory.deleteMany).not.toHaveBeenCalled(); }); }); @@ -304,10 +445,10 @@ describe('TableOpenApiService.dropTables', () => { const prismaService = { txClient: vi.fn().mockReturnValue(metaTxClient), }; - const dataPrismaService = { - txClient: vi.fn().mockReturnValue({ - $executeRawUnsafe: executeRawUnsafe, - }), + const databaseRouter = { + executeDataPrismaForTable: vi.fn(async (_tableId: string, sql: string) => + executeRawUnsafe(sql) + ), }; const batchService = { saveRawOps: vi.fn().mockResolvedValue(undefined), @@ -321,7 +462,7 @@ describe('TableOpenApiService.dropTables', () => { const service = new TableOpenApiService( prismaService as never, - dataPrismaService as never, + databaseRouter as never, {} as never, {} as never, {} as never, @@ -366,8 +507,8 @@ describe('TableOpenApiService.sqlQuery', () => { }, $queryRawUnsafe: metaQueryRawUnsafe, }; - const dataPrismaService = { - $queryRawUnsafe: dataQueryRawUnsafe, + const databaseRouter = { + queryDataPrismaForTable: vi.fn((_tableId: string, sql: string) => dataQueryRawUnsafe(sql)), }; const recordService = { buildFilterSortQuery: vi.fn().mockResolvedValue({ @@ -379,7 +520,7 @@ describe('TableOpenApiService.sqlQuery', () => { const service = new TableOpenApiService( prismaService as never, - dataPrismaService as never, + databaseRouter as never, {} as never, {} as never, recordService as never, @@ -440,8 +581,11 @@ describe('TableOpenApiService.updateDbTableName', () => { const dataTxClient = { $executeRawUnsafe: dataExecuteRawUnsafe, }; - const dataPrismaService = { - $tx: vi.fn(async (fn: (prisma: typeof dataTxClient) => Promise) => fn(dataTxClient)), + const databaseRouter = { + dataPrismaTransactionForTable: vi.fn( + async (_tableId: string, fn: (prisma: typeof dataTxClient) => Promise) => + fn(dataTxClient) + ), }; const tableService = { updateTable: vi.fn().mockResolvedValue(undefined), @@ -457,7 +601,7 @@ describe('TableOpenApiService.updateDbTableName', () => { const service = new TableOpenApiService( prismaService as never, - dataPrismaService as never, + databaseRouter as never, {} as never, {} as never, {} as never, @@ -513,8 +657,11 @@ describe('TableOpenApiService.updateDbTableName', () => { const dataTxClient = { $executeRawUnsafe: dataExecuteRawUnsafe, }; - const dataPrismaService = { - $tx: vi.fn(async (fn: (prisma: typeof dataTxClient) => Promise) => fn(dataTxClient)), + const databaseRouter = { + dataPrismaTransactionForTable: vi.fn( + async (_tableId: string, fn: (prisma: typeof dataTxClient) => Promise) => + fn(dataTxClient) + ), }; const dbProvider = { joinDbTableName: vi.fn().mockReturnValue(renamedOrdersTable), @@ -527,7 +674,7 @@ describe('TableOpenApiService.updateDbTableName', () => { const service = new TableOpenApiService( prismaService as never, - dataPrismaService as never, + databaseRouter as never, {} as never, {} as never, {} as never, diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts index c11d44bbdb..2e18fb3475 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts @@ -20,11 +20,11 @@ import { IdPrefix, TemplateRolePermission, actionPrefixMap, + getRandomString, getBasePermission, isLinkLookupOptions, } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; -import { PrismaService } from '@teable/db-main-prisma'; +import { PrismaService, ProvisionState } from '@teable/db-main-prisma'; import type { ICreateRecordsRo, ICreateTableRo, @@ -44,6 +44,8 @@ import { InjectDbProvider } from '../../../db-provider/db.provider'; import { IDbProvider } from '../../../db-provider/db.provider.interface'; import { EventEmitterService } from '../../../event-emitter/event-emitter.service'; import { Events } from '../../../event-emitter/events'; +import type { IDataDbRoutingOptions } from '../../../global/data-db-client-manager.service'; +import { DatabaseRouter } from '../../../global/database-router.service'; import { RawOpType } from '../../../share-db/interface'; import type { IClsStore } from '../../../types/cls'; import { updateOrder } from '../../../utils/update-order'; @@ -66,7 +68,7 @@ export class TableOpenApiService { private logger = new Logger(TableOpenApiService.name); constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly recordOpenApiService: RecordOpenApiService, private readonly viewOpenApiService: ViewOpenApiService, private readonly recordService: RecordService, @@ -144,7 +146,58 @@ export class TableOpenApiService { return this.recordOpenApiService.createRecords(tableId, data); } + private async completeTableCreateSchemaOperation( + baseId: string, + tableId: string, + recordCount: number + ) { + const now = new Date(); + const userId = this.cls.get('user.id'); + + await this.prismaService.txClient().schemaOperation.upsert({ + where: { + idempotencyKey: `table.create:table:${tableId}`, + }, + create: { + id: `sgo${getRandomString(16)}`, + type: 'table.create', + status: 'ready', + phase: 'ready', + resourceType: 'table', + resourceId: tableId, + baseId, + tableId, + idempotencyKey: `table.create:table:${tableId}`, + payload: { recordCount }, + attempts: 0, + maxAttempts: 8, + nextRunAt: now, + createdBy: userId, + lastModifiedTime: now, + lastModifiedBy: userId, + }, + update: { + type: 'table.create', + status: 'ready', + phase: 'ready', + resourceType: 'table', + resourceId: tableId, + baseId, + tableId, + payload: { recordCount }, + attempts: 0, + maxAttempts: 8, + nextRunAt: now, + lockedAt: null, + lockedBy: null, + lastError: null, + lastModifiedBy: userId, + }, + }); + } + private async cleanupCreatedDataTable( + baseId: string, table: Pick | undefined, reason: unknown ) { @@ -153,7 +206,10 @@ export class TableOpenApiService { } try { - await this.dataPrismaService.$executeRawUnsafe(this.dbProvider.dropTable(table.dbTableName)); + await this.databaseRouter.executeDataPrismaForBase( + baseId, + this.dbProvider.dropTable(table.dbTableName) + ); await this.tableMutationCacheInvalidator.invalidateDroppedTable(table.dbTableName); } catch (cleanupError) { this.logger.error( @@ -178,7 +234,9 @@ export class TableOpenApiService { const fields: IFieldVo[] = await this.fieldSupplementService.prepareCreateFields( tableId, - independentFields + independentFields, + undefined, + { useTransaction: true } ); const allFieldRos = independentFields.concat(dependentFields); @@ -193,7 +251,8 @@ export class TableOpenApiService { const computedFieldVo = await this.fieldSupplementService.prepareCreateField( tableId, fieldRo, - batchFieldVos + batchFieldVos, + { useTransaction: true } ); fieldVoMap.set(fieldRo, computedFieldVo); } @@ -253,6 +312,11 @@ export class TableOpenApiService { const orderB = fieldIdOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; return orderA - orderB; }); + await this.completeTableCreateSchemaOperation( + baseId, + tableId, + tableRo.records?.length ?? 0 + ); return { ...tableVo, @@ -263,7 +327,7 @@ export class TableOpenApiService { }; }) .catch(async (error) => { - await this.cleanupCreatedDataTable(createdTable, error); + await this.cleanupCreatedDataTable(baseId, createdTable, error); throw error; }); @@ -315,6 +379,7 @@ export class TableOpenApiService { where: { baseId, deletedTime: null, + provisionState: ProvisionState.ready, id: includeTableIds ? { in: includeTableIds } : undefined, }, }); @@ -375,7 +440,7 @@ export class TableOpenApiService { async () => { await this.dropTables(tableIds); await this.cleanTaskRelatedData(tableIds); - await this.cleanTablesRelatedData(baseId, tableIds); + await this.cleanTablesRelatedData(baseId, tableIds, { useTransaction: true }); }, { timeout: this.thresholdConfig.bigTransactionTimeout, @@ -388,15 +453,17 @@ export class TableOpenApiService { where: { id: { in: tableIds } }, select: { dbTableName: true, version: true, id: true, baseId: true, deletedTime: true }, }); - const dataPrisma = this.dataPrismaService.txClient(); - for (const table of tables) { if (!table.deletedTime) { await this.batchService.saveRawOps(table.baseId, RawOpType.Del, IdPrefix.Table, [ { docId: table.id, version: table.version }, ]); } - await dataPrisma.$executeRawUnsafe(this.dbProvider.dropTable(table.dbTableName)); + await this.databaseRouter.executeDataPrismaForTable( + table.id, + this.dbProvider.dropTable(table.dbTableName), + { useTransaction: true } + ); await this.tableMutationCacheInvalidator.invalidateDroppedTable(table.dbTableName); } } @@ -441,7 +508,11 @@ export class TableOpenApiService { }); } - async cleanTablesRelatedData(baseId: string, tableIds: string[]) { + async cleanTablesRelatedData( + baseId: string, + tableIds: string[], + routingOptions?: IDataDbRoutingOptions + ) { const metaPrisma = this.prismaService.txClient(); // delete field for table @@ -474,7 +545,11 @@ export class TableOpenApiService { }); // record history and trash snapshots live with the physical record tables on the data DB. - const dataPrisma = this.dataPrismaService.txClient(); + const routedDataPrisma = await this.databaseRouter.dataPrismaForBase(baseId, routingOptions); + const dataPrisma = + 'txClient' in routedDataPrisma && typeof routedDataPrisma.txClient === 'function' + ? routedDataPrisma.txClient() + : routedDataPrisma; // clean record history for table await dataPrisma.recordHistory.deleteMany({ @@ -585,7 +660,7 @@ export class TableOpenApiService { `; this.logger.log('sqlQuery:sql:combine: ' + combinedQuery); - return this.dataPrismaService.$queryRawUnsafe(combinedQuery); + return this.databaseRouter.queryDataPrismaForTable(tableId, combinedQuery); } async updateName(baseId: string, tableId: string, name: string) { @@ -652,7 +727,8 @@ export class TableOpenApiService { const renameSql = this.dbProvider.renameTableName(oldDbTableName, dbTableName); const rollbackRenameSql = this.dbProvider.renameTableName(dbTableName, oldDbTableName); - await this.dataPrismaService.$tx( + await this.databaseRouter.dataPrismaTransactionForTable( + tableId, async (prisma) => { for (const sql of renameSql) { await prisma.$executeRawUnsafe(sql); @@ -697,8 +773,9 @@ export class TableOpenApiService { await this.tableService.updateTable(baseId, tableId, { dbTableName }); }); } catch (error) { - await this.dataPrismaService - .$tx( + await this.databaseRouter + .dataPrismaTransactionForTable( + tableId, async (prisma) => { for (const sql of rollbackRenameSql) { await prisma.$executeRawUnsafe(sql); @@ -722,7 +799,7 @@ export class TableOpenApiService { async shuffle(baseId: string) { const tables = await this.prismaService.tableMeta.findMany({ - where: { baseId, deletedTime: null }, + where: { baseId, deletedTime: null, provisionState: ProvisionState.ready }, select: { id: true }, orderBy: { order: 'asc' }, }); @@ -744,6 +821,7 @@ export class TableOpenApiService { where: { baseId, deletedTime: null, + provisionState: ProvisionState.ready, }, select: { order: true, @@ -762,7 +840,7 @@ export class TableOpenApiService { const table = await this.prismaService.tableMeta .findFirstOrThrow({ select: { order: true, id: true }, - where: { baseId, id: tableId, deletedTime: null }, + where: { baseId, id: tableId, deletedTime: null, provisionState: ProvisionState.ready }, }) .catch(() => { throw new CustomHttpException(`Table ${tableId} not found`, HttpErrorCode.NOT_FOUND, { @@ -775,7 +853,7 @@ export class TableOpenApiService { const anchorTable = await this.prismaService.tableMeta .findFirstOrThrow({ select: { order: true, id: true }, - where: { baseId, id: anchorId, deletedTime: null }, + where: { baseId, id: anchorId, deletedTime: null, provisionState: ProvisionState.ready }, }) .catch(() => { throw new CustomHttpException(`Anchor ${anchorId} not found`, HttpErrorCode.NOT_FOUND, { @@ -796,6 +874,7 @@ export class TableOpenApiService { where: { baseId, deletedTime: null, + provisionState: ProvisionState.ready, order: whereOrder, }, orderBy: { order: align }, diff --git a/apps/nestjs-backend/src/features/table/open-api/v2-table-mutation-cache-invalidator.service.ts b/apps/nestjs-backend/src/features/table/open-api/v2-table-mutation-cache-invalidator.service.ts index 4fdcf30c65..0f9ef2c5d5 100644 --- a/apps/nestjs-backend/src/features/table/open-api/v2-table-mutation-cache-invalidator.service.ts +++ b/apps/nestjs-backend/src/features/table/open-api/v2-table-mutation-cache-invalidator.service.ts @@ -9,7 +9,10 @@ export class V2TableMutationCacheInvalidatorService implements TableMutationCach constructor(private readonly v2ContainerService: V2ContainerService) {} async invalidateDroppedTable(dbTableName: string): Promise { - const container = await this.v2ContainerService.getContainer(); + const baseId = dbTableName.split('.')[0]; + const container = baseId + ? await this.v2ContainerService.getContainerForBase(baseId) + : await this.v2ContainerService.getContainer(); const rootDb = container.resolve(v2DataDbTokens.db); invalidateUndoCaptureTableCache(dbTableName, rootDb); } diff --git a/apps/nestjs-backend/src/features/table/table-duplicate.service.ts b/apps/nestjs-backend/src/features/table/table-duplicate.service.ts index 9a2748ca93..67f1fa476b 100644 --- a/apps/nestjs-backend/src/features/table/table-duplicate.service.ts +++ b/apps/nestjs-backend/src/features/table/table-duplicate.service.ts @@ -10,7 +10,6 @@ import { } from '@teable/core'; import type { View } from '@teable/db-main-prisma'; import { PrismaService, ProvisionState } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { CreateRecordAction, type IDuplicateTableRo, @@ -26,6 +25,7 @@ import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; import { EventEmitterService } from '../../event-emitter/event-emitter.service'; import { Events } from '../../event-emitter/events'; +import { DataDbClientManager } from '../../global/data-db-client-manager.service'; import { CUSTOM_KNEX, DATA_KNEX } from '../../global/knex/knex.module'; import type { IClsStore } from '../../types/cls'; import { DataLoaderService } from '../data-loader/data-loader.service'; @@ -37,6 +37,15 @@ import { ROW_ORDER_FIELD_PREFIX } from '../view/constant'; import { createViewVoByRaw } from '../view/model/factory'; import { TableService } from './table.service'; +type IDataPrismaExecutor = { + $executeRawUnsafe(query: string, ...values: unknown[]): Promise; + $queryRawUnsafe(query: string, ...values: unknown[]): Promise; +}; + +type IDataPrismaScopedClient = IDataPrismaExecutor & { + txClient?: () => IDataPrismaExecutor; +}; + @Injectable() export class TableDuplicateService { private logger = new Logger(TableDuplicateService.name); @@ -44,7 +53,6 @@ export class TableDuplicateService { constructor( private readonly cls: ClsService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, private readonly tableService: TableService, private readonly fieldOpenService: FieldOpenApiService, private readonly fieldDuplicateService: FieldDuplicateService, @@ -52,9 +60,30 @@ export class TableDuplicateService { @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel(CUSTOM_KNEX) private readonly knex: Knex, @InjectModel(DATA_KNEX) private readonly dataKnex: Knex, - private readonly eventEmitterService: EventEmitterService + private readonly eventEmitterService: EventEmitterService, + private readonly dataDbClientManager: DataDbClientManager ) {} + private getDataPrismaExecutor(prisma: IDataPrismaScopedClient): IDataPrismaExecutor { + return prisma.txClient?.() ?? prisma; + } + + private async assertSameDataDatabaseForRecordCopy(sourceTableId: string, targetBaseId: string) { + const [source, target] = await Promise.all([ + this.dataDbClientManager.getDataDatabaseForTable(sourceTableId, { useTransaction: true }), + this.dataDbClientManager.getDataDatabaseForBase(targetBaseId, { useTransaction: true }), + ]); + + if (source.cacheKey === target.cacheKey) { + return; + } + + throw new CustomHttpException( + 'Duplicating records across different space data databases is not supported yet', + HttpErrorCode.VALIDATION_ERROR + ); + } + private disableTableDomainDataLoader() { if (!this.cls.isActive()) { return; @@ -108,18 +137,32 @@ export class TableDuplicateService { await this.repairDuplicateOmit(sourceToTargetFieldMap, sourceToTargetViewMap, newTableVo.id); if (includeRecords) { + await this.assertSameDataDatabaseForRecordCopy(tableId, baseId); + const dataPrisma = this.getDataPrismaExecutor( + await this.dataDbClientManager.dataPrismaForTable(newTableVo.id, { + useTransaction: true, + }) + ); const count = await this.duplicateTableData( dbTableName, newTableVo.dbTableName, sourceToTargetViewMap, sourceToTargetFieldMap, - [] + [], + dataPrisma ); - await this.duplicateAttachments(sourceTableId, newTableVo.id, sourceToTargetFieldMap); + await this.duplicateAttachments( + sourceTableId, + newTableVo.id, + sourceToTargetFieldMap, + dataPrisma + ); await this.duplicateLinkJunction( { [sourceTableId]: newTableVo.id }, - sourceToTargetFieldMap + sourceToTargetFieldMap, + true, + dataPrisma ); await this.emitTableDuplicateAuditLog(newTableVo.id, count, duplicateRo); } @@ -181,9 +224,10 @@ export class TableDuplicateService { targetDbTableName: string, sourceToTargetViewMap: Record, sourceToTargetFieldMap: Record, - crossBaseLinkInfo: { dbFieldName: string; selfKeyName: string; isMultipleCellValue: boolean }[] + crossBaseLinkInfo: { dbFieldName: string; selfKeyName: string; isMultipleCellValue: boolean }[], + dataPrisma: IDataPrismaExecutor ) { - const prisma = this.dataPrismaService.txClient(); + const prisma = dataPrisma; const metaPrisma = this.prismaService.txClient(); const qb = this.dataKnex.queryBuilder(); @@ -262,11 +306,11 @@ export class TableDuplicateService { ); for (const name of newRowColumns) { - await this.createRowOrderField(targetDbTableName, name.slice(6)); + await this.createRowOrderField(targetDbTableName, name.slice(6), prisma); } for (const name of newFkColumns) { - await this.createFkField(targetDbTableName, name.slice(5)); + await this.createFkField(targetDbTableName, name.slice(5), prisma); } // following field should not be duplicated @@ -330,8 +374,12 @@ export class TableDuplicateService { return Number(sourceTableCountResult[0]?.count || 0); } - private async createRowOrderField(dbTableName: string, viewId: string) { - const prisma = this.dataPrismaService.txClient(); + private async createRowOrderField( + dbTableName: string, + viewId: string, + dataPrisma: IDataPrismaExecutor + ) { + const prisma = dataPrisma; const rowIndexFieldName = `${ROW_ORDER_FIELD_PREFIX}_${viewId}`; @@ -365,8 +413,12 @@ export class TableDuplicateService { await prisma.$executeRawUnsafe(createRowIndexSQL); } - private async createFkField(dbTableName: string, fieldId: string) { - const prisma = this.dataPrismaService.txClient(); + private async createFkField( + dbTableName: string, + fieldId: string, + dataPrisma: IDataPrismaExecutor + ) { + const prisma = dataPrisma; const fkFieldName = `__fk_${fieldId}`; @@ -585,7 +637,9 @@ export class TableDuplicateService { // Only attempt to rename if a physical column exists. // Link fields do not create standard columns; self-link symmetric side definitely doesn't. - const dataPrisma = this.dataPrismaService.txClient(); + const dataPrisma = this.getDataPrismaExecutor( + await this.dataDbClientManager.dataPrismaForTable(targetTableId, { useTransaction: true }) + ); const exists = await this.dbProvider.checkColumnExist( targetDbTableName, genDbFieldName, @@ -856,10 +910,12 @@ export class TableDuplicateService { async duplicateAttachments( sourceTableId: string, targetTableId: string, - fieldIdMap: Record + fieldIdMap: Record, + dataPrisma: IDataPrismaExecutor ) { - const prisma = this.prismaService.txClient(); - const attachmentFieldRaws = await prisma.field.findMany({ + const prisma = dataPrisma; + const metaPrisma = this.prismaService.txClient(); + const attachmentFieldRaws = await metaPrisma.field.findMany({ where: { tableId: sourceTableId, type: FieldType.Attachment, @@ -895,11 +951,12 @@ export class TableDuplicateService { async duplicateLinkJunction( tableIdMap: Record, fieldIdMap: Record, - allowCrossBase: boolean = true, + allowCrossBase: boolean, + routedDataPrisma: IDataPrismaExecutor, disconnectedLinkFieldIds?: string[] ) { const metaPrisma = this.prismaService.txClient(); - const dataPrisma = this.dataPrismaService.txClient(); + const dataPrisma = routedDataPrisma; const sourceLinkFieldRaws = await metaPrisma.field.findMany({ where: { tableId: { in: Object.keys(tableIdMap) }, diff --git a/apps/nestjs-backend/src/features/table/table-index.service.ts b/apps/nestjs-backend/src/features/table/table-index.service.ts index 563e608fe4..e8c1fb3170 100644 --- a/apps/nestjs-backend/src/features/table/table-index.service.ts +++ b/apps/nestjs-backend/src/features/table/table-index.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { FieldType, HttpErrorCode } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { TableIndex } from '@teable/openapi'; import type { IGetAbnormalVo, ITableIndexType, IToggleIndexRo } from '@teable/openapi'; import { ClsService } from 'nestjs-cls'; @@ -10,6 +9,8 @@ import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.confi import { CustomHttpException } from '../../custom.exception'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; +import type { IDataDbRoutingOptions } from '../../global/data-db-client-manager.service'; +import { DatabaseRouter } from '../../global/database-router.service'; import type { IClsStore } from '../../types/cls'; import type { IFieldInstance } from '../field/model/factory'; import { createFieldInstanceByRaw } from '../field/model/factory'; @@ -21,7 +22,7 @@ export class TableIndexService { constructor( private readonly cls: ClsService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, @InjectDbProvider() private readonly dbProvider: IDbProvider ) {} @@ -44,7 +45,8 @@ export class TableIndexService { async getActivatedTableIndexes( tableId: string, - type: TableIndex = TableIndex.search + type: TableIndex = TableIndex.search, + routingOptions?: IDataDbRoutingOptions ): Promise { const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({ where: { @@ -57,11 +59,11 @@ export class TableIndexService { if (type === TableIndex.search) { const searchIndexSql = this.dbProvider.searchIndex().getExistTableIndexSql(dbTableName); - const [{ exists: searchIndexExist }] = await this.dataPrismaService.$queryRawUnsafe< + const [{ exists: searchIndexExist }] = await this.databaseRouter.queryDataPrismaForTable< { exists: boolean; }[] - >(searchIndexSql); + >(tableId, searchIndexSql, routingOptions); const result: ITableIndexType[] = []; @@ -110,13 +112,19 @@ export class TableIndexService { }, }); - await this.toggleSearchIndex(dbTableName, fields, !index.includes(type)); + await this.toggleSearchIndex(tableId, dbTableName, fields, !index.includes(type)); } - async toggleSearchIndex(dbTableName: string, fields: IFieldInstance[], toEnable: boolean) { + async toggleSearchIndex( + tableId: string, + dbTableName: string, + fields: IFieldInstance[], + toEnable: boolean + ) { if (toEnable) { const sqls = this.dbProvider.searchIndex().getCreateIndexSql(dbTableName, fields); - return await this.dataPrismaService.$tx( + return await this.databaseRouter.dataPrismaTransactionForTable( + tableId, async (prisma) => { for (let i = 0; i < sqls.length; i++) { const sql = sqls[i]; @@ -142,7 +150,7 @@ export class TableIndexService { const sql = this.dbProvider.searchIndex().getDropIndexSql(dbTableName); try { - return await this.dataPrismaService.$executeRawUnsafe(sql); + return await this.databaseRouter.executeDataPrismaForTable(tableId, sql); } catch (error) { console.error('toggleSearchIndex:drop:error', sql); throw new CustomHttpException( @@ -167,11 +175,15 @@ export class TableIndexService { if (index.includes(TableIndex.search)) { const sql = this.dbProvider.searchIndex().getDeleteSingleIndexSql(dbTableName, field); // Execute within current transaction if present to keep boundaries consistent - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql); } } - async createSearchFieldSingleIndex(tableId: string, fieldInstance: IFieldInstance) { + async createSearchFieldSingleIndex( + tableId: string, + fieldInstance: IFieldInstance, + routingOptions?: IDataDbRoutingOptions + ) { if (fieldInstance.type === FieldType.Button) { return; } @@ -180,10 +192,10 @@ export class TableIndexService { select: { dbTableName: true }, }); const { dbTableName } = tableRaw; - const index = await this.getActivatedTableIndexes(tableId); + const index = await this.getActivatedTableIndexes(tableId, TableIndex.search, routingOptions); const sql = this.dbProvider.searchIndex().createSingleIndexSql(dbTableName, fieldInstance); if (index.includes(TableIndex.search) && sql) { - await this.dataPrismaService.txClient().$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql, routingOptions); } } @@ -202,7 +214,7 @@ export class TableIndexService { const sql = this.dbProvider .searchIndex() .getUpdateSingleIndexNameSql(dbTableName, oldField, newField); - await this.dataPrismaService.$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql); } } @@ -214,7 +226,7 @@ export class TableIndexService { const { dbTableName } = tableRaw; const sql = this.dbProvider.searchIndex().getIndexInfoSql(dbTableName); - return this.dataPrismaService.$queryRawUnsafe(sql); + return this.databaseRouter.queryDataPrismaForTable(tableId, sql); } async getAbnormalTableIndex(tableId: string, type: TableIndex) { @@ -267,7 +279,8 @@ export class TableIndexService { const dropSql = this.dbProvider.searchIndex().getDropIndexSql(dbTableName); const fieldInstances = await this.getSearchIndexFields(tableId); const createSqls = this.dbProvider.searchIndex().getCreateIndexSql(dbTableName, fieldInstances); - await this.dataPrismaService.$tx( + await this.databaseRouter.dataPrismaTransactionForTable( + tableId, async (prisma) => { await prisma.$executeRawUnsafe(dropSql); for (let i = 0; i < createSqls.length; i++) { diff --git a/apps/nestjs-backend/src/features/table/table.service.spec.ts b/apps/nestjs-backend/src/features/table/table.service.spec.ts index 87f3ee1883..7bb512ead2 100644 --- a/apps/nestjs-backend/src/features/table/table.service.spec.ts +++ b/apps/nestjs-backend/src/features/table/table.service.spec.ts @@ -38,4 +38,56 @@ describe('TableService', () => { const dbTableName = service.generateValidName(''); expect(dbTableName).toBe('unnamed'); }); + + it('uses the routed data transaction client when creating a physical table', async () => { + const dataTxClient = { + $executeRawUnsafe: vi.fn().mockResolvedValue(0), + }; + const dataRootClient = { + txClient: vi.fn().mockReturnValue(dataTxClient), + $executeRawUnsafe: vi.fn().mockResolvedValue(0), + }; + const metaTxClient = { + tableMeta: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: 'tblTest', dbTableName: 'bseTest.orders' }), + update: vi.fn().mockResolvedValue(undefined), + }, + }; + const mockedService = new TableService( + { get: vi.fn().mockReturnValue('usrTest') } as never, + { txClient: vi.fn().mockReturnValue(metaTxClient) } as never, + { dataPrismaForBase: vi.fn().mockResolvedValue(dataRootClient) } as never, + {} as never, + { + driver: 'sqlite', + generateDbTableName: vi.fn((_baseId: string, name: string) => `bseTest.${name}`), + dropTable: vi.fn((name: string) => `drop table ${name}`), + } as never, + { + schema: { + createTable: vi.fn().mockReturnValue({ + toSQL: () => [{ sql: 'create table "bseTest"."orders" ("__id" text)' }], + }), + }, + } as never + ); + + await ( + mockedService as unknown as { + createDBTable( + baseId: string, + tableRo: { name: string }, + createTable?: boolean + ): Promise; + } + ).createDBTable('bseTest', { name: 'orders' }); + + expect(dataRootClient.txClient).toHaveBeenCalled(); + expect(dataTxClient.$executeRawUnsafe).toHaveBeenCalledWith( + 'create table "bseTest"."orders" ("__id" text)' + ); + expect(dataRootClient.$executeRawUnsafe).not.toHaveBeenCalled(); + }); }); diff --git a/apps/nestjs-backend/src/features/table/table.service.ts b/apps/nestjs-backend/src/features/table/table.service.ts index 1c1bb7fef1..1e4808a6cc 100644 --- a/apps/nestjs-backend/src/features/table/table.service.ts +++ b/apps/nestjs-backend/src/features/table/table.service.ts @@ -12,7 +12,6 @@ import { } from '@teable/core'; import type { Prisma } from '@teable/db-main-prisma'; import { PrismaService, ProvisionState } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import type { ICreateTableRo, ITableVo } from '@teable/openapi'; import { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; @@ -20,13 +19,22 @@ import { ClsService } from 'nestjs-cls'; import { CustomHttpException } from '../../custom.exception'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; +import { DataDbClientManager } from '../../global/data-db-client-manager.service'; +import { DATA_KNEX } from '../../global/knex'; import type { IReadonlyAdapterService } from '../../share-db/interface'; import { RawOpType } from '../../share-db/interface'; import type { IClsStore } from '../../types/cls'; import { convertNameToValidCharacter } from '../../utils/name-conversion'; -import { DATA_KNEX } from '../../global/knex'; import { BatchService } from '../calculation/batch.service'; +type IDataPrismaExecutor = { + $executeRawUnsafe(query: string, ...values: unknown[]): PromiseLike; +}; + +type IDataPrismaScopedClient = IDataPrismaExecutor & { + txClient?: () => IDataPrismaExecutor; +}; + @Injectable() export class TableService implements IReadonlyAdapterService { private logger = new Logger(TableService.name); @@ -34,7 +42,7 @@ export class TableService implements IReadonlyAdapterService { constructor( private readonly cls: ClsService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly dataDbClientManager: DataDbClientManager, private readonly batchService: BatchService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @InjectModel(DATA_KNEX) private readonly knex: Knex @@ -51,11 +59,13 @@ export class TableService implements IReadonlyAdapterService { .$executeRaw`select id from base where id = ${baseId} for update`; } - private async cleanupCreatedDataTable(dbTableName: string, reason: unknown) { + private async cleanupCreatedDataTable( + dataPrisma: IDataPrismaExecutor, + dbTableName: string, + reason: unknown + ) { try { - await this.dataPrismaService - .txClient() - .$executeRawUnsafe(this.dbProvider.dropTable(dbTableName)); + await dataPrisma.$executeRawUnsafe(this.dbProvider.dropTable(dbTableName)); } catch (cleanupError) { this.logger.error( `Failed to clean up data table ${dbTableName} after table metadata provisioning error: ${ @@ -66,6 +76,10 @@ export class TableService implements IReadonlyAdapterService { } } + private getDataPrismaExecutor(prisma: IDataPrismaScopedClient): IDataPrismaExecutor { + return prisma.txClient?.() ?? prisma; + } + private async createDBTable(baseId: string, tableRo: ICreateTableRo, createTable = true) { const userId = this.cls.get('user.id'); await this.lockBaseRow(baseId); @@ -152,8 +166,12 @@ export class TableService implements IReadonlyAdapterService { table.integer('__version').notNullable(); }); + let dataPrisma: IDataPrismaExecutor | undefined; try { - const dataPrisma = this.dataPrismaService.txClient(); + const scopedDataPrisma = await this.dataDbClientManager.dataPrismaForBase(baseId, { + useTransaction: true, + }); + dataPrisma = this.getDataPrismaExecutor(scopedDataPrisma); for (const sql of createTableSchema.toSQL()) { await dataPrisma.$executeRawUnsafe(sql.sql); } @@ -165,7 +183,9 @@ export class TableService implements IReadonlyAdapterService { }, }); } catch (error) { - await this.cleanupCreatedDataTable(dbTableName, error); + if (dataPrisma) { + await this.cleanupCreatedDataTable(dataPrisma, dbTableName, error); + } await this.prismaService.txClient().tableMeta.update({ where: { id: tableMeta.id }, data: { @@ -210,7 +230,7 @@ export class TableService implements IReadonlyAdapterService { async getTableMeta(baseId: string, tableId: string): Promise { const tableMeta = await this.prismaService.txClient().tableMeta.findFirst({ - where: { id: tableId, baseId, deletedTime: null }, + where: { id: tableId, baseId, deletedTime: null, provisionState: ProvisionState.ready }, }); if (!tableMeta) { @@ -440,7 +460,7 @@ export class TableService implements IReadonlyAdapterService { ): Promise[]> { const { ignoreDefaultViewId } = ops; const tables = await this.prismaService.txClient().tableMeta.findMany({ - where: { baseId, id: { in: ids }, deletedTime: null }, + where: { baseId, id: { in: ids }, deletedTime: null, provisionState: ProvisionState.ready }, orderBy: { order: 'asc' }, }); @@ -473,6 +493,7 @@ export class TableService implements IReadonlyAdapterService { where: { deletedTime: null, baseId, + provisionState: ProvisionState.ready, ...(projectionTableIds ? { id: { in: projectionTableIds }, diff --git a/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.spec.ts b/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.spec.ts index f243cce325..47dd92e2d8 100644 --- a/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.spec.ts +++ b/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.spec.ts @@ -26,8 +26,11 @@ describe('TableTrashListener', () => { create: vi.fn(), }, }; + const dataDbClientManager = { + dataPrismaForTable: vi.fn().mockResolvedValue(dataPrismaService), + }; const listener = new TableTrashListener( - dataPrismaService as never, + dataDbClientManager as never, { bigTransactionTimeout: 30_000, } as never @@ -44,6 +47,9 @@ describe('TableTrashListener', () => { await listener.recordDeleteListener(payload); + expect(dataDbClientManager.dataPrismaForTable).toHaveBeenCalledWith('tblTrashListenerTable', { + useTransaction: true, + }); expect(dataPrismaService.$tx).toHaveBeenCalledWith(expect.any(Function), { timeout: 30_000, }); @@ -95,14 +101,18 @@ describe('TableTrashListener', () => { create: tableTrashCreate, }, }; + const dataDbClientManager = { + dataPrismaForTable: vi.fn().mockResolvedValue(dataPrismaService), + }; const listener = new TableTrashListener( - dataPrismaService as never, + dataDbClientManager as never, { bigTransactionTimeout: 30_000, } as never ); const fieldPayload: IDeleteFieldsPayload = { operationId: 'oprTrashListenerField', + windowId: 'winTrashListenerWindow', tableId: 'tblTrashListenerTable', userId: 'usrTrashListenerUser', fields: [{ id: 'fldTrashListenerField', name: 'Name' }] as never, @@ -110,6 +120,7 @@ describe('TableTrashListener', () => { }; const viewPayload: IDeleteViewPayload = { operationId: 'oprTrashListenerView', + windowId: 'winTrashListenerWindow', tableId: 'tblTrashListenerTable', userId: 'usrTrashListenerUser', viewId: 'viwTrashListenerView', @@ -118,6 +129,16 @@ describe('TableTrashListener', () => { await listener.fieldDeleteListener(fieldPayload); await listener.viewDeleteListener(viewPayload); + expect(dataDbClientManager.dataPrismaForTable).toHaveBeenNthCalledWith( + 1, + 'tblTrashListenerTable', + { useTransaction: true } + ); + expect(dataDbClientManager.dataPrismaForTable).toHaveBeenNthCalledWith( + 2, + 'tblTrashListenerTable', + { useTransaction: true } + ); expect(tableTrashCreate).toHaveBeenNthCalledWith(1, { data: { id: 'oprTrashListenerField', diff --git a/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts b/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts index 385cc3d6a8..806d12dff3 100644 --- a/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts +++ b/apps/nestjs-backend/src/features/trash/listener/table-trash.listener.ts @@ -1,21 +1,70 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { generateRecordTrashId } from '@teable/core'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { ResourceType } from '@teable/openapi'; import { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config'; import { Events } from '../../../event-emitter/events'; +import { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import { IDeleteFieldsPayload } from '../../undo-redo/operations/delete-fields.operation'; import { IDeleteRecordsPayload } from '../../undo-redo/operations/delete-records.operation'; import { IDeleteViewPayload } from '../../undo-redo/operations/delete-view.operation'; +type ITableTrashDataPrisma = { + tableTrash: { + create(args: unknown): PromiseLike; + }; + recordTrash: { + createMany(args: unknown): PromiseLike; + }; +}; + +type IScopedTableTrashDataPrisma = ITableTrashDataPrisma & { + txClient?: () => ITableTrashDataPrisma; + $tx?: ( + fn: (prisma: ITableTrashDataPrisma) => Promise, + options?: { timeout?: number } + ) => Promise; + $transaction?: ( + fn: (prisma: ITableTrashDataPrisma) => Promise, + options?: { timeout?: number } + ) => Promise; +}; + @Injectable() export class TableTrashListener { constructor( - private readonly dataPrismaService: DataPrismaService, + private readonly dataDbClientManager: DataDbClientManager, @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig ) {} + private getDataPrismaExecutor(prisma: IScopedTableTrashDataPrisma): ITableTrashDataPrisma { + return prisma.txClient?.() ?? prisma; + } + + private async dataPrismaForTable(tableId: string): Promise { + return (await this.dataDbClientManager.dataPrismaForTable(tableId, { + useTransaction: true, + })) as IScopedTableTrashDataPrisma; + } + + private async dataPrismaTransactionForTable( + tableId: string, + fn: (prisma: ITableTrashDataPrisma) => Promise, + options?: { timeout?: number } + ): Promise { + const prisma = await this.dataPrismaForTable(tableId); + + if (prisma.$tx) { + return await prisma.$tx(fn, options); + } + + if (prisma.$transaction) { + return await prisma.$transaction(fn, options); + } + + return await fn(this.getDataPrismaExecutor(prisma)); + } + @OnEvent(Events.OPERATION_RECORDS_DELETE) async recordDeleteListener(payload: IDeleteRecordsPayload) { const { operationId, userId, tableId, records } = payload; @@ -25,7 +74,8 @@ export class TableTrashListener { const recordIds = records.map((record) => record.id); const createdTime = new Date(); - await this.dataPrismaService.$tx( + await this.dataPrismaTransactionForTable( + tableId, async (prisma) => { await prisma.tableTrash.create({ data: { @@ -65,7 +115,9 @@ export class TableTrashListener { if (!operationId) return; - await this.dataPrismaService.tableTrash.create({ + const dataPrisma = this.getDataPrismaExecutor(await this.dataPrismaForTable(tableId)); + + await dataPrisma.tableTrash.create({ data: { id: operationId, tableId, @@ -82,7 +134,9 @@ export class TableTrashListener { if (!operationId) return; - await this.dataPrismaService.tableTrash.create({ + const dataPrisma = this.getDataPrismaExecutor(await this.dataPrismaForTable(tableId)); + + await dataPrisma.tableTrash.create({ data: { id: operationId, tableId, diff --git a/apps/nestjs-backend/src/features/trash/trash.controller.ts b/apps/nestjs-backend/src/features/trash/trash.controller.ts index adbdf7c896..b397c97031 100644 --- a/apps/nestjs-backend/src/features/trash/trash.controller.ts +++ b/apps/nestjs-backend/src/features/trash/trash.controller.ts @@ -46,13 +46,14 @@ export class TrashController { @TokenAccess() async restoreTrash( @Param('trashId') trashId: string, + @Query('tableId') tableId: string | undefined, @Res({ passthrough: true }) response: Response ): Promise { await this.prepareRestoreTableCanary(trashId, response); if (this.cls.get('useV2')) { return await this.trashService.restoreTrashV2(trashId); } - return await this.trashService.restoreTrash(trashId); + return await this.trashService.restoreTrash(trashId, tableId); } @Delete('reset-items') diff --git a/apps/nestjs-backend/src/features/trash/trash.service.ts b/apps/nestjs-backend/src/features/trash/trash.service.ts index c367c4e84a..56f6056010 100644 --- a/apps/nestjs-backend/src/features/trash/trash.service.ts +++ b/apps/nestjs-backend/src/features/trash/trash.service.ts @@ -28,6 +28,7 @@ import { ClsService } from 'nestjs-cls'; import type { ICreateFieldsOperation } from '../../cache/types'; import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config'; import { CustomHttpException } from '../../custom.exception'; +import { DataDbClientManager } from '../../global/data-db-client-manager.service'; import { META_KNEX } from '../../global/knex'; import type { IPerformanceCacheStore } from '../../performance-cache'; import { PerformanceCacheService } from '../../performance-cache'; @@ -51,12 +52,22 @@ import { resolveV2TrashRecordDisplayName } from './v2-trash-record-name'; type IRecordTrashSnapshot = IDeleteRecordsPayload['records'][number]; +type ITrashDataPrisma = { + tableTrash: DataPrismaService['tableTrash']; + recordTrash: DataPrismaService['recordTrash']; +}; + +type IScopedTrashDataPrisma = ITrashDataPrisma & { + txClient?: () => ITrashDataPrisma; + $tx?: DataPrismaService['$tx']; + $transaction?: DataPrismaService['$transaction']; +}; + @Injectable() export class TrashService { constructor( protected readonly performanceCacheService: PerformanceCacheService, protected readonly prismaService: PrismaService, - protected readonly dataPrismaService: DataPrismaService, protected readonly cls: ClsService, protected readonly userService: UserService, protected readonly permissionService: PermissionService, @@ -71,10 +82,42 @@ export class TrashService { protected readonly v2ContainerService: V2ContainerService, protected readonly v2ExecutionContextFactory: V2ExecutionContextFactory, protected readonly canaryService: CanaryService, + protected readonly dataDbClientManager: DataDbClientManager, @ThresholdConfig() protected readonly thresholdConfig: IThresholdConfig, @InjectModel(META_KNEX) protected readonly knex: Knex ) {} + private getTrashDataPrismaExecutor(prisma: IScopedTrashDataPrisma): ITrashDataPrisma { + return prisma.txClient?.() ?? prisma; + } + + private async trashDataPrismaForTable(tableId: string): Promise { + return (await this.dataDbClientManager.dataPrismaForTable(tableId, { + useTransaction: true, + })) as IScopedTrashDataPrisma; + } + + private async trashDataPrismaTransactionForTable( + tableId: string, + fn: (prisma: ITrashDataPrisma) => Promise + ): Promise { + const prisma = await this.trashDataPrismaForTable(tableId); + + if (prisma.$tx) { + return await prisma.$tx(fn, { + timeout: this.thresholdConfig.bigTransactionTimeout, + }); + } + + if (prisma.$transaction) { + return await prisma.$transaction(fn, { + timeout: this.thresholdConfig.bigTransactionTimeout, + }); + } + + return await fn(this.getTrashDataPrismaExecutor(prisma)); + } + async getAuthorizedSpacesAndBases() { const userId = this.cls.get('user.id'); const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id); @@ -273,11 +316,11 @@ export class TrashService { } try { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const tableQueryService = container.resolve( v2CoreTokens.tableQueryService ); - const queryContext = await this.v2ExecutionContextFactory.createContext(); + const queryContext = await this.v2ExecutionContextFactory.createContext(container); const tableResult = await tableQueryService.getById(queryContext, tableIdResult.value); return tableResult.isOk() ? tableResult.value : null; @@ -393,7 +436,10 @@ export class TrashService { }, {} as IResourceMapVo); } case TableTrashType.Record: { - const recordList = await this.dataPrismaService.recordTrash.findMany({ + const dataPrisma = this.getTrashDataPrismaExecutor( + await this.trashDataPrismaForTable(tableId) + ); + const recordList = await dataPrisma.recordTrash.findMany({ where: { tableId, recordId: { in: resourceIds } }, select: { recordId: true, @@ -428,7 +474,8 @@ export class TrashService { true ); - const list = await this.dataPrismaService.tableTrash.findMany({ + const dataPrisma = this.getTrashDataPrismaExecutor(await this.trashDataPrismaForTable(tableId)); + const list = await dataPrisma.tableTrash.findMany({ where: { tableId, }, @@ -756,15 +803,29 @@ export class TrashService { } } - async restoreTableResource(trashId: string) { + async restoreTableResource(trashId: string, routedTableId?: string) { const accessTokenId = this.cls.get('accessTokenId'); + if (!routedTableId) { + throw new CustomHttpException( + `Table id is required to restore table trash ${trashId}`, + HttpErrorCode.VALIDATION_ERROR, + { + localization: { + i18nKey: 'httpErrors.trash.tableNotFound', + }, + } + ); + } + const lookupDataPrisma = this.getTrashDataPrismaExecutor( + await this.trashDataPrismaForTable(routedTableId) + ); const { tableId, resourceType, snapshot: originSnapshot, createdTime, - } = await this.dataPrismaService.tableTrash + } = await lookupDataPrisma.tableTrash .findUniqueOrThrow({ where: { id: trashId }, select: { @@ -785,6 +846,9 @@ export class TrashService { } ); }); + const dataPrisma = routedTableId + ? lookupDataPrisma + : this.getTrashDataPrismaExecutor(await this.trashDataPrismaForTable(tableId)); await this.permissionService.validPermissions( tableId, @@ -829,7 +893,7 @@ export class TrashService { createdTime: true; }; }>; - const recordTrashRows = await this.dataPrismaService.recordTrash.findMany({ + const recordTrashRows = await dataPrisma.recordTrash.findMany({ where: { tableId, recordId: { in: recordIds } }, select: { id: true, @@ -871,19 +935,14 @@ export class TrashService { }, true ); - await this.dataPrismaService.$tx( - async (prisma) => { - await prisma.recordTrash.deleteMany({ - where: { id: { in: matchedRecordTrashRows.map(({ id }) => id) } }, - }); - await prisma.tableTrash.delete({ - where: { id: trashId }, - }); - }, - { - timeout: this.thresholdConfig.bigTransactionTimeout, - } - ); + await this.trashDataPrismaTransactionForTable(tableId, async (prisma) => { + await prisma.recordTrash.deleteMany({ + where: { id: { in: matchedRecordTrashRows.map(({ id }) => id) } }, + }); + await prisma.tableTrash.delete({ + where: { id: trashId }, + }); + }); return; } default: @@ -898,7 +957,7 @@ export class TrashService { ); } - await this.dataPrismaService.tableTrash.delete({ + await dataPrisma.tableTrash.delete({ where: { id: trashId }, }); } @@ -969,9 +1028,9 @@ export class TrashService { }; } - async restoreTrash(trashId: string) { + async restoreTrash(trashId: string, tableId?: string) { if (trashId.startsWith(IdPrefix.Operation)) { - return await this.restoreTableResource(trashId); + return await this.restoreTableResource(trashId, tableId); } await this.prismaService.$tx(async (prisma) => { @@ -1066,7 +1125,8 @@ export class TrashService { true ); - const deletedList = await this.dataPrismaService.tableTrash.findMany({ + const dataPrisma = this.getTrashDataPrismaExecutor(await this.trashDataPrismaForTable(tableId)); + const deletedList = await dataPrisma.tableTrash.findMany({ where: { tableId }, select: { resourceType: true, snapshot: true }, }); @@ -1117,7 +1177,7 @@ export class TrashService { }); }); - await this.dataPrismaService.$tx(async (prisma) => { + await this.trashDataPrismaTransactionForTable(tableId, async (prisma) => { await prisma.recordTrash.deleteMany({ where: { tableId }, }); diff --git a/apps/nestjs-backend/src/features/trash/v2-record-trash.service.ts b/apps/nestjs-backend/src/features/trash/v2-record-trash.service.ts index aacff76fe7..d3359bc387 100644 --- a/apps/nestjs-backend/src/features/trash/v2-record-trash.service.ts +++ b/apps/nestjs-backend/src/features/trash/v2-record-trash.service.ts @@ -59,7 +59,7 @@ export class V2RecordTrashService { return; } - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const db = container.resolve(v2DataDbTokens.db) as TrashDbClient; const recordIds = records.map((record) => record.id); const createdTime = new Date(); diff --git a/apps/nestjs-backend/src/features/trash/v2-table-trash.service.spec.ts b/apps/nestjs-backend/src/features/trash/v2-table-trash.service.spec.ts index 2167c417ba..908c09583f 100644 --- a/apps/nestjs-backend/src/features/trash/v2-table-trash.service.spec.ts +++ b/apps/nestjs-backend/src/features/trash/v2-table-trash.service.spec.ts @@ -84,6 +84,8 @@ const createV2ContainerService = () => { selectFrom: vi.fn().mockReturnValue(selectQuery), }; const dataDb = { + deleteFrom: vi.fn().mockReturnValue(deleteQuery), + insertInto: vi.fn().mockReturnValue(insertQuery), transaction: vi.fn(() => ({ execute: vi.fn(async () => undefined), })), @@ -108,6 +110,7 @@ const createV2ContainerService = () => { selectQuery, service: { getContainer: vi.fn().mockResolvedValue(container), + getContainerForTable: vi.fn().mockResolvedValue(container), }, }; }; @@ -117,6 +120,7 @@ describe('V2TableTrashedProjection', () => { const deletedTime = new Date('2026-03-12T00:00:00.000Z'); const { db, + dataDb, deleteQuery, insertQuery, selectQuery, @@ -152,12 +156,29 @@ describe('V2TableTrashedProjection', () => { deleted_time: deletedTime, deleted_by: 'usrTestUserId', }); + expect(v2ContainerService.getContainerForTable).toHaveBeenCalledWith('tblaaaaaaaaaaaaaaaa'); + expect(dataDb.deleteFrom).toHaveBeenCalledWith('table_trash'); + expect(dataDb.insertInto).toHaveBeenCalledWith('table_trash'); + expect(insertQuery.values).toHaveBeenCalledWith({ + id: expect.any(String), + table_id: 'tblaaaaaaaaaaaaaaaa', + resource_type: ResourceType.Table, + snapshot: JSON.stringify({ + tableId: 'tblaaaaaaaaaaaaaaaa', + baseId: 'bseaaaaaaaaaaaaaaaa', + name: 'Trash Me', + fieldIds: [], + viewIds: [], + }), + created_by: 'usrTestUserId', + created_time: deletedTime, + }); }); }); describe('V2TableRestoredProjection', () => { it('removes a table trash entry after restore', async () => { - const { db, deleteQuery, service: v2ContainerService } = createV2ContainerService(); + const { db, dataDb, deleteQuery, service: v2ContainerService } = createV2ContainerService(); const projection = new V2TableRestoredProjection(v2ContainerService as never); const context = { actorId: ActorId.create('usrTestUserId')._unsafeUnwrap(), @@ -176,12 +197,17 @@ describe('V2TableRestoredProjection', () => { expect(db.deleteFrom).toHaveBeenCalledWith('trash'); expect(deleteQuery.where).toHaveBeenNthCalledWith(1, 'resource_id', '=', 'tblaaaaaaaaaaaaaaaa'); expect(deleteQuery.where).toHaveBeenNthCalledWith(2, 'resource_type', '=', ResourceType.Table); + expect(v2ContainerService.getContainerForTable).toHaveBeenCalledWith('tblaaaaaaaaaaaaaaaa'); + expect(dataDb.deleteFrom).toHaveBeenCalledWith('table_trash'); }); }); describe('V2RecordTrashService', () => { it('persists deleted records through the v2 Kysely db transaction', async () => { const operations: Array<{ table: string; values: unknown }> = []; + type ITrashTransaction = { + insertInto: ReturnType; + }; const trx = { insertInto: vi.fn((table: string) => ({ values: (values: unknown) => ({ @@ -194,10 +220,10 @@ describe('V2RecordTrashService', () => { }), }), })), - }; + } satisfies ITrashTransaction; const db = { transaction: vi.fn(() => ({ - execute: async (callback: (trx: typeof trx) => Promise) => callback(trx), + execute: async (callback: (trx: ITrashTransaction) => Promise) => callback(trx), })), }; const container = { @@ -209,7 +235,7 @@ describe('V2RecordTrashService', () => { }), }; const v2ContainerService = { - getContainer: vi.fn().mockResolvedValue(container), + getContainerForTable: vi.fn().mockResolvedValue(container), }; const service = new V2RecordTrashService(v2ContainerService as never); const tracer = new FakeTracer(); @@ -231,7 +257,7 @@ describe('V2RecordTrashService', () => { await service.persistDeletedRecords(payload, { tracer } as Pick); - expect(v2ContainerService.getContainer).toHaveBeenCalled(); + expect(v2ContainerService.getContainerForTable).toHaveBeenCalledWith('tblaaaaaaaaaaaaaaaa'); expect(db.transaction).toHaveBeenCalled(); expect(operations).toHaveLength(2); expect(operations[0]).toEqual({ diff --git a/apps/nestjs-backend/src/features/trash/v2-table-trash.service.ts b/apps/nestjs-backend/src/features/trash/v2-table-trash.service.ts index e6a1de793d..b5cb59b919 100644 --- a/apps/nestjs-backend/src/features/trash/v2-table-trash.service.ts +++ b/apps/nestjs-backend/src/features/trash/v2-table-trash.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import type { IRecord } from '@teable/core'; import { generateOperationId } from '@teable/core'; import { ResourceType } from '@teable/openapi'; -import { v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { v2DataDbTokens, v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { ProjectionHandler, RecordsDeleted, @@ -45,6 +45,19 @@ type IAttachmentsTableDb = V1TeableDatabase & { }; /* eslint-enable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/naming-convention */ +type ITableTrashDataDb = V1TeableDatabase & { + table_trash: { + id: string; + table_id: string; + resource_type: string; + snapshot: string; + created_by: string; + created_time: Date; + }; +}; +/* eslint-enable @typescript-eslint/naming-convention */ + @ProjectionHandler(RecordsDeleted) export class V2RecordsDeletedTableTrashProjection implements IEventHandler { constructor(private readonly v2RecordTrashService: V2RecordTrashService) {} @@ -209,6 +222,34 @@ export class V2TableTrashedProjection implements IEventHandler { }) .execute(); + const dataContainer = await this.v2ContainerService.getContainerForTable( + event.tableId.toString() + ); + const dataDb = dataContainer.resolve>(v2DataDbTokens.db); + await dataDb + .deleteFrom('table_trash') + .where('table_id', '=', event.tableId.toString()) + .where('resource_type', '=', ResourceType.Table) + .execute(); + + await dataDb + .insertInto('table_trash') + .values({ + id: nanoid(), + table_id: event.tableId.toString(), + resource_type: ResourceType.Table, + snapshot: JSON.stringify({ + tableId: event.tableId.toString(), + baseId: event.baseId.toString(), + name: event.tableName.toString(), + fieldIds: event.fieldIds.map((fieldId) => fieldId.toString()), + viewIds: event.viewIds.map((viewId) => viewId.toString()), + }), + created_by: context.actorId.toString(), + created_time: table.deleted_time, + }) + .execute(); + return ok(undefined); } } @@ -229,6 +270,16 @@ export class V2TableRestoredProjection implements IEventHandler { .where('resource_type', '=', ResourceType.Table) .execute(); + const dataContainer = await this.v2ContainerService.getContainerForTable( + event.tableId.toString() + ); + const dataDb = dataContainer.resolve>(v2DataDbTokens.db); + await dataDb + .deleteFrom('table_trash') + .where('table_id', '=', event.tableId.toString()) + .where('resource_type', '=', ResourceType.Table) + .execute(); + return ok(undefined); } } diff --git a/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.service.ts b/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.service.ts index 13ce9c36bf..89a48f1acf 100644 --- a/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.service.ts +++ b/apps/nestjs-backend/src/features/undo-redo/open-api/undo-redo.service.ts @@ -306,9 +306,9 @@ export class UndoRedoService { mode: 'undo' | 'redo' ): Promise | undefined> { try { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); context.windowId = windowId; const commandResult = @@ -393,11 +393,11 @@ export class UndoRedoService { return; } - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const stackService = container.resolve( v2CoreTokens.undoRedoService ); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); context.windowId = windowId; const replayContext = toUndoRedoStackReplayContext(context); diff --git a/apps/nestjs-backend/src/features/undo-redo/operations/delete-fields.operation.ts b/apps/nestjs-backend/src/features/undo-redo/operations/delete-fields.operation.ts index a561e90154..c2897a3156 100644 --- a/apps/nestjs-backend/src/features/undo-redo/operations/delete-fields.operation.ts +++ b/apps/nestjs-backend/src/features/undo-redo/operations/delete-fields.operation.ts @@ -2,18 +2,31 @@ import { FieldKeyType } from '@teable/core'; import type { DataPrismaService } from '@teable/db-data-prisma'; import type { IDeleteFieldsOperation } from '../../../cache/types'; import { OperationName } from '../../../cache/types'; +import type { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import type { FieldOpenApiService } from '../../field/open-api/field-open-api.service'; import type { RecordOpenApiService } from '../../record/open-api/record-open-api.service'; import type { ICreateFieldsPayload } from './create-fields.operation'; export type IDeleteFieldsPayload = ICreateFieldsPayload & { operationId: string }; + +type IScopedDataPrismaService = DataPrismaService & { + txClient?: () => DataPrismaService; +}; + export class DeleteFieldsOperation { constructor( private readonly fieldOpenApiService: FieldOpenApiService, private readonly recordOpenApiService: RecordOpenApiService, - private readonly dataPrismaService: DataPrismaService + private readonly dataDbClientManager: DataDbClientManager ) {} + private async dataPrismaForTable(tableId: string): Promise { + const dataPrisma = (await this.dataDbClientManager.dataPrismaForTable(tableId, { + useTransaction: true, + })) as IScopedDataPrismaService; + return (dataPrisma.txClient?.() ?? dataPrisma) as DataPrismaService; + } + async event2Operation(payload: IDeleteFieldsPayload): Promise { return { name: OperationName.DeleteFields, @@ -32,14 +45,17 @@ export class DeleteFieldsOperation { const { params, result, operationId = '' } = operation; const { tableId } = params; const { fields, records } = result; + const dataPrisma = await this.dataPrismaForTable(tableId); - const count = await this.dataPrismaService.tableTrash.count({ + const count = await dataPrisma.tableTrash.count({ where: { id: operationId }, }); if (operationId && Number(count) === 0) return operation; - await this.fieldOpenApiService.createFields(tableId, fields); + await this.fieldOpenApiService.createFields(tableId, fields, undefined, { + restoreViewOrder: true, + }); if (records) { await this.recordOpenApiService.updateRecords(tableId, { @@ -49,7 +65,7 @@ export class DeleteFieldsOperation { } if (operationId) { - await this.dataPrismaService.tableTrash.delete({ + await dataPrisma.tableTrash.delete({ where: { id: operationId }, }); } diff --git a/apps/nestjs-backend/src/features/undo-redo/operations/delete-records.operation.ts b/apps/nestjs-backend/src/features/undo-redo/operations/delete-records.operation.ts index 9a16777a21..748fc9be8a 100644 --- a/apps/nestjs-backend/src/features/undo-redo/operations/delete-records.operation.ts +++ b/apps/nestjs-backend/src/features/undo-redo/operations/delete-records.operation.ts @@ -4,6 +4,7 @@ import type { DataPrismaService } from '@teable/db-data-prisma'; import type { IDeleteRecordsOperation } from '../../../cache/types'; import { OperationName } from '../../../cache/types'; import type { IThresholdConfig } from '../../../configs/threshold.config'; +import type { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import type { RecordOpenApiService } from '../../record/open-api/record-open-api.service'; export interface IDeleteRecordsPayload { @@ -17,10 +18,42 @@ export interface IDeleteRecordsPayload { export class DeleteRecordsOperation { constructor( private readonly recordOpenApiService: RecordOpenApiService, - private readonly dataPrismaService: DataPrismaService, - private readonly thresholdConfig: IThresholdConfig + private readonly thresholdConfig: IThresholdConfig, + private readonly dataDbClientManager: DataDbClientManager ) {} + private async dataPrismaForTable(tableId: string): Promise { + return (await this.dataDbClientManager.dataPrismaForTable(tableId, { + useTransaction: true, + })) as DataPrismaService; + } + + private async dataPrismaExecutorForTable(tableId: string): Promise { + const dataPrisma = await this.dataPrismaForTable(tableId); + return (dataPrisma.txClient?.() ?? dataPrisma) as DataPrismaService; + } + + private async dataPrismaTransactionForTable( + tableId: string, + fn: (prisma: DataPrismaService) => Promise + ): Promise { + const dataPrisma = await this.dataPrismaForTable(tableId); + + if (dataPrisma.$tx) { + return await dataPrisma.$tx(fn as never, { + timeout: this.thresholdConfig.bigTransactionTimeout, + }); + } + + if (dataPrisma.$transaction) { + return await dataPrisma.$transaction(fn as never, { + timeout: this.thresholdConfig.bigTransactionTimeout, + }); + } + + return await fn((dataPrisma.txClient?.() ?? dataPrisma) as DataPrismaService); + } + async event2Operation(payload: IDeleteRecordsPayload): Promise { return { name: OperationName.DeleteRecords, @@ -36,8 +69,9 @@ export class DeleteRecordsOperation { async undo(operation: IDeleteRecordsOperation) { const { params, result, operationId = '' } = operation; + const dataPrisma = await this.dataPrismaExecutorForTable(params.tableId); - const count = await this.dataPrismaService.tableTrash.count({ + const count = await dataPrisma.tableTrash.count({ where: { id: operationId }, }); @@ -51,22 +85,17 @@ export class DeleteRecordsOperation { if (operationId) { const recordIds = result.records.map((record) => record.id); - await this.dataPrismaService.$tx( - async (prisma) => { - await prisma.tableTrash.delete({ - where: { id: operationId }, - }); - await prisma.recordTrash.deleteMany({ - where: { - tableId: params.tableId, - recordId: { in: recordIds }, - }, - }); - }, - { - timeout: this.thresholdConfig.bigTransactionTimeout, - } - ); + await this.dataPrismaTransactionForTable(params.tableId, async (prisma) => { + await prisma.tableTrash.delete({ + where: { id: operationId }, + }); + await prisma.recordTrash.deleteMany({ + where: { + tableId: params.tableId, + recordId: { in: recordIds }, + }, + }); + }); } return operation; diff --git a/apps/nestjs-backend/src/features/undo-redo/operations/delete-trash-routing.spec.ts b/apps/nestjs-backend/src/features/undo-redo/operations/delete-trash-routing.spec.ts index e343be3831..6614e82b86 100644 --- a/apps/nestjs-backend/src/features/undo-redo/operations/delete-trash-routing.spec.ts +++ b/apps/nestjs-backend/src/features/undo-redo/operations/delete-trash-routing.spec.ts @@ -18,10 +18,13 @@ describe('trash-backed undo operations', () => { delete: vi.fn().mockResolvedValue(undefined), }, }; + const dataDbClientManager = { + dataPrismaForTable: vi.fn().mockResolvedValue(dataPrismaService), + }; const operation = new DeleteFieldsOperation( fieldOpenApiService as never, recordOpenApiService as never, - dataPrismaService as never + dataDbClientManager as never ); await operation.undo({ @@ -37,9 +40,15 @@ describe('trash-backed undo operations', () => { expect(dataPrismaService.tableTrash.count).toHaveBeenCalledWith({ where: { id: 'otrash1' }, }); - expect(fieldOpenApiService.createFields).toHaveBeenCalledWith('tbl1', [ - { id: 'fld1', name: 'Name' }, - ]); + expect(dataDbClientManager.dataPrismaForTable).toHaveBeenCalledWith('tbl1', { + useTransaction: true, + }); + expect(fieldOpenApiService.createFields).toHaveBeenCalledWith( + 'tbl1', + [{ id: 'fld1', name: 'Name' }], + undefined, + { restoreViewOrder: true } + ); expect(recordOpenApiService.updateRecords).toHaveBeenCalledWith('tbl1', { fieldKeyType: FieldKeyType.Id, records: [{ id: 'rec1' }], @@ -66,10 +75,13 @@ describe('trash-backed undo operations', () => { }); }), }; + const dataDbClientManager = { + dataPrismaForTable: vi.fn().mockResolvedValue(dataPrismaService), + }; const operation = new DeleteRecordsOperation( recordOpenApiService as never, - dataPrismaService as never, - { bigTransactionTimeout: 60_000 } as never + { bigTransactionTimeout: 60_000 } as never, + dataDbClientManager as never ); await operation.undo({ @@ -85,6 +97,9 @@ describe('trash-backed undo operations', () => { fieldKeyType: FieldKeyType.Id, records: [{ id: 'rec1' }, { id: 'rec2' }], }); + expect(dataDbClientManager.dataPrismaForTable).toHaveBeenCalledWith('tbl1', { + useTransaction: true, + }); expect(dataPrismaService.$tx).toHaveBeenCalled(); expect(tableTrashDelete).toHaveBeenCalledWith({ where: { id: 'otrash2' }, @@ -108,10 +123,13 @@ describe('trash-backed undo operations', () => { delete: vi.fn().mockResolvedValue(undefined), }, }; + const dataDbClientManager = { + dataPrismaForTable: vi.fn().mockResolvedValue(dataPrismaService), + }; const operation = new DeleteViewOperation( viewOpenApiService as never, viewService as never, - dataPrismaService as never + dataDbClientManager as never ); await operation.undo({ diff --git a/apps/nestjs-backend/src/features/undo-redo/operations/delete-view.operation.ts b/apps/nestjs-backend/src/features/undo-redo/operations/delete-view.operation.ts index 11ba0a4022..f5b0984054 100644 --- a/apps/nestjs-backend/src/features/undo-redo/operations/delete-view.operation.ts +++ b/apps/nestjs-backend/src/features/undo-redo/operations/delete-view.operation.ts @@ -1,6 +1,7 @@ import type { DataPrismaService } from '@teable/db-data-prisma'; import type { IDeleteViewOperation } from '../../../cache/types'; import { OperationName } from '../../../cache/types'; +import type { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import type { ViewOpenApiService } from '../../view/open-api/view-open-api.service'; import type { ViewService } from '../../view/view.service'; @@ -12,13 +13,24 @@ export interface IDeleteViewPayload { userId: string; } +type IScopedDataPrismaService = DataPrismaService & { + txClient?: () => DataPrismaService; +}; + export class DeleteViewOperation { constructor( private readonly viewOpenApiService: ViewOpenApiService, private readonly viewService: ViewService, - private readonly dataPrismaService: DataPrismaService + private readonly dataDbClientManager: DataDbClientManager ) {} + private async dataPrismaForTable(tableId: string): Promise { + const dataPrisma = (await this.dataDbClientManager.dataPrismaForTable(tableId, { + useTransaction: true, + })) as IScopedDataPrismaService; + return (dataPrisma.txClient?.() ?? dataPrisma) as DataPrismaService; + } + async event2Operation(payload: IDeleteViewPayload): Promise { return { name: OperationName.DeleteView, @@ -33,8 +45,9 @@ export class DeleteViewOperation { async undo(operation: IDeleteViewOperation) { const { params, operationId = '' } = operation; const { tableId, viewId } = params; + const dataPrisma = await this.dataPrismaForTable(tableId); - const count = await this.dataPrismaService.tableTrash.count({ + const count = await dataPrisma.tableTrash.count({ where: { id: operationId }, }); @@ -43,7 +56,7 @@ export class DeleteViewOperation { await this.viewService.restoreView(tableId, viewId); if (operationId) { - await this.dataPrismaService.tableTrash.delete({ + await dataPrisma.tableTrash.delete({ where: { id: operationId }, }); } diff --git a/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-operation.service.ts b/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-operation.service.ts index d674eaa89e..c6eaf601fb 100644 --- a/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-operation.service.ts +++ b/apps/nestjs-backend/src/features/undo-redo/stack/undo-redo-operation.service.ts @@ -3,11 +3,11 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { assertNever } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import type { IUndoRedoOperation } from '../../../cache/types'; import { OperationName } from '../../../cache/types'; import { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config'; import { Events, IEventRawContext } from '../../../event-emitter/events'; +import { DataDbClientManager } from '../../../global/data-db-client-manager.service'; import { FieldOpenApiV2Service } from '../../field/open-api/field-open-api-v2.service'; import { FieldOpenApiService } from '../../field/open-api/field-open-api.service'; import { RecordOpenApiService } from '../../record/open-api/record-open-api.service'; @@ -67,7 +67,7 @@ export class UndoRedoOperationService { private readonly recordService: RecordService, private readonly viewService: ViewService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly dataDbClientManager: DataDbClientManager, private readonly tableDomainQueryService: TableDomainQueryService, @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig ) { @@ -78,8 +78,8 @@ export class UndoRedoOperationService { ); this.deleteRecords = new DeleteRecordsOperation( this.recordOpenApiService, - this.dataPrismaService, - this.thresholdConfig + this.thresholdConfig, + this.dataDbClientManager ); this.updateRecords = new UpdateRecordsOperation(this.recordOpenApiService, this.recordService); this.updateRecordsOrder = new UpdateRecordsOrderOperation(this.viewOpenApiService); @@ -90,7 +90,7 @@ export class UndoRedoOperationService { this.deleteFields = new DeleteFieldsOperation( this.fieldOpenApiService, this.recordOpenApiService, - this.dataPrismaService + this.dataDbClientManager ); this.convertField = new ConvertFieldOperation( this.fieldOpenApiService, @@ -105,7 +105,7 @@ export class UndoRedoOperationService { this.deleteView = new DeleteViewOperation( this.viewOpenApiService, this.viewService, - this.dataPrismaService + this.dataDbClientManager ); this.createView = new CreateViewOperation(this.viewOpenApiService, this.viewService); this.updateView = new UpdateViewOperation(this.viewOpenApiService); diff --git a/apps/nestjs-backend/src/features/user/user.service.ts b/apps/nestjs-backend/src/features/user/user.service.ts index aedf63cffd..d0105cf40f 100644 --- a/apps/nestjs-backend/src/features/user/user.service.ts +++ b/apps/nestjs-backend/src/features/user/user.service.ts @@ -54,6 +54,23 @@ export class UserService { ); } + async getUsersByIdsOrEmails(params: { ids?: string[]; emails?: string[] }) { + const { ids = [], emails = [] } = params; + const conditions = []; + if (ids.length > 0) conditions.push({ id: { in: ids } }); + if (emails.length > 0) conditions.push({ email: { in: emails.map((e) => e.toLowerCase()) } }); + if (conditions.length === 0) return []; + + const users = await this.prismaService.user.findMany({ + where: { OR: conditions, deletedTime: null }, + }); + return users.map((u) => ({ + ...u, + avatar: u.avatar && getPublicFullStorageUrl(u.avatar), + notifyMeta: u.notifyMeta ? (JSON.parse(u.notifyMeta) as IUserNotifyMeta) : null, + })); + } + async getUserByEmail(email: string) { return await this.prismaService.txClient().user.findUnique({ where: { email: email.toLowerCase(), deletedTime: null }, diff --git a/apps/nestjs-backend/src/features/v2/v2-container.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-container.service.spec.ts index 85ee0f03da..62f647f4d5 100644 --- a/apps/nestjs-backend/src/features/v2/v2-container.service.spec.ts +++ b/apps/nestjs-backend/src/features/v2/v2-container.service.spec.ts @@ -17,6 +17,8 @@ vi.mock('../attachments/attachments-storage.service', () => ({ import { CacheService } from '../../cache/cache.service'; import { thresholdConfig } from '../../configs/threshold.config'; +import { DataDbClientManager } from '../../global/data-db-client-manager.service'; +import { DataDbRuntimeCacheService } from '../../global/data-db-runtime-cache.service'; import { ShareDbService } from '../../share-db/share-db.service'; import { AttachmentsStorageService } from '../attachments/attachments-storage.service'; import { V2ContainerService } from './v2-container.service'; @@ -139,6 +141,12 @@ const createService = (providers: InstanceWrapper[] = []) => { getPreviewUrlByPath: vi.fn(), getTableThumbnailUrl: vi.fn(), }; + const dataDbClientManager = { + getDataDatabaseForSpace: vi.fn(), + getDataDatabaseForBase: vi.fn(), + getDataDatabaseForTable: vi.fn(), + }; + const runtimeCache = new DataDbRuntimeCacheService(); const reflector = new Reflector(); const discoveryService = { getProviders: vi.fn().mockReturnValue(providers), @@ -152,7 +160,9 @@ const createService = (providers: InstanceWrapper[] = []) => { attachmentsStorageService as never, { undoExpirationTime: 60, maxUndoStackSize: 20 } as never, reflector, - discoveryService + discoveryService, + dataDbClientManager as never, + runtimeCache ); return { @@ -161,6 +171,8 @@ const createService = (providers: InstanceWrapper[] = []) => { shareDbService, cacheService, attachmentsStorageService, + dataDbClientManager, + runtimeCache, discoveryService, }; }; @@ -176,6 +188,12 @@ const createTestingModule = async (providers: InstanceWrapper[] = []) => { getPreviewUrlByPath: vi.fn(), getTableThumbnailUrl: vi.fn(), }; + const dataDbClientManager = { + getDataDatabaseForSpace: vi.fn(), + getDataDatabaseForBase: vi.fn(), + getDataDatabaseForTable: vi.fn(), + }; + const runtimeCache = new DataDbRuntimeCacheService(); const reflector = new Reflector(); const discoveryService = { getProviders: vi.fn().mockReturnValue(providers), @@ -189,6 +207,8 @@ const createTestingModule = async (providers: InstanceWrapper[] = []) => { { provide: ShareDbService, useValue: shareDbService }, { provide: CacheService, useValue: cacheService }, { provide: AttachmentsStorageService, useValue: attachmentsStorageService }, + { provide: DataDbClientManager, useValue: dataDbClientManager }, + { provide: DataDbRuntimeCacheService, useValue: runtimeCache }, { provide: thresholdConfig.KEY, useValue: { undoExpirationTime: 60, maxUndoStackSize: 20 }, @@ -249,7 +269,7 @@ describe('V2ContainerService', () => { expect(registrar.registerProjections).toHaveBeenCalledTimes(1); }); - it('accepts split meta/data envs without requiring the legacy alias', async () => { + it('uses the meta database as the default data database without a global data env', async () => { const container = createContainerMock(); mocks.createV2NodePgContainer.mockResolvedValue(container); const { service, configService } = createService(); @@ -258,9 +278,6 @@ describe('V2ContainerService', () => { if (key === 'PRISMA_META_DATABASE_URL') { return 'postgres://meta-db'; } - if (key === 'PRISMA_DATA_DATABASE_URL') { - return 'postgres://data-db'; - } return undefined; }); @@ -269,7 +286,7 @@ describe('V2ContainerService', () => { expect(mocks.createV2NodePgContainer).toHaveBeenCalledWith( expect.objectContaining({ metaConnectionString: 'postgres://meta-db', - dataConnectionString: 'postgres://data-db', + dataConnectionString: 'postgres://meta-db', }) ); }); @@ -319,6 +336,39 @@ describe('V2ContainerService', () => { ); }); + it('creates a scoped container from the base data database binding', async () => { + const defaultContainer = createContainerMock(); + const scopedContainer = createContainerMock(); + mocks.createV2NodePgContainer + .mockResolvedValueOnce(defaultContainer) + .mockResolvedValueOnce(scopedContainer); + const { service, configService, dataDbClientManager } = createService(); + + configService.get.mockImplementation((key: string) => + key === 'PRISMA_META_DATABASE_URL' ? 'postgres://meta-db' : undefined + ); + dataDbClientManager.getDataDatabaseForBase.mockResolvedValue({ + cacheKey: 'dcnxxx', + url: 'postgres://space-data-db', + isMetaFallback: false, + connectionId: 'dcnxxx', + }); + + await expect(service.getContainer()).resolves.toBe(defaultContainer); + await expect(service.getContainerForBase('bsexxx')).resolves.toBe(scopedContainer); + await expect(service.getContainerForBase('bsexxx')).resolves.toBe(scopedContainer); + + expect(dataDbClientManager.getDataDatabaseForBase).toHaveBeenCalledWith('bsexxx'); + expect(mocks.createV2NodePgContainer).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + metaConnectionString: 'postgres://meta-db', + dataConnectionString: 'postgres://space-data-db', + }) + ); + expect(mocks.createV2NodePgContainer).toHaveBeenCalledTimes(2); + }); + it('falls back to DATABASE_URL when neither meta nor legacy alias is configured', async () => { const container = createContainerMock(); mocks.createV2NodePgContainer.mockResolvedValue(container); diff --git a/apps/nestjs-backend/src/features/v2/v2-container.service.ts b/apps/nestjs-backend/src/features/v2/v2-container.service.ts index 235bdbae2a..d13a77631d 100644 --- a/apps/nestjs-backend/src/features/v2/v2-container.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-container.service.ts @@ -18,6 +18,11 @@ import { registerV2ImportServices } from '@teable/v2-import'; import { PinoLogger } from 'nestjs-pino'; import { CacheService } from '../../cache/cache.service'; import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config'; +import { DataDbClientManager } from '../../global/data-db-client-manager.service'; +import { + DataDbRuntimeCacheService, + V2_CONTAINER_CACHE_NAMESPACE, +} from '../../global/data-db-runtime-cache.service'; import { ShareDbService } from '../../share-db/share-db.service'; import { AttachmentsStorageService } from '../attachments/attachments-storage.service'; import { V2AttachmentUrlSignerService } from './v2-attachment-url-signer.service'; @@ -41,7 +46,6 @@ const resolvePositiveInteger = (value: unknown): number | undefined => { @Injectable() export class V2ContainerService implements OnApplicationBootstrap, OnModuleDestroy { private readonly logger = new Logger(V2ContainerService.name); - private containerPromise?: Promise; constructor( private readonly configService: ConfigService, @@ -51,7 +55,9 @@ export class V2ContainerService implements OnApplicationBootstrap, OnModuleDestr private readonly attachmentsStorageService: AttachmentsStorageService, @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, private readonly reflector: Reflector, - private readonly discoveryService: DiscoveryService + private readonly discoveryService: DiscoveryService, + private readonly dataDbClientManager: DataDbClientManager, + private readonly runtimeCache: DataDbRuntimeCacheService ) {} async onApplicationBootstrap(): Promise { @@ -59,23 +65,46 @@ export class V2ContainerService implements OnApplicationBootstrap, OnModuleDestr } async getContainer(): Promise { - if (!this.containerPromise) { - this.containerPromise = this.createContainer().catch((error) => { - this.containerPromise = undefined; - throw error; - }); - } + return await this.getContainerForDataDb('default', this.getMetaConnectionString()); + } + + async getContainerForSpace(spaceId: string): Promise { + const dataDb = await this.dataDbClientManager.getDataDatabaseForSpace(spaceId); + return await this.getContainerForDataDb(dataDb.cacheKey, dataDb.url); + } + + async getContainerForBase(baseId: string): Promise { + const dataDb = await this.dataDbClientManager.getDataDatabaseForBase(baseId); + return await this.getContainerForDataDb(dataDb.cacheKey, dataDb.url); + } - return this.containerPromise; + async getContainerForTable(tableId: string): Promise { + const dataDb = await this.dataDbClientManager.getDataDatabaseForTable(tableId); + return await this.getContainerForDataDb(dataDb.cacheKey, dataDb.url); } - private async createContainer(): Promise { - const metaConnectionString = + private async getContainerForDataDb( + cacheKey: string, + dataConnectionString: string + ): Promise { + return await this.runtimeCache.getOrCreate( + V2_CONTAINER_CACHE_NAMESPACE, + cacheKey, + () => this.createContainer(dataConnectionString), + (container) => this.destroyContainer(container) + ); + } + + private getMetaConnectionString(): string { + return ( this.configService.get('PRISMA_META_DATABASE_URL') ?? this.configService.get('PRISMA_DATABASE_URL') ?? - this.configService.getOrThrow('DATABASE_URL'); - const dataConnectionString = - this.configService.get('PRISMA_DATA_DATABASE_URL') ?? metaConnectionString; + this.configService.getOrThrow('DATABASE_URL') + ); + } + + private async createContainer(dataConnectionString: string): Promise { + const metaConnectionString = this.getMetaConnectionString(); const logger = new PinoLoggerAdapter(this.pinoLogger); const tracer = new OpenTelemetryTracer(); const commandBusMiddlewares = [new CommandBusTracingMiddleware()]; @@ -88,7 +117,7 @@ export class V2ContainerService implements OnApplicationBootstrap, OnModuleDestr this.configService.get('MAX_FREE_ROW_LIMIT') ); - this.logger.log('Initializing shared V2 container'); + this.logger.log('Initializing V2 container'); const container = await createV2NodePgContainer({ metaConnectionString, @@ -141,7 +170,7 @@ export class V2ContainerService implements OnApplicationBootstrap, OnModuleDestr registrar.registerProjections(container); } - this.logger.log('Shared V2 container initialized'); + this.logger.log('V2 container initialized'); return container; } @@ -182,9 +211,10 @@ export class V2ContainerService implements OnApplicationBootstrap, OnModuleDestr } async onModuleDestroy(): Promise { - if (!this.containerPromise) return; + await this.runtimeCache.deleteByNamespace(V2_CONTAINER_CACHE_NAMESPACE); + } - const container = await this.containerPromise; + private async destroyContainer(container: DependencyContainer): Promise { await this.stopComputedUpdatePolling(container); const closers = Array.from( new Set([ diff --git a/apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts b/apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts index 0b13313359..803238e70c 100644 --- a/apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts +++ b/apps/nestjs-backend/src/features/v2/v2-execution-context.factory.ts @@ -1,6 +1,7 @@ import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import type { IExecutionContext, ITracer } from '@teable/v2-core'; import { ActorId, v2CoreTokens, type TableDataSafetyLimitConfig } from '@teable/v2-core'; +import type { DependencyContainer } from '@teable/v2-di'; import { ClsService } from 'nestjs-cls'; import { I18nContext, I18nService } from 'nestjs-i18n'; @@ -24,8 +25,8 @@ export class V2ExecutionContextFactory { * Creates a complete execution context with actorId, tracer, and requestId. * @throws HttpException if user.id is not available or ActorId creation fails */ - async createContext(): Promise { - const container = await this.v2ContainerService.getContainer(); + async createContext(container?: DependencyContainer): Promise { + container ??= await this.v2ContainerService.getContainer(); const tracer = container.resolve(v2CoreTokens.tracer); const tableLimits = container.isRegistered(v2CoreTokens.tableDataSafetyLimits) ? container.resolve(v2CoreTokens.tableDataSafetyLimits) diff --git a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts index 50160c5f66..f311252794 100644 --- a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts +++ b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.spec.ts @@ -1,7 +1,25 @@ import { ViewOpBuilder } from '@teable/core'; -import { v2DataDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { describe, expect, it, vi } from 'vitest'; import { V2_FIELD_DELETE_COMPAT_CONTEXT_KEY } from './v2-field-delete-compat.constants'; + +const mockV2Tokens = vi.hoisted(() => ({ + v2DataDbTokens: { + db: Symbol('v2.data.db'), + }, +})); + +vi.mock('@teable/v2-adapter-db-postgres-pg', () => ({ + v2DataDbTokens: mockV2Tokens.v2DataDbTokens, +})); + +vi.mock('./v2-container.service', () => ({ + V2ContainerService: class V2ContainerService {}, +})); + +vi.mock('./v2-view-compat.service', () => ({ + V2ViewCompatService: class V2ViewCompatService {}, +})); + import { V2FieldDeletedCompatProjection } from './v2-field-delete-compat.service'; const createInsertDb = () => { @@ -19,7 +37,7 @@ const createInsertDb = () => { const createV2ContainerService = (db: unknown) => ({ getContainer: vi.fn().mockResolvedValue({ resolve: vi.fn((token: symbol) => { - if (token !== v2DataDbTokens.db) { + if (token !== mockV2Tokens.v2DataDbTokens.db) { throw new Error(`Unexpected token ${String(token)}`); } @@ -58,15 +76,14 @@ describe('V2FieldDeletedCompatProjection', () => { }, }; - const result = await projection.handle( - { - [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]: compatContext, - } as never, - { - tableId: { toString: () => 'tblCompatTable0001' }, - fieldId: { toString: () => 'fldCompatA00000001' }, - } as never - ); + const executionContext = { + [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]: compatContext, + } as never; + + const result = await projection.handle(executionContext, { + tableId: { toString: () => 'tblCompatTable0001' }, + fieldId: { toString: () => 'fldCompatA00000001' }, + } as never); expect(result._unsafeUnwrap()).toBeUndefined(); expect(compatContext.completed).toBeUndefined(); @@ -105,21 +122,21 @@ describe('V2FieldDeletedCompatProjection', () => { }, }; - const result = await projection.handle( - { - [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]: compatContext, - } as never, - { - tableId: { toString: () => 'tblCompatTable0001' }, - fieldId: { toString: () => 'fldCompatA00000001' }, - } as never - ); + const executionContext = { + [V2_FIELD_DELETE_COMPAT_CONTEXT_KEY]: compatContext, + } as never; + + const result = await projection.handle(executionContext, { + tableId: { toString: () => 'tblCompatTable0001' }, + fieldId: { toString: () => 'fldCompatA00000001' }, + } as never); expect(result._unsafeUnwrap()).toBeUndefined(); expect(compatContext.completed).toBe(true); expect(v2ViewCompatService.batchUpdateViewByOps).toHaveBeenCalledWith( 'tblCompatTable0001', - compatContext.frozenFieldOps + compatContext.frozenFieldOps, + executionContext ); expect(db.insertInto).toHaveBeenCalledWith('table_trash'); expect(query.values).toHaveBeenCalledWith({ diff --git a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts index 141dc99798..ce135446d8 100644 --- a/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-field-delete-compat.service.ts @@ -76,11 +76,12 @@ export class V2FieldDeletedCompatProjection implements IEventHandler 0) { await this.v2ViewCompatService.batchUpdateViewByOps( compatContext.tableId, - compatContext.frozenFieldOps + compatContext.frozenFieldOps, + context ); } - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(compatContext.tableId); const db = container.resolve>(v2DataDbTokens.db); await db diff --git a/apps/nestjs-backend/src/features/v2/v2-record-history.service.ts b/apps/nestjs-backend/src/features/v2/v2-record-history.service.ts index 98dc827a8a..577aac3b0e 100644 --- a/apps/nestjs-backend/src/features/v2/v2-record-history.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-record-history.service.ts @@ -64,9 +64,10 @@ type IRecordHistoryDb = V1TeableDatabase & { }; const getRecordHistoryDb = async ( - v2ContainerService: V2ContainerService + v2ContainerService: V2ContainerService, + tableId: string ): Promise> => { - const container = await v2ContainerService.getContainer(); + const container = await v2ContainerService.getContainerForTable(tableId); return container.resolve>(v2DataDbTokens.db); }; @@ -315,7 +316,7 @@ export class V2RecordUpdatedHistoryProjection implements IEventHandler ({ + v2MetaDbTokens: { + db: Symbol('v2.meta.db'), + }, + v2CoreTokens: { + viewOperationPluginRunner: Symbol('v2.core.viewOperationPluginRunner'), + }, +})); + +vi.mock('@teable/v2-adapter-db-postgres-pg', () => ({ + v2MetaDbTokens: mockV2Tokens.v2MetaDbTokens, +})); + +vi.mock('@teable/v2-core', () => ({ + v2CoreTokens: mockV2Tokens.v2CoreTokens, + ViewOperationKind: { + update: 'update', + }, +})); + +vi.mock('./v2-container.service', () => ({ + V2ContainerService: class V2ContainerService {}, +})); + +vi.mock('./v2-execution-context.factory', () => ({ + V2ExecutionContextFactory: class V2ExecutionContextFactory {}, +})); + import { V2ViewCompatService } from './v2-view-compat.service'; -const createV2ContainerService = (db: unknown) => ({ +const createV2ContainerService = (db: unknown, viewOperationPluginRunner: unknown) => ({ getContainer: vi.fn().mockResolvedValue({ resolve: vi.fn((token: symbol) => { - if (token !== v2MetaDbTokens.db) { - throw new Error(`Unexpected token ${String(token)}`); + if (token === mockV2Tokens.v2MetaDbTokens.db) { + return db; } - return db; + if (token === mockV2Tokens.v2CoreTokens.viewOperationPluginRunner) { + return viewOperationPluginRunner; + } + + throw new Error(`Unexpected token ${String(token)}`); }), }), }); +const okResult = (value: T) => ({ + value, + isErr: () => false, +}); + +const errResult = (error: T) => ({ + error, + isErr: () => true, +}); + +const createViewOperationPluginRunner = (guardResult = okResult(undefined)) => { + const guard = vi.fn().mockResolvedValue(guardResult); + const prepare = vi.fn().mockResolvedValue(okResult({ guard })); + return { guard, prepare }; +}; + +const createV2ContextFactory = () => ({ + createContext: vi.fn().mockResolvedValue({ + actorId: { toString: () => 'usrCompatWriter00001' }, + }), +}); + describe('V2ViewCompatService', () => { it('updates matching views through the v2 db and stores raw ops in cls state', async () => { const executeSelect = vi.fn().mockResolvedValue([{ id: 'viwCompat000000001', version: 3 }]); @@ -33,7 +87,9 @@ describe('V2ViewCompatService', () => { selectFrom: vi.fn().mockReturnValue(selectQuery), updateTable: vi.fn().mockReturnValue(updateQuery), }; - const v2ContainerService = createV2ContainerService(db); + const viewOperationPluginRunner = createViewOperationPluginRunner(); + const v2ContainerService = createV2ContainerService(db, viewOperationPluginRunner); + const v2ContextFactory = createV2ContextFactory(); const clsState = new Map(); const cls = { getId: vi.fn().mockReturnValue('cls-request-id'), @@ -48,7 +104,11 @@ describe('V2ViewCompatService', () => { clsState.set(key, value); }), }; - const service = new V2ViewCompatService(v2ContainerService as never, cls as never); + const service = new V2ViewCompatService( + v2ContainerService as never, + cls as never, + v2ContextFactory as never + ); const ops = [ ViewOpBuilder.editor.setViewProperty.build({ key: 'options', @@ -61,6 +121,19 @@ describe('V2ViewCompatService', () => { viwCompat000000001: ops, }); + expect(viewOperationPluginRunner.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'update', + payload: { + tableId: 'tblCompatTable0001', + viewId: 'viwCompat000000001', + patch: { options: { frozenFieldId: 'fldNewFrozen00001' } }, + }, + }) + ); + expect(viewOperationPluginRunner.guard).toHaveBeenCalledWith( + expect.objectContaining({ actorId: expect.anything() }) + ); expect(db.selectFrom).toHaveBeenCalledWith('view'); expect(db.updateTable).toHaveBeenCalledWith('view'); expect(updateQuery.set).toHaveBeenCalledWith({ @@ -80,4 +153,58 @@ describe('V2ViewCompatService', () => { v: 3, }); }); + + it('rejects view updates when the v2 view operation plugin reports a limit error', async () => { + const executeSelect = vi.fn().mockResolvedValue([{ id: 'viwCompat000000001', version: 3 }]); + const selectQuery = { + where: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + execute: executeSelect, + }; + const updateQuery = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + execute: vi.fn().mockResolvedValue(undefined), + }; + const db = { + selectFrom: vi.fn().mockReturnValue(selectQuery), + updateTable: vi.fn().mockReturnValue(updateQuery), + }; + const limitError = { + code: 'validation.limit.view_options_max_bytes', + message: 'Table data safety limit exceeded: validation.limit.view_options_max_bytes', + details: { attempted: 16, max: 4 }, + }; + const viewOperationPluginRunner = createViewOperationPluginRunner(errResult(limitError)); + const v2ContainerService = createV2ContainerService(db, viewOperationPluginRunner); + const cls = { + getId: vi.fn().mockReturnValue('cls-request-id'), + get: vi.fn().mockReturnValue(undefined), + set: vi.fn(), + }; + const service = new V2ViewCompatService( + v2ContainerService as never, + cls as never, + createV2ContextFactory() as never + ); + const ops = [ + ViewOpBuilder.editor.setViewProperty.build({ + key: 'options', + oldValue: {}, + newValue: { frozenFieldId: 'fldNewFrozen00001' }, + }), + ]; + + await expect( + service.batchUpdateViewByOps('tblCompatTable0001', { + viwCompat000000001: ops, + }) + ).rejects.toMatchObject({ + data: { + domainCode: 'validation.limit.view_options_max_bytes', + details: { attempted: 16, max: 4 }, + }, + }); + expect(db.updateTable).not.toHaveBeenCalled(); + }); }); diff --git a/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts index 10abcda9ec..12adcf0e1d 100644 --- a/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts +++ b/apps/nestjs-backend/src/features/v2/v2-view-compat.service.ts @@ -9,6 +9,15 @@ import { type ISetViewPropertyOpContext, } from '@teable/core'; import { v2MetaDbTokens } from '@teable/v2-adapter-db-postgres-pg'; +import { + v2CoreTokens, + ViewOperationKind, + type DomainError, + type IExecutionContext, + type ViewOperationPayloadViewConfig, + type ViewOperationPluginContext, + type ViewOperationPluginRunner, +} from '@teable/v2-core'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; import type { Kysely } from 'kysely'; import { snakeCase } from 'lodash'; @@ -18,6 +27,7 @@ import { CustomHttpException } from '../../custom.exception'; import type { IRawOp, IRawOpMap } from '../../share-db/interface'; import type { IClsStore } from '../../types/cls'; import { V2ContainerService } from './v2-container.service'; +import { V2ExecutionContextFactory } from './v2-execution-context.factory'; /* eslint-disable @typescript-eslint/naming-convention */ type IV2ViewCompatDb = V1TeableDatabase & { @@ -43,16 +53,20 @@ type IV2ViewCompatDb = V1TeableDatabase & { export class V2ViewCompatService { constructor( private readonly v2ContainerService: V2ContainerService, - private readonly cls: ClsService + private readonly cls: ClsService, + private readonly v2ContextFactory: V2ExecutionContextFactory ) {} - private async getDb(): Promise> { - const container = await this.v2ContainerService.getContainer(); - return container.resolve>(v2MetaDbTokens.db); + private throwDomainError(error: DomainError): never { + throw new CustomHttpException(error.message, HttpErrorCode.VALIDATION_ERROR, { + domainCode: error.code, + domainTags: error.tags, + details: error.details, + }); } private mergeSetViewPropertyByOpContexts(opContexts: ISetViewPropertyOpContext[]) { - const result: Record = {}; + const result: Record = {}; for (const opContext of opContexts) { const { key, newValue } = opContext; const parseResult = viewVoSchema.partial().safeParse({ [key]: newValue }); @@ -69,12 +83,7 @@ export class V2ViewCompatService { } const parsedValue = parseResult.data[key]; - result[key] = - parsedValue == null - ? null - : typeof parsedValue === 'object' - ? JSON.stringify(parsedValue) - : parsedValue; + result[key] = parsedValue == null ? null : parsedValue; } return result; @@ -129,13 +138,38 @@ export class V2ViewCompatService { return rawOpMap; } - async batchUpdateViewByOps(tableId: string, opsMap: { [viewId: string]: IOtOperation[] }) { + private async ensureViewOperation( + runner: ViewOperationPluginRunner, + executionContext: IExecutionContext, + context: ViewOperationPluginContext + ): Promise { + const preparedResult = await runner.prepare(context); + if (preparedResult.isErr()) { + this.throwDomainError(preparedResult.error); + } + + const guardResult = await preparedResult.value.guard(executionContext); + if (guardResult.isErr()) { + this.throwDomainError(guardResult.error); + } + } + + async batchUpdateViewByOps( + tableId: string, + opsMap: { [viewId: string]: IOtOperation[] }, + context?: IExecutionContext + ) { const updatedViewIds = Object.keys(opsMap); if (!updatedViewIds.length) { return; } - const db = await this.getDb(); + const container = await this.v2ContainerService.getContainer(); + const db = container.resolve>(v2MetaDbTokens.db); + const viewOperationPluginRunner = container.resolve( + v2CoreTokens.viewOperationPluginRunner + ); + const executionContext = context ?? (await this.v2ContextFactory.createContext()); const views = await db .selectFrom('view') .where('id', 'in', updatedViewIds) @@ -153,8 +187,22 @@ export class V2ViewCompatService { continue; } + await this.ensureViewOperation(viewOperationPluginRunner, executionContext, { + kind: ViewOperationKind.update, + executionContext, + payload: { + tableId, + viewId: view.id, + patch: properties as ViewOperationPayloadViewConfig, + }, + isTransactionBound: false, + }); + const dbValues = Object.fromEntries( - Object.entries(properties).map(([key, value]) => [snakeCase(key), value]) + Object.entries(properties).map(([key, value]) => [ + snakeCase(key), + value == null ? null : typeof value === 'object' ? JSON.stringify(value) : value, + ]) ); await db diff --git a/apps/nestjs-backend/src/features/v2/v2.controller.ts b/apps/nestjs-backend/src/features/v2/v2.controller.ts index bb4ce75ea0..28bb61d70f 100644 --- a/apps/nestjs-backend/src/features/v2/v2.controller.ts +++ b/apps/nestjs-backend/src/features/v2/v2.controller.ts @@ -45,9 +45,9 @@ export class V2Controller { tables() { return { create: implement(v2Contract.tables.create).handler(async ({ input }) => { - const container = await this.v2Container.getContainer(); + const container = await this.v2Container.getContainerForBase(input.baseId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const result = await executeCreateTableEndpoint(context, input, commandBus); @@ -56,9 +56,9 @@ export class V2Controller { throwOrpcErrorByStatus(result.status, result.body.error); }), getById: implement(v2Contract.tables.getById).handler(async ({ input }) => { - const container = await this.v2Container.getContainer(); + const container = await this.v2Container.getContainerForTable(input.tableId); const queryBus = container.resolve(v2CoreTokens.queryBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const result = await executeGetTableByIdEndpoint(context, input, queryBus); if (result.status === 200) return result.body; @@ -66,9 +66,9 @@ export class V2Controller { throwOrpcErrorByStatus(result.status, result.body.error); }), deleteRecords: implement(v2Contract.tables.deleteRecords).handler(async ({ input }) => { - const container = await this.v2Container.getContainer(); + const container = await this.v2Container.getContainerForTable(input.tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const result = await executeDeleteRecordsEndpoint(context, input, commandBus); @@ -77,9 +77,9 @@ export class V2Controller { throwOrpcErrorByStatus(result.status, result.body.error); }), updateRecords: implement(v2Contract.tables.updateRecords).handler(async ({ input }) => { - const container = await this.v2Container.getContainer(); + const container = await this.v2Container.getContainerForTable(input.tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const result = await executeUpdateRecordsEndpoint(context, input, commandBus); diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts index 7b90f9f141..c233b9e631 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api-v2.service.ts @@ -38,9 +38,9 @@ export class ViewOpenApiV2Service { viewId: string, updateRecordOrdersRo: IUpdateRecordOrdersRo ): Promise { - const container = await this.v2ContainerService.getContainer(); + const container = await this.v2ContainerService.getContainerForTable(tableId); const commandBus = container.resolve(v2CoreTokens.commandBus); - const context = await this.v2ContextFactory.createContext(); + const context = await this.v2ContextFactory.createContext(container); const v2Input = { tableId, diff --git a/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts b/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts index f231c3107d..6dc9fff327 100644 --- a/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts +++ b/apps/nestjs-backend/src/features/view/open-api/view-open-api.service.ts @@ -33,7 +33,6 @@ import { HttpErrorCode, } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { PluginPosition, PluginStatus } from '@teable/openapi'; import type { IViewPluginUpdateStorageRo, @@ -53,6 +52,7 @@ import { InjectDbProvider } from '../../../db-provider/db.provider'; import { IDbProvider } from '../../../db-provider/db.provider.interface'; import { EventEmitterService } from '../../../event-emitter/event-emitter.service'; import { Events } from '../../../event-emitter/events'; +import { DatabaseRouter } from '../../../global/database-router.service'; import { DATA_KNEX } from '../../../global/knex/knex.module'; import type { IClsStore } from '../../../types/cls'; import { Timing } from '../../../utils/timing'; @@ -71,7 +71,7 @@ export class ViewOpenApiService { constructor( private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, private readonly recordService: RecordService, private readonly viewService: ViewService, private readonly fieldService: FieldService, @@ -148,7 +148,11 @@ export class ViewOpenApiService { const { sortObjs } = viewOrderRo; const dbTableName = await this.recordService.getDbTableName(tableId); const fields = await this.fieldService.getFieldsByQuery(tableId, { viewId }); - const indexField = await this.viewService.getOrCreateViewIndexField(dbTableName, viewId); + const indexField = await this.viewService.getOrCreateViewIndexFieldForTable( + tableId, + dbTableName, + viewId + ); const queryBuilder = this.knex(dbTableName); @@ -170,7 +174,8 @@ export class ViewOpenApiService { manualSort: true, }; - await this.dataPrismaService.$tx( + await this.databaseRouter.dataPrismaTransactionForTable( + tableId, async (prisma) => { await prisma.$executeRawUnsafe( this.updateRecordOrderSql(orderRawSql, dbTableName, indexField) @@ -627,8 +632,8 @@ export class ViewOpenApiService { /** * shuffle record order */ - async shuffleRecords(dbTableName: string, indexField: string) { - const recordCount = await this.recordService.getAllRecordCount(dbTableName); + async shuffleRecords(tableId: string, dbTableName: string, indexField: string) { + const recordCount = await this.recordService.getAllRecordCount(dbTableName, tableId); if (recordCount > 100_000) { throw new CustomHttpException( `Not enough gap to shuffle the row here, record count: ${recordCount}`, @@ -647,7 +652,7 @@ export class ViewOpenApiService { indexField ); - await this.dataPrismaService.$executeRawUnsafe(sql); + await this.databaseRouter.executeDataPrismaForTable(tableId, sql); } @Timing() @@ -673,9 +678,8 @@ export class ViewOpenApiService { .where('__id', anchorId) .toQuery(); - const anchorRecord = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ id: string; order: number }[]>(anchorRecordSql) + const anchorRecord = await this.databaseRouter + .queryDataPrismaForTable<{ id: string; order: number }[]>(tableId, anchorRecordSql) .then((res) => { return res[0]; }); @@ -711,16 +715,15 @@ export class ViewOpenApiService { .orderBy(indexField, align) .limit(1) .toQuery(); - return this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ id: string; order: number }[]>(nextRecordSql) + return this.databaseRouter + .queryDataPrismaForTable<{ id: string; order: number }[]>(tableId, nextRecordSql) .then((res) => { return res[0]; }); }, update, shuffle: async () => { - await this.shuffleRecords(dbTableName, indexField); + await this.shuffleRecords(tableId, dbTableName, indexField); }, }); } @@ -753,7 +756,11 @@ export class ViewOpenApiService { ? await this.recordService.getRecordIndexes(table, recordIds, viewId) : undefined; - const indexField = await this.viewService.getOrCreateViewIndexField(dbTableName, viewId); + const indexField = await this.viewService.getOrCreateViewIndexFieldForTable( + table.id, + dbTableName, + viewId + ); await this.updateRecordOrdersInner({ tableId: table.id, @@ -762,7 +769,7 @@ export class ViewOpenApiService { indexField, orderRo, update: async (indexes) => { - await this.dataPrismaService.$tx(async (prisma) => { + await this.databaseRouter.dataPrismaTransactionForTable(table.id, async (prisma) => { for (let i = 0; i < recordIds.length; i++) { const recordId = recordIds[i]; const updateRecordSql = this.knex(dbTableName) @@ -1032,9 +1039,9 @@ export class ViewOpenApiService { .whereIn('__id', Array.from(recordSet)) .toQuery(); - const list = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ id: string; title: string | null }[]>(nativeQuery); + const list = await this.databaseRouter.queryDataPrismaForTable< + { id: string; title: string | null }[] + >(foreignTableId, nativeQuery); const fieldInstances = createFieldInstanceByRaw(lookupedFieldRaw); res.push({ tableId: foreignTableId, diff --git a/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.spec.ts b/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.spec.ts new file mode 100644 index 0000000000..70d1a874ad --- /dev/null +++ b/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.spec.ts @@ -0,0 +1,159 @@ +import type { ConfigService } from '@nestjs/config'; +import { HttpErrorCode, type IFilter } from '@teable/core'; +import type { PrismaService } from '@teable/db-main-prisma'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('@teable/db-main-prisma', () => ({ PrismaService: class PrismaService {} })); + +let ViewDataSafetyLimitService: typeof import('./view-data-safety-limit.service').ViewDataSafetyLimitService; + +const filterItem = { + fieldId: 'fldTest', + operator: 'is', + value: 'x', + isSymbol: false, +}; + +const createService = ( + env: Record, + options: { currentViewCount?: number } = {} +) => { + const count = vi.fn().mockResolvedValue(options.currentViewCount ?? 0); + const configService = { + get: vi.fn((key: string) => env[key]), + } as unknown as ConfigService; + const prismaService = { + txClient: () => ({ + view: { count }, + }), + } as unknown as PrismaService; + + return { + service: new ViewDataSafetyLimitService(configService, prismaService), + count, + }; +}; + +const expectLimitError = (error: unknown, domainCode: string) => { + expect(error).toMatchObject({ + code: HttpErrorCode.VALIDATION_ERROR, + data: { + domainCode, + }, + }); +}; + +describe('ViewDataSafetyLimitService', () => { + beforeAll(async () => { + ({ ViewDataSafetyLimitService } = await import('./view-data-safety-limit.service')); + }, 30_000); + + it('rejects creating a view when the table has reached the views-per-table limit', async () => { + const { service, count } = createService( + { TABLE_LIMIT_VIEWS_PER_TABLE_MAX: '2' }, + { currentViewCount: 2 } + ); + + await expect(service.ensureCanCreateView('tblTest')).rejects.toSatisfy((error: unknown) => { + expectLimitError(error, 'validation.limit.views_per_table_max'); + return true; + }); + expect(count).toHaveBeenCalledWith({ where: { tableId: 'tblTest', deletedTime: null } }); + }); + + it('allows creating a view at the views-per-table boundary', async () => { + const { service } = createService( + { TABLE_LIMIT_VIEWS_PER_TABLE_MAX: '2' }, + { currentViewCount: 1 } + ); + + await expect(service.ensureCanCreateView('tblTest')).resolves.toBeUndefined(); + }); + + it.each([ + [ + 'validation.limit.name_max_length', + { TABLE_LIMIT_NAME_MAX_LENGTH: '3' }, + () => ({ name: 'Long' }), + ], + [ + 'validation.limit.description_max_length', + { TABLE_LIMIT_DESCRIPTION_MAX_LENGTH: '3' }, + () => ({ description: 'Long' }), + ], + [ + 'validation.limit.view_filter_items_max', + { TABLE_LIMIT_VIEW_FILTER_ITEMS_MAX: '1' }, + () => ({ + filter: { + conjunction: 'and', + filterSet: [filterItem, filterItem], + } as unknown as IFilter, + }), + ], + [ + 'validation.limit.view_filter_depth_max', + { TABLE_LIMIT_VIEW_FILTER_DEPTH_MAX: '1' }, + () => ({ + filter: { + conjunction: 'and', + filterSet: [{ conjunction: 'and', filterSet: [filterItem] }], + } as unknown as IFilter, + }), + ], + [ + 'validation.limit.view_sort_items_max', + { TABLE_LIMIT_VIEW_SORT_ITEMS_MAX: '1' }, + () => ({ + sort: { + sortObjs: [ + { fieldId: 'fldA', order: 'asc' }, + { fieldId: 'fldB', order: 'desc' }, + ], + }, + }), + ], + [ + 'validation.limit.view_group_items_max', + { TABLE_LIMIT_VIEW_GROUP_ITEMS_MAX: '1' }, + () => ({ + group: [ + { fieldId: 'fldA', order: 'asc' }, + { fieldId: 'fldB', order: 'desc' }, + ], + }), + ], + [ + 'validation.limit.view_options_max_bytes', + { TABLE_LIMIT_VIEW_OPTIONS_MAX_BYTES: '4' }, + () => ({ options: { rowHeight: 1 } }), + ], + ])('rejects %s for view payloads', (expectedCode, env, payloadFactory) => { + const { service } = createService(env); + + try { + service.ensureViewPayload(payloadFactory()); + throw new Error('Expected limit error'); + } catch (error) { + expectLimitError(error, expectedCode); + } + }); + + it('validates serialized view property updates', () => { + const { service } = createService({ TABLE_LIMIT_VIEW_SORT_ITEMS_MAX: '1' }); + + try { + service.ensureSerializedProperties({ + sort: JSON.stringify({ + sortObjs: [ + { fieldId: 'fldA', order: 'asc' }, + { fieldId: 'fldB', order: 'desc' }, + ], + }), + }); + throw new Error('Expected limit error'); + } catch (error) { + expectLimitError(error, 'validation.limit.view_sort_items_max'); + } + }); +}); diff --git a/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.ts b/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.ts new file mode 100644 index 0000000000..6a294f2044 --- /dev/null +++ b/apps/nestjs-backend/src/features/view/view-data-safety-limit.service.ts @@ -0,0 +1,178 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + HttpErrorCode, + type IFilter, + type IGroup, + type ISort, + type IViewOptions, +} from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import { + ensureTableDataSafetyViewOperationLimits, + resolveTableDataSafetyLimits, + type ResolvedTableDataSafetyLimitConfig, + type TableDataSafetyLimitConfig, + ViewOperationKind, + type ViewOperationPayloadViewConfig, + type ViewOperationPluginContext, +} from '@teable/v2-core'; +import { CustomHttpException } from '../../custom.exception'; + +type SerializedViewProperties = { + name?: string | null; + description?: string | null; + filter?: string | null; + sort?: string | null; + group?: string | null; + options?: string | null; +}; + +type ViewPayload = ViewOperationPayloadViewConfig & { + name?: string | null; + description?: string | null; + filter?: IFilter; + sort?: ISort; + group?: IGroup; + options?: IViewOptions; +}; + +const TABLE_LIMIT_ENV_KEYS = { + tableSchema: { + maxViewsPerTable: 'TABLE_LIMIT_VIEWS_PER_TABLE_MAX', + }, + viewConfig: { + maxFilterItems: 'TABLE_LIMIT_VIEW_FILTER_ITEMS_MAX', + maxFilterDepth: 'TABLE_LIMIT_VIEW_FILTER_DEPTH_MAX', + maxSortItems: 'TABLE_LIMIT_VIEW_SORT_ITEMS_MAX', + maxGroupItems: 'TABLE_LIMIT_VIEW_GROUP_ITEMS_MAX', + maxOptionsBytes: 'TABLE_LIMIT_VIEW_OPTIONS_MAX_BYTES', + }, + displayText: { + maxNameLength: 'TABLE_LIMIT_NAME_MAX_LENGTH', + maxDescriptionLength: 'TABLE_LIMIT_DESCRIPTION_MAX_LENGTH', + }, +} as const; + +const parseJsonProperty = (value: string | null | undefined): T | undefined => { + if (value == null) return undefined; + return JSON.parse(value) as T; +}; + +@Injectable() +export class ViewDataSafetyLimitService { + constructor( + private readonly configService: ConfigService, + private readonly prismaService: PrismaService + ) {} + + private getPositiveInteger(key: string): number | undefined { + const value = this.configService.get(key); + const parsed = + typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN; + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; + } + + private getLimits(): ResolvedTableDataSafetyLimitConfig { + const config: TableDataSafetyLimitConfig = { + tableSchema: { + maxViewsPerTable: this.getPositiveInteger( + TABLE_LIMIT_ENV_KEYS.tableSchema.maxViewsPerTable + ), + }, + viewConfig: { + maxFilterItems: this.getPositiveInteger(TABLE_LIMIT_ENV_KEYS.viewConfig.maxFilterItems), + maxFilterDepth: this.getPositiveInteger(TABLE_LIMIT_ENV_KEYS.viewConfig.maxFilterDepth), + maxSortItems: this.getPositiveInteger(TABLE_LIMIT_ENV_KEYS.viewConfig.maxSortItems), + maxGroupItems: this.getPositiveInteger(TABLE_LIMIT_ENV_KEYS.viewConfig.maxGroupItems), + maxOptionsBytes: this.getPositiveInteger(TABLE_LIMIT_ENV_KEYS.viewConfig.maxOptionsBytes), + }, + displayText: { + maxNameLength: this.getPositiveInteger(TABLE_LIMIT_ENV_KEYS.displayText.maxNameLength), + maxDescriptionLength: this.getPositiveInteger( + TABLE_LIMIT_ENV_KEYS.displayText.maxDescriptionLength + ), + }, + }; + + return resolveTableDataSafetyLimits(config); + } + + private ensureViewOperation(context: ViewOperationPluginContext): void { + const result = ensureTableDataSafetyViewOperationLimits(context, this.getLimits()); + if (result.isOk()) return; + + const error = result.error; + throw new CustomHttpException(error.message, HttpErrorCode.VALIDATION_ERROR, { + domainCode: error.code, + domainTags: error.tags, + details: error.details, + }); + } + + async ensureCanCreateView(tableId: string): Promise { + const currentViewCount = await this.prismaService.txClient().view.count({ + where: { tableId, deletedTime: null }, + }); + + this.ensureViewOperation({ + kind: ViewOperationKind.create, + executionContext: {} as ViewOperationPluginContext['executionContext'], + payload: { + tableId, + currentViewCount, + view: {}, + }, + isTransactionBound: false, + }); + } + + ensureViewPayload(payload: ViewPayload): void { + this.ensureViewOperation({ + kind: ViewOperationKind.update, + executionContext: {} as ViewOperationPluginContext['executionContext'], + payload: { + tableId: '', + viewId: '', + patch: payload, + }, + isTransactionBound: false, + }); + } + + ensureName(name: string | null | undefined): void { + this.ensureViewPayload({ name }); + } + + ensureDescription(description: string | null | undefined): void { + this.ensureViewPayload({ description }); + } + + ensureFilter(filter: IFilter | undefined): void { + this.ensureViewPayload({ filter }); + } + + ensureSort(sort: ISort | undefined): void { + this.ensureViewPayload({ sort }); + } + + ensureGroup(group: IGroup | undefined): void { + this.ensureViewPayload({ group }); + } + + ensureOptions(options: IViewOptions | undefined): void { + this.ensureViewPayload({ options }); + } + + ensureSerializedProperties(properties: SerializedViewProperties | undefined): void { + if (!properties) return; + this.ensureViewPayload({ + name: properties.name, + description: properties.description, + filter: parseJsonProperty(properties.filter), + sort: parseJsonProperty(properties.sort), + group: parseJsonProperty(properties.group), + options: parseJsonProperty(properties.options), + }); + } +} diff --git a/apps/nestjs-backend/src/features/view/view.module.ts b/apps/nestjs-backend/src/features/view/view.module.ts index 6a678ee315..7f8db3ee08 100644 --- a/apps/nestjs-backend/src/features/view/view.module.ts +++ b/apps/nestjs-backend/src/features/view/view.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { DbProvider } from '../../db-provider/db.provider'; import { CalculationModule } from '../calculation/calculation.module'; +import { ViewDataSafetyLimitService } from './view-data-safety-limit.service'; import { ViewService } from './view.service'; @Module({ imports: [CalculationModule], - providers: [ViewService, DbProvider], + providers: [ViewService, ViewDataSafetyLimitService, DbProvider], exports: [ViewService], }) export class ViewModule {} diff --git a/apps/nestjs-backend/src/features/view/view.service.ts b/apps/nestjs-backend/src/features/view/view.service.ts index 116076338e..6517832f37 100644 --- a/apps/nestjs-backend/src/features/view/view.service.ts +++ b/apps/nestjs-backend/src/features/view/view.service.ts @@ -34,7 +34,6 @@ import { } from '@teable/core'; import type { Prisma } from '@teable/db-main-prisma'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import { Knex } from 'knex'; import { isEmpty, isNull, isString, merge, snakeCase, uniq } from 'lodash'; import { InjectModel } from 'nest-knexjs'; @@ -43,6 +42,8 @@ import { fromZodError } from 'zod-validation-error'; import { CustomHttpException } from '../../custom.exception'; import { InjectDbProvider } from '../../db-provider/db.provider'; import { IDbProvider } from '../../db-provider/db.provider.interface'; +import type { IDataPrismaQueryExecutor } from '../../global/database-router.service'; +import { DatabaseRouter } from '../../global/database-router.service'; import { CUSTOM_KNEX, DATA_KNEX } from '../../global/knex/knex.module'; import type { IReadonlyAdapterService } from '../../share-db/interface'; import { RawOpType } from '../../share-db/interface'; @@ -52,6 +53,7 @@ import { BatchService } from '../calculation/batch.service'; import { ROW_ORDER_FIELD_PREFIX } from './constant'; import { createViewInstanceByRaw, createViewVoByRaw } from './model/factory'; import { adjustFrozenField } from './utils/derive-frozen-fields'; +import { ViewDataSafetyLimitService } from './view-data-safety-limit.service'; type IViewOpContext = IUpdateViewColumnMetaOpContext | ISetViewPropertyOpContext; @@ -61,10 +63,11 @@ export class ViewService implements IReadonlyAdapterService { private readonly cls: ClsService, private readonly batchService: BatchService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, @InjectModel(CUSTOM_KNEX) private readonly knex: Knex, @InjectModel(DATA_KNEX) private readonly dataKnex: Knex, - @InjectDbProvider() private readonly dbProvider: IDbProvider + @InjectDbProvider() private readonly dbProvider: IDbProvider, + private readonly viewDataSafetyLimitService: ViewDataSafetyLimitService ) {} getRowIndexFieldName(viewId: string) { @@ -93,26 +96,25 @@ export class ViewService implements IReadonlyAdapterService { return { name, order }; } - async existIndex(dbTableName: string, viewId: string) { + async existIndex(dbTableName: string, viewId: string, prisma: IDataPrismaQueryExecutor) { const columnName = this.getRowIndexFieldName(viewId); - const exists = await this.dbProvider.checkColumnExist( - dbTableName, - columnName, - this.dataPrismaService.txClient() - ); + const exists = await this.dbProvider.checkColumnExist(dbTableName, columnName, prisma); if (exists) { return columnName; } } - async createViewIndexField(dbTableName: string, viewId: string) { - const prisma = this.dataPrismaService.txClient(); - + async createViewIndexField( + dbTableName: string, + viewId: string, + prisma: IDataPrismaQueryExecutor, + knex: Knex = this.dataKnex + ) { const rowIndexFieldName = this.getRowIndexFieldName(viewId); // add a field for maintain row order number - const addRowIndexColumnSql = this.dataKnex.schema + const addRowIndexColumnSql = knex.schema .alterTable(dbTableName, (table) => { table.double(rowIndexFieldName); }) @@ -120,15 +122,15 @@ export class ViewService implements IReadonlyAdapterService { await prisma.$executeRawUnsafe(addRowIndexColumnSql); // fill initial order for every record, with auto increment integer - const updateRowIndexSql = this.dataKnex(dbTableName) + const updateRowIndexSql = knex(dbTableName) .update({ - [rowIndexFieldName]: this.dataKnex.ref('__auto_number'), + [rowIndexFieldName]: knex.ref('__auto_number'), }) .toQuery(); await prisma.$executeRawUnsafe(updateRowIndexSql); // create index - const createRowIndexSQL = this.dataKnex.schema + const createRowIndexSQL = knex.schema .alterTable(dbTableName, (table) => { table.index(rowIndexFieldName, this.getRowIndexFieldIndexName(viewId)); }) @@ -138,11 +140,27 @@ export class ViewService implements IReadonlyAdapterService { } async getOrCreateViewIndexField(dbTableName: string, viewId: string) { - const indexFieldName = await this.existIndex(dbTableName, viewId); - if (indexFieldName) { - return indexFieldName; - } - return this.createViewIndexField(dbTableName, viewId); + const view = await this.prismaService.txClient().view.findUniqueOrThrow({ + where: { id: viewId }, + select: { tableId: true }, + }); + return this.getOrCreateViewIndexFieldForTable(view.tableId, dbTableName, viewId); + } + + async getOrCreateViewIndexFieldForTable(tableId: string, dbTableName: string, viewId: string) { + return await this.databaseRouter.dataPrismaTransactionForTable(tableId, async (prisma) => { + const indexFieldName = await this.existIndex(dbTableName, viewId, prisma); + if (indexFieldName) { + return indexFieldName; + } + + return this.createViewIndexField( + dbTableName, + viewId, + prisma, + await this.databaseRouter.dataKnexForTable(tableId) + ); + }); } // eslint-disable-next-line sonarjs/cognitive-complexity @@ -252,6 +270,7 @@ export class ViewService implements IReadonlyAdapterService { async createDbView(tableId: string, viewRo: IViewRo) { const userId = this.cls.get('user.id'); + await this.viewDataSafetyLimitService.ensureCanCreateView(tableId); const createViewRo = await this.viewDataCompensation(tableId, viewRo); const { @@ -269,6 +288,14 @@ export class ViewService implements IReadonlyAdapterService { } = createViewRo; const { name, order } = await this.polishOrderAndName(tableId, createViewRo); + this.viewDataSafetyLimitService.ensureViewPayload({ + name, + description, + filter, + sort, + group, + options, + }); const viewId = generateViewId(); const prisma = this.prismaService.txClient(); @@ -381,6 +408,7 @@ export class ViewService implements IReadonlyAdapterService { } async updateViewSort(tableId: string, viewId: string, sort: ISort) { + this.viewDataSafetyLimitService.ensureSort(sort); const viewRaw = await this.prismaService .txClient() .view.findFirstOrThrow({ @@ -506,6 +534,9 @@ export class ViewService implements IReadonlyAdapterService { values, }; }); + for (const viewId of updatedViewIds) { + this.viewDataSafetyLimitService.ensureSerializedProperties(updateViewMap[viewId]?.property); + } if (data.length === 1) { const { id, values } = data[0]; diff --git a/apps/nestjs-backend/src/filter/global-exception.filter.spec.ts b/apps/nestjs-backend/src/filter/global-exception.filter.spec.ts index 3c232702d7..0f126c83b9 100644 --- a/apps/nestjs-backend/src/filter/global-exception.filter.spec.ts +++ b/apps/nestjs-backend/src/filter/global-exception.filter.spec.ts @@ -2,23 +2,63 @@ import { BadRequestException, Logger } from '@nestjs/common'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GlobalExceptionFilter } from './global-exception.filter'; -const { sentryScope, captureException, withScope } = vi.hoisted(() => { - const sentryScope = { - setTag: vi.fn(), - setUser: vi.fn(), - }; - return { - sentryScope, - captureException: vi.fn(), - withScope: vi.fn((callback: (scope: typeof sentryScope) => void) => callback(sentryScope)), - }; -}); +const { activeSpan, runtimeErrorCounter, sentryScope, captureException, withScope } = vi.hoisted( + () => { + const activeSpan = { + setAttributes: vi.fn(), + setStatus: vi.fn(), + }; + const runtimeErrorCounter = { + add: vi.fn(), + }; + const sentryScope = { + setContext: vi.fn(), + setTag: vi.fn(), + setUser: vi.fn(), + }; + return { + activeSpan, + runtimeErrorCounter, + sentryScope, + captureException: vi.fn(), + withScope: vi.fn((callback: (scope: typeof sentryScope) => void) => callback(sentryScope)), + }; + } +); + +vi.mock('@opentelemetry/api', () => ({ + metrics: { + getMeter: vi.fn(() => ({ + createCounter: vi.fn(() => runtimeErrorCounter), + })), + }, + SpanStatusCode: { + ERROR: 2, + }, + trace: { + getActiveSpan: vi.fn(() => activeSpan), + }, +})); vi.mock('@sentry/nestjs', () => ({ captureException, withScope, })); +const userId = 'usr123'; +const userEmail = 'user@example.com'; +const spaceId = 'spc123'; +const dataDbConnectionId = 'dcn123'; +const dataDbUrlFingerprint = 'fp123'; +const dataDbErrorCode = 'data_db.database_missing'; +const dataDbOtelAttribute = { + errorCode: 'teable.data_db.error_code', + connectionId: 'teable.data_db.connection_id', + urlFingerprint: 'teable.data_db.url_fingerprint', + retryable: 'teable.data_db.retryable', + userActionable: 'teable.data_db.user_actionable', +} as const; + describe('GlobalExceptionFilter', () => { const configService = { getOrThrow: vi.fn(() => ({ enableGlobalErrorLogging: false })), @@ -49,9 +89,9 @@ describe('GlobalExceptionFilter', () => { const cls = { get: vi.fn((key: string) => { const values = new Map([ - ['user.id', 'usr123'], - ['user.email', 'user@example.com'], - ['spaceId', 'spc123'], + ['user.id', userId], + ['user.email', userEmail], + ['spaceId', spaceId], ]); return values.get(key); }), @@ -64,10 +104,10 @@ describe('GlobalExceptionFilter', () => { expect(withScope).toHaveBeenCalledTimes(1); expect(sentryScope.setUser).toHaveBeenNthCalledWith(1, null); expect(sentryScope.setUser).toHaveBeenNthCalledWith(2, { - id: 'usr123', - email: 'user@example.com', + id: userId, + email: userEmail, }); - expect(sentryScope.setTag).toHaveBeenCalledWith('space.id', 'spc123'); + expect(sentryScope.setTag).toHaveBeenCalledWith('space.id', spaceId); expect(captureException).toHaveBeenCalledWith(exception, { mechanism: { handled: false, type: 'auto.function.nestjs.exception_captured' }, }); @@ -81,4 +121,85 @@ describe('GlobalExceptionFilter', () => { expect(withScope).not.toHaveBeenCalled(); expect(captureException).not.toHaveBeenCalled(); }); + + it('returns a classified BYODB runtime error and annotates Sentry plus OTel', () => { + const cls = { + get: vi.fn((key: string) => { + const values = new Map([ + ['user.id', userId], + ['user.email', userEmail], + ['spaceId', spaceId], + [ + 'dataDb', + { + mode: 'byodb', + spaceId, + connectionId: dataDbConnectionId, + urlFingerprint: dataDbUrlFingerprint, + displayHost: 'db.example.com', + displayDatabase: 'customer_data', + internalSchema: 'teable_internal', + }, + ], + ]); + return values.get(key); + }), + }; + const exception = Object.assign(new Error('database "secret_customer_db" does not exist'), { + code: '3D000', + }); + const filter = new GlobalExceptionFilter(configService as never, cls as never); + + filter.catch(exception, host as never); + + expect(response.status).toHaveBeenCalledWith(503); + expect(response.json).toHaveBeenCalledWith({ + message: + 'The data database bound to this space is currently unavailable. Please check the external database connection and try again.', + status: 503, + code: 'database_connection_unavailable', + data: { + dataDb: { + code: dataDbErrorCode, + retryable: false, + userActionable: true, + connectionId: dataDbConnectionId, + urlFingerprint: dataDbUrlFingerprint, + displayHost: 'db.example.com', + displayDatabase: 'customer_data', + internalSchema: 'teable_internal', + }, + }, + }); + expect(JSON.stringify(response.json.mock.calls.at(-1)?.[0])).not.toContain( + 'secret_customer_db' + ); + expect(sentryScope.setTag).toHaveBeenCalledWith('data_db.error_code', dataDbErrorCode); + expect(sentryScope.setTag).toHaveBeenCalledWith('data_db.connection_id', dataDbConnectionId); + expect(sentryScope.setContext).toHaveBeenCalledWith( + 'data_db', + expect.objectContaining({ + errorCode: dataDbErrorCode, + driverCode: '3D000', + connectionId: dataDbConnectionId, + urlFingerprint: dataDbUrlFingerprint, + }) + ); + expect(activeSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [dataDbOtelAttribute.errorCode]: dataDbErrorCode, + [dataDbOtelAttribute.connectionId]: dataDbConnectionId, + [dataDbOtelAttribute.urlFingerprint]: dataDbUrlFingerprint, + }) + ); + expect(activeSpan.setStatus).toHaveBeenCalledWith({ + code: 2, + message: dataDbErrorCode, + }); + expect(runtimeErrorCounter.add).toHaveBeenCalledWith(1, { + [dataDbOtelAttribute.errorCode]: dataDbErrorCode, + [dataDbOtelAttribute.retryable]: false, + [dataDbOtelAttribute.userActionable]: true, + }); + }); }); diff --git a/apps/nestjs-backend/src/filter/global-exception.filter.ts b/apps/nestjs-backend/src/filter/global-exception.filter.ts index b7dd1c39fb..fdbf4e5520 100644 --- a/apps/nestjs-backend/src/filter/global-exception.filter.ts +++ b/apps/nestjs-backend/src/filter/global-exception.filter.ts @@ -11,14 +11,35 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { metrics, trace, SpanStatusCode } from '@opentelemetry/api'; import * as Sentry from '@sentry/nestjs'; +import { HttpErrorCode } from '@teable/core'; import type { Request, Response } from 'express'; import { ClsService } from 'nestjs-cls'; import type { ILoggerConfig } from '../configs/logger.config'; import { TemplateAppTokenNotAllowedException } from '../custom.exception'; +import { classifyDataDbRuntimeError } from '../global/data-db-runtime-error'; +import type { IDataDbRuntimeErrorClassification } from '../global/data-db-runtime-error'; import type { IClsStore } from '../types/cls'; import { exceptionParse } from '../utils/exception-parse'; +const dataDbRuntimeErrorCounter = metrics + .getMeter('teable-observability') + .createCounter('teable.data_db.runtime_errors', { + description: 'Runtime errors from an external data database bound to a space', + }); +const dataDbOtelAttribute = { + mode: 'teable.data_db.mode', + errorCode: 'teable.data_db.error_code', + connectionId: 'teable.data_db.connection_id', + urlFingerprint: 'teable.data_db.url_fingerprint', + host: 'teable.data_db.host', + database: 'teable.data_db.database', + internalSchema: 'teable.data_db.internal_schema', + retryable: 'teable.data_db.retryable', + userActionable: 'teable.data_db.user_actionable', +} as const; + @Catch() export class GlobalExceptionFilter implements ExceptionFilter { private logger = new Logger(GlobalExceptionFilter.name); @@ -34,8 +55,12 @@ export class GlobalExceptionFilter implements ExceptionFilter { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); + const dataDbContext = this.getDataDbContext(); + const dataDbError = dataDbContext ? classifyDataDbRuntimeError(exception) : null; - this.captureException(exception); + this.annotateActiveSpan(dataDbError); + this.recordDataDbMetric(dataDbError); + this.captureException(exception, dataDbError); if ( enableGlobalErrorLogging || @@ -54,6 +79,26 @@ export class GlobalExceptionFilter implements ExceptionFilter { message: exception.message, }); } + if (dataDbError) { + return response.status(503).json({ + message: + 'The data database bound to this space is currently unavailable. Please check the external database connection and try again.', + status: 503, + code: HttpErrorCode.DATABASE_CONNECTION_UNAVAILABLE, + data: { + dataDb: { + code: dataDbError.code, + retryable: dataDbError.retryable, + userActionable: dataDbError.userActionable, + connectionId: dataDbContext?.connectionId, + urlFingerprint: dataDbContext?.urlFingerprint, + displayHost: dataDbContext?.displayHost, + displayDatabase: dataDbContext?.displayDatabase, + internalSchema: dataDbContext?.internalSchema, + }, + }, + }); + } const customHttpException = exceptionParse(exception); const status = customHttpException.getStatus(); return response.status(status).json({ @@ -64,11 +109,15 @@ export class GlobalExceptionFilter implements ExceptionFilter { }); } - private captureException(exception: Error | HttpException) { + private captureException( + exception: Error | HttpException, + dataDbError?: IDataDbRuntimeErrorClassification | null + ) { if (this.isExpectedError(exception)) return; Sentry.withScope((scope) => { this.setSentryContext(scope); + this.setSentryDataDbContext(scope, dataDbError); Sentry.captureException(exception, { mechanism: { handled: false, type: 'auto.function.nestjs.exception_captured' }, }); @@ -95,6 +144,67 @@ export class GlobalExceptionFilter implements ExceptionFilter { } } + private setSentryDataDbContext( + scope: Sentry.Scope, + dataDbError?: IDataDbRuntimeErrorClassification | null + ) { + const dataDbContext = this.getDataDbContext(); + if (!dataDbContext || !dataDbError) return; + + scope.setTag('data_db.mode', dataDbContext.mode); + scope.setTag('data_db.error_code', dataDbError.code); + scope.setTag('data_db.connection_id', dataDbContext.connectionId); + scope.setTag('data_db.host', dataDbContext.displayHost ?? 'unknown'); + scope.setTag('data_db.database', dataDbContext.displayDatabase ?? 'unknown'); + scope.setTag('data_db.internal_schema', dataDbContext.internalSchema ?? 'unknown'); + scope.setContext('data_db', { + ...dataDbContext, + errorCode: dataDbError.code, + driverCode: dataDbError.driverCode, + retryable: dataDbError.retryable, + userActionable: dataDbError.userActionable, + }); + } + + private annotateActiveSpan(dataDbError?: IDataDbRuntimeErrorClassification | null) { + const dataDbContext = this.getDataDbContext(); + if (!dataDbContext || !dataDbError) return; + + const span = trace.getActiveSpan(); + if (!span) return; + + span.setAttributes({ + [dataDbOtelAttribute.mode]: dataDbContext.mode, + [dataDbOtelAttribute.errorCode]: dataDbError.code, + [dataDbOtelAttribute.connectionId]: dataDbContext.connectionId, + [dataDbOtelAttribute.urlFingerprint]: dataDbContext.urlFingerprint ?? '', + [dataDbOtelAttribute.host]: dataDbContext.displayHost ?? '', + [dataDbOtelAttribute.database]: dataDbContext.displayDatabase ?? '', + [dataDbOtelAttribute.internalSchema]: dataDbContext.internalSchema ?? '', + [dataDbOtelAttribute.retryable]: dataDbError.retryable, + [dataDbOtelAttribute.userActionable]: dataDbError.userActionable, + }); + span.setStatus({ code: SpanStatusCode.ERROR, message: dataDbError.code }); + } + + private recordDataDbMetric(dataDbError?: IDataDbRuntimeErrorClassification | null) { + if (!dataDbError) return; + + dataDbRuntimeErrorCounter.add(1, { + [dataDbOtelAttribute.errorCode]: dataDbError.code, + [dataDbOtelAttribute.retryable]: dataDbError.retryable, + [dataDbOtelAttribute.userActionable]: dataDbError.userActionable, + }); + } + + private getDataDbContext() { + try { + return this.cls?.get('dataDb'); + } catch { + return undefined; + } + } + private isExpectedError(exception: unknown) { return ( typeof exception === 'object' && @@ -104,10 +214,22 @@ export class GlobalExceptionFilter implements ExceptionFilter { } protected logError(exception: Error, request: Request) { + const dataDbContext = this.getDataDbContext(); + const dataDbError = dataDbContext ? classifyDataDbRuntimeError(exception) : null; this.logger.error( { url: request?.url, message: exception.message, + dataDb: dataDbError + ? { + code: dataDbError.code, + connectionId: dataDbContext?.connectionId, + urlFingerprint: dataDbContext?.urlFingerprint, + displayHost: dataDbContext?.displayHost, + displayDatabase: dataDbContext?.displayDatabase, + internalSchema: dataDbContext?.internalSchema, + } + : undefined, }, exception.stack ); diff --git a/apps/nestjs-backend/src/global/byodb-routing.guard.spec.ts b/apps/nestjs-backend/src/global/byodb-routing.guard.spec.ts new file mode 100644 index 0000000000..d92ec39ce0 --- /dev/null +++ b/apps/nestjs-backend/src/global/byodb-routing.guard.spec.ts @@ -0,0 +1,55 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; + +const backendRoot = join(__dirname, '../..'); + +const tableScopedDataPlaneFiles = [ + 'src/event-emitter/listeners/record-history.listener.ts', + 'src/features/aggregation/aggregation.service.ts', + 'src/features/base/base-query/base-query.service.ts', + 'src/features/base/base.service.ts', + 'src/features/base/base-export.service.ts', + 'src/features/base/db-connection.service.ts', + 'src/features/base-sql-executor/base-sql-executor.service.ts', + 'src/features/calculation/batch.service.ts', + 'src/features/calculation/field-calculation.service.ts', + 'src/features/calculation/link.service.ts', + 'src/features/calculation/system-field.service.ts', + 'src/features/database-view/database-view.service.ts', + 'src/features/field/field-calculate/field-converting.service.ts', + 'src/features/field/field-calculate/field-converting-link.service.ts', + 'src/features/field/field-calculate/field-supplement.service.ts', + 'src/features/field/field-duplicate/field-duplicate.service.ts', + 'src/features/field/field.service.ts', + 'src/features/field/open-api/field-open-api.service.ts', + 'src/features/graph/graph.service.ts', + 'src/features/integrity/foreign-key.service.ts', + 'src/features/integrity/link-field.service.ts', + 'src/features/integrity/link-integrity.service.ts', + 'src/features/integrity/unique-index.service.ts', + 'src/features/record/computed/services/computed-dependency-collector.service.ts', + 'src/features/record/computed/services/computed-orchestrator.service.ts', + 'src/features/record/computed/services/link-cascade-resolver.ts', + 'src/features/record/computed/services/record-computed-update.service.ts', + 'src/features/record/open-api/record-open-api.service.ts', + 'src/features/record/record-query.service.ts', + 'src/features/record/record.service.ts', + 'src/features/share/share.service.ts', + 'src/features/table/table-index.service.ts', + 'src/features/table/open-api/table-open-api.service.ts', + 'src/features/view/open-api/view-open-api.service.ts', + 'src/share-db/readonly/record-readonly.service.ts', +]; + +describe('BYODB data-plane routing guard', () => { + it('keeps migrated table-scoped data-plane services off the default data Prisma client', () => { + for (const file of tableScopedDataPlaneFiles) { + const content = readFileSync(join(backendRoot, file), 'utf8'); + + expect(content, file).not.toContain("from '@teable/db-data-prisma'"); + expect(content, file).not.toContain('private readonly dataPrismaService'); + expect(content, file).not.toContain('this.dataPrismaService'); + } + }); +}); diff --git a/apps/nestjs-backend/src/global/data-db-client-manager.service.spec.ts b/apps/nestjs-backend/src/global/data-db-client-manager.service.spec.ts index 685dfe819c..25ad553ece 100644 --- a/apps/nestjs-backend/src/global/data-db-client-manager.service.spec.ts +++ b/apps/nestjs-backend/src/global/data-db-client-manager.service.spec.ts @@ -1,54 +1,167 @@ import { describe, expect, it, vi } from 'vitest'; import { encryptDataDbUrl } from '../features/space/data-db-url-secret'; import { DataDbClientManager } from './data-db-client-manager.service'; +import { DataDbRuntimeCacheService } from './data-db-runtime-cache.service'; + +const withTxClient = (txClient: T) => ({ + ...txClient, + txClient: vi.fn(() => txClient), +}); describe('DataDbClientManager', () => { - it('uses the default data DB clients when a space has no BYODB binding', async () => { - const prismaService = { + it('falls back to the meta DB clients when a space has no BYODB binding', async () => { + const prismaService = withTxClient({ spaceDataDbBinding: { findUnique: vi.fn().mockResolvedValue(null), }, - }; - const defaultDataPrisma = {}; - const defaultDataKnex = {}; + }); + const metaFallbackDataPrisma = {}; + const metaFallbackDataKnex = {}; const manager = new DataDbClientManager( prismaService as never, - defaultDataPrisma as never, - defaultDataKnex as never + metaFallbackDataPrisma as never, + metaFallbackDataKnex as never, + new DataDbRuntimeCacheService() ); - await expect(manager.dataPrismaForSpace('spcxxx')).resolves.toBe(defaultDataPrisma); - await expect(manager.dataKnexForSpace('spcxxx')).resolves.toBe(defaultDataKnex); + await expect(manager.dataPrismaForSpace('spcxxx')).resolves.toBe(metaFallbackDataPrisma); + await expect(manager.dataKnexForSpace('spcxxx')).resolves.toBe(metaFallbackDataKnex); }); it('resolves base scoped clients through the base space', async () => { - const prismaService = { + const prismaService = withTxClient({ base: { findUnique: vi.fn().mockResolvedValue({ spaceId: 'spcxxx' }), }, spaceDataDbBinding: { findUnique: vi.fn().mockResolvedValue(null), }, - }; - const defaultDataPrisma = {}; - const defaultDataKnex = {}; + }); + const metaFallbackDataPrisma = {}; + const metaFallbackDataKnex = {}; const manager = new DataDbClientManager( prismaService as never, - defaultDataPrisma as never, - defaultDataKnex as never + metaFallbackDataPrisma as never, + metaFallbackDataKnex as never, + new DataDbRuntimeCacheService() ); - await expect(manager.dataPrismaForBase('bsexxx')).resolves.toBe(defaultDataPrisma); - await expect(manager.dataKnexForBase('bsexxx')).resolves.toBe(defaultDataKnex); + await expect(manager.dataPrismaForBase('bsexxx')).resolves.toBe(metaFallbackDataPrisma); + await expect(manager.dataKnexForBase('bsexxx')).resolves.toBe(metaFallbackDataKnex); expect(prismaService.base.findUnique).toHaveBeenCalledWith({ where: { id: 'bsexxx' }, select: { spaceId: true }, }); + expect(prismaService.txClient).not.toHaveBeenCalled(); + }); + + it('resolves table scoped clients through the table base space', async () => { + const prismaService = withTxClient({ + tableMeta: { + findUnique: vi.fn().mockResolvedValue({ base: { spaceId: 'spcxxx' } }), + }, + spaceDataDbBinding: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }); + const metaFallbackDataPrisma = {}; + const metaFallbackDataKnex = {}; + const manager = new DataDbClientManager( + prismaService as never, + metaFallbackDataPrisma as never, + metaFallbackDataKnex as never, + new DataDbRuntimeCacheService() + ); + + await expect(manager.dataPrismaForTable('tblxxx')).resolves.toBe(metaFallbackDataPrisma); + await expect(manager.dataKnexForTable('tblxxx')).resolves.toBe(metaFallbackDataKnex); + expect(prismaService.tableMeta.findUnique).toHaveBeenCalledWith({ + where: { id: 'tblxxx' }, + select: { base: { select: { spaceId: true } } }, + }); + expect(prismaService.txClient).not.toHaveBeenCalled(); + }); + + it('uses the active transaction when explicitly requested', async () => { + const txClient = { + tableMeta: { + findUnique: vi.fn().mockResolvedValue({ base: { spaceId: 'spc_in_tx' } }), + }, + spaceDataDbBinding: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + const prismaService = { + ...withTxClient(txClient), + tableMeta: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + const metaFallbackDataPrisma = {}; + const metaFallbackDataKnex = {}; + const manager = new DataDbClientManager( + prismaService as never, + metaFallbackDataPrisma as never, + metaFallbackDataKnex as never, + new DataDbRuntimeCacheService() + ); + + await expect( + manager.dataPrismaForTable('tbl_new_in_tx', { useTransaction: true }) + ).resolves.toBe(metaFallbackDataPrisma); + expect(txClient.tableMeta.findUnique).toHaveBeenCalledWith({ + where: { id: 'tbl_new_in_tx' }, + select: { base: { select: { spaceId: true } } }, + }); + expect(prismaService.tableMeta.findUnique).not.toHaveBeenCalled(); + }); + + it('uses the root meta client by default even when transaction context exists', async () => { + const txClient = { + tableMeta: { + findUnique: vi.fn().mockResolvedValue({ base: { spaceId: 'spc_in_tx' } }), + }, + spaceDataDbBinding: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + const prismaService = { + ...withTxClient(txClient), + tableMeta: { + findUnique: vi.fn().mockResolvedValue({ base: { spaceId: 'spc_after_tx' } }), + }, + spaceDataDbBinding: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + const metaFallbackDataPrisma = {}; + const metaFallbackDataKnex = {}; + const manager = new DataDbClientManager( + prismaService as never, + metaFallbackDataPrisma as never, + metaFallbackDataKnex as never, + new DataDbRuntimeCacheService() + ); + + await expect(manager.dataPrismaForTable('tbl_after_tx')).resolves.toBe(metaFallbackDataPrisma); + expect(txClient.tableMeta.findUnique).not.toHaveBeenCalled(); + expect(prismaService.tableMeta.findUnique).toHaveBeenCalledWith({ + where: { id: 'tbl_after_tx' }, + select: { base: { select: { spaceId: true } } }, + }); + expect(prismaService.spaceDataDbBinding.findUnique).toHaveBeenCalledWith({ + where: { spaceId: 'spc_after_tx' }, + include: { dataDbConnection: true }, + }); }); it('resolves BYODB connection details from a ready space binding', async () => { const dataUrl = 'postgresql://teable:secret@example.com:5432/teable_data'; - const prismaService = { + const internalSchema = 'teable_meta_test'; + const cls = { + set: vi.fn(), + }; + const prismaService = withTxClient({ spaceDataDbBinding: { findUnique: vi.fn().mockResolvedValue({ mode: 'byodb', @@ -56,21 +169,86 @@ describe('DataDbClientManager', () => { dataDbConnection: { id: 'dcnxxx', status: 'ready', + internalSchema, + displayHost: 'example.com', + displayDatabase: 'teable_data', + urlFingerprint: 'fp_xxx', encryptedUrl: encryptDataDbUrl(dataUrl), }, }), }, - }; - const defaultDataPrisma = {}; - const defaultDataKnex = {}; + }); + const metaFallbackDataPrisma = {}; + const metaFallbackDataKnex = {}; const manager = new DataDbClientManager( prismaService as never, - defaultDataPrisma as never, - defaultDataKnex as never + metaFallbackDataPrisma as never, + metaFallbackDataKnex as never, + new DataDbRuntimeCacheService(), + undefined, + cls as never ); - await expect(manager.getDataDatabaseUrlForSpace('spcxxx')).resolves.toBe(dataUrl); - await expect(manager.dataKnexForSpace('spcxxx')).resolves.not.toBe(defaultDataKnex); + await expect(manager.getDataDatabaseUrlForSpace('spcxxx')).resolves.toBe( + `${dataUrl}?schema=${internalSchema}&options=-c+search_path%3D${internalSchema}` + ); + await expect(manager.getDataDatabaseForSpace('spcxxx')).resolves.toMatchObject({ + cacheKey: 'dcnxxx', + connectionId: 'dcnxxx', + isMetaFallback: false, + url: `${dataUrl}?schema=${internalSchema}&options=-c+search_path%3D${internalSchema}`, + }); + expect(cls.set).toHaveBeenCalledWith('dataDb', { + mode: 'byodb', + spaceId: 'spcxxx', + connectionId: 'dcnxxx', + urlFingerprint: 'fp_xxx', + displayHost: 'example.com', + displayDatabase: 'teable_data', + internalSchema, + }); + await expect(manager.dataKnexForSpace('spcxxx')).resolves.not.toBe(metaFallbackDataKnex); await manager.onModuleDestroy(); }); + + it('ensures the BYODB internal schema is migrated before returning a scoped URL', async () => { + const dataUrl = 'postgresql://teable:secret@example.com:5432/teable_data'; + const internalSchema = 'teable_meta_test'; + const dataDbMigrationService = { + ensureConnectionMigrated: vi.fn().mockResolvedValue([]), + }; + const prismaService = withTxClient({ + spaceDataDbBinding: { + findUnique: vi.fn().mockResolvedValue({ + mode: 'byodb', + state: 'migrating', + dataDbConnection: { + id: 'dcnxxx', + status: 'migrating', + internalSchema, + encryptedUrl: encryptDataDbUrl(dataUrl), + }, + }), + }, + }); + const manager = new DataDbClientManager( + prismaService as never, + {} as never, + {} as never, + new DataDbRuntimeCacheService(), + dataDbMigrationService as never + ); + + await expect(manager.getDataDatabaseForSpace('spcxxx')).resolves.toMatchObject({ + cacheKey: 'dcnxxx', + connectionId: 'dcnxxx', + internalSchema, + isMetaFallback: false, + }); + expect(dataDbMigrationService.ensureConnectionMigrated).toHaveBeenCalledWith({ + connectionId: 'dcnxxx', + internalSchema, + url: dataUrl, + }); + }); }); diff --git a/apps/nestjs-backend/src/global/data-db-client-manager.service.ts b/apps/nestjs-backend/src/global/data-db-client-manager.service.ts index 71a4690a67..d6baabbe16 100644 --- a/apps/nestjs-backend/src/global/data-db-client-manager.service.ts +++ b/apps/nestjs-backend/src/global/data-db-client-manager.service.ts @@ -1,153 +1,273 @@ -import type { OnModuleDestroy } from '@nestjs/common'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import { DataPrismaService, PrismaClient as DataPrismaClient, - getDataDatabaseUrl, + getMetaDatabaseUrl, } from '@teable/db-data-prisma'; import { PrismaService } from '@teable/db-main-prisma'; import createKnex, { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; +import { ClsService } from 'nestjs-cls'; +import { withDataDbInternalSchemaParam } from '../features/space/data-db-internal-schema'; +import { DataDbMigrationService } from '../features/space/data-db-migration.service'; import { decryptDataDbUrl } from '../features/space/data-db-url-secret'; +import type { IClsStore } from '../types/cls'; +import { + DATA_DB_KNEX_CACHE_NAMESPACE, + DATA_DB_PRISMA_CACHE_NAMESPACE, + DataDbRuntimeCacheService, +} from './data-db-runtime-cache.service'; import { DATA_KNEX } from './knex'; -@Injectable() -export class DataDbClientManager implements OnModuleDestroy { - private readonly knexClients = new Map(); - private readonly prismaClients = new Map(); +export interface IResolvedDataDatabase { + cacheKey: string; + url: string; + isMetaFallback: boolean; + connectionId?: string; + internalSchema?: string; +} + +export interface IDataDbRoutingOptions { + useTransaction?: boolean; +} +type IMetaRoutingClient = PrismaService | NonNullable; + +@Injectable() +export class DataDbClientManager { constructor( private readonly prismaService: PrismaService, - private readonly defaultDataPrismaService: DataPrismaService, - @InjectModel(DATA_KNEX) private readonly defaultDataKnex: Knex + private readonly metaFallbackDataPrismaService: DataPrismaService, + @InjectModel(DATA_KNEX) private readonly metaFallbackDataKnex: Knex, + private readonly runtimeCache: DataDbRuntimeCacheService, + @Optional() + private readonly dataDbMigrationService?: DataDbMigrationService, + @Optional() + @Inject(ClsService) + private readonly cls?: ClsService ) {} - async getDataDatabaseUrlForSpace(spaceId: string) { - const binding = await this.prismaService.spaceDataDbBinding.findUnique({ - where: { spaceId }, - include: { dataDbConnection: true }, - }); + private getMetaRoutingClient(options?: IDataDbRoutingOptions): IMetaRoutingClient { + return options?.useTransaction ? this.prismaService.txClient() : this.prismaService; + } - if (!binding || binding.mode === 'default') { - return getDataDatabaseUrl(); - } + async getDataDatabaseForSpace( + spaceId: string, + options?: IDataDbRoutingOptions + ): Promise { + const resolved = await this.resolveSpaceDataDb(spaceId, options); - if (binding.state !== 'ready' || binding.dataDbConnection?.status !== 'ready') { - throw new Error(`Data database binding for space ${spaceId} is not ready`); + if (resolved.isMetaFallback) { + return { + cacheKey: 'meta-fallback', + url: getMetaDatabaseUrl(), + isMetaFallback: true, + }; } - if (!binding.dataDbConnection.encryptedUrl) { - throw new Error(`Data database connection for space ${spaceId} has no encrypted URL`); - } + return { + cacheKey: resolved.connectionId, + connectionId: resolved.connectionId, + internalSchema: resolved.internalSchema, + url: withDataDbInternalSchemaParam(resolved.url, resolved.internalSchema), + isMetaFallback: false, + }; + } - return decryptDataDbUrl(binding.dataDbConnection.encryptedUrl); + async getDataDatabaseUrlForSpace(spaceId: string, options?: IDataDbRoutingOptions) { + return (await this.getDataDatabaseForSpace(spaceId, options)).url; } - async dataKnexForSpace(spaceId: string) { - const binding = await this.prismaService.spaceDataDbBinding.findUnique({ - where: { spaceId }, - include: { dataDbConnection: true }, + async getDataDatabaseForBase(baseId: string, options?: IDataDbRoutingOptions) { + const base = await this.getMetaRoutingClient(options).base.findUnique({ + where: { id: baseId }, + select: { spaceId: true }, }); - - if (!binding || binding.mode === 'default') { - return this.defaultDataKnex; + if (!base) { + throw new Error(`Base ${baseId} not found`); } + return await this.getDataDatabaseForSpace(base.spaceId, options); + } - if (binding.state !== 'ready' || binding.dataDbConnection?.status !== 'ready') { - throw new Error(`Data database binding for space ${spaceId} is not ready`); - } + async getDataDatabaseUrlForBase(baseId: string, options?: IDataDbRoutingOptions) { + return (await this.getDataDatabaseForBase(baseId, options)).url; + } - const connectionId = binding.dataDbConnection.id; - const existing = this.knexClients.get(connectionId); - if (existing) { - return existing; + async getDataDatabaseForTable(tableId: string, options?: IDataDbRoutingOptions) { + const table = await this.getMetaRoutingClient(options).tableMeta.findUnique({ + where: { id: tableId }, + select: { base: { select: { spaceId: true } } }, + }); + if (!table) { + throw new Error(`Table ${tableId} not found`); } + return await this.getDataDatabaseForSpace(table.base.spaceId, options); + } - const client = createKnex({ - client: 'pg', - connection: decryptDataDbUrl(binding.dataDbConnection.encryptedUrl), - pool: { - min: 0, - max: Number(process.env.BYODB_DATA_DB_POOL_MAX ?? 5), - }, - }); - this.knexClients.set(connectionId, client); - return client; + async getDataDatabaseUrlForTable(tableId: string, options?: IDataDbRoutingOptions) { + return (await this.getDataDatabaseForTable(tableId, options)).url; } - async dataPrismaForSpace(spaceId: string) { - const binding = await this.prismaService.spaceDataDbBinding.findUnique({ - where: { spaceId }, - include: { dataDbConnection: true }, - }); + async dataKnexForSpace(spaceId: string, options?: IDataDbRoutingOptions) { + const resolved = await this.resolveSpaceDataDb(spaceId, options); - if (!binding || binding.mode === 'default') { - return this.defaultDataPrismaService; + if (resolved.isMetaFallback) { + return this.metaFallbackDataKnex; } - if (binding.state !== 'ready' || binding.dataDbConnection?.status !== 'ready') { - throw new Error(`Data database binding for space ${spaceId} is not ready`); - } + return await this.runtimeCache.getOrCreate( + DATA_DB_KNEX_CACHE_NAMESPACE, + resolved.connectionId, + () => + createKnex({ + client: 'pg', + connection: resolved.url, + searchPath: [resolved.internalSchema], + pool: { + min: 0, + max: Number(process.env.BYODB_DATA_DB_POOL_MAX ?? 5), + }, + }), + (client) => client.destroy() + ); + } - const connectionId = binding.dataDbConnection.id; - const existing = this.prismaClients.get(connectionId); - if (existing) { - return existing; + async dataPrismaForSpace(spaceId: string, options?: IDataDbRoutingOptions) { + const resolved = await this.resolveSpaceDataDb(spaceId, options); + + if (resolved.isMetaFallback) { + return this.metaFallbackDataPrismaService; } - const client = new DataPrismaClient({ - datasources: { - db: { - url: decryptDataDbUrl(binding.dataDbConnection.encryptedUrl), - }, - }, - }); - this.prismaClients.set(connectionId, client); - return client; + return await this.runtimeCache.getOrCreate( + DATA_DB_PRISMA_CACHE_NAMESPACE, + resolved.connectionId, + () => + new DataPrismaClient({ + datasources: { + db: { + url: withDataDbInternalSchemaParam(resolved.url, resolved.internalSchema), + }, + }, + }), + (client) => client.$disconnect() + ); } - async dataKnexForBase(baseId: string) { - const base = await this.prismaService.base.findUnique({ + async dataKnexForBase(baseId: string, options?: IDataDbRoutingOptions) { + const base = await this.getMetaRoutingClient(options).base.findUnique({ where: { id: baseId }, select: { spaceId: true }, }); if (!base) { throw new Error(`Base ${baseId} not found`); } - return await this.dataKnexForSpace(base.spaceId); + return await this.dataKnexForSpace(base.spaceId, options); + } + + async dataKnexForTable(tableId: string, options?: IDataDbRoutingOptions) { + const table = await this.getMetaRoutingClient(options).tableMeta.findUnique({ + where: { id: tableId }, + select: { base: { select: { spaceId: true } } }, + }); + if (!table) { + throw new Error(`Table ${tableId} not found`); + } + return await this.dataKnexForSpace(table.base.spaceId, options); } - async dataPrismaForBase(baseId: string) { - const base = await this.prismaService.base.findUnique({ + async dataPrismaForTable(tableId: string, options?: IDataDbRoutingOptions) { + const table = await this.getMetaRoutingClient(options).tableMeta.findUnique({ + where: { id: tableId }, + select: { base: { select: { spaceId: true } } }, + }); + if (!table) { + throw new Error(`Table ${tableId} not found`); + } + return await this.dataPrismaForSpace(table.base.spaceId, options); + } + + async dataPrismaForBase(baseId: string, options?: IDataDbRoutingOptions) { + const base = await this.getMetaRoutingClient(options).base.findUnique({ where: { id: baseId }, select: { spaceId: true }, }); if (!base) { throw new Error(`Base ${baseId} not found`); } - return await this.dataPrismaForSpace(base.spaceId); + return await this.dataPrismaForSpace(base.spaceId, options); } - invalidateConnection(connectionId: string) { - const knex = this.knexClients.get(connectionId); - if (knex) { - void knex.destroy(); - this.knexClients.delete(connectionId); + async invalidateConnection(connectionId: string) { + await this.runtimeCache.deleteByKey(connectionId); + } + + private async resolveSpaceDataDb( + spaceId: string, + options?: IDataDbRoutingOptions + ): Promise< + | { isMetaFallback: true } + | { connectionId: string; internalSchema: string; isMetaFallback: false; url: string } + > { + const binding = await this.getMetaRoutingClient(options).spaceDataDbBinding.findUnique({ + where: { spaceId }, + include: { dataDbConnection: true }, + }); + + if (!binding || binding.mode === 'default') { + return { isMetaFallback: true }; } - const prisma = this.prismaClients.get(connectionId); - if (prisma) { - void prisma.$disconnect(); - this.prismaClients.delete(connectionId); + const connection = binding.dataDbConnection; + if (!connection) { + throw new Error(`Data database connection for space ${spaceId} was not found`); + } + + const migratableStates = this.dataDbMigrationService + ? ['ready', 'migrating', 'error'] + : ['ready']; + + if (!migratableStates.includes(binding.state)) { + throw new Error(`Data database binding for space ${spaceId} is not ready`); + } + + if (!migratableStates.includes(connection.status)) { + throw new Error(`Data database binding for space ${spaceId} is not ready`); } + + if (!connection.encryptedUrl) { + throw new Error(`Data database connection for space ${spaceId} has no encrypted URL`); + } + + this.cls?.set('dataDb', { + mode: 'byodb', + spaceId, + connectionId: connection.id, + urlFingerprint: connection.urlFingerprint, + displayHost: connection.displayHost, + displayDatabase: connection.displayDatabase, + internalSchema: connection.internalSchema, + }); + + const url = decryptDataDbUrl(connection.encryptedUrl); + await this.dataDbMigrationService?.ensureConnectionMigrated({ + connectionId: connection.id, + internalSchema: connection.internalSchema, + url, + }); + + return { + connectionId: connection.id, + internalSchema: connection.internalSchema, + isMetaFallback: false, + url, + }; } async onModuleDestroy() { await Promise.all([ - ...Array.from(this.knexClients.values()).map((client) => client.destroy()), - ...Array.from(this.prismaClients.values()).map((client) => client.$disconnect()), + this.runtimeCache.deleteByNamespace(DATA_DB_KNEX_CACHE_NAMESPACE), + this.runtimeCache.deleteByNamespace(DATA_DB_PRISMA_CACHE_NAMESPACE), ]); - this.knexClients.clear(); - this.prismaClients.clear(); } } diff --git a/apps/nestjs-backend/src/global/data-db-runtime-cache.service.spec.ts b/apps/nestjs-backend/src/global/data-db-runtime-cache.service.spec.ts new file mode 100644 index 0000000000..e9385bac02 --- /dev/null +++ b/apps/nestjs-backend/src/global/data-db-runtime-cache.service.spec.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DataDbRuntimeCacheService } from './data-db-runtime-cache.service'; + +describe('DataDbRuntimeCacheService', () => { + afterEach(() => { + delete process.env.BYODB_RUNTIME_CACHE_MAX; + }); + + it('reuses entries by namespace and key', async () => { + const cache = new DataDbRuntimeCacheService(); + const create = vi.fn().mockResolvedValue({ id: 'client' }); + const destroy = vi.fn(); + + await expect(cache.getOrCreate('ns', 'key', create, destroy)).resolves.toEqual({ + id: 'client', + }); + await expect(cache.getOrCreate('ns', 'key', create, destroy)).resolves.toEqual({ + id: 'client', + }); + + expect(create).toHaveBeenCalledTimes(1); + expect(destroy).not.toHaveBeenCalled(); + await cache.onModuleDestroy(); + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it('evicts the least recently used entry and destroys it', async () => { + process.env.BYODB_RUNTIME_CACHE_MAX = '2'; + const cache = new DataDbRuntimeCacheService(); + const destroy = vi.fn(); + + await cache.getOrCreate('ns', 'a', () => Promise.resolve('a'), destroy); + await cache.getOrCreate('ns', 'b', () => Promise.resolve('b'), destroy); + await cache.getOrCreate('ns', 'a', () => Promise.resolve('new-a'), destroy); + await cache.getOrCreate('ns', 'c', () => Promise.resolve('c'), destroy); + + expect(destroy).toHaveBeenCalledWith('b'); + expect(cache.size).toBe(2); + await cache.onModuleDestroy(); + }); + + it('can actively invalidate all runtimes for a connection key', async () => { + const cache = new DataDbRuntimeCacheService(); + const destroy = vi.fn(); + + await cache.getOrCreate('prisma', 'dcnxxx', () => Promise.resolve('prisma'), destroy); + await cache.getOrCreate('knex', 'dcnxxx', () => Promise.resolve('knex'), destroy); + await cache.getOrCreate('v2', 'dcnxxx', () => Promise.resolve('container'), destroy); + + await cache.deleteByKey('dcnxxx'); + + expect(destroy).toHaveBeenCalledTimes(3); + expect(cache.size).toBe(0); + }); +}); diff --git a/apps/nestjs-backend/src/global/data-db-runtime-cache.service.ts b/apps/nestjs-backend/src/global/data-db-runtime-cache.service.ts new file mode 100644 index 0000000000..0cffd5668c --- /dev/null +++ b/apps/nestjs-backend/src/global/data-db-runtime-cache.service.ts @@ -0,0 +1,137 @@ +import type { OnModuleDestroy } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; + +export const DATA_DB_KNEX_CACHE_NAMESPACE = 'data-db:knex'; +export const DATA_DB_PRISMA_CACHE_NAMESPACE = 'data-db:prisma'; +export const V2_CONTAINER_CACHE_NAMESPACE = 'v2:container'; + +type DestroyFn = (value: T) => Promise | void; +type UnknownDestroyFn = (value: unknown) => Promise | void; + +interface ICacheEntry { + namespace: string; + key: string; + promise: Promise; + value?: unknown; + destroy: UnknownDestroyFn; +} + +const resolveMaxEntries = () => { + const raw = Number(process.env.BYODB_RUNTIME_CACHE_MAX ?? 50); + return Number.isInteger(raw) && raw > 0 ? raw : 50; +}; + +@Injectable() +export class DataDbRuntimeCacheService implements OnModuleDestroy { + private readonly logger = new Logger(DataDbRuntimeCacheService.name); + private readonly entries = new Map(); + private readonly maxEntries = resolveMaxEntries(); + + async getOrCreate( + namespace: string, + key: string, + create: () => Promise | T, + destroy: DestroyFn + ): Promise { + const cacheKey = this.getCacheKey(namespace, key); + const existing = this.entries.get(cacheKey); + if (existing) { + this.entries.delete(cacheKey); + this.entries.set(cacheKey, existing); + return (await existing.promise) as T; + } + + const entry: ICacheEntry = { + namespace, + key, + destroy: (value) => destroy(value as T), + promise: Promise.resolve(undefined), + }; + + entry.promise = Promise.resolve() + .then(create) + .then((value) => { + entry.value = value; + return value; + }) + .catch((error) => { + this.entries.delete(cacheKey); + throw error; + }); + + this.entries.set(cacheKey, entry); + await this.evictIfNeeded(); + return (await entry.promise) as T; + } + + async delete(namespace: string, key: string) { + const cacheKey = this.getCacheKey(namespace, key); + const entry = this.entries.get(cacheKey); + if (!entry) return; + + this.entries.delete(cacheKey); + await this.destroyEntry(entry); + } + + async deleteByNamespace(namespace: string) { + const entries = Array.from(this.entries.entries()).filter( + ([, entry]) => entry.namespace === namespace + ); + await Promise.all( + entries.map(async ([cacheKey, entry]) => { + this.entries.delete(cacheKey); + await this.destroyEntry(entry); + }) + ); + } + + async deleteByKey(key: string) { + const entries = Array.from(this.entries.entries()).filter(([, entry]) => entry.key === key); + await Promise.all( + entries.map(async ([cacheKey, entry]) => { + this.entries.delete(cacheKey); + await this.destroyEntry(entry); + }) + ); + } + + get size() { + return this.entries.size; + } + + private async evictIfNeeded() { + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.entries().next().value as [string, ICacheEntry] | undefined; + if (!oldest) return; + + const [cacheKey, entry] = oldest; + this.entries.delete(cacheKey); + await this.destroyEntry(entry); + } + } + + private async destroyEntry(entry: ICacheEntry) { + try { + const value = entry.value ?? (await entry.promise.catch(() => undefined)); + if (value != null) { + await entry.destroy(value); + } + } catch (error) { + this.logger.warn( + `Failed to destroy cached data DB runtime ${entry.namespace}:${entry.key}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + private getCacheKey(namespace: string, key: string) { + return `${namespace}:${key}`; + } + + async onModuleDestroy() { + const entries = Array.from(this.entries.entries()); + this.entries.clear(); + await Promise.all(entries.map(([, entry]) => this.destroyEntry(entry))); + } +} diff --git a/apps/nestjs-backend/src/global/data-db-runtime-error.spec.ts b/apps/nestjs-backend/src/global/data-db-runtime-error.spec.ts new file mode 100644 index 0000000000..59414aa6fb --- /dev/null +++ b/apps/nestjs-backend/src/global/data-db-runtime-error.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { classifyDataDbRuntimeError } from './data-db-runtime-error'; + +describe('classifyDataDbRuntimeError', () => { + it('classifies a missing external database without echoing the raw driver message', () => { + const error = Object.assign(new Error('database "customer_deleted_db" does not exist'), { + code: '3D000', + }); + + expect(classifyDataDbRuntimeError(error)).toMatchObject({ + code: 'data_db.database_missing', + message: 'The bound data database no longer exists or cannot be selected.', + retryable: false, + userActionable: true, + pgCode: '3D000', + driverCode: '3D000', + }); + }); + + it('classifies common auth, missing relation, timeout, and pool errors', () => { + expect(classifyDataDbRuntimeError({ code: '28P01', message: 'password failed' })).toMatchObject( + { + code: 'data_db.auth_failed', + retryable: false, + userActionable: true, + } + ); + expect( + classifyDataDbRuntimeError({ code: '42P01', message: 'relation missing' }) + ).toMatchObject({ + code: 'data_db.relation_missing', + retryable: false, + userActionable: true, + }); + expect( + classifyDataDbRuntimeError({ code: 'ETIMEDOUT', message: 'connect timed out' }) + ).toMatchObject({ + code: 'data_db.timeout', + retryable: true, + userActionable: true, + driverCode: 'ETIMEDOUT', + }); + expect( + classifyDataDbRuntimeError({ code: 'P2024', message: 'Timed out fetching a new connection' }) + ).toMatchObject({ + code: 'data_db.pool_exhausted', + retryable: true, + userActionable: true, + driverCode: 'P2024', + }); + }); + + it('classifies Prisma messages even when the code is missing', () => { + expect( + classifyDataDbRuntimeError(new Error("Can't reach database server at `db.example.com:5432`")) + ).toMatchObject({ + code: 'data_db.timeout', + retryable: true, + userActionable: true, + }); + }); + + it('returns null for unrelated application errors', () => { + expect(classifyDataDbRuntimeError(new Error('field validation failed'))).toBeNull(); + }); +}); diff --git a/apps/nestjs-backend/src/global/data-db-runtime-error.ts b/apps/nestjs-backend/src/global/data-db-runtime-error.ts new file mode 100644 index 0000000000..12874403c0 --- /dev/null +++ b/apps/nestjs-backend/src/global/data-db-runtime-error.ts @@ -0,0 +1,207 @@ +export type IDataDbRuntimeErrorCode = + | 'data_db.database_missing' + | 'data_db.auth_failed' + | 'data_db.connection_refused' + | 'data_db.timeout' + | 'data_db.network_unreachable' + | 'data_db.connection_lost' + | 'data_db.schema_missing' + | 'data_db.relation_missing' + | 'data_db.permission_denied' + | 'data_db.pool_exhausted'; + +export type IDataDbRuntimeErrorClassification = { + code: IDataDbRuntimeErrorCode; + message: string; + retryable: boolean; + userActionable: boolean; + pgCode?: string; + driverCode?: string; +}; + +const getErrorCode = (error: unknown): string | undefined => { + if (!error || typeof error !== 'object') { + return undefined; + } + + const candidate = error as { code?: unknown; errorCode?: unknown }; + return typeof candidate.code === 'string' + ? candidate.code + : typeof candidate.errorCode === 'string' + ? candidate.errorCode + : undefined; +}; + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + return String(error); +}; + +const buildClassification = ( + error: unknown, + code: IDataDbRuntimeErrorCode, + message: string, + options: Pick +): IDataDbRuntimeErrorClassification => { + const driverCode = getErrorCode(error); + const isPgCode = driverCode + ? /^[0-9A-Z]{5}$/.test(driverCode) && !/^P\d{4}$/.test(driverCode) + : false; + return { + code, + message, + ...options, + ...(driverCode ? { driverCode } : {}), + ...(isPgCode ? { pgCode: driverCode } : {}), + }; +}; + +export const classifyDataDbRuntimeError = ( + error: unknown +): IDataDbRuntimeErrorClassification | null => { + const driverCode = getErrorCode(error); + const message = getErrorMessage(error); + + switch (driverCode) { + case '3D000': + case 'P1003': + return buildClassification( + error, + 'data_db.database_missing', + 'The bound data database no longer exists or cannot be selected.', + { retryable: false, userActionable: true } + ); + case '28P01': + case 'P1000': + return buildClassification( + error, + 'data_db.auth_failed', + 'The bound data database rejected the configured credentials.', + { retryable: false, userActionable: true } + ); + case 'ECONNREFUSED': + return buildClassification( + error, + 'data_db.connection_refused', + 'The bound data database refused the connection.', + { retryable: true, userActionable: true } + ); + case 'ETIMEDOUT': + case 'P1008': + case '57014': + return buildClassification(error, 'data_db.timeout', 'The bound data database timed out.', { + retryable: true, + userActionable: true, + }); + case 'ENOTFOUND': + case 'ENETUNREACH': + case 'EHOSTUNREACH': + case 'EAI_AGAIN': + case 'P1001': + return buildClassification( + error, + 'data_db.network_unreachable', + 'The bound data database host is not reachable.', + { retryable: true, userActionable: true } + ); + case 'ECONNRESET': + case '08000': + case '08001': + case '08003': + case '08004': + case '08006': + case '08007': + case '57P01': + case 'P1017': + return buildClassification( + error, + 'data_db.connection_lost', + 'The bound data database connection was interrupted.', + { retryable: true, userActionable: true } + ); + case '3F000': + return buildClassification( + error, + 'data_db.schema_missing', + 'The bound data database internal schema is missing.', + { retryable: false, userActionable: true } + ); + case '42P01': + case 'P2021': + return buildClassification( + error, + 'data_db.relation_missing', + 'A required table or relation is missing from the bound data database.', + { retryable: false, userActionable: true } + ); + case '42501': + return buildClassification( + error, + 'data_db.permission_denied', + 'The bound data database user does not have the required permissions.', + { retryable: false, userActionable: true } + ); + case '53300': + case '53400': + case 'P2024': + return buildClassification( + error, + 'data_db.pool_exhausted', + 'The bound data database does not have enough available connections.', + { retryable: true, userActionable: true } + ); + default: + break; + } + + if (/database ".+" does not exist/i.test(message)) { + return buildClassification( + error, + 'data_db.database_missing', + 'The bound data database no longer exists or cannot be selected.', + { retryable: false, userActionable: true } + ); + } + if (/password authentication failed/i.test(message)) { + return buildClassification( + error, + 'data_db.auth_failed', + 'The bound data database rejected the configured credentials.', + { retryable: false, userActionable: true } + ); + } + if (/relation ".+" does not exist/i.test(message)) { + return buildClassification( + error, + 'data_db.relation_missing', + 'A required table or relation is missing from the bound data database.', + { retryable: false, userActionable: true } + ); + } + if (/permission denied/i.test(message)) { + return buildClassification( + error, + 'data_db.permission_denied', + 'The bound data database user does not have the required permissions.', + { retryable: false, userActionable: true } + ); + } + if (/Unable to start a transaction|Timed out fetching a new connection/i.test(message)) { + return buildClassification( + error, + 'data_db.pool_exhausted', + 'The bound data database does not have enough available connections.', + { retryable: true, userActionable: true } + ); + } + if (/Can't reach database server|connect ETIMEDOUT|connection timed out/i.test(message)) { + return buildClassification(error, 'data_db.timeout', 'The bound data database timed out.', { + retryable: true, + userActionable: true, + }); + } + + return null; +}; diff --git a/apps/nestjs-backend/src/global/database-router.service.spec.ts b/apps/nestjs-backend/src/global/database-router.service.spec.ts new file mode 100644 index 0000000000..788cddab35 --- /dev/null +++ b/apps/nestjs-backend/src/global/database-router.service.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DatabaseRouter } from './database-router.service'; + +describe('DatabaseRouter', () => { + it('executes table scoped raw queries through the scoped table client', async () => { + const queryRaw = vi.fn().mockResolvedValue([{ count: 1 }]); + const executeRaw = vi.fn().mockResolvedValue(1); + const dataDbClientManager = { + dataPrismaForTable: vi.fn().mockResolvedValue({ + txClient: () => ({ + $queryRawUnsafe: queryRaw, + $executeRawUnsafe: executeRaw, + }), + }), + }; + const router = new DatabaseRouter( + {} as never, + {} as never, + {} as never, + {} as never, + dataDbClientManager as never + ); + + await expect(router.queryDataPrismaForTable('tblxxx', 'select 1')).resolves.toEqual([ + { count: 1 }, + ]); + await expect(router.executeDataPrismaForTable('tblxxx', 'update x')).resolves.toBe(1); + expect(dataDbClientManager.dataPrismaForTable).toHaveBeenCalledWith('tblxxx', undefined); + expect(queryRaw).toHaveBeenCalledWith('select 1'); + expect(executeRaw).toHaveBeenCalledWith('update x'); + }); + + it('passes explicit routing options separately from raw query values', async () => { + const queryRaw = vi.fn().mockResolvedValue([{ id: 'recxxx' }]); + const dataDbClientManager = { + dataPrismaForTable: vi.fn().mockResolvedValue({ + txClient: () => ({ + $queryRawUnsafe: queryRaw, + $executeRawUnsafe: vi.fn(), + }), + }), + }; + const router = new DatabaseRouter( + {} as never, + {} as never, + {} as never, + {} as never, + dataDbClientManager as never + ); + + await router.queryDataPrismaForTable( + 'tblxxx', + 'select * from x where id = $1', + { useTransaction: true }, + 'recxxx' + ); + + expect(dataDbClientManager.dataPrismaForTable).toHaveBeenCalledWith('tblxxx', { + useTransaction: true, + }); + expect(queryRaw).toHaveBeenCalledWith('select * from x where id = $1', 'recxxx'); + }); + + it('executes table scoped transactions with PrismaClient transaction fallback', async () => { + const executeRaw = vi.fn().mockResolvedValue(1); + const transaction = vi.fn(async (fn) => await fn({ $executeRawUnsafe: executeRaw })); + const dataDbClientManager = { + dataPrismaForTable: vi.fn().mockResolvedValue({ + $executeRawUnsafe: vi.fn(), + $queryRawUnsafe: vi.fn(), + $transaction: transaction, + }), + }; + const router = new DatabaseRouter( + {} as never, + {} as never, + {} as never, + {} as never, + dataDbClientManager as never + ); + + await router.dataPrismaTransactionForTable('tblxxx', async (prisma) => { + await prisma.$executeRawUnsafe('alter table x'); + }); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(executeRaw).toHaveBeenCalledWith('alter table x'); + }); +}); diff --git a/apps/nestjs-backend/src/global/database-router.service.ts b/apps/nestjs-backend/src/global/database-router.service.ts index 627286dd24..3ffacbb7e6 100644 --- a/apps/nestjs-backend/src/global/database-router.service.ts +++ b/apps/nestjs-backend/src/global/database-router.service.ts @@ -4,8 +4,34 @@ import { getDatabaseUrl, MetaPrismaService } from '@teable/db-main-prisma'; import { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; import { DataDbClientManager } from './data-db-client-manager.service'; +import type { IDataDbRoutingOptions } from './data-db-client-manager.service'; import { DATA_KNEX, META_KNEX } from './knex'; +export type IDataPrismaQueryExecutor = { + $queryRawUnsafe(query: string, ...values: unknown[]): Promise; + $executeRawUnsafe(query: string, ...values: unknown[]): Promise; +}; + +type IDataPrismaScopedClient = IDataPrismaQueryExecutor & { + txClient?: () => IDataPrismaQueryExecutor; + $tx?: ( + fn: (prisma: IDataPrismaQueryExecutor) => Promise, + options?: { + maxWait?: number; + timeout?: number; + isolationLevel?: unknown; + } + ) => Promise; + $transaction?: ( + fn: (prisma: IDataPrismaQueryExecutor) => Promise, + options?: { + maxWait?: number; + timeout?: number; + isolationLevel?: unknown; + } + ) => Promise; +}; + @Injectable() export class DatabaseRouter { constructor( @@ -36,23 +62,177 @@ export class DatabaseRouter { return getDatabaseUrl(target); } - async getDataDatabaseUrlForSpace(spaceId: string) { - return await this.dataDbClientManager.getDataDatabaseUrlForSpace(spaceId); + async getDataDatabaseUrlForSpace(spaceId: string, options?: IDataDbRoutingOptions) { + return await this.dataDbClientManager.getDataDatabaseUrlForSpace(spaceId, options); + } + + async getDataDatabaseUrlForTable(tableId: string, options?: IDataDbRoutingOptions) { + return await this.dataDbClientManager.getDataDatabaseUrlForTable(tableId, options); + } + + async getDataDatabaseUrlForBase(baseId: string, options?: IDataDbRoutingOptions) { + return await this.dataDbClientManager.getDataDatabaseUrlForBase(baseId, options); + } + + async dataKnexForSpace(spaceId: string, options?: IDataDbRoutingOptions) { + return await this.dataDbClientManager.dataKnexForSpace(spaceId, options); + } + + async dataPrismaForSpace(spaceId: string, options?: IDataDbRoutingOptions) { + return await this.dataDbClientManager.dataPrismaForSpace(spaceId, options); + } + + async dataKnexForBase(baseId: string, options?: IDataDbRoutingOptions) { + return await this.dataDbClientManager.dataKnexForBase(baseId, options); + } + + async dataPrismaForBase(baseId: string, options?: IDataDbRoutingOptions) { + return await this.dataDbClientManager.dataPrismaForBase(baseId, options); + } + + async dataKnexForTable(tableId: string, options?: IDataDbRoutingOptions) { + return await this.dataDbClientManager.dataKnexForTable(tableId, options); + } + + async dataPrismaForTable(tableId: string, options?: IDataDbRoutingOptions) { + return await this.dataDbClientManager.dataPrismaForTable(tableId, options); + } + + private getDataPrismaExecutor(prisma: IDataPrismaScopedClient): IDataPrismaQueryExecutor { + return prisma.txClient?.() ?? prisma; } - async dataKnexForSpace(spaceId: string) { - return await this.dataDbClientManager.dataKnexForSpace(spaceId); + async dataPrismaExecutorForTable( + tableId: string, + options?: IDataDbRoutingOptions + ): Promise { + const prisma = (await this.dataPrismaForTable(tableId, options)) as IDataPrismaScopedClient; + return this.getDataPrismaExecutor(prisma); } - async dataPrismaForSpace(spaceId: string) { - return await this.dataDbClientManager.dataPrismaForSpace(spaceId); + async dataPrismaExecutorForBase( + baseId: string, + options?: IDataDbRoutingOptions + ): Promise { + const prisma = (await this.dataPrismaForBase(baseId, options)) as IDataPrismaScopedClient; + return this.getDataPrismaExecutor(prisma); } - async dataKnexForBase(baseId: string) { - return await this.dataDbClientManager.dataKnexForBase(baseId); + async queryDataPrismaForTable( + tableId: string, + query: string, + optionsOrFirstValue?: IDataDbRoutingOptions | unknown, + ...values: unknown[] + ): Promise { + const { options, queryValues } = this.normalizeRoutingOptions(optionsOrFirstValue, values); + const prisma = await this.dataPrismaExecutorForTable(tableId, options); + return await prisma.$queryRawUnsafe(query, ...queryValues); } - async dataPrismaForBase(baseId: string) { - return await this.dataDbClientManager.dataPrismaForBase(baseId); + async executeDataPrismaForTable( + tableId: string, + query: string, + optionsOrFirstValue?: IDataDbRoutingOptions | unknown, + ...values: unknown[] + ): Promise { + const { options, queryValues } = this.normalizeRoutingOptions(optionsOrFirstValue, values); + const prisma = await this.dataPrismaExecutorForTable(tableId, options); + return await prisma.$executeRawUnsafe(query, ...queryValues); + } + + async queryDataPrismaForBase( + baseId: string, + query: string, + optionsOrFirstValue?: IDataDbRoutingOptions | unknown, + ...values: unknown[] + ): Promise { + const { options, queryValues } = this.normalizeRoutingOptions(optionsOrFirstValue, values); + const prisma = await this.dataPrismaExecutorForBase(baseId, options); + return await prisma.$queryRawUnsafe(query, ...queryValues); + } + + async executeDataPrismaForBase( + baseId: string, + query: string, + optionsOrFirstValue?: IDataDbRoutingOptions | unknown, + ...values: unknown[] + ): Promise { + const { options, queryValues } = this.normalizeRoutingOptions(optionsOrFirstValue, values); + const prisma = await this.dataPrismaExecutorForBase(baseId, options); + return await prisma.$executeRawUnsafe(query, ...queryValues); + } + + async dataPrismaTransactionForTable( + tableId: string, + fn: (prisma: IDataPrismaQueryExecutor) => Promise, + options?: { + maxWait?: number; + timeout?: number; + isolationLevel?: unknown; + }, + routingOptions?: IDataDbRoutingOptions + ): Promise { + const prisma = (await this.dataPrismaForTable( + tableId, + routingOptions + )) as IDataPrismaScopedClient; + + if (prisma.$tx) { + return await prisma.$tx(fn, options); + } + + if (prisma.$transaction) { + return await prisma.$transaction(fn, options); + } + + return await fn(this.getDataPrismaExecutor(prisma)); + } + + async dataPrismaTransactionForBase( + baseId: string, + fn: (prisma: IDataPrismaQueryExecutor) => Promise, + options?: { + maxWait?: number; + timeout?: number; + isolationLevel?: unknown; + }, + routingOptions?: IDataDbRoutingOptions + ): Promise { + const prisma = (await this.dataPrismaForBase( + baseId, + routingOptions + )) as IDataPrismaScopedClient; + + if (prisma.$tx) { + return await prisma.$tx(fn, options); + } + + if (prisma.$transaction) { + return await prisma.$transaction(fn, options); + } + + return await fn(this.getDataPrismaExecutor(prisma)); + } + + private isRoutingOptions(value: unknown): value is IDataDbRoutingOptions { + return ( + Boolean(value) && + typeof value === 'object' && + Object.keys(value as Record).length > 0 && + Object.keys(value as Record).every((key) => key === 'useTransaction') + ); + } + + private normalizeRoutingOptions( + optionsOrFirstValue: IDataDbRoutingOptions | unknown, + values: unknown[] + ): { options?: IDataDbRoutingOptions; queryValues: unknown[] } { + if (this.isRoutingOptions(optionsOrFirstValue)) { + return { options: optionsOrFirstValue, queryValues: values }; + } + + return { + queryValues: optionsOrFirstValue === undefined ? values : [optionsOrFirstValue, ...values], + }; } } diff --git a/apps/nestjs-backend/src/global/global.module.ts b/apps/nestjs-backend/src/global/global.module.ts index 5003a92c43..9bbcb526cc 100644 --- a/apps/nestjs-backend/src/global/global.module.ts +++ b/apps/nestjs-backend/src/global/global.module.ts @@ -24,12 +24,14 @@ import { PermissionGuard } from '../features/auth/guard/permission.guard'; import { PermissionModule } from '../features/auth/permission.module'; import { DataLoaderModule } from '../features/data-loader/data-loader.module'; import { ModelModule } from '../features/model/model.module'; +import { DataDbMigrationService } from '../features/space/data-db-migration.service'; import { RequestInfoMiddleware } from '../middleware/request-info.middleware'; import { SessionCsrfMiddleware } from '../middleware/session-csrf.middleware'; import { PerformanceCacheModule } from '../performance-cache'; import { RouteTracingInterceptor } from '../tracing/route-tracing.interceptor'; import { getI18nPath, getI18nTypesOutputPath } from '../utils/i18n'; import { DataDbClientManager } from './data-db-client-manager.service'; +import { DataDbRuntimeCacheService } from './data-db-runtime-cache.service'; import { DatabaseRouter } from './database-router.service'; import { KnexModule } from './knex'; @@ -93,7 +95,9 @@ const globalModules = { // for overriding the default TablePermissionService, FieldPermissionService, RecordPermissionService, and ViewPermissionService providers: [ DbProvider, + DataDbRuntimeCacheService, DataDbClientManager, + DataDbMigrationService, DatabaseRouter, RequestInfoMiddleware, SessionCsrfMiddleware, @@ -112,7 +116,9 @@ const globalModules = { ], exports: [ DbProvider, + DataDbRuntimeCacheService, DataDbClientManager, + DataDbMigrationService, DatabaseRouter, KnexModule, PrismaModule, diff --git a/apps/nestjs-backend/src/share-db/readonly/record-readonly.service.ts b/apps/nestjs-backend/src/share-db/readonly/record-readonly.service.ts index 286c6b28f0..9286f2ddb2 100644 --- a/apps/nestjs-backend/src/share-db/readonly/record-readonly.service.ts +++ b/apps/nestjs-backend/src/share-db/readonly/record-readonly.service.ts @@ -1,11 +1,11 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '@teable/db-main-prisma'; -import { DataPrismaService } from '@teable/db-data-prisma'; import type { IGetRecordsRo } from '@teable/openapi'; import { IS_TEMPLATE_HEADER, BASE_SHARE_ID_HEADER } from '@teable/openapi'; import { Knex } from 'knex'; import { InjectModel } from 'nest-knexjs'; import { ClsService } from 'nestjs-cls'; +import { DatabaseRouter } from '../../global/database-router.service'; import { DATA_KNEX } from '../../global/knex/knex.module'; import type { IShareDbReadonlyAdapterService, RawOpType } from '../interface'; import { ReadonlyService } from './readonly.service'; @@ -19,7 +19,7 @@ export class RecordReadonlyServiceAdapter constructor( private readonly cls: ClsService, private readonly prismaService: PrismaService, - private readonly dataPrismaService: DataPrismaService, + private readonly databaseRouter: DatabaseRouter, @InjectModel(DATA_KNEX) private readonly knex: Knex ) { super(cls); @@ -99,17 +99,11 @@ export class RecordReadonlyServiceAdapter async getVersionAndType(tableId: string, recordId: string) { const table = await this.validateTable(tableId); - return this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ version: number; deletedTime: Date | null }[]>( - this.knex(table.dbTableName) - .select('__version as version') - .where('__id', recordId) - .toQuery() - ) - .then((res) => { - return this.formatVersionAndType(res[0]); - }); + return this.databaseRouter + .queryDataPrismaForTable< + { version: number; deletedTime: Date | null }[] + >(tableId, this.knex(table.dbTableName).select('__version as version').where('__id', recordId).toQuery()) + .then((res) => this.formatVersionAndType(res[0])); } async getVersionAndTypeMap(tableId: string, recordIds: string[]) { @@ -118,9 +112,9 @@ export class RecordReadonlyServiceAdapter .select('__version as version', '__id') .whereIn('__id', recordIds) .toQuery(); - const recordRaw = await this.dataPrismaService - .txClient() - .$queryRawUnsafe<{ version: number; deletedTime: Date | null; __id: string }[]>(nativeQuery); + const recordRaw = await this.databaseRouter.queryDataPrismaForTable< + { version: number; deletedTime: Date | null; __id: string }[] + >(tableId, nativeQuery); return recordRaw.reduce( (acc, record) => { acc[record.__id] = this.formatVersionAndType(record); diff --git a/apps/nestjs-backend/src/tracing-db-context.spec.ts b/apps/nestjs-backend/src/tracing-db-context.spec.ts new file mode 100644 index 0000000000..a91796132b --- /dev/null +++ b/apps/nestjs-backend/src/tracing-db-context.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + resolveTeableDbTraceContext, + setTeableDbSpanAttributes, + setTeableDbSpanAttributesFromSpan, +} from './tracing-db-context'; + +describe('tracing db context', () => { + const env = { + PRISMA_DATABASE_URL: 'postgresql://postgres:secret@meta.example.test:5433/teable', + }; + + it('marks connections matching the meta URL as meta and redacts the password', () => { + expect( + resolveTeableDbTraceContext( + { + database: 'teable', + host: 'meta.example.test', + port: 5433, + user: 'postgres', + }, + env + ) + ).toEqual({ + role: 'meta', + source: 'PRISMA_DATABASE_URL', + url: 'postgresql://postgres@meta.example.test:5433/teable', + }); + }); + + it('marks non-meta postgres connections as dynamic data DB connections', () => { + expect( + resolveTeableDbTraceContext( + { + database: 'postgres', + host: 'byodb.example.test', + port: 5544, + user: 'postgres', + }, + env + ) + ).toEqual({ + role: 'data', + source: 'inferred.non_meta_postgres', + url: 'postgresql://postgres@byodb.example.test:5544/postgres', + }); + }); + + it('writes teable db attributes to query spans', () => { + const span = { setAttribute: vi.fn() }; + + setTeableDbSpanAttributes( + span, + { + database: 'teable', + host: 'meta.example.test', + port: 5433, + user: 'postgres', + }, + env + ); + + expect(span.setAttribute).toHaveBeenCalledWith('teable.db.role', 'meta'); + expect(span.setAttribute).toHaveBeenCalledWith( + 'teable.db.url', + 'postgresql://postgres@meta.example.test:5433/teable' + ); + expect(span.setAttribute).toHaveBeenCalledWith('teable.db.source', 'PRISMA_DATABASE_URL'); + }); + + it('writes teable db attributes to connection spans from existing span attributes', () => { + const span = { + attributes: { + 'db.name': 'teable_data', + 'db.user': 'postgres', + 'net.peer.name': 'data.example.test', + 'net.peer.port': 5434, + }, + setAttribute: vi.fn(), + }; + + setTeableDbSpanAttributesFromSpan(span, env); + + expect(span.setAttribute).toHaveBeenCalledWith('teable.db.role', 'data'); + expect(span.setAttribute).toHaveBeenCalledWith( + 'teable.db.url', + 'postgresql://postgres@data.example.test:5434/teable_data' + ); + expect(span.setAttribute).toHaveBeenCalledWith( + 'teable.db.source', + 'inferred.non_meta_postgres' + ); + }); +}); diff --git a/apps/nestjs-backend/src/tracing-db-context.ts b/apps/nestjs-backend/src/tracing-db-context.ts new file mode 100644 index 0000000000..f12c8699ec --- /dev/null +++ b/apps/nestjs-backend/src/tracing-db-context.ts @@ -0,0 +1,149 @@ +type IEnv = Record; + +export type ITraceDbRole = 'meta' | 'data'; + +export type ITraceDbConnection = { + database?: string; + host?: string; + port?: number | string; + user?: string; +}; + +export type ITraceDbContext = { + role: ITraceDbRole; + url: string; + source: string; +}; + +export type ITraceDbSpan = { + setAttribute(key: string, value: string): void; +}; + +const META_DATABASE_URL_KEYS = ['PRISMA_META_DATABASE_URL', 'PRISMA_DATABASE_URL', 'DATABASE_URL']; + +const normalizeDatabaseName = (value?: string) => value?.replace(/^\//, '') || undefined; + +const normalizePort = (value?: string | number) => { + if (value == null || value === '') { + return 5432; + } + + const port = Number(value); + return Number.isFinite(port) ? port : 5432; +}; + +const normalizeConnection = (connection: ITraceDbConnection) => ({ + database: normalizeDatabaseName(connection.database), + host: connection.host?.toLowerCase(), + port: normalizePort(connection.port), + user: connection.user, +}); + +const parseDatabaseUrl = (url: string | undefined) => { + if (!url) { + return; + } + + try { + const parsed = new URL(url); + const userPart = parsed.username ? `${decodeURIComponent(parsed.username)}@` : ''; + const port = normalizePort(parsed.port); + const database = normalizeDatabaseName(parsed.pathname); + + return { + database, + host: parsed.hostname.toLowerCase(), + port, + user: parsed.username ? decodeURIComponent(parsed.username) : undefined, + url: `${parsed.protocol}//${userPart}${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}${database ? `/${database}` : ''}`, + }; + } catch { + return; + } +}; + +const findMatchingDatabaseUrl = ( + connection: ReturnType, + keys: string[], + env: IEnv +) => { + for (const key of keys) { + const candidate = parseDatabaseUrl(env[key]); + if (!candidate) { + continue; + } + + if ( + candidate.host === connection.host && + candidate.port === connection.port && + candidate.database === connection.database + ) { + return { key, url: candidate.url }; + } + } +}; + +const buildConnectionUrl = (connection: ReturnType) => { + const userPart = connection.user ? `${connection.user}@` : ''; + const host = connection.host || 'unknown-host'; + const databasePart = connection.database ? `/${connection.database}` : ''; + return `postgresql://${userPart}${host}:${connection.port}${databasePart}`; +}; + +export const resolveTeableDbTraceContext = ( + connection: ITraceDbConnection, + env: IEnv = process.env +): ITraceDbContext => { + const normalized = normalizeConnection(connection); + const metaMatch = findMatchingDatabaseUrl(normalized, META_DATABASE_URL_KEYS, env); + if (metaMatch) { + return { + role: 'meta', + url: metaMatch.url, + source: metaMatch.key, + }; + } + + return { + role: 'data', + url: buildConnectionUrl(normalized), + source: 'inferred.non_meta_postgres', + }; +}; + +export const setTeableDbSpanAttributes = ( + span: ITraceDbSpan, + connection: ITraceDbConnection, + env: IEnv = process.env +) => { + const context = resolveTeableDbTraceContext(connection, env); + span.setAttribute('teable.db.role', context.role); + span.setAttribute('teable.db.url', context.url); + span.setAttribute('teable.db.source', context.source); +}; + +export const setTeableDbSpanAttributesFromSpan = ( + span: ITraceDbSpan & { attributes?: Record }, + env: IEnv = process.env +) => { + const attributes = span.attributes ?? {}; + const host = attributes['net.peer.name'] ?? attributes['server.address']; + const port = attributes['net.peer.port'] ?? attributes['server.port']; + const database = attributes['db.name'] ?? attributes['db.namespace']; + const user = attributes['db.user'] ?? attributes['db.user.name']; + + if (!host && !database) { + return; + } + + setTeableDbSpanAttributes( + span, + { + database: typeof database === 'string' ? database : undefined, + host: typeof host === 'string' ? host : undefined, + port: typeof port === 'string' || typeof port === 'number' ? port : undefined, + user: typeof user === 'string' ? user : undefined, + }, + env + ); +}; diff --git a/apps/nestjs-backend/src/tracing.ts b/apps/nestjs-backend/src/tracing.ts index c390745869..c323c5431e 100644 --- a/apps/nestjs-backend/src/tracing.ts +++ b/apps/nestjs-backend/src/tracing.ts @@ -55,6 +55,7 @@ import { SentrySpanProcessor, wrapContextManagerClass, } from '@sentry/opentelemetry'; +import { setTeableDbSpanAttributes, setTeableDbSpanAttributesFromSpan } from './tracing-db-context'; // Use webpack's special require that bypasses bundling, falling back to standard require // This is needed because webpack transforms import.meta.url and createRequire in ways @@ -267,12 +268,30 @@ const httpClientActiveRequestsProcessor: SpanProcessor = { forceFlush: () => Promise.resolve(), }; +const teableDbSpanAttributeProcessor: SpanProcessor = { + onStart(span): void { + const attributes = (span as unknown as { attributes?: Record }).attributes; + const dbSystem = attributes?.['db.system']; + if (dbSystem !== 'postgresql' && dbSystem !== 'postgres') { + return; + } + + setTeableDbSpanAttributesFromSpan( + span as unknown as Parameters[0] + ); + }, + onEnd: () => undefined, + shutdown: () => Promise.resolve(), + forceFlush: () => Promise.resolve(), +}; + // Span processors - NoopSpanProcessor ensures trace context is always generated // even when no exporter is configured (needed for trace ID in logs) const spanProcessors = [ ...(hasSentry ? [new SentrySpanProcessor()] : []), ...(traceExporter ? [createSmartBatchProcessor(traceExporter)] : [new NoopSpanProcessor()]), httpClientActiveRequestsProcessor, + teableDbSpanAttributeProcessor, ]; // When Sentry is enabled, use SentryPropagator and SentryContextManager to ensure @@ -351,6 +370,9 @@ const otelSDK = new opentelemetry.NodeSDK({ new PgInstrumentation({ enhancedDatabaseReporting: true, // Records SQL; ensure sensitive data is scrubbed. requireParentSpan: false, // Create spans even without parent, ensures v2 Kysely queries are traced + requestHook: (span, queryInfo) => { + setTeableDbSpanAttributes(span, queryInfo.connection); + }, }), new PinoInstrumentation(), new RuntimeNodeInstrumentation(), diff --git a/apps/nestjs-backend/src/types/cls.ts b/apps/nestjs-backend/src/types/cls.ts index 06a2edd6f8..68a71fea53 100644 --- a/apps/nestjs-backend/src/types/cls.ts +++ b/apps/nestjs-backend/src/types/cls.ts @@ -7,7 +7,7 @@ import type { IPerformanceCacheStore } from '../performance-cache'; import type { IRawOpMap } from '../share-db/interface'; import type { IDataLoaderCache } from './data-loader'; -export type V2Reason = +export type IV2Reason = | 'env_force_v2_all' | 'config_force_v2_all' | 'new_base' @@ -62,6 +62,15 @@ export interface IClsStore extends ClsStore { permissions: Action[]; // this is used to check if the user is in the space when the user operate in a space spaceId?: string; + dataDb?: { + mode: 'byodb'; + spaceId: string; + connectionId: string; + urlFingerprint?: string | null; + displayHost?: string | null; + displayDatabase?: string | null; + internalSchema?: string | null; + }; // for share db adapter cookie?: string; oldField?: IFieldVo; @@ -82,7 +91,7 @@ export interface IClsStore extends ClsStore { clearCacheKeys?: (keyof IPerformanceCacheStore)[]; canaryHeader?: string; // x-canary header value for canary release override useV2?: boolean; // Flag to indicate if V2 implementation should be used (set by V2FeatureGuard) - v2Reason?: V2Reason; // Reason why V2 was enabled or disabled + v2Reason?: IV2Reason; // Reason why V2 was enabled or disabled v2Feature?: V2Feature; // The feature name that triggered V2 check windowId?: string; // Window ID from x-window-id header for undo/redo tracking skipFieldComputation?: boolean; // Skip computed field evaluation during bulk structure creation (import/duplicate) diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index c7eda51e38..6170a262d4 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -3722,6 +3722,67 @@ export type I18nTranslations = { }; "collaborators": string; "more": string; + "dataDb": { + "create": { + "title": string; + "description": string; + "defaultOption": string; + "defaultHint": string; + "byodbOption": string; + "byodbHint": string; + "urlLabel": string; + "sslHint": string; + "testConnection": string; + "testing": string; + "retestRequired": string; + "databaseLabel": string; + "databasePlaceholder": string; + "databaseHint": string; + "preflightPassed": string; + "preflightFailed": string; + "missingCapabilities": string; + "testFailed": string; + "errors": { + "INVALID_DATABASE_URL": { + "message": string; + }; + "PRIVATE_NETWORK_BLOCKED": { + "message": string; + "remediation": string; + }; + "CONNECTION_FAILED": { + "message": string; + "remediation": string; + }; + "IPV6_NETWORK_UNREACHABLE": { + "message": string; + "remediation": string; + }; + "PRIVILEGE_CHECK_FAILED": { + "message": string; + }; + "DDL_PRIVILEGE_CHECK_FAILED": { + "message": string; + "remediation": string; + }; + "NON_EMPTY_UNKNOWN_DATABASE": { + "message": string; + "remediation": string; + }; + "INCOMPATIBLE_TEABLE_DATABASE": { + "message": string; + "remediation": string; + }; + }; + }; + "fields": { + "host": string; + "database": string; + "internalSchema": string; + "version": string; + "classification": string; + }; + }; }; "table": { "toolbar": { @@ -4935,6 +4996,10 @@ export type I18nTranslations = { "contextTipNewChat": string; "contextTipMemory": string; }; + "contextCompaction": { + "auto": string; + "manual": string; + }; "taskProgress": { "title": string; }; @@ -5055,6 +5120,7 @@ export type I18nTranslations = { "medium": string; "high": string; "xhigh": string; + "max": string; }; "sandboxExpiry": { "expiresIn": string; diff --git a/apps/nestjs-backend/test/base-sql-executor.e2e-spec.ts b/apps/nestjs-backend/test/base-sql-executor.e2e-spec.ts index 83fb93f50c..fe22fb46c2 100644 --- a/apps/nestjs-backend/test/base-sql-executor.e2e-spec.ts +++ b/apps/nestjs-backend/test/base-sql-executor.e2e-spec.ts @@ -55,10 +55,10 @@ describe('BaseSqlExecutorService', () => { expect(result).toBeDefined(); }); - it('read only role can not execute sql to throw error', async () => { + it('read only role can not execute write sql to throw error', async () => { await expect( - baseSqlExecutorService['db']?.$queryRawUnsafe(`create table ${tableDbName} (id int)`) - ).rejects.toThrow('ERROR: permission denied for schema'); + baseSqlExecutorService.executeQuerySql(baseId, `create table ${tableDbName} (id int)`) + ).rejects.toThrow('An error occurred while checking table access'); }); it('read only role can read base', async () => { diff --git a/apps/nestjs-backend/test/byodb-space-storage-placement.e2e-spec.ts b/apps/nestjs-backend/test/byodb-space-storage-placement.e2e-spec.ts new file mode 100644 index 0000000000..18f3c87d6c --- /dev/null +++ b/apps/nestjs-backend/test/byodb-space-storage-placement.e2e-spec.ts @@ -0,0 +1,1403 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import fs from 'fs'; +import path from 'path'; +import type { INestApplication } from '@nestjs/common'; +import type { ILinkFieldOptions } from '@teable/core'; +import { FieldKeyType, FieldType, Relationship, SortFunc, StatisticsFunc } from '@teable/core'; +import { + analyzeFile as apiAnalyzeFile, + ensureUndoRedoWindowIdHeader, + exportBase, + getAggregation, + getFields, + getGroupPoints, + getImportStatus as apiGetImportStatus, + getRecordHistory, + getRowCount, + getSignature as apiGetSignature, + getTableList, + getTableActivatedIndex, + GroupPointType, + importBase, + importTableFromFile as apiImportTableFromFile, + type INotifyVo, + notify as apiNotify, + redo, + ResourceType, + SettingKey, + SUPPORTEDTYPE, + TableIndex, + toggleTableIndex, + undo, + updateSetting, + updateDbTableName, + updateRecordOrders, + UploadType, + uploadFile as apiUploadFile, + type ITableFullVo, +} from '@teable/openapi'; +import Knex from 'knex'; +import type { Knex as KnexType } from 'knex'; +import type { ClsStore } from 'nestjs-cls'; +import { ClsService } from 'nestjs-cls'; +import type { IBaseConfig } from '../src/configs/base.config'; +import { baseConfig } from '../src/configs/base.config'; +import { EventEmitterService } from '../src/event-emitter/event-emitter.service'; +import { Events } from '../src/event-emitter/events'; +import StorageAdapter from '../src/features/attachments/plugins/adapter'; +import { CsvImporter } from '../src/features/import/open-api/import.class'; +import { createAwaitWithEventWithResult } from './utils/event-promise'; +import { + createBase, + createField, + createRecords, + createSpace, + createTable, + deleteField, + deleteRecord, + deleteTable, + getRecords, + getTable, + initApp, + permanentDeleteBase, + permanentDeleteSpace, + permanentDeleteTable, + updateRecord, +} from './utils/init-app'; + +const databaseIdentity = (url?: string) => { + if (!url) { + return undefined; + } + + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}${parsed.pathname}`; +}; + +const metaDatabaseUrl = + process.env.PRISMA_META_DATABASE_URL ?? + process.env.PRISMA_DATABASE_URL ?? + process.env.DATABASE_URL; +const byodbDataDatabaseUrl = process.env.BYODB_E2E_DATA_DATABASE_URL; +const isIndependentByodbDataDb = + databaseIdentity(metaDatabaseUrl) != null && + databaseIdentity(byodbDataDatabaseUrl) != null && + databaseIdentity(metaDatabaseUrl) !== databaseIdentity(byodbDataDatabaseUrl); +const describeByodbStorage = isIndependentByodbDataDb ? describe : describe.skip; + +const dataPlaneSystemTables = [ + '__teable_data_schema_migrations', + 'computed_update_outbox', + 'computed_update_outbox_seed', + 'computed_update_dead_letter', + 'computed_update_pause_scope', + 'record_history', + 'table_trash', + 'record_trash', + '__undo_log', +]; + +const metaPlaneTables = [ + 'space', + 'base', + 'base_node', + 'table_meta', + 'field', + 'view', + 'reference', + 'ops', + 'trash', + 'data_db_connection', + 'space_data_db_binding', +]; + +const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`; + +const parseDbTableName = (dbTableName: string) => { + const [schemaName, tableName] = dbTableName.split('.'); + + if (!schemaName || !tableName) { + throw new Error(`Invalid dbTableName: ${dbTableName}`); + } + + return { schemaName, tableName }; +}; + +const rawRows = async ( + client: KnexType, + query: string, + bindings: unknown[] = [] +): Promise => { + const result = await client.raw(query, bindings); + return result.rows as T[]; +}; + +const schemaExists = async (client: KnexType, schemaName: string) => { + const rows = await rawRows<{ exists: boolean }>( + client, + ` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.schemata + WHERE schema_name = ? + ) AS exists + `, + [schemaName] + ); + + return Boolean(rows[0]?.exists); +}; + +const relationExists = async (client: KnexType, schemaName: string, tableName: string) => { + const rows = await rawRows<{ exists: boolean }>( + client, + ` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = ? AND table_name = ? + ) AS exists + `, + [schemaName, tableName] + ); + + return Boolean(rows[0]?.exists); +}; + +const tableExists = async (client: KnexType, dbTableName: string) => { + const { schemaName, tableName } = parseDbTableName(dbTableName); + return relationExists(client, schemaName, tableName); +}; + +const columnExists = async (client: KnexType, dbTableName: string, columnName: string) => { + const { schemaName, tableName } = parseDbTableName(dbTableName); + const rows = await rawRows<{ exists: boolean }>( + client, + ` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = ? AND table_name = ? AND column_name = ? + ) AS exists + `, + [schemaName, tableName, columnName] + ); + + return Boolean(rows[0]?.exists); +}; + +const countRows = async ( + client: KnexType, + schemaName: string, + tableName: string, + whereSql?: string, + bindings: unknown[] = [] +) => { + if (!(await relationExists(client, schemaName, tableName))) { + return 0; + } + + const where = whereSql ? ` WHERE ${whereSql}` : ''; + const rows = await rawRows<{ count: number | string }>( + client, + `SELECT COUNT(*)::int AS count FROM ${quoteIdent(schemaName)}.${quoteIdent(tableName)}${where}`, + bindings + ); + + return Number(rows[0]?.count ?? 0); +}; + +const countDbTableRows = async (client: KnexType, dbTableName: string) => { + const { schemaName, tableName } = parseDbTableName(dbTableName); + return countRows(client, schemaName, tableName); +}; + +const dataDbMigrationVersions = async (client: KnexType, schemaName: string) => + rawRows<{ id: string }>( + client, + `SELECT ${quoteIdent('id')} FROM ${quoteIdent(schemaName)}.${quoteIdent( + '__teable_data_schema_migrations' + )} ORDER BY ${quoteIdent('id')}` + ); + +const dataDbConnectionVersionForSpace = async (client: KnexType, targetSpaceId: string) => + rawRows<{ schema_version: string | null }>( + client, + ` + SELECT c.${quoteIdent('schema_version')} + FROM ${quoteIdent('space_data_db_binding')} b + JOIN ${quoteIdent('data_db_connection')} c ON c.${quoteIdent('id')} = b.${quoteIdent( + 'data_db_connection_id' + )} + WHERE b.${quoteIdent('space_id')} = ? + `, + [targetSpaceId] + ); + +const constraintExists = async (client: KnexType, schemaName: string, constraintName: string) => { + const rows = await rawRows<{ exists: boolean }>( + client, + ` + SELECT EXISTS ( + SELECT 1 + FROM pg_constraint c + JOIN pg_namespace n ON n.oid = c.connamespace + WHERE n.nspname = ? AND c.conname = ? + ) AS exists + `, + [schemaName, constraintName] + ); + + return Boolean(rows[0]?.exists); +}; + +const countDbTableRowsWhere = async ( + client: KnexType, + dbTableName: string, + whereSql: string, + bindings: unknown[] +) => { + const { schemaName, tableName } = parseDbTableName(dbTableName); + return countRows(client, schemaName, tableName, whereSql, bindings); +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const streamToBuffer = async (stream: NodeJS.ReadableStream) => { + const chunks: Buffer[] = []; + + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + return Buffer.concat(chunks); +}; + +const waitForCount = async ( + getCount: () => Promise, + expectedCount: number, + maxRetries = 60 +) => { + for (let i = 0; i < maxRetries; i++) { + const count = await getCount(); + if (count === expectedCount) { + return count; + } + await sleep(100); + } + + return getCount(); +}; + +const waitForAtLeast = async ( + getCount: () => Promise, + expectedMinimum: number, + maxRetries = 60 +) => { + for (let i = 0; i < maxRetries; i++) { + const count = await getCount(); + if (count >= expectedMinimum) { + return count; + } + await sleep(100); + } + + return getCount(); +}; + +const waitForImportCompleted = async (tableId: string, expectedSuccessCount: number) => { + const maxRetries = 60; + + for (let i = 0; i < maxRetries; i++) { + const { data } = await apiGetImportStatus(tableId); + + if (data.status === 'completed' || data.status === 'failed') { + expect(data.status).toBe('completed'); + expect(data.successCount).toBe(expectedSuccessCount); + expect(data.failedCount ?? 0).toBe(0); + return; + } + + expect(data.status).not.toBe('not_found'); + await sleep(500); + } + + const { data } = await apiGetImportStatus(tableId); + throw new Error(`BYODB import timed out with latest status: ${data.status}`); +}; + +const createPgClient = (url: string) => + Knex({ + client: 'pg', + connection: url, + }); + +const importCsvData = `You_Xiang,Ming_Zi,order_count +ada@example.com,Ada,3 +bob@example.com,Bob,5 +`; + +const uploadImportCsv = async () => { + const tmpPath = path.resolve( + path.join(StorageAdapter.TEMPORARY_DIR, `byodb-import-${Date.now().toString(36)}.csv`) + ); + fs.writeFileSync(tmpPath, importCsvData); + + try { + const stats = fs.statSync(tmpPath); + const { token, requestHeaders } = ( + await apiGetSignature( + { + type: UploadType.Import, + contentLength: stats.size, + contentType: 'text/csv', + }, + undefined + ) + ).data; + + await apiUploadFile(token, fs.createReadStream(tmpPath), requestHeaders); + const { + data: { presignedUrl }, + } = await apiNotify(token, undefined, 'byodb-import.csv'); + + return presignedUrl; + } finally { + fs.unlinkSync(tmpPath); + } +}; + +const safeDropSchema = async (client: KnexType | undefined, schemaName: string | undefined) => { + if (!client || !schemaName) { + return; + } + + await client + .raw(`DROP SCHEMA IF EXISTS ${quoteIdent(schemaName)} CASCADE`) + .catch(() => undefined); +}; + +describeByodbStorage('BYODB space storage placement (e2e)', () => { + let app: INestApplication; + let metaDb: KnexType; + let dataDb: KnexType; + let baseConfigService: IBaseConfig; + let recordHistoryDisabled: boolean | undefined; + let spaceId: string | undefined; + let baseId: string | undefined; + const userId = globalThis.testConfig.userId; + + const internalSchema = `byodb_e2e_${Date.now().toString(36)}`; + + beforeAll(async () => { + metaDb = createPgClient(metaDatabaseUrl!); + dataDb = createPgClient(byodbDataDatabaseUrl!); + + const appCtx = await initApp(); + app = appCtx.app; + baseConfigService = app.get(baseConfig.KEY) as IBaseConfig; + recordHistoryDisabled = baseConfigService.recordHistoryDisabled; + baseConfigService.recordHistoryDisabled = false; + ensureUndoRedoWindowIdHeader(`win_byodb_storage_${Date.now()}`); + }, 60_000); + + afterAll(async () => { + if (baseId) { + await permanentDeleteBase(baseId).catch(() => undefined); + } + if (spaceId) { + await permanentDeleteSpace(spaceId).catch(() => undefined); + } + + await safeDropSchema(dataDb, baseId); + await safeDropSchema(metaDb, baseId); + await safeDropSchema(dataDb, internalSchema); + await safeDropSchema(metaDb, internalSchema); + + if (baseConfigService) { + baseConfigService.recordHistoryDisabled = recordHistoryDisabled ?? false; + } + + await dataDb?.destroy().catch(() => undefined); + await metaDb?.destroy().catch(() => undefined); + await app?.close(); + }, 60_000); + + const uploadExportedBase = async (targetBaseId: string) => { + const awaitExportWithPreview = createAwaitWithEventWithResult<{ + status?: 'success' | 'failed'; + previewUrl: string; + attachment?: { name: string; path: string }; + errorMessage?: string; + }>(app.get(EventEmitterService), Events.BASE_EXPORT_COMPLETE); + const { status, previewUrl, attachment, errorMessage } = await awaitExportWithPreview( + async () => { + await exportBase(targetBaseId); + } + ); + + if (status === 'failed') { + throw new Error(`Exported base is not available: ${errorMessage ?? 'unknown error'}`); + } + + return await app.get(ClsService).runWith>( + { + user: { + id: userId, + name: 'Test User', + email: 'test@example.com', + isAdmin: null, + }, + } as unknown as ClsStore, + async () => { + if (!attachment) { + throw new Error(`Missing exported base attachment payload for ${previewUrl}`); + } + + const storageAdapter = app.get(Symbol.for('ObjectStorage')); + const exportStream = await storageAdapter.downloadFile( + StorageAdapter.getBucket(UploadType.ExportBase), + attachment.path + ); + const exportBuffer = await streamToBuffer(exportStream); + const { token, requestHeaders } = ( + await apiGetSignature({ + type: UploadType.Import, + contentType: 'application/octet-stream', + contentLength: exportBuffer.length, + }) + ).data; + await apiUploadFile(token, exportBuffer, requestHeaders); + + return (await apiNotify(token, undefined, attachment.name)).data; + } + ); + }; + + it('keeps metadata in the meta DB and physical data artifacts in the bound data DB', async () => { + const space = await createSpace({ + name: 'BYODB placement e2e', + dataDb: { + mode: 'byodb', + url: byodbDataDatabaseUrl!, + targetMode: 'initialize-empty', + internalSchema, + }, + }); + spaceId = space.id; + + const base = await createBase({ spaceId: space.id, name: 'BYODB placement base' }); + baseId = base.id; + + const mainTable = await createTable(base.id, { + name: 'BYODB placement main', + fields: [ + { name: 'Name', type: FieldType.SingleLineText }, + { name: 'Amount', type: FieldType.Number }, + { + name: 'Status', + type: FieldType.SingleSelect, + options: { + choices: [ + { id: 'opt_todo', name: 'Todo', color: 'blue' }, + { id: 'opt_done', name: 'Done', color: 'green' }, + ], + }, + }, + ], + records: [{ fields: {} }, { fields: {} }, { fields: {} }], + }); + expect(mainTable.records).toHaveLength(3); + const foreignTable = await createTable(base.id, { + name: 'BYODB placement foreign', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [], + }); + + const linkField = await createField(mainTable.id, { + name: 'Foreign link', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: foreignTable.id, + }, + }); + const linkOptions = linkField.options as ILinkFieldOptions; + const primaryFieldId = mainTable.fields.find((field) => field.isPrimary)?.id; + const amountFieldId = mainTable.fields.find((field) => field.name === 'Amount')?.id; + const statusFieldId = mainTable.fields.find((field) => field.name === 'Status')?.id; + const foreignPrimaryFieldId = foreignTable.fields.find((field) => field.isPrimary)?.id; + expect(primaryFieldId).toBeTruthy(); + expect(amountFieldId).toBeTruthy(); + expect(statusFieldId).toBeTruthy(); + expect(foreignPrimaryFieldId).toBeTruthy(); + const defaultViewId = mainTable.defaultViewId!; + + await assertMetaPlaneRows(space.id, base.id, mainTable, foreignTable, linkField.id); + await assertSchemaOperationsReady(base.id, mainTable.id, foreignTable.id); + await assertMetaPlaneTablesAreNotCopiedToDataDb(base.id); + await assertDataPlaneBaseline(internalSchema); + await assertPhysicalTables(mainTable, foreignTable, linkOptions.fkHostTableName); + await expect(countDbTableRows(dataDb, mainTable.dbTableName)).resolves.toBe(3); + await expect(countDbTableRows(metaDb, mainTable.dbTableName)).resolves.toBe(0); + const initialRecordList = await getRecords(mainTable.id, { + fieldKeyType: FieldKeyType.Id, + viewId: defaultViewId, + }); + expect(initialRecordList.records).toHaveLength(3); + expect(initialRecordList.records.map((record) => record.id)).toEqual( + mainTable.records.map((record) => record.id) + ); + await Promise.all( + mainTable.records.map((record, index) => + updateRecord(mainTable.id, record.id, { + fieldKeyType: FieldKeyType.Id, + record: { + fields: { + [primaryFieldId!]: `Seed row ${index + 1}`, + [amountFieldId!]: (index + 1) * 10, + [statusFieldId!]: index === 2 ? 'Done' : 'Todo', + }, + }, + }) + ) + ); + const initialRowCount = await getRowCount(mainTable.id, { + viewId: defaultViewId, + }); + expect(initialRowCount.data.rowCount).toBe(3); + await expect(countDbTableRows(dataDb, mainTable.dbTableName)).resolves.toBe(3); + await expect(countDbTableRows(metaDb, mainTable.dbTableName)).resolves.toBe(0); + + const mainRecords = await createRecords(mainTable.id, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: { [primaryFieldId!]: 'Source row' } }], + }); + const foreignRecords = await createRecords(foreignTable.id, { + fieldKeyType: FieldKeyType.Id, + records: [{ fields: { [foreignPrimaryFieldId!]: 'Foreign row' } }], + }); + const recordId = mainRecords.records[0].id; + const foreignRecordId = foreignRecords.records[0].id; + + await expect( + countDbTableRowsWhere(dataDb, mainTable.dbTableName, `${quoteIdent('__id')} = ?`, [recordId]) + ).resolves.toBe(1); + await expect( + countDbTableRowsWhere(dataDb, foreignTable.dbTableName, `${quoteIdent('__id')} = ?`, [ + foreignRecordId, + ]) + ).resolves.toBe(1); + await expect( + countDbTableRowsWhere(metaDb, mainTable.dbTableName, `${quoteIdent('__id')} = ?`, [recordId]) + ).resolves.toBe(0); + await expect( + countDbTableRowsWhere(metaDb, foreignTable.dbTableName, `${quoteIdent('__id')} = ?`, [ + foreignRecordId, + ]) + ).resolves.toBe(0); + + const updatedRecord = await updateRecord(mainTable.id, recordId, { + fieldKeyType: FieldKeyType.Id, + record: { + fields: { + [primaryFieldId!]: 'Updated source row', + [amountFieldId!]: 7, + [statusFieldId!]: 'Done', + [linkField.id]: [{ id: foreignRecordId }], + }, + }, + }); + expect(updatedRecord.fields[primaryFieldId!]).toBe('Updated source row'); + expect(updatedRecord.fields[linkField.id]).toEqual([ + expect.objectContaining({ id: foreignRecordId }), + ]); + const rowCountAfterInsert = await getRowCount(mainTable.id, { + viewId: defaultViewId, + }); + expect(rowCountAfterInsert.data.rowCount).toBe(4); + const aggregation = ( + await getAggregation(mainTable.id, { + viewId: defaultViewId, + field: { + [StatisticsFunc.Sum]: [amountFieldId!], + [StatisticsFunc.Count]: [primaryFieldId!], + }, + groupBy: [{ fieldId: statusFieldId!, order: SortFunc.Asc }], + }) + ).data; + const amountAggregation = aggregation.aggregations?.find( + (item) => item.fieldId === amountFieldId + ); + const primaryAggregation = aggregation.aggregations?.find( + (item) => item.fieldId === primaryFieldId + ); + expect(Number(amountAggregation?.total?.value)).toBe(67); + expect(Number(primaryAggregation?.total?.value)).toBe(4); + expect(Object.keys(amountAggregation?.group ?? {})).toHaveLength(2); + + const groupPoints = ( + await getGroupPoints(mainTable.id, { + viewId: defaultViewId, + groupBy: [{ fieldId: statusFieldId!, order: SortFunc.Asc }], + }) + ).data; + expect(groupPoints?.filter((point) => point.type === GroupPointType.Header)).toHaveLength(2); + expect( + groupPoints?.reduce( + (sum, point) => (point.type === GroupPointType.Row ? sum + point.count : sum), + 0 + ) + ).toBe(4); + + await updateRecordOrders(mainTable.id, defaultViewId, { + anchorId: mainTable.records[0].id, + position: 'before', + recordIds: [recordId], + }); + const reorderedRecords = await getRecords(mainTable.id, { + fieldKeyType: FieldKeyType.Id, + viewId: defaultViewId, + }); + expect(reorderedRecords.records[0].id).toBe(recordId); + + await toggleTableIndex(base.id, mainTable.id, { type: TableIndex.search }); + expect((await getTableActivatedIndex(base.id, mainTable.id)).data).toContain(TableIndex.search); + + const extraField = await createField(mainTable.id, { + name: 'BYODB extra notes', + type: FieldType.LongText, + }); + const extraDbFieldName = extraField.dbFieldName!; + await expect(columnExists(dataDb, mainTable.dbTableName, extraDbFieldName)).resolves.toBe(true); + await expect(columnExists(metaDb, mainTable.dbTableName, extraDbFieldName)).resolves.toBe( + false + ); + await deleteField(mainTable.id, extraField.id); + await expect(columnExists(dataDb, mainTable.dbTableName, extraDbFieldName)).resolves.toBe( + false + ); + await expect(columnExists(metaDb, mainTable.dbTableName, extraDbFieldName)).resolves.toBe( + false + ); + + await expect(countDbTableRows(dataDb, mainTable.dbTableName)).resolves.toBe(4); + await expect(countDbTableRows(metaDb, mainTable.dbTableName)).resolves.toBe(0); + + await expect( + waitForAtLeast(() => countDbTableRows(dataDb, linkOptions.fkHostTableName), 1) + ).resolves.toBeGreaterThan(0); + await expect(countDbTableRows(metaDb, linkOptions.fkHostTableName)).resolves.toBe(0); + + await expect( + waitForAtLeast( + () => + countRows( + dataDb, + internalSchema, + 'record_history', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [mainTable.id, recordId] + ), + 1 + ) + ).resolves.toBeGreaterThan(0); + const { data: recordHistory } = await getRecordHistory(mainTable.id, recordId, {}); + expect(recordHistory.historyList.length).toBeGreaterThan(0); + await expect( + countRows( + metaDb, + 'public', + 'record_history', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [mainTable.id, recordId] + ) + ).resolves.toBe(0); + + await deleteRecord(mainTable.id, recordId); + await assertRecordTrashPlacement(mainTable.id, recordId, 1); + + const undoResult = await undo(mainTable.id); + expect(undoResult.data.status).toBe('fulfilled'); + await assertRecordTrashPlacement(mainTable.id, recordId, 0); + await expect( + countDbTableRowsWhere(dataDb, mainTable.dbTableName, `${quoteIdent('__id')} = ?`, [recordId]) + ).resolves.toBe(1); + + const redoResult = await redo(mainTable.id); + expect(redoResult.data.status).toBe('fulfilled'); + await assertRecordTrashPlacement(mainTable.id, recordId, 1); + await expect( + countDbTableRowsWhere(dataDb, mainTable.dbTableName, `${quoteIdent('__id')} = ?`, [recordId]) + ).resolves.toBe(0); + + await assertTableLifecycleRouting(base.id); + await assertImportedTableRouting(base.id); + await assertDotTeaBaseImportRouting(space.id); + await assertComputedSideEffectsStayOutOfMetaDb(base.id, mainTable.id, recordId); + }, 240_000); + + const assertMetaPlaneRows = async ( + targetSpaceId: string, + targetBaseId: string, + mainTable: ITableFullVo, + foreignTable: ITableFullVo, + linkFieldId: string + ) => { + await expect( + countRows(metaDb, 'public', 'space', `${quoteIdent('id')} = ?`, [targetSpaceId]) + ).resolves.toBe(1); + await expect( + countRows(dataDb, 'public', 'space', `${quoteIdent('id')} = ?`, [targetSpaceId]) + ).resolves.toBe(0); + + await expect( + countRows(metaDb, 'public', 'space_data_db_binding', `${quoteIdent('space_id')} = ?`, [ + targetSpaceId, + ]) + ).resolves.toBe(1); + await expect( + countRows(dataDb, 'public', 'space_data_db_binding', `${quoteIdent('space_id')} = ?`, [ + targetSpaceId, + ]) + ).resolves.toBe(0); + + await expect( + countRows(metaDb, 'public', 'base', `${quoteIdent('id')} = ?`, [targetBaseId]) + ).resolves.toBe(1); + await expect( + countRows(dataDb, 'public', 'base', `${quoteIdent('id')} = ?`, [targetBaseId]) + ).resolves.toBe(0); + await expect( + countRows(metaDb, 'public', 'table_meta', `${quoteIdent('base_id')} = ?`, [targetBaseId]) + ).resolves.toBeGreaterThanOrEqual(2); + await expect( + countRows(dataDb, 'public', 'table_meta', `${quoteIdent('base_id')} = ?`, [targetBaseId]) + ).resolves.toBe(0); + + await expect( + countRows(metaDb, 'public', 'field', `${quoteIdent('table_id')} IN (?, ?)`, [ + mainTable.id, + foreignTable.id, + ]) + ).resolves.toBeGreaterThanOrEqual(3); + await expect( + countRows(dataDb, 'public', 'field', `${quoteIdent('table_id')} IN (?, ?)`, [ + mainTable.id, + foreignTable.id, + ]) + ).resolves.toBe(0); + + const selectFields = await rawRows<{ options: string | Record | null }>( + metaDb, + ` + SELECT options + FROM public.field + WHERE table_id = ? AND type = ? + `, + [mainTable.id, FieldType.SingleSelect] + ); + expect(selectFields).toHaveLength(1); + const selectOptions = + typeof selectFields[0]?.options === 'string' + ? JSON.parse(selectFields[0].options) + : selectFields[0]?.options; + expect(selectOptions).toMatchObject({ + choices: [ + expect.objectContaining({ name: 'Todo' }), + expect.objectContaining({ name: 'Done' }), + ], + }); + + await expect( + countRows(metaDb, 'public', 'view', `${quoteIdent('table_id')} IN (?, ?)`, [ + mainTable.id, + foreignTable.id, + ]) + ).resolves.toBeGreaterThanOrEqual(2); + await expect( + countRows(dataDb, 'public', 'view', `${quoteIdent('table_id')} IN (?, ?)`, [ + mainTable.id, + foreignTable.id, + ]) + ).resolves.toBe(0); + + await expect( + countRows( + metaDb, + 'public', + 'reference', + `${quoteIdent('from_field_id')} = ? OR ${quoteIdent('to_field_id')} = ?`, + [linkFieldId, linkFieldId] + ) + ).resolves.toBeGreaterThan(0); + await expect( + countRows( + dataDb, + 'public', + 'reference', + `${quoteIdent('from_field_id')} = ? OR ${quoteIdent('to_field_id')} = ?`, + [linkFieldId, linkFieldId] + ) + ).resolves.toBe(0); + }; + + const assertSchemaOperationsReady = async ( + targetBaseId: string, + mainTableId: string, + foreignTableId: string + ) => { + const tableIdsPredicate = `${quoteIdent('base_id')} = ? AND ${quoteIdent( + 'table_id' + )} IN (?, ?)`; + const tableIdsParams = [targetBaseId, mainTableId, foreignTableId]; + + await expect( + countRows( + metaDb, + 'public', + 'schema_operation', + `${tableIdsPredicate} AND ${quoteIdent('type')} = ? AND ${quoteIdent('status')} = ?`, + [...tableIdsParams, 'table.create', 'ready'] + ) + ).resolves.toBe(2); + await expect( + countRows( + metaDb, + 'public', + 'schema_operation', + `${tableIdsPredicate} AND ${quoteIdent('type')} = ? AND ${quoteIdent('status')} = ?`, + [...tableIdsParams, 'table.update', 'ready'] + ) + ).resolves.toBe(2); + await expect( + countRows( + metaDb, + 'public', + 'schema_operation', + `${tableIdsPredicate} AND ${quoteIdent('status')} <> ?`, + [...tableIdsParams, 'ready'] + ) + ).resolves.toBe(0); + await expect( + countRows(dataDb, 'public', 'schema_operation', tableIdsPredicate, tableIdsParams) + ).resolves.toBe(0); + }; + + const assertMetaPlaneTablesAreNotCopiedToDataDb = async (targetBaseId: string) => { + for (const tableName of metaPlaneTables) { + await expect(relationExists(metaDb, 'public', tableName)).resolves.toBe(true); + await expect(relationExists(dataDb, 'public', tableName)).resolves.toBe(false); + await expect(relationExists(dataDb, internalSchema, tableName)).resolves.toBe(false); + await expect(relationExists(dataDb, targetBaseId, tableName)).resolves.toBe(false); + } + }; + + const assertDataPlaneBaseline = async (targetInternalSchema: string) => { + await expect(schemaExists(dataDb, targetInternalSchema)).resolves.toBe(true); + await expect(schemaExists(metaDb, targetInternalSchema)).resolves.toBe(false); + + for (const tableName of dataPlaneSystemTables) { + await expect(relationExists(dataDb, targetInternalSchema, tableName)).resolves.toBe(true); + await expect(relationExists(metaDb, targetInternalSchema, tableName)).resolves.toBe(false); + } + + await expect(dataDbMigrationVersions(dataDb, targetInternalSchema)).resolves.toEqual( + expect.arrayContaining([{ id: '20260421000000_init_data_db_baseline' }]) + ); + await expect( + constraintExists(dataDb, targetInternalSchema, 'computed_update_outbox_seed_task_id_fkey') + ).resolves.toBe(true); + }; + + it('initializes data DB migrations independently for multiple internal schemas', async () => { + const firstInternalSchema = `byodb_migration_a_${Date.now().toString(36)}`; + const secondInternalSchema = `byodb_migration_b_${Date.now().toString(36)}`; + let firstSpaceId: string | undefined; + let secondSpaceId: string | undefined; + + try { + const firstSpace = await createSpace({ + name: 'BYODB migration smoke A', + dataDb: { + mode: 'byodb', + url: byodbDataDatabaseUrl!, + targetMode: 'initialize-empty', + internalSchema: firstInternalSchema, + }, + }); + firstSpaceId = firstSpace.id; + const secondSpace = await createSpace({ + name: 'BYODB migration smoke B', + dataDb: { + mode: 'byodb', + url: byodbDataDatabaseUrl!, + targetMode: 'initialize-empty', + internalSchema: secondInternalSchema, + }, + }); + secondSpaceId = secondSpace.id; + + await assertDataPlaneBaseline(firstInternalSchema); + await assertDataPlaneBaseline(secondInternalSchema); + + const firstVersions = await dataDbMigrationVersions(dataDb, firstInternalSchema); + const secondVersions = await dataDbMigrationVersions(dataDb, secondInternalSchema); + await expect(dataDbConnectionVersionForSpace(metaDb, firstSpace.id)).resolves.toEqual([ + { schema_version: firstVersions.at(-1)?.id }, + ]); + await expect(dataDbConnectionVersionForSpace(metaDb, secondSpace.id)).resolves.toEqual([ + { schema_version: secondVersions.at(-1)?.id }, + ]); + } finally { + if (secondSpaceId) { + await permanentDeleteSpace(secondSpaceId).catch(() => undefined); + } + if (firstSpaceId) { + await permanentDeleteSpace(firstSpaceId).catch(() => undefined); + } + await safeDropSchema(dataDb, secondInternalSchema); + await safeDropSchema(dataDb, firstInternalSchema); + await safeDropSchema(metaDb, secondInternalSchema); + await safeDropSchema(metaDb, firstInternalSchema); + } + }); + + const assertPhysicalTables = async ( + mainTable: ITableFullVo, + foreignTable: ITableFullVo, + junctionTableName: string + ) => { + await expect(schemaExists(dataDb, baseId!)).resolves.toBe(true); + await expect(schemaExists(metaDb, baseId!)).resolves.toBe(false); + + for (const dbTableName of [ + mainTable.dbTableName, + foreignTable.dbTableName, + junctionTableName, + ]) { + await expect(tableExists(dataDb, dbTableName)).resolves.toBe(true); + await expect(tableExists(metaDb, dbTableName)).resolves.toBe(false); + await expect(countDbTableRows(dataDb, dbTableName)).resolves.toBeGreaterThanOrEqual(0); + await expect(countDbTableRows(metaDb, dbTableName)).resolves.toBe(0); + } + }; + + const assertRecordTrashPlacement = async ( + tableId: string, + recordId: string, + expectedCount: number + ) => { + await expect( + waitForCount( + () => + countRows( + dataDb, + internalSchema, + 'table_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('resource_type')} = ?`, + [tableId, ResourceType.Record] + ), + expectedCount + ) + ).resolves.toBe(expectedCount); + await expect( + waitForCount( + () => + countRows( + dataDb, + internalSchema, + 'record_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [tableId, recordId] + ), + expectedCount + ) + ).resolves.toBe(expectedCount); + + await expect( + countRows( + metaDb, + 'public', + 'table_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('resource_type')} = ?`, + [tableId, ResourceType.Record] + ) + ).resolves.toBe(0); + await expect( + countRows( + metaDb, + 'public', + 'record_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('record_id')} = ?`, + [tableId, recordId] + ) + ).resolves.toBe(0); + }; + + const assertTableLifecycleRouting = async (targetBaseId: string) => { + const lifecycleTable = await createTable(targetBaseId, { + name: 'BYODB lifecycle table', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [{ fields: { Name: 'Lifecycle row' } }], + }); + const oldDbTableName = lifecycleTable.dbTableName; + const renamedTableName = `byodb_lifecycle_${Date.now().toString(36)}`; + const renamedDbTableName = `${targetBaseId}.${renamedTableName}`; + + await expect(tableExists(dataDb, oldDbTableName)).resolves.toBe(true); + await expect(tableExists(metaDb, oldDbTableName)).resolves.toBe(false); + + await updateDbTableName(targetBaseId, lifecycleTable.id, { + dbTableName: renamedTableName, + }); + await expect(tableExists(dataDb, oldDbTableName)).resolves.toBe(false); + await expect(tableExists(dataDb, renamedDbTableName)).resolves.toBe(true); + await expect(tableExists(metaDb, renamedDbTableName)).resolves.toBe(false); + await expect(countDbTableRows(dataDb, renamedDbTableName)).resolves.toBe(1); + await expect(countDbTableRows(metaDb, renamedDbTableName)).resolves.toBe(0); + + const renamedRecords = await getRecords(lifecycleTable.id, { + fieldKeyType: FieldKeyType.Id, + viewId: lifecycleTable.defaultViewId, + }); + expect(renamedRecords.records).toHaveLength(1); + + await deleteTable(targetBaseId, lifecycleTable.id, 200); + await expect( + waitForAtLeast( + () => + countRows( + dataDb, + internalSchema, + 'table_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('resource_type')} = ?`, + [lifecycleTable.id, ResourceType.Table] + ), + 1 + ) + ).resolves.toBeGreaterThan(0); + await expect( + countRows( + metaDb, + 'public', + 'table_trash', + `${quoteIdent('table_id')} = ? AND ${quoteIdent('resource_type')} = ?`, + [lifecycleTable.id, ResourceType.Table] + ) + ).resolves.toBe(0); + + await permanentDeleteTable(targetBaseId, lifecycleTable.id, 200); + await expect(tableExists(dataDb, renamedDbTableName)).resolves.toBe(false); + await expect(tableExists(metaDb, renamedDbTableName)).resolves.toBe(false); + await expect( + countRows(metaDb, 'public', 'table_meta', `${quoteIdent('id')} = ?`, [lifecycleTable.id]) + ).resolves.toBe(0); + }; + + const assertImportedTableRouting = async (targetBaseId: string) => { + const attachmentUrl = await uploadImportCsv(); + const { + data: { worksheets }, + } = await apiAnalyzeFile({ + attachmentUrl, + fileType: SUPPORTEDTYPE.CSV, + }); + const columns = worksheets[CsvImporter.DEFAULT_SHEETKEY].columns.map((column, index) => ({ + ...column, + sourceColumnIndex: index, + })); + + const importResult = await apiImportTableFromFile(targetBaseId, { + attachmentUrl, + fileType: SUPPORTEDTYPE.CSV, + worksheets: { + [CsvImporter.DEFAULT_SHEETKEY]: { + name: 'BYODB imported table', + columns, + useFirstRowAsHeader: true, + importData: true, + }, + }, + tz: 'Asia/Shanghai', + }); + const importedTable = importResult.data[0]; + expect(importedTable.fields.map((field) => ({ name: field.name, type: field.type }))).toEqual([ + { name: 'You_Xiang', type: FieldType.SingleLineText }, + { name: 'Ming_Zi', type: FieldType.SingleLineText }, + { name: 'order_count', type: FieldType.Number }, + ]); + + await waitForImportCompleted(importedTable.id, 2); + + const importedRecords = await getRecords(importedTable.id, { + fieldKeyType: FieldKeyType.Name, + viewId: importedTable.defaultViewId, + }); + expect(importedRecords.records).toHaveLength(2); + expect(importedRecords.records.map((record) => record.fields)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ['You_Xiang']: 'ada@example.com', + ['Ming_Zi']: 'Ada', + order_count: 3, + }), + expect.objectContaining({ + ['You_Xiang']: 'bob@example.com', + ['Ming_Zi']: 'Bob', + order_count: 5, + }), + ]) + ); + + await expect( + countRows(metaDb, 'public', 'table_meta', `${quoteIdent('id')} = ?`, [importedTable.id]) + ).resolves.toBe(1); + await expect( + countRows(dataDb, 'public', 'table_meta', `${quoteIdent('id')} = ?`, [importedTable.id]) + ).resolves.toBe(0); + await expect( + countRows(metaDb, 'public', 'field', `${quoteIdent('table_id')} = ?`, [importedTable.id]) + ).resolves.toBe(3); + await expect( + countRows(dataDb, 'public', 'field', `${quoteIdent('table_id')} = ?`, [importedTable.id]) + ).resolves.toBe(0); + await expect( + countRows(metaDb, 'public', 'view', `${quoteIdent('table_id')} = ?`, [importedTable.id]) + ).resolves.toBeGreaterThanOrEqual(1); + await expect( + countRows(dataDb, 'public', 'view', `${quoteIdent('table_id')} = ?`, [importedTable.id]) + ).resolves.toBe(0); + + await expect(tableExists(dataDb, importedTable.dbTableName)).resolves.toBe(true); + await expect(tableExists(metaDb, importedTable.dbTableName)).resolves.toBe(false); + await expect(countDbTableRows(dataDb, importedTable.dbTableName)).resolves.toBe(2); + await expect(countDbTableRows(metaDb, importedTable.dbTableName)).resolves.toBe(0); + + await expect( + countRows( + metaDb, + 'public', + 'schema_operation', + `${quoteIdent('base_id')} = ? AND ${quoteIdent('table_id')} = ? AND ${quoteIdent( + 'type' + )} = ? AND ${quoteIdent('status')} = ?`, + [targetBaseId, importedTable.id, 'table.create', 'ready'] + ) + ).resolves.toBe(1); + await expect( + countRows( + metaDb, + 'public', + 'schema_operation', + `${quoteIdent('base_id')} = ? AND ${quoteIdent('table_id')} = ? AND ${quoteIdent( + 'status' + )} <> ?`, + [targetBaseId, importedTable.id, 'ready'] + ) + ).resolves.toBe(0); + await expect( + countRows(dataDb, 'public', 'schema_operation', `${quoteIdent('table_id')} = ?`, [ + importedTable.id, + ]) + ).resolves.toBe(0); + + await expect( + waitForAtLeast( + () => + countRows(dataDb, internalSchema, 'record_history', `${quoteIdent('table_id')} = ?`, [ + importedTable.id, + ]), + 1 + ) + ).resolves.toBeGreaterThan(0); + await expect( + countRows(metaDb, 'public', 'record_history', `${quoteIdent('table_id')} = ?`, [ + importedTable.id, + ]) + ).resolves.toBe(0); + }; + + const assertDotTeaBaseImportRouting = async (targetSpaceId: string) => { + let sourceBaseId: string | undefined; + let importedBaseId: string | undefined; + + await updateSetting({ + [SettingKey.CANARY_CONFIG]: { + enabled: true, + spaceIds: [targetSpaceId], + }, + }); + + try { + const sourceBase = await createBase({ + spaceId: targetSpaceId, + name: 'BYODB dottea source', + }); + sourceBaseId = sourceBase.id; + + const foreignTable = await createTable(sourceBase.id, { + name: 'BYODB dottea foreign', + fields: [{ name: 'Title', type: FieldType.SingleLineText }], + records: [{ fields: { Title: 'Foreign row' } }], + }); + const foreignPrimaryFieldId = foreignTable.fields.find((field) => field.isPrimary)?.id; + expect(foreignPrimaryFieldId).toBeTruthy(); + + const hostTable = await createTable(sourceBase.id, { + name: 'BYODB dottea host', + fields: [{ name: 'Name', type: FieldType.SingleLineText }], + records: [{ fields: { Name: 'Host row' } }], + }); + const hostRecordId = hostTable.records[0]?.id; + const foreignRecordId = foreignTable.records[0]?.id; + expect(hostRecordId).toBeTruthy(); + expect(foreignRecordId).toBeTruthy(); + + const linkField = await createField(hostTable.id, { + name: 'Foreign link', + type: FieldType.Link, + options: { + relationship: Relationship.ManyMany, + foreignTableId: foreignTable.id, + }, + }); + await updateRecord(hostTable.id, hostRecordId!, { + fieldKeyType: FieldKeyType.Id, + record: { + fields: { + [linkField.id]: [{ id: foreignRecordId! }], + }, + }, + }); + + const notify = await uploadExportedBase(sourceBase.id); + const imported = ( + await importBase({ + notify: notify as unknown as INotifyVo, + spaceId: targetSpaceId, + }) + ).data; + importedBaseId = imported.base.id; + + const importedTables = (await getTableList(importedBaseId)).data; + expect(importedTables.map((table) => table.name).sort()).toEqual( + ['BYODB dottea foreign', 'BYODB dottea host'].sort() + ); + + const importedHostMeta = importedTables.find((table) => table.name === hostTable.name)!; + const importedForeignMeta = importedTables.find((table) => table.name === foreignTable.name)!; + const importedHost = await getTable(importedBaseId, importedHostMeta.id, { + includeContent: true, + }); + const importedForeign = await getTable(importedBaseId, importedForeignMeta.id, { + includeContent: true, + }); + + await expect( + countRows(metaDb, 'public', 'base', `${quoteIdent('id')} = ?`, [importedBaseId]) + ).resolves.toBe(1); + await expect( + countRows(dataDb, 'public', 'base', `${quoteIdent('id')} = ?`, [importedBaseId]) + ).resolves.toBe(0); + await expect( + countRows(metaDb, 'public', 'table_meta', `${quoteIdent('base_id')} = ?`, [importedBaseId]) + ).resolves.toBe(2); + await expect( + countRows(dataDb, 'public', 'table_meta', `${quoteIdent('base_id')} = ?`, [importedBaseId]) + ).resolves.toBe(0); + await expect( + countRows(metaDb, 'public', 'field', `${quoteIdent('table_id')} IN (?, ?)`, [ + importedHostMeta.id, + importedForeignMeta.id, + ]) + ).resolves.toBeGreaterThanOrEqual(3); + await expect( + countRows(dataDb, 'public', 'field', `${quoteIdent('table_id')} IN (?, ?)`, [ + importedHostMeta.id, + importedForeignMeta.id, + ]) + ).resolves.toBe(0); + + await expect(tableExists(dataDb, importedHost.dbTableName)).resolves.toBe(true); + await expect(tableExists(dataDb, importedForeign.dbTableName)).resolves.toBe(true); + await expect(tableExists(metaDb, importedHost.dbTableName)).resolves.toBe(false); + await expect(tableExists(metaDb, importedForeign.dbTableName)).resolves.toBe(false); + await expect(countDbTableRows(dataDb, importedHost.dbTableName)).resolves.toBe(1); + await expect(countDbTableRows(dataDb, importedForeign.dbTableName)).resolves.toBe(1); + await expect(countDbTableRows(metaDb, importedHost.dbTableName)).resolves.toBe(0); + await expect(countDbTableRows(metaDb, importedForeign.dbTableName)).resolves.toBe(0); + + const importedLinkField = (await getFields(importedHostMeta.id)).data.find( + (field) => field.type === FieldType.Link + ); + expect(importedLinkField).toBeDefined(); + await expect( + countRows( + metaDb, + 'public', + 'reference', + `${quoteIdent('to_field_id')} = ? OR ${quoteIdent('from_field_id')} = ?`, + [importedLinkField!.id, importedLinkField!.id] + ) + ).resolves.toBeGreaterThan(0); + await expect( + countRows( + dataDb, + 'public', + 'reference', + `${quoteIdent('to_field_id')} = ? OR ${quoteIdent('from_field_id')} = ?`, + [importedLinkField!.id, importedLinkField!.id] + ) + ).resolves.toBe(0); + + const importedLinkOptions = importedLinkField!.options as ILinkFieldOptions; + await expect(tableExists(dataDb, importedLinkOptions.fkHostTableName)).resolves.toBe(true); + await expect(tableExists(metaDb, importedLinkOptions.fkHostTableName)).resolves.toBe(false); + await expect( + waitForAtLeast(() => countDbTableRows(dataDb, importedLinkOptions.fkHostTableName), 1) + ).resolves.toBeGreaterThan(0); + await expect(countDbTableRows(metaDb, importedLinkOptions.fkHostTableName)).resolves.toBe(0); + } finally { + await updateSetting({ + [SettingKey.CANARY_CONFIG]: { + enabled: false, + spaceIds: [], + }, + }).catch(() => undefined); + if (importedBaseId) { + await permanentDeleteBase(importedBaseId).catch(() => undefined); + } + if (sourceBaseId) { + await permanentDeleteBase(sourceBaseId).catch(() => undefined); + } + } + }; + + const assertComputedSideEffectsStayOutOfMetaDb = async ( + targetBaseId: string, + tableId: string, + recordId: string + ) => { + await expect( + countRows(metaDb, 'public', 'computed_update_outbox', `${quoteIdent('base_id')} = ?`, [ + targetBaseId, + ]) + ).resolves.toBe(0); + await expect( + countRows(metaDb, 'public', 'computed_update_dead_letter', `${quoteIdent('base_id')} = ?`, [ + targetBaseId, + ]) + ).resolves.toBe(0); + await expect( + countRows(metaDb, 'public', 'computed_update_outbox_seed', `${quoteIdent('table_id')} = ?`, [ + tableId, + ]) + ).resolves.toBe(0); + await expect( + countRows(metaDb, 'public', 'computed_update_outbox_seed', `${quoteIdent('record_id')} = ?`, [ + recordId, + ]) + ).resolves.toBe(0); + }; +}); diff --git a/apps/nestjs-backend/test/dual-db-split.e2e-spec.ts b/apps/nestjs-backend/test/dual-db-split.e2e-spec.ts index fd8bbb4546..681d71c518 100644 --- a/apps/nestjs-backend/test/dual-db-split.e2e-spec.ts +++ b/apps/nestjs-backend/test/dual-db-split.e2e-spec.ts @@ -48,7 +48,7 @@ const metaDatabaseUrl = process.env.PRISMA_META_DATABASE_URL ?? process.env.PRISMA_DATABASE_URL ?? process.env.DATABASE_URL; -const dataDatabaseUrl = process.env.PRISMA_DATA_DATABASE_URL; +const dataDatabaseUrl = undefined; const isTrueSplitDb = databaseIdentity(metaDatabaseUrl) != null && databaseIdentity(dataDatabaseUrl) != null && @@ -323,7 +323,7 @@ describeSplitDb('Dual DB split smoke (e2e)', () => { ); expect(recordTrashItem).toBeDefined(); - await restoreTrash(recordTrashItem!.id); + await restoreTrash(recordTrashItem!.id, table.id); await expect( waitForCount(() => countTableTrash(dataPrisma, table.id, ResourceType.Record), 0) diff --git a/apps/nestjs-backend/test/large-table-operations.e2e-spec.ts b/apps/nestjs-backend/test/large-table-operations.e2e-spec.ts index 5ecd6e95a7..c1c939b2bf 100644 --- a/apps/nestjs-backend/test/large-table-operations.e2e-spec.ts +++ b/apps/nestjs-backend/test/large-table-operations.e2e-spec.ts @@ -312,23 +312,21 @@ async function ensureLargeTableContext(): Promise { throw new Error('Benchmark setup failed: no linked records available.'); } - await Promise.all( - seededRecords.records.map((record, index) => { - const value = [ - { id: linkTargets[index % linkTargets.length] }, - { id: linkTargets[(index + 1) % linkTargets.length] }, - ]; - - return updateRecord(mainTable.id, record.id, { - fieldKeyType: FieldKeyType.Id, - record: { - fields: { - [linkField.id]: value, - }, + for (const [index, record] of seededRecords.records.entries()) { + const value = [ + { id: linkTargets[index % linkTargets.length] }, + { id: linkTargets[(index + 1) % linkTargets.length] }, + ]; + + await updateRecord(mainTable.id, record.id, { + fieldKeyType: FieldKeyType.Id, + record: { + fields: { + [linkField.id]: value, }, - }); - }) - ); + }, + }); + } const sampleRecordId = seededRecords.records[0]?.id; @@ -373,7 +371,7 @@ describe('Large table operations timing (e2e)', () => { beforeAll(async () => { context = await ensureLargeTableContext(); - }); + }, 300_000); afterAll(async () => { if (context) { diff --git a/apps/nestjs-backend/test/table-trash.e2e-spec.ts b/apps/nestjs-backend/test/table-trash.e2e-spec.ts index 8c934752b9..3724915483 100644 --- a/apps/nestjs-backend/test/table-trash.e2e-spec.ts +++ b/apps/nestjs-backend/test/table-trash.e2e-spec.ts @@ -366,7 +366,7 @@ describe('Trash (e2e)', () => { await awaitWithViewEvent(() => deleteView(tableId, deletedViewId)); const result = await waitForTableTrashItems(tableId); - const restored = await restoreTrash(result.data.trashItems[0].id); + const restored = await restoreTrash(result.data.trashItems[0].id, tableId); expect(restored.status).toEqual(201); }); @@ -378,7 +378,7 @@ describe('Trash (e2e)', () => { await awaitWithFieldDeleteSync(async () => deleteFields(tableId, deletedFieldIds)); const result = await waitForTableTrashItems(tableId); - const restored = await restoreTrash(result.data.trashItems[0].id); + const restored = await restoreTrash(result.data.trashItems[0].id, tableId); expect(restored.status).toEqual(201); }); @@ -395,7 +395,7 @@ describe('Trash (e2e)', () => { await awaitWithFieldDeleteSync(async () => deleteFields(tableId, [formulaField.id])); const result = await waitForTableTrashItems(tableId); - const restored = await restoreTrash(result.data.trashItems[0].id); + const restored = await restoreTrash(result.data.trashItems[0].id, tableId); expect(restored.status).toEqual(201); }); @@ -451,7 +451,7 @@ describe('Trash (e2e)', () => { })), }); - const restored = await restoreTrash(recordTrashItem!.id); + const restored = await restoreTrash(recordTrashItem!.id, tableId); expect(restored.status).toEqual(201); const recordsAfterRestore = await getRecords(tableId, { @@ -497,7 +497,7 @@ describe('Trash (e2e)', () => { ) as ITableTrashItemVo | undefined; expect(recordTrashItem).toBeTruthy(); - const restored = await restoreTrash(recordTrashItem!.id); + const restored = await restoreTrash(recordTrashItem!.id, tableId); expect(restored.status).toEqual(201); expect(legacyRestoreSpy).not.toHaveBeenCalled(); @@ -558,7 +558,7 @@ describe('Trash (e2e)', () => { expect(fieldTrashItem).toBeTruthy(); - const restored = await restoreTrash(fieldTrashItem!.id); + const restored = await restoreTrash(fieldTrashItem!.id, tableId); expect(restored.status).toEqual(201); const afterFields = await getFields(tableId); @@ -572,7 +572,7 @@ describe('Trash (e2e)', () => { await deleteRecords(tableId, deletedRecordIds); const result = await waitForTableTrashItems(tableId, 1); - const restored = await restoreTrash(result.data.trashItems[0].id); + const restored = await restoreTrash(result.data.trashItems[0].id, tableId); expect(restored.status).toEqual(201); }); diff --git a/apps/nextjs-app/src/features/app/blocks/space/NoSpacesPlaceholder.tsx b/apps/nextjs-app/src/features/app/blocks/space/NoSpacesPlaceholder.tsx index 9d99dcdc43..c589c8494e 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/NoSpacesPlaceholder.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/NoSpacesPlaceholder.tsx @@ -34,6 +34,10 @@ export const NoSpacesPlaceholder = () => { }; const handleCreateSpace = () => { + if (createSpaceLoading) { + return; + } + const name = spaceName.trim() || getUniqName(t('noun.space'), spaceList?.length ? spaceList?.map((space) => space?.name) : []); @@ -85,22 +89,20 @@ export const NoSpacesPlaceholder = () => { }} onConfirm={handleCreateSpace} content={ -
-
- space?.name) : [] - )} - value={spaceName} - onChange={(e) => setSpaceName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleCreateSpace(); - } - }} - /> -
+
+ space?.name) : [] + )} + value={spaceName} + onChange={(e) => setSpaceName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreateSpace(); + } + }} + />
} /> diff --git a/apps/nextjs-app/src/features/app/blocks/space/data-db/ByodbSpaceCreateSection.spec.tsx b/apps/nextjs-app/src/features/app/blocks/space/data-db/ByodbSpaceCreateSection.spec.tsx new file mode 100644 index 0000000000..21c7b8e47a --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/data-db/ByodbSpaceCreateSection.spec.tsx @@ -0,0 +1,152 @@ +import type { IDataDbPreflightVo } from '@teable/openapi'; +import { render, screen, userEvent } from '@/test-utils'; +import { ByodbSpaceCreateSection } from './ByodbSpaceCreateSection'; + +vi.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const defaultProps = { + mode: 'default' as const, + url: '', + onModeChange: vi.fn(), + onUrlChange: vi.fn(), + onTestConnection: vi.fn(), +}; + +describe('ByodbSpaceCreateSection', () => { + it('lets users enable BYODB with the switch', async () => { + const onModeChange = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('switch')); + + expect(onModeChange).toHaveBeenCalledWith('byodb'); + }); + + it('renders preflight summary without exposing the password', () => { + const result: IDataDbPreflightVo = { + ok: true, + provider: 'postgres', + maskedUrl: 'postgresql://user:***@db.example.com/teable_byodb', + urlFingerprint: 'fingerprint', + displayHost: 'db.example.com', + displayDatabase: 'teable_byodb', + serverVersion: '16.3', + classification: 'empty', + availableDatabases: ['postgres', 'teable_byodb'], + capabilities: { + createSchema: true, + createTable: true, + createFunction: true, + createTrigger: true, + createRole: true, + grantPrivileges: true, + inspectActivity: true, + }, + errors: [], + }; + + render( + + ); + + expect(screen.getByText(/db.example.com/)).toBeInTheDocument(); + expect(screen.getAllByText(/teable_byodb/).length).toBeGreaterThan(0); + expect(screen.queryByText(/secret/)).not.toBeInTheDocument(); + }); + + it('lets users choose a database when preflight returns candidates', async () => { + const onUrlChange = vi.fn(); + const result: IDataDbPreflightVo = { + ok: false, + provider: 'postgres', + maskedUrl: 'postgresql://user:***@db.example.com:5432', + urlFingerprint: 'fingerprint', + displayHost: 'db.example.com:5432', + displayDatabase: '', + serverVersion: '16.3', + classification: 'non-empty-unknown', + availableDatabases: ['postgres', 'teable_data'], + requiresDatabaseSelection: true, + capabilities: { + createSchema: false, + createTable: false, + createFunction: false, + createTrigger: false, + createRole: false, + grantPrivileges: false, + inspectActivity: false, + }, + errors: [], + }; + + render( + + ); + + await userEvent.selectOptions(screen.getByRole('combobox'), 'teable_data'); + + expect(onUrlChange).toHaveBeenCalledWith( + 'postgresql://user:secret@db.example.com:5432/teable_data' + ); + expect(screen.queryByText(/dataDb.create.preflightFailed/)).not.toBeInTheDocument(); + expect(screen.queryByText(/dataDb.create.missingCapabilities/)).not.toBeInTheDocument(); + }); + + it('renders IPv6 network errors without missing capability noise', () => { + const result: IDataDbPreflightVo = { + ok: false, + provider: 'postgres', + displayHost: 'db.example.com:5432', + displayDatabase: 'postgres', + classification: 'non-empty-unknown', + capabilities: { + createSchema: false, + createTable: false, + createFunction: false, + createTrigger: false, + createRole: false, + grantPrivileges: false, + inspectActivity: false, + }, + errors: [ + { + code: 'IPV6_NETWORK_UNREACHABLE', + message: 'The database host resolved to an IPv6 address.', + remediation: 'Use an IPv4-reachable database endpoint.', + }, + ], + }; + + render( + + ); + + expect( + screen.getByText(/dataDb.create.errors.IPV6_NETWORK_UNREACHABLE.message/) + ).toBeInTheDocument(); + expect(screen.queryByText(/dataDb.create.missingCapabilities/)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/nextjs-app/src/features/app/blocks/space/data-db/ByodbSpaceCreateSection.tsx b/apps/nextjs-app/src/features/app/blocks/space/data-db/ByodbSpaceCreateSection.tsx new file mode 100644 index 0000000000..aa13d76b7b --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/data-db/ByodbSpaceCreateSection.tsx @@ -0,0 +1,215 @@ +import type { IDataDbPreflightVo } from '@teable/openapi'; +import { Button, Input, Switch, cn } from '@teable/ui-lib/shadcn'; +import { CheckCircle2, TriangleAlert } from 'lucide-react'; +import { useTranslation } from 'next-i18next'; + +type DataDbMode = 'default' | 'byodb'; + +interface IByodbSpaceCreateSectionProps { + mode: DataDbMode; + url: string; + preflightResult?: IDataDbPreflightVo; + preflightError?: string; + testedUrl?: string; + isTesting?: boolean; + onModeChange: (mode: DataDbMode) => void; + onUrlChange: (url: string) => void; + onTestConnection: () => void; +} + +const requiredCapabilityKeys: Array = [ + 'createSchema', + 'createTable', + 'createFunction', + 'createTrigger', + 'createRole', + 'grantPrivileges', +]; + +export const getDatabaseNameFromUrl = (url: string) => { + try { + return decodeURIComponent(new URL(url.trim()).pathname.replace(/^\//, '')); + } catch { + return ''; + } +}; + +export const setDatabaseNameInUrl = (url: string, database: string) => { + try { + const parsed = new URL(url.trim()); + parsed.pathname = `/${encodeURIComponent(database)}`; + return parsed.toString(); + } catch { + return url; + } +}; + +export const ByodbSpaceCreateSection = (props: IByodbSpaceCreateSectionProps) => { + const { + mode, + url, + preflightResult, + preflightError, + testedUrl, + isTesting, + onModeChange, + onUrlChange, + onTestConnection, + } = props; + const { t } = useTranslation('space'); + const useByodb = mode === 'byodb'; + const trimmedUrl = url.trim(); + const hasStaleResult = Boolean(preflightResult && testedUrl !== trimmedUrl); + const requiresDatabaseSelection = Boolean( + preflightResult?.requiresDatabaseSelection && !hasStaleResult + ); + const hasCapabilityResult = !preflightResult?.errors.some((error) => + ['CONNECTION_FAILED', 'IPV6_NETWORK_UNREACHABLE'].includes(error.code) + ); + const missingCapabilities = preflightResult + ? requiredCapabilityKeys.filter( + (key) => hasCapabilityResult && !preflightResult.capabilities[key] + ) + : []; + const availableDatabases = + preflightResult && !hasStaleResult ? preflightResult.availableDatabases ?? [] : []; + const selectedDatabase = getDatabaseNameFromUrl(url); + + return ( +
+
+

{t('dataDb.create.title')}

+

{t('dataDb.create.description')}

+
+ +
+
+ +

+ {useByodb ? t('dataDb.create.byodbHint') : t('dataDb.create.defaultHint')} +

+
+ onModeChange(checked ? 'byodb' : 'default')} + /> +
+ + {useByodb && ( +
+
+ + onUrlChange(e.target.value)} + /> +

{t('dataDb.create.sslHint')}

+
+ +
+ + {hasStaleResult && ( + + {t('dataDb.create.retestRequired')} + + )} +
+ + {preflightError && ( +
+ + {preflightError} +
+ )} + + {availableDatabases.length > 0 && ( +
+ + +

{t('dataDb.create.databaseHint')}

+
+ )} + + {preflightResult && !hasStaleResult && !requiresDatabaseSelection && ( +
+
+ {preflightResult.ok ? ( + + ) : ( + + )} + {preflightResult.ok + ? t('dataDb.create.preflightPassed') + : t('dataDb.create.preflightFailed')} +
+
+ + {t('dataDb.fields.host')}: {preflightResult.displayHost || '-'} + + + {t('dataDb.fields.database')}: {preflightResult.displayDatabase || '-'} + + + {t('dataDb.fields.internalSchema')}: {preflightResult.internalSchema || '-'} + + + {t('dataDb.fields.version')}: {preflightResult.serverVersion || '-'} + + + {t('dataDb.fields.classification')}: {preflightResult.classification} + +
+ {missingCapabilities.length > 0 && ( +
+ {t('dataDb.create.missingCapabilities')}: {missingCapabilities.join(', ')} +
+ )} + {preflightResult.errors.map((error) => ( +
+ {t(`dataDb.create.errors.${error.code}.message`, { + defaultValue: error.message, + })} + {error.remediation + ? ` ${t(`dataDb.create.errors.${error.code}.remediation`, { + defaultValue: error.remediation, + })}` + : ''} +
+ ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/space/data-db/create-space-data-db.spec.ts b/apps/nextjs-app/src/features/app/blocks/space/data-db/create-space-data-db.spec.ts new file mode 100644 index 0000000000..f2aaad7ee1 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/data-db/create-space-data-db.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { + canCreateSpaceWithDataDb, + getCreateSpaceDataDbPayload, + isByodbSpaceCreateEnabled, +} from './create-space-data-db'; + +const byodbUrl = 'postgresql://user:pass@db/teable'; + +describe('create space data DB helpers', () => { + it('keeps the default database payload empty', () => { + expect(getCreateSpaceDataDbPayload('default', byodbUrl)).toEqual({}); + expect(canCreateSpaceWithDataDb('default', '', undefined, undefined)).toBe(true); + }); + + it('builds a trimmed BYODB create-space payload', () => { + expect(getCreateSpaceDataDbPayload('byodb', ` ${byodbUrl} `)).toEqual({ + dataDb: { + mode: 'byodb', + url: byodbUrl, + targetMode: 'initialize-empty', + }, + }); + }); + + it('requires a successful current preflight before BYODB space creation', () => { + const url = byodbUrl; + + expect(canCreateSpaceWithDataDb('byodb', url, { ok: false }, url)).toBe(false); + expect(canCreateSpaceWithDataDb('byodb', `${url}?sslmode=require`, { ok: true }, url)).toBe( + false + ); + expect(canCreateSpaceWithDataDb('byodb', url, { ok: true }, url)).toBe(true); + }); + + it('enables BYODB create UX only for EE builds', () => { + expect(isByodbSpaceCreateEnabled('EE')).toBe(true); + expect(isByodbSpaceCreateEnabled('CLOUD')).toBe(false); + expect(isByodbSpaceCreateEnabled(undefined)).toBe(false); + }); +}); diff --git a/apps/nextjs-app/src/features/app/blocks/space/data-db/create-space-data-db.ts b/apps/nextjs-app/src/features/app/blocks/space/data-db/create-space-data-db.ts new file mode 100644 index 0000000000..1ab79740a9 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/data-db/create-space-data-db.ts @@ -0,0 +1,38 @@ +import type { ICreateSpaceRo, IDataDbPreflightVo } from '@teable/openapi'; + +type DataDbMode = 'default' | 'byodb'; + +export const isByodbSpaceCreateEnabled = (edition?: string) => { + return edition?.toUpperCase() === 'EE'; +}; + +export const getCreateSpaceDataDbPayload = ( + mode: DataDbMode, + url: string +): Partial => { + if (mode !== 'byodb') { + return {}; + } + + return { + dataDb: { + mode: 'byodb', + url: url.trim(), + targetMode: 'initialize-empty', + }, + }; +}; + +export const canCreateSpaceWithDataDb = ( + mode: DataDbMode, + url: string, + preflightResult?: Pick, + testedUrl?: string +) => { + if (mode === 'default') { + return true; + } + + const trimmedUrl = url.trim(); + return Boolean(trimmedUrl && preflightResult?.ok && testedUrl === trimmedUrl); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/space/data-db/index.ts b/apps/nextjs-app/src/features/app/blocks/space/data-db/index.ts new file mode 100644 index 0000000000..3c1aaa3862 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/data-db/index.ts @@ -0,0 +1,3 @@ +export * from './ByodbSpaceCreateSection'; +export * from './create-space-data-db'; +export * from './useByodbSpaceCreate'; diff --git a/apps/nextjs-app/src/features/app/blocks/space/data-db/useByodbSpaceCreate.tsx b/apps/nextjs-app/src/features/app/blocks/space/data-db/useByodbSpaceCreate.tsx new file mode 100644 index 0000000000..936b2a9c64 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/space/data-db/useByodbSpaceCreate.tsx @@ -0,0 +1,89 @@ +import { useMutation } from '@tanstack/react-query'; +import { preflightSpaceDataDb, type IDataDbPreflightVo } from '@teable/openapi'; +import { useTranslation } from 'next-i18next'; +import { useCallback, useState } from 'react'; +import { useEnv } from '@/features/app/hooks/useEnv'; +import { ByodbSpaceCreateSection } from './ByodbSpaceCreateSection'; +import { + canCreateSpaceWithDataDb, + getCreateSpaceDataDbPayload, + isByodbSpaceCreateEnabled, +} from './create-space-data-db'; + +export const useByodbSpaceCreate = () => { + const { t } = useTranslation('space'); + const { edition } = useEnv(); + const enabled = isByodbSpaceCreateEnabled(edition); + const [dataDbMode, setDataDbMode] = useState<'default' | 'byodb'>('default'); + const [dataDbUrl, setDataDbUrl] = useState(''); + const [preflightResult, setPreflightResult] = useState(); + const [preflightTestedUrl, setPreflightTestedUrl] = useState(); + const [preflightError, setPreflightError] = useState(); + + const { mutateAsync: preflightDataDb, isPending: isPreflightPending } = useMutation({ + mutationFn: preflightSpaceDataDb, + }); + + const reset = useCallback(() => { + setDataDbMode('default'); + setDataDbUrl(''); + setPreflightResult(undefined); + setPreflightTestedUrl(undefined); + setPreflightError(undefined); + }, []); + + const handlePreflightDataDb = useCallback(async () => { + const url = dataDbUrl.trim(); + if (!url) return; + + try { + const result = await preflightDataDb({ url, targetMode: 'initialize-empty' }); + setPreflightResult(result.data); + setPreflightTestedUrl(url); + setPreflightError(undefined); + } catch { + setPreflightResult(undefined); + setPreflightTestedUrl(undefined); + setPreflightError(t('dataDb.create.testFailed')); + } + }, [dataDbUrl, preflightDataDb, t]); + + const getPayload = useCallback( + () => getCreateSpaceDataDbPayload(dataDbMode, dataDbUrl), + [dataDbMode, dataDbUrl] + ); + + const confirmDisabled = !canCreateSpaceWithDataDb( + dataDbMode, + dataDbUrl, + preflightResult, + preflightTestedUrl + ); + + const content = enabled ? ( + { + setDataDbUrl(url); + setPreflightResult(undefined); + setPreflightTestedUrl(undefined); + setPreflightError(undefined); + }} + onTestConnection={handlePreflightDataDb} + /> + ) : undefined; + + return { + enabled, + content, + confirmDisabled: enabled ? confirmDisabled : false, + getPayload: enabled ? getPayload : undefined, + reset: enabled ? reset : undefined, + }; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx index 95911ff299..39981f0ea9 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceList.tsx @@ -85,6 +85,10 @@ export const SpaceList: FC = () => { }, [spaceList]); const handleCreateSpace = () => { + if (isLoading) { + return; + } + const name = spaceName.trim() || getUniqName(t('noun.space'), spaceList?.length ? spaceList?.map((space) => space?.name) : []); @@ -189,22 +193,20 @@ export const SpaceList: FC = () => { }} onConfirm={handleCreateSpace} content={ -
-
- space?.name) : [] - )} - value={spaceName} - onChange={(e) => setSpaceName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleCreateSpace(); - } - }} - /> -
+
+ space?.name) : [] + )} + value={spaceName} + onChange={(e) => setSpaceName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreateSpace(); + } + }} + />
} /> diff --git a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx index de1f724f1a..77f9477579 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/space-side-bar/SpaceSwitcher.tsx @@ -117,7 +117,6 @@ export const SpaceSwitcher = (props: ISpaceSwitcherProps) => { }, [spaceList, currentSpaceId]); const organization = user?.organization; - const { mutate: addSpace, isPending: isLoading } = useMutation({ mutationFn: createSpace, onSuccess: async (data) => { @@ -142,6 +141,10 @@ export const SpaceSwitcher = (props: ISpaceSwitcherProps) => { }; const handleCreateSpace = () => { + if (isLoading) { + return; + } + const name = spaceName.trim() || getUniqName(t('common:noun.space'), spaceList?.length ? spaceList?.map((s) => s.name) : []); @@ -336,22 +339,20 @@ export const SpaceSwitcher = (props: ISpaceSwitcherProps) => { }} onConfirm={handleCreateSpace} content={ -
-
- s.name) : [] - )} - value={spaceName} - onChange={(e) => setSpaceName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleCreateSpace(); - } - }} - /> -
+
+ s.name) : [] + )} + value={spaceName} + onChange={(e) => setSpaceName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreateSpace(); + } + }} + />
} /> diff --git a/apps/nextjs-app/src/features/app/blocks/trash/components/TableTrash.tsx b/apps/nextjs-app/src/features/app/blocks/trash/components/TableTrash.tsx index dbef6cd5d0..d93d4db1a2 100644 --- a/apps/nextjs-app/src/features/app/blocks/trash/components/TableTrash.tsx +++ b/apps/nextjs-app/src/features/app/blocks/trash/components/TableTrash.tsx @@ -63,7 +63,7 @@ export const TableTrash = (props: ITableTrashProps) => { }); const { mutateAsync: mutateRestore } = useMutation({ - mutationFn: (props: { trashId: string }) => restoreTrash(props.trashId), + mutationFn: (props: { trashId: string }) => restoreTrash(props.trashId, tableId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ReactQueryKeys.getTrashItems(tableId) }); toast.success(t('actions.restoreSucceed')); diff --git a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx index 5915507285..3ccedb2548 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.spec.tsx @@ -1,23 +1,101 @@ -import { CellValueType, DbFieldType, FieldType } from '@teable/core'; -import { render, screen } from '@/test-utils'; -import { FieldSettingBase } from './FieldSetting'; +import { + CellValueType, + DbFieldType, + FieldAIActionType, + FieldType, + StatisticsFunc, +} from '@teable/core'; +import type * as OpenApi from '@teable/openapi'; +import type * as SdkHooks from '@teable/sdk/hooks'; +import { act } from 'react'; +import { render, screen, TestAnchorProvider, userEvent, waitFor } from '@/test-utils'; +import { FieldSetting, FieldSettingBase } from './FieldSetting'; import { FieldOperator } from './type'; +const fieldOperationMocks = vi.hoisted(() => ({ + createField: vi.fn(), + convertField: vi.fn(), + planFieldCreate: vi.fn(), + planFieldConvert: vi.fn(), + autoFillField: vi.fn(), +})); + +const openapiMocks = vi.hoisted(() => ({ + getAggregation: vi.fn(), +})); + +vi.mock('@teable/openapi', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAggregation: openapiMocks.getAggregation, + }; +}); + +vi.mock('@teable/sdk/hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTableId: () => 'tblTest0000000001', + useView: () => ({ id: 'viwTest0000000001' }), + useRowCount: () => 30, + useFieldOperations: () => fieldOperationMocks, + }; +}); + vi.mock('./DynamicFieldEditor', () => ({ DynamicFieldEditor: ({ field, + onChange, + onSave, }: { field: { name?: string; type?: string; isLookup?: boolean }; + onChange?: (field: unknown) => void; + onSave?: () => void | Promise; }) => (
{field.name ?? ''} {field.type ?? ''} {field.isLookup ? 'true' : 'false'} + +
), })); +const createDeferred = () => { + let resolve: (value: T) => void = () => undefined; + let reject: (reason?: unknown) => void = () => undefined; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + describe('FieldSettingBase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('disables save when editing target field is not available yet', () => { render( { id: 'fldLookup0000000001', name: 'Lookup Child Name', type: FieldType.SingleLineText, - description: null, options: {}, isLookup: true, - lookupOptions: { - foreignTableId: 'tblForeign000000001', - linkFieldId: 'fldLink000000000001', - lookupFieldId: 'fldTarget0000000001', - }, cellValueType: CellValueType.String, isMultipleCellValue: false, dbFieldType: DbFieldType.Text, @@ -79,4 +151,98 @@ describe('FieldSettingBase', () => { expect(screen.getByTestId('editor-field-type')).toHaveTextContent(FieldType.SingleLineText); expect(screen.getByTestId('editor-field-is-lookup')).toHaveTextContent('true'); }); + + it('keeps the AI apply dialog open until field save succeeds', async () => { + const field = { + id: 'fldTest0000000001', + name: 'Reply', + type: FieldType.SingleLineText, + options: {}, + aiConfig: { + type: FieldAIActionType.Customization, + modelKey: 'old-model', + prompt: 'Old prompt', + isAutoFill: true, + }, + cellValueType: CellValueType.String, + isMultipleCellValue: false, + dbFieldType: DbFieldType.Text, + dbFieldName: 'Reply', + } as const; + const updatedField = { + ...field, + name: 'AI Reply', + aiConfig: { + type: FieldAIActionType.Customization, + modelKey: 'gpt-5.5', + prompt: 'Write a concise customer-service reply.', + isAutoFill: true, + }, + }; + const convertDeferred = createDeferred(); + + openapiMocks.getAggregation.mockResolvedValue({ + data: { + aggregations: [ + { + fieldId: field.id, + total: { aggFunc: StatisticsFunc.Empty, value: 0 }, + }, + { + fieldId: field.id, + total: { aggFunc: StatisticsFunc.Filled, value: 30 }, + }, + ], + }, + }); + fieldOperationMocks.planFieldConvert.mockResolvedValue({ + estimateTime: 0, + linkFieldCount: 0, + }); + fieldOperationMocks.convertField.mockReturnValue(convertDeferred.promise); + fieldOperationMocks.autoFillField.mockResolvedValue(undefined); + + render( + + undefined} + onConfirm={() => undefined} + /> + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Mock change AI config' })); + await userEvent.click(screen.getByRole('button', { name: 'common:actions.save' })); + + expect( + await screen.findByText('table:field.aiConfig.autoFillConfirm.title') + ).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { name: 'table:field.aiConfig.autoFillConfirm.generateAll' }) + ); + + expect(fieldOperationMocks.convertField).toHaveBeenCalled(); + expect(fieldOperationMocks.autoFillField).not.toHaveBeenCalled(); + expect(screen.getByText('table:field.aiConfig.autoFillConfirm.title')).toBeInTheDocument(); + + await act(async () => { + convertDeferred.resolve(updatedField); + await convertDeferred.promise; + }); + + await waitFor(() => { + expect( + screen.queryByText('table:field.aiConfig.autoFillConfirm.title') + ).not.toBeInTheDocument(); + }); + expect(fieldOperationMocks.autoFillField).toHaveBeenCalledWith({ + tableId: 'tblTest0000000001', + fieldId: field.id, + query: { viewId: 'viwTest0000000001', mode: 'all' }, + }); + }); }); diff --git a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx index 0748426f9f..3d5edca80f 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx +++ b/apps/nextjs-app/src/features/app/components/field-setting/FieldSetting.tsx @@ -360,21 +360,22 @@ export const FieldSetting = (props: IFieldSetting) => { }; const handleConfirmWithAutoFill = async (mode: AiAutoFillMode) => { - if (!fieldRo) return; + if (!fieldRo) return false; autoFillModeRef.current = mode; const plan = await getPlan(fieldRo); if (!plan) { - return; + return false; } setPlan(plan); const estimateTime = plan?.estimateTime || 0; const linkFieldCount = plan?.linkFieldCount || 0; if (estimateTime > 1000 || linkFieldCount > 0) { setGraphVisible(true); - return; + return true; } await performAction(fieldRo); + return true; }; return ( @@ -448,8 +449,10 @@ export const FieldSetting = (props: IFieldSetting) => { }} onClose={() => setAiConfirmVisible(false)} onConfirm={async (mode) => { - setAiConfirmVisible(false); - await handleConfirmWithAutoFill(mode); + const shouldClose = await handleConfirmWithAutoFill(mode); + if (shouldClose) { + setAiConfirmVisible(false); + } }} /> { const dynamicComponent = useCallback(() => { switch (notifyType) { case NotificationTypeEnum.ExportBase: - case NotificationTypeEnum.System: { + case NotificationTypeEnum.System: + case NotificationTypeEnum.AdminNotice: { const { iconUrl } = notifyIcon as INotificationSystemIcon; return ( diff --git a/apps/nextjs-app/src/features/app/components/notifications/NotificationItem.tsx b/apps/nextjs-app/src/features/app/components/notifications/NotificationItem.tsx index 5140289662..fba7644b90 100644 --- a/apps/nextjs-app/src/features/app/components/notifications/NotificationItem.tsx +++ b/apps/nextjs-app/src/features/app/components/notifications/NotificationItem.tsx @@ -20,9 +20,7 @@ export const NotificationItem = React.forwardRef @@ -40,7 +38,7 @@ export const NotificationItem = React.forwardRef ); - if (isExportBase) { + if (isExportBase || !url) { return (
} className={className} {...rest}> {content} diff --git a/apps/nextjs-app/src/features/app/components/notifications/NotificationsManage.tsx b/apps/nextjs-app/src/features/app/components/notifications/NotificationsManage.tsx index 3d64e8b26d..3ec2d6c143 100644 --- a/apps/nextjs-app/src/features/app/components/notifications/NotificationsManage.tsx +++ b/apps/nextjs-app/src/features/app/components/notifications/NotificationsManage.tsx @@ -20,7 +20,6 @@ import dayjs from 'dayjs'; import { useTranslation } from 'next-i18next'; import type { TFunction } from 'next-i18next'; import React, { useEffect, useState } from 'react'; -import { useLocalStorage } from 'react-use'; import { LinkNotification } from './notification-component'; import { NotificationIcon } from './NotificationIcon'; import { NotificationList } from './NotificationList'; @@ -29,7 +28,6 @@ const SHOWN_NOTIFICATIONS_LIMIT = 100; const TOAST_AUTO_CLOSE_DURATION = 1000 * 3; const TOAST_MANUAL_CLOSE_DURATION = Infinity; const shownNotificationIds = new Set(); -const NOTIFICATION_SEVERITY_FILTER_KEY = 'teable:notification:severity-filter'; const CREDIT_EXHAUSTED_NOTIFICATION_TOAST_ID = 'credit-exhausted-notification'; const CREDIT_NOTIFICATION_I18N_KEYS = new Set([ 'email.templates.notify.task.ai.cancelled.creditExhausted', @@ -41,9 +39,6 @@ const NOTIFICATION_SEVERITIES = [ NotificationSeverityEnum.Info, ] as const; -const isNotificationSeverity = (value: unknown): value is NotificationSeverityEnum => - NOTIFICATION_SEVERITIES.includes(value as NotificationSeverityEnum); - const getNotificationToastDuration = (notification: Pick) => notification.severity === NotificationSeverityEnum.Critical ? TOAST_MANUAL_CLOSE_DURATION @@ -169,17 +164,9 @@ export const NotificationsManage: React.FC = () => { const [newUnreadCount, setNewUnreadCount] = useState(undefined); const [notifyStatus, setNotifyStatus] = useState(NotificationStatesEnum.Unread); - const [storedSeverity, setStoredSeverity, removeStoredSeverity] = - useLocalStorage(NOTIFICATION_SEVERITY_FILTER_KEY, undefined, { - raw: true, - }); - const selectedSeverity = isNotificationSeverity(storedSeverity) ? storedSeverity : undefined; - - useEffect(() => { - if (storedSeverity && !isNotificationSeverity(storedSeverity)) { - removeStoredSeverity(); - } - }, [removeStoredSeverity, storedSeverity]); + const [selectedSeverity, setSelectedSeverity] = useState( + undefined + ); const { data: queryUnreadCount = 0 } = useQuery({ queryKey: ReactQueryKeys.notifyUnreadCount(), @@ -252,13 +239,8 @@ export const NotificationsManage: React.FC = () => { const getSeverityLabel = (severity: NotificationSeverityEnum) => t(`notification.severity.${severity}`); - const handleSeverityClick = (severity: NotificationSeverityEnum) => { - if (selectedSeverity === severity) { - removeStoredSeverity(); - return; - } - - setStoredSeverity(severity); + const handleSeverityClick = (severity?: NotificationSeverityEnum) => { + setSelectedSeverity(severity); }; const renderNewButton = () => { @@ -334,6 +316,28 @@ export const NotificationsManage: React.FC = () => {
+ {NOTIFICATION_SEVERITIES.map((severity) => { const isSelected = selectedSeverity === severity; @@ -344,7 +348,7 @@ export const NotificationsManage: React.FC = () => { size="xs" className={cn( 'h-7 gap-1.5 rounded px-2.5 text-xs font-medium text-muted-foreground hover:bg-muted/70 hover:text-foreground', - isSelected && 'bg-muted/80 text-foreground hover:bg-muted/80' + isSelected && 'bg-foreground/10 text-foreground hover:bg-foreground/10' )} onClick={() => handleSeverityClick(severity)} > diff --git a/apps/nextjs-app/src/features/app/components/notifications/notification-component/LinkNotification.tsx b/apps/nextjs-app/src/features/app/components/notifications/notification-component/LinkNotification.tsx index c77e0c0acd..7a1e8edec4 100644 --- a/apps/nextjs-app/src/features/app/components/notifications/notification-component/LinkNotification.tsx +++ b/apps/nextjs-app/src/features/app/components/notifications/notification-component/LinkNotification.tsx @@ -54,7 +54,7 @@ export const LinkNotification = (props: LinkNotificationProps) => { } }; - if (disableLink || notifyType === NotificationTypeEnum.ExportBase) { + if (disableLink || !url || notifyType === NotificationTypeEnum.ExportBase) { return ( <> {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} diff --git a/apps/nextjs-app/src/lib/database-url.spec.ts b/apps/nextjs-app/src/lib/database-url.spec.ts index a02c7fb268..d9cfe308aa 100644 --- a/apps/nextjs-app/src/lib/database-url.spec.ts +++ b/apps/nextjs-app/src/lib/database-url.spec.ts @@ -21,12 +21,6 @@ describe('getAppDatabaseUrl', () => { ); }); - it('uses the data db as a last-resort safety net', () => { - expect(getAppDatabaseUrl({ PRISMA_DATA_DATABASE_URL: 'postgresql://data' })).toBe( - 'postgresql://data' - ); - }); - it('throws when no database url exists', () => { expect(() => getAppDatabaseUrl({})).toThrow('Missing database url'); }); diff --git a/apps/nextjs-app/src/lib/database-url.ts b/apps/nextjs-app/src/lib/database-url.ts index 28f70c1ad2..3893862915 100644 --- a/apps/nextjs-app/src/lib/database-url.ts +++ b/apps/nextjs-app/src/lib/database-url.ts @@ -2,7 +2,6 @@ const APP_DATABASE_ENV_KEYS = [ 'PRISMA_META_DATABASE_URL', 'PRISMA_DATABASE_URL', 'DATABASE_URL', - 'PRISMA_DATA_DATABASE_URL', ] as const; export const getAppDatabaseUrl = (env: NodeJS.ProcessEnv = process.env): string => { diff --git a/packages/common-i18n/src/locales/de/common.json b/packages/common-i18n/src/locales/de/common.json index 0905c18f2a..3ac0773502 100644 --- a/packages/common-i18n/src/locales/de/common.json +++ b/packages/common-i18n/src/locales/de/common.json @@ -1154,9 +1154,9 @@ } }, "changelog": { - "newUpdate": "UPDATE VOM 12. MAI", - "title": "Integrierte Anmeldung fuer App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "UPDATE VOM 14. MAI", + "title": "Jeden Knoten in AI Chat erwähnen", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/de/sdk.json b/packages/common-i18n/src/locales/de/sdk.json index 3ebf22cc02..bc7d382590 100644 --- a/packages/common-i18n/src/locales/de/sdk.json +++ b/packages/common-i18n/src/locales/de/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "Automatisierungsknoten benötigt Test", "automationNodeTestOutdated": "Automatisierungsknoten-Test veraltet", "invalidToken": "Ungültiges Token", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" Feld \"{{fieldName}}\" darf keine leeren Werte enthalten, bitte vollständig ausfüllen bevor Sie absenden.", "fieldValueDuplicate": "\"{{tableName}}\" Feld \"{{fieldName}}\" darf keine doppelten Werte enthalten, bitte einen eindeutigen Wert vor dem Absenden ausfüllen.", diff --git a/packages/common-i18n/src/locales/en/common.json b/packages/common-i18n/src/locales/en/common.json index 257ba5619f..d9f38c058e 100644 --- a/packages/common-i18n/src/locales/en/common.json +++ b/packages/common-i18n/src/locales/en/common.json @@ -1502,9 +1502,9 @@ } }, "changelog": { - "newUpdate": "MAY 12 UPDATE", - "title": "Built-in Login for App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "MAY 14 UPDATE", + "title": "@ Any Node in AI Chat", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/en/sdk.json b/packages/common-i18n/src/locales/en/sdk.json index 4990585728..c503e32678 100644 --- a/packages/common-i18n/src/locales/en/sdk.json +++ b/packages/common-i18n/src/locales/en/sdk.json @@ -938,6 +938,31 @@ "automationNodeNeedTest": "Automation node need test", "automationNodeTestOutdated": "Automation node test outdated", "invalidToken": "Invalid token", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" field \"{{fieldName}}\" does not allow empty values, please fill in completely before submitting.", "fieldValueDuplicate": "\"{{tableName}}\" field \"{{fieldName}}\" does not allow duplicate values, please fill in a unique value before submitting.", diff --git a/packages/common-i18n/src/locales/en/space.json b/packages/common-i18n/src/locales/en/space.json index 0c38a74c24..67f6a76e23 100644 --- a/packages/common-i18n/src/locales/en/space.json +++ b/packages/common-i18n/src/locales/en/space.json @@ -76,7 +76,6 @@ "description": "All the bases that invited me to join", "empty": "No bases shared with me yet" }, - "integration": { "title": "Integrations", "description": "Manage integrations of your space", @@ -233,5 +232,66 @@ "redeploy": "Redeploy", "unnamedApp": "Unnamed App" } + }, + "dataDb": { + "create": { + "title": "Data database", + "description": "Choose where this space stores base schemas and records.", + "defaultOption": "Use Teable default database", + "defaultHint": "Recommended for most spaces.", + "byodbOption": "Use my PostgreSQL database", + "byodbHint": "Initialize this space in a Teable internal schema inside your customer-managed database.", + "urlLabel": "PostgreSQL connection URL", + "sslHint": "Use the same SSL parameters required by your PostgreSQL server. The password is never shown after testing.", + "testConnection": "Test connection", + "testing": "Testing...", + "retestRequired": "Test again after changing the URL.", + "databaseLabel": "Database", + "databasePlaceholder": "Select a database", + "databaseHint": "Choose the PostgreSQL database for this space, then test the connection again.", + "preflightPassed": "Connection checks passed", + "preflightFailed": "Connection checks failed", + "missingCapabilities": "Missing privileges", + "testFailed": "Failed to test the database connection.", + "errors": { + "INVALID_DATABASE_URL": { + "message": "The PostgreSQL connection URL is invalid." + }, + "PRIVATE_NETWORK_BLOCKED": { + "message": "Private network database hosts are blocked by default.", + "remediation": "Set TEABLE_SSRF_PROTECTION_DISABLED=true only in trusted self-hosted deployments." + }, + "CONNECTION_FAILED": { + "message": "Failed to connect to the database.", + "remediation": "Check host, port, database name, username, password, and SSL parameters." + }, + "IPV6_NETWORK_UNREACHABLE": { + "message": "The database host resolved to an IPv6 address, but this Teable deployment cannot reach IPv6 networks.", + "remediation": "Use an IPv4-reachable database endpoint or connection pooler, or enable IPv6 outbound networking for this Teable deployment." + }, + "PRIVILEGE_CHECK_FAILED": { + "message": "Database privilege checks failed." + }, + "DDL_PRIVILEGE_CHECK_FAILED": { + "message": "The account is missing DDL privileges required to initialize a Teable data database.", + "remediation": "Grant privileges to create schemas, tables, functions, and triggers." + }, + "NON_EMPTY_UNKNOWN_DATABASE": { + "message": "Teable's internal schema already contains objects that are not managed by Teable.", + "remediation": "Use a database without a conflicting internal schema, or remove the unknown objects from that schema." + }, + "INCOMPATIBLE_TEABLE_DATABASE": { + "message": "This database contains incomplete or incompatible Teable data objects.", + "remediation": "Use an empty database or a compatible Teable data database migration/adopt flow." + } + } + }, + "fields": { + "host": "Host", + "database": "Database", + "internalSchema": "Internal schema", + "version": "Version", + "classification": "Classification" + } } } diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index 1b48325f3b..f569ee5c62 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -1241,6 +1241,10 @@ "contextTipNewChat": "Start a new chat for different topics to significantly reduce Credit usage.", "contextTipMemory": "Say \"remember something\" to save to long-term memory, accessible across chats." }, + "contextCompaction": { + "auto": "Context was automatically compacted", + "manual": "Context was compacted" + }, "taskProgress": { "title": "Task Progress" }, diff --git a/packages/common-i18n/src/locales/es/common.json b/packages/common-i18n/src/locales/es/common.json index eadda73182..6a5a1bfa7e 100644 --- a/packages/common-i18n/src/locales/es/common.json +++ b/packages/common-i18n/src/locales/es/common.json @@ -1157,9 +1157,9 @@ } }, "changelog": { - "newUpdate": "ACTUALIZACION DEL 12 DE MAYO", - "title": "Inicio de sesion integrado para App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "ACTUALIZACION DEL 14 DE MAYO", + "title": "Menciona cualquier nodo en AI Chat", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/es/sdk.json b/packages/common-i18n/src/locales/es/sdk.json index c3802f9078..ab78165786 100644 --- a/packages/common-i18n/src/locales/es/sdk.json +++ b/packages/common-i18n/src/locales/es/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "El nodo de automatización necesita prueba", "automationNodeTestOutdated": "Prueba del nodo de automatización desactualizada", "invalidToken": "Token no válido", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" campo \"{{fieldName}}\" no permite valores vacíos, por favor complete antes de enviar.", "fieldValueDuplicate": "\"{{tableName}}\" campo \"{{fieldName}}\" no permite valores duplicados, por favor complete un valor único antes de enviar.", diff --git a/packages/common-i18n/src/locales/fr/common.json b/packages/common-i18n/src/locales/fr/common.json index 5025d378e7..3c00914a2a 100644 --- a/packages/common-i18n/src/locales/fr/common.json +++ b/packages/common-i18n/src/locales/fr/common.json @@ -1159,9 +1159,9 @@ } }, "changelog": { - "newUpdate": "MISE A JOUR DU 12 MAI", - "title": "Connexion integree pour App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "MISE A JOUR DU 14 MAI", + "title": "Mentionnez n’importe quel noeud dans AI Chat", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/fr/sdk.json b/packages/common-i18n/src/locales/fr/sdk.json index 15f699e065..2ff3f958b4 100644 --- a/packages/common-i18n/src/locales/fr/sdk.json +++ b/packages/common-i18n/src/locales/fr/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "Le nœud d'automatisation nécessite un test", "automationNodeTestOutdated": "Test du nœud d'automatisation obsolète", "invalidToken": "Jeton non valide", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" champ \"{{fieldName}}\" ne permet pas les valeurs vides, veuillez les remplir complètement avant de soumettre.", "fieldValueDuplicate": "\"{{tableName}}\" champ \"{{fieldName}}\" ne permet pas les valeurs dupliquées, veuillez remplir une valeur unique avant de soumettre.", diff --git a/packages/common-i18n/src/locales/it/common.json b/packages/common-i18n/src/locales/it/common.json index 6919e92fca..8056a7c7e9 100644 --- a/packages/common-i18n/src/locales/it/common.json +++ b/packages/common-i18n/src/locales/it/common.json @@ -1159,9 +1159,9 @@ } }, "changelog": { - "newUpdate": "AGGIORNAMENTO DEL 12 MAGGIO", - "title": "Accesso integrato per App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "AGGIORNAMENTO DEL 14 MAGGIO", + "title": "Menziona qualsiasi nodo in AI Chat", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/it/sdk.json b/packages/common-i18n/src/locales/it/sdk.json index 9ae7b9a883..28513b2b46 100644 --- a/packages/common-i18n/src/locales/it/sdk.json +++ b/packages/common-i18n/src/locales/it/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "Il nodo di automazione ha bisogno di test", "automationNodeTestOutdated": "Test del nodo di automazione obsoleto", "invalidToken": "Token non valido", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" campo \"{{fieldName}}\" non consente valori vuoti, per favore riempilo completamente prima di inviare.", "fieldValueDuplicate": "\"{{tableName}}\" campo \"{{fieldName}}\" non consente valori duplicati, per favore riempilo con un valore unico prima di inviare.", diff --git a/packages/common-i18n/src/locales/ja/common.json b/packages/common-i18n/src/locales/ja/common.json index 9b39808b9f..0f8e31c7e7 100644 --- a/packages/common-i18n/src/locales/ja/common.json +++ b/packages/common-i18n/src/locales/ja/common.json @@ -1161,9 +1161,9 @@ } }, "changelog": { - "newUpdate": "5月12日アップデート", - "title": "App Builder の組み込みログイン", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "5月14日アップデート", + "title": "AI Chat で任意のノードを @ メンション", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/ja/sdk.json b/packages/common-i18n/src/locales/ja/sdk.json index 3d5990e9d2..e60a336754 100644 --- a/packages/common-i18n/src/locales/ja/sdk.json +++ b/packages/common-i18n/src/locales/ja/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "自動化ノードはテストが必要です", "automationNodeTestOutdated": "自動化ノードのテストが古くなっています", "invalidToken": "無効なトークン", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" フィールド \"{{fieldName}}\" は空の値を許可しません。送信する前に完全に入力してください。", "fieldValueDuplicate": "\"{{tableName}}\" フィールド \"{{fieldName}}\" は重複する値を許可しません。送信する前に一意の値を入力してください。", diff --git a/packages/common-i18n/src/locales/ru/common.json b/packages/common-i18n/src/locales/ru/common.json index 6b462fab41..86d24e75d8 100644 --- a/packages/common-i18n/src/locales/ru/common.json +++ b/packages/common-i18n/src/locales/ru/common.json @@ -1117,9 +1117,9 @@ } }, "changelog": { - "newUpdate": "ОБНОВЛЕНИЕ ОТ 12 МАЯ", - "title": "Встроенный вход для App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "ОБНОВЛЕНИЕ ОТ 14 МАЯ", + "title": "Упоминайте любой узел в AI Chat", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/ru/sdk.json b/packages/common-i18n/src/locales/ru/sdk.json index e6f0fa136c..cd671d509b 100644 --- a/packages/common-i18n/src/locales/ru/sdk.json +++ b/packages/common-i18n/src/locales/ru/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "Узел автоматизации требует тестирования", "automationNodeTestOutdated": "Тест узла автоматизации устарел", "invalidToken": "Недействительный токен", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" поле \"{{fieldName}}\" не допускает пустые значения, пожалуйста, заполните его полностью перед отправкой.", "fieldValueDuplicate": "\"{{tableName}}\" поле \"{{fieldName}}\" не допускает дубликаты значений, пожалуйста, заполните уникальное значение перед отправкой.", diff --git a/packages/common-i18n/src/locales/tr/common.json b/packages/common-i18n/src/locales/tr/common.json index 70c4f0a19f..8d3c8b78b7 100644 --- a/packages/common-i18n/src/locales/tr/common.json +++ b/packages/common-i18n/src/locales/tr/common.json @@ -1149,9 +1149,9 @@ } }, "changelog": { - "newUpdate": "12 MAYIS GUNCELLEMESI", - "title": "App Builder icin yerlesik giris", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "14 MAYIS GUNCELLEMESI", + "title": "AI Chat icinde herhangi bir node etiketleyin", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/tr/sdk.json b/packages/common-i18n/src/locales/tr/sdk.json index c1a934a84f..2b926d47cc 100644 --- a/packages/common-i18n/src/locales/tr/sdk.json +++ b/packages/common-i18n/src/locales/tr/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "Otomasyon Düğümü Test Gereksinimi", "automationNodeTestOutdated": "Otomasyon Düğümü Testi Güncel Değil", "invalidToken": "Geçersiz token", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" alanı \"{{fieldName}}\" boş değerlere izin vermiyor, lütfen tamamlayınız ve gönderimden önce doldurun.", "fieldValueDuplicate": "\"{{tableName}}\" alanı \"{{fieldName}}\" tekrarlayan değerlere izin vermiyor, lütfen benzersiz bir değer doldurun ve gönderimden önce doldurun.", diff --git a/packages/common-i18n/src/locales/uk/common.json b/packages/common-i18n/src/locales/uk/common.json index e3b443703a..8c9778707c 100644 --- a/packages/common-i18n/src/locales/uk/common.json +++ b/packages/common-i18n/src/locales/uk/common.json @@ -1138,9 +1138,9 @@ } }, "changelog": { - "newUpdate": "ОНОВЛЕННЯ ВІД 12 ТРАВНЯ", - "title": "Вбудований вхід для App Builder", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "ОНОВЛЕННЯ ВІД 14 ТРАВНЯ", + "title": "Згадуйте будь-який вузол в AI Chat", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/uk/sdk.json b/packages/common-i18n/src/locales/uk/sdk.json index 71f77662fd..0a37ca510e 100644 --- a/packages/common-i18n/src/locales/uk/sdk.json +++ b/packages/common-i18n/src/locales/uk/sdk.json @@ -913,6 +913,31 @@ "automationNodeNeedTest": "Вузол автоматизації потребує тестування", "automationNodeTestOutdated": "Тест вузла автоматизації застарів", "invalidToken": "Недійсний токен", + "limit": { + "fieldOptionsMaxBytes": "Field options are too large. Maximum is {{max}} bytes.", + "selectChoicesMax": "This select field has too many choices. Maximum is {{max}} choices.", + "selectChoiceNameMaxLength": "Select choice names are too long. Maximum is {{max}} characters.", + "selectDefaultValuesMax": "This select field has too many default values. Maximum is {{max}} values.", + "cellValueMaxBytes": "Cell value is too large. Maximum is {{max}} bytes.", + "recordFieldsMaxBytes": "Record payload is too large. Maximum is {{max}} bytes.", + "recordsPerMutationMax": "This operation changes too many records. Maximum is {{max}} records at a time.", + "computedCellValueMaxBytes": "Computed cell value is too large. Maximum is {{max}} bytes.", + "formulaMaxLength": "Formula is too long. Maximum is {{max}} characters.", + "tablesPerBaseMax": "This base has reached the table limit. Maximum is {{max}} tables.", + "fieldsPerTableMax": "This table has too many fields. Maximum is {{max}} fields.", + "rowsPerTableMax": "This table has too many records. Maximum is {{max}} records.", + "viewsPerTableMax": "This table has reached the view limit. Maximum is {{max}} views.", + "createTableFieldsMax": "The new table has too many fields. Maximum is {{max}} fields.", + "createTableViewsMax": "The new table has too many views. Maximum is {{max}} views.", + "createTableRecordsMax": "The new table has too many records. Maximum is {{max}} records.", + "viewFilterItemsMax": "This view has too many filter conditions. Maximum is {{max}} conditions.", + "viewFilterDepthMax": "This view filter is too deeply nested. Maximum depth is {{max}}.", + "viewSortItemsMax": "This view has too many sort rules. Maximum is {{max}} rules.", + "viewGroupItemsMax": "This view has too many group rules. Maximum is {{max}} rules.", + "viewOptionsMaxBytes": "View options are too large. Maximum is {{max}} bytes.", + "nameMaxLength": "Name is too long. Maximum is {{max}} characters.", + "descriptionMaxLength": "Description is too long. Maximum is {{max}} characters." + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" поле \"{{fieldName}}\" не допускає пусті значення, будь ласка, заповніть його повністю перед відправкою.", "fieldValueDuplicate": "\"{{tableName}}\" поле \"{{fieldName}}\" не допускає дублікатів значень, будь ласка, заповніть унікальне значення перед відправкою.", diff --git a/packages/common-i18n/src/locales/zh/common.json b/packages/common-i18n/src/locales/zh/common.json index 9d37d357dd..7587e6c0be 100644 --- a/packages/common-i18n/src/locales/zh/common.json +++ b/packages/common-i18n/src/locales/zh/common.json @@ -1499,9 +1499,9 @@ } }, "changelog": { - "newUpdate": "5 月 12 日更新", - "title": "App Builder 支持内置登录", - "url": "https://help.teable.ai/en/changelog#may-12-2026", - "id": "changelog-2026-05-12-built-in-login-for-app-builder" + "newUpdate": "5 月 14 日更新", + "title": "在 AI Chat 中 @ 任意节点", + "url": "https://help.teable.ai/en/changelog#may-14-2026", + "id": "changelog-2026-05-14-any-node-in-ai-chat" } } diff --git a/packages/common-i18n/src/locales/zh/sdk.json b/packages/common-i18n/src/locales/zh/sdk.json index b796d1a100..53f7ee1c6d 100644 --- a/packages/common-i18n/src/locales/zh/sdk.json +++ b/packages/common-i18n/src/locales/zh/sdk.json @@ -930,6 +930,31 @@ "automationNodeNeedTest": "自动化节点需要测试", "automationNodeTestOutdated": "自动化节点测试已过期", "invalidToken": "无效的令牌", + "limit": { + "fieldOptionsMaxBytes": "字段选项过大,最大允许 {{max}} 字节。", + "selectChoicesMax": "该选择字段的选项过多,最多允许 {{max}} 个选项。", + "selectChoiceNameMaxLength": "选择项名称过长,最多允许 {{max}} 个字符。", + "selectDefaultValuesMax": "该选择字段的默认值过多,最多允许 {{max}} 个默认值。", + "cellValueMaxBytes": "单元格值过大,最大允许 {{max}} 字节。", + "recordFieldsMaxBytes": "记录内容过大,最大允许 {{max}} 字节。", + "recordsPerMutationMax": "本次操作的记录数过多,最多一次处理 {{max}} 条记录。", + "computedCellValueMaxBytes": "计算单元格值过大,最大允许 {{max}} 字节。", + "formulaMaxLength": "公式过长,最多允许 {{max}} 个字符。", + "tablesPerBaseMax": "该 base 已达到表数量限制,最多允许 {{max}} 张表。", + "fieldsPerTableMax": "该表字段过多,最多允许 {{max}} 个字段。", + "rowsPerTableMax": "该表记录过多,最多允许 {{max}} 条记录。", + "viewsPerTableMax": "该表已达到视图数量限制,最多允许 {{max}} 个视图。", + "createTableFieldsMax": "新建表字段过多,最多允许 {{max}} 个字段。", + "createTableViewsMax": "新建表视图过多,最多允许 {{max}} 个视图。", + "createTableRecordsMax": "新建表记录过多,最多允许 {{max}} 条记录。", + "viewFilterItemsMax": "该视图的筛选条件过多,最多允许 {{max}} 个条件。", + "viewFilterDepthMax": "该视图的筛选嵌套过深,最多允许 {{max}} 层。", + "viewSortItemsMax": "该视图的排序规则过多,最多允许 {{max}} 条规则。", + "viewGroupItemsMax": "该视图的分组规则过多,最多允许 {{max}} 条规则。", + "viewOptionsMaxBytes": "视图配置过大,最大允许 {{max}} 字节。", + "nameMaxLength": "名称过长,最多允许 {{max}} 个字符。", + "descriptionMaxLength": "描述过长,最多允许 {{max}} 个字符。" + }, "custom": { "fieldValueNotNull": "\"{{tableName}}\" 中的 \"{{fieldName}}\" 字段不允许空值,请填写完整再提交", "fieldValueDuplicate": "\"{{tableName}}\" 中的 \"{{fieldName}}\" 字段不允许重复值,请填写唯一值再提交", diff --git a/packages/common-i18n/src/locales/zh/space.json b/packages/common-i18n/src/locales/zh/space.json index 5832befadf..6bbdef545e 100644 --- a/packages/common-i18n/src/locales/zh/space.json +++ b/packages/common-i18n/src/locales/zh/space.json @@ -232,5 +232,66 @@ "redeploy": "重新部署", "unnamedApp": "未命名应用" } + }, + "dataDb": { + "create": { + "title": "数据数据库", + "description": "选择此空间的数据库结构和记录存储位置。", + "defaultOption": "使用 Teable 默认数据库", + "defaultHint": "适用于大多数空间。", + "byodbOption": "使用我的 PostgreSQL 数据库", + "byodbHint": "在客户自管数据库中使用 Teable 内部 Schema 初始化此空间。", + "urlLabel": "PostgreSQL 连接 URL", + "sslHint": "请使用 PostgreSQL 服务要求的 SSL 参数。测试后不会显示密码。", + "testConnection": "测试连接", + "testing": "测试中...", + "retestRequired": "URL 修改后需要重新测试。", + "databaseLabel": "数据库", + "databasePlaceholder": "选择数据库", + "databaseHint": "选择该空间要使用的 PostgreSQL 数据库,然后重新测试连接。", + "preflightPassed": "连接检查通过", + "preflightFailed": "连接检查失败", + "missingCapabilities": "缺少权限", + "testFailed": "数据库连接测试失败。", + "errors": { + "INVALID_DATABASE_URL": { + "message": "PostgreSQL 连接 URL 无效。" + }, + "PRIVATE_NETWORK_BLOCKED": { + "message": "默认禁止连接内网数据库主机。", + "remediation": "仅在可信的自托管部署中开启 TEABLE_SSRF_PROTECTION_DISABLED=true。" + }, + "CONNECTION_FAILED": { + "message": "无法连接到数据库。", + "remediation": "请检查主机、端口、数据库名、用户名、密码和 SSL 参数。" + }, + "IPV6_NETWORK_UNREACHABLE": { + "message": "数据库主机解析到了 IPv6 地址,但当前 Teable 部署无法访问 IPv6 网络。", + "remediation": "请使用可通过 IPv4 访问的数据库地址或连接池地址,或为当前 Teable 部署开启 IPv6 出站网络。" + }, + "PRIVILEGE_CHECK_FAILED": { + "message": "数据库权限检查失败。" + }, + "DDL_PRIVILEGE_CHECK_FAILED": { + "message": "当前账号缺少初始化 Teable 数据库所需的 DDL 权限。", + "remediation": "请为该账号授予创建 Schema、表、函数和触发器所需的权限。" + }, + "NON_EMPTY_UNKNOWN_DATABASE": { + "message": "Teable 内部 Schema 中已有非 Teable 管理的对象。", + "remediation": "请使用没有冲突内部 Schema 的数据库,或清理该 Schema 中的未知对象。" + }, + "INCOMPATIBLE_TEABLE_DATABASE": { + "message": "此数据库包含不完整或不兼容的 Teable 数据对象。", + "remediation": "请使用空数据库,或使用兼容的 Teable 数据数据库迁移/接管流程。" + } + } + }, + "fields": { + "host": "主机", + "database": "数据库", + "internalSchema": "内部 Schema", + "version": "版本", + "classification": "分类" + } } } diff --git a/packages/common-i18n/src/locales/zh/table.json b/packages/common-i18n/src/locales/zh/table.json index 54241df99a..e5f0362da2 100644 --- a/packages/common-i18n/src/locales/zh/table.json +++ b/packages/common-i18n/src/locales/zh/table.json @@ -1243,6 +1243,10 @@ "contextTipNewChat": "讨论新话题建议开启新对话,可显著减少 Credit 消耗。", "contextTipMemory": "对 AI 说「记住 XXX」可存入长期记忆,跨对话保留。" }, + "contextCompaction": { + "auto": "上下文已自动压缩", + "manual": "上下文已压缩" + }, "taskProgress": { "title": "任务进度" }, diff --git a/packages/core/src/formula/visitor.spec.ts b/packages/core/src/formula/visitor.spec.ts index 27c8dc0a9c..ef1af1d6ed 100644 --- a/packages/core/src/formula/visitor.spec.ts +++ b/packages/core/src/formula/visitor.spec.ts @@ -241,6 +241,21 @@ describe('EvalVisitor', () => { expect(evalFormula('sum({fldNumber}, 1, 2, 3)', fieldContext, record)).toBe(14); }); + it('matches numeric field values stored as strings in SWITCH cases', () => { + const numericStringRecord: IRecord = { + ...record, + fields: { + ...record.fields, + fldNumber: '30', + }, + }; + + expect(evalFormula('IF({fldNumber}=30,20,0)', fieldContext, numericStringRecord)).toBe(20); + expect( + evalFormula('SWITCH({fldNumber},30,20,45,30,60,40,0)', fieldContext, numericStringRecord) + ).toBe(20); + }); + it('evaluates TEXTBEFORE and TEXTSPLIT function calls', () => { expect(evalFormula('TEXTBEFORE("20, 04, 79", ",")', fieldContext, record)).toBe('20'); expect(evalFormula('TEXTSPLIT("20, 04, 79", ",")', fieldContext, record)).toEqual([ diff --git a/packages/core/src/formula/visitor.ts b/packages/core/src/formula/visitor.ts index 7b592f9730..1a636901ed 100644 --- a/packages/core/src/formula/visitor.ts +++ b/packages/core/src/formula/visitor.ts @@ -436,6 +436,15 @@ export class EvalVisitor private createTypedValueByField(field: FieldCore) { let value: any = this.record ? this.record.fields[field.id] : null; + if (field.cellValueType === CellValueType.Number) { + return new TypedValue( + this.normalizeNumberCellValue(value, field.isMultipleCellValue), + field.cellValueType, + field.isMultipleCellValue, + field + ); + } + if ( value == null || ![CellValueType.String, CellValueType.DateTime].includes(field.cellValueType) @@ -454,6 +463,21 @@ export class EvalVisitor return new TypedValue(value, field.cellValueType, field.isMultipleCellValue, field); } + private normalizeNumberCellValue(value: any, isMultiple?: boolean) { + const normalize = (cellValue: any) => { + if (cellValue == null || cellValue === '') { + return null; + } + return typeof cellValue === 'number' ? cellValue : Number(cellValue); + }; + + if (isMultiple) { + return Array.isArray(value) ? value.map(normalize) : value; + } + + return normalize(value); + } + visitFieldReferenceCurly(ctx: FieldReferenceCurlyContext) { const fieldId = extractFieldReferenceId(ctx); if (!fieldId) { diff --git a/packages/core/src/models/notification/notification.enum.ts b/packages/core/src/models/notification/notification.enum.ts index d9c4dfc4aa..d7603b17f3 100644 --- a/packages/core/src/models/notification/notification.enum.ts +++ b/packages/core/src/models/notification/notification.enum.ts @@ -4,6 +4,7 @@ export enum NotificationTypeEnum { CollaboratorMultiRowTag = 'collaboratorMultiRowTag', Comment = 'comment', ExportBase = 'exportBase', + AdminNotice = 'adminNotice', } export enum NotificationStatesEnum { diff --git a/packages/db-data-prisma/package.json b/packages/db-data-prisma/package.json index da1fd57ca2..4ea827f858 100644 --- a/packages/db-data-prisma/package.json +++ b/packages/db-data-prisma/package.json @@ -28,7 +28,7 @@ "lint": "eslint . --ext .ts,.tsx,.js,.jsx,.cjs,.mjs --cache --cache-location ../../.cache/eslint/db-data-prisma.eslintcache", "postinstall": "pnpm prisma-generate-ci", "prisma-generate": "node ./scripts/run-prisma-command.mjs generate --schema ./prisma/schema.prisma", - "prisma-generate-ci": "cross-env PRISMA_DATA_DATABASE_URL=\"postgresql://teable:teable@127.0.0.1:5432/teable-data?schema=public\" prisma generate --schema ./prisma/schema.prisma", + "prisma-generate-ci": "cross-env PRISMA_DATABASE_URL=\"postgresql://teable:teable@127.0.0.1:5432/teable?schema=public\" prisma generate --schema ./prisma/schema.prisma", "prisma-migrate": "dotenv-flow -p ../../apps/nextjs-app -- node ./scripts/run-prisma-command.mjs migrate", "prisma-migrate-reset": "dotenv-flow -p ../../apps/nextjs-app -- node ./scripts/run-prisma-command.mjs migrate reset --schema ./prisma/schema.prisma", "prisma-migrate-status": "dotenv-flow -p ../../apps/nextjs-app -- node ./scripts/run-prisma-command.mjs migrate status --schema ./prisma/schema.prisma", diff --git a/packages/db-data-prisma/prisma/migrations/20260421000000_init_data_db_baseline/migration.sql b/packages/db-data-prisma/prisma/migrations/20260421000000_init_data_db_baseline/migration.sql index b1852407f1..73668697b5 100644 --- a/packages/db-data-prisma/prisma/migrations/20260421000000_init_data_db_baseline/migration.sql +++ b/packages/db-data-prisma/prisma/migrations/20260421000000_init_data_db_baseline/migration.sql @@ -190,6 +190,7 @@ BEGIN SELECT 1 FROM pg_constraint WHERE conname = 'computed_update_outbox_seed_task_id_fkey' + AND connamespace = current_schema()::regnamespace ) THEN ALTER TABLE "computed_update_outbox_seed" ADD CONSTRAINT "computed_update_outbox_seed_task_id_fkey" @@ -206,7 +207,7 @@ $$; -- Dynamic business tables still install their own "__teable_undo_capture" trigger at runtime, -- but the shared log table and trigger function are part of the data DB baseline. -CREATE TABLE IF NOT EXISTS "public"."__undo_log" ( +CREATE TABLE IF NOT EXISTS "__undo_log" ( "id" BIGSERIAL PRIMARY KEY, "batch_id" TEXT NOT NULL, "operation" TEXT NOT NULL, @@ -218,17 +219,17 @@ CREATE TABLE IF NOT EXISTS "public"."__undo_log" ( ); CREATE INDEX IF NOT EXISTS "__undo_log_batch_id_idx" -ON "public"."__undo_log" ("batch_id"); +ON "__undo_log" ("batch_id"); -ALTER TABLE "public"."__undo_log" SET ( +ALTER TABLE "__undo_log" SET ( autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 100 ); -ALTER SEQUENCE IF EXISTS "public"."__undo_log_id_seq" +ALTER SEQUENCE IF EXISTS "__undo_log_id_seq" CACHE 100; -CREATE OR REPLACE FUNCTION "public"."__teable_capture_undo_row"() +CREATE OR REPLACE FUNCTION "__teable_capture_undo_row"() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -256,7 +257,7 @@ BEGIN RETURN NULL; END IF; - INSERT INTO "public"."__undo_log" ( + INSERT INTO "__undo_log" ( "batch_id", "operation", "table_name", diff --git a/packages/db-data-prisma/prisma/schema.prisma b/packages/db-data-prisma/prisma/schema.prisma index 9a043d0155..5232d7ce7d 100644 --- a/packages/db-data-prisma/prisma/schema.prisma +++ b/packages/db-data-prisma/prisma/schema.prisma @@ -5,7 +5,7 @@ generator client { datasource db { provider = "postgresql" - url = env("PRISMA_DATA_DATABASE_URL") + url = env("PRISMA_DATABASE_URL") } model ComputedUpdateOutbox { diff --git a/packages/db-data-prisma/scripts/run-prisma-command.mjs b/packages/db-data-prisma/scripts/run-prisma-command.mjs index 119caecbb7..67331c8363 100644 --- a/packages/db-data-prisma/scripts/run-prisma-command.mjs +++ b/packages/db-data-prisma/scripts/run-prisma-command.mjs @@ -3,14 +3,13 @@ import { spawnSync } from 'node:child_process'; const dataDatabaseUrl = - process.env.PRISMA_DATA_DATABASE_URL ?? process.env.PRISMA_META_DATABASE_URL ?? process.env.PRISMA_DATABASE_URL ?? process.env.DATABASE_URL; if (!dataDatabaseUrl) { console.error( - 'Missing data database url (PRISMA_DATA_DATABASE_URL, PRISMA_META_DATABASE_URL, PRISMA_DATABASE_URL, DATABASE_URL)' + 'Missing data database url (PRISMA_META_DATABASE_URL, PRISMA_DATABASE_URL, DATABASE_URL)' ); process.exit(1); } @@ -19,7 +18,7 @@ const result = spawnSync('pnpm', ['prisma', ...process.argv.slice(2)], { stdio: 'inherit', env: { ...process.env, - PRISMA_DATA_DATABASE_URL: dataDatabaseUrl, + PRISMA_DATABASE_URL: dataDatabaseUrl, }, shell: process.platform === 'win32', }); diff --git a/packages/db-data-prisma/src/database-url.ts b/packages/db-data-prisma/src/database-url.ts index 6df4bd9a64..e3188db2ac 100644 --- a/packages/db-data-prisma/src/database-url.ts +++ b/packages/db-data-prisma/src/database-url.ts @@ -1,12 +1,5 @@ const META_DATABASE_ENV_KEYS = ['PRISMA_META_DATABASE_URL', 'PRISMA_DATABASE_URL', 'DATABASE_URL']; -const DATA_DATABASE_ENV_KEYS = [ - 'PRISMA_DATA_DATABASE_URL', - 'PRISMA_META_DATABASE_URL', - 'PRISMA_DATABASE_URL', - 'DATABASE_URL', -]; - export const getMetaDatabaseUrl = (env: NodeJS.ProcessEnv = process.env): string => { for (const key of META_DATABASE_ENV_KEYS) { const value = env[key]; @@ -18,16 +11,8 @@ export const getMetaDatabaseUrl = (env: NodeJS.ProcessEnv = process.env): string throw new Error(`Missing meta database url (${META_DATABASE_ENV_KEYS.join(', ')})`); }; -export const getDataDatabaseUrl = (env: NodeJS.ProcessEnv = process.env): string => { - for (const key of DATA_DATABASE_ENV_KEYS) { - const value = env[key]; - if (value) { - return value; - } - } - - throw new Error(`Missing data database url (${DATA_DATABASE_ENV_KEYS.join(', ')})`); -}; +export const getDataDatabaseUrl = (env: NodeJS.ProcessEnv = process.env): string => + getMetaDatabaseUrl(env); export const isSharedMetaDataDatabase = (env: NodeJS.ProcessEnv = process.env): boolean => getMetaDatabaseUrl(env) === getDataDatabaseUrl(env); diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20260507075100_add_data_db_internal_schema/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20260507075100_add_data_db_internal_schema/migration.sql new file mode 100644 index 0000000000..0aa3b89105 --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20260507075100_add_data_db_internal_schema/migration.sql @@ -0,0 +1,13 @@ +ALTER TABLE "data_db_connection" +ADD COLUMN IF NOT EXISTS "internal_schema" TEXT; + +UPDATE "data_db_connection" +SET "internal_schema" = 'teable_' || SUBSTRING( + MD5(COALESCE("display_host", '') || '/' || COALESCE("display_database", '')), + 1, + 16 +) +WHERE "internal_schema" IS NULL; + +ALTER TABLE "data_db_connection" +ALTER COLUMN "internal_schema" SET NOT NULL; diff --git a/packages/db-main-prisma/prisma/postgres/schema.prisma b/packages/db-main-prisma/prisma/postgres/schema.prisma index 25a67b5f28..7258054645 100644 --- a/packages/db-main-prisma/prisma/postgres/schema.prisma +++ b/packages/db-main-prisma/prisma/postgres/schema.prisma @@ -66,6 +66,7 @@ model DataDbConnection { urlFingerprint String @unique @map("url_fingerprint") displayHost String? @map("display_host") displayDatabase String? @map("display_database") + internalSchema String @map("internal_schema") status DataDbConnectionStatus @default(pending) schemaVersion String? @map("schema_version") capabilities Json? diff --git a/packages/db-main-prisma/src/database-url.ts b/packages/db-main-prisma/src/database-url.ts index 0307887cb9..d9afe1da76 100644 --- a/packages/db-main-prisma/src/database-url.ts +++ b/packages/db-main-prisma/src/database-url.ts @@ -1,24 +1,17 @@ export type IDatabaseTarget = 'meta' | 'data'; const metaDatabaseEnvKeys = ['PRISMA_META_DATABASE_URL', 'PRISMA_DATABASE_URL', 'DATABASE_URL']; -const dataDatabaseEnvKeys = [ - 'PRISMA_DATA_DATABASE_URL', - 'PRISMA_META_DATABASE_URL', - 'PRISMA_DATABASE_URL', - 'DATABASE_URL', -]; export const getDatabaseUrl = ( target: IDatabaseTarget, env: NodeJS.ProcessEnv = process.env ): string => { - const keys = target === 'data' ? dataDatabaseEnvKeys : metaDatabaseEnvKeys; - for (const key of keys) { + for (const key of metaDatabaseEnvKeys) { const value = env[key]; if (value) { return value; } } - throw new Error(`Missing ${target} database url (${keys.join(', ')})`); + throw new Error(`Missing ${target} database url (${metaDatabaseEnvKeys.join(', ')})`); }; diff --git a/packages/openapi/src/admin/setting/get.ts b/packages/openapi/src/admin/setting/get.ts index 56c2c96b79..f107fee141 100644 --- a/packages/openapi/src/admin/setting/get.ts +++ b/packages/openapi/src/admin/setting/get.ts @@ -3,13 +3,7 @@ import { z } from 'zod'; import { axios } from '../../axios'; import { mailTransportConfigSchema } from '../../mail'; import { registerRoute } from '../../utils'; -import { - aiConfigVoSchema, - appConfigSchema, - canaryConfigSchema, - imConfigSchema, - sandboxAgentConfigSchema, -} from './update'; +import { aiConfigVoSchema, appConfigSchema, canaryConfigSchema, imConfigSchema } from './update'; export const settingVoSchema = z.object({ instanceId: z.string(), @@ -27,7 +21,6 @@ export const settingVoSchema = z.object({ automationMailTransportConfig: mailTransportConfigSchema.nullable().optional(), appConfig: appConfigSchema.nullable().optional(), canaryConfig: canaryConfigSchema.nullable().optional(), - sandboxAgentConfig: sandboxAgentConfigSchema.nullable().optional(), trashCleanupEnabledAt: z.string().nullable().optional(), imConfig: imConfigSchema.nullable().optional(), createdTime: z.string().optional(), diff --git a/packages/openapi/src/admin/setting/key.enum.ts b/packages/openapi/src/admin/setting/key.enum.ts index c9f0623a8e..4780306e2f 100644 --- a/packages/openapi/src/admin/setting/key.enum.ts +++ b/packages/openapi/src/admin/setting/key.enum.ts @@ -15,6 +15,5 @@ export enum SettingKey { ENABLE_CREDIT_REWARD = 'enableCreditReward', CANARY_CONFIG = 'canaryConfig', TRASH_CLEANUP_ENABLED_AT = 'trashCleanupEnabledAt', - SANDBOX_AGENT_CONFIG = 'sandboxAgentConfig', IM_CONFIG = 'imConfig', } diff --git a/packages/openapi/src/admin/setting/update.ts b/packages/openapi/src/admin/setting/update.ts index ec82027d8a..edc15b0ff5 100644 --- a/packages/openapi/src/admin/setting/update.ts +++ b/packages/openapi/src/admin/setting/update.ts @@ -1,6 +1,5 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; import { z } from 'zod'; -import { DEFAULT_EFFORT_LEVEL, effortLevelSchema } from '../../ai/effort'; import { DEFAULT_REALTIME_TRANSCRIPTION_MODEL, realtimeTranscriptionModelSchema, @@ -309,30 +308,6 @@ export type ICanaryConfig = z.infer; // Header name for canary release override export const X_CANARY_HEADER = 'x-teable-canary'; -export const sandboxAgentModelSchema = z.object({ - id: z.string(), - name: z.string(), -}); - -export type ISandboxAgentModel = z.infer; - -export const sandboxAgentConfigSchema = z.object({ - spaceIds: z.array(z.string()).default([]), - forceAll: z.boolean().optional(), - defaultAgent: z.enum(['claude']).default('claude').optional(), - models: z.record(z.string(), z.array(sandboxAgentModelSchema)).optional().default({}), - maxDuration: z.number().min(1).max(1440).default(300).optional(), - maxIdleTime: z.number().min(60).max(7200).default(1800).optional(), - maxConcurrentChats: z.number().min(1).max(20).default(3).optional(), - activeSnapshotId: z.string().optional(), - activeAppBuilderSnapshotId: z.string().optional(), - defaultEffort: effortLevelSchema.default(DEFAULT_EFFORT_LEVEL).optional(), -}); - -export type ISandboxAgentConfig = z.infer; - -export const X_SANDBOX_AGENT_HEADER = 'x-teable-sandbox-agent'; - export const imTelegramConfigSchema = z.object({ botToken: z.string(), botUsername: z.string(), @@ -362,7 +337,6 @@ export const updateSettingRoSchema = z.object({ appConfig: appConfigSchema.optional(), brandName: z.string().optional(), canaryConfig: canaryConfigSchema.optional(), - sandboxAgentConfig: sandboxAgentConfigSchema.optional(), notifyMailTransportConfig: mailTransportConfigSchema.nullable().optional(), automationMailTransportConfig: mailTransportConfigSchema.nullable().optional(), imConfig: imConfigSchema.nullable().optional(), diff --git a/packages/openapi/src/base/import.ts b/packages/openapi/src/base/import.ts index 279e422ed7..e30ad359bd 100644 --- a/packages/openapi/src/base/import.ts +++ b/packages/openapi/src/base/import.ts @@ -126,7 +126,7 @@ const handleSSEEvent = ( case 'done': return event.data; case 'error': - throw new Error(event.message); + throw new Error(event.message.trim() || 'Import base failed'); } }; diff --git a/packages/openapi/src/notification/index.ts b/packages/openapi/src/notification/index.ts index c5a81184a9..355f8d55b4 100644 --- a/packages/openapi/src/notification/index.ts +++ b/packages/openapi/src/notification/index.ts @@ -2,3 +2,4 @@ export * from './get-list'; export * from './update-status'; export * from './read-all'; export * from './unread-count'; +export * from './send-admin-notification'; diff --git a/packages/openapi/src/notification/send-admin-notification.ts b/packages/openapi/src/notification/send-admin-notification.ts new file mode 100644 index 0000000000..dd6ea1f222 --- /dev/null +++ b/packages/openapi/src/notification/send-admin-notification.ts @@ -0,0 +1,26 @@ +import { NotificationSeverityEnum } from '@teable/core'; +import { axios } from '../axios'; +import { z } from '../zod'; + +export const ADMIN_SEND_NOTIFICATION = '/admin/notification'; + +export const adminSendNotificationRoSchema = z.object({ + message: z.string().min(1).max(5000), + severity: z.enum(NotificationSeverityEnum).optional().default(NotificationSeverityEnum.Info), + userIds: z.array(z.string()).max(500).optional(), + emails: z.array(z.string().email()).max(500).optional(), +}); + +export type IAdminSendNotificationRo = z.infer; + +export const adminSendNotificationVoSchema = z.object({ + sentCount: z.number(), + invalidEmails: z.array(z.string()).optional(), + invalidUserIds: z.array(z.string()).optional(), +}); + +export type IAdminSendNotificationVo = z.infer; + +export const sendAdminNotification = async (ro: IAdminSendNotificationRo) => { + return axios.post(ADMIN_SEND_NOTIFICATION, ro); +}; diff --git a/packages/openapi/src/space/create.ts b/packages/openapi/src/space/create.ts index 292b0c532c..4d9cfa808a 100644 --- a/packages/openapi/src/space/create.ts +++ b/packages/openapi/src/space/create.ts @@ -2,7 +2,7 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; import { axios } from '../axios'; import { registerRoute } from '../utils'; import { z } from '../zod'; -import { dataDbTargetModeSchema } from './data-db'; +import { dataDbInternalSchemaSchema, dataDbTargetModeSchema } from './data-db'; export const CREATE_SPACE = '/space'; @@ -13,6 +13,7 @@ export const createSpaceRoSchema = z.object({ mode: z.enum(['default', 'byodb']), url: z.string().min(1).optional(), targetMode: dataDbTargetModeSchema.optional().default('initialize-empty'), + internalSchema: dataDbInternalSchemaSchema, preflightToken: z.string().optional(), }) .optional(), diff --git a/packages/openapi/src/space/data-db.spec.ts b/packages/openapi/src/space/data-db.spec.ts index c5ddfcf223..5572a41987 100644 --- a/packages/openapi/src/space/data-db.spec.ts +++ b/packages/openapi/src/space/data-db.spec.ts @@ -10,6 +10,7 @@ describe('space data DB schemas', () => { it('accepts a BYODB preflight request', () => { const result = dataDbPreflightRoSchema.safeParse({ url: 'postgresql://teable:secret@example.com:5432/teable_data', + internalSchema: 'teable_meta_test', }); expect(result.success).toBe(true); @@ -48,8 +49,11 @@ describe('space data DB schemas', () => { urlFingerprint: 'dbfp_123', displayHost: 'example.com:5432', displayDatabase: 'teable_data', + internalSchema: 'teable_meta_test', serverVersion: '14.12', classification: 'empty', + availableDatabases: ['postgres', 'teable_data'], + requiresDatabaseSelection: false, capabilities: { createSchema: true, createTable: true, @@ -63,6 +67,8 @@ describe('space data DB schemas', () => { }); expect(result.maskedUrl).not.toContain('secret'); + expect(result.availableDatabases).toEqual(['postgres', 'teable_data']); + expect(result.requiresDatabaseSelection).toBe(false); }); it('accepts a default space data DB summary', () => { @@ -76,4 +82,22 @@ describe('space data DB schemas', () => { state: 'ready', }); }); + + it('accepts a BYODB summary with schema version metadata', () => { + expect( + dataDbConnectionSummaryVoSchema.parse({ + mode: 'byodb', + state: 'ready', + provider: 'postgres', + displayHost: 'example.com:5432', + displayDatabase: 'teable_data', + internalSchema: 'teable_meta_test', + schemaVersion: '20260421000000_init_data_db_baseline', + lastValidatedAt: '2026-05-06T00:00:00.000Z', + }) + ).toMatchObject({ + mode: 'byodb', + schemaVersion: '20260421000000_init_data_db_baseline', + }); + }); }); diff --git a/packages/openapi/src/space/data-db.ts b/packages/openapi/src/space/data-db.ts index 869c757009..bf43975b0e 100644 --- a/packages/openapi/src/space/data-db.ts +++ b/packages/openapi/src/space/data-db.ts @@ -5,6 +5,10 @@ import { z } from '../zod'; export const SPACE_DATA_DB_PREFLIGHT = '/space/data-db/preflight'; export const GET_SPACE_DATA_DB = '/space/{spaceId}/data-db'; +export const UPDATE_SPACE_DATA_DB = '/space/{spaceId}/data-db'; +export const RETEST_SPACE_DATA_DB = '/space/{spaceId}/data-db/retest'; +export const RETRY_SPACE_DATA_DB_MIGRATION = '/space/{spaceId}/data-db/retry'; +const refreshedDataDbSummaryDescription = 'Returns the refreshed data database binding summary.'; export const dataDbModeSchema = z.enum(['default', 'byodb']); export const dataDbTargetModeSchema = z.enum([ @@ -36,6 +40,10 @@ export const dataDbCapabilitiesSchema = z.object({ grantPrivileges: z.boolean(), inspectActivity: z.boolean(), }); +export const dataDbInternalSchemaSchema = z + .string() + .regex(/^[a-z_]\w*$/i) + .optional(); export const dataDbPreflightErrorSchema = z.object({ code: z.string(), @@ -46,6 +54,7 @@ export const dataDbPreflightErrorSchema = z.object({ export const dataDbPreflightRoSchema = z.object({ url: z.string().min(1), targetMode: dataDbTargetModeSchema.optional().default('initialize-empty'), + internalSchema: dataDbInternalSchemaSchema, }); export type IDataDbPreflightRo = z.infer; @@ -57,8 +66,11 @@ export const dataDbPreflightVoSchema = z.object({ urlFingerprint: z.string().optional(), displayHost: z.string().optional(), displayDatabase: z.string().optional(), + internalSchema: z.string().optional(), serverVersion: z.string().optional(), classification: dataDbClassificationSchema, + availableDatabases: z.array(z.string()).optional(), + requiresDatabaseSelection: z.boolean().optional(), capabilities: dataDbCapabilitiesSchema, errors: z.array(dataDbPreflightErrorSchema), }); @@ -71,6 +83,8 @@ export const dataDbConnectionSummaryVoSchema = z.object({ provider: z.literal('postgres').optional(), displayHost: z.string().optional(), displayDatabase: z.string().optional(), + internalSchema: z.string().optional(), + schemaVersion: z.string().nullable().optional(), lastValidatedAt: z.string().optional(), lastError: z.string().optional(), capabilities: dataDbCapabilitiesSchema.optional(), @@ -126,6 +140,80 @@ export const GetSpaceDataDbRoute: RouteConfig = registerRoute({ tags: ['space'], }); +export const UpdateSpaceDataDbRoute: RouteConfig = registerRoute({ + method: 'patch', + path: UPDATE_SPACE_DATA_DB, + description: + 'Update PostgreSQL credentials or connection parameters for the existing BYODB database', + request: { + params: z.object({ + spaceId: z.string(), + }), + body: { + content: { + 'application/json': { + schema: dataDbPreflightRoSchema, + }, + }, + }, + }, + responses: { + 200: { + description: refreshedDataDbSummaryDescription, + content: { + 'application/json': { + schema: dataDbConnectionSummaryVoSchema, + }, + }, + }, + }, + tags: ['space'], +}); + +export const RetestSpaceDataDbRoute: RouteConfig = registerRoute({ + method: 'post', + path: RETEST_SPACE_DATA_DB, + description: 'Retest the PostgreSQL data database connection for a BYODB space', + request: { + params: z.object({ + spaceId: z.string(), + }), + }, + responses: { + 200: { + description: refreshedDataDbSummaryDescription, + content: { + 'application/json': { + schema: dataDbConnectionSummaryVoSchema, + }, + }, + }, + }, + tags: ['space'], +}); + +export const RetrySpaceDataDbMigrationRoute: RouteConfig = registerRoute({ + method: 'post', + path: RETRY_SPACE_DATA_DB_MIGRATION, + description: 'Retry pending PostgreSQL data database migrations for a BYODB space', + request: { + params: z.object({ + spaceId: z.string(), + }), + }, + responses: { + 200: { + description: refreshedDataDbSummaryDescription, + content: { + 'application/json': { + schema: dataDbConnectionSummaryVoSchema, + }, + }, + }, + }, + tags: ['space'], +}); + export const preflightSpaceDataDb = async (data: IDataDbPreflightRo) => { return axios.post(SPACE_DATA_DB_PREFLIGHT, data); }; @@ -133,3 +221,20 @@ export const preflightSpaceDataDb = async (data: IDataDbPreflightRo) => { export const getSpaceDataDb = async (spaceId: string) => { return axios.get(urlBuilder(GET_SPACE_DATA_DB, { spaceId })); }; + +export const updateSpaceDataDb = async (spaceId: string, data: IDataDbPreflightRo) => { + return axios.patch( + urlBuilder(UPDATE_SPACE_DATA_DB, { spaceId }), + data + ); +}; + +export const retestSpaceDataDb = async (spaceId: string) => { + return axios.post(urlBuilder(RETEST_SPACE_DATA_DB, { spaceId })); +}; + +export const retrySpaceDataDbMigration = async (spaceId: string) => { + return axios.post( + urlBuilder(RETRY_SPACE_DATA_DB_MIGRATION, { spaceId }) + ); +}; diff --git a/packages/openapi/src/trash/restore.ts b/packages/openapi/src/trash/restore.ts index 4611a2b899..d3a7f57b23 100644 --- a/packages/openapi/src/trash/restore.ts +++ b/packages/openapi/src/trash/restore.ts @@ -13,6 +13,9 @@ export const RestoreTrashRoute: RouteConfig = registerRoute({ params: z.object({ trashId: z.string(), }), + query: z.object({ + tableId: z.string().optional(), + }), }, responses: { 201: { @@ -22,10 +25,12 @@ export const RestoreTrashRoute: RouteConfig = registerRoute({ tags: ['space'], }); -export const restoreTrash = async (trashId: string) => { +export const restoreTrash = async (trashId: string, tableId?: string) => { return axios.post( urlBuilder(RESTORE_TRASH, { trashId, - }) + }), + undefined, + { params: { tableId } } ); }; diff --git a/packages/sdk/src/components/expand-record/Modal.spec.tsx b/packages/sdk/src/components/expand-record/Modal.spec.tsx new file mode 100644 index 0000000000..fe4607311a --- /dev/null +++ b/packages/sdk/src/components/expand-record/Modal.spec.tsx @@ -0,0 +1,33 @@ +import { fireEvent, render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Modal } from './Modal'; + +describe('Modal (ExpandRecord wrapper)', () => { + it('calls onClose when the overlay is clicked (T956)', () => { + const onClose = vi.fn(); + render( + +
inner
+
+ ); + + const overlay = document.querySelector('[data-state="open"].fixed.inset-0'); + expect(overlay).not.toBeNull(); + fireEvent.click(overlay!); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when clicking inside the dialog content', () => { + const onClose = vi.fn(); + const { getByTestId } = render( + +
inner
+
+ ); + + fireEvent.click(getByTestId('content')); + + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/src/components/expand-record/Modal.tsx b/packages/sdk/src/components/expand-record/Modal.tsx index 1ed971fe81..e21a0a9fc5 100644 --- a/packages/sdk/src/components/expand-record/Modal.tsx +++ b/packages/sdk/src/components/expand-record/Modal.tsx @@ -1,4 +1,4 @@ -import { Dialog, DialogContent, cn } from '@teable/ui-lib'; +import { Dialog, DialogContent, DialogOverlay, cn } from '@teable/ui-lib'; import { type FC, type PropsWithChildren } from 'react'; import { useRef } from 'react'; import { ModalContext } from './ModalContext'; @@ -22,6 +22,7 @@ export const Modal: FC< container={container} className={cn('h-full block p-0 max-w-4xl', className)} style={{ width: 'calc(100% - 40px)', height: 'calc(100% - 100px)' }} + overlay={ onClose?.()} />} onKeyDown={(e) => { if (e.key === 'Escape') { onClose?.(); diff --git a/packages/sdk/src/context/app/queryClient.spec.ts b/packages/sdk/src/context/app/queryClient.spec.ts index d5c8ed293b..e3a20c3c64 100644 --- a/packages/sdk/src/context/app/queryClient.spec.ts +++ b/packages/sdk/src/context/app/queryClient.spec.ts @@ -1,15 +1,27 @@ +import deSdk from '@teable/common-i18n/src/locales/de/sdk.json'; import deTable from '@teable/common-i18n/src/locales/de/table.json'; +import enSdk from '@teable/common-i18n/src/locales/en/sdk.json'; import enTable from '@teable/common-i18n/src/locales/en/table.json'; +import esSdk from '@teable/common-i18n/src/locales/es/sdk.json'; import esTable from '@teable/common-i18n/src/locales/es/table.json'; +import frSdk from '@teable/common-i18n/src/locales/fr/sdk.json'; import frTable from '@teable/common-i18n/src/locales/fr/table.json'; +import itSdk from '@teable/common-i18n/src/locales/it/sdk.json'; import itTable from '@teable/common-i18n/src/locales/it/table.json'; +import jaSdk from '@teable/common-i18n/src/locales/ja/sdk.json'; import jaTable from '@teable/common-i18n/src/locales/ja/table.json'; +import ruSdk from '@teable/common-i18n/src/locales/ru/sdk.json'; import ruTable from '@teable/common-i18n/src/locales/ru/table.json'; +import trSdk from '@teable/common-i18n/src/locales/tr/sdk.json'; import trTable from '@teable/common-i18n/src/locales/tr/table.json'; +import ukSdk from '@teable/common-i18n/src/locales/uk/sdk.json'; import ukTable from '@teable/common-i18n/src/locales/uk/table.json'; +import zhSdk from '@teable/common-i18n/src/locales/zh/sdk.json'; import zhTable from '@teable/common-i18n/src/locales/zh/table.json'; import { describe, expect, it } from 'vitest'; import { tableI18nKeys } from '../../../../i18n-keys/src'; +import type { ILocaleFunction } from './i18n'; +import { getHttpErrorMessage } from './queryClient'; const collectLeafKeys = (value: unknown, prefix = ''): string[] => { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -58,3 +70,67 @@ describe('table locale coverage', () => { expect(expectedKeys.filter((key) => !localeKeys.has(key))).toEqual([]); }); }); + +describe('sdk table data safety limit locale coverage', () => { + const expectedKeys = Object.keys(enSdk.httpErrors.limit).sort(); + const locales = { + de: deSdk, + en: enSdk, + es: esSdk, + fr: frSdk, + it: itSdk, + ja: jaSdk, + ru: ruSdk, + tr: trSdk, + uk: ukSdk, + zh: zhSdk, + }; + + it.each(Object.entries(locales))( + 'covers all table data safety limit messages in %s', + (_locale, sdk) => { + expect(Object.keys(sdk.httpErrors.limit).sort()).toEqual(expectedKeys); + } + ); +}); + +const t: ILocaleFunction = ((key: string, options?: Record) => { + if (key === 'sdk:httpErrors.limit.nameMaxLength') { + return `${key}:${options?.max}`; + } + return key; +}) as ILocaleFunction; + +describe('getHttpErrorMessage', () => { + it('localizes v2 table data safety validation limit errors by domain code', () => { + const message = getHttpErrorMessage( + { + message: 'Table data safety limit exceeded: validation.limit.name_max_length', + data: { + domainCode: 'validation.limit.name_max_length', + details: { max: 100 }, + }, + }, + t, + 'sdk' + ); + + expect(message).toBe('sdk:httpErrors.limit.nameMaxLength:100'); + }); + + it('falls back to the server message for unknown validation limit keys', () => { + const message = getHttpErrorMessage( + { + message: 'fallback', + data: { + domainCode: 'validation.limit.unknown_limit', + details: { max: 1 }, + }, + }, + t, + 'sdk' + ); + + expect(message).toBe('fallback'); + }); +}); diff --git a/packages/sdk/src/context/app/queryClient.tsx b/packages/sdk/src/context/app/queryClient.tsx index 8fe5d8657f..7a3018911f 100644 --- a/packages/sdk/src/context/app/queryClient.tsx +++ b/packages/sdk/src/context/app/queryClient.tsx @@ -26,6 +26,29 @@ export function toCamelCaseErrorCode(errorCode: string): string { .join(''); } +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const getValidationLimitMessage = ( + data: ICustomHttpExceptionData | undefined, + t: ILocaleFunction, + prefix?: string +) => { + const domainCode = data?.domainCode; + if (typeof domainCode !== 'string' || !domainCode.startsWith('validation.limit.')) { + return; + } + + const limitKey = toCamelCaseErrorCode(domainCode.slice('validation.limit.'.length)); + const key = `httpErrors.limit.${limitKey}`; + const prefixedKey = prefix ? `${prefix}:${key}` : key; + const details = isRecord(data?.details) ? data.details : {}; + const message = t(prefixedKey as TKey, details); + return typeof message === 'string' && message !== prefixedKey && message !== key + ? message + : undefined; +}; + export const getLocalizationMessage = ( localization: ILocalization, t: ILocaleFunction, @@ -38,7 +61,11 @@ export const getLocalizationMessage = ( export const getHttpErrorMessage = (error: unknown, t: ILocaleFunction, prefix?: string) => { const { message, data } = error as IHttpError; - const { localization } = (data as ICustomHttpExceptionData) || {}; + const customData = (data as ICustomHttpExceptionData) || {}; + const limitMessage = getValidationLimitMessage(customData, t, prefix); + if (limitMessage) return limitMessage; + + const { localization } = customData; return localization ? getLocalizationMessage(localization, t, prefix) : message; }; diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts index 8a31803751..23e83bfbbc 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts @@ -655,7 +655,7 @@ export class PostgresTableRepository implements core.ITableRepository { .orderBy('is_primary') .orderBy('order') .orderBy('created_time'); - if (effectiveState === 'active') { + if (effectiveState === 'active' || effectiveState === 'activeWithPending') { query = query.where('deleted_time', 'is', null); } else if (effectiveState === 'deleted') { query = query.where('deleted_time', 'is not', null); @@ -674,7 +674,7 @@ export class PostgresTableRepository implements core.ITableRepository { .select(['id', 'name', 'type', 'options', 'column_meta', 'sort', 'filter', 'group']) .where(sql`${sql.ref('view.table_id')} = ${sql.ref('table_meta.id')}`) .orderBy('order'); - if (effectiveState === 'active') { + if (effectiveState === 'active' || effectiveState === 'activeWithPending') { query = query.where('deleted_time', 'is', null); } else if (effectiveState === 'deleted') { query = query.where('deleted_time', 'is not', null); @@ -769,7 +769,7 @@ export class PostgresTableRepository implements core.ITableRepository { .orderBy('is_primary') .orderBy('order') .orderBy('created_time'); - if (effectiveState === 'active') { + if (effectiveState === 'active' || effectiveState === 'activeWithPending') { query = query.where('deleted_time', 'is', null); } else if (effectiveState === 'deleted') { query = query.where('deleted_time', 'is not', null); @@ -788,7 +788,7 @@ export class PostgresTableRepository implements core.ITableRepository { .select(['id', 'name', 'type', 'options', 'column_meta', 'sort', 'filter', 'group']) .where(sql`${sql.ref('view.table_id')} = ${sql.ref('table_meta.id')}`) .orderBy('order'); - if (effectiveState === 'active') { + if (effectiveState === 'active' || effectiveState === 'activeWithPending') { query = query.where('deleted_time', 'is', null); } else if (effectiveState === 'deleted') { query = query.where('deleted_time', 'is not', null); diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.ts index 274101e73f..c80cc458fa 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRowLimitPlugin.ts @@ -137,9 +137,10 @@ export class PostgresTableRowLimitPlugin if (rowCount + recordCount > preparedState.maxRowCount) { return err( core.domainError.validation({ - code: 'validation.max_row_limit', + code: 'validation.limit.rows_per_table_max', message: `Exceed max row limit: ${preparedState.maxRowCount}, please contact us to increase the limit`, details: { + max: preparedState.maxRowCount, maxRowCount: preparedState.maxRowCount, rowCount, recordCount, diff --git a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.spec.ts b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.spec.ts index 8b2e231687..577c1480e4 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.spec.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.spec.ts @@ -36,13 +36,31 @@ describe('TableWhereVisitor', () => { const eb = createExpressionBuilder(); expect(new TableWhereVisitor('active').where()._unsafeUnwrap()(eb as never)).toEqual({ - type: 'comparison', - args: ['deleted_time', 'is', null], + type: 'and', + args: [ + { + type: 'comparison', + args: ['deleted_time', 'is', null], + }, + expect.anything(), + ], }); expect(new TableWhereVisitor('deleted').where()._unsafeUnwrap()(eb as never)).toEqual({ type: 'comparison', args: ['deleted_time', 'is not', null], }); + expect(new TableWhereVisitor('activeWithPending').where()._unsafeUnwrap()(eb as never)).toEqual( + { + type: 'and', + args: [ + { + type: 'comparison', + args: ['deleted_time', 'is', null], + }, + expect.anything(), + ], + } + ); expect(new TableWhereVisitor('all').where().isErr()).toBe(true); }); diff --git a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.ts b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.ts index 110d7b2649..9c2aea53a5 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableWhereVisitor.ts @@ -97,6 +97,10 @@ export class TableWhereVisitor super(); if (state === 'active') { this.addCond((eb) => eb.eb('deleted_time', 'is', null)); + this.addCond(() => sql`"table_meta"."provision_state" = 'ready'`); + } else if (state === 'activeWithPending') { + this.addCond((eb) => eb.eb('deleted_time', 'is', null)); + this.addCond(() => sql`"table_meta"."provision_state" in ('ready', 'pending')`); } else if (state === 'deleted') { this.addCond((eb) => eb.eb('deleted_time', 'is not', null)); } diff --git a/packages/v2/adapter-table-repository-postgres/src/integration/commands/CreateRecordHandler.db.spec.ts b/packages/v2/adapter-table-repository-postgres/src/integration/commands/CreateRecordHandler.db.spec.ts index ee239ec733..92fbcf6968 100644 --- a/packages/v2/adapter-table-repository-postgres/src/integration/commands/CreateRecordHandler.db.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/integration/commands/CreateRecordHandler.db.spec.ts @@ -3,8 +3,10 @@ import { v2PostgresDbTokens } from '@teable/v2-adapter-db-postgres-pg'; import { createV2NodeTestContainer } from '@teable/v2-container-node-test'; import { ActorId, + CreateFieldCommand, CreateRecordCommand, CreateTableCommand, + type CreateFieldResult, type CreateRecordResult, type CreateTableResult, type ICommandBus, @@ -150,6 +152,112 @@ describe('CreateRecordHandler (db)', () => { expect(rows[0]['__id']).toBe(record.id().toString()); }); + it('inserts a record when a number formula resolves to an empty string', async () => { + const { container, baseId, processOutbox } = getV2NodeTestContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const db = container.resolve>(v2PostgresDbTokens.db); + + const deviceFieldId = `fld${'d'.repeat(16)}`; + const startTimeFieldId = `fld${'u'.repeat(16)}`; + const endTimeFieldId = `fld${'t'.repeat(16)}`; + const statusFieldId = `fld${'s'.repeat(16)}`; + const textFormulaFieldId = `fld${'g'.repeat(16)}`; + const numberFormulaFieldId = `fld${'f'.repeat(16)}`; + const createTableCommand = CreateTableCommand.create({ + baseId: baseId.toString(), + name: 'Number Formula Blank Insert', + fields: [ + { type: 'singleLineText', name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: deviceFieldId, name: 'Device' }, + { type: 'singleLineText', id: startTimeFieldId, name: 'Start Time' }, + { type: 'singleLineText', id: endTimeFieldId, name: 'End Time' }, + { + type: 'singleSelect', + id: statusFieldId, + name: 'Status', + options: { + choices: [{ id: `cho${'w'.repeat(10)}`, name: '工作日', color: 'greenBright' }], + }, + }, + ], + views: [{ type: 'grid' }], + })._unsafeUnwrap(); + + const tableResult = await commandBus.execute( + createContext(), + createTableCommand + ); + const { table } = tableResult._unsafeUnwrap(); + const tableId = table.id().toString(); + + const createTextFormulaCommand = CreateFieldCommand.create({ + baseId: baseId.toString(), + tableId, + field: { + id: textFormulaFieldId, + type: 'formula', + name: 'Workday Minutes Text', + options: { + expression: `IF(AND({${startTimeFieldId}} = "", {${endTimeFieldId}} = ""), "", IF({${statusFieldId}} = "工作日", IF({${deviceFieldId}} = "行政考勤", {${endTimeFieldId}} % 100, {${endTimeFieldId}} % 100 - 35), ""))`, + }, + }, + })._unsafeUnwrap(); + const textFormulaResult = await commandBus.execute( + createContext(), + createTextFormulaCommand + ); + expect(textFormulaResult.isOk()).toBe(true); + + const createNumberFormulaCommand = CreateFieldCommand.create({ + baseId: baseId.toString(), + tableId, + field: { + id: numberFormulaFieldId, + type: 'formula', + name: 'Blank Number Formula', + options: { + expression: `VALUE(IF({${statusFieldId}} = "工作日", IF({${deviceFieldId}} = "行政考勤", IF(OR({${endTimeFieldId}} = "", {${endTimeFieldId}} < "17:00"), "", IF({${endTimeFieldId}} < "17:00", "", {${textFormulaFieldId}})), ""), ""))`, + }, + }, + })._unsafeUnwrap(); + const formulaResult = await commandBus.execute( + createContext(), + createNumberFormulaCommand + ); + expect(formulaResult.isOk(), formulaResult.isErr() ? formulaResult.error.message : '').toBe( + true + ); + const formulaTable = formulaResult._unsafeUnwrap().table; + const formulaField = formulaTable + .getFields() + .find((field) => field.id().toString() === numberFormulaFieldId); + expect(formulaField).toBeDefined(); + if (!formulaField) return; + + const createRecordCommand = CreateRecordCommand.create({ + tableId, + fields: {}, + })._unsafeUnwrap(); + const result = await commandBus.execute( + createContext(), + createRecordCommand + ); + + expect(result.isOk()).toBe(true); + const { record } = result._unsafeUnwrap(); + await processOutbox(); + + const dbTableName = formulaTable.dbTableName()._unsafeUnwrap().value()._unsafeUnwrap(); + const formulaDbField = formulaField.dbFieldName()._unsafeUnwrap().value()._unsafeUnwrap(); + const rows = await (db as unknown as Kysely>>) + .selectFrom(dbTableName) + .select([formulaDbField]) + .where('__id', '=', record.id().toString()) + .execute(); + + expect(rows).toEqual([{ [formulaDbField]: null }]); + }); + it('inserts multiple records with unique IDs', async () => { const { container, baseId } = getV2NodeTestContainer(); const commandBus = container.resolve(v2CoreTokens.commandBus); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts index d34b7f415c..f00ceb5e4f 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/ComputedFieldUpdater.ts @@ -74,12 +74,12 @@ const SAME_TABLE_BATCH_CHUNK_TRIGGER = 1000; const SAME_TABLE_BATCH_CHUNK_SIZE = 500; const quoteIdentifier = (value: string): string => `"${value.replaceAll('"', '""')}"`; - /** * Change data for a single field in a record. */ export type FieldChangeData = { fieldId: string; + oldValue?: unknown; newValue: unknown; }; @@ -1079,8 +1079,10 @@ export class ComputedFieldUpdater { for (const row of rows) { const changes: FieldChangeData[] = []; for (const [column, fieldId] of compiledResult.columnToFieldId) { + const oldValueAlias = compiledResult.oldColumnAliases.get(column); changes.push({ fieldId, + oldValue: oldValueAlias ? row[oldValueAlias] : undefined, newValue: row[column], }); } @@ -1490,7 +1492,9 @@ export class ComputedFieldUpdater { .withoutBaseId() .byIds([...tableIds.values()]) .build(); - const tables = yield* await this.tableRepository.find(context, spec); + const tables = yield* await this.tableRepository.find(context, spec, { + state: 'activeWithPending', + }); return ok(tables); }.bind(this) diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/UpdateFromSelectBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/UpdateFromSelectBuilder.ts index 862830d928..f7407426c9 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/UpdateFromSelectBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/UpdateFromSelectBuilder.ts @@ -77,6 +77,8 @@ export type UpdateWithReturningResult = { compiled: CompiledQuery; /** Mapping from column name to field ID */ columnToFieldId: Map; + /** Mapping from column name to RETURNING alias for the old value */ + oldColumnAliases: Map; }; /** @@ -89,6 +91,13 @@ export type UpdatedRecordRow = { [column: string]: unknown; }; +const oldValueAliasForColumn = (column: string): string => `__old_${column.replaceAll(/\W/g, '_')}`; + +const quoteIdentifier = (value: string): string => `"${value.replaceAll('"', '""')}"`; + +const quoteQualifiedTableName = (value: string): string => + value.split('.').map(quoteIdentifier).join('.'); + /** * Build UPDATE...FROM statements using a computed SELECT subquery. * @@ -194,18 +203,37 @@ export class UpdateFromSelectBuilder { // Add RETURNING clause for record ID, old version, and all updated columns // Use double quotes to preserve case-sensitivity in PostgreSQL // Return __version - 1 as __old_version (the version BEFORE this computed update) + const oldTableAlias = '__old'; const returningColumns = [ `"${tableAlias}"."__id"`, `"${tableAlias}"."__version" - 1 as "__old_version"`, ]; + const oldColumnAliases = new Map(); for (const [column] of columnMapping) { + const oldAlias = oldValueAliasForColumn(column); + oldColumnAliases.set(column, oldAlias); + returningColumns.push(`"${oldTableAlias}"."${column}" as "${oldAlias}"`); returningColumns.push(`"${tableAlias}"."${column}"`); } // Use raw SQL for RETURNING since Kysely's typing doesn't support it well for updates const compiled = query.compile(); + const whereIndex = compiled.sql.lastIndexOf(' where '); + if (whereIndex === -1) { + return err( + domainError.validation({ + message: 'UpdateFromSelect returning query is missing WHERE clause', + }) + ); + } + const sqlWithOldTable = + compiled.sql.slice(0, whereIndex) + + `, ${quoteQualifiedTableName(tableName)} as "${oldTableAlias}"` + + compiled.sql.slice(whereIndex, whereIndex + ' where '.length) + + `"${oldTableAlias}"."__id" = "${selectAlias}"."__id" and ` + + compiled.sql.slice(whereIndex + ' where '.length); const returningClause = ` RETURNING ${returningColumns.join(', ')}`; - const sqlWithReturning = compiled.sql + returningClause; + const sqlWithReturning = sqlWithOldTable + returningClause; return ok({ compiled: { @@ -213,6 +241,7 @@ export class UpdateFromSelectBuilder { sql: sqlWithReturning, }, columnToFieldId: columnMapping, + oldColumnAliases, }); } ); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts index 8ff8f6b46d..1453397e99 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/__tests__/UpdateFromSelectBuilder.spec.ts @@ -153,7 +153,7 @@ describe('UpdateFromSelectBuilder', () => { WHEN BTRIM(("c_src"."col_score")::text) ~ '^[+-]?([0-9]+([.][0-9]+)?|[.][0-9]+)([eE][+-]?[0-9]+)?$' THEN BTRIM(("c_src"."col_score")::text)::double precision ELSE NULL - END as "__set_col_score" from (select "t"."__id" as "__id", "t"."__version" as "__version", 1 as "col_score" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" where "t"."__id" in (select "d"."record_id" from "tmp_computed_dirty" as "d" where "d"."table_id" = $1)) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_score" IS DISTINCT FROM "c"."__set_col_score")" + END as "__set_col_score" from (select "t"."__id" as "__id", "t"."__version" as "__version", NULLIF(BTRIM((1)::text), '')::double precision as "col_score" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" where "t"."__id" in (select "d"."record_id" from "tmp_computed_dirty" as "d" where "d"."table_id" = $1)) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_score" IS DISTINCT FROM "c"."__set_col_score")" ` ); }); @@ -232,7 +232,7 @@ describe('UpdateFromSelectBuilder', () => { WHEN BTRIM(("c_src"."col_score")::text) ~ '^[+-]?([0-9]+([.][0-9]+)?|[.][0-9]+)([eE][+-]?[0-9]+)?$' THEN BTRIM(("c_src"."col_score")::text)::double precision ELSE NULL - END as "__set_col_score" from (select "t"."__id" as "__id", "t"."__version" as "__version", 1 as "col_score" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" inner join "tmp_computed_dirty" as "__dirty" on "t"."__id" = "__dirty"."record_id" and "__dirty"."table_id" = $1) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_score" IS DISTINCT FROM "c"."__set_col_score")" + END as "__set_col_score" from (select "t"."__id" as "__id", "t"."__version" as "__version", NULLIF(BTRIM((1)::text), '')::double precision as "col_score" from "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "t" inner join "tmp_computed_dirty" as "__dirty" on "t"."__id" = "__dirty"."record_id" and "__dirty"."table_id" = $1) as "c_src") as "c" where "u"."__id" = "c"."__id" and ("u"."col_score" IS DISTINCT FROM "c"."__set_col_score")" ` ); }); @@ -374,10 +374,61 @@ describe('UpdateFromSelectBuilder', () => { // Verify RETURNING includes the formula column expect(updateResult.value.compiled.sql).toContain('"u"."col_score"'); + expect(updateResult.value.compiled.sql).toContain( + ', "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "__old" where "__old"."__id" = "c"."__id"' + ); + expect(updateResult.value.compiled.sql).toContain('"__old"."col_score" as "__old_col_score"'); + expect(updateResult.value.oldColumnAliases.get('col_score')).toBe('__old_col_score'); // Verify columnToFieldId mapping const fieldIdForColumn = updateResult.value.columnToFieldId.get('col_score'); expect(fieldIdForColumn).toBe(formulaFieldId.toString()); }); + + it('injects old table into the outer UPDATE FROM scope when source select has nested where clauses', () => { + const db = createTestDb(); + const { table, formulaFieldId } = createFormulaTable(); + + const selectBuilder = new ComputedTableRecordQueryBuilder(db, { typeValidationStrategy }) + .from(table) + .select([formulaFieldId]); + const selectResult = selectBuilder.build(); + expect(selectResult.isOk()).toBe(true); + if (selectResult.isErr()) return; + + const dirtySubquery = db + .selectFrom('tmp_computed_dirty as d') + .select('d.record_id') + .where('d.table_id', '=', table.id().toString()); + + const filteredSelect = selectResult.value.where( + `${COMPUTED_TABLE_ALIAS}.__id`, + 'in', + dirtySubquery + ); + + const builder = new UpdateFromSelectBuilder(db); + const updateResult = builder.buildWithReturning({ + table, + fieldIds: [formulaFieldId], + selectQuery: filteredSelect, + }); + + expect(updateResult.isOk()).toBe(true); + if (updateResult.isErr()) return; + + const sql = updateResult.value.compiled.sql; + const sourceAliasIndex = sql.lastIndexOf(') as "c"'); + const oldTableIndex = sql.indexOf( + ', "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "__old" where "__old"."__id" = "c"."__id"' + ); + + expect(sourceAliasIndex).toBeGreaterThan(-1); + expect(oldTableIndex).toBeGreaterThan(sourceAliasIndex); + expect(sql).toContain('where "t"."__id" in (select "d"."record_id"'); + expect(sql).not.toContain( + 'from "tmp_computed_dirty" as "d", "bseaaaaaaaaaaaaaaaa"."tblbbbbbbbbbbbbbbbb" as "__old"' + ); + }); }); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/HybridWithOutboxStrategy.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/HybridWithOutboxStrategy.ts index 047f065071..3903cc9b81 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/HybridWithOutboxStrategy.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/strategies/HybridWithOutboxStrategy.ts @@ -646,7 +646,7 @@ const buildComputedUpdateEvents = ( newVersion: change.oldVersion + 1, changes: change.changes.map((fieldChange) => ({ fieldId: fieldChange.fieldId, - oldValue: null as unknown, + oldValue: fieldChange.oldValue, newValue: fieldChange.newValue, })), })); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdatePollingService.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdatePollingService.spec.ts index 5e65b66b34..6a9ab44ed9 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdatePollingService.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdatePollingService.spec.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from 'async_hooks'; import type { ILogger } from '@teable/v2-core'; import { ok } from 'neverthrow'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -91,4 +92,40 @@ describe('ComputedUpdatePollingService', () => { expect.objectContaining({ workerId: 'poll-debug', delayMs: 1000 }) ); }); + + it('runs auto-started polling outside the request async context that created it', async () => { + vi.useFakeTimers(); + + const storage = new AsyncLocalStorage<{ tableId: string }>(); + const seenContexts: Array<{ tableId: string } | undefined> = []; + const worker = { + runOnce: vi.fn().mockImplementation(() => { + seenContexts.push(storage.getStore()); + return Promise.resolve(ok(seenContexts.length === 1 ? 5 : 0)); + }), + }; + const logger = createLogger(); + + let service: ComputedUpdatePollingService | undefined; + storage.run({ tableId: 'tbl-from-request' }, () => { + service = new ComputedUpdatePollingService( + worker as never, + { + ...defaultPollingConfig, + enabled: true, + workerId: 'poll-detached', + batchSize: 5, + pollIntervalMs: 1000, + }, + logger + ); + }); + + await vi.runOnlyPendingTimersAsync(); + await vi.runOnlyPendingTimersAsync(); + + expect(seenContexts).toEqual([undefined, undefined]); + + await service?.stop(); + }); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdatePollingService.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdatePollingService.ts index 5e75378f46..2bb59ed7e6 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdatePollingService.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdatePollingService.ts @@ -1,3 +1,4 @@ +import { AsyncResource } from 'async_hooks'; import type { ILogger } from '@teable/v2-core'; import { v2CoreTokens } from '@teable/v2-core'; import { inject, injectable } from '@teable/v2-di'; @@ -72,6 +73,8 @@ export const externalPollingConfig: ComputedUpdatePollingConfig = { pollIntervalMs: 500, // More aggressive polling for external mode }; +const pollingAsyncResource = new AsyncResource('teable-v2-computed-polling'); + /** * Background polling service for computed field updates. * @@ -112,7 +115,7 @@ export class ComputedUpdatePollingService { pollIntervalMs: this.config.pollIntervalMs, }); // Use setImmediate to avoid blocking constructor - setImmediate(() => this.start()); + this.scheduleImmediate(() => this.start()); } } @@ -249,7 +252,7 @@ export class ComputedUpdatePollingService { batchSize: this.config.batchSize, processed, }); - setImmediate(() => void this.poll()); + this.scheduleImmediate(() => void this.poll()); return; } } @@ -268,7 +271,14 @@ export class ComputedUpdatePollingService { workerId: this.config.workerId, delayMs: this.config.pollIntervalMs, }); - this.pollTimer = setTimeout(() => void this.poll(), this.config.pollIntervalMs); + this.pollTimer = setTimeout( + () => pollingAsyncResource.runInAsyncScope(() => void this.poll()), + this.config.pollIntervalMs + ); } } + + private scheduleImmediate(callback: () => void): void { + setImmediate(() => pollingAsyncResource.runInAsyncScope(callback)); + } } diff --git a/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.ts b/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.ts index 322b6097fd..798b53e58a 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/computed/worker/ComputedUpdateWorker.ts @@ -1463,7 +1463,7 @@ const buildComputedUpdateEvents = ( newVersion: change.oldVersion + 1, changes: change.changes.map((fieldChange) => ({ fieldId: fieldChange.fieldId, - oldValue: null as unknown, + oldValue: fieldChange.oldValue, newValue: fieldChange.newValue, })), })); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts index e27bd13dba..5a4cda9aaa 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedFieldSelectExpressionVisitor.ts @@ -1,5 +1,7 @@ import { + CellValueType, FieldType, + FieldValueTypeVisitor, type AttachmentField, type AutoNumberField, type ButtonField, @@ -462,6 +464,15 @@ export class ComputedFieldSelectExpressionVisitor finalValueSql = expr.valueSql; } + const fieldValueTypeResult = field.accept(new FieldValueTypeVisitor()); + if ( + fieldValueTypeResult.isOk() && + !formulaIsMultiple && + fieldValueTypeResult.value.cellValueType.equals(CellValueType.number()) + ) { + finalValueSql = `NULLIF(BTRIM((${finalValueSql})::text), '')::double precision`; + } + const typedSql = guardValueSql(finalValueSql, expr.errorConditionSql); return ok(sql.raw(typedSql).as(colAlias)); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.ts index c75d9db895..a2827e29d6 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/ComputedTableRecordQueryBuilder.ts @@ -297,7 +297,9 @@ export class ComputedTableRecordQueryBuilder implements ITableRecordQueryBuilder if (externalTableIds.length > 0) { // Use withoutBaseId() to support cross-base foreign tables const foreignSpec = yield* table.specs().withoutBaseId().byIds(externalTableIds).build(); - const loadedTables = yield* await deps.tableRepository.find(deps.context, foreignSpec); + const loadedTables = yield* await deps.tableRepository.find(deps.context, foreignSpec, { + state: 'activeWithPending', + }); for (const loadedTable of loadedTables) { foreignTables.set(loadedTable.id().toString(), loadedTable); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.ts index a1dbe5c354..039e48d00d 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/FieldReferenceSqlVisitor.ts @@ -404,9 +404,6 @@ export class FieldReferenceSqlVisitor implements IFieldVisitor { */ visitLookupField(field: LookupField): Result { return this.getColAlias(field).andThen((colAlias) => { - if (this.isMissingForeignTableId(field.foreignTableId().toString())) { - return this.missingForeignTableExpr(field); - } const exprOptionsResult = this.getLookupExprOptions(field); if (exprOptionsResult.isErr()) return err(exprOptionsResult.error); const exprOptions = exprOptionsResult.value; @@ -464,7 +461,7 @@ export class FieldReferenceSqlVisitor implements IFieldVisitor { visitRollupField(field: RollupField): Result { return this.getColAlias(field).andThen((colAlias) => { if (this.isMissingForeignTableId(field.foreignTableId().toString())) { - return this.missingForeignTableExpr(field); + return this.erroredFieldExpr(field); } if (field.hasError().isError()) { return this.erroredFieldExpr(field); @@ -495,12 +492,12 @@ export class FieldReferenceSqlVisitor implements IFieldVisitor { visitConditionalLookupField(field: ConditionalLookupField): Result { return this.getColAlias(field).andThen((colAlias) => { const options = field.conditionalLookupOptions(); - if (this.isMissingForeignTableId(options.foreignTableId().toString())) { - return this.missingForeignTableExpr(field); - } const exprOptionsResult = this.getLookupExprOptions(field); if (exprOptionsResult.isErr()) return err(exprOptionsResult.error); const exprOptions = exprOptionsResult.value; + if (this.isMissingForeignTableId(options.foreignTableId().toString())) { + return this.erroredFieldExpr(field, exprOptions); + } if (field.hasError().isError()) { return this.erroredFieldExpr(field, exprOptions); } @@ -536,7 +533,7 @@ export class FieldReferenceSqlVisitor implements IFieldVisitor { return this.getColAlias(field).andThen((colAlias) => { const config = field.config(); if (this.isMissingForeignTableId(config.foreignTableId().toString())) { - return this.missingForeignTableExpr(field); + return this.erroredFieldExpr(field); } if (field.hasError().isError()) { return this.erroredFieldExpr(field); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts index a73ef64a19..2d68023b71 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/query-builder/computed/SameTableBatchQueryBuilder.ts @@ -1,4 +1,4 @@ -import { domainError, FieldType, FieldValueTypeVisitor } from '@teable/v2-core'; +import { CellValueType, domainError, FieldType, FieldValueTypeVisitor } from '@teable/v2-core'; import type { ConditionalLookupField, DomainError, @@ -256,27 +256,38 @@ export class SameTableBatchQueryBuilder { } private normalizeFormulaValueSql(formulaField: FormulaField, expr: SqlExpr): string { - if (expr.storageKind !== 'json' || !this.shouldExtractJsonDisplay(expr)) { - return expr.valueSql; - } + let valueSql = expr.valueSql; const formulaIsMultiple = formulaField .isMultipleCellValue() .map((multiplicity) => multiplicity.isMultiple()) .unwrapOr(false); - if (formulaIsMultiple || expr.isArray) { - const normalized = normalizeToJsonArrayWithStrategy( - expr.valueSql, - this.typeValidationStrategy - ); - return `( + if (expr.storageKind === 'json' && this.shouldExtractJsonDisplay(expr)) { + if (formulaIsMultiple || expr.isArray) { + const normalized = normalizeToJsonArrayWithStrategy( + expr.valueSql, + this.typeValidationStrategy + ); + valueSql = `( SELECT jsonb_agg(to_jsonb(${extractJsonScalarText('elem')}) ORDER BY ord) FROM jsonb_array_elements(${normalized}) WITH ORDINALITY AS _jae(elem, ord) )`; + } else { + valueSql = extractJsonScalarText(`(${expr.valueSql})::jsonb`); + } + } + + const fieldValueTypeResult = formulaField.accept(new FieldValueTypeVisitor()); + if ( + fieldValueTypeResult.isOk() && + !formulaIsMultiple && + fieldValueTypeResult.value.cellValueType.equals(CellValueType.number()) + ) { + return `NULLIF(BTRIM((${valueSql})::text), '')::double precision`; } - return extractJsonScalarText(`(${expr.valueSql})::jsonb`); + return valueSql; } private shouldExtractJsonDisplay(expr: SqlExpr): boolean { diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.pglite.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.pglite.spec.ts index 663da058d9..e6eb995da6 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.pglite.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.pglite.spec.ts @@ -445,6 +445,57 @@ describe('PostgresTableRecordQueryRepository projection (pglite)', () => { expect(driver.queries[0].sql).not.toContain('order by "t"."__auto_number"'); }); + it('re-checks view row order column existence after it is created', async () => { + const fixture = await setupRepositoryFixture({ + db, + createdSchemas, + seed: 'row-order', + rows: [ + { name: 'A', age: 10 }, + { name: 'B', age: 20 }, + { name: 'C', age: 30 }, + ], + }); + const viewId = fixture.table.views()[0]!.id().toString(); + const orderColumn = `__row_${viewId}` as const; + + const firstResult = await fixture.repository.find(fixture.context, fixture.table, undefined, { + mode: 'stored', + includeTotal: false, + orderBy: [{ column: orderColumn, direction: 'asc' }], + }); + expect(firstResult.isOk()).toBe(true); + if (firstResult.isErr()) return; + expect(firstResult.value.records.map((record) => record.id)).toEqual(fixture.insertedRecordIds); + + await sql` + ALTER TABLE ${sql.table(`${fixture.table.baseId().toString()}.${fixture.table.id().toString()}`)} + ADD COLUMN ${sql.id(orderColumn)} double precision + `.execute(db); + await sql` + UPDATE ${sql.table(`${fixture.table.baseId().toString()}.${fixture.table.id().toString()}`)} + SET ${sql.id(orderColumn)} = + CASE __auto_number + WHEN 1 THEN 2 + WHEN 2 THEN 3 + ELSE 1 + END + `.execute(db); + + const secondResult = await fixture.repository.find(fixture.context, fixture.table, undefined, { + mode: 'stored', + includeTotal: false, + orderBy: [{ column: orderColumn, direction: 'asc' }], + }); + expect(secondResult.isOk()).toBe(true); + if (secondResult.isErr()) return; + expect(secondResult.value.records.map((record) => record.id)).toEqual([ + fixture.insertedRecordIds[2], + fixture.insertedRecordIds[0], + fixture.insertedRecordIds[1], + ]); + }); + it('streams correct pages for cursor pagination and respects projection', async () => { const fixture = await setupRepositoryFixture({ db, diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.ts index 22889d8ce5..04ee9b0f05 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordQueryRepository.ts @@ -593,10 +593,12 @@ export class PostgresTableRecordQueryRepository implements ITableRecordQueryRepo `.execute(db); const exists = Boolean(columnCheckResult.rows[0]?.exists); - this.orderColumnExistsCache.set(cacheKey, { - exists, - cachedAt: now, - }); + if (exists) { + this.orderColumnExistsCache.set(cacheKey, { + exists, + cachedAt: now, + }); + } return exists; } diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.delete.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.delete.spec.ts index 87e485e504..92f8b5e497 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.delete.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.delete.spec.ts @@ -135,7 +135,7 @@ const createRecordingDb = (rowProvider?: RowProvider) => { if (compiledQuery.sql.includes('FROM pg_trigger AS t')) { return [{ exists: true }]; } - if (compiledQuery.sql.includes('FROM "public"."__undo_log"')) { + if (compiledQuery.sql.includes('FROM "__undo_log"')) { return [ { record_id: RECORD_ID, @@ -303,7 +303,7 @@ const isUndoCaptureQuery = (query: CompiledQuery) => { const text = query.sql; return ( text.includes('teable_undo_capture_') || - text.includes('"public"."__undo_log"') || + text.includes('"__undo_log"') || text.includes("table_name = '__undo_log'") || text.includes('__teable_capture_undo_row') || text.includes('FROM pg_trigger AS t') || @@ -357,7 +357,7 @@ const createUndoLogRowProvider = ( }> ): RowProvider => { return (compiledQuery) => { - if (compiledQuery.sql.includes('FROM "public"."__undo_log"')) { + if (compiledQuery.sql.includes('FROM "__undo_log"')) { return [...rows]; } return []; diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts index 33ab9b8126..faa2639acc 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.ts @@ -3608,7 +3608,7 @@ const buildComputedUpdateEvents = ( newVersion: change.oldVersion + 1, changes: change.changes.map((fieldChange) => ({ fieldId: fieldChange.fieldId, - oldValue: null as unknown, + oldValue: fieldChange.oldValue, newValue: fieldChange.newValue, })), })); diff --git a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.update.spec.ts b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.update.spec.ts index 880c236878..6144ffe0b8 100644 --- a/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.update.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/record/repository/PostgresTableRecordRepository.update.spec.ts @@ -7,6 +7,7 @@ import { LinkFieldConfig, RecordByIdsSpec, RecordId, + RecordsBatchUpdated, Table, TableRecord, TableId, @@ -23,6 +24,7 @@ import type { ISpecification, ITablePersistenceDTO, LinkField, + IEventBus, IRecordOrderCalculator, ITableRecordConditionSpecVisitor, } from '@teable/v2-core'; @@ -165,7 +167,7 @@ const createRecordingDb = (rowProvider?: RowProvider) => { if (compiledQuery.sql.includes("current_setting('teable.undo_batch_id', true)")) { return [{ batch_id: currentUndoBatchId ?? null }]; } - if (compiledQuery.sql.includes('FROM "public"."__undo_log"')) { + if (compiledQuery.sql.includes('FROM "__undo_log"')) { return [ { operation: 'UPDATE', @@ -316,7 +318,7 @@ const isUndoCaptureQuery = (query: CompiledQuery) => { const text = query.sql; return ( text.includes('teable_undo_capture_') || - text.includes('"public"."__undo_log"') || + text.includes('"__undo_log"') || text.includes("table_name = '__undo_log'") || text.includes('__teable_capture_undo_row') || text.includes('FROM pg_trigger AS t') || @@ -349,7 +351,7 @@ const createUndoLogRowProvider = ( }> ): RowProvider => { return (compiledQuery) => { - if (compiledQuery.sql.includes('FROM "public"."__undo_log"')) { + if (compiledQuery.sql.includes('FROM "__undo_log"')) { return [...rows]; } return []; @@ -1718,6 +1720,7 @@ const createHybridRepository = ( computedUpdatePlanner?: ComputedUpdatePlanner; computedUpdateOutbox?: IComputedUpdateOutbox; computedUpdateStrategy?: IUpdateStrategy; + eventBus?: IEventBus; } = {} ) => { const logger = createLogger(); @@ -1744,7 +1747,7 @@ const createHybridRepository = ( db as unknown as Kysely, logger ), - createNoopEventBus(), + overrides.eventBus ?? createNoopEventBus(), hasher ); }; @@ -1947,6 +1950,136 @@ describe('PostgresTableRecordRepository hybrid/async computed update', () => { vi.useRealTimers(); }); + it('publishes computed update events with old values in sync mode', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + const baseId = BaseId.create(BASE_ID)._unsafeUnwrap(); + const tableId = TableId.create(TABLE_ID)._unsafeUnwrap(); + const textFieldId = FieldId.create(NAME_FIELD_ID)._unsafeUnwrap(); + const recordIdA = RecordId.create(RECORD_ID)._unsafeUnwrap(); + const actorId = ActorId.create(ACTOR_ID)._unsafeUnwrap(); + + const builder = Table.builder() + .withId(tableId) + .withBaseId(baseId) + .withName(TableName.create('SyncComputedEventTable')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(textFieldId) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder.view().defaultGrid().done(); + + const table = builder.build()._unsafeUnwrap(); + table + .getField((field) => field.id().equals(textFieldId)) + ._unsafeUnwrap() + .setDbFieldName(DbFieldName.rehydrate('col_name')._unsafeUnwrap()) + ._unsafeUnwrap(); + + const updateResult = table + .updateRecord(recordIdA, new Map([[NAME_FIELD_ID, 'Bob']])) + ._unsafeUnwrap(); + + const planStageSpy = vi.fn().mockResolvedValue( + ok({ + baseId: table.baseId(), + seedTableId: table.id(), + seedRecordIds: [recordIdA], + extraSeedRecords: [], + steps: [{ tableId, level: 0, fieldIds: [textFieldId] }], + edges: [], + estimatedComplexity: 1, + changeType: 'update' as const, + }) + ); + + const computedUpdatePlanner = { + plan: async () => ok({ steps: [] }), + planStage: planStageSpy, + resolveBeforeImageRequirements: async () => + ok({ needsBeforeImage: false, requiredFieldIds: [] }), + } as unknown as ComputedUpdatePlanner; + + const syncStrategy = { + mode: 'sync' as const, + name: 'sync', + execute: async () => + ok({ + changesByStep: [ + { + tableId: tableId.toString(), + recordChanges: [ + { + recordId: recordIdA.toString(), + oldVersion: 7, + changes: [ + { + fieldId: textFieldId.toString(), + oldValue: 'Plastic', + newValue: 'Plastic', + }, + ], + }, + ], + }, + ], + }), + scheduleDispatch: vi.fn(), + }; + + const publishedEvents: unknown[] = []; + const eventBus: IEventBus = { + publish: async (context, event) => { + publishedEvents.push(event); + return createNoopEventBus().publish(context, event); + }, + publishMany: async (_context, events) => { + publishedEvents.push(...events); + return ok(undefined); + }, + subscribe: () => ({ unsubscribe: () => undefined }), + } as IEventBus; + + const tableName = `"${BASE_ID}"."${TABLE_ID}"`; + const { db } = createRecordingDb((compiledQuery) => { + if ( + compiledQuery.sql.includes(`UPDATE ${tableName} AS t`) && + compiledQuery.sql.includes('RETURNING') + ) { + return [{ record_id: recordIdA.toString(), new_version: 2 }]; + } + return []; + }); + + const repo = createHybridRepository(db, table, { + computedUpdatePlanner, + computedUpdateStrategy: syncStrategy, + eventBus, + }); + + function* batches() { + yield ok([updateResult]); + } + + const result = await repo.updateManyStream({ actorId }, table, batches()); + expect(result.isOk()).toBe(true); + + const batchEvent = publishedEvents.find( + (event): event is RecordsBatchUpdated => event instanceof RecordsBatchUpdated + ); + expect(batchEvent?.updates[0]?.changes[0]).toMatchObject({ + fieldId: textFieldId.toString(), + oldValue: 'Plastic', + newValue: 'Plastic', + }); + + vi.useRealTimers(); + }); + it('does not call planStage in hybrid mode for updateMany (non-computed fields skip early)', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.spec.ts b/packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.spec.ts index f3afb5d506..8608d6d3e2 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.spec.ts @@ -703,4 +703,115 @@ describe('PostgresTableSchemaRepository', () => { expect(columnResult?.column_name).toBe(fkColumnName); }); + + it('routes batch reference metadata statements to the meta DB when databases are split', async () => { + const dataPglite = await PGlite.create(); + const metaPglite = await PGlite.create(); + const dataDb = new Kysely({ + dialect: new PGliteDialect(dataPglite), + }); + const metaDb = new Kysely({ + dialect: new PGliteDialect(metaPglite), + }); + + try { + await installUndoCaptureGlobals(dataDb); + await createReferenceTable(metaDb); + + const baseId = BaseId.generate()._unsafeUnwrap(); + const actorId = ActorId.create('system')._unsafeUnwrap(); + const context: IExecutionContext = { actorId }; + + const hostTableBuilder = Table.builder() + .withBaseId(baseId) + .withId(TableId.generate()._unsafeUnwrap()) + .withName(TableName.create('Batch Split Host')._unsafeUnwrap()); + hostTableBuilder + .field() + .singleLineText() + .withName(FieldName.create('Name')._unsafeUnwrap()) + .done(); + hostTableBuilder.view().defaultGrid().done(); + const hostTable = hostTableBuilder.build()._unsafeUnwrap(); + + const foreignTableBuilder = Table.builder() + .withBaseId(baseId) + .withId(TableId.generate()._unsafeUnwrap()) + .withName(TableName.create('Batch Split Foreign')._unsafeUnwrap()); + foreignTableBuilder + .field() + .singleLineText() + .withName(FieldName.create('Title')._unsafeUnwrap()) + .done(); + foreignTableBuilder.view().defaultGrid().done(); + const foreignTable = foreignTableBuilder.build()._unsafeUnwrap(); + const foreignPrimaryFieldId = foreignTable.getFields()[0]?.id(); + if (!foreignPrimaryFieldId) { + throw new Error('Foreign table primary field missing'); + } + + const linkFieldId = FieldId.generate()._unsafeUnwrap(); + const symmetricFieldId = FieldId.generate()._unsafeUnwrap(); + const linkDbConfig = LinkFieldConfig.buildDbConfig({ + fkHostTableName: DbTableName.rehydrate( + `${baseId.toString()}.${foreignTable.id().toString()}` + )._unsafeUnwrap(), + relationship: LinkRelationship.oneMany(), + fieldId: linkFieldId, + symmetricFieldId, + isOneWay: false, + })._unsafeUnwrap(); + const linkConfig = LinkFieldConfig.create({ + relationship: 'oneMany', + foreignTableId: foreignTable.id().toString(), + lookupFieldId: foreignPrimaryFieldId.toString(), + isOneWay: false, + symmetricFieldId: symmetricFieldId.toString(), + fkHostTableName: linkDbConfig.fkHostTableName.value()._unsafeUnwrap(), + selfKeyName: linkDbConfig.selfKeyName.value()._unsafeUnwrap(), + foreignKeyName: linkDbConfig.foreignKeyName.value()._unsafeUnwrap(), + })._unsafeUnwrap(); + const linkField = createLinkField({ + id: linkFieldId, + name: FieldName.create('Foreign')._unsafeUnwrap(), + config: linkConfig, + })._unsafeUnwrap(); + const hostWithLink = hostTable + .update((mutator) => mutator.addField(linkField)) + ._unsafeUnwrap().table; + + const tableRepository = new FakeTableRepository([hostWithLink, foreignTable]); + const repository = new PostgresTableSchemaRepository( + dataDb, + tableRepository as never, + new FakeComputedFieldBackfillService(), + new FakeComputedFieldCascadeService(), + new FakeComputedUpdatePlanner() as never, + new FakeFieldDependencyGraph() as never, + metaDb + ); + + const result = await repository.insertMany(context, [hostWithLink, foreignTable]); + result._unsafeUnwrap(); + + const referenceRows = await metaDb + .selectFrom('reference') + .select(['to_field_id', 'from_field_id']) + .execute(); + + expect(referenceRows).toEqual([ + { + to_field_id: linkFieldId.toString(), + from_field_id: foreignPrimaryFieldId.toString(), + }, + ]); + + await expect( + dataDb.selectFrom('reference').select(['to_field_id', 'from_field_id']).execute() + ).rejects.toThrow(/relation "reference" does not exist|does not exist/i); + } finally { + await dataDb.destroy(); + await metaDb.destroy(); + } + }); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.ts b/packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.ts index 1a84d49f49..393121079d 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/repositories/PostgresTableSchemaRepository.ts @@ -64,7 +64,10 @@ import type { TableSchemaStatementBuilder } from '../rules/core'; import { DependencyChangeDetectorVisitor } from '../visitors/DependencyChangeDetectorVisitor'; import { FieldValueChangeCollectorVisitor } from '../visitors/FieldValueChangeCollectorVisitor'; import type { ICreateTableBuilderRef } from '../visitors/PostgresTableSchemaFieldCreateVisitor'; -import { PostgresTableSchemaFieldCreateVisitor } from '../visitors/PostgresTableSchemaFieldCreateVisitor'; +import { + buildTableLocationsById, + PostgresTableSchemaFieldCreateVisitor, +} from '../visitors/PostgresTableSchemaFieldCreateVisitor'; import { TableAddFieldCollectorVisitor } from '../visitors/TableAddFieldCollectorVisitor'; import { TableSchemaUpdateVisitor } from '../visitors/TableSchemaUpdateVisitor'; @@ -154,12 +157,15 @@ export class PostgresTableSchemaRepository implements ITableSchemaRepository { const flush = async () => { if (!currentScope || batch.length === 0) return; const targetDb = currentScope === 'meta' ? this.resolveMetaDb(context) : db; - await executeTableSchemaStatements(targetDb, batch, trace); + await executeTableSchemaStatements(targetDb, batch, { + ...trace, + enforceRelationAccess: this.db !== this.metaDb, + }); batch = []; }; for (const statement of statements) { - const statementScope = statement.scope ?? 'data'; + const statementScope = statement.scope; if (currentScope && currentScope !== statementScope) { await flush(); } @@ -278,7 +284,8 @@ export class PostgresTableSchemaRepository implements ITableSchemaRepository { private async insertTableFieldSchemas( context: IExecutionContext, - table: Table + table: Table, + knownTables: ReadonlyArray = [table] ): Promise> { const repository = this; return await safeTry(async function* () { @@ -287,6 +294,7 @@ export class PostgresTableSchemaRepository implements ITableSchemaRepository { const { schema, tableName } = yield* table .dbTableName() .andThen((name) => name.split({ defaultSchema: null })); + const tableLocationsById = yield* buildTableLocationsById(knownTables); const db = resolvePostgresDbOrTx(repository.db, context); const visitor = PostgresTableSchemaFieldCreateVisitor.forSchemaUpdate({ @@ -294,6 +302,7 @@ export class PostgresTableSchemaRepository implements ITableSchemaRepository { schema, tableName, tableId: table.id().toString(), + tableLocationsById, }); const statements = yield* visitor.apply(table); @@ -302,7 +311,7 @@ export class PostgresTableSchemaRepository implements ITableSchemaRepository { } try { - await executeTableSchemaStatements(db, statements, { + await repository.executeScopedTableSchemaStatements(context, db, statements, { tracer: context.tracer, attributes: { [TeableSpanAttributes.TABLE_ID]: table.id().toString(), @@ -383,6 +392,7 @@ export class PostgresTableSchemaRepository implements ITableSchemaRepository { schema, tableName, tableId: table.id().toString(), + tableLocationsById: yield* buildTableLocationsById([table]), }); const fieldStatements = yield* visitor.apply(table); @@ -430,15 +440,17 @@ export class PostgresTableSchemaRepository implements ITableSchemaRepository { @TraceSpan() async insertMany( context: IExecutionContext, - tables: ReadonlyArray
+ tables: ReadonlyArray
, + options?: { knownTables?: ReadonlyArray
} ): Promise> { + const knownTables = options?.knownTables ?? tables; for (const table of tables) { const result = await this.insertTableSkeleton(context, table); if (result.isErr()) return err(result.error); } for (const table of tables) { - const result = await this.insertTableFieldSchemas(context, table); + const result = await this.insertTableFieldSchemas(context, table, knownTables); if (result.isErr()) return err(result.error); } diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/ISchemaRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/ISchemaRule.ts index 40a8646d7e..88d3f83fe1 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/ISchemaRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/ISchemaRule.ts @@ -8,11 +8,16 @@ import type { SchemaRuleContext } from '../context/SchemaRuleContext'; * Represents a compiled SQL statement that can be executed against the database. * This is the same type used in the existing visitors. */ -export type TableSchemaStatementBuilder = { - scope?: 'data' | 'meta'; +export type TableSchemaStatementScope = 'data' | 'meta'; + +export type TableSchemaStatementCompiler = { compile: (executorProvider: QueryExecutorProvider) => CompiledQuery; }; +export type TableSchemaStatementBuilder = TableSchemaStatementCompiler & { + readonly scope: TableSchemaStatementScope; +}; + export type SchemaRuleI18nValue = string | number | boolean; export interface SchemaRuleI18nMessage { diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/SchemaStatementAccessPolicy.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/SchemaStatementAccessPolicy.ts new file mode 100644 index 0000000000..da127a12d1 --- /dev/null +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/SchemaStatementAccessPolicy.ts @@ -0,0 +1,81 @@ +import type { CompiledQuery } from 'kysely'; + +import type { TableSchemaStatementBuilder } from './ISchemaRule'; + +export type SchemaStatementRelationAccess = { + readonly relation: string; + readonly plane: 'meta' | 'data'; +}; + +const metaRelations = [ + 'space', + 'base', + 'table_meta', + 'field', + 'view', + 'reference', + 'schema_operation', + 'data_db_connection', + 'space_data_db_binding', +] as const; + +const dataRelations = [ + 'record_history', + 'record_trash', + 'table_trash', + '__undo_log', + 'computed_update_outbox', + 'computed_update_outbox_seed', + 'computed_update_dead_letter', + 'computed_update_pause_scope', +] as const; + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const relationAccessPattern = (relation: string): RegExp => { + const escaped = escapeRegExp(relation); + return new RegExp( + [ + `\\b(?:from|join|update|into|table)\\s+(?:only\\s+)?(?:(?:"public"|public)\\s*\\.\\s*)?(?:"${escaped}"|${escaped})\\b`, + `\\bto_regclass\\(\\s*'(?:(?:"public"|public)\\.)?(?:"${escaped}"|${escaped})'\\s*\\)`, + ].join('|'), + 'i' + ); +}; + +const findAccesses = ( + sql: string, + relations: ReadonlyArray, + plane: SchemaStatementRelationAccess['plane'] +): ReadonlyArray => + relations + .filter((relation) => relationAccessPattern(relation).test(sql)) + .map((relation) => ({ relation, plane })); + +export const findSchemaStatementRelationAccessViolations = ( + statement: TableSchemaStatementBuilder, + compiled: CompiledQuery +): ReadonlyArray => { + if (statement.scope === 'data') { + return findAccesses(compiled.sql, metaRelations, 'meta'); + } + + return findAccesses(compiled.sql, dataRelations, 'data'); +}; + +export const assertSchemaStatementRelationAccess = ( + statement: TableSchemaStatementBuilder, + compiled: CompiledQuery +): void => { + const violations = findSchemaStatementRelationAccessViolations(statement, compiled); + if (violations.length === 0) { + return; + } + + const relationList = violations + .map((violation) => `${violation.plane}:${violation.relation}`) + .join(', '); + throw new Error( + `Schema statement scope "${statement.scope}" cannot access relations owned by another storage plane: ${relationList}` + ); +}; diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/index.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/index.ts index 92b76fa4d6..2f013010fa 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/index.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/core/index.ts @@ -1,3 +1,8 @@ +export type { SchemaStatementRelationAccess } from './SchemaStatementAccessPolicy'; +export { + assertSchemaStatementRelationAccess, + findSchemaStatementRelationAccessViolations, +} from './SchemaStatementAccessPolicy'; export type { ISchemaRule, SchemaRuleManualRepairOptions, @@ -5,4 +10,6 @@ export type { SchemaRuleRepairHint, SchemaRuleValidationResult, TableSchemaStatementBuilder, + TableSchemaStatementCompiler, + TableSchemaStatementScope, } from './ISchemaRule'; diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnExistsRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnExistsRule.ts index 06ff4fc5db..2600a01cc3 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnExistsRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnExistsRule.ts @@ -18,7 +18,11 @@ import type { SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; -import { dropColumnStatement, type TableIdentifier } from '../helpers/StatementBuilders'; +import { + dataStatement, + dropColumnStatement, + type TableIdentifier, +} from '../helpers/StatementBuilders'; import { ColumnUniqueConstraintRule } from './ColumnUniqueConstraintRule'; import { NotNullConstraintRule } from './NotNullConstraintRule'; @@ -152,7 +156,7 @@ export class ColumnExistsRule implements ISchemaRule { .alterTable(ctx.tableName) .addColumn(columnName, dataType, (col) => col.ifNotExists()); - return ok([statement]); + return ok([dataStatement(statement)]); }); } diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnUniqueConstraintRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnUniqueConstraintRule.ts index 2c8f82d89f..672fb4f108 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnUniqueConstraintRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ColumnUniqueConstraintRule.ts @@ -10,6 +10,7 @@ import type { TableSchemaStatementBuilder, } from '../core/ISchemaRule'; import { + dataStatement, dropConstraintStatement, dropIndexStatement, type TableIdentifier, @@ -142,7 +143,7 @@ export class ColumnUniqueConstraintRule implements ISchemaRule { .unique() .ifNotExists(); - return ok([statement]); + return ok([dataStatement(statement)]); }); } diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldSchemaRulesFactory.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldSchemaRulesFactory.ts index 515f55b637..042a149c9f 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldSchemaRulesFactory.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/FieldSchemaRulesFactory.ts @@ -57,6 +57,8 @@ export interface FieldSchemaRulesContext { tableName: string; /** Current table ID */ tableId: string; + /** Known logical table ID to physical table location mapping for batch schema creation. */ + tableLocationsById?: ReadonlyMap; } /** @@ -241,6 +243,7 @@ export class FieldSchemaRulesVisitor extends AbstractFieldVisitor col.ifNotExists()), + dataStatement( + schemaBuilder + .alterTable(target.tableName) + .addColumn(columnName, 'text', (col) => col.ifNotExists()) + ), ]; const isCurrentContextTarget = diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ForeignKeyRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ForeignKeyRule.ts index db0907ccde..c44b1a088d 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ForeignKeyRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/ForeignKeyRule.ts @@ -14,7 +14,6 @@ import type { import { countOrphanForeignKeyRows } from '../helpers/ForeignKeyDiagnostics'; import { createForeignKeyConstraintStatement, - createForeignKeyConstraintStatementFromTableMeta, dropConstraintStatement, type TableIdentifier, } from '../helpers/StatementBuilders'; @@ -390,24 +389,14 @@ export class ForeignKeyRule implements ISchemaRule { up(ctx: SchemaRuleContext): Result, DomainError> { const sourceTable = this.getLocalTable(ctx); return ok([ - this.targetTableMetaId - ? createForeignKeyConstraintStatementFromTableMeta( - sourceTable, - this.constraintName, - this.columnName, - this.targetTableMetaId, - '__id', - this.onDelete, - this.targetTable - ) - : createForeignKeyConstraintStatement( - sourceTable, - this.constraintName, - this.columnName, - this.targetTable, - '__id', - this.onDelete - ), + createForeignKeyConstraintStatement( + sourceTable, + this.constraintName, + this.columnName, + this.targetTable, + '__id', + this.onDelete + ), ]); } diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnMetaRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnMetaRule.ts index 3962860ca9..6207e94dd7 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnMetaRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/GeneratedColumnMetaRule.ts @@ -13,7 +13,11 @@ import type { SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; -import { dropColumnStatement, type TableIdentifier } from '../helpers/StatementBuilders'; +import { + dataStatement, + dropColumnStatement, + type TableIdentifier, +} from '../helpers/StatementBuilders'; import type { GeneratedColumnRule } from './GeneratedColumnRule'; /** @@ -91,9 +95,11 @@ export class GeneratedColumnMetaRule implements ISchemaRule { return ok([ dropColumnStatement(table, columnName), - schemaBuilder - .alterTable(ctx.tableName) - .addColumn(columnName, dataType, (column) => column.ifNotExists()), + dataStatement( + schemaBuilder + .alterTable(ctx.tableName) + .addColumn(columnName, dataType, (column) => column.ifNotExists()) + ), ]); }); } diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/JunctionTableRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/JunctionTableRule.ts index 7bf4ab3fc6..82ebfbcf3b 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/JunctionTableRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/JunctionTableRule.ts @@ -16,8 +16,8 @@ import { countOrphanForeignKeyRows } from '../helpers/ForeignKeyDiagnostics'; import { backfillJunctionTableFromLinkValueStatement, createForeignKeyConstraintStatement, - createForeignKeyConstraintStatementFromTableMeta, createIndexStatement, + dataStatement, dropConstraintStatement, dropIndexStatement, dropTableStatement, @@ -268,43 +268,56 @@ export class JunctionTableExistsRule implements ISchemaRule { 'double precision' ); } - statements.push(createTableBuilder); + statements.push(dataStatement(createTableBuilder)); // Also repair partially-created junction tables by adding any missing columns. statements.push( - schemaBuilder - .alterTable(config.junctionTable.tableName) - .addColumn('__id', 'serial', (col) => col.ifNotExists()) + dataStatement( + schemaBuilder + .alterTable(config.junctionTable.tableName) + .addColumn('__id', 'serial', (col) => col.ifNotExists()) + ) ); statements.push( - schemaBuilder - .alterTable(config.junctionTable.tableName) - .addColumn(config.selfKeyName, 'text', (col) => col.ifNotExists()) + dataStatement( + schemaBuilder + .alterTable(config.junctionTable.tableName) + .addColumn(config.selfKeyName, 'text', (col) => col.ifNotExists()) + ) ); statements.push( - schemaBuilder - .alterTable(config.junctionTable.tableName) - .addColumn(config.foreignKeyName, 'text', (col) => col.ifNotExists()) + dataStatement( + schemaBuilder + .alterTable(config.junctionTable.tableName) + .addColumn(config.foreignKeyName, 'text', (col) => col.ifNotExists()) + ) ); if (config.orderColumnName) { statements.push( - schemaBuilder - .alterTable(config.junctionTable.tableName) - .addColumn(config.orderColumnName, 'double precision', (col) => col.ifNotExists()) + dataStatement( + schemaBuilder + .alterTable(config.junctionTable.tableName) + .addColumn(config.orderColumnName, 'double precision', (col) => col.ifNotExists()) + ) ); } const sourceLinkValueColumnName = yield* resolveColumnName(self.field); + const sameColumnLinkFieldCount = + ctx.table?.getFields().filter((field) => { + const dbFieldName = field.dbFieldName().andThen((name) => name.value()); + return dbFieldName.isOk() && dbFieldName.value === sourceLinkValueColumnName; + }).length ?? 1; statements.push( backfillJunctionTableFromLinkValueStatement({ sourceTable: config.sourceTable, - sourceTableId: ctx.tableId, sourceLinkValueColumnName, junctionTable: config.junctionTable, selfKeyName: config.selfKeyName, foreignKeyName: config.foreignKeyName, orderColumnName: config.orderColumnName, + skipBackfill: sameColumnLinkFieldCount > 1, }) ); @@ -376,7 +389,7 @@ export class JunctionTableUniqueConstraintRule implements ISchemaRule { .alterTable(this.junctionTable.tableName) .addUniqueConstraint(this.constraintName, [this.selfKeyName, this.foreignKeyName]); - return ok([builder]); + return ok([dataStatement(builder)]); } down(_ctx: SchemaRuleContext): Result, DomainError> { @@ -767,19 +780,6 @@ export class JunctionTableForeignKeyRule implements ISchemaRule { } up(_ctx: SchemaRuleContext): Result, DomainError> { - if (this.targetTableMetaId) { - return ok([ - createForeignKeyConstraintStatementFromTableMeta( - this.junctionTable, - this.constraintName, - this.columnName, - this.targetTableMetaId, - '__id', - 'CASCADE', - this.targetTable - ), - ]); - } return ok([ createForeignKeyConstraintStatement( this.junctionTable, diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/LinkValueColumnRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/LinkValueColumnRule.ts index 3fbc1782e3..53987b0c62 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/LinkValueColumnRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/LinkValueColumnRule.ts @@ -10,7 +10,11 @@ import type { SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; -import { dropColumnStatement, type TableIdentifier } from '../helpers/StatementBuilders'; +import { + dataStatement, + dropColumnStatement, + type TableIdentifier, +} from '../helpers/StatementBuilders'; /** * Schema rule for creating/dropping a JSONB column for link field values. @@ -85,7 +89,7 @@ export class LinkValueColumnRule implements ISchemaRule { .alterTable(ctx.tableName) .addColumn(columnName, 'jsonb', (col) => col.ifNotExists()); - return ok([statement]); + return ok([dataStatement(statement)]); }); } diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/NotNullConstraintRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/NotNullConstraintRule.ts index 1736171945..f387845ea8 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/NotNullConstraintRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/NotNullConstraintRule.ts @@ -10,6 +10,7 @@ import type { SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; +import { dataStatement } from '../helpers/StatementBuilders'; /** * Schema rule for adding/removing NOT NULL constraint on a column. @@ -80,7 +81,7 @@ export class NotNullConstraintRule implements ISchemaRule { `ALTER TABLE "${schemaName}"."${ctx.tableName}" ALTER COLUMN "${columnName}" SET NOT NULL` ); - return ok([statement]); + return ok([dataStatement(statement)]); }); } @@ -94,7 +95,7 @@ export class NotNullConstraintRule implements ISchemaRule { `ALTER TABLE "${schemaName}"."${ctx.tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL` ); - return ok([statement]); + return ok([dataStatement(statement)]); }); } } diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/OrderColumnRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/OrderColumnRule.ts index 47e7f760f8..39f9407b21 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/OrderColumnRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/OrderColumnRule.ts @@ -9,7 +9,11 @@ import type { SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; -import { dropColumnStatement, type TableIdentifier } from '../helpers/StatementBuilders'; +import { + dataStatement, + dropColumnStatement, + type TableIdentifier, +} from '../helpers/StatementBuilders'; /** * Schema rule for creating/dropping an order column for link fields. @@ -99,7 +103,7 @@ export class OrderColumnRule implements ISchemaRule { .alterTable(targetTable.tableName) .addColumn(columnName, 'double precision', (col) => col.ifNotExists()); - return ok([statement]); + return ok([dataStatement(statement)]); } down(_ctx: SchemaRuleContext): Result, DomainError> { diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SchemaRules.pglite.spec.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SchemaRules.pglite.spec.ts index 0762bb8c5e..29b27bd17d 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SchemaRules.pglite.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SchemaRules.pglite.spec.ts @@ -1393,7 +1393,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { expect((await rule.isValid(ctx))._unsafeUnwrap().valid).toBe(true); }); - it('should resolve the FK target table from table_meta dbTableName', async () => { + it('should create FK constraint against resolved table_meta dbTableName without metadata access in DDL', async () => { const targetTableMetaId = createValidTableId('logical_target'); const targetPhysicalTableName = 'students_custom'; @@ -1411,7 +1411,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { const rule = ForeignKeyRule.forField( field, 'fk_col', - { schema: TEST_SCHEMA, tableName: targetTableMetaId }, + { schema: TEST_SCHEMA, tableName: targetPhysicalTableName }, fkColumnRule, 'Students', 'CASCADE', @@ -1423,7 +1423,9 @@ describe('Schema Rules Unit Tests with PGlite', () => { expect((await rule.isValid(ctx))._unsafeUnwrap().valid).toBe(false); for (const stmt of rule.up(ctx)._unsafeUnwrap()) { - await db.executeQuery(stmt.compile(db)); + const compiled = stmt.compile(db); + expect(compiled.sql).not.toContain('table_meta'); + await db.executeQuery(compiled); } expect((await rule.isValid(ctx))._unsafeUnwrap().valid).toBe(true); @@ -2234,8 +2236,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { expect((await rule.isValid(ctx))._unsafeUnwrap().valid).toBe(false); }); - it('should resolve the junction FK target table from table_meta dbTableName', async () => { - const logicalTargetTableName = FOREIGN_TABLE_META_ID; + it('should create junction FK against resolved table_meta dbTableName without metadata access in DDL', async () => { const physicalTargetTableName = 'test_jct_fk_target_legacy'; await createTestTable(SOURCE_TABLE); @@ -2265,7 +2266,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { foreignKeyName: 'foreign_key', orderColumnName: 'order_col', sourceTable: { schema: TEST_SCHEMA, tableName: SOURCE_TABLE }, - foreignTable: { schema: TEST_SCHEMA, tableName: logicalTargetTableName }, + foreignTable: { schema: TEST_SCHEMA, tableName: physicalTargetTableName }, foreignTableMetaId: FOREIGN_TABLE_META_ID, withIndexes: false, }; @@ -2274,7 +2275,7 @@ describe('Schema Rules Unit Tests with PGlite', () => { linkField, { schema: TEST_SCHEMA, tableName: JUNCTION_TABLE }, 'foreign_key', - { schema: TEST_SCHEMA, tableName: logicalTargetTableName }, + { schema: TEST_SCHEMA, tableName: physicalTargetTableName }, 'foreign', junctionRule, FOREIGN_TABLE_META_ID @@ -2284,7 +2285,9 @@ describe('Schema Rules Unit Tests with PGlite', () => { expect((await rule.isValid(ctx))._unsafeUnwrap().valid).toBe(false); for (const stmt of rule.up(ctx)._unsafeUnwrap()) { - await db.executeQuery(stmt.compile(db)); + const compiled = stmt.compile(db); + expect(compiled.sql).not.toContain('table_meta'); + await db.executeQuery(compiled); } expect((await rule.isValid(ctx))._unsafeUnwrap().valid).toBe(true); @@ -4349,14 +4352,29 @@ describe('Schema Rules Unit Tests with PGlite', () => { foreignKeyName, hasOrderColumn: true, })._unsafeUnwrap(); - const table = createTableAggregate(sourceTableName, field); + const otherField = createRealLinkField({ + id: 'otherLinkB', + name: 'Other Link', + dbFieldName: 'link_value', + relationship: 'manyMany', + foreignTableId: targetTableName, + fkHostTableName: junctionTableName, + selfKeyName, + foreignKeyName, + hasOrderColumn: true, + })._unsafeUnwrap(); + const table = createTableAggregateWithId( + createValidTableId(`table_${sourceTableName}`), + sourceTableName, + [field, otherField] + ); const sourceTableId = table.id().toString(); await sql` INSERT INTO field (id, name, type, table_id, db_field_name) VALUES (${field.id().toString()}, 'ManyMany Ambiguous Link', 'link', ${sourceTableId}, 'link_value'), - (${createValidFieldId('otherLinkB')}, 'Other Link', 'link', ${sourceTableId}, 'link_value') + (${otherField.id().toString()}, 'Other Link', 'link', ${sourceTableId}, 'link_value') `.execute(db); await sql.raw(`DROP TABLE ${TEST_SCHEMA}.${junctionTableName}`).execute(db); diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SelectOptionsMetaRule.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SelectOptionsMetaRule.ts index 9a6417a755..a158648eaa 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SelectOptionsMetaRule.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/field/SelectOptionsMetaRule.ts @@ -13,6 +13,8 @@ import type { } from '../core/ISchemaRule'; import { compressSql, + dataStatement, + metaStatement, quoteIdentifier, quoteLiteral, quoteTableIdentifier, @@ -79,6 +81,14 @@ export class SelectOptionsMetaRule implements ISchemaRule { return choices; } + private sourceChoices(): ReadonlyArray { + const choices: SelectChoiceDto[] = []; + for (const option of this.field.selectOptions()) { + choices.push(option.toDto()); + } + return choices; + } + private parseOptions(raw: unknown): Record | undefined { if (raw == null) { return {}; @@ -188,7 +198,7 @@ export class SelectOptionsMetaRule implements ISchemaRule { }) .where('id', '=', fieldId); - return ok([rule.repairStoredChoiceValues(ctx, columnName), updateOptions]); + return ok([rule.repairStoredChoiceValues(ctx, columnName), metaStatement(updateOptions)]); }); } @@ -201,20 +211,16 @@ export class SelectOptionsMetaRule implements ISchemaRule { }) .where('id', '=', fieldId); - return ok([updateOptions]); + return ok([metaStatement(updateOptions)]); } private buildChoiceTokenMapSql(): string { return compressSql(` - current_choices AS ( + source_choices AS ( SELECT choice->>'id' AS old_id, choice->>'name' AS old_name - FROM field f - CROSS JOIN LATERAL jsonb_array_elements( - COALESCE(f.options::jsonb->'choices', '[]'::jsonb) - ) AS choice - WHERE f.id = ${quoteLiteral(this.field.id().toString())} + FROM jsonb_array_elements(${quoteLiteral(JSON.stringify(this.sourceChoices()))}::jsonb) AS choice ), canonical_choices AS ( SELECT @@ -232,7 +238,7 @@ export class SelectOptionsMetaRule implements ISchemaRule { WHEN c.old_id IS NOT NULL AND c.old_id <> canonical.id THEN c.old_id END AS token, canonical.name AS canonical_name - FROM current_choices c + FROM source_choices c CROSS JOIN LATERAL ( SELECT e.id, e.name FROM canonical_choices e @@ -246,7 +252,7 @@ export class SelectOptionsMetaRule implements ISchemaRule { WHEN c.old_name IS NOT NULL AND c.old_name <> canonical.name THEN c.old_name END AS token, canonical.name AS canonical_name - FROM current_choices c + FROM source_choices c CROSS JOIN LATERAL ( SELECT e.id, e.name FROM canonical_choices e @@ -269,44 +275,48 @@ export class SelectOptionsMetaRule implements ISchemaRule { const column = quoteIdentifier(columnName); if (this.field.type().toString() === 'multipleSelect') { - return sql.raw( - compressSql(` - WITH ${this.buildChoiceTokenMapSql()} - UPDATE ${tableName} AS t - SET ${column} = COALESCE( - ( - SELECT jsonb_agg(d.mapped_value ORDER BY d.ord) - FROM ( - SELECT mapped_value, MIN(ord) AS ord + return dataStatement( + sql.raw( + compressSql(` + WITH ${this.buildChoiceTokenMapSql()} + UPDATE ${tableName} AS t + SET ${column} = COALESCE( + ( + SELECT jsonb_agg(d.mapped_value ORDER BY d.ord) FROM ( - SELECT - COALESCE(m.canonical_name, elem.value #>> '{}') AS mapped_value, - elem.ord - FROM jsonb_array_elements(t.${column}) WITH ORDINALITY AS elem(value, ord) - LEFT JOIN choice_token_map m - ON jsonb_typeof(elem.value) = 'string' - AND elem.value #>> '{}' = m.token - ) expanded - GROUP BY mapped_value - ) d - ), - '[]'::jsonb - ) - WHERE t.${column} IS NOT NULL - AND jsonb_typeof(t.${column}) = 'array' - AND EXISTS (SELECT 1 FROM choice_token_map) + SELECT mapped_value, MIN(ord) AS ord + FROM ( + SELECT + COALESCE(m.canonical_name, elem.value #>> '{}') AS mapped_value, + elem.ord + FROM jsonb_array_elements(t.${column}) WITH ORDINALITY AS elem(value, ord) + LEFT JOIN choice_token_map m + ON jsonb_typeof(elem.value) = 'string' + AND elem.value #>> '{}' = m.token + ) expanded + GROUP BY mapped_value + ) d + ), + '[]'::jsonb + ) + WHERE t.${column} IS NOT NULL + AND jsonb_typeof(t.${column}) = 'array' + AND EXISTS (SELECT 1 FROM choice_token_map); `) + ) ); } - return sql.raw( - compressSql(` - WITH ${this.buildChoiceTokenMapSql()} - UPDATE ${tableName} AS t - SET ${column} = m.canonical_name - FROM choice_token_map m - WHERE t.${column} = m.token + return dataStatement( + sql.raw( + compressSql(` + WITH ${this.buildChoiceTokenMapSql()} + UPDATE ${tableName} AS t + SET ${column} = m.canonical_name + FROM choice_token_map m + WHERE t.${column} = m.token; `) + ) ); } } diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/StatementBuilders.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/StatementBuilders.ts index 58a23aa68d..01ccfb6491 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/StatementBuilders.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/StatementBuilders.ts @@ -1,6 +1,10 @@ import { sql } from 'kysely'; -import type { TableSchemaStatementBuilder } from '../core/ISchemaRule'; +import type { + TableSchemaStatementBuilder, + TableSchemaStatementCompiler, + TableSchemaStatementScope, +} from '../core/ISchemaRule'; /** * Represents a table in the database with optional schema. @@ -18,13 +22,22 @@ export const quoteTableIdentifier = (target: TableIdentifier): string => ? `${quoteIdentifier(target.schema)}.${quoteIdentifier(target.tableName)}` : quoteIdentifier(target.tableName); -export const metaStatement = ( - statement: TableSchemaStatementBuilder +const scopedStatement = ( + scope: TableSchemaStatementScope, + statement: TableSchemaStatementCompiler ): TableSchemaStatementBuilder => ({ - scope: 'meta', + scope, compile: (executorProvider) => statement.compile(executorProvider), }); +export const dataStatement = ( + statement: TableSchemaStatementCompiler +): TableSchemaStatementBuilder => scopedStatement('data', statement); + +export const metaStatement = ( + statement: TableSchemaStatementCompiler +): TableSchemaStatementBuilder => scopedStatement('meta', statement); + /** * Builds a qualified table reference for SQL statements. */ @@ -43,13 +56,17 @@ export const dropColumnStatement = ( target: TableIdentifier, columnName: string ): TableSchemaStatementBuilder => - sql`alter table ${buildTableIdentifier(target)} drop column if exists ${sql.ref(columnName)} cascade`; + dataStatement( + sql`alter table ${buildTableIdentifier(target)} drop column if exists ${sql.ref( + columnName + )} cascade` + ); /** * Creates a DROP TABLE statement. */ export const dropTableStatement = (target: TableIdentifier): TableSchemaStatementBuilder => - sql`drop table if exists ${buildTableIdentifier(target)} cascade`; + dataStatement(sql`drop table if exists ${buildTableIdentifier(target)} cascade`); /** * Creates a DROP INDEX statement. @@ -59,9 +76,9 @@ export const dropIndexStatement = ( indexName: string ): TableSchemaStatementBuilder => { if (!target.schema) { - return sql`drop index if exists ${sql.ref(indexName)}`; + return dataStatement(sql`drop index if exists ${sql.ref(indexName)}`); } - return sql`drop index if exists ${sql.ref(target.schema)}.${sql.ref(indexName)}`; + return dataStatement(sql`drop index if exists ${sql.ref(target.schema)}.${sql.ref(indexName)}`); }; /** @@ -71,7 +88,11 @@ export const dropConstraintStatement = ( target: TableIdentifier, constraintName: string ): TableSchemaStatementBuilder => - sql`alter table if exists ${buildTableIdentifier(target)} drop constraint if exists ${sql.ref(constraintName)}`; + dataStatement( + sql`alter table if exists ${buildTableIdentifier(target)} drop constraint if exists ${sql.ref( + constraintName + )}` + ); /** * Creates a CREATE INDEX statement. @@ -81,7 +102,11 @@ export const createIndexStatement = ( indexName: string, columnName: string ): TableSchemaStatementBuilder => - sql`create index if not exists ${sql.ref(indexName)} on ${buildTableIdentifier(target)} (${sql.ref(columnName)})`; + dataStatement( + sql`create index if not exists ${sql.ref(indexName)} on ${buildTableIdentifier( + target + )} (${sql.ref(columnName)})` + ); /** * Creates a CREATE UNIQUE INDEX statement. @@ -91,7 +116,11 @@ export const createUniqueIndexStatement = ( indexName: string, columnName: string ): TableSchemaStatementBuilder => - sql`create unique index if not exists ${sql.ref(indexName)} on ${buildTableIdentifier(target)} (${sql.ref(columnName)})`; + dataStatement( + sql`create unique index if not exists ${sql.ref(indexName)} on ${buildTableIdentifier( + target + )} (${sql.ref(columnName)})` + ); /** * Creates a FK constraint statement that checks if the target table exists first. @@ -113,8 +142,9 @@ export const createForeignKeyConstraintStatement = ( : `"${targetTable.tableName}"`; const targetSchema = targetTable.schema ?? 'public'; - return sql.raw( - compressSql(` + return dataStatement( + sql.raw( + compressSql(` DO $$ BEGIN IF EXISTS ( @@ -135,81 +165,7 @@ export const createForeignKeyConstraintStatement = ( END $$; `) - ); -}; - -/** - * Creates a FK constraint statement that resolves the target physical table from table_meta. - * This is needed for legacy/custom db_table_name values that do not match logical table ids. - */ -export const createForeignKeyConstraintStatementFromTableMeta = ( - sourceTable: TableIdentifier, - constraintName: string, - columnName: string, - targetTableMetaId: string, - targetColumn: string, - onDelete: 'CASCADE' | 'SET NULL' | 'RESTRICT' = 'CASCADE', - fallbackTargetTable?: TableIdentifier -): TableSchemaStatementBuilder => { - const sourceTableFull = quoteTableIdentifier(sourceTable); - const fallbackTargetSchema = fallbackTargetTable?.schema ?? 'public'; - const fallbackTargetName = fallbackTargetTable?.tableName ?? ''; - - return sql.raw( - compressSql(` - DO $$ - DECLARE - target_tbl text; - target_schema text; - target_name text; - BEGIN - IF to_regclass('public.table_meta') IS NULL THEN - target_schema := ${quoteLiteral(fallbackTargetSchema)}; - target_name := ${quoteLiteral(fallbackTargetName)}; - ELSE - SELECT db_table_name INTO target_tbl - FROM table_meta - WHERE id = ${quoteLiteral(targetTableMetaId)} AND deleted_time IS NULL - LIMIT 1; - - IF target_tbl IS NULL THEN - target_schema := ${quoteLiteral(fallbackTargetSchema)}; - target_name := ${quoteLiteral(fallbackTargetName)}; - ELSIF strpos(target_tbl, '.') > 0 THEN - target_schema := split_part(target_tbl, '.', 1); - target_name := split_part(target_tbl, '.', 2); - ELSE - target_schema := 'public'; - target_name := target_tbl; - END IF; - END IF; - - IF target_name IS NULL OR target_name = '' THEN - RETURN; - END IF; - - IF EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_schema = target_schema - AND table_name = target_name - ) THEN - BEGIN - EXECUTE format( - 'ALTER TABLE ${sourceTableFull} ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES %I.%I (%I) ON DELETE ${onDelete}', - ${quoteLiteral(constraintName)}, - ${quoteLiteral(columnName)}, - target_schema, - target_name, - ${quoteLiteral(targetColumn)} - ); - EXCEPTION WHEN duplicate_object THEN - NULL; - END; - END IF; - END - $$; - `) + ) ); }; @@ -221,9 +177,11 @@ export const addGeneratedColumnStatement = ( columnName: string, definition: ReturnType ): TableSchemaStatementBuilder => - sql`alter table ${buildTableIdentifier(target)} add column if not exists ${sql.ref( - columnName - )} ${definition}`; + dataStatement( + sql`alter table ${buildTableIdentifier(target)} add column if not exists ${sql.ref( + columnName + )} ${definition}` + ); export const backfillFkColumnFromLinkValueStatement = ( target: TableIdentifier, @@ -236,8 +194,9 @@ export const backfillFkColumnFromLinkValueStatement = ( const schemaName = target.schema ?? 'public'; const tableName = target.tableName; - return sql.raw( - compressSql(` + return dataStatement( + sql.raw( + compressSql(` DO $$ BEGIN IF EXISTS ( @@ -259,6 +218,7 @@ export const backfillFkColumnFromLinkValueStatement = ( END $$; `) + ) ); }; @@ -275,8 +235,9 @@ export const backfillForeignHostFkColumnFromLinkValueStatement = (params: { const sourceSchemaName = params.sourceTable.schema ?? 'public'; const sourceTableName = params.sourceTable.tableName; - return sql.raw( - compressSql(` + return dataStatement( + sql.raw( + compressSql(` DO $$ BEGIN IF EXISTS ( @@ -316,17 +277,18 @@ export const backfillForeignHostFkColumnFromLinkValueStatement = (params: { END $$; `) + ) ); }; export const backfillJunctionTableFromLinkValueStatement = (params: { sourceTable: TableIdentifier; - sourceTableId?: string; sourceLinkValueColumnName: string; junctionTable: TableIdentifier; selfKeyName: string; foreignKeyName: string; orderColumnName?: string; + skipBackfill?: boolean; }): TableSchemaStatementBuilder => { const sourceTable = quoteTableIdentifier(params.sourceTable); const junctionTable = quoteTableIdentifier(params.junctionTable); @@ -344,13 +306,15 @@ export const backfillJunctionTableFromLinkValueStatement = (params: { ? `d.self_id, d.foreign_id, d.order_pos::double precision` : `d.self_id, d.foreign_id`; - return sql.raw( - compressSql(` + return dataStatement( + sql.raw( + compressSql(` DO $$ - DECLARE - same_db_field_count integer := 0; - has_field_deleted_time boolean := false; BEGIN + IF ${params.skipBackfill ? 'TRUE' : 'FALSE'} THEN + RETURN; + END IF; + IF EXISTS ( SELECT 1 FROM information_schema.columns @@ -358,36 +322,6 @@ export const backfillJunctionTableFromLinkValueStatement = (params: { AND table_name = ${quoteLiteral(sourceTableName)} AND column_name = ${quoteLiteral(params.sourceLinkValueColumnName)} ) THEN - IF ${params.sourceTableId ? 'TRUE' : 'FALSE'} AND EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'field' - AND column_name = 'db_field_name' - ) THEN - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'field' - AND column_name = 'deleted_time' - ) INTO has_field_deleted_time; - - IF has_field_deleted_time THEN - EXECUTE 'SELECT count(*) FROM field WHERE table_id = $1 AND db_field_name = $2 AND deleted_time IS NULL' - INTO same_db_field_count - USING ${quoteLiteral(params.sourceTableId ?? '')}, ${quoteLiteral(params.sourceLinkValueColumnName)}; - ELSE - EXECUTE 'SELECT count(*) FROM field WHERE table_id = $1 AND db_field_name = $2' - INTO same_db_field_count - USING ${quoteLiteral(params.sourceTableId ?? '')}, ${quoteLiteral(params.sourceLinkValueColumnName)}; - END IF; - - IF same_db_field_count > 1 THEN - RETURN; - END IF; - END IF; - WITH pairs AS ( SELECT s."__id" AS self_id, @@ -423,5 +357,6 @@ export const backfillJunctionTableFromLinkValueStatement = (params: { END $$; `) + ) ); }; diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/index.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/index.ts index 3dad36b0bf..b0b635db48 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/index.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/helpers/index.ts @@ -3,11 +3,13 @@ export { buildTableIdentifier, compressSql, createForeignKeyConstraintStatement, + dataStatement, createIndexStatement, createUniqueIndexStatement, dropColumnStatement, dropConstraintStatement, dropIndexStatement, dropTableStatement, + metaStatement, type TableIdentifier, } from './StatementBuilders'; diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/index.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/index.ts index d7d3659263..90fa70a94b 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/index.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/index.ts @@ -18,6 +18,8 @@ export type { SchemaRuleRepairHint, SchemaRuleValidationResult, TableSchemaStatementBuilder, + TableSchemaStatementCompiler, + TableSchemaStatementScope, } from './core'; // Field rules @@ -54,12 +56,14 @@ export { buildTableIdentifier, compressSql, createForeignKeyConstraintStatement, + dataStatement, createIndexStatement, createUniqueIndexStatement, dropColumnStatement, dropConstraintStatement, dropIndexStatement, dropTableStatement, + metaStatement, type TableIdentifier, } from './helpers'; diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/rules/table/SystemTableRules.ts b/packages/v2/adapter-table-repository-postgres/src/schema/rules/table/SystemTableRules.ts index 56c46dd055..7c0924c897 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/rules/table/SystemTableRules.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/rules/table/SystemTableRules.ts @@ -10,7 +10,12 @@ import type { SchemaRuleValidationResult, TableSchemaStatementBuilder, } from '../core/ISchemaRule'; -import { buildTableIdentifier, dropConstraintStatement, dropColumnStatement } from '../helpers'; +import { + buildTableIdentifier, + dataStatement, + dropColumnStatement, + dropConstraintStatement, +} from '../helpers'; export const SYSTEM_RULE_FIELD_ID = '__system__'; export const SYSTEM_RULE_FIELD_NAME = 'System Columns'; @@ -103,9 +108,11 @@ class SystemColumnExistsRule implements ISchemaRule { up(ctx: SchemaRuleContext): Result, DomainError> { const target = { schema: ctx.schema, tableName: ctx.tableName }; return ok([ - sql`alter table ${buildTableIdentifier(target)} add column if not exists ${sql.ref( - this.columnName - )} ${sql.raw(this.columnDefinition)}`, + dataStatement( + sql`alter table ${buildTableIdentifier(target)} add column if not exists ${sql.ref( + this.columnName + )} ${sql.raw(this.columnDefinition)}` + ), ]); } @@ -156,8 +163,10 @@ class SystemColumnNotNullRule implements ISchemaRule { up(ctx: SchemaRuleContext): Result, DomainError> { const qualifiedTable = toQualifiedTableSql({ schema: ctx.schema, tableName: ctx.tableName }); return ok([ - sql.raw( - `ALTER TABLE ${qualifiedTable} ALTER COLUMN ${quoteIdentifier(this.columnName)} SET NOT NULL` + dataStatement( + sql.raw( + `ALTER TABLE ${qualifiedTable} ALTER COLUMN ${quoteIdentifier(this.columnName)} SET NOT NULL` + ) ), ]); } @@ -165,8 +174,10 @@ class SystemColumnNotNullRule implements ISchemaRule { down(ctx: SchemaRuleContext): Result, DomainError> { const qualifiedTable = toQualifiedTableSql({ schema: ctx.schema, tableName: ctx.tableName }); return ok([ - sql.raw( - `ALTER TABLE ${qualifiedTable} ALTER COLUMN ${quoteIdentifier(this.columnName)} DROP NOT NULL` + dataStatement( + sql.raw( + `ALTER TABLE ${qualifiedTable} ALTER COLUMN ${quoteIdentifier(this.columnName)} DROP NOT NULL` + ) ), ]); } @@ -209,10 +220,12 @@ class SystemUniqueIndexRule implements ISchemaRule { up(ctx: SchemaRuleContext): Result, DomainError> { const qualifiedTable = toQualifiedTableSql({ schema: ctx.schema, tableName: ctx.tableName }); return ok([ - sql.raw( - `ALTER TABLE ${qualifiedTable} ADD CONSTRAINT ${quoteIdentifier( - this.constraintName - )} UNIQUE (${quoteIdentifier(this.columnName)})` + dataStatement( + sql.raw( + `ALTER TABLE ${qualifiedTable} ADD CONSTRAINT ${quoteIdentifier( + this.constraintName + )} UNIQUE (${quoteIdentifier(this.columnName)})` + ) ), ]); } @@ -265,10 +278,12 @@ class SystemPrimaryKeyRule implements ISchemaRule { const qualifiedTable = toQualifiedTableSql({ schema: ctx.schema, tableName: ctx.tableName }); const constraintName = this.constraintName(ctx.tableName); return ok([ - sql.raw( - `ALTER TABLE ${qualifiedTable} ADD CONSTRAINT ${quoteIdentifier( - constraintName - )} PRIMARY KEY (${quoteIdentifier(this.columnName)})` + dataStatement( + sql.raw( + `ALTER TABLE ${qualifiedTable} ADD CONSTRAINT ${quoteIdentifier( + constraintName + )} PRIMARY KEY (${quoteIdentifier(this.columnName)})` + ) ), ]); } @@ -337,8 +352,10 @@ class SystemDefaultRule implements ISchemaRule { down(ctx: SchemaRuleContext): Result, DomainError> { const qualifiedTable = toQualifiedTableSql({ schema: ctx.schema, tableName: ctx.tableName }); return ok([ - sql.raw( - `ALTER TABLE ${qualifiedTable} ALTER COLUMN ${quoteIdentifier(this.columnName)} DROP DEFAULT` + dataStatement( + sql.raw( + `ALTER TABLE ${qualifiedTable} ALTER COLUMN ${quoteIdentifier(this.columnName)} DROP DEFAULT` + ) ), ]); } @@ -354,21 +371,27 @@ const createAutoNumberDefaultStatements = ( const sequenceLiteral = toQualifiedSequenceLiteral(target, sequenceName); return [ - sql.raw(`CREATE SEQUENCE IF NOT EXISTS ${qualifiedSequence}`), - sql.raw( - `ALTER SEQUENCE ${qualifiedSequence} OWNED BY ${qualifiedTable}.${quoteIdentifier( - '__auto_number' - )}` + dataStatement(sql.raw(`CREATE SEQUENCE IF NOT EXISTS ${qualifiedSequence}`)), + dataStatement( + sql.raw( + `ALTER SEQUENCE ${qualifiedSequence} OWNED BY ${qualifiedTable}.${quoteIdentifier( + '__auto_number' + )}` + ) ), - sql.raw( - `ALTER TABLE ${qualifiedTable} ALTER COLUMN ${quoteIdentifier( - '__auto_number' - )} SET DEFAULT nextval(${sequenceLiteral}::regclass)` + dataStatement( + sql.raw( + `ALTER TABLE ${qualifiedTable} ALTER COLUMN ${quoteIdentifier( + '__auto_number' + )} SET DEFAULT nextval(${sequenceLiteral}::regclass)` + ) ), - sql.raw( - `SELECT setval(${sequenceLiteral}::regclass, GREATEST(COALESCE((SELECT MAX(${quoteIdentifier( - '__auto_number' - )}) FROM ${qualifiedTable}), 0), 1), true)` + dataStatement( + sql.raw( + `SELECT setval(${sequenceLiteral}::regclass, GREATEST(COALESCE((SELECT MAX(${quoteIdentifier( + '__auto_number' + )}) FROM ${qualifiedTable}), 0), 1), true)` + ) ), ]; }; @@ -408,10 +431,12 @@ export const createSystemTableRules = (): ReadonlyArray => { tableName: ctx.tableName, }); return [ - sql.raw( - `ALTER TABLE ${qualifiedTable} ALTER COLUMN ${quoteIdentifier( - '__created_time' - )} SET DEFAULT now()` + dataStatement( + sql.raw( + `ALTER TABLE ${qualifiedTable} ALTER COLUMN ${quoteIdentifier( + '__created_time' + )} SET DEFAULT now()` + ) ), ]; }, diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldTypeConversionVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldTypeConversionVisitor.ts index 28df29b3dd..428638d0d4 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldTypeConversionVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/FieldTypeConversionVisitor.ts @@ -39,10 +39,8 @@ import { err, ok, safeTry } from 'neverthrow'; import type { Result } from 'neverthrow'; import { resolveUserAvatarUrlPrefix } from '../../shared/userAvatarUrl'; -import { - PostgresTableSchemaFieldCreateVisitor, - type TableSchemaStatementBuilder, -} from './PostgresTableSchemaFieldCreateVisitor'; +import type { TableSchemaStatementBuilder } from '../rules/core'; +import { PostgresTableSchemaFieldCreateVisitor } from './PostgresTableSchemaFieldCreateVisitor'; import { PostgresTableSchemaFieldDeleteVisitor } from './PostgresTableSchemaFieldDeleteVisitor'; export type FieldConversionParams = { @@ -59,6 +57,7 @@ const createCompiledStatementBuilder = ( db: Kysely, sqlText: string ): TableSchemaStatementBuilder => ({ + scope: 'data', compile: () => sql.raw(sqlText).compile(db), }); @@ -66,6 +65,9 @@ const quoteIdent = (value: string): string => `"${value.replace(/"/g, '""')}"`; const quoteLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`; +const ISO_DATE_OR_DATETIME_SQL_REGEX = + '^[0-9]{4}-[0-9]{2}-[0-9]{2}([T ][0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?([Zz]|[+-][0-9]{2}(:?[0-9]{2})?)?)?$'; + const buildSelectOptionsFromValuesStatement = ( params: FieldConversionParams, distinctValuesSql: string @@ -247,16 +249,12 @@ const buildScalarToLinkMigrationStatements = ( } } - if ( - !isOneWay && - sourceFkColumnName && - (relationship === 'manyOne' || relationship === 'oneOne') - ) { - const sourceQualifiedName = `${quoteIdent(sourceSchema)}.${quoteIdent(sourceTableName)}`; - const sourceFkIdent = quoteIdent(sourceFkColumnName); - const sourceOrderExpr = sourceOrderColumnName - ? `${quoteIdent(sourceOrderColumnName)} IS NULL DESC, ${quoteIdent(sourceOrderColumnName)} ASC, "__id" ASC` - : '"__id" ASC'; + if (!isOneWay && (relationship === 'manyOne' || relationship === 'oneOne')) { + if (!sourceFkColumnName) { + return err( + domainError.unexpected({ message: 'Missing source FK column for link mapping' }) + ); + } const foreignTableId = newLinkField.foreignTableId().toString(); const currentFieldId = newLinkField.id().toString(); const explicitSymmetricFieldId = newLinkField.symmetricFieldId()?.toString(); @@ -274,8 +272,14 @@ const buildScalarToLinkMigrationStatements = ( AND ( options::jsonb->>'symmetricFieldId' = ${quoteLiteral(currentFieldId)}${explicitSymmetricClause} ) - ORDER BY id - LIMIT 1;`; + ORDER BY id + LIMIT 1;`; + + const sourceQualifiedName = `${quoteIdent(sourceSchema)}.${quoteIdent(sourceTableName)}`; + const sourceFkIdent = quoteIdent(sourceFkColumnName); + const sourceOrderExpr = sourceOrderColumnName + ? `${quoteIdent(sourceOrderColumnName)} IS NULL DESC, ${quoteIdent(sourceOrderColumnName)} ASC, "__id" ASC` + : '"__id" ASC'; if (relationship === 'manyOne') { symmetricBackfillSql = ` @@ -317,6 +321,62 @@ const buildScalarToLinkMigrationStatements = ( ); END IF;`; } + } else if (!isOneWay && relationship === 'oneMany') { + const foreignTableId = newLinkField.foreignTableId().toString(); + const currentFieldId = newLinkField.id().toString(); + const explicitSymmetricFieldId = newLinkField.symmetricFieldId()?.toString(); + const explicitSymmetricClause = explicitSymmetricFieldId + ? ` OR id = ${quoteLiteral(explicitSymmetricFieldId)}` + : ''; + + symmetricDeclareSql = ' symmetric_col text;'; + symmetricResolveSql = ` + SELECT db_field_name INTO symmetric_col + FROM field + WHERE table_id = ${quoteLiteral(foreignTableId)} + AND type = 'link' + AND deleted_time IS NULL + AND ( + options::jsonb->>'symmetricFieldId' = ${quoteLiteral(currentFieldId)}${explicitSymmetricClause} + ) + ORDER BY id + LIMIT 1;`; + + symmetricBackfillSql = ` + IF symmetric_col IS NOT NULL THEN + EXECUTE format( + 'WITH candidates AS ( + SELECT t.__id AS source_id, p.part_idx, f.__id AS foreign_id + FROM %I.%I AS t + CROSS JOIN LATERAL ( + SELECT trim(part) AS token, ordinality AS part_idx + FROM unnest(string_to_array(t.%I::text, '','')) WITH ORDINALITY AS parts(part, ordinality) + WHERE trim(part) <> '''' + ) AS p + JOIN %I.%I AS f ON f.%I::text = p.token + WHERE t.%I IS NOT NULL + ), + picked AS ( + SELECT DISTINCT ON (foreign_id) foreign_id, source_id, part_idx + FROM candidates + ORDER BY foreign_id, part_idx, source_id + ) + UPDATE %I.%I AS f + SET %I = jsonb_build_object(''id'', p.source_id) + FROM picked AS p + WHERE f.__id = p.foreign_id', + ${quoteLiteral(sourceSchema)}, + ${quoteLiteral(sourceTableName)}, + ${quoteLiteral(tmpColumnName)}, + foreign_schema, + foreign_name, + lookup_col, + ${quoteLiteral(tmpColumnName)}, + foreign_schema, + foreign_name, + symmetric_col + ); + END IF;`; } const mapFkSql = ` @@ -641,7 +701,7 @@ const buildLookupToBasicFieldMigrationStatements = ( case 'checkbox': return `CASE WHEN lower((${firstValueExpression})::text) IN ('true', 't', '1', 'yes', 'y') THEN TRUE WHEN lower((${firstValueExpression})::text) IN ('false', 'f', '0', 'no', 'n') THEN FALSE WHEN (${firstValueExpression}) IS NOT NULL AND (${firstValueExpression}) <> '' THEN TRUE ELSE NULL END`; case 'date': - return `CASE WHEN (${firstValueExpression}) ~ '^\\d{4}-\\d{2}-\\d{2}' THEN (${firstValueExpression})::timestamptz ELSE NULL END`; + return `CASE WHEN (${firstValueExpression}) ~ ${quoteLiteral(ISO_DATE_OR_DATETIME_SQL_REGEX)} THEN (${firstValueExpression})::timestamptz ELSE NULL END`; case 'singleSelect': return firstValueExpression; case 'multipleSelect': @@ -737,7 +797,7 @@ function buildFormulaMigrationSql( } if (isString) { // Try to parse string as timestamp - return `UPDATE ${tbl} SET ${dst} = CASE WHEN ${tmp} ~ '^\\d{4}' THEN ${tmp}::timestamptz ELSE NULL END ${whereNotNull}`; + return `UPDATE ${tbl} SET ${dst} = CASE WHEN ${tmp} ~ ${quoteLiteral(ISO_DATE_OR_DATETIME_SQL_REGEX)} THEN ${tmp}::timestamptz ELSE NULL END ${whereNotNull}`; } // number, boolean → date: incompatible return null; @@ -1306,7 +1366,7 @@ export abstract class BaseFieldConversionVisitor extends AbstractFieldVisitor< * Create a statement builder from a compiled query. */ protected toBuilder(query: CompiledQuery): TableSchemaStatementBuilder { - return { compile: () => query }; + return { scope: 'data', compile: () => query }; } /** @@ -1316,6 +1376,7 @@ export abstract class BaseFieldConversionVisitor extends AbstractFieldVisitor< const { db, dbFieldName } = this.params; const fullTableName = this.fullTableName; return { + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullTableName)} ALTER COLUMN "${sql.raw(dbFieldName)}" TYPE ${sql.raw(newType)} USING "${sql.raw(dbFieldName)}"::${sql.raw(newType)}`.compile( db @@ -1331,6 +1392,7 @@ export abstract class BaseFieldConversionVisitor extends AbstractFieldVisitor< const { db, dbFieldName } = this.params; const fullTableName = this.fullTableName; return { + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullTableName)} ALTER COLUMN "${sql.raw(dbFieldName)}" TYPE ${sql.raw(newType)} USING ${sql.raw(usingExpr)}`.compile( db @@ -1374,6 +1436,7 @@ export abstract class BaseFieldConversionVisitor extends AbstractFieldVisitor< // 4. Merges new choices with existing choices // 5. Updates the field table return { + scope: 'data', compile: () => sql` WITH distinct_values AS ( @@ -1699,7 +1762,7 @@ class TextFieldConversionVisitor extends BaseFieldConversionVisitor { return ok([ this.alterColumnTypeUsing( 'timestamptz', - `CASE WHEN ${col} ~ '^\\d{4}-\\d{2}-\\d{2}' THEN ${col}::timestamptz ELSE NULL END` + `CASE WHEN ${col} ~ ${quoteLiteral(ISO_DATE_OR_DATETIME_SQL_REGEX)} THEN ${col}::timestamptz ELSE NULL END` ), ]); } @@ -1735,6 +1798,7 @@ class TextFieldConversionVisitor extends BaseFieldConversionVisitor { if (fieldId) { const colors = SELECT_OPTION_COLORS; statements.push({ + scope: 'data', compile: () => sql` WITH distinct_values AS ( @@ -1791,6 +1855,7 @@ class TextFieldConversionVisitor extends BaseFieldConversionVisitor { // CSV-aware split: aggregate quoted/unquoted fields into jsonb array statements.push({ + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET ${sql.raw(col)} = ( SELECT jsonb_agg(COALESCE(trim(m[1]), trim(m[2])))::text @@ -1891,12 +1956,15 @@ $v2_user_conv$;`; return ok([ { + scope: 'data', compile: () => sql.raw(updateSql).compile(db), }, { + scope: 'data', compile: () => sql.raw(cleanupSql).compile(db), }, { + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullTableName)} ALTER COLUMN ${sql.raw(col)} TYPE jsonb USING ${sql.raw(col)}::jsonb`.compile( db @@ -1929,6 +1997,7 @@ class LongTextFieldConversionVisitor extends TextFieldConversionVisitor { const { db, dbFieldName } = this.params; const fullTableName = this.fullTableName; return { + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET "${sql.raw(dbFieldName)}" = REPLACE(REPLACE("${sql.raw(dbFieldName)}", E'\r\n', ' '), E'\n', ' ') WHERE "${sql.raw(dbFieldName)}" IS NOT NULL AND "${sql.raw(dbFieldName)}" LIKE '%' || E'\n' || '%'`.compile( db @@ -2008,6 +2077,7 @@ class NumberFieldConversionVisitor extends BaseFieldConversionVisitor { const max = field.ratingMax().toNumber(); const statements: TableSchemaStatementBuilder[] = [ { + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET "${sql.raw(dbFieldName)}" = GREATEST(0, LEAST(FLOOR("${sql.raw(dbFieldName)}"), ${sql.val(max)}))`.compile( db @@ -2453,6 +2523,7 @@ class MultipleSelectFieldConversionVisitor extends BaseFieldConversionVisitor { if (fieldId) { const colors = SELECT_OPTION_COLORS; statements.push({ + scope: 'data', compile: () => sql` WITH distinct_values AS ( @@ -2646,6 +2717,7 @@ class UserFieldConversionVisitor extends BaseFieldConversionVisitor { const col = `"${dbFieldName}"`; return ok([ { + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET ${sql.raw(col)} = CASE @@ -2727,6 +2799,7 @@ class UserFieldConversionVisitor extends BaseFieldConversionVisitor { const statements: TableSchemaStatementBuilder[] = []; statements.push({ + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET ${sql.raw(col)} = CASE @@ -2772,6 +2845,7 @@ class UserFieldConversionVisitor extends BaseFieldConversionVisitor { // Convert user objects to array of title strings statements.push({ + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET "${sql.raw(dbFieldName)}" = CASE WHEN "${sql.raw(dbFieldName)}" IS NULL THEN NULL @@ -2791,6 +2865,7 @@ class UserFieldConversionVisitor extends BaseFieldConversionVisitor { if (fieldId) { const colors = SELECT_OPTION_COLORS; statements.push({ + scope: 'data', compile: () => sql` WITH distinct_values AS ( diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/PostgresTableSchemaFieldCreateVisitor.spec.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/PostgresTableSchemaFieldCreateVisitor.spec.ts index 90c6b67fba..49b7a34a30 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/PostgresTableSchemaFieldCreateVisitor.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/PostgresTableSchemaFieldCreateVisitor.spec.ts @@ -150,6 +150,11 @@ describe('PostgresTableSchemaFieldCreateVisitor', () => { tableId: 'tbl_draft', }); const table = { + id: () => asId('tbl_draft'), + dbTableName: () => + ok({ + split: () => ok({ schema: null, tableName: 'draft_tasks' }), + }), getFields: () => [field], }; diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/PostgresTableSchemaFieldCreateVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/PostgresTableSchemaFieldCreateVisitor.ts index 8ab197fedd..50ddf02cc3 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/PostgresTableSchemaFieldCreateVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/PostgresTableSchemaFieldCreateVisitor.ts @@ -27,7 +27,7 @@ import { type UserField, } from '@teable/v2-core'; import type { V1TeableDatabase } from '@teable/v2-postgres-schema'; -import type { CompiledQuery, CreateTableBuilder, Kysely, QueryExecutorProvider } from 'kysely'; +import type { CreateTableBuilder, Kysely } from 'kysely'; import { ok, safeTry } from 'neverthrow'; import type { Result } from 'neverthrow'; @@ -39,11 +39,8 @@ import { type FieldSchemaRulesContext, type SchemaRuleContext, } from '../rules'; - -export type TableSchemaStatementBuilder = { - scope?: 'data' | 'meta'; - compile: (executorProvider: QueryExecutorProvider) => CompiledQuery; -}; +import type { TableSchemaStatementBuilder } from '../rules/core'; +import type { TableIdentifier } from '../rules/helpers'; type ICreateTableBuilder = CreateTableBuilder; @@ -51,6 +48,23 @@ export interface ICreateTableBuilderRef { builder: ICreateTableBuilder; } +export const buildTableLocationsById = ( + tables: ReadonlyArray
+): Result, DomainError> => + safeTry, DomainError>(function* () { + const locations = new Map(); + for (const table of tables) { + const location = yield* table + .dbTableName() + .andThen((name) => name.split({ defaultSchema: null })); + locations.set(table.id().toString(), { + schema: location.schema, + tableName: location.tableName, + }); + } + return ok(locations); + }); + /** * Visitor that generates schema statements for field creation. * @@ -64,7 +78,7 @@ export class PostgresTableSchemaFieldCreateVisitor extends AbstractFieldVisitor< > { private constructor( private readonly db: Kysely, - private readonly rulesContext: FieldSchemaRulesContext, + private rulesContext: FieldSchemaRulesContext, private readonly builderRef?: ICreateTableBuilderRef ) { super(); @@ -80,6 +94,7 @@ export class PostgresTableSchemaFieldCreateVisitor extends AbstractFieldVisitor< schema: string | null; tableName: string; tableId: string; + tableLocationsById?: ReadonlyMap; }): PostgresTableSchemaFieldCreateVisitor { return new PostgresTableSchemaFieldCreateVisitor( params.db, @@ -87,6 +102,7 @@ export class PostgresTableSchemaFieldCreateVisitor extends AbstractFieldVisitor< schema: params.schema, tableName: params.tableName, tableId: params.tableId, + tableLocationsById: params.tableLocationsById, }, params.builderRef ); @@ -101,11 +117,13 @@ export class PostgresTableSchemaFieldCreateVisitor extends AbstractFieldVisitor< schema: string | null; tableName: string; tableId: string; + tableLocationsById?: ReadonlyMap; }): PostgresTableSchemaFieldCreateVisitor { return new PostgresTableSchemaFieldCreateVisitor(params.db, { schema: params.schema, tableName: params.tableName, tableId: params.tableId, + tableLocationsById: params.tableLocationsById, }); } @@ -128,6 +146,16 @@ export class PostgresTableSchemaFieldCreateVisitor extends AbstractFieldVisitor< const fields = PostgresTableSchemaFieldCreateVisitor.isFieldArray(tableOrFields) ? tableOrFields : tableOrFields.getFields(); + if (!PostgresTableSchemaFieldCreateVisitor.isFieldArray(tableOrFields)) { + const tableLocationsById = new Map([ + ...(visitor.rulesContext.tableLocationsById ?? new Map()), + ...(yield* buildTableLocationsById([tableOrFields])), + ]); + visitor.rulesContext = { + ...visitor.rulesContext, + tableLocationsById, + }; + } const statements: Array = []; for (const field of fields) { diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts index 1dde9c37bb..8b71de9ce6 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/TableSchemaUpdateVisitor.ts @@ -81,6 +81,7 @@ import { sql } from 'kysely'; import { err, ok, safeTry } from 'neverthrow'; import type { Result } from 'neverthrow'; import { PostgresSchemaIntrospector } from '../rules/context/PostgresSchemaIntrospector'; +import type { TableSchemaStatementBuilder } from '../rules/core'; import { createSchemaRuleContext } from '../rules/context/SchemaRuleContext'; import { createFieldSchemaRules } from '../rules/field/FieldSchemaRulesFactory'; import { ReferenceRule } from '../rules/field/ReferenceRule'; @@ -89,7 +90,6 @@ import { type FieldConversionParams, } from './FieldTypeConversionVisitor'; import { FieldValueDuplicateVisitor } from './FieldValueDuplicateVisitor'; -import type { TableSchemaStatementBuilder } from './PostgresTableSchemaFieldCreateVisitor'; import { PostgresTableSchemaFieldCreateVisitor } from './PostgresTableSchemaFieldCreateVisitor'; import { PostgresTableSchemaFieldDeleteVisitor } from './PostgresTableSchemaFieldDeleteVisitor'; @@ -155,6 +155,7 @@ export class TableSchemaUpdateVisitor const indexName = TableSchemaUpdateVisitor.getSearchIndexName(tableName, fieldId, dbFieldName); const qualifiedIndex = schema ? `"${schema}"."${indexName}"` : `"${indexName}"`; return { + scope: 'data', compile: () => sql`DROP INDEX IF EXISTS ${sql.raw(qualifiedIndex)}`.compile(db), }; } @@ -222,6 +223,7 @@ export class TableSchemaUpdateVisitor `; return { + scope: 'data', compile: () => sql.raw(createSql).compile(db), }; } @@ -276,6 +278,7 @@ export class TableSchemaUpdateVisitor ); const pgSchema = schema ?? 'public'; return { + scope: 'data', compile: () => sql`ALTER INDEX IF EXISTS ${sql.raw(`"${pgSchema}"."${oldIndexName}"`)} RENAME TO ${sql.raw(`"${newIndexName}"`)}`.compile( db @@ -520,9 +523,12 @@ export class TableSchemaUpdateVisitor newField, }); const valueStatements = yield* sourceField.accept(valueVisitor); - const valueDuplicationStatements = valueStatements.map((query) => ({ - compile: () => query, - })); + const valueDuplicationStatements: TableSchemaStatementBuilder[] = valueStatements.map( + (query) => ({ + scope: 'data', + compile: () => query, + }) + ); const allStatements = [...schemaStatements, ...valueDuplicationStatements]; yield* addCond(allStatements); @@ -615,6 +621,7 @@ export class TableSchemaUpdateVisitor const statements: TableSchemaStatementBuilder[] = [ { + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullTableName)} RENAME COLUMN ${sql.ref(previousName)} TO ${sql.ref(nextName)}`.compile( db @@ -715,6 +722,7 @@ export class TableSchemaUpdateVisitor if (spec.nextNotNull().toBoolean()) { // Add NOT NULL constraint statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullTableName)} ALTER COLUMN ${sql.ref(dbFieldName)} SET NOT NULL`.compile( db @@ -723,6 +731,7 @@ export class TableSchemaUpdateVisitor } else { // Remove NOT NULL constraint statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullTableName)} ALTER COLUMN ${sql.ref(dbFieldName)} DROP NOT NULL`.compile( db @@ -739,6 +748,7 @@ export class TableSchemaUpdateVisitor if (spec.nextUnique().toBoolean()) { // Add UNIQUE constraint statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullTableName)} ADD CONSTRAINT ${sql.ref(constraintName)} UNIQUE (${sql.ref(dbFieldName)})`.compile( db @@ -747,6 +757,7 @@ export class TableSchemaUpdateVisitor } else { // Remove UNIQUE constraint statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullTableName)} DROP CONSTRAINT IF EXISTS ${sql.ref(constraintName)}`.compile( db @@ -802,6 +813,7 @@ export class TableSchemaUpdateVisitor // Keep errored computed fields aligned with query behavior (undefined/null) // by clearing any stale persisted values. statements.push({ + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET ${sql.ref(dbFieldName)} = NULL`.compile( visitor.params.db @@ -945,6 +957,7 @@ export class TableSchemaUpdateVisitor // Clamp values: UPDATE records SET col = newMax WHERE col > newMax const statements: TableSchemaStatementBuilder[] = [ { + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET ${sql.ref(dbFieldName)} = ${newMax} WHERE ${sql.ref(dbFieldName)} > ${newMax}`.compile( db @@ -1000,6 +1013,7 @@ export class TableSchemaUpdateVisitor // Array → Single: Extract first element from jsonb array // User field stores as jsonb, so we extract the first element statements.push({ + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET ${sql.ref(dbFieldName)} = (${sql.ref(dbFieldName)}->0) WHERE ${sql.ref(dbFieldName)} IS NOT NULL AND jsonb_array_length(${sql.ref(dbFieldName)}) > 0`.compile( db @@ -1008,6 +1022,7 @@ export class TableSchemaUpdateVisitor } else if (spec.isSingleToMultiple()) { // Single → Array: Wrap in jsonb array statements.push({ + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET ${sql.ref(dbFieldName)} = jsonb_build_array(${sql.ref(dbFieldName)}) WHERE ${sql.ref(dbFieldName)} IS NOT NULL`.compile( db @@ -1084,6 +1099,7 @@ export class TableSchemaUpdateVisitor : `"${visitor.params.tableName}"`; statements.push({ + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET ${sql.ref(dbFieldName)} = NULL WHERE ${sql.ref(dbFieldName)} IS NOT NULL`.compile( visitor.params.db @@ -1119,6 +1135,7 @@ export class TableSchemaUpdateVisitor const oldName = previous.name().toString(); const newName = next.name().toString(); statements.push({ + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET ${sql.ref(dbFieldName)} = ${newName} WHERE ${sql.ref(dbFieldName)} = ${oldName}`.compile( db @@ -1130,6 +1147,7 @@ export class TableSchemaUpdateVisitor for (const removed of spec.removedOptions()) { const deletedName = removed.name().toString(); statements.push({ + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullTableName)} SET ${sql.ref(dbFieldName)} = NULL WHERE ${sql.ref(dbFieldName)} = ${deletedName}`.compile( db @@ -1180,6 +1198,7 @@ export class TableSchemaUpdateVisitor const oldName = previous.name().toString(); const newName = next.name().toString(); statements.push({ + scope: 'data', compile: () => sql` UPDATE ${sql.raw(fullTableName)} @@ -1197,6 +1216,7 @@ export class TableSchemaUpdateVisitor for (const removed of spec.removedOptions()) { const deletedName = removed.name().toString(); statements.push({ + scope: 'data', compile: () => sql` UPDATE ${sql.raw(fullTableName)} @@ -1300,6 +1320,7 @@ export class TableSchemaUpdateVisitor if (nextIsMultiple) { return [ { + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullHostTableName)} SET ${sql.ref(dbFieldName)} = CASE @@ -1314,6 +1335,7 @@ export class TableSchemaUpdateVisitor return [ { + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullHostTableName)} SET ${sql.ref(dbFieldName)} = CASE @@ -1385,6 +1407,7 @@ export class TableSchemaUpdateVisitor return [ { + scope: 'data', compile: () => sql .raw( @@ -1393,9 +1416,11 @@ export class TableSchemaUpdateVisitor .compile(db), }, { + scope: 'data', compile: () => sql`DROP INDEX IF EXISTS ${sql.raw(fullIndexName)}`.compile(db), }, { + scope: 'data', compile: () => sql .raw( @@ -1432,6 +1457,7 @@ export class TableSchemaUpdateVisitor if (nextHasOrder && !prevHasOrder) { // Add __order column to junction table statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullJunctionTableName)} ADD COLUMN IF NOT EXISTS "__order" double precision`.compile( db @@ -1440,6 +1466,7 @@ export class TableSchemaUpdateVisitor } else if (prevHasOrder && !nextHasOrder) { // Drop __order column from junction table statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullJunctionTableName)} DROP COLUMN IF EXISTS "__order"`.compile( db @@ -1516,12 +1543,14 @@ export class TableSchemaUpdateVisitor // 1. Create FK columns on the new host table. statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullNewHostTableName)} ADD COLUMN IF NOT EXISTS ${sql.ref(newFkColumnName)} text`.compile( db ), }); statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullNewHostTableName)} ADD COLUMN IF NOT EXISTS ${sql.ref(newOrderColumnName)} double precision`.compile( db @@ -1530,6 +1559,7 @@ export class TableSchemaUpdateVisitor // 2. Move relationships from old host FK to new host FK. statements.push({ + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullNewHostTableName)} AS n SET ${sql.ref(newFkColumnName)} = ( @@ -1548,12 +1578,14 @@ export class TableSchemaUpdateVisitor // 3. Drop old FK columns from the old host table. statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullOldHostTableName)} DROP COLUMN IF EXISTS ${sql.ref(oldFkColumnName)}`.compile( db ), }); statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullOldHostTableName)} DROP COLUMN IF EXISTS ${sql.ref(oldOrderColumnName)}`.compile( db @@ -1606,6 +1638,7 @@ export class TableSchemaUpdateVisitor // 1. Add FK column to FK host table statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullFkHostTableName)} ADD COLUMN IF NOT EXISTS ${sql.ref(newFkColumnName)} text`.compile( db @@ -1614,6 +1647,7 @@ export class TableSchemaUpdateVisitor // 2. Add order column to FK host table statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullFkHostTableName)} ADD COLUMN IF NOT EXISTS ${sql.ref(newOrderColumnName)} double precision`.compile( db @@ -1627,6 +1661,7 @@ export class TableSchemaUpdateVisitor ? `ORDER BY j."__order", j."__id"` : `ORDER BY j."__id"`; statements.push({ + scope: 'data', compile: () => sql`UPDATE ${sql.raw(fullFkHostTableName)} AS h SET ${sql.ref(newFkColumnName)} = ( @@ -1645,6 +1680,7 @@ export class TableSchemaUpdateVisitor // 4. Drop junction table statements.push({ + scope: 'data', compile: () => sql`DROP TABLE IF EXISTS ${sql.raw(fullJunctionTableName)} CASCADE`.compile(db), }); @@ -1762,6 +1798,7 @@ END $v2_link_trim$;`; statements.push({ + scope: 'data', compile: () => sql.raw(trimSymmetricSql).compile(db), }); } @@ -1823,6 +1860,7 @@ $v2_link_trim$;`; // 1. Create junction table, with or without __order column depending on target relationship if (junctionNeedsOrder) { statements.push({ + scope: 'data', compile: () => sql`CREATE TABLE IF NOT EXISTS ${sql.raw(fullJunctionTableName)} ("__id" serial PRIMARY KEY, ${sql.ref(newSelfKeyName)} text, ${sql.ref(newForeignKeyName)} text, "__order" double precision)`.compile( db @@ -1830,6 +1868,7 @@ $v2_link_trim$;`; }); } else { statements.push({ + scope: 'data', compile: () => sql`CREATE TABLE IF NOT EXISTS ${sql.raw(fullJunctionTableName)} ("__id" serial PRIMARY KEY, ${sql.ref(newSelfKeyName)} text, ${sql.ref(newForeignKeyName)} text)`.compile( db @@ -1839,6 +1878,7 @@ $v2_link_trim$;`; // 2. Migrate data from FK column to junction table statements.push({ + scope: 'data', compile: () => sql`INSERT INTO ${sql.raw(fullJunctionTableName)} (${sql.ref(newSelfKeyName)}, ${sql.ref(newForeignKeyName)}) SELECT ${sql.raw(junctionSelfSourceExpr)}, ${sql.raw(junctionForeignSourceExpr)} FROM ${sql.raw(fullForeignTableName)} WHERE ${sql.raw(sourceLinkedIdExpr)} IS NOT NULL`.compile( db @@ -1847,6 +1887,7 @@ $v2_link_trim$;`; // 3. Drop FK column from foreign table statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullForeignTableName)} DROP COLUMN IF EXISTS ${sql.ref(oldFkColumnName)}`.compile( db @@ -1855,6 +1896,7 @@ $v2_link_trim$;`; // 4. Drop order column from foreign table statements.push({ + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullForeignTableName)} DROP COLUMN IF EXISTS ${sql.ref(oldOrderColumnName)}`.compile( db @@ -1915,6 +1957,7 @@ $v2_link_trim$;`; // 1. Create new junction table with proper columns const newOrderCol = nextHasOrder ? `, "__order" double precision` : ''; statements.push({ + scope: 'data', compile: () => sql`CREATE TABLE IF NOT EXISTS ${sql.raw(fullNewJunctionTableName)} ( "__id" serial PRIMARY KEY, @@ -1938,6 +1981,7 @@ $v2_link_trim$;`; } statements.push({ + scope: 'data', compile: () => sql`INSERT INTO ${sql.raw(fullNewJunctionTableName)} (${sql.raw(insertColumns.join(', '))}) SELECT ${sql.raw(selectColumns.join(', '))} @@ -1946,6 +1990,7 @@ $v2_link_trim$;`; // 3. Drop old junction table statements.push({ + scope: 'data', compile: () => sql`DROP TABLE IF EXISTS ${sql.raw(fullOldJunctionTableName)} CASCADE`.compile(db), }); @@ -2033,6 +2078,7 @@ $v2_link_trim$;`; // Only drop the JSONB column, NOT the junction table const statements: TableSchemaStatementBuilder[] = [ { + scope: 'data', compile: () => sql`ALTER TABLE ${sql.raw(fullTableName)} DROP COLUMN IF EXISTS ${sql.ref(dbFieldName)}`.compile( db diff --git a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/FieldTypeConversionVisitor.spec.ts b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/FieldTypeConversionVisitor.spec.ts index 16a6d41dc3..90b00f7c1d 100644 --- a/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/FieldTypeConversionVisitor.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/schema/visitors/__tests__/FieldTypeConversionVisitor.spec.ts @@ -191,6 +191,28 @@ const mkManyOneLinkFieldWithSymmetric = () => return field; })(); +const mkOneManyLinkFieldWithSymmetric = () => + (() => { + const field = createLinkField({ + id: mkFieldId('tgtOneManySym'), + name: mkFieldName('Target OneMany Sym'), + config: LinkFieldConfig.create({ + relationship: LinkRelationship.oneMany().toString(), + foreignTableId: `tbl${'b'.repeat(16)}`, + lookupFieldId: createValidFieldId('lookOmSym'), + symmetricFieldId: createValidFieldId('symOm01'), + })._unsafeUnwrap(), + })._unsafeUnwrap() as LinkField; + field + .ensureDbConfig({ + baseId: BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(), + hostTableId: TableId.create(`tbl${'a'.repeat(16)}`)._unsafeUnwrap(), + }) + ._unsafeUnwrap(); + field.setDbFieldName(DbFieldName.rehydrate(DB_FIELD_NAME)._unsafeUnwrap())._unsafeUnwrap(); + return field; + })(); + const mkManyOneLinkFieldWithForeign = (fieldSeed: string, foreignTableId: string) => (() => { const field = createLinkField({ @@ -286,7 +308,14 @@ describe('FieldTypeConversionVisitor', () => { const sqls = getVisitorSqls(mkTextField(), mkDateField()); expect(sqls[0]).toContain('ELSE NULL END'); // Regex checks for ISO date pattern - expect(sqls[0]).toMatch(/\\d\{4\}.*\\d\{2\}.*\\d\{2\}/); + expect(sqls[0]).toMatch(/\[0-9\]\{4\}.*\[0-9\]\{2\}.*\[0-9\]\{2\}/); + }); + + it('should require the whole text value to be an ISO date before casting', () => { + const sqls = getVisitorSqls(mkTextField(), mkDateField()); + expect(sqls[0]).toContain('CASE WHEN'); + expect(sqls[0]).toContain('$'); + expect(sqls[0]).not.toContain("'^[0-9]{4}'"); }); }); @@ -906,6 +935,16 @@ describe('FieldTypeConversionVisitor', () => { expect(mappingSql).toContain('WHERE f."__id" = src.foreign_id'); }); + it('should backfill symmetric manyOne json values for oneMany conversion', () => { + const sqls = getConversionSqls(mkTextField(), mkOneManyLinkFieldWithSymmetric()); + const mappingSql = sqls.find((sql) => sql.includes('DO $v2_link_map$')); + expect(mappingSql).toBeDefined(); + expect(mappingSql).toContain('symmetric_col'); + expect(mappingSql).toContain('DISTINCT ON (foreign_id)'); + expect(mappingSql).toContain("jsonb_build_object(''id'', p.source_id)"); + expect(mappingSql).toContain('SET %I = jsonb_build_object'); + }); + it('should push link -> text conversion into a single SQL mapping statement', () => { const sqls = getConversionSqls(mkManyOneLinkField(), mkTextField()); const mappingSql = sqls.find((sql) => sql.includes('DO $v2_link_to_text$')); diff --git a/packages/v2/adapter-table-repository-postgres/src/shared/db.spec.ts b/packages/v2/adapter-table-repository-postgres/src/shared/db.spec.ts index 118b718a8a..2e9bc62775 100644 --- a/packages/v2/adapter-table-repository-postgres/src/shared/db.spec.ts +++ b/packages/v2/adapter-table-repository-postgres/src/shared/db.spec.ts @@ -55,7 +55,11 @@ class FakeTracer implements ITracer { const compiledQuery = (sql: string, parameters: unknown[] = []): CompiledQuery => ({ sql, parameters }) as unknown as CompiledQuery; -const statement = (compiled: CompiledQuery): TableSchemaStatementBuilder => ({ +const statement = ( + compiled: CompiledQuery, + scope: TableSchemaStatementBuilder['scope'] = 'data' +): TableSchemaStatementBuilder => ({ + scope, compile: vi.fn(() => compiled), }); @@ -116,4 +120,29 @@ describe('executeTableSchemaStatements', () => { expect(tracer.spans[0]?.errors).toEqual(['schema update failed']); expect(tracer.spans[0]?.ended).toBe(true); }); + + it('rejects data-scoped statements that access metadata relations', async () => { + const db = { + executeQuery: vi.fn(async () => ({ rows: [] })), + } as unknown as Kysely; + + await expect( + executeTableSchemaStatements(db, [statement(compiledQuery('select * from field'))], { + enforceRelationAccess: true, + }) + ).rejects.toThrow('cannot access relations owned by another storage plane'); + expect(db.executeQuery).not.toHaveBeenCalled(); + }); + + it('allows metadata-scoped statements to access metadata relations', async () => { + const db = { + executeQuery: vi.fn(async () => ({ rows: [] })), + } as unknown as Kysely; + + await executeTableSchemaStatements(db, [ + statement(compiledQuery('update field set options = $1 where id = $2'), 'meta'), + ]); + + expect(db.executeQuery).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/v2/adapter-table-repository-postgres/src/shared/db.ts b/packages/v2/adapter-table-repository-postgres/src/shared/db.ts index f31da6d763..1d9da97f3c 100644 --- a/packages/v2/adapter-table-repository-postgres/src/shared/db.ts +++ b/packages/v2/adapter-table-repository-postgres/src/shared/db.ts @@ -2,6 +2,7 @@ import type { ISpan, ITracer, SpanAttributes } from '@teable/v2-core'; import type { Kysely, Transaction, CompiledQuery } from 'kysely'; import type { TableSchemaStatementBuilder } from '../schema/rules/core'; +import { assertSchemaStatementRelationAccess } from '../schema/rules/core'; export { getPostgresTransaction, resolvePostgresDbOrTx, @@ -10,6 +11,7 @@ export { type ExecuteQueryTraceOptions = { readonly tracer?: ITracer; readonly attributes?: SpanAttributes; + readonly enforceRelationAccess?: boolean; }; const schemaStatementSpanName = 'teable.postgres.schema_statement.execute'; @@ -101,9 +103,13 @@ export const executeTableSchemaStatements = async ( statements: ReadonlyArray, trace?: ExecuteQueryTraceOptions ): Promise => { - await executeCompiledQueries( - db, - statements.map((statement) => statement.compile(db)), - trace - ); + const compiled = statements.map((statement) => { + const compiledQuery = statement.compile(db); + if (trace?.enforceRelationAccess) { + assertSchemaStatementRelationAccess(statement, compiledQuery); + } + return compiledQuery; + }); + + await executeCompiledQueries(db, compiled, trace); }; diff --git a/packages/v2/adapter-table-repository-postgres/src/shared/undoCapture.ts b/packages/v2/adapter-table-repository-postgres/src/shared/undoCapture.ts index 93b1eb17f1..9d6941c59a 100644 --- a/packages/v2/adapter-table-repository-postgres/src/shared/undoCapture.ts +++ b/packages/v2/adapter-table-repository-postgres/src/shared/undoCapture.ts @@ -116,7 +116,7 @@ const hasUndoLogTable = async (db: DbOrTx): Promise => { SELECT EXISTS ( SELECT 1 FROM information_schema.tables - WHERE table_schema = 'public' + WHERE table_schema = current_schema() AND table_name = '__undo_log' ) AS "exists" `.execute(db); @@ -129,7 +129,7 @@ const hasUndoLogNewRowColumn = async (db: DbOrTx): Promise => { SELECT EXISTS ( SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' + WHERE table_schema = current_schema() AND table_name = '__undo_log' AND column_name = 'new_row' ) AS "exists" @@ -144,7 +144,7 @@ const hasUndoLogFunction = async (db: DbOrTx): Promise => { SELECT 1 FROM pg_proc WHERE proname = '__teable_capture_undo_row' - AND pronamespace = 'public'::regnamespace + AND pronamespace = current_schema()::regnamespace ) AS "exists" `.execute(db); @@ -205,7 +205,7 @@ const createTableTrigger = async (db: DbOrTx, tableRef: QualifiedIdentif CREATE OR REPLACE TRIGGER "__teable_undo_capture" AFTER INSERT OR UPDATE OR DELETE ON ${tableRef} FOR EACH ROW - EXECUTE FUNCTION "public"."__teable_capture_undo_row"() + EXECUTE FUNCTION "__teable_capture_undo_row"() ` ) .execute(db); @@ -312,7 +312,7 @@ export const loadAndClearUndoLogRows = async ( ): Promise => { const rowsResult = await sql` WITH deleted AS ( - DELETE FROM "public"."__undo_log" + DELETE FROM "__undo_log" WHERE "batch_id" = ${batchId} RETURNING "id", "operation", "table_name", "record_id", "old_row", "new_row" ) diff --git a/packages/v2/adapter-table-repository-postgres/src/shared/undoCaptureGlobalsSql.ts b/packages/v2/adapter-table-repository-postgres/src/shared/undoCaptureGlobalsSql.ts index b3d79b7564..b33748d80d 100644 --- a/packages/v2/adapter-table-repository-postgres/src/shared/undoCaptureGlobalsSql.ts +++ b/packages/v2/adapter-table-repository-postgres/src/shared/undoCaptureGlobalsSql.ts @@ -1,6 +1,6 @@ export const undoCaptureGlobalStatements = [ ` - CREATE TABLE IF NOT EXISTS "public"."__undo_log" ( + CREATE TABLE IF NOT EXISTS "__undo_log" ( "id" BIGSERIAL PRIMARY KEY, "batch_id" TEXT NOT NULL, "operation" TEXT NOT NULL, @@ -13,20 +13,20 @@ export const undoCaptureGlobalStatements = [ `, ` CREATE INDEX IF NOT EXISTS "__undo_log_batch_id_idx" - ON "public"."__undo_log" ("batch_id") + ON "__undo_log" ("batch_id") `, ` - ALTER TABLE "public"."__undo_log" SET ( + ALTER TABLE "__undo_log" SET ( autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 100 ) `, ` - ALTER SEQUENCE IF EXISTS "public"."__undo_log_id_seq" + ALTER SEQUENCE IF EXISTS "__undo_log_id_seq" CACHE 100 `, ` - CREATE OR REPLACE FUNCTION "public"."__teable_capture_undo_row"() + CREATE OR REPLACE FUNCTION "__teable_capture_undo_row"() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -55,7 +55,7 @@ export const undoCaptureGlobalStatements = [ RETURN NULL; END IF; - INSERT INTO "public"."__undo_log" ( + INSERT INTO "__undo_log" ( "batch_id", "operation", "table_name", diff --git a/packages/v2/benchmark-node/src/benchmarkTableDataSafetyLimits.ts b/packages/v2/benchmark-node/src/benchmarkTableDataSafetyLimits.ts new file mode 100644 index 0000000000..81cf6fb4c1 --- /dev/null +++ b/packages/v2/benchmark-node/src/benchmarkTableDataSafetyLimits.ts @@ -0,0 +1,8 @@ +import type { TableDataSafetyLimitConfig } from '@teable/v2-core'; + +export const benchmarkTableDataSafetyLimits = { + tableSchema: { + maxCreateTableFields: 1_000, + maxFieldsPerTable: 1_000, + }, +} satisfies TableDataSafetyLimitConfig; diff --git a/packages/v2/benchmark-node/src/create-table.bench.ts b/packages/v2/benchmark-node/src/create-table.bench.ts index e5cadf6133..bcef7aca9c 100644 --- a/packages/v2/benchmark-node/src/create-table.bench.ts +++ b/packages/v2/benchmark-node/src/create-table.bench.ts @@ -17,6 +17,7 @@ import { import express from 'express'; import fastify from 'fastify'; import { afterAll, beforeAll, bench, describe } from 'vitest'; +import { benchmarkTableDataSafetyLimits } from './benchmarkTableDataSafetyLimits'; const benchOptions = { iterations: 0, @@ -109,7 +110,9 @@ const setupHono = async (container: DependencyContainer): Promise }; const setup = async () => { - const testContainer = await createV2NodeTestContainer(); + const testContainer = await createV2NodeTestContainer({ + tableDataSafetyLimits: benchmarkTableDataSafetyLimits, + }); testContainer.container.registerInstance(v2CoreTokens.logger, new NoopLogger()); dispose = testContainer.dispose; baseId = testContainer.baseId.toString(); diff --git a/packages/v2/benchmark-node/src/get-table-by-id.bench.ts b/packages/v2/benchmark-node/src/get-table-by-id.bench.ts index c932dd517d..298b1b6cf2 100644 --- a/packages/v2/benchmark-node/src/get-table-by-id.bench.ts +++ b/packages/v2/benchmark-node/src/get-table-by-id.bench.ts @@ -17,6 +17,7 @@ import { import express from 'express'; import fastify from 'fastify'; import { afterAll, beforeAll, bench, describe } from 'vitest'; +import { benchmarkTableDataSafetyLimits } from './benchmarkTableDataSafetyLimits'; const benchOptions = { iterations: 0, @@ -131,7 +132,9 @@ const createTable = async ( }; const setup = async () => { - const testContainer = await createV2NodeTestContainer(); + const testContainer = await createV2NodeTestContainer({ + tableDataSafetyLimits: benchmarkTableDataSafetyLimits, + }); testContainer.container.registerInstance(v2CoreTokens.logger, new NoopLogger()); dispose = testContainer.dispose; baseId = testContainer.baseId.toString(); diff --git a/packages/v2/container-node/src/index.ts b/packages/v2/container-node/src/index.ts index 58dd1e1c2e..8a9f636d95 100644 --- a/packages/v2/container-node/src/index.ts +++ b/packages/v2/container-node/src/index.ts @@ -100,8 +100,7 @@ export const registerV2NodePgDependencies = async ( 'Missing pg meta connectionString (options.metaConnectionString or PRISMA_META_DATABASE_URL)' ); } - const dataConnectionString = - options.dataConnectionString ?? process.env.PRISMA_DATA_DATABASE_URL ?? metaConnectionString; + const dataConnectionString = options.dataConnectionString ?? metaConnectionString; if (metaConnectionString === dataConnectionString) { await registerV2PostgresDb(c, { pg: { connectionString: metaConnectionString } }); diff --git a/packages/v2/core/src/application/projections/RecordsBatchCreatedRealtimeProjection.ts b/packages/v2/core/src/application/projections/RecordsBatchCreatedRealtimeProjection.ts index e4f1eb7939..563da5991c 100644 --- a/packages/v2/core/src/application/projections/RecordsBatchCreatedRealtimeProjection.ts +++ b/packages/v2/core/src/application/projections/RecordsBatchCreatedRealtimeProjection.ts @@ -60,6 +60,8 @@ export class RecordsBatchCreatedRealtimeProjection implements IEventHandler ...`, so the inferred + // return is Promise> (the async wraps ResultAsync). const tasks: Array<() => Promise>> = []; for (const record of event.records) { diff --git a/packages/v2/core/src/application/projections/runRealtimeTasks.ts b/packages/v2/core/src/application/projections/runRealtimeTasks.ts index 652304e4f4..b01b3c8435 100644 --- a/packages/v2/core/src/application/projections/runRealtimeTasks.ts +++ b/packages/v2/core/src/application/projections/runRealtimeTasks.ts @@ -10,6 +10,7 @@ export async function runRealtimeTasks( tasks: ReadonlyArray< () => | Promise> + | Promise> | ResultAsync | ResultAsync >, diff --git a/packages/v2/core/src/application/services/FieldUpdateSideEffectService.spec.ts b/packages/v2/core/src/application/services/FieldUpdateSideEffectService.spec.ts index 3cdd3e86ab..34e7df2683 100644 --- a/packages/v2/core/src/application/services/FieldUpdateSideEffectService.spec.ts +++ b/packages/v2/core/src/application/services/FieldUpdateSideEffectService.spec.ts @@ -29,7 +29,7 @@ import { CloneViewVisitor } from '../../domain/table/views/visitors/CloneViewVis import type { IEventBus } from '../../ports/EventBus'; import type { IExecutionContext, IUnitOfWorkTransaction } from '../../ports/ExecutionContext'; import { MemoryTableRepository } from '../../ports/memory/MemoryTableRepository'; -import type { ITableRepository } from '../../ports/TableRepository'; +import type { ITableRepository, TableFindOptions } from '../../ports/TableRepository'; import type { ITableSchemaRepository } from '../../ports/TableSchemaRepository'; import type { IUnitOfWork, UnitOfWorkOperation } from '../../ports/UnitOfWork'; import { FieldCrossTableUpdateSideEffectService } from './FieldCrossTableUpdateSideEffectService'; @@ -89,6 +89,19 @@ class FakeUnitOfWork implements IUnitOfWork { } } +class RecordingFindOneStateRepository extends MemoryTableRepository { + readonly findOneStates: Array | undefined> = []; + + override async findOne( + context: IExecutionContext, + spec: ISpecification, + options?: Pick + ): Promise> { + this.findOneStates.push(options); + return super.findOne(context, spec); + } +} + const createBaseId = (seed: string) => BaseId.create(`bse${seed.repeat(16)}`)._unsafeUnwrap(); const createTableId = (seed: string) => TableId.create(`tbl${seed.repeat(16)}`)._unsafeUnwrap(); const createFieldId = (seed: string) => FieldId.create(`fld${seed.repeat(16)}`)._unsafeUnwrap(); @@ -571,7 +584,7 @@ describe('FieldUpdateSideEffectService', () => { it('uses latest foreign table from repo during execute for non-link -> link conversion', async () => { const context = createContext(); - const repo = new MemoryTableRepository(); + const repo = new RecordingFindOneStateRepository(); const flow = buildFlow(repo); const service = new FieldUpdateSideEffectService( flow, @@ -649,6 +662,7 @@ describe('FieldUpdateSideEffectService', () => { }); expect(result.isOk()).toBe(true); + expect(repo.findOneStates).toContainEqual({ state: 'activeWithPending' }); const foreignInRepo = repo.tables().find((t) => t.id().equals(foreignTableId)); expect(foreignInRepo?.getFields(buildFieldSpec((builder) => builder.isLink()))).toHaveLength(1); }); diff --git a/packages/v2/core/src/application/services/FieldUpdateSideEffectService.ts b/packages/v2/core/src/application/services/FieldUpdateSideEffectService.ts index 7e55dede8b..996292538b 100644 --- a/packages/v2/core/src/application/services/FieldUpdateSideEffectService.ts +++ b/packages/v2/core/src/application/services/FieldUpdateSideEffectService.ts @@ -356,7 +356,9 @@ export class FieldUpdateSideEffectService { return err(whereSpecResult.error); } - const latestForeignResult = await this.tableRepository.findOne(context, whereSpecResult.value); + const latestForeignResult = await this.tableRepository.findOne(context, whereSpecResult.value, { + state: 'activeWithPending', + }); if (latestForeignResult.isErr()) { return err(latestForeignResult.error); } diff --git a/packages/v2/core/src/application/services/TableCreationService.spec.ts b/packages/v2/core/src/application/services/TableCreationService.spec.ts index f88f43b501..e724882e7b 100644 --- a/packages/v2/core/src/application/services/TableCreationService.spec.ts +++ b/packages/v2/core/src/application/services/TableCreationService.spec.ts @@ -57,6 +57,10 @@ class FakeTableRepository implements ITableRepository { return ok(undefined); } + async restore() { + return ok(undefined); + } + async delete() { return ok(undefined); } @@ -64,14 +68,20 @@ class FakeTableRepository implements ITableRepository { class FakeTableSchemaRepository implements ITableSchemaRepository { inserted: Table[] = []; + insertManyOptions: Array<{ knownTables?: ReadonlyArray
} | undefined> = []; async insert(_context: IExecutionContext, table: Table) { this.inserted.push(table); return ok(undefined); } - async insertMany(_context: IExecutionContext, tables: ReadonlyArray
) { + async insertMany( + _context: IExecutionContext, + tables: ReadonlyArray
, + options?: { knownTables?: ReadonlyArray
} + ) { this.inserted.push(...tables); + this.insertManyOptions.push(options); return ok(undefined); } @@ -161,4 +171,29 @@ describe('TableCreationService', () => { expect(payload.persistedTables[1]?.id().equals(tableA.id())).toBe(true); expect(sideEffectService.calls).toHaveLength(2); }); + + it('passes external and persisted tables as known schema locations', async () => { + const table = buildTable('d', 'a'); + const externalTable = buildTable('d', 'b'); + const tableRepository = new FakeTableRepository(); + const schemaRepository = new FakeTableSchemaRepository(); + const sideEffectService = new FakeFieldCreationSideEffectService(); + const service = new TableCreationService( + tableRepository, + schemaRepository, + sideEffectService as never + ); + + const result = await service.execute(createContext(), { + baseId: table.baseId(), + tables: [table], + externalTables: [externalTable], + referencesByTable: [[]], + }); + + expect(result.isOk()).toBe(true); + expect( + schemaRepository.insertManyOptions[0]?.knownTables?.map((t) => t.id().toString()) + ).toEqual([externalTable.id().toString(), table.id().toString()]); + }); }); diff --git a/packages/v2/core/src/application/services/TableCreationService.ts b/packages/v2/core/src/application/services/TableCreationService.ts index 277a531a93..5c85dd95c7 100644 --- a/packages/v2/core/src/application/services/TableCreationService.ts +++ b/packages/v2/core/src/application/services/TableCreationService.ts @@ -204,7 +204,9 @@ export class TableCreationService { ); // Create physical table structures - yield* await service.tableSchemaRepository.insertMany(context, persistedTables); + yield* await service.tableSchemaRepository.insertMany(context, persistedTables, { + knownTables: [...externalTables, ...persistedTables], + }); // Build initial table state let tableState = new Map(); diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitFieldOperationPlugin.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitFieldOperationPlugin.ts index 474a082d63..b40c8c585e 100644 --- a/packages/v2/core/src/application/services/TableDataSafetyLimitFieldOperationPlugin.ts +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitFieldOperationPlugin.ts @@ -202,7 +202,7 @@ const ensureFormulaLength = ( ); }; -const ensureFieldLimits = ( +export const ensureTableDataSafetyFieldLimits = ( field: Field, domainContext: IDomainContext | undefined, limits: ResolvedTableDataSafetyLimitConfig @@ -332,13 +332,21 @@ export class TableDataSafetyLimitFieldOperationPlugin ): Result { const limits = preparedState?.limits ?? resolveTableDataSafetyLimits(); if (context.kind === FieldOperationKind.create && context.result?.createdField) { - return ensureFieldLimits(context.result.createdField, preparedState?.domainContext, limits); + return ensureTableDataSafetyFieldLimits( + context.result.createdField, + preparedState?.domainContext, + limits + ); } if (context.kind === FieldOperationKind.update && context.result?.updatedField) { - return ensureFieldLimits(context.result.updatedField, preparedState?.domainContext, limits); + return ensureTableDataSafetyFieldLimits( + context.result.updatedField, + preparedState?.domainContext, + limits + ); } if (context.kind === FieldOperationKind.duplicate && context.result?.duplicatedField) { - return ensureFieldLimits( + return ensureTableDataSafetyFieldLimits( context.result.duplicatedField, preparedState?.domainContext, limits diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.spec.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.spec.ts index e79da7f4fc..af6027faa2 100644 --- a/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.spec.ts +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.spec.ts @@ -28,14 +28,19 @@ import { TableDataSafetyLimitTableOperationPlugin } from './TableDataSafetyLimit const actorId = ActorId.create('system')._unsafeUnwrap(); const baseId = BaseId.create(`bse${'a'.repeat(16)}`)._unsafeUnwrap(); -const createTable = (idSeed: string, name: string, tableBaseId = baseId): Table => +const createTable = ( + idSeed: string, + name: string, + tableBaseId = baseId, + fieldName = 'Title' +): Table => Table.builder() .withId(TableId.create(`tbl${idSeed.repeat(16)}`)._unsafeUnwrap()) .withBaseId(tableBaseId) .withName(TableName.create(name)._unsafeUnwrap()) .field() .singleLineText() - .withName(FieldName.create('Title')._unsafeUnwrap()) + .withName(FieldName.create(fieldName)._unsafeUnwrap()) .primary() .done() .view() @@ -81,6 +86,10 @@ class FakeTableRepository implements ITableRepository { return ok(undefined); } + async restore(): Promise> { + return ok(undefined); + } + async delete(): Promise> { return ok(undefined); } @@ -122,6 +131,24 @@ const createViewsPerTableOnlyPlugin = (repository: ITableRepository) => ]) ); +const createFieldsPerTableOnlyPlugin = (repository: ITableRepository) => + new TableDataSafetyLimitTableOperationPlugin( + repository, + new TableDataSafetyLimitComposer([ + new StaticTableDataSafetyLimitPlugin({ + displayText: { maxNameLength: 20 }, + tableSchema: { + maxTablesPerBase: 3, + maxFieldsPerTable: 2, + maxCreateTableFields: 5, + maxCreateTableViews: 2, + maxCreateTableRecords: 2, + maxViewsPerTable: 2, + }, + }), + ]) + ); + const runPlugin = async ( plugin: TableDataSafetyLimitTableOperationPlugin, context: TableOperationPluginContext @@ -359,6 +386,44 @@ describe('TableDataSafetyLimitTableOperationPlugin', () => { expect(result._unsafeUnwrapErr().code).toBe('validation.limit.views_per_table_max'); }); + it('rejects create when the field count exceeds the configured fields-per-table limit', async () => { + const repository = new FakeTableRepository(); + const result = await runPlugin( + createFieldsPerTableOnlyPlugin(repository), + createContext(TableOperationKind.create, { + baseId, + tableName: TableName.create('Create')._unsafeUnwrap(), + fieldCount: 3, + viewCount: 1, + recordCount: 0, + viewNames: ['View A'], + }) + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().code).toBe('validation.limit.fields_per_table_max'); + }); + + it('rejects create when a built field exceeds display text limits', async () => { + const repository = new FakeTableRepository(); + const result = await runPlugin( + createPlugin(repository), + createContext(TableOperationKind.create, { + baseId, + tableName: TableName.create('Create')._unsafeUnwrap(), + table: createTable('e', 'Create', baseId, 'Too Long Field'), + fieldCount: 1, + viewCount: 1, + recordCount: 0, + viewNames: ['View A'], + }) + ); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().code).toBe('validation.limit.name_max_length'); + expect(result._unsafeUnwrapErr().details?.target).toBe('field.name'); + }); + it('composes multiple table limit plugins with the strictest numeric limit', async () => { const repository = new FakeTableRepository(); const plugin = new TableDataSafetyLimitTableOperationPlugin( diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.ts index ab9952d5bc..8340d8135f 100644 --- a/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.ts +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitTableOperationPlugin.ts @@ -2,6 +2,7 @@ import { err, ok } from 'neverthrow'; import type { Result } from 'neverthrow'; import type { BaseId } from '../../domain/base/BaseId'; +import type { IDomainContext } from '../../domain/shared/DomainContext'; import type { DomainError } from '../../domain/shared/DomainError'; import { ensureWithinTableDataSafetyLimit, @@ -9,16 +10,19 @@ import { type ResolvedTableDataSafetyLimitConfig, } from '../../domain/shared/TableDataSafetyLimits'; import { Table } from '../../domain/table/Table'; -import type { IExecutionContext } from '../../ports/ExecutionContext'; +import { getDomainContext, type IExecutionContext } from '../../ports/ExecutionContext'; import type { ITableOperationPlugin, TableOperationPluginContext, } from '../../ports/TableOperationPlugin'; import { TableOperationKind } from '../../ports/TableOperationPlugin'; import type { ITableRepository } from '../../ports/TableRepository'; +import { ensureTableDataSafetyFieldLimits } from './TableDataSafetyLimitFieldOperationPlugin'; +import { ensureTableDataSafetyViewConfigLimits } from './TableDataSafetyLimitViewOperationPlugin'; import { TableDataSafetyLimitComposer } from './TableDataSafetyLimitComposer'; type PreparedTableDataSafetyOperationLimitState = { + readonly domainContext: IDomainContext | undefined; readonly limits: ResolvedTableDataSafetyLimitConfig; }; @@ -48,7 +52,7 @@ export class TableDataSafetyLimitTableOperationPlugin private readonly limitComposer: TableDataSafetyLimitComposer ) {} - supports(): boolean { + supports(_operation: TableOperationKind): boolean { return true; } @@ -57,7 +61,10 @@ export class TableDataSafetyLimitTableOperationPlugin ): Promise> { const configResult = await this.limitComposer.compose(context.executionContext); if (configResult.isErr()) return err(configResult.error); - return ok({ limits: resolveTableDataSafetyLimits(configResult.value) }); + return ok({ + domainContext: getDomainContext(context.executionContext), + limits: resolveTableDataSafetyLimits(configResult.value), + }); } async guard( @@ -83,7 +90,11 @@ export class TableDataSafetyLimitTableOperationPlugin limits ); if (tableCountResult.isErr()) return tableCountResult; - return this.ensureCreatePayloadLimits(context.payload, limits); + return this.ensureCreatePayloadLimits( + context.payload, + limits, + preparedState?.domainContext + ); } case TableOperationKind.createMany: { const tableCountResult = await this.ensureTablesPerBaseLimit( @@ -95,18 +106,30 @@ export class TableDataSafetyLimitTableOperationPlugin if (tableCountResult.isErr()) return tableCountResult; for (const table of context.payload.tables) { - const result = this.ensureCreatePayloadLimits(table, limits); + const result = this.ensureCreatePayloadLimits( + table, + limits, + preparedState?.domainContext + ); if (result.isErr()) return result; } return ok(undefined); } - case TableOperationKind.duplicate: - return this.ensureTablesPerBaseLimit( + case TableOperationKind.duplicate: { + const tableCountResult = await this.ensureTablesPerBaseLimit( context.executionContext, context.payload.baseId, 1, limits ); + if (tableCountResult.isErr()) return tableCountResult; + if (!context.payload.table) return ok(undefined); + return this.ensureTableStructureLimits( + context.payload.table, + limits, + preparedState?.domainContext + ); + } case TableOperationKind.importCsv: { const tableCountResult = await this.ensureTablesPerBaseLimit( context.executionContext, @@ -115,7 +138,11 @@ export class TableDataSafetyLimitTableOperationPlugin limits ); if (tableCountResult.isErr()) return tableCountResult; - return this.ensureImportCsvPayloadLimits(context.payload, limits); + return this.ensureImportCsvPayloadLimits( + context.payload, + limits, + preparedState?.domainContext + ); } case TableOperationKind.rename: return ok(undefined); @@ -128,8 +155,10 @@ export class TableDataSafetyLimitTableOperationPlugin readonly viewCount: number; readonly recordCount: number; readonly viewNames: ReadonlyArray; + readonly table?: Table; }, - limits: ResolvedTableDataSafetyLimitConfig + limits: ResolvedTableDataSafetyLimitConfig, + domainContext: IDomainContext | undefined ): Result { const fieldsResult = ensureWithinTableDataSafetyLimit( 'validation.limit.create_table_fields_max', @@ -139,6 +168,9 @@ export class TableDataSafetyLimitTableOperationPlugin ); if (fieldsResult.isErr()) return fieldsResult; + const fieldsPerTableResult = this.ensureFieldsPerTableLimit(payload.fieldCount, limits); + if (fieldsPerTableResult.isErr()) return fieldsPerTableResult; + const viewsResult = ensureWithinTableDataSafetyLimit( 'validation.limit.create_table_views_max', payload.viewCount, @@ -176,16 +208,74 @@ export class TableDataSafetyLimitTableOperationPlugin if (viewNameResult.isErr()) return viewNameResult; } + if (payload.table) { + return this.ensureTableStructureLimits(payload.table, limits, domainContext); + } + return ok(undefined); } + private ensureTableStructureLimits( + table: Table, + limits: ResolvedTableDataSafetyLimitConfig, + domainContext: IDomainContext | undefined + ): Result { + const fieldsPerTableResult = this.ensureFieldsPerTableLimit(table.getFields().length, limits); + if (fieldsPerTableResult.isErr()) return fieldsPerTableResult; + + const viewsPerTableResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.views_per_table_max', + table.views().length > 0 ? table.views().length : 1, + limits.tableSchema.maxViewsPerTable, + { target: 'table.views' } + ); + if (viewsPerTableResult.isErr()) return viewsPerTableResult; + + for (const field of table.getFields()) { + const fieldResult = ensureTableDataSafetyFieldLimits(field, domainContext, limits); + if (fieldResult.isErr()) return fieldResult; + } + + for (const view of table.views()) { + const queryDefaultsResult = view.queryDefaults(); + const queryDefaults = queryDefaultsResult.isOk() ? queryDefaultsResult.value.toDto() : {}; + const viewResult = ensureTableDataSafetyViewConfigLimits( + { + name: view.name().toString(), + filter: queryDefaults.filter, + sort: queryDefaults.sort, + group: queryDefaults.group, + options: view.options(), + }, + limits + ); + if (viewResult.isErr()) return viewResult; + } + + return ok(undefined); + } + + private ensureFieldsPerTableLimit( + fieldCount: number, + limits: ResolvedTableDataSafetyLimitConfig + ): Result { + return ensureWithinTableDataSafetyLimit( + 'validation.limit.fields_per_table_max', + fieldCount, + limits.tableSchema.maxFieldsPerTable, + { target: 'table.fields' } + ); + } + private ensureImportCsvPayloadLimits( payload: { readonly fieldCount: number; readonly viewCount: number; readonly recordCount: number; + readonly table?: Table; }, - limits: ResolvedTableDataSafetyLimitConfig + limits: ResolvedTableDataSafetyLimitConfig, + domainContext: IDomainContext | undefined ): Result { return this.ensureCreatePayloadLimits( { @@ -193,8 +283,10 @@ export class TableDataSafetyLimitTableOperationPlugin viewCount: payload.viewCount, recordCount: payload.recordCount, viewNames: [], + table: payload.table, }, - limits + limits, + domainContext ); } diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.spec.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.spec.ts new file mode 100644 index 0000000000..2f2380c58b --- /dev/null +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.spec.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; + +import { ActorId } from '../../domain/shared/ActorId'; +import type { TableDataSafetyLimitConfig } from '../../domain/shared/TableDataSafetyLimits'; +import type { IExecutionContext } from '../../ports/ExecutionContext'; +import { + ViewOperationKind, + type ViewOperationPluginContext, +} from '../../ports/ViewOperationPlugin'; +import { + StaticTableDataSafetyLimitPlugin, + TableDataSafetyLimitComposer, +} from './TableDataSafetyLimitComposer'; +import { TableDataSafetyLimitViewOperationPlugin } from './TableDataSafetyLimitViewOperationPlugin'; + +type TableLimits = TableDataSafetyLimitConfig; + +const actorId = ActorId.create('system')._unsafeUnwrap(); + +const filterItem = { + fieldId: 'fldTest', + operator: 'is', + value: 'x', + isSymbol: false, +}; + +const createExecutionContext = (limits?: TableLimits) => + ({ + actorId, + config: limits ? { tableLimits: limits } : undefined, + }) as IExecutionContext; + +const createPlugin = (limits: TableLimits) => + new TableDataSafetyLimitViewOperationPlugin( + new TableDataSafetyLimitComposer([new StaticTableDataSafetyLimitPlugin(limits)]) + ); + +const runPlugin = async ( + plugin: TableDataSafetyLimitViewOperationPlugin, + context: ViewOperationPluginContext +) => { + const preparedResult = await plugin.prepare(context); + if (preparedResult.isErr()) return preparedResult; + return plugin.guard(context, preparedResult.value); +}; + +const createContext = ( + kind: ViewOperationKind, + payload: Record, + limits?: TableLimits +): ViewOperationPluginContext => + ({ + kind, + executionContext: createExecutionContext(limits), + payload, + isTransactionBound: false, + }) as unknown as ViewOperationPluginContext; + +describe('TableDataSafetyLimitViewOperationPlugin', () => { + it('supports all view operation kinds', () => { + const plugin = createPlugin({}); + + expect(plugin.supports(ViewOperationKind.create)).toBe(true); + expect(plugin.supports(ViewOperationKind.duplicate)).toBe(true); + expect(plugin.supports(ViewOperationKind.update)).toBe(true); + }); + + it.each([ + [ViewOperationKind.create, { tableId: 'tblTest', currentViewCount: 1, view: { name: 'Ok' } }], + [ + ViewOperationKind.duplicate, + { tableId: 'tblTest', currentViewCount: 1, addedViewCount: 1, view: { name: 'Ok' } }, + ], + [ViewOperationKind.update, { tableId: 'tblTest', viewId: 'viwTest', patch: { name: 'Ok' } }], + ] satisfies ReadonlyArray]>)( + 'allows %s at configured view operation boundaries', + async (kind, payload) => { + const plugin = createPlugin({ + displayText: { maxNameLength: 2 }, + tableSchema: { maxViewsPerTable: 2 }, + }); + + const result = await runPlugin(plugin, createContext(kind, payload)); + + expect(result.isOk()).toBe(true); + } + ); + + it.each([ + [ + 'validation.limit.views_per_table_max', + ViewOperationKind.create, + { tableId: 'tblTest', currentViewCount: 2, view: {} }, + { tableSchema: { maxViewsPerTable: 2 } }, + ], + [ + 'validation.limit.views_per_table_max', + ViewOperationKind.duplicate, + { tableId: 'tblTest', currentViewCount: 1, addedViewCount: 2, view: {} }, + { tableSchema: { maxViewsPerTable: 2 } }, + ], + [ + 'validation.limit.name_max_length', + ViewOperationKind.update, + { tableId: 'tblTest', viewId: 'viwTest', patch: { name: 'Long' } }, + { displayText: { maxNameLength: 3 } }, + ], + [ + 'validation.limit.description_max_length', + ViewOperationKind.update, + { tableId: 'tblTest', viewId: 'viwTest', patch: { description: 'Long' } }, + { displayText: { maxDescriptionLength: 3 } }, + ], + [ + 'validation.limit.view_filter_items_max', + ViewOperationKind.update, + { + tableId: 'tblTest', + viewId: 'viwTest', + patch: { filter: { conjunction: 'and', filterSet: [filterItem, filterItem] } }, + }, + { viewConfig: { maxFilterItems: 1 } }, + ], + [ + 'validation.limit.view_filter_depth_max', + ViewOperationKind.update, + { + tableId: 'tblTest', + viewId: 'viwTest', + patch: { + filter: { + conjunction: 'and', + filterSet: [{ conjunction: 'and', filterSet: [filterItem] }], + }, + }, + }, + { viewConfig: { maxFilterDepth: 1 } }, + ], + [ + 'validation.limit.view_sort_items_max', + ViewOperationKind.update, + { + tableId: 'tblTest', + viewId: 'viwTest', + patch: { + sort: { + sortObjs: [ + { fieldId: 'fldA', order: 'asc' }, + { fieldId: 'fldB', order: 'desc' }, + ], + }, + }, + }, + { viewConfig: { maxSortItems: 1 } }, + ], + [ + 'validation.limit.view_sort_items_max', + ViewOperationKind.update, + { + tableId: 'tblTest', + viewId: 'viwTest', + patch: { + sort: [ + { fieldId: 'fldA', order: 'asc' }, + { fieldId: 'fldB', order: 'desc' }, + ], + }, + }, + { viewConfig: { maxSortItems: 1 } }, + ], + [ + 'validation.limit.view_group_items_max', + ViewOperationKind.update, + { + tableId: 'tblTest', + viewId: 'viwTest', + patch: { + group: [ + { fieldId: 'fldA', order: 'asc' }, + { fieldId: 'fldB', order: 'desc' }, + ], + }, + }, + { viewConfig: { maxGroupItems: 1 } }, + ], + [ + 'validation.limit.view_options_max_bytes', + ViewOperationKind.update, + { tableId: 'tblTest', viewId: 'viwTest', patch: { options: { rowHeight: 1 } } }, + { viewConfig: { maxOptionsBytes: 4 } }, + ], + ] satisfies ReadonlyArray< + readonly [string, ViewOperationKind, Record, TableLimits] + >)('rejects %s', async (expectedCode, kind, payload, limits) => { + const result = await runPlugin(createPlugin(limits), createContext(kind, payload)); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.code).toBe(expectedCode); + } + }); + + it('uses execution context table limits through the shared composer', async () => { + const plugin = new TableDataSafetyLimitViewOperationPlugin(); + const context = createContext( + ViewOperationKind.update, + { tableId: 'tblTest', viewId: 'viwTest', patch: { name: 'Long' } }, + { displayText: { maxNameLength: 3 } } + ); + + const result = await runPlugin(plugin, context); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.code).toBe('validation.limit.name_max_length'); + } + }); +}); diff --git a/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.ts b/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.ts new file mode 100644 index 0000000000..d5a256b1df --- /dev/null +++ b/packages/v2/core/src/application/services/TableDataSafetyLimitViewOperationPlugin.ts @@ -0,0 +1,204 @@ +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../../domain/shared/DomainError'; +import { + ensureWithinTableDataSafetyLimit, + measureJsonBytes, + resolveTableDataSafetyLimits, + type ResolvedTableDataSafetyLimitConfig, +} from '../../domain/shared/TableDataSafetyLimits'; +import { + ViewOperationKind, + type IViewOperationPlugin, + type ViewOperationPayloadViewConfig, + type ViewOperationPluginContext, +} from '../../ports/ViewOperationPlugin'; +import { + createDefaultTableDataSafetyLimitComposer, + TableDataSafetyLimitComposer, +} from './TableDataSafetyLimitComposer'; + +type PreparedTableDataSafetyViewLimitState = { + readonly limits: ResolvedTableDataSafetyLimitConfig; +}; + +type FilterSetLike = { + readonly filterSet: ReadonlyArray; +}; + +type FilterNode = FilterSetLike | Readonly>; +type FilterMeasureResult = { itemCount: number; depth: number }; + +const isFilterSet = (value: unknown): value is FilterSetLike => + Boolean( + value && + typeof value === 'object' && + 'filterSet' in value && + Array.isArray((value as { filterSet?: unknown }).filterSet) + ); + +const measureFilter = (filter: unknown): FilterMeasureResult => { + if (filter == null) return { itemCount: 0, depth: 0 }; + + const visit = (node: unknown, depth: number): FilterMeasureResult => { + if (!isFilterSet(node)) return { itemCount: 1, depth }; + + return node.filterSet.reduce( + (acc, child) => { + const childResult = visit(child, depth + 1); + return { + itemCount: acc.itemCount + childResult.itemCount, + depth: Math.max(acc.depth, childResult.depth), + }; + }, + { itemCount: 0, depth } + ); + }; + + return visit(filter, 1); +}; + +const sortItemCount = (sort: unknown): number => { + if (sort == null) return 0; + if (Array.isArray(sort)) return sort.length; + if (typeof sort !== 'object') return 0; + const sortObjs = (sort as { sortObjs?: unknown }).sortObjs; + return Array.isArray(sortObjs) ? sortObjs.length : 0; +}; + +const groupItemCount = (group: unknown): number => (Array.isArray(group) ? group.length : 0); + +export const ensureTableDataSafetyViewOperationLimits = ( + context: ViewOperationPluginContext, + limits: ResolvedTableDataSafetyLimitConfig +): Result => { + if (context.kind === ViewOperationKind.create || context.kind === ViewOperationKind.duplicate) { + const addedViewCount = context.payload.addedViewCount ?? 1; + const viewsPerTableResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.views_per_table_max', + context.payload.currentViewCount + addedViewCount, + limits.tableSchema.maxViewsPerTable, + { + target: 'table.views', + tableId: context.payload.tableId, + currentViewCount: context.payload.currentViewCount, + addedViewCount, + } + ); + if (viewsPerTableResult.isErr()) return viewsPerTableResult; + + return ensureTableDataSafetyViewConfigLimits(context.payload.view, limits); + } + + return ensureTableDataSafetyViewConfigLimits(context.payload.patch, limits); +}; + +export const ensureTableDataSafetyViewConfigLimits = ( + view: ViewOperationPayloadViewConfig, + limits: ResolvedTableDataSafetyLimitConfig +): Result => { + if (view.name != null) { + const nameResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.name_max_length', + view.name.length, + limits.displayText.maxNameLength, + { target: 'view.name' } + ); + if (nameResult.isErr()) return nameResult; + } + + if (view.description != null) { + const descriptionResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.description_max_length', + view.description.length, + limits.displayText.maxDescriptionLength, + { target: 'view.description' } + ); + if (descriptionResult.isErr()) return descriptionResult; + } + + if (view.filter !== undefined) { + const { itemCount, depth } = measureFilter(view.filter); + const filterItemsResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.view_filter_items_max', + itemCount, + limits.viewConfig.maxFilterItems, + { target: 'view.filter' } + ); + if (filterItemsResult.isErr()) return filterItemsResult; + + const filterDepthResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.view_filter_depth_max', + depth, + limits.viewConfig.maxFilterDepth, + { target: 'view.filter' } + ); + if (filterDepthResult.isErr()) return filterDepthResult; + } + + if (view.sort !== undefined) { + const sortResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.view_sort_items_max', + sortItemCount(view.sort), + limits.viewConfig.maxSortItems, + { target: 'view.sort' } + ); + if (sortResult.isErr()) return sortResult; + } + + if (view.group !== undefined) { + const groupResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.view_group_items_max', + groupItemCount(view.group), + limits.viewConfig.maxGroupItems, + { target: 'view.group' } + ); + if (groupResult.isErr()) return groupResult; + } + + if (view.options !== undefined) { + const optionsResult = ensureWithinTableDataSafetyLimit( + 'validation.limit.view_options_max_bytes', + measureJsonBytes(view.options), + limits.viewConfig.maxOptionsBytes, + { target: 'view.options' } + ); + if (optionsResult.isErr()) return optionsResult; + } + + return ok(undefined); +}; + +export class TableDataSafetyLimitViewOperationPlugin + implements IViewOperationPlugin +{ + readonly name = 'table-data-safety-view-operation-limit'; + readonly enforce = 'post' as const; + + constructor( + private readonly limitComposer: TableDataSafetyLimitComposer = createDefaultTableDataSafetyLimitComposer() + ) {} + + supports(_operation: ViewOperationKind): boolean { + return true; + } + + async prepare( + context: ViewOperationPluginContext + ): Promise> { + const configResult = await this.limitComposer.compose(context.executionContext); + if (configResult.isErr()) return err(configResult.error); + return ok({ limits: resolveTableDataSafetyLimits(configResult.value) }); + } + + guard( + context: ViewOperationPluginContext, + preparedState: PreparedTableDataSafetyViewLimitState | undefined + ): Result { + return ensureTableDataSafetyViewOperationLimits( + context, + preparedState?.limits ?? resolveTableDataSafetyLimits() + ); + } +} diff --git a/packages/v2/core/src/application/services/TableUpdateFlow.spec.ts b/packages/v2/core/src/application/services/TableUpdateFlow.spec.ts index cdfa13fdf4..30c1aa1b36 100644 --- a/packages/v2/core/src/application/services/TableUpdateFlow.spec.ts +++ b/packages/v2/core/src/application/services/TableUpdateFlow.spec.ts @@ -265,6 +265,32 @@ describe('TableUpdateFlow', () => { expect(order).toEqual(['schema-update', 'after-persist', 'deferred-task']); }); + it('does not change provision state when prepare validation fails before persistence', async () => { + const table = buildTable(); + const repository = new FakeTableRepository(); + const flow = new TableUpdateFlow( + repository, + new FakeTableSchemaRepository(), + new FakeEventBus(), + new FakeUnitOfWork() + ); + + const nextName = TableName.create('Flow Table Invalid')._unsafeUnwrap(); + const result = await flow.execute( + createContext(), + { table }, + (tableToUpdate) => tableToUpdate.update((mutator) => mutator.rename(nextName)), + { + hooks: { + prepare: async () => err(domainError.validation({ message: 'invalid update' })), + }, + } + ); + + expect(result._unsafeUnwrapErr().message).toBe('invalid update'); + expect(repository.provisionStateChanges).toEqual([]); + }); + it('attaches persisted view versions to view column meta events', async () => { const table = buildTable(); const eventBus = new FakeEventBus(); diff --git a/packages/v2/core/src/application/services/TableUpdateFlow.ts b/packages/v2/core/src/application/services/TableUpdateFlow.ts index 079a6b0c2a..4c7153c494 100644 --- a/packages/v2/core/src/application/services/TableUpdateFlow.ts +++ b/packages/v2/core/src/application/services/TableUpdateFlow.ts @@ -4,7 +4,14 @@ import type { Result } from 'neverthrow'; import type { TableUpdateCommand } from '../../commands/TableUpdateCommand'; import type { BaseId } from '../../domain/base/BaseId'; -import { domainError, isNotFoundError, type DomainError } from '../../domain/shared/DomainError'; +import { + domainError, + isConflictError, + isInvariantError, + isNotFoundError, + isValidationError, + type DomainError, +} from '../../domain/shared/DomainError'; import type { IDomainEvent } from '../../domain/shared/DomainEvent'; import type { ISpecification } from '../../domain/shared/specification/ISpecification'; import { FieldOptionsAdded } from '../../domain/table/events/FieldOptionsAdded'; @@ -90,6 +97,10 @@ const normalizeHookResult = ( return { events: result }; }; +const shouldFailSchemaOperation = (error: DomainError): boolean => { + return !isValidationError(error) && !isConflictError(error) && !isInvariantError(error); +}; + @injectable() // Application service: wraps transactional table updates, persistence, schema changes, and events. // Mutations are provided by domain code; this class only orchestrates ports. @@ -128,15 +139,9 @@ export class TableUpdateFlow { events.push(...hostEvents); const mutateSpec = updated.mutateSpec; - yield* await beginTableSchemaOperation( - handler.unitOfWork, - handler.tableRepository, - context, - latestTable, - { type: 'table.update' } - ); let transactionContextRef: IExecutionContext | undefined; + let schemaOperationStarted = false; const transactionResult = await handler.unitOfWork.withTransaction( context, async (metaTransactionContext) => { @@ -155,6 +160,15 @@ export class TableUpdateFlow { latestTable = normalizedResult.table ?? latestTable; } + yield* await beginTableSchemaOperation( + handler.unitOfWork, + handler.tableRepository, + metaTransactionContext, + latestTable, + { type: 'table.update' } + ); + schemaOperationStarted = true; + tableUpdatePersistResult = yield* await handler.tableRepository.updateOne( metaTransactionContext, latestTable, @@ -200,16 +214,20 @@ export class TableUpdateFlow { if (transactionContextRef) { abortTableUpdateTransactionScope(transactionContextRef); } - yield* await failTableSchemaOperation( - handler.unitOfWork, - handler.tableRepository, - context, - latestTable, - { - lastError: transactionResult.error.message, - type: 'table.update', - } - ); + if (schemaOperationStarted && shouldFailSchemaOperation(transactionResult.error)) { + await failTableSchemaOperation( + handler.unitOfWork, + handler.tableRepository, + context, + latestTable, + { + lastError: transactionResult.error.message, + type: 'table.update', + } + ); + } + // Preserve the original failure; the operation-state write can fail when + // a reused transaction has already been aborted by the data phase. return err(transactionResult.error); } diff --git a/packages/v2/core/src/application/services/ViewOperationPluginRunner.ts b/packages/v2/core/src/application/services/ViewOperationPluginRunner.ts new file mode 100644 index 0000000000..6a87f30e5d --- /dev/null +++ b/packages/v2/core/src/application/services/ViewOperationPluginRunner.ts @@ -0,0 +1,161 @@ +import { inject, injectable } from '@teable/v2-di'; +import { err, ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { domainError, type DomainError } from '../../domain/shared/DomainError'; +import type { IExecutionContext } from '../../ports/ExecutionContext'; +import { NoopLogger } from '../../ports/defaults/NoopLogger'; +import * as LoggerPort from '../../ports/Logger'; +import { v2CoreTokens } from '../../ports/tokens'; +import { + type IViewOperationPlugin, + type ViewOperationPluginContext, + type ViewOperationPluginEnforce, +} from '../../ports/ViewOperationPlugin'; + +type PreparedPluginEntry = { + readonly plugin: IViewOperationPlugin; + readonly preparedState: unknown; +}; + +const enforceOrder = (enforce?: ViewOperationPluginEnforce): number => { + if (enforce === 'pre') return 0; + if (enforce === 'post') return 2; + return 1; +}; + +const createEnforceGroups = ( + items: ReadonlyArray, + getEnforce: (item: T) => ViewOperationPluginEnforce | undefined +): T[][] => { + const groups: [T[], T[], T[]] = [[], [], []]; + + for (const item of items) { + groups[enforceOrder(getEnforce(item))].push(item); + } + + return groups.filter((group) => group.length > 0); +}; + +const withTransactionBoundContext = ( + context: ViewOperationPluginContext, + executionContext: IExecutionContext +): ViewOperationPluginContext => { + return { + ...context, + executionContext, + isTransactionBound: true, + } as ViewOperationPluginContext; +}; + +export class ViewOperationPluginExecution { + constructor( + private readonly logger: LoggerPort.ILogger, + private readonly context: ViewOperationPluginContext, + private readonly preparedPlugins: ReadonlyArray + ) {} + + async guard(executionContext?: IExecutionContext): Promise> { + const context = executionContext + ? withTransactionBoundContext(this.context, executionContext) + : this.context; + + for (const group of createEnforceGroups( + this.preparedPlugins, + (entry) => entry.plugin.enforce + )) { + const results = await Promise.all(group.map((entry) => this.invokeGuard(context, entry))); + + for (const result of results) { + if (result.isErr()) return err(result.error); + } + } + + return ok(undefined); + } + + private async invokeGuard( + context: ViewOperationPluginContext, + entry: PreparedPluginEntry + ): Promise> { + const plugin = entry.plugin; + if (!plugin.guard) return ok(undefined); + + try { + const result = await plugin.guard.call(plugin, context, entry.preparedState); + if (result.isErr()) return err(result.error); + return ok(undefined); + } catch (error) { + return err( + domainError.fromUnknown(error, { + code: 'view_operation_plugin.guard_failed', + details: { + operation: context.kind, + plugin: plugin.name, + }, + }) + ); + } + } + + logSkippedAfterError(pluginName: string, error: DomainError): void { + this.logger.error('View operation plugin failed', { + operation: this.context.kind, + plugin: pluginName, + error, + }); + } +} + +@injectable() +export class ViewOperationPluginRunner { + constructor( + @inject(v2CoreTokens.viewOperationPlugins) + private readonly plugins?: IViewOperationPlugin[], + @inject(v2CoreTokens.logger) + private readonly logger?: LoggerPort.ILogger + ) {} + + async prepare( + context: ViewOperationPluginContext + ): Promise> { + const matchedPlugins = (this.plugins ?? []).filter((plugin) => plugin.supports(context.kind)); + const preparedPlugins: PreparedPluginEntry[] = []; + + for (const group of createEnforceGroups(matchedPlugins, (plugin) => plugin.enforce)) { + const results = await Promise.all(group.map((plugin) => this.preparePlugin(plugin, context))); + + for (const result of results) { + if (result.isErr()) return err(result.error); + preparedPlugins.push(result.value); + } + } + + return ok( + new ViewOperationPluginExecution(this.logger ?? new NoopLogger(), context, preparedPlugins) + ); + } + + private async preparePlugin( + plugin: IViewOperationPlugin, + context: ViewOperationPluginContext + ): Promise> { + if (!plugin.prepare) return ok({ plugin, preparedState: undefined }); + + try { + const result = await plugin.prepare.call(plugin, context); + if (result.isErr()) return err(result.error); + return ok({ plugin, preparedState: result.value }); + } catch (error) { + return err( + domainError.fromUnknown(error, { + code: 'view_operation_plugin.prepare_failed', + details: { + operation: context.kind, + plugin: plugin.name, + }, + }) + ); + } + } +} diff --git a/packages/v2/core/src/commands/CreateTableHandler.ts b/packages/v2/core/src/commands/CreateTableHandler.ts index 583c8f393d..629170de94 100644 --- a/packages/v2/core/src/commands/CreateTableHandler.ts +++ b/packages/v2/core/src/commands/CreateTableHandler.ts @@ -103,6 +103,7 @@ export class CreateTableHandler implements ICommandHandler ({ baseId: command.baseId, tableName: table.name(), + table, fieldCount: table.getFields().length, viewCount: table.views().length, recordCount: tableCommands[index]?.records.length ?? 0, diff --git a/packages/v2/core/src/commands/DeleteTableHandler.ts b/packages/v2/core/src/commands/DeleteTableHandler.ts index a339a61ce2..668a2a0b60 100644 --- a/packages/v2/core/src/commands/DeleteTableHandler.ts +++ b/packages/v2/core/src/commands/DeleteTableHandler.ts @@ -122,10 +122,22 @@ export class DeleteTableHandler implements ICommandHandler + ): Promise> { return err({ code: 'not_implemented', message: 'not implemented', @@ -76,7 +89,11 @@ class FakeTableRepository implements ITableRepository { }); } - async find() { + async find( + _: IExecutionContext, + __: ISpecification, + ___?: IFindOptions + ): Promise, DomainError>> { return ok([]); } @@ -146,7 +163,7 @@ class FakeTableCreationService { ): Promise> { const persisted = await this.persistMetadata(context, input); if (persisted.isErr()) { - return persisted; + return err(persisted.error); } return this.provisionData(context, { ...input, @@ -403,4 +420,54 @@ describe('ImportDotTeaStructureHandler', () => { expect(firstValue.fieldIdMap[fieldId]).not.toBe(secondValue.fieldIdMap[fieldId]); expect(firstValue.viewIdMap[viewId]).not.toBe(secondValue.viewIdMap[viewId]); }); + + it('runs table operation safety limits for imported structures', async () => { + const parser = new FakeDotTeaParser( + ok({ + tables: [ + { + name: 'Products', + fields: [ + { name: 'Name', type: 'singleLineText', isPrimary: true }, + { name: 'Sku', type: 'singleLineText' }, + ], + }, + ], + }) + ); + const tableRepository = new FakeTableRepository(); + const tableCreationService = new FakeTableCreationService(); + const tableOperationPluginRunner = createTableOperationPluginRunner([ + new TableDataSafetyLimitTableOperationPlugin( + tableRepository, + new TableDataSafetyLimitComposer([ + new StaticTableDataSafetyLimitPlugin({ + tableSchema: { + maxFieldsPerTable: 1, + }, + }), + ]) + ), + ]); + const handler = new ImportDotTeaStructureHandler( + parser, + new FakeForeignTableLoaderService() as never, + tableRepository, + tableCreationService as never, + new FakeEventBus(), + new FakeUnitOfWork(), + tableOperationPluginRunner + ); + + const command = ImportDotTeaStructureCommand.createFromBuffer({ + baseId, + dotTeaData: new Uint8Array([1]), + })._unsafeUnwrap(); + + const result = await handler.handle(createContext(), command); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().code).toBe('validation.limit.fields_per_table_max'); + expect(tableCreationService.lastInput).toBeUndefined(); + }); }); diff --git a/packages/v2/core/src/commands/ImportDotTeaStructureHandler.ts b/packages/v2/core/src/commands/ImportDotTeaStructureHandler.ts index 6e782eed4c..582b82f8c8 100644 --- a/packages/v2/core/src/commands/ImportDotTeaStructureHandler.ts +++ b/packages/v2/core/src/commands/ImportDotTeaStructureHandler.ts @@ -4,6 +4,7 @@ import type { Result } from 'neverthrow'; import { ForeignTableLoaderService } from '../application/services/ForeignTableLoaderService'; import { TableCreationService } from '../application/services/TableCreationService'; +import { TableOperationPluginRunner } from '../application/services/TableOperationPluginRunner'; import { beginTablesSchemaOperation, completeTablesSchemaOperation, @@ -20,11 +21,13 @@ import { TableId } from '../domain/table/TableId'; import { ViewId } from '../domain/table/views/ViewId'; import * as DotTeaParserPort from '../ports/DotTeaParser'; import type { NormalizedDotTeaStructure } from '../ports/DotTeaParser'; +import { NoopLogger } from '../ports/defaults/NoopLogger'; import * as EventBusPort from '../ports/EventBus'; import * as ExecutionContextPort from '../ports/ExecutionContext'; import * as TableRepositoryPort from '../ports/TableRepository'; import { v2CoreTokens } from '../ports/tokens'; import { TraceSpan } from '../ports/TraceSpan'; +import { TableOperationKind } from '../ports/TableOperationPlugin'; import * as UnitOfWorkPort from '../ports/UnitOfWork'; import type { ITableFieldInput } from '../schemas/field'; import { CommandHandler, type ICommandHandler } from './CommandHandler'; @@ -177,7 +180,12 @@ export class ImportDotTeaStructureHandler @inject(v2CoreTokens.eventBus) private readonly eventBus: EventBusPort.IEventBus, @inject(v2CoreTokens.unitOfWork) - private readonly unitOfWork: UnitOfWorkPort.IUnitOfWork + private readonly unitOfWork: UnitOfWorkPort.IUnitOfWork, + @inject(v2CoreTokens.tableOperationPluginRunner) + private readonly tableOperationPluginRunner: TableOperationPluginRunner = new TableOperationPluginRunner( + [], + new NoopLogger() + ) ) {} @TraceSpan() @@ -310,6 +318,25 @@ export class ImportDotTeaStructureHandler // Extract tables and foreign references from build results const builtTables = buildResults.map((r) => r.table); + const tablePluginExecution = yield* await handler.tableOperationPluginRunner.prepare({ + kind: TableOperationKind.createMany, + executionContext: context, + payload: { + baseId: command.baseId, + tables: builtTables.map((table) => ({ + baseId: command.baseId, + tableName: table.name(), + table, + fieldCount: table.getFields().length, + viewCount: table.views().length, + recordCount: 0, + viewNames: table.views().map((view) => view.name().toString()), + })), + }, + isTransactionBound: false, + }); + yield* await tablePluginExecution.guard(); + const recordCountByTableId = Object.fromEntries( builtTables.map((table) => [table.id().toString(), 0]) ); diff --git a/packages/v2/core/src/commands/ImportRecordsHandler.spec.ts b/packages/v2/core/src/commands/ImportRecordsHandler.spec.ts index 2612148452..7b33383c26 100644 --- a/packages/v2/core/src/commands/ImportRecordsHandler.spec.ts +++ b/packages/v2/core/src/commands/ImportRecordsHandler.spec.ts @@ -427,7 +427,7 @@ describe('ImportRecordsHandler', () => { expectRecordWritePluginToBeSkipped(calls, RecordWriteOperationKind.importAppend); }); - it('returns validation.max_row_limit for async sources that exceed maxRowCount', async () => { + it('returns validation.limit.rows_per_table_max for async sources that exceed maxRowCount', async () => { const { table, textFieldId } = buildTable(); async function* rowsAsync() { yield ['row 1']; @@ -472,7 +472,7 @@ describe('ImportRecordsHandler', () => { ); expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr().code).toBe('validation.max_row_limit'); + expect(result._unsafeUnwrapErr().code).toBe('validation.limit.rows_per_table_max'); expect(tableRecordRepository.inserted).toHaveLength(0); expect(calls.prepare).toHaveLength(0); expect(calls.guard).toHaveLength(0); diff --git a/packages/v2/core/src/commands/ImportRecordsHandler.ts b/packages/v2/core/src/commands/ImportRecordsHandler.ts index dbf3f16110..8e97804f6c 100644 --- a/packages/v2/core/src/commands/ImportRecordsHandler.ts +++ b/packages/v2/core/src/commands/ImportRecordsHandler.ts @@ -134,9 +134,10 @@ export class ImportRecordsHandler if (error instanceof MaxRowCountExceededError) { return err( domainError.validation({ - code: 'validation.max_row_limit', + code: 'validation.limit.rows_per_table_max', message: `Exceed max row limit: ${error.maxRowCount}`, details: { + max: error.maxRowCount, maxRowCount: error.maxRowCount, rowCount: error.rowCount, }, diff --git a/packages/v2/core/src/di/registerCoreServices.ts b/packages/v2/core/src/di/registerCoreServices.ts index 6895ba6861..541664b6e5 100644 --- a/packages/v2/core/src/di/registerCoreServices.ts +++ b/packages/v2/core/src/di/registerCoreServices.ts @@ -40,12 +40,14 @@ import { TableFieldLimitFieldOperationPlugin } from '../application/services/Tab import { TableDataSafetyLimitFieldOperationPlugin } from '../application/services/TableDataSafetyLimitFieldOperationPlugin'; import { TableDataSafetyLimitRecordWritePlugin } from '../application/services/TableDataSafetyLimitRecordWritePlugin'; import { TableDataSafetyLimitTableOperationPlugin } from '../application/services/TableDataSafetyLimitTableOperationPlugin'; +import { TableDataSafetyLimitViewOperationPlugin } from '../application/services/TableDataSafetyLimitViewOperationPlugin'; import { TableOperationPluginRunner } from '../application/services/TableOperationPluginRunner'; import { TableQueryService } from '../application/services/TableQueryService'; import { TableSchemaOperationRepairHandler } from '../application/services/TableSchemaOperationRepairHandler'; import { TableUpdateFlow } from '../application/services/TableUpdateFlow'; import { UndoRedoStackService } from '../application/services/UndoRedoStackService'; import { UserValueResolverService } from '../application/services/UserValueResolverService'; +import { ViewOperationPluginRunner } from '../application/services/ViewOperationPluginRunner'; import { PasteStreamApplicationService } from '../commands/PasteHandler'; import { NoopAttachmentUrlSignerService } from '../ports/defaults/NoopAttachmentUrlSignerService'; import { NoopRecordOrderCalculator } from '../ports/defaults/NoopRecordOrderCalculator'; @@ -54,11 +56,13 @@ import type { IFieldOperationPlugin } from '../ports/FieldOperationPlugin'; import type { IRecordWritePlugin } from '../ports/RecordWritePlugin'; import type { ITableDataSafetyLimitPlugin } from '../ports/TableDataSafetyLimitPlugin'; import type { ITableOperationPlugin } from '../ports/TableOperationPlugin'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; import { v2CoreTokens } from '../ports/tokens'; import { registerFieldOperationPlugin } from './registerFieldOperationPlugin'; import { registerRecordWritePlugin } from './registerRecordWritePlugin'; import { registerTableDataSafetyLimitPlugin } from './registerTableDataSafetyLimitPlugin'; import { registerTableOperationPlugin } from './registerTableOperationPlugin'; +import { registerViewOperationPlugin } from './registerViewOperationPlugin'; /** * Register all v2 core internal application services. @@ -393,6 +397,24 @@ export const registerV2CoreServices = ( }); } + if (!container.isRegistered(v2CoreTokens.viewOperationPlugins)) { + container.registerInstance(v2CoreTokens.viewOperationPlugins, [] as IViewOperationPlugin[]); + } + + registerViewOperationPlugin( + container, + new TableDataSafetyLimitViewOperationPlugin(tableDataSafetyLimitComposer), + { + source: 'registerV2CoreServices', + } + ); + + if (!container.isRegistered(v2CoreTokens.viewOperationPluginRunner)) { + container.register(v2CoreTokens.viewOperationPluginRunner, ViewOperationPluginRunner, { + lifecycle, + }); + } + // RecordMutationSpecResolverService - resolve external values in specs if (!container.isRegistered(v2CoreTokens.recordMutationSpecResolverService)) { container.register( diff --git a/packages/v2/core/src/di/registerViewOperationPlugin.ts b/packages/v2/core/src/di/registerViewOperationPlugin.ts new file mode 100644 index 0000000000..5a58853d2c --- /dev/null +++ b/packages/v2/core/src/di/registerViewOperationPlugin.ts @@ -0,0 +1,68 @@ +import type { DependencyContainer } from '@teable/v2-di'; + +import { NoopLogger } from '../ports/defaults/NoopLogger'; +import type { ILogger } from '../ports/Logger'; +import { v2CoreTokens } from '../ports/tokens'; +import type { IViewOperationPlugin } from '../ports/ViewOperationPlugin'; + +export interface IRegisterViewOperationPluginOptions { + source?: string; + logger?: ILogger; +} + +export interface IRegisterViewOperationPluginResult { + plugin: IViewOperationPlugin; + registered: boolean; + totalPlugins: number; +} + +const resolveLogger = (container: DependencyContainer, explicitLogger?: ILogger): ILogger => { + if (explicitLogger) return explicitLogger; + if (container.isRegistered(v2CoreTokens.logger)) { + return container.resolve(v2CoreTokens.logger); + } + return new NoopLogger(); +}; + +const ensurePluginRegistry = (container: DependencyContainer): IViewOperationPlugin[] => { + if (!container.isRegistered(v2CoreTokens.viewOperationPlugins)) { + container.registerInstance(v2CoreTokens.viewOperationPlugins, [] as IViewOperationPlugin[]); + } + + return container.resolve(v2CoreTokens.viewOperationPlugins); +}; + +export const registerViewOperationPlugin = ( + container: DependencyContainer, + plugin: IViewOperationPlugin, + options: IRegisterViewOperationPluginOptions = {} +): IRegisterViewOperationPluginResult => { + const plugins = ensurePluginRegistry(container); + const logger = resolveLogger(container, options.logger).scope('viewOperationPlugin', { + plugin: plugin.name, + source: options.source, + }); + + const existingPlugin = plugins.find((registeredPlugin) => registeredPlugin.name === plugin.name); + if (existingPlugin) { + logger.info('View operation plugin already registered', { + totalPlugins: plugins.length, + }); + return { + plugin: existingPlugin, + registered: false, + totalPlugins: plugins.length, + }; + } + + plugins.push(plugin); + logger.info('View operation plugin registered', { + totalPlugins: plugins.length, + }); + + return { + plugin, + registered: true, + totalPlugins: plugins.length, + }; +}; diff --git a/packages/v2/core/src/domain/table/Table.ts b/packages/v2/core/src/domain/table/Table.ts index 2728272db2..b1d0dda697 100644 --- a/packages/v2/core/src/domain/table/Table.ts +++ b/packages/v2/core/src/domain/table/Table.ts @@ -1138,12 +1138,13 @@ export class Table extends AggregateRoot { } } - // Check for name uniqueness if name changed (excluding the current field) - const nameConflict = this.fieldsValue.some( - (f) => !f.id().equals(fieldId) && f.name().equals(newField.name()) - ); - if (nameConflict) { - return err(domainError.conflict({ message: 'Field names must be unique' })); + if (!oldField.name().equals(newField.name())) { + const nameConflict = this.fieldsValue.some( + (f) => !f.id().equals(fieldId) && f.name().equals(newField.name()) + ); + if (nameConflict) { + return err(domainError.conflict({ message: 'Field names must be unique' })); + } } const nextFields = this.fieldsValue.map((f) => (f.id().equals(fieldId) ? newField : f)); diff --git a/packages/v2/core/src/index.ts b/packages/v2/core/src/index.ts index c2f5bf371b..71ddb4b00f 100644 --- a/packages/v2/core/src/index.ts +++ b/packages/v2/core/src/index.ts @@ -31,7 +31,9 @@ export * from './application/services/TableDataSafetyLimitComposer'; export * from './application/services/TableDataSafetyLimitFieldOperationPlugin'; export * from './application/services/TableDataSafetyLimitRecordWritePlugin'; export * from './application/services/TableDataSafetyLimitTableOperationPlugin'; +export * from './application/services/TableDataSafetyLimitViewOperationPlugin'; export * from './application/services/TableOperationPluginRunner'; +export * from './application/services/ViewOperationPluginRunner'; export * from './application/services/FieldKeyResolverService'; export * from './application/services/FieldDeletionSideEffectService'; export * from './application/services/FieldUndoRedoReplayService'; @@ -155,6 +157,7 @@ export * from './ports/ComputedUpdateDrainService'; export * from './ports/FieldOperationPlugin'; export * from './ports/TableDataSafetyLimitPlugin'; export * from './ports/TableOperationPlugin'; +export * from './ports/ViewOperationPlugin'; export * from './ports/UserRenamePropagationService'; export * from './ports/UserLookupService'; export * from './ports/UserAvatarUrl'; @@ -183,6 +186,7 @@ export * from './di/registerFieldOperationPlugin'; export * from './di/registerRecordWritePlugin'; export * from './di/registerTableDataSafetyLimitPlugin'; export * from './di/registerTableOperationPlugin'; +export * from './di/registerViewOperationPlugin'; export * from './domain/table/resolveFormulaFields'; export { FunctionName, FormulaFuncType } from './domain/formula/functions/common'; export { normalizeFunctionNameAlias } from './domain/formula/function-aliases'; diff --git a/packages/v2/core/src/ports/TableOperationPlugin.ts b/packages/v2/core/src/ports/TableOperationPlugin.ts index 4757ad644f..346e835bd5 100644 --- a/packages/v2/core/src/ports/TableOperationPlugin.ts +++ b/packages/v2/core/src/ports/TableOperationPlugin.ts @@ -2,6 +2,7 @@ import type { Result } from 'neverthrow'; import type { BaseId } from '../domain/base/BaseId'; import type { DomainError } from '../domain/shared/DomainError'; +import type { Table } from '../domain/table/Table'; import type { TableName } from '../domain/table/TableName'; import type { IExecutionContext } from './ExecutionContext'; import type { PluginTraceContext } from './Tracer'; @@ -31,6 +32,7 @@ interface ITableOperationPluginContextBase & { state?: TableQueryState; diff --git a/packages/v2/core/src/ports/TableSchemaRepository.ts b/packages/v2/core/src/ports/TableSchemaRepository.ts index 9bccdea4bf..f053e33194 100644 --- a/packages/v2/core/src/ports/TableSchemaRepository.ts +++ b/packages/v2/core/src/ports/TableSchemaRepository.ts @@ -12,7 +12,10 @@ export interface ITableSchemaRepository { ensureInserted?(context: IExecutionContext, table: Table): Promise>; insertMany( context: IExecutionContext, - tables: ReadonlyArray
+ tables: ReadonlyArray
, + options?: { + knownTables?: ReadonlyArray
; + } ): Promise>; ensureInsertedMany?( context: IExecutionContext, diff --git a/packages/v2/core/src/ports/ViewOperationPlugin.ts b/packages/v2/core/src/ports/ViewOperationPlugin.ts new file mode 100644 index 0000000000..b1d3cd5b19 --- /dev/null +++ b/packages/v2/core/src/ports/ViewOperationPlugin.ts @@ -0,0 +1,92 @@ +import type { Result } from 'neverthrow'; + +import type { DomainError } from '../domain/shared/DomainError'; +import type { IExecutionContext } from './ExecutionContext'; +import type { PluginTraceContext } from './Tracer'; + +export const ViewOperationKind = { + create: 'create', + duplicate: 'duplicate', + update: 'update', +} as const; + +export type ViewOperationKind = (typeof ViewOperationKind)[keyof typeof ViewOperationKind]; + +export type ViewOperationPluginEnforce = 'pre' | 'post'; + +type ViewOperationPluginHookResult = Result | Promise>; + +export type ViewOperationPayloadViewConfig = { + readonly name?: string | null; + readonly description?: string | null; + readonly filter?: unknown; + readonly sort?: unknown; + readonly group?: unknown; + readonly options?: unknown; +}; + +type ViewOperationCountLimitPayload = { + readonly tableId: string; + readonly currentViewCount: number; + readonly addedViewCount?: number; +}; + +export type ViewOperationCreatePayload = ViewOperationCountLimitPayload & { + readonly view: ViewOperationPayloadViewConfig; +}; + +export type ViewOperationDuplicatePayload = ViewOperationCountLimitPayload & { + readonly sourceViewId?: string; + readonly view: ViewOperationPayloadViewConfig; +}; + +export type ViewOperationUpdatePayload = { + readonly tableId: string; + readonly viewId: string; + readonly patch: ViewOperationPayloadViewConfig; +}; + +interface IViewOperationPluginContextBase { + readonly kind: TKind; + readonly executionContext: IExecutionContext; + readonly payload: TPayload; + readonly trace?: PluginTraceContext; + readonly isTransactionBound: boolean; +} + +export type IViewOperationCreateContext = IViewOperationPluginContextBase< + 'create', + ViewOperationCreatePayload +>; + +export type IViewOperationDuplicateContext = IViewOperationPluginContextBase< + 'duplicate', + ViewOperationDuplicatePayload +>; + +export type IViewOperationUpdateContext = IViewOperationPluginContextBase< + 'update', + ViewOperationUpdatePayload +>; + +export type ViewOperationPluginContextMap = { + create: IViewOperationCreateContext; + duplicate: IViewOperationDuplicateContext; + update: IViewOperationUpdateContext; +}; + +export type ViewOperationPluginContext = ViewOperationPluginContextMap[ViewOperationKind]; + +export interface IViewOperationPlugin { + readonly name: string; + readonly enforce?: ViewOperationPluginEnforce; + + supports(operation: ViewOperationKind): boolean; + + prepare?(context: ViewOperationPluginContext): ViewOperationPluginHookResult; + + guard?( + context: ViewOperationPluginContext, + preparedState: TPreparedState | undefined + ): ViewOperationPluginHookResult; +} diff --git a/packages/v2/core/src/ports/tokens.ts b/packages/v2/core/src/ports/tokens.ts index e2c9a3212f..ab4fa76ac6 100644 --- a/packages/v2/core/src/ports/tokens.ts +++ b/packages/v2/core/src/ports/tokens.ts @@ -47,6 +47,8 @@ export const v2CoreTokens = { fieldOperationPlugins: Symbol('v2.core.fieldOperationPlugins'), tableOperationPluginRunner: Symbol('v2.core.tableOperationPluginRunner'), tableOperationPlugins: Symbol('v2.core.tableOperationPlugins'), + viewOperationPluginRunner: Symbol('v2.core.viewOperationPluginRunner'), + viewOperationPlugins: Symbol('v2.core.viewOperationPlugins'), tableDataSafetyLimitComposer: Symbol('v2.core.tableDataSafetyLimitComposer'), tableDataSafetyLimitPlugins: Symbol('v2.core.tableDataSafetyLimitPlugins'), tableMapper: Symbol('v2.core.tableMapper'), diff --git a/packages/v2/e2e/src/deleteTable.e2e.spec.ts b/packages/v2/e2e/src/deleteTable.e2e.spec.ts index 51675ff69c..6c0b330522 100644 --- a/packages/v2/e2e/src/deleteTable.e2e.spec.ts +++ b/packages/v2/e2e/src/deleteTable.e2e.spec.ts @@ -665,6 +665,94 @@ describe('v2 http deleteTable (e2e)', () => { } }); + it('deletes a foreign table when referencing link fields already have duplicate names', async () => { + let foreignTableId: string | undefined; + let hostTableId: string | undefined; + + try { + const foreignTable = await ctx.createTable({ + baseId: ctx.baseId, + name: nextName('DeleteTable Duplicate Link Name Foreign'), + fields: [{ type: 'singleLineText', name: 'Name', isPrimary: true }], + }); + foreignTableId = foreignTable.id; + + const foreignPrimaryFieldId = foreignTable.fields.find((field) => field.isPrimary)?.id; + if (!foreignPrimaryFieldId) { + throw new Error('Missing duplicate-name foreign primary field'); + } + + const hostTable = await ctx.createTable({ + baseId: ctx.baseId, + name: nextName('DeleteTable Duplicate Link Name Host'), + fields: [{ type: 'singleLineText', name: 'Host Name', isPrimary: true }], + }); + hostTableId = hostTable.id; + + const tableWithFirstLink = await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + type: 'link', + name: 'Public Release Drafts A', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + const firstLinkFieldId = tableWithFirstLink.fields.find( + (field) => field.name === 'Public Release Drafts A' + )?.id; + if (!firstLinkFieldId) { + throw new Error('Missing first duplicate-name link field'); + } + + const tableWithSecondLink = await ctx.createField({ + baseId: ctx.baseId, + tableId: hostTable.id, + field: { + type: 'link', + name: 'Public Release Drafts B', + options: { + relationship: 'manyOne', + foreignTableId: foreignTable.id, + lookupFieldId: foreignPrimaryFieldId, + isOneWay: true, + }, + }, + }); + const secondLinkFieldId = tableWithSecondLink.fields.find( + (field) => field.name === 'Public Release Drafts B' + )?.id; + if (!secondLinkFieldId) { + throw new Error('Missing second duplicate-name link field'); + } + + await sql` + UPDATE "field" + SET "name" = 'Public Release Drafts' + WHERE "id" IN (${firstLinkFieldId}, ${secondLinkFieldId}) + `.execute(ctx.testContainer.db); + + await ctx.deleteTable(foreignTable.id, { mode: 'soft' }); + + const refreshedHost = await ctx.getTableById(hostTable.id); + const convertedFields = refreshedHost.fields.filter((field) => + [firstLinkFieldId, secondLinkFieldId].includes(field.id) + ); + + expect(convertedFields).toHaveLength(2); + expect(convertedFields.every((field) => field.name === 'Public Release Drafts')).toBe(true); + expect(convertedFields.every((field) => field.type === 'singleLineText')).toBe(true); + } finally { + await safeDeleteTable(hostTableId); + await safeDeleteTable(foreignTableId); + } + }); + it('publishes schema refresh action triggers for affected host tables during delete-table side effects', async () => { let foreignTableId: string | undefined; let hostTableId: string | undefined; diff --git a/packages/v2/e2e/src/update-field/longText/conversion/to-date.spec.ts b/packages/v2/e2e/src/update-field/longText/conversion/to-date.spec.ts index 68a0463b02..b793ec594d 100644 --- a/packages/v2/e2e/src/update-field/longText/conversion/to-date.spec.ts +++ b/packages/v2/e2e/src/update-field/longText/conversion/to-date.spec.ts @@ -109,17 +109,22 @@ describe('update-field: longText → date conversion', () => { await ctx.deleteRecords(tableId, [r1.id, r2.id]); }); - test('should reject conversion when value is multi-line date-like text', async () => { + test('should convert multi-line date-like text to null without breaking the table', async () => { const fieldId = await createLongTextField('Multiline Date Text Field'); const r1 = await ctx.createRecord(tableId, { [fieldId]: '2024-01-15\nsome notes' }); - await expect( - ctx.updateField({ - tableId, - fieldId, - field: { type: 'date' }, - }) - ).rejects.toThrow(); + const updatedTable = await ctx.updateField({ + tableId, + fieldId, + field: { type: 'date' }, + }); + + const updatedField = updatedTable.fields.find((f) => f.id === fieldId); + expect(updatedField?.type).toBe('date'); + + const records = await ctx.listRecords(tableId); + const rec1 = records.find((r) => r.id === r1.id); + expect(rec1?.fields[fieldId]).toBeNull(); await ctx.deleteField({ tableId, fieldId }); await ctx.deleteRecords(tableId, [r1.id]); diff --git a/packages/v2/formula-sql-pg/src/FormulaSqlPgExpressionBuilder.ts b/packages/v2/formula-sql-pg/src/FormulaSqlPgExpressionBuilder.ts index c7c9a7301c..f0e4c59a3c 100644 --- a/packages/v2/formula-sql-pg/src/FormulaSqlPgExpressionBuilder.ts +++ b/packages/v2/formula-sql-pg/src/FormulaSqlPgExpressionBuilder.ts @@ -1157,7 +1157,8 @@ export class FormulaSqlPgExpressionBuilder { } const textValue = this.coerceToString(base); - const numericCast = this.buildLooseNumericCast(textValue.valueSql); + const numericTextSql = this.nullifyBlankCaseBranches(textValue.valueSql); + const numericCast = this.buildLooseNumericCast(numericTextSql); const valueSql = numericCast.valueSql; const errorCondition = numericCast.invalidSql; const combinedErrorCondition = combineErrorConditions([ @@ -1476,6 +1477,10 @@ export class FormulaSqlPgExpressionBuilder { ); } + private nullifyBlankCaseBranches(valueSql: string): string { + return valueSql.replace(/\b(THEN|ELSE)\s+''(?=\s|$)/g, '$1 NULL'); + } + protected getFieldTypeName(expr: SqlExpr): string | undefined { return expr.field?.type().toString(); } @@ -1489,6 +1494,10 @@ export class FormulaSqlPgExpressionBuilder { } private normalizeLookupArrayExpr(expr: SqlExpr): string { + if (expr.valueSql.trim().toUpperCase() === 'NULL') { + return "'[]'::jsonb"; + } + const base = `(${expr.valueSql})`; // Lookup fields may come from various sources. Use safeJsonbWithStrategy for type safety. if (expr.field && this.isLookupArrayField(expr)) { diff --git a/packages/v2/formula-sql-pg/src/LookupArrayNormalization.spec.ts b/packages/v2/formula-sql-pg/src/LookupArrayNormalization.spec.ts index 9e022c069d..9f85389301 100644 --- a/packages/v2/formula-sql-pg/src/LookupArrayNormalization.spec.ts +++ b/packages/v2/formula-sql-pg/src/LookupArrayNormalization.spec.ts @@ -109,6 +109,13 @@ describe('lookup array normalization', () => { // Should not have bare ''::jsonb which would fail expect(normalized).not.toContain("''::jsonb"); }); + + it('normalizes null lookup projections without polymorphic to_jsonb calls', () => { + const expr = makeExpr('NULL', 'unknown', true, undefined, undefined, lookupField, 'array'); + const normalized = builder.normalizeArray(expr); + + expect(normalized).toBe("'[]'::jsonb"); + }); }); const unwrapOrThrow = (result: Result, label: string): T => { diff --git a/packages/v2/formula-sql-pg/src/PgSqlHelpers.ts b/packages/v2/formula-sql-pg/src/PgSqlHelpers.ts index 00acc2ef27..436f42897e 100644 --- a/packages/v2/formula-sql-pg/src/PgSqlHelpers.ts +++ b/packages/v2/formula-sql-pg/src/PgSqlHelpers.ts @@ -19,6 +19,7 @@ export const buildErrorLiteral = (code: ErrorCode, reason: string): string => export const safeJsonb = (expr: string): string => { const baseExpr = `(${expr})`; + const textTypes = SQL_TEXT_TYPES.join(', '); const textSql = `(${baseExpr})::text`; const trimmedText = `BTRIM(${textSql})`; return `(CASE @@ -26,6 +27,7 @@ export const safeJsonb = (expr: string): string => { WHEN pg_typeof(${baseExpr}) = 'jsonb'::regtype THEN to_jsonb(${baseExpr}) WHEN pg_typeof(${baseExpr}) = 'json'::regtype THEN to_jsonb(${baseExpr}) WHEN NULLIF(${trimmedText}, '') IS NULL THEN NULL + WHEN pg_typeof(${baseExpr}) IN (${textTypes}) THEN to_jsonb(${textSql}) ELSE to_jsonb(${baseExpr}) END)`; }; @@ -48,7 +50,7 @@ export const safeJsonbWithStrategy = ( CASE WHEN NULLIF(${trimmedText}, '') IS NULL THEN NULL WHEN ${looksJson} AND ${jsonValid} THEN (${textSql})::jsonb - ELSE to_jsonb(${baseExpr}) + ELSE to_jsonb(${textSql}) END ELSE to_jsonb(${baseExpr}) END)`; @@ -75,7 +77,7 @@ export const normalizeToJsonArray = (expr: string): string => { CASE WHEN NULLIF(${trimmedText}, '') IS NULL THEN '[]'::jsonb WHEN ${looksJson} AND ${jsonValid} THEN (${textSql})::jsonb - ELSE to_jsonb(${baseExpr}) + ELSE to_jsonb(${textSql}) END ELSE to_jsonb(${baseExpr}) END)`; @@ -113,7 +115,7 @@ export const normalizeToJsonArrayWithStrategy = ( CASE WHEN NULLIF(${trimmedText}, '') IS NULL THEN '[]'::jsonb WHEN ${looksJson} AND ${jsonValid} THEN (${textSql})::jsonb - ELSE to_jsonb(${baseExpr}) + ELSE to_jsonb(${textSql}) END ELSE to_jsonb(${baseExpr}) END)`; diff --git a/packages/v2/formula-sql-pg/src/__snapshots__/ArrayFunctions.spec.ts.snap b/packages/v2/formula-sql-pg/src/__snapshots__/ArrayFunctions.spec.ts.snap index 5d507f2f54..e09c9dec45 100644 --- a/packages/v2/formula-sql-pg/src/__snapshots__/ArrayFunctions.spec.ts.snap +++ b/packages/v2/formula-sql-pg/src/__snapshots__/ArrayFunctions.spec.ts.snap @@ -99,7 +99,7 @@ exports[`array functions > 'ArrayCompact' with 'button' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) END) AS v) AS _cj)) WITH ORDINALITY AS elem(value, ord) @@ -117,7 +117,7 @@ exports[`array functions > 'ArrayCompact' with 'button' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) END) AS v) AS _cj)) WITH ORDINALITY AS elem(value, ord) @@ -207,7 +207,7 @@ exports[`array functions > 'ArrayCompact' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS elem(value, ord) @@ -225,7 +225,7 @@ exports[`array functions > 'ArrayCompact' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS elem(value, ord) @@ -547,7 +547,7 @@ exports[`array functions > 'ArrayCompact' with 'link' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) END) AS v) AS _cj)) WITH ORDINALITY AS elem(value, ord) @@ -565,7 +565,7 @@ exports[`array functions > 'ArrayCompact' with 'link' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) END) AS v) AS _cj)) WITH ORDINALITY AS elem(value, ord) @@ -652,7 +652,7 @@ exports[`array functions > 'ArrayCompact' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS elem(value, ord) @@ -670,7 +670,7 @@ exports[`array functions > 'ArrayCompact' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS elem(value, ord) @@ -1022,7 +1022,7 @@ exports[`array functions > 'ArrayFlatten' with 'button' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) END) AS v) AS _cj)) WITH ORDINALITY AS elem(value, ord) UNION ALL SELECT elem.value, 1 AS arg_index, ord @@ -1039,7 +1039,7 @@ exports[`array functions > 'ArrayFlatten' with 'button' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) END) AS v) AS _cj)) WITH ORDINALITY AS elem(value, ord)) AS combined(value, arg_index, ord) @@ -1126,7 +1126,7 @@ exports[`array functions > 'ArrayFlatten' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS elem(value, ord) UNION ALL SELECT elem.value, 1 AS arg_index, ord @@ -1143,7 +1143,7 @@ exports[`array functions > 'ArrayFlatten' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS elem(value, ord)) AS combined(value, arg_index, ord) @@ -1450,7 +1450,7 @@ exports[`array functions > 'ArrayFlatten' with 'link' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) END) AS v) AS _cj)) WITH ORDINALITY AS elem(value, ord) UNION ALL SELECT elem.value, 1 AS arg_index, ord @@ -1467,7 +1467,7 @@ exports[`array functions > 'ArrayFlatten' with 'link' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) END) AS v) AS _cj)) WITH ORDINALITY AS elem(value, ord)) AS combined(value, arg_index, ord) @@ -1551,7 +1551,7 @@ exports[`array functions > 'ArrayFlatten' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS elem(value, ord) UNION ALL SELECT elem.value, 1 AS arg_index, ord @@ -1568,7 +1568,7 @@ exports[`array functions > 'ArrayFlatten' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS elem(value, ord)) AS combined(value, arg_index, ord) @@ -1899,7 +1899,7 @@ exports[`array functions > 'ArrayJoin' with 'button' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) END) AS v) AS _cj)) AS elem @@ -1980,7 +1980,7 @@ exports[`array functions > 'ArrayJoin' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) AS elem @@ -2247,7 +2247,7 @@ exports[`array functions > 'ArrayJoin' with 'link' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) END) AS v) AS _cj)) AS elem @@ -2325,7 +2325,7 @@ exports[`array functions > 'ArrayJoin' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) AS elem @@ -2631,7 +2631,7 @@ exports[`array functions > 'ArrayUnique' with 'button' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) END) AS v) AS _cj)) WITH ORDINALITY AS elem(value, ord) UNION ALL SELECT elem.value, 1 AS arg_index, ord @@ -2648,7 +2648,7 @@ exports[`array functions > 'ArrayUnique' with 'button' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."Button")::jsonb->>'title', ("t"."Button")::jsonb->>'name', ("t"."Button")::jsonb #>> '{}'))) END) AS v) AS _cj)) WITH ORDINALITY AS elem(value, ord)) AS combined(value, arg_index, ord) @@ -2735,7 +2735,7 @@ exports[`array functions > 'ArrayUnique' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS elem(value, ord) UNION ALL SELECT elem.value, 1 AS arg_index, ord @@ -2752,7 +2752,7 @@ exports[`array functions > 'ArrayUnique' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS elem(value, ord)) AS combined(value, arg_index, ord) @@ -3059,7 +3059,7 @@ exports[`array functions > 'ArrayUnique' with 'link' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) END) AS v) AS _cj)) WITH ORDINALITY AS elem(value, ord) UNION ALL SELECT elem.value, 1 AS arg_index, ord @@ -3076,7 +3076,7 @@ exports[`array functions > 'ArrayUnique' with 'link' 1`] = ` CASE WHEN NULLIF(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), '') IS NULL THEN '[]'::jsonb WHEN (LEFT(BTRIM(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text, 'jsonb') THEN (((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text)::jsonb - ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) + ELSE to_jsonb(((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}')))::text) END ELSE to_jsonb((COALESCE(("t"."LinkType")::jsonb->>'title', ("t"."LinkType")::jsonb->>'name', ("t"."LinkType")::jsonb #>> '{}'))) END) AS v) AS _cj)) WITH ORDINALITY AS elem(value, ord)) AS combined(value, arg_index, ord) @@ -3160,7 +3160,7 @@ exports[`array functions > 'ArrayUnique' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS elem(value, ord) UNION ALL SELECT elem.value, 1 AS arg_index, ord @@ -3177,7 +3177,7 @@ exports[`array functions > 'ArrayUnique' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS elem(value, ord)) AS combined(value, arg_index, ord) @@ -3539,7 +3539,7 @@ exports[`array functions > 'Count' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) AS elem @@ -3774,7 +3774,7 @@ exports[`array functions > 'Count' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) AS elem @@ -4054,7 +4054,7 @@ exports[`array functions > 'CountA' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) AS elem @@ -4293,7 +4293,7 @@ exports[`array functions > 'CountA' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) AS elem @@ -4575,7 +4575,7 @@ exports[`array functions > 'CountAll' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)), 0)", @@ -4806,7 +4806,7 @@ exports[`array functions > 'CountAll' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)), 0)", diff --git a/packages/v2/formula-sql-pg/src/__snapshots__/BinaryOperators.spec.ts.snap b/packages/v2/formula-sql-pg/src/__snapshots__/BinaryOperators.spec.ts.snap index b2a47f4856..e40df11791 100644 --- a/packages/v2/formula-sql-pg/src/__snapshots__/BinaryOperators.spec.ts.snap +++ b/packages/v2/formula-sql-pg/src/__snapshots__/BinaryOperators.spec.ts.snap @@ -394,7 +394,7 @@ exports[`binary comparison operators > 'Eq' 'conditionalLookup' '=' 'conditional CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, 0) = COALESCE(((SELECT CASE @@ -414,7 +414,7 @@ exports[`binary comparison operators > 'Eq' 'conditionalLookup' '=' 'conditional CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, 0))", @@ -1151,7 +1151,7 @@ exports[`binary comparison operators > 'Eq' 'lookup' '=' 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), '') = COALESCE((SELECT CASE @@ -1171,7 +1171,7 @@ exports[`binary comparison operators > 'Eq' 'lookup' '=' 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), ''))", @@ -2981,7 +2981,7 @@ exports[`binary comparison operators > 'Gt' 'conditionalLookup' '>' 'conditional CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, 0) > COALESCE(((SELECT CASE @@ -3001,7 +3001,7 @@ exports[`binary comparison operators > 'Gt' 'conditionalLookup' '>' 'conditional CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, 0))", @@ -3738,7 +3738,7 @@ exports[`binary comparison operators > 'Gt' 'lookup' '>' 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), '') > COALESCE((SELECT CASE @@ -3758,7 +3758,7 @@ exports[`binary comparison operators > 'Gt' 'lookup' '>' 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), ''))", @@ -5568,7 +5568,7 @@ exports[`binary comparison operators > 'Gte' 'conditionalLookup' '>=' 'condition CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, 0) >= COALESCE(((SELECT CASE @@ -5588,7 +5588,7 @@ exports[`binary comparison operators > 'Gte' 'conditionalLookup' '>=' 'condition CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, 0))", @@ -6325,7 +6325,7 @@ exports[`binary comparison operators > 'Gte' 'lookup' '>=' 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), '') >= COALESCE((SELECT CASE @@ -6345,7 +6345,7 @@ exports[`binary comparison operators > 'Gte' 'lookup' '>=' 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), ''))", @@ -8155,7 +8155,7 @@ exports[`binary comparison operators > 'Lt' 'conditionalLookup' '<' 'conditional CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, 0) < COALESCE(((SELECT CASE @@ -8175,7 +8175,7 @@ exports[`binary comparison operators > 'Lt' 'conditionalLookup' '<' 'conditional CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, 0))", @@ -8912,7 +8912,7 @@ exports[`binary comparison operators > 'Lt' 'lookup' '<' 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), '') < COALESCE((SELECT CASE @@ -8932,7 +8932,7 @@ exports[`binary comparison operators > 'Lt' 'lookup' '<' 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), ''))", @@ -10742,7 +10742,7 @@ exports[`binary comparison operators > 'Lte' 'conditionalLookup' '<=' 'condition CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, 0) <= COALESCE(((SELECT CASE @@ -10762,7 +10762,7 @@ exports[`binary comparison operators > 'Lte' 'conditionalLookup' '<=' 'condition CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, 0))", @@ -11499,7 +11499,7 @@ exports[`binary comparison operators > 'Lte' 'lookup' '<=' 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), '') <= COALESCE((SELECT CASE @@ -11519,7 +11519,7 @@ exports[`binary comparison operators > 'Lte' 'lookup' '<=' 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), ''))", @@ -13329,7 +13329,7 @@ exports[`binary comparison operators > 'Neq' 'conditionalLookup' '!=' 'condition CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, 0) <> COALESCE(((SELECT CASE @@ -13349,7 +13349,7 @@ exports[`binary comparison operators > 'Neq' 'conditionalLookup' '!=' 'condition CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, 0))", @@ -14086,7 +14086,7 @@ exports[`binary comparison operators > 'Neq' 'lookup' '!=' 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), '') <> COALESCE((SELECT CASE @@ -14106,7 +14106,7 @@ exports[`binary comparison operators > 'Neq' 'lookup' '!=' 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), ''))", diff --git a/packages/v2/formula-sql-pg/src/__snapshots__/DateFunctions.spec.ts.snap b/packages/v2/formula-sql-pg/src/__snapshots__/DateFunctions.spec.ts.snap index 50d823b63f..3f119d2b28 100644 --- a/packages/v2/formula-sql-pg/src/__snapshots__/DateFunctions.spec.ts.snap +++ b/packages/v2/formula-sql-pg/src/__snapshots__/DateFunctions.spec.ts.snap @@ -133,7 +133,7 @@ exports[`date functions batch 1 > 'Day' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -390,7 +390,7 @@ exports[`date functions batch 1 > 'Day' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -696,7 +696,7 @@ exports[`date functions batch 1 > 'Hour' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -953,7 +953,7 @@ exports[`date functions batch 1 > 'Hour' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -1259,7 +1259,7 @@ exports[`date functions batch 1 > 'Minute' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -1516,7 +1516,7 @@ exports[`date functions batch 1 > 'Minute' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -1822,7 +1822,7 @@ exports[`date functions batch 1 > 'Month' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -2079,7 +2079,7 @@ exports[`date functions batch 1 > 'Month' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -2385,7 +2385,7 @@ exports[`date functions batch 1 > 'WeekNum' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -2642,7 +2642,7 @@ exports[`date functions batch 1 > 'WeekNum' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -2948,7 +2948,7 @@ exports[`date functions batch 1 > 'Weekday' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -3205,7 +3205,7 @@ exports[`date functions batch 1 > 'Weekday' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -3511,7 +3511,7 @@ exports[`date functions batch 1 > 'Year' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -3768,7 +3768,7 @@ exports[`date functions batch 1 > 'Year' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -4059,7 +4059,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) - (to_timestamp(((SELECT CASE @@ -4079,7 +4079,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) + ((1)::double precision) * INTERVAL '1 day')::timestamptz)))) < 1 THEN 0::double precision ELSE ((EXTRACT(EPOCH FROM (to_timestamp(((SELECT CASE @@ -4099,7 +4099,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) - (to_timestamp(((SELECT CASE @@ -4119,7 +4119,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) + ((1)::double precision) * INTERVAL '1 day')::timestamptz))) / 86400) END)", @@ -4378,7 +4378,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) OR (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4398,7 +4398,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v)) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4418,7 +4418,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4438,7 +4438,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4458,7 +4458,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4478,7 +4478,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4498,7 +4498,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END ELSE '#ERROR:TYPE:invalid_date_add' END ELSE '#ERROR:TYPE:invalid_datetime_diff' END ELSE ((CASE WHEN ((SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4518,7 +4518,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) OR (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4538,7 +4538,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v)) THEN NULL ELSE (CASE WHEN ABS((EXTRACT(EPOCH FROM ((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4558,7 +4558,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -4584,7 +4584,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) - ((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4604,7 +4604,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4624,7 +4624,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -4650,7 +4650,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) + ((1)::double precision) * INTERVAL '1 day' END))::timestamptz)))) < 1 THEN 0::double precision ELSE ((EXTRACT(EPOCH FROM ((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4670,7 +4670,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -4696,7 +4696,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) - ((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4716,7 +4716,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -4736,7 +4736,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -4762,7 +4762,7 @@ exports[`date functions batch 2 > 'DatetimeDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) + ((1)::double precision) * INTERVAL '1 day' END))::timestamptz))) / 86400) END) END))::text END", @@ -6878,7 +6878,7 @@ exports[`date functions batch 2 > 'FromNow' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision))))) < 1 THEN 0::double precision ELSE ((EXTRACT(EPOCH FROM (NOW() - to_timestamp(((SELECT CASE @@ -6898,7 +6898,7 @@ exports[`date functions batch 2 > 'FromNow' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision)))) / 86400) END)", @@ -7145,7 +7145,7 @@ exports[`date functions batch 2 > 'FromNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -7165,7 +7165,7 @@ exports[`date functions batch 2 > 'FromNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END ELSE ((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -7185,7 +7185,7 @@ exports[`date functions batch 2 > 'FromNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN ABS((EXTRACT(EPOCH FROM (NOW() - (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -7205,7 +7205,7 @@ exports[`date functions batch 2 > 'FromNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -7231,7 +7231,7 @@ exports[`date functions batch 2 > 'FromNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END))))) < 1 THEN 0::double precision ELSE ((EXTRACT(EPOCH FROM (NOW() - (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -7251,7 +7251,7 @@ exports[`date functions batch 2 > 'FromNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -7277,7 +7277,7 @@ exports[`date functions batch 2 > 'FromNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END)))) / 86400) END) END))::text END", @@ -7568,7 +7568,7 @@ exports[`date functions batch 2 > 'Second' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -7825,7 +7825,7 @@ exports[`date functions batch 2 > 'Second' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -8116,7 +8116,7 @@ exports[`date functions batch 2 > 'ToNow' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision))))) < 1 THEN 0::double precision ELSE ((EXTRACT(EPOCH FROM (NOW() - to_timestamp(((SELECT CASE @@ -8136,7 +8136,7 @@ exports[`date functions batch 2 > 'ToNow' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision)))) / 86400) END)", @@ -8383,7 +8383,7 @@ exports[`date functions batch 2 > 'ToNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -8403,7 +8403,7 @@ exports[`date functions batch 2 > 'ToNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END ELSE ((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -8423,7 +8423,7 @@ exports[`date functions batch 2 > 'ToNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN ABS((EXTRACT(EPOCH FROM (NOW() - (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -8443,7 +8443,7 @@ exports[`date functions batch 2 > 'ToNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -8469,7 +8469,7 @@ exports[`date functions batch 2 > 'ToNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END))))) < 1 THEN 0::double precision ELSE ((EXTRACT(EPOCH FROM (NOW() - (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -8489,7 +8489,7 @@ exports[`date functions batch 2 > 'ToNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -8515,7 +8515,7 @@ exports[`date functions batch 2 > 'ToNow' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END)))) / 86400) END) END))::text END", @@ -8944,7 +8944,7 @@ exports[`date functions batch 2 > 'Workday' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision)) AT TIME ZONE 'UTC')::date AS start_date, COALESCE(((2)::double precision)::integer, 0) AS day_count, COALESCE('', '') AS holiday_text @@ -9466,7 +9466,7 @@ exports[`date functions batch 2 > 'Workday' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -9486,7 +9486,7 @@ exports[`date functions batch 2 > 'Workday' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -9506,7 +9506,7 @@ exports[`date functions batch 2 > 'Workday' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END ELSE '#ERROR:TYPE:invalid_workday' END ELSE ((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -9526,7 +9526,7 @@ exports[`date functions batch 2 > 'Workday' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE ( @@ -9575,7 +9575,7 @@ exports[`date functions batch 2 > 'Workday' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -9601,7 +9601,7 @@ exports[`date functions batch 2 > 'Workday' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END)) AT TIME ZONE 'UTC')::date AS start_date, COALESCE(((2)::double precision)::integer, 0) AS day_count, COALESCE('', '') AS holiday_text @@ -10090,7 +10090,7 @@ exports[`date functions batch 3 > 'DateAdd' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) + ((1)::double precision) * INTERVAL '1 day'", @@ -10331,7 +10331,7 @@ exports[`date functions batch 3 > 'DateAdd' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -10351,7 +10351,7 @@ exports[`date functions batch 3 > 'DateAdd' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -10371,7 +10371,7 @@ exports[`date functions batch 3 > 'DateAdd' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END ELSE '#ERROR:TYPE:invalid_date_add' END ELSE ((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -10391,7 +10391,7 @@ exports[`date functions batch 3 > 'DateAdd' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -10411,7 +10411,7 @@ exports[`date functions batch 3 > 'DateAdd' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -10437,7 +10437,7 @@ exports[`date functions batch 3 > 'DateAdd' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) + ((1)::double precision) * INTERVAL '1 day' END))::text END", @@ -10764,7 +10764,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) + ((1)::double precision) * INTERVAL '1 day')::text AS val) AS v) OR ((to_timestamp(((SELECT CASE @@ -10784,7 +10784,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) + ((1)::double precision) * INTERVAL '1 day')::text IS NULL)) AND ((SELECT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric')) FROM (SELECT ((SELECT CASE @@ -10804,7 +10804,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::text AS val) AS v) OR (((SELECT CASE @@ -10824,7 +10824,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::text IS NULL)) THEN (COALESCE((SELECT (CASE @@ -10848,7 +10848,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) + ((1)::double precision) * INTERVAL '1 day')::text AS val) AS v), 0) > COALESCE((SELECT (CASE @@ -10872,7 +10872,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::text AS val) AS v), 0)) @@ -10893,7 +10893,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) + ((1)::double precision) * INTERVAL '1 day')::text, '') > COALESCE(((SELECT CASE @@ -10913,7 +10913,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::text, '')) @@ -11290,7 +11290,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -11310,7 +11310,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -11330,7 +11330,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -11350,7 +11350,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END ELSE '#ERROR:TYPE:invalid_date_add' END ELSE '#ERROR:TYPE:invalid_comparison' END ELSE ((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -11370,7 +11370,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE @@ -11391,7 +11391,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -11411,7 +11411,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -11437,7 +11437,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) + ((1)::double precision) * INTERVAL '1 day' END))::text AS val) AS v) AND (SELECT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp')) FROM (SELECT (SELECT CASE @@ -11457,7 +11457,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v)) THEN ((SELECT (CASE @@ -11481,7 +11481,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -11501,7 +11501,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -11527,7 +11527,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) + ((1)::double precision) * INTERVAL '1 day' END))::text AS val) AS v) > (SELECT (CASE @@ -11551,7 +11551,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v)) @@ -11572,7 +11572,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -11592,7 +11592,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -11618,7 +11618,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) + ((1)::double precision) * INTERVAL '1 day' END))::text, '') > COALESCE((SELECT CASE @@ -11638,7 +11638,7 @@ exports[`date functions batch 3 > 'IsAfter' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), '')) @@ -12079,7 +12079,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::text AS val) AS v) OR (((SELECT CASE @@ -12099,7 +12099,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::text IS NULL)) AND ((SELECT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric')) FROM (SELECT (to_timestamp(((SELECT CASE @@ -12119,7 +12119,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) + ((1)::double precision) * INTERVAL '1 day')::text AS val) AS v) OR ((to_timestamp(((SELECT CASE @@ -12139,7 +12139,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) + ((1)::double precision) * INTERVAL '1 day')::text IS NULL)) THEN (COALESCE((SELECT (CASE @@ -12163,7 +12163,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::text AS val) AS v), 0) < COALESCE((SELECT (CASE @@ -12187,7 +12187,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) + ((1)::double precision) * INTERVAL '1 day')::text AS val) AS v), 0)) @@ -12208,7 +12208,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::text, '') < COALESCE((to_timestamp(((SELECT CASE @@ -12228,7 +12228,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) + ((1)::double precision) * INTERVAL '1 day')::text, '')) @@ -12605,7 +12605,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -12625,7 +12625,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -12645,7 +12645,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -12665,7 +12665,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END ELSE '#ERROR:TYPE:invalid_date_add' END ELSE '#ERROR:TYPE:invalid_comparison' END ELSE ((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -12685,7 +12685,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE @@ -12706,7 +12706,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) AND (SELECT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp')) FROM (SELECT ((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -12726,7 +12726,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -12746,7 +12746,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -12772,7 +12772,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) + ((1)::double precision) * INTERVAL '1 day' END))::text AS val) AS v)) THEN ((SELECT (CASE @@ -12796,7 +12796,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) < (SELECT (CASE @@ -12820,7 +12820,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -12840,7 +12840,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -12866,7 +12866,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) + ((1)::double precision) * INTERVAL '1 day' END))::text AS val) AS v)) @@ -12887,7 +12887,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp), '') < COALESCE(((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -12907,7 +12907,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -12927,7 +12927,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -12953,7 +12953,7 @@ exports[`date functions batch 3 > 'IsBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) + ((1)::double precision) * INTERVAL '1 day' END))::text, '')) @@ -13337,7 +13337,7 @@ exports[`date functions batch 3 > 'IsSame' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision)) AT TIME ZONE 'UTC') = DATE_TRUNC('day', ((to_timestamp(((SELECT CASE @@ -13357,7 +13357,7 @@ exports[`date functions batch 3 > 'IsSame' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) + ((1)::double precision) * INTERVAL '1 day')::timestamptz) AT TIME ZONE 'UTC')", @@ -13604,7 +13604,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) OR (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -13624,7 +13624,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v)) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -13644,7 +13644,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -13664,7 +13664,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -13684,7 +13684,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -13704,7 +13704,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -13724,7 +13724,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END ELSE '#ERROR:TYPE:invalid_date_add' END ELSE '#ERROR:TYPE:invalid_is_same' END ELSE ((CASE WHEN ((SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -13744,7 +13744,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) OR (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -13764,7 +13764,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v)) THEN NULL ELSE DATE_TRUNC('day', ((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -13784,7 +13784,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -13810,7 +13810,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END)) AT TIME ZONE 'UTC') = DATE_TRUNC('day', (((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -13830,7 +13830,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -13850,7 +13850,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -13876,7 +13876,7 @@ exports[`date functions batch 3 > 'IsSame' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) + ((1)::double precision) * INTERVAL '1 day' END))::timestamptz) AT TIME ZONE 'UTC') END))::text END", @@ -15853,7 +15853,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) OR (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -15873,7 +15873,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v)) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -15893,7 +15893,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -15913,7 +15913,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -15933,7 +15933,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -15953,7 +15953,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -15973,7 +15973,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END ELSE '#ERROR:TYPE:invalid_date_add' END ELSE '#ERROR:TYPE:invalid_workday_diff' END ELSE ((CASE WHEN ((SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -15993,7 +15993,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) OR (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -16013,7 +16013,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v)) THEN NULL ELSE ( @@ -16035,7 +16035,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -16061,7 +16061,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END)) AT TIME ZONE 'UTC')::date AS start_date, ((((CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -16081,7 +16081,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT (SELECT CASE @@ -16101,7 +16101,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -16127,7 +16127,7 @@ exports[`date functions batch 3 > 'WorkdayDiff' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) + ((2)::double precision) * INTERVAL '1 day' END))::timestamptz) AT TIME ZONE 'UTC')::date AS end_date, COALESCE('', '') AS holiday_text @@ -16638,7 +16638,7 @@ exports[`date functions batch 4 > 'DateAddCountField' with 'conditionalLookup' 1 CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) * INTERVAL '1 day' END))::text END", @@ -16963,7 +16963,7 @@ exports[`date functions batch 4 > 'DateAddCountField' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v)) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT '2024-01-02T00:00:00Z' AS val) AS v) THEN CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT '2024-01-02T00:00:00Z' AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_datetime' ELSE '#ERROR:TYPE:cannot_cast_to_datetime' END WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -16983,7 +16983,7 @@ exports[`date functions batch 4 > 'DateAddCountField' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -17003,7 +17003,7 @@ exports[`date functions batch 4 > 'DateAddCountField' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE '#ERROR:TYPE:invalid_date_add' END ELSE ((CASE WHEN ((SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT '2024-01-02T00:00:00Z' AS val) AS v) OR (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -17023,7 +17023,7 @@ exports[`date functions batch 4 > 'DateAddCountField' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v)) THEN NULL ELSE (CASE WHEN (SELECT (v.val IS NOT NULL AND NOT (__teable_input_is_valid((v.val)::text, 'timestamptz') OR __teable_input_is_valid((v.val)::text, 'timestamp'))) FROM (SELECT '2024-01-02T00:00:00Z' AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -17049,7 +17049,7 @@ exports[`date functions batch 4 > 'DateAddCountField' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -17073,7 +17073,7 @@ exports[`date functions batch 4 > 'DateAddCountField' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END)) * INTERVAL '1 day' END))::text END", @@ -18570,7 +18570,7 @@ exports[`date functions batch 4 > 'Datestr' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -18828,7 +18828,7 @@ exports[`date functions batch 4 > 'Datestr' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -19290,7 +19290,7 @@ exports[`date functions batch 4 > 'DatetimeFormat' with 'conditionalLookup' 1`] CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -19628,7 +19628,7 @@ exports[`date functions batch 4 > 'DatetimeFormat' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20087,7 +20087,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20106,7 +20106,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20125,7 +20125,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20144,7 +20144,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20163,7 +20163,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20182,7 +20182,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20201,7 +20201,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20220,7 +20220,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20239,7 +20239,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20259,7 +20259,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20279,7 +20279,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20298,7 +20298,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20317,7 +20317,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20336,7 +20336,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20356,7 +20356,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20375,7 +20375,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20395,7 +20395,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20414,7 +20414,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'conditionalLookup' 1`] = CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20668,7 +20668,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20687,7 +20687,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20706,7 +20706,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20725,7 +20725,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20744,7 +20744,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20763,7 +20763,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20782,7 +20782,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20801,7 +20801,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20820,7 +20820,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20840,7 +20840,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20860,7 +20860,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20879,7 +20879,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20898,7 +20898,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20917,7 +20917,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20937,7 +20937,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20956,7 +20956,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20976,7 +20976,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -20995,7 +20995,7 @@ exports[`date functions batch 4 > 'DatetimeParse' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -21323,7 +21323,7 @@ exports[`date functions batch 4 > 'Timestr' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -21581,7 +21581,7 @@ exports[`date functions batch 4 > 'Timestr' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) diff --git a/packages/v2/formula-sql-pg/src/__snapshots__/IfBranchNormalization.spec.ts.snap b/packages/v2/formula-sql-pg/src/__snapshots__/IfBranchNormalization.spec.ts.snap index 79817df66b..1d9492a2d6 100644 --- a/packages/v2/formula-sql-pg/src/__snapshots__/IfBranchNormalization.spec.ts.snap +++ b/packages/v2/formula-sql-pg/src/__snapshots__/IfBranchNormalization.spec.ts.snap @@ -43,7 +43,7 @@ exports[`if branch normalization > 'IfEmptyTextThenLookup' snapshots 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -98,7 +98,7 @@ exports[`if branch normalization > 'IfLookupThenEmptyText' snapshots 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) diff --git a/packages/v2/formula-sql-pg/src/__snapshots__/LogicalFunctions.spec.ts.snap b/packages/v2/formula-sql-pg/src/__snapshots__/LogicalFunctions.spec.ts.snap index ebd169dd84..47394a633d 100644 --- a/packages/v2/formula-sql-pg/src/__snapshots__/LogicalFunctions.spec.ts.snap +++ b/packages/v2/formula-sql-pg/src/__snapshots__/LogicalFunctions.spec.ts.snap @@ -124,7 +124,7 @@ exports[`logical functions > 'And' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) IS NULL OR jsonb_typeof((SELECT CASE @@ -140,7 +140,7 @@ exports[`logical functions > 'And' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) = 'null' THEN FALSE @@ -157,7 +157,7 @@ exports[`logical functions > 'And' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) = 'array' THEN jsonb_array_length((SELECT CASE @@ -173,7 +173,7 @@ exports[`logical functions > 'And' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) > 0 @@ -466,7 +466,7 @@ exports[`logical functions > 'And' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) IS NULL OR jsonb_typeof((SELECT CASE @@ -482,7 +482,7 @@ exports[`logical functions > 'And' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) = 'null' THEN FALSE @@ -499,7 +499,7 @@ exports[`logical functions > 'And' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) = 'array' THEN jsonb_array_length((SELECT CASE @@ -515,7 +515,7 @@ exports[`logical functions > 'And' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) > 0 @@ -864,7 +864,7 @@ exports[`logical functions > 'If' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) IS NULL OR jsonb_typeof((SELECT CASE @@ -880,7 +880,7 @@ exports[`logical functions > 'If' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) = 'null' THEN FALSE @@ -897,7 +897,7 @@ exports[`logical functions > 'If' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) = 'array' THEN jsonb_array_length((SELECT CASE @@ -913,7 +913,7 @@ exports[`logical functions > 'If' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) > 0 @@ -1206,7 +1206,7 @@ exports[`logical functions > 'If' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) IS NULL OR jsonb_typeof((SELECT CASE @@ -1222,7 +1222,7 @@ exports[`logical functions > 'If' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) = 'null' THEN FALSE @@ -1239,7 +1239,7 @@ exports[`logical functions > 'If' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) = 'array' THEN jsonb_array_length((SELECT CASE @@ -1255,7 +1255,7 @@ exports[`logical functions > 'If' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) > 0 @@ -1540,7 +1540,7 @@ exports[`logical functions > 'IsError' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -1782,7 +1782,7 @@ exports[`logical functions > 'IsError' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -2059,7 +2059,7 @@ exports[`logical functions > 'Not' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) IS NULL OR jsonb_typeof((SELECT CASE @@ -2075,7 +2075,7 @@ exports[`logical functions > 'Not' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) = 'null' THEN FALSE @@ -2092,7 +2092,7 @@ exports[`logical functions > 'Not' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) = 'array' THEN jsonb_array_length((SELECT CASE @@ -2108,7 +2108,7 @@ exports[`logical functions > 'Not' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) > 0 @@ -2401,7 +2401,7 @@ exports[`logical functions > 'Not' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) IS NULL OR jsonb_typeof((SELECT CASE @@ -2417,7 +2417,7 @@ exports[`logical functions > 'Not' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) = 'null' THEN FALSE @@ -2434,7 +2434,7 @@ exports[`logical functions > 'Not' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) = 'array' THEN jsonb_array_length((SELECT CASE @@ -2450,7 +2450,7 @@ exports[`logical functions > 'Not' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) > 0 @@ -2740,7 +2740,7 @@ exports[`logical functions > 'Or' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) IS NULL OR jsonb_typeof((SELECT CASE @@ -2756,7 +2756,7 @@ exports[`logical functions > 'Or' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) = 'null' THEN FALSE @@ -2773,7 +2773,7 @@ exports[`logical functions > 'Or' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) = 'array' THEN jsonb_array_length((SELECT CASE @@ -2789,7 +2789,7 @@ exports[`logical functions > 'Or' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) > 0 @@ -3082,7 +3082,7 @@ exports[`logical functions > 'Or' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) IS NULL OR jsonb_typeof((SELECT CASE @@ -3098,7 +3098,7 @@ exports[`logical functions > 'Or' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) = 'null' THEN FALSE @@ -3115,7 +3115,7 @@ exports[`logical functions > 'Or' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) = 'array' THEN jsonb_array_length((SELECT CASE @@ -3131,7 +3131,7 @@ exports[`logical functions > 'Or' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) > 0 @@ -3416,7 +3416,7 @@ exports[`logical functions > 'Switch' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -3658,7 +3658,7 @@ exports[`logical functions > 'Switch' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -3935,7 +3935,7 @@ exports[`logical functions > 'Xor' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) IS NULL OR jsonb_typeof((SELECT CASE @@ -3951,7 +3951,7 @@ exports[`logical functions > 'Xor' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) = 'null' THEN FALSE @@ -3968,7 +3968,7 @@ exports[`logical functions > 'Xor' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) = 'array' THEN jsonb_array_length((SELECT CASE @@ -3984,7 +3984,7 @@ exports[`logical functions > 'Xor' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) > 0 @@ -4277,7 +4277,7 @@ exports[`logical functions > 'Xor' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) IS NULL OR jsonb_typeof((SELECT CASE @@ -4293,7 +4293,7 @@ exports[`logical functions > 'Xor' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) = 'null' THEN FALSE @@ -4310,7 +4310,7 @@ exports[`logical functions > 'Xor' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) = 'array' THEN jsonb_array_length((SELECT CASE @@ -4326,7 +4326,7 @@ exports[`logical functions > 'Xor' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) > 0 diff --git a/packages/v2/formula-sql-pg/src/__snapshots__/NumericFunctions.spec.ts.snap b/packages/v2/formula-sql-pg/src/__snapshots__/NumericFunctions.spec.ts.snap index e25ada24f2..0b577d0d36 100644 --- a/packages/v2/formula-sql-pg/src/__snapshots__/NumericFunctions.spec.ts.snap +++ b/packages/v2/formula-sql-pg/src/__snapshots__/NumericFunctions.spec.ts.snap @@ -142,7 +142,7 @@ exports[`numeric functions > 'Abs' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision)", @@ -429,7 +429,7 @@ exports[`numeric functions > 'Abs' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -449,7 +449,7 @@ exports[`numeric functions > 'Abs' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -469,7 +469,7 @@ exports[`numeric functions > 'Abs' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE ABS((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -489,7 +489,7 @@ exports[`numeric functions > 'Abs' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -513,7 +513,7 @@ exports[`numeric functions > 'Abs' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END)) END))::text END", @@ -856,7 +856,7 @@ exports[`numeric functions > 'Average' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) AS t(elem) @@ -873,7 +873,7 @@ exports[`numeric functions > 'Average' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp))) + 1, 0)", @@ -1177,7 +1177,7 @@ exports[`numeric functions > 'Average' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) AS t(elem) @@ -1194,7 +1194,7 @@ exports[`numeric functions > 'Average' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp))) + 1, 0)", @@ -1557,7 +1557,7 @@ exports[`numeric functions > 'Ceiling' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -1858,7 +1858,7 @@ exports[`numeric functions > 'Ceiling' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -2216,7 +2216,7 @@ exports[`numeric functions > 'Even' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision AS val) AS v)", @@ -2530,7 +2530,7 @@ exports[`numeric functions > 'Even' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -2550,7 +2550,7 @@ exports[`numeric functions > 'Even' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -2570,7 +2570,7 @@ exports[`numeric functions > 'Even' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -2593,7 +2593,7 @@ exports[`numeric functions > 'Even' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -2617,7 +2617,7 @@ exports[`numeric functions > 'Even' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) AS val) AS v) END))::text END", @@ -2982,7 +2982,7 @@ exports[`numeric functions > 'Exp' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision)", @@ -3269,7 +3269,7 @@ exports[`numeric functions > 'Exp' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -3289,7 +3289,7 @@ exports[`numeric functions > 'Exp' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -3309,7 +3309,7 @@ exports[`numeric functions > 'Exp' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE EXP((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -3329,7 +3329,7 @@ exports[`numeric functions > 'Exp' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -3353,7 +3353,7 @@ exports[`numeric functions > 'Exp' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END)) END))::text END", @@ -3704,7 +3704,7 @@ exports[`numeric functions > 'Floor' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -4005,7 +4005,7 @@ exports[`numeric functions > 'Floor' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -4345,7 +4345,7 @@ exports[`numeric functions > 'Int' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -4646,7 +4646,7 @@ exports[`numeric functions > 'Int' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -4983,7 +4983,7 @@ exports[`numeric functions > 'Log' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision) / NULLIF(LN((10)::double precision), 0))", @@ -5278,7 +5278,7 @@ exports[`numeric functions > 'Log' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -5298,7 +5298,7 @@ exports[`numeric functions > 'Log' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -5318,7 +5318,7 @@ exports[`numeric functions > 'Log' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -5338,7 +5338,7 @@ exports[`numeric functions > 'Log' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (LN((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -5358,7 +5358,7 @@ exports[`numeric functions > 'Log' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -5382,7 +5382,7 @@ exports[`numeric functions > 'Log' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END)) / NULLIF(LN((10)::double precision), 0)) END))::text END", @@ -5754,7 +5754,7 @@ exports[`numeric functions > 'Max' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) AS t(elem) @@ -6059,7 +6059,7 @@ exports[`numeric functions > 'Max' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) AS t(elem) @@ -6415,7 +6415,7 @@ exports[`numeric functions > 'Min' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) AS t(elem) @@ -6720,7 +6720,7 @@ exports[`numeric functions > 'Min' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) AS t(elem) @@ -7081,7 +7081,7 @@ exports[`numeric functions > 'Mod' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision::numeric, (2)::double precision::numeric)::double precision END))::text END", @@ -7376,7 +7376,7 @@ exports[`numeric functions > 'Mod' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) OR ((2)::double precision) = 0) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -7396,7 +7396,7 @@ exports[`numeric functions > 'Mod' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -7416,7 +7416,7 @@ exports[`numeric functions > 'Mod' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END WHEN ((2)::double precision) = 0 THEN '#ERROR:DIV0:division_by_zero' ELSE '#ERROR:DIV0:division_by_zero' END ELSE ((CASE WHEN ((SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -7436,7 +7436,7 @@ exports[`numeric functions > 'Mod' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) OR ((2)::double precision) = 0) THEN NULL ELSE MOD((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -7456,7 +7456,7 @@ exports[`numeric functions > 'Mod' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -7480,7 +7480,7 @@ exports[`numeric functions > 'Mod' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END)::numeric, (2)::double precision::numeric)::double precision END))::text END", @@ -7847,7 +7847,7 @@ exports[`numeric functions > 'Odd' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision AS val) AS v)", @@ -8161,7 +8161,7 @@ exports[`numeric functions > 'Odd' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -8181,7 +8181,7 @@ exports[`numeric functions > 'Odd' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -8201,7 +8201,7 @@ exports[`numeric functions > 'Odd' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -8224,7 +8224,7 @@ exports[`numeric functions > 'Odd' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -8248,7 +8248,7 @@ exports[`numeric functions > 'Odd' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END) AS val) AS v) END))::text END", @@ -8617,7 +8617,7 @@ exports[`numeric functions > 'Power' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision, (2)::double precision)", @@ -8912,7 +8912,7 @@ exports[`numeric functions > 'Power' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -8932,7 +8932,7 @@ exports[`numeric functions > 'Power' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -8952,7 +8952,7 @@ exports[`numeric functions > 'Power' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -8972,7 +8972,7 @@ exports[`numeric functions > 'Power' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE POWER((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -8992,7 +8992,7 @@ exports[`numeric functions > 'Power' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -9016,7 +9016,7 @@ exports[`numeric functions > 'Power' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END), (2)::double precision) END))::text END", @@ -9395,7 +9395,7 @@ exports[`numeric functions > 'Round' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -9716,7 +9716,7 @@ exports[`numeric functions > 'Round' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -10100,7 +10100,7 @@ exports[`numeric functions > 'RoundDown' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -10421,7 +10421,7 @@ exports[`numeric functions > 'RoundDown' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -10847,7 +10847,7 @@ exports[`numeric functions > 'RoundUp' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -11168,7 +11168,7 @@ exports[`numeric functions > 'RoundUp' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -11546,7 +11546,7 @@ exports[`numeric functions > 'Sqrt' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp))::double precision)", @@ -11833,7 +11833,7 @@ exports[`numeric functions > 'Sqrt' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -11853,7 +11853,7 @@ exports[`numeric functions > 'Sqrt' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN '#ERROR:TYPE:cannot_cast_to_number' ELSE '#ERROR:TYPE:cannot_cast_to_number' END ELSE ((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -11873,7 +11873,7 @@ exports[`numeric functions > 'Sqrt' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE SQRT((CASE WHEN (SELECT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NOT (SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)') IS NOT NULL AND NOT (NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') IS NOT NULL AND NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') ~ '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)[eE][+-]?\\d+') AND __teable_input_is_valid(SUBSTRING(NULLIF(REGEXP_REPLACE(BTRIM((v.val)::text), '[,\\s]', '', 'g'), '') FROM '^([+-]?\\d+\\.?\\d*|[+-]?\\d*\\.\\d+)'), 'numeric'))) FROM (SELECT (SELECT CASE @@ -11893,7 +11893,7 @@ exports[`numeric functions > 'Sqrt' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) THEN NULL ELSE (SELECT (CASE @@ -11917,7 +11917,7 @@ exports[`numeric functions > 'Sqrt' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp) -> 0) AS elem) AS lkp) AS val) AS v) END)) END))::text END", @@ -12260,7 +12260,7 @@ exports[`numeric functions > 'Sum' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) AS t(elem) @@ -12565,7 +12565,7 @@ exports[`numeric functions > 'Sum' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) AS t(elem) @@ -12961,7 +12961,7 @@ exports[`numeric functions > 'Value' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -13254,7 +13254,7 @@ exports[`numeric functions > 'Value' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) diff --git a/packages/v2/formula-sql-pg/src/__snapshots__/TextFunctions.spec.ts.snap b/packages/v2/formula-sql-pg/src/__snapshots__/TextFunctions.spec.ts.snap index 1694be795a..2a2dcb909e 100644 --- a/packages/v2/formula-sql-pg/src/__snapshots__/TextFunctions.spec.ts.snap +++ b/packages/v2/formula-sql-pg/src/__snapshots__/TextFunctions.spec.ts.snap @@ -119,7 +119,7 @@ exports[`text functions > 'Concatenate' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -361,7 +361,7 @@ exports[`text functions > 'Concatenate' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -700,7 +700,7 @@ exports[`text functions > 'EncodeUrlComponent' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -733,7 +733,7 @@ exports[`text functions > 'EncodeUrlComponent' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -1129,7 +1129,7 @@ exports[`text functions > 'EncodeUrlComponent' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -1162,7 +1162,7 @@ exports[`text functions > 'EncodeUrlComponent' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -1554,7 +1554,7 @@ exports[`text functions > 'Find' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -1796,7 +1796,7 @@ exports[`text functions > 'Find' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -2086,7 +2086,7 @@ exports[`text functions > 'Left' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -2328,7 +2328,7 @@ exports[`text functions > 'Left' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -2600,7 +2600,7 @@ exports[`text functions > 'Len' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -2842,7 +2842,7 @@ exports[`text functions > 'Len' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -3114,7 +3114,7 @@ exports[`text functions > 'Lower' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -3356,7 +3356,7 @@ exports[`text functions > 'Lower' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -3628,7 +3628,7 @@ exports[`text functions > 'Mid' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -3870,7 +3870,7 @@ exports[`text functions > 'Mid' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -4142,7 +4142,7 @@ exports[`text functions > 'RegExpReplace' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -4384,7 +4384,7 @@ exports[`text functions > 'RegExpReplace' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -4656,7 +4656,7 @@ exports[`text functions > 'Replace' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -4898,7 +4898,7 @@ exports[`text functions > 'Replace' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -5170,7 +5170,7 @@ exports[`text functions > 'Rept' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -5412,7 +5412,7 @@ exports[`text functions > 'Rept' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -5684,7 +5684,7 @@ exports[`text functions > 'Right' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -5926,7 +5926,7 @@ exports[`text functions > 'Right' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -6198,7 +6198,7 @@ exports[`text functions > 'Search' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -6440,7 +6440,7 @@ exports[`text functions > 'Search' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -6730,7 +6730,7 @@ exports[`text functions > 'Substitute' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -6972,7 +6972,7 @@ exports[`text functions > 'Substitute' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -7244,7 +7244,7 @@ exports[`text functions > 'T' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -7486,7 +7486,7 @@ exports[`text functions > 'T' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -7764,7 +7764,7 @@ exports[`text functions > 'TextBefore' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -7783,7 +7783,7 @@ exports[`text functions > 'TextBefore' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -7802,7 +7802,7 @@ exports[`text functions > 'TextBefore' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -8060,7 +8060,7 @@ exports[`text functions > 'TextBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -8079,7 +8079,7 @@ exports[`text functions > 'TextBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -8098,7 +8098,7 @@ exports[`text functions > 'TextBefore' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -8382,7 +8382,7 @@ exports[`text functions > 'TextSplit' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -8624,7 +8624,7 @@ exports[`text functions > 'TextSplit' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -8896,7 +8896,7 @@ exports[`text functions > 'Trim' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -9138,7 +9138,7 @@ exports[`text functions > 'Trim' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -9410,7 +9410,7 @@ exports[`text functions > 'Upper' with 'conditionalLookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."ConditionalLookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."ConditionalLookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."ConditionalLookupType")))::text, 'jsonb') THEN (((("t"."ConditionalLookupType")))::text)::jsonb - ELSE to_jsonb((("t"."ConditionalLookupType"))) + ELSE to_jsonb(((("t"."ConditionalLookupType")))::text) END ELSE to_jsonb((("t"."ConditionalLookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) @@ -9652,7 +9652,7 @@ exports[`text functions > 'Upper' with 'lookup' 1`] = ` CASE WHEN NULLIF(BTRIM(((("t"."LookupType")))::text), '') IS NULL THEN NULL WHEN (LEFT(BTRIM(((("t"."LookupType")))::text), 1) IN ('[', '{')) AND __teable_input_is_valid(((("t"."LookupType")))::text, 'jsonb') THEN (((("t"."LookupType")))::text)::jsonb - ELSE to_jsonb((("t"."LookupType"))) + ELSE to_jsonb(((("t"."LookupType")))::text) END ELSE to_jsonb((("t"."LookupType"))) END) AS v) AS _lkp)) WITH ORDINALITY AS _jae(elem, ord) diff --git a/packages/v2/formula-sql-pg/vitest.config.ts b/packages/v2/formula-sql-pg/vitest.config.ts index f5490958ad..6197594a01 100644 --- a/packages/v2/formula-sql-pg/vitest.config.ts +++ b/packages/v2/formula-sql-pg/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ enabled: false, }, pool: 'forks', + isolate: false, fileParallelism: !isCI, maxWorkers: isCI ? 1 : undefined, coverage: { diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index d81cad808c..6bb237338a 100755 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -3,7 +3,7 @@ import 'zx/globals' const env = $.env; const metaDatabaseUrl = env.PRISMA_META_DATABASE_URL ?? env.PRISMA_DATABASE_URL; -const dataDatabaseUrl = env.PRISMA_DATA_DATABASE_URL ?? metaDatabaseUrl; +const dataDatabaseUrl = metaDatabaseUrl; const parseDsn = (dsn, label) => { try {