509 lines
13 KiB
Python
509 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from govoplan_dataflow.backend.expressions import (
|
|
ExpressionError,
|
|
infer_expression_type,
|
|
parse_expression,
|
|
)
|
|
from govoplan_dataflow.backend.graph import topological_order
|
|
from govoplan_dataflow.backend.node_library import node_definition
|
|
from govoplan_dataflow.backend.schema_validation import (
|
|
SchemaState,
|
|
propagate_graph_schemas,
|
|
)
|
|
from govoplan_dataflow.backend.schemas import (
|
|
DataflowDiagnostic,
|
|
GraphEdge,
|
|
GraphNode,
|
|
GraphPosition,
|
|
PipelineGraph,
|
|
)
|
|
|
|
|
|
DATAFLOW_IR_VERSION = 1
|
|
OPERATOR_CONTRACT_VERSION = 1
|
|
DataType = Literal[
|
|
"unknown",
|
|
"null",
|
|
"string",
|
|
"integer",
|
|
"number",
|
|
"boolean",
|
|
"date",
|
|
"datetime",
|
|
"binary",
|
|
"json",
|
|
]
|
|
|
|
|
|
class IrModel(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
class IrField(IrModel):
|
|
name: str = Field(min_length=1, max_length=300)
|
|
type: DataType = "unknown"
|
|
nullable: bool = True
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class IrSchema(IrModel):
|
|
fields: tuple[IrField, ...] = ()
|
|
open: bool = False
|
|
semantic_hash: str = Field(default="", max_length=64)
|
|
|
|
|
|
class IrPort(IrModel):
|
|
id: str = Field(min_length=1, max_length=80)
|
|
direction: Literal["input", "output"]
|
|
required: bool = True
|
|
multiple: bool = False
|
|
minimum_connections: int = Field(default=1, ge=0)
|
|
data_schema: IrSchema | None = None
|
|
|
|
|
|
class IrExpression(IrModel):
|
|
source: str = Field(min_length=1, max_length=10_000)
|
|
dialect: Literal["duckdb"] = "duckdb"
|
|
columns: tuple[str, ...] = ()
|
|
result_type: DataType = "unknown"
|
|
semantic_hash: str = Field(min_length=64, max_length=64)
|
|
|
|
|
|
class IrParameter(IrModel):
|
|
id: str = Field(min_length=1, max_length=120)
|
|
type: DataType = "unknown"
|
|
required: bool = False
|
|
default: Any = None
|
|
description: str | None = Field(default=None, max_length=1_000)
|
|
|
|
|
|
class IrNode(IrModel):
|
|
id: str = Field(min_length=1, max_length=100)
|
|
type: str = Field(min_length=1, max_length=100)
|
|
operator_version: int = Field(default=OPERATOR_CONTRACT_VERSION, ge=1)
|
|
label: str = Field(min_length=1, max_length=300)
|
|
position: GraphPosition
|
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
input_ports: tuple[IrPort, ...] = ()
|
|
output_ports: tuple[IrPort, ...] = ()
|
|
expressions: tuple[IrExpression, ...] = ()
|
|
output_schema: IrSchema = Field(default_factory=IrSchema)
|
|
|
|
|
|
class IrEdge(IrModel):
|
|
id: str = Field(min_length=1, max_length=255)
|
|
source: str = Field(min_length=1, max_length=100)
|
|
target: str = Field(min_length=1, max_length=100)
|
|
source_port: str = Field(default="output", min_length=1, max_length=80)
|
|
target_port: str = Field(default="input", min_length=1, max_length=80)
|
|
|
|
|
|
class IrLineage(IrModel):
|
|
generated_from: Literal["graph", "sql", "template"] = "graph"
|
|
source_fingerprints: tuple[dict[str, Any], ...] = ()
|
|
parent_semantic_hashes: tuple[str, ...] = ()
|
|
loss_diagnostics: tuple[DataflowDiagnostic, ...] = ()
|
|
|
|
|
|
class TypedGraphIr(IrModel):
|
|
ir_version: Literal[1] = DATAFLOW_IR_VERSION
|
|
graph_schema_version: int = Field(default=1, ge=1)
|
|
nodes: tuple[IrNode, ...] = Field(default=(), max_length=100)
|
|
edges: tuple[IrEdge, ...] = Field(default=(), max_length=200)
|
|
parameters: tuple[IrParameter, ...] = Field(default=(), max_length=100)
|
|
lineage: IrLineage = Field(default_factory=IrLineage)
|
|
semantic_hash: str = Field(min_length=64, max_length=64)
|
|
|
|
|
|
class IrExecutionResult(IrModel):
|
|
ir_version: Literal[1] = DATAFLOW_IR_VERSION
|
|
backend: str = Field(min_length=1, max_length=100)
|
|
backend_version: str = Field(min_length=1, max_length=100)
|
|
graph_semantic_hash: str = Field(min_length=64, max_length=64)
|
|
result_schema: IrSchema
|
|
row_count: int = Field(ge=0)
|
|
byte_count: int = Field(ge=0)
|
|
truncated: bool = False
|
|
diagnostics: tuple[DataflowDiagnostic, ...] = ()
|
|
lineage: IrLineage = Field(default_factory=IrLineage)
|
|
|
|
|
|
def graph_to_ir(
|
|
graph: PipelineGraph,
|
|
*,
|
|
parameters: tuple[IrParameter, ...] = (),
|
|
lineage: IrLineage | None = None,
|
|
) -> TypedGraphIr:
|
|
ordered, cyclic = topological_order(graph)
|
|
schemas, schema_diagnostics = (
|
|
({}, [])
|
|
if cyclic
|
|
else propagate_graph_schemas(graph, ordered=ordered)
|
|
)
|
|
resolved_lineage = _lineage_with_diagnostics(
|
|
lineage or IrLineage(),
|
|
schema_diagnostics,
|
|
)
|
|
nodes = tuple(
|
|
_node_ir(node, schemas.get(node.id, _unknown_schema()))
|
|
for node in graph.nodes
|
|
)
|
|
edges = tuple(_edge_ir(edge) for edge in graph.edges)
|
|
semantic_hash = _graph_semantic_hash(
|
|
graph_schema_version=graph.schema_version,
|
|
nodes=nodes,
|
|
edges=edges,
|
|
parameters=parameters,
|
|
)
|
|
return TypedGraphIr(
|
|
graph_schema_version=graph.schema_version,
|
|
nodes=nodes,
|
|
edges=edges,
|
|
parameters=parameters,
|
|
lineage=resolved_lineage,
|
|
semantic_hash=semantic_hash,
|
|
)
|
|
|
|
|
|
def ir_to_graph(ir: TypedGraphIr) -> PipelineGraph:
|
|
return PipelineGraph(
|
|
schema_version=ir.graph_schema_version,
|
|
nodes=[
|
|
GraphNode(
|
|
id=node.id,
|
|
type=node.type,
|
|
label=node.label,
|
|
position=node.position.model_copy(deep=True),
|
|
config=dict(node.config),
|
|
)
|
|
for node in ir.nodes
|
|
],
|
|
edges=[
|
|
GraphEdge(
|
|
id=edge.id,
|
|
source=edge.source,
|
|
target=edge.target,
|
|
source_port=edge.source_port,
|
|
target_port=edge.target_port,
|
|
)
|
|
for edge in ir.edges
|
|
],
|
|
)
|
|
|
|
|
|
def schema_from_rows(rows: list[dict[str, Any]]) -> IrSchema:
|
|
names: list[str] = []
|
|
seen: set[str] = set()
|
|
for row in rows:
|
|
for name in row:
|
|
normalized = str(name)
|
|
if normalized not in seen:
|
|
names.append(normalized)
|
|
seen.add(normalized)
|
|
columns = {
|
|
name: [row.get(name) for row in rows]
|
|
for name in names
|
|
}
|
|
fields = tuple(
|
|
IrField(
|
|
name=name,
|
|
type=_values_type(values),
|
|
nullable=any(value is None for value in values),
|
|
)
|
|
for name, values in columns.items()
|
|
)
|
|
return _schema(fields)
|
|
|
|
|
|
def schema_from_fields(
|
|
fields: tuple[IrField, ...],
|
|
*,
|
|
open: bool = False,
|
|
) -> IrSchema:
|
|
return _schema(fields, open=open)
|
|
|
|
|
|
def _node_ir(node: GraphNode, output_state: SchemaState) -> IrNode:
|
|
definition = node_definition(node.type)
|
|
input_ports = (
|
|
tuple(
|
|
IrPort(
|
|
id=port.id,
|
|
direction="input",
|
|
required=port.required,
|
|
multiple=port.multiple,
|
|
minimum_connections=port.minimum_connections,
|
|
)
|
|
for port in definition.input_ports
|
|
)
|
|
if definition
|
|
else ()
|
|
)
|
|
output_schema = _state_schema(output_state)
|
|
output_ports = (
|
|
tuple(
|
|
IrPort(
|
|
id=port.id,
|
|
direction="output",
|
|
required=port.required,
|
|
multiple=port.multiple,
|
|
minimum_connections=port.minimum_connections,
|
|
data_schema=output_schema,
|
|
)
|
|
for port in definition.output_ports
|
|
)
|
|
if definition
|
|
else ()
|
|
)
|
|
return IrNode(
|
|
id=node.id,
|
|
type=node.type,
|
|
label=node.label,
|
|
position=node.position.model_copy(deep=True),
|
|
config=dict(node.config),
|
|
input_ports=input_ports,
|
|
output_ports=output_ports,
|
|
expressions=_node_expressions(node, output_state),
|
|
output_schema=output_schema,
|
|
)
|
|
|
|
|
|
def _node_expressions(
|
|
node: GraphNode,
|
|
output_state: SchemaState,
|
|
) -> tuple[IrExpression, ...]:
|
|
if node.type == "calculate":
|
|
sources = [
|
|
str(item.get("expression") or "")
|
|
for item in node.config.get("calculations", [])
|
|
if isinstance(item, dict) and item.get("expression")
|
|
]
|
|
return tuple(
|
|
item
|
|
for source in sources
|
|
if (item := _ir_expression(source, output_state)) is not None
|
|
)
|
|
source = node.config.get("expression")
|
|
if node.type not in {"expression", "filter.expression"} or not source:
|
|
return ()
|
|
expression = _ir_expression(str(source), output_state)
|
|
return (expression,) if expression is not None else ()
|
|
|
|
|
|
def _ir_expression(
|
|
source: str,
|
|
output_state: SchemaState,
|
|
) -> IrExpression | None:
|
|
try:
|
|
parsed = parse_expression(str(source))
|
|
except ExpressionError:
|
|
return None
|
|
inferred = infer_expression_type(parsed, output_state.types)
|
|
return IrExpression(
|
|
source=parsed.source,
|
|
columns=parsed.columns,
|
|
result_type=_data_type(inferred),
|
|
semantic_hash=_hash_payload(
|
|
{
|
|
"dialect": "duckdb",
|
|
"source": parsed.sql(),
|
|
"result_type": inferred,
|
|
}
|
|
),
|
|
)
|
|
|
|
|
|
def _edge_ir(edge: GraphEdge) -> IrEdge:
|
|
return IrEdge(
|
|
id=edge.id,
|
|
source=edge.source,
|
|
target=edge.target,
|
|
source_port=edge.source_port,
|
|
target_port=edge.target_port,
|
|
)
|
|
|
|
|
|
def _state_schema(state: SchemaState) -> IrSchema:
|
|
return _schema(
|
|
tuple(
|
|
IrField(
|
|
name=name,
|
|
type=_data_type(state.type_of(name)),
|
|
nullable=True,
|
|
)
|
|
for name in sorted(state.columns)
|
|
),
|
|
open=state.open,
|
|
)
|
|
|
|
|
|
def _schema(
|
|
fields: tuple[IrField, ...],
|
|
*,
|
|
open: bool = False,
|
|
) -> IrSchema:
|
|
semantic_hash = _hash_payload(
|
|
{
|
|
"fields": [
|
|
{
|
|
"name": item.name,
|
|
"type": item.type,
|
|
"nullable": item.nullable,
|
|
}
|
|
for item in fields
|
|
],
|
|
"open": open,
|
|
}
|
|
)
|
|
return IrSchema(
|
|
fields=fields,
|
|
open=open,
|
|
semantic_hash=semantic_hash,
|
|
)
|
|
|
|
|
|
def _graph_semantic_hash(
|
|
*,
|
|
graph_schema_version: int,
|
|
nodes: tuple[IrNode, ...],
|
|
edges: tuple[IrEdge, ...],
|
|
parameters: tuple[IrParameter, ...],
|
|
) -> str:
|
|
return _hash_payload(
|
|
{
|
|
"ir_version": DATAFLOW_IR_VERSION,
|
|
"graph_schema_version": graph_schema_version,
|
|
"nodes": [
|
|
{
|
|
"id": node.id,
|
|
"type": node.type,
|
|
"operator_version": node.operator_version,
|
|
"config": node.config,
|
|
"expressions": [
|
|
expression.model_dump(mode="json")
|
|
for expression in node.expressions
|
|
],
|
|
}
|
|
for node in nodes
|
|
],
|
|
"edges": [
|
|
edge.model_dump(mode="json")
|
|
for edge in edges
|
|
],
|
|
"parameters": [
|
|
parameter.model_dump(mode="json")
|
|
for parameter in parameters
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
def _lineage_with_diagnostics(
|
|
lineage: IrLineage,
|
|
diagnostics: list[DataflowDiagnostic],
|
|
) -> IrLineage:
|
|
if not diagnostics:
|
|
return lineage
|
|
return lineage.model_copy(
|
|
update={
|
|
"loss_diagnostics": (
|
|
*lineage.loss_diagnostics,
|
|
*diagnostics,
|
|
)
|
|
}
|
|
)
|
|
|
|
|
|
def _values_type(values: list[Any]) -> DataType:
|
|
concrete = {
|
|
_value_type(value)
|
|
for value in values
|
|
if value is not None
|
|
}
|
|
if not concrete:
|
|
return "null"
|
|
if concrete <= {"integer", "number"}:
|
|
return "number" if "number" in concrete else "integer"
|
|
return concrete.pop() if len(concrete) == 1 else "unknown"
|
|
|
|
|
|
def _value_type(value: Any) -> DataType:
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
|
|
if isinstance(value, bool):
|
|
return "boolean"
|
|
if isinstance(value, int):
|
|
return "integer"
|
|
if isinstance(value, (float, Decimal)):
|
|
return "number"
|
|
if isinstance(value, datetime):
|
|
return "datetime"
|
|
if isinstance(value, date):
|
|
return "date"
|
|
if isinstance(value, bytes):
|
|
return "binary"
|
|
if isinstance(value, str):
|
|
return "string"
|
|
if isinstance(value, (dict, list, tuple)):
|
|
return "json"
|
|
return "unknown"
|
|
|
|
|
|
def _data_type(value: str) -> DataType:
|
|
known = {
|
|
"unknown",
|
|
"null",
|
|
"string",
|
|
"integer",
|
|
"number",
|
|
"boolean",
|
|
"date",
|
|
"datetime",
|
|
"binary",
|
|
"json",
|
|
}
|
|
return value if value in known else "unknown" # type: ignore[return-value]
|
|
|
|
|
|
def _unknown_schema() -> SchemaState:
|
|
return SchemaState(frozenset(), open=True)
|
|
|
|
|
|
def _hash_payload(payload: object) -> str:
|
|
encoded = json.dumps(
|
|
payload,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=True,
|
|
default=str,
|
|
)
|
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
|
|
|
|
__all__ = [
|
|
"DATAFLOW_IR_VERSION",
|
|
"DataType",
|
|
"IrEdge",
|
|
"IrExecutionResult",
|
|
"IrExpression",
|
|
"IrField",
|
|
"IrLineage",
|
|
"IrNode",
|
|
"IrParameter",
|
|
"IrPort",
|
|
"IrSchema",
|
|
"TypedGraphIr",
|
|
"graph_to_ir",
|
|
"ir_to_graph",
|
|
"schema_from_fields",
|
|
"schema_from_rows",
|
|
]
|