Skip to content

Commit d495386

Browse files
committed
feat: link field integrity check
1 parent a84669c commit d495386

File tree

14 files changed

+1144
-194
lines changed

14 files changed

+1144
-194
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,14 @@ cd apps/nestjs-backend
204204
pnpm dev
205205
```
206206

207+
By default, the plugin development server is not started. To preview and develop plugins, run:
208+
```sh
209+
cd plugins
210+
pnpm dev
211+
```
212+
This will start the plugin development server on port 3002.
213+
214+
207215
## Why Teable?
208216

209217
No-code tools have significantly speed up how we get things done, allowing non-tech users to build amazing apps and changing the way many work and live. People like using spreadsheet-like UI to handle their data because it's easy, flexible, and great for team collaboration. They also prefer designing their app screens without being stuck with clunky templates.

apps/nestjs-backend/src/db-provider/postgres.provider.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,9 +450,22 @@ export class PostgresProvider implements IDbProvider {
450450
.select({
451451
tableId: 'table_id',
452452
id: 'id',
453-
type: 'type',
454453
name: 'name',
454+
description: 'description',
455+
notNull: 'not_null',
456+
unique: 'unique',
457+
isPrimary: 'is_primary',
458+
dbFieldName: 'db_field_name',
459+
isComputed: 'is_computed',
460+
isPending: 'is_pending',
461+
hasError: 'has_error',
462+
dbFieldType: 'db_field_type',
463+
isMultipleCellValue: 'is_multiple_cell_value',
464+
isLookup: 'is_lookup',
465+
lookupOptions: 'lookup_options',
466+
type: 'type',
455467
options: 'options',
468+
cellValueType: 'cell_value_type',
456469
})
457470
.whereNull('deleted_time')
458471
.whereNull('is_lookup')

apps/nestjs-backend/src/db-provider/sqlite.provider.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,10 +406,24 @@ export class SqliteProvider implements IDbProvider {
406406
optionsQuery(type: FieldType, optionsKey: string, value: string): string {
407407
return this.knex('field')
408408
.select({
409+
tableId: 'table_id',
409410
id: 'id',
410-
type: 'type',
411411
name: 'name',
412+
description: 'description',
413+
notNull: 'not_null',
414+
unique: 'unique',
415+
isPrimary: 'is_primary',
416+
dbFieldName: 'db_field_name',
417+
isComputed: 'is_computed',
418+
isPending: 'is_pending',
419+
hasError: 'has_error',
420+
dbFieldType: 'db_field_type',
421+
isMultipleCellValue: 'is_multiple_cell_value',
422+
isLookup: 'is_lookup',
423+
lookupOptions: 'lookup_options',
424+
type: 'type',
412425
options: 'options',
426+
cellValueType: 'cell_value_type',
413427
})
414428
.where('type', type)
415429
.whereNull('is_lookup')

apps/nestjs-backend/src/features/calculation/batch.service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
2-
import { Injectable, Logger } from '@nestjs/common';
2+
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
33
import type { IOtOperation } from '@teable/core';
44
import { IdPrefix, RecordOpBuilder } from '@teable/core';
55
import { PrismaService } from '@teable/db-main-prisma';
@@ -104,6 +104,12 @@ export class BatchService {
104104
);
105105
const versionGroup = keyBy(raw, '__id');
106106

107+
opsPair.map(([recordId]) => {
108+
if (!versionGroup[recordId]) {
109+
throw new BadRequestException(`Record ${recordId} not found in ${tableId}`);
110+
}
111+
});
112+
107113
const opsData = this.buildRecordOpsData(opsPair, versionGroup);
108114
if (!opsData.length) return;
109115

apps/nestjs-backend/src/features/calculation/reference.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,7 @@ export class ReferenceService {
617617
const result = dependenciesIndexed[v.id];
618618
if (!result) {
619619
throw new InternalServerErrorException(
620-
`Record not found for: ${JSON.stringify(v)}, fieldId: ${field.id}`
620+
`Record not found for: ${JSON.stringify(v)}, fieldId: ${field.id}, when calculate ${JSON.stringify(recordItem.record.id)}`
621621
);
622622
}
623623
return result;
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { FieldType, type ILinkFieldOptions } from '@teable/core';
3+
import { Prisma, PrismaService } from '@teable/db-main-prisma';
4+
import { IntegrityIssueType, type IIntegrityIssue } from '@teable/openapi';
5+
import { Knex } from 'knex';
6+
import { InjectModel } from 'nest-knexjs';
7+
import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto';
8+
9+
@Injectable()
10+
export class ForeignKeyIntegrityService {
11+
private readonly logger = new Logger(ForeignKeyIntegrityService.name);
12+
13+
constructor(
14+
private readonly prismaService: PrismaService,
15+
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex
16+
) {}
17+
18+
async getIssues(tableId: string, field: LinkFieldDto): Promise<IIntegrityIssue[]> {
19+
const { foreignTableId, fkHostTableName, foreignKeyName, selfKeyName } = field.options;
20+
const issues: IIntegrityIssue[] = [];
21+
22+
const { name: selfTableName, dbTableName: selfTableDbTableName } =
23+
await this.prismaService.tableMeta.findFirstOrThrow({
24+
where: { id: tableId, deletedTime: null },
25+
select: { name: true, dbTableName: true },
26+
});
27+
28+
const { name: foreignTableName, dbTableName: foreignTableDbTableName } =
29+
await this.prismaService.tableMeta.findFirstOrThrow({
30+
where: { id: foreignTableId, deletedTime: null },
31+
select: { name: true, dbTableName: true },
32+
});
33+
34+
// Check self references
35+
if (selfTableDbTableName !== fkHostTableName) {
36+
const selfIssues = await this.checkInvalidReferences({
37+
fkHostTableName,
38+
targetTableName: selfTableDbTableName,
39+
keyName: selfKeyName,
40+
field,
41+
referencedTableName: selfTableName,
42+
isSelfReference: true,
43+
});
44+
issues.push(...selfIssues);
45+
}
46+
47+
// Check foreign references
48+
if (foreignTableDbTableName !== fkHostTableName) {
49+
const foreignIssues = await this.checkInvalidReferences({
50+
fkHostTableName,
51+
targetTableName: foreignTableDbTableName,
52+
keyName: foreignKeyName,
53+
field,
54+
referencedTableName: foreignTableName,
55+
isSelfReference: false,
56+
});
57+
issues.push(...foreignIssues);
58+
}
59+
60+
return issues;
61+
}
62+
63+
private async checkInvalidReferences({
64+
fkHostTableName,
65+
targetTableName,
66+
keyName,
67+
field,
68+
referencedTableName,
69+
isSelfReference,
70+
}: {
71+
fkHostTableName: string;
72+
targetTableName: string;
73+
keyName: string;
74+
field: { id: string; name: string };
75+
referencedTableName: string;
76+
isSelfReference: boolean;
77+
}): Promise<IIntegrityIssue[]> {
78+
const issues: IIntegrityIssue[] = [];
79+
80+
const invalidQuery = this.knex(fkHostTableName)
81+
.leftJoin(targetTableName, `${fkHostTableName}.${keyName}`, `${targetTableName}.__id`)
82+
.whereNull(`${targetTableName}.__id`)
83+
.count(`${fkHostTableName}.${keyName} as count`)
84+
.first()
85+
.toQuery();
86+
87+
try {
88+
const invalidRefs =
89+
await this.prismaService.$queryRawUnsafe<{ count: bigint }[]>(invalidQuery);
90+
const refCount = Number(invalidRefs[0]?.count || 0);
91+
92+
if (refCount > 0) {
93+
const message = isSelfReference
94+
? `Found ${refCount} invalid self references in table ${referencedTableName}`
95+
: `Found ${refCount} invalid foreign references to table ${referencedTableName}`;
96+
97+
issues.push({
98+
type: IntegrityIssueType.MissingRecordReference,
99+
message: `${message} (Field Name: ${field.name}, Field ID: ${field.id})`,
100+
});
101+
}
102+
} catch (error) {
103+
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2010') {
104+
console.error('error ignored:', error);
105+
} else {
106+
throw error;
107+
}
108+
}
109+
110+
return issues;
111+
}
112+
113+
async fix(_tableId: string, fieldId: string): Promise<IIntegrityIssue | undefined> {
114+
const field = await this.prismaService.field.findFirstOrThrow({
115+
where: { id: fieldId, type: FieldType.Link, isLookup: null, deletedTime: null },
116+
});
117+
118+
const options = JSON.parse(field.options as string) as ILinkFieldOptions;
119+
const { foreignTableId, fkHostTableName, foreignKeyName, selfKeyName } = options;
120+
const foreignTable = await this.prismaService.tableMeta.findFirstOrThrow({
121+
where: { id: foreignTableId, deletedTime: null },
122+
select: { id: true, name: true, dbTableName: true },
123+
});
124+
125+
let totalFixed = 0;
126+
127+
// Fix invalid self references
128+
if (fkHostTableName !== fkHostTableName) {
129+
const selfDeleted = await this.deleteMissingReferences({
130+
fkHostTableName,
131+
targetTableName: fkHostTableName,
132+
keyName: selfKeyName,
133+
});
134+
totalFixed += selfDeleted;
135+
}
136+
137+
// Fix invalid foreign references
138+
if (foreignTable.dbTableName !== fkHostTableName) {
139+
const foreignDeleted = await this.deleteMissingReferences({
140+
fkHostTableName,
141+
targetTableName: foreignTable.dbTableName,
142+
keyName: foreignKeyName,
143+
});
144+
totalFixed += foreignDeleted;
145+
}
146+
147+
if (totalFixed > 0) {
148+
return {
149+
type: IntegrityIssueType.MissingRecordReference,
150+
message: `Fixed ${totalFixed} invalid references and inconsistent links for link field (Field Name: ${field.name}, Field ID: ${field.id})`,
151+
};
152+
}
153+
}
154+
155+
private async deleteMissingReferences({
156+
fkHostTableName,
157+
targetTableName,
158+
keyName,
159+
}: {
160+
fkHostTableName: string;
161+
targetTableName: string;
162+
keyName: string;
163+
}) {
164+
const deleteQuery = this.knex(fkHostTableName)
165+
.whereNotExists(
166+
this.knex
167+
.select('__id')
168+
.from(targetTableName)
169+
.where('__id', this.knex.ref(`${fkHostTableName}.${keyName}`))
170+
)
171+
.delete()
172+
.toQuery();
173+
return await this.prismaService.$executeRawUnsafe(deleteQuery);
174+
}
175+
}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { Module } from '@nestjs/common';
2+
import { ForeignKeyIntegrityService } from './foreign-key.service';
23
import { IntegrityController } from './integrity.controller';
4+
import { LinkFieldIntegrityService } from './link-field.service';
35
import { LinkIntegrityService } from './link-integrity.service';
46

57
@Module({
68
controllers: [IntegrityController],
7-
providers: [LinkIntegrityService],
9+
providers: [ForeignKeyIntegrityService, LinkFieldIntegrityService, LinkIntegrityService],
810
exports: [LinkIntegrityService],
911
})
1012
export class IntegrityModule {}

0 commit comments

Comments
 (0)