Expand governed Dataflow editor and node library
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user