Skip to content

Commit 99c800a

Browse files
nichenqinteable-bot
authored andcommitted
[sync] T2324: fix v1 create for legacy createdTime columns (#1427)
Synced from teableio/teable-ee@431bf87
1 parent 21436da commit 99c800a

2 files changed

Lines changed: 266 additions & 3 deletions

File tree

apps/nestjs-backend/src/features/record/record.service.ts

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
CellFormat,
2626
CellValueType,
2727
DbFieldType,
28+
DriverClient,
2829
FieldKeyType,
2930
FieldType,
3031
generateRecordId,
@@ -91,6 +92,11 @@ import { InjectRecordQueryBuilder, IRecordQueryBuilder } from './query-builder';
9192
import { RecordPermissionService } from './record-permission.service';
9293

9394
type IUserFields = { id: string; dbFieldName: string }[];
95+
type IGeneratedColumnMeta = { meta?: { persistedAsGeneratedColumn?: boolean } };
96+
type IGeneratedColumnStateRow = {
97+
column_name: string;
98+
is_generated: string | null;
99+
};
94100

95101
function removeUndefined<T extends Record<string, unknown>>(obj: T) {
96102
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as T;
@@ -135,6 +141,61 @@ export class RecordService {
135141
return field.dbFieldName;
136142
}
137143

144+
private async getWritableCreatedTimeFieldNames(
145+
dbTableName: string,
146+
fields: readonly FieldCore[]
147+
): Promise<Set<string>> {
148+
const createdTimeFields = fields.filter(
149+
(field) => field.type === FieldType.CreatedTime && !field.isLookup
150+
);
151+
if (!createdTimeFields.length) {
152+
return new Set<string>();
153+
}
154+
155+
const fallbackWritableFieldNames = new Set(
156+
createdTimeFields
157+
.filter(
158+
(field) => (field as IGeneratedColumnMeta).meta?.persistedAsGeneratedColumn !== true
159+
)
160+
.map((field) => field.dbFieldName)
161+
);
162+
163+
if (this.dbProvider.driver !== DriverClient.Pg) {
164+
return fallbackWritableFieldNames;
165+
}
166+
167+
const [schemaName, tableName] = this.dbProvider.splitTableName(dbTableName);
168+
const sqlNative = this.knex('information_schema.columns')
169+
.select<IGeneratedColumnStateRow[]>('column_name', 'is_generated')
170+
.where({
171+
table_schema: schemaName,
172+
table_name: tableName,
173+
})
174+
.whereIn(
175+
'column_name',
176+
createdTimeFields.map((field) => field.dbFieldName)
177+
)
178+
.toSQL()
179+
.toNative();
180+
181+
const rows = await this.prismaService
182+
.txClient()
183+
.$queryRawUnsafe<IGeneratedColumnStateRow[]>(sqlNative.sql, ...sqlNative.bindings);
184+
const columnStateMap = new Map(rows.map((row) => [row.column_name, row.is_generated]));
185+
186+
return new Set(
187+
createdTimeFields
188+
.filter((field) => {
189+
const isGenerated = columnStateMap.get(field.dbFieldName);
190+
if (isGenerated == null) {
191+
return fallbackWritableFieldNames.has(field.dbFieldName);
192+
}
193+
return isGenerated === 'NEVER';
194+
})
195+
.map((field) => field.dbFieldName)
196+
);
197+
}
198+
138199
private dbRecord2RecordFields(
139200
record: IRecord['fields'],
140201
fields: IFieldInstance[],
@@ -1217,6 +1278,10 @@ export class RecordService {
12171278
await this.creditCheck(table.id);
12181279
const dbTableName = table.dbTableName;
12191280
const fields = await this.getFieldsByProjection(table.id);
1281+
const writableCreatedTimeFieldNames = await this.getWritableCreatedTimeFieldNames(
1282+
dbTableName,
1283+
fields
1284+
);
12201285
const auditUserValue =
12211286
user &&
12221287
UserFieldDto.fullAvatarUrl({
@@ -1236,6 +1301,8 @@ export class RecordService {
12361301
);
12371302

12381303
const newRecords = records.map((record) => {
1304+
const createdTime =
1305+
writableCreatedTimeFieldNames.size > 0 ? new Date().toISOString() : undefined;
12391306
const fieldsValues: Record<string, unknown> = {};
12401307
Object.entries(record.fields).forEach(([fieldId, value]) => {
12411308
const fieldInstance = fieldInstanceMap[fieldId];
@@ -1248,12 +1315,18 @@ export class RecordService {
12481315
});
12491316
});
12501317
}
1251-
return {
1318+
writableCreatedTimeFieldNames.forEach((dbFieldName) => {
1319+
if (createdTime != null) {
1320+
fieldsValues[dbFieldName] = createdTime;
1321+
}
1322+
});
1323+
return removeUndefined({
12521324
__id: generateRecordId(),
12531325
__created_by: userId,
1326+
__created_time: createdTime,
12541327
__version: 1,
12551328
...fieldsValues,
1256-
};
1329+
});
12571330
});
12581331
const sql = this.dbProvider.batchInsertSql(dbTableName, newRecords);
12591332
await this.prismaService.txClient().$executeRawUnsafe(sql);
@@ -1343,6 +1416,10 @@ export class RecordService {
13431416

13441417
const { dbTableName, name: tableName } = table;
13451418
const maxRecordOrder = await this.getMaxRecordOrder(dbTableName);
1419+
const writableCreatedTimeFieldNames = await this.getWritableCreatedTimeFieldNames(
1420+
dbTableName,
1421+
fields
1422+
);
13461423

13471424
const views = await this.prismaService.txClient().view.findMany({
13481425
where: { tableId: table.id, deletedTime: null },
@@ -1396,6 +1473,9 @@ export class RecordService {
13961473
.map((order, i) => {
13971474
const snapshot = records[i];
13981475
const fields = snapshot.fields;
1476+
const createdTime =
1477+
snapshot.createdTime ??
1478+
(writableCreatedTimeFieldNames.size > 0 ? new Date().toISOString() : undefined);
13991479

14001480
const dbFieldValueMap = validationFields.reduce(
14011481
(map, field) => {
@@ -1416,17 +1496,28 @@ export class RecordService {
14161496
});
14171497
}
14181498

1499+
const createdTimeFieldValues = Array.from(writableCreatedTimeFieldNames).reduce(
1500+
(map, dbFieldName) => {
1501+
if (createdTime != null) {
1502+
map[dbFieldName] = createdTime;
1503+
}
1504+
return map;
1505+
},
1506+
{} as Record<string, unknown>
1507+
);
1508+
14191509
return removeUndefined({
14201510
__id: snapshot.id,
14211511
__created_by: snapshot.createdBy || userId,
14221512
__last_modified_by: snapshot.lastModifiedBy || undefined,
1423-
__created_time: snapshot.createdTime || undefined,
1513+
__created_time: createdTime,
14241514
__last_modified_time: snapshot.lastModifiedTime || undefined,
14251515
__auto_number: snapshot.autoNumber == null ? undefined : snapshot.autoNumber,
14261516
__version: 1,
14271517
...order,
14281518
...dbFieldValueMap,
14291519
...auditFieldValues,
1520+
...createdTimeFieldValues,
14301521
});
14311522
});
14321523

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/* eslint-disable @typescript-eslint/naming-convention */
2+
import type { INestApplication } from '@nestjs/common';
3+
import { FieldKeyType, FieldType } from '@teable/core';
4+
import { PrismaService } from '@teable/db-main-prisma';
5+
import type { ITableFullVo } from '@teable/openapi';
6+
import { ClsService } from 'nestjs-cls';
7+
import { RecordCreateService } from '../src/features/record/record-modify/record-create.service';
8+
import type { IClsStore } from '../src/types/cls';
9+
import {
10+
createField,
11+
createRecords,
12+
createTable,
13+
initApp,
14+
permanentDeleteTable,
15+
getRecords,
16+
runWithTestUser,
17+
} from './utils/init-app';
18+
19+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
20+
21+
const parseSchemaAndTable = (dbTableName: string): [string, string] => {
22+
const trimQuotes = (value: string) =>
23+
value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
24+
const parts = dbTableName.split('.');
25+
return [trimQuotes(parts[0] ?? dbTableName), trimQuotes(parts[1] ?? dbTableName)];
26+
};
27+
28+
describe('Legacy createdTime create compatibility (e2e)', () => {
29+
let app: INestApplication;
30+
let prisma: PrismaService;
31+
let clsService: ClsService<IClsStore>;
32+
let recordCreateService: RecordCreateService;
33+
const baseId = globalThis.testConfig.baseId;
34+
35+
beforeAll(async () => {
36+
app = (await initApp()).app;
37+
prisma = app.get(PrismaService);
38+
clsService = app.get<ClsService<IClsStore>>(ClsService);
39+
recordCreateService = app.get(RecordCreateService);
40+
});
41+
42+
afterAll(async () => {
43+
await app.close();
44+
});
45+
46+
it('fills legacy plain createdTime columns during create so dependent formulas stay correct', async () => {
47+
const table: ITableFullVo = await createTable(baseId, {
48+
name: 'legacy_created_time_create',
49+
fields: [{ name: 'Name', type: FieldType.SingleLineText }],
50+
records: [],
51+
});
52+
53+
try {
54+
const nameField = table.fields.find((field) => field.name === 'Name');
55+
expect(nameField).toBeDefined();
56+
57+
const createdTimeField = await createField(table.id, {
58+
name: 'Created Time',
59+
type: FieldType.CreatedTime,
60+
});
61+
const statusField = await createField(table.id, {
62+
name: 'Created Status',
63+
type: FieldType.Formula,
64+
options: {
65+
expression: `IF({${createdTimeField.id}}, "ok", "bad")`,
66+
},
67+
});
68+
69+
const tableMeta = await prisma.tableMeta.findUniqueOrThrow({
70+
where: { id: table.id },
71+
select: { dbTableName: true },
72+
});
73+
const [schemaName, rawTableName] = parseSchemaAndTable(tableMeta.dbTableName);
74+
const quotedTableName = `"${schemaName}"."${rawTableName}"`;
75+
76+
await prisma.$executeRawUnsafe(
77+
`ALTER TABLE ${quotedTableName} DROP COLUMN "${createdTimeField.dbFieldName}"`
78+
);
79+
await prisma.$executeRawUnsafe(
80+
`ALTER TABLE ${quotedTableName} ADD COLUMN "${createdTimeField.dbFieldName}" TIMESTAMPTZ`
81+
);
82+
await prisma.$executeRawUnsafe(
83+
`UPDATE field SET meta = NULL WHERE id = '${createdTimeField.id}'`
84+
);
85+
86+
const created = await createRecords(table.id, {
87+
fieldKeyType: FieldKeyType.Id,
88+
records: [
89+
{
90+
fields: {
91+
[nameField!.id]: 'legacy-row',
92+
},
93+
},
94+
],
95+
});
96+
97+
const recordId = created.records[0].id;
98+
let row:
99+
| {
100+
created_time: Date | string | null;
101+
legacy_created_time: Date | string | null;
102+
created_status: string | null;
103+
}
104+
| undefined;
105+
106+
for (let i = 0; i < 20; i++) {
107+
const rows = await prisma.$queryRawUnsafe<
108+
{
109+
created_time: Date | string | null;
110+
legacy_created_time: Date | string | null;
111+
created_status: string | null;
112+
}[]
113+
>(
114+
`SELECT "__created_time" AS created_time,
115+
"${createdTimeField.dbFieldName}" AS legacy_created_time,
116+
"${statusField.dbFieldName}" AS created_status
117+
FROM ${quotedTableName}
118+
WHERE "__id" = '${recordId}'`
119+
);
120+
row = rows[0];
121+
if (row?.legacy_created_time && row.created_status === 'ok') {
122+
break;
123+
}
124+
await sleep(200);
125+
}
126+
127+
expect(row?.created_time).toBeTruthy();
128+
expect(row?.legacy_created_time).toBeTruthy();
129+
expect(row?.created_status).toBe('ok');
130+
expect(new Date(row!.legacy_created_time as string | Date).toISOString()).toEqual(
131+
new Date(row!.created_time as string | Date).toISOString()
132+
);
133+
} finally {
134+
await permanentDeleteTable(baseId, table.id);
135+
}
136+
});
137+
138+
it('keeps createRecordsOnlySql working for tables without legacy createdTime columns', async () => {
139+
const table: ITableFullVo = await createTable(baseId, {
140+
name: 'create_records_only_sql_plain',
141+
fields: [{ name: 'Name', type: FieldType.SingleLineText }],
142+
records: [],
143+
});
144+
145+
try {
146+
const nameField = table.fields.find((field) => field.name === 'Name');
147+
expect(nameField).toBeDefined();
148+
149+
await runWithTestUser(clsService, async () => {
150+
await recordCreateService.createRecordsOnlySql(table.id, {
151+
fieldKeyType: FieldKeyType.Id,
152+
records: [
153+
{
154+
fields: {
155+
[nameField!.id]: 'plain-row',
156+
},
157+
},
158+
],
159+
});
160+
});
161+
162+
const result = await getRecords(table.id, {
163+
fieldKeyType: FieldKeyType.Id,
164+
});
165+
166+
expect(result.records).toHaveLength(1);
167+
expect(result.records[0].fields[nameField!.id]).toBe('plain-row');
168+
} finally {
169+
await permanentDeleteTable(baseId, table.id);
170+
}
171+
});
172+
});

0 commit comments

Comments
 (0)