Skip to content

Commit df0b2c1

Browse files
[sync] fix(backend): skip broken link storage in field plan (#1505) (#2816)
Synced from teableio/teable-ee@f7689c3 Co-authored-by: nichenqin <nichenqin@hotmail.com>
1 parent 6716cc9 commit df0b2c1

2 files changed

Lines changed: 209 additions & 33 deletions

File tree

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

Lines changed: 120 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
22
import type { IFieldRo, ILinkFieldOptions, IConvertFieldRo } from '@teable/core';
33
import { FieldType, Relationship, isLinkLookupOptions } from '@teable/core';
44
import type { Field, TableMeta } from '@teable/db-main-prisma';
5-
import { PrismaService } from '@teable/db-main-prisma';
5+
import { Prisma, PrismaService } from '@teable/db-main-prisma';
66
import type {
77
IGraphEdge,
88
IGraphNode,
@@ -47,6 +47,12 @@ interface ITinyTable {
4747
dbTableName: string;
4848
}
4949

50+
interface IAffectedCountQuery {
51+
fieldId: string;
52+
fieldName: string;
53+
query: string;
54+
}
55+
5056
@Injectable()
5157
export class GraphService {
5258
private logger = new Logger(GraphService.name);
@@ -455,45 +461,127 @@ export class GraphService {
455461
fieldMap: IFieldMap,
456462
fieldId2DbTableName: Record<string, string>
457463
): Promise<number> {
458-
const queries = fieldIds.map((fieldId) => {
459-
const field = fieldMap[fieldId];
460-
const lookupOptions = field.lookupOptions;
464+
const queries = fieldIds
465+
.map((fieldId) =>
466+
this.buildAffectedCountQuery(hostFieldId, fieldId, fieldMap, fieldId2DbTableName)
467+
)
468+
.filter((query): query is IAffectedCountQuery => query != null);
461469

462-
if (field.id !== hostFieldId) {
463-
if (field.type === FieldType.Link) {
464-
const { relationship, fkHostTableName, selfKeyName, foreignKeyName } =
465-
field.options as ILinkFieldOptions;
466-
const query =
467-
relationship === Relationship.OneOne || relationship === Relationship.ManyOne
468-
? this.knex.count(foreignKeyName, { as: 'count' }).from(fkHostTableName)
469-
: this.knex.countDistinct(selfKeyName, { as: 'count' }).from(fkHostTableName);
470-
471-
return query.toQuery();
470+
let total = 0;
471+
for (const { fieldId, fieldName, query } of queries) {
472+
try {
473+
const [{ count }] = await this.prismaService.$queryRawUnsafe<{ count: bigint }[]>(query);
474+
total += Number(count);
475+
} catch (error) {
476+
if (this.shouldSkipAffectedCountError(error)) {
477+
this.logger.warn(
478+
`Skip affected cell count for field=${fieldId} name="${fieldName}" due to broken storage: ${
479+
error.meta?.message || error.message
480+
}`
481+
);
482+
continue;
472483
}
484+
throw error;
485+
}
486+
}
487+
return total;
488+
}
473489

474-
if (lookupOptions && isLinkLookupOptions(lookupOptions)) {
475-
const { relationship, fkHostTableName, selfKeyName, foreignKeyName } = lookupOptions;
476-
const query =
477-
relationship === Relationship.OneOne || relationship === Relationship.ManyOne
478-
? this.knex.count(foreignKeyName, { as: 'count' }).from(fkHostTableName)
479-
: this.knex.countDistinct(selfKeyName, { as: 'count' }).from(fkHostTableName);
490+
private buildAffectedCountQuery(
491+
hostFieldId: string,
492+
fieldId: string,
493+
fieldMap: IFieldMap,
494+
fieldId2DbTableName: Record<string, string>
495+
): IAffectedCountQuery | null {
496+
const field = fieldMap[fieldId];
480497

481-
return query.toQuery();
482-
}
498+
if (!field) {
499+
this.logger.warn(`Skip affected cell count for missing field metadata: ${fieldId}`);
500+
return null;
501+
}
502+
503+
const lookupOptions = field.lookupOptions;
504+
505+
if (field.id !== hostFieldId) {
506+
if (field.type === FieldType.Link) {
507+
return this.buildLinkAffectedCountQuery(
508+
field.id,
509+
field.name,
510+
field.options as ILinkFieldOptions
511+
);
483512
}
484513

485-
const dbTableName = fieldId2DbTableName[fieldId];
486-
return this.knex.count('*', { as: 'count' }).from(dbTableName).toQuery();
487-
});
488-
// console.log('queries', queries);
514+
if (lookupOptions && isLinkLookupOptions(lookupOptions)) {
515+
return this.buildLinkAffectedCountQuery(field.id, field.name, lookupOptions);
516+
}
517+
}
489518

490-
let total = 0;
491-
for (const query of queries) {
492-
const [{ count }] = await this.prismaService.$queryRawUnsafe<{ count: bigint }[]>(query);
493-
// console.log('count', count);
494-
total += Number(count);
519+
const dbTableName = fieldId2DbTableName[fieldId];
520+
if (!dbTableName) {
521+
this.logger.warn(
522+
`Skip affected cell count for field=${fieldId} name="${field.name}" because db table name is missing`
523+
);
524+
return null;
495525
}
496-
return total;
526+
527+
return {
528+
fieldId,
529+
fieldName: field.name,
530+
query: this.knex.count('*', { as: 'count' }).from(dbTableName).toQuery(),
531+
};
532+
}
533+
534+
private buildLinkAffectedCountQuery(
535+
fieldId: string,
536+
fieldName: string,
537+
options: Pick<
538+
ILinkFieldOptions,
539+
'relationship' | 'fkHostTableName' | 'selfKeyName' | 'foreignKeyName'
540+
>
541+
): IAffectedCountQuery | null {
542+
const { relationship, fkHostTableName, selfKeyName, foreignKeyName } = options;
543+
544+
if (!fkHostTableName || !foreignKeyName) {
545+
this.logger.warn(
546+
`Skip affected cell count for field=${fieldId} name="${fieldName}" because link storage metadata is incomplete`
547+
);
548+
return null;
549+
}
550+
551+
if (
552+
relationship !== Relationship.OneOne &&
553+
relationship !== Relationship.ManyOne &&
554+
!selfKeyName
555+
) {
556+
this.logger.warn(
557+
`Skip affected cell count for field=${fieldId} name="${fieldName}" because link key metadata is incomplete`
558+
);
559+
return null;
560+
}
561+
562+
const query =
563+
relationship === Relationship.OneOne || relationship === Relationship.ManyOne
564+
? this.knex.count(foreignKeyName, { as: 'count' }).from(fkHostTableName)
565+
: this.knex.countDistinct(selfKeyName as string, { as: 'count' }).from(fkHostTableName);
566+
567+
return {
568+
fieldId,
569+
fieldName,
570+
query: query.toQuery(),
571+
};
572+
}
573+
574+
private shouldSkipAffectedCountError(
575+
error: unknown
576+
): error is Prisma.PrismaClientKnownRequestError & {
577+
meta?: { code?: string; message?: string };
578+
} {
579+
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2010') {
580+
return false;
581+
}
582+
583+
const storageErrorCode = (error.meta as { code?: string } | undefined)?.code;
584+
return storageErrorCode === '42703' || storageErrorCode === '42P01';
497585
}
498586

499587
@Timing()

apps/nestjs-backend/test/graph.e2e-spec.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
/* eslint-disable sonarjs/no-duplicate-string */
22
import type { INestApplication } from '@nestjs/common';
3-
import { FieldType, Relationship, type IFieldRo, FieldKeyType } from '@teable/core';
3+
import {
4+
FieldType,
5+
Relationship,
6+
type IFieldRo,
7+
type ILinkFieldOptions,
8+
FieldKeyType,
9+
} from '@teable/core';
410
import { PrismaService } from '@teable/db-main-prisma';
511
import type { ITableFullVo } from '@teable/openapi';
612
import { planField, planFieldCreate, planFieldConvert, updateRecord } from '@teable/openapi';
@@ -391,4 +397,86 @@ describe('OpenAPI Graph (e2e)', () => {
391397
await permanentDeleteTable(baseId, tempTable.id);
392398
}
393399
});
400+
401+
it('should ignore broken link key metadata when planning single select conversion', async () => {
402+
const hostField = table1.fields[0];
403+
const linkField = await createField(table2.id, {
404+
name: 'broken key link',
405+
type: FieldType.Link,
406+
options: {
407+
relationship: Relationship.ManyMany,
408+
foreignTableId: table1.id,
409+
},
410+
});
411+
const originalOptions = linkField.options as ILinkFieldOptions;
412+
413+
try {
414+
const { selfKeyName: _selfKeyName, ...brokenOptions } = originalOptions;
415+
await prisma.txClient().field.update({
416+
where: { id: linkField.id },
417+
data: {
418+
options: JSON.stringify(brokenOptions),
419+
},
420+
});
421+
422+
const { data: plan } = await planFieldConvert(table1.id, hostField.id, {
423+
type: FieldType.SingleSelect,
424+
});
425+
426+
expect(plan.skip).toBeUndefined();
427+
expect(plan.updateCellCount).toEqual(table1.records.length);
428+
expect(plan.graph?.nodes).toHaveLength(2);
429+
expect(plan.graph?.edges).toHaveLength(1);
430+
expect(plan.graph?.combos).toHaveLength(2);
431+
} finally {
432+
await prisma.txClient().field.update({
433+
where: { id: linkField.id },
434+
data: {
435+
options: JSON.stringify(originalOptions),
436+
},
437+
});
438+
}
439+
});
440+
441+
it('should ignore missing junction storage when planning single select conversion', async () => {
442+
const hostField = table1.fields[0];
443+
const linkField = await createField(table2.id, {
444+
name: 'broken storage link',
445+
type: FieldType.Link,
446+
options: {
447+
relationship: Relationship.ManyMany,
448+
foreignTableId: table1.id,
449+
},
450+
});
451+
const originalOptions = linkField.options as ILinkFieldOptions;
452+
453+
try {
454+
await prisma.txClient().field.update({
455+
where: { id: linkField.id },
456+
data: {
457+
options: JSON.stringify({
458+
...originalOptions,
459+
fkHostTableName: `${originalOptions.fkHostTableName}_missing`,
460+
}),
461+
},
462+
});
463+
464+
const { data: plan } = await planFieldConvert(table1.id, hostField.id, {
465+
type: FieldType.SingleSelect,
466+
});
467+
468+
expect(plan.skip).toBeUndefined();
469+
expect(plan.updateCellCount).toEqual(table1.records.length);
470+
expect(plan.graph?.nodes).toHaveLength(2);
471+
expect(plan.graph?.edges).toHaveLength(1);
472+
expect(plan.graph?.combos).toHaveLength(2);
473+
} finally {
474+
await prisma.txClient().field.update({
475+
where: { id: linkField.id },
476+
data: {
477+
options: JSON.stringify(originalOptions),
478+
},
479+
});
480+
}
481+
});
394482
});

0 commit comments

Comments
 (0)