Skip to content

Commit 9c0aaf5

Browse files
authored
fix: field unique index finder and add UniqueIndexService for index check (#1670)
* feat: enhanced FieldService find unique indexes and add UniqueIndexService for index check * feat: enhance index retrieval in FieldService and update Integrity component for better display * refactor: simplify findUniqueIndexesForField method by removing fieldId parameter * feat: enhance index retrieval in Postgres provider * feat: update index retrieval in SQLite provider and FieldService for consistency with Postgres
1 parent 7f2dcf8 commit 9c0aaf5

File tree

11 files changed

+379
-30
lines changed

11 files changed

+379
-30
lines changed

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -555,9 +555,28 @@ WHERE tc.constraint_type = 'FOREIGN KEY'
555555
return this.knex
556556
.raw(
557557
`
558-
SELECT indexname as name
559-
FROM pg_indexes
560-
WHERE tablename = ?
558+
SELECT
559+
i.relname AS name,
560+
ix.indisunique AS "isUnique",
561+
CAST(jsonb_agg(a.attname ORDER BY u.attposition) AS TEXT) AS columns
562+
FROM
563+
pg_class t,
564+
pg_class i,
565+
pg_index ix,
566+
pg_attribute a,
567+
unnest(ix.indkey) WITH ORDINALITY u(attnum, attposition)
568+
WHERE
569+
t.oid = ix.indrelid
570+
AND i.oid = ix.indexrelid
571+
AND a.attrelid = t.oid
572+
AND a.attnum = u.attnum
573+
AND t.relname = ?
574+
GROUP BY
575+
i.relname,
576+
ix.indisunique,
577+
ix.indisprimary
578+
ORDER BY
579+
i.relname;
561580
`,
562581
[tableName]
563582
)

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,21 @@ export class SqliteProvider implements IDbProvider {
487487
}
488488

489489
getTableIndexes(dbTableName: string): string {
490-
return this.knex.raw(`PRAGMA index_list(??)`, [dbTableName]).toQuery();
490+
return this.knex
491+
.raw(
492+
`SELECT
493+
s.name AS name,
494+
(SELECT "unique" FROM pragma_index_list(s.tbl_name) WHERE name = s.name) AS isUnique,
495+
(SELECT json_group_array(name) FROM pragma_index_info(s.name) ORDER BY seqno) AS columns
496+
FROM
497+
sqlite_schema AS s
498+
WHERE
499+
s.type = 'index'
500+
AND s.tbl_name = ?
501+
ORDER BY
502+
s.name;`,
503+
[dbTableName]
504+
)
505+
.toQuery();
491506
}
492507
}

apps/nestjs-backend/src/features/field/field-calculate/field-converting.service.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,8 +1214,7 @@ export class FieldConvertingService {
12141214

12151215
const matchedIndexes = await this.fieldService.findUniqueIndexesForField(
12161216
dbTableName,
1217-
dbFieldName,
1218-
oldField.id
1217+
dbFieldName
12191218
);
12201219

12211220
const fieldValidationQuery = this.knex.schema

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -373,17 +373,18 @@ export class FieldService implements IReadonlyAdapterService {
373373
});
374374
}
375375

