209 lines
7.0 KiB
Python
209 lines
7.0 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
from govoplan_dataflow.backend.executor import PipelineExecutionError, execute_preview
|
|
from govoplan_dataflow.backend.schemas import (
|
|
GraphEdge,
|
|
GraphNode,
|
|
GraphPosition,
|
|
PipelineGraph,
|
|
)
|
|
from govoplan_dataflow.backend.sql_compiler import SqlCompilationError, compile_sql, render_sql
|
|
|
|
|
|
def inline_source() -> GraphNode:
|
|
return GraphNode(
|
|
id="source",
|
|
type="source.inline",
|
|
label="Monthly input",
|
|
position=GraphPosition(x=40, y=160),
|
|
config={
|
|
"source_name": "monthly_files",
|
|
"rows": [
|
|
{"department": "A", "status": "open", "amount": 10},
|
|
{"department": "A", "status": "closed", "amount": 5},
|
|
{"department": "B", "status": "open", "amount": 20},
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
class DataflowGraphAndSqlTests(unittest.TestCase):
|
|
def test_compiles_renders_and_executes_grouped_query(self) -> None:
|
|
sql = """
|
|
SELECT department, COUNT(*) AS records, SUM(amount) AS total
|
|
FROM monthly_files
|
|
WHERE status = 'open'
|
|
GROUP BY department
|
|
ORDER BY total DESC
|
|
LIMIT 50
|
|
"""
|
|
graph, normalized, diagnostics = compile_sql(sql, source_nodes=[inline_source()])
|
|
|
|
self.assertEqual([], diagnostics)
|
|
self.assertEqual(
|
|
["source.inline", "filter", "aggregate", "sort", "limit", "output"],
|
|
[node.type for node in graph.nodes],
|
|
)
|
|
self.assertEqual(normalized, render_sql(graph)[0])
|
|
|
|
result = execute_preview(graph, row_limit=100)
|
|
self.assertEqual(
|
|
[
|
|
{"department": "B", "records": 1, "total": 20},
|
|
{"department": "A", "records": 1, "total": 10},
|
|
],
|
|
result.rows,
|
|
)
|
|
self.assertEqual(2, result.total_rows)
|
|
self.assertFalse(result.truncated)
|
|
self.assertEqual(3, result.input_row_count)
|
|
self.assertEqual(6, len(result.node_diagnostics))
|
|
self.assertEqual(1, len(result.source_fingerprints))
|
|
|
|
def test_rejects_effectful_or_multi_statement_sql(self) -> None:
|
|
for sql in (
|
|
"DELETE FROM monthly_files",
|
|
"SELECT * FROM monthly_files; DROP TABLE monthly_files",
|
|
"INSERT INTO target SELECT * FROM monthly_files",
|
|
):
|
|
with self.subTest(sql=sql), self.assertRaises(SqlCompilationError):
|
|
compile_sql(sql, source_nodes=[inline_source()])
|
|
|
|
def test_rejects_join_until_comparison_slice(self) -> None:
|
|
with self.assertRaisesRegex(SqlCompilationError, "JOIN support"):
|
|
compile_sql(
|
|
"SELECT * FROM monthly_files JOIN other ON monthly_files.id = other.id",
|
|
source_nodes=[inline_source()],
|
|
)
|
|
|
|
def test_graph_validation_rejects_cycle(self) -> None:
|
|
source = inline_source()
|
|
output = GraphNode(
|
|
id="output",
|
|
type="output",
|
|
label="Output",
|
|
position=GraphPosition(x=300, y=160),
|
|
config={},
|
|
)
|
|
graph = PipelineGraph(
|
|
nodes=[source, output],
|
|
edges=[
|
|
GraphEdge(id="forward", source="source", target="output"),
|
|
GraphEdge(id="back", source="output", target="source"),
|
|
],
|
|
)
|
|
|
|
from govoplan_dataflow.backend.graph import validate_graph
|
|
|
|
codes = {item.code for item in validate_graph(graph)}
|
|
self.assertIn("graph.cycle", codes)
|
|
|
|
def test_preview_truncates_response_without_changing_pipeline_limit(self) -> None:
|
|
source = inline_source()
|
|
output = GraphNode(
|
|
id="output",
|
|
type="output",
|
|
label="Output",
|
|
position=GraphPosition(x=300, y=160),
|
|
config={},
|
|
)
|
|
graph = PipelineGraph(
|
|
nodes=[source, output],
|
|
edges=[GraphEdge(id="edge", source="source", target="output")],
|
|
)
|
|
|
|
result = execute_preview(graph, row_limit=2)
|
|
|
|
self.assertEqual(2, len(result.rows))
|
|
self.assertEqual(3, result.total_rows)
|
|
self.assertTrue(result.truncated)
|
|
|
|
def test_empty_aggregate_and_null_sort_are_deterministic(self) -> None:
|
|
graph, _, _ = compile_sql(
|
|
"""
|
|
SELECT status, COUNT(*) AS records
|
|
FROM monthly_files
|
|
GROUP BY status
|
|
ORDER BY status DESC
|
|
""",
|
|
source_nodes=[
|
|
inline_source().model_copy(
|
|
update={
|
|
"config": {
|
|
"source_name": "monthly_files",
|
|
"rows": [
|
|
{"status": None},
|
|
{"status": "open"},
|
|
{"status": "closed"},
|
|
],
|
|
}
|
|
}
|
|
)
|
|
],
|
|
)
|
|
|
|
first = execute_preview(graph, row_limit=100)
|
|
second = execute_preview(graph, row_limit=100)
|
|
|
|
self.assertEqual(first.rows, second.rows)
|
|
self.assertEqual(
|
|
[
|
|
{"status": "open", "records": 1},
|
|
{"status": "closed", "records": 1},
|
|
{"status": None, "records": 1},
|
|
],
|
|
first.rows,
|
|
)
|
|
self.assertEqual(
|
|
[
|
|
diagnostic.model_dump(exclude={"duration_ms"})
|
|
for diagnostic in first.node_diagnostics
|
|
],
|
|
[
|
|
diagnostic.model_dump(exclude={"duration_ms"})
|
|
for diagnostic in second.node_diagnostics
|
|
],
|
|
)
|
|
|
|
def test_empty_global_aggregate_returns_a_count_row(self) -> None:
|
|
graph, _, _ = compile_sql(
|
|
"SELECT COUNT(*) AS records FROM monthly_files",
|
|
source_nodes=[
|
|
inline_source().model_copy(
|
|
update={"config": {"source_name": "monthly_files", "rows": []}}
|
|
)
|
|
],
|
|
)
|
|
|
|
result = execute_preview(graph, row_limit=100)
|
|
|
|
self.assertEqual([{"records": 0}], result.rows)
|
|
self.assertEqual(0, result.input_row_count)
|
|
|
|
def test_invalid_runtime_comparison_is_scoped_to_the_node(self) -> None:
|
|
graph, _, _ = compile_sql(
|
|
"SELECT * FROM monthly_files WHERE amount > 10",
|
|
source_nodes=[
|
|
inline_source().model_copy(
|
|
update={
|
|
"config": {
|
|
"source_name": "monthly_files",
|
|
"rows": [{"amount": "not-a-number"}],
|
|
}
|
|
}
|
|
)
|
|
],
|
|
)
|
|
|
|
with self.assertRaises(PipelineExecutionError) as raised:
|
|
execute_preview(graph, row_limit=100)
|
|
|
|
self.assertEqual("filter-1", raised.exception.node_id)
|
|
self.assertIn("Cannot apply", str(raised.exception))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|