Complete SQL diagnostics and editor accessibility

This commit is contained in:
2026-07-31 17:18:26 +02:00
parent 28a92feb32
commit 8724591bf9
7 changed files with 494 additions and 76 deletions
+10
View File
@@ -70,12 +70,22 @@ class PipelineGraph(BaseModel):
edges: list[GraphEdge] = Field(default_factory=list, max_length=200) edges: list[GraphEdge] = Field(default_factory=list, max_length=200)
class DiagnosticSourceLocation(BaseModel):
start_line: int = Field(ge=1)
start_column: int = Field(ge=1)
end_line: int = Field(ge=1)
end_column: int = Field(ge=1)
start_offset: int | None = Field(default=None, ge=0)
end_offset: int | None = Field(default=None, ge=0)
class DataflowDiagnostic(BaseModel): class DataflowDiagnostic(BaseModel):
severity: DiagnosticSeverity severity: DiagnosticSeverity
code: str code: str
message: str message: str
node_id: str | None = None node_id: str | None = None
field: str | None = None field: str | None = None
source_location: DiagnosticSourceLocation | None = None
class PipelineRevisionResponse(BaseModel): class PipelineRevisionResponse(BaseModel):
+350 -39
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from contextvars import ContextVar
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Callable, Iterable from typing import Any, Callable, Iterable
@@ -12,6 +13,7 @@ from govoplan_dataflow.backend.expressions import parse_expression
from govoplan_dataflow.backend.operator_registry import OPERATOR_REGISTRY from govoplan_dataflow.backend.operator_registry import OPERATOR_REGISTRY
from govoplan_dataflow.backend.schemas import ( from govoplan_dataflow.backend.schemas import (
DataflowDiagnostic, DataflowDiagnostic,
DiagnosticSourceLocation,
GraphEdge, GraphEdge,
GraphNode, GraphNode,
GraphPosition, GraphPosition,
@@ -31,6 +33,18 @@ LAYOUT_LAYER_GAP = 240
LAYOUT_BRANCH_GAP = 120 LAYOUT_BRANCH_GAP = 120
@dataclass(frozen=True)
class _SqlDiagnosticContext:
source_text: str
root_expression: exp.Expression | None = None
_SQL_DIAGNOSTIC_CONTEXT: ContextVar[_SqlDiagnosticContext | None] = ContextVar(
"dataflow_sql_diagnostic_context",
default=None,
)
@dataclass @dataclass
class _SqlRenderState: class _SqlRenderState:
where_conditions: list[exp.Expression] = field(default_factory=list) where_conditions: list[exp.Expression] = field(default_factory=list)
@@ -102,7 +116,18 @@ def compile_sql(
*, *,
source_nodes: Iterable[GraphNode] = (), source_nodes: Iterable[GraphNode] = (),
) -> tuple[PipelineGraph, str, list[DataflowDiagnostic]]: ) -> tuple[PipelineGraph, str, list[DataflowDiagnostic]]:
query, union_expression = _parse_sql_query(sql_text) source_text = sql_text
context_token = _SQL_DIAGNOSTIC_CONTEXT.set(
_SqlDiagnosticContext(source_text=source_text)
)
try:
query, union_expression = _parse_sql_query(source_text)
_SQL_DIAGNOSTIC_CONTEXT.set(
_SqlDiagnosticContext(
source_text=source_text,
root_expression=union_expression or query,
)
)
from_clause, joins = _validated_source_clause( from_clause, joins = _validated_source_clause(
query, query,
union_expression=union_expression, union_expression=union_expression,
@@ -136,14 +161,15 @@ def compile_sql(
if any(item.severity == "error" for item in diagnostics): if any(item.severity == "error" for item in diagnostics):
raise SqlCompilationError(diagnostics) raise SqlCompilationError(diagnostics)
return graph, query.sql(dialect="duckdb", pretty=True), diagnostics return graph, query.sql(dialect="duckdb", pretty=True), diagnostics
finally:
_SQL_DIAGNOSTIC_CONTEXT.reset(context_token)
def _parse_sql_query(sql_text: str) -> tuple[exp.Select, exp.Union | None]: def _parse_sql_query(sql_text: str) -> tuple[exp.Select, exp.Union | None]:
source_text = sql_text.strip() if not sql_text.strip():
if not source_text:
raise SqlCompilationError([_sql_error("sql.empty", "Enter a SELECT query.")]) raise SqlCompilationError([_sql_error("sql.empty", "Enter a SELECT query.")])
try: try:
statements = sqlglot.parse(source_text, read="duckdb") statements = sqlglot.parse(sql_text, read="duckdb")
except ParseError as exc: except ParseError as exc:
raise _parse_error(exc) from exc raise _parse_error(exc) from exc
if len(statements) != 1 or not isinstance( if len(statements) != 1 or not isinstance(
@@ -155,6 +181,7 @@ def _parse_sql_query(sql_text: str) -> tuple[exp.Select, exp.Union | None]:
_sql_error( _sql_error(
"sql.select_only", "sql.select_only",
"Dataflow SQL accepts exactly one SELECT or UNION BY NAME statement.", "Dataflow SQL accepts exactly one SELECT or UNION BY NAME statement.",
expression=statements[0] if statements else None,
) )
] ]
) )
@@ -172,6 +199,7 @@ def _parse_error(exc: ParseError) -> SqlCompilationError:
_sql_error( _sql_error(
"sql.parse", "sql.parse",
f"SQL could not be parsed{location}: {description}", f"SQL could not be parsed{location}: {description}",
source_location=_parse_error_location(detail),
) )
] ]
) )
@@ -325,12 +353,17 @@ def _initialize_tabular_sources(
def _validate_logical_tables(tables: list[exp.Table]) -> None: def _validate_logical_tables(tables: list[exp.Table]) -> None:
if any(table.catalog or table.db for table in tables): qualified_table = next(
(table for table in tables if table.catalog or table.db),
None,
)
if qualified_table is not None:
raise SqlCompilationError( raise SqlCompilationError(
[ [
_sql_error( _sql_error(
"sql.qualified_source", "sql.qualified_source",
"Use logical source names without a catalog or schema.", "Use logical source names without a catalog or schema.",
expression=qualified_table,
) )
] ]
) )
@@ -477,6 +510,7 @@ def _projection_item(
_sql_error( _sql_error(
"sql.select_expression", "sql.select_expression",
"SELECT supports columns and COUNT/SUM/AVG/MIN/MAX only.", "SELECT supports columns and COUNT/SUM/AVG/MIN/MAX only.",
expression=item,
) )
] ]
) )
@@ -494,25 +528,55 @@ def _validate_projection_plan(
) -> None: ) -> None:
if plan.saw_star and len(query.expressions) != 1: if plan.saw_star and len(query.expressions) != 1:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.star_mix", "SELECT * cannot be mixed with other expressions in this dialect.")] [
_sql_error(
"sql.star_mix",
"SELECT * cannot be mixed with other expressions in this dialect.",
expression=query.expressions[0],
)
]
) )
if not (plan.saw_aggregate or plan.group_by): if not (plan.saw_aggregate or plan.group_by):
return return
if plan.saw_star: if plan.saw_star:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.aggregate_star", "SELECT * cannot be grouped.")] [
_sql_error(
"sql.aggregate_star",
"SELECT * cannot be grouped.",
expression=query.expressions[0],
)
]
) )
if any(field["column"] not in plan.group_by for field in plan.fields): if any(field["column"] not in plan.group_by for field in plan.fields):
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.grouping", "Every non-aggregate SELECT column must appear in GROUP BY.")] [
_sql_error(
"sql.grouping",
"Every non-aggregate SELECT column must appear in GROUP BY.",
expression=query.args.get("group") or query,
)
]
) )
if any(field["alias"] != field["column"] for field in plan.fields): if any(field["alias"] != field["column"] for field in plan.fields):
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.group_alias", "Aliases for GROUP BY columns are not supported yet.")] [
_sql_error(
"sql.group_alias",
"Aliases for GROUP BY columns are not supported yet.",
expression=query.args.get("group") or query,
)
]
) )
if not plan.aggregates: if not plan.aggregates:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.aggregate_required", "GROUP BY requires at least one aggregate in this dialect.")] [
_sql_error(
"sql.aggregate_required",
"GROUP BY requires at least one aggregate in this dialect.",
expression=query.args.get("group") or query,
)
]
) )
@@ -555,7 +619,13 @@ def _append_distinct_node(
return return
if plan.saw_aggregate or plan.group_by: if plan.saw_aggregate or plan.group_by:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.distinct_group", "DISTINCT cannot be combined with aggregation yet.")] [
_sql_error(
"sql.distinct_group",
"DISTINCT cannot be combined with aggregation yet.",
expression=query.args.get("distinct") or query,
)
]
) )
state.append_transform( state.append_transform(
GraphNode( GraphNode(
@@ -597,7 +667,13 @@ def _order_field(
) -> dict[str, str]: ) -> dict[str, str]:
if not isinstance(item, exp.Ordered): if not isinstance(item, exp.Ordered):
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.order", "Unsupported ORDER BY expression.")] [
_sql_error(
"sql.order",
"Unsupported ORDER BY expression.",
expression=item,
)
]
) )
return { return {
"column": _column_name( "column": _column_name(
@@ -628,17 +704,35 @@ def _append_limit_node(state: _SqlCompileState, query: exp.Select) -> None:
def _limit_count(expression: exp.Expression | None) -> int: def _limit_count(expression: exp.Expression | None) -> int:
if not isinstance(expression, exp.Literal) or expression.is_string: if not isinstance(expression, exp.Literal) or expression.is_string:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.limit", "LIMIT must be a positive integer.")] [
_sql_error(
"sql.limit",
"LIMIT must be a positive integer.",
expression=expression,
)
]
) )
try: try:
count = int(expression.this) count = int(expression.this)
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.limit", "LIMIT must be a positive integer.")] [
_sql_error(
"sql.limit",
"LIMIT must be a positive integer.",
expression=expression,
)
]
) from exc ) from exc
if not 1 <= count <= 100_000: if not 1 <= count <= 100_000:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.limit_range", "LIMIT must be between 1 and 100,000.")] [
_sql_error(
"sql.limit_range",
"LIMIT must be between 1 and 100,000.",
expression=expression,
)
]
) )
return count return count
@@ -985,6 +1079,7 @@ def _union_tables(union_expression: exp.Union) -> tuple[list[exp.Table], str]:
_sql_error( _sql_error(
"sql.union_by_name", "sql.union_by_name",
"Use UNION BY NAME so SQL matches Dataflow column-name append semantics.", "Use UNION BY NAME so SQL matches Dataflow column-name append semantics.",
expression=expression,
) )
] ]
) )
@@ -994,6 +1089,9 @@ def _union_tables(union_expression: exp.Union) -> tuple[list[exp.Table], str]:
_sql_error( _sql_error(
"sql.union_branch_order", "sql.union_branch_order",
"Apply ORDER BY or LIMIT after the complete UNION BY NAME.", "Apply ORDER BY or LIMIT after the complete UNION BY NAME.",
expression=expression.args.get("order")
or expression.args.get("limit")
or expression,
) )
] ]
) )
@@ -1003,7 +1101,13 @@ def _union_tables(union_expression: exp.Union) -> tuple[list[exp.Table], str]:
return return
if not isinstance(expression, exp.Select): if not isinstance(expression, exp.Select):
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.union_branch", "UNION BY NAME branches must be SELECT statements.")] [
_sql_error(
"sql.union_branch",
"UNION BY NAME branches must be SELECT statements.",
expression=expression,
)
]
) )
branches.append(expression) branches.append(expression)
@@ -1034,6 +1138,7 @@ def _union_tables(union_expression: exp.Union) -> tuple[list[exp.Table], str]:
_sql_error( _sql_error(
"sql.union_branch_shape", "sql.union_branch_shape",
"UNION BY NAME inputs must be direct SELECT * source reads.", "UNION BY NAME inputs must be direct SELECT * source reads.",
expression=branch,
) )
] ]
) )
@@ -1043,6 +1148,7 @@ def _union_tables(union_expression: exp.Union) -> tuple[list[exp.Table], str]:
_sql_error( _sql_error(
"sql.union_branch_projection", "sql.union_branch_projection",
"UNION BY NAME inputs must select all source columns.", "UNION BY NAME inputs must select all source columns.",
expression=branch.expressions[0] if branch.expressions else branch,
) )
] ]
) )
@@ -1050,11 +1156,23 @@ def _union_tables(union_expression: exp.Union) -> tuple[list[exp.Table], str]:
table = from_clause.this if from_clause is not None else None table = from_clause.this if from_clause is not None else None
if not isinstance(table, exp.Table): if not isinstance(table, exp.Table):
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.union_source", "Each UNION BY NAME input needs a logical source.")] [
_sql_error(
"sql.union_source",
"Each UNION BY NAME input needs a logical source.",
expression=branch,
)
]
) )
if table.catalog or table.db: if table.catalog or table.db:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.qualified_source", "Use logical source names without a catalog or schema.")] [
_sql_error(
"sql.qualified_source",
"Use logical source names without a catalog or schema.",
expression=table,
)
]
) )
tables.append(table) tables.append(table)
return tables, "distinct" if modes == {True} else "all" return tables, "distinct" if modes == {True} else "all"
@@ -1074,15 +1192,35 @@ def _reject_unsupported_query_shape(
"locks": "locking clauses", "locks": "locking clauses",
} }
for key, label in unsupported_args.items(): for key, label in unsupported_args.items():
if query.args.get(key): unsupported = query.args.get(key)
if unsupported:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.unsupported_clause", f"{label} are not supported by the first Dataflow dialect.")] [
_sql_error(
"sql.unsupported_clause",
f"{label} are not supported by the first Dataflow dialect.",
expression=unsupported,
) )
if any( ]
subquery is not allowed_subquery )
unsupported_subquery = next(
(
subquery
for subquery in query.find_all(exp.Subquery) for subquery in query.find_all(exp.Subquery)
): if subquery is not allowed_subquery
raise SqlCompilationError([_sql_error("sql.subquery", "Subqueries are not supported by the first Dataflow dialect.")]) ),
None,
)
if unsupported_subquery is not None:
raise SqlCompilationError(
[
_sql_error(
"sql.subquery",
"Subqueries are not supported by the first Dataflow dialect.",
expression=unsupported_subquery,
)
]
)
def _source_node( def _source_node(
@@ -1142,6 +1280,7 @@ def _join_config(
"JOIN supports INNER, LEFT, RIGHT, FULL, SEMI, " "JOIN supports INNER, LEFT, RIGHT, FULL, SEMI, "
"or ANTI joins only." "or ANTI joins only."
), ),
expression=join,
) )
] ]
) )
@@ -1151,6 +1290,7 @@ def _join_config(
_sql_error( _sql_error(
"sql.join_type", "sql.join_type",
"SEMI and ANTI joins cannot use a side qualifier.", "SEMI and ANTI joins cannot use a side qualifier.",
expression=join,
) )
] ]
) )
@@ -1161,19 +1301,37 @@ def _join_config(
) )
if not join_type: if not join_type:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.join_type", "OUTER JOIN requires LEFT, RIGHT, or FULL.")] [
_sql_error(
"sql.join_type",
"OUTER JOIN requires LEFT, RIGHT, or FULL.",
expression=join,
)
]
) )
on_expression = join.args.get("on") on_expression = join.args.get("on")
if on_expression is None: if on_expression is None:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.join_condition", "JOIN requires an ON key comparison.")] [
_sql_error(
"sql.join_condition",
"JOIN requires an ON key comparison.",
expression=join,
)
]
) )
left_qualifiers = _table_qualifiers(left_table) left_qualifiers = _table_qualifiers(left_table)
right_qualifiers = _table_qualifiers(right_table) right_qualifiers = _table_qualifiers(right_table)
if left_qualifiers & right_qualifiers: if left_qualifiers & right_qualifiers:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.join_alias", "Joined sources need distinct names or aliases.")] [
_sql_error(
"sql.join_alias",
"Joined sources need distinct names or aliases.",
expression=join,
)
]
) )
left_keys: list[str] = [] left_keys: list[str] = []
right_keys: list[str] = [] right_keys: list[str] = []
@@ -1184,7 +1342,13 @@ def _join_config(
or not isinstance(condition.expression, exp.Column) or not isinstance(condition.expression, exp.Column)
): ):
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.join_condition", "JOIN ON accepts equality comparisons between source columns.")] [
_sql_error(
"sql.join_condition",
"JOIN ON accepts equality comparisons between source columns.",
expression=condition,
)
]
) )
first = condition.this first = condition.this
second = condition.expression second = condition.expression
@@ -1202,6 +1366,7 @@ def _join_config(
_sql_error( _sql_error(
"sql.join_qualification", "sql.join_qualification",
"Every JOIN key must qualify one left and one right source column.", "Every JOIN key must qualify one left and one right source column.",
expression=condition,
) )
] ]
) )
@@ -1236,7 +1401,15 @@ def _flatten_and(expression: exp.Expression) -> list[exp.Expression]:
if isinstance(expression, exp.And): if isinstance(expression, exp.And):
return [*_flatten_and(expression.this), *_flatten_and(expression.expression)] return [*_flatten_and(expression.this), *_flatten_and(expression.expression)]
if isinstance(expression, exp.Or): if isinstance(expression, exp.Or):
raise SqlCompilationError([_sql_error("sql.or", "OR conditions are not supported yet; use separate pipelines.")]) raise SqlCompilationError(
[
_sql_error(
"sql.or",
"OR conditions are not supported yet; use separate pipelines.",
expression=expression,
)
]
)
return [expression] return [expression]
@@ -1291,7 +1464,13 @@ def _condition_config(
value = _literal_value(expression.expression) value = _literal_value(expression.expression)
if not isinstance(value, str) or not (value.startswith("%") and value.endswith("%")): if not isinstance(value, str) or not (value.startswith("%") and value.endswith("%")):
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.like", "LIKE is supported only as a contains pattern: LIKE '%value%'.")] [
_sql_error(
"sql.like",
"LIKE is supported only as a contains pattern: LIKE '%value%'.",
expression=expression,
)
]
) )
return { return {
"column": _column_name( "column": _column_name(
@@ -1303,7 +1482,13 @@ def _condition_config(
"value": value[1:-1], "value": value[1:-1],
} }
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.condition", "WHERE supports simple column comparisons joined with AND.")] [
_sql_error(
"sql.condition",
"WHERE supports simple column comparisons joined with AND.",
expression=expression,
)
]
) )
@@ -1785,7 +1970,15 @@ def _literal_value(expression: exp.Expression) -> Any:
if isinstance(expression, exp.Boolean): if isinstance(expression, exp.Boolean):
return bool(expression.this) return bool(expression.this)
if not isinstance(expression, exp.Literal): if not isinstance(expression, exp.Literal):
raise SqlCompilationError([_sql_error("sql.literal", "Comparisons require a literal value.")]) raise SqlCompilationError(
[
_sql_error(
"sql.literal",
"Comparisons require a literal value.",
expression=expression,
)
]
)
if expression.is_string: if expression.is_string:
return str(expression.this) return str(expression.this)
text = str(expression.this) text = str(expression.this)
@@ -1795,7 +1988,15 @@ def _literal_value(expression: exp.Expression) -> Any:
try: try:
return float(text) return float(text)
except ValueError as exc: except ValueError as exc:
raise SqlCompilationError([_sql_error("sql.literal", f"Unsupported literal {text!r}.")]) from exc raise SqlCompilationError(
[
_sql_error(
"sql.literal",
f"Unsupported literal {text!r}.",
expression=expression,
)
]
) from exc
def _aggregate_config( def _aggregate_config(
@@ -1825,7 +2026,13 @@ def _aggregate_config(
) )
else: else:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.aggregate_argument", f"{function.upper()} requires a column or * argument.")] [
_sql_error(
"sql.aggregate_argument",
f"{function.upper()} requires a column or * argument.",
expression=argument,
)
]
) )
output_alias = alias or (f"{function}_all" if column == "*" else f"{function}_{column}") output_alias = alias or (f"{function}_all" if column == "*" else f"{function}_{column}")
return {"function": function, "column": column, "alias": output_alias} return {"function": function, "column": column, "alias": output_alias}
@@ -1851,7 +2058,13 @@ def _column_name(
) -> str: ) -> str:
if not isinstance(expression, exp.Column): if not isinstance(expression, exp.Column):
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.column", f"{context} accepts column names only.")] [
_sql_error(
"sql.column",
f"{context} accepts column names only.",
expression=expression,
)
]
) )
if not expression.table and qualifier_prefixes is not None: if not expression.table and qualifier_prefixes is not None:
raise SqlCompilationError( raise SqlCompilationError(
@@ -1859,6 +2072,7 @@ def _column_name(
_sql_error( _sql_error(
"sql.join_column_qualification", "sql.join_column_qualification",
f"{context} columns must be source-qualified when a JOIN is present.", f"{context} columns must be source-qualified when a JOIN is present.",
expression=expression,
) )
] ]
) )
@@ -1866,12 +2080,24 @@ def _column_name(
return expression.name return expression.name
if qualifier_prefixes is None: if qualifier_prefixes is None:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.column", f"{context} accepts unqualified column names only.")] [
_sql_error(
"sql.column",
f"{context} accepts unqualified column names only.",
expression=expression,
)
]
) )
prefix = qualifier_prefixes.get(expression.table.casefold()) prefix = qualifier_prefixes.get(expression.table.casefold())
if prefix is None: if prefix is None:
raise SqlCompilationError( raise SqlCompilationError(
[_sql_error("sql.column_source", f"{context} references an unknown source qualifier.")] [
_sql_error(
"sql.column_source",
f"{context} references an unknown source qualifier.",
expression=expression,
)
]
) )
return f"{prefix}{expression.name}" return f"{prefix}{expression.name}"
@@ -1901,8 +2127,93 @@ def _combine_and(expressions: list[exp.Expression]) -> exp.Expression:
return combined return combined
def _sql_error(code: str, message: str) -> DataflowDiagnostic: def _sql_error(
return DataflowDiagnostic(severity="error", code=code, message=message, field="sql_text") code: str,
message: str,
*,
expression: exp.Expression | None = None,
source_location: DiagnosticSourceLocation | None = None,
) -> DataflowDiagnostic:
context = _SQL_DIAGNOSTIC_CONTEXT.get()
location_expression = expression or (context.root_expression if context else None)
return DataflowDiagnostic(
severity="error",
code=code,
message=message,
field="sql_text",
source_location=source_location or _expression_location(location_expression),
)
def _expression_location(
expression: exp.Expression | None,
) -> DiagnosticSourceLocation | None:
context = _SQL_DIAGNOSTIC_CONTEXT.get()
if context is None or expression is None:
return None
offsets = [
(int(node.meta["start"]), int(node.meta["end"]))
for node in expression.walk()
if node.meta.get("start") is not None and node.meta.get("end") is not None
]
if not offsets:
return None
start_offset = min(item[0] for item in offsets)
end_offset = max(item[1] for item in offsets)
start_line, start_column = _line_and_column(context.source_text, start_offset)
end_line, end_column = _line_and_column(context.source_text, end_offset)
return DiagnosticSourceLocation(
start_line=start_line,
start_column=start_column,
end_line=end_line,
end_column=end_column,
start_offset=start_offset,
end_offset=end_offset,
)
def _parse_error_location(detail: dict[str, Any]) -> DiagnosticSourceLocation | None:
context = _SQL_DIAGNOSTIC_CONTEXT.get()
line = detail.get("line")
column = detail.get("col")
if context is None or not isinstance(line, int) or not isinstance(column, int):
return None
highlight = str(detail.get("highlight") or "")
start_column = max(1, column - max(1, len(highlight)) + 1)
start_offset = _offset_for_line_and_column(
context.source_text,
line,
start_column,
)
end_offset = min(
len(context.source_text) - 1,
start_offset + max(0, len(highlight) - 1),
)
return DiagnosticSourceLocation(
start_line=line,
start_column=start_column,
end_line=line,
end_column=max(start_column, column),
start_offset=max(0, start_offset),
end_offset=max(0, end_offset),
)
def _line_and_column(source_text: str, offset: int) -> tuple[int, int]:
bounded_offset = min(max(0, offset), max(0, len(source_text) - 1))
line = source_text.count("\n", 0, bounded_offset) + 1
last_newline = source_text.rfind("\n", 0, bounded_offset)
return line, bounded_offset - last_newline
def _offset_for_line_and_column(source_text: str, line: int, column: int) -> int:
lines = source_text.splitlines(keepends=True)
if not lines:
return 0
bounded_line = min(max(1, line), len(lines))
line_start = sum(len(item) for item in lines[: bounded_line - 1])
line_content = lines[bounded_line - 1].rstrip("\r\n")
return line_start + min(max(0, column - 1), max(0, len(line_content) - 1))
def _node_sql_error(node_id: str, code: str, message: str) -> DataflowDiagnostic: def _node_sql_error(node_id: str, code: str, message: str) -> DataflowDiagnostic:
+42
View File
@@ -138,6 +138,48 @@ class DataflowGraphAndSqlTests(unittest.TestCase):
with self.subTest(sql=sql), self.assertRaises(SqlCompilationError): with self.subTest(sql=sql), self.assertRaises(SqlCompilationError):
compile_sql(sql, source_nodes=[inline_source()]) compile_sql(sql, source_nodes=[inline_source()])
def test_sql_diagnostics_include_stable_source_locations(self) -> None:
cases = (
(
"SELECT *\nFROM monthly_files\nQUALIFY amount > 2",
"sql.unsupported_clause",
(3, 9, 3, 18, 36, 45),
),
(
"SELECT *\nFROM monthly_files\n"
"WHERE status = 'open' OR amount > 1",
"sql.or",
(3, 7, 3, 35, 34, 62),
),
(
"SELECT *\nFROM",
"sql.parse",
(2, 1, 2, 4, 9, 12),
),
)
for sql, code, expected_location in cases:
with self.subTest(code=code), self.assertRaises(SqlCompilationError) as raised:
compile_sql(sql, source_nodes=[inline_source()])
diagnostic = raised.exception.diagnostics[0]
self.assertEqual(code, diagnostic.code)
self.assertEqual("sql_text", diagnostic.field)
self.assertIsNotNone(diagnostic.source_location)
location = diagnostic.source_location
assert location is not None
self.assertEqual(
expected_location,
(
location.start_line,
location.start_column,
location.end_line,
location.end_column,
location.start_offset,
location.end_offset,
),
)
def test_compiles_renders_and_executes_two_source_join(self) -> None: def test_compiles_renders_and_executes_two_source_join(self) -> None:
sql = """ sql = """
SELECT monthly_files.department, lookup.label SELECT monthly_files.department, lookup.label
+8
View File
@@ -39,6 +39,14 @@ export type DataflowDiagnostic = {
message: string; message: string;
node_id?: string | null; node_id?: string | null;
field?: string | null; field?: string | null;
source_location?: {
start_line: number;
start_column: number;
end_line: number;
end_column: number;
start_offset?: number | null;
end_offset?: number | null;
} | null;
}; };
export type PipelineRevision = { export type PipelineRevision = {
@@ -54,6 +54,7 @@ export default function DataflowCanvas({
onGraphChange, onGraphChange,
onSelectNode onSelectNode
}: DataflowCanvasProps) { }: DataflowCanvasProps) {
const canvasRef = useRef<HTMLDivElement | null>(null);
const [instance, setInstance] = useState<ReactFlowInstance<DataflowFlowNode, Edge> | null>(null); const [instance, setInstance] = useState<ReactFlowInstance<DataflowFlowNode, Edge> | null>(null);
const [proximityEdge, setProximityEdge] = useState<Edge | null>(null); const [proximityEdge, setProximityEdge] = useState<Edge | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null); const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
@@ -129,6 +130,24 @@ export default function DataflowCanvas({
}); });
}; };
const restoreFocusAfterRemoval = (removedNodeIds: Set<string>) => {
const focusedNodeId = document.activeElement
?.closest<HTMLElement>(".react-flow__node")
?.dataset.id;
if (!focusedNodeId || !removedNodeIds.has(focusedNodeId)) return;
const removedIndex = graph.nodes.findIndex((node) => node.id === focusedNodeId);
const remainingNodes = graph.nodes.filter((node) => !removedNodeIds.has(node.id));
const nextNode = remainingNodes[Math.min(Math.max(0, removedIndex), remainingNodes.length - 1)];
requestAnimationFrame(() => {
const nextElement = nextNode
? canvasRef.current?.querySelector<HTMLElement>(
`.react-flow__node[data-id="${CSS.escape(nextNode.id)}"]`
)
: null;
(nextElement ?? canvasRef.current)?.focus();
});
};
const updateEdges = (nextEdges: Edge[]) => { const updateEdges = (nextEdges: Edge[]) => {
onGraphChange({ onGraphChange({
...graph, ...graph,
@@ -206,7 +225,10 @@ export default function DataflowCanvas({
return ( return (
<div <div
ref={canvasRef}
className="dataflow-canvas" className="dataflow-canvas"
tabIndex={0}
aria-label="Dataflow graph canvas"
onDragOver={(event) => { onDragOver={(event) => {
if (event.dataTransfer.types.includes("application/x-govoplan-dataflow-node")) { if (event.dataTransfer.types.includes("application/x-govoplan-dataflow-node")) {
event.preventDefault(); event.preventDefault();
@@ -224,6 +246,11 @@ export default function DataflowCanvas({
if (readOnly) return; if (readOnly) return;
const graphChanges = changes.filter((change) => change.type !== "dimensions"); const graphChanges = changes.filter((change) => change.type !== "dimensions");
if (graphChanges.length) { if (graphChanges.length) {
restoreFocusAfterRemoval(new Set(
graphChanges
.filter((change) => change.type === "remove")
.map((change) => change.id)
));
updateNodes(applyNodeChanges(graphChanges, nodes)); updateNodes(applyNodeChanges(graphChanges, nodes));
} }
}} }}
+7 -1
View File
@@ -2221,7 +2221,13 @@ function DiagnosticsPanel({
<strong>{item.code}</strong> <strong>{item.code}</strong>
<small>{item.message}</small> <small>{item.message}</small>
</span> </span>
{item.node_id ? <code>{item.node_id}</code> : null} {item.node_id ? (
<code>{item.node_id}</code>
) : item.source_location ? (
<code>
{item.source_location.start_line}:{item.source_location.start_column}
</code>
) : null}
</div> </div>
))} ))}
{nodeDiagnostics.map((item) => ( {nodeDiagnostics.map((item) => (
+18 -4
View File
@@ -359,6 +359,11 @@
grid-template-columns: minmax(0, 1fr) minmax(260px, 310px); grid-template-columns: minmax(0, 1fr) minmax(260px, 310px);
} }
.dataflow-canvas:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.dataflow-palette, .dataflow-palette,
.dataflow-inspector { .dataflow-inspector {
min-width: 0; min-width: 0;
@@ -1120,7 +1125,7 @@
@media (max-width: 680px) { @media (max-width: 680px) {
.dataflow-page { .dataflow-page {
height: calc(100vh - 94px); height: calc(100dvh - 115px);
} }
.dataflow-shell { .dataflow-shell {
@@ -1133,15 +1138,24 @@
border-bottom: var(--border-line); border-bottom: var(--border-line);
} }
.dataflow-workspace-toolbar {
overflow-x: auto;
align-items: flex-start;
scrollbar-gutter: stable;
}
.dataflow-identity-fields, .dataflow-identity-fields,
.dataflow-command-bar { .dataflow-command-bar {
flex-wrap: wrap; width: max-content;
min-width: max-content;
flex-wrap: nowrap;
} }
.dataflow-name-input, .dataflow-name-input,
.dataflow-description-input { .dataflow-description-input {
max-width: none; width: 220px;
min-width: 180px; min-width: 220px;
max-width: 220px;
} }
.dataflow-editor { .dataflow-editor {