376-
async findUniqueIndexesForField(dbTableName: string, dbFieldName: string, fieldId: string) {
376+
async findUniqueIndexesForField(dbTableName: string, dbFieldName: string) {
377377
const indexesQuery = this.dbProvider.getTableIndexes(dbTableName);
378378
const indexes = await this.prismaService
379379
.txClient()
380-
.$queryRawUnsafe<{ name: string }[]>(indexesQuery);
380+
.$queryRawUnsafe<{ name: string; columns: string; isUnique: boolean }[]>(indexesQuery);
381+
381382
return indexes
382-
.filter(
383-
(index) =>
384-
index.name.includes(`${dbFieldName.toLowerCase()}_unique`) ||
385-
index.name.includes(`${fieldId.toLowerCase()}_unique`)
386-
)
383+
.filter((index) => {
384+
const { columns, isUnique } = index;
385+
const columnsArray = JSON.parse(columns) as string[];
386+
return isUnique && columnsArray.includes(dbFieldName);
387+
})
387388
.map((index) => index.name);
388389
}
389390

@@ -410,7 +411,7 @@ export class FieldService implements IReadonlyAdapterService {
410411
}
411412

412413
const dbTableName = table.dbTableName;
413-
const matchedIndexes = await this.findUniqueIndexesForField(dbTableName, dbFieldName, fieldId);
414+
const matchedIndexes = await this.findUniqueIndexesForField(dbTableName, dbFieldName);
414415

415416
const fieldValidationSqls = this.knex.schema
416417
.alterTable(dbTableName, (table) => {
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import { Module } from '@nestjs/common';
2+
import { FieldModule } from '../field/field.module';
23
import { ForeignKeyIntegrityService } from './foreign-key.service';
34
import { IntegrityController } from './integrity.controller';
45
import { LinkFieldIntegrityService } from './link-field.service';
56
import { LinkIntegrityService } from './link-integrity.service';
7+
import { UniqueIndexService } from './unique-index.service';
68

79
@Module({
10+
imports: [FieldModule],
811
controllers: [IntegrityController],
9-
providers: [ForeignKeyIntegrityService, LinkFieldIntegrityService, LinkIntegrityService],
12+
providers: [
13+
ForeignKeyIntegrityService,
14+
LinkFieldIntegrityService,
15+
LinkIntegrityService,
16+
UniqueIndexService,
17+
],
1018
exports: [LinkIntegrityService],
1119
})
1220
export class IntegrityModule {}

apps/nestjs-backend/src/features/integrity/link-integrity.service.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createFieldInstanceByRaw } from '../field/model/factory';
99
import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto';
1010
import { ForeignKeyIntegrityService } from './foreign-key.service';
1111
import { LinkFieldIntegrityService } from './link-field.service';
12+
import { UniqueIndexService } from './unique-index.service';
1213

1314
@Injectable()
1415
export class LinkIntegrityService {
@@ -18,6 +19,7 @@ export class LinkIntegrityService {
1819
private readonly prismaService: PrismaService,
1920
private readonly foreignKeyIntegrityService: ForeignKeyIntegrityService,
2021
private readonly linkFieldIntegrityService: LinkFieldIntegrityService,
22+
private readonly uniqueIndexService: UniqueIndexService,
2123
@InjectDbProvider() private readonly dbProvider: IDbProvider
2224
) {}
2325

@@ -32,6 +34,7 @@ export class LinkIntegrityService {
3234
select: {
3335
id: true,
3436
name: true,
37+
dbTableName: true,
3538
fields: {
3639
where: { type: FieldType.Link, isLookup: null, deletedTime: null },
3740
},
@@ -57,6 +60,16 @@ export class LinkIntegrityService {
5760
issues: tableIssues,
5861
});
5962
}
63+
const uniqueIndexIssues = await this.uniqueIndexService.checkUniqueIndex(table);
64+
if (uniqueIndexIssues.length > 0) {
65+
linkFieldIssues.push({
66+
baseId: mainBase.id,
67+
baseName: mainBase.name,
68+
tableId: table.id,
69+
tableName: table.name,
70+
issues: uniqueIndexIssues,
71+
});
72+
}
6073
}
6174

6275
for (const field of crossBaseLinkFields) {
@@ -299,6 +312,14 @@ export class LinkIntegrityService {
299312
result && fixResults.push(result);
300313
break;
301314
}
315+
case IntegrityIssueType.UniqueIndexNotFound: {
316+
const result = await this.uniqueIndexService.fixUniqueIndex(
317+
issues.tableId,
318+
issue.fieldId
319+
);
320+
result && fixResults.push(result);
321+
break;
322+
}
302323
default:
303324
break;
304325
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { IdPrefix } from '@teable/core';
3+
import { 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 { InjectDbProvider } from '../../db-provider/db.provider';
8+
import { IDbProvider } from '../../db-provider/db.provider.interface';
9+
import { FieldService } from '../field/field.service';
10+
11+
@Injectable()
12+
export class UniqueIndexService {
13+
constructor(
14+
private readonly prismaService: PrismaService,
15+
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
16+
@InjectDbProvider() private readonly dbProvider: IDbProvider,
17+
private readonly fieldService: FieldService
18+
) {}
19+
20+
async checkUniqueIndex(table: {
21+
id: string;
22+
name: string;
23+
dbTableName: string;
24+
}): Promise<IIntegrityIssue[]> {
25+
const issues: IIntegrityIssue[] = [];
26+
27+
const colId = '__id';
28+
const idUniqueIndexExists =
29+
(await this.fieldService.findUniqueIndexesForField(table.dbTableName, colId)).length > 0;
30+
31+
if (!idUniqueIndexExists) {
32+
issues.push({
33+
fieldId: colId,
34+
type: IntegrityIssueType.UniqueIndexNotFound,
35+
message: `Unique index ${colId} not found for table ${table.name}`,
36+
});
37+
}
38+
39+
const uniqueFields = await this.prismaService.field.findMany({
40+
where: { tableId: table.id, deletedTime: null, unique: true },
41+
select: { id: true, dbFieldName: true },
42+
});
43+
44+
for (const field of uniqueFields) {
45+
const indexNames = await this.fieldService.findUniqueIndexesForField(
46+
table.dbTableName,
47+
field.dbFieldName
48+
);
49+
if (indexNames.length === 0) {
50+
issues.push({
51+
fieldId: field.id,
52+
type: IntegrityIssueType.UniqueIndexNotFound,
53+
message: `Unique index ${field.id} not found for table ${table.name}`,
54+
});
55+
}
56+
}
57+
return issues;
58+
}
59+
60+
async fixUniqueIndex(tableId?: string, fieldId?: string): Promise<IIntegrityIssue | undefined> {
61+
if (!tableId || !fieldId) {
62+
return;
63+
}
64+
65+
const table = await this.prismaService.tableMeta.findFirstOrThrow({
66+
where: { id: tableId, deletedTime: null },
67+
select: { dbTableName: true, name: true },
68+
});
69+
70+
let sql: string | undefined;
71+
if (fieldId.startsWith('__')) {
72+
sql = this.knex.schema
73+
.alterTable(table.dbTableName, (table) => {
74+
table.unique([fieldId]);
75+
})
76+
.toQuery();
77+
} else if (fieldId.startsWith(IdPrefix.Field)) {
78+
const field = await this.prismaService.field.findFirstOrThrow({
79+
where: { id: fieldId, deletedTime: null },
80+
select: { dbFieldName: true },
81+
});
82+
83+
const indexName = this.fieldService.getFieldUniqueKeyName(
84+
table.dbTableName,
85+
field.dbFieldName,
86+
fieldId
87+
);
88+
89+
sql = this.knex.schema
90+
.alterTable(table.dbTableName, (table) => {
91+
table.unique([field.dbFieldName], {
92+
indexName,
93+
});
94+
})
95+
.toQuery();
96+
}
97+
98+
if (!sql) {
99+
return;
100+
}
101+
await this.prismaService.txClient().$executeRawUnsafe(sql);
102+
103+
return {
104+
type: IntegrityIssueType.UniqueIndexNotFound,
105+
fieldId,
106+
message: `Unique index ${fieldId} fixed for table ${table.name}`,
107+
};
108+
}
109+
}

apps/nestjs-backend/test/field-converting.e2e-spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ import {
3131
} from '@teable/core';
3232
import { PrismaService } from '@teable/db-main-prisma';
3333
import { type ITableFullVo } from '@teable/openapi';
34+
import type { Knex } from 'knex';
3435
import { DB_PROVIDER_SYMBOL } from '../src/db-provider/db.provider';
3536
import type { IDbProvider } from '../src/db-provider/db.provider.interface';
37+
import { FieldService } from '../src/features/field/field.service';
3638
import {
3739
getRecords,
3840
createField,
@@ -56,12 +58,16 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => {
5658
const baseId = globalThis.testConfig.baseId;
5759
let dbProvider: IDbProvider;
5860
let prisma: PrismaService;
61+
let fieldService: FieldService;
62+
let knex: Knex;
5963

6064
beforeAll(async () => {
6165
const appCtx = await initApp();
6266
app = appCtx.app;
6367
dbProvider = appCtx.app.get<IDbProvider>(DB_PROVIDER_SYMBOL);
6468
prisma = appCtx.app.get<PrismaService>(PrismaService);
69+
fieldService = appCtx.app.get<FieldService>(FieldService);
70+
knex = appCtx.app.get('CUSTOM_KNEX');
6571
});
6672

6773
afterAll(async () => {
@@ -4010,4 +4016,69 @@ describe('OpenAPI Freely perform column transformations (e2e)', () => {
40104016
]);
40114017
});
40124018
});
4019+
4020+
describe('modify primary field', () => {
4021+
bfAf();
4022+
4023+
it('should modify general property', async () => {
4024+
const primaryField = table1.fields[0];
4025+
const primaryFieldId = primaryField.id;
4026+
const newFieldRo: IFieldRo = {
4027+
...primaryField,
4028+
dbFieldName: 'id',
4029+
};
4030+
4031+
const field = await convertField(table1.id, primaryField.id, newFieldRo);
4032+
expect(field.dbFieldName).toEqual('id');
4033+
4034+
const uniqueFieldRo: IFieldRo = {
4035+
...field,
4036+
unique: true,
4037+
};
4038+
4039+
const uniqueField = await convertField(table1.id, primaryFieldId, uniqueFieldRo);
4040+
expect(uniqueField.unique).toEqual(true);
4041+
const matchedIndexes1 = await fieldService.findUniqueIndexesForField(
4042+
table1.dbTableName,
4043+
uniqueField.dbFieldName
4044+
);
4045+
expect(matchedIndexes1).toHaveLength(1);
4046+
4047+
const dropUniqueFieldRo: IFieldRo = {
4048+
...uniqueField,
4049+
unique: false,
4050+
};
4051+
4052+
const dropUniqueField = await convertField(table1.id, primaryFieldId, dropUniqueFieldRo);
4053+
expect(dropUniqueField.unique).toEqual(false);
4054+
const matchedIndexes2 = await fieldService.findUniqueIndexesForField(
4055+
table1.dbTableName,
4056+
dropUniqueField.dbFieldName
4057+
);
4058+
expect(matchedIndexes2).toHaveLength(0);
4059+
});
4060+
4061+
it('should modify old unique property', async () => {
4062+
const field = table1.fields[0];
4063+
const matchedIndexes = await fieldService.findUniqueIndexesForField(
4064+
table1.dbTableName,
4065+
field.dbFieldName
4066+
);
4067+
expect(matchedIndexes).toHaveLength(0);
4068+
4069+
const sql = knex.schema
4070+
.alterTable(table1.dbTableName, (table) => {
4071+
table.unique([field.dbFieldName], {});
4072+
})
4073+
.toQuery();
4074+
4075+
await prisma.txClient().$executeRawUnsafe(sql);
4076+
4077+
const matchedIndexes1 = await fieldService.findUniqueIndexesForField(
4078+
table1.dbTableName,
4079+
field.dbFieldName
4080+
);
4081+
expect(matchedIndexes1).toHaveLength(1);
4082+
});
4083+
});
40134084
});

0 commit comments

Comments
 (0)