From afde2b2656133ba76a23b9f8296a7b9798e1cd88 Mon Sep 17 00:00:00 2001 From: tea artist Date: Thu, 23 Nov 2023 20:58:42 +0800 Subject: [PATCH] feat: one to one, many to many relationship in link options (#253) * feat: complete link options and unified relationship lookup and calculation process * fix: sqlite capability --- .../src/db-provider/db.provider.interface.ts | 14 +- .../src/db-provider/postgres.provider.ts | 124 +- .../src/db-provider/sqlite.provider.ts | 116 +- .../src/features/calculation/batch.service.ts | 82 +- .../calculation/field-calculation.service.ts | 242 +-- .../features/calculation/link.service.spec.ts | 1357 +++++++++-------- .../src/features/calculation/link.service.ts | 1034 +++++++++---- .../features/calculation/reference.service.ts | 1158 ++++++-------- .../calculation/utils/compose-maps.spec.ts | 140 +- .../calculation/utils/compose-maps.ts | 44 +- .../features/calculation/utils/dfs.spec.ts | 85 ++ .../src/features/calculation/utils/dfs.ts | 213 +++ .../field-converting-link.service.ts | 138 +- .../field-converting.service.ts | 38 +- .../field-calculate/field-creating.service.ts | 16 +- .../field-calculate/field-deleting.service.ts | 6 +- .../field-supplement.service.spec.ts | 25 +- .../field-supplement.service.ts | 373 +++-- .../src/features/field/model/factory.ts | 9 +- .../field/model/field-dto/link-field.dto.ts | 4 +- .../field/open-api/field-open-api.service.ts | 2 +- .../nestjs-backend/src/features/field/util.ts | 26 +- .../record-calculate.service.ts | 6 +- .../features/selection/selection.service.ts | 2 +- .../src/features/space/space.service.ts | 2 +- .../src/features/table/constant.ts | 2 +- .../features/table/open-api/graph.service.ts | 18 +- .../open-api/table-open-api.controller.ts | 4 +- .../table/open-api/table-open-api.service.ts | 42 +- .../src/features/table/open-api/table.pipe.ts | 33 +- .../src/features/user/user.service.ts | 2 +- .../src/filter/global-exception.filter.ts | 2 +- .../src/global/knex/knex.extend.ts | 18 + apps/nestjs-backend/src/share-db/interface.ts | 4 - .../src/share-db/ws-derivate.service.ts | 3 +- .../test/field-converting.e2e-spec.ts | 12 +- .../test/field-reference.e2e-spec.ts | 8 +- apps/nestjs-backend/test/field.e2e-spec.ts | 16 +- apps/nestjs-backend/test/link-api.e2e-spec.ts | 39 +- ....e2e-spec.ts => reference.e2e-spec.ts.bak} | 83 +- apps/nestjs-backend/test/rollup.e2e-spec.ts | 8 +- apps/nestjs-backend/test/view.e2e-spec.ts | 2 +- .../features/app/blocks/space/SpaceCard.tsx | 4 +- .../features/app/blocks/space/SpacePage.tsx | 4 +- .../collaborator-manage/space/Invite.tsx | 2 +- .../hooks/useIsMultipleCellValue.ts | 4 +- .../field-setting/useFieldTypeSubtitle.ts | 10 - .../app/components/setting/System.tsx | 4 +- packages/core/src/models/field/constant.ts | 14 +- .../models/field/derivate/link.field.spec.ts | 4 +- .../src/models/field/derivate/link.field.ts | 20 +- .../core/src/models/field/field.schema.ts | 19 +- .../core/src/models/field/show-as/number.ts | 17 +- .../core/src/models/field/show-as/text.ts | 5 +- .../core/src/models/table/table.schema.ts | 8 - packages/core/src/permission/role/constant.ts | 2 +- packages/core/src/utils/id-generator.ts | 7 +- .../migration.sql | 11 + .../prisma/postgres/schema.prisma | 2 +- .../migration.sql | 11 + .../prisma/sqlite/schema.prisma | 2 +- .../db-main-prisma/prisma/template.prisma | 2 +- packages/db-main-prisma/src/prisma.service.ts | 11 +- .../sdk/src/components/editor/link/Editor.tsx | 4 +- .../src/components/editor/link/EditorMain.tsx | 14 +- packages/sdk/src/model/field/factory.ts | 5 - 66 files changed, 3093 insertions(+), 2645 deletions(-) create mode 100644 apps/nestjs-backend/src/features/calculation/utils/dfs.spec.ts create mode 100644 apps/nestjs-backend/src/features/calculation/utils/dfs.ts rename apps/nestjs-backend/test/{reference.e2e-spec.ts => reference.e2e-spec.ts.bak} (96%) create mode 100644 packages/db-main-prisma/prisma/postgres/migrations/20231121123531_reference_index/migration.sql create mode 100644 packages/db-main-prisma/prisma/sqlite/migrations/20231121123524_reference_index/migration.sql diff --git a/apps/nestjs-backend/src/db-provider/db.provider.interface.ts b/apps/nestjs-backend/src/db-provider/db.provider.interface.ts index 9a86c7788..7448aeaf7 100644 --- a/apps/nestjs-backend/src/db-provider/db.provider.interface.ts +++ b/apps/nestjs-backend/src/db-provider/db.provider.interface.ts @@ -1,7 +1,5 @@ import type { DriverClient, IFilter } from '@teable-group/core'; import type { Knex } from 'knex'; -import type { IOpsData } from '../features/calculation/batch.service'; -import type { ITopoLinkOrder } from '../features/calculation/reference.service'; import type { IFieldInstance } from '../features/field/model/factory'; import type { IAggregationFunctionInterface } from './aggregation/aggregation-function.interface'; import type { IFilterQueryInterface } from './filter-query/filter-query.interface'; @@ -13,18 +11,12 @@ export interface IDbProvider { batchInsertSql(tableName: string, insertData: ReadonlyArray): string; - affectedRecordItemsQuerySql( - topoOrder: ITopoLinkOrder[], - originRecordIdItems: { dbTableName: string; id: string }[] - ): string; - executeUpdateRecordsSqlList(params: { dbTableName: string; - fieldMap: { [fieldId: string]: IFieldInstance }; - opsData: IOpsData[]; tempTableName: string; - columnNames: string[]; - userId: string; + idFieldName: string; + dbFieldNames: string[]; + data: { id: string; values: { [key: string]: unknown } }[]; }): { insertTempTableSql: string; updateRecordSql: string }; aggregationFunction(dbTableName: string, field: IFieldInstance): IAggregationFunctionInterface; diff --git a/apps/nestjs-backend/src/db-provider/postgres.provider.ts b/apps/nestjs-backend/src/db-provider/postgres.provider.ts index d911a31db..6f5c05c2e 100644 --- a/apps/nestjs-backend/src/db-provider/postgres.provider.ts +++ b/apps/nestjs-backend/src/db-provider/postgres.provider.ts @@ -1,10 +1,7 @@ import { Logger } from '@nestjs/common'; import type { IFilter } from '@teable-group/core'; -import { DriverClient, Relationship } from '@teable-group/core'; +import { DriverClient } from '@teable-group/core'; import type { Knex } from 'knex'; -import { map } from 'lodash'; -import type { IOpsData } from '../features/calculation/batch.service'; -import type { ITopoLinkOrder } from '../features/calculation/reference.service'; import type { IFieldInstance } from '../features/field/model/factory'; import type { IAggregationFunctionInterface } from './aggregation/aggregation-function.interface'; import { AggregationFunctionPostgres } from './aggregation/aggregation-function.postgres'; @@ -29,125 +26,34 @@ export class PostgresProvider implements IDbProvider { return this.knex.insert(insertData).into(tableName).toQuery(); } - affectedRecordItemsQuerySql( - topoOrder: ITopoLinkOrder[], - originRecordIdItems: { dbTableName: string; id: string }[] - ): string { - // Initialize the base case for the recursive CTE - const initTableName = topoOrder[0].linkedTable; - const initQuery = this.knex - .select({ - __id: '__id', - dbTableName: this.knex.raw('?', initTableName), - selectIn: this.knex.raw('?', null), - relationTo: this.knex.raw('?::varchar', null), - fieldId: this.knex.raw('?', null), - }) - .from(initTableName) - .whereIn('__id', map(originRecordIdItems, 'id')); - - let finalQuery = this.knex.queryBuilder(); - // Iterate over the nodes in topological order - for (let i = 0; i < topoOrder.length; i++) { - const currentOrder = topoOrder[i]; - const { fieldId, foreignKeyField, dbTableName, linkedTable } = currentOrder; - const affectedRecordsTable = `affected_records_${i}`; - - // Append the current node to the recursive CTE - if (currentOrder.relationship === Relationship.OneMany) { - const oneManyQuery = this.knex - .select({ - __id: this.knex.ref(`${linkedTable}.${foreignKeyField}`), - dbTableName: this.knex.raw('?', dbTableName), - selectIn: this.knex.raw('?', `${linkedTable}#${foreignKeyField}`), - relationTo: this.knex.raw('?', null), - fieldId: this.knex.raw('?', fieldId), - }) - .from(linkedTable) - .join(affectedRecordsTable, `${linkedTable}.__id`, '=', `${affectedRecordsTable}.__id`) - .where(`${affectedRecordsTable}.dbTableName`, linkedTable); - - const recursiveQuery = - i < 1 ? initQuery : this.knex.select('*').from(`affected_records_${i - 1}`); - - finalQuery = finalQuery.withRecursive( - affectedRecordsTable, - recursiveQuery.union(oneManyQuery) - ); - } else { - const manyOneQuery = this.knex - .select({ - __id: this.knex.ref(`${dbTableName}.__id`), - dbTableName: this.knex.raw('?', dbTableName), - selectIn: this.knex.raw('?', null), - relationTo: this.knex.ref(`${affectedRecordsTable}.__id`), - fieldId: this.knex.raw('?', fieldId), - }) - .from(dbTableName) - .join( - affectedRecordsTable, - `${dbTableName}.${foreignKeyField}`, - '=', - `${affectedRecordsTable}.__id` - ) - .where(`${affectedRecordsTable}.dbTableName`, linkedTable); - - const recursiveQuery = - i < 1 ? initQuery : this.knex.select('*').from(`affected_records_${i - 1}`); - - finalQuery = finalQuery.withRecursive( - affectedRecordsTable, - recursiveQuery.union(manyOneQuery) - ); - } - } - - // Construct the final query using the recursive CTE - finalQuery = finalQuery.select('*').from(`affected_records_${topoOrder.length - 1}`); - - // this.logger.log('affectedRecordItemsSql:%s', finalQuery.toQuery()); - return finalQuery.toQuery(); - } - executeUpdateRecordsSqlList(params: { dbTableName: string; - fieldMap: { [fieldId: string]: IFieldInstance }; - opsData: IOpsData[]; tempTableName: string; - columnNames: string[]; - userId: string; - }): { insertTempTableSql: string; updateRecordSql: string } { - const { dbTableName, fieldMap, opsData, tempTableName, columnNames, userId } = params; - - // 2.initialize temporary table data - const insertRowsData = opsData.map((data) => { + idFieldName: string; + dbFieldNames: string[]; + data: { id: string; values: { [key: string]: unknown } }[]; + }) { + const { dbTableName, tempTableName, idFieldName, dbFieldNames, data } = params; + const insertRowsData = data.map((item) => { return { - __id: data.recordId, - __version: data.version + 1, - __last_modified_time: new Date().toISOString(), - __last_modified_by: userId, - ...Object.entries(data.updateParam).reduce<{ [dbFieldName: string]: unknown }>( - (pre, [fieldId, value]) => { - const field = fieldMap[fieldId]; - const { dbFieldName } = field; - pre[dbFieldName] = field.convertCellValue2DBValue(value); - return pre; - }, - {} - ), + [idFieldName]: item.id, + ...item.values, }; }); + + // initialize temporary table data const insertTempTableSql = this.knex.insert(insertRowsData).into(tempTableName).toQuery(); - // 3.update data - const updateColumns = columnNames.reduce<{ [key: string]: unknown }>((pre, columnName) => { + // update data + const updateColumns = dbFieldNames.reduce<{ [key: string]: unknown }>((pre, columnName) => { pre[columnName] = this.knex.ref(`${tempTableName}.${columnName}`); return pre; }, {}); + const updateRecordSql = this.knex(dbTableName) .update(updateColumns) .updateFrom(tempTableName) - .where(`${dbTableName}.__id`, this.knex.ref(`${tempTableName}.__id`)) + .where(`${dbTableName}.${idFieldName}`, this.knex.ref(`${tempTableName}.${idFieldName}`)) .toQuery(); return { insertTempTableSql, updateRecordSql }; diff --git a/apps/nestjs-backend/src/db-provider/sqlite.provider.ts b/apps/nestjs-backend/src/db-provider/sqlite.provider.ts index 98c6008d8..318c7b25b 100644 --- a/apps/nestjs-backend/src/db-provider/sqlite.provider.ts +++ b/apps/nestjs-backend/src/db-provider/sqlite.provider.ts @@ -1,10 +1,7 @@ import { Logger } from '@nestjs/common'; import type { IFilter } from '@teable-group/core'; -import { DriverClient, Relationship } from '@teable-group/core'; +import { DriverClient } from '@teable-group/core'; import type { Knex } from 'knex'; -import { map } from 'lodash'; -import type { IOpsData } from '../features/calculation/batch.service'; -import type { ITopoLinkOrder } from '../features/calculation/reference.service'; import type { IFieldInstance } from '../features/field/model/factory'; import type { IAggregationFunctionInterface } from './aggregation/aggregation-function.interface'; import { AggregationFunctionSqlite } from './aggregation/aggregation-function.sqlite'; @@ -39,116 +36,31 @@ export class SqliteProvider implements IDbProvider { return this.knex.raw(sql + body, bindings).toQuery(); } - affectedRecordItemsQuerySql( - topoOrder: ITopoLinkOrder[], - originRecordIdItems: { dbTableName: string; id: string }[] - ): string { - // Initialize the base case for the recursive CTE - const initTableName = topoOrder[0].linkedTable; - const cteQuery = this.knex - .select({ - __id: '__id', - dbTableName: this.knex.raw('?', initTableName), - selectIn: this.knex.raw('?', null), - relationTo: this.knex.raw('?', null), - fieldId: this.knex.raw('?', null), - }) - .from(initTableName) - .whereIn('__id', map(originRecordIdItems, 'id')); - - // Iterate over the nodes in topological order - for (let i = 0; i < topoOrder.length; i++) { - const currentOrder = topoOrder[i]; - const { fieldId, foreignKeyField, dbTableName, linkedTable } = currentOrder; - const affectedRecordsTable = `affected_records`; - - // Append the current node to the recursive CTE - if (currentOrder.relationship === Relationship.OneMany) { - const oneManyQuery = this.knex - .select({ - __id: this.knex.ref(`${linkedTable}.${foreignKeyField}`), - dbTableName: this.knex.raw('?', dbTableName), - selectIn: this.knex.raw('?', `${linkedTable}#${foreignKeyField}`), - relationTo: this.knex.raw('?', null), - fieldId: this.knex.raw('?', fieldId), - }) - .from(linkedTable) - .join(affectedRecordsTable, `${linkedTable}.__id`, '=', `${affectedRecordsTable}.__id`) - .where(`${affectedRecordsTable}.dbTableName`, linkedTable); - - cteQuery.union(oneManyQuery); - } else { - const manyOneQuery = this.knex - .select({ - __id: this.knex.ref(`${dbTableName}.__id`), - dbTableName: this.knex.raw('?', dbTableName), - selectIn: this.knex.raw('?', null), - relationTo: this.knex.ref(`${affectedRecordsTable}.__id`), - fieldId: this.knex.raw('?', fieldId), - }) - .from(dbTableName) - .join( - affectedRecordsTable, - `${dbTableName}.${foreignKeyField}`, - '=', - `${affectedRecordsTable}.__id` - ) - .where(`${affectedRecordsTable}.dbTableName`, linkedTable); - - cteQuery.union(manyOneQuery); - } - } - - // Construct the final query using the recursive CTE - const finalQuery = this.knex - .withRecursive('affected_records', cteQuery) - .select('*') - .from(`affected_records`); - - // this.logger.log('affectedRecordItemsSql:%s', finalQuery.toQuery()); - return finalQuery.toQuery(); - } - executeUpdateRecordsSqlList(params: { dbTableName: string; - fieldMap: { [fieldId: string]: IFieldInstance }; - opsData: IOpsData[]; tempTableName: string; - columnNames: string[]; - userId: string; - }): { - insertTempTableSql: string; - updateRecordSql: string; - } { - const { dbTableName, fieldMap, opsData, tempTableName, columnNames, userId } = params; - - // 2.initialize temporary table data - const insertRowsData = opsData.map((data) => { + idFieldName: string; + dbFieldNames: string[]; + data: { id: string; values: { [key: string]: unknown } }[]; + }) { + const { dbTableName, tempTableName, idFieldName, dbFieldNames, data } = params; + const insertRowsData = data.map((item) => { return { - __id: data.recordId, - __version: data.version + 1, - __last_modified_time: new Date().toISOString(), - __last_modified_by: userId, - ...Object.entries(data.updateParam).reduce<{ [dbFieldName: string]: unknown }>( - (pre, [fieldId, value]) => { - const field = fieldMap[fieldId]; - const { dbFieldName } = field; - pre[dbFieldName] = field.convertCellValue2DBValue(value); - return pre; - }, - {} - ), + [idFieldName]: item.id, + ...item.values, }; }); + + // initialize temporary table data const insertTempTableSql = this.batchInsertSql(tempTableName, insertRowsData); - // 3.update data - const updateColumns = columnNames.reduce<{ [key: string]: unknown }>((pre, columnName) => { + // update data + const updateColumns = dbFieldNames.reduce<{ [key: string]: unknown }>((pre, columnName) => { pre[columnName] = this.knex.ref(`${tempTableName}.${columnName}`); return pre; }, {}); let updateRecordSql = this.knex(dbTableName).update(updateColumns).toQuery(); - updateRecordSql += ` FROM \`${tempTableName}\` WHERE ${dbTableName}.__id = ${tempTableName}.__id`; + updateRecordSql += ` FROM \`${tempTableName}\` WHERE ${dbTableName}.${idFieldName} = ${tempTableName}.${idFieldName}`; return { insertTempTableSql, updateRecordSql }; } diff --git a/apps/nestjs-backend/src/features/calculation/batch.service.ts b/apps/nestjs-backend/src/features/calculation/batch.service.ts index c72189f11..654c3603a 100644 --- a/apps/nestjs-backend/src/features/calculation/batch.service.ts +++ b/apps/nestjs-backend/src/features/calculation/batch.service.ts @@ -14,7 +14,7 @@ import type { IClsStore } from '../../types/cls'; import { Timing } from '../../utils/timing'; import type { IFieldInstance } from '../field/model/factory'; import { createFieldInstanceByRaw } from '../field/model/factory'; -import { dbType2knexFormat } from '../field/util'; +import { SchemaType, dbType2knexFormat } from '../field/util'; import { IOpsMap } from './reference.service'; export interface IOpsData { @@ -184,34 +184,21 @@ export class BatchService { } } - private async executeUpdateRecordsInner( + async batchUpdateDB( dbTableName: string, - fieldMap: { [fieldId: string]: IFieldInstance }, - opsData: IOpsData[] + idFieldName: string, + schemas: { schemaType: SchemaType; dbFieldName: string }[], + data: { id: string; values: { [key: string]: unknown } }[] ) { - if (!opsData.length) { - return; - } - - const userId = this.cls.get('user.id'); - const prisma = this.prismaService.txClient(); const tempTableName = `temp_` + customAlphabet('abcdefghijklmnopqrstuvwxyz', 10)(); - const fieldIds = Array.from(new Set(opsData.flatMap((d) => Object.keys(d.updateParam)))); - const columnNames = fieldIds - .map((id) => fieldMap[id].dbFieldName) - .concat(['__version', '__last_modified_time', '__last_modified_by']); + const prisma = this.prismaService.txClient(); // 1.create temporary table structure const createTempTableSchema = this.knex.schema.createTable(tempTableName, (table) => { - table.string('__id').primary(); - fieldIds.forEach((id) => { - const { dbFieldName, dbFieldType } = fieldMap[id]; - const typeKey = dbType2knexFormat(this.knex, dbFieldType); - table[typeKey](dbFieldName); + table.string(idFieldName).primary(); + schemas.forEach(({ dbFieldName, schemaType }) => { + table[schemaType](dbFieldName); }); - table.integer('__version'); - table.dateTime('__last_modified_time'); - table.string('__last_modified_by'); }); const createTempTableSql = createTempTableSchema @@ -221,11 +208,10 @@ export class BatchService { const { insertTempTableSql, updateRecordSql } = this.dbProvider.executeUpdateRecordsSqlList({ dbTableName, - fieldMap, - opsData, tempTableName, - columnNames, - userId, + idFieldName, + dbFieldNames: schemas.map((s) => s.dbFieldName), + data, }); // 2.initialize temporary table data @@ -239,6 +225,50 @@ export class BatchService { await prisma.$executeRawUnsafe(dropTempTableSql); } + private async executeUpdateRecordsInner( + dbTableName: string, + fieldMap: { [fieldId: string]: IFieldInstance }, + opsData: IOpsData[] + ) { + if (!opsData.length) { + return; + } + + const userId = this.cls.get('user.id'); + const fieldIds = Array.from(new Set(opsData.flatMap((d) => Object.keys(d.updateParam)))); + const data = opsData.map((data) => { + return { + id: data.recordId, + values: { + ...Object.entries(data.updateParam).reduce<{ [dbFieldName: string]: unknown }>( + (pre, [fieldId, value]) => { + const field = fieldMap[fieldId]; + const { dbFieldName } = field; + pre[dbFieldName] = field.convertCellValue2DBValue(value); + return pre; + }, + {} + ), + __version: data.version + 1, + __last_modified_time: new Date().toISOString(), + __last_modified_by: userId, + }, + }; + }); + + const schemas = [ + ...fieldIds.map((id) => { + const { dbFieldName, dbFieldType } = fieldMap[id]; + return { dbFieldName, schemaType: dbType2knexFormat(this.knex, dbFieldType) }; + }), + { dbFieldName: '__version', schemaType: SchemaType.Integer }, + { dbFieldName: '__last_modified_time', schemaType: SchemaType.Datetime }, + { dbFieldName: '__last_modified_by', schemaType: SchemaType.String }, + ]; + + await this.batchUpdateDB(dbTableName, '__id', schemas, data); + } + @Timing() async saveRawOps( collectionId: string, 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 364658aa6..e444795ce 100644 --- a/apps/nestjs-backend/src/features/calculation/field-calculation.service.ts +++ b/apps/nestjs-backend/src/features/calculation/field-calculation.service.ts @@ -1,15 +1,14 @@ import { Injectable } from '@nestjs/common'; import type { ILookupOptionsVo } from '@teable-group/core'; -import { Relationship } from '@teable-group/core'; import { PrismaService } from '@teable-group/db-main-prisma'; import { Knex } from 'knex'; -import { uniq, uniqBy } from 'lodash'; +import { groupBy, isEmpty, uniq } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import { Timing } from '../../utils/timing'; import { tinyPreservedFieldName } from '../field/constant'; -import type { IFieldInstance } from '../field/model/factory'; +import type { IFieldInstance, IFieldMap } from '../field/model/factory'; import { BatchService } from './batch.service'; -import type { IFieldMap, IRecordRefItem, ITopoItem } from './reference.service'; +import type { IGraphItem, ITopoItem } from './reference.service'; import { ReferenceService } from './reference.service'; import type { ICellChange } from './utils/changes'; import { formatChangesToOps, mergeDuplicateChange } from './utils/changes'; @@ -19,6 +18,9 @@ import { nameConsole } from './utils/name-console'; export interface ITopoOrdersContext { fieldMap: IFieldMap; + startFieldIds: string[]; + directedGraph: IGraphItem[]; + fieldId2DbTableName: { [fieldId: string]: string }; topoOrdersByFieldId: { [fieldId: string]: ITopoItem[] }; tableId2DbTableName: { [tableId: string]: string }; dbTableName2fields: { [dbTableName: string]: IFieldInstance[] }; @@ -41,82 +43,25 @@ export class FieldCalculationService { .txClient() .$queryRawUnsafe<{ __id: string }[]>(nativeSql.sql, ...nativeSql.bindings); - return results.map((item) => ({ - dbTableName: dbTableName, - id: item.__id, - })); + return results.map((item) => item.__id); } - private async getOneManyOriginRecords( - _tableId: string, - tableId2DbTableName: Record, - lookupOptions: ILookupOptionsVo - ) { - const { dbForeignKeyName, foreignTableId } = lookupOptions; - const foreignDbTableName = tableId2DbTableName[foreignTableId]; + private async getOriginLookupRecords(lookupOptions: ILookupOptionsVo) { + const { selfKeyName, foreignKeyName, fkHostTableName } = lookupOptions; - const nativeSql = this.knex + const querySql = this.knex .queryBuilder() - .whereNotNull(dbForeignKeyName) - .select('__id') - .from(foreignDbTableName) - .toSQL() - .toNative(); + .whereNotNull(selfKeyName) + .whereNotNull(foreignKeyName) + .select(foreignKeyName) + .from(fkHostTableName) + .toQuery(); const results = await this.prismaService .txClient() - .$queryRawUnsafe<{ __id: string }[]>(nativeSql.sql, ...nativeSql.bindings); - - return results.map((item) => ({ - dbTableName: foreignDbTableName, - id: item.__id, - })); - } - - private async getManyOneOriginRecords( - tableId: string, - tableId2DbTableName: Record, - lookupOptions: ILookupOptionsVo - ) { - const { dbForeignKeyName, foreignTableId } = lookupOptions; - const dbTableName = tableId2DbTableName[tableId]; - const foreignDbTableName = tableId2DbTableName[foreignTableId]; - const nativeSql = this.knex - .queryBuilder() - .whereNotNull(dbForeignKeyName) - .select(dbForeignKeyName) - .from(dbTableName) - .toSQL() - .toNative(); - - const results = await this.prismaService - .txClient() - .$queryRawUnsafe<{ [key: string]: string }[]>(nativeSql.sql, ...nativeSql.bindings); - - return uniqBy( - results.map((item) => ({ - dbTableName: foreignDbTableName, - id: item[dbForeignKeyName], - })), - 'id' - ); - } - - private async getOriginLookupRecords( - tableId: string, - tableId2DbTableName: Record, - field: IFieldInstance - ) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const lookupOptions = field.lookupOptions!; - if (lookupOptions.relationship === Relationship.ManyOne) { - return this.getManyOneOriginRecords(tableId, tableId2DbTableName, lookupOptions); - } - if (lookupOptions.relationship === Relationship.OneMany) { - return this.getOneManyOriginRecords(tableId, tableId2DbTableName, lookupOptions); - } + .$queryRawUnsafe<{ [key: string]: string }[]>(querySql); - throw new Error('Invalid relationship'); + return results.map((item) => item[foreignKeyName]); } @Timing() @@ -133,26 +78,31 @@ export class FieldCalculationService { } async getTopoOrdersContext(fieldIds: string[]): Promise { - const undirectedGraph = await this.referenceService.getDependentNodesCTE(fieldIds); + const directedGraph = await this.referenceService.getFieldGraphItems(fieldIds); // get all related field by undirected graph - const allFieldIds = uniq(this.referenceService.flatGraph(undirectedGraph).concat(fieldIds)); + const allFieldIds = uniq(this.referenceService.flatGraph(directedGraph).concat(fieldIds)); // prepare all related data - const { fieldMap, fieldId2TableId, dbTableName2fields, tableId2DbTableName } = - await this.referenceService.createAuxiliaryData(allFieldIds); + const { + fieldMap, + fieldId2TableId, + dbTableName2fields, + fieldId2DbTableName, + tableId2DbTableName, + } = await this.referenceService.createAuxiliaryData(allFieldIds); // topological sorting - const topoOrdersByFieldId = this.referenceService.getTopoOrdersByFieldId( - fieldIds, - undirectedGraph - ); + const topoOrdersByFieldId = this.referenceService.getTopoOrdersMap(fieldIds, directedGraph); // nameConsole('topoOrdersByFieldId', topoOrdersByFieldId, fieldMap); return { + startFieldIds: fieldIds, fieldMap, + directedGraph, topoOrdersByFieldId, tableId2DbTableName, + fieldId2DbTableName, dbTableName2fields, fieldId2TableId, }; @@ -160,67 +110,25 @@ export class FieldCalculationService { private async getRecordItems(params: { tableId: string; - fieldId2TableId: { [fieldId: string]: string }; - tableId2DbTableName: { [tableId: string]: string }; - topoOrdersByFieldId: { [fieldId: string]: ITopoItem[] }; + startFieldIds: string[]; + itemsToCalculate: string[]; + directedGraph: IGraphItem[]; fieldMap: IFieldMap; - originRecordIds?: string[]; }) { - const { - tableId, - fieldId2TableId, - tableId2DbTableName, - topoOrdersByFieldId, - fieldMap, - originRecordIds, - } = params; - // the origin change will lead to affected record changes - let affectedRecordItems: IRecordRefItem[] = []; - let originRecordIdItems: { dbTableName: string; id: string }[] = []; + const { directedGraph, itemsToCalculate, startFieldIds, fieldMap } = params; - const dbTableName = tableId2DbTableName[tableId]; + const linkAdjacencyMap = this.referenceService.getLinkAdjacencyMap(fieldMap, directedGraph); - if (originRecordIds) { - originRecordIdItems = originRecordIds.map((id) => ({ dbTableName, id })); - } else { - originRecordIdItems = await this.getSelfOriginRecords(dbTableName); + if (!itemsToCalculate.length || isEmpty(linkAdjacencyMap)) { + return []; } - for (const fieldId in topoOrdersByFieldId) { - const field = fieldMap[fieldId]; - const topoOrders = topoOrdersByFieldId[fieldId]; - const linkOrders = this.referenceService.getLinkOrderFromTopoOrders({ - tableId2DbTableName, - topoOrders, - fieldMap, - fieldId2TableId, - }); - - if (!fieldMap[fieldId].isComputed) { - continue; - } - let itemsToCalculate = originRecordIdItems; - - if (field.lookupOptions) { - itemsToCalculate = await this.getOriginLookupRecords(tableId, tableId2DbTableName, field); - originRecordIdItems = originRecordIdItems.concat(itemsToCalculate); - } - - if (!itemsToCalculate.length) { - continue; - } - - // nameConsole('getAffectedRecordItems:topoOrder', linkOrders, fieldMap); - // nameConsole('getAffectedRecordItems:originRecordIdItems', originRecordIdItems, fieldMap); - const items = await this.referenceService.getAffectedRecordItems( - linkOrders, - itemsToCalculate - ); - // nameConsole('fieldId:', { fieldId }, fieldMap); - // nameConsole('affectedRecordItems:', items, fieldMap); - affectedRecordItems = affectedRecordItems.concat(items); - } - return { affectedRecordItems, originRecordIdItems }; + return this.referenceService.getAffectedRecordItems( + startFieldIds, + fieldMap, + linkAdjacencyMap, + itemsToCalculate + ); } async getRecordsBatchByFields(dbTableName2fields: { [dbTableName: string]: IFieldInstance[] }) { @@ -260,14 +168,14 @@ export class FieldCalculationService { fieldId2TableId, } = context; - const dbTableName2records = await this.getRecordsBatchByFields(dbTableName2fields); + const dbTableName2recordMap = await this.getRecordsBatchByFields(dbTableName2fields); const changes = Object.values(fieldIds).reduce((cellChanges, fieldId) => { const tableId = fieldId2TableId[fieldId]; const dbTableName = tableId2DbTableName[tableId]; - const records = dbTableName2records[dbTableName]; - records - .filter((record) => record.fields[fieldId]) + const recordMap = dbTableName2recordMap[dbTableName]; + Object.values(recordMap) + .filter((record) => record.fields[fieldId] != null) .forEach((record) => { cellChanges.push({ tableId, @@ -350,67 +258,57 @@ export class FieldCalculationService { ) { const { fieldMap, + startFieldIds, + directedGraph, topoOrdersByFieldId, + fieldId2DbTableName, dbTableName2fields, tableId2DbTableName, fieldId2TableId, } = context; - const { affectedRecordItems, originRecordIdItems } = await this.getRecordItems({ + + const dbTableName = tableId2DbTableName[tableId]; + const initialRecordIds = recordIds ? recordIds : await this.getSelfOriginRecords(dbTableName); + + const relatedRecordItems = await this.getRecordItems({ tableId, - fieldId2TableId, - tableId2DbTableName, - topoOrdersByFieldId, + itemsToCalculate: initialRecordIds, + startFieldIds, + directedGraph, fieldMap, - originRecordIds: recordIds, }); - const dependentRecordItems = - await this.referenceService.getDependentRecordItems(affectedRecordItems); - - // nameConsole('topoOrdersByFieldId', topoOrdersByFieldId, fieldMap); - // nameConsole('recordIds', recordIds, fieldMap); - // nameConsole('originRecordIdItems', originRecordIdItems, fieldMap); - // nameConsole('affectedRecordItems', affectedRecordItems, fieldMap); - // nameConsole('dependentRecordItems', dependentRecordItems, fieldMap); - // record data source - const dbTableName2records = await this.referenceService.getRecordsBatch({ - originRecordItems: originRecordIdItems, - affectedRecordItems, - dependentRecordItems, + const dbTableName2recordMap = await this.referenceService.getRecordMapBatch({ + fieldMap, + fieldId2DbTableName, dbTableName2fields, + initialRecordIdMap: { [dbTableName]: new Set(initialRecordIds) }, + modifiedRecords: [], + relatedRecordItems, }); - // nameConsole('dbTableName2records', dbTableName2records, fieldMap); - // nameConsole('dbTableName2records', dbTableName2records, fieldMap); if (resetFieldIds) { - Object.values(dbTableName2records).forEach((records) => { - records.forEach((record) => { + Object.values(dbTableName2recordMap).forEach((records) => { + Object.values(records).forEach((record) => { resetFieldIds.forEach((fieldId) => { record.fields[fieldId] = null; }); }); }); } - + const relatedRecordItemsIndexed = groupBy(relatedRecordItems, 'fieldId'); return Object.values(topoOrdersByFieldId).reduce((pre, topoOrders) => { const orderWithRecords = this.referenceService.createTopoItemWithRecords({ topoOrders, fieldMap, tableId2DbTableName, fieldId2TableId, - dbTableName2records, - affectedRecordItems, - dependentRecordItems, + dbTableName2recordMap, + relatedRecordItemsIndexed, }); return pre.concat( - this.referenceService.collectChanges( - orderWithRecords, - fieldMap, - fieldId2TableId, - tableId2DbTableName, - {} - ) + this.referenceService.collectChanges(orderWithRecords, fieldMap, fieldId2TableId) ); }, []); } diff --git a/apps/nestjs-backend/src/features/calculation/link.service.spec.ts b/apps/nestjs-backend/src/features/calculation/link.service.spec.ts index 9c3f9d819..01c231aee 100644 --- a/apps/nestjs-backend/src/features/calculation/link.service.spec.ts +++ b/apps/nestjs-backend/src/features/calculation/link.service.spec.ts @@ -2,11 +2,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; -import { FieldType, Relationship } from '@teable-group/core'; import { GlobalModule } from '../../global/global.module'; -import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto'; import { CalculationModule } from './calculation.module'; -import type { ILinkCellContext, ITinyFieldMapByTableId } from './link.service'; import { LinkService } from './link.service'; describe('LinkService', () => { @@ -20,680 +17,684 @@ describe('LinkService', () => { service = module.get(LinkService); }); - describe('getCellMutation', () => { - let fieldMapByTableId: ITinyFieldMapByTableId = {}; - beforeEach(() => { - fieldMapByTableId = { - tableA: { - 'ManyOne-LinkB': { - id: 'ManyOne-LinkB', - type: FieldType.Link, - dbFieldName: 'ManyOne-LinkB', - options: { - relationship: Relationship.ManyOne, - foreignTableId: 'tableB', - lookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - symmetricFieldId: 'OneMany-LinkA', - }, - } as LinkFieldDto, - }, - tableB: { - 'OneMany-LinkA': { - id: 'OneMany-LinkA', - type: FieldType.Link, - dbFieldName: 'OneMany-LinkA', - options: { - relationship: Relationship.OneMany, - foreignTableId: 'tableA', - lookupFieldId: 'fieldA', - dbForeignKeyName: '__fk_ManyOne-LinkB', - symmetricFieldId: 'ManyOne-LinkB', - }, - } as LinkFieldDto, - }, - }; - }); - - it('should create correct ForeignKeyParams when add value for ManyOne field', () => { - const ctx1: ILinkCellContext[] = [ - { - recordId: 'A1', - fieldId: 'ManyOne-LinkB', - newValue: { id: 'B1' }, - }, - ]; - - const result1 = service['getRecordMapStructAndForeignKeyParams']( - 'tableA', - fieldMapByTableId, - ctx1 - ); - expect(result1.recordMapByTableId).toEqual({ - tableA: { - A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, - }, - tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } }, - }); - - expect(result1.updateForeignKeyParams).toEqual([ - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: 'B1', - }, - ]); - }); - - it('should create correct ForeignKeyParams when delete value for ManyOne field', () => { - const ctx1: ILinkCellContext[] = [ - { - recordId: 'A1', - fieldId: 'ManyOne-LinkB', - oldValue: { id: 'B1' }, - newValue: undefined, - }, - ]; - - const result1 = service['getRecordMapStructAndForeignKeyParams']( - 'tableA', - fieldMapByTableId, - ctx1 - ); - expect(result1.recordMapByTableId).toEqual({ - tableA: { - A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, - }, - tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } }, - }); - - expect(result1.updateForeignKeyParams).toEqual([ - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: null, - }, - ]); - }); - - it('should create correct ForeignKeyParams when replace value for ManyOne field', () => { - const ctx1: ILinkCellContext[] = [ - { - recordId: 'A1', - fieldId: 'ManyOne-LinkB', - oldValue: { id: 'B1' }, - newValue: { id: 'B2' }, - }, - ]; - - const result1 = service['getRecordMapStructAndForeignKeyParams']( - 'tableA', - fieldMapByTableId, - ctx1 - ); - - expect(result1.recordMapByTableId).toEqual({ - tableA: { - A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, - }, - tableB: { - B1: { fieldB: undefined, 'OneMany-LinkA': undefined }, - B2: { fieldB: undefined, 'OneMany-LinkA': undefined }, - }, - }); - - expect(result1.updateForeignKeyParams).toEqual([ - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: 'B2', - }, - ]); - }); - - it('should create correct ForeignKeyParams when add value for oneMany field', () => { - const ctx1: ILinkCellContext[] = [ - { - recordId: 'B1', - fieldId: 'OneMany-LinkA', - newValue: [{ id: 'A1' }], - }, - ]; - - const result1 = service['getRecordMapStructAndForeignKeyParams']( - 'tableB', - fieldMapByTableId, - ctx1 - ); - expect(result1.recordMapByTableId).toEqual({ - tableA: { - A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, - }, - tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } }, - }); - - expect(result1.updateForeignKeyParams).toEqual([ - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: 'B1', - }, - ]); - }); - - it('should create correct ForeignKeyParams when del value for oneMany field', () => { - const ctx1: ILinkCellContext[] = [ - { - recordId: 'B1', - fieldId: 'OneMany-LinkA', - oldValue: [{ id: 'A1' }], - newValue: undefined, - }, - ]; - - const result1 = service['getRecordMapStructAndForeignKeyParams']( - 'tableB', - fieldMapByTableId, - ctx1 - ); - expect(result1.recordMapByTableId).toEqual({ - tableA: { - A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, - }, - tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } }, - }); - - expect(result1.updateForeignKeyParams).toEqual([ - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: null, - }, - ]); - }); - - it('should create correct ForeignKeyParams when replace value for oneMany field', () => { - const ctx1: ILinkCellContext[] = [ - { - recordId: 'B1', - fieldId: 'OneMany-LinkA', - oldValue: [{ id: 'A1' }], - newValue: [{ id: 'A1' }, { id: 'A2' }], - }, - ]; - - const result1 = service['getRecordMapStructAndForeignKeyParams']( - 'tableB', - fieldMapByTableId, - ctx1 - ); - - expect(result1.recordMapByTableId).toEqual({ - tableA: { - A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, - A2: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, - }, - tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } }, - }); - - expect(result1.updateForeignKeyParams).toEqual([ - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: null, - }, - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: 'B1', - }, - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A2', - fRecordId: 'B1', - }, - ]); - }); - - it('should throw error when when illegal value for oneMany field', () => { - const ctx1: ILinkCellContext[] = [ - { - recordId: 'B1', - fieldId: 'OneMany-LinkA', - oldValue: [{ id: 'A1' }], - newValue: [{ id: 'A1' }, { id: 'A2' }], - }, - { - recordId: 'B2', - fieldId: 'OneMany-LinkA', - newValue: [{ id: 'A1' }, { id: 'A2' }], - }, - ]; - - expect(() => - service['getRecordMapStructAndForeignKeyParams']('tableB', fieldMapByTableId, ctx1) - ).toThrow(); - }); - - it('should update foreign key in memory correctly when add value', () => { - const recordMapByTableId = { - tableA: { - A1: { - fieldA: 'A1', - 'ManyOne-LinkB': undefined, - '__fk_ManyOne-LinkB': undefined, - }, - }, - tableB: { - B1: { - fieldB: 'B1', - 'OneMany-LinkA': undefined, - }, - }, - }; - - const updateForeignKeyParams = [ - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: 'B1', - }, - ]; - - const result1 = service['updateForeignKeyInMemory']( - updateForeignKeyParams, - recordMapByTableId - ); - - expect(result1).toEqual({ - tableA: { - A1: { - fieldA: 'A1', - 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, - '__fk_ManyOne-LinkB': 'B1', - }, - }, - tableB: { - B1: { - fieldB: 'B1', - 'OneMany-LinkA': [{ id: 'A1', title: 'A1' }], - }, - }, - }); - }); - - it('should update foreign key in memory correctly when del value', () => { - const recordMapByTableId = { - tableA: { - A1: { - fieldA: 'A1', - 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, - '__fk_ManyOne-LinkB': 'B1', - }, - }, - tableB: { - B1: { - fieldB: 'B1', - 'OneMany-LinkA': [{ id: 'A1', title: 'A1' }], - }, - }, - }; - - const updateForeignKeyParams = [ - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: null, - }, - ]; - - const result1 = service['updateForeignKeyInMemory']( - updateForeignKeyParams, - recordMapByTableId - ); - - expect(result1).toEqual({ - tableA: { - A1: { - fieldA: 'A1', - 'ManyOne-LinkB': null, - '__fk_ManyOne-LinkB': null, - }, - }, - tableB: { - B1: { - fieldB: 'B1', - 'OneMany-LinkA': null, - }, - }, - }); - }); - - it('should update foreign key in memory correctly when replace value', () => { - const recordMapByTableId = { - tableA: { - A1: { - fieldA: 'A1', - 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, - '__fk_ManyOne-LinkB': 'B1', - }, - }, - tableB: { - B1: { - fieldB: 'B1', - 'OneMany-LinkA': [{ id: 'A1', title: 'A1' }], - }, - B2: { - fieldB: 'B2', - 'OneMany-LinkA': undefined, - }, - }, - }; - - const updateForeignKeyParams = [ - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: 'B2', - }, - ]; - - const result1 = service['updateForeignKeyInMemory']( - updateForeignKeyParams, - recordMapByTableId - ); - - expect(result1).toEqual({ - tableA: { - A1: { - fieldA: 'A1', - 'ManyOne-LinkB': { id: 'B2', title: 'B2' }, - '__fk_ManyOne-LinkB': 'B2', - }, - }, - tableB: { - B1: { - fieldB: 'B1', - 'OneMany-LinkA': null, - }, - B2: { - fieldB: 'B2', - 'OneMany-LinkA': [{ id: 'A1', title: 'A1' }], - }, - }, - }); - }); - - it('should update foreign key in memory correctly when replace multiple value', () => { - const recordMapByTableId = { - tableA: { - A1: { - fieldA: 'A1', - 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, - '__fk_ManyOne-LinkB': 'B1', - }, - A2: { - fieldA: 'A2', - 'ManyOne-LinkB': undefined, - '__fk_ManyOne-LinkB': undefined, - }, - }, - tableB: { - B1: { - fieldB: 'B1', - 'OneMany-LinkA': [{ id: 'A1', title: 'A1' }], - }, - }, - }; - - const updateForeignKeyParams = [ - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: null, - }, - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: 'B1', - }, - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A2', - fRecordId: 'B1', - }, - ]; - - const result1 = service['updateForeignKeyInMemory']( - updateForeignKeyParams, - recordMapByTableId - ); - - expect(result1).toEqual({ - tableA: { - A1: { - fieldA: 'A1', - 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, - '__fk_ManyOne-LinkB': 'B1', - }, - A2: { - fieldA: 'A2', - 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, - '__fk_ManyOne-LinkB': 'B1', - }, - }, - tableB: { - B1: { - fieldB: 'B1', - 'OneMany-LinkA': [ - { id: 'A1', title: 'A1' }, - { id: 'A2', title: 'A2' }, - ], - }, - }, - }); - }); - - it('should update foreign key in memory correctly event when illegal value', () => { - const recordMapByTableId = { - tableA: { - A1: { - fieldA: 'A1', - 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, - '__fk_ManyOne-LinkB': 'B1', - }, - A2: { - fieldA: 'A2', - 'ManyOne-LinkB': undefined, - '__fk_ManyOne-LinkB': undefined, - }, - }, - tableB: { - B1: { - fieldB: 'B1', - 'OneMany-LinkA': [{ id: 'A1', title: 'A1' }], - }, - B2: { - fieldB: 'B2', - 'OneMany-LinkA': undefined, - }, - }, - }; - - const updateForeignKeyParams = [ - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: null, - }, - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: 'B1', - }, - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A2', - fRecordId: 'B1', - }, - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A1', - fRecordId: 'B2', - }, - { - tableId: 'tableA', - foreignTableId: 'tableB', - mainLinkFieldId: 'ManyOne-LinkB', - mainTableLookupFieldId: 'fieldA', - foreignLinkFieldId: 'OneMany-LinkA', - foreignTableLookupFieldId: 'fieldB', - dbForeignKeyName: '__fk_ManyOne-LinkB', - recordId: 'A2', - fRecordId: 'B2', - }, - ]; - - const result1 = service['updateForeignKeyInMemory']( - updateForeignKeyParams, - recordMapByTableId - ); - - expect(result1).toEqual({ - tableA: { - A1: { - fieldA: 'A1', - 'ManyOne-LinkB': { id: 'B2', title: 'B2' }, - '__fk_ManyOne-LinkB': 'B2', - }, - A2: { - fieldA: 'A2', - 'ManyOne-LinkB': { id: 'B2', title: 'B2' }, - '__fk_ManyOne-LinkB': 'B2', - }, - }, - tableB: { - B1: { - fieldB: 'B1', - 'OneMany-LinkA': null, - }, - B2: { - fieldB: 'B2', - 'OneMany-LinkA': [ - { id: 'A1', title: 'A1' }, - { id: 'A2', title: 'A2' }, - ], - }, - }, - }); - }); + it('should be defined', () => { + expect(service).toBeDefined(); }); + + // describe('getCellMutation', () => { + // let fieldMapByTableId: IFieldMapByTableId = {}; + // beforeEach(() => { + // fieldMapByTableId = { + // tableA: { + // 'ManyOne-LinkB': { + // id: 'ManyOne-LinkB', + // type: FieldType.Link, + // dbFieldName: 'ManyOne-LinkB', + // options: { + // relationship: Relationship.ManyOne, + // foreignTableId: 'tableB', + // lookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // symmetricFieldId: 'OneMany-LinkA', + // }, + // } as LinkFieldDto, + // }, + // tableB: { + // 'OneMany-LinkA': { + // id: 'OneMany-LinkA', + // type: FieldType.Link, + // dbFieldName: 'OneMany-LinkA', + // options: { + // relationship: Relationship.OneMany, + // foreignTableId: 'tableA', + // lookupFieldId: 'fieldA', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // symmetricFieldId: 'ManyOne-LinkB', + // }, + // } as LinkFieldDto, + // }, + // }; + // }); + + // it('should create correct ForeignKeyParams when add value for ManyOne field', () => { + // const ctx1: ILinkCellContext[] = [ + // { + // recordId: 'A1', + // fieldId: 'ManyOne-LinkB', + // newValue: { id: 'B1' }, + // }, + // ]; + + // const result1 = service['getRecordMapStructAndForeignKeyParams']( + // 'tableA', + // fieldMapByTableId, + // ctx1 + // ); + // expect(result1.recordMapByTableId).toEqual({ + // tableA: { + // A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, + // }, + // tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } }, + // }); + + // expect(result1.updateForeignKeyParams).toEqual([ + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: 'B1', + // }, + // ]); + // }); + + // it('should create correct ForeignKeyParams when delete value for ManyOne field', () => { + // const ctx1: ILinkCellContext[] = [ + // { + // recordId: 'A1', + // fieldId: 'ManyOne-LinkB', + // oldValue: { id: 'B1' }, + // newValue: undefined, + // }, + // ]; + + // const result1 = service['getRecordMapStructAndForeignKeyParams']( + // 'tableA', + // fieldMapByTableId, + // ctx1 + // ); + // expect(result1.recordMapByTableId).toEqual({ + // tableA: { + // A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, + // }, + // tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } }, + // }); + + // expect(result1.updateForeignKeyParams).toEqual([ + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: null, + // }, + // ]); + // }); + + // it('should create correct ForeignKeyParams when replace value for ManyOne field', () => { + // const ctx1: ILinkCellContext[] = [ + // { + // recordId: 'A1', + // fieldId: 'ManyOne-LinkB', + // oldValue: { id: 'B1' }, + // newValue: { id: 'B2' }, + // }, + // ]; + + // const result1 = service['getRecordMapStructAndForeignKeyParams']( + // 'tableA', + // fieldMapByTableId, + // ctx1 + // ); + + // expect(result1.recordMapByTableId).toEqual({ + // tableA: { + // A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, + // }, + // tableB: { + // B1: { fieldB: undefined, 'OneMany-LinkA': undefined }, + // B2: { fieldB: undefined, 'OneMany-LinkA': undefined }, + // }, + // }); + + // expect(result1.updateForeignKeyParams).toEqual([ + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: 'B2', + // }, + // ]); + // }); + + // it('should create correct ForeignKeyParams when add value for oneMany field', () => { + // const ctx1: ILinkCellContext[] = [ + // { + // recordId: 'B1', + // fieldId: 'OneMany-LinkA', + // newValue: [{ id: 'A1' }], + // }, + // ]; + + // const result1 = service['getRecordMapStructAndForeignKeyParams']( + // 'tableB', + // fieldMapByTableId, + // ctx1 + // ); + // expect(result1.recordMapByTableId).toEqual({ + // tableA: { + // A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, + // }, + // tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } }, + // }); + + // expect(result1.updateForeignKeyParams).toEqual([ + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: 'B1', + // }, + // ]); + // }); + + // it('should create correct ForeignKeyParams when del value for oneMany field', () => { + // const ctx1: ILinkCellContext[] = [ + // { + // recordId: 'B1', + // fieldId: 'OneMany-LinkA', + // oldValue: [{ id: 'A1' }], + // newValue: undefined, + // }, + // ]; + + // const result1 = service['getRecordMapStructAndForeignKeyParams']( + // 'tableB', + // fieldMapByTableId, + // ctx1 + // ); + // expect(result1.recordMapByTableId).toEqual({ + // tableA: { + // A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, + // }, + // tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } }, + // }); + + // expect(result1.updateForeignKeyParams).toEqual([ + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: null, + // }, + // ]); + // }); + + // it('should create correct ForeignKeyParams when replace value for oneMany field', () => { + // const ctx1: ILinkCellContext[] = [ + // { + // recordId: 'B1', + // fieldId: 'OneMany-LinkA', + // oldValue: [{ id: 'A1' }], + // newValue: [{ id: 'A1' }, { id: 'A2' }], + // }, + // ]; + + // const result1 = service['getRecordMapStructAndForeignKeyParams']( + // 'tableB', + // fieldMapByTableId, + // ctx1 + // ); + + // expect(result1.recordMapByTableId).toEqual({ + // tableA: { + // A1: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, + // A2: { fieldA: undefined, 'ManyOne-LinkB': undefined, '__fk_ManyOne-LinkB': undefined }, + // }, + // tableB: { B1: { fieldB: undefined, 'OneMany-LinkA': undefined } }, + // }); + + // expect(result1.updateForeignKeyParams).toEqual([ + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: null, + // }, + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: 'B1', + // }, + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A2', + // fRecordId: 'B1', + // }, + // ]); + // }); + + // it('should throw error when when illegal value for oneMany field', () => { + // const ctx1: ILinkCellContext[] = [ + // { + // recordId: 'B1', + // fieldId: 'OneMany-LinkA', + // oldValue: [{ id: 'A1' }], + // newValue: [{ id: 'A1' }, { id: 'A2' }], + // }, + // { + // recordId: 'B2', + // fieldId: 'OneMany-LinkA', + // newValue: [{ id: 'A1' }, { id: 'A2' }], + // }, + // ]; + + // expect(() => + // service['getRecordMapStructAndForeignKeyParams']('tableB', fieldMapByTableId, ctx1) + // ).toThrow(); + // }); + + // it('should update foreign key in memory correctly when add value', () => { + // const recordMapByTableId = { + // tableA: { + // A1: { + // fieldA: 'A1', + // 'ManyOne-LinkB': undefined, + // '__fk_ManyOne-LinkB': undefined, + // }, + // }, + // tableB: { + // B1: { + // fieldB: 'B1', + // 'OneMany-LinkA': undefined, + // }, + // }, + // }; + + // const updateForeignKeyParams = [ + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: 'B1', + // }, + // ]; + + // const result1 = service['updateForeignKeyInMemory']( + // updateForeignKeyParams, + // recordMapByTableId + // ); + + // expect(result1).toEqual({ + // tableA: { + // A1: { + // fieldA: 'A1', + // 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, + // '__fk_ManyOne-LinkB': 'B1', + // }, + // }, + // tableB: { + // B1: { + // fieldB: 'B1', + // 'OneMany-LinkA': [{ id: 'A1', title: 'A1' }], + // }, + // }, + // }); + // }); + + // it('should update foreign key in memory correctly when del value', () => { + // const recordMapByTableId = { + // tableA: { + // A1: { + // fieldA: 'A1', + // 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, + // '__fk_ManyOne-LinkB': 'B1', + // }, + // }, + // tableB: { + // B1: { + // fieldB: 'B1', + // 'OneMany-LinkA': [{ id: 'A1', title: 'A1' }], + // }, + // }, + // }; + + // const updateForeignKeyParams = [ + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: null, + // }, + // ]; + + // const result1 = service['updateForeignKeyInMemory']( + // updateForeignKeyParams, + // recordMapByTableId + // ); + + // expect(result1).toEqual({ + // tableA: { + // A1: { + // fieldA: 'A1', + // 'ManyOne-LinkB': null, + // '__fk_ManyOne-LinkB': null, + // }, + // }, + // tableB: { + // B1: { + // fieldB: 'B1', + // 'OneMany-LinkA': null, + // }, + // }, + // }); + // }); + + // it('should update foreign key in memory correctly when replace value', () => { + // const recordMapByTableId = { + // tableA: { + // A1: { + // fieldA: 'A1', + // 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, + // '__fk_ManyOne-LinkB': 'B1', + // }, + // }, + // tableB: { + // B1: { + // fieldB: 'B1', + // 'OneMany-LinkA': [{ id: 'A1', title: 'A1' }], + // }, + // B2: { + // fieldB: 'B2', + // 'OneMany-LinkA': undefined, + // }, + // }, + // }; + + // const updateForeignKeyParams = [ + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: 'B2', + // }, + // ]; + + // const result1 = service['updateForeignKeyInMemory']( + // updateForeignKeyParams, + // recordMapByTableId + // ); + + // expect(result1).toEqual({ + // tableA: { + // A1: { + // fieldA: 'A1', + // 'ManyOne-LinkB': { id: 'B2', title: 'B2' }, + // '__fk_ManyOne-LinkB': 'B2', + // }, + // }, + // tableB: { + // B1: { + // fieldB: 'B1', + // 'OneMany-LinkA': null, + // }, + // B2: { + // fieldB: 'B2', + // 'OneMany-LinkA': [{ id: 'A1', title: 'A1' }], + // }, + // }, + // }); + // }); + + // it('should update foreign key in memory correctly when replace multiple value', () => { + // const recordMapByTableId = { + // tableA: { + // A1: { + // fieldA: 'A1', + // 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, + // '__fk_ManyOne-LinkB': 'B1', + // }, + // A2: { + // fieldA: 'A2', + // 'ManyOne-LinkB': undefined, + // '__fk_ManyOne-LinkB': undefined, + // }, + // }, + // tableB: { + // B1: { + // fieldB: 'B1', + // 'OneMany-LinkA': [{ id: 'A1', title: 'A1' }], + // }, + // }, + // }; + + // const updateForeignKeyParams = [ + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: null, + // }, + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: 'B1', + // }, + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A2', + // fRecordId: 'B1', + // }, + // ]; + + // const result1 = service['updateForeignKeyInMemory']( + // updateForeignKeyParams, + // recordMapByTableId + // ); + + // expect(result1).toEqual({ + // tableA: { + // A1: { + // fieldA: 'A1', + // 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, + // '__fk_ManyOne-LinkB': 'B1', + // }, + // A2: { + // fieldA: 'A2', + // 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, + // '__fk_ManyOne-LinkB': 'B1', + // }, + // }, + // tableB: { + // B1: { + // fieldB: 'B1', + // 'OneMany-LinkA': [ + // { id: 'A1', title: 'A1' }, + // { id: 'A2', title: 'A2' }, + // ], + // }, + // }, + // }); + // }); + + // it('should update foreign key in memory correctly event when illegal value', () => { + // const recordMapByTableId = { + // tableA: { + // A1: { + // fieldA: 'A1', + // 'ManyOne-LinkB': { id: 'B1', title: 'B1' }, + // '__fk_ManyOne-LinkB': 'B1', + // }, + // A2: { + // fieldA: 'A2', + // 'ManyOne-LinkB': undefined, + // '__fk_ManyOne-LinkB': undefined, + // }, + // }, + // tableB: { + // B1: { + // fieldB: 'B1', + // 'OneMany-LinkA': [{ id: 'A1', title: 'A1' }], + // }, + // B2: { + // fieldB: 'B2', + // 'OneMany-LinkA': undefined, + // }, + // }, + // }; + + // const updateForeignKeyParams = [ + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: null, + // }, + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: 'B1', + // }, + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A2', + // fRecordId: 'B1', + // }, + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A1', + // fRecordId: 'B2', + // }, + // { + // tableId: 'tableA', + // foreignTableId: 'tableB', + // mainLinkFieldId: 'ManyOne-LinkB', + // mainTableLookupFieldId: 'fieldA', + // foreignLinkFieldId: 'OneMany-LinkA', + // foreignTableLookupFieldId: 'fieldB', + // dbForeignKeyName: '__fk_ManyOne-LinkB', + // recordId: 'A2', + // fRecordId: 'B2', + // }, + // ]; + + // const result1 = service['updateForeignKeyInMemory']( + // updateForeignKeyParams, + // recordMapByTableId + // ); + + // expect(result1).toEqual({ + // tableA: { + // A1: { + // fieldA: 'A1', + // 'ManyOne-LinkB': { id: 'B2', title: 'B2' }, + // '__fk_ManyOne-LinkB': 'B2', + // }, + // A2: { + // fieldA: 'A2', + // 'ManyOne-LinkB': { id: 'B2', title: 'B2' }, + // '__fk_ManyOne-LinkB': 'B2', + // }, + // }, + // tableB: { + // B1: { + // fieldB: 'B1', + // 'OneMany-LinkA': null, + // }, + // B2: { + // fieldB: 'B2', + // 'OneMany-LinkA': [ + // { id: 'A1', title: 'A1' }, + // { id: 'A2', title: 'A2' }, + // ], + // }, + // }, + // }); + // }); + // }); }); diff --git a/apps/nestjs-backend/src/features/calculation/link.service.ts b/apps/nestjs-backend/src/features/calculation/link.service.ts index cf0309cd0..3219f7b81 100644 --- a/apps/nestjs-backend/src/features/calculation/link.service.ts +++ b/apps/nestjs-backend/src/features/calculation/link.service.ts @@ -1,16 +1,29 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import type { ILinkFieldOptions } from '@teable-group/core'; +import type { ILinkCellValue, ILinkFieldOptions } from '@teable-group/core'; import { FieldType, Relationship } from '@teable-group/core'; import { PrismaService } from '@teable-group/db-main-prisma'; import { Knex } from 'knex'; -import { cloneDeep, keyBy, isEqual, merge, set } from 'lodash'; +import { cloneDeep, keyBy, difference, groupBy, isEqual, set } from 'lodash'; import { InjectModel } from 'nest-knexjs'; -import type { IFieldInstance } from '../field/model/factory'; +import type { IFieldInstance, IFieldMap } from '../field/model/factory'; import { createFieldInstanceByRaw } from '../field/model/factory'; -import type { IFkOpMap } from './reference.service'; +import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto'; +import { SchemaType } from '../field/util'; +import { BatchService } from './batch.service'; import type { ICellChange } from './utils/changes'; import { isLinkCellValue } from './utils/detect-link'; +export interface IFkRecordMap { + [fieldId: string]: { + [recordId: string]: IFkRecordItem; + }; +} + +export interface IFkRecordItem { + oldKey: string | string[] | null; // null means record have no foreignKey + newKey: string | string[] | null; // null means to delete the foreignKey +} + export interface IRecordMapByTableId { [tableId: string]: { [recordId: string]: { @@ -19,7 +32,7 @@ export interface IRecordMapByTableId { }; } -export interface ITinyFieldMapByTableId { +export interface IFieldMapByTableId { [tableId: string]: { [fieldId: string]: IFieldInstance; }; @@ -39,22 +52,11 @@ export interface ICellContext { oldValue?: unknown; } -interface IUpdateForeignKeyParam { - tableId: string; - foreignTableId: string; - mainLinkFieldId: string; - mainTableLookupFieldId: string; - foreignLinkFieldId: string; - foreignTableLookupFieldId: string; - dbForeignKeyName: string; - recordId: string; - fRecordId: string | null; -} - @Injectable() export class LinkService { constructor( private readonly prismaService: PrismaService, + private readonly batchService: BatchService, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex ) {} @@ -72,253 +74,547 @@ export class LinkService { }); } - private async getFieldsByIds(fieldIds: string[]) { + private async getFieldMapByTableId(fieldIds: string[]): Promise { const fieldRaws = await this.prismaService.txClient().field.findMany({ where: { id: { in: fieldIds } }, }); + const fields = fieldRaws.map(createFieldInstanceByRaw) as LinkFieldDto[]; - return fieldRaws.map((field) => { - // return { ...createFieldInstanceByRaw(field), tableId: field.tableId }; - // return Object.assign(createFieldInstanceByRaw(field), { tableId: field.tableId }); - return merge(createFieldInstanceByRaw(field), { tableId: field.tableId }); + const symmetricFieldRaws = await this.prismaService.txClient().field.findMany({ + where: { + id: { + in: fields + .filter((field) => field.options.symmetricFieldId) + .map((field) => field.options.symmetricFieldId as string), + }, + }, }); - } - private async getTinyFieldMapByTableId(fieldIds: string[]): Promise { - const fields = await this.getFieldsByIds(fieldIds); + const symmetricFields = symmetricFieldRaws.map(createFieldInstanceByRaw) as LinkFieldDto[]; - const symmetricFields = await this.getFieldsByIds( - fields.map((field) => (field.options as ILinkFieldOptions).symmetricFieldId) - ); + const lookedFieldRaws = await this.prismaService.txClient().field.findMany({ + where: { + id: { + in: fields + .map((field) => field.options.lookupFieldId) + .concat(symmetricFields.map((field) => field.options.lookupFieldId)), + }, + }, + }); + const lookedFields = lookedFieldRaws.map(createFieldInstanceByRaw); - const relatedFields = await this.getFieldsByIds( - fields - .map((field) => (field.options as ILinkFieldOptions).lookupFieldId) - .concat(symmetricFields.map((field) => (field.options as ILinkFieldOptions).lookupFieldId)) - ); + const instanceMap = keyBy([...fields, ...symmetricFields, ...lookedFields], 'id'); - return fields - .concat(symmetricFields, relatedFields) - .reduce((acc, field) => { + return [...fieldRaws, ...symmetricFieldRaws, ...lookedFieldRaws].reduce( + (acc, field) => { const { tableId, id } = field; if (!acc[tableId]) { acc[tableId] = {}; } - acc[tableId][id] = field; + acc[tableId][id] = instanceMap[id]; return acc; - }, {}); + }, + {} + ); } - /** - * mainLinkFieldId is the link fieldId of the main table, contain only one link cell value - * foreignLinkFieldId is the link fieldId of the foreign table, contain multiple link cell value - */ - private updateForeignKeyInMemory( - updateForeignKeyParams: IUpdateForeignKeyParam[], - recordMapByTableId: IRecordMapByTableId - ) { - recordMapByTableId = cloneDeep(recordMapByTableId); - // eslint-disable-next-line sonarjs/cognitive-complexity - updateForeignKeyParams.forEach((param) => { - const { - tableId, - foreignTableId, - mainLinkFieldId, - foreignTableLookupFieldId, - mainTableLookupFieldId, - foreignLinkFieldId, - dbForeignKeyName: fkFieldId, - recordId, - fRecordId, - } = param; - const foreignTable = recordMapByTableId[foreignTableId]; - const mainRecord = recordMapByTableId[tableId][recordId]; - if (!mainRecord) { - throw new Error('mainRecord not found'); - } + // eslint-disable-next-line sonarjs/cognitive-complexity + private updateForeignCellForManyMany(params: { + fkItem: IFkRecordItem; + recordId: string; + symmetricFieldId: string; + sourceLookedFieldId: string; + sourceRecordMap: IRecordMapByTableId['tableId']; + foreignRecordMap: IRecordMapByTableId['tableId']; + }) { + const { + fkItem, + recordId, + symmetricFieldId, + sourceLookedFieldId, + foreignRecordMap, + sourceRecordMap, + } = params; + const oldKey = (fkItem.oldKey || []) as string[]; + const newKey = (fkItem.newKey || []) as string[]; + + const toDelete = difference(oldKey, newKey); + const toAdd = difference(newKey, oldKey); + + // Update link cell values for symmetric field of the foreign table + if (toDelete.length) { + toDelete.forEach((foreignRecordId) => { + const foreignCellValue = foreignRecordMap[foreignRecordId][symmetricFieldId] as + | ILinkCellValue[] + | null; + + if (foreignCellValue) { + const filteredCellValue = foreignCellValue.filter((item) => item.id !== recordId); + foreignRecordMap[foreignRecordId][symmetricFieldId] = filteredCellValue.length + ? filteredCellValue + : null; + } + }); + } - // If there is an old value, remove this record from the old foreign Link Field - const oldFRecordId = mainRecord[fkFieldId] as string; - if (oldFRecordId) { - const fRecord = foreignTable[oldFRecordId]; - if (!fRecord) { + if (toAdd.length) { + toAdd.forEach((foreignRecordId) => { + const sourceRecordTitle = sourceRecordMap[recordId][sourceLookedFieldId] as + | string + | undefined; + const newForeignRecord = foreignRecordMap[foreignRecordId]; + if (!newForeignRecord) { throw new BadRequestException( - 'Consistency error, Can not set duplicate link record in this field' + `Consistency error, recordId ${foreignRecordId} is not exist` ); } - const oldFRecordFLink = fRecord[foreignLinkFieldId] as - | { id: string; title?: string }[] - | undefined; - const newFRecordFLink = (oldFRecordFLink || []).filter((record) => record.id !== recordId); - fRecord[foreignLinkFieldId] = newFRecordFLink.length ? newFRecordFLink : null; - } - - // If the fRecordId is not null, add this record to the new foreignTable's foreignLinkField - if (fRecordId) { - const newFRecord = foreignTable[fRecordId]; - const newFRecordFLink = newFRecord[foreignLinkFieldId] as - | { id: string; title?: string }[] - | undefined; - const title = mainRecord[mainTableLookupFieldId] as string | undefined; - if (newFRecordFLink) { - const index = newFRecordFLink.findIndex((record) => record.id === recordId); - if (index === -1) { - newFRecordFLink.push({ - id: recordId, - title, - }); - } else { - newFRecordFLink[index] = { id: recordId, title }; - } + const foreignCellValue = newForeignRecord[symmetricFieldId] as ILinkCellValue[] | null; + if (foreignCellValue) { + newForeignRecord[symmetricFieldId] = foreignCellValue.concat({ + id: recordId, + title: sourceRecordTitle, + }); } else { - newFRecord[foreignLinkFieldId] = [{ id: recordId, title }]; + newForeignRecord[symmetricFieldId] = [{ id: recordId, title: sourceRecordTitle }]; } + }); + } + } + + private updateForeignCellForManyOne(params: { + fkItem: IFkRecordItem; + recordId: string; + symmetricFieldId: string; + sourceLookedFieldId: string; + sourceRecordMap: IRecordMapByTableId['tableId']; + foreignRecordMap: IRecordMapByTableId['tableId']; + }) { + const { + fkItem, + recordId, + symmetricFieldId, + sourceLookedFieldId, + foreignRecordMap, + sourceRecordMap, + } = params; + const oldKey = fkItem.oldKey as string | null; + const newKey = fkItem.newKey as string | null; + + // Update link cell values for symmetric field of the foreign table + if (oldKey) { + const foreignCellValue = foreignRecordMap[oldKey][symmetricFieldId] as + | ILinkCellValue[] + | null; + + if (foreignCellValue) { + const filteredCellValue = foreignCellValue.filter((item) => item.id !== recordId); + foreignRecordMap[oldKey][symmetricFieldId] = filteredCellValue.length + ? filteredCellValue + : null; } + } - if (fRecordId) { - mainRecord[mainLinkFieldId] = { id: fRecordId }; + if (newKey) { + const sourceRecordTitle = sourceRecordMap[recordId][sourceLookedFieldId] as + | string + | undefined; + const newForeignRecord = foreignRecordMap[newKey]; + if (!newForeignRecord) { + throw new BadRequestException(`Consistency error, recordId ${newKey} is not exist`); + } + const foreignCellValue = newForeignRecord[symmetricFieldId] as ILinkCellValue[] | null; + if (foreignCellValue) { + newForeignRecord[symmetricFieldId] = foreignCellValue.concat({ + id: recordId, + title: sourceRecordTitle, + }); + } else { + newForeignRecord[symmetricFieldId] = [{ id: recordId, title: sourceRecordTitle }]; } + } + } - // Update the link field in main table - mainRecord[mainLinkFieldId] = fRecordId - ? { id: fRecordId, title: foreignTable[fRecordId][foreignTableLookupFieldId] as string } - : null; + private updateForeignCellForOneMany(params: { + fkItem: IFkRecordItem; + recordId: string; + symmetricFieldId: string; + sourceLookedFieldId: string; + sourceRecordMap: IRecordMapByTableId['tableId']; + foreignRecordMap: IRecordMapByTableId['tableId']; + }) { + const { + fkItem, + recordId, + symmetricFieldId, + sourceLookedFieldId, + foreignRecordMap, + sourceRecordMap, + } = params; + + const oldKey = (fkItem.oldKey || []) as string[]; + const newKey = (fkItem.newKey || []) as string[]; + + const toDelete = difference(oldKey, newKey); + const toAdd = difference(newKey, oldKey); + + if (toDelete.length) { + toDelete.forEach((foreignRecordId) => { + foreignRecordMap[foreignRecordId][symmetricFieldId] = null; + }); + } - // Update the foreignKey field in main table - mainRecord[fkFieldId] = fRecordId; - }); - return recordMapByTableId; + if (toAdd.length) { + const sourceRecordTitle = sourceRecordMap[recordId][sourceLookedFieldId] as + | string + | undefined; + + toAdd.forEach((foreignRecordId) => { + foreignRecordMap[foreignRecordId][symmetricFieldId] = { + id: recordId, + title: sourceRecordTitle, + }; + }); + } + } + + private updateForeignCellForOneOne(params: { + fkItem: IFkRecordItem; + recordId: string; + symmetricFieldId: string; + sourceLookedFieldId: string; + sourceRecordMap: IRecordMapByTableId['tableId']; + foreignRecordMap: IRecordMapByTableId['tableId']; + }) { + const { + fkItem, + recordId, + symmetricFieldId, + sourceLookedFieldId, + foreignRecordMap, + sourceRecordMap, + } = params; + + const oldKey = fkItem.oldKey as string | undefined; + const newKey = fkItem.newKey as string | undefined; + + if (oldKey) { + foreignRecordMap[oldKey][symmetricFieldId] = null; + } + + if (newKey) { + const sourceRecordTitle = sourceRecordMap[recordId][sourceLookedFieldId] as + | string + | undefined; + + foreignRecordMap[newKey][symmetricFieldId] = { + id: recordId, + title: sourceRecordTitle, + }; + } + } + + // update link cellValue title for the user input value of the source table + private fixLinkCellTitle(params: { + newKey: string | string[] | null; + recordId: string; + linkFieldId: string; + foreignLookedFieldId: string; + sourceRecordMap: IRecordMapByTableId['tableId']; + foreignRecordMap: IRecordMapByTableId['tableId']; + }) { + const { + newKey, + recordId, + linkFieldId, + foreignLookedFieldId, + foreignRecordMap, + sourceRecordMap, + } = params; + + if (!newKey) { + return; + } + + if (Array.isArray(newKey)) { + sourceRecordMap[recordId][linkFieldId] = newKey.map((key) => ({ + id: key, + title: foreignRecordMap[key][foreignLookedFieldId] as string | undefined, + })); + return; + } + + const foreignRecordTitle = foreignRecordMap[newKey][foreignLookedFieldId] as string | undefined; + sourceRecordMap[recordId][linkFieldId] = { id: newKey, title: foreignRecordTitle }; } // eslint-disable-next-line sonarjs/cognitive-complexity - private getRecordMapStructAndForeignKeyParams( + private async updateLinkRecord( tableId: string, - fieldMapByTableId: ITinyFieldMapByTableId, - cellContexts: ILinkCellContext[] + fkRecordMap: IFkRecordMap, + fieldMapByTableId: { [tableId: string]: IFieldMap }, + originRecordMapByTableId: IRecordMapByTableId + ): Promise { + const recordMapByTableId = cloneDeep(originRecordMapByTableId); + for (const fieldId in fkRecordMap) { + const linkField = fieldMapByTableId[tableId][fieldId] as LinkFieldDto; + const linkFieldId = linkField.id; + const relationship = linkField.options.relationship; + const foreignTableId = linkField.options.foreignTableId; + const foreignLookedFieldId = linkField.options.lookupFieldId; + + const sourceRecordMap = recordMapByTableId[tableId]; + const foreignRecordMap = recordMapByTableId[foreignTableId]; + const symmetricFieldId = linkField.options.symmetricFieldId; + + for (const recordId in fkRecordMap[fieldId]) { + const fkItem = fkRecordMap[fieldId][recordId]; + + this.fixLinkCellTitle({ + newKey: fkItem.newKey, + recordId, + linkFieldId, + foreignLookedFieldId, + sourceRecordMap, + foreignRecordMap, + }); + + if (!symmetricFieldId) { + continue; + } + const symmetricField = fieldMapByTableId[foreignTableId][symmetricFieldId] as LinkFieldDto; + const sourceLookedFieldId = symmetricField.options.lookupFieldId; + const params = { + fkItem, + recordId, + symmetricFieldId, + sourceLookedFieldId, + sourceRecordMap, + foreignRecordMap, + }; + if (relationship === Relationship.ManyMany) { + this.updateForeignCellForManyMany(params); + } + if (relationship === Relationship.ManyOne) { + this.updateForeignCellForManyOne(params); + } + if (relationship === Relationship.OneMany) { + this.updateForeignCellForOneMany(params); + } + if (relationship === Relationship.OneOne) { + this.updateForeignCellForOneOne(params); + } + } + } + return recordMapByTableId; + } + + private async getForeignKeys( + recordIds: string[], + linkRecordIds: string[], + options: ILinkFieldOptions ) { - const recordMapByTableId: IRecordMapByTableId = {}; - // include main table update message, and foreign table update will reflect to main table - const updateForeignKeyParams: IUpdateForeignKeyParam[] = []; - const checkSetMap: { [fieldId: string]: Set } = {}; - function pushForeignKeyParam(param: IUpdateForeignKeyParam) { - let checkSet = checkSetMap[param.mainLinkFieldId]; - if (!checkSet) { - checkSet = new Set(); - checkSetMap[param.mainLinkFieldId] = checkSet; + const { fkHostTableName, selfKeyName, foreignKeyName } = options; + + const query = this.knex(fkHostTableName) + .select({ + id: selfKeyName, + foreignId: foreignKeyName, + }) + .whereIn(selfKeyName, recordIds) + .orWhereIn(foreignKeyName, linkRecordIds) + .whereNotNull(selfKeyName) + .whereNotNull(foreignKeyName) + .toQuery(); + + return this.prismaService + .txClient() + .$queryRawUnsafe<{ id: string; foreignId: string }[]>(query); + } + + /** + * Checks if there are duplicate associations in one-to-one and one-to-many relationships. + */ + private checkForIllegalDuplicateLinks( + field: LinkFieldDto, + recordIds: string[], + indexedCellContext: Record + ) { + const relationship = field.options.relationship; + if (relationship === Relationship.ManyMany || relationship === Relationship.ManyOne) { + return; + } + const checkSet = new Set(); + + recordIds.forEach((recordId) => { + const cellValue = indexedCellContext[`${field.id}-${recordId}`].newValue; + if (!cellValue) { + return; + } + if (Array.isArray(cellValue)) { + cellValue.forEach((item) => { + if (checkSet.has(item.id)) { + throw new BadRequestException( + `Consistency error, ${relationship} link field {${field.id}} unable to link a record (${item.id}) more than once` + ); + } + checkSet.add(item.id); + }); + return; } + if (checkSet.has(cellValue.id)) { + throw new BadRequestException( + `Consistency error, ${relationship} link field {${field.id}} unable to link a record (${cellValue.id}) more than once` + ); + } + checkSet.add(cellValue.id); + }); + } - if (param.fRecordId) { - if (checkSet.has(param.recordId)) { - throw new BadRequestException( - `Consistency error, link field {${param.foreignLinkFieldId}} unable to link a record (${param.recordId}) more than once` - ); + private parseFkRecordItem( + field: LinkFieldDto, + cellContexts: ILinkCellContext[], + foreignKeys: { + id: string; + foreignId: string; + }[] + ): Record { + const relationship = field.options.relationship; + const foreignKeysIndexed = groupBy(foreignKeys, 'id'); + const foreignKeysReverseIndexed = groupBy(foreignKeys, 'foreignId'); + + // eslint-disable-next-line sonarjs/cognitive-complexity + return cellContexts.reduce((acc, cellContext) => { + // this two relations only have one key in one recordId + const id = cellContext.recordId; + const foreignKeys = foreignKeysIndexed[id]; + if (relationship === Relationship.OneOne || relationship === Relationship.ManyOne) { + const newCellValue = cellContext.newValue as ILinkCellValue | undefined; + if ((foreignKeys?.length ?? 0) > 1) { + throw new Error('duplicate foreign key from database'); } - checkSet.add(param.recordId); + + const foreignRecordId = foreignKeys?.[0].foreignId; + const oldKey = foreignRecordId || null; + const newKey = newCellValue?.id || null; + if (oldKey === newKey) { + return acc; + } + acc[id] = { oldKey, newKey }; + return acc; } - return updateForeignKeyParams.push(param); + + if (relationship === Relationship.ManyMany || relationship === Relationship.OneMany) { + const newCellValue = cellContext.newValue as ILinkCellValue[] | undefined; + const oldKey = foreignKeys?.map((key) => key.foreignId) ?? null; + const newKey = newCellValue?.map((item) => item.id) ?? null; + + const extraKey = difference(newKey ?? [], oldKey ?? []); + + extraKey.forEach((key) => { + if (foreignKeysReverseIndexed[key]) { + throw new BadRequestException( + `Consistency error, ${relationship} link field {${field.id}} unable to link a record (${key}) more than once` + ); + } + }); + acc[id] = { + oldKey, + newKey, + }; + return acc; + } + return acc; + }, {}); + } + + /** + * Tip: for single source of truth principle, we should only trust foreign key recordId + * + * 1. get all edited recordId and group by fieldId + * 2. get all exist foreign key recordId + */ + private async getFkRecordMap( + fieldMap: IFieldMap, + cellContexts: ILinkCellContext[] + ): Promise { + const fkRecordMap: IFkRecordMap = {}; + + const cellGroupByFieldId = groupBy(cellContexts, (ctx) => ctx.fieldId); + const indexedCellContext = keyBy(cellContexts, (ctx) => `${ctx.fieldId}-${ctx.recordId}`); + for (const fieldId in cellGroupByFieldId) { + const field = fieldMap[fieldId]; + if (!field) { + throw new BadRequestException(`Field ${fieldId} not found`); + } + + if (field.type !== FieldType.Link) { + throw new BadRequestException(`Field ${fieldId} is not link field`); + } + + const recordIds = cellGroupByFieldId[fieldId].map((ctx) => ctx.recordId); + const linkRecordIds = cellGroupByFieldId[fieldId] + .map((ctx) => + [ctx.oldValue, ctx.newValue] + .flat() + .filter(Boolean) + .map((item) => item?.id as string) + ) + .flat(); + + const foreignKeys = await this.getForeignKeys(recordIds, linkRecordIds, field.options); + this.checkForIllegalDuplicateLinks(field, recordIds, indexedCellContext); + + fkRecordMap[fieldId] = this.parseFkRecordItem( + field, + cellGroupByFieldId[fieldId], + foreignKeys + ); } + + return fkRecordMap; + } + + // create the key for recordMapByTableId but leave the undefined value for the next step + private getRecordMapStruct( + tableId: string, + fieldMapByTableId: { [tableId: string]: IFieldMap }, + cellContexts: ILinkCellContext[] + ) { + const recordMapByTableId: IRecordMapByTableId = {}; + for (const cellContext of cellContexts) { const { recordId, fieldId, newValue, oldValue } = cellContext; const linkRecordIds = [oldValue, newValue] .flat() .filter(Boolean) .map((item) => item?.id as string); - const field = fieldMapByTableId[tableId][fieldId]; - - const { - dbForeignKeyName, - foreignTableId, - symmetricFieldId: foreignLinkFieldId, - lookupFieldId: foreignLookupFieldId, - relationship, - } = field.options as ILinkFieldOptions; - - const foreignField = fieldMapByTableId[foreignTableId][foreignLinkFieldId]; - const lookupFieldId = (foreignField.options as ILinkFieldOptions).lookupFieldId; + const field = fieldMapByTableId[tableId][fieldId] as LinkFieldDto; + const foreignTableId = field.options.foreignTableId; + const symmetricFieldId = field.options.symmetricFieldId; + const symmetricField = symmetricFieldId + ? (fieldMapByTableId[foreignTableId][symmetricFieldId] as LinkFieldDto) + : undefined; + const foreignLookedFieldId = field.options.lookupFieldId; + const lookedFieldId = symmetricField?.options.lookupFieldId; set(recordMapByTableId, [tableId, recordId, fieldId], undefined); - set(recordMapByTableId, [tableId, recordId, lookupFieldId], undefined); + lookedFieldId && set(recordMapByTableId, [tableId, recordId, lookedFieldId], undefined); + // create object key for record in looked field linkRecordIds.forEach((linkRecordId) => { - set(recordMapByTableId, [foreignTableId, linkRecordId, foreignLinkFieldId], undefined); - set(recordMapByTableId, [foreignTableId, linkRecordId, foreignLookupFieldId], undefined); + symmetricFieldId && + set(recordMapByTableId, [foreignTableId, linkRecordId, symmetricFieldId], undefined); + set(recordMapByTableId, [foreignTableId, linkRecordId, foreignLookedFieldId], undefined); }); - - if (relationship === Relationship.ManyOne) { - if (newValue && !('id' in newValue)) { - throw new BadRequestException('ManyOne relationship should not have multiple records'); - } - // add dbForeignKeyName to the record - set(recordMapByTableId, [tableId, recordId, dbForeignKeyName], undefined); - pushForeignKeyParam({ - tableId, - foreignTableId, - mainLinkFieldId: fieldId, - mainTableLookupFieldId: lookupFieldId, - foreignLinkFieldId, - foreignTableLookupFieldId: foreignLookupFieldId, - dbForeignKeyName: dbForeignKeyName, - recordId, - fRecordId: newValue?.id || null, // to set main table link cellValue to null; - }); - } - if (relationship === Relationship.OneMany) { - if (newValue && !Array.isArray(newValue)) { - throw new BadRequestException( - `Consistency error, OneMany relationship newValue should have multiple records, received: ${JSON.stringify( - newValue - )}` - ); - } - if (oldValue && !Array.isArray(oldValue)) { - throw new BadRequestException( - `Consistency error, OneMany relationship oldValue should have multiple records, received: ${JSON.stringify( - oldValue - )}` - ); - } - const paramCommon = { - tableId: foreignTableId, - foreignTableId: tableId, - mainLinkFieldId: foreignLinkFieldId, - mainTableLookupFieldId: foreignLookupFieldId, - foreignLinkFieldId: fieldId, - foreignTableLookupFieldId: lookupFieldId, - dbForeignKeyName: dbForeignKeyName, - }; - - oldValue && - oldValue.forEach((oldValueItem) => { - // add dbForeignKeyName to the record - set(recordMapByTableId, [foreignTableId, oldValueItem.id, dbForeignKeyName], undefined); - pushForeignKeyParam({ - ...paramCommon, - recordId: oldValueItem.id, - fRecordId: null, - }); - }); - newValue && - newValue.forEach((newValueItem) => { - // add dbForeignKeyName to the record - set(recordMapByTableId, [foreignTableId, newValueItem.id, dbForeignKeyName], undefined); - pushForeignKeyParam({ - ...paramCommon, - recordId: newValueItem.id, - fRecordId: recordId, - }); - }); - } } - return { - recordMapByTableId, - updateForeignKeyParams, - }; + + return recordMapByTableId; } // eslint-disable-next-line sonarjs/cognitive-complexity - private async fillRecordMap( + private async fetchRecordMap( tableId2DbTableName: { [tableId: string]: string }, - fieldMapByTableId: ITinyFieldMapByTableId, + fieldMapByTableId: { [tableId: string]: IFieldMap }, recordMapByTableId: IRecordMapByTableId, cellContexts: ICellContext[], fromReset?: boolean @@ -390,29 +686,6 @@ export class LinkService { return recordMapByTableId; } - private generateFkRecordMapByDbTableName( - tableId2DbTableName: { [tableId: string]: string }, - fkFieldNameMap: { [fkFieldName: string]: Set }, - updatedRecordMapByTableId: IRecordMapByTableId - ) { - const fkRecordMap: IFkOpMap = {}; - for (const tableId in updatedRecordMapByTableId) { - if (!fkFieldNameMap[tableId]) { - continue; - } - const fkFieldNames = Array.from(fkFieldNameMap[tableId]); - for (const recordId in updatedRecordMapByTableId[tableId]) { - const record = updatedRecordMapByTableId[tableId][recordId]; - fkFieldNames.forEach((fkFieldName) => { - const value = record[fkFieldName] as string | null; - - set(fkRecordMap, [tableId2DbTableName[tableId], recordId, fkFieldName], value); - }); - } - } - return fkRecordMap; - } - private async getTableId2DbTableName(tableIds: string[]) { const tableRaws = await this.prismaService.txClient().tableMeta.findMany({ where: { @@ -431,19 +704,8 @@ export class LinkService { }, {}); } - private getFkFieldNameMap(updateForeignKeyParams: IUpdateForeignKeyParam[]) { - return updateForeignKeyParams.reduce<{ [fkFieldName: string]: Set }>((pre, cur) => { - const { tableId, dbForeignKeyName } = cur; - if (!pre[tableId]) { - pre[tableId] = new Set(); - } - pre[tableId].add(dbForeignKeyName); - return pre; - }, {}); - } - - private getDiffCellChangeByRecordMap( - fkFieldNameMap: { [fkFieldName: string]: Set }, + private diffLinkCellChange( + fieldMapByTableId: { [tableId: string]: IFieldMap }, originRecordMapByTableId: IRecordMapByTableId, updatedRecordMapByTableId: IRecordMapByTableId ): ICellChange[] { @@ -452,14 +714,14 @@ export class LinkService { for (const tableId in originRecordMapByTableId) { const originRecords = originRecordMapByTableId[tableId]; const updatedRecords = updatedRecordMapByTableId[tableId]; + const fieldMap = fieldMapByTableId[tableId]; for (const recordId in originRecords) { const originFields = originRecords[recordId]; const updatedFields = updatedRecords[recordId]; for (const fieldId in originFields) { - // ignore foreignKey field - if (fkFieldNameMap[tableId]?.has(fieldId)) { + if (fieldMap[fieldId].type !== FieldType.Link) { continue; } @@ -476,102 +738,228 @@ export class LinkService { return changes; } - private deepEqualIgnoreArrayOrder(a: unknown, b: unknown): boolean { - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) return false; - - const mapA: { [id: string]: { id: string; title?: string } } = {}; - for (const obj of a as { id: string; title?: string }[]) { - mapA[obj.id] = obj; - } - - for (const obj of b as { id: string; title?: string }[]) { - const correspondingObjA = mapA[obj.id]; - if (!correspondingObjA || obj.title !== correspondingObjA.title) { - return false; - } - } - - return true; - } - - return isEqual(a, b); - } - - private filterCellChangeByCellContexts( - tableId: string, - cellContexts: ICellContext[], - cellChanges: ICellChange[] - ): ICellChange[] { - // Create a map for quick access to cell contexts by tableId, recordId and fieldId - const cellContextMap: { [key: string]: ICellContext } = {}; - for (const context of cellContexts) { - const key = `${tableId}-${context.recordId}-${context.fieldId}`; - cellContextMap[key] = context; - } - - return cellChanges.filter((change) => { - const key = `${change.tableId}-${change.recordId}-${change.fieldId}`; - const context = cellContextMap[key]; - - // If cell context does not exist or its new value is not equal to the change's new value, - // keep the change - return !context || !this.deepEqualIgnoreArrayOrder(change.newValue, context.newValue); - }); - } - private async getDerivateByCellContexts( tableId: string, tableId2DbTableName: { [tableId: string]: string }, - fieldMapByTableId: ITinyFieldMapByTableId, + fieldMapByTableId: { [tableId: string]: IFieldMap }, linkContexts: ILinkCellContext[], cellContexts: ICellContext[], fromReset?: boolean - ): Promise<{ cellChanges: ICellChange[]; fkRecordMap: IFkOpMap }> { - const { recordMapByTableId, updateForeignKeyParams } = - this.getRecordMapStructAndForeignKeyParams(tableId, fieldMapByTableId, linkContexts); + ): Promise<{ + cellChanges: ICellChange[]; + saveForeignKeyToDb: () => Promise; + }> { + const fieldMap = fieldMapByTableId[tableId]; + const recordMapStruct = this.getRecordMapStruct(tableId, fieldMapByTableId, linkContexts); - const originRecordMapByTableId = await this.fillRecordMap( + const fkRecordMap = await this.getFkRecordMap(fieldMap, linkContexts); + + const originRecordMapByTableId = await this.fetchRecordMap( tableId2DbTableName, fieldMapByTableId, - recordMapByTableId, + recordMapStruct, cellContexts, fromReset ); - // console.log('originRecordMapByTableId:', JSON.stringify(originRecordMapByTableId, null, 2)); - - const updatedRecordMapByTableId = this.updateForeignKeyInMemory( - updateForeignKeyParams, - recordMapByTableId - ); - const fkFieldNameMap = this.getFkFieldNameMap(updateForeignKeyParams); - const fkRecordMap = this.generateFkRecordMapByDbTableName( - tableId2DbTableName, - fkFieldNameMap, - updatedRecordMapByTableId + const updatedRecordMapByTableId = await this.updateLinkRecord( + tableId, + fkRecordMap, + fieldMapByTableId, + originRecordMapByTableId ); - // console.log('updatedRecordMapByTableId:', JSON.stringify(updatedRecordMapByTableId, null, 2)); - // console.log('fkRecordMap:', JSON.stringify(updatedRecordMapByTableId, null, 2)); - - const originCellChanges = this.getDiffCellChangeByRecordMap( - fkFieldNameMap, + const cellChanges = this.diffLinkCellChange( + fieldMapByTableId, originRecordMapByTableId, updatedRecordMapByTableId ); - const cellChanges = this.filterCellChangeByCellContexts( - tableId, - linkContexts, - originCellChanges - ); return { cellChanges, - fkRecordMap, + saveForeignKeyToDb: async () => { + return this.saveForeignKeyToDb(fieldMapByTableId[tableId], fkRecordMap); + }, }; } + private async saveForeignKeyForManyMany( + field: LinkFieldDto, + fkMap: { [recordId: string]: IFkRecordItem } + ) { + const { selfKeyName, foreignKeyName, fkHostTableName } = field.options; + + const toDelete: [string, string][] = []; + const toAdd: [string, string][] = []; + for (const recordId in fkMap) { + const fkItem = fkMap[recordId]; + const oldKey = (fkItem.oldKey || []) as string[]; + const newKey = (fkItem.newKey || []) as string[]; + + difference(oldKey, newKey).forEach((key) => toDelete.push([recordId, key])); + difference(newKey, oldKey).forEach((key) => toAdd.push([recordId, key])); + } + + if (toDelete.length) { + const query = this.knex(fkHostTableName) + .whereIn([selfKeyName, foreignKeyName], toDelete) + .delete() + .toQuery(); + await this.prismaService.txClient().$executeRawUnsafe(query); + } + + if (toAdd.length) { + const query = this.knex(fkHostTableName) + .insert( + toAdd.map(([source, target]) => ({ + [selfKeyName]: source, + [foreignKeyName]: target, + })) + ) + .toQuery(); + await this.prismaService.txClient().$executeRawUnsafe(query); + } + } + + private async saveForeignKeyForManyOne( + field: LinkFieldDto, + fkMap: { [recordId: string]: IFkRecordItem } + ) { + const { selfKeyName, foreignKeyName, fkHostTableName } = field.options; + + const toDelete: [string, string][] = []; + const toAdd: [string, string][] = []; + for (const recordId in fkMap) { + const fkItem = fkMap[recordId]; + const oldKey = fkItem.oldKey as string | null; + const newKey = fkItem.newKey as string | null; + + oldKey && toDelete.push([recordId, oldKey]); + newKey && toAdd.push([recordId, newKey]); + } + + if (toDelete.length) { + const query = this.knex(fkHostTableName) + .update({ [foreignKeyName]: null }) + .whereIn([selfKeyName, foreignKeyName], toDelete) + .toQuery(); + await this.prismaService.txClient().$executeRawUnsafe(query); + } + + if (toAdd.length) { + await this.batchService.batchUpdateDB( + fkHostTableName, + selfKeyName, + [{ dbFieldName: foreignKeyName, schemaType: SchemaType.String }], + toAdd.map(([recordId, foreignRecordId]) => ({ + id: recordId, + values: { [foreignKeyName]: foreignRecordId }, + })) + ); + } + } + + private async saveForeignKeyForOneMany( + field: LinkFieldDto, + fkMap: { [recordId: string]: IFkRecordItem } + ) { + const { selfKeyName, foreignKeyName, fkHostTableName } = field.options; + + const toDelete: [string, string][] = []; + const toAdd: [string, string][] = []; + for (const recordId in fkMap) { + const fkItem = fkMap[recordId]; + const oldKey = (fkItem.oldKey || []) as string[]; + const newKey = (fkItem.newKey || []) as string[]; + + difference(oldKey, newKey).forEach((key) => toDelete.push([recordId, key])); + difference(newKey, oldKey).forEach((key) => toAdd.push([recordId, key])); + } + + if (toDelete.length) { + const query = this.knex(fkHostTableName) + .update({ [selfKeyName]: null }) + .whereIn([selfKeyName, foreignKeyName], toDelete) + .toQuery(); + await this.prismaService.txClient().$executeRawUnsafe(query); + } + + if (toAdd.length) { + await this.batchService.batchUpdateDB( + fkHostTableName, + foreignKeyName, + [{ dbFieldName: selfKeyName, schemaType: SchemaType.String }], + toAdd.map(([recordId, foreignRecordId]) => ({ + id: foreignRecordId, + values: { [selfKeyName]: recordId }, + })) + ); + } + } + + private async saveForeignKeyForOneOne( + field: LinkFieldDto, + fkMap: { [recordId: string]: IFkRecordItem } + ) { + const { selfKeyName, foreignKeyName, fkHostTableName } = field.options; + if (selfKeyName === '__id') { + await this.saveForeignKeyForManyOne(field, fkMap); + } else { + const toDelete: [string, string][] = []; + const toAdd: [string, string][] = []; + for (const recordId in fkMap) { + const fkItem = fkMap[recordId]; + const oldKey = fkItem.oldKey as string | null; + const newKey = fkItem.newKey as string | null; + + oldKey && toDelete.push([recordId, oldKey]); + newKey && toAdd.push([recordId, newKey]); + } + + if (toDelete.length) { + const query = this.knex(fkHostTableName) + .update({ [selfKeyName]: null }) + .whereIn([selfKeyName, foreignKeyName], toDelete) + .toQuery(); + await this.prismaService.txClient().$executeRawUnsafe(query); + } + + if (toAdd.length) { + await this.batchService.batchUpdateDB( + fkHostTableName, + foreignKeyName, + [{ dbFieldName: selfKeyName, schemaType: SchemaType.String }], + toAdd.map(([recordId, foreignRecordId]) => ({ + id: foreignRecordId, + values: { [selfKeyName]: recordId }, + })) + ); + } + } + + throw Error('Invalid OneOne field options'); + } + + private async saveForeignKeyToDb(fieldMap: IFieldMap, fkRecordMap: IFkRecordMap) { + for (const fieldId in fkRecordMap) { + const fkMap = fkRecordMap[fieldId]; + const field = fieldMap[fieldId] as LinkFieldDto; + const relationship = field.options.relationship; + if (relationship === Relationship.ManyMany) { + await this.saveForeignKeyForManyMany(field, fkMap); + } + if (relationship === Relationship.ManyOne) { + await this.saveForeignKeyForManyOne(field, fkMap); + } + if (relationship === Relationship.OneMany) { + await this.saveForeignKeyForOneMany(field, fkMap); + } + if (relationship === Relationship.OneOne) { + await this.saveForeignKeyForOneOne(field, fkMap); + } + } + } + /** * v2.0 improved strategy * 0: define `main table` is where foreign key located in, `foreign table` is where foreign key referenced to @@ -588,11 +976,11 @@ export class LinkService { async getDerivateByLink(tableId: string, cellContexts: ICellContext[], fromReset?: boolean) { const linkContexts = this.filterLinkContext(cellContexts as ILinkCellContext[]); if (!linkContexts.length) { - return null; + return; } const fieldIds = linkContexts.map((ctx) => ctx.fieldId); - const fieldMapByTableId = await this.getTinyFieldMapByTableId(fieldIds); + const fieldMapByTableId = await this.getFieldMapByTableId(fieldIds); const tableId2DbTableName = await this.getTableId2DbTableName(Object.keys(fieldMapByTableId)); return this.getDerivateByCellContexts( diff --git a/apps/nestjs-backend/src/features/calculation/reference.service.ts b/apps/nestjs-backend/src/features/calculation/reference.service.ts index e759438c9..4a42a9964 100644 --- a/apps/nestjs-backend/src/features/calculation/reference.service.ts +++ b/apps/nestjs-backend/src/features/calculation/reference.service.ts @@ -1,29 +1,35 @@ -import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import type { IFieldVo, ILinkCellValue, ILinkFieldOptions, - ILookupOptionsVo, IOtOperation, ITinyRecord, } from '@teable-group/core'; -import { evaluate, FieldType, RecordOpBuilder, Relationship } from '@teable-group/core'; +import { + evaluate, + FieldType, + isMultiValueLink, + RecordOpBuilder, + Relationship, +} from '@teable-group/core'; import { PrismaService } from '@teable-group/db-main-prisma'; import { instanceToPlain } from 'class-transformer'; import { Knex } from 'knex'; -import { difference, groupBy, intersectionBy, isEmpty, keyBy, uniq } from 'lodash'; +import { cloneDeep, difference, groupBy, isEmpty, keyBy, unionWith, uniq } from 'lodash'; import { InjectModel } from 'nest-knexjs'; -import { IDbProvider } from '../../db-provider/db.provider.interface'; import { preservedFieldName } from '../field/constant'; -import type { IFieldInstance } from '../field/model/factory'; +import type { IFieldInstance, IFieldMap } from '../field/model/factory'; import { createFieldInstanceByRaw, createFieldInstanceByVo } from '../field/model/factory'; import type { FormulaFieldDto } from '../field/model/field-dto/formula-field.dto'; import type { ICellChange } from './utils/changes'; import { formatChangesToOps, mergeDuplicateChange } from './utils/changes'; import { isLinkCellValue } from './utils/detect-link'; +import type { IAdjacencyMap } from './utils/dfs'; +import { buildCompressedAdjacencyMap, filterDirectedGraph, getTopologicalOrder } from './utils/dfs'; +import { nameConsole } from './utils/name-console'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars - +// topo item is for field level reference, all id stands for fieldId; export interface ITopoItem { id: string; dependencies: string[]; @@ -34,24 +40,26 @@ export interface IGraphItem { toFieldId: string; } -export interface IFieldMap { - [fieldId: string]: IFieldInstance; +export interface IRecordMap { + [recordId: string]: ITinyRecord; } export interface IRecordItem { record: ITinyRecord; - dependencies?: ITinyRecord | ITinyRecord[]; + dependencies?: ITinyRecord[]; } -interface IRecordData { +export interface IRecordData { id: string; fieldId: string; oldValue?: unknown; newValue: unknown; } -interface IRecordDataMap { - [tableId: string]: IRecordData[]; +export interface IRelatedRecordItem { + fieldId: string; + toId: string; + fromId: string; } export interface IOpsMap { @@ -61,31 +69,15 @@ export interface IOpsMap { } export interface ITopoItemWithRecords extends ITopoItem { - recordItems: IRecordItem[]; -} - -export interface IFkOpMap { - [dbTableName: string]: { - [recordId: string]: { - [fkField: string]: string | null; - }; - }; + recordItemMap?: Record; } export interface ITopoLinkOrder { - dbTableName: string; fieldId: string; - foreignKeyField: string; relationship: Relationship; - linkedTable: string; -} - -export interface IRecordRefItem { - id: string; - dbTableName: string; - fieldId?: string; - selectIn?: string; - relationTo?: string; + fkHostTableName: string; + selfKeyName: string; + foreignKeyName: string; } @Injectable() @@ -94,8 +86,7 @@ export class ReferenceService { constructor( private readonly prismaService: PrismaService, - @InjectModel('CUSTOM_KNEX') private readonly knex: Knex, - @Inject('DbProvider') private dbProvider: IDbProvider + @InjectModel('CUSTOM_KNEX') private readonly knex: Knex ) {} /** @@ -113,27 +104,30 @@ export class ReferenceService { * 2. update foreignKey * 3. calculate the others operation * - * fkOpMap is a map of foreignKey update operation. linkDerivation generate fkOpMap operation, - * but we need it do calculation, so we have to pass origin fkOpMap it to calculateOpsMap. + * saveForeignKeyToDb a method of foreignKey update operation. we should call it after delete operation. */ - async calculateOpsMap(opsMap: IOpsMap, fkOpMap: IFkOpMap = {}) { - const { recordDataMapWithDelete, recordDataMapRemains } = - this.splitOpsMapToRecordDataMap(opsMap); - - // console.log('recordDataMapWithDelete', JSON.stringify(recordDataMapWithDelete, null, 2)); - // console.log('recordDataMapRemains', JSON.stringify(recordDataMapRemains, null, 2)); - // console.log('updateForeignKey:', JSON.stringify(fkOpMap, null, 2)); - const resultBefore = await this.calculateRecordDataMap(recordDataMapWithDelete, fkOpMap); - // console.log('resultBefore', resultBefore?.cellChanges); - await this.updateForeignKey(fkOpMap); - const resultAfter = await this.calculateRecordDataMap(recordDataMapRemains, fkOpMap); - // console.log('resultAfter', resultAfter); - const changes = resultBefore.cellChanges.concat(resultAfter.cellChanges); - const fieldMap = Object.assign({}, resultBefore.fieldMap, resultAfter.fieldMap); + async calculateOpsMap(opsMap: IOpsMap, saveForeignKeyToDb?: () => Promise) { + const { recordDataDelete, recordDataRemains } = this.splitOpsMap(opsMap); + // console.log('recordDataDelete', JSON.stringify(recordDataDelete, null, 2)); + const resultBefore = await this.calculate(this.mergeDuplicateRecordData(recordDataDelete)); + // console.log('resultBefore', JSON.stringify(resultBefore?.changes, null, 2)); + + saveForeignKeyToDb && (await saveForeignKeyToDb()); + + // console.log('recordDataRemains', JSON.stringify(recordDataRemains, null, 2)); + const resultAfter = await this.calculate(this.mergeDuplicateRecordData(recordDataRemains)); + // console.log('resultAfter', JSON.stringify(resultAfter?.changes, null, 2)); + + const changes = [resultBefore?.changes, resultAfter?.changes] + .filter(Boolean) + .flat() as ICellChange[]; + + const fieldMap = Object.assign({}, resultBefore?.fieldMap, resultAfter?.fieldMap); + const tableId2DbTableName = Object.assign( {}, - resultBefore.tableId2DbTableName, - resultAfter.tableId2DbTableName + resultBefore?.tableId2DbTableName, + resultAfter?.tableId2DbTableName ); return { @@ -143,15 +137,28 @@ export class ReferenceService { }; } - getTopoOrdersByFieldId(fieldIds: string[], directedGraph: IGraphItem[]) { + getTopoOrdersMap(fieldIds: string[], directedGraph: IGraphItem[]) { return fieldIds.reduce<{ [fieldId: string]: ITopoItem[]; }>((pre, fieldId) => { - pre[fieldId] = this.getTopologicalOrder(fieldId, directedGraph); + pre[fieldId] = getTopologicalOrder(fieldId, directedGraph); return pre; }, {}); } + getLinkAdjacencyMap(fieldMap: IFieldMap, directedGraph: IGraphItem[]) { + const linkIdSet = Object.values(fieldMap).reduce((pre, field) => { + if (field.lookupOptions || field.type === FieldType.Link) { + pre.add(field.id); + } + return pre; + }, new Set()); + if (linkIdSet.size === 0) { + return {}; + } + return buildCompressedAdjacencyMap(directedGraph, linkIdSet); + } + /** * link field should not be the first item in topo order when calculate. */ @@ -177,99 +184,57 @@ export class ReferenceService { }, {}); } - async prepareCalculation(tableId: string, recordData: IRecordData[]) { + async prepareCalculation(recordData: IRecordData[]) { if (!recordData.length) { return; } - const { directedGraph, startFieldIds, extraRecordIdItems } = + const { directedGraph, startFieldIds, startRecordIds } = await this.getDirectedGraph(recordData); if (!directedGraph.length) { return; } - // skip calculate when not all field in graph - const graphSet: Set = new Set( - directedGraph.flatMap((item) => [item.fromFieldId, item.toFieldId]) - ); - for (const fieldId of startFieldIds) { - if (!graphSet.has(fieldId)) { - return; - } - } - // get all related field by undirected graph const allFieldIds = this.flatGraph(directedGraph); // prepare all related data - const { fieldMap, fieldId2TableId, dbTableName2fields, tableId2DbTableName } = - await this.createAuxiliaryData(allFieldIds); - - // topological sorting - const topoOrdersByFieldId = this.removeFirstLinkItem( + const { fieldMap, - this.getTopoOrdersByFieldId(startFieldIds, directedGraph) - ); + fieldId2TableId, + dbTableName2fields, + tableId2DbTableName, + fieldId2DbTableName, + } = await this.createAuxiliaryData(allFieldIds); + + const topoOrdersMap = this.getTopoOrdersMap(startFieldIds, directedGraph); + + const linkAdjacencyMap = this.getLinkAdjacencyMap(fieldMap, directedGraph); - if (isEmpty(topoOrdersByFieldId)) { + if (isEmpty(topoOrdersMap)) { return; } - // nameConsole('recordData', recordData, fieldMap); - // nameConsole('startFieldIds', startFieldIds, fieldMap); - // nameConsole('allFieldIds', allFieldIds, fieldMap); - // nameConsole('directedGraph', directedGraph, fieldMap); - // nameConsole('topoOrdersByFieldId', topoOrdersByFieldId, fieldMap); - - // submitted changed records - const originRecordItems = recordData.map((record) => ({ - dbTableName: tableId2DbTableName[tableId], - fieldId: record.fieldId, - newValue: record.newValue, - id: record.id, - })); - // nameConsole('originRecordItems:', originRecordItems, fieldMap); - - // the origin change will lead to affected record changes - // console.log('fieldMap', fieldMap); - let affectedRecordItems: IRecordRefItem[] = []; - for (const fieldId in topoOrdersByFieldId) { - const topoOrders = topoOrdersByFieldId[fieldId]; - // nameConsole('topoOrders:', topoOrders, fieldMap); - const linkOrders = this.getLinkOrderFromTopoOrders({ - tableId2DbTableName, - topoOrders, - fieldMap, - fieldId2TableId, - }); - // only affected records included - const originRecordIdItems = extraRecordIdItems - .map((item) => ({ - dbTableName: tableId2DbTableName[item.tableId], - id: item.id, - })) - .concat(originRecordItems); - // nameConsole('getAffectedRecordItems:originRecordIdItems', originRecordIdItems, fieldMap); - // nameConsole('getAffectedRecordItems:topoOrder', linkOrders, fieldMap); - const items = await this.getAffectedRecordItems(linkOrders, originRecordIdItems); - // nameConsole('fieldId:', { fieldId }, fieldMap); - // nameConsole('affectedRecordItems:', items, fieldMap); - affectedRecordItems = affectedRecordItems.concat(items); - } - // console.log('affectedRecordItems', JSON.stringify(affectedRecordItems, null, 2)); + // console.log('linkAdjacencyMap', linkAdjacencyMap); - const dependentRecordItems = await this.getDependentRecordItems(affectedRecordItems); - // nameConsole('dependentRecordItems', dependentRecordItems, fieldMap); + const relatedRecordItems = await this.getRelatedItems( + startFieldIds, + fieldMap, + linkAdjacencyMap, + startRecordIds + ); // record data source - const dbTableName2records = await this.getRecordsBatch({ - originRecordItems, - affectedRecordItems, - dependentRecordItems, + const dbTableName2recordMap = await this.getRecordMapBatch({ + fieldMap, + fieldId2DbTableName, dbTableName2fields, + modifiedRecords: recordData, + relatedRecordItems, }); - // nameConsole('dbTableName2records', dbTableName2records, fieldMap); - // nameConsole('affectedRecordItems', affectedRecordItems, fieldMap); - const orderWithRecordsByFieldId = Object.entries(topoOrdersByFieldId).reduce<{ + const relatedRecordItemsIndexed = groupBy(relatedRecordItems, 'fieldId'); + // console.log('relatedRecordItems', relatedRecordItems); + // console.log('dbTableName2recordMap', JSON.stringify(dbTableName2recordMap, null, 2)); + const orderWithRecordsByFieldId = Object.entries(topoOrdersMap).reduce<{ [fieldId: string]: ITopoItemWithRecords[]; }>((pre, [fieldId, topoOrders]) => { const orderWithRecords = this.createTopoItemWithRecords({ @@ -277,9 +242,8 @@ export class ReferenceService { fieldMap, tableId2DbTableName, fieldId2TableId, - dbTableName2records, - affectedRecordItems, - dependentRecordItems, + dbTableName2recordMap, + relatedRecordItemsIndexed, }); pre[fieldId] = orderWithRecords; return pre; @@ -291,12 +255,12 @@ export class ReferenceService { fieldId2TableId, tableId2DbTableName, orderWithRecordsByFieldId, - dbTableName2records, + dbTableName2recordMap, }; } - async calculate(tableId: string, recordData: IRecordData[], fkOpMap: IFkOpMap) { - const result = await this.prepareCalculation(tableId, recordData); + async calculate(recordData: IRecordData[]) { + const result = await this.prepareCalculation(recordData); if (!result) { return; } @@ -305,15 +269,7 @@ export class ReferenceService { const changes = Object.values(orderWithRecordsByFieldId).reduce( (pre, orderWithRecords) => { // nameConsole('orderWithRecords:', orderWithRecords, fieldMap); - return pre.concat( - this.collectChanges( - orderWithRecords, - fieldMap, - fieldId2TableId, - tableId2DbTableName, - fkOpMap - ) - ); + return pre.concat(this.collectChanges(orderWithRecords, fieldMap, fieldId2TableId)); }, [] ); @@ -326,13 +282,10 @@ export class ReferenceService { }; } - // eslint-disable-next-line sonarjs/cognitive-complexity - private splitOpsMapToRecordDataMap(opsMap: IOpsMap) { - const recordDataMapWithDelete: IRecordDataMap = {}; - const recordDataMapRemains: IRecordDataMap = {}; + private splitOpsMap(opsMap: IOpsMap) { + const recordDataDelete: IRecordData[] = []; + const recordDataRemains: IRecordData[] = []; for (const tableId in opsMap) { - const recordDataRemains: IRecordData[] = []; - const recordDataWithDeleteLink: IRecordData[] = []; for (const recordId in opsMap[tableId]) { opsMap[tableId][recordId].forEach((op) => { const ctx = RecordOpBuilder.editor.setRecord.detect(op); @@ -343,7 +296,7 @@ export class ReferenceService { } if (isLinkCellValue(ctx.oldValue) || isLinkCellValue(ctx.newValue)) { ctx.oldValue && - recordDataWithDeleteLink.push({ + recordDataDelete.push({ id: recordId, fieldId: ctx.fieldId, oldValue: ctx.oldValue, @@ -365,138 +318,41 @@ export class ReferenceService { } }); } - recordDataMapWithDelete[tableId] = recordDataWithDeleteLink; - recordDataMapRemains[tableId] = recordDataRemains; } return { - recordDataMapWithDelete, - recordDataMapRemains, + recordDataDelete, + recordDataRemains, }; } private async getDirectedGraph(recordData: IRecordData[]) { let startFieldIds = recordData.map((data) => data.fieldId); - const linkedData = recordData.filter( + const linkData = recordData.filter( (data) => isLinkCellValue(data.newValue) || isLinkCellValue(data.oldValue) ); - const linkFieldIds = linkedData.map((data) => data.fieldId); - // we need add extra record id items for lookup effect dependency update when link field change - // only need a single one id in one linkedData item - const effectedRecordIds: string[] = linkedData.reduce((pre, data) => { - const linkValues = data.newValue || data.oldValue; - if (Array.isArray(linkValues)) { - pre.push((linkValues[0] as ILinkCellValue).id); - } else { - pre.push((linkValues as ILinkCellValue).id); - } - return pre; - }, []); - let foreignTableId: string | undefined; + // const linkIds = linkData + // .map((data) => [data.newValue, data.oldValue] as ILinkCellValue[]) + // .flat() + // .filter(Boolean) + // .map((d) => d.id); + const startRecordIds = recordData.map((data) => data.id); + const linkFieldIds = linkData.map((data) => data.fieldId); // when link cell change, we need to get all lookup field if (linkFieldIds.length) { const lookupFieldRaw = await this.prismaService.txClient().field.findMany({ where: { lookupLinkedFieldId: { in: linkFieldIds }, deletedTime: null }, - select: { id: true, lookupOptions: true }, - }); - lookupFieldRaw.forEach((field) => { - const lookupOptions = JSON.parse(field.lookupOptions as string) as ILookupOptionsVo; - foreignTableId = lookupOptions.foreignTableId; - startFieldIds.push(lookupOptions.lookupFieldId); + select: { id: true }, }); + lookupFieldRaw.forEach((field) => startFieldIds.push(field.id)); } startFieldIds = uniq(startFieldIds); - const directedGraph = await this.getDependentNodesCTE(startFieldIds); - + const directedGraph = await this.getFieldGraphItems(startFieldIds); return { directedGraph, startFieldIds, - extraRecordIdItems: foreignTableId - ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - effectedRecordIds.map((id) => ({ id, tableId: foreignTableId! })) - : [], - }; - } - - /** - * Generate a directed graph. - * - * @param undirectedGraph - The elements of the undirected graph. - * @param fieldIds - One or more field IDs to start the DFS from. - * @returns Returns all relations related to the given fieldIds. - */ - // eslint-disable-next-line sonarjs/cognitive-complexity - private filterDirectedGraph(undirectedGraph: IGraphItem[], fieldIds: string[]): IGraphItem[] { - const result: IGraphItem[] = []; - const visited: Set = new Set(); - - // Build adjacency lists for quick look-up - const outgoingAdjList: Record = {}; - const incomingAdjList: Record = {}; - - for (const item of undirectedGraph) { - // Outgoing edges - if (!outgoingAdjList[item.fromFieldId]) { - outgoingAdjList[item.fromFieldId] = []; - } - outgoingAdjList[item.fromFieldId].push(item); - - // Incoming edges - if (!incomingAdjList[item.toFieldId]) { - incomingAdjList[item.toFieldId] = []; - } - incomingAdjList[item.toFieldId].push(item); - } - - function dfs(currentNode: string) { - visited.add(currentNode); - - // Add incoming edges related to currentNode - if (incomingAdjList[currentNode]) { - result.push(...incomingAdjList[currentNode]); - } - - // Process outgoing edges from currentNode - if (outgoingAdjList[currentNode]) { - for (const item of outgoingAdjList[currentNode]) { - if (!visited.has(item.toFieldId)) { - result.push(item); - dfs(item.toFieldId); - } - } - } - } - - // Run DFS for each specified fieldId - for (const fieldId of fieldIds) { - if (!visited.has(fieldId)) { - dfs(fieldId); - } - } - - return result; - } - - private async calculateRecordDataMap(recordDataMap: IRecordDataMap, fkOpMap: IFkOpMap) { - const cellChanges: ICellChange[] = []; - const allTableId2DbTableName: { [tableId: string]: string } = {}; - const allFieldMap: IFieldMap = {}; - for (const tableId in recordDataMap) { - const recordData = this.mergeDuplicateRecordData(recordDataMap[tableId]); - const calculateResult = await this.calculate(tableId, recordData, fkOpMap); - if (calculateResult) { - const { changes, fieldMap, tableId2DbTableName } = calculateResult; - Object.assign(allTableId2DbTableName, tableId2DbTableName); - Object.assign(allFieldMap, fieldMap); - cellChanges.push(...changes); - } - } - - return { - cellChanges, - fieldMap: allFieldMap, - tableId2DbTableName: allTableId2DbTableName, + startRecordIds, }; } @@ -523,21 +379,32 @@ export class ReferenceService { return lookupValues; } + private shouldSkipCompute(field: IFieldInstance, recordItem: IRecordItem) { + if (!field.isComputed && field.type !== FieldType.Link) { + return true; + } + + // skip calculate when direct set link cell by input (it has no dependencies) + if (field.type === FieldType.Link && !field.lookupOptions && !recordItem.dependencies) { + return true; + } + + if ((field.lookupOptions || field.type === FieldType.Link) && !recordItem.dependencies) { + // console.log('empty:field', field); + // console.log('empty:recordItem', JSON.stringify(recordItem, null, 2)); + return true; + } + return false; + } + private calculateComputeField( field: IFieldInstance, fieldMap: IFieldMap, - recordItem: IRecordItem, - fieldId2TableId: { [fieldId: string]: string }, - tableId2DbTableName: { [tableId: string]: string }, - fkRecordMap: IFkOpMap + recordItem: IRecordItem ) { const record = recordItem.record; - if (field.type === FieldType.Link || field.lookupOptions) { - if (!recordItem.dependencies) { - throw new Error( - `Dependency should not be undefined when contains a link/lookup/rollup field` - ); - } + + if (field.lookupOptions || field.type === FieldType.Link) { const lookupFieldId = field.lookupOptions ? field.lookupOptions.lookupFieldId : (field.options as ILinkFieldOptions).lookupFieldId; @@ -555,14 +422,7 @@ export class ReferenceService { const lookedField = fieldMap[lookupFieldId]; // nameConsole('calculateLookup:dependencies', recordItem.dependencies, fieldMap); - const lookupValues = this.calculateLookup( - field, - lookedField, - recordItem, - fieldId2TableId, - tableId2DbTableName, - fkRecordMap - ); + const lookupValues = this.calculateLookup(field, lookedField, recordItem); // console.log('calculateLookup:dependencies', recordItem.dependencies); // console.log('calculateLookup:lookupValues', lookupValues, recordItem); @@ -593,56 +453,63 @@ export class ReferenceService { } /** - * lookup values should filter by foreignKey === null - * because fkField is delete after calculation. - * checkout calculateOpsMap for detail logic. + * lookup values should filter by linkCellValue */ + // eslint-disable-next-line sonarjs/cognitive-complexity private calculateLookup( field: IFieldInstance, lookedField: IFieldInstance, - recordItem: IRecordItem, - fieldId2TableId: { [fieldId: string]: string }, - tableId2DbTableName: { [tableId: string]: string }, - fkRecordTableMap: IFkOpMap + recordItem: IRecordItem ) { const fieldId = lookedField.id; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - let dependencies = recordItem.dependencies!; + const dependencies = recordItem.dependencies!; + const lookupOptions = field.lookupOptions + ? field.lookupOptions + : (field.options as ILinkFieldOptions); + const { relationship } = lookupOptions; + const linkFieldId = field.lookupOptions ? field.lookupOptions.linkFieldId : field.id; + const cellValue = recordItem.record.fields[linkFieldId]; + + if (relationship === Relationship.OneMany || relationship === Relationship.ManyMany) { + if (!dependencies) { + return null; + } - const fkFieldId = Array.isArray(dependencies) ? lookedField.id : field.id; - const tableId = fieldId2TableId[fkFieldId]; - const dbTableName = tableId2DbTableName[tableId]; - const fkRecordMap = fkRecordTableMap[dbTableName]; - const fkFieldName = field.lookupOptions?.dbForeignKeyName || ''; + // console.log('dependencies', dependencies); + // console.log('linkCellValues', cellValue); - if (Array.isArray(dependencies)) { // sort lookup values by link cell order - const linkFieldId = field.lookupOptions ? field.lookupOptions.linkFieldId : field.id; - - const linkCellValues = recordItem.record.fields[linkFieldId] as ILinkCellValue[]; - const dependenciesIndexed = keyBy(dependencies, 'id'); - // when delete a link cell, the link cell value will be null + const linkCellValues = cellValue as ILinkCellValue[]; + // when reset a link cell, the link cell value will be null // but dependencies will still be there in the first round calculation if (linkCellValues) { - dependencies = linkCellValues.map((v) => { - return dependenciesIndexed[v.id]; - }); + return linkCellValues + .map((v) => { + return dependenciesIndexed[v.id]; + }) + .map((depRecord) => depRecord.fields[fieldId]); } - return dependencies - .filter( - (depRecord) => - fkRecordMap?.[depRecord.id]?.[fkFieldName] === undefined || - fkRecordMap?.[depRecord.id]?.[fkFieldName] === recordItem.record.id - ) - .map((depRecord) => depRecord.fields[fieldId]); + return null; } - if (fkRecordMap?.[recordItem.record.id]?.[fkFieldName] === null) { + if (relationship === Relationship.ManyOne || relationship === Relationship.OneOne) { + if (!dependencies) { + return null; + } + if (dependencies.length !== 1) { + throw new Error( + 'dependencies should have only 1 element when relationship is manyOne or oneOne' + ); + } + const linkCellValue = cellValue as ILinkCellValue; + if (linkCellValue) { + return dependencies[0].fields[fieldId] ?? null; + } return null; } - return dependencies.fields[fieldId] ?? null; } private calculateRollup( @@ -660,7 +527,7 @@ export class ReferenceService { const virtualField = createFieldInstanceByVo({ ...fieldVo, id: 'values', - isMultipleCellValue: fieldVo.isMultipleCellValue || relationship !== Relationship.ManyOne, + isMultipleCellValue: fieldVo.isMultipleCellValue || isMultiValueLink(relationship), }); if (field.type === FieldType.Rollup) { @@ -693,27 +560,13 @@ export class ReferenceService { } } - private async updateForeignKey(fkRecordMap: IFkOpMap) { - for (const dbTableName in fkRecordMap) { - for (const recordId in fkRecordMap[dbTableName]) { - const updateParam = fkRecordMap[dbTableName][recordId]; - const nativeSql = this.knex(dbTableName) - .update(updateParam) - .where('__id', recordId) - .toSQL() - .toNative(); - - await this.prismaService.txClient().$executeRawUnsafe(nativeSql.sql, ...nativeSql.bindings); - } - } - } - async createAuxiliaryData(allFieldIds: string[]) { const prisma = this.prismaService.txClient(); const fieldRaws = await prisma.field.findMany({ where: { id: { in: allFieldIds }, deletedTime: null }, }); + // if a field that has been looked up has changed, the link field should be retrieved as context const extraLinkFieldIds = difference( fieldRaws .filter((field) => field.lookupLinkedFieldId) @@ -761,9 +614,15 @@ export class ReferenceService { {} ); + const fieldId2DbTableName = fieldRaws.reduce<{ [fieldId: string]: string }>((pre, f) => { + pre[f.id] = tableId2DbTableName[f.tableId]; + return pre; + }, {}); + return { fieldMap, fieldId2TableId, + fieldId2DbTableName, dbTableName2fields, tableId2DbTableName, }; @@ -772,33 +631,29 @@ export class ReferenceService { collectChanges( orders: ITopoItemWithRecords[], fieldMap: IFieldMap, - fieldId2TableId: { [fieldId: string]: string }, - tableId2DbTableName: { [tableId: string]: string }, - fkRecordMap: IFkOpMap + fieldId2TableId: { [fieldId: string]: string } ) { // detail changes const changes: ICellChange[] = []; + // console.log('collectChanges:orders:', JSON.stringify(orders, null, 2)); orders.forEach((item) => { - item.recordItems.forEach((recordItem) => { + Object.values(item.recordItemMap || {}).forEach((recordItem) => { const field = fieldMap[item.id]; - // console.log('collectChanges:recordItems:', field, recordItem); - if (!field.isComputed && field.type !== FieldType.Link) { + const record = recordItem.record; + if (this.shouldSkipCompute(field, recordItem)) { return; } - const record = recordItem.record; - const value = this.calculateComputeField( - field, - fieldMap, - recordItem, - fieldId2TableId, - tableId2DbTableName, - fkRecordMap - ); - // console.log(`calculated: ${field.id}.${record.id}`, recordItem.record.fields, value); + + const value = this.calculateComputeField(field, fieldMap, recordItem); + // console.log( + // `calculated: ${field.type}.${field.id}.${record.id}`, + // recordItem.record.fields, + // value + // ); const oldValue = record.fields[field.id]; record.fields[field.id] = value; - if (oldValue !== value) { + if (oldValue != value) { changes.push({ tableId: fieldId2TableId[field.id], fieldId: field.id, @@ -812,10 +667,7 @@ export class ReferenceService { return changes; } - private recordRaw2Record( - fields: IFieldInstance[], - raw: { [dbFieldName: string]: unknown } & { __id: string } - ) { + private recordRaw2Record(fields: IFieldInstance[], raw: { [dbFieldName: string]: unknown }) { const fieldsData = fields.reduce<{ [fieldId: string]: unknown }>((acc, field) => { acc[field.id] = field.convertDBValue2CellValue(raw[field.dbFieldName] as string); return acc; @@ -823,77 +675,119 @@ export class ReferenceService { return { fields: fieldsData, - id: raw.__id, + id: raw.__id as string, recordOrder: {}, }; } getLinkOrderFromTopoOrders(params: { - fieldId2TableId: { [fieldId: string]: string }; - tableId2DbTableName: { [tableId: string]: string }; topoOrders: ITopoItem[]; fieldMap: IFieldMap; }): ITopoLinkOrder[] { const newOrder: ITopoLinkOrder[] = []; - const { tableId2DbTableName, fieldId2TableId, topoOrders, fieldMap } = params; + const { topoOrders, fieldMap } = params; + // one link fieldId only need to add once + const checkSet = new Set(); for (const item of topoOrders) { const field = fieldMap[item.id]; - const tableId = fieldId2TableId[field.id]; - const dbTableName = tableId2DbTableName[tableId]; if (field.lookupOptions) { - const { dbForeignKeyName, relationship, foreignTableId, linkFieldId } = field.lookupOptions; - const linkedTable = tableId2DbTableName[foreignTableId]; - + const { fkHostTableName, selfKeyName, foreignKeyName, relationship, linkFieldId } = + field.lookupOptions; + if (checkSet.has(linkFieldId)) { + continue; + } + checkSet.add(linkFieldId); newOrder.push({ - dbTableName, fieldId: linkFieldId, - foreignKeyField: dbForeignKeyName, - linkedTable, relationship, + fkHostTableName, + selfKeyName, + foreignKeyName, }); continue; } if (field.type === FieldType.Link) { - const { dbForeignKeyName, foreignTableId } = field.options; - const linkedTable = tableId2DbTableName[foreignTableId]; - + const { fkHostTableName, selfKeyName, foreignKeyName } = field.options; + if (checkSet.has(field.id)) { + continue; + } + checkSet.add(field.id); newOrder.push({ - dbTableName, fieldId: field.id, - foreignKeyField: dbForeignKeyName, - linkedTable, relationship: field.options.relationship, + fkHostTableName, + selfKeyName, + foreignKeyName, }); } } return newOrder; } - async getRecordsBatch(params: { - originRecordItems: { - dbTableName: string; - id: string; - fieldId?: string; - newValue?: unknown; - }[]; - dbTableName2fields: { [tableId: string]: IFieldInstance[] }; - affectedRecordItems: IRecordRefItem[]; - dependentRecordItems: IRecordRefItem[]; + async getRecordMapBatch(params: { + fieldMap: IFieldMap; + fieldId2DbTableName: Record; + dbTableName2fields: Record; + initialRecordIdMap?: { [dbTableName: string]: Set }; + modifiedRecords: IRecordData[]; + relatedRecordItems: IRelatedRecordItem[]; }) { - const { originRecordItems, affectedRecordItems, dependentRecordItems, dbTableName2fields } = - params; - const recordIdsByTableName = groupBy( - [...affectedRecordItems, ...dependentRecordItems, ...originRecordItems], - 'dbTableName' - ); + const { + fieldMap, + fieldId2DbTableName, + dbTableName2fields, + initialRecordIdMap, + modifiedRecords, + relatedRecordItems, + } = params; + const recordIdsByTableName = cloneDeep(initialRecordIdMap) || {}; + const insertId = (fieldId: string, id: string) => { + const dbTableName = fieldId2DbTableName[fieldId]; + if (!recordIdsByTableName[dbTableName]) { + recordIdsByTableName[dbTableName] = new Set(); + } + recordIdsByTableName[dbTableName].add(id); + }; + + modifiedRecords.forEach((item) => { + insertId(item.fieldId, item.id); + const field = fieldMap[item.fieldId]; + if (field.type !== FieldType.Link) { + return; + } + const lookupFieldId = field.options.lookupFieldId; + + const { newValue } = item; + [newValue] + .flat() + .filter(Boolean) + .map((item) => insertId(lookupFieldId, (item as ILinkCellValue).id)); + }); + + relatedRecordItems.forEach((item) => { + const field = fieldMap[item.fieldId]; + const options = field.lookupOptions ?? (field.options as ILinkFieldOptions); + + insertId(options.lookupFieldId, item.fromId); + insertId(item.fieldId, item.toId); + }); + const recordMap = await this.getRecordMap(recordIdsByTableName, dbTableName2fields); + this.coverRecordData(fieldId2DbTableName, modifiedRecords, recordMap); + return recordMap; + } + + async getRecordMap( + recordIdsByTableName: Record>, + dbTableName2fields: Record + ) { const results: { [dbTableName: string]: { [dbFieldName: string]: unknown }[]; } = {}; for (const dbTableName in recordIdsByTableName) { // deduplication is needed - const recordIds = uniq(recordIdsByTableName[dbTableName].map((r) => r.id)); + const recordIds = Array.from(recordIdsByTableName[dbTableName]); const dbFieldNames = dbTableName2fields[dbTableName] .map((f) => f.dbFieldName) .concat([...preservedFieldName]); @@ -907,99 +801,7 @@ export class ReferenceService { results[dbTableName] = result; } - const formattedResults = this.formatRecordQueryResult(results, dbTableName2fields); - - this.coverRecordData( - originRecordItems.filter((item) => item.fieldId) as { - dbTableName: string; - id: string; - fieldId: string; - newValue?: unknown; - }[], - formattedResults - ); - - return formattedResults; - } - - private getOneManyDependencies(params: { - linkFieldId: string; - record: ITinyRecord; - foreignTableRecords: ITinyRecord[]; - dependentRecordItems: IRecordRefItem[]; - }): ITinyRecord[] { - const { linkFieldId, dependentRecordItems, record, foreignTableRecords } = params; - const foreignTableRecordsIndexed = keyBy(foreignTableRecords, 'id'); - return dependentRecordItems - .filter((item) => item.relationTo === record.id && item.fieldId === linkFieldId) - .map((item) => { - const record = foreignTableRecordsIndexed[item.id]; - if (!record) { - throw new Error('Can not find link record'); - } - return record; - }); - } - - private getMany2OneDependency(params: { - record: ITinyRecord; - foreignTableRecords: ITinyRecord[]; - affectedRecordItems: IRecordRefItem[]; - }): ITinyRecord { - const { record, affectedRecordItems, foreignTableRecords } = params; - const linkRecordRef = affectedRecordItems - .filter((item) => item.relationTo) - .find((item) => item.id === record.id); - if (!linkRecordRef) { - throw new Error('Can not find link record ref'); - } - - const linkRecord = foreignTableRecords.find((r) => r.id === linkRecordRef.relationTo); - if (!linkRecord) { - throw new Error('Can not find link record'); - } - return linkRecord; - } - - private getDependencyRecordItems(params: { - linkFieldId: string; - relationship: Relationship; - records: ITinyRecord[]; - foreignTableRecords: ITinyRecord[]; - affectedRecordItems: IRecordRefItem[]; - dependentRecordItems: IRecordRefItem[]; - }) { - const { - linkFieldId, - records, - relationship, - foreignTableRecords, - dependentRecordItems, - affectedRecordItems, - } = params; - const dependenciesArr = records.map((record) => { - if (relationship === Relationship.OneMany) { - return this.getOneManyDependencies({ - record, - linkFieldId: linkFieldId, - foreignTableRecords, - dependentRecordItems, - }); - } - if (relationship === Relationship.ManyOne) { - return this.getMany2OneDependency({ - record, - foreignTableRecords, - affectedRecordItems, - }); - } - throw new BadRequestException('Unsupported relationship'); - }); - return records - .map((record, i) => ({ record, dependencies: dependenciesArr[i] })) - .filter((item) => - Array.isArray(item.dependencies) ? item.dependencies.length : item.dependencies - ); + return this.formatRecordQueryResult(results, dbTableName2fields); } createTopoItemWithRecords(params: { @@ -1007,107 +809,87 @@ export class ReferenceService { tableId2DbTableName: { [tableId: string]: string }; fieldId2TableId: { [fieldId: string]: string }; fieldMap: IFieldMap; - dbTableName2records: { [tableName: string]: ITinyRecord[] }; - affectedRecordItems: IRecordRefItem[]; - dependentRecordItems: IRecordRefItem[]; + dbTableName2recordMap: { [tableName: string]: IRecordMap }; + relatedRecordItemsIndexed: Record; }): ITopoItemWithRecords[] { const { topoOrders, fieldMap, tableId2DbTableName, fieldId2TableId, - dbTableName2records, - affectedRecordItems, - dependentRecordItems, + dbTableName2recordMap, + relatedRecordItemsIndexed, } = params; - const affectedRecordItemIndexed = groupBy(affectedRecordItems, 'dbTableName'); - const dependentRecordItemIndexed = groupBy(dependentRecordItems, 'dbTableName'); - return topoOrders.reduce((pre, order) => { + return topoOrders.map((order) => { const field = fieldMap[order.id]; + const fieldId = field.id; const tableId = fieldId2TableId[order.id]; const dbTableName = tableId2DbTableName[tableId]; - const allRecords = dbTableName2records[dbTableName]; - const affectedRecordItems = affectedRecordItemIndexed[dbTableName]; - // only affected record need to be calculated - const records = intersectionBy(allRecords, affectedRecordItems, 'id'); - - const appendRecordItems = ( - foreignTableId: string, - linkFieldId: string, - relationship: Relationship - ) => { - const foreignTableName = tableId2DbTableName[foreignTableId]; - const foreignTableRecords = dbTableName2records[foreignTableName]; - const dependentRecordItems = dependentRecordItemIndexed[foreignTableName]; - return { - ...order, - recordItems: this.getDependencyRecordItems({ - linkFieldId, - relationship, - records, - foreignTableRecords, - affectedRecordItems, - dependentRecordItems, - }), - }; - }; - - // update cross table dependency (from lookup or link field) - if (field.lookupOptions) { - if ( - !affectedRecordItems?.find((item) => item.fieldId === field.lookupOptions?.linkFieldId) - ) { - return pre; - } - const { foreignTableId, linkFieldId, relationship } = field.lookupOptions; - pre.push(appendRecordItems(foreignTableId, linkFieldId, relationship)); - return pre; - } + const recordMap = dbTableName2recordMap[dbTableName]; + const relatedItems = relatedRecordItemsIndexed[fieldId]; - if (field.type === FieldType.Link) { - if (!affectedRecordItems?.find((item) => item.fieldId === field.id)) { - return pre; - } - const { foreignTableId, relationship } = field.options; - pre.push(appendRecordItems(foreignTableId, field.id, relationship)); - return pre; - } - - pre.push({ + // console.log('withRecord:order', JSON.stringify(order, null, 2)); + // console.log('withRecord:relatedItems', relatedItems); + return { ...order, - recordItems: records.map((record) => ({ record })), - }); - return pre; - }, []); + recordItemMap: + recordMap && + Object.values(recordMap).reduce>((pre, record) => { + let dependencies: ITinyRecord[] | undefined; + if (relatedItems) { + const options = field.lookupOptions + ? field.lookupOptions + : (field.options as ILinkFieldOptions); + const foreignTableId = options.foreignTableId; + const foreignDbTableName = tableId2DbTableName[foreignTableId]; + const foreignRecordMap = dbTableName2recordMap[foreignDbTableName]; + const dependentRecordIdsIndexed = groupBy(relatedItems, 'toId'); + const dependentRecordIds = dependentRecordIdsIndexed[record.id]; + if (dependentRecordIds) { + dependencies = dependentRecordIds.map((item) => foreignRecordMap[item.fromId]); + } + } + + if (dependencies) { + pre[record.id] = { record, dependencies }; + } else { + pre[record.id] = { record }; + } + + return pre; + }, {}), + }; + }); } formatRecordQueryResult( formattedResults: { - [tableName: string]: { [dbFiendName: string]: unknown }[]; + [tableName: string]: { [dbFieldName: string]: unknown }[]; }, dbTableName2fields: { [tableId: string]: IFieldInstance[] } ) { return Object.entries(formattedResults).reduce<{ - [dbTableName: string]: ITinyRecord[]; - }>((acc, e) => { - const [dbTableName, recordMap] = e; + [dbTableName: string]: IRecordMap; + }>((acc, [dbTableName, records]) => { const fields = dbTableName2fields[dbTableName]; - acc[dbTableName] = recordMap.map((r) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.recordRaw2Record(fields, r as any); - }); + acc[dbTableName] = records.reduce((pre, recordRaw) => { + const record = this.recordRaw2Record(fields, recordRaw); + pre[record.id] = record; + return pre; + }, {}); return acc; }, {}); } // use modified record data to cover the record data from db private coverRecordData( - newRecordData: { id: string; dbTableName: string; fieldId: string; newValue?: unknown }[], - allRecordByDbTableName: { [tableName: string]: ITinyRecord[] } + fieldId2DbTableName: Record, + newRecordData: IRecordData[], + allRecordByDbTableName: { [tableName: string]: IRecordMap } ) { newRecordData.forEach((cover) => { - const records = allRecordByDbTableName[cover.dbTableName]; - const record = records.find((r) => r.id === cover.id); + const dbTableName = fieldId2DbTableName[cover.fieldId]; + const record = allRecordByDbTableName[dbTableName][cover.id]; if (!record) { throw new BadRequestException(`Can not find record: ${cover.id} in database`); } @@ -1115,63 +897,15 @@ export class ReferenceService { }); } - /** - * Generate a topological order based on the starting node ID. - * - * @param startNodeId - The ID to start the search from. - * @param graph - The input graph. - * @returns An array of ITopoItem representing the topological order. - */ - private getTopologicalOrder( - startNodeId: string, - graph: { toFieldId: string; fromFieldId: string }[] - ): ITopoItem[] { - const visitedNodes = new Set(); - const sortedNodes: ITopoItem[] = []; - - // Build adjacency list and reverse adjacency list - const adjList: Record = {}; - const reverseAdjList: Record = {}; - for (const edge of graph) { - if (!adjList[edge.fromFieldId]) adjList[edge.fromFieldId] = []; - adjList[edge.fromFieldId].push(edge.toFieldId); - - if (!reverseAdjList[edge.toFieldId]) reverseAdjList[edge.toFieldId] = []; - reverseAdjList[edge.toFieldId].push(edge.fromFieldId); - } - - function visit(node: string) { - if (!visitedNodes.has(node)) { - visitedNodes.add(node); - - // Get incoming edges (dependencies) - const dependencies = reverseAdjList[node] || []; - - // Process outgoing edges - if (adjList[node]) { - for (const neighbor of adjList[node]) { - visit(neighbor); - } - } - - sortedNodes.push({ id: node, dependencies: dependencies }); - } - } - - visit(startNodeId); - return sortedNodes.reverse(); - } - - async getDependentNodesCTE(startFieldIds: string[]): Promise { - let result: { fromFieldId: string; toFieldId: string }[] = []; - const getResult = async (startFieldId: string) => { + async getFieldGraphItems(startFieldIds: string[]): Promise { + const getResult = async (startFieldIds: string[]) => { const _knex = this.knex; const nonRecursiveQuery = _knex .select('from_field_id', 'to_field_id') .from('reference') - .where({ from_field_id: startFieldId }) - .orWhere({ to_field_id: startFieldId }); + .whereIn('from_field_id', startFieldIds) + .orWhereIn('to_field_id', startFieldIds); const recursiveQuery = _knex .select('deps.from_field_id', 'deps.to_field_id') .from('reference as deps') @@ -1198,30 +932,23 @@ export class ReferenceService { const finalQuery = this.knex .withRecursive('connected_reference', ['from_field_id', 'to_field_id'], cteQuery) .distinct('from_field_id', 'to_field_id') - .from('connected_reference'); - - // this.logger.log('getDependentNodesCTE Sql: %s', finalQuery.toQuery()); + .from('connected_reference') + .toQuery(); - const sqlNative = finalQuery.toSQL().toNative(); return ( this.prismaService .txClient() // eslint-disable-next-line @typescript-eslint/naming-convention - .$queryRawUnsafe<{ from_field_id: string; to_field_id: string }[]>( - sqlNative.sql, - ...sqlNative.bindings - ) + .$queryRawUnsafe<{ from_field_id: string; to_field_id: string }[]>(finalQuery) ); }; - for (const fieldId of startFieldIds) { - const queryResult = await getResult(fieldId); - result = result.concat( - queryResult.map((row) => ({ fromFieldId: row.from_field_id, toFieldId: row.to_field_id })) - ); - } + const queryResult = await getResult(startFieldIds); - return this.filterDirectedGraph(result, startFieldIds); + return filterDirectedGraph( + queryResult.map((row) => ({ fromFieldId: row.from_field_id, toFieldId: row.to_field_id })), + startFieldIds + ); } private mergeDuplicateRecordData(recordData: IRecordData[]) { @@ -1245,76 +972,181 @@ export class ReferenceService { * example: C = A + B * A changed, C will be affected and B is the dependent record */ - async getDependentRecordItems(recordItems: IRecordRefItem[]): Promise { - if (!recordItems.length) { - return []; - } + async getDependentRecordItems( + fieldMap: IFieldMap, + recordItems: IRelatedRecordItem[] + ): Promise { + const indexRecordItems = groupBy(recordItems, 'fieldId'); + + const queries = Object.entries(indexRecordItems) + .filter(([fieldId]) => { + const options = + fieldMap[fieldId].lookupOptions || (fieldMap[fieldId].options as ILinkFieldOptions); + const relationship = options.relationship; + return relationship === Relationship.ManyMany || relationship === Relationship.OneMany; + }) + .map(([fieldId, recordItem]) => { + const options = + fieldMap[fieldId].lookupOptions || (fieldMap[fieldId].options as ILinkFieldOptions); + const { fkHostTableName, selfKeyName, foreignKeyName } = options; + const ids = recordItem.map((item) => item.toId); - const queries = recordItems - .filter((item) => item.selectIn) - .map((item) => { - const { id, fieldId, selectIn } = item; - const [dbTableName, selectField] = (selectIn as string)!.split('#'); return this.knex .select({ - id: '__id', - relationTo: selectField, - dbTableName: this.knex.raw('?', dbTableName), - fieldId: this.knex.raw('?', fieldId ?? null), + fieldId: this.knex.raw('?', fieldId), + toId: selfKeyName, + fromId: foreignKeyName, }) - .from(dbTableName) - .where(selectField, id); + .from(fkHostTableName) + .whereIn(selfKeyName, ids); }); + if (!queries.length) { return []; } const [firstQuery, ...restQueries] = queries; - const nativeSql = firstQuery.union(restQueries).toSQL().toNative(); - return this.prismaService - .txClient() - .$queryRawUnsafe(nativeSql.sql, ...nativeSql.bindings); + const sqlQuery = firstQuery.unionAll(restQueries).toQuery(); + return this.prismaService.txClient().$queryRawUnsafe(sqlQuery); } - async getAffectedRecordItems( - topoOrder: ITopoLinkOrder[], - originRecordIdItems: { dbTableName: string; id: string }[] - ): Promise { - if (!topoOrder.length) { - return originRecordIdItems; + affectedRecordItemsQuerySql( + startFieldIds: string[], + fieldMap: IFieldMap, + linkAdjacencyMap: IAdjacencyMap, + startRecordIds: string[] + ): string { + const visited = new Set(); + const knex = this.knex; + const query = knex.queryBuilder(); + + function visit(node: string, preNode: string) { + if (visited.has(node)) { + return; + } + + visited.add(node); + const options = fieldMap[node].lookupOptions || (fieldMap[node].options as ILinkFieldOptions); + const { fkHostTableName, selfKeyName, foreignKeyName } = options; + + query.with( + node, + knex + .distinct({ + toId: `${fkHostTableName}.${selfKeyName}`, + fromId: `${preNode}.toId`, + }) + .from(fkHostTableName) + .whereNotNull(`${fkHostTableName}.${selfKeyName}`) // toId + .join(preNode, `${preNode}.toId`, '=', `${fkHostTableName}.${foreignKeyName}`) + ); + const nextNodes = linkAdjacencyMap[node]; + // Process outgoing edges + if (nextNodes) { + for (const neighbor of nextNodes) { + visit(neighbor, node); + } + } } - const affectedRecordItemsQuerySql = this.dbProvider.affectedRecordItemsQuerySql( - topoOrder, - originRecordIdItems + startFieldIds.forEach((fieldId) => { + const field = fieldMap[fieldId]; + if (field.lookupOptions || field.type === FieldType.Link) { + const options = field.lookupOptions || (field.options as ILinkFieldOptions); + const { fkHostTableName, selfKeyName, foreignKeyName } = options; + if (visited.has(fieldId)) { + return; + } + visited.add(fieldId); + query.with( + fieldId, + knex + .distinct({ + toId: `${fkHostTableName}.${selfKeyName}`, + fromId: `${fkHostTableName}.${foreignKeyName}`, + }) + .from(fkHostTableName) + .whereIn(`${fkHostTableName}.${selfKeyName}`, startRecordIds) + .whereNotNull(`${fkHostTableName}.${foreignKeyName}`) + ); + } else { + query.with( + fieldId, + knex.unionAll( + startRecordIds.map((id) => + knex.select({ toId: knex.raw('?', id), fromId: knex.raw('?', null) }) + ) + ) + ); + } + const nextNodes = linkAdjacencyMap[fieldId]; + + // start visit + if (nextNodes) { + for (const neighbor of nextNodes) { + visit(neighbor, fieldId); + } + } + }); + + // union all result + query.unionAll( + Array.from(visited).map((fieldId) => + knex + .select({ + fieldId: knex.raw('?', fieldId), + fromId: knex.ref(`${fieldId}.fromId`), + toId: knex.ref(`${fieldId}.toId`), + }) + .from(fieldId) + ) ); - const results = await this.prismaService.txClient().$queryRawUnsafe< - { - __id: string; - dbTableName: string; - selectIn?: string; - fieldId?: string; - relationTo?: string; - }[] - >(affectedRecordItemsQuerySql); + return query.toQuery(); + } + + async getAffectedRecordItems( + startFieldIds: string[], + fieldMap: IFieldMap, + linkAdjacencyMap: IAdjacencyMap, + startRecordIds: string[] + ): Promise { + const affectedRecordItemsQuerySql = this.affectedRecordItemsQuerySql( + startFieldIds, + fieldMap, + linkAdjacencyMap, + startRecordIds + ); - // this.logger.log({ affectedRecordItemsResult: results }); + return await this.prismaService + .txClient() + .$queryRawUnsafe(affectedRecordItemsQuerySql); + } - if (!results.length) { - return originRecordIdItems; + async getRelatedItems( + startFieldIds: string[], + fieldMap: IFieldMap, + linkAdjacencyMap: IAdjacencyMap, + startRecordIds: string[] + ) { + if (isEmpty(startRecordIds) || isEmpty(linkAdjacencyMap)) { + return []; } + const effectedItems = await this.getAffectedRecordItems( + startFieldIds, + fieldMap, + linkAdjacencyMap, + startRecordIds + ); - // only need to return result with relationTo or selectIn - return results - .filter((record) => record.__id) - .map((record) => ({ - id: record.__id, - dbTableName: record.dbTableName, - ...(record.relationTo ? { relationTo: record.relationTo } : {}), - ...(record.fieldId ? { fieldId: record.fieldId } : {}), - ...(record.selectIn ? { selectIn: record.selectIn } : {}), - })); + const dependentItems = await this.getDependentRecordItems(fieldMap, effectedItems); + + return unionWith( + effectedItems, + dependentItems, + (left, right) => + left.toId === right.toId && left.fromId === right.fromId && left.fieldId === right.fieldId + ); } flatGraph(graph: { toFieldId: string; fromFieldId: string }[]) { diff --git a/apps/nestjs-backend/src/features/calculation/utils/compose-maps.spec.ts b/apps/nestjs-backend/src/features/calculation/utils/compose-maps.spec.ts index eb078acb3..8808f994a 100644 --- a/apps/nestjs-backend/src/features/calculation/utils/compose-maps.spec.ts +++ b/apps/nestjs-backend/src/features/calculation/utils/compose-maps.spec.ts @@ -1,96 +1,102 @@ import { composeMaps } from './compose-maps'; - describe('composeMaps', () => { - it('should correctly merge maps with overlapping keys', () => { - const opsMaps: { [x: string]: { [y: string]: string[] } }[] = [ - { - table1: { - record1: ['A'], - record2: ['B'], - }, - }, - { - table1: { - record2: ['C'], - record3: ['D'], - }, - }, - ]; + it('should return an empty object when no maps are provided', () => { + expect(composeMaps([])).toEqual({}); + }); - const expected = { + it('should return the same map if only one map is provided', () => { + const singleMap = { table1: { - record1: ['A'], - record2: ['B', 'C'], - record3: ['D'], + record1: [{ p: [1, 2], otherProps: 'value' }], }, }; - - expect(composeMaps(opsMaps)).toEqual(expected); + expect(composeMaps([singleMap])).toEqual(singleMap); }); - it('should correctly merge maps without overlapping keys', () => { - const opsMaps: { [x: string]: { [y: string]: string[] } }[] = [ - { - table1: { - record1: ['A'], - record2: ['B'], - }, + it('should merge maps without overlapping keys correctly', () => { + const map1 = { + table1: { + record1: [{ p: [1], value: 'a' }], }, - { - table2: { - record3: ['C'], - record4: ['D'], - }, + }; + const map2 = { + table2: { + record2: [{ p: [2], value: 'b' }], }, - ]; - + }; const expected = { table1: { - record1: ['A'], - record2: ['B'], + record1: [{ p: [1], value: 'a' }], }, table2: { - record3: ['C'], - record4: ['D'], + record2: [{ p: [2], value: 'b' }], }, }; - - expect(composeMaps(opsMaps)).toEqual(expected); + expect(composeMaps([map1, map2])).toEqual(expected); }); - it('should correctly handle empty input', () => { - const opsMaps = [undefined, undefined, undefined]; - - const expected = {}; - - expect(composeMaps(opsMaps)).toEqual(expected); + it('should overwrite operations with the same "p" value in the same record', () => { + const map1 = { + table1: { + record1: [{ p: [1], value: 'a' }], + }, + }; + const map2 = { + table1: { + record1: [{ p: [1], value: 'b' }], + }, + }; + const expected = { + table1: { + record1: [{ p: [1], value: 'b' }], + }, + }; + expect(composeMaps([map1, map2])).toEqual(expected); }); - it('should correctly handle input with both defined and undefined maps', () => { - const opsMaps: ({ [x: string]: { [y: string]: string[] } } | undefined)[] = [ - { - table1: { - record1: ['A'], - record2: ['B'], - }, + it('should overwrite 3 operations with the same "p" value in the same record', () => { + const map1 = { + table1: { + record1: [{ p: [1], value: 'a' }], }, - undefined, - { - table1: { - record2: ['C'], - record3: ['D'], - }, + }; + const map2 = { + table1: { + record1: [{ p: [1], value: 'b' }], }, - ]; - + }; + const map3 = { + table1: { + record1: [{ p: [1], value: 'c' }], + }, + }; const expected = { table1: { - record1: ['A'], - record2: ['B', 'C'], - record3: ['D'], + record1: [{ p: [1], value: 'c' }], }, }; + expect(composeMaps([map1, map2, map3])).toEqual(expected); + }); - expect(composeMaps(opsMaps)).toEqual(expected); + it('should append operations with different "p" values in the same record', () => { + const map1 = { + table1: { + record1: [{ p: [1], value: 'a' }], + }, + }; + const map2 = { + table1: { + record1: [{ p: [2], value: 'b' }], + }, + }; + const expected = { + table1: { + record1: [ + { p: [1], value: 'a' }, + { p: [2], value: 'b' }, + ], + }, + }; + expect(composeMaps([map1, map2])).toEqual(expected); }); }); diff --git a/apps/nestjs-backend/src/features/calculation/utils/compose-maps.ts b/apps/nestjs-backend/src/features/calculation/utils/compose-maps.ts index cf51537a4..f45ebbf35 100644 --- a/apps/nestjs-backend/src/features/calculation/utils/compose-maps.ts +++ b/apps/nestjs-backend/src/features/calculation/utils/compose-maps.ts @@ -1,24 +1,40 @@ -export function composeMaps(opsMaps: ({ [x: string]: { [y: string]: T[] } } | undefined)[]): { +import sharedb from 'sharedb'; + +export function composeMaps( + opsMaps: ({ [x: string]: { [y: string]: T[] } } | undefined)[] +): { [x: string]: { [y: string]: T[] }; } { - return opsMaps + return (opsMaps as { [x: string]: { [y: string]: T[] } }[]) .filter(Boolean) .reduce<{ [x: string]: { [y: string]: T[] } }>((composedMap, currentMap) => { - for (const tableId in currentMap) { - if (composedMap[tableId]) { - for (const recordId in currentMap[tableId]) { - if (composedMap[tableId][recordId]) { - composedMap[tableId][recordId] = composedMap[tableId][recordId].concat( - currentMap[tableId][recordId] + Object.keys(currentMap).forEach((tableId) => { + composedMap[tableId] = composedMap[tableId] || {}; + + Object.keys(currentMap[tableId]).forEach((recordId) => { + composedMap[tableId][recordId] = composedMap[tableId][recordId] || []; + + const opIndexObj: Record = {}; + composedMap[tableId][recordId].forEach((op, index) => { + opIndexObj[op.p.join()] = index; + }); + + currentMap[tableId][recordId].forEach((op) => { + const opKey = op.p.join(); + const existingOpIndex = opIndexObj[opKey]; + if (existingOpIndex !== undefined) { + const oldOp = composedMap[tableId][recordId][existingOpIndex]; + composedMap[tableId][recordId][existingOpIndex] = sharedb.types.map['json0'].compose( + op, + oldOp ); } else { - composedMap[tableId][recordId] = currentMap[tableId][recordId]; + opIndexObj[opKey] = composedMap[tableId][recordId].length; + composedMap[tableId][recordId].push(op); } - } - } else { - composedMap[tableId] = currentMap[tableId]; - } - } + }); + }); + }); return composedMap; }, {}); } diff --git a/apps/nestjs-backend/src/features/calculation/utils/dfs.spec.ts b/apps/nestjs-backend/src/features/calculation/utils/dfs.spec.ts new file mode 100644 index 000000000..02448a6f6 --- /dev/null +++ b/apps/nestjs-backend/src/features/calculation/utils/dfs.spec.ts @@ -0,0 +1,85 @@ +import { buildAdjacencyMap, buildCompressedAdjacencyMap } from './dfs'; + +describe('Graph Processing Functions', () => { + describe('buildAdjacencyMap', () => { + it('should create an adjacency map from a graph', () => { + const graph = [ + { fromFieldId: 'a', toFieldId: 'b' }, + { fromFieldId: 'b', toFieldId: 'c' }, + ]; + const expected = { + a: ['b'], + b: ['c'], + }; + expect(buildAdjacencyMap(graph)).toEqual(expected); + }); + + it('should handle graphs with multiple edges from a single node', () => { + const graph = [ + { fromFieldId: 'a', toFieldId: 'b' }, + { fromFieldId: 'a', toFieldId: 'c' }, + ]; + const expected = { + a: ['b', 'c'], + }; + expect(buildAdjacencyMap(graph)).toEqual(expected); + }); + + it('should return an empty object for an empty graph', () => { + expect(buildAdjacencyMap([])).toEqual({}); + }); + }); + + describe('buildCompressedAdjacencyMap', () => { + it('should compress a graph based on linkIdSet', () => { + const graph = [ + { fromFieldId: 'id1', toFieldId: 'id2' }, + { fromFieldId: 'id2', toFieldId: 'id3' }, + { fromFieldId: 'id2', toFieldId: 'id4' }, + { fromFieldId: 'id3', toFieldId: 'id5' }, + ]; + const linkIdSet = new Set(['id2', 'id4', 'id5']); + const expected = { + id1: ['id2'], + id2: ['id5', 'id4'], + id3: ['id5'], + }; + expect(buildCompressedAdjacencyMap(graph, linkIdSet)).toEqual(expected); + }); + + it('should handle empty linkIdSet', () => { + const graph = [ + { fromFieldId: 'id1', toFieldId: 'id2' }, + { fromFieldId: 'id2', toFieldId: 'id3' }, + ]; + expect(buildCompressedAdjacencyMap(graph, new Set())).toEqual({}); + }); + + it('should handle graphs with no valid paths', () => { + const graph = [ + { fromFieldId: 'id1', toFieldId: 'id2' }, + { fromFieldId: 'id2', toFieldId: 'id3' }, + ]; + const linkIdSet = new Set(['id4']); + expect(buildCompressedAdjacencyMap(graph, linkIdSet)).toEqual({}); + }); + }); + + describe('buildCompressedAdjacencyMap with unordered graph', () => { + it('should handle graphs with unordered edges', () => { + const graph = [ + { fromFieldId: 'id3', toFieldId: 'id5' }, + { fromFieldId: 'id1', toFieldId: 'id2' }, + { fromFieldId: 'id2', toFieldId: 'id4' }, + { fromFieldId: 'id2', toFieldId: 'id3' }, + ]; + const linkIdSet = new Set(['id2', 'id4', 'id5']); + const expected = { + id1: ['id2'], + id2: ['id4', 'id5'], + id3: ['id5'], + }; + expect(buildCompressedAdjacencyMap(graph, linkIdSet)).toEqual(expected); + }); + }); +}); diff --git a/apps/nestjs-backend/src/features/calculation/utils/dfs.ts b/apps/nestjs-backend/src/features/calculation/utils/dfs.ts new file mode 100644 index 000000000..f20f84929 --- /dev/null +++ b/apps/nestjs-backend/src/features/calculation/utils/dfs.ts @@ -0,0 +1,213 @@ +import { uniq } from 'lodash'; + +// topo item is for field level reference, all id stands for fieldId; +export interface ITopoItem { + id: string; + dependencies: string[]; +} + +export interface IGraphItem { + fromFieldId: string; + toFieldId: string; +} + +export type IAdjacencyMap = Record; + +export function buildAdjacencyMap(graph: IGraphItem[]): IAdjacencyMap { + const adjList: IAdjacencyMap = {}; + graph.forEach((edge) => { + if (!adjList[edge.fromFieldId]) { + adjList[edge.fromFieldId] = []; + } + adjList[edge.fromFieldId].push(edge.toFieldId); + }); + return adjList; +} + +export function findNextValidNode( + current: string, + adjMap: IAdjacencyMap, + linkIdSet: Set +): string | null { + if (linkIdSet.has(current)) { + return current; + } + + const neighbors = adjMap[current]; + if (!neighbors) { + return null; + } + + for (const neighbor of neighbors) { + const validNode = findNextValidNode(neighbor, adjMap, linkIdSet); + if (validNode) { + return validNode; + } + } + + return null; +} + +/** + * Builds a compressed adjacency map based on the provided graph, linkIdSet, and startFieldIds. + * The compressed adjacency map represents the neighbors of each node in the graph, excluding nodes that are not valid according to the linkIdSet. + * + * @param graph - The graph containing the nodes and their connections. + * @param linkIdSet - A set of valid link IDs. + * @returns The compressed adjacency map representing the neighbors of each node. + */ +export function buildCompressedAdjacencyMap( + graph: IGraphItem[], + linkIdSet: Set +): IAdjacencyMap { + const adjMap = buildAdjacencyMap(graph); + const compressedAdjMap: IAdjacencyMap = {}; + + // eslint-disable-next-line sonarjs/cognitive-complexity + Object.keys(adjMap).forEach((from) => { + const queue = [from]; + const visited = new Set(); + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || visited.has(current)) continue; + + visited.add(current); + const neighbors = adjMap[current] || []; + const compressedNeighbors = []; + + for (const neighbor of neighbors) { + const nextValid = findNextValidNode(neighbor, adjMap, linkIdSet); + if (nextValid && !visited.has(nextValid)) { + compressedNeighbors.push(nextValid); + if (!linkIdSet.has(current)) { + queue.push(nextValid); + } + } + } + + if (compressedNeighbors.length > 0) { + compressedAdjMap[current] = compressedNeighbors; + } + } + }); + + return compressedAdjMap; +} + +/** + * Generate a topological order based on the starting node ID. + * + * @param startNodeId - The ID to start the search from. + * @param graph - The input graph. + * @returns An array of ITopoItem representing the topological order. + */ +export function getTopologicalOrder( + startNodeId: string, + graph: { toFieldId: string; fromFieldId: string }[] +): ITopoItem[] { + const visitedNodes = new Set(); + const sortedNodes: ITopoItem[] = []; + + // Build adjacency list and reverse adjacency list + const adjList: Record = {}; + const reverseAdjList: Record = {}; + for (const edge of graph) { + if (!adjList[edge.fromFieldId]) adjList[edge.fromFieldId] = []; + adjList[edge.fromFieldId].push(edge.toFieldId); + + if (!reverseAdjList[edge.toFieldId]) reverseAdjList[edge.toFieldId] = []; + reverseAdjList[edge.toFieldId].push(edge.fromFieldId); + } + + function visit(node: string) { + if (!visitedNodes.has(node)) { + visitedNodes.add(node); + + // Get incoming edges (dependencies) + const dependencies = reverseAdjList[node] || []; + + // Process outgoing edges + if (adjList[node]) { + for (const neighbor of adjList[node]) { + visit(neighbor); + } + } + + sortedNodes.push({ id: node, dependencies: dependencies }); + } + } + + visit(startNodeId); + return sortedNodes.reverse().map((node) => ({ + id: node.id, + dependencies: uniq(node.dependencies), + })); +} + +/** + * Returns all relations related to the given fieldIds. + */ +// eslint-disable-next-line sonarjs/cognitive-complexity +export function filterDirectedGraph( + undirectedGraph: IGraphItem[], + fieldIds: string[] +): IGraphItem[] { + const result: IGraphItem[] = []; + const visited: Set = new Set(); + const addedEdges: Set = new Set(); // 新增:用于存储已添加的边 + + // Build adjacency lists for quick look-up + const outgoingAdjList: Record = {}; + const incomingAdjList: Record = {}; + + function addEdgeIfNotExists(edge: IGraphItem) { + const edgeKey = edge.fromFieldId + '-' + edge.toFieldId; + if (!addedEdges.has(edgeKey)) { + addedEdges.add(edgeKey); + result.push(edge); + } + } + + for (const item of undirectedGraph) { + // Outgoing edges + if (!outgoingAdjList[item.fromFieldId]) { + outgoingAdjList[item.fromFieldId] = []; + } + outgoingAdjList[item.fromFieldId].push(item); + + // Incoming edges + if (!incomingAdjList[item.toFieldId]) { + incomingAdjList[item.toFieldId] = []; + } + incomingAdjList[item.toFieldId].push(item); + } + + function dfs(currentNode: string) { + visited.add(currentNode); + + // Add incoming edges related to currentNode + if (incomingAdjList[currentNode]) { + incomingAdjList[currentNode].forEach((edge) => addEdgeIfNotExists(edge)); + } + + // Process outgoing edges from currentNode + if (outgoingAdjList[currentNode]) { + outgoingAdjList[currentNode].forEach((item) => { + if (!visited.has(item.toFieldId)) { + addEdgeIfNotExists(item); + dfs(item.toFieldId); + } + }); + } + } + + // Run DFS for each specified fieldId + for (const fieldId of fieldIds) { + if (!visited.has(fieldId)) { + dfs(fieldId); + } + } + + return result; +} 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 1b7583527..05446478b 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 @@ -6,6 +6,7 @@ import { FieldType, generateRecordId, RecordOpBuilder, + isMultiValueLink, } from '@teable-group/core'; import { PrismaService } from '@teable-group/db-main-prisma'; import { isEmpty, isEqual } from 'lodash'; @@ -32,22 +33,56 @@ export class FieldConvertingLinkService { private readonly fieldCalculationService: FieldCalculationService ) {} - private async generateSymmetricFieldChange(linkField: LinkFieldDto) { + private async generateSymmetricFieldChange( + tableId: string, + oldField: LinkFieldDto, + newField: LinkFieldDto + ) { + // noting change + if (!newField.options.symmetricFieldId && !oldField.options.symmetricFieldId) { + return; + } + + // delete old symmetric link + if (oldField.options.symmetricFieldId !== newField.options.symmetricFieldId) { + if (oldField.options.symmetricFieldId) { + await this.fieldDeletingService.delateAndCleanRef( + oldField.options.foreignTableId, + oldField.options.symmetricFieldId + ); + } + if (newField.options.symmetricFieldId) { + const symmetricField = await this.fieldSupplementService.generateSymmetricField( + tableId, + newField + ); + await this.fieldCreatingService.createAndCalculate( + newField.options.foreignTableId, + symmetricField + ); + } + return; + } + + // field options has been modified but symmetricFieldId not change const fieldRaw = await this.prismaService.txClient().field.findFirstOrThrow({ - where: { id: linkField.options.symmetricFieldId, deletedTime: null }, + where: { id: newField.options.symmetricFieldId, deletedTime: null }, }); - const oldField = createFieldInstanceByRaw(fieldRaw); + const newFieldVo = rawField2FieldObj(fieldRaw); const options = newFieldVo.options as ILinkFieldOptions; - options.relationship = RelationshipRevert[linkField.options.relationship]; - options.dbForeignKeyName = linkField.options.dbForeignKeyName; - newFieldVo.isMultipleCellValue = options.relationship !== Relationship.ManyOne || undefined; + options.relationship = RelationshipRevert[newField.options.relationship]; + options.fkHostTableName = newField.options.fkHostTableName; + options.selfKeyName = newField.options.foreignKeyName; + options.foreignKeyName = newField.options.selfKeyName; + newFieldVo.isMultipleCellValue = isMultiValueLink(options.relationship); + // return modified changes in foreignTable return { - tableId: linkField.options.foreignTableId, + tableId: newField.options.foreignTableId, newField: createFieldInstanceByVo(newFieldVo), - oldField, + oldField: createFieldInstanceByRaw(fieldRaw), }; } @@ -67,9 +102,21 @@ export class FieldConvertingLinkService { }, }); await this.fieldSupplementService.createReference(newField); - await this.fieldSupplementService.cleanForeignKey(tableId, oldField.options); + await this.fieldSupplementService.cleanForeignKey(oldField.options); + await this.fieldSupplementService.createForeignKey(newField.options); + } else if (newField.options.relationship !== oldField.options.relationship) { + await this.fieldSupplementService.cleanForeignKey(oldField.options); // create new symmetric link - await this.fieldSupplementService.createForeignKey(tableId, newField); + await this.fieldSupplementService.createForeignKey(newField.options); + } + + return this.generateSymmetricFieldChange(tableId, oldField, newField); + } + + private async otherToLink(tableId: string, newField: LinkFieldDto) { + await this.fieldSupplementService.createForeignKey(newField.options); + await this.fieldSupplementService.createReference(newField); + if (newField.options.symmetricFieldId) { const symmetricField = await this.fieldSupplementService.generateSymmetricField( tableId, newField @@ -78,48 +125,19 @@ export class FieldConvertingLinkService { newField.options.foreignTableId, symmetricField ); - - // delete old symmetric link - await this.fieldDeletingService.delateAndCleanRef( - oldField.options.foreignTableId, - oldField.options.symmetricFieldId - ); - return; } - - if (newField.options.relationship !== oldField.options.relationship) { - await this.fieldSupplementService.cleanForeignKey(tableId, oldField.options); - // create new symmetric link - await this.fieldSupplementService.createForeignKey(tableId, newField); - await this.fieldDeletingService.cleanField(newField.options.foreignTableId, [ - newField.options.symmetricFieldId, - ]); - - return this.generateSymmetricFieldChange(newField); - } - } - - private async otherToLink(tableId: string, newField: LinkFieldDto) { - await this.fieldSupplementService.createForeignKey(tableId, newField); - const symmetricField = await this.fieldSupplementService.generateSymmetricField( - tableId, - newField - ); - await this.fieldSupplementService.createReference(newField); - await this.fieldCreatingService.createAndCalculate( - newField.options.foreignTableId, - symmetricField - ); } private async linkToOther(tableId: string, oldField: LinkFieldDto) { await this.fieldDeletingService.cleanRef(tableId, oldField.id, true); - await this.fieldDeletingService.delateAndCleanRef( - oldField.options.foreignTableId, - oldField.options.symmetricFieldId, - true - ); + if (oldField.options.symmetricFieldId) { + await this.fieldDeletingService.delateAndCleanRef( + oldField.options.foreignTableId, + oldField.options.symmetricFieldId, + true + ); + } } /** @@ -183,19 +201,22 @@ export class FieldConvertingLinkService { const records = await this.getRecords(tableId, oldField); // TODO: should not get all records in foreignTable, only get records witch title is not exist in candidate records link cell value title - const foreignRecords = await this.getRecords(foreignTableId, lookupField); + const foreignRecordMap = await this.getRecords(foreignTableId, lookupField); - const primaryNameToIdMap = foreignRecords.reduce<{ [name: string]: string }>((pre, record) => { - const str = lookupField.cellValue2String(record.fields[lookupField.id]); - pre[str] = record.id; - return pre; - }, {}); + const primaryNameToIdMap = Object.values(foreignRecordMap).reduce<{ [name: string]: string }>( + (pre, record) => { + const str = lookupField.cellValue2String(record.fields[lookupField.id]); + pre[str] = record.id; + return pre; + }, + {} + ); const recordOpsMap: IOpsMap = { [tableId]: {}, [foreignTableId]: {} }; const recordsForCreate: { [title: string]: ITinyRecord } = {}; const checkSet = new Set(); // eslint-disable-next-line sonarjs/cognitive-complexity - records.forEach((record) => { + Object.values(records).forEach((record) => { const oldCellValue = record.fields[fieldId]; if (oldCellValue == null) { return; @@ -213,12 +234,15 @@ export class FieldConvertingLinkService { const newCellValue: ILinkCellValue[] = []; function pushNewCellValue(linkCell: ILinkCellValue) { - if (newField.options.relationship !== Relationship.OneMany) { + // OneMany and OneOne relationship only allow link to one same recordId + if ( + newField.options.relationship === Relationship.OneMany || + newField.options.relationship === Relationship.OneOne + ) { + if (checkSet.has(linkCell.id)) return; + checkSet.add(linkCell.id); return newCellValue.push(linkCell); } - // OneMany relationship only allow link to one same recordId - if (checkSet.has(linkCell.id)) return; - checkSet.add(linkCell.id); return newCellValue.push(linkCell); } 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 f0f2463b5..1b744738a 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 @@ -17,12 +17,12 @@ import { ColorUtils, generateChoiceId, DbFieldType, - Relationship, FieldKeyType, FIELD_VO_PROPERTIES, RecordOpBuilder, FieldType, FieldOpBuilder, + isMultiValueLink, } from '@teable-group/core'; import { PrismaService } from '@teable-group/db-main-prisma'; import { instanceToPlain } from 'class-transformer'; @@ -33,13 +33,13 @@ import { BatchService } from '../../calculation/batch.service'; import { FieldCalculationService } from '../../calculation/field-calculation.service'; import type { ICellContext } from '../../calculation/link.service'; import { LinkService } from '../../calculation/link.service'; -import type { IFieldMap, IFkOpMap, IOpsMap } from '../../calculation/reference.service'; +import type { IOpsMap } from '../../calculation/reference.service'; import { ReferenceService } from '../../calculation/reference.service'; import { formatChangesToOps } from '../../calculation/utils/changes'; import { composeMaps } from '../../calculation/utils/compose-maps'; import { RecordCalculateService } from '../../record/record-calculate/record-calculate.service'; import { FieldService } from '../field.service'; -import type { IFieldInstance } from '../model/factory'; +import type { IFieldInstance, IFieldMap } from '../model/factory'; import { createFieldInstanceByVo } from '../model/factory'; import { FormulaFieldDto } from '../model/field-dto/formula-field.dto'; import type { LinkFieldDto } from '../model/field-dto/link-field.dto'; @@ -108,8 +108,7 @@ export class FieldConvertingService { return; } const oldValue = field[key]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (field[key] as any) = value; + (field[key] as unknown) = value; return FieldOpBuilder.editor.setFieldProperty.build({ key, oldValue, newValue: value }); } @@ -145,7 +144,9 @@ export class FieldConvertingService { this.buildOpAndMutateField(field, 'lookupOptions', { ...lookupOptions, relationship: linkField.options.relationship, - dbForeignKeyName: linkField.options.dbForeignKeyName, + fkHostTableName: linkField.options.fkHostTableName, + selfKeyName: linkField.options.selfKeyName, + foreignKeyName: linkField.options.foreignKeyName, } as ILookupOptionsVo) ); } @@ -207,7 +208,7 @@ export class FieldConvertingService { const { cellValueType, isMultipleCellValue } = RollupFieldDto.getParsedValueType( field.options.expression, lookupField, - lookupField.isMultipleCellValue || relationship !== Relationship.ManyOne + Boolean(lookupField.isMultipleCellValue || isMultiValueLink(relationship)) ); if (field.cellValueType !== cellValueType) { @@ -251,6 +252,7 @@ export class FieldConvertingService { for (let i = 1; i < topoOrders.length; i++) { const topoOrder = topoOrders[i]; + // curField will be mutate in loop const curField = fieldMap[topoOrder.id]; const tableId = fieldId2TableId[curField.id]; const dbTableName = tableId2DbTableName[tableId]; @@ -653,13 +655,12 @@ export class FieldConvertingService { const derivate = await this.linkService.getDerivateByLink(tableId, changes, true); const cellChanges = derivate?.cellChanges || []; - const fkRecordMap = derivate?.fkRecordMap || {}; const opsMapByLink = cellChanges.length ? formatChangesToOps(cellChanges) : {}; return { opsMapByLink, - fkRecordMap, + saveForeignKeyToDb: derivate?.saveForeignKeyToDb, }; } @@ -668,11 +669,10 @@ export class FieldConvertingService { field: IFieldInstance, recordOpsMap: IOpsMap ) { - let fkRecordMap: IFkOpMap = {}; + let saveForeignKeyToDb: (() => Promise) | undefined; if (field.type === FieldType.Link && !field.isLookup) { const result = await this.getDerivateByLink(tableId, recordOpsMap[tableId]); - // console.log('getDerivateByLink:result', JSON.stringify(result, null, 2)); - fkRecordMap = result.fkRecordMap; + saveForeignKeyToDb = result?.saveForeignKeyToDb; recordOpsMap = composeMaps([recordOpsMap, result.opsMapByLink]); } @@ -680,7 +680,7 @@ export class FieldConvertingService { opsMap: calculatedOpsMap, fieldMap, tableId2DbTableName, - } = await this.referenceService.calculateOpsMap(recordOpsMap, fkRecordMap); + } = await this.referenceService.calculateOpsMap(recordOpsMap, saveForeignKeyToDb); const composedOpsMap = composeMaps([recordOpsMap, calculatedOpsMap]); @@ -691,7 +691,7 @@ export class FieldConvertingService { await this.batchService.updateRecords(composedOpsMap, fieldMap, tableId2DbTableName); } - private async getRecords(tableId: string, newField: IFieldInstance) { + private async getRecordMap(tableId: string, newField: IFieldInstance) { const { dbTableName } = await this.prismaService.txClient().tableMeta.findFirstOrThrow({ where: { id: tableId }, select: { dbTableName: true }, @@ -716,13 +716,13 @@ export class FieldConvertingService { oldField: IFieldInstance ) { const fieldId = newField.id; - const records = await this.getRecords(tableId, oldField); + const recordMap = await this.getRecordMap(tableId, oldField); const choices = newField.options.choices; const opsMap: { [recordId: string]: IOtOperation[] } = {}; const fieldOps: IOtOperation[] = []; const choicesMap = keyBy(choices, 'name'); const newChoicesSet = new Set(); - records.forEach((record) => { + Object.values(recordMap).forEach((record) => { const oldCellValue = record.fields[fieldId]; if (oldCellValue == null) { return; @@ -792,9 +792,9 @@ export class FieldConvertingService { } const fieldId = newField.id; - const records = await this.getRecords(tableId, oldField); + const records = await this.getRecordMap(tableId, oldField); const opsMap: { [recordId: string]: IOtOperation[] } = {}; - records.forEach((record) => { + Object.values(records).forEach((record) => { const oldCellValue = record.fields[fieldId]; if (oldCellValue == null) { return; @@ -904,6 +904,7 @@ export class FieldConvertingService { await this.calculateAndSaveRecords(tableId, newField, result.recordOpsMap); } + // TODO: reduce unnecessary calculation if (newField.isComputed) { await this.fieldCalculationService.calculateFields(tableId, [newField.id]); } @@ -943,6 +944,7 @@ export class FieldConvertingService { const oldFieldInstance = createFieldInstanceByVo(fieldVo); const newFieldVo = await this.fieldSupplementService.prepareUpdateField( + tableId, updateFieldRo, oldFieldInstance ); 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 0b37acaba..60208cee1 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 @@ -60,13 +60,15 @@ export class FieldCreatingService { async createField(tableId: string, field: IFieldInstance): Promise { if (field.type === FieldType.Link && !field.isLookup) { - await this.fieldSupplementService.createForeignKey(tableId, field); - const symmetricField = await this.fieldSupplementService.generateSymmetricField( - tableId, - field - ); - - await this.createAndCalculate(field.options.foreignTableId, symmetricField); + await this.fieldSupplementService.createForeignKey(field.options); + if (field.options.symmetricFieldId) { + const symmetricField = await this.fieldSupplementService.generateSymmetricField( + tableId, + field + ); + + await this.createAndCalculate(field.options.foreignTableId, symmetricField); + } return this.createAndCalculate(tableId, field); } diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-deleting.service.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-deleting.service.ts index b713bb855..547f890af 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-deleting.service.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-deleting.service.ts @@ -67,9 +67,11 @@ export class FieldDeletingService { if (type === FieldType.Link && !isLookup) { const linkFieldOptions: ILinkFieldOptions = JSON.parse(options as string); const { foreignTableId, symmetricFieldId } = linkFieldOptions; - await this.fieldSupplementService.cleanForeignKey(tableId, linkFieldOptions); + await this.fieldSupplementService.cleanForeignKey(linkFieldOptions); await this.delateAndCleanRef(tableId, fieldId, true); - await this.delateAndCleanRef(foreignTableId, symmetricFieldId, true); + if (symmetricFieldId) { + await this.delateAndCleanRef(foreignTableId, symmetricFieldId, true); + } return; } await this.delateAndCleanRef(tableId, fieldId); diff --git a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.spec.ts b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.spec.ts index f5c4990b6..6b9c3b2c0 100644 --- a/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.spec.ts +++ b/apps/nestjs-backend/src/features/field/field-calculate/field-supplement.service.spec.ts @@ -35,38 +35,17 @@ describe('FieldSupplementService', () => { cellValueType: CellValueType.String, dbFieldType: DbFieldType.Text, }; - const result = await service.prepareCreateField(field); + const result = await service.prepareCreateField('tableId', field); expect(result).toMatchObject(preparedField); expect(result.dbFieldName).toBeTruthy(); expect(result.id).toBeTruthy(); }); - - it('should prepare the options for a link field', async () => { - const field: IFieldRo = { - name: 'link', - type: FieldType.Link, - options: { - relationship: Relationship.ManyOne, - foreignTableId: 'foreignTable', - // lookupFieldId - // dbForeignKeyName - // symmetricFieldId - }, - }; - const mockField = { id: 'mockFieldId' }; - (prismaService as any).field = { findFirstOrThrow: jest.fn().mockResolvedValue(mockField) }; - - const result = await service.prepareCreateField(field); - expect(result.id).toBeDefined(); - expect(result.options).toMatchObject({ lookupFieldId: mockField.id }); - expect(prismaService.field.findFirstOrThrow).toHaveBeenCalled(); - }); }); describe('supplementByCreate', () => { it('should throw an error if the field is not a link field', async () => { const nonLinkField: any = { type: FieldType.SingleLineText /* other properties */ }; - await expect(service.createForeignKey('tableId', nonLinkField)).rejects.toThrow(); + await expect(service.createForeignKey(nonLinkField)).rejects.toThrow(); }); }); 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 f9d4ac645..9d6103b81 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 @@ -34,6 +34,8 @@ import { CheckboxFieldCore, RatingFieldCore, LongTextFieldCore, + isMultiValueLink, + getRandomString, } from '@teable-group/core'; import { PrismaService } from '@teable-group/db-main-prisma'; import { Knex } from 'knex'; @@ -41,7 +43,6 @@ import { keyBy, merge } from 'lodash'; import { InjectModel } from 'nest-knexjs'; import type { z } from 'zod'; import { fromZodError } from 'zod-validation-error'; -import type { ISupplementService } from '../../../share-db/interface'; import { FieldService } from '../field.service'; import { createFieldInstanceByRaw, createFieldInstanceByVo } from '../model/factory'; import type { IFieldInstance } from '../model/factory'; @@ -50,11 +51,11 @@ import type { LinkFieldDto } from '../model/field-dto/link-field.dto'; import { RollupFieldDto } from '../model/field-dto/rollup-field.dto'; @Injectable() -export class FieldSupplementService implements ISupplementService { +export class FieldSupplementService { constructor( - private readonly prismaService: PrismaService, + @InjectModel('CUSTOM_KNEX') private readonly knex: Knex, private readonly fieldService: FieldService, - @InjectModel('CUSTOM_KNEX') private readonly knex: Knex + private readonly prismaService: PrismaService ) {} private async getDbTableName(tableId: string) { @@ -80,81 +81,203 @@ export class FieldSupplementService implements ISupplementService { return tableRaw.name; } - private async prepareLinkField(field: IFieldRo) { - const { relationship, foreignTableId } = field.options as ILinkFieldOptionsRo; + async generateNewLinkOptionsVo( + tableId: string, + fieldId: string, + optionsRo: ILinkFieldOptionsRo + ): Promise { + const { relationship, foreignTableId, isOneWay } = optionsRo; + const symmetricFieldId = isOneWay ? undefined : generateFieldId(); + const dbTableName = await this.getDbTableName(tableId); + const foreignTableName = await this.getDbTableName(foreignTableId); + const { id: lookupFieldId } = await this.prismaService.field.findFirstOrThrow({ where: { tableId: foreignTableId, isPrimary: true }, select: { id: true }, }); - const fieldId = field.id ?? generateFieldId(); - const symmetricFieldId = generateFieldId(); - let dbForeignKeyName = ''; + const common = { + ...optionsRo, + symmetricFieldId, + lookupFieldId, + }; + + if (relationship === Relationship.ManyMany) { + const pgMaxTableNameLength = 63; + const fkHostTableName = `junction_${fieldId}_${dbTableName}_${foreignTableName}`.slice( + 0, + pgMaxTableNameLength + ); + return { + ...common, + fkHostTableName, + selfKeyName: this.getForeignKeyFieldName(fieldId), + foreignKeyName: this.getForeignKeyFieldName( + symmetricFieldId ? symmetricFieldId : `rad${getRandomString(16)}` + ), + }; + } + if (relationship === Relationship.ManyOne) { - dbForeignKeyName = this.getForeignKeyFieldName(fieldId); + return { + ...common, + fkHostTableName: dbTableName, + selfKeyName: '__id', + foreignKeyName: this.getForeignKeyFieldName(fieldId), + }; } + if (relationship === Relationship.OneMany) { - dbForeignKeyName = this.getForeignKeyFieldName(symmetricFieldId); + return { + ...common, + fkHostTableName: foreignTableName, + selfKeyName: this.getForeignKeyFieldName(symmetricFieldId ? symmetricFieldId : dbTableName), + foreignKeyName: '__id', + }; + } + + if (relationship === Relationship.OneOne) { + return { + ...common, + fkHostTableName: dbTableName, + selfKeyName: '__id', + foreignKeyName: this.getForeignKeyFieldName(fieldId), + }; + } + + throw new BadRequestException('relationship is invalid'); + } + + async generateUpdatedLinkOptionsVo( + tableId: string, + fieldId: string, + oldOptions: ILinkFieldOptions, + newOptionsRo: ILinkFieldOptionsRo + ): Promise { + const { relationship, foreignTableId, isOneWay } = newOptionsRo; + + const dbTableName = await this.getDbTableName(tableId); + const foreignTableName = await this.getDbTableName(foreignTableId); + + const symmetricFieldId = isOneWay + ? undefined + : oldOptions.foreignTableId === newOptionsRo.foreignTableId + ? oldOptions.symmetricFieldId + : generateFieldId(); + + const lookupFieldId = + oldOptions.foreignTableId === foreignTableId + ? oldOptions.lookupFieldId + : ( + await this.prismaService.field.findFirstOrThrow({ + where: { tableId: foreignTableId, isPrimary: true, deletedTime: null }, + select: { id: true }, + }) + ).id; + + const common = { + ...newOptionsRo, + symmetricFieldId, + lookupFieldId, + }; + + if (relationship === Relationship.ManyMany) { + const pgMaxTableNameLength = 63; + const fkHostTableName = `junction_${fieldId}_${dbTableName}_${foreignTableName}`.slice( + 0, + pgMaxTableNameLength + ); + return { + ...common, + fkHostTableName, + selfKeyName: this.getForeignKeyFieldName(fieldId), + foreignKeyName: this.getForeignKeyFieldName( + symmetricFieldId ? symmetricFieldId : `rad${getRandomString(16)}` + ), + }; + } + + if (relationship === Relationship.ManyOne) { + return { + ...common, + fkHostTableName: dbTableName, + selfKeyName: '__id', + foreignKeyName: this.getForeignKeyFieldName(fieldId), + }; } + if (relationship === Relationship.OneMany) { + return { + ...common, + fkHostTableName: foreignTableName, + selfKeyName: this.getForeignKeyFieldName(symmetricFieldId ? symmetricFieldId : dbTableName), + foreignKeyName: '__id', + }; + } + + if (relationship === Relationship.OneOne) { + return { + ...common, + fkHostTableName: dbTableName, + selfKeyName: '__id', + foreignKeyName: this.getForeignKeyFieldName(fieldId), + }; + } + + throw new BadRequestException('relationship is invalid'); + } + + private async prepareLinkField(tableId: string, field: IFieldRo) { + const options = field.options as ILinkFieldOptionsRo; + const { relationship, foreignTableId } = options; + + const fieldId = field.id ?? generateFieldId(); + const optionsVo = await this.generateNewLinkOptionsVo(tableId, fieldId, options); + return { ...field, id: fieldId, name: field.name ?? (await this.getDefaultLinkName(foreignTableId)), - options: { - relationship, - foreignTableId, - lookupFieldId, - dbForeignKeyName, - symmetricFieldId, - }, - isMultipleCellValue: relationship !== Relationship.ManyOne || undefined, + options: optionsVo, + isMultipleCellValue: isMultiValueLink(relationship), dbFieldType: DbFieldType.Json, cellValueType: CellValueType.String, }; } - private async prepareUpdateLinkField(fieldRo: IFieldRo, oldFieldVo: IFieldVo) { - const newOptions = fieldRo.options as ILinkFieldOptionsRo; + // only for linkField to linkField + private async prepareUpdateLinkField(tableId: string, fieldRo: IFieldRo, oldFieldVo: IFieldVo) { + const newOptionsRo = fieldRo.options as ILinkFieldOptionsRo; const oldOptions = oldFieldVo.options as ILinkFieldOptions; if ( - oldOptions.foreignTableId === newOptions.foreignTableId && - oldOptions.relationship === newOptions.relationship + oldOptions.foreignTableId === newOptionsRo.foreignTableId && + oldOptions.relationship === newOptionsRo.relationship ) { - return merge({}, oldFieldVo, fieldRo); + return { + ...oldFieldVo, + ...fieldRo, + options: { + ...oldOptions, + ...newOptionsRo, + symmetricFieldId: newOptionsRo.isOneWay ? undefined : oldOptions.symmetricFieldId, + }, + }; } - const { relationship, foreignTableId } = newOptions; - let { dbForeignKeyName, symmetricFieldId, lookupFieldId } = oldOptions; const fieldId = oldFieldVo.id; - if (oldOptions.foreignTableId !== foreignTableId) { - symmetricFieldId = generateFieldId(); - const lookupFieldRaw = await this.prismaService.field.findFirstOrThrow({ - where: { tableId: foreignTableId, isPrimary: true, deletedTime: null }, - select: { id: true }, - }); - lookupFieldId = lookupFieldRaw.id; - } - - if (relationship === Relationship.ManyOne) { - dbForeignKeyName = this.getForeignKeyFieldName(fieldId); - } - if (relationship === Relationship.OneMany) { - dbForeignKeyName = this.getForeignKeyFieldName(symmetricFieldId); - } + const optionsVo = await this.generateUpdatedLinkOptionsVo( + tableId, + fieldId, + oldOptions, + newOptionsRo + ); return { ...oldFieldVo, ...fieldRo, - options: { - relationship, - foreignTableId, - lookupFieldId, - dbForeignKeyName, - symmetricFieldId, - }, - isMultipleCellValue: relationship !== Relationship.ManyOne || undefined, + options: optionsVo, + isMultipleCellValue: isMultiValueLink(optionsVo.relationship), dbFieldType: DbFieldType.Json, cellValueType: CellValueType.String, }; @@ -199,7 +322,9 @@ export class FieldSupplementService implements ISupplementService { lookupFieldId, foreignTableId, relationship: linkFieldOptions.relationship, - dbForeignKeyName: linkFieldOptions.dbForeignKeyName, + fkHostTableName: linkFieldOptions.fkHostTableName, + selfKeyName: linkFieldOptions.selfKeyName, + foreignKeyName: linkFieldOptions.foreignKeyName, }, lookupFieldRaw, linkFieldRaw, @@ -568,15 +693,18 @@ export class FieldSupplementService implements ISupplementService { }; } - private async prepareCreateFieldInner(fieldRo: IFieldRo, batchFieldVos?: IFieldVo[]) { + private async prepareCreateFieldInner( + tableId: string, + fieldRo: IFieldRo, + batchFieldVos?: IFieldVo[] + ) { if (fieldRo.isLookup) { return this.prepareLookupField(fieldRo, batchFieldVos); } switch (fieldRo.type) { - case FieldType.Link: { - return this.prepareLinkField(fieldRo); - } + case FieldType.Link: + return this.prepareLinkField(tableId, fieldRo); case FieldType.Rollup: return this.prepareRollupField(fieldRo, batchFieldVos); case FieldType.Formula: @@ -604,9 +732,9 @@ export class FieldSupplementService implements ISupplementService { } } - private async prepareUpdateFieldInner(fieldRo: IFieldRo, oldFieldVo: IFieldVo) { + private async prepareUpdateFieldInner(tableId: string, fieldRo: IFieldRo, oldFieldVo: IFieldVo) { if (fieldRo.type !== oldFieldVo.type) { - return this.prepareCreateFieldInner(fieldRo); + return this.prepareCreateFieldInner(tableId, fieldRo); } if (fieldRo.isLookup) { @@ -615,7 +743,7 @@ export class FieldSupplementService implements ISupplementService { switch (fieldRo.type) { case FieldType.Link: { - return this.prepareUpdateLinkField(fieldRo, oldFieldVo); + return this.prepareUpdateLinkField(tableId, fieldRo, oldFieldVo); } case FieldType.Rollup: return this.prepareUpdateRollupField(fieldRo, oldFieldVo); @@ -672,8 +800,8 @@ export class FieldSupplementService implements ISupplementService { * prepare properties for computed field to make sure it's valid * this method do not do any db update */ - async prepareCreateField(fieldRo: IFieldRo, batchFieldVos?: IFieldVo[]) { - const field = await this.prepareCreateFieldInner(fieldRo, batchFieldVos); + async prepareCreateField(tableId: string, fieldRo: IFieldRo, batchFieldVos?: IFieldVo[]) { + const field = await this.prepareCreateFieldInner(tableId, fieldRo, batchFieldVos); const fieldId = field.id || generateFieldId(); @@ -692,13 +820,14 @@ export class FieldSupplementService implements ISupplementService { return fieldVo; } - async prepareUpdateField(fieldRo: IUpdateFieldRo, oldField: IFieldInstance) { + async prepareUpdateField(tableId: string, fieldRo: IUpdateFieldRo, oldField: IFieldInstance) { // make sure all keys in FIELD_RO_PROPERTIES are define, so we can override old value. FIELD_RO_PROPERTIES.forEach( (key) => !fieldRo[key] && ((fieldRo as Record)[key] = undefined) ); const fieldVo = (await this.prepareUpdateFieldInner( + tableId, { ...fieldRo, name: fieldRo.name ?? oldField.name }, // for convenience, we fallback name when it be undefined oldField )) as IFieldVo; @@ -715,6 +844,10 @@ export class FieldSupplementService implements ISupplementService { } async generateSymmetricField(tableId: string, field: LinkFieldDto) { + if (!field.options.symmetricFieldId) { + throw new Error('symmetricFieldId is required'); + } + const prisma = this.prismaService.txClient(); const { name: tableName } = await prisma.tableMeta.findUniqueOrThrow({ where: { id: tableId }, @@ -728,10 +861,11 @@ export class FieldSupplementService implements ISupplementService { }); const relationship = RelationshipRevert[field.options.relationship]; - const isMultipleCellValue = relationship !== Relationship.ManyOne; + const isMultipleCellValue = isMultiValueLink(relationship); const [dbFieldName] = this.fieldService.generateDbFieldName([ { id: field.options.symmetricFieldId, name: tableName }, ]); + return createFieldInstanceByVo({ id: field.options.symmetricFieldId, name: tableName, @@ -741,7 +875,9 @@ export class FieldSupplementService implements ISupplementService { relationship, foreignTableId: tableId, lookupFieldId, - dbForeignKeyName: field.options.dbForeignKeyName, + fkHostTableName: field.options.fkHostTableName, + selfKeyName: field.options.foreignKeyName, + foreignKeyName: field.options.selfKeyName, symmetricFieldId: field.id, }, isMultipleCellValue, @@ -750,71 +886,94 @@ export class FieldSupplementService implements ISupplementService { } as IFieldVo) as LinkFieldDto; } - private async columnExists(dbTableName: string, columnName: string): Promise { - const columnListSql = this.knex.queryBuilder().columnList(dbTableName).toQuery(); + async createForeignKey(options: ILinkFieldOptions) { + const { relationship, fkHostTableName, selfKeyName, foreignKeyName } = options; - const result = await this.prismaService - .txClient() - .$queryRawUnsafe<{ name: string }[]>(columnListSql); - return result.some((row) => row.name === columnName); - } + let alterTableSchema: Knex.SchemaBuilder | undefined; - private async createForeignKeyField( - tableId: string, // tableId for current field belongs to - dbForeignKeyName: string - ) { - const dbTableName = await this.getDbTableName(tableId); - if (await this.columnExists(dbTableName, dbForeignKeyName)) { - return; + if (relationship === Relationship.ManyMany) { + alterTableSchema = this.knex.schema.createTable(fkHostTableName, (table) => { + table.increments('__id').primary(); + table.string(selfKeyName); + table.string(foreignKeyName); + table.unique([selfKeyName, foreignKeyName], { + indexName: `index_${selfKeyName}_${foreignKeyName}`, + }); + }); + } + + if (relationship === Relationship.ManyOne) { + alterTableSchema = this.knex.schema.alterTable(fkHostTableName, (table) => { + table.string(foreignKeyName); + console.log('createIndex', `index_${foreignKeyName}`); + table.index([foreignKeyName], `index_${foreignKeyName}`); + }); + } + + if (relationship === Relationship.OneMany) { + alterTableSchema = this.knex.schema.alterTable(fkHostTableName, (table) => { + table.string(selfKeyName); + table.index([selfKeyName], `index_${selfKeyName}`); + }); + } + + // assume options is from the main field (user created one) + if (relationship === Relationship.OneOne) { + alterTableSchema = this.knex.schema.alterTable(fkHostTableName, (table) => { + if (foreignKeyName === '__id') { + throw new Error('can not use __id for foreignKeyName'); + } + table.string(foreignKeyName); + table.unique([foreignKeyName], { + indexName: `index_${foreignKeyName}`, + }); + }); + } + + if (!alterTableSchema) { + throw new Error('alterTableSchema is undefined'); } - const alterTableSchema = this.knex.schema.alterTable(dbTableName, (table) => { - table.string(dbForeignKeyName).index().nullable(); - }); for (const sql of alterTableSchema.toSQL()) { await this.prismaService.txClient().$executeRawUnsafe(sql.sql); } } - private async cleanForeignKeyField( - tableId: string, // tableId for current field belongs to - dbForeignKeyName: string - ) { - const dbTableName = await this.getDbTableName(tableId); - // sqlite cannot drop column, so we just set it to null - const nativeSql = this.knex(dbTableName) - .update({ [dbForeignKeyName]: null }) - .toSQL() - .toNative(); + async cleanForeignKey(options: ILinkFieldOptions) { + const { fkHostTableName, relationship, selfKeyName, foreignKeyName } = options; - await this.prismaService.txClient().$executeRawUnsafe(nativeSql.sql, ...nativeSql.bindings); - } + if (relationship === Relationship.ManyMany) { + const alterTableSchema = this.knex.schema.dropTable(fkHostTableName); - async createForeignKey(tableId: string, field: LinkFieldDto) { - if (field.type !== FieldType.Link) { - throw new Error('only link field need to create supplement field'); + for (const sql of alterTableSchema.toSQL()) { + await this.prismaService.txClient().$executeRawUnsafe(sql.sql); + } + return; } - const { foreignTableId, dbForeignKeyName, relationship } = field.options; - - if (relationship === Relationship.OneMany) { - await this.createForeignKeyField(foreignTableId, dbForeignKeyName); - } + const dropColumn = async (tableName: string, columnName: string) => { + const dropIndexSql = this.knex + .queryBuilder() + .dropIndex(tableName, `index_${columnName}`) + .toQuery(); + const dropColumnSql = this.knex + .raw(`ALTER TABLE ?? DROP ??`, [tableName, columnName]) + .toQuery(); + + await this.prismaService.txClient().$executeRawUnsafe(dropIndexSql); + await this.prismaService.txClient().$executeRawUnsafe(dropColumnSql); + }; if (relationship === Relationship.ManyOne) { - await this.createForeignKeyField(tableId, dbForeignKeyName); + await dropColumn(fkHostTableName, foreignKeyName); } - } - - async cleanForeignKey(tableId: string, options: ILinkFieldOptions) { - const { foreignTableId, relationship, dbForeignKeyName } = options; if (relationship === Relationship.OneMany) { - await this.cleanForeignKeyField(foreignTableId, dbForeignKeyName); + await dropColumn(fkHostTableName, selfKeyName); } - if (relationship === Relationship.ManyOne) { - await this.cleanForeignKeyField(tableId, dbForeignKeyName); + if (relationship === Relationship.OneOne) { + await dropColumn(fkHostTableName, foreignKeyName === '__id' ? selfKeyName : foreignKeyName); } } diff --git a/apps/nestjs-backend/src/features/field/model/factory.ts b/apps/nestjs-backend/src/features/field/model/factory.ts index f74bdf333..1b4c6430d 100644 --- a/apps/nestjs-backend/src/features/field/model/factory.ts +++ b/apps/nestjs-backend/src/features/field/model/factory.ts @@ -70,18 +70,13 @@ export function createFieldInstanceByVo(field: IFieldVo) { return plainToInstance(RatingFieldDto, field); case FieldType.Button: case FieldType.CreatedBy: - case FieldType.Email: case FieldType.LastModifiedBy: - case FieldType.PhoneNumber: - case FieldType.URL: case FieldType.User: case FieldType.AutoNumber: case FieldType.Count: case FieldType.CreatedTime: case FieldType.Duration: case FieldType.LastModifiedTime: - case FieldType.Currency: - case FieldType.Percent: throw new Error('did not implement yet'); default: assertNever(field.type); @@ -89,3 +84,7 @@ export function createFieldInstanceByVo(field: IFieldVo) { } export type IFieldInstance = ReturnType; + +export interface IFieldMap { + [fieldId: string]: IFieldInstance; +} diff --git a/apps/nestjs-backend/src/features/field/model/field-dto/link-field.dto.ts b/apps/nestjs-backend/src/features/field/model/field-dto/link-field.dto.ts index 6a59c2f48..02476fd95 100644 --- a/apps/nestjs-backend/src/features/field/model/field-dto/link-field.dto.ts +++ b/apps/nestjs-backend/src/features/field/model/field-dto/link-field.dto.ts @@ -20,12 +20,12 @@ export class LinkFieldDto extends LinkFieldCore implements IFieldBase { const titles = title as string[]; return values.map((v, i) => ({ id: v.id, - title: titles[i], + title: titles[i] || undefined, })); } return { id: (value as ILinkCellValue).id, - title: title as string, + title: (title as string | null) || undefined, }; } 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 521da7808..ef961daf4 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 @@ -20,7 +20,7 @@ export class FieldOpenApiService { async createField(tableId: string, fieldRo: IFieldRo) { return await this.prismaService.$tx(async () => { - const fieldVo = await this.fieldSupplementService.prepareCreateField(fieldRo); + const fieldVo = await this.fieldSupplementService.prepareCreateField(tableId, fieldRo); const fieldInstance = createFieldInstanceByVo(fieldVo); return await this.fieldCreatingService.createField(tableId, fieldInstance); }); diff --git a/apps/nestjs-backend/src/features/field/util.ts b/apps/nestjs-backend/src/features/field/util.ts index 19ced2c93..83d937324 100644 --- a/apps/nestjs-backend/src/features/field/util.ts +++ b/apps/nestjs-backend/src/features/field/util.ts @@ -2,25 +2,37 @@ import { assertNever, DbFieldType, DriverClient } from '@teable-group/core'; import type { Knex } from 'knex'; import { getDriverName } from '../../utils/db-helpers'; +// from knex define +export enum SchemaType { + Binary = 'binary', + Integer = 'integer', + String = 'string', + Text = 'text', + Json = 'json', + Double = 'double', + Datetime = 'datetime', + Boolean = 'boolean', +} + export function dbType2knexFormat(knex: Knex, dbFieldType: DbFieldType) { const driverName = getDriverName(knex); switch (dbFieldType) { case DbFieldType.Blob: - return 'binary'; + return SchemaType.Binary; case DbFieldType.Integer: - return 'integer'; + return SchemaType.Integer; case DbFieldType.Json: { - return driverName === DriverClient.Sqlite ? 'text' : 'json'; + return driverName === DriverClient.Sqlite ? SchemaType.Text : SchemaType.Json; } case DbFieldType.Real: - return 'double'; + return SchemaType.Double; case DbFieldType.Text: - return 'text'; + return SchemaType.Text; case DbFieldType.DateTime: - return 'datetime'; + return SchemaType.Datetime; case DbFieldType.Boolean: - return 'boolean'; + return SchemaType.Boolean; default: assertNever(dbFieldType); } diff --git a/apps/nestjs-backend/src/features/record/record-calculate/record-calculate.service.ts b/apps/nestjs-backend/src/features/record/record-calculate/record-calculate.service.ts index 269e91db5..c24de6fc8 100644 --- a/apps/nestjs-backend/src/features/record/record-calculate/record-calculate.service.ts +++ b/apps/nestjs-backend/src/features/record/record-calculate/record-calculate.service.ts @@ -92,7 +92,6 @@ export class RecordCalculateService { const derivate = await this.linkService.getDerivateByLink(tableId, opContexts); const cellChanges = derivate?.cellChanges || []; - const fkRecordMap = derivate?.fkRecordMap || {}; const opsMapByLink = cellChanges.length ? formatChangesToOps(cellChanges) : {}; // calculate by origin ops and link derivation @@ -102,9 +101,10 @@ export class RecordCalculateService { tableId2DbTableName, } = await this.referenceService.calculateOpsMap( composeMaps([opsMapOrigin, opsMapByLink]), - fkRecordMap + derivate?.saveForeignKeyToDb ); + // console.log(JSON.stringify({ opsMapOrigin, opsMapByLink, opsMapByCalculation }, null, 2)); return { opsMap: composeMaps([opsMapOrigin, opsMapByLink, opsMapByCalculation]), fieldMap, @@ -145,6 +145,8 @@ export class RecordCalculateService { opsContexts ); + // console.log('final:opsMap', JSON.stringify(opsMap, null, 2)); + // 3. save all ops if (!isEmpty(opsMap)) { await this.batchService.updateRecords(opsMap, fieldMap, tableId2DbTableName); diff --git a/apps/nestjs-backend/src/features/selection/selection.service.ts b/apps/nestjs-backend/src/features/selection/selection.service.ts index fac8b7b46..4a340dc19 100644 --- a/apps/nestjs-backend/src/features/selection/selection.service.ts +++ b/apps/nestjs-backend/src/features/selection/selection.service.ts @@ -231,7 +231,7 @@ export class SelectionService { : { type: FieldType.SingleLineText, }; - const fieldVo = await this.fieldSupplementService.prepareCreateField(field); + const fieldVo = await this.fieldSupplementService.prepareCreateField(tableId, field); const fieldInstance = createFieldInstanceByVo(fieldVo); const newField = await this.fieldCreatingService.createField(tableId, fieldInstance); res.push(newField); diff --git a/apps/nestjs-backend/src/features/space/space.service.ts b/apps/nestjs-backend/src/features/space/space.service.ts index 6a021348b..00beadd08 100644 --- a/apps/nestjs-backend/src/features/space/space.service.ts +++ b/apps/nestjs-backend/src/features/space/space.service.ts @@ -104,7 +104,7 @@ export class SpaceService { }); const names = spaceList.map((space) => space.name); - const uniqName = getUniqName(createSpaceRo.name ?? 'Workspace', names); + const uniqName = getUniqName(createSpaceRo.name ?? 'Space', names); return await this.createSpaceByParams({ id: generateSpaceId(), name: uniqName, diff --git a/apps/nestjs-backend/src/features/table/constant.ts b/apps/nestjs-backend/src/features/table/constant.ts index e9ae1f2a4..801af34aa 100644 --- a/apps/nestjs-backend/src/features/table/constant.ts +++ b/apps/nestjs-backend/src/features/table/constant.ts @@ -32,7 +32,7 @@ export const DEFAULT_FIELDS: IFieldRo[] = [ // eslint-disable-next-line @typescript-eslint/naming-convention export const DEFAULT_VIEWS: IViewRo[] = [ { - name: 'GridView', + name: 'Grid view', type: ViewType.Grid, }, ]; diff --git a/apps/nestjs-backend/src/features/table/open-api/graph.service.ts b/apps/nestjs-backend/src/features/table/open-api/graph.service.ts index cd8e53af0..324946743 100644 --- a/apps/nestjs-backend/src/features/table/open-api/graph.service.ts +++ b/apps/nestjs-backend/src/features/table/open-api/graph.service.ts @@ -9,10 +9,10 @@ import type { import { FieldType } from '@teable-group/core'; import { PrismaService } from '@teable-group/db-main-prisma'; import { keyBy } from 'lodash'; -import type { IFieldMap, IRecordItem } from '../../calculation/reference.service'; +import type { IRecordItem } from '../../calculation/reference.service'; import { ReferenceService } from '../../calculation/reference.service'; import { FieldService } from '../../field/field.service'; -import type { IFieldInstance } from '../../field/model/factory'; +import type { IFieldInstance, IFieldMap } from '../../field/model/factory'; import type { FormulaFieldDto } from '../../field/model/field-dto/formula-field.dto'; import { RecordService } from '../../record/record.service'; @@ -89,16 +89,16 @@ export class GraphService { fieldMap: IFieldMap, tableMap: { [dbTableName: string]: { dbTableName: string; name: string } }, selectedCell: { recordId: string; fieldId: string }, - dbTableName2records: { [dbTableName: string]: ITinyRecord[] } + dbTableName2recordMap: { [dbTableName: string]: Record } ) { const nodes: IGraphNode[] = []; const combos: IGraphCombo[] = []; - Object.entries(dbTableName2records).forEach(([dbTableName, records]) => { + Object.entries(dbTableName2recordMap).forEach(([dbTableName, recordMap]) => { combos.push({ id: dbTableName, label: tableMap[dbTableName].name, }); - records.forEach((record) => { + Object.values(recordMap).forEach((record) => { Object.entries(record.fields).forEach(([fieldId, cellValue]) => { const field = fieldMap[fieldId]; nodes.push({ @@ -130,13 +130,13 @@ export class GraphService { async getGraph(tableId: string, cell: [number, number], viewId?: string): Promise { const { recordId, fieldId, cellValue } = await this.getCellInfo(tableId, cell, viewId); - const prepared = await this.referenceService.prepareCalculation(tableId, [ + const prepared = await this.referenceService.prepareCalculation([ { id: recordId, fieldId: fieldId, newValue: cellValue }, ]); if (!prepared) { return; } - const { orderWithRecordsByFieldId, fieldMap, dbTableName2records, tableId2DbTableName } = + const { orderWithRecordsByFieldId, fieldMap, dbTableName2recordMap, tableId2DbTableName } = prepared; const tableMap = await this.getTableMap(tableId2DbTableName); const orderWithRecords = orderWithRecordsByFieldId[fieldId]; @@ -144,11 +144,11 @@ export class GraphService { fieldMap, tableMap, { recordId, fieldId }, - dbTableName2records + dbTableName2recordMap ); const edges = orderWithRecords.reduce((pre, order) => { const field = fieldMap[order.id]; - order.recordItems.forEach((record) => { + Object.values(order.recordItemMap || {}).forEach((record) => { if (field.lookupOptions || field.type === FieldType.Link) { const lookupEdge = this.getLookupEdge(field, fieldMap, record); lookupEdge && pre.push(...lookupEdge); diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts index ed2730617..13a31d901 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts @@ -5,11 +5,11 @@ import { getGraphRoSchema, IGetGraphRo, getTableQuerySchema, - ICreateTablePreparedRo, IGetTableQuery, tableRoSchema, getRowCountSchema, IGetRowCountRo, + ICreateTableRo, } from '@teable-group/core'; import { ISqlQuerySchema, sqlQuerySchema } from '@teable-group/openapi'; import { ZodValidationPipe } from '../../../zod.validation.pipe'; @@ -64,7 +64,7 @@ export class TableController { @Post() async createTable( @Param('baseId') baseId: string, - @Body(new ZodValidationPipe(tableRoSchema), TablePipe) createTableRo: ICreateTablePreparedRo + @Body(new ZodValidationPipe(tableRoSchema), TablePipe) createTableRo: ICreateTableRo ): Promise { return await this.tableOpenApiService.createTable(baseId, createTableRo); } 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 2a66492e7..85ee38346 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 @@ -1,8 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import type { ICreateRecordsRo, - ICreateTablePreparedRo, ICreateTableRo, + IFieldRo, IFieldVo, IGetRowCountRo, IGetTableQuery, @@ -11,9 +11,10 @@ import type { IViewRo, IViewVo, } from '@teable-group/core'; -import { FieldKeyType } from '@teable-group/core'; +import { FieldKeyType, FieldType } from '@teable-group/core'; import { PrismaService } from '@teable-group/db-main-prisma'; import { FieldCreatingService } from '../../field/field-calculate/field-creating.service'; +import { FieldSupplementService } from '../../field/field-calculate/field-supplement.service'; import { createFieldInstanceByVo } from '../../field/model/factory'; import { RecordOpenApiService } from '../../record/open-api/record-open-api.service'; import { RecordService } from '../../record/record.service'; @@ -29,7 +30,8 @@ export class TableOpenApiService { private readonly viewOpenApiService: ViewOpenApiService, private readonly recordService: RecordService, private readonly tableService: TableService, - private readonly fieldCreatingService: FieldCreatingService + private readonly fieldCreatingService: FieldCreatingService, + private readonly fieldSupplementService: FieldSupplementService ) {} private async createView(tableId: string, viewRos: IViewRo[]) { @@ -56,7 +58,36 @@ export class TableOpenApiService { return this.recordOpenApiService.createRecords(tableId, data.records, data.fieldKeyType); } - async createTable(baseId: string, tableRo: ICreateTablePreparedRo): Promise { + private async prepareFields(tableId: string, fieldRos: IFieldRo[]) { + const fields: IFieldVo[] = []; + const simpleFields: IFieldRo[] = []; + const computeFields: IFieldRo[] = []; + fieldRos.forEach((field) => { + if (field.type === FieldType.Link || field.type === FieldType.Formula || field.isLookup) { + computeFields.push(field); + } else { + simpleFields.push(field); + } + }); + + for (const fieldRo of simpleFields) { + fields.push(await this.fieldSupplementService.prepareCreateField(tableId, fieldRo)); + } + + const allFieldRos = simpleFields.concat(computeFields); + for (const fieldRo of computeFields) { + fields.push( + await this.fieldSupplementService.prepareCreateField( + tableId, + fieldRo, + allFieldRos.filter((ro) => ro !== fieldRo) as IFieldVo[] + ) + ); + } + return fields; + } + + async createTable(baseId: string, tableRo: ICreateTableRo): Promise { return await this.prismaService.$tx(async () => { if (!tableRo.fields || !tableRo.views || !tableRo.records) { throw new Error('table fields views and rows are required.'); @@ -66,7 +97,8 @@ export class TableOpenApiService { const tableId = tableVo.id; const viewVos = await this.createView(tableId, tableRo.views); - const fieldVos = await this.createField(tableId, viewVos, tableRo.fields); + const preparedFields = await this.prepareFields(tableId, tableRo.fields); + const fieldVos = await this.createField(tableId, viewVos, preparedFields); const { records } = await this.createRecords(tableId, { records: tableRo.records, fieldKeyType: tableRo.fieldKeyType ?? FieldKeyType.Name, diff --git a/apps/nestjs-backend/src/features/table/open-api/table.pipe.ts b/apps/nestjs-backend/src/features/table/open-api/table.pipe.ts index 6f8a13579..156d4dbe6 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table.pipe.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table.pipe.ts @@ -1,49 +1,22 @@ import type { ArgumentMetadata, PipeTransform } from '@nestjs/common'; import { Injectable } from '@nestjs/common'; -import type { ICreateTableRo, IFieldRo, IFieldVo } from '@teable-group/core'; -import { FieldType } from '@teable-group/core'; -import { FieldSupplementService } from '../../field/field-calculate/field-supplement.service'; +import type { ICreateTableRo, IFieldVo } from '@teable-group/core'; import { DEFAULT_FIELDS, DEFAULT_RECORD_DATA, DEFAULT_VIEWS } from '../constant'; @Injectable() export class TablePipe implements PipeTransform { - constructor(private readonly fieldSupplementService: FieldSupplementService) {} async transform(value: ICreateTableRo, _metadata: ArgumentMetadata) { return this.prepareDefaultRo(value); } async prepareDefaultRo(tableRo: ICreateTableRo): Promise { - const fieldRos = tableRo.fields ? tableRo.fields : DEFAULT_FIELDS; + const fieldRos = tableRo.fields && tableRo.fields.length ? tableRo.fields : DEFAULT_FIELDS; // make sure first field to be the primary field; (fieldRos[0] as IFieldVo).isPrimary = true; - const fields: IFieldVo[] = []; - const simpleFields: IFieldRo[] = []; - const computeFields: IFieldRo[] = []; - fieldRos.forEach((field) => { - if (field.type === FieldType.Link || field.type === FieldType.Formula || field.isLookup) { - computeFields.push(field); - } else { - simpleFields.push(field); - } - }); - - for (const fieldRo of simpleFields) { - fields.push(await this.fieldSupplementService.prepareCreateField(fieldRo)); - } - - const allFieldRos = simpleFields.concat(computeFields); - for (const fieldRo of computeFields) { - fields.push( - await this.fieldSupplementService.prepareCreateField( - fieldRo, - allFieldRos.filter((ro) => ro !== fieldRo) as IFieldVo[] - ) - ); - } return { ...tableRo, - fields, + fields: fieldRos, views: tableRo.views && tableRo.views.length ? tableRo.views : DEFAULT_VIEWS, records: tableRo.records ? tableRo.records : DEFAULT_RECORD_DATA, }; diff --git a/apps/nestjs-backend/src/features/user/user.service.ts b/apps/nestjs-backend/src/features/user/user.service.ts index 07fb66678..a2770b6cc 100644 --- a/apps/nestjs-backend/src/features/user/user.service.ts +++ b/apps/nestjs-backend/src/features/user/user.service.ts @@ -24,7 +24,7 @@ export class UserService { async createSpaceBySignup(createSpaceRo: ICreateSpaceRo) { const userId = this.cls.get('user.id'); - const uniqName = createSpaceRo.name ?? 'Workspace'; + const uniqName = createSpaceRo.name ?? 'Space'; const space = await this.prismaService.txClient().space.create({ select: { diff --git a/apps/nestjs-backend/src/filter/global-exception.filter.ts b/apps/nestjs-backend/src/filter/global-exception.filter.ts index d0945b0e1..688cd0e07 100644 --- a/apps/nestjs-backend/src/filter/global-exception.filter.ts +++ b/apps/nestjs-backend/src/filter/global-exception.filter.ts @@ -12,7 +12,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { const ctx = host.switchToHttp(); const response = ctx.getResponse(); - const shouldLogStack = 'getStatus' in exception && exception.getStatus?.() >= 500; + const shouldLogStack = !('getStatus' in exception) || exception.getStatus?.() >= 500; this.logger.error(`${exception.message}\n${shouldLogStack ? exception.stack : ''}`); diff --git a/apps/nestjs-backend/src/global/knex/knex.extend.ts b/apps/nestjs-backend/src/global/knex/knex.extend.ts index f3f92b7f4..0192a2b58 100644 --- a/apps/nestjs-backend/src/global/knex/knex.extend.ts +++ b/apps/nestjs-backend/src/global/knex/knex.extend.ts @@ -27,11 +27,29 @@ knex.QueryBuilder.extend('columnList', function (tableName: string) { return this; }); +knex.QueryBuilder.extend('dropIndex', function (tableName: string, indexName: string) { + const driverClient = getDriverName(this); + + switch (driverClient) { + case DriverClient.Sqlite: + return knex(this.client.config).raw(`DROP INDEX ??`, [indexName]); + case DriverClient.Pg: { + const [schema] = tableName.split('.'); + return knex(this.client.config).raw(`DROP INDEX ??.??`, [schema, indexName]); + break; + } + } + return this; +}); + declare module 'knex' { namespace Knex { interface QueryBuilder { columnList(tableName: string): Knex.QueryBuilder; } + interface QueryBuilder { + dropIndex(tableName: string, indexName: string): Knex.QueryBuilder; + } } } diff --git a/apps/nestjs-backend/src/share-db/interface.ts b/apps/nestjs-backend/src/share-db/interface.ts index 4fbff792c..d54bf187f 100644 --- a/apps/nestjs-backend/src/share-db/interface.ts +++ b/apps/nestjs-backend/src/share-db/interface.ts @@ -25,10 +25,6 @@ export interface IAdapterService { ): Promise<{ ids: string[]; extra?: unknown }>; } -export interface ISupplementService { - createForeignKey(collectionId: string, snapshot: unknown): Promise; -} - export interface IShareDbConfig { db: DB; } diff --git a/apps/nestjs-backend/src/share-db/ws-derivate.service.ts b/apps/nestjs-backend/src/share-db/ws-derivate.service.ts index 60fdf6e7c..8939c428e 100644 --- a/apps/nestjs-backend/src/share-db/ws-derivate.service.ts +++ b/apps/nestjs-backend/src/share-db/ws-derivate.service.ts @@ -37,7 +37,6 @@ export class WsDerivateService { const derivate = await this.linkService.getDerivateByLink(changes[0].tableId, changes); const cellChanges = derivate?.cellChanges || []; - const fkRecordMap = derivate?.fkRecordMap || {}; const opsMapOrigin = formatChangesToOps(changes); const opsMapByLink = formatChangesToOps(cellChanges); @@ -48,7 +47,7 @@ export class WsDerivateService { tableId2DbTableName, } = await this.referenceService.calculateOpsMap( composeMaps([opsMapOrigin, opsMapByLink]), - fkRecordMap + derivate?.saveForeignKeyToDb ); opsMaps.push(opsMapByLink, opsMapByCalculate); const composedMap = composeMaps(opsMaps); diff --git a/apps/nestjs-backend/test/field-converting.e2e-spec.ts b/apps/nestjs-backend/test/field-converting.e2e-spec.ts index f70d8bddf..41b04732b 100644 --- a/apps/nestjs-backend/test/field-converting.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-converting.e2e-spec.ts @@ -1458,7 +1458,11 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { }); // make sure symmetricField have been created - const symmetricField = await getField(request, table3.id, newFieldOptions.symmetricFieldId); + const symmetricField = await getField( + request, + table3.id, + newFieldOptions.symmetricFieldId as string + ); expect(symmetricField).toMatchObject({ cellValueType: CellValueType.String, isMultipleCellValue: true, @@ -1533,7 +1537,11 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => { }); // make sure symmetricField have been created - const symmetricField = await getField(request, table3.id, newFieldOptions.symmetricFieldId); + const symmetricField = await getField( + request, + table3.id, + newFieldOptions.symmetricFieldId as string + ); expect(symmetricField).toMatchObject({ cellValueType: CellValueType.String, dbFieldType: DbFieldType.Json, diff --git a/apps/nestjs-backend/test/field-reference.e2e-spec.ts b/apps/nestjs-backend/test/field-reference.e2e-spec.ts index db8286012..2bb430739 100644 --- a/apps/nestjs-backend/test/field-reference.e2e-spec.ts +++ b/apps/nestjs-backend/test/field-reference.e2e-spec.ts @@ -57,8 +57,8 @@ describe('OpenAPI link field reference (e2e)', () => { expect(field2.options.relationship).toBe(Relationship.OneMany); expect(field2.options.foreignTableId).toBe(table1Id); expect(field2.options.symmetricFieldId).toBe(field1.id); - expect(field1.options.dbForeignKeyName).toBe(`__fk_${field1.id}`); - expect(field2.options.dbForeignKeyName).toBe(`__fk_${field1.id}`); + expect(field1.options.foreignKeyName).toBe(`__fk_${field1.id}`); + expect(field2.options.selfKeyName).toBe(`__fk_${field1.id}`); }); it('/api/table/{tableId}/field (POST) create OneMany', async () => { @@ -85,7 +85,7 @@ describe('OpenAPI link field reference (e2e)', () => { expect(field2.options.relationship).toBe(Relationship.ManyOne); expect(field2.options.foreignTableId).toBe(table1Id); expect(field2.options.symmetricFieldId).toBe(field1.id); - expect(field1.options.dbForeignKeyName).toBe(`__fk_${field2.id}`); - expect(field2.options.dbForeignKeyName).toBe(`__fk_${field2.id}`); + expect(field1.options.selfKeyName).toBe(`__fk_${field2.id}`); + expect(field2.options.foreignKeyName).toBe(`__fk_${field2.id}`); }); }); diff --git a/apps/nestjs-backend/test/field.e2e-spec.ts b/apps/nestjs-backend/test/field.e2e-spec.ts index 00047a0b6..2e3d150f0 100644 --- a/apps/nestjs-backend/test/field.e2e-spec.ts +++ b/apps/nestjs-backend/test/field.e2e-spec.ts @@ -436,10 +436,9 @@ describe('OpenAPI FieldController (e2e)', () => { expect(referenceBefore[0].fromFieldId).toBe(table2PrimaryField.id); // foreignKey should be created - const dbTableName = table1.dbTableName; - const { dbForeignKeyName } = linkField.options as ILinkFieldOptions; + const { fkHostTableName, foreignKeyName } = linkField.options as ILinkFieldOptions; const linkedRecords = await prisma.$queryRawUnsafe<{ __id: string }[]>( - knex(dbTableName).select('*').where(dbForeignKeyName, table2.records[0].id).toQuery() + knex(fkHostTableName).select('*').where(foreignKeyName, table2.records[0].id).toQuery() ); expect(linkedRecords.length).toBe(1); @@ -460,14 +459,15 @@ describe('OpenAPI FieldController (e2e)', () => { expect(symLinkReferenceAfter).toBeFalsy(); // foreignKey should be removed - const linkedRecordsAfter = await prisma.$queryRawUnsafe<{ __id: string }[]>( - knex(dbTableName).select('*').whereNotNull(dbForeignKeyName).toQuery() - ); - expect(linkedRecordsAfter.length).toBe(0); + expect( + prisma.$queryRawUnsafe( + knex(fkHostTableName).select('*').whereNotNull(foreignKeyName).toQuery() + ) + ).rejects.toThrow(); // cell should be clean const linkedCellAfter = await prisma.$queryRawUnsafe<{ __id: string }[]>( - knex(dbTableName).select('*').whereNotNull(linkField.dbFieldName).toQuery() + knex(fkHostTableName).select('*').whereNotNull(linkField.dbFieldName).toQuery() ); expect(linkedCellAfter.length).toBe(0); diff --git a/apps/nestjs-backend/test/link-api.e2e-spec.ts b/apps/nestjs-backend/test/link-api.e2e-spec.ts index ae73493dc..847f269d0 100644 --- a/apps/nestjs-backend/test/link-api.e2e-spec.ts +++ b/apps/nestjs-backend/test/link-api.e2e-spec.ts @@ -110,7 +110,8 @@ describe('OpenAPI link (e2e)', () => { relationship: Relationship.OneMany, foreignTableId: table2Id, lookupFieldId: createTable2Result.body.fields[0].id, - dbForeignKeyName: '__fk_' + createTable2Result.body.fields[2].id, + selfKeyName: '__fk_' + createTable2Result.body.fields[2].id, + foreignKeyName: '__id', symmetricFieldId: createTable2Result.body.fields[2].id, }, }); @@ -121,7 +122,8 @@ describe('OpenAPI link (e2e)', () => { relationship: Relationship.ManyOne, foreignTableId: table1Id, lookupFieldId: getTable1FieldsResult.body[0].id, - dbForeignKeyName: '__fk_' + createTable2Result.body.fields[2].id, + foreignKeyName: '__fk_' + createTable2Result.body.fields[2].id, + selfKeyName: '__id', symmetricFieldId: getTable1FieldsResult.body[2].id, }, }); @@ -177,7 +179,8 @@ describe('OpenAPI link (e2e)', () => { relationship: Relationship.ManyOne, foreignTableId: table2Id, lookupFieldId: createTable2Result.body.fields[0].id, - dbForeignKeyName: '__fk_' + getTable1FieldsResult.body[2].id, + selfKeyName: '__id', + foreignKeyName: '__fk_' + getTable1FieldsResult.body[2].id, symmetricFieldId: createTable2Result.body.fields[2].id, }, }); @@ -188,7 +191,8 @@ describe('OpenAPI link (e2e)', () => { relationship: Relationship.OneMany, foreignTableId: table1Id, lookupFieldId: getTable1FieldsResult.body[0].id, - dbForeignKeyName: '__fk_' + getTable1FieldsResult.body[2].id, + foreignKeyName: '__id', + selfKeyName: '__fk_' + getTable1FieldsResult.body[2].id, symmetricFieldId: getTable1FieldsResult.body[2].id, }, }); @@ -988,20 +992,27 @@ describe('OpenAPI link (e2e)', () => { id: table2.records[0].id, }, ]); + const { records: table1Records1 } = await getRecords(request, table1.id); + expect(table1Records1[0].fields[oneManyField.id]).toEqual([ + { + title: 'x', + id: table2.records[0].id, + }, + ]); await updateRecordByApi(request, table2.id, table2.records[0].id, table2.fields[0].id, 'y'); - const { records: table1Records } = await getRecords(request, table1.id); - expect(table1Records[0].fields[oneManyField.id]).toEqual([ + const { records: table1Records2 } = await getRecords(request, table1.id); + expect(table1Records2[0].fields[oneManyField.id]).toEqual([ { title: 'y', id: table2.records[0].id, }, ]); - expect(table1Records[0].fields[lookupOneManyField.id]).toEqual(['y']); - expect(table1Records[0].fields[rollupOneManyField.id]).toEqual(1); - expect(table1Records[0].fields[lookupManyOneField.id]).toEqual(undefined); - expect(table1Records[0].fields[rollupManyOneField.id]).toEqual(undefined); + expect(table1Records2[0].fields[lookupOneManyField.id]).toEqual(['y']); + expect(table1Records2[0].fields[rollupOneManyField.id]).toEqual(1); + expect(table1Records2[0].fields[lookupManyOneField.id]).toEqual(undefined); + expect(table1Records2[0].fields[rollupManyOneField.id]).toEqual(undefined); }); }); @@ -1040,7 +1051,7 @@ describe('OpenAPI link (e2e)', () => { const symManyOneField = await getField( request, table2.id, - (manyOneField.options as ILinkFieldOptions).symmetricFieldId + (manyOneField.options as ILinkFieldOptions).symmetricFieldId as string ); await updateRecordByApi(request, table1.id, table1.records[0].id, manyOneField.id, { @@ -1070,7 +1081,7 @@ describe('OpenAPI link (e2e)', () => { const symManyOneField = await getField( request, table2.id, - (manyOneField.options as ILinkFieldOptions).symmetricFieldId + (manyOneField.options as ILinkFieldOptions).symmetricFieldId as string ); await updateRecordByApi(request, table1.id, table1.records[0].id, manyOneField.id, { @@ -1117,12 +1128,12 @@ describe('OpenAPI link (e2e)', () => { const symManyOneField = await getField( request, table2.id, - (manyOneField.options as ILinkFieldOptions).symmetricFieldId + (manyOneField.options as ILinkFieldOptions).symmetricFieldId as string ); const symOneManyField = await getField( request, table2.id, - (oneManyField.options as ILinkFieldOptions).symmetricFieldId + (oneManyField.options as ILinkFieldOptions).symmetricFieldId as string ); await updateRecordByApi(request, table2.id, table2.records[0].id, symOneManyField.id, { diff --git a/apps/nestjs-backend/test/reference.e2e-spec.ts b/apps/nestjs-backend/test/reference.e2e-spec.ts.bak similarity index 96% rename from apps/nestjs-backend/test/reference.e2e-spec.ts rename to apps/nestjs-backend/test/reference.e2e-spec.ts.bak index 2c17d6324..c06c02a80 100644 --- a/apps/nestjs-backend/test/reference.e2e-spec.ts +++ b/apps/nestjs-backend/test/reference.e2e-spec.ts.bak @@ -33,7 +33,6 @@ describe('Reference Service (e2e)', () => { }[]; let db: Knex; const s = JSON.stringify; - beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [GlobalModule, CalculationModule], @@ -42,11 +41,9 @@ describe('Reference Service (e2e)', () => { prisma = module.get(PrismaService); db = module.get('CUSTOM_KNEX'); }); - afterAll(async () => { await prisma.$disconnect(); }); - async function executeKnex(builder: Knex.SchemaBuilder | Knex.QueryBuilder) { const sql = builder.toSQL(); if (Array.isArray(sql)) { @@ -58,7 +55,6 @@ describe('Reference Service (e2e)', () => { await prisma.$executeRawUnsafe(nativeSql.sql, ...nativeSql.bindings); } } - beforeEach(async () => { // create tables await executeKnex( @@ -85,7 +81,6 @@ describe('Reference Service (e2e)', () => { table.string('__fk_manyToOneB'); }) ); - initialReferences = [ { fromFieldId: 'f1', toFieldId: 'f2' }, { fromFieldId: 'f2', toFieldId: 'f3' }, @@ -94,14 +89,12 @@ describe('Reference Service (e2e)', () => { { fromFieldId: 'f5', toFieldId: 'f4' }, { fromFieldId: 'f7', toFieldId: 'f8' }, ]; - for (const data of initialReferences) { await prisma.reference.create({ data, }); } }); - afterEach(async () => { // Delete test data for (const data of initialReferences) { @@ -118,23 +111,6 @@ describe('Reference Service (e2e)', () => { await executeKnex(db.schema.dropTable('B')); await executeKnex(db.schema.dropTable('C')); }); - - it('topological order with dependencies:', async () => { - const graph = [ - { fromFieldId: 'a', toFieldId: 'c' }, - { fromFieldId: 'b', toFieldId: 'c' }, - { fromFieldId: 'c', toFieldId: 'd' }, - ]; - - const sortedNodes = service['getTopologicalOrder']('a', graph); - - expect(sortedNodes).toEqual([ - { id: 'a', dependencies: [] }, - { id: 'c', dependencies: ['a', 'b'] }, - { id: 'd', dependencies: ['c'] }, - ]); - }); - it('many to one link relationship order for getAffectedRecords', async () => { // fill data await executeKnex( @@ -179,7 +155,6 @@ describe('Reference Service (e2e)', () => { { __id: 'idC4', fieldC: 'C4', manyToOneB: 'A2', __fk_manyToOneB: 'idB3' }, ]) ); - const topoOrder = [ { dbTableName: 'B', @@ -198,11 +173,9 @@ describe('Reference Service (e2e)', () => { dependencies: ['fieldB'], }, ]; - const records = await service['getAffectedRecordItems'](topoOrder, [ { id: 'idA1', dbTableName: 'A' }, ]); - expect(records).toEqual([ { id: 'idA1', dbTableName: 'A' }, { id: 'idB1', dbTableName: 'B', fieldId: 'manyToOneA', relationTo: 'idA1' }, @@ -211,12 +184,10 @@ describe('Reference Service (e2e)', () => { { id: 'idC2', dbTableName: 'C', fieldId: 'manyToOneB', relationTo: 'idB1' }, { id: 'idC3', dbTableName: 'C', fieldId: 'manyToOneB', relationTo: 'idB2' }, ]); - const recordsWithMultiInput = await service['getAffectedRecordItems'](topoOrder, [ { id: 'idA1', dbTableName: 'A' }, { id: 'idA2', dbTableName: 'A' }, ]); - expect(recordsWithMultiInput).toEqual([ { id: 'idA1', dbTableName: 'A' }, { id: 'idA2', dbTableName: 'A' }, @@ -229,7 +200,6 @@ describe('Reference Service (e2e)', () => { { id: 'idC4', dbTableName: 'C', relationTo: 'idB3', fieldId: 'manyToOneB' }, ]); }); - it('one to many link relationship order for getAffectedRecords', async () => { await executeKnex( db('A').insert([{ __id: 'idA1', fieldA: 'A1', oneToManyB: s(['C1, C2', 'C3']) }]) @@ -287,11 +257,9 @@ describe('Reference Service (e2e)', () => { linkedTable: 'B', }, ]; - const records = await service['getAffectedRecordItems'](topoOrder, [ { id: 'idC1', dbTableName: 'C' }, ]); - // manyToOneB: ['B1', 'B2'] expect(records).toEqual([ { id: 'idC1', dbTableName: 'C' }, @@ -300,9 +268,7 @@ describe('Reference Service (e2e)', () => { { id: 'idC1', dbTableName: 'C', fieldId: 'manyToOneB', relationTo: 'idB1' }, { id: 'idC2', dbTableName: 'C', fieldId: 'manyToOneB', relationTo: 'idB1' }, ]); - const extraRecords = await service['getDependentRecordItems'](records); - expect(extraRecords).toEqual([ { id: 'idB1', dbTableName: 'B', fieldId: 'oneToManyB', relationTo: 'idA1' }, { id: 'idB2', dbTableName: 'B', fieldId: 'oneToManyB', relationTo: 'idA1' }, @@ -310,14 +276,12 @@ describe('Reference Service (e2e)', () => { { id: 'idC2', dbTableName: 'C', fieldId: 'oneToManyC', relationTo: 'idB1' }, ]); }); - it('getDependentNodesCTE should return all dependent nodes', async () => { const result = await service['getDependentNodesCTE'](['f2']); const resultData = [...initialReferences]; resultData.pop(); expect(result).toEqual(expect.arrayContaining(resultData)); }); - it('should filter full graph by fieldIds', async () => { /** * f1 -> f3 -> f4 @@ -349,7 +313,6 @@ describe('Reference Service (e2e)', () => { ); }); }); - describe('ReferenceService calculation', () => { let service: ReferenceService; let fieldMap: { [oneToMany: string]: IFieldInstance }; @@ -357,15 +320,12 @@ describe('Reference Service (e2e)', () => { let recordMap: { [recordId: string]: IRecord }; let ordersWithRecords: ITopoItemWithRecords[]; let tableId2DbTableName: { [tableId: string]: string }; - beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [GlobalModule, CalculationModule], }).compile(); - service = module.get(ReferenceService); }); - beforeEach(() => { fieldMap = { fieldA: createFieldInstanceByVo({ @@ -483,7 +443,6 @@ describe('Reference Service (e2e)', () => { dbFieldType: DbFieldType.Json, } as LinkFieldDto), }; - fieldId2TableId = { fieldA: 'A', oneToManyB: 'A', @@ -493,13 +452,11 @@ describe('Reference Service (e2e)', () => { fieldC: 'C', manyToOneB: 'C', }; - tableId2DbTableName = { A: 'A', B: 'B', C: 'C', }; - recordMap = { // use new value fieldC: 'CX' here idC1: { @@ -550,7 +507,6 @@ describe('Reference Service (e2e)', () => { recordOrder: {}, }, }; - // topoOrder Graph: // C.fieldC -> B.oneToManyC -> B.fieldB -> A.oneToManyB // -> C.manyToOneB @@ -558,7 +514,7 @@ describe('Reference Service (e2e)', () => { { id: 'oneToManyC', dependencies: ['fieldC'], - recordItems: [ + recordItemMap: [ { record: recordMap['idB1'], dependencies: [recordMap['idC1'], recordMap['idC2']], @@ -568,7 +524,7 @@ describe('Reference Service (e2e)', () => { { id: 'fieldB', dependencies: ['oneToManyC'], - recordItems: [ + recordItemMap: [ { record: recordMap['idB1'], }, @@ -577,7 +533,7 @@ describe('Reference Service (e2e)', () => { { id: 'oneToManyB', dependencies: ['fieldB'], - recordItems: [ + recordItemMap: [ { record: recordMap['idA1'], dependencies: [recordMap['idB1'], recordMap['idB2']], @@ -587,7 +543,7 @@ describe('Reference Service (e2e)', () => { { id: 'manyToOneB', dependencies: ['fieldB'], - recordItems: [ + recordItemMap: [ { record: recordMap['idC1'], dependencies: recordMap['idB1'], @@ -600,16 +556,9 @@ describe('Reference Service (e2e)', () => { }, ]; }); - it('should correctly collect changes for Link and Computed fields', () => { // 2. Act - const changes = service['collectChanges']( - ordersWithRecords, - fieldMap, - fieldId2TableId, - tableId2DbTableName, - {} - ); + const changes = service['collectChanges'](ordersWithRecords, fieldMap, fieldId2TableId); // 3. Assert // topoOrder Graph: // C.fieldC -> B.oneToManyC -> B.fieldB -> A.oneToManyB @@ -671,7 +620,6 @@ describe('Reference Service (e2e)', () => { }, ]); }); - it('should createTopoItemWithRecords from prepared context', () => { const tableId2DbTableName = { A: 'A', @@ -689,14 +637,12 @@ describe('Reference Service (e2e)', () => { { id: 'idC1', dbTableName: 'C', fieldId: 'manyToOneB', relationTo: 'idB1' }, { id: 'idC2', dbTableName: 'C', fieldId: 'manyToOneB', relationTo: 'idB1' }, ]; - const dependentRecordItems = [ { id: 'idB1', dbTableName: 'B', fieldId: 'oneToManyB', relationTo: 'idA1' }, { id: 'idB2', dbTableName: 'B', fieldId: 'oneToManyB', relationTo: 'idA1' }, { id: 'idC1', dbTableName: 'C', fieldId: 'oneToManyC', relationTo: 'idB1' }, { id: 'idC2', dbTableName: 'C', fieldId: 'oneToManyC', relationTo: 'idB1' }, ]; - // topoOrder Graph: // C.fieldC -> B.oneToManyC -> B.fieldB -> A.oneToManyB // -> C.manyToOneB @@ -718,32 +664,26 @@ describe('Reference Service (e2e)', () => { dependencies: ['fieldB'], }, ]; - const topoItems = service['createTopoItemWithRecords']({ tableId2DbTableName, - dbTableName2records, + dbTableName2recordMap: dbTableName2records, affectedRecordItems, dependentRecordItems, fieldMap, fieldId2TableId, topoOrders, }); - expect(topoItems).toEqual(ordersWithRecords); }); }); - describe('ReferenceService simple formula calculation', () => { let service: ReferenceService; - beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [GlobalModule, CalculationModule], }).compile(); - service = module.get(ReferenceService); }); - it('should correctly collect changes for Computed fields', () => { const fieldMap = { fieldA: createFieldInstanceByVo({ @@ -775,18 +715,15 @@ describe('Reference Service (e2e)', () => { dbFieldType: DbFieldType.Text, } as SingleLineTextFieldDto), }; - const fieldId2TableId = { fieldA: 'A', fieldB: 'A', fieldC: 'A', }; - const recordMap = { // use new value fieldA: 1 here idA1: { id: 'idA1', fields: { fieldA: 1, fieldB: null, fieldC: 'X' }, recordOrder: {} }, }; - // topoOrder Graph: // A.fieldA -> A.fieldB const ordersWithRecords = [ @@ -800,13 +737,7 @@ describe('Reference Service (e2e)', () => { ], }, ]; - const changes = service['collectChanges']( - ordersWithRecords, - fieldMap, - fieldId2TableId, - {}, - {} - ); + const changes = service['collectChanges'](ordersWithRecords, fieldMap, fieldId2TableId); expect(changes).toEqual([ { tableId: 'A', diff --git a/apps/nestjs-backend/test/rollup.e2e-spec.ts b/apps/nestjs-backend/test/rollup.e2e-spec.ts index 3dbe99cec..147a8c9e3 100644 --- a/apps/nestjs-backend/test/rollup.e2e-spec.ts +++ b/apps/nestjs-backend/test/rollup.e2e-spec.ts @@ -487,12 +487,12 @@ describe('OpenAPI Rollup field (e2e)', () => { [{ id: table2.records[1].id }, { id: table2.records[2].id }] ); - const lookupFieldVo = await rollupFrom(table2, lookedUpToField.id); + const rollupFieldVo = await rollupFrom(table2, lookedUpToField.id); const record0 = await getRecord(table2.id, table2.records[0].id); - expect(record0.fields[lookupFieldVo.id]).toEqual(undefined); + expect(record0.fields[rollupFieldVo.id]).toEqual(undefined); const record1 = await getRecord(table2.id, table2.records[1].id); - expect(record1.fields[lookupFieldVo.id]).toEqual(1); + expect(record1.fields[rollupFieldVo.id]).toEqual(1); const record2 = await getRecord(table2.id, table2.records[2].id); - expect(record2.fields[lookupFieldVo.id]).toEqual(1); + expect(record2.fields[rollupFieldVo.id]).toEqual(1); }); }); diff --git a/apps/nestjs-backend/test/view.e2e-spec.ts b/apps/nestjs-backend/test/view.e2e-spec.ts index ac621a300..751b1dcfa 100644 --- a/apps/nestjs-backend/test/view.e2e-spec.ts +++ b/apps/nestjs-backend/test/view.e2e-spec.ts @@ -6,7 +6,7 @@ import { initApp } from './utils/init-app'; const defaultViews = [ { - name: 'GridView', + name: 'Grid view', type: ViewType.Grid, }, ]; diff --git a/apps/nextjs-app/src/features/app/blocks/space/SpaceCard.tsx b/apps/nextjs-app/src/features/app/blocks/space/SpaceCard.tsx index 5865515a5..5cbbd4430 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/SpaceCard.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/SpaceCard.tsx @@ -133,9 +133,7 @@ export const SpaceCard: FC = (props) => { ))} ) : ( -
- This workspace is empty -
+
This space is empty
)} diff --git a/apps/nextjs-app/src/features/app/blocks/space/SpacePage.tsx b/apps/nextjs-app/src/features/app/blocks/space/SpacePage.tsx index de5358e09..28630b6fa 100644 --- a/apps/nextjs-app/src/features/app/blocks/space/SpacePage.tsx +++ b/apps/nextjs-app/src/features/app/blocks/space/SpacePage.tsx @@ -36,9 +36,9 @@ export const SpacePage: FC = () => { return (
-

All Workspaces

+

All Spaces

diff --git a/apps/nextjs-app/src/features/app/components/collaborator-manage/space/Invite.tsx b/apps/nextjs-app/src/features/app/components/collaborator-manage/space/Invite.tsx index b4d2efcec..1a88554af 100644 --- a/apps/nextjs-app/src/features/app/components/collaborator-manage/space/Invite.tsx +++ b/apps/nextjs-app/src/features/app/components/collaborator-manage/space/Invite.tsx @@ -108,7 +108,7 @@ export const Invite: React.FC = (props) => { ))} setEmail(e.target.value)} diff --git a/apps/nextjs-app/src/features/app/components/field-setting/hooks/useIsMultipleCellValue.ts b/apps/nextjs-app/src/features/app/components/field-setting/hooks/useIsMultipleCellValue.ts index 7c7a1cd80..81e1746b9 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/hooks/useIsMultipleCellValue.ts +++ b/apps/nextjs-app/src/features/app/components/field-setting/hooks/useIsMultipleCellValue.ts @@ -1,5 +1,5 @@ import type { ILookupOptionsRo } from '@teable-group/core'; -import { Relationship } from '@teable-group/core'; +import { isMultiValueLink } from '@teable-group/core'; import { useFields } from '@teable-group/sdk/hooks'; import type { IFieldInstance, LinkField } from '@teable-group/sdk/model'; import { useMemo } from 'react'; @@ -22,6 +22,6 @@ export const useIsMultipleCellValue = ( const relationship = linkField.options.relationship; - return relationship !== Relationship.ManyOne; + return Boolean(isMultiValueLink(relationship)); }, [fields, isLookup, lookupField?.isMultipleCellValue, lookupOptions]); }; diff --git a/apps/nextjs-app/src/features/app/components/field-setting/useFieldTypeSubtitle.ts b/apps/nextjs-app/src/features/app/components/field-setting/useFieldTypeSubtitle.ts index 3ae09bae4..56a30269f 100644 --- a/apps/nextjs-app/src/features/app/components/field-setting/useFieldTypeSubtitle.ts +++ b/apps/nextjs-app/src/features/app/components/field-setting/useFieldTypeSubtitle.ts @@ -22,18 +22,8 @@ export const useFieldTypeSubtitle = () => { return 'Add an user to a record.'; case FieldType.Date: return 'Enter a date (e.g. 11/12/2023) or choose one from a calendar.'; - case FieldType.PhoneNumber: - return 'Enter a telephone number (e.g. (415) 555-0000).'; - case FieldType.Email: - return 'Enter an email address (e.g. bieber@example.com).'; - case FieldType.URL: - return 'Enter a URL (e.g. teable.io or https://github.com/teableio).'; case FieldType.Number: return 'Enter a number, or prefill each new cell with a default value.'; - case FieldType.Currency: - return 'Enter a monetary amount, or prefill each new cell with a default value.'; - case FieldType.Percent: - return 'Enter a percentage, or prefill each new cell with a default value.'; case FieldType.Duration: return 'Enter a duration of time in hours, minutes or seconds (e.g. 1:23).'; case FieldType.Rating: diff --git a/apps/nextjs-app/src/features/app/components/setting/System.tsx b/apps/nextjs-app/src/features/app/components/setting/System.tsx index 69a3eae14..9016c2048 100644 --- a/apps/nextjs-app/src/features/app/components/setting/System.tsx +++ b/apps/nextjs-app/src/features/app/components/setting/System.tsx @@ -40,7 +40,7 @@ export const System: React.FC = () => {

System

- Upload your .db file here, it will auto import and replace your workspace + Upload your .db file here, it will auto import and replace your space

@@ -58,7 +58,7 @@ export const System: React.FC = () => {
- +