Preserve dataflow layout across SQL edits

This commit is contained in:
2026-07-28 18:36:09 +02:00
parent 74f1210a32
commit e54225a86e
8 changed files with 331 additions and 19 deletions

View File

@@ -36,6 +36,9 @@ Join nodes have explicit left and right ports. Append nodes accept two or more
inputs. Derived columns use a constrained operation catalogue rather than inputs. Derived columns use a constrained operation catalogue rather than
arbitrary code. The graph editor validates manual, proximity-created, and arbitrary code. The graph editor validates manual, proximity-created, and
reconnected edges against the same port, multiplicity, and cycle rules. reconnected edges against the same port, multiplicity, and cycle rules.
SQL round-trips retain node and edge identity, labels, and coordinates while
the graph topology remains compatible. Structural changes use a compact
layered layout with bounded branch and edge spacing.
Governed inputs are resolved through the versioned Core capability Governed inputs are resolved through the versioned Core capability
`datasources.catalogue`; Dataflow imports neither Datasources nor Connectors and `datasources.catalogue`; Dataflow imports neither Datasources nor Connectors and

View File

@@ -55,6 +55,74 @@ def definition_hash(graph: PipelineGraph, sql_text: str | None = None) -> str:
return hashlib.sha256(encoded.encode("utf-8")).hexdigest() return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def preserve_compatible_graph_layout(
reference: PipelineGraph,
compiled: PipelineGraph,
) -> PipelineGraph:
"""Keep graph identity and layout when SQL preserves the same topology."""
reference_keys = _structural_node_keys(reference)
compiled_keys = _structural_node_keys(compiled)
if reference_keys is None or compiled_keys is None:
return compiled
if (
len(set(reference_keys.values())) != len(reference_keys)
or len(set(compiled_keys.values())) != len(compiled_keys)
or set(reference_keys.values()) != set(compiled_keys.values())
):
return compiled
compiled_by_key = {
key: next(node for node in compiled.nodes if node.id == node_id)
for node_id, key in compiled_keys.items()
}
reference_edges = {
_structural_edge_key(edge, reference_keys): edge
for edge in reference.edges
}
compiled_edges = {
_structural_edge_key(edge, compiled_keys): edge
for edge in compiled.edges
}
if (
len(reference_edges) != len(reference.edges)
or len(compiled_edges) != len(compiled.edges)
or set(reference_edges) != set(compiled_edges)
):
return compiled
nodes = []
for reference_node in reference.nodes:
node = compiled_by_key[reference_keys[reference_node.id]]
nodes.append(
node.model_copy(
update={
"id": reference_node.id,
"label": reference_node.label,
"position": reference_node.position.model_copy(deep=True),
},
deep=True,
)
)
edges = []
for reference_edge in reference.edges:
edge_key = _structural_edge_key(reference_edge, reference_keys)
edge = compiled_edges[edge_key]
edges.append(
edge.model_copy(
update={
"id": reference_edge.id,
"source": reference_edge.source,
"target": reference_edge.target,
},
deep=True,
)
)
return compiled.model_copy(
update={"nodes": nodes, "edges": edges},
deep=True,
)
def validate_graph(graph: PipelineGraph) -> list[DataflowDiagnostic]: def validate_graph(graph: PipelineGraph) -> list[DataflowDiagnostic]:
diagnostics = [ diagnostics = [
DataflowDiagnostic( DataflowDiagnostic(
@@ -151,6 +219,70 @@ def validate_graph(graph: PipelineGraph) -> list[DataflowDiagnostic]:
return _dedupe_diagnostics(diagnostics) return _dedupe_diagnostics(diagnostics)
def _structural_node_keys(
graph: PipelineGraph,
) -> dict[str, tuple[object, ...]] | None:
node_by_id = {node.id: node for node in graph.nodes}
if (
len(node_by_id) != len(graph.nodes)
or any(
edge.source not in node_by_id or edge.target not in node_by_id
for edge in graph.edges
)
):
return None
ordered, cyclic = topological_order(graph)
if cyclic:
return None
incoming: dict[str, list[GraphEdge]] = {
node.id: []
for node in graph.nodes
}
for edge in graph.edges:
if edge.target in incoming:
incoming[edge.target].append(edge)
keys: dict[str, tuple[object, ...]] = {}
for node_id in ordered:
node = node_by_id[node_id]
input_keys: list[tuple[object, ...]] = []
for edge in incoming[node_id]:
source_key = keys.get(edge.source)
if source_key is None:
return None
input_keys.append(
(
edge.target_port,
edge.source_port,
source_key,
)
)
if node.type != "combine.union":
input_keys.sort(key=repr)
source_name = (
str(node.config.get("source_name", "")).strip().casefold()
if node.type.startswith("source.")
else None
)
keys[node_id] = (
node.type,
source_name,
tuple(input_keys),
)
return keys
def _structural_edge_key(
edge: GraphEdge,
node_keys: dict[str, tuple[object, ...]],
) -> tuple[object, ...]:
return (
node_keys[edge.source],
node_keys[edge.target],
edge.source_port,
edge.target_port,
)
def _dedupe_diagnostics( def _dedupe_diagnostics(
diagnostics: list[DataflowDiagnostic], diagnostics: list[DataflowDiagnostic],
) -> list[DataflowDiagnostic]: ) -> list[DataflowDiagnostic]:

View File

@@ -40,7 +40,12 @@ from govoplan_dataflow.backend.governance import (
definition_governance_payload, definition_governance_payload,
require_definition_action, require_definition_action,
) )
from govoplan_dataflow.backend.graph import canonical_graph_payload, definition_hash, validate_graph from govoplan_dataflow.backend.graph import (
canonical_graph_payload,
definition_hash,
preserve_compatible_graph_layout,
validate_graph,
)
from govoplan_dataflow.backend.schemas import ( from govoplan_dataflow.backend.schemas import (
DataflowDiagnostic, DataflowDiagnostic,
GraphNode, GraphNode,
@@ -488,6 +493,8 @@ def compile_sql_draft(payload: PipelineDraftRequest) -> PipelineSqlResponse:
payload.sql_text, payload.sql_text,
source_nodes=_source_nodes(payload.graph, payload.source_nodes), source_nodes=_source_nodes(payload.graph, payload.source_nodes),
) )
if payload.graph is not None:
graph = preserve_compatible_graph_layout(payload.graph, graph)
except SqlCompilationError as exc: except SqlCompilationError as exc:
return PipelineSqlResponse( return PipelineSqlResponse(
valid=False, valid=False,

View File

@@ -22,6 +22,12 @@ class SqlCompilationError(ValueError):
self.diagnostics = diagnostics self.diagnostics = diagnostics
LAYOUT_ORIGIN_X = 60
LAYOUT_CENTER_Y = 180
LAYOUT_LAYER_GAP = 240
LAYOUT_BRANCH_GAP = 120
def compile_sql( def compile_sql(
sql_text: str, sql_text: str,
*, *,
@@ -90,12 +96,13 @@ def compile_sql(
[_sql_error("sql.union_join", "JOIN outside UNION BY NAME is not supported.")] [_sql_error("sql.union_join", "JOIN outside UNION BY NAME is not supported.")]
) )
union_tables, union_mode = _union_tables(union_expression) union_tables, union_mode = _union_tables(union_expression)
branch_positions = _branch_positions(len(union_tables))
for index, table in enumerate(union_tables, start=1): for index, table in enumerate(union_tables, start=1):
source = _source_node( source = _source_node(
table, table,
source_node_list, source_node_list,
fallback_id=f"source-{index}", fallback_id=f"source-{index}",
position=GraphPosition(x=60, y=80 + ((index - 1) * 140)), position=branch_positions[index - 1],
) )
if any(existing.id == source.id for existing in nodes): if any(existing.id == source.id for existing in nodes):
raise SqlCompilationError( raise SqlCompilationError(
@@ -111,7 +118,11 @@ def compile_sql(
id="union", id="union",
type="combine.union", type="combine.union",
label="Append rows", label="Append rows",
position=GraphPosition(x=300, y=80 + ((len(nodes) - 1) * 70)), position=_position(
1,
y=sum(position.y for position in branch_positions)
/ len(branch_positions),
),
config={"mode": union_mode}, config={"mode": union_mode},
) )
nodes.append(union_node) nodes.append(union_node)
@@ -125,6 +136,7 @@ def compile_sql(
if source.type.startswith("source.") if source.type.startswith("source.")
) )
previous_node_id = union_node.id previous_node_id = union_node.id
next_transform_layer = 2
else: else:
left_table = from_clause.this left_table = from_clause.this
if not isinstance(left_table, exp.Table): if not isinstance(left_table, exp.Table):
@@ -146,16 +158,22 @@ def compile_sql(
left_table, left_table,
source_node_list, source_node_list,
fallback_id="source-left" if right_table is not None else "source", fallback_id="source-left" if right_table is not None else "source",
position=GraphPosition(x=60, y=120 if right_table is not None else 180), position=(
_branch_positions(2)[0]
if right_table is not None
else _position(0)
),
) )
nodes.append(left_source) nodes.append(left_source)
previous_node_id = left_source.id previous_node_id = left_source.id
next_transform_layer = 1
if isinstance(right_table, exp.Table): if isinstance(right_table, exp.Table):
join_positions = _branch_positions(2)
right_source = _source_node( right_source = _source_node(
right_table, right_table,
source_node_list, source_node_list,
fallback_id="source-right", fallback_id="source-right",
position=GraphPosition(x=60, y=280), position=join_positions[1],
) )
if right_source.id == left_source.id: if right_source.id == left_source.id:
raise SqlCompilationError( raise SqlCompilationError(
@@ -168,7 +186,7 @@ def compile_sql(
id="join", id="join",
type="combine.join", type="combine.join",
label=f"Join {left_table.name} and {right_table.name}", label=f"Join {left_table.name} and {right_table.name}",
position=GraphPosition(x=300, y=200), position=_position(1),
config=join_config, config=join_config,
) )
nodes.extend((right_source, join_node)) nodes.extend((right_source, join_node))
@@ -194,6 +212,13 @@ def compile_sql(
right_table, right_table,
right_prefix=right_prefix, right_prefix=right_prefix,
) )
next_transform_layer = 2
def next_transform_position() -> GraphPosition:
nonlocal next_transform_layer
position = _position(next_transform_layer)
next_transform_layer += 1
return position
def append_transform(node: GraphNode) -> None: def append_transform(node: GraphNode) -> None:
nonlocal previous_node_id nonlocal previous_node_id
@@ -214,7 +239,7 @@ def compile_sql(
id=f"filter-{index}", id=f"filter-{index}",
type="filter", type="filter",
label=f"Filter {index}", label=f"Filter {index}",
position=_position(len(nodes)), position=next_transform_position(),
config=_condition_config( config=_condition_config(
condition, condition,
qualifier_prefixes=qualifier_prefixes, qualifier_prefixes=qualifier_prefixes,
@@ -286,7 +311,7 @@ def compile_sql(
id="aggregate", id="aggregate",
type="aggregate", type="aggregate",
label="Aggregate", label="Aggregate",
position=_position(len(nodes)), position=next_transform_position(),
config={"group_by": group_by, "aggregates": aggregate_specs}, config={"group_by": group_by, "aggregates": aggregate_specs},
) )
) )
@@ -296,7 +321,7 @@ def compile_sql(
id="select", id="select",
type="select", type="select",
label="Select columns", label="Select columns",
position=_position(len(nodes)), position=next_transform_position(),
config={"fields": projection_fields}, config={"fields": projection_fields},
) )
) )
@@ -311,7 +336,7 @@ def compile_sql(
id="distinct", id="distinct",
type="distinct", type="distinct",
label="Remove duplicates", label="Remove duplicates",
position=_position(len(nodes)), position=next_transform_position(),
config={"columns": []}, config={"columns": []},
) )
) )
@@ -337,7 +362,7 @@ def compile_sql(
id="sort", id="sort",
type="sort", type="sort",
label="Sort", label="Sort",
position=_position(len(nodes)), position=next_transform_position(),
config={"fields": fields}, config={"fields": fields},
) )
) )
@@ -360,7 +385,7 @@ def compile_sql(
id="limit", id="limit",
type="limit", type="limit",
label="Limit", label="Limit",
position=_position(len(nodes)), position=next_transform_position(),
config={"count": count}, config={"count": count},
) )
) )
@@ -370,7 +395,7 @@ def compile_sql(
id="output", id="output",
type="output", type="output",
label="Preview output", label="Preview output",
position=_position(len(nodes)), position=next_transform_position(),
config={}, config={},
) )
) )
@@ -801,7 +826,12 @@ def _source_node(
), ),
None, None,
) )
return preserved or GraphNode( if preserved is not None:
return preserved.model_copy(
update={"position": position},
deep=True,
)
return GraphNode(
id=fallback_id, id=fallback_id,
type="source.reference", type="source.reference",
label=source_name, label=source_name,
@@ -1099,8 +1129,22 @@ def _column_name(
return f"{prefix}{expression.name}" return f"{prefix}{expression.name}"
def _position(index: int) -> GraphPosition: def _position(layer: int, *, y: float = LAYOUT_CENTER_Y) -> GraphPosition:
return GraphPosition(x=80 + index * 220, y=180) return GraphPosition(
x=LAYOUT_ORIGIN_X + layer * LAYOUT_LAYER_GAP,
y=y,
)
def _branch_positions(count: int) -> list[GraphPosition]:
top = max(
40,
LAYOUT_CENTER_Y - ((count - 1) * LAYOUT_BRANCH_GAP / 2),
)
return [
_position(0, y=top + index * LAYOUT_BRANCH_GAP)
for index in range(count)
]
def _combine_and(expressions: list[exp.Expression]) -> exp.Expression: def _combine_and(expressions: list[exp.Expression]) -> exp.Expression:

View File

@@ -13,8 +13,10 @@ from govoplan_dataflow.backend.schemas import (
GraphEdge, GraphEdge,
GraphNode, GraphNode,
GraphPosition, GraphPosition,
PipelineDraftRequest,
PipelineGraph, PipelineGraph,
) )
from govoplan_dataflow.backend.service import compile_sql_draft
from govoplan_dataflow.backend.sql_compiler import SqlCompilationError, compile_sql, render_sql from govoplan_dataflow.backend.sql_compiler import SqlCompilationError, compile_sql, render_sql
@@ -301,6 +303,14 @@ class DataflowGraphAndSqlTests(unittest.TestCase):
["source.inline", "source.inline", "combine.union", "output"], ["source.inline", "source.inline", "combine.union", "output"],
[node.type for node in roundtrip.nodes], [node.type for node in roundtrip.nodes],
) )
self.assertEqual(
[60, 60, 300, 540],
[node.position.x for node in roundtrip.nodes],
)
self.assertEqual(
120,
roundtrip.nodes[1].position.y - roundtrip.nodes[0].position.y,
)
self.assertEqual(result.rows, execute_preview(roundtrip, row_limit=100).rows) self.assertEqual(result.rows, execute_preview(roundtrip, row_limit=100).rows)
def test_legacy_composite_edge_ids_remain_readable(self) -> None: def test_legacy_composite_edge_ids_remain_readable(self) -> None:
@@ -310,6 +320,110 @@ class DataflowGraphAndSqlTests(unittest.TestCase):
self.assertEqual(edge_id, edge.id) self.assertEqual(edge_id, edge.id)
def test_sql_roundtrip_preserves_layout_for_compatible_topology(self) -> None:
compiled, sql_text, _ = compile_sql(
"""
SELECT *
FROM monthly_files
WHERE status = 'open'
ORDER BY amount DESC
""",
source_nodes=[inline_source()],
)
id_map = {
node.id: f"custom-node-{index}"
for index, node in enumerate(compiled.nodes, start=1)
}
reference = compiled.model_copy(
update={
"nodes": [
node.model_copy(
update={
"id": id_map[node.id],
"label": f"Custom {index}",
"position": GraphPosition(
x=111 + index * 137,
y=73 + index * 41,
),
},
deep=True,
)
for index, node in enumerate(compiled.nodes, start=1)
],
"edges": [
edge.model_copy(
update={
"id": f"custom-edge-{index}",
"source": id_map[edge.source],
"target": id_map[edge.target],
},
deep=True,
)
for index, edge in enumerate(compiled.edges, start=1)
],
},
deep=True,
)
reference = reference.model_copy(
update={
"nodes": list(reversed(reference.nodes)),
"edges": list(reversed(reference.edges)),
},
deep=True,
)
response = compile_sql_draft(
PipelineDraftRequest(
graph=reference,
sql_text=sql_text.replace("'open'", "'closed'"),
)
)
self.assertTrue(response.valid)
self.assertIsNotNone(response.graph)
assert response.graph is not None
self.assertEqual(
[
(node.id, node.label, node.position)
for node in reference.nodes
],
[
(node.id, node.label, node.position)
for node in response.graph.nodes
],
)
self.assertEqual(
[edge.id for edge in reference.edges],
[edge.id for edge in response.graph.edges],
)
filter_node = next(
node
for node in response.graph.nodes
if node.type == "filter"
)
self.assertEqual("closed", filter_node.config["value"])
def test_changed_sql_topology_uses_compact_layer_layout(self) -> None:
reference, _, _ = compile_sql(
"SELECT * FROM monthly_files WHERE status = 'open'",
source_nodes=[inline_source()],
)
response = compile_sql_draft(
PipelineDraftRequest(
graph=reference,
sql_text="SELECT * FROM monthly_files",
)
)
self.assertTrue(response.valid)
self.assertIsNotNone(response.graph)
assert response.graph is not None
self.assertEqual(
[60, 300],
[node.position.x for node in response.graph.nodes],
)
def test_connector_source_uses_bounded_resolver_and_records_lineage(self) -> None: def test_connector_source_uses_bounded_resolver_and_records_lineage(self) -> None:
source = GraphNode( source = GraphNode(
id="connector", id="connector",

View File

@@ -27,6 +27,14 @@ const checks = [
&& !css.includes(".dataflow-alerts"), && !css.includes(".dataflow-alerts"),
"central floating dismissible alerts" "central floating dismissible alerts"
], ],
[
!css.includes(".dataflow-results-toolbar .segmented-control"),
"ungapped shared results segmented control"
],
[
page.includes("response.graph.nodes.some((node) => node.id === selectedNodeId)"),
"selected node preservation across SQL roundtrips"
],
[page.includes("useUnsavedDraftGuard"), "unsaved-change guard"], [page.includes("useUnsavedDraftGuard"), "unsaved-change guard"],
[canvas.includes("application/x-govoplan-dataflow-node"), "palette drop handling"], [canvas.includes("application/x-govoplan-dataflow-node"), "palette drop handling"],
[canvas.includes("isValidConnection"), "edge validation"], [canvas.includes("isValidConnection"), "edge validation"],

View File

@@ -397,7 +397,12 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
sqlText: response.sql_text ?? draft.sqlText, sqlText: response.sql_text ?? draft.sqlText,
editorMode: switchToGraph ? "graph" : "sql" editorMode: switchToGraph ? "graph" : "sql"
}); });
setSelectedNodeId(response.graph.nodes[0]?.id ?? null); setSelectedNodeId(
selectedNodeId
&& response.graph.nodes.some((node) => node.id === selectedNodeId)
? selectedNodeId
: response.graph.nodes[0]?.id ?? null
);
setSuccess("SQL compiled into the pipeline graph."); setSuccess("SQL compiled into the pipeline graph.");
return true; return true;
} catch (compileError) { } catch (compileError) {

View File

@@ -66,8 +66,7 @@
.dataflow-toolbar-actions, .dataflow-toolbar-actions,
.dataflow-command-bar, .dataflow-command-bar,
.dataflow-identity-fields, .dataflow-identity-fields {
.dataflow-results-toolbar .segmented-control {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;