diff --git a/apps/playground/src/components/playground/ExplainResultPanel.tsx b/apps/playground/src/components/playground/ExplainResultPanel.tsx index a84eff058..ba28f2710 100644 --- a/apps/playground/src/components/playground/ExplainResultPanel.tsx +++ b/apps/playground/src/components/playground/ExplainResultPanel.tsx @@ -32,6 +32,8 @@ interface ExplainResultPanelProps { className?: string; } +type ComputedUpdateReason = NonNullable; + function ComplexityScoreCard({ level, score }: { level: string; score: number }) { const config: Record = { trivial: { @@ -149,6 +151,100 @@ function ExplainOutputBlock({ ); } +function ComputedReasonBlock({ reason }: { reason: ComputedUpdateReason }) { + return ( +
+
+ + Computed Update Reason + + {reason.changeType} + +
+ {reason.notes.length > 0 && ( +
{reason.notes.join(' ')}
+ )} +
+
Triggered By
+
+ {reason.seedFields.length > 0 ? ( + reason.seedFields.map((seed) => { + const Icon = getFieldTypeIcon(seed.fieldType); + return ( +
+ + {seed.fieldName} + ({seed.fieldType}) + + {seed.impact === 'link_relation' ? 'link' : 'value'} + +
+ ); + }) + ) : ( + No seed fields + )} +
+
+
+
Updates
+
+ {reason.targetFields.length > 0 ? ( + reason.targetFields.map((target) => { + const Icon = getFieldTypeIcon(target.fieldType); + return ( +
+
+ + {target.fieldName} + ({target.fieldType}) +
+
+ {target.dependencies.length > 0 ? ( + target.dependencies.map((dep, index) => ( +
+ + {dep.fromTableName}.{dep.fromFieldName} + + ({dep.fromFieldType}) + + {dep.kind} + + {dep.semantic && ( + + {dep.semantic} + + )} + {dep.isSeed && ( + + seed + + )} +
+ )) + ) : ( + No direct dependencies + )} +
+
+ ); + }) + ) : ( + No computed targets + )} +
+
+
+ ); +} + function StatCard({ icon: Icon, label, @@ -584,6 +680,9 @@ export function ExplainResultPanel({ result, className }: ExplainResultPanelProp )} + {sqlInfo.computedReason && ( + + )} ))} diff --git a/packages/v2/command-explain/src/analyzers/CreateRecordAnalyzer.ts b/packages/v2/command-explain/src/analyzers/CreateRecordAnalyzer.ts index 2acbe5517..dd26338d1 100644 --- a/packages/v2/command-explain/src/analyzers/CreateRecordAnalyzer.ts +++ b/packages/v2/command-explain/src/analyzers/CreateRecordAnalyzer.ts @@ -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 | 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 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; +}; + +/** + * Reason details for computed update batches. + */ +export type ComputedUpdateReason = { + readonly changeType: 'insert' | 'update' | 'delete'; + readonly seedFields: ReadonlyArray; + readonly targetFields: ReadonlyArray; + readonly notes: ReadonlyArray; +}; + /** * SQL explain information for a single step. */ @@ -119,6 +168,7 @@ export type SqlExplainInfo = { readonly parameters: ReadonlyArray; readonly explainAnalyze: ExplainAnalyzeOutput | null; readonly explainOnly: ExplainOutput | null; + readonly computedReason?: ComputedUpdateReason; }; /** diff --git a/packages/v2/command-explain/src/utils/ComputedUpdateReasonBuilder.ts b/packages/v2/command-explain/src/utils/ComputedUpdateReasonBuilder.ts new file mode 100644 index 000000000..6eee188a1 --- /dev/null +++ b/packages/v2/command-explain/src/utils/ComputedUpdateReasonBuilder.ts @@ -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; + changedFieldIds: ReadonlyArray; + targetFieldIds: ReadonlyArray; + 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>(); + 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(); + 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(); + 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(); + 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, + }; +}; diff --git a/packages/v2/contract-http/src/table/explainCommand.ts b/packages/v2/contract-http/src/table/explainCommand.ts index 2f3422e85..ff4500a05 100644 --- a/packages/v2/contract-http/src/table/explainCommand.ts +++ b/packages/v2/contract-http/src/table/explainCommand.ts @@ -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({