onestep-sql 0.1.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.
- onestep_sql/__init__.py +21 -0
- onestep_sql/_shared/__init__.py +26 -0
- onestep_sql/_shared/resilience.py +147 -0
- onestep_sql/_shared/state_keys.py +31 -0
- onestep_sql/_shared/state_sqlalchemy.py +209 -0
- onestep_sql/_shared/table_sink_policy.py +119 -0
- onestep_sql/mysql/__init__.py +41 -0
- onestep_sql/mysql/connector.py +1054 -0
- onestep_sql/mysql/resilience.py +104 -0
- onestep_sql/mysql/resources.py +352 -0
- onestep_sql/mysql/state_sqlalchemy.py +44 -0
- onestep_sql/postgres/__init__.py +50 -0
- onestep_sql/postgres/connector.py +767 -0
- onestep_sql/postgres/execution_backend.py +1151 -0
- onestep_sql/postgres/execution_schema.py +207 -0
- onestep_sql/postgres/execution_source.py +474 -0
- onestep_sql/postgres/resilience.py +106 -0
- onestep_sql/postgres/resources.py +402 -0
- onestep_sql/postgres/state_sqlalchemy.py +44 -0
- onestep_sql-0.1.0.dist-info/METADATA +85 -0
- onestep_sql-0.1.0.dist-info/RECORD +23 -0
- onestep_sql-0.1.0.dist-info/WHEEL +4 -0
- onestep_sql-0.1.0.dist-info/entry_points.txt +2 -0
onestep_sql/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from onestep.resource_registry import ResourceRegistry
|
|
4
|
+
|
|
5
|
+
from . import mysql, postgres
|
|
6
|
+
from .mysql import register_resources as _register_mysql
|
|
7
|
+
from .postgres import register_resources as _register_postgres
|
|
8
|
+
|
|
9
|
+
__all__ = ["register_resources", "mysql", "postgres"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register_resources(registry: ResourceRegistry) -> None:
|
|
13
|
+
"""Register every onestep-sql resource type (MySQL + PostgreSQL).
|
|
14
|
+
|
|
15
|
+
This is the single entry point for the canonical ``onestep-sql``
|
|
16
|
+
distribution (declared as ``sql`` in the ``onestep.resources`` group).
|
|
17
|
+
It delegates to the per-backend registrars without changing any of the
|
|
18
|
+
14 existing YAML type names, their catalog roles, or their fields.
|
|
19
|
+
"""
|
|
20
|
+
_register_mysql(registry)
|
|
21
|
+
_register_postgres(registry)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Internal shared SQL behaviour for the canonical ``onestep-sql`` package.
|
|
2
|
+
|
|
3
|
+
Phase 2 of the ``onestep-mysql`` / ``onestep-postgres`` consolidation
|
|
4
|
+
(issue #133, design in PR #134) keeps exactly one copy of the machinery that
|
|
5
|
+
previously lived in parallel in both backend subpackages (as monitored by the
|
|
6
|
+
now-retired ``scripts/check_plugin_drift.py``):
|
|
7
|
+
|
|
8
|
+
* :mod:`onestep_sql._shared.state_sqlalchemy` — the SQLAlchemy state/cursor
|
|
9
|
+
stores; backends subclass them and override only the asyncio driver mapping
|
|
10
|
+
(``mysql+asyncmy`` vs ``postgresql+psycopg``) and install hint;
|
|
11
|
+
* :mod:`onestep_sql._shared.table_sink_policy` — the table-sink
|
|
12
|
+
``update_columns`` write policies (``overwrite`` / ``skip_null`` /
|
|
13
|
+
``backfill``), the ``_normalize_update_columns`` validator and the
|
|
14
|
+
``_update_payload`` / ``_coerce_json_values`` mixin;
|
|
15
|
+
* :mod:`onestep_sql._shared.state_keys` — the default incremental state-key
|
|
16
|
+
derivation feeding the at-least-once cursor state;
|
|
17
|
+
* :mod:`onestep_sql._shared.resilience` — the secret-token collection, message
|
|
18
|
+
redaction, redacted error-cause base class and the
|
|
19
|
+
``ConnectorOperationError`` factory; the dialect-specific SQLAlchemy error
|
|
20
|
+
classification tables stay in each backend's ``resilience.py``.
|
|
21
|
+
|
|
22
|
+
This package is **not** public API. Backend capabilities never move here:
|
|
23
|
+
MySQL binlog CDC stays in ``onestep_sql.mysql`` and the PostgreSQL tracked
|
|
24
|
+
execution modules stay in ``onestep_sql.postgres``. Dual-backend contract
|
|
25
|
+
tests live in ``tests/contract/test_onestep_sql_shared.py``.
|
|
26
|
+
"""
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Shared secret-redaction scaffolding for the onestep-sql backends.
|
|
2
|
+
|
|
3
|
+
Phase 2 of the mysql/postgres consolidation (issue #133, design §3.1) keeps
|
|
4
|
+
exactly one copy of the resilience machinery that previously existed in
|
|
5
|
+
parallel in both backend ``resilience.py`` modules and differed only in the
|
|
6
|
+
backend name (see the retired ``scripts/check_plugin_drift.py``):
|
|
7
|
+
|
|
8
|
+
* the secret-token collection used to scrub error causes
|
|
9
|
+
(:func:`collect_sensitive_tokens`);
|
|
10
|
+
* the message redaction itself (:func:`redact_message`);
|
|
11
|
+
* the redacted error-cause base class (:class:`SQLErrorCause`) that backends
|
|
12
|
+
subclass with their own ``backend`` label so ``str(cause)`` keeps its
|
|
13
|
+
per-database prefix;
|
|
14
|
+
* the :class:`~onestep.resilience.ConnectorOperationError` factory
|
|
15
|
+
(:func:`as_connector_operation_error`).
|
|
16
|
+
|
|
17
|
+
The SQLAlchemy error-classification tables stay in each backend's
|
|
18
|
+
``resilience.py`` because they encode genuinely different server error
|
|
19
|
+
messages (``server has gone away`` vs ``server closed the connection``,
|
|
20
|
+
``deadlock found`` vs ``deadlock detected`` and so on).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable, Mapping
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import ClassVar
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
from onestep.resilience import (
|
|
31
|
+
ConnectorErrorKind,
|
|
32
|
+
ConnectorOperation,
|
|
33
|
+
ConnectorOperationError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
REDACTED = "<redacted>"
|
|
37
|
+
MAX_MESSAGE_LENGTH = 500
|
|
38
|
+
SECRET_OPTION_KEYS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"password",
|
|
41
|
+
"passwd",
|
|
42
|
+
"pwd",
|
|
43
|
+
"secret",
|
|
44
|
+
"token",
|
|
45
|
+
"access_token",
|
|
46
|
+
"api_key",
|
|
47
|
+
"apikey",
|
|
48
|
+
"credentials",
|
|
49
|
+
"authorization",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def collect_sensitive_tokens(*config_values: object) -> list[str]:
|
|
55
|
+
"""Collect secret substrings that may surface in SQL backend error messages.
|
|
56
|
+
|
|
57
|
+
SQLAlchemy masks the password in its rendered URL, but the underlying DBAPI
|
|
58
|
+
``orig`` exception can still echo credentials (e.g. after echoing the full
|
|
59
|
+
connection string). Tokens are derived from connector config (raw DSN +
|
|
60
|
+
parsed userinfo + known secret option values) and used to scrub error
|
|
61
|
+
causes before they leave the plugin.
|
|
62
|
+
"""
|
|
63
|
+
tokens: list[str] = []
|
|
64
|
+
seen: set[str] = set()
|
|
65
|
+
|
|
66
|
+
def add(value: object) -> None:
|
|
67
|
+
if value is None:
|
|
68
|
+
return
|
|
69
|
+
text = str(value)
|
|
70
|
+
if text and text not in seen:
|
|
71
|
+
seen.add(text)
|
|
72
|
+
tokens.append(text)
|
|
73
|
+
|
|
74
|
+
def collect_mapping(value: Mapping[object, object]) -> None:
|
|
75
|
+
for key, item in value.items():
|
|
76
|
+
if str(key).lower() in SECRET_OPTION_KEYS:
|
|
77
|
+
add(item)
|
|
78
|
+
elif isinstance(item, Mapping):
|
|
79
|
+
collect_mapping(item)
|
|
80
|
+
|
|
81
|
+
for value in config_values:
|
|
82
|
+
if isinstance(value, Mapping):
|
|
83
|
+
collect_mapping(value)
|
|
84
|
+
continue
|
|
85
|
+
add(value)
|
|
86
|
+
try:
|
|
87
|
+
parsed = urlsplit(str(value))
|
|
88
|
+
except ValueError:
|
|
89
|
+
continue
|
|
90
|
+
username = parsed.username
|
|
91
|
+
password = parsed.password
|
|
92
|
+
if username and password:
|
|
93
|
+
add(f"{username}:{password}")
|
|
94
|
+
add(f"{username}:{password}@")
|
|
95
|
+
add(password)
|
|
96
|
+
return tokens
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def redact_message(message: str, tokens: list[str]) -> str:
|
|
100
|
+
redacted = message
|
|
101
|
+
for token in sorted(tokens, key=len, reverse=True):
|
|
102
|
+
if token:
|
|
103
|
+
redacted = redacted.replace(token, REDACTED)
|
|
104
|
+
return redacted[:MAX_MESSAGE_LENGTH]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class SQLErrorCause(Exception):
|
|
109
|
+
"""Redacted cause attached to normalized SQL connector errors.
|
|
110
|
+
|
|
111
|
+
Backends subclass this with their own ``backend`` label so the rendered
|
|
112
|
+
message keeps its per-database prefix (``mysql error: ...`` /
|
|
113
|
+
``postgres error: ...``) while the dataclass shape, freezing and identity
|
|
114
|
+
semantics are implemented once here.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
message: str
|
|
118
|
+
|
|
119
|
+
backend: ClassVar[str] = "sql"
|
|
120
|
+
|
|
121
|
+
def __str__(self) -> str:
|
|
122
|
+
return f"{self.backend} error: {self.message}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def as_connector_operation_error(
|
|
126
|
+
*,
|
|
127
|
+
backend: str,
|
|
128
|
+
operation: ConnectorOperation,
|
|
129
|
+
exc: BaseException,
|
|
130
|
+
classify: Callable[[BaseException], ConnectorErrorKind | None],
|
|
131
|
+
redacted_cause: Callable[..., Exception],
|
|
132
|
+
source_name: str | None = None,
|
|
133
|
+
retry_delay_s: float | None = None,
|
|
134
|
+
secrets: list[str] | None = None,
|
|
135
|
+
) -> ConnectorOperationError | None:
|
|
136
|
+
"""Normalize ``exc`` using the backend's classifier and redacted cause."""
|
|
137
|
+
kind = classify(exc)
|
|
138
|
+
if kind is None:
|
|
139
|
+
return None
|
|
140
|
+
return ConnectorOperationError(
|
|
141
|
+
backend=backend,
|
|
142
|
+
operation=operation,
|
|
143
|
+
kind=kind,
|
|
144
|
+
source_name=source_name,
|
|
145
|
+
retry_delay_s=retry_delay_s,
|
|
146
|
+
cause=redacted_cause(exc, secrets=secrets),
|
|
147
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Shared default incremental state-key derivation for the onestep-sql backends.
|
|
2
|
+
|
|
3
|
+
Phase 2 of the mysql/postgres consolidation (issue #133, design §3.1) keeps
|
|
4
|
+
exactly one copy of ``_default_incremental_state_key``, which previously
|
|
5
|
+
existed in parallel in both backend ``connector.py`` modules (see the retired
|
|
6
|
+
``scripts/check_plugin_drift.py``). The derived key feeds the at-least-once
|
|
7
|
+
cursor state, so its format is stable contract: changing it would orphan
|
|
8
|
+
existing persisted cursors.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _default_incremental_state_key(
|
|
18
|
+
*,
|
|
19
|
+
table: str,
|
|
20
|
+
cursor: Sequence[str],
|
|
21
|
+
key: str,
|
|
22
|
+
where: str | None,
|
|
23
|
+
) -> str:
|
|
24
|
+
normalized_where = " ".join((where or "").split())
|
|
25
|
+
if normalized_where:
|
|
26
|
+
where_fragment = normalized_where
|
|
27
|
+
if len(where_fragment) > 64:
|
|
28
|
+
where_fragment = f"sha1:{hashlib.sha1(where_fragment.encode('utf-8')).hexdigest()}"
|
|
29
|
+
else:
|
|
30
|
+
where_fragment = "-"
|
|
31
|
+
return f"{table}:{','.join(cursor)}:key={key}:where={where_fragment}"
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Shared SQLAlchemy state/cursor stores for the onestep-sql backends.
|
|
2
|
+
|
|
3
|
+
Phase 2 of the mysql/postgres consolidation (issue #133, design §3.1) keeps
|
|
4
|
+
exactly one copy of the store implementation that previously lived in parallel
|
|
5
|
+
in both backend ``state_sqlalchemy.py`` modules (see the retired
|
|
6
|
+
``scripts/check_plugin_drift.py``). Dual-backend contract tests in
|
|
7
|
+
``tests/contract/test_onestep_sql_shared.py`` pin the behaviour for both
|
|
8
|
+
backends.
|
|
9
|
+
|
|
10
|
+
Only two things are genuinely per-database and stay in the backend adapters:
|
|
11
|
+
|
|
12
|
+
* the asyncio driver mapping (``mysql+asyncmy`` vs ``postgresql+psycopg``) —
|
|
13
|
+
backends override :meth:`SQLAlchemyStateStore._resolve_async_driver`;
|
|
14
|
+
* the installation hint in the missing-SQLAlchemy error message — backends
|
|
15
|
+
override ``_install_hint``.
|
|
16
|
+
|
|
17
|
+
Backends subclass :class:`SQLAlchemyStateStore` /
|
|
18
|
+
:class:`SQLAlchemyCursorStore` so their public class identities stay inside
|
|
19
|
+
``onestep_sql.mysql`` / ``onestep_sql.postgres`` while the behaviour is
|
|
20
|
+
implemented here once.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import sqlalchemy as sa
|
|
32
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
33
|
+
except ImportError: # pragma: no cover - exercised when optional deps are missing
|
|
34
|
+
sa = None
|
|
35
|
+
AsyncEngine = None
|
|
36
|
+
create_async_engine = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sqlite_async_driver(drivername: str) -> str | None:
|
|
40
|
+
"""Return the asyncio sqlite driver for sqlite URLs, else ``None``."""
|
|
41
|
+
if drivername in ("sqlite", "sqlite+pysqlite"):
|
|
42
|
+
return "sqlite+aiosqlite"
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def async_dsn(dsn: str, *, resolve_driver: Any) -> str:
|
|
47
|
+
"""Return a SQLAlchemy URL backed by an asyncio-compatible dialect.
|
|
48
|
+
|
|
49
|
+
``resolve_driver`` maps the URL's current drivername to the
|
|
50
|
+
asyncio-compatible drivername to use, or ``None`` to keep the DSN
|
|
51
|
+
unchanged. The per-database mappings live in the backend adapters.
|
|
52
|
+
"""
|
|
53
|
+
if sa is None:
|
|
54
|
+
return dsn
|
|
55
|
+
url = sa.engine.make_url(dsn)
|
|
56
|
+
async_drivername = resolve_driver(url.drivername)
|
|
57
|
+
if async_drivername is None:
|
|
58
|
+
return dsn
|
|
59
|
+
return url.set(drivername=async_drivername).render_as_string(hide_password=False)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SQLAlchemyStateStore:
|
|
63
|
+
_install_hint = "Install onestep-sql."
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def _resolve_async_driver(drivername: str) -> str | None:
|
|
67
|
+
return sqlite_async_driver(drivername)
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
dsn: str | None = None,
|
|
73
|
+
engine: Any | None = None,
|
|
74
|
+
table: str = "onestep_state",
|
|
75
|
+
key_column: str = "state_key",
|
|
76
|
+
value_column: str = "state_value",
|
|
77
|
+
updated_at_column: str = "updated_at",
|
|
78
|
+
auto_create: bool = True,
|
|
79
|
+
**engine_options: Any,
|
|
80
|
+
) -> None:
|
|
81
|
+
if create_async_engine is None or sa is None:
|
|
82
|
+
raise RuntimeError(f"SQLAlchemyStateStore requires SQLAlchemy. {self._install_hint}")
|
|
83
|
+
if engine is None and dsn is None:
|
|
84
|
+
raise ValueError("dsn or engine is required")
|
|
85
|
+
if engine is not None and dsn is not None:
|
|
86
|
+
raise ValueError("pass either dsn or engine, not both")
|
|
87
|
+
engine_options.setdefault("pool_pre_ping", True)
|
|
88
|
+
self.engine = engine or create_async_engine(
|
|
89
|
+
async_dsn(dsn, resolve_driver=self._resolve_async_driver), **engine_options
|
|
90
|
+
)
|
|
91
|
+
self._owns_engine = engine is None
|
|
92
|
+
self.table_name = table
|
|
93
|
+
self.key_column_name = key_column
|
|
94
|
+
self.value_column_name = value_column
|
|
95
|
+
self.updated_at_column_name = updated_at_column
|
|
96
|
+
self.auto_create = auto_create
|
|
97
|
+
self._metadata = sa.MetaData()
|
|
98
|
+
self._table = sa.Table(
|
|
99
|
+
table,
|
|
100
|
+
self._metadata,
|
|
101
|
+
sa.Column(key_column, sa.String(255), primary_key=True),
|
|
102
|
+
sa.Column(value_column, sa.Text(), nullable=False),
|
|
103
|
+
sa.Column(updated_at_column, sa.DateTime(timezone=True), nullable=False),
|
|
104
|
+
)
|
|
105
|
+
self._ready = False
|
|
106
|
+
self._ready_lock: asyncio.Lock | None = None
|
|
107
|
+
|
|
108
|
+
async def load(self, key: str) -> Any | None:
|
|
109
|
+
await self._ensure_ready()
|
|
110
|
+
key_column = self._table.c[self.key_column_name]
|
|
111
|
+
value_column = self._table.c[self.value_column_name]
|
|
112
|
+
async with self.engine.begin() as conn:
|
|
113
|
+
row = (
|
|
114
|
+
await conn.execute(sa.select(value_column).where(key_column == key))
|
|
115
|
+
).scalar_one_or_none()
|
|
116
|
+
if row is None:
|
|
117
|
+
return None
|
|
118
|
+
return json.loads(row)
|
|
119
|
+
|
|
120
|
+
async def save(self, key: str, value: Any) -> None:
|
|
121
|
+
await self._ensure_ready()
|
|
122
|
+
key_column = self._table.c[self.key_column_name]
|
|
123
|
+
payload = {
|
|
124
|
+
self.key_column_name: key,
|
|
125
|
+
self.value_column_name: json.dumps(value, ensure_ascii=False),
|
|
126
|
+
self.updated_at_column_name: datetime.now(timezone.utc),
|
|
127
|
+
}
|
|
128
|
+
async with self.engine.begin() as conn:
|
|
129
|
+
exists = (
|
|
130
|
+
await conn.execute(sa.select(key_column).where(key_column == key))
|
|
131
|
+
).scalar_one_or_none()
|
|
132
|
+
if exists is None:
|
|
133
|
+
await conn.execute(sa.insert(self._table).values(**payload))
|
|
134
|
+
return
|
|
135
|
+
await conn.execute(
|
|
136
|
+
sa.update(self._table)
|
|
137
|
+
.where(key_column == key)
|
|
138
|
+
.values(
|
|
139
|
+
**{
|
|
140
|
+
self.value_column_name: payload[self.value_column_name],
|
|
141
|
+
self.updated_at_column_name: payload[self.updated_at_column_name],
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
async def delete(self, key: str) -> None:
|
|
147
|
+
await self._ensure_ready()
|
|
148
|
+
key_column = self._table.c[self.key_column_name]
|
|
149
|
+
async with self.engine.begin() as conn:
|
|
150
|
+
await conn.execute(sa.delete(self._table).where(key_column == key))
|
|
151
|
+
|
|
152
|
+
async def close(self) -> None:
|
|
153
|
+
if self._owns_engine:
|
|
154
|
+
await self.engine.dispose()
|
|
155
|
+
|
|
156
|
+
async def _ensure_ready(self) -> None:
|
|
157
|
+
if self._ready or not self.auto_create:
|
|
158
|
+
return
|
|
159
|
+
lock = self._ready_lock
|
|
160
|
+
if lock is None:
|
|
161
|
+
lock = asyncio.Lock()
|
|
162
|
+
self._ready_lock = lock
|
|
163
|
+
async with lock:
|
|
164
|
+
if self._ready:
|
|
165
|
+
return
|
|
166
|
+
async with self.engine.begin() as conn:
|
|
167
|
+
await conn.run_sync(
|
|
168
|
+
lambda sync_conn: self._metadata.create_all(
|
|
169
|
+
sync_conn, tables=[self._table], checkfirst=True
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
self._ready = True
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SQLAlchemyCursorStore(SQLAlchemyStateStore):
|
|
176
|
+
_DATETIME_TYPE_KEY = "__onestep_cursor_type__"
|
|
177
|
+
_DATETIME_TYPE_VALUE = "datetime"
|
|
178
|
+
_DATETIME_VALUE_KEY = "value"
|
|
179
|
+
|
|
180
|
+
async def load(self, key: str) -> Any | None:
|
|
181
|
+
value = await super().load(key)
|
|
182
|
+
if not isinstance(value, list):
|
|
183
|
+
return value
|
|
184
|
+
return [self._decode_cursor_component(component) for component in value]
|
|
185
|
+
|
|
186
|
+
async def save(self, key: str, value: Any) -> None:
|
|
187
|
+
if isinstance(value, (list, tuple)):
|
|
188
|
+
value = [self._encode_cursor_component(component) for component in value]
|
|
189
|
+
await super().save(key, value)
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def _encode_cursor_component(cls, value: Any) -> Any:
|
|
193
|
+
if isinstance(value, datetime):
|
|
194
|
+
return {
|
|
195
|
+
cls._DATETIME_TYPE_KEY: cls._DATETIME_TYPE_VALUE,
|
|
196
|
+
cls._DATETIME_VALUE_KEY: value.isoformat(),
|
|
197
|
+
}
|
|
198
|
+
return value
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def _decode_cursor_component(cls, value: Any) -> Any:
|
|
202
|
+
if (
|
|
203
|
+
isinstance(value, dict)
|
|
204
|
+
and set(value) == {cls._DATETIME_TYPE_KEY, cls._DATETIME_VALUE_KEY}
|
|
205
|
+
and value[cls._DATETIME_TYPE_KEY] == cls._DATETIME_TYPE_VALUE
|
|
206
|
+
and isinstance(value[cls._DATETIME_VALUE_KEY], str)
|
|
207
|
+
):
|
|
208
|
+
return datetime.fromisoformat(value[cls._DATETIME_VALUE_KEY])
|
|
209
|
+
return value
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Shared table-sink update-policy machinery for the onestep-sql backends.
|
|
2
|
+
|
|
3
|
+
Phase 2 of the mysql/postgres consolidation (issue #133, design §3.1) keeps
|
|
4
|
+
exactly one copy of the update-policy helpers that previously lived in
|
|
5
|
+
parallel in both backend ``connector.py`` modules (see the retired
|
|
6
|
+
``scripts/check_plugin_drift.py``):
|
|
7
|
+
|
|
8
|
+
* ``_UPDATE_COLUMN_POLICIES`` / ``_normalize_update_columns`` — validation of
|
|
9
|
+
the ``update_columns`` write policies (``overwrite`` / ``skip_null`` /
|
|
10
|
+
``backfill``) against keys and ``update_expr``;
|
|
11
|
+
* :class:`TableSinkUpdatePolicy` — the ``_update_payload`` column-write
|
|
12
|
+
policy and ``_coerce_json_values`` JSON serialization mixin used by both
|
|
13
|
+
``TableSink`` (mysql) and ``PostgresTableSink``.
|
|
14
|
+
|
|
15
|
+
Everything that genuinely differs between the backends stays in their
|
|
16
|
+
``connector.py``: constructor validation order, ``_build_statement`` SQL
|
|
17
|
+
dialect branches (``ON DUPLICATE KEY UPDATE`` vs ``ON CONFLICT DO UPDATE``)
|
|
18
|
+
and the ``_send`` logging.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from collections.abc import Mapping, Sequence
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import sqlalchemy as sa
|
|
29
|
+
except ImportError: # pragma: no cover - exercised when optional deps are missing
|
|
30
|
+
sa = None
|
|
31
|
+
|
|
32
|
+
_UPDATE_COLUMN_POLICIES = frozenset({"overwrite", "skip_null", "backfill"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_update_columns(
|
|
36
|
+
update_columns: Sequence[str | Mapping[str, str]] | None,
|
|
37
|
+
*,
|
|
38
|
+
keys: tuple[str, ...],
|
|
39
|
+
update_expr: Mapping[str, str] | None = None,
|
|
40
|
+
) -> tuple[tuple[str, ...] | None, dict[str, str]]:
|
|
41
|
+
if update_columns is None:
|
|
42
|
+
return None, {}
|
|
43
|
+
names: list[str] = []
|
|
44
|
+
policies: dict[str, str] = {}
|
|
45
|
+
for entry in update_columns:
|
|
46
|
+
if isinstance(entry, str):
|
|
47
|
+
if not entry:
|
|
48
|
+
raise ValueError("update_columns entries must be non-empty")
|
|
49
|
+
name, policy = entry, "overwrite"
|
|
50
|
+
elif isinstance(entry, Mapping):
|
|
51
|
+
unknown_keys = set(entry) - {"name", "policy"}
|
|
52
|
+
if unknown_keys:
|
|
53
|
+
raise ValueError(f"unknown update_columns entry keys: {', '.join(sorted(unknown_keys))}")
|
|
54
|
+
name = entry.get("name")
|
|
55
|
+
policy = entry.get("policy", "overwrite")
|
|
56
|
+
if not isinstance(name, str) or not name:
|
|
57
|
+
raise ValueError("update_columns entry requires a non-empty 'name'")
|
|
58
|
+
if policy not in _UPDATE_COLUMN_POLICIES:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"update_columns policy must be one of 'overwrite', 'skip_null' or 'backfill', "
|
|
61
|
+
f"got {policy!r}"
|
|
62
|
+
)
|
|
63
|
+
if name in keys:
|
|
64
|
+
raise ValueError(f"update_columns policy cannot apply to key column {name!r}")
|
|
65
|
+
else:
|
|
66
|
+
raise TypeError("update_columns entries must be strings or mappings")
|
|
67
|
+
if name in policies:
|
|
68
|
+
raise ValueError(f"duplicate update column {name!r}")
|
|
69
|
+
names.append(name)
|
|
70
|
+
policies[name] = policy
|
|
71
|
+
update_expr_keys = set(update_expr) if update_expr else set()
|
|
72
|
+
conflicting = sorted(set(policies) & update_expr_keys)
|
|
73
|
+
if conflicting:
|
|
74
|
+
raise ValueError(f"update_columns policy conflicts with update_expr for: {', '.join(conflicting)}")
|
|
75
|
+
return tuple(names), policies
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TableSinkUpdatePolicy:
|
|
79
|
+
"""Mixin implementing the shared SQL table-sink column-write policy.
|
|
80
|
+
|
|
81
|
+
Host sinks must set these instance attributes (both existing backends
|
|
82
|
+
already do): ``keys``, ``update_columns``, ``column_policies``,
|
|
83
|
+
``update_expr`` and ``serialize_json``.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def _update_payload(self, payload: dict[str, Any], table: sa.Table) -> tuple[dict[str, Any], bool]:
|
|
87
|
+
if self.update_columns is not None:
|
|
88
|
+
candidates = {key: value for key, value in payload.items() if key in self.update_columns}
|
|
89
|
+
else:
|
|
90
|
+
candidates = {key: value for key, value in payload.items() if key not in self.keys}
|
|
91
|
+
update_payload: dict[str, Any] = {}
|
|
92
|
+
skipped = False
|
|
93
|
+
for column, value in candidates.items():
|
|
94
|
+
policy = self.column_policies.get(column, "overwrite")
|
|
95
|
+
if policy == "skip_null" and value is None:
|
|
96
|
+
skipped = True
|
|
97
|
+
continue
|
|
98
|
+
if policy == "backfill":
|
|
99
|
+
update_payload[column] = sa.func.coalesce(table.columns[column], value)
|
|
100
|
+
else:
|
|
101
|
+
update_payload[column] = value
|
|
102
|
+
for column, expr in self.update_expr.items():
|
|
103
|
+
update_payload[column] = sa.literal_column(expr)
|
|
104
|
+
return update_payload, skipped
|
|
105
|
+
|
|
106
|
+
def _coerce_json_values(self, payload: dict[str, Any], table: sa.Table) -> dict[str, Any]:
|
|
107
|
+
if self.serialize_json == "never":
|
|
108
|
+
return payload
|
|
109
|
+
coerced = dict(payload)
|
|
110
|
+
for column_name, value in list(payload.items()):
|
|
111
|
+
if not isinstance(value, (list, dict)):
|
|
112
|
+
continue
|
|
113
|
+
column = table.columns.get(column_name)
|
|
114
|
+
if column is None:
|
|
115
|
+
continue
|
|
116
|
+
is_json_column = isinstance(column.type, sa.JSON)
|
|
117
|
+
if self.serialize_json == "always" or not is_json_column:
|
|
118
|
+
coerced[column_name] = json.dumps(value, ensure_ascii=False)
|
|
119
|
+
return coerced
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version as _package_version
|
|
4
|
+
|
|
5
|
+
from .connector import (
|
|
6
|
+
BinlogDelivery,
|
|
7
|
+
BinlogSource,
|
|
8
|
+
IncrementalDelivery,
|
|
9
|
+
IncrementalTableSource,
|
|
10
|
+
MySQLConnector,
|
|
11
|
+
TableQueueDelivery,
|
|
12
|
+
TableQueueSource,
|
|
13
|
+
TableSink,
|
|
14
|
+
)
|
|
15
|
+
from .resources import register_resources
|
|
16
|
+
from .resilience import classify_sqlalchemy_error
|
|
17
|
+
from .state_sqlalchemy import SQLAlchemyCursorStore, SQLAlchemyStateStore
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
__version__ = _package_version("onestep-sql")
|
|
21
|
+
except PackageNotFoundError: # pragma: no cover - local source tree before install
|
|
22
|
+
__version__ = "dev"
|
|
23
|
+
|
|
24
|
+
register = register_resources
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"BinlogDelivery",
|
|
28
|
+
"BinlogSource",
|
|
29
|
+
"IncrementalDelivery",
|
|
30
|
+
"IncrementalTableSource",
|
|
31
|
+
"MySQLConnector",
|
|
32
|
+
"SQLAlchemyCursorStore",
|
|
33
|
+
"SQLAlchemyStateStore",
|
|
34
|
+
"TableQueueDelivery",
|
|
35
|
+
"TableQueueSource",
|
|
36
|
+
"TableSink",
|
|
37
|
+
"__version__",
|
|
38
|
+
"classify_sqlalchemy_error",
|
|
39
|
+
"register",
|
|
40
|
+
"register_resources",
|
|
41
|
+
]
|