From 55d7cbbe0e690126d1f18b74556d458553dd21eb Mon Sep 17 00:00:00 2001 From: LienJack Date: Sat, 25 Apr 2026 13:28:47 +0900 Subject: [PATCH 1/5] feat(modeling): enhance relationship modeling with cardinality and type support - Added `type` and `cardinality` properties to the `ModelingRelationshipRecommendation` type, allowing for more precise relationship definitions. - Updated the `WorkspaceModelingService` to handle relationship types and cardinalities in recommendations. - Enhanced integration tests to verify the correct handling of relationship types and cardinalities in the modeling setup. - Improved UI components to display relationship metadata, including cardinality and source labels, enhancing user experience during modeling setup. --- .../workspace/workspace-modeling.service.ts | 12 + .../workspace-modeling-setup.spec.ts | 6 +- apps/frontend/src/app/data-sources/page.tsx | 17 +- .../src/app/settings/modeling/page.tsx | 672 +- .../data-sources/setup-relationships-step.tsx | 53 +- .../modeling/modeling-flow-canvas.tsx | 135 +- .../settings/modeling/modeling-flow-edge.tsx | 230 +- .../settings/modeling/modeling-flow-node.tsx | 20 +- apps/frontend/src/lib/admin-api-client.ts | 14 + ...admin-api-client-modeling-contract.spec.ts | 59 +- .../unit/data-sources-modeling-setup.spec.tsx | 150 + ...s-modeling-auto-layout-resilience.spec.tsx | 54 + ...settings-modeling-complete-parity.spec.tsx | 9 +- .../unit/settings-modeling-deploy.spec.tsx | 25 +- .../settings-modeling-flow-canvas.spec.tsx | 120 + .../settings-modeling-flow-node-edge.spec.tsx | 119 +- .../settings-modeling-layout-parity.spec.tsx | 12 +- .../settings-modeling-schema-change.spec.tsx | 9 +- .../unit/settings-modeling-view-sync.spec.tsx | 15 +- .../unit/settings-modeling-workspace.spec.tsx | 429 +- graphify-out/GRAPH_REPORT.md | 194 +- graphify-out/graph.html | 8 +- graphify-out/graph.json | 5810 +++++++++-------- 23 files changed, 5123 insertions(+), 3049 deletions(-) diff --git a/apps/backend/src/modules/governance/workspace/workspace-modeling.service.ts b/apps/backend/src/modules/governance/workspace/workspace-modeling.service.ts index 86f0466..fbf5225 100644 --- a/apps/backend/src/modules/governance/workspace/workspace-modeling.service.ts +++ b/apps/backend/src/modules/governance/workspace/workspace-modeling.service.ts @@ -50,6 +50,8 @@ type ModelingRelationshipRecommendation = { confidence: number; reason: "foreign_key_constraint" | "fk_naming_suffix"; isAutoGenerated: true; + type: "many-to-one" | "one-to-many" | "one-to-one"; + cardinality: "many-to-one" | "one-to-many" | "one-to-one"; bridge: { left: { dataset: string; @@ -2088,11 +2090,19 @@ export class WorkspaceModelingService { .map((relationship) => { const source: ModelingGraphPayload["relationships"][number]["source"] = relationship.reason === "foreign_key_constraint" ? "fk" : "inferred"; + const relationshipType = + relationship.type === "many-to-one" || + relationship.type === "one-to-many" || + relationship.type === "one-to-one" + ? relationship.type + : "many-to-one"; return { id: relationship.id, name: relationship.name, source, confidence: Number(relationship.confidence.toFixed(4)), + type: relationshipType, + cardinality: relationshipType, bridge: { left: { dataset: relationship.bridge.left.dataset, @@ -2472,6 +2482,8 @@ ORDER BY kcu.table_name, kcu.column_name, ccu.table_name, ccu.column_name confidence: normalizedConfidence, reason: input.reason, isAutoGenerated: true, + type: "many-to-one", + cardinality: "many-to-one", bridge: { left: { dataset: input.datasourceId, diff --git a/apps/backend/test/integration/workspace-modeling-setup.spec.ts b/apps/backend/test/integration/workspace-modeling-setup.spec.ts index 7bdcfa4..d52b97e 100644 --- a/apps/backend/test/integration/workspace-modeling-setup.spec.ts +++ b/apps/backend/test/integration/workspace-modeling-setup.spec.ts @@ -130,6 +130,8 @@ describe("workspace modeling setup integration", () => { expect(preview.relationshipRecommendations[0]?.sourceTable).toBe("orders"); expect(preview.relationshipRecommendations[0]?.targetTable).toBe("customers"); expect(preview.relationshipRecommendations[0]?.reason).toBe("foreign_key_constraint"); + expect(preview.relationshipRecommendations[0]?.type).toBe("many-to-one"); + expect(preview.relationshipRecommendations[0]?.cardinality).toBe("many-to-one"); const committed = await service.commitSetup( actor, @@ -166,7 +168,9 @@ describe("workspace modeling setup integration", () => { relationships: [ expect.objectContaining({ id: preview.relationshipRecommendations[0]!.id, - source: "fk" + source: "fk", + type: "many-to-one", + cardinality: "many-to-one" }) ] }) diff --git a/apps/frontend/src/app/data-sources/page.tsx b/apps/frontend/src/app/data-sources/page.tsx index 23ebc75..43441a0 100644 --- a/apps/frontend/src/app/data-sources/page.tsx +++ b/apps/frontend/src/app/data-sources/page.tsx @@ -596,6 +596,13 @@ function DataSourcesPageContent() { return; } const selectedTableNames = normalizeSelectedTableNames(setupWizard.selectedTableNames); + const previousSuggestionIds = normalizeSelectedIds(setupWizard.selectedSuggestionIds); + const previousSuggestionIdSet = new Set(previousSuggestionIds); + const previousAllSuggestionIdSet = new Set( + setupWizard.suggestions.map((item) => item.id) + ); + const hasExistingSuggestionSelectionState = + setupWizard.step === 2 || previousAllSuggestionIdSet.size > 0; if (selectedTableNames.length === 0) { setSetupWizard((previous) => ({ ...previous, @@ -637,13 +644,21 @@ function DataSourcesPageContent() { selectedTables: persistedTableNames } ); + const nextSelectedSuggestionIds = hasExistingSuggestionSelectionState + ? suggestions + .filter( + (item) => + previousSuggestionIdSet.has(item.id) || !previousAllSuggestionIdSet.has(item.id) + ) + .map((item) => item.id) + : suggestions.map((item) => item.id); setSetupWizard((previous) => ({ ...previous, step: 2, submitting: false, selectedTableNames: persistedTableNames, suggestions, - selectedSuggestionIds: suggestions.map((item) => item.id), + selectedSuggestionIds: nextSelectedSuggestionIds, failure: null })); } catch (error) { diff --git a/apps/frontend/src/app/settings/modeling/page.tsx b/apps/frontend/src/app/settings/modeling/page.tsx index 0ca7e18..cc4baf2 100644 --- a/apps/frontend/src/app/settings/modeling/page.tsx +++ b/apps/frontend/src/app/settings/modeling/page.tsx @@ -14,8 +14,10 @@ import { listWorkspaceDatasourceTablePermissions, listWorkspaces, precheckWorkspaceModelingDeploy, + recommendModelingSetupRelationships, resolveWorkspaceModelingSchemaChange, upsertWorkspaceModelingGraph, + type ModelingSetupRelationshipSuggestion, type PrecheckWorkspaceModelingDeployResult, type WorkspaceModelingGraphSnapshot } from "@/lib/admin-api-client"; @@ -222,6 +224,12 @@ type SaveModelingGraphOptions = { suppressSuccessMessage?: boolean; }; +type SyncRecommendedRelationshipsResult = { + nextPayload: ModelingGraphPayload; + addedCount: number; + skippedCount: number; +}; + type ModelingPositionPatch = { models: Record; views: Record; @@ -290,8 +298,221 @@ function mergePositionPatchIntoGraphPayload( }; } +function normalizeSignaturePart(value: string | undefined): string { + return value?.trim().toLowerCase() ?? ""; +} + +function normalizeRelationshipOperator(value: string | undefined): "eq" { + return normalizeSignaturePart(value) === "eq" ? "eq" : "eq"; +} + +function buildEndpointSignature(endpoint: { + dataset?: string; + table?: string; + column?: string; +}): string | null { + const dataset = normalizeSignaturePart(endpoint.dataset); + const table = normalizeSignaturePart(endpoint.table); + const column = normalizeSignaturePart(endpoint.column); + if (!table || !column) { + return null; + } + return `${dataset}|${table}|${column}`; +} + +function buildDirectedRelationshipSignature(input: { + left: { dataset?: string; table?: string; column?: string }; + right: { dataset?: string; table?: string; column?: string }; + operator?: string; +}): string | null { + const leftSignature = buildEndpointSignature(input.left); + const rightSignature = buildEndpointSignature(input.right); + if (!leftSignature || !rightSignature) { + return null; + } + const operator = normalizeSignaturePart(input.operator) || "eq"; + return `${leftSignature}->${rightSignature}|${operator}`; +} + +function buildUndirectedRelationshipSignature(input: { + left: { dataset?: string; table?: string; column?: string }; + right: { dataset?: string; table?: string; column?: string }; + operator?: string; +}): string | null { + const leftSignature = buildEndpointSignature(input.left); + const rightSignature = buildEndpointSignature(input.right); + if (!leftSignature || !rightSignature) { + return null; + } + const operator = normalizeSignaturePart(input.operator) || "eq"; + const [endpointA, endpointB] = [leftSignature, rightSignature].sort(); + return `${endpointA}<->${endpointB}|${operator}`; +} + +function resolveRecommendationRelationshipType( + recommendation: ModelingSetupRelationshipSuggestion +): ModelingGraphPayload["relationships"][number]["type"] { + const relationshipType = recommendation.type ?? recommendation.cardinality; + if ( + relationshipType === "many-to-one" || + relationshipType === "one-to-many" || + relationshipType === "one-to-one" + ) { + return relationshipType; + } + return "many-to-one"; +} + +function resolveRecommendationOperator( + recommendation: ModelingSetupRelationshipSuggestion +): "eq" { + const recommendationRecord = recommendation as ModelingSetupRelationshipSuggestion & { + operator?: string; + bridge?: { + operator?: string; + }; + }; + return normalizeRelationshipOperator( + recommendationRecord.bridge?.operator ?? recommendationRecord.operator + ); +} + +function mergeRecommendedRelationshipsIntoGraphPayload( + payload: ModelingGraphPayload, + recommendations: ModelingSetupRelationshipSuggestion[] +): SyncRecommendedRelationshipsResult { + const directedSignatureSet = new Set(); + const undirectedSignatureSet = new Set(); + + for (const relationship of payload.relationships) { + const directedSignature = buildDirectedRelationshipSignature({ + left: relationship.bridge.left, + right: relationship.bridge.right, + operator: relationship.bridge.operator + }); + if (directedSignature) { + directedSignatureSet.add(directedSignature); + } + const undirectedSignature = buildUndirectedRelationshipSignature({ + left: relationship.bridge.left, + right: relationship.bridge.right, + operator: relationship.bridge.operator + }); + if (undirectedSignature) { + undirectedSignatureSet.add(undirectedSignature); + } + } + + const additions: ModelingGraphPayload["relationships"] = []; + let skippedCount = 0; + + for (const recommendation of recommendations) { + const relationshipType = resolveRecommendationRelationshipType(recommendation); + const recommendationOperator = resolveRecommendationOperator(recommendation); + const nextRelationship: ModelingGraphPayload["relationships"][number] = { + id: recommendation.id, + name: recommendation.name, + source: recommendation.reason === "foreign_key_constraint" ? "fk" : "inferred", + confidence: Number(Math.max(0, Math.min(1, recommendation.confidence)).toFixed(4)), + type: relationshipType, + cardinality: relationshipType, + bridge: { + left: { + dataset: recommendation.left.dataset, + table: recommendation.left.table, + column: recommendation.left.column + }, + right: { + dataset: recommendation.right.dataset, + table: recommendation.right.table, + column: recommendation.right.column + }, + operator: recommendationOperator, + confidence: Number(Math.max(0, Math.min(1, recommendation.confidence)).toFixed(4)) + } + }; + const directedSignature = buildDirectedRelationshipSignature({ + left: nextRelationship.bridge.left, + right: nextRelationship.bridge.right, + operator: nextRelationship.bridge.operator + }); + const undirectedSignature = buildUndirectedRelationshipSignature({ + left: nextRelationship.bridge.left, + right: nextRelationship.bridge.right, + operator: nextRelationship.bridge.operator + }); + if (!directedSignature || !undirectedSignature) { + skippedCount += 1; + continue; + } + if ( + directedSignatureSet.has(directedSignature) || + undirectedSignatureSet.has(undirectedSignature) + ) { + skippedCount += 1; + continue; + } + directedSignatureSet.add(directedSignature); + undirectedSignatureSet.add(undirectedSignature); + additions.push(nextRelationship); + } + + if (additions.length === 0) { + return { + nextPayload: payload, + addedCount: 0, + skippedCount + }; + } + + return { + nextPayload: { + ...payload, + relationships: [...payload.relationships, ...additions] + }, + addedCount: additions.length, + skippedCount + }; +} + +function normalizeModelTableNamesForRecommendation(payload: ModelingGraphPayload): string[] { + const normalized = new Set(); + for (const model of payload.models) { + const tableName = model.tableName?.trim().toLowerCase(); + if (!tableName) { + continue; + } + normalized.add(tableName); + } + return Array.from(normalized).sort((left, right) => left.localeCompare(right)); +} + +function buildBootstrapAttemptDraftKey(input: { + workspaceId: string; + datasourceId: string; + snapshot: WorkspaceModelingGraphSnapshot | null; + payload: ModelingGraphPayload; +}): string | null { + const modelTableNames = normalizeModelTableNamesForRecommendation(input.payload); + if (modelTableNames.length === 0 || input.payload.relationships.length > 0) { + return null; + } + const draftRevision = input.snapshot?.draft?.revision ?? "none"; + const draftGraphHash = input.snapshot?.draft?.graphHash ?? "none"; + return [ + input.workspaceId, + input.datasourceId, + draftRevision, + draftGraphHash, + modelTableNames.join(",") + ].join("|"); +} + export default function ModelingWorkspacePage() { const showDetailsPanel = process.env.NEXT_PUBLIC_MODELING_SHOW_DETAILS_PANEL === "true"; + const showSchemaAndDeployPanels = + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS === "true"; + const showContextDrawer = showDetailsPanel || showSchemaAndDeployPanels; const [workspaceId, setWorkspaceId] = useState(""); const [datasourceId, setDatasourceId] = useState(""); const [policyVersion, setPolicyVersion] = useState(0); @@ -311,6 +532,7 @@ export default function ModelingWorkspacePage() { const [hasPendingDraftChanges, setHasPendingDraftChanges] = useState(false); const [busy, setBusy] = useState(false); const [saving, setSaving] = useState(false); + const [syncingRecommendations, setSyncingRecommendations] = useState(false); const [deploying, setDeploying] = useState(false); const [schemaChangeItems, setSchemaChangeItems] = useState< Array<{ @@ -328,6 +550,8 @@ export default function ModelingWorkspacePage() { const latestSnapshotLoadTokenRef = useRef(0); const positionPatchChangedRef = useRef(false); const nextEditorIntentRequestIdRef = useRef(1); + const graphPayloadRef = useRef(createEmptyGraphPayload()); + const bootstrapAttemptedDraftKeysRef = useRef(new Set()); const scrollToLayoutPane = useCallback((paneId: string): void => { if (typeof window === "undefined") { @@ -505,6 +729,10 @@ export default function ModelingWorkspacePage() { setDeployPrecheck(null); }, [graphPayload]); + useEffect(() => { + graphPayloadRef.current = graphPayload; + }, [graphPayload]); + useEffect(() => { if (!message) { return; @@ -573,87 +801,179 @@ export default function ModelingWorkspacePage() { return undefined; }, [graphPayload.models, graphPayload.relationships, selectedNode]); - const saveModelingGraph = async ( - options: SaveModelingGraphOptions = {} - ): Promise => { - if (!workspaceId || !datasourceId) { - setError("请先选择工作空间与数据源。"); - return null; - } - const graphPayloadToSave = { - models: graphPayload.models, - relationships: graphPayload.relationships, - calculatedFields: graphPayload.calculatedFields, - views: graphPayload.views, - schemaChanges: graphPayload.schemaChanges - }; - const applySavedSnapshot = ( - nextSnapshot: WorkspaceModelingGraphSnapshot, - fallbackPolicyVersion: number - ): void => { - const nextGraphPayload = normalizeGraphPayload(nextSnapshot.draft?.graphPayload); - positionPatchChangedRef.current = false; - setSnapshot(nextSnapshot); - setGraphPayload(nextGraphPayload); - setPolicyVersion(nextSnapshot.draft?.policyVersion ?? fallbackPolicyVersion); - setSelectedNode((previous) => resolveNextSelectedNode(nextGraphPayload, previous)); - setDetailsDirty(false); - setHasPendingDraftChanges(false); - setDeployPrecheck(null); - }; - setSaving(true); - setError(""); - setMessage(""); - try { - const nextSnapshot = await upsertWorkspaceModelingGraph(workspaceId, datasourceId, { - policyVersion, - ...graphPayloadToSave - }); - applySavedSnapshot(nextSnapshot, policyVersion); - if (!options.suppressSuccessMessage) { - setMessage(`Modeling Draft 已保存(revision=${nextSnapshot.draft?.revision ?? "-"})。`); + const persistModelingGraphPayload = useCallback( + async ( + graphPayloadToSave: ModelingGraphPayload, + options: SaveModelingGraphOptions = {} + ): Promise => { + if (!workspaceId || !datasourceId) { + setError("请先选择工作空间与数据源。"); + return null; } - return nextSnapshot; - } catch (submitError) { - if (isPolicyVersionConflictError(submitError)) { - try { - const latestPermissions = await listWorkspaceDatasourceTablePermissions( - workspaceId, - datasourceId - ); - const latestPolicyVersion = latestPermissions.policyVersion; - if (latestPolicyVersion !== policyVersion) { - const retriedSnapshot = await upsertWorkspaceModelingGraph( + const applySavedSnapshot = ( + nextSnapshot: WorkspaceModelingGraphSnapshot, + fallbackPolicyVersion: number + ): void => { + const nextGraphPayload = normalizeGraphPayload(nextSnapshot.draft?.graphPayload); + positionPatchChangedRef.current = false; + setSnapshot(nextSnapshot); + setGraphPayload(nextGraphPayload); + setPolicyVersion(nextSnapshot.draft?.policyVersion ?? fallbackPolicyVersion); + setSelectedNode((previous) => resolveNextSelectedNode(nextGraphPayload, previous)); + setDetailsDirty(false); + setHasPendingDraftChanges(false); + setDeployPrecheck(null); + }; + setSaving(true); + setError(""); + setMessage(""); + try { + const nextSnapshot = await upsertWorkspaceModelingGraph(workspaceId, datasourceId, { + policyVersion, + ...graphPayloadToSave + }); + applySavedSnapshot(nextSnapshot, policyVersion); + if (!options.suppressSuccessMessage) { + setMessage(`Modeling Draft 已保存(revision=${nextSnapshot.draft?.revision ?? "-"})。`); + } + return nextSnapshot; + } catch (submitError) { + if (isPolicyVersionConflictError(submitError)) { + try { + const latestPermissions = await listWorkspaceDatasourceTablePermissions( workspaceId, - datasourceId, - { - policyVersion: latestPolicyVersion, - ...graphPayloadToSave - } + datasourceId ); - applySavedSnapshot(retriedSnapshot, latestPolicyVersion); - if (!options.suppressSuccessMessage) { - setMessage( - `policyVersion 已从 ${policyVersion} 更新为 ${latestPolicyVersion},已自动重试并保存成功(revision=${retriedSnapshot.draft?.revision ?? "-"})。` + const latestPolicyVersion = latestPermissions.policyVersion; + if (latestPolicyVersion !== policyVersion) { + const retriedSnapshot = await upsertWorkspaceModelingGraph( + workspaceId, + datasourceId, + { + policyVersion: latestPolicyVersion, + ...graphPayloadToSave + } ); + applySavedSnapshot(retriedSnapshot, latestPolicyVersion); + if (!options.suppressSuccessMessage) { + setMessage( + `policyVersion 已从 ${policyVersion} 更新为 ${latestPolicyVersion},已自动重试并保存成功(revision=${retriedSnapshot.draft?.revision ?? "-"})。` + ); + } + return retriedSnapshot; } - return retriedSnapshot; + } catch (retryError) { + setError( + retryError instanceof Error + ? `policyVersion 自动刷新重试失败:${retryError.message}` + : "policyVersion 自动刷新重试失败,请刷新后重试。" + ); + return null; } - } catch (retryError) { - setError( - retryError instanceof Error - ? `policyVersion 自动刷新重试失败:${retryError.message}` - : "policyVersion 自动刷新重试失败,请刷新后重试。" - ); - return null; } + setError(submitError instanceof Error ? submitError.message : "保存建模图失败"); + return null; + } finally { + setSaving(false); } - setError(submitError instanceof Error ? submitError.message : "保存建模图失败"); - return null; - } finally { - setSaving(false); + }, + [datasourceId, policyVersion, workspaceId] + ); + + const saveModelingGraph = useCallback( + async (options: SaveModelingGraphOptions = {}): Promise => + persistModelingGraphPayload( + { + models: graphPayload.models, + relationships: graphPayload.relationships, + calculatedFields: graphPayload.calculatedFields, + views: graphPayload.views, + schemaChanges: graphPayload.schemaChanges + }, + options + ), + [graphPayload, persistModelingGraphPayload] + ); + + const syncRecommendedRelationships = useCallback( + async (mode: "bootstrap" | "manual"): Promise => { + if (!workspaceId || !datasourceId) { + if (mode === "manual") { + setError("请先选择工作空间与数据源。"); + } + return; + } + const currentPayload = graphPayloadRef.current; + const selectedTableNames = normalizeModelTableNamesForRecommendation(currentPayload); + if (selectedTableNames.length === 0) { + if (mode === "manual") { + setMessage("当前草稿缺少可同步的模型表,请先创建或导入模型。"); + } + return; + } + setSyncingRecommendations(true); + setError(""); + if (mode === "manual") { + setMessage(""); + } + try { + const recommendations = await recommendModelingSetupRelationships( + workspaceId, + datasourceId, + { + selectedTables: selectedTableNames + } + ); + const mergeResult = mergeRecommendedRelationshipsIntoGraphPayload( + currentPayload, + recommendations + ); + if (mergeResult.addedCount === 0) { + if (mode === "manual") { + setMessage(`同步数据库完成:无新增关系(跳过 ${mergeResult.skippedCount} 条)。`); + } + return; + } + const savedSnapshot = await persistModelingGraphPayload(mergeResult.nextPayload, { + suppressSuccessMessage: true + }); + if (!savedSnapshot) { + return; + } + setMessage( + mode === "bootstrap" + ? `已自动补全 ${mergeResult.addedCount} 条关系(跳过 ${mergeResult.skippedCount} 条)。` + : `同步数据库完成:新增 ${mergeResult.addedCount} 条,跳过 ${mergeResult.skippedCount} 条。` + ); + } catch (syncError) { + const fallbackMessage = mode === "bootstrap" ? "自动补全关系失败" : "同步数据库失败"; + setError(syncError instanceof Error ? syncError.message : fallbackMessage); + } finally { + setSyncingRecommendations(false); + } + }, + [datasourceId, persistModelingGraphPayload, workspaceId] + ); + + useEffect(() => { + if (!workspaceId || !datasourceId) { + return; } - }; + const nextAttemptDraftKey = buildBootstrapAttemptDraftKey({ + workspaceId, + datasourceId, + snapshot, + payload: graphPayload + }); + if (!nextAttemptDraftKey) { + return; + } + if (bootstrapAttemptedDraftKeysRef.current.has(nextAttemptDraftKey)) { + return; + } + bootstrapAttemptedDraftKeysRef.current.add(nextAttemptDraftKey); + void syncRecommendedRelationships("bootstrap"); + }, [datasourceId, graphPayload, snapshot, syncRecommendedRelationships, workspaceId]); const publishModelingDraft = async (): Promise => { if (!workspaceId || !datasourceId) { @@ -823,7 +1143,8 @@ export default function ModelingWorkspacePage() { return true; } const shouldOpenContextDrawer = - showDetailsPanel && (nextNode.kind === "view" || nextNode.kind === "relationship"); + nextNode.kind === "relationship" || + (showDetailsPanel && nextNode.kind === "view"); const isSame = selectedNode?.kind === nextNode.kind && selectedNode?.id === nextNode.id; if (isSame) { @@ -1107,12 +1428,35 @@ export default function ModelingWorkspacePage() {
+ @@ -1218,7 +1562,7 @@ export default function ModelingWorkspacePage() { - { - if (open) { - setContextDrawerOpen(true); - return; - } - if (detailsDirty && typeof window !== "undefined") { - const confirmed = window.confirm("当前详情面板有未保存改动,确认关闭 Context Drawer 吗?"); - if (!confirmed) { + {showContextDrawer ? ( + { + if (open) { + setContextDrawerOpen(true); return; } - } - setContextDrawerOpen(false); - }} - > - {showDetailsPanel ? ( - { - setGraphPayload((previous) => { - if (node.kind === "model") { + if (detailsDirty && typeof window !== "undefined") { + const confirmed = window.confirm("当前详情面板有未保存改动,确认关闭 Context Drawer 吗?"); + if (!confirmed) { + return; + } + } + setContextDrawerOpen(false); + }} + > + {showDetailsPanel ? ( + { + setGraphPayload((previous) => { + if (node.kind === "model") { + return { + ...previous, + models: previous.models.map((item) => + item.id === node.id + ? { + ...item, + displayName: input.displayName, + description: input.description + } + : item + ) + }; + } return { ...previous, - models: previous.models.map((item) => + views: previous.views.map((item) => item.id === node.id ? { ...item, @@ -1285,66 +1643,58 @@ export default function ModelingWorkspacePage() { : item ) }; - } - return { + }); + setHasPendingDraftChanges(true); + setDeployPrecheck(null); + setMessage("Metadata 已更新,点击“保存 Modeling Draft”后提交。"); + }} + onCalculatedFieldsSave={async (fields) => { + setGraphPayload((previous) => ({ ...previous, - views: previous.views.map((item) => - item.id === node.id - ? { - ...item, - displayName: input.displayName, - description: input.description - } - : item - ) - }; - }); - setHasPendingDraftChanges(true); - setDeployPrecheck(null); - setMessage("Metadata 已更新,点击“保存 Modeling Draft”后提交。"); - }} - onCalculatedFieldsSave={async (fields) => { - setGraphPayload((previous) => ({ - ...previous, - calculatedFields: fields - })); - setHasPendingDraftChanges(true); - setDeployPrecheck(null); - setMessage("Calculated Fields 已更新,点击“保存 Modeling Draft”后提交。"); - }} - onRelationshipsSave={handleRelationshipsSave} - onLoadPreview={loadPreviewForDetails} - onDeleteTarget={async ({ targetKind, targetId }) => { - if (targetKind === "model") { - deleteModelFromSidebar(targetId); - return; - } - deleteViewFromSidebar(targetId); - }} - onSelectRelationship={handleSelectRelationship} - /> - ) : null} - - item.status === "detected").length} - onDetect={detectSchemaChanges} - onResolve={resolveSchemaChange} - /> + calculatedFields: fields + })); + setHasPendingDraftChanges(true); + setDeployPrecheck(null); + setMessage("Calculated Fields 已更新,点击“保存 Modeling Draft”后提交。"); + }} + onRelationshipsSave={handleRelationshipsSave} + onLoadPreview={loadPreviewForDetails} + onDeleteTarget={async ({ targetKind, targetId }) => { + if (targetKind === "model") { + deleteModelFromSidebar(targetId); + return; + } + deleteViewFromSidebar(targetId); + }} + onSelectRelationship={handleSelectRelationship} + /> + ) : null} - - + {showSchemaAndDeployPanels ? ( + <> + item.status === "detected").length} + onDetect={detectSchemaChanges} + onResolve={resolveSchemaChange} + /> + + + + ) : null} + + ) : null}
diff --git a/apps/frontend/src/components/data-sources/setup-relationships-step.tsx b/apps/frontend/src/components/data-sources/setup-relationships-step.tsx index f024526..0b132e0 100644 --- a/apps/frontend/src/components/data-sources/setup-relationships-step.tsx +++ b/apps/frontend/src/components/data-sources/setup-relationships-step.tsx @@ -17,6 +17,40 @@ function dedupe(value: string[]): string[] { return Array.from(new Set(value.map((item) => item.trim()).filter(Boolean))); } +function resolveCardinalityBadge( + cardinality: ModelingSetupRelationshipSuggestion["cardinality"] +): "1:N" | "1:1" { + if (cardinality === "one-to-one") { + return "1:1"; + } + return "1:N"; +} + +function resolveDirectionText( + cardinality: ModelingSetupRelationshipSuggestion["cardinality"] +): "N -> 1" | "1 -> N" | "1 -> 1" { + if (cardinality === "one-to-many") { + return "1 -> N"; + } + if (cardinality === "one-to-one") { + return "1 -> 1"; + } + return "N -> 1"; +} + +function resolveSourceLabel(reason?: string): string { + if (reason === "foreign_key_constraint") { + return "FK"; + } + if (reason === "fk_naming_suffix") { + return "命名推断"; + } + if (reason?.trim()) { + return reason.trim(); + } + return "推断"; +} + export function SetupRelationshipsStep({ suggestions, selectedSuggestionIds, @@ -89,14 +123,22 @@ export function SetupRelationshipsStep({ aria-label={`选择关系建议 ${item.name}`} />
-

{item.name}

+
+

{item.name}

+ + {resolveCardinalityBadge(item.cardinality)} + + + {resolveSourceLabel(item.reason)} + +

- {item.left.dataset}.{item.left.table}.{item.left.column} ={" "} + Bridge: {item.left.dataset}.{item.left.table}.{item.left.column} ={" "} {item.right.dataset}.{item.right.table}.{item.right.column}

- {item.reason ? ( -

依据:{item.reason}

- ) : null} +

+ 方向 {resolveDirectionText(item.cardinality)} +

置信度 {(item.confidence * 100).toFixed(0)}% @@ -108,4 +150,3 @@ export function SetupRelationshipsStep({ ); } - diff --git a/apps/frontend/src/components/settings/modeling/modeling-flow-canvas.tsx b/apps/frontend/src/components/settings/modeling/modeling-flow-canvas.tsx index 3e4c9e1..3da1aaf 100644 --- a/apps/frontend/src/components/settings/modeling/modeling-flow-canvas.tsx +++ b/apps/frontend/src/components/settings/modeling/modeling-flow-canvas.tsx @@ -81,6 +81,47 @@ export type ModelingFlowNodeActionEvent = { action: ModelingFlowNodeAction; }; +function areStringArraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false; + } + return left.every((value, index) => value === right[index]); +} + +function resolveHighlightedRelationshipIdsForNode( + nodeData: ModelingFlowNodeData, + selectedRelationshipId: string +): string[] { + const normalizedRelationshipId = selectedRelationshipId.trim(); + if (!normalizedRelationshipId || nodeData.kind !== "model") { + return []; + } + const hasRelationshipActionId = (nodeData.relationshipActionIds ?? []).some( + (relationshipActionId) => relationshipActionId === normalizedRelationshipId + ); + return hasRelationshipActionId ? [normalizedRelationshipId] : []; +} + +function decorateFlowEdgeWithSelectionState( + edge: Edge, + selectedRelationshipId: string, + selectedModelFlowNodeId: string +): Edge { + const isSelectedRelationship = edge.id === selectedRelationshipId; + const isIncidentToSelectedModel = Boolean(selectedModelFlowNodeId) && + (edge.source === selectedModelFlowNodeId || edge.target === selectedModelFlowNodeId); + const previousData = edge.data ?? ({} as ModelingFlowEdgeData); + return { + ...edge, + selected: isSelectedRelationship, + data: { + ...previousData, + highlightedBySelectedRelationship: isSelectedRelationship, + highlightedBySelectedModel: isIncidentToSelectedModel + } + }; +} + function resolveModelLabel(model: ModelingGraphPayload["models"][number]): string { return model.displayName?.trim() || model.modelName?.trim() || model.tableName; } @@ -960,6 +1001,7 @@ export function ModelingFlowCanvas(props: { selectedNode: ModelingSidebarNode | null; busy?: boolean; autoLayoutKey: string; + programmaticAutoLayoutRequestId?: number | null; onSelectNode: (node: ModelingSidebarNode | null) => void; onNodeAction?: (event: ModelingFlowNodeActionEvent) => void; onNodePositionsChange?: (patch: ModelingFlowPositionPatch) => void; @@ -969,6 +1011,7 @@ export function ModelingFlowCanvas(props: { selectedNode, busy, autoLayoutKey, + programmaticAutoLayoutRequestId, onSelectNode, onNodeAction, onNodePositionsChange @@ -983,8 +1026,11 @@ export function ModelingFlowCanvas(props: { const [layoutBusy, setLayoutBusy] = useState(false); const [layoutMessage, setLayoutMessage] = useState(""); const [layoutMessageVariant, setLayoutMessageVariant] = useState<"success" | "error">("success"); + const [pendingProgrammaticAutoLayoutRequestId, setPendingProgrammaticAutoLayoutRequestId] = + useState(null); const selectionFocusKeyRef = useRef(""); const latestLayoutRunIdRef = useRef(0); + const latestProgrammaticAutoLayoutRequestIdRef = useRef(null); const graph = useMemo(() => { try { @@ -1014,6 +1060,7 @@ export function ModelingFlowCanvas(props: { selectedNode?.kind === "model" || selectedNode?.kind === "view" ? toFlowNodeId(selectedNode) : ""; + const selectedModelFlowNodeId = selectedNode?.kind === "model" ? toFlowNodeId(selectedNode) : ""; const selectedRelationshipId = selectedNode?.kind === "relationship" ? selectedNode.id : ""; useEffect(() => { @@ -1024,54 +1071,90 @@ export function ModelingFlowCanvas(props: { const nextNodes = graph.nodes.map((node) => ({ ...node, selected: node.id === selectedFlowNodeId, + data: { + ...node.data, + highlightedRelationshipIds: resolveHighlightedRelationshipIdsForNode( + node.data, + selectedRelationshipId + ) + }, position: persistedPositionByNodeId.get(node.id) ?? previousPositionById.get(node.id) ?? node.position })); return nextNodes; }); setFlowEdges( - graph.edges.map((edge) => ({ - ...edge, - selected: edge.id === selectedRelationshipId - })) + graph.edges.map((edge) => + decorateFlowEdgeWithSelectionState(edge, selectedRelationshipId, selectedModelFlowNodeId) + ) ); - }, [graph, persistedPositionByNodeId, selectedFlowNodeId, selectedRelationshipId]); + }, [ + graph, + persistedPositionByNodeId, + selectedFlowNodeId, + selectedModelFlowNodeId, + selectedRelationshipId + ]); useEffect(() => { setFlowNodes((previousNodes) => previousNodes.map((node) => { const isSelected = node.id === selectedFlowNodeId; - if (node.selected === isSelected) { + const nextHighlightedRelationshipIds = resolveHighlightedRelationshipIdsForNode( + node.data, + selectedRelationshipId + ); + const previousHighlightedRelationshipIds = node.data.highlightedRelationshipIds ?? []; + const highlightedRelationshipChanged = !areStringArraysEqual( + previousHighlightedRelationshipIds, + nextHighlightedRelationshipIds + ); + if (node.selected === isSelected && !highlightedRelationshipChanged) { return node; } return { ...node, - selected: isSelected + selected: isSelected, + data: highlightedRelationshipChanged + ? { + ...node.data, + highlightedRelationshipIds: nextHighlightedRelationshipIds + } + : node.data }; }) ); setFlowEdges((previousEdges) => - previousEdges.map((edge) => { - const isSelected = edge.id === selectedRelationshipId; - if (edge.selected === isSelected) { - return edge; - } - return { - ...edge, - selected: isSelected - }; - }) + previousEdges.map((edge) => + decorateFlowEdgeWithSelectionState(edge, selectedRelationshipId, selectedModelFlowNodeId) + ) ); - }, [selectedFlowNodeId, selectedRelationshipId]); + }, [selectedFlowNodeId, selectedModelFlowNodeId, selectedRelationshipId]); useEffect(() => { setDidAutoFit(false); selectionFocusKeyRef.current = ""; latestLayoutRunIdRef.current += 1; + latestProgrammaticAutoLayoutRequestIdRef.current = null; + setPendingProgrammaticAutoLayoutRequestId(null); setLayoutBusy(false); setLayoutMessage(""); }, [autoLayoutKey]); + useEffect(() => { + if ( + typeof programmaticAutoLayoutRequestId !== "number" || + !Number.isFinite(programmaticAutoLayoutRequestId) + ) { + return; + } + if (latestProgrammaticAutoLayoutRequestIdRef.current === programmaticAutoLayoutRequestId) { + return; + } + latestProgrammaticAutoLayoutRequestIdRef.current = programmaticAutoLayoutRequestId; + setPendingProgrammaticAutoLayoutRequestId(programmaticAutoLayoutRequestId); + }, [programmaticAutoLayoutRequestId]); + useEffect(() => { if (!layoutMessage || layoutMessageVariant !== "success") { return; @@ -1209,6 +1292,22 @@ export function ModelingFlowCanvas(props: { } }, [busy, flowEdges, flowInstance, flowNodes, graph.handleLookup, layoutBusy, onNodePositionsChange]); + useEffect(() => { + if (pendingProgrammaticAutoLayoutRequestId === null || busy || layoutBusy || flowNodes.length < 2) { + return; + } + setPendingProgrammaticAutoLayoutRequestId((currentRequestId) => + currentRequestId === pendingProgrammaticAutoLayoutRequestId ? null : currentRequestId + ); + void handleAutoLayout(); + }, [ + busy, + flowNodes.length, + handleAutoLayout, + layoutBusy, + pendingProgrammaticAutoLayoutRequestId + ]); + const handleNodeClick = useCallback( (_event: unknown, node: Node) => { const nextNode = fromFlowNodeId(node.id); diff --git a/apps/frontend/src/components/settings/modeling/modeling-flow-edge.tsx b/apps/frontend/src/components/settings/modeling/modeling-flow-edge.tsx index 9e3942e..56ed87c 100644 --- a/apps/frontend/src/components/settings/modeling/modeling-flow-edge.tsx +++ b/apps/frontend/src/components/settings/modeling/modeling-flow-edge.tsx @@ -5,7 +5,7 @@ import { getSmoothStepPath, type EdgeProps } from "@xyflow/react"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import type { ModelingGraphRelationshipType } from "@text2sql/shared-types"; import { cn } from "@/lib/utils"; @@ -29,8 +29,17 @@ export type ModelingFlowEdgeData = { }; description?: string; invalid?: boolean; + highlightedBySelectedRelationship?: boolean; + highlightedBySelectedModel?: boolean; }; +type ModelingFlowEdgeVisualState = + | "invalid" + | "selected-relationship" + | "selected-model-incident" + | "inferred-or-low-confidence" + | "normal"; + function resolveEdgeRelationshipType( edgeData?: ModelingFlowEdgeData ): ModelingGraphRelationshipType | "many-to-many" | null { @@ -82,6 +91,34 @@ function resolveEndpointMarkers( }; } +function resolveCardinalityBadgeText(value: "one" | "many" | "none"): string { + if (value === "one") { + return "1"; + } + if (value === "many") { + return "N"; + } + return ""; +} + +function resolveCardinalityBadgeOffset( + position: string | null | undefined +): { x: number; y: number } { + if (position === "left") { + return { x: -14, y: 0 }; + } + if (position === "right") { + return { x: 14, y: 0 }; + } + if (position === "top") { + return { x: 0, y: -14 }; + } + if (position === "bottom") { + return { x: 0, y: 14 }; + } + return { x: 0, y: 0 }; +} + function resolveRelationshipTypeLabel( relationshipType: ModelingGraphRelationshipType | "many-to-many" | null ): string { @@ -109,6 +146,29 @@ function formatEndpointPath( return `${endpoint.dataset}.${endpoint.table}.${endpoint.column}`; } +function resolveEdgeVisualState(params: { + invalid: boolean; + selectedRelationship: boolean; + selectedModelIncident: boolean; + inferred: boolean; + lowConfidence: boolean; +}): ModelingFlowEdgeVisualState { + const { invalid, selectedRelationship, selectedModelIncident, inferred, lowConfidence } = params; + if (invalid) { + return "invalid"; + } + if (selectedRelationship) { + return "selected-relationship"; + } + if (selectedModelIncident) { + return "selected-model-incident"; + } + if (inferred || lowConfidence) { + return "inferred-or-low-confidence"; + } + return "normal"; +} + export function ModelingFlowEdge({ id, sourceX, @@ -127,38 +187,40 @@ export function ModelingFlowEdge({ const markerType = resolveEndpointMarkers(relationshipType); const relationshipTypeLabel = resolveRelationshipTypeLabel(relationshipType); const isInvalid = Boolean(edgeData?.invalid); + const isInferred = edgeData?.source === "inferred"; const isLowConfidence = !isInvalid && confidence < 0.6; - const hasDash = !selected && (isInvalid || edgeData?.source === "inferred" || isLowConfidence); - const confidenceBand = isInvalid ? "invalid" : isLowConfidence ? "low" : "normal"; + const selectedRelationship = Boolean(selected || edgeData?.highlightedBySelectedRelationship); + const selectedModelIncident = Boolean(edgeData?.highlightedBySelectedModel) && !selectedRelationship; + const visualState = resolveEdgeVisualState({ + invalid: isInvalid, + selectedRelationship, + selectedModelIncident, + inferred: isInferred, + lowConfidence: isLowConfidence + }); + const hasDash = visualState === "invalid" || visualState === "inferred-or-low-confidence"; + const confidenceBand = isInvalid + ? "invalid" + : isLowConfidence + ? "low" + : isInferred + ? "inferred" + : "normal"; const fromPath = formatEndpointPath(edgeData?.from); const toPath = formatEndpointPath(edgeData?.to); - const markerColor = isInvalid - ? selected + const sourceBadgeText = resolveCardinalityBadgeText(markerType.source); + const targetBadgeText = resolveCardinalityBadgeText(markerType.target); + const sourceBadgeOffset = resolveCardinalityBadgeOffset(sourcePosition); + const targetBadgeOffset = resolveCardinalityBadgeOffset(targetPosition); + const badgeColor = + visualState === "invalid" ? "#dc2626" - : "#ef4444" - : isLowConfidence - ? "#d97706" - : selected + : visualState === "selected-relationship" ? "var(--action-primary)" - : "var(--border-strong)"; - const edgeMarkerIdPrefix = useMemo( - () => `modeling-flow-edge-${id.replace(/[^a-zA-Z0-9_-]/g, "_")}`, - [id] - ); - const oneMarkerId = `${edgeMarkerIdPrefix}-one`; - const manyMarkerId = `${edgeMarkerIdPrefix}-many`; - const markerStart = - markerType.source === "one" - ? `url(#${oneMarkerId})` - : markerType.source === "many" - ? `url(#${manyMarkerId})` - : undefined; - const markerEnd = - markerType.target === "one" - ? `url(#${oneMarkerId})` - : markerType.target === "many" - ? `url(#${manyMarkerId})` - : undefined; + : isLowConfidence + ? "#b45309" + : "#64748b"; + const badgeRadius = 10; const shouldShowDetailCard = Boolean(edgeData && (hovered || selected)); const [path, labelX, labelY] = getSmoothStepPath({ @@ -174,54 +236,23 @@ export function ModelingFlowEdge({ return ( <> - - - - - - - - { setHovered(true); @@ -230,28 +261,73 @@ export function ModelingFlowEdge({ setHovered(false); }} data-confidence-band={confidenceBand} - data-selected={selected ? "true" : "false"} + data-selected={selectedRelationship ? "true" : "false"} + data-visual-state={visualState} data-testid="modeling-flow-edge-path" /> + {sourceBadgeText ? ( + + + + {sourceBadgeText} + + + ) : null} + {targetBadgeText ? ( + + + + {targetBadgeText} + + + ) : null} {shouldShowDetailCard ? ( - -
+ +
Relationship
-
+

From

-

{fromPath}

+

+ {fromPath} +

To

-

{toPath}

+

+ {toPath} +

diff --git a/apps/frontend/src/components/settings/modeling/modeling-flow-node.tsx b/apps/frontend/src/components/settings/modeling/modeling-flow-node.tsx index 8c3524c..bec1ba6 100644 --- a/apps/frontend/src/components/settings/modeling/modeling-flow-node.tsx +++ b/apps/frontend/src/components/settings/modeling/modeling-flow-node.tsx @@ -60,6 +60,7 @@ export type ModelingFlowNodeData = { columnDisplayMeta?: ModelingFlowColumnDisplayMeta[]; relationshipDisplayMeta?: ModelingFlowRelationshipDisplayMeta[]; relationshipActionIds?: Array; + highlightedRelationshipIds?: string[]; actionsDisabled?: boolean; onNodeAction?: (action: ModelingFlowNodeAction) => void; }; @@ -165,6 +166,11 @@ export function ModelingFlowNode({ } nodeData.onNodeAction?.(action); }; + const highlightedRelationshipIdSet = new Set( + (nodeData.highlightedRelationshipIds ?? []) + .map((relationshipId) => relationshipId.trim()) + .filter((relationshipId) => relationshipId.length > 0) + ); return (
{hasRelationshipActionEntry ? ( <> diff --git a/apps/frontend/src/lib/admin-api-client.ts b/apps/frontend/src/lib/admin-api-client.ts index 99e084b..dbbe786 100644 --- a/apps/frontend/src/lib/admin-api-client.ts +++ b/apps/frontend/src/lib/admin-api-client.ts @@ -199,6 +199,8 @@ export interface ModelingSetupRelationshipSuggestion { confidence: number; left: RelationshipBridgeEndpoint; right: RelationshipBridgeEndpoint; + type: ModelingGraphRelationship["type"]; + cardinality: ModelingGraphRelationship["cardinality"]; reason?: string; } @@ -653,6 +655,16 @@ function normalizeModelingSetupRelationshipSuggestion( typeof value.name === "string" && value.name.trim() ? value.name.trim() : `${left.table}.${left.column} = ${right.table}.${right.column}`; + const typeRaw = + typeof value.type === "string" + ? value.type + : typeof value.cardinality === "string" + ? value.cardinality + : undefined; + const relationshipType: ModelingGraphRelationship["type"] = + typeRaw === "many-to-one" || typeRaw === "one-to-many" || typeRaw === "one-to-one" + ? typeRaw + : "many-to-one"; return { id, name, @@ -662,6 +674,8 @@ function normalizeModelingSetupRelationshipSuggestion( ), left, right, + type: relationshipType, + cardinality: relationshipType, reason: typeof value.reason === "string" ? value.reason diff --git a/apps/frontend/tests/unit/admin-api-client-modeling-contract.spec.ts b/apps/frontend/tests/unit/admin-api-client-modeling-contract.spec.ts index 47c895d..54810f7 100644 --- a/apps/frontend/tests/unit/admin-api-client-modeling-contract.spec.ts +++ b/apps/frontend/tests/unit/admin-api-client-modeling-contract.spec.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getWorkspaceModelingGraph, - precheckWorkspaceModelingDeploy + precheckWorkspaceModelingDeploy, + recommendModelingSetupRelationships } from "@/lib/admin-api-client"; describe("admin-api-client modeling contract parity", () => { @@ -153,4 +154,60 @@ describe("admin-api-client modeling contract parity", () => { deployState: "undeployed" }); }); + + it("preserves explicit recommendation cardinality and defaults missing to many-to-one", async () => { + global.fetch = vi.fn(async () => + new Response( + JSON.stringify({ + status: "ok", + data: { + suggestions: [ + { + id: "rel-explicit", + name: "orders_to_customers", + reason: "foreign_key_constraint", + type: "one-to-one", + bridge: { + left: { dataset: "analytics", table: "orders", column: "customer_id" }, + right: { dataset: "analytics", table: "customers", column: "id" }, + operator: "eq", + confidence: 0.98 + } + }, + { + id: "rel-defaulted", + name: "payments_to_orders", + reason: "fk_naming_suffix", + bridge: { + left: { dataset: "analytics", table: "payments", column: "order_id" }, + right: { dataset: "analytics", table: "orders", column: "id" }, + operator: "eq", + confidence: 0.86 + } + } + ] + } + }), + { + status: 200, + headers: { "content-type": "application/json" } + } + ) + ) as typeof fetch; + + const suggestions = await recommendModelingSetupRelationships("ws-1", "ds-1", { + selectedTables: ["orders", "customers", "payments"] + }); + expect(suggestions).toHaveLength(2); + expect(suggestions[0]).toMatchObject({ + id: "rel-explicit", + type: "one-to-one", + cardinality: "one-to-one" + }); + expect(suggestions[1]).toMatchObject({ + id: "rel-defaulted", + type: "many-to-one", + cardinality: "many-to-one" + }); + }); }); diff --git a/apps/frontend/tests/unit/data-sources-modeling-setup.spec.tsx b/apps/frontend/tests/unit/data-sources-modeling-setup.spec.tsx index 634378a..6d1dd2c 100644 --- a/apps/frontend/tests/unit/data-sources-modeling-setup.spec.tsx +++ b/apps/frontend/tests/unit/data-sources-modeling-setup.spec.tsx @@ -145,6 +145,9 @@ describe("DataSourcesPage modeling setup wizard", () => { id: "rel-orders-customers", name: "orders.customer_id = customers.id", confidence: 0.92, + type: "many-to-one", + cardinality: "many-to-one", + reason: "foreign_key_constraint", left: { dataset: "analytics", table: "orders", column: "customer_id" }, right: { dataset: "analytics", table: "customers", column: "id" } } @@ -256,4 +259,151 @@ describe("DataSourcesPage modeling setup wizard", () => { expect(mockListModelingSetupTables).toHaveBeenCalledWith("ws-existing", "mysql_main"); }); }); + + it("shows relationship card metadata with cardinality/source/bridge/confidence", async () => { + const user = userEvent.setup(); + await createDatasourceAndOpenSetupWizard(user); + + await user.click(screen.getByRole("button", { name: "下一步:确认关系" })); + + expect(await screen.findByText("orders.customer_id = customers.id")).toBeInTheDocument(); + expect(screen.getByText("1:N")).toBeInTheDocument(); + expect(screen.getByText("FK")).toBeInTheDocument(); + expect( + screen.getByText("Bridge: analytics.orders.customer_id = analytics.customers.id") + ).toBeInTheDocument(); + expect(screen.getByText("置信度 92%")).toBeInTheDocument(); + }); + + it("preserves existing suggestion selection and auto-selects newly added suggestions", async () => { + const user = userEvent.setup(); + mockRecommendModelingSetupRelationships + .mockResolvedValueOnce([ + { + id: "rel-orders-customers", + name: "orders.customer_id = customers.id", + confidence: 0.92, + type: "many-to-one", + cardinality: "many-to-one", + reason: "foreign_key_constraint", + left: { dataset: "analytics", table: "orders", column: "customer_id" }, + right: { dataset: "analytics", table: "customers", column: "id" } + }, + { + id: "rel-orders-users", + name: "orders.user_id = users.id", + confidence: 0.88, + type: "many-to-one", + cardinality: "many-to-one", + reason: "fk_naming_suffix", + left: { dataset: "analytics", table: "orders", column: "user_id" }, + right: { dataset: "analytics", table: "users", column: "id" } + } + ]) + .mockResolvedValueOnce([ + { + id: "rel-orders-customers", + name: "orders.customer_id = customers.id", + confidence: 0.92, + type: "many-to-one", + cardinality: "many-to-one", + reason: "foreign_key_constraint", + left: { dataset: "analytics", table: "orders", column: "customer_id" }, + right: { dataset: "analytics", table: "customers", column: "id" } + }, + { + id: "rel-orders-users", + name: "orders.user_id = users.id", + confidence: 0.88, + type: "many-to-one", + cardinality: "many-to-one", + reason: "fk_naming_suffix", + left: { dataset: "analytics", table: "orders", column: "user_id" }, + right: { dataset: "analytics", table: "users", column: "id" } + }, + { + id: "rel-orders-regions", + name: "orders.region_id = regions.id", + confidence: 0.81, + type: "many-to-one", + cardinality: "many-to-one", + reason: "fk_naming_suffix", + left: { dataset: "analytics", table: "orders", column: "region_id" }, + right: { dataset: "analytics", table: "regions", column: "id" } + } + ]); + + await createDatasourceAndOpenSetupWizard(user); + await user.click(screen.getByRole("button", { name: "下一步:确认关系" })); + + const preservedCheckbox = await screen.findByRole("checkbox", { + name: "选择关系建议 orders.user_id = users.id" + }); + expect(preservedCheckbox).toBeChecked(); + await user.click(preservedCheckbox); + expect(preservedCheckbox).not.toBeChecked(); + + await user.click(screen.getByRole("button", { name: "上一步" })); + await user.click(screen.getByRole("button", { name: "下一步:确认关系" })); + + const customersCheckbox = await screen.findByRole("checkbox", { + name: "选择关系建议 orders.customer_id = customers.id" + }); + const usersCheckbox = screen.getByRole("checkbox", { + name: "选择关系建议 orders.user_id = users.id" + }); + const regionsCheckbox = screen.getByRole("checkbox", { + name: "选择关系建议 orders.region_id = regions.id" + }); + + expect(customersCheckbox).toBeChecked(); + expect(usersCheckbox).not.toBeChecked(); + expect(regionsCheckbox).toBeChecked(); + }); + + it("keeps select-all and clear-all behavior on relationship step", async () => { + const user = userEvent.setup(); + mockRecommendModelingSetupRelationships.mockResolvedValueOnce([ + { + id: "rel-orders-customers", + name: "orders.customer_id = customers.id", + confidence: 0.92, + type: "many-to-one", + cardinality: "many-to-one", + reason: "foreign_key_constraint", + left: { dataset: "analytics", table: "orders", column: "customer_id" }, + right: { dataset: "analytics", table: "customers", column: "id" } + }, + { + id: "rel-orders-users", + name: "orders.user_id = users.id", + confidence: 0.88, + type: "many-to-one", + cardinality: "many-to-one", + reason: "fk_naming_suffix", + left: { dataset: "analytics", table: "orders", column: "user_id" }, + right: { dataset: "analytics", table: "users", column: "id" } + } + ]); + + await createDatasourceAndOpenSetupWizard(user); + await user.click(screen.getByRole("button", { name: "下一步:确认关系" })); + + const checkboxA = await screen.findByRole("checkbox", { + name: "选择关系建议 orders.customer_id = customers.id" + }); + const checkboxB = screen.getByRole("checkbox", { + name: "选择关系建议 orders.user_id = users.id" + }); + expect(checkboxA).toBeChecked(); + expect(checkboxB).toBeChecked(); + + await user.click(screen.getByRole("button", { name: "全不选" })); + expect(checkboxA).not.toBeChecked(); + expect(checkboxB).not.toBeChecked(); + + await user.click(screen.getByRole("button", { name: "全选" })); + expect(checkboxA).toBeChecked(); + expect(checkboxB).toBeChecked(); + }); }); diff --git a/apps/frontend/tests/unit/settings-modeling-auto-layout-resilience.spec.tsx b/apps/frontend/tests/unit/settings-modeling-auto-layout-resilience.spec.tsx index 83c7bf0..c9d52e9 100644 --- a/apps/frontend/tests/unit/settings-modeling-auto-layout-resilience.spec.tsx +++ b/apps/frontend/tests/unit/settings-modeling-auto-layout-resilience.spec.tsx @@ -200,4 +200,58 @@ describe("ModelingFlowCanvas auto layout resilience", () => { expect(autoLayoutButton).toBeEnabled(); }); }); + + it("queues programmatic layout requests as one-shot triggers keyed by request id", async () => { + computeElkLayoutMock.mockResolvedValue({ + ok: true, + elapsedMs: 180, + positions: { + "model:model.a": { x: 120, y: 80 }, + "model:model.b": { x: 420, y: 80 } + } + }); + + const { rerender } = render( + + ); + + await waitFor(() => { + expect(computeElkLayoutMock).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("resilience-first-position")).toHaveTextContent("120,80"); + }); + + rerender( + + ); + + await waitFor(() => { + expect(computeElkLayoutMock).toHaveBeenCalledTimes(1); + }); + + rerender( + + ); + + await waitFor(() => { + expect(computeElkLayoutMock).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/apps/frontend/tests/unit/settings-modeling-complete-parity.spec.tsx b/apps/frontend/tests/unit/settings-modeling-complete-parity.spec.tsx index 3c99239..381e6d9 100644 --- a/apps/frontend/tests/unit/settings-modeling-complete-parity.spec.tsx +++ b/apps/frontend/tests/unit/settings-modeling-complete-parity.spec.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ModelingWorkspacePage from "@/app/settings/modeling/page"; import { deployWorkspaceModeling, @@ -95,10 +95,13 @@ const mockGetWorkspaceModelingPreview = vi.mocked(getWorkspaceModelingPreview); const mockUpsertWorkspaceModelingGraph = vi.mocked(upsertWorkspaceModelingGraph); const mockPrecheckWorkspaceModelingDeploy = vi.mocked(precheckWorkspaceModelingDeploy); const mockDeployWorkspaceModeling = vi.mocked(deployWorkspaceModeling); +const originalShowSchemaDeployPanels = + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS; describe("settings modeling complete parity", () => { beforeEach(() => { vi.clearAllMocks(); + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS = "true"; window.history.replaceState( {}, "", @@ -337,6 +340,10 @@ describe("settings modeling complete parity", () => { }); }); + afterEach(() => { + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS = originalShowSchemaDeployPanels; + }); + it("keeps save-as-view context, supports view delete, and completes deploy gating flow", async () => { const user = userEvent.setup(); render(); diff --git a/apps/frontend/tests/unit/settings-modeling-deploy.spec.tsx b/apps/frontend/tests/unit/settings-modeling-deploy.spec.tsx index 6941460..4228f37 100644 --- a/apps/frontend/tests/unit/settings-modeling-deploy.spec.tsx +++ b/apps/frontend/tests/unit/settings-modeling-deploy.spec.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ModelingWorkspacePage from "@/app/settings/modeling/page"; import { deployWorkspaceModeling, @@ -8,7 +8,8 @@ import { listWorkspaceDatasourceBindings, listWorkspaceDatasourceTablePermissions, listWorkspaces, - precheckWorkspaceModelingDeploy + precheckWorkspaceModelingDeploy, + recommendModelingSetupRelationships } from "@/lib/admin-api-client"; vi.mock("@/components/settings/modeling/modeling-flow-canvas", () => ({ @@ -45,7 +46,8 @@ vi.mock("@/lib/admin-api-client", async (importOriginal) => { listWorkspaceDatasourceTablePermissions: vi.fn(), getWorkspaceModelingGraph: vi.fn(), precheckWorkspaceModelingDeploy: vi.fn(), - deployWorkspaceModeling: vi.fn() + deployWorkspaceModeling: vi.fn(), + recommendModelingSetupRelationships: vi.fn() }; }); @@ -57,10 +59,14 @@ const mockListWorkspaceDatasourceTablePermissions = vi.mocked( const mockGetWorkspaceModelingGraph = vi.mocked(getWorkspaceModelingGraph); const mockPrecheckWorkspaceModelingDeploy = vi.mocked(precheckWorkspaceModelingDeploy); const mockDeployWorkspaceModeling = vi.mocked(deployWorkspaceModeling); +const mockRecommendModelingSetupRelationships = vi.mocked(recommendModelingSetupRelationships); +const originalShowSchemaDeployPanels = + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS; describe("settings modeling deploy flow", () => { beforeEach(() => { vi.clearAllMocks(); + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS = "true"; window.history.replaceState({}, "", "/modeling?workspaceId=ws-1&datasourceId=ds-1"); mockListWorkspaces.mockResolvedValue({ @@ -85,6 +91,11 @@ describe("settings modeling deploy flow", () => { tableNames: ["orders"], policyVersion: 11 }); + mockRecommendModelingSetupRelationships.mockResolvedValue([]); + }); + + afterEach(() => { + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS = originalShowSchemaDeployPanels; }); it("shows policy_version_conflict guidance and dry-run failure details", async () => { @@ -305,10 +316,12 @@ describe("settings modeling deploy flow", () => { await screen.findByRole("button", { name: "选择 model Orders Model" }); expect(screen.getByTestId("modeling-top-status-bar")).toHaveTextContent(/Deploy State synced/); + await user.click(screen.getByRole("button", { name: "选择 model Orders Model" })); + await user.click(screen.getByRole("button", { name: "编辑 metadata" })); - await user.clear(screen.getByRole("textbox", { name: "显示名称" })); - await user.type(screen.getByRole("textbox", { name: "显示名称" }), "订单模型未保存"); - await user.click(screen.getByRole("button", { name: "保存 Metadata" })); + await user.clear(screen.getByRole("textbox", { name: "Model alias" })); + await user.type(screen.getByRole("textbox", { name: "Model alias" }), "订单模型未保存"); + await user.click(screen.getByRole("button", { name: "Submit" })); const topStatusBar = screen.getByTestId("modeling-top-status-bar"); expect(topStatusBar).toHaveTextContent(/Deploy State undeployed/); diff --git a/apps/frontend/tests/unit/settings-modeling-flow-canvas.spec.tsx b/apps/frontend/tests/unit/settings-modeling-flow-canvas.spec.tsx index f6eb4e9..6de2579 100644 --- a/apps/frontend/tests/unit/settings-modeling-flow-canvas.spec.tsx +++ b/apps/frontend/tests/unit/settings-modeling-flow-canvas.spec.tsx @@ -91,6 +91,12 @@ vi.mock("@xyflow/react", () => { selected?: boolean; sourceHandle?: string; targetHandle?: string; + source?: string; + target?: string; + data?: { + highlightedBySelectedRelationship?: boolean; + highlightedBySelectedModel?: boolean; + }; }> | undefined) ?? []; const onNodeClick = props.onNodeClick as | ((event: unknown, node: { id: string }) => void) @@ -234,6 +240,26 @@ vi.mock("@xyflow/react", () => { .map((edge) => edge.id) .join(",")}

+

+ {nodes + .filter((node) => node.id.startsWith("model:")) + .map((node) => { + const highlightedRelationshipIds = ( + (node.data as { highlightedRelationshipIds?: string[] } | undefined) + ?.highlightedRelationshipIds ?? [] + ).join(","); + return `${node.id}:${highlightedRelationshipIds}`; + }) + .join("|")} +

+

+ {edges + .map( + (edge) => + `${edge.id}:${edge.data?.highlightedBySelectedRelationship ? 1 : 0},${edge.data?.highlightedBySelectedModel ? 1 : 0}` + ) + .join("|")} +

{edges[0] ? `${edges[0].sourceHandle ?? ""}->${edges[0].targetHandle ?? ""}` : "none"}

@@ -415,6 +441,56 @@ describe("ModelingFlowCanvas", () => { expect(onSelectNode).toHaveBeenCalledWith({ kind: "model", id: "model.customers" }); }); + it("runs programmatic auto-layout as a one-shot request by request id", async () => { + const onNodePositionsChange = vi.fn(); + const { rerender } = render( + + ); + + await waitFor(() => { + expect(computeElkLayoutMock).toHaveBeenCalledTimes(1); + expect(onNodePositionsChange).toHaveBeenCalledTimes(1); + }); + + rerender( + + ); + + await waitFor(() => { + expect(computeElkLayoutMock).toHaveBeenCalledTimes(1); + }); + + rerender( + + ); + + await waitFor(() => { + expect(computeElkLayoutMock).toHaveBeenCalledTimes(2); + expect(onNodePositionsChange).toHaveBeenCalledTimes(2); + }); + }); + it("prefers persisted payload positions over default grid fallback", async () => { render( { expect(screen.getByTestId("selected-node-ids")).toHaveTextContent("model:model.orders"); }); }); + + it("highlights selected relationship edge and both endpoint relationship rows", async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId("selected-edge-ids")).toHaveTextContent("rel-orders-customers"); + expect(screen.getByTestId("edge-highlight-map")).toHaveTextContent( + "rel-orders-customers:1,0" + ); + expect(screen.getByTestId("node-highlight-map")).toHaveTextContent( + "model:model.customers:rel-orders-customers" + ); + expect(screen.getByTestId("node-highlight-map")).toHaveTextContent( + "model:model.orders:rel-orders-customers" + ); + }); + }); + + it("softly marks incident edges when a model node is selected", async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId("selected-edge-ids")).not.toHaveTextContent(/\S/); + expect(screen.getByTestId("edge-highlight-map")).toHaveTextContent( + "rel-orders-customers:0,1" + ); + expect(screen.getByTestId("node-highlight-map")).toHaveTextContent("model:model.orders:"); + expect(screen.getByTestId("node-highlight-map")).toHaveTextContent("model:model.customers:"); + }); + }); }); diff --git a/apps/frontend/tests/unit/settings-modeling-flow-node-edge.spec.tsx b/apps/frontend/tests/unit/settings-modeling-flow-node-edge.spec.tsx index ed99b75..842af08 100644 --- a/apps/frontend/tests/unit/settings-modeling-flow-node-edge.spec.tsx +++ b/apps/frontend/tests/unit/settings-modeling-flow-node-edge.spec.tsx @@ -29,6 +29,7 @@ vi.mock("@xyflow/react", () => ({ onMouseLeave?: () => void; "data-confidence-band"?: string; "data-selected"?: string; + "data-visual-state"?: string; }) => (
({ data-marker-end={markerEnd ?? ""} data-confidence-band={props["data-confidence-band"]} data-selected={props["data-selected"]} + data-visual-state={props["data-visual-state"]} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> @@ -265,6 +267,40 @@ describe("ModelingFlowNode", () => { }); }); + it("highlights relationship rows when selected relationship ids are provided", () => { + const nodeProps = { + id: "model.orders", + data: { + kind: "model", + title: "Orders", + subtitle: "orders", + sections: { + columns: ["id"], + calculatedFields: [], + relationships: ["rel-orders-customers", "rel-orders-invoices"] + }, + relationshipDisplayMeta: [ + { + primaryText: "customers" + }, + { + primaryText: "invoices" + } + ], + relationshipActionIds: ["rel-orders-customers", "rel-orders-invoices"], + highlightedRelationshipIds: ["rel-orders-customers"] + }, + selected: false + } as unknown as Parameters[0]; + + render(); + + const customersRow = screen.getByText("customers").closest("li"); + const invoicesRow = screen.getByText("invoices").closest("li"); + expect(customersRow).toHaveAttribute("data-relationship-highlighted", "true"); + expect(invoicesRow).toHaveAttribute("data-relationship-highlighted", "false"); + }); + it("keeps action buttons disabled-safe and ignores keyboard triggers when actions are disabled", async () => { const user = userEvent.setup(); const onNodeAction = vi.fn(); @@ -361,6 +397,7 @@ describe("ModelingFlowEdge", () => { expect(edgeClass).toContain("[stroke-dasharray:6_4]"); expect(edge).toHaveAttribute("data-confidence-band", "low"); expect(edge).toHaveAttribute("data-selected", "false"); + expect(edge).toHaveAttribute("data-visual-state", "inferred-or-low-confidence"); expect(edge).toHaveAttribute("data-marker-start", ""); expect(edge).toHaveAttribute("data-marker-end", ""); expect(screen.queryByTestId("modeling-flow-edge-hover-card")).not.toBeInTheDocument(); @@ -397,12 +434,13 @@ describe("ModelingFlowEdge", () => { const edge = screen.getByTestId("flow-base-edge"); const edgeClass = edge.getAttribute("data-class") ?? ""; expect(edgeClass).toContain("stroke-[var(--action-primary)]"); - expect(edgeClass).toContain("stroke-[2.5]"); + expect(edgeClass).toContain("stroke-[2.7]"); expect(screen.getByTestId("modeling-flow-edge-hover-card")).toBeInTheDocument(); expect(screen.getByText("Description")).toBeInTheDocument(); expect(screen.getAllByText("-").length).toBeGreaterThan(0); expect(edgeClass).not.toContain("[stroke-dasharray:6_4]"); expect(edge).toHaveAttribute("data-selected", "true"); + expect(edge).toHaveAttribute("data-visual-state", "selected-relationship"); }); it("renders one/many markers based on relationship cardinality", async () => { @@ -431,8 +469,8 @@ describe("ModelingFlowEdge", () => { ); const edge = screen.getByTestId("flow-base-edge"); - expect(edge.getAttribute("data-marker-start")).toContain("-many"); - expect(edge.getAttribute("data-marker-end")).toContain("-one"); + expect(screen.getByTestId("modeling-flow-edge-source-cardinality")).toHaveTextContent("N"); + expect(screen.getByTestId("modeling-flow-edge-target-cardinality")).toHaveTextContent("1"); await user.hover(edge); expect(screen.getByTestId("modeling-flow-edge-hover-card")).toBeInTheDocument(); @@ -464,10 +502,10 @@ describe("ModelingFlowEdge", () => { ); - const edge = screen.getByTestId("flow-base-edge"); - expect(edge.getAttribute("data-marker-start")).toContain("-many"); - expect(edge.getAttribute("data-marker-end")).toContain("-many"); + expect(screen.getByTestId("modeling-flow-edge-source-cardinality")).toHaveTextContent("N"); + expect(screen.getByTestId("modeling-flow-edge-target-cardinality")).toHaveTextContent("N"); + const edge = screen.getByTestId("flow-base-edge"); await user.hover(edge); expect(screen.getByText("多对多 (Many-to-many)")).toBeInTheDocument(); }); @@ -528,13 +566,78 @@ describe("ModelingFlowEdge", () => { const edge = screen.getByTestId("flow-base-edge"); const edgeClass = edge.getAttribute("data-class") ?? ""; - expect(edgeClass).toContain("stroke-red-500"); - expect(edgeClass).toContain("stroke-[2.2]"); + expect(edgeClass).toContain("stroke-red-600"); + expect(edgeClass).toContain("stroke-[2.8]"); expect(edgeClass).toContain("[stroke-dasharray:6_4]"); expect(edge).toHaveAttribute("data-confidence-band", "invalid"); + expect(edge).toHaveAttribute("data-visual-state", "invalid"); await user.hover(edge); expect(screen.getByTestId("modeling-flow-edge-hover-card")).toBeInTheDocument(); expect(screen.getByText("Unknown")).toBeInTheDocument(); }); + + it("uses soft incident style when selected model highlights an edge", () => { + const edgeProps = { + id: "rel-selected-model-incident", + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 50, + sourcePosition: "right", + targetPosition: "left", + selected: false, + data: { + label: "orders.customer_id = customers.id", + source: "manual", + confidence: 0.92, + highlightedBySelectedModel: true + } + } as unknown as Parameters[0]; + + render( + + + + ); + + const edge = screen.getByTestId("flow-base-edge"); + const edgeClass = edge.getAttribute("data-class") ?? ""; + expect(edgeClass).toContain("stroke-slate-400"); + expect(edgeClass).toContain("stroke-[2.1]"); + expect(edgeClass).toContain("[stroke-dasharray:6_4]"); + expect(edge).toHaveAttribute("data-visual-state", "selected-model-incident"); + }); + + it("keeps invalid edge priority above selected-relationship style", () => { + const edgeProps = { + id: "rel-invalid-selected", + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 50, + sourcePosition: "right", + targetPosition: "left", + selected: true, + data: { + label: "orders.customer_id = missing.id", + source: "inferred", + confidence: 0.12, + invalid: true + } + } as unknown as Parameters[0]; + + render( + + + + ); + + const edge = screen.getByTestId("flow-base-edge"); + const edgeClass = edge.getAttribute("data-class") ?? ""; + expect(edgeClass).toContain("stroke-red-600"); + expect(edgeClass).toContain("stroke-[2.8]"); + expect(edge).toHaveAttribute("data-visual-state", "invalid"); + expect(edge).toHaveAttribute("data-selected", "true"); + }); }); diff --git a/apps/frontend/tests/unit/settings-modeling-layout-parity.spec.tsx b/apps/frontend/tests/unit/settings-modeling-layout-parity.spec.tsx index f188277..8838290 100644 --- a/apps/frontend/tests/unit/settings-modeling-layout-parity.spec.tsx +++ b/apps/frontend/tests/unit/settings-modeling-layout-parity.spec.tsx @@ -1,7 +1,7 @@ import React from "react"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ModelingWorkspacePage from "@/app/settings/modeling/page"; import { getWorkspaceModelingGraph, @@ -143,11 +143,16 @@ const mockListWorkspaceDatasourceTablePermissions = vi.mocked( listWorkspaceDatasourceTablePermissions ); const mockGetWorkspaceModelingGraph = vi.mocked(getWorkspaceModelingGraph); +const originalShowDetailsPanel = process.env.NEXT_PUBLIC_MODELING_SHOW_DETAILS_PANEL; +const originalShowSchemaDeployPanels = + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS; describe("Modeling layout parity", () => { beforeEach(() => { vi.clearAllMocks(); fitViewMock.mockClear(); + process.env.NEXT_PUBLIC_MODELING_SHOW_DETAILS_PANEL = "true"; + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS = "true"; Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1280 }); Element.prototype.scrollIntoView = vi.fn(); Object.defineProperty(window, "ResizeObserver", { @@ -230,6 +235,11 @@ describe("Modeling layout parity", () => { }); }); + afterEach(() => { + process.env.NEXT_PUBLIC_MODELING_SHOW_DETAILS_PANEL = originalShowDetailsPanel; + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS = originalShowSchemaDeployPanels; + }); + it("renders flowchart-first workbench layout as left tree, center canvas, and right context", async () => { render(); diff --git a/apps/frontend/tests/unit/settings-modeling-schema-change.spec.tsx b/apps/frontend/tests/unit/settings-modeling-schema-change.spec.tsx index 278c8fb..c895767 100644 --- a/apps/frontend/tests/unit/settings-modeling-schema-change.spec.tsx +++ b/apps/frontend/tests/unit/settings-modeling-schema-change.spec.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ModelingWorkspacePage from "@/app/settings/modeling/page"; import { detectWorkspaceModelingSchemaChanges, @@ -36,9 +36,12 @@ const mockDetectWorkspaceModelingSchemaChanges = vi.mocked( const mockResolveWorkspaceModelingSchemaChange = vi.mocked( resolveWorkspaceModelingSchemaChange ); +const originalShowSchemaDeployPanels = + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS; describe("settings modeling schema change flow", () => { beforeEach(() => { + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS = "true"; vi.clearAllMocks(); window.history.replaceState({}, "", "/modeling?workspaceId=ws-1&datasourceId=ds-1"); @@ -233,6 +236,10 @@ describe("settings modeling schema change flow", () => { }); }); + afterEach(() => { + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS = originalShowSchemaDeployPanels; + }); + it("supports detect -> resolve -> re-detect and keeps residual count clear", async () => { const user = userEvent.setup(); render(); diff --git a/apps/frontend/tests/unit/settings-modeling-view-sync.spec.tsx b/apps/frontend/tests/unit/settings-modeling-view-sync.spec.tsx index e5e9061..2bf17d8 100644 --- a/apps/frontend/tests/unit/settings-modeling-view-sync.spec.tsx +++ b/apps/frontend/tests/unit/settings-modeling-view-sync.spec.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ModelingWorkspacePage from "@/app/settings/modeling/page"; import { SaveAsViewDialog } from "@/components/chat/save-as-view-dialog"; import { @@ -8,6 +8,7 @@ import { listWorkspaceDatasourceBindings, listWorkspaceDatasourceTablePermissions, listWorkspaces, + recommendModelingSetupRelationships, saveModelingViewFromRun } from "@/lib/admin-api-client"; @@ -27,7 +28,8 @@ vi.mock("@/lib/admin-api-client", async (importOriginal) => { listWorkspaces: vi.fn(), listWorkspaceDatasourceBindings: vi.fn(), listWorkspaceDatasourceTablePermissions: vi.fn(), - getWorkspaceModelingGraph: vi.fn() + getWorkspaceModelingGraph: vi.fn(), + recommendModelingSetupRelationships: vi.fn() }; }); @@ -38,6 +40,7 @@ const mockListWorkspaceDatasourceTablePermissions = vi.mocked( listWorkspaceDatasourceTablePermissions ); const mockGetWorkspaceModelingGraph = vi.mocked(getWorkspaceModelingGraph); +const mockRecommendModelingSetupRelationships = vi.mocked(recommendModelingSetupRelationships); describe("settings modeling view sync from chat", () => { beforeEach(() => { @@ -109,8 +112,11 @@ describe("settings modeling view sync from chat", () => { }); describe("settings modeling page view selection sync", () => { + const originalShowDetailsPanel = process.env.NEXT_PUBLIC_MODELING_SHOW_DETAILS_PANEL; + beforeEach(() => { vi.clearAllMocks(); + process.env.NEXT_PUBLIC_MODELING_SHOW_DETAILS_PANEL = "true"; window.sessionStorage.clear(); mockListWorkspaces.mockResolvedValue({ items: [{ id: "ws-sync", name: "Workspace Sync", isDefault: true }], @@ -134,6 +140,7 @@ describe("settings modeling page view selection sync", () => { tableNames: ["orders"], policyVersion: 4 }); + mockRecommendModelingSetupRelationships.mockResolvedValue([]); mockGetWorkspaceModelingGraph.mockResolvedValue({ workspaceId: "ws-sync", datasourceId: "ds-sync", @@ -171,6 +178,10 @@ describe("settings modeling page view selection sync", () => { }); }); + afterEach(() => { + process.env.NEXT_PUBLIC_MODELING_SHOW_DETAILS_PANEL = originalShowDetailsPanel; + }); + it("prefers query viewId and selects target view on page load", async () => { window.sessionStorage.setItem("text2sql.modeling.selectViewId", "view.old"); window.history.replaceState( diff --git a/apps/frontend/tests/unit/settings-modeling-workspace.spec.tsx b/apps/frontend/tests/unit/settings-modeling-workspace.spec.tsx index 940d289..0956635 100644 --- a/apps/frontend/tests/unit/settings-modeling-workspace.spec.tsx +++ b/apps/frontend/tests/unit/settings-modeling-workspace.spec.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ModelingWorkspacePage from "@/app/settings/modeling/page"; import { ModelingCalculatedFieldEditor } from "@/components/settings/modeling/modeling-calculated-field-editor"; import { @@ -11,6 +11,7 @@ import { listWorkspaceDatasourceTablePermissions, listWorkspaces, precheckWorkspaceModelingDeploy, + recommendModelingSetupRelationships, upsertWorkspaceModelingGraph } from "@/lib/admin-api-client"; @@ -130,6 +131,7 @@ vi.mock("@/lib/admin-api-client", async (importOriginal) => { getWorkspaceModelingPreview: vi.fn(), getWorkspaceModelingGraph: vi.fn(), precheckWorkspaceModelingDeploy: vi.fn(), + recommendModelingSetupRelationships: vi.fn(), upsertWorkspaceModelingGraph: vi.fn() }; }); @@ -142,7 +144,10 @@ const mockListWorkspaceDatasourceTablePermissions = vi.mocked( const mockGetWorkspaceModelingPreview = vi.mocked(getWorkspaceModelingPreview); const mockGetWorkspaceModelingGraph = vi.mocked(getWorkspaceModelingGraph); const mockPrecheckWorkspaceModelingDeploy = vi.mocked(precheckWorkspaceModelingDeploy); +const mockRecommendModelingSetupRelationships = vi.mocked(recommendModelingSetupRelationships); const mockUpsertWorkspaceModelingGraph = vi.mocked(upsertWorkspaceModelingGraph); +const originalShowSchemaDeployPanels = + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS; async function waitForWorkspaceDatasourceReady(): Promise { await waitFor(() => { @@ -153,6 +158,7 @@ async function waitForWorkspaceDatasourceReady(): Promise { describe("ModelingWorkspacePage", () => { beforeEach(() => { + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS = "true"; vi.clearAllMocks(); window.history.replaceState( {}, @@ -257,6 +263,7 @@ describe("ModelingWorkspacePage", () => { unresolvedSchemaChangeIds: [] } }); + mockRecommendModelingSetupRelationships.mockResolvedValue([]); mockUpsertWorkspaceModelingGraph.mockImplementation(async (workspaceId, datasourceId, input) => ({ workspaceId, @@ -278,6 +285,426 @@ describe("ModelingWorkspacePage", () => { })); }); + it("bootstraps recommendations once when draft has models but no relationships", async () => { + const user = userEvent.setup(); + mockRecommendModelingSetupRelationships.mockResolvedValueOnce([ + { + id: "rel-orders-customers", + name: "orders.customer_id -> customers.id", + confidence: 0.96, + reason: "foreign_key_constraint", + type: "many-to-one", + cardinality: "many-to-one", + left: { + dataset: "analytics", + table: "orders", + column: "customer_id" + }, + right: { + dataset: "analytics", + table: "customers", + column: "id" + } + } + ]); + + render(); + + await waitForWorkspaceDatasourceReady(); + await waitFor(() => { + expect(mockRecommendModelingSetupRelationships).toHaveBeenCalledWith("ws-2", "ds-2b", { + selectedTables: ["orders"] + }); + }); + await waitFor(() => { + expect(mockUpsertWorkspaceModelingGraph).toHaveBeenCalledWith( + "ws-2", + "ds-2b", + expect.objectContaining({ + relationships: [ + expect.objectContaining({ + id: "rel-orders-customers", + source: "fk", + bridge: expect.objectContaining({ + operator: "eq" + }) + }) + ] + }) + ); + }); + expect( + await screen.findByText("已自动补全 1 条关系(跳过 0 条)。") + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "trigger-node-action-add-calculated-field" })); + await waitFor(() => { + expect(mockRecommendModelingSetupRelationships).toHaveBeenCalledTimes(1); + }); + }); + + afterEach(() => { + process.env.NEXT_PUBLIC_MODELING_SHOW_SCHEMA_DEPLOY_PANELS = originalShowSchemaDeployPanels; + }); + + it("does not auto-bootstrap when draft already has relationships", async () => { + mockGetWorkspaceModelingGraph.mockResolvedValueOnce({ + workspaceId: "ws-2", + datasourceId: "ds-2b", + activeRevision: 1, + draft: { + policyVersion: 7, + revision: 2, + graphHash: "hash-r2-with-relationships", + updatedAt: "2026-04-23T00:00:00.000Z", + graphPayload: { + models: [ + { + id: "model.orders", + tableName: "orders", + modelName: "orders", + displayName: "Orders", + description: null, + columns: [] + }, + { + id: "model.customers", + tableName: "customers", + modelName: "customers", + displayName: "Customers", + description: null, + columns: [] + } + ], + relationships: [ + { + id: "rel-orders-customers", + source: "manual", + confidence: 0.9, + bridge: { + left: { dataset: "analytics", table: "orders", column: "customer_id" }, + right: { dataset: "analytics", table: "customers", column: "id" }, + operator: "eq", + confidence: 0.9 + } + } + ], + calculatedFields: [], + views: [], + schemaChanges: [] + } + } + }); + + render(); + + await waitForWorkspaceDatasourceReady(); + expect(await screen.findByText("orders.customer_id = customers.id")).toBeInTheDocument(); + expect(mockRecommendModelingSetupRelationships).not.toHaveBeenCalled(); + }); + + it("syncs database non-destructively and keeps manual reverse relationship unchanged", async () => { + const user = userEvent.setup(); + mockGetWorkspaceModelingGraph.mockResolvedValueOnce({ + workspaceId: "ws-2", + datasourceId: "ds-2b", + activeRevision: 1, + draft: { + policyVersion: 7, + revision: 2, + graphHash: "hash-r2-manual-reverse", + updatedAt: "2026-04-23T00:00:00.000Z", + graphPayload: { + models: [ + { + id: "model.orders", + tableName: "orders", + modelName: "orders", + displayName: "Orders", + description: null, + columns: [] + }, + { + id: "model.customers", + tableName: "customers", + modelName: "customers", + displayName: "Customers", + description: null, + columns: [] + }, + { + id: "model.regions", + tableName: "regions", + modelName: "regions", + displayName: "Regions", + description: null, + columns: [] + } + ], + relationships: [ + { + id: "rel-manual-customers-orders", + source: "manual", + confidence: 0.91, + bridge: { + left: { dataset: "analytics", table: "customers", column: "id" }, + right: { dataset: "analytics", table: "orders", column: "customer_id" }, + operator: "eq", + confidence: 0.91 + } + } + ], + calculatedFields: [], + views: [], + schemaChanges: [] + } + } + }); + mockRecommendModelingSetupRelationships.mockResolvedValueOnce([ + { + id: "rel-orders-customers-recommended", + name: "orders.customer_id -> customers.id", + confidence: 0.95, + reason: "foreign_key_constraint", + type: "many-to-one", + cardinality: "many-to-one", + left: { + dataset: "analytics", + table: "orders", + column: "customer_id" + }, + right: { + dataset: "analytics", + table: "customers", + column: "id" + } + }, + { + id: "rel-orders-regions", + name: "orders.region_id -> regions.id", + confidence: 0.84, + reason: "name_inference", + type: "many-to-one", + cardinality: "many-to-one", + left: { + dataset: "analytics", + table: "orders", + column: "region_id" + }, + right: { + dataset: "analytics", + table: "regions", + column: "id" + } + } + ]); + + render(); + + await waitForWorkspaceDatasourceReady(); + await user.click(screen.getByRole("button", { name: "同步数据库" })); + + await waitFor(() => { + expect(mockRecommendModelingSetupRelationships).toHaveBeenCalledWith("ws-2", "ds-2b", { + selectedTables: ["customers", "orders", "regions"] + }); + }); + await waitFor(() => { + expect(mockUpsertWorkspaceModelingGraph).toHaveBeenCalledWith( + "ws-2", + "ds-2b", + expect.objectContaining({ + relationships: [ + expect.objectContaining({ + id: "rel-manual-customers-orders", + source: "manual", + bridge: expect.objectContaining({ + left: expect.objectContaining({ + table: "customers", + column: "id" + }), + right: expect.objectContaining({ + table: "orders", + column: "customer_id" + }), + operator: "eq" + }) + }), + expect.objectContaining({ + id: "rel-orders-regions", + source: "inferred" + }) + ] + }) + ); + }); + expect(await screen.findByText("同步数据库完成:新增 1 条,跳过 1 条。")).toBeInTheDocument(); + expect(screen.getByText("customers.id = orders.customer_id")).toBeInTheDocument(); + expect(screen.getByText("orders.region_id = regions.id")).toBeInTheDocument(); + }); + + it("shows no-op feedback and skips save when sync finds no missing relationships", async () => { + const user = userEvent.setup(); + mockGetWorkspaceModelingGraph.mockResolvedValueOnce({ + workspaceId: "ws-2", + datasourceId: "ds-2b", + activeRevision: 1, + draft: { + policyVersion: 7, + revision: 2, + graphHash: "hash-r2-sync-noop", + updatedAt: "2026-04-23T00:00:00.000Z", + graphPayload: { + models: [ + { + id: "model.orders", + tableName: "orders", + modelName: "orders", + displayName: "Orders", + description: null, + columns: [] + }, + { + id: "model.customers", + tableName: "customers", + modelName: "customers", + displayName: "Customers", + description: null, + columns: [] + } + ], + relationships: [ + { + id: "rel-orders-customers", + source: "manual", + confidence: 0.9, + bridge: { + left: { dataset: "analytics", table: "orders", column: "customer_id" }, + right: { dataset: "analytics", table: "customers", column: "id" }, + operator: "eq", + confidence: 0.9 + } + } + ], + calculatedFields: [], + views: [], + schemaChanges: [] + } + } + }); + mockRecommendModelingSetupRelationships.mockResolvedValueOnce([ + { + id: "rel-orders-customers-recommended", + name: "orders.customer_id -> customers.id", + confidence: 0.95, + reason: "foreign_key_constraint", + type: "many-to-one", + cardinality: "many-to-one", + left: { + dataset: "analytics", + table: "orders", + column: "customer_id" + }, + right: { + dataset: "analytics", + table: "customers", + column: "id" + } + } + ]); + + render(); + + await waitForWorkspaceDatasourceReady(); + await user.click(screen.getByRole("button", { name: "同步数据库" })); + + expect( + await screen.findByText("同步数据库完成:无新增关系(跳过 1 条)。") + ).toBeInTheDocument(); + expect(mockUpsertWorkspaceModelingGraph).not.toHaveBeenCalled(); + }); + + it("keeps graph/pending state unchanged when sync save fails", async () => { + const user = userEvent.setup(); + mockGetWorkspaceModelingGraph.mockResolvedValueOnce({ + workspaceId: "ws-2", + datasourceId: "ds-2b", + activeRevision: 2, + draft: { + policyVersion: 7, + revision: 2, + graphHash: "hash-r2-sync-fail", + updatedAt: "2026-04-23T00:00:00.000Z", + graphPayload: { + models: [ + { + id: "model.orders", + tableName: "orders", + modelName: "orders", + displayName: "Orders", + description: null, + columns: [] + }, + { + id: "model.regions", + tableName: "regions", + modelName: "regions", + displayName: "Regions", + description: null, + columns: [] + } + ], + relationships: [ + { + id: "rel-orders-customers", + source: "manual", + confidence: 0.9, + bridge: { + left: { dataset: "analytics", table: "orders", column: "customer_id" }, + right: { dataset: "analytics", table: "customers", column: "id" }, + operator: "eq", + confidence: 0.9 + } + } + ], + calculatedFields: [], + views: [], + schemaChanges: [] + } + } + }); + mockRecommendModelingSetupRelationships.mockResolvedValueOnce([ + { + id: "rel-orders-regions", + name: "orders.region_id -> regions.id", + confidence: 0.84, + reason: "name_inference", + type: "many-to-one", + cardinality: "many-to-one", + left: { + dataset: "analytics", + table: "orders", + column: "region_id" + }, + right: { + dataset: "analytics", + table: "regions", + column: "id" + } + } + ]); + mockUpsertWorkspaceModelingGraph.mockRejectedValueOnce(new Error("sync save failed")); + + render(); + + await waitForWorkspaceDatasourceReady(); + await user.click(screen.getByRole("button", { name: "同步数据库" })); + + expect(await screen.findByText("sync save failed")).toBeInTheDocument(); + expect(screen.getByTestId("modeling-top-status-bar")).toHaveTextContent("Deploy State synced"); + expect(screen.queryByText("orders.region_id = regions.id")).not.toBeInTheDocument(); + }); + it("updates model metadata in workspace and saves via modeling graph upsert", async () => { const user = userEvent.setup(); render(); diff --git a/graphify-out/GRAPH_REPORT.md b/graphify-out/GRAPH_REPORT.md index f3c9874..e25f472 100644 --- a/graphify-out/GRAPH_REPORT.md +++ b/graphify-out/GRAPH_REPORT.md @@ -1,12 +1,12 @@ -# Graph Report - /Users/lienli/Documents/GitHub/text2sql (2026-04-24) +# Graph Report - /Users/lienli/Documents/GitHub/text2sql (2026-04-25) ## Corpus Check -- 664 files · ~960,874 words +- 664 files · ~971,237 words - Verdict: corpus is large enough that graph structure adds value. ## Summary -- 3252 nodes · 6127 edges · 473 communities detected -- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 1327 edges (avg confidence: 0.8) +- 3270 nodes · 6154 edges · 473 communities detected +- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 1325 edges (avg confidence: 0.8) - Token cost: 0 input · 0 output ## Community Hubs (Navigation) @@ -497,8 +497,8 @@ 10. `SemanticSpineRepository` - 35 edges ## Surprising Connections (you probably didn't know these) -- `setError()` --calls--> `save()` [INFERRED] - /Users/lienli/Documents/GitHub/text2sql/apps/frontend/src/components/settings/workspace-management-panel.tsx → /Users/lienli/Documents/GitHub/text2sql/apps/frontend/src/components/settings/modeling/modeling-relationship-editor.tsx +- `loadPreviewForDetails()` --calls--> `getWorkspaceModelingPreview()` [INFERRED] + /Users/lienli/Documents/GitHub/text2sql/apps/frontend/src/app/settings/modeling/page.tsx → /Users/lienli/Documents/GitHub/text2sql/apps/frontend/src/lib/admin-api-client.ts - `load()` --calls--> `setError()` [INFERRED] /Users/lienli/Documents/GitHub/text2sql/apps/frontend/src/app/glossary/page.tsx → /Users/lienli/Documents/GitHub/text2sql/apps/frontend/src/components/settings/workspace-management-panel.tsx - `load()` --calls--> `listGlossaryTerms()` [INFERRED] @@ -511,84 +511,84 @@ ## Communities ### Community 0 - "Community 0" -Cohesion: 0.03 -Nodes (15): DataBootstrapService, PlatformDataBootstrapModule, ChatPolicyGuardService, ChatRepository, ChatRunPersistenceService, ChatService, EvalService, ExecuteMessageUsecase (+7 more) +Cohesion: 0.02 +Nodes (15): ChatController, ChatPolicyGuardService, ChatRepository, ChatRunPersistenceService, ChatService, DatasourceAccessPolicyService, ExecuteMessageUsecase, ExecuteSqlNode (+7 more) ### Community 1 - "Community 1" -Cohesion: 0.03 -Nodes (131): addWorkspaceDatasourceBindings(), addWorkspaceMembers(), AdminApiError, commitModelingSetup(), composeApiUrl(), createGlossaryAnchor(), createGlossaryTerm(), createPromptTemplate() (+123 more) +Cohesion: 0.02 +Nodes (48): extractLatestUserText(), mapToThreadMessages(), parseSseEvents(), buildContextEnvelopeFromDraft(), parseEntityMappings(), splitByDelimiters(), trimOrUndefined(), DeliveryContractMapper (+40 more) ### Community 2 - "Community 2" Cohesion: 0.02 -Nodes (39): AppConfigService, DatasourceRegistryService, countTracedRuns(), main(), parseArgs(), computeLangsmithCoverage(), bootstrap(), buildGraph() (+31 more) +Nodes (33): AppConfigService, DatasourceRegistryService, countTracedRuns(), main(), parseArgs(), computeLangsmithCoverage(), bootstrap(), buildGraph() (+25 more) ### Community 3 - "Community 3" -Cohesion: 0.03 -Nodes (45): extractLatestUserText(), mapToThreadMessages(), ChatDeliveryEnrichmentService, parseSseEvents(), buildContextEnvelopeFromDraft(), parseEntityMappings(), splitByDelimiters(), trimOrUndefined() (+37 more) +Cohesion: 0.04 +Nodes (10): ModelingSchemaChangeRepository, toScopeKey(), SaveViewFromRunUsecase, normalizeName(), SchemaChangeDetectorService, WorkspaceDatasourceController, normalizeName(), WorkspaceModelingSchemaChangeDetectorService (+2 more) ### Community 4 - "Community 4" Cohesion: 0.03 -Nodes (14): ok(), ChatController, DatasourceController, EvalController, GlossaryController, MemoryController, SettingsController, UserController (+6 more) +Nodes (12): ok(), DatasourceController, DatasourceWorkflowService, EvalController, GlossaryController, MemoryController, SettingsController, UserController (+4 more) ### Community 5 - "Community 5" -Cohesion: 0.04 -Nodes (9): ModelingSchemaChangeRepository, toScopeKey(), SaveViewFromRunUsecase, normalizeName(), SchemaChangeDetectorService, normalizeName(), WorkspaceModelingSchemaChangeDetectorService, normalizeTableName() (+1 more) +Cohesion: 0.05 +Nodes (103): addWorkspaceDatasourceBindings(), addWorkspaceMembers(), AdminApiError, commitModelingSetup(), composeApiUrl(), createGlossaryAnchor(), createGlossaryTerm(), createPromptTemplate() (+95 more) ### Community 6 - "Community 6" -Cohesion: 0.05 -Nodes (8): BuildRagIndexJob, asActor(), asActor(), GlossaryService, RagIndexBuilderService, RagIndexRepository, withActor(), asAdmin() +Cohesion: 0.04 +Nodes (12): BuildRagIndexJob, asActor(), asActor(), LlmConfigRepository, toModelHealthStatus(), toProviderCode(), toProviderSyncStatus(), ProviderCatalogService (+4 more) ### Community 7 - "Community 7" -Cohesion: 0.04 -Nodes (9): LlmConfigRepository, toModelHealthStatus(), toProviderCode(), toProviderSyncStatus(), ModelRerankerAdapter, PromptTemplateService, ProviderCatalogService, ProviderRouterService (+1 more) +Cohesion: 0.03 +Nodes (66): EvalService, handleKeyDown(), isRecord(), nextFieldId(), normalizeFunctionGroupHints(), resolveCalculatedFieldSaveError(), save(), toForm() (+58 more) ### Community 8 - "Community 8" Cohesion: 0.06 -Nodes (19): handleKeyDown(), onSubmit(), submit(), toVariableRows(), validateEmail(), validateVariableKey(), validateVariableValue(), toPlatformUserStatus() (+11 more) +Nodes (8): AuditLogRepository, asNonEmptyString(), toBindingKey(), toRuleKey(), toTablePermissionKey(), toTablePermissionSetKey(), WorkspaceDatasourcePolicyRepository, WorkspaceDatasourceService ### Community 9 - "Community 9" Cohesion: 0.04 -Nodes (45): BuildSemanticQueryNode, buildConversationKnowledgeAllowlistSet(), collectFiles(), compactLine(), createLineStarts(), extractImportReferences(), findRepoRoot(), indexToLineColumn() (+37 more) +Nodes (50): BuildSemanticQueryNode, buildConversationKnowledgeAllowlistSet(), collectFiles(), compactLine(), createLineStarts(), extractImportReferences(), findRepoRoot(), indexToLineColumn() (+42 more) ### Community 10 - "Community 10" -Cohesion: 0.04 -Nodes (7): ChatPostRunHooksService, MemoryPromotionPolicy, MemoryPromotionService, RagBudgetPolicy, RagReplayRepository, RagRerankService, RetrieveKnowledgeNode +Cohesion: 0.05 +Nodes (18): GeminiAdapter, resolveGeminiUrl(), isTimeoutAbortError(), LlmGatewayService, toErrorMessage(), LlmModelFactory, resolveProviderBaseUrl(), checkHealth() (+10 more) ### Community 11 - "Community 11" -Cohesion: 0.06 -Nodes (58): createIdempotencyKey(), resolveDatasourceWorkflowApiError(), applySessionView(), dedupeSessions(), extractErrorCode(), init(), loadMessages(), mergeSessionMessages() (+50 more) +Cohesion: 0.05 +Nodes (7): DataBootstrapService, PlatformDataBootstrapModule, FakePgClient, DatasourceRepository, DatasourceService, PostgresExecutorService, RelationshipDryRunService ### Community 12 - "Community 12" -Cohesion: 0.06 -Nodes (5): DatasourceRepository, DatasourceWorkflowService, ExecuteSqlNode, RelationshipDryRunService, WorkspaceDatasourceService +Cohesion: 0.05 +Nodes (6): ChatPostRunHooksService, MemoryPromotionPolicy, MemoryPromotionService, RagReplayRepository, RagRerankService, RetrieveKnowledgeNode ### Community 13 - "Community 13" -Cohesion: 0.08 -Nodes (7): normalizeValue(), WorkspaceAdminGuard, toMembershipKey(), toWorkspaceMemberRole(), toWorkspaceStatus(), WorkspaceRepository, WorkspaceService +Cohesion: 0.04 +Nodes (8): HealthController, MysqlExecutorService, RagIngestionMetricsService, RagQualityController, RagQualityService, SemanticSpineShadowService, SqliteExecutorService, SqliteQueryService ### Community 14 - "Community 14" -Cohesion: 0.09 -Nodes (7): DatasourceAccessPolicyService, asNonEmptyString(), toBindingKey(), toRuleKey(), toTablePermissionKey(), toTablePermissionSetKey(), WorkspaceDatasourcePolicyRepository +Cohesion: 0.08 +Nodes (7): normalizeValue(), WorkspaceAdminGuard, toMembershipKey(), toWorkspaceMemberRole(), toWorkspaceStatus(), WorkspaceRepository, WorkspaceService ### Community 15 - "Community 15" -Cohesion: 0.07 -Nodes (8): LaneTimeoutError, RagRetrievalService, appendUnique(), fuseWithRrf(), laneOrder(), normalizeRankConstant(), stableSortLaneHits(), SkillRegistryService +Cohesion: 0.06 +Nodes (8): getChunkProfileConfig(), RagBudgetPolicy, RagChunkingService, sha256(), RagDocumentFactory, sha256(), RagEventConsumerService, SemanticRegistryService ### Community 16 - "Community 16" -Cohesion: 0.06 -Nodes (6): FakePgClient, DatasourceService, MysqlExecutorService, PostgresExecutorService, SqliteExecutorService, SqliteQueryService +Cohesion: 0.07 +Nodes (53): createIdempotencyKey(), resolveDatasourceWorkflowApiError(), applySessionView(), dedupeSessions(), extractErrorCode(), init(), loadMessages(), mergeSessionMessages() (+45 more) ### Community 17 - "Community 17" -Cohesion: 0.05 -Nodes (13): GraphBuilderService, createLangGraphNodeHandlers(), createNodeRuntime(), getCallbacks(), summarize(), truncate(), createLangGraphRuntime(), LangGraphRuntimeService (+5 more) +Cohesion: 0.08 +Nodes (10): toPlatformUserStatus(), UserRepository, UserService, onConfirmBatchDelete(), onConfirmDeleteOne(), onConfirmResetPassword(), onSubmitEditor(), onToggleStatus() (+2 more) ### Community 18 - "Community 18" -Cohesion: 0.06 -Nodes (7): getChunkProfileConfig(), RagChunkingService, sha256(), RagDocumentFactory, sha256(), RagEventConsumerService, SemanticRegistryService +Cohesion: 0.05 +Nodes (13): GraphBuilderService, createLangGraphNodeHandlers(), createNodeRuntime(), getCallbacks(), summarize(), truncate(), createLangGraphRuntime(), LangGraphRuntimeService (+5 more) ### Community 19 - "Community 19" -Cohesion: 0.06 -Nodes (5): HealthController, RagIngestionMetricsService, RagQualityController, RagQualityService, SemanticSpineShadowService +Cohesion: 0.1 +Nodes (1): GlossaryService ### Community 20 - "Community 20" Cohesion: 0.1 @@ -607,49 +607,49 @@ Cohesion: 0.09 Nodes (35): createRunId(), withActor(), formatGovernanceError(), patchModel(), readRunIdFromQuery(), readWorkspaceIdFromQuery(), refreshModels(), resolveWorkspaceId() (+27 more) ### Community 24 - "Community 24" -Cohesion: 0.1 -Nodes (2): AuditLogRepository, RagAuditReplayService - -### Community 25 - "Community 25" Cohesion: 0.13 Nodes (1): SemanticSpineRepository -### Community 26 - "Community 26" +### Community 25 - "Community 25" Cohesion: 0.11 Nodes (5): GenerateSqlNode, RagCacheKeyFactory, SqlGenerationService, SqlOutputExtractor, SqlPromptBuilder +### Community 26 - "Community 26" +Cohesion: 0.1 +Nodes (2): PromptTemplateService, SettingsService + ### Community 27 - "Community 27" Cohesion: 0.12 Nodes (21): buildOperationNotice(), closeDialog(), closeDrawer(), createIdempotencyKey(), handleDelete(), handleToggleTerm(), load(), loadTerms() (+13 more) ### Community 28 - "Community 28" -Cohesion: 0.12 -Nodes (11): GeminiAdapter, resolveGeminiUrl(), isTimeoutAbortError(), LlmGatewayService, toErrorMessage(), LlmModelFactory, resolveProviderBaseUrl(), checkHealth() (+3 more) - -### Community 29 - "Community 29" Cohesion: 0.14 Nodes (2): GraphAccelerationCircuitBreaker, GraphService -### Community 30 - "Community 30" +### Community 29 - "Community 29" Cohesion: 0.25 Nodes (19): ensureRunLoaded(), isRecord(), mergeRunThinkingSteps(), normalizeArtifact(), normalizeDeliveryContract(), normalizeEvidence(), normalizeRunForVisibility(), normalizeSelectedContext() (+11 more) -### Community 31 - "Community 31" +### Community 30 - "Community 30" Cohesion: 0.13 Nodes (7): formatCell(), normalizeNullableText(), normalizeTableKey(), openEditDialog(), resolveModelRelationships(), resolveRelationshipCounterpartTable(), submitMetadataUpdate() +### Community 31 - "Community 31" +Cohesion: 0.24 +Nodes (1): RagAuditReplayService + ### Community 32 - "Community 32" +Cohesion: 0.25 +Nodes (1): ChatDeliveryEnrichmentService + +### Community 33 - "Community 33" Cohesion: 0.26 Nodes (10): createIdempotencyKey(), isConflictError(), isRecord(), normalizeTableNames(), readNumberCandidate(), resolveConflictPolicyVersion(), resolveConflictSummary(), setsEqual() (+2 more) -### Community 33 - "Community 33" +### Community 34 - "Community 34" Cohesion: 0.29 Nodes (1): FileDatasourceExecutorService -### Community 34 - "Community 34" -Cohesion: 0.31 -Nodes (12): buildCreateForm(), handleTableChange(), makeRelationshipId(), normalizeTableName(), openCreateDialog(), openEditDialog(), resolveColumnOptions(), resolveRelationshipType() (+4 more) - ### Community 35 - "Community 35" Cohesion: 0.28 Nodes (4): GraphAccelerationAdapter, GraphAccelerationError, GraphAccelerationTimeoutError, GraphAccelerationUnsupportedOperatorError @@ -663,53 +663,53 @@ Cohesion: 0.19 Nodes (2): GateMetricsService, TraceService ### Community 38 - "Community 38" +Cohesion: 0.36 +Nodes (1): SkillRegistryService + +### Community 39 - "Community 39" Cohesion: 0.29 Nodes (9): ClarifyNode, decideSlotFilling(), hasEntityMappings(), hasNonEmpty(), hasStringArray(), hasTimeRangeEnvelope(), resolveClarificationQuestion(), resolveReason() (+1 more) -### Community 39 - "Community 39" +### Community 40 - "Community 40" Cohesion: 0.25 Nodes (3): JsonFileSemanticSpineRepositoryDouble, SemanticSpineContractError, validateSemanticObject() -### Community 40 - "Community 40" +### Community 41 - "Community 41" Cohesion: 0.29 Nodes (2): BuildPhysicalPlanNode, PlannerCacheService -### Community 41 - "Community 41" +### Community 42 - "Community 42" +Cohesion: 0.31 +Nodes (6): handleOpenChange(), includesFailClosedHint(), normalizeAlerts(), resolveAutoExpandedSection(), resolveFailClosedAlerts(), sectionSeverityScore() + +### Community 43 - "Community 43" Cohesion: 0.25 Nodes (0): -### Community 42 - "Community 42" -Cohesion: 0.46 -Nodes (7): isRecord(), nextFieldId(), normalizeFunctionGroupHints(), resolveCalculatedFieldSaveError(), save(), toForm(), upsertField() +### Community 44 - "Community 44" +Cohesion: 0.25 +Nodes (0): -### Community 43 - "Community 43" +### Community 45 - "Community 45" Cohesion: 0.29 Nodes (3): InMemorySemanticSpineRepositoryDouble, SemanticSpineContractError, validateSemanticObject() -### Community 44 - "Community 44" +### Community 46 - "Community 46" Cohesion: 0.29 Nodes (0): -### Community 45 - "Community 45" +### Community 47 - "Community 47" Cohesion: 0.29 Nodes (0): -### Community 46 - "Community 46" +### Community 48 - "Community 48" Cohesion: 0.33 Nodes (2): providerStatusLabel(), providerStatusVariant() -### Community 47 - "Community 47" -Cohesion: 0.57 -Nodes (2): IngestionSourceAdapter, uniqueNonEmpty() - -### Community 48 - "Community 48" +### Community 49 - "Community 49" Cohesion: 0.29 Nodes (1): KnowledgeChatSupportFacade -### Community 49 - "Community 49" -Cohesion: 0.33 -Nodes (0): - ### Community 50 - "Community 50" Cohesion: 0.33 Nodes (0): @@ -723,24 +723,24 @@ Cohesion: 0.33 Nodes (0): ### Community 53 - "Community 53" +Cohesion: 0.33 +Nodes (0): + +### Community 54 - "Community 54" Cohesion: 0.4 Nodes (3): buildReadySnapshot(), buildSnakeCaseSnapshot(), SemanticSpineRepositoryStub -### Community 54 - "Community 54" +### Community 55 - "Community 55" Cohesion: 0.33 Nodes (1): PolicyEvaluatorService -### Community 55 - "Community 55" +### Community 56 - "Community 56" Cohesion: 0.33 Nodes (4): RelationshipBridgeDto, RelationshipBridgeEndpointDto, RelationshipGraphEdgeDto, ReplaceWorkspaceRelationshipGraphDto -### Community 56 - "Community 56" -Cohesion: 0.4 -Nodes (1): ResizeObserver - ### Community 57 - "Community 57" Cohesion: 0.4 -Nodes (0): +Nodes (1): ResizeObserver ### Community 58 - "Community 58" Cohesion: 0.4 @@ -751,17 +751,17 @@ Cohesion: 0.4 Nodes (0): ### Community 60 - "Community 60" +Cohesion: 0.4 +Nodes (0): + +### Community 61 - "Community 61" Cohesion: 0.5 Nodes (2): CarouselNext(), useCarousel() -### Community 61 - "Community 61" +### Community 62 - "Community 62" Cohesion: 0.6 Nodes (4): nextEdgeId(), RelationshipEdgeEditorDialog(), toEdge(), toForm() -### Community 62 - "Community 62" -Cohesion: 0.4 -Nodes (0): - ### Community 63 - "Community 63" Cohesion: 0.4 Nodes (0): @@ -3100,17 +3100,17 @@ Nodes (0): ## Suggested Questions _Questions this graph is uniquely positioned to answer:_ -- **Why does `ok()` connect `Community 4` to `Community 0`, `Community 19`, `Community 5`?** - _High betweenness centrality (0.042) - this node is a cross-community bridge._ -- **Why does `setError()` connect `Community 1` to `Community 34`, `Community 8`, `Community 42`, `Community 23`, `Community 27`?** - _High betweenness centrality (0.022) - this node is a cross-community bridge._ -- **Why does `WorkspaceModelingService` connect `Community 5` to `Community 4`?** - _High betweenness centrality (0.016) - this node is a cross-community bridge._ +- **Why does `ok()` connect `Community 4` to `Community 0`, `Community 3`, `Community 13`?** + _High betweenness centrality (0.030) - this node is a cross-community bridge._ +- **Why does `DatasourceService` connect `Community 11` to `Community 0`, `Community 1`?** + _High betweenness centrality (0.020) - this node is a cross-community bridge._ +- **Why does `RagRetrievalService` connect `Community 10` to `Community 20`, `Community 28`, `Community 21`?** + _High betweenness centrality (0.019) - this node is a cross-community bridge._ - **Are the 85 inferred relationships involving `ok()` (e.g. with `.applyFeedback()` and `.createSession()`) actually correct?** _`ok()` has 85 INFERRED edges - model-reasoned connections that need verification._ - **What connects `ELK`, `ElkLayoutTimeoutError`, `AppModule` to the rest of the system?** _80 weakly-connected nodes found - possible documentation gaps or missing edges._ - **Should `Community 0` be split into smaller, more focused modules?** - _Cohesion score 0.03 - nodes in this community are weakly interconnected._ + _Cohesion score 0.02 - nodes in this community are weakly interconnected._ - **Should `Community 1` be split into smaller, more focused modules?** - _Cohesion score 0.03 - nodes in this community are weakly interconnected._ \ No newline at end of file + _Cohesion score 0.02 - nodes in this community are weakly interconnected._ \ No newline at end of file diff --git a/graphify-out/graph.html b/graphify-out/graph.html index 567494c..337dca0 100644 --- a/graphify-out/graph.html +++ b/graphify-out/graph.html @@ -50,12 +50,12 @@

Node Info

Communities

-
3252 nodes · 6127 edges · 473 communities
+
3270 nodes · 6154 edges · 473 communities