Expand governed Dataflow editor and node library

This commit is contained in:
2026-07-28 11:14:01 +02:00
parent df468a2bd8
commit dee8380631
22 changed files with 3563 additions and 290 deletions
+456 -71
View File
@@ -1,12 +1,12 @@
from __future__ import annotations
from typing import Any, Iterable
from typing import Any, Callable, Iterable
import sqlglot
from sqlglot import exp
from sqlglot.errors import ParseError
from govoplan_dataflow.backend.graph import topological_order, validate_graph
from govoplan_dataflow.backend.graph import graph_inputs_by_port, topological_order, validate_graph
from govoplan_dataflow.backend.schemas import (
DataflowDiagnostic,
GraphEdge,
@@ -47,49 +47,121 @@ def compile_sql(
query = statements[0]
_reject_unsupported_query_shape(query)
tables = list(query.find_all(exp.Table))
if len(tables) != 1:
from_clause = query.args.get("from_")
if from_clause is None or not isinstance(from_clause.this, exp.Table):
raise SqlCompilationError(
[_sql_error("sql.source_count", "The first release supports exactly one logical source.")]
[_sql_error("sql.source_required", "SELECT requires a logical tabular source.")]
)
table = tables[0]
if table.catalog or table.db:
joins = list(query.args.get("joins") or [])
if len(joins) > 1:
raise SqlCompilationError(
[_sql_error("sql.qualified_source", "Use the logical source name without a catalog or schema.")]
[_sql_error("sql.join_count", "Dataflow SQL currently supports one two-source join.")]
)
source_name = table.name
preserved_source = next(
(
node.model_copy(deep=True)
for node in source_nodes
if node.type.startswith("source.")
and str(node.config.get("source_name", "")).casefold() == source_name.casefold()
),
None,
left_table = from_clause.this
right_table = joins[0].this if joins else None
if right_table is not None and not isinstance(right_table, exp.Table):
raise SqlCompilationError(
[_sql_error("sql.join_source", "JOIN requires a logical tabular source.")]
)
tables = [left_table, *([right_table] if isinstance(right_table, exp.Table) else [])]
for table in tables:
if table.catalog or table.db:
raise SqlCompilationError(
[_sql_error("sql.qualified_source", "Use logical source names without a catalog or schema.")]
)
source_node_list = list(source_nodes)
left_source = _source_node(
left_table,
source_node_list,
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),
)
source_node = preserved_source or GraphNode(
id="source",
type="source.reference",
label=source_name,
position=GraphPosition(x=80, y=180),
config={"source_name": source_name},
)
nodes = [left_source]
edges: list[GraphEdge] = []
previous_node_id = left_source.id
qualifier_prefixes: dict[str, str] | None = None
if isinstance(right_table, exp.Table):
right_source = _source_node(
right_table,
source_node_list,
fallback_id="source-right",
position=GraphPosition(x=60, y=280),
)
if right_source.id == left_source.id:
raise SqlCompilationError(
[_sql_error("sql.source_identity", "Joined sources must use different graph nodes.")]
)
right_prefix = f"{right_table.alias_or_name}_"
join_config = _join_config(joins[0], left_table=left_table, right_table=right_table)
join_config["right_prefix"] = right_prefix
join_node = GraphNode(
id="join",
type="combine.join",
label=f"Join {left_table.name} and {right_table.name}",
position=GraphPosition(x=300, y=200),
config=join_config,
)
nodes.extend((right_source, join_node))
edges.extend(
(
GraphEdge(
id=f"edge-{left_source.id}-{join_node.id}-left",
source=left_source.id,
target=join_node.id,
target_port="left",
),
GraphEdge(
id=f"edge-{right_source.id}-{join_node.id}-right",
source=right_source.id,
target=join_node.id,
target_port="right",
),
)
)
previous_node_id = join_node.id
qualifier_prefixes = _join_qualifier_prefixes(
left_table,
right_table,
right_prefix=right_prefix,
)
def append_transform(node: GraphNode) -> None:
nonlocal previous_node_id
nodes.append(node)
edges.append(
GraphEdge(
id=f"edge-{previous_node_id}-{node.id}",
source=previous_node_id,
target=node.id,
)
)
previous_node_id = node.id
nodes = [source_node]
conditions = _flatten_and(query.args.get("where").this) if query.args.get("where") else []
for index, condition in enumerate(conditions, start=1):
nodes.append(
append_transform(
GraphNode(
id=f"filter-{index}",
type="filter",
label=f"Filter {index}",
position=_position(len(nodes)),
config=_condition_config(condition),
config=_condition_config(
condition,
qualifier_prefixes=qualifier_prefixes,
),
)
)
group = query.args.get("group")
group_by = [_column_name(item, context="GROUP BY") for item in group.expressions] if group else []
group_by = [
_column_name(
item,
context="GROUP BY",
qualifier_prefixes=qualifier_prefixes,
)
for item in group.expressions
] if group else []
aggregate_specs: list[dict[str, Any]] = []
projection_fields: list[dict[str, str]] = []
saw_star = False
@@ -97,7 +169,11 @@ def compile_sql(
for item in query.expressions:
inner = item.this if isinstance(item, exp.Alias) else item
alias = item.alias if isinstance(item, exp.Alias) else ""
aggregate = _aggregate_config(inner, alias=alias)
aggregate = _aggregate_config(
inner,
alias=alias,
qualifier_prefixes=qualifier_prefixes,
)
if aggregate is not None:
saw_aggregate = True
aggregate_specs.append(aggregate)
@@ -109,7 +185,11 @@ def compile_sql(
raise SqlCompilationError(
[_sql_error("sql.select_expression", "SELECT supports columns and COUNT/SUM/AVG/MIN/MAX only.")]
)
column = _column_name(inner, context="SELECT")
column = _column_name(
inner,
context="SELECT",
qualifier_prefixes=qualifier_prefixes,
)
projection_fields.append({"column": column, "alias": alias or column})
if saw_star and len(query.expressions) != 1:
@@ -132,7 +212,7 @@ def compile_sql(
raise SqlCompilationError(
[_sql_error("sql.aggregate_required", "GROUP BY requires at least one aggregate in this dialect.")]
)
nodes.append(
append_transform(
GraphNode(
id="aggregate",
type="aggregate",
@@ -142,7 +222,7 @@ def compile_sql(
)
)
elif not saw_star:
nodes.append(
append_transform(
GraphNode(
id="select",
type="select",
@@ -152,6 +232,21 @@ def compile_sql(
)
)
if query.args.get("distinct"):
if saw_aggregate or group_by:
raise SqlCompilationError(
[_sql_error("sql.distinct_group", "DISTINCT cannot be combined with aggregation yet.")]
)
append_transform(
GraphNode(
id="distinct",
type="distinct",
label="Remove duplicates",
position=_position(len(nodes)),
config={"columns": []},
)
)
order = query.args.get("order")
if order:
fields: list[dict[str, str]] = []
@@ -160,11 +255,15 @@ def compile_sql(
raise SqlCompilationError([_sql_error("sql.order", "Unsupported ORDER BY expression.")])
fields.append(
{
"column": _column_name(item.this, context="ORDER BY"),
"column": _column_name(
item.this,
context="ORDER BY",
qualifier_prefixes=qualifier_prefixes,
),
"direction": "desc" if item.args.get("desc") else "asc",
}
)
nodes.append(
append_transform(
GraphNode(
id="sort",
type="sort",
@@ -187,7 +286,7 @@ def compile_sql(
raise SqlCompilationError(
[_sql_error("sql.limit_range", "LIMIT must be between 1 and 100,000.")]
)
nodes.append(
append_transform(
GraphNode(
id="limit",
type="limit",
@@ -197,7 +296,7 @@ def compile_sql(
)
)
nodes.append(
append_transform(
GraphNode(
id="output",
type="output",
@@ -206,14 +305,6 @@ def compile_sql(
config={},
)
)
edges = [
GraphEdge(
id=f"edge-{source.id}-{target.id}",
source=source.id,
target=target.id,
)
for source, target in zip(nodes, nodes[1:])
]
graph = PipelineGraph(nodes=nodes, edges=edges)
diagnostics = validate_graph(graph)
if any(item.severity == "error" for item in diagnostics):
@@ -229,10 +320,57 @@ def render_sql(graph: PipelineGraph) -> tuple[str, list[DataflowDiagnostic]]:
if cyclic:
raise SqlCompilationError([_sql_error("graph.cycle", "A cyclic graph cannot be rendered as SQL.")])
node_by_id = {node.id: node for node in graph.nodes}
source = node_by_id[ordered[0]]
source_name = str(source.config.get("source_name", "")).strip()
if not source_name:
raise SqlCompilationError([_sql_error("source.name_required", "The source needs a logical SQL name.")])
source_nodes = [node for node in graph.nodes if node.type.startswith("source.")]
join_nodes = [node for node in graph.nodes if node.type == "combine.join"]
if len(join_nodes) > 1:
raise SqlCompilationError(
[_sql_error("sql.join_count", "Only one two-source join can be rendered as SQL.")]
)
left_source: GraphNode
right_source: GraphNode | None = None
join_node = join_nodes[0] if join_nodes else None
right_prefix: str | None = None
right_alias: str | None = None
if join_node is not None:
inputs = graph_inputs_by_port(graph).get(join_node.id, {})
left_source = node_by_id[inputs["left"][0]]
right_source = node_by_id[inputs["right"][0]]
right_prefix = str(join_node.config["right_prefix"])
right_alias = right_prefix[:-1]
elif len(source_nodes) == 1:
left_source = source_nodes[0]
else:
raise SqlCompilationError(
[_sql_error("sql.source_count", "SQL rendering needs one source or one two-source join.")]
)
left_source_name = str(left_source.config.get("source_name", "")).strip()
right_source_name = (
str(right_source.config.get("source_name", "")).strip()
if right_source is not None
else None
)
if not left_source_name or (right_source is not None and not right_source_name):
raise SqlCompilationError(
[_sql_error("source.name_required", "Every source needs a logical SQL name.")]
)
if right_alias and right_alias.casefold() == left_source_name.casefold():
raise SqlCompilationError(
[_sql_error("sql.join_alias", "The right-column prefix conflicts with the left source name.")]
)
def column_expression(name: str) -> exp.Column:
if right_prefix and right_alias and name.startswith(right_prefix):
right_name = name[len(right_prefix) :]
if not right_name:
raise SqlCompilationError(
[_sql_error("sql.column", "A right-side column name is missing.")]
)
return exp.column(right_name, table=right_alias)
if right_source is not None:
return exp.column(name, table=left_source_name)
return exp.column(name)
where_conditions: list[exp.Expression] = []
select_expressions: list[exp.Expression] = [exp.Star()]
@@ -240,25 +378,40 @@ def render_sql(graph: PipelineGraph) -> tuple[str, list[DataflowDiagnostic]]:
order_by: list[exp.Expression] = []
limit: int | None = None
selected = False
distinct = False
for node_id in ordered[1:]:
for node_id in ordered:
node = node_by_id[node_id]
if node.type.startswith("source.") or node.type == "combine.join":
continue
if node.type == "filter":
if selected:
raise SqlCompilationError(
[_node_sql_error(node.id, "sql.filter_order", "Filters after projection or aggregation are not representable yet.")]
)
where_conditions.append(_condition_expression(node.config))
where_conditions.append(
_condition_expression(node.config, column_expression=column_expression)
)
elif node.type == "select":
if selected:
raise SqlCompilationError(
[_node_sql_error(node.id, "sql.multiple_select", "Only one select or aggregate transform is supported.")]
)
if distinct:
raise SqlCompilationError(
[
_node_sql_error(
node.id,
"sql.distinct_order",
"Projection after deduplication is not representable as SELECT DISTINCT.",
)
]
)
select_expressions = []
for field in node.config["fields"]:
column = field if isinstance(field, str) else str(field["column"])
alias = column if isinstance(field, str) else str(field.get("alias") or column)
expression: exp.Expression = exp.column(column)
expression: exp.Expression = column_expression(column)
if alias != column:
expression = expression.as_(alias)
select_expressions.append(expression)
@@ -268,19 +421,55 @@ def render_sql(graph: PipelineGraph) -> tuple[str, list[DataflowDiagnostic]]:
raise SqlCompilationError(
[_node_sql_error(node.id, "sql.multiple_select", "Only one select or aggregate transform is supported.")]
)
select_expressions = [exp.column(column) for column in node.config.get("group_by", [])]
group_by = [exp.column(column) for column in node.config.get("group_by", [])]
if distinct:
raise SqlCompilationError(
[
_node_sql_error(
node.id,
"sql.distinct_order",
"Aggregation after deduplication is not representable in the constrained dialect.",
)
]
)
select_expressions = [
column_expression(str(column))
for column in node.config.get("group_by", [])
]
group_by = [
column_expression(str(column))
for column in node.config.get("group_by", [])
]
for aggregate in node.config["aggregates"]:
function = str(aggregate["function"])
column = aggregate.get("column")
argument: exp.Expression = exp.Star() if function == "count" and column in (None, "", "*") else exp.column(str(column))
argument: exp.Expression = (
exp.Star()
if function == "count" and column in (None, "", "*")
else column_expression(str(column))
)
aggregate_expression = _aggregate_expression(function, argument)
select_expressions.append(aggregate_expression.as_(str(aggregate["alias"])))
selected = True
elif node.type == "distinct":
if node.config.get("columns"):
raise SqlCompilationError(
[
_node_sql_error(
node.id,
"sql.distinct_keys",
"Key-based deduplication is not representable as SELECT DISTINCT.",
)
]
)
if distinct:
raise SqlCompilationError(
[_node_sql_error(node.id, "sql.multiple_distinct", "Only one DISTINCT transform is supported.")]
)
distinct = True
elif node.type == "sort":
order_by = [
exp.Ordered(
this=exp.column(str(field["column"])),
this=column_expression(str(field["column"])),
desc=field.get("direction", "asc") == "desc",
nulls_first=False,
)
@@ -293,13 +482,32 @@ def render_sql(graph: PipelineGraph) -> tuple[str, list[DataflowDiagnostic]]:
[_node_sql_error(node.id, "sql.node_not_representable", f"{node.type!r} cannot be rendered as SQL.")]
)
query = exp.select(*select_expressions).from_(exp.to_table(source_name))
query = exp.select(*select_expressions).from_(exp.to_table(left_source_name))
if join_node is not None and right_source_name and right_alias:
join_conditions = [
exp.EQ(
this=exp.column(str(left_key), table=left_source_name),
expression=exp.column(str(right_key), table=right_alias),
)
for left_key, right_key in zip(
join_node.config["left_keys"],
join_node.config["right_keys"],
strict=True,
)
]
query = query.join(
exp.to_table(right_source_name).as_(right_alias),
on=_combine_and(join_conditions),
join_type=str(join_node.config.get("join_type", "inner")),
)
if where_conditions:
query = query.where(_combine_and(where_conditions))
if group_by:
query = query.group_by(*group_by)
if order_by:
query = query.order_by(*order_by)
if distinct:
query = query.distinct()
if limit is not None:
query = query.limit(limit)
return query.sql(dialect="duckdb", pretty=True), diagnostics
@@ -308,7 +516,6 @@ def render_sql(graph: PipelineGraph) -> tuple[str, list[DataflowDiagnostic]]:
def _reject_unsupported_query_shape(query: exp.Select) -> None:
unsupported_args = {
"with_": "WITH queries",
"distinct": "DISTINCT",
"having": "HAVING",
"qualify": "QUALIFY",
"offset": "OFFSET",
@@ -320,12 +527,126 @@ def _reject_unsupported_query_shape(query: exp.Select) -> None:
raise SqlCompilationError(
[_sql_error("sql.unsupported_clause", f"{label} are not supported by the first Dataflow dialect.")]
)
if any(True for _ in query.find_all(exp.Join)):
raise SqlCompilationError([_sql_error("sql.join", "JOIN support belongs to the comparison/reconciliation slice.")])
if any(True for _ in query.find_all(exp.Subquery)):
raise SqlCompilationError([_sql_error("sql.subquery", "Subqueries are not supported by the first Dataflow dialect.")])
def _source_node(
table: exp.Table,
source_nodes: list[GraphNode],
*,
fallback_id: str,
position: GraphPosition,
) -> GraphNode:
source_name = table.name
preserved = next(
(
node.model_copy(deep=True)
for node in source_nodes
if node.type.startswith("source.")
and str(node.config.get("source_name", "")).casefold() == source_name.casefold()
),
None,
)
return preserved or GraphNode(
id=fallback_id,
type="source.reference",
label=source_name,
position=position,
config={
"source_ref": "",
"source_name": source_name,
"expected_fingerprint": "",
},
)
def _join_config(
join: exp.Join,
*,
left_table: exp.Table,
right_table: exp.Table,
) -> dict[str, Any]:
kind = str(join.args.get("kind") or "").casefold()
side = str(join.args.get("side") or "").casefold()
if kind not in {"", "inner", "outer"} or side not in {"", "left", "right", "full"}:
raise SqlCompilationError(
[_sql_error("sql.join_type", "JOIN supports INNER, LEFT, RIGHT, or FULL joins only.")]
)
join_type = side or ("inner" if kind in {"", "inner"} else "")
if not join_type:
raise SqlCompilationError(
[_sql_error("sql.join_type", "OUTER JOIN requires LEFT, RIGHT, or FULL.")]
)
on_expression = join.args.get("on")
if on_expression is None:
raise SqlCompilationError(
[_sql_error("sql.join_condition", "JOIN requires an ON key comparison.")]
)
left_qualifiers = _table_qualifiers(left_table)
right_qualifiers = _table_qualifiers(right_table)
if left_qualifiers & right_qualifiers:
raise SqlCompilationError(
[_sql_error("sql.join_alias", "Joined sources need distinct names or aliases.")]
)
left_keys: list[str] = []
right_keys: list[str] = []
for condition in _flatten_and(on_expression):
if (
not isinstance(condition, exp.EQ)
or not isinstance(condition.this, exp.Column)
or not isinstance(condition.expression, exp.Column)
):
raise SqlCompilationError(
[_sql_error("sql.join_condition", "JOIN ON accepts equality comparisons between source columns.")]
)
first = condition.this
second = condition.expression
first_qualifier = first.table.casefold()
second_qualifier = second.table.casefold()
if first_qualifier in left_qualifiers and second_qualifier in right_qualifiers:
left_keys.append(first.name)
right_keys.append(second.name)
elif first_qualifier in right_qualifiers and second_qualifier in left_qualifiers:
left_keys.append(second.name)
right_keys.append(first.name)
else:
raise SqlCompilationError(
[
_sql_error(
"sql.join_qualification",
"Every JOIN key must qualify one left and one right source column.",
)
]
)
return {
"join_type": join_type,
"left_keys": left_keys,
"right_keys": right_keys,
}
def _join_qualifier_prefixes(
left_table: exp.Table,
right_table: exp.Table,
*,
right_prefix: str,
) -> dict[str, str]:
return {
**{qualifier: "" for qualifier in _table_qualifiers(left_table)},
**{qualifier: right_prefix for qualifier in _table_qualifiers(right_table)},
}
def _table_qualifiers(table: exp.Table) -> set[str]:
return {
qualifier.casefold()
for qualifier in (table.name, table.alias_or_name)
if qualifier
}
def _flatten_and(expression: exp.Expression) -> list[exp.Expression]:
if isinstance(expression, exp.And):
return [*_flatten_and(expression.this), *_flatten_and(expression.expression)]
@@ -334,14 +655,32 @@ def _flatten_and(expression: exp.Expression) -> list[exp.Expression]:
return [expression]
def _condition_config(expression: exp.Expression) -> dict[str, Any]:
def _condition_config(
expression: exp.Expression,
*,
qualifier_prefixes: dict[str, str] | None = None,
) -> dict[str, Any]:
if isinstance(expression, exp.Not) and isinstance(expression.this, exp.Is):
inner = expression.this
if isinstance(inner.this, exp.Column) and isinstance(inner.expression, exp.Null):
return {"column": _column_name(inner.this, context="WHERE"), "operator": "not_null"}
return {
"column": _column_name(
inner.this,
context="WHERE",
qualifier_prefixes=qualifier_prefixes,
),
"operator": "not_null",
}
if isinstance(expression, exp.Is):
if isinstance(expression.this, exp.Column) and isinstance(expression.expression, exp.Null):
return {"column": _column_name(expression.this, context="WHERE"), "operator": "is_null"}
return {
"column": _column_name(
expression.this,
context="WHERE",
qualifier_prefixes=qualifier_prefixes,
),
"operator": "is_null",
}
mapping: tuple[tuple[type[exp.Expression], str], ...] = (
(exp.EQ, "eq"),
(exp.NEQ, "ne"),
@@ -355,7 +694,11 @@ def _condition_config(expression: exp.Expression) -> dict[str, Any]:
if not isinstance(expression.this, exp.Column):
break
return {
"column": _column_name(expression.this, context="WHERE"),
"column": _column_name(
expression.this,
context="WHERE",
qualifier_prefixes=qualifier_prefixes,
),
"operator": operator,
"value": _literal_value(expression.expression),
}
@@ -366,7 +709,11 @@ def _condition_config(expression: exp.Expression) -> dict[str, Any]:
[_sql_error("sql.like", "LIKE is supported only as a contains pattern: LIKE '%value%'.")]
)
return {
"column": _column_name(expression.this, context="WHERE"),
"column": _column_name(
expression.this,
context="WHERE",
qualifier_prefixes=qualifier_prefixes,
),
"operator": "contains",
"value": value[1:-1],
}
@@ -375,8 +722,12 @@ def _condition_config(expression: exp.Expression) -> dict[str, Any]:
)
def _condition_expression(config: dict[str, Any]) -> exp.Expression:
column = exp.column(str(config["column"]))
def _condition_expression(
config: dict[str, Any],
*,
column_expression: Callable[[str], exp.Column] = exp.column,
) -> exp.Expression:
column = column_expression(str(config["column"]))
operator = str(config["operator"])
if operator == "is_null":
return exp.Is(this=column, expression=exp.Null())
@@ -417,7 +768,12 @@ def _literal_value(expression: exp.Expression) -> Any:
raise SqlCompilationError([_sql_error("sql.literal", f"Unsupported literal {text!r}.")]) from exc
def _aggregate_config(expression: exp.Expression, *, alias: str) -> dict[str, Any] | None:
def _aggregate_config(
expression: exp.Expression,
*,
alias: str,
qualifier_prefixes: dict[str, str] | None = None,
) -> dict[str, Any] | None:
mapping: tuple[tuple[type[exp.Expression], str], ...] = (
(exp.Count, "count"),
(exp.Sum, "sum"),
@@ -432,7 +788,11 @@ def _aggregate_config(expression: exp.Expression, *, alias: str) -> dict[str, An
if isinstance(argument, exp.Star):
column = "*"
elif isinstance(argument, exp.Column):
column = _column_name(argument, context=function.upper())
column = _column_name(
argument,
context=function.upper(),
qualifier_prefixes=qualifier_prefixes,
)
else:
raise SqlCompilationError(
[_sql_error("sql.aggregate_argument", f"{function.upper()} requires a column or * argument.")]
@@ -453,12 +813,37 @@ def _aggregate_expression(function: str, argument: exp.Expression) -> exp.Expres
return mapping[function](this=argument)
def _column_name(expression: exp.Expression, *, context: str) -> str:
if not isinstance(expression, exp.Column) or expression.table:
def _column_name(
expression: exp.Expression,
*,
context: str,
qualifier_prefixes: dict[str, str] | None = None,
) -> str:
if not isinstance(expression, exp.Column):
raise SqlCompilationError(
[_sql_error("sql.column", f"{context} accepts column names only.")]
)
if not expression.table and qualifier_prefixes is not None:
raise SqlCompilationError(
[
_sql_error(
"sql.join_column_qualification",
f"{context} columns must be source-qualified when a JOIN is present.",
)
]
)
if not expression.table:
return expression.name
if qualifier_prefixes is None:
raise SqlCompilationError(
[_sql_error("sql.column", f"{context} accepts unqualified column names only.")]
)
return expression.name
prefix = qualifier_prefixes.get(expression.table.casefold())
if prefix is None:
raise SqlCompilationError(
[_sql_error("sql.column_source", f"{context} references an unknown source qualifier.")]
)
return f"{prefix}{expression.name}"
def _position(index: int) -> GraphPosition: