pipelantic-sql 0.6.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ .eggs/
7
+ dist/
8
+ build/
9
+ site/
10
+ .venv/
11
+ venv/
12
+ .env
13
+
14
+ # Tools
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ .mypy_cache/
18
+ .coverage
19
+ htmlcov/
20
+ .DS_Store
21
+ examples/_file_storage_out/
@@ -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,43 @@
1
+ # pipelantic-sql
2
+
3
+ PostgreSQL reference SQL execution plugin for [Pipelantic](https://github.com/eddiethedean/pipelantic).
4
+
5
+ ```bash
6
+ pip install pipelantic-sql
7
+ # or: pip install 'pipelantic[sql]'
8
+ export PIPELANTIC_SQL_URL=postgresql+psycopg://user:pass@localhost:5432/pipelantic
9
+ # SQLite is fine for demos only:
10
+ # export PIPELANTIC_SQL_URL=sqlite+pysqlite:///:memory:
11
+ ```
12
+
13
+ Uses SQLAlchemy Core. Driver dependencies stay out of `pipelantic` core.
14
+
15
+ ## Wiring
16
+
17
+ ```python
18
+ from pipelantic import Profile
19
+
20
+ Profile(name="sql-prod", sql_engine="sql")
21
+ ```
22
+
23
+ Register `@Transformation.implementation("sql")` handlers that take
24
+ `RelationRef` inputs and return SQL query handles (not fetched rows).
25
+
26
+ ## Capabilities (0.6)
27
+
28
+ - SQL→SQL fusion without intermediate Python row fetch
29
+ - Durable run-scoped staging tables (not session TEMP)
30
+ - Insert-select / CTAS-style publication
31
+ - Fail-closed planning when required capabilities are missing
32
+
33
+ **Not included:** `MERGE` / upsert (`sql_merge=False`). Requiring merge fails
34
+ at planning.
35
+
36
+ ## Examples
37
+
38
+ ```bash
39
+ python examples/sql_to_sql.py
40
+ python examples/sql_boundary_hybrid.py
41
+ python examples/sql_transactional_write.py
42
+ python examples/sql_failure_recovery.py
43
+ ```
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "pipelantic-sql"
3
+ version = "0.6.0"
4
+ description = "PostgreSQL reference SQL execution plugin for Pipelantic."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ authors = [{ name = "Odo Matthews", email = "odosmatthews@gmail.com" }]
9
+ dependencies = [
10
+ "pipelantic>=0.6.0,<0.7",
11
+ "sqlalchemy>=2.0,<3",
12
+ "psycopg[binary]>=3.1,<4",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ sqlite = []
17
+
18
+ [project.entry-points."pipelantic.sql_plugins"]
19
+ sql = "pipelantic_sql:create_plugin"
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/pipelantic_sql"]
@@ -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()
@@ -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()
@@ -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}