pipelantic-sql 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pipelantic_sql/__init__.py +9 -0
- pipelantic_sql/catalog.py +54 -0
- pipelantic_sql/compiler.py +190 -0
- pipelantic_sql/dialect_postgresql.py +26 -0
- pipelantic_sql/executor.py +344 -0
- pipelantic_sql/plugin.py +247 -0
- pipelantic_sql/writes.py +19 -0
- pipelantic_sql-0.6.0.dist-info/METADATA +56 -0
- pipelantic_sql-0.6.0.dist-info/RECORD +11 -0
- pipelantic_sql-0.6.0.dist-info/WHEEL +4 -0
- pipelantic_sql-0.6.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""pipelantic-sql — PostgreSQL reference SQL execution plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.6.0"
|
|
6
|
+
|
|
7
|
+
from pipelantic_sql.plugin import PostgresSqlPlugin, create_plugin
|
|
8
|
+
|
|
9
|
+
__all__ = ["PostgresSqlPlugin", "__version__", "create_plugin"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Catalog / information_schema inspection (metadata only, no row reads)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import text
|
|
8
|
+
from sqlalchemy.engine import Engine
|
|
9
|
+
|
|
10
|
+
from pipelantic.sql.helpers import require_safe_identifier
|
|
11
|
+
from pipelantic.sql.protocol import RelationRef
|
|
12
|
+
from pipelantic_sql.dialect_postgresql import quote_identifier
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def inspect_relation(
|
|
16
|
+
engine: Engine,
|
|
17
|
+
relation: RelationRef,
|
|
18
|
+
*,
|
|
19
|
+
dialect: str,
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
schema = relation.namespace
|
|
22
|
+
table = relation.name
|
|
23
|
+
require_safe_identifier(table)
|
|
24
|
+
if dialect == "postgresql":
|
|
25
|
+
sql = text(
|
|
26
|
+
"""
|
|
27
|
+
SELECT column_name, data_type, is_nullable
|
|
28
|
+
FROM information_schema.columns
|
|
29
|
+
WHERE table_name = :table
|
|
30
|
+
AND (:schema IS NULL OR table_schema = :schema)
|
|
31
|
+
ORDER BY ordinal_position
|
|
32
|
+
"""
|
|
33
|
+
)
|
|
34
|
+
with engine.connect() as conn:
|
|
35
|
+
rows = conn.execute(sql, {"table": table, "schema": schema}).mappings()
|
|
36
|
+
columns = {
|
|
37
|
+
r["column_name"]: {
|
|
38
|
+
"type": r["data_type"],
|
|
39
|
+
"nullable": r["is_nullable"] == "YES",
|
|
40
|
+
}
|
|
41
|
+
for r in rows
|
|
42
|
+
}
|
|
43
|
+
else:
|
|
44
|
+
with engine.connect() as conn:
|
|
45
|
+
rows = conn.execute(
|
|
46
|
+
text(f"PRAGMA table_info({quote_identifier(table, dialect=dialect)})")
|
|
47
|
+
)
|
|
48
|
+
columns = {r[1]: {"type": r[2], "nullable": not bool(r[3])} for r in rows}
|
|
49
|
+
return {
|
|
50
|
+
"identity": relation.qualified_name,
|
|
51
|
+
"columns": columns,
|
|
52
|
+
"source": "catalog",
|
|
53
|
+
"dialect": dialect,
|
|
54
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Compile portable SQL IR into parameterized dialect SQL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from pipelantic.sql.helpers import require_safe_identifier
|
|
9
|
+
from pipelantic.sql.protocol import (
|
|
10
|
+
AliasedExpr,
|
|
11
|
+
AtomicPublicationStrategy,
|
|
12
|
+
ColumnRef,
|
|
13
|
+
CompiledSql,
|
|
14
|
+
ConcatExpr,
|
|
15
|
+
LiteralExpr,
|
|
16
|
+
RelationRef,
|
|
17
|
+
SqlExecutionContext,
|
|
18
|
+
SqlQuery,
|
|
19
|
+
SqlWrite,
|
|
20
|
+
WriteIntentKind,
|
|
21
|
+
)
|
|
22
|
+
from pipelantic_sql.dialect_postgresql import quote_identifier
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SqlCompiler:
|
|
26
|
+
"""IR → parameterized SQL text (values never interpolated)."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, *, dialect: str, supports_merge: bool) -> None:
|
|
29
|
+
self.dialect = dialect
|
|
30
|
+
self.supports_merge = supports_merge
|
|
31
|
+
self._param_counter = 0
|
|
32
|
+
|
|
33
|
+
def quote(self, name: str) -> str:
|
|
34
|
+
return quote_identifier(name, dialect=self.dialect)
|
|
35
|
+
|
|
36
|
+
def qid(self, relation: RelationRef) -> str:
|
|
37
|
+
parts = []
|
|
38
|
+
for part in (relation.catalog, relation.namespace, relation.name):
|
|
39
|
+
if part:
|
|
40
|
+
parts.append(self.quote(require_safe_identifier(part)))
|
|
41
|
+
return ".".join(parts)
|
|
42
|
+
|
|
43
|
+
def next_param(self, params: dict[str, Any], value: Any) -> str:
|
|
44
|
+
self._param_counter += 1
|
|
45
|
+
name = f"p{self._param_counter}"
|
|
46
|
+
params[name] = value
|
|
47
|
+
return f":{name}"
|
|
48
|
+
|
|
49
|
+
def compile_expr(
|
|
50
|
+
self, expr: Any, *, params: dict[str, Any], relation_sql: str
|
|
51
|
+
) -> str:
|
|
52
|
+
if isinstance(expr, ColumnRef):
|
|
53
|
+
col = self.quote(require_safe_identifier(expr.column))
|
|
54
|
+
if expr.relation:
|
|
55
|
+
return f"{self.quote(expr.relation)}.{col}"
|
|
56
|
+
return col
|
|
57
|
+
if isinstance(expr, str):
|
|
58
|
+
return self.quote(require_safe_identifier(expr))
|
|
59
|
+
if isinstance(expr, LiteralExpr):
|
|
60
|
+
return self.next_param(params, expr.value)
|
|
61
|
+
if isinstance(expr, ConcatExpr):
|
|
62
|
+
rendered = [
|
|
63
|
+
self.compile_expr(p, params=params, relation_sql=relation_sql)
|
|
64
|
+
for p in expr.parts
|
|
65
|
+
]
|
|
66
|
+
pieces: list[str] = []
|
|
67
|
+
for i, part in enumerate(rendered):
|
|
68
|
+
if i:
|
|
69
|
+
pieces.append(self.next_param(params, expr.separator))
|
|
70
|
+
pieces.append(part)
|
|
71
|
+
body = " || ".join(pieces)
|
|
72
|
+
if expr.alias:
|
|
73
|
+
return f"{body} AS {self.quote(expr.alias)}"
|
|
74
|
+
return body
|
|
75
|
+
if isinstance(expr, AliasedExpr):
|
|
76
|
+
inner = self.compile_expr(
|
|
77
|
+
expr.expr, params=params, relation_sql=relation_sql
|
|
78
|
+
)
|
|
79
|
+
return f"{inner} AS {self.quote(expr.alias)}"
|
|
80
|
+
raise ValueError(f"Unsupported SQL expression: {type(expr)!r}")
|
|
81
|
+
|
|
82
|
+
def compile_query(
|
|
83
|
+
self,
|
|
84
|
+
query: SqlQuery,
|
|
85
|
+
*,
|
|
86
|
+
context: SqlExecutionContext,
|
|
87
|
+
) -> CompiledSql:
|
|
88
|
+
params: dict[str, Any] = {}
|
|
89
|
+
source_sql = self.qid(query.source)
|
|
90
|
+
if query.columns:
|
|
91
|
+
cols = ", ".join(
|
|
92
|
+
self.compile_expr(c, params=params, relation_sql=source_sql)
|
|
93
|
+
for c in query.columns
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
cols = "*"
|
|
97
|
+
distinct = "DISTINCT " if query.distinct else ""
|
|
98
|
+
sql = f"SELECT {distinct}{cols} FROM {source_sql}"
|
|
99
|
+
if query.where is not None:
|
|
100
|
+
sql += " WHERE " + self.compile_expr(
|
|
101
|
+
query.where, params=params, relation_sql=source_sql
|
|
102
|
+
)
|
|
103
|
+
if query.limit is not None:
|
|
104
|
+
sql += f" LIMIT {int(query.limit)}"
|
|
105
|
+
return CompiledSql(
|
|
106
|
+
statement_id=f"stmt:{context.step_name}:{uuid4().hex[:8]}",
|
|
107
|
+
text=sql,
|
|
108
|
+
param_names=tuple(params.keys()),
|
|
109
|
+
redacted_params={k: "<redacted>" for k in params},
|
|
110
|
+
dialect=self.dialect,
|
|
111
|
+
logical_nodes=(context.step_name,),
|
|
112
|
+
metadata={"_bound_params": params},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def compile_write(
|
|
116
|
+
self,
|
|
117
|
+
write: SqlWrite,
|
|
118
|
+
*,
|
|
119
|
+
context: SqlExecutionContext,
|
|
120
|
+
) -> CompiledSql:
|
|
121
|
+
target = self.qid(write.target)
|
|
122
|
+
if write.intent in {
|
|
123
|
+
WriteIntentKind.APPEND,
|
|
124
|
+
WriteIntentKind.INSERT_SELECT,
|
|
125
|
+
}:
|
|
126
|
+
if isinstance(write.source, SqlQuery):
|
|
127
|
+
compiled = self.compile_query(write.source, context=context)
|
|
128
|
+
sql = f"INSERT INTO {target} {compiled.text}"
|
|
129
|
+
bound = dict(compiled.metadata.get("_bound_params") or {})
|
|
130
|
+
return CompiledSql(
|
|
131
|
+
statement_id=f"write:{context.step_name}:{uuid4().hex[:8]}",
|
|
132
|
+
text=sql,
|
|
133
|
+
param_names=tuple(bound.keys()),
|
|
134
|
+
redacted_params={k: "<redacted>" for k in bound},
|
|
135
|
+
dialect=self.dialect,
|
|
136
|
+
logical_nodes=(context.step_name,),
|
|
137
|
+
metadata={"_bound_params": bound, "intent": write.intent.value},
|
|
138
|
+
)
|
|
139
|
+
if isinstance(write.source, RelationRef):
|
|
140
|
+
sql = f"INSERT INTO {target} SELECT * FROM {self.qid(write.source)}"
|
|
141
|
+
return CompiledSql(
|
|
142
|
+
statement_id=f"write:{context.step_name}:{uuid4().hex[:8]}",
|
|
143
|
+
text=sql,
|
|
144
|
+
dialect=self.dialect,
|
|
145
|
+
logical_nodes=(context.step_name,),
|
|
146
|
+
metadata={"intent": write.intent.value},
|
|
147
|
+
)
|
|
148
|
+
if write.intent in {WriteIntentKind.REPLACE, WriteIntentKind.SNAPSHOT}:
|
|
149
|
+
if write.atomic is AtomicPublicationStrategy.UNSUPPORTED:
|
|
150
|
+
raise ValueError("Atomic publication unsupported for replace")
|
|
151
|
+
# Only create staging here. Executor publishes via rename-swap so the
|
|
152
|
+
# live target is never dropped before the replacement exists.
|
|
153
|
+
staging = RelationRef(
|
|
154
|
+
name=f"{write.target.name}__staging_{uuid4().hex[:8]}",
|
|
155
|
+
namespace=write.target.namespace,
|
|
156
|
+
catalog=write.target.catalog,
|
|
157
|
+
)
|
|
158
|
+
if isinstance(write.source, SqlQuery):
|
|
159
|
+
compiled = self.compile_query(write.source, context=context)
|
|
160
|
+
create = f"CREATE TABLE {self.qid(staging)} AS {compiled.text}"
|
|
161
|
+
bound = dict(compiled.metadata.get("_bound_params") or {})
|
|
162
|
+
elif isinstance(write.source, RelationRef):
|
|
163
|
+
create = (
|
|
164
|
+
f"CREATE TABLE {self.qid(staging)} AS "
|
|
165
|
+
f"SELECT * FROM {self.qid(write.source)}"
|
|
166
|
+
)
|
|
167
|
+
bound = {}
|
|
168
|
+
else:
|
|
169
|
+
raise ValueError("Replace requires a source query or relation")
|
|
170
|
+
return CompiledSql(
|
|
171
|
+
statement_id=f"replace:{context.step_name}:{uuid4().hex[:8]}",
|
|
172
|
+
text=create,
|
|
173
|
+
param_names=tuple(bound.keys()),
|
|
174
|
+
redacted_params={k: "<redacted>" for k in bound},
|
|
175
|
+
dialect=self.dialect,
|
|
176
|
+
logical_nodes=(context.step_name,),
|
|
177
|
+
metadata={
|
|
178
|
+
"_bound_params": bound,
|
|
179
|
+
"intent": write.intent.value,
|
|
180
|
+
"staging": staging.to_dict(),
|
|
181
|
+
"target": write.target.to_dict(),
|
|
182
|
+
"needs_publish_swap": True,
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
if write.intent is WriteIntentKind.MERGE:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"MERGE is not implemented by the 0.6 reference plugin; "
|
|
188
|
+
"fail closed before mutation"
|
|
189
|
+
)
|
|
190
|
+
raise ValueError(f"Unsupported write intent: {write.intent}")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Dialect helpers for identifier quoting and dialect detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from pipelantic.sql.helpers import require_safe_identifier
|
|
8
|
+
|
|
9
|
+
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def detect_dialect(url: str) -> str:
|
|
13
|
+
if url.startswith("postgresql"):
|
|
14
|
+
return "postgresql"
|
|
15
|
+
return "sqlite"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def quote_identifier(name: str, *, dialect: str = "postgresql") -> str:
|
|
19
|
+
"""Quote a validated SQL identifier (double-quotes for PG and SQLite)."""
|
|
20
|
+
require_safe_identifier(name)
|
|
21
|
+
_ = dialect # both reference dialects use ANSI double-quotes
|
|
22
|
+
return f'"{name}"'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_safe_ident(name: str) -> bool:
|
|
26
|
+
return bool(_IDENT.fullmatch(name))
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Connection / transaction execution for compiled SQL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, MutableMapping, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import text
|
|
9
|
+
from sqlalchemy.engine import Engine
|
|
10
|
+
from sqlalchemy.exc import DBAPIError, InterfaceError, OperationalError
|
|
11
|
+
|
|
12
|
+
from pipelantic.sql.helpers import require_safe_identifier
|
|
13
|
+
from pipelantic.sql.protocol import (
|
|
14
|
+
CompiledSql,
|
|
15
|
+
RelationRef,
|
|
16
|
+
SqlExecutionContext,
|
|
17
|
+
SqlExecutionResult,
|
|
18
|
+
SqlMetrics,
|
|
19
|
+
SqlQuery,
|
|
20
|
+
TransactionOutcome,
|
|
21
|
+
)
|
|
22
|
+
from pipelantic_sql.compiler import SqlCompiler
|
|
23
|
+
from pipelantic_sql.dialect_postgresql import quote_identifier
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _classify_failure(exc: BaseException, *, started: bool) -> TransactionOutcome:
|
|
27
|
+
"""Classify transaction failure; ambiguous post-start failures → UNKNOWN."""
|
|
28
|
+
if isinstance(exc, (InterfaceError, OperationalError)):
|
|
29
|
+
return TransactionOutcome.UNKNOWN if started else TransactionOutcome.ROLLED_BACK
|
|
30
|
+
if isinstance(exc, DBAPIError) and getattr(exc, "connection_invalidated", False):
|
|
31
|
+
return TransactionOutcome.UNKNOWN
|
|
32
|
+
msg = str(exc).lower()
|
|
33
|
+
if started and (
|
|
34
|
+
"commit" in msg
|
|
35
|
+
or "connection" in msg
|
|
36
|
+
or "server closed" in msg
|
|
37
|
+
or "broken pipe" in msg
|
|
38
|
+
):
|
|
39
|
+
return TransactionOutcome.UNKNOWN
|
|
40
|
+
return TransactionOutcome.ROLLED_BACK
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SqlExecutor:
|
|
44
|
+
"""Run compiled statements inside a transaction."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
engine: Engine,
|
|
50
|
+
dialect: str,
|
|
51
|
+
rows_fetched_counter: list[int],
|
|
52
|
+
bound_params: MutableMapping[str, dict[str, Any]],
|
|
53
|
+
staging_tables: list[str],
|
|
54
|
+
) -> None:
|
|
55
|
+
self.engine = engine
|
|
56
|
+
self.dialect = dialect
|
|
57
|
+
self._rows_fetched = rows_fetched_counter
|
|
58
|
+
self._bound_params = bound_params
|
|
59
|
+
self._staging_tables = staging_tables
|
|
60
|
+
|
|
61
|
+
def _resolve_bound(
|
|
62
|
+
self, stmt: CompiledSql, params: Mapping[str, Any]
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
bound = dict(self._bound_params.pop(stmt.statement_id, {}))
|
|
65
|
+
bound.update(params)
|
|
66
|
+
return bound
|
|
67
|
+
|
|
68
|
+
def execute(
|
|
69
|
+
self,
|
|
70
|
+
compiled: Sequence[CompiledSql],
|
|
71
|
+
*,
|
|
72
|
+
params: Mapping[str, Any],
|
|
73
|
+
context: SqlExecutionContext,
|
|
74
|
+
fetch: bool = False,
|
|
75
|
+
) -> SqlExecutionResult:
|
|
76
|
+
_ = context
|
|
77
|
+
metrics = SqlMetrics(statements=0, phases=["execute"])
|
|
78
|
+
results: list[CompiledSql] = []
|
|
79
|
+
records: list[Any] | None = None
|
|
80
|
+
outcome = TransactionOutcome.NOT_STARTED
|
|
81
|
+
started = False
|
|
82
|
+
try:
|
|
83
|
+
with self.engine.begin() as conn:
|
|
84
|
+
started = True
|
|
85
|
+
for stmt in compiled:
|
|
86
|
+
bound = self._resolve_bound(stmt, params)
|
|
87
|
+
public = CompiledSql(
|
|
88
|
+
statement_id=stmt.statement_id,
|
|
89
|
+
text=stmt.text,
|
|
90
|
+
param_names=stmt.param_names,
|
|
91
|
+
redacted_params=stmt.redacted_params,
|
|
92
|
+
dialect=stmt.dialect,
|
|
93
|
+
logical_nodes=stmt.logical_nodes,
|
|
94
|
+
metadata={
|
|
95
|
+
k: v
|
|
96
|
+
for k, v in stmt.metadata.items()
|
|
97
|
+
if not str(k).startswith("_")
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
results.append(public)
|
|
101
|
+
for part in stmt.text.split(";;"):
|
|
102
|
+
part = part.strip()
|
|
103
|
+
if not part:
|
|
104
|
+
continue
|
|
105
|
+
result = conn.execute(text(part), bound)
|
|
106
|
+
metrics.statements += 1
|
|
107
|
+
if fetch:
|
|
108
|
+
rows = [dict(row._mapping) for row in result]
|
|
109
|
+
self._rows_fetched[0] += len(rows)
|
|
110
|
+
metrics.rows_fetched += len(rows)
|
|
111
|
+
records = (records or []) + rows
|
|
112
|
+
elif result.rowcount is not None and result.rowcount >= 0:
|
|
113
|
+
metrics.rows_affected = (metrics.rows_affected or 0) + int(
|
|
114
|
+
result.rowcount
|
|
115
|
+
)
|
|
116
|
+
outcome = TransactionOutcome.COMMITTED
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
outcome = _classify_failure(exc, started=started)
|
|
119
|
+
return SqlExecutionResult(
|
|
120
|
+
outcome=outcome,
|
|
121
|
+
metrics=metrics,
|
|
122
|
+
compiled=results,
|
|
123
|
+
diagnostics=[
|
|
124
|
+
{
|
|
125
|
+
"code": "PMSQL500",
|
|
126
|
+
"severity": "error",
|
|
127
|
+
"message": str(exc),
|
|
128
|
+
}
|
|
129
|
+
],
|
|
130
|
+
)
|
|
131
|
+
return SqlExecutionResult(
|
|
132
|
+
outcome=outcome,
|
|
133
|
+
metrics=metrics,
|
|
134
|
+
compiled=results,
|
|
135
|
+
records=records,
|
|
136
|
+
backend_ref=f"sqlalchemy:{self.dialect}",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def materialize_temp(
|
|
140
|
+
self,
|
|
141
|
+
compiler: SqlCompiler,
|
|
142
|
+
query: SqlQuery,
|
|
143
|
+
*,
|
|
144
|
+
temp_name: str,
|
|
145
|
+
params: Mapping[str, Any],
|
|
146
|
+
context: SqlExecutionContext,
|
|
147
|
+
seal: Any,
|
|
148
|
+
) -> SqlExecutionResult:
|
|
149
|
+
"""Materialize as a durable run-scoped table (visible across pool checkouts)."""
|
|
150
|
+
require_safe_identifier(temp_name)
|
|
151
|
+
compiled = seal(compiler.compile_query(query, context=context))
|
|
152
|
+
bound = dict(self._bound_params.get(compiled.statement_id) or {})
|
|
153
|
+
bound.update(params)
|
|
154
|
+
# Re-register after peek so execute can consume.
|
|
155
|
+
if bound:
|
|
156
|
+
self._bound_params[compiled.statement_id] = bound
|
|
157
|
+
qid = quote_identifier(temp_name, dialect=self.dialect)
|
|
158
|
+
sql = f"DROP TABLE IF EXISTS {qid};;CREATE TABLE {qid} AS {compiled.text}"
|
|
159
|
+
stmt = seal(
|
|
160
|
+
CompiledSql(
|
|
161
|
+
statement_id=f"temp:{temp_name}:{compiled.statement_id}",
|
|
162
|
+
text=sql,
|
|
163
|
+
param_names=tuple(bound.keys()),
|
|
164
|
+
redacted_params={k: "<redacted>" for k in bound},
|
|
165
|
+
dialect=self.dialect,
|
|
166
|
+
logical_nodes=(context.step_name,),
|
|
167
|
+
metadata={"_bound_params": bound},
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
result = self.execute([stmt], params={}, context=context, fetch=False)
|
|
171
|
+
if result.outcome is TransactionOutcome.COMMITTED:
|
|
172
|
+
result.relation = RelationRef(name=temp_name)
|
|
173
|
+
if temp_name not in self._staging_tables:
|
|
174
|
+
self._staging_tables.append(temp_name)
|
|
175
|
+
else:
|
|
176
|
+
result.relation = None
|
|
177
|
+
result.metrics.fused_steps = 1
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
def publish_replace(
|
|
181
|
+
self,
|
|
182
|
+
*,
|
|
183
|
+
target: RelationRef,
|
|
184
|
+
staging: RelationRef,
|
|
185
|
+
compiler: SqlCompiler,
|
|
186
|
+
context: SqlExecutionContext,
|
|
187
|
+
) -> SqlExecutionResult:
|
|
188
|
+
"""Swap staging into target without dropping the live table first."""
|
|
189
|
+
_ = context
|
|
190
|
+
target_sql = compiler.qid(target)
|
|
191
|
+
staging_sql = compiler.qid(staging)
|
|
192
|
+
old_name = f"{target.name}__old_{staging.name[-8:]}"
|
|
193
|
+
require_safe_identifier(old_name)
|
|
194
|
+
old = RelationRef(
|
|
195
|
+
name=old_name, namespace=target.namespace, catalog=target.catalog
|
|
196
|
+
)
|
|
197
|
+
old_sql = compiler.qid(old)
|
|
198
|
+
started = False
|
|
199
|
+
try:
|
|
200
|
+
with self.engine.begin() as conn:
|
|
201
|
+
started = True
|
|
202
|
+
# Detect existing target.
|
|
203
|
+
exists = False
|
|
204
|
+
if self.dialect == "postgresql":
|
|
205
|
+
row = conn.execute(
|
|
206
|
+
text(
|
|
207
|
+
"SELECT 1 FROM information_schema.tables "
|
|
208
|
+
"WHERE table_name = :name "
|
|
209
|
+
"AND (:schema IS NULL OR table_schema = :schema)"
|
|
210
|
+
),
|
|
211
|
+
{"name": target.name, "schema": target.namespace or "public"},
|
|
212
|
+
).first()
|
|
213
|
+
exists = row is not None
|
|
214
|
+
else:
|
|
215
|
+
row = conn.execute(
|
|
216
|
+
text(
|
|
217
|
+
"SELECT 1 FROM sqlite_master "
|
|
218
|
+
"WHERE type='table' AND name = :name"
|
|
219
|
+
),
|
|
220
|
+
{"name": target.name},
|
|
221
|
+
).first()
|
|
222
|
+
exists = row is not None
|
|
223
|
+
conn.execute(text(f"DROP TABLE IF EXISTS {old_sql}"))
|
|
224
|
+
if exists:
|
|
225
|
+
conn.execute(
|
|
226
|
+
text(
|
|
227
|
+
f"ALTER TABLE {target_sql} RENAME TO "
|
|
228
|
+
f"{quote_identifier(old_name, dialect=self.dialect)}"
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
conn.execute(
|
|
232
|
+
text(
|
|
233
|
+
f"ALTER TABLE {staging_sql} RENAME TO "
|
|
234
|
+
f"{quote_identifier(target.name, dialect=self.dialect)}"
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
if exists:
|
|
238
|
+
conn.execute(text(f"DROP TABLE IF EXISTS {old_sql}"))
|
|
239
|
+
return SqlExecutionResult(
|
|
240
|
+
outcome=TransactionOutcome.COMMITTED,
|
|
241
|
+
relation=target,
|
|
242
|
+
metrics=SqlMetrics(statements=1, phases=["publish"]),
|
|
243
|
+
backend_ref=f"sqlalchemy:{self.dialect}",
|
|
244
|
+
)
|
|
245
|
+
except Exception as exc:
|
|
246
|
+
return SqlExecutionResult(
|
|
247
|
+
outcome=_classify_failure(exc, started=started),
|
|
248
|
+
diagnostics=[
|
|
249
|
+
{"code": "PMSQL520", "severity": "error", "message": str(exc)}
|
|
250
|
+
],
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def load_records(
|
|
254
|
+
self,
|
|
255
|
+
records: Sequence[Any],
|
|
256
|
+
*,
|
|
257
|
+
target: RelationRef,
|
|
258
|
+
context: SqlExecutionContext,
|
|
259
|
+
compiler: SqlCompiler,
|
|
260
|
+
) -> SqlExecutionResult:
|
|
261
|
+
_ = context
|
|
262
|
+
rows = [
|
|
263
|
+
r.model_dump() if hasattr(r, "model_dump") else dict(r) for r in records
|
|
264
|
+
]
|
|
265
|
+
if not rows:
|
|
266
|
+
return SqlExecutionResult(
|
|
267
|
+
outcome=TransactionOutcome.COMMITTED,
|
|
268
|
+
relation=target,
|
|
269
|
+
metrics=SqlMetrics(rows_affected=0, phases=["load"]),
|
|
270
|
+
)
|
|
271
|
+
cols = list(rows[0].keys())
|
|
272
|
+
for c in cols:
|
|
273
|
+
require_safe_identifier(c)
|
|
274
|
+
col_sql = ", ".join(compiler.quote(c) for c in cols)
|
|
275
|
+
placeholders = ", ".join(f":{c}" for c in cols)
|
|
276
|
+
target_sql = compiler.qid(target)
|
|
277
|
+
create_cols = ", ".join(f"{compiler.quote(c)} TEXT" for c in cols)
|
|
278
|
+
create = f"CREATE TABLE IF NOT EXISTS {target_sql} ({create_cols})"
|
|
279
|
+
insert = f"INSERT INTO {target_sql} ({col_sql}) VALUES ({placeholders})"
|
|
280
|
+
started = False
|
|
281
|
+
try:
|
|
282
|
+
with self.engine.begin() as conn:
|
|
283
|
+
started = True
|
|
284
|
+
conn.execute(text(create))
|
|
285
|
+
for row in rows:
|
|
286
|
+
conn.execute(text(insert), row)
|
|
287
|
+
return SqlExecutionResult(
|
|
288
|
+
outcome=TransactionOutcome.COMMITTED,
|
|
289
|
+
relation=target,
|
|
290
|
+
metrics=SqlMetrics(
|
|
291
|
+
rows_affected=len(rows),
|
|
292
|
+
phases=["load"],
|
|
293
|
+
statements=1 + len(rows),
|
|
294
|
+
),
|
|
295
|
+
backend_ref=f"sqlalchemy:{self.dialect}",
|
|
296
|
+
)
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
return SqlExecutionResult(
|
|
299
|
+
outcome=_classify_failure(exc, started=started),
|
|
300
|
+
diagnostics=[
|
|
301
|
+
{"code": "PMSQL510", "severity": "error", "message": str(exc)}
|
|
302
|
+
],
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def fetch_records(
|
|
306
|
+
self,
|
|
307
|
+
compiler: SqlCompiler,
|
|
308
|
+
relation: RelationRef | SqlQuery,
|
|
309
|
+
*,
|
|
310
|
+
params: Mapping[str, Any],
|
|
311
|
+
context: SqlExecutionContext,
|
|
312
|
+
contract_type: type[Any] | None = None,
|
|
313
|
+
seal: Any = None,
|
|
314
|
+
) -> SqlExecutionResult:
|
|
315
|
+
if isinstance(relation, SqlQuery):
|
|
316
|
+
compiled = compiler.compile_query(relation, context=context)
|
|
317
|
+
if seal is not None:
|
|
318
|
+
compiled = seal(compiled)
|
|
319
|
+
else:
|
|
320
|
+
compiled = CompiledSql(
|
|
321
|
+
statement_id=f"fetch:{relation.qualified_name}",
|
|
322
|
+
text=f"SELECT * FROM {compiler.qid(relation)}",
|
|
323
|
+
dialect=self.dialect,
|
|
324
|
+
logical_nodes=(context.step_name,),
|
|
325
|
+
)
|
|
326
|
+
result = self.execute([compiled], params=params, context=context, fetch=True)
|
|
327
|
+
if contract_type is not None and result.records:
|
|
328
|
+
result.records = [contract_type.model_validate(r) for r in result.records]
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
def cleanup_staging(self) -> None:
|
|
332
|
+
if not self._staging_tables:
|
|
333
|
+
return
|
|
334
|
+
try:
|
|
335
|
+
with self.engine.begin() as conn:
|
|
336
|
+
for name in list(self._staging_tables):
|
|
337
|
+
try:
|
|
338
|
+
require_safe_identifier(name)
|
|
339
|
+
qid = quote_identifier(name, dialect=self.dialect)
|
|
340
|
+
conn.execute(text(f"DROP TABLE IF EXISTS {qid}"))
|
|
341
|
+
except Exception:
|
|
342
|
+
continue
|
|
343
|
+
finally:
|
|
344
|
+
self._staging_tables.clear()
|
pipelantic_sql/plugin.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""SQLAlchemy-backed PostgreSQL (reference) SQL plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import create_engine
|
|
11
|
+
from sqlalchemy.engine import Engine
|
|
12
|
+
|
|
13
|
+
from pipelantic.capabilities import PluginCapabilities
|
|
14
|
+
from pipelantic.sql.helpers import require_safe_identifier
|
|
15
|
+
from pipelantic.sql.protocol import (
|
|
16
|
+
SQL_PROTOCOL_VERSION,
|
|
17
|
+
CompiledSql,
|
|
18
|
+
RelationRef,
|
|
19
|
+
SqlExecutionContext,
|
|
20
|
+
SqlExecutionResult,
|
|
21
|
+
SqlPluginInfo,
|
|
22
|
+
SqlQuery,
|
|
23
|
+
SqlWrite,
|
|
24
|
+
TransactionOutcome,
|
|
25
|
+
WriteIntentKind,
|
|
26
|
+
)
|
|
27
|
+
from pipelantic_sql.catalog import inspect_relation as catalog_inspect
|
|
28
|
+
from pipelantic_sql.compiler import SqlCompiler
|
|
29
|
+
from pipelantic_sql.dialect_postgresql import detect_dialect, quote_identifier
|
|
30
|
+
from pipelantic_sql.executor import SqlExecutor
|
|
31
|
+
|
|
32
|
+
__version__ = "0.6.0"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_plugin() -> PostgresSqlPlugin:
|
|
36
|
+
"""Entry-point factory."""
|
|
37
|
+
return PostgresSqlPlugin()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PostgresSqlPlugin:
|
|
41
|
+
"""Reference SQL plugin (PostgreSQL-shaped; SQLAlchemy Core)."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, *, url: str | None = None) -> None:
|
|
44
|
+
self._url = url or os.environ.get(
|
|
45
|
+
"PIPELANTIC_SQL_URL", "sqlite+pysqlite:///:memory:"
|
|
46
|
+
)
|
|
47
|
+
self._engine: Engine | None = None
|
|
48
|
+
self._rows_fetched = [0]
|
|
49
|
+
self._bound_params: dict[str, dict[str, Any]] = {}
|
|
50
|
+
self._staging_tables: list[str] = []
|
|
51
|
+
dialect = detect_dialect(self._url)
|
|
52
|
+
extras = (
|
|
53
|
+
frozenset({"postgresql", "sqlalchemy"})
|
|
54
|
+
if dialect == "postgresql"
|
|
55
|
+
else frozenset({"sqlite", "sqlalchemy"})
|
|
56
|
+
)
|
|
57
|
+
# MERGE is not implemented in 0.6 — never advertise it.
|
|
58
|
+
caps = PluginCapabilities(
|
|
59
|
+
engine="sql",
|
|
60
|
+
async_execution=False,
|
|
61
|
+
dataframe=False,
|
|
62
|
+
sql=True,
|
|
63
|
+
transactions=True,
|
|
64
|
+
cancellation=False,
|
|
65
|
+
schema_inspection=True,
|
|
66
|
+
sql_merge=False,
|
|
67
|
+
sql_cte=True,
|
|
68
|
+
sql_returning=dialect == "postgresql",
|
|
69
|
+
sql_transactional_ddl=dialect == "postgresql",
|
|
70
|
+
sql_atomic_rename=True,
|
|
71
|
+
sql_catalog_inspect=True,
|
|
72
|
+
sql_trusted_fragments=False,
|
|
73
|
+
eager=False,
|
|
74
|
+
lazy=False,
|
|
75
|
+
extras=extras,
|
|
76
|
+
)
|
|
77
|
+
self._info = SqlPluginInfo(
|
|
78
|
+
name="pipelantic-sql",
|
|
79
|
+
engine="sql",
|
|
80
|
+
dialect=dialect,
|
|
81
|
+
version=__version__,
|
|
82
|
+
protocol_version=SQL_PROTOCOL_VERSION,
|
|
83
|
+
capabilities=caps,
|
|
84
|
+
)
|
|
85
|
+
self._compiler = SqlCompiler(dialect=dialect, supports_merge=False)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def info(self) -> SqlPluginInfo:
|
|
89
|
+
return self._info
|
|
90
|
+
|
|
91
|
+
def capabilities(self) -> PluginCapabilities:
|
|
92
|
+
assert self._info.capabilities is not None
|
|
93
|
+
return self._info.capabilities
|
|
94
|
+
|
|
95
|
+
def rows_fetched_total(self) -> int:
|
|
96
|
+
return self._rows_fetched[0]
|
|
97
|
+
|
|
98
|
+
def _get_engine(self) -> Engine:
|
|
99
|
+
if self._engine is None:
|
|
100
|
+
self._engine = create_engine(self._url, future=True)
|
|
101
|
+
return self._engine
|
|
102
|
+
|
|
103
|
+
def _executor(self) -> SqlExecutor:
|
|
104
|
+
return SqlExecutor(
|
|
105
|
+
engine=self._get_engine(),
|
|
106
|
+
dialect=self.info.dialect,
|
|
107
|
+
rows_fetched_counter=self._rows_fetched,
|
|
108
|
+
bound_params=self._bound_params,
|
|
109
|
+
staging_tables=self._staging_tables,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _seal(self, compiled: CompiledSql) -> CompiledSql:
|
|
113
|
+
"""Move live bound values into a private map; strip from public metadata."""
|
|
114
|
+
meta = dict(compiled.metadata)
|
|
115
|
+
bound = dict(meta.pop("_bound_params", {}) or {})
|
|
116
|
+
if bound:
|
|
117
|
+
self._bound_params[compiled.statement_id] = bound
|
|
118
|
+
return replace(compiled, metadata=meta)
|
|
119
|
+
|
|
120
|
+
def quote_identifier(self, name: str) -> str:
|
|
121
|
+
return quote_identifier(name, dialect=self.info.dialect)
|
|
122
|
+
|
|
123
|
+
def relation_from_binding(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
binding: str,
|
|
127
|
+
location: str | None,
|
|
128
|
+
metadata: Mapping[str, Any] | None = None,
|
|
129
|
+
) -> RelationRef:
|
|
130
|
+
_ = metadata
|
|
131
|
+
if location:
|
|
132
|
+
rel = RelationRef.parse(location)
|
|
133
|
+
for part in (rel.catalog, rel.namespace, rel.name):
|
|
134
|
+
if part is not None:
|
|
135
|
+
require_safe_identifier(part)
|
|
136
|
+
return rel
|
|
137
|
+
return RelationRef(name=require_safe_identifier(binding))
|
|
138
|
+
|
|
139
|
+
def compile_query(
|
|
140
|
+
self,
|
|
141
|
+
query: SqlQuery,
|
|
142
|
+
*,
|
|
143
|
+
context: SqlExecutionContext,
|
|
144
|
+
) -> CompiledSql:
|
|
145
|
+
return self._seal(self._compiler.compile_query(query, context=context))
|
|
146
|
+
|
|
147
|
+
def compile_write(
|
|
148
|
+
self,
|
|
149
|
+
write: SqlWrite,
|
|
150
|
+
*,
|
|
151
|
+
context: SqlExecutionContext,
|
|
152
|
+
) -> CompiledSql:
|
|
153
|
+
return self._seal(self._compiler.compile_write(write, context=context))
|
|
154
|
+
|
|
155
|
+
def execute(
|
|
156
|
+
self,
|
|
157
|
+
compiled: Sequence[CompiledSql],
|
|
158
|
+
*,
|
|
159
|
+
params: Mapping[str, Any],
|
|
160
|
+
context: SqlExecutionContext,
|
|
161
|
+
fetch: bool = False,
|
|
162
|
+
) -> SqlExecutionResult:
|
|
163
|
+
return self._executor().execute(
|
|
164
|
+
compiled, params=params, context=context, fetch=fetch
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def execute_write(
|
|
168
|
+
self,
|
|
169
|
+
write: SqlWrite,
|
|
170
|
+
*,
|
|
171
|
+
params: Mapping[str, Any],
|
|
172
|
+
context: SqlExecutionContext,
|
|
173
|
+
) -> SqlExecutionResult:
|
|
174
|
+
compiled = self.compile_write(write, context=context)
|
|
175
|
+
result = self.execute([compiled], params=params, context=context, fetch=False)
|
|
176
|
+
if result.outcome is not TransactionOutcome.COMMITTED:
|
|
177
|
+
return result
|
|
178
|
+
if write.intent in {WriteIntentKind.REPLACE, WriteIntentKind.SNAPSHOT}:
|
|
179
|
+
staging_data = compiled.metadata.get("staging") or {}
|
|
180
|
+
staging = RelationRef.from_dict(dict(staging_data))
|
|
181
|
+
swap = self._executor().publish_replace(
|
|
182
|
+
target=write.target,
|
|
183
|
+
staging=staging,
|
|
184
|
+
compiler=self._compiler,
|
|
185
|
+
context=context,
|
|
186
|
+
)
|
|
187
|
+
swap.compiled = list(result.compiled) + list(swap.compiled)
|
|
188
|
+
return swap
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
def materialize_temp(
|
|
192
|
+
self,
|
|
193
|
+
query: SqlQuery,
|
|
194
|
+
*,
|
|
195
|
+
temp_name: str,
|
|
196
|
+
params: Mapping[str, Any],
|
|
197
|
+
context: SqlExecutionContext,
|
|
198
|
+
) -> SqlExecutionResult:
|
|
199
|
+
return self._executor().materialize_temp(
|
|
200
|
+
self._compiler,
|
|
201
|
+
query,
|
|
202
|
+
temp_name=temp_name,
|
|
203
|
+
params=params,
|
|
204
|
+
context=context,
|
|
205
|
+
seal=self._seal,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def load_records(
|
|
209
|
+
self,
|
|
210
|
+
records: Sequence[Any],
|
|
211
|
+
*,
|
|
212
|
+
target: RelationRef,
|
|
213
|
+
context: SqlExecutionContext,
|
|
214
|
+
) -> SqlExecutionResult:
|
|
215
|
+
return self._executor().load_records(
|
|
216
|
+
records, target=target, context=context, compiler=self._compiler
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def fetch_records(
|
|
220
|
+
self,
|
|
221
|
+
relation: RelationRef | SqlQuery,
|
|
222
|
+
*,
|
|
223
|
+
params: Mapping[str, Any],
|
|
224
|
+
context: SqlExecutionContext,
|
|
225
|
+
contract_type: type[Any] | None = None,
|
|
226
|
+
) -> SqlExecutionResult:
|
|
227
|
+
return self._executor().fetch_records(
|
|
228
|
+
self._compiler,
|
|
229
|
+
relation,
|
|
230
|
+
params=params,
|
|
231
|
+
context=context,
|
|
232
|
+
contract_type=contract_type,
|
|
233
|
+
seal=self._seal,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def inspect_relation(
|
|
237
|
+
self,
|
|
238
|
+
relation: RelationRef,
|
|
239
|
+
*,
|
|
240
|
+
context: SqlExecutionContext,
|
|
241
|
+
) -> dict[str, Any]:
|
|
242
|
+
_ = context
|
|
243
|
+
return catalog_inspect(self._get_engine(), relation, dialect=self.info.dialect)
|
|
244
|
+
|
|
245
|
+
def cleanup_staging(self) -> None:
|
|
246
|
+
"""Drop run-scoped durable staging tables."""
|
|
247
|
+
self._executor().cleanup_staging()
|
pipelantic_sql/writes.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Write-intent helpers for the reference SQL plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pipelantic.sql.protocol import WriteIntentKind
|
|
6
|
+
|
|
7
|
+
SUPPORTED_WRITE_INTENTS = frozenset(
|
|
8
|
+
{
|
|
9
|
+
WriteIntentKind.APPEND,
|
|
10
|
+
WriteIntentKind.INSERT_SELECT,
|
|
11
|
+
WriteIntentKind.REPLACE,
|
|
12
|
+
WriteIntentKind.SNAPSHOT,
|
|
13
|
+
WriteIntentKind.MERGE,
|
|
14
|
+
}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def requires_atomic_publication(intent: WriteIntentKind) -> bool:
|
|
19
|
+
return intent in {WriteIntentKind.REPLACE, WriteIntentKind.SNAPSHOT}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pipelantic-sql
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: PostgreSQL reference SQL execution plugin for Pipelantic.
|
|
5
|
+
Author-email: Odo Matthews <odosmatthews@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: pipelantic<0.7,>=0.6.0
|
|
9
|
+
Requires-Dist: psycopg[binary]<4,>=3.1
|
|
10
|
+
Requires-Dist: sqlalchemy<3,>=2.0
|
|
11
|
+
Provides-Extra: sqlite
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# pipelantic-sql
|
|
15
|
+
|
|
16
|
+
PostgreSQL reference SQL execution plugin for [Pipelantic](https://github.com/eddiethedean/pipelantic).
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install pipelantic-sql
|
|
20
|
+
# or: pip install 'pipelantic[sql]'
|
|
21
|
+
export PIPELANTIC_SQL_URL=postgresql+psycopg://user:pass@localhost:5432/pipelantic
|
|
22
|
+
# SQLite is fine for demos only:
|
|
23
|
+
# export PIPELANTIC_SQL_URL=sqlite+pysqlite:///:memory:
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Uses SQLAlchemy Core. Driver dependencies stay out of `pipelantic` core.
|
|
27
|
+
|
|
28
|
+
## Wiring
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from pipelantic import Profile
|
|
32
|
+
|
|
33
|
+
Profile(name="sql-prod", sql_engine="sql")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Register `@Transformation.implementation("sql")` handlers that take
|
|
37
|
+
`RelationRef` inputs and return SQL query handles (not fetched rows).
|
|
38
|
+
|
|
39
|
+
## Capabilities (0.6)
|
|
40
|
+
|
|
41
|
+
- SQL→SQL fusion without intermediate Python row fetch
|
|
42
|
+
- Durable run-scoped staging tables (not session TEMP)
|
|
43
|
+
- Insert-select / CTAS-style publication
|
|
44
|
+
- Fail-closed planning when required capabilities are missing
|
|
45
|
+
|
|
46
|
+
**Not included:** `MERGE` / upsert (`sql_merge=False`). Requiring merge fails
|
|
47
|
+
at planning.
|
|
48
|
+
|
|
49
|
+
## Examples
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python examples/sql_to_sql.py
|
|
53
|
+
python examples/sql_boundary_hybrid.py
|
|
54
|
+
python examples/sql_transactional_write.py
|
|
55
|
+
python examples/sql_failure_recovery.py
|
|
56
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pipelantic_sql/__init__.py,sha256=egRoaPc8_SknbI6lUr0ZdWtzFtJ5jKVW9QOBPWfXD1I,260
|
|
2
|
+
pipelantic_sql/catalog.py,sha256=SEGZbsE5Kxte-mb93Ft86cWQiO7-FzkoDh-YDlNJpf4,1682
|
|
3
|
+
pipelantic_sql/compiler.py,sha256=hQTGln1BvHKZiEJ5pbGBUk9V_qJB67viZlbZjJfAnKY,7558
|
|
4
|
+
pipelantic_sql/dialect_postgresql.py,sha256=mbh52DXBF83xzeIr6PTetlg_oqdQ8hlszZuyREzA5KI,704
|
|
5
|
+
pipelantic_sql/executor.py,sha256=O3ehU5ee3GQEKS3wYh9IWqlA8TwAqdWe4R2-dL4RhDs,13174
|
|
6
|
+
pipelantic_sql/plugin.py,sha256=jC7Tnf5cCtE72oeQp6MlqfnlWXLyVlLSioWBLl8qGno,7765
|
|
7
|
+
pipelantic_sql/writes.py,sha256=4eK56cGKRNhf4DjCLlt9R0wLw25cS_4JhUXPy8Gon1I,508
|
|
8
|
+
pipelantic_sql-0.6.0.dist-info/METADATA,sha256=f0P5xg1q2_2Hg2DvB40s0PYMEerRBd3w_gSyQpI-1Do,1593
|
|
9
|
+
pipelantic_sql-0.6.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
pipelantic_sql-0.6.0.dist-info/entry_points.txt,sha256=uVWCKu6XMR2YGQNC7hHbV_QDprwwQOWbxXFqHbJBN_w,60
|
|
11
|
+
pipelantic_sql-0.6.0.dist-info/RECORD,,
|