feat(v2): explain computed update reasons

This commit is contained in:
nichenqin 2026-01-08 15:38:23 +08:00
parent 848efcd238
commit d970fa66d4
7 changed files with 398 additions and 3 deletions

View File

@ -32,6 +32,8 @@ interface ExplainResultPanelProps {
className?: string;
}
type ComputedUpdateReason = NonNullable<IExplainResultDto['sqlExplains'][number]['computedReason']>;
function ComplexityScoreCard({ level, score }: { level: string; score: number }) {
const config: Record<string, { bg: string; border: string; text: string; label: string }> = {
trivial: {
@ -149,6 +151,100 @@ function ExplainOutputBlock({
);
}
function ComputedReasonBlock({ reason }: { reason: ComputedUpdateReason }) {
return (
<div className="mt-4 rounded-md border bg-muted/40 p-3 text-xs space-y-3">
<div className="flex items-center gap-2 text-muted-foreground">
<GitBranch className="h-4 w-4" />
<span className="font-medium">Computed Update Reason</span>
<Badge variant="outline" className="text-[10px] h-4 px-1 uppercase">
{reason.changeType}
</Badge>
</div>
{reason.notes.length > 0 && (
<div className="text-muted-foreground">{reason.notes.join(' ')}</div>
)}
<div>
<div className="text-[11px] font-medium text-muted-foreground mb-1">Triggered By</div>
<div className="flex flex-wrap gap-1.5">
{reason.seedFields.length > 0 ? (
reason.seedFields.map((seed) => {
const Icon = getFieldTypeIcon(seed.fieldType);
return (
<div
key={seed.fieldId}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-background border text-[11px]"
title={`${seed.tableName} · ${seed.fieldType}`}
>
<Icon className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="font-medium">{seed.fieldName}</span>
<span className="text-muted-foreground">({seed.fieldType})</span>
<Badge variant="secondary" className="text-[10px] h-4 px-1">
{seed.impact === 'link_relation' ? 'link' : 'value'}
</Badge>
</div>
);
})
) : (
<span className="text-muted-foreground">No seed fields</span>
)}
</div>
</div>
<div>
<div className="text-[11px] font-medium text-muted-foreground mb-1">Updates</div>
<div className="space-y-2">
{reason.targetFields.length > 0 ? (
reason.targetFields.map((target) => {
const Icon = getFieldTypeIcon(target.fieldType);
return (
<div key={target.fieldId} className="rounded-md border bg-background px-2 py-2">
<div className="flex items-center gap-2">
<Icon className="h-3 w-3 text-muted-foreground" />
<span className="font-medium">{target.fieldName}</span>
<span className="text-muted-foreground">({target.fieldType})</span>
</div>
<div className="mt-1 space-y-1 text-[11px]">
{target.dependencies.length > 0 ? (
target.dependencies.map((dep, index) => (
<div
key={`${dep.fromFieldId}-${index}`}
className="flex flex-wrap items-center gap-1.5 text-muted-foreground"
>
<span className="font-medium text-foreground">
{dep.fromTableName}.{dep.fromFieldName}
</span>
<span>({dep.fromFieldType})</span>
<Badge variant="outline" className="text-[10px] h-4 px-1">
{dep.kind}
</Badge>
{dep.semantic && (
<Badge variant="outline" className="text-[10px] h-4 px-1">
{dep.semantic}
</Badge>
)}
{dep.isSeed && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">
seed
</Badge>
)}
</div>
))
) : (
<span className="text-muted-foreground">No direct dependencies</span>
)}
</div>
</div>
);
})
) : (
<span className="text-muted-foreground">No computed targets</span>
)}
</div>
</div>
</div>
);
}
function StatCard({
icon: Icon,
label,
@ -584,6 +680,9 @@ export function ExplainResultPanel({ result, className }: ExplainResultPanelProp
</div>
)}
</div>
{sqlInfo.computedReason && (
<ComputedReasonBlock reason={sqlInfo.computedReason} />
)}
</div>
</div>
))}

View File

@ -45,6 +45,7 @@ import { DEFAULT_EXPLAIN_OPTIONS } from '../types';
import { v2CommandExplainTokens } from '../di/tokens';
import { SqlExplainRunner } from '../utils/SqlExplainRunner';
import { ComplexityCalculator } from '../utils/ComplexityCalculator';
import { buildComputedUpdateReason } from '../utils/ComputedUpdateReasonBuilder';
/**
* Analyzer for CreateRecordCommand.
@ -266,6 +267,14 @@ export class CreateRecordAnalyzer implements ICommandAnalyzer<CreateRecordComman
for (const step of batch.steps) {
batchFieldIds.push(...step.fieldIds);
}
const computedReason = buildComputedUpdateReason({
plan,
graphData,
tableById,
changedFieldIds,
targetFieldIds: batchFieldIds,
changeType: plan.changeType,
});
// Get batch table name
const batchTableNameResult = batchTable.dbTableName();
@ -290,6 +299,7 @@ export class CreateRecordAnalyzer implements ICommandAnalyzer<CreateRecordComman
parameters: [],
explainAnalyze: null,
explainOnly: null,
computedReason,
});
continue;
}
@ -302,6 +312,7 @@ export class CreateRecordAnalyzer implements ICommandAnalyzer<CreateRecordComman
parameters: [],
explainAnalyze: null,
explainOnly: null,
computedReason,
});
continue;
}
@ -322,6 +333,7 @@ export class CreateRecordAnalyzer implements ICommandAnalyzer<CreateRecordComman
parameters: [],
explainAnalyze: null,
explainOnly: null,
computedReason,
});
continue;
}
@ -372,6 +384,7 @@ export class CreateRecordAnalyzer implements ICommandAnalyzer<CreateRecordComman
parameters: compiled.parameters as unknown[],
explainAnalyze,
explainOnly,
computedReason,
});
}
}

View File

@ -43,6 +43,7 @@ import { DEFAULT_EXPLAIN_OPTIONS } from '../types';
import { v2CommandExplainTokens } from '../di/tokens';
import { SqlExplainRunner } from '../utils/SqlExplainRunner';
import { ComplexityCalculator } from '../utils/ComplexityCalculator';
import { buildComputedUpdateReason } from '../utils/ComputedUpdateReasonBuilder';
/**
* Analyzer for DeleteRecordsCommand.
@ -112,6 +113,7 @@ export class DeleteRecordsAnalyzer implements ICommandAnalyzer<DeleteRecordsComm
let computedImpact: ComputedImpactInfo;
let plan: ComputedUpdatePlan | null = null;
let tableById: Map<string, Table> | null = null;
let graphData: FieldDependencyGraphData | null = null;
if (linkFieldIds.length > 0) {
plan = yield* await analyzer.planner.plan({
@ -122,7 +124,7 @@ export class DeleteRecordsAnalyzer implements ICommandAnalyzer<DeleteRecordsComm
});
planningMs = Date.now() - graphStartTime;
const graphData = yield* await analyzer.dependencyGraph.load(table.baseId());
graphData = yield* await analyzer.dependencyGraph.load(table.baseId());
dependencyGraphMs = Date.now() - graphStartTime;
// Load tables for name resolution
@ -136,7 +138,7 @@ export class DeleteRecordsAnalyzer implements ICommandAnalyzer<DeleteRecordsComm
mergedOptions
);
} else {
const graphData = yield* await analyzer.dependencyGraph.load(table.baseId());
graphData = yield* await analyzer.dependencyGraph.load(table.baseId());
dependencyGraphMs = Date.now() - graphStartTime;
planningMs = dependencyGraphMs;
@ -192,7 +194,7 @@ export class DeleteRecordsAnalyzer implements ICommandAnalyzer<DeleteRecordsComm
});
// Generate SQL for computed field updates on linked tables
if (plan && plan.sameTableBatches.length > 0 && tableById) {
if (plan && plan.sameTableBatches.length > 0 && tableById && graphData) {
for (let i = 0; i < plan.sameTableBatches.length; i++) {
const batch = plan.sameTableBatches[i];
const batchTable = tableById.get(batch.tableId.toString());
@ -206,6 +208,14 @@ export class DeleteRecordsAnalyzer implements ICommandAnalyzer<DeleteRecordsComm
for (const step of batch.steps) {
batchFieldIds.push(...step.fieldIds);
}
const computedReason = buildComputedUpdateReason({
plan,
graphData,
tableById,
changedFieldIds: linkFieldIds,
targetFieldIds: batchFieldIds,
changeType: plan.changeType,
});
// Get batch table name
const batchTableNameResult = batchTable.dbTableName();
@ -230,6 +240,7 @@ export class DeleteRecordsAnalyzer implements ICommandAnalyzer<DeleteRecordsComm
parameters: [],
explainAnalyze: null,
explainOnly: null,
computedReason,
});
continue;
}
@ -242,6 +253,7 @@ export class DeleteRecordsAnalyzer implements ICommandAnalyzer<DeleteRecordsComm
parameters: [],
explainAnalyze: null,
explainOnly: null,
computedReason,
});
continue;
}
@ -262,6 +274,7 @@ export class DeleteRecordsAnalyzer implements ICommandAnalyzer<DeleteRecordsComm
parameters: [],
explainAnalyze: null,
explainOnly: null,
computedReason,
});
continue;
}
@ -312,6 +325,7 @@ export class DeleteRecordsAnalyzer implements ICommandAnalyzer<DeleteRecordsComm
parameters: compiled.parameters as unknown[],
explainAnalyze,
explainOnly,
computedReason,
});
}
}

View File

@ -44,6 +44,7 @@ import { DEFAULT_EXPLAIN_OPTIONS } from '../types';
import { v2CommandExplainTokens } from '../di/tokens';
import { SqlExplainRunner } from '../utils/SqlExplainRunner';
import { ComplexityCalculator } from '../utils/ComplexityCalculator';
import { buildComputedUpdateReason } from '../utils/ComputedUpdateReasonBuilder';
/**
* Analyzer for UpdateRecordCommand.
@ -275,6 +276,14 @@ export class UpdateRecordAnalyzer implements ICommandAnalyzer<UpdateRecordComman
for (const step of batch.steps) {
batchFieldIds.push(...step.fieldIds);
}
const computedReason = buildComputedUpdateReason({
plan,
graphData,
tableById,
changedFieldIds,
targetFieldIds: batchFieldIds,
changeType: plan.changeType,
});
// Get batch table name
const batchTableNameResult = batchTable.dbTableName();
@ -300,6 +309,7 @@ export class UpdateRecordAnalyzer implements ICommandAnalyzer<UpdateRecordComman
parameters: [],
explainAnalyze: null,
explainOnly: null,
computedReason,
});
continue;
}
@ -312,6 +322,7 @@ export class UpdateRecordAnalyzer implements ICommandAnalyzer<UpdateRecordComman
parameters: [],
explainAnalyze: null,
explainOnly: null,
computedReason,
});
continue;
}
@ -332,6 +343,7 @@ export class UpdateRecordAnalyzer implements ICommandAnalyzer<UpdateRecordComman
parameters: [],
explainAnalyze: null,
explainOnly: null,
computedReason,
});
continue;
}
@ -382,6 +394,7 @@ export class UpdateRecordAnalyzer implements ICommandAnalyzer<UpdateRecordComman
parameters: compiled.parameters as unknown[],
explainAnalyze,
explainOnly,
computedReason,
});
}
}

View File

@ -110,6 +110,55 @@ export type ExplainAnalyzeOutput = {
readonly estimatedRows?: number;
};
/**
* Seed field that triggered computed updates.
*/
export type ComputedUpdateSeedField = {
readonly fieldId: string;
readonly fieldName: string;
readonly fieldType: string;
readonly tableId: string;
readonly tableName: string;
readonly impact: 'value' | 'link_relation';
};
/**
* Direct dependency of a computed field.
*/
export type ComputedUpdateDependency = {
readonly fromFieldId: string;
readonly fromFieldName: string;
readonly fromFieldType: string;
readonly fromTableId: string;
readonly fromTableName: string;
readonly kind: 'same_record' | 'cross_record';
readonly semantic?: string;
readonly linkFieldId?: string;
readonly isSeed: boolean;
};
/**
* Computed field updated in a batch, including dependencies.
*/
export type ComputedUpdateTargetField = {
readonly fieldId: string;
readonly fieldName: string;
readonly fieldType: string;
readonly tableId: string;
readonly tableName: string;
readonly dependencies: ReadonlyArray<ComputedUpdateDependency>;
};
/**
* Reason details for computed update batches.
*/
export type ComputedUpdateReason = {
readonly changeType: 'insert' | 'update' | 'delete';
readonly seedFields: ReadonlyArray<ComputedUpdateSeedField>;
readonly targetFields: ReadonlyArray<ComputedUpdateTargetField>;
readonly notes: ReadonlyArray<string>;
};
/**
* SQL explain information for a single step.
*/
@ -119,6 +168,7 @@ export type SqlExplainInfo = {
readonly parameters: ReadonlyArray<unknown>;
readonly explainAnalyze: ExplainAnalyzeOutput | null;
readonly explainOnly: ExplainOutput | null;
readonly computedReason?: ComputedUpdateReason;
};
/**

View File

@ -0,0 +1,158 @@
import type {
ComputedUpdatePlan,
FieldDependencyGraphData,
} from '@teable/v2-adapter-table-repository-postgres';
import type { FieldId, Table, TableId } from '@teable/v2-core';
import type {
ComputedUpdateReason,
ComputedUpdateSeedField,
ComputedUpdateTargetField,
ComputedUpdateDependency,
} from '../types';
type BuildComputedUpdateReasonParams = {
plan: ComputedUpdatePlan;
graphData: FieldDependencyGraphData;
tableById: Map<string, Table>;
changedFieldIds: ReadonlyArray<FieldId>;
targetFieldIds: ReadonlyArray<FieldId>;
changeType: ComputedUpdatePlan['changeType'];
};
export const buildComputedUpdateReason = (
params: BuildComputedUpdateReasonParams
): ComputedUpdateReason => {
const { plan, graphData, tableById, changedFieldIds, targetFieldIds, changeType } = params;
const { fieldsById, edges } = graphData;
const resolveTableName = (tableId: TableId): string => {
const table = tableById.get(tableId.toString());
return table ? table.name().toString() : tableId.toString();
};
const resolveFieldName = (tableId: TableId, fieldId: FieldId): string => {
const table = tableById.get(tableId.toString());
if (table) {
const fieldResult = table.getField((f) => f.id().equals(fieldId));
if (fieldResult.isOk()) {
return fieldResult.value.name().toString();
}
}
return fieldId.toString();
};
const resolveFieldType = (fieldId: FieldId): string =>
fieldsById.get(fieldId.toString())?.type ?? 'unknown';
const seedFieldIdSet = new Set(changedFieldIds.map((id) => id.toString()));
const targetFieldIdSet = new Set(targetFieldIds.map((id) => id.toString()));
const edgesByTarget = new Map<string, Array<(typeof edges)[number]>>();
for (const edge of edges) {
const targetId = edge.toFieldId.toString();
if (!targetFieldIdSet.has(targetId)) {
continue;
}
const existing = edgesByTarget.get(targetId) ?? [];
existing.push(edge);
edgesByTarget.set(targetId, existing);
}
const pickPreferredEdges = (
targetEdges: ReadonlyArray<(typeof edges)[number]>
): ReadonlyArray<(typeof edges)[number]> => {
const preferredSemanticGroups = new Set<string>();
for (const edge of targetEdges) {
if (edge.semantic && edge.semantic !== 'formula_ref') {
preferredSemanticGroups.add(`${edge.fromFieldId.toString()}|${edge.kind}`);
}
}
const filtered = targetEdges.filter((edge) => {
if (edge.semantic !== 'formula_ref') return true;
const key = `${edge.fromFieldId.toString()}|${edge.kind}`;
return !preferredSemanticGroups.has(key);
});
const deduped = new Map<string, (typeof edges)[number]>();
for (const edge of filtered) {
const linkKey = edge.linkFieldId?.toString() ?? '';
const key = `${edge.fromFieldId.toString()}|${edge.kind}|${linkKey}`;
if (!deduped.has(key)) {
deduped.set(key, edge);
}
}
return [...deduped.values()];
};
const targetFields: ComputedUpdateTargetField[] = targetFieldIds.map((fieldId) => {
const meta = fieldsById.get(fieldId.toString());
const tableId = meta?.tableId ?? plan.seedTableId;
const dependencies: ComputedUpdateDependency[] = pickPreferredEdges(
edgesByTarget.get(fieldId.toString()) ?? []
).map((edge) => ({
fromFieldId: edge.fromFieldId.toString(),
fromFieldName: resolveFieldName(edge.fromTableId, edge.fromFieldId),
fromFieldType: resolveFieldType(edge.fromFieldId),
fromTableId: edge.fromTableId.toString(),
fromTableName: resolveTableName(edge.fromTableId),
kind: edge.kind,
semantic: edge.semantic,
linkFieldId: edge.linkFieldId?.toString(),
isSeed: seedFieldIdSet.has(edge.fromFieldId.toString()),
}));
return {
fieldId: fieldId.toString(),
fieldName: resolveFieldName(tableId, fieldId),
fieldType: meta?.type ?? 'unknown',
tableId: tableId.toString(),
tableName: resolveTableName(tableId),
dependencies,
};
});
const seededDependencyIds = new Set<string>();
for (const target of targetFields) {
for (const dep of target.dependencies) {
if (dep.isSeed) {
seededDependencyIds.add(dep.fromFieldId);
}
}
}
const seedFields: ComputedUpdateSeedField[] = changedFieldIds
.filter((fieldId) => seededDependencyIds.has(fieldId.toString()))
.map((fieldId) => {
const meta = fieldsById.get(fieldId.toString());
const tableId = meta?.tableId ?? plan.seedTableId;
return {
fieldId: fieldId.toString(),
fieldName: resolveFieldName(tableId, fieldId),
fieldType: meta?.type ?? 'unknown',
tableId: tableId.toString(),
tableName: resolveTableName(tableId),
impact: meta?.type === 'link' ? 'link_relation' : 'value',
};
});
const notes: string[] = [];
if (changeType === 'insert') {
notes.push('Insert computes stored computed fields in the seed table for initial values.');
}
if (changeType === 'delete') {
notes.push('Delete recomputes computed fields impacted by link relation changes.');
}
if (seedFields.length === 0 && changeType === 'update') {
notes.push('No changed fields directly matched dependency edges for this batch.');
}
return {
changeType,
seedFields,
targetFields,
notes,
};
};

View File

@ -54,12 +54,60 @@ const explainAnalyzeOutputSchema = z.object({
estimatedRows: z.number().optional(),
});
const computedUpdateSeedFieldSchema = z.object({
fieldId: z.string(),
fieldName: z.string(),
fieldType: z.string(),
tableId: z.string(),
tableName: z.string(),
impact: z.enum(['value', 'link_relation']),
});
const computedUpdateDependencySchema = z.object({
fromFieldId: z.string(),
fromFieldName: z.string(),
fromFieldType: z.string(),
fromTableId: z.string(),
fromTableName: z.string(),
kind: z.enum(['same_record', 'cross_record']),
semantic: z
.enum([
'formula_ref',
'lookup_source',
'lookup_link',
'link_title',
'rollup_source',
'conditional_rollup_source',
'conditional_lookup_source',
])
.optional(),
linkFieldId: z.string().optional(),
isSeed: z.boolean(),
});
const computedUpdateTargetFieldSchema = z.object({
fieldId: z.string(),
fieldName: z.string(),
fieldType: z.string(),
tableId: z.string(),
tableName: z.string(),
dependencies: z.array(computedUpdateDependencySchema),
});
const computedUpdateReasonSchema = z.object({
changeType: z.enum(['insert', 'update', 'delete']),
seedFields: z.array(computedUpdateSeedFieldSchema),
targetFields: z.array(computedUpdateTargetFieldSchema),
notes: z.array(z.string()),
});
const sqlExplainInfoSchema = z.object({
stepDescription: z.string(),
sql: z.string(),
parameters: z.array(z.unknown()),
explainAnalyze: explainAnalyzeOutputSchema.nullable(),
explainOnly: explainOutputSchema.nullable(),
computedReason: computedUpdateReasonSchema.optional(),
});
const dependencyEdgeInfoSchema = z.object({