sql2deltalake 0.1.0rc1__cp310-abi3-win_amd64.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.
- sql2deltalake/__init__.py +43 -0
- sql2deltalake/_core.pyd +4 -0
- sql2deltalake/_core.pyi +72 -0
- sql2deltalake/_native.py +327 -0
- sql2deltalake/consistency.py +56 -0
- sql2deltalake/db_to_delta.py +1763 -0
- sql2deltalake/delta_logger.py +161 -0
- sql2deltalake/destination/__init__.py +1 -0
- sql2deltalake/destination/azure.py +89 -0
- sql2deltalake/destination/azure_utils.py +76 -0
- sql2deltalake/destination/databricks.py +86 -0
- sql2deltalake/destination/destination.py +64 -0
- sql2deltalake/destination/file_system.py +66 -0
- sql2deltalake/load_infos.py +83 -0
- sql2deltalake/load_lock.py +72 -0
- sql2deltalake/load_result.py +40 -0
- sql2deltalake/logging.py +21 -0
- sql2deltalake/metadata.py +238 -0
- sql2deltalake/query.py +55 -0
- sql2deltalake/read_utils/delta_rs.py +180 -0
- sql2deltalake/read_utils/duckdb_utils.py +14 -0
- sql2deltalake/reader/__init__.py +1 -0
- sql2deltalake/reader/adbc_reader.py +128 -0
- sql2deltalake/reader/arrowsql_reader.py +249 -0
- sql2deltalake/reader/local_reader.py +268 -0
- sql2deltalake/reader/reader.py +131 -0
- sql2deltalake/reader/spark_reader.py +465 -0
- sql2deltalake/spark/__init__.py +50 -0
- sql2deltalake/spark/_driver.py +1012 -0
- sql2deltalake/spark/_options.py +178 -0
- sql2deltalake/spark/_plan.py +223 -0
- sql2deltalake/spark/_source.py +85 -0
- sql2deltalake/spark/_source_load.py +393 -0
- sql2deltalake/sql_glot_utils.py +56 -0
- sql2deltalake/sql_schema.py +10 -0
- sql2deltalake/utils.py +32 -0
- sql2deltalake/write_init.py +341 -0
- sql2deltalake/write_utils/__init__.py +0 -0
- sql2deltalake/write_utils/restore_pk.py +247 -0
- sql2deltalake-0.1.0rc1.dist-info/METADATA +288 -0
- sql2deltalake-0.1.0rc1.dist-info/RECORD +44 -0
- sql2deltalake-0.1.0rc1.dist-info/WHEEL +4 -0
- sql2deltalake-0.1.0rc1.dist-info/licenses/LICENSE +21 -0
- sql2deltalake-0.1.0rc1.dist-info/sboms/sql2deltalake-py.cyclonedx.json +11568 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""sql2deltalake: incremental loads from SQL Server and Postgres into append-only SCD2 Delta tables.
|
|
2
|
+
|
|
3
|
+
The Spark data source lives in ``sql2deltalake.spark``."""
|
|
4
|
+
|
|
5
|
+
from .write_init import (
|
|
6
|
+
make_writer,
|
|
7
|
+
DBDeltaPathConfigs,
|
|
8
|
+
WriteConfig,
|
|
9
|
+
DEFAULT_DATA_TYPE_MAP,
|
|
10
|
+
WriteConfigAndInfos,
|
|
11
|
+
)
|
|
12
|
+
from .reader.reader import DataSourceReader
|
|
13
|
+
from .destination.destination import Destination
|
|
14
|
+
from .load_lock import LoadAlreadyRunning
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write_db_to_delta(
|
|
20
|
+
source: Union[DataSourceReader, str],
|
|
21
|
+
table_or_query: tuple[str, str],
|
|
22
|
+
destination: Union[Destination, Path],
|
|
23
|
+
write_config: Union[WriteConfig, None] = None,
|
|
24
|
+
):
|
|
25
|
+
return make_writer(
|
|
26
|
+
source=source,
|
|
27
|
+
table_or_query=table_or_query,
|
|
28
|
+
destination=destination,
|
|
29
|
+
write_config=write_config,
|
|
30
|
+
).execute()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"DEFAULT_DATA_TYPE_MAP",
|
|
35
|
+
"DataSourceReader",
|
|
36
|
+
"DBDeltaPathConfigs",
|
|
37
|
+
"Destination",
|
|
38
|
+
"LoadAlreadyRunning",
|
|
39
|
+
"WriteConfig",
|
|
40
|
+
"WriteConfigAndInfos",
|
|
41
|
+
"make_writer",
|
|
42
|
+
"write_db_to_delta",
|
|
43
|
+
]
|
sql2deltalake/_core.pyd
ADDED
sql2deltalake/_core.pyi
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Type stubs of the Rust extension module (crates/sql2deltalake-py)."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Optional, Sequence, Union
|
|
4
|
+
|
|
5
|
+
__version__: str
|
|
6
|
+
|
|
7
|
+
class ArrowStream:
|
|
8
|
+
"""An Arrow C stream (`__arrow_c_stream__`), consumable once."""
|
|
9
|
+
|
|
10
|
+
def __arrow_c_stream__(self, requested_schema: Optional[object] = None) -> object: ...
|
|
11
|
+
def __arrow_c_schema__(self) -> object: ...
|
|
12
|
+
|
|
13
|
+
class SqlBuilder:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
dialect_name: str,
|
|
17
|
+
source_ref: Union[Sequence[str], str],
|
|
18
|
+
type_map_spec: Mapping[str, tuple[str, bool]],
|
|
19
|
+
no_trim: bool,
|
|
20
|
+
columns: Sequence[Mapping[str, Any]],
|
|
21
|
+
) -> None: ...
|
|
22
|
+
def full_load(self, valid_from: Optional[str] = None) -> str: ...
|
|
23
|
+
def pk_ts(self, columns: Sequence[int]) -> str: ...
|
|
24
|
+
def updates(self, criteria: Sequence[str], valid_from: Optional[str] = None) -> str: ...
|
|
25
|
+
def criterion(self, column: int, op: str, value: Any) -> str: ...
|
|
26
|
+
def source_ts_cnt(self, column: Optional[int] = None) -> str: ...
|
|
27
|
+
def rows_by_pk_json(
|
|
28
|
+
self, pks: Sequence[int], json: str, valid_from: Optional[str] = None
|
|
29
|
+
) -> str: ...
|
|
30
|
+
|
|
31
|
+
def load_timestamp_literal(dialect_name: str, micros: int) -> str: ...
|
|
32
|
+
def compat_name(name: str) -> str: ...
|
|
33
|
+
def normalize(
|
|
34
|
+
source: object,
|
|
35
|
+
time_scales: Optional[Mapping[int, int]] = None,
|
|
36
|
+
target: Optional[object] = None,
|
|
37
|
+
) -> ArrowStream: ...
|
|
38
|
+
def read_sql(
|
|
39
|
+
connection_string: str,
|
|
40
|
+
sql: str,
|
|
41
|
+
batch_size: int = 65536,
|
|
42
|
+
utf8_strings: bool = False,
|
|
43
|
+
partition_on: Optional[str] = None,
|
|
44
|
+
partitions: int = 1,
|
|
45
|
+
) -> ArrowStream:
|
|
46
|
+
"""Only in builds with the cargo feature `arrowsql-core` (the default)."""
|
|
47
|
+
...
|
|
48
|
+
def pk_diff(
|
|
49
|
+
snapshot: object,
|
|
50
|
+
previous: object,
|
|
51
|
+
keys: Sequence[str],
|
|
52
|
+
delta: str,
|
|
53
|
+
watermark: object,
|
|
54
|
+
) -> dict[str, Any]: ...
|
|
55
|
+
def count_new_keys(rows: object, previous: object, keys: Sequence[str]) -> int: ...
|
|
56
|
+
def merge_latest(
|
|
57
|
+
previous: Optional[object],
|
|
58
|
+
loaded: object,
|
|
59
|
+
keys: Sequence[str],
|
|
60
|
+
delta: str,
|
|
61
|
+
order: str = "__timestamp",
|
|
62
|
+
deleted: str = "__is_deleted",
|
|
63
|
+
) -> dict[str, Any]: ...
|
|
64
|
+
def replay_state(
|
|
65
|
+
log: object,
|
|
66
|
+
keys: Sequence[str],
|
|
67
|
+
delta: str,
|
|
68
|
+
seq: str = "__state_seq",
|
|
69
|
+
deleted: str = "__is_deleted",
|
|
70
|
+
) -> ArrowStream: ...
|
|
71
|
+
def run_load(host: object, load_spec: Mapping[str, Any]) -> dict[str, Any]: ...
|
|
72
|
+
def restore_last_pk(host: object, load_spec: Mapping[str, Any]) -> bool: ...
|
sql2deltalake/_native.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""The boundary to the Rust core (`sql2deltalake._core`): sqlglot objects and user callables are
|
|
2
|
+
turned into plain values here, once per load, so the core needs no Python while it runs."""
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Mapping, Union
|
|
5
|
+
|
|
6
|
+
import sqlglot.expressions as ex
|
|
7
|
+
|
|
8
|
+
from .sql_schema import is_string_type
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .metadata import InformationSchemaColInfo
|
|
12
|
+
from .write_init import WriteConfig, WriteConfigAndInfos
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def column_spec(
|
|
16
|
+
c: "InformationSchemaColInfo", write_config: "WriteConfig"
|
|
17
|
+
) -> dict[str, Any]:
|
|
18
|
+
dialect = write_config.dialect
|
|
19
|
+
return {
|
|
20
|
+
"name": c.column_name,
|
|
21
|
+
"target": write_config.get_target_name(c),
|
|
22
|
+
"type_key": ex.DataType(this=c.data_type.this).sql(dialect).lower(),
|
|
23
|
+
"type_sql": c.data_type.sql(dialect),
|
|
24
|
+
"orig_type": c.data_type_str or "",
|
|
25
|
+
"is_string": bool(is_string_type(c.data_type)),
|
|
26
|
+
"is_numeric": c.data_type.this
|
|
27
|
+
in [ex.DataType.Type.BOOLEAN, *ex.DataType.NUMERIC_TYPES],
|
|
28
|
+
"is_identity": bool(c.is_identity),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def type_map_spec(write_config: "WriteConfig") -> dict[str, tuple[str, bool]]:
|
|
33
|
+
from .write_init import DEFAULT_DATA_TYPE_MAP
|
|
34
|
+
|
|
35
|
+
tm: Mapping[str, Union[ex.DataType, ex.DataType.Type]] = (
|
|
36
|
+
DEFAULT_DATA_TYPE_MAP.get(write_config.dialect, {})
|
|
37
|
+
if write_config.data_type_map == "default"
|
|
38
|
+
else write_config.data_type_map
|
|
39
|
+
)
|
|
40
|
+
res: dict[str, tuple[str, bool]] = {}
|
|
41
|
+
for key, mapped in tm.items():
|
|
42
|
+
if not isinstance(mapped, (ex.DataType, ex.DataType.Type, str)):
|
|
43
|
+
continue # never looked up, e.g. a dialect-keyed map merged in by mistake
|
|
44
|
+
to = ex.cast(ex.column("x"), mapped).to # what sqlglot renders for CAST(x AS mapped)
|
|
45
|
+
res[key] = (to.sql(write_config.dialect), bool(is_string_type(mapped)))
|
|
46
|
+
return res
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def source_spec(infos: "WriteConfigAndInfos") -> Union[list[str], str]:
|
|
50
|
+
"""The table name parts, or the query rendered in the source dialect."""
|
|
51
|
+
toq = infos.table_or_query
|
|
52
|
+
if isinstance(toq, ex.Query):
|
|
53
|
+
return toq.sql(infos.write_config.dialect)
|
|
54
|
+
if isinstance(toq, str):
|
|
55
|
+
return [toq]
|
|
56
|
+
return list(toq)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fail_point(name: str) -> "Union[bool, None]":
|
|
60
|
+
"""Test hook, called by the load at a few named steps (`load_updates`, `additional_updates`,
|
|
61
|
+
`restore_last_pk`, `write_delta2`, `local_pk_count`). Tests patch it to inject a failure or to
|
|
62
|
+
change the source mid-load; returning False from `restore_last_pk` skips the restore."""
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PanicError(RuntimeError):
|
|
67
|
+
"""A Rust panic during a load (pyo3's ``PanicException``, a ``BaseException``; arrowsql
|
|
68
|
+
raises one when the server kills the connection), turned into an ``Exception`` once the load
|
|
69
|
+
restored its state, so ``except Exception`` in callers sees it."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_panic(e: BaseException) -> bool:
|
|
73
|
+
"""pyo3's ``pyo3_runtime.PanicException``, of any extension module (it cannot be imported)."""
|
|
74
|
+
return not isinstance(e, Exception) and type(e).__name__ == "PanicException"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def as_load_error(e: BaseException) -> BaseException:
|
|
78
|
+
"""``e``, or a :class:`PanicError` caused by it if it is a panic."""
|
|
79
|
+
if is_panic(e):
|
|
80
|
+
err = PanicError(f"the load panicked: {e}")
|
|
81
|
+
err.__cause__ = e
|
|
82
|
+
if getattr(e, "__notes__", None): # e.g. a restore that failed too
|
|
83
|
+
err.__notes__ = list(e.__notes__) # type: ignore[attr-defined]
|
|
84
|
+
return err
|
|
85
|
+
return e
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def enabled() -> bool:
|
|
89
|
+
import os
|
|
90
|
+
|
|
91
|
+
return os.getenv("SQL2DELTA_NATIVE", "1") not in ("0", "false", "False", "")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def supports(infos: "WriteConfigAndInfos") -> bool:
|
|
95
|
+
"""The Rust load handles readers with local Delta tables; for now SQL Server through arrowsql."""
|
|
96
|
+
from .reader.arrowsql_reader import ArrowSQLReader
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
enabled()
|
|
100
|
+
and isinstance(infos.source, ArrowSQLReader)
|
|
101
|
+
and infos.write_config.dialect in ("tsql", "mssql")
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _overrides(reader, name: str) -> bool:
|
|
106
|
+
"""True if a subclass replaced the reader's own implementation of `name`."""
|
|
107
|
+
from .reader.adbc_reader import ADBCReader
|
|
108
|
+
from .reader.arrowsql_reader import ArrowSQLReader
|
|
109
|
+
|
|
110
|
+
impl = getattr(type(reader), name)
|
|
111
|
+
return all(
|
|
112
|
+
impl is not getattr(base, name, None) for base in (ArrowSQLReader, ADBCReader)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Host:
|
|
117
|
+
"""What the Rust load calls back into: the source, the Delta tables below the destination and
|
|
118
|
+
the logger. Data crosses as Arrow C streams."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, infos: "WriteConfigAndInfos"):
|
|
121
|
+
self.infos = infos
|
|
122
|
+
self.reader = infos.source
|
|
123
|
+
self.dest = infos.destination
|
|
124
|
+
self.logger = infos.logger
|
|
125
|
+
self._copies = _overrides(self.reader, "source_write_sql_to_delta")
|
|
126
|
+
|
|
127
|
+
# source
|
|
128
|
+
def source_query(self, sql: str):
|
|
129
|
+
reader = self.reader
|
|
130
|
+
if hasattr(reader, "_source_arrow") and not _overrides(reader, "source_sql_to_py"):
|
|
131
|
+
return reader._source_arrow(sql, utf8_strings=False) # type: ignore
|
|
132
|
+
import pyarrow as pa
|
|
133
|
+
|
|
134
|
+
return pa.Table.from_pylist(reader.source_sql_to_py(sql))
|
|
135
|
+
|
|
136
|
+
def source_connection(self):
|
|
137
|
+
"""(connection string, utf8_strings, partitions) if Rust reads the source itself through
|
|
138
|
+
arrowsql's core, None if the reads go through the reader's Python methods."""
|
|
139
|
+
from .reader.arrowsql_reader import ArrowSQLSource, core_reads
|
|
140
|
+
|
|
141
|
+
reader = self.reader
|
|
142
|
+
if (
|
|
143
|
+
self._copies
|
|
144
|
+
or not isinstance(reader, ArrowSQLSource)
|
|
145
|
+
or _overrides(reader, "source_sql_to_py")
|
|
146
|
+
or _overrides(reader, "_source_arrow")
|
|
147
|
+
or not core_reads(reader.connection_string)
|
|
148
|
+
):
|
|
149
|
+
return None
|
|
150
|
+
return (reader.connection_string, reader.utf8_strings, reader.partitions)
|
|
151
|
+
|
|
152
|
+
def source_stream(self, sql: str, partition_on=None):
|
|
153
|
+
if self._copies or not hasattr(self.reader, "_source_arrow"):
|
|
154
|
+
return None
|
|
155
|
+
return self.reader._source_arrow( # type: ignore
|
|
156
|
+
sql,
|
|
157
|
+
utf8_strings=getattr(self.reader, "utf8_strings", False),
|
|
158
|
+
partition_on=partition_on,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def source_copy(self, sql: str, rel: str, mode, drift):
|
|
162
|
+
self.reader.source_write_sql_to_delta(
|
|
163
|
+
sql, self.dest / rel, mode, allow_schema_drift=drift
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# target
|
|
167
|
+
def kernel_root(self):
|
|
168
|
+
"""The target folder as a local path, whose Delta tables Rust reads itself (delta-kernel-rs)."""
|
|
169
|
+
from .destination.file_system import FileSystemDestination
|
|
170
|
+
|
|
171
|
+
if type(self.dest) is FileSystemDestination:
|
|
172
|
+
return str(self.dest.path.absolute())
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def _dt(self, rel: str, version=None):
|
|
176
|
+
from .reader.local_reader import _delta_table
|
|
177
|
+
|
|
178
|
+
dt = _delta_table(self.dest / rel)
|
|
179
|
+
if version is not None:
|
|
180
|
+
dt.load_as_version(version)
|
|
181
|
+
return dt
|
|
182
|
+
|
|
183
|
+
def table_exists(self, rel: str) -> bool:
|
|
184
|
+
return self.reader.local_delta_table_exists(self.dest / rel)
|
|
185
|
+
|
|
186
|
+
def path_exists(self, rel: str) -> bool:
|
|
187
|
+
return (self.dest / rel).exists()
|
|
188
|
+
|
|
189
|
+
def version(self, rel: str) -> int:
|
|
190
|
+
return self._dt(rel).version()
|
|
191
|
+
|
|
192
|
+
def table_id(self, rel: str) -> "Union[str, None]":
|
|
193
|
+
return str(self._dt(rel).metadata().id) # a UUID string (typed int by deltalake)
|
|
194
|
+
|
|
195
|
+
def read(self, rel: str, version, columns):
|
|
196
|
+
import pyarrow as pa
|
|
197
|
+
|
|
198
|
+
ds = self._dt(rel, version).to_pyarrow_dataset()
|
|
199
|
+
if columns is not None:
|
|
200
|
+
names = {n.lower(): n for n in ds.schema.names}
|
|
201
|
+
columns = [c if c in ds.schema.names else names.get(c.lower(), c) for c in columns]
|
|
202
|
+
return ds.scanner(columns=columns).to_reader()
|
|
203
|
+
|
|
204
|
+
def column_names(self, rel: str) -> "list[str]":
|
|
205
|
+
return [f.name for f in self._dt(rel).schema().fields]
|
|
206
|
+
|
|
207
|
+
def write(self, rel: str, stream, mode, drift):
|
|
208
|
+
import pyarrow as pa
|
|
209
|
+
|
|
210
|
+
self.reader._write_reader_to_delta( # type: ignore
|
|
211
|
+
pa.RecordBatchReader.from_stream(stream),
|
|
212
|
+
self.dest / rel,
|
|
213
|
+
mode,
|
|
214
|
+
drift,
|
|
215
|
+
all_nullable=True,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def restore(self, rel: str, version: int):
|
|
219
|
+
self.reader.get_local_delta_ops(self.dest / rel).restore(version)
|
|
220
|
+
|
|
221
|
+
def remove(self, rel: str, recursive: bool):
|
|
222
|
+
(self.dest / rel).remove(recursive)
|
|
223
|
+
|
|
224
|
+
def vacuum(self, rel: str):
|
|
225
|
+
self.reader.get_local_delta_ops(self.dest / rel).vacuum()
|
|
226
|
+
|
|
227
|
+
def mkdir(self, rel: str):
|
|
228
|
+
(self.dest / rel).mkdir()
|
|
229
|
+
|
|
230
|
+
def acquire_lock(self, rel: str) -> str:
|
|
231
|
+
from .load_lock import acquire
|
|
232
|
+
|
|
233
|
+
return acquire(self.dest / rel, self.logger)
|
|
234
|
+
|
|
235
|
+
def release_lock(self, rel: str, token: str):
|
|
236
|
+
from .load_lock import release
|
|
237
|
+
|
|
238
|
+
release(self.dest / rel, token, self.logger)
|
|
239
|
+
|
|
240
|
+
def log(self, level: str, msg: str, kwargs: dict):
|
|
241
|
+
getattr(self.logger, level)(msg, **kwargs)
|
|
242
|
+
|
|
243
|
+
def fail_point(self, name: str):
|
|
244
|
+
return fail_point(name)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def load_spec(infos: "WriteConfigAndInfos") -> dict[str, Any]:
|
|
248
|
+
from .db_to_delta import (
|
|
249
|
+
_shortcut_allowed,
|
|
250
|
+
_source_schema_fingerprint,
|
|
251
|
+
_watermark_parts,
|
|
252
|
+
_watermark_sql,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
wc = infos.write_config
|
|
256
|
+
cols = list(infos.col_infos)
|
|
257
|
+
|
|
258
|
+
def _index(c) -> int:
|
|
259
|
+
for i, x in enumerate(cols):
|
|
260
|
+
if x is c:
|
|
261
|
+
return i
|
|
262
|
+
for i, x in enumerate(cols):
|
|
263
|
+
if x.column_name == c.column_name:
|
|
264
|
+
return i
|
|
265
|
+
raise ValueError(f"column {c.column_name} not in the columns of the load")
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
"dialect": wc.dialect,
|
|
269
|
+
"source": source_spec(infos),
|
|
270
|
+
"type_map": type_map_spec(wc),
|
|
271
|
+
"no_trim": wc.no_trim,
|
|
272
|
+
"columns": [column_spec(c, wc) for c in cols],
|
|
273
|
+
"pks": [_index(c) for c in infos.pk_cols],
|
|
274
|
+
"delta_col": _index(infos.delta_col) if infos.delta_col is not None else None,
|
|
275
|
+
"load_mode": wc.load_mode,
|
|
276
|
+
"allow_schema_drift": wc.allow_schema_drift,
|
|
277
|
+
"no_complex_entries_load": wc.no_complex_entries_load,
|
|
278
|
+
"schema_fingerprint": _source_schema_fingerprint(infos),
|
|
279
|
+
"shortcut_allowed": _shortcut_allowed(infos),
|
|
280
|
+
"watermark_sql": _watermark_sql(infos),
|
|
281
|
+
"watermark_parts": _watermark_parts(infos),
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _result(res: dict):
|
|
286
|
+
from .load_result import (
|
|
287
|
+
AppendOnlyLoadResult,
|
|
288
|
+
DeltaLoadResult,
|
|
289
|
+
FullLoadResult,
|
|
290
|
+
NoLoadResult,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
t = res["type"]
|
|
294
|
+
if t == "full":
|
|
295
|
+
return FullLoadResult()
|
|
296
|
+
if t == "none":
|
|
297
|
+
return NoLoadResult()
|
|
298
|
+
if t == "append_only":
|
|
299
|
+
return AppendOnlyLoadResult()
|
|
300
|
+
r = DeltaLoadResult()
|
|
301
|
+
r.starting_local_state = res["starting_local_state"]
|
|
302
|
+
r.starting_source_state = res["starting_source_state"]
|
|
303
|
+
if "end_source_state" in res:
|
|
304
|
+
r.end_source_state = res["end_source_state"]
|
|
305
|
+
r.dirty = res["dirty"]
|
|
306
|
+
return r
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def execute(infos: "WriteConfigAndInfos"):
|
|
310
|
+
"""Runs the load in Rust (`_core.run_load`); the GIL is released while it runs."""
|
|
311
|
+
from . import _core
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
return _result(_core.run_load(Host(infos), load_spec(infos)))
|
|
315
|
+
except BaseException as e:
|
|
316
|
+
err = as_load_error(e)
|
|
317
|
+
if err is e:
|
|
318
|
+
raise
|
|
319
|
+
raise err from e
|
|
320
|
+
finally:
|
|
321
|
+
infos.logger.flush()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def restore_last_pk(infos: "WriteConfigAndInfos") -> bool:
|
|
325
|
+
from . import _core
|
|
326
|
+
|
|
327
|
+
return _core.restore_last_pk(Host(infos), load_spec(infos))
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from .write_init import WriteConfigAndInfos, DBDeltaPathConfigs
|
|
3
|
+
from .write_utils.restore_pk import create_last_pk_version_view
|
|
4
|
+
import sqlglot as sg
|
|
5
|
+
import sqlglot.expressions as ex
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InconsistentPrimaryKeyError(Exception):
|
|
9
|
+
"""Exception raised when primary keys are inconsistent."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message, invalid_data: list[dict[str, Any]]):
|
|
12
|
+
self.message = message
|
|
13
|
+
self.invalid_data = invalid_data
|
|
14
|
+
super().__init__(self.message)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_latest_pk(infos: WriteConfigAndInfos, raise_if_not_consistent=True):
|
|
18
|
+
if not infos.delta_col or not infos.pk_cols:
|
|
19
|
+
raise ValueError("Primary keys and delta column must be defined")
|
|
20
|
+
postfix = "_" + str(abs(hash(str(infos.destination))))
|
|
21
|
+
lpk_view = "lastest_pk_" + postfix
|
|
22
|
+
infos.source.local_register_update_view(
|
|
23
|
+
infos.destination / "delta_load" / DBDeltaPathConfigs.LATEST_PK_VERSION,
|
|
24
|
+
lpk_view,
|
|
25
|
+
)
|
|
26
|
+
_, view_name, success = create_last_pk_version_view(
|
|
27
|
+
infos, view_prefix="v_" + postfix
|
|
28
|
+
)
|
|
29
|
+
d_cols = list(infos.pk_cols) + [infos.delta_col]
|
|
30
|
+
col_names = [infos.write_config.get_target_name(p) for p in d_cols]
|
|
31
|
+
assert success
|
|
32
|
+
assert view_name is not None
|
|
33
|
+
query1 = sg.except_(
|
|
34
|
+
sg.from_(ex.table_(lpk_view, alias="lpk"))
|
|
35
|
+
.select(*[ex.column(c, "lpk", quoted=True) for c in col_names])
|
|
36
|
+
.select(ex.convert("added in persisted data"), append=True),
|
|
37
|
+
sg.from_(ex.table_(view_name, alias="rs"))
|
|
38
|
+
.select(*[ex.column(c, "rs", quoted=True) for c in col_names])
|
|
39
|
+
.select(ex.convert("added in persisted data"), append=True),
|
|
40
|
+
)
|
|
41
|
+
query2 = sg.except_(
|
|
42
|
+
sg.from_(ex.table_(view_name, alias="rs"))
|
|
43
|
+
.select(*[ex.column(c, "rs", quoted=True) for c in col_names])
|
|
44
|
+
.select(ex.convert("missing in persisted data"), append=True),
|
|
45
|
+
sg.from_(ex.table_(lpk_view, alias="lpk"))
|
|
46
|
+
.select(*[ex.column(c, "lpk", quoted=True) for c in col_names])
|
|
47
|
+
.select(ex.convert("missing in persisted data"), append=True),
|
|
48
|
+
)
|
|
49
|
+
result = infos.source.local_execute_sql_to_py(
|
|
50
|
+
ex.union(query1, query2, distinct=False)
|
|
51
|
+
)
|
|
52
|
+
if result:
|
|
53
|
+
print(result)
|
|
54
|
+
if raise_if_not_consistent:
|
|
55
|
+
raise InconsistentPrimaryKeyError("Primary keys are not consistent", result)
|
|
56
|
+
return result
|