adbc-replicate 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.
- adbc_replicate/__init__.py +3 -0
- adbc_replicate/arrowutil.py +112 -0
- adbc_replicate/cli.py +302 -0
- adbc_replicate/config.py +348 -0
- adbc_replicate/engine.py +497 -0
- adbc_replicate/events.py +57 -0
- adbc_replicate/matching.py +69 -0
- adbc_replicate/models.py +90 -0
- adbc_replicate/planner.py +183 -0
- adbc_replicate/sources/__init__.py +24 -0
- adbc_replicate/sources/base.py +175 -0
- adbc_replicate/sources/duckdb.py +93 -0
- adbc_replicate/sources/oracle.py +180 -0
- adbc_replicate/sources/postgresql.py +154 -0
- adbc_replicate/sources/snowflake.py +138 -0
- adbc_replicate/state.py +196 -0
- adbc_replicate/targets/__init__.py +5 -0
- adbc_replicate/targets/duckdb.py +139 -0
- adbc_replicate/views.py +162 -0
- adbc_replicate-0.1.0.dist-info/METADATA +650 -0
- adbc_replicate-0.1.0.dist-info/RECORD +24 -0
- adbc_replicate-0.1.0.dist-info/WHEEL +4 -0
- adbc_replicate-0.1.0.dist-info/entry_points.txt +2 -0
- adbc_replicate-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Streaming wrappers around a RecordBatchReader. Each one keeps the stream streamed: at most
|
|
2
|
+
one (coalesced) batch is held at a time, so client memory doesn't grow with table size."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
import pyarrow as pa
|
|
9
|
+
import pyarrow.compute as pc
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _storage_type(data_type: pa.DataType) -> pa.DataType:
|
|
13
|
+
"""Strip Arrow extension types (arrow.json, arrow.opaque, ...) down to their storage type,
|
|
14
|
+
recursively, and widen second/millisecond timestamps. Targets ingest the physical types;
|
|
15
|
+
the extension metadata is source-specific."""
|
|
16
|
+
if isinstance(data_type, pa.BaseExtensionType):
|
|
17
|
+
return _storage_type(data_type=data_type.storage_type)
|
|
18
|
+
if pa.types.is_list(data_type):
|
|
19
|
+
return pa.list_(_storage_type(data_type=data_type.value_type))
|
|
20
|
+
if pa.types.is_large_list(data_type):
|
|
21
|
+
return pa.large_list(_storage_type(data_type=data_type.value_type))
|
|
22
|
+
if pa.types.is_struct(data_type):
|
|
23
|
+
return pa.struct([f.with_type(_storage_type(data_type=f.type)) for f in data_type])
|
|
24
|
+
if pa.types.is_map(data_type):
|
|
25
|
+
return pa.map_(_storage_type(data_type=data_type.key_type), _storage_type(data_type=data_type.item_type))
|
|
26
|
+
if pa.types.is_timestamp(data_type) and data_type.unit in ("s", "ms"):
|
|
27
|
+
# Widen to microseconds (lossless): DuckDB's native TIMESTAMP, rather than
|
|
28
|
+
# TIMESTAMP_S / TIMESTAMP_MS, which some BI tools and drivers handle poorly.
|
|
29
|
+
return pa.timestamp(unit="us", tz=data_type.tz)
|
|
30
|
+
return data_type
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _strip_array(array: pa.Array, target: pa.DataType) -> pa.Array:
|
|
34
|
+
if isinstance(array, pa.ExtensionArray):
|
|
35
|
+
array = array.storage
|
|
36
|
+
if array.type.equals(target):
|
|
37
|
+
return array
|
|
38
|
+
return array.cast(target_type=target)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def transform(reader: pa.RecordBatchReader, names: list[str],
|
|
42
|
+
casts: dict[int, pa.DataType] | None = None) -> pa.RecordBatchReader:
|
|
43
|
+
"""Rename every column to `names` (zero-copy), strip extension types, and apply the
|
|
44
|
+
per-column `casts` ({column index: type}; a strict Arrow cast, so precision loss raises).
|
|
45
|
+
|
|
46
|
+
An empty source yields a single zero-row batch, because a Flight DoPut needs at least one
|
|
47
|
+
message to carry the schema.
|
|
48
|
+
"""
|
|
49
|
+
casts = casts or {}
|
|
50
|
+
fields = []
|
|
51
|
+
for i, f in enumerate(reader.schema):
|
|
52
|
+
data_type = casts.get(i) or _storage_type(data_type=f.type)
|
|
53
|
+
fields.append(pa.field(names[i], data_type, nullable=True))
|
|
54
|
+
schema = pa.schema(fields)
|
|
55
|
+
|
|
56
|
+
def convert(batch: pa.RecordBatch) -> pa.RecordBatch:
|
|
57
|
+
arrays = []
|
|
58
|
+
for i, array in enumerate(batch.columns):
|
|
59
|
+
target = schema.field(i).type
|
|
60
|
+
if isinstance(array, pa.ExtensionArray):
|
|
61
|
+
array = array.storage
|
|
62
|
+
if not array.type.equals(target):
|
|
63
|
+
array = pc.cast(array, target_type=target) if i in casts else _strip_array(array=array, target=target)
|
|
64
|
+
arrays.append(array)
|
|
65
|
+
return pa.RecordBatch.from_arrays(arrays=arrays, schema=schema)
|
|
66
|
+
|
|
67
|
+
def batches():
|
|
68
|
+
yielded = False
|
|
69
|
+
for batch in reader:
|
|
70
|
+
yielded = True
|
|
71
|
+
yield convert(batch=batch)
|
|
72
|
+
if not yielded:
|
|
73
|
+
yield pa.RecordBatch.from_arrays(arrays=[pa.array([], type=f.type) for f in schema], schema=schema)
|
|
74
|
+
|
|
75
|
+
return pa.RecordBatchReader.from_batches(schema=schema, batches=batches())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def coalesce(reader: pa.RecordBatchReader, max_rows: int, max_bytes: int) -> pa.RecordBatchReader:
|
|
79
|
+
"""Combine consecutive small batches into batches of up to `max_rows` rows and `max_bytes`
|
|
80
|
+
Arrow buffer bytes. A pending group is sent before a batch that would push it over either
|
|
81
|
+
cap; a single source batch that is already bigger passes through unchanged."""
|
|
82
|
+
schema = reader.schema
|
|
83
|
+
|
|
84
|
+
def combine(pending: list[pa.RecordBatch]) -> pa.RecordBatch:
|
|
85
|
+
if len(pending) == 1:
|
|
86
|
+
return pending[0]
|
|
87
|
+
return pa.Table.from_batches(batches=pending, schema=schema).combine_chunks().to_batches()[0]
|
|
88
|
+
|
|
89
|
+
def batches():
|
|
90
|
+
pending: list[pa.RecordBatch] = []
|
|
91
|
+
rows = size = 0
|
|
92
|
+
for batch in reader:
|
|
93
|
+
if pending and (rows + batch.num_rows > max_rows or size + batch.nbytes > max_bytes):
|
|
94
|
+
yield combine(pending=pending)
|
|
95
|
+
pending, rows, size = [], 0, 0
|
|
96
|
+
pending.append(batch)
|
|
97
|
+
rows += batch.num_rows
|
|
98
|
+
size += batch.nbytes
|
|
99
|
+
if pending:
|
|
100
|
+
yield combine(pending=pending)
|
|
101
|
+
|
|
102
|
+
return pa.RecordBatchReader.from_batches(schema=schema, batches=batches())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def counting(reader: pa.RecordBatchReader, on_batch: Callable[[int], None]) -> pa.RecordBatchReader:
|
|
106
|
+
"""Call `on_batch(rows)` for every batch that passes through (for progress reporting)."""
|
|
107
|
+
def batches():
|
|
108
|
+
for batch in reader:
|
|
109
|
+
on_batch(batch.num_rows)
|
|
110
|
+
yield batch
|
|
111
|
+
|
|
112
|
+
return pa.RecordBatchReader.from_batches(schema=reader.schema, batches=batches())
|
adbc_replicate/cli.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""The adbc-replicate command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .config import ConfigError, json_schema, load_config
|
|
15
|
+
from .engine import EXIT_CONFIG, EXIT_FAILED, EXIT_OK, Replicator, RunOptions
|
|
16
|
+
from .events import Reporter, fmt_seconds
|
|
17
|
+
from .planner import PlanError
|
|
18
|
+
from .views import ViewTranslationError, create_view_sql, load_override, translate_view
|
|
19
|
+
|
|
20
|
+
CONFIG_OPTION = click.option("-c", "--config", "config_path", required=True, type=click.Path(dir_okay=False),
|
|
21
|
+
envvar="ADBC_REPLICATE_CONFIG", help="Replication config (YAML or JSON).")
|
|
22
|
+
ENV_FILE_OPTION = click.option("--env-file", type=click.Path(dir_okay=False), envvar="DOTENV_PATH",
|
|
23
|
+
help="Load environment variables from this file first (existing variables win).")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load(config_path: str, env_file: str | None):
|
|
27
|
+
if env_file:
|
|
28
|
+
from dotenv import load_dotenv
|
|
29
|
+
if not Path(env_file).is_file():
|
|
30
|
+
raise ConfigError(f"env file {env_file} not found")
|
|
31
|
+
load_dotenv(dotenv_path=env_file, override=False)
|
|
32
|
+
return load_config(path=config_path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fail(message: str, code: int = EXIT_CONFIG) -> None:
|
|
36
|
+
click.echo(f"β {message}", err=True)
|
|
37
|
+
sys.exit(code)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
41
|
+
@click.version_option(version=__version__, prog_name="adbc-replicate")
|
|
42
|
+
def main() -> None:
|
|
43
|
+
"""π adbc-replicate β stream whole schemas from one database into another over ADBC.
|
|
44
|
+
|
|
45
|
+
Describe what to replicate in a YAML config, check it with `plan`, then `run` it.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@main.command()
|
|
50
|
+
@CONFIG_OPTION
|
|
51
|
+
@ENV_FILE_OPTION
|
|
52
|
+
@click.option("--full-refresh", is_flag=True, help="Copy every table and recreate every view, ignoring sync state.")
|
|
53
|
+
@click.option("--row-limit", type=click.IntRange(min=0), default=None,
|
|
54
|
+
help="Copy at most N rows per table: a quick end-to-end trial. Sampled tables are re-copied in "
|
|
55
|
+
"full by the next normal run.")
|
|
56
|
+
@click.option("--tables", "only", multiple=True, metavar="PATTERN",
|
|
57
|
+
help="Only these DB_SCHEMA.NAME patterns (repeatable), e.g. --tables 'HR.EMP*'.")
|
|
58
|
+
@click.option("--parallelism", type=click.IntRange(min=1, max=64), default=None,
|
|
59
|
+
help="Override the config's parallelism.")
|
|
60
|
+
@click.option("--skip-views", is_flag=True, help="Copy tables only.")
|
|
61
|
+
@click.option("--log-format", type=click.Choice(["text", "json"]), default="text", show_default=True,
|
|
62
|
+
help="json: one event object per line (for log pipelines and UIs).")
|
|
63
|
+
@click.option("-q", "--quiet", is_flag=True, help="Text mode: only warnings, errors and the summary.")
|
|
64
|
+
def run(config_path, env_file, full_refresh, row_limit, only, parallelism, skip_views, log_format, quiet) -> None:
|
|
65
|
+
"""Run the replication."""
|
|
66
|
+
try:
|
|
67
|
+
config = _load(config_path=config_path, env_file=env_file)
|
|
68
|
+
except ConfigError as e:
|
|
69
|
+
_fail(message=str(e))
|
|
70
|
+
if parallelism and parallelism > 1 and config.source.consistency == "serializable":
|
|
71
|
+
_fail(message="consistency 'serializable' needs parallelism 1")
|
|
72
|
+
reporter = Reporter(fmt=log_format, quiet=quiet)
|
|
73
|
+
started = time.monotonic()
|
|
74
|
+
replicator = Replicator(config=config, reporter=reporter,
|
|
75
|
+
options=RunOptions(full_refresh=full_refresh, row_limit=row_limit, only=list(only),
|
|
76
|
+
parallelism=parallelism, skip_views=skip_views))
|
|
77
|
+
try:
|
|
78
|
+
summary = replicator.run()
|
|
79
|
+
except PlanError as e:
|
|
80
|
+
reporter.emit(event="run_failed", text=f"β {e}", level="error", error=str(e))
|
|
81
|
+
sys.exit(EXIT_CONFIG)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
reporter.emit(event="run_failed", text=f"π₯ {type(e).__name__}: {e}", level="error",
|
|
84
|
+
error=f"{type(e).__name__}: {e}")
|
|
85
|
+
sys.exit(EXIT_FAILED)
|
|
86
|
+
reporter.emit(event="elapsed", text=f"β±οΈ {fmt_seconds(time.monotonic() - started)}",
|
|
87
|
+
seconds=round(time.monotonic() - started, 2))
|
|
88
|
+
sys.exit(summary.exit_code)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@main.command()
|
|
92
|
+
@CONFIG_OPTION
|
|
93
|
+
@ENV_FILE_OPTION
|
|
94
|
+
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text", show_default=True)
|
|
95
|
+
@click.option("--show-sql", is_flag=True, help="Print each table's SELECT and each view's translated DDL.")
|
|
96
|
+
@click.option("--tables", "only", multiple=True, metavar="PATTERN", help="Only these DB_SCHEMA.NAME patterns.")
|
|
97
|
+
def plan(config_path, env_file, fmt, show_sql, only) -> None:
|
|
98
|
+
"""Show what a run would copy, without writing anything.
|
|
99
|
+
|
|
100
|
+
Connects to the source only: lists the matching tables and views, their target names,
|
|
101
|
+
excluded and masked columns, and whether each view translates.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
config = _load(config_path=config_path, env_file=env_file)
|
|
105
|
+
except ConfigError as e:
|
|
106
|
+
_fail(message=str(e))
|
|
107
|
+
replicator = Replicator(config=config, reporter=Reporter(fmt="text", quiet=True),
|
|
108
|
+
options=RunOptions(only=list(only)))
|
|
109
|
+
source = replicator.source
|
|
110
|
+
conn = source.connect()
|
|
111
|
+
try:
|
|
112
|
+
try:
|
|
113
|
+
the_plan = replicator.build_plan(source_conn=conn)
|
|
114
|
+
except PlanError as e:
|
|
115
|
+
_fail(message=str(e))
|
|
116
|
+
definitions = {}
|
|
117
|
+
for catalog in sorted({v.source.catalog for v in the_plan.views}, key=lambda c: c or ""):
|
|
118
|
+
in_catalog = [v.source for v in the_plan.views if v.source.catalog == catalog]
|
|
119
|
+
definitions.update(source.view_definitions(conn=conn, catalog=catalog, views=in_catalog))
|
|
120
|
+
|
|
121
|
+
tables_out = []
|
|
122
|
+
for t in the_plan.tables:
|
|
123
|
+
tables_out.append({
|
|
124
|
+
"source": t.source.qualified_name, "source_kind": t.source.kind,
|
|
125
|
+
"target": f"{t.target_db_schema}.{t.target_table}",
|
|
126
|
+
"columns": len(t.columns), "excluded_columns": t.excluded_columns,
|
|
127
|
+
"masked_columns": {c.column.name: c.mask.strategy for c in t.columns if c.mask},
|
|
128
|
+
"fingerprint": "yes" if source.fingerprint_sql(task=t) else "no (copied every run)",
|
|
129
|
+
"note": t.note,
|
|
130
|
+
**({"sql": source.select_sql(task=t)} if show_sql else {}),
|
|
131
|
+
})
|
|
132
|
+
views_out = []
|
|
133
|
+
for v in the_plan.views:
|
|
134
|
+
entry = {"source": v.source.qualified_name, "target": f"{v.target_db_schema}.{v.target_view}"}
|
|
135
|
+
override = load_override(overrides_dir=config.views.overrides_dir, db_schema=v.source.db_schema,
|
|
136
|
+
view=v.source.name)
|
|
137
|
+
try:
|
|
138
|
+
if override is not None:
|
|
139
|
+
sql = create_view_sql(target_db_schema=v.target_db_schema, target_view=v.target_view, body=override,
|
|
140
|
+
target_catalog=config.target.catalog)
|
|
141
|
+
entry["status"] = "override"
|
|
142
|
+
else:
|
|
143
|
+
definition = definitions.get((v.source.db_schema, v.source.name))
|
|
144
|
+
if not definition:
|
|
145
|
+
raise ViewTranslationError("the source returned no definition (no privilege on it?)")
|
|
146
|
+
sql = translate_view(definition=definition, source_dialect=source.dialect,
|
|
147
|
+
view_db_schema=v.source.db_schema, target_db_schema=v.target_db_schema,
|
|
148
|
+
target_view=v.target_view, name_map=the_plan.name_map,
|
|
149
|
+
case=config.naming.case, source_columns=[c.name for c in v.source.columns],
|
|
150
|
+
target_catalog=config.target.catalog)
|
|
151
|
+
entry["status"] = "translates"
|
|
152
|
+
if show_sql:
|
|
153
|
+
entry["sql"] = sql
|
|
154
|
+
except Exception as e:
|
|
155
|
+
entry["status"] = "fails"
|
|
156
|
+
entry["error"] = str(e)
|
|
157
|
+
views_out.append(entry)
|
|
158
|
+
finally:
|
|
159
|
+
with suppress(Exception):
|
|
160
|
+
conn.close()
|
|
161
|
+
|
|
162
|
+
result = {"replication": config.name, "source": config.source.type, "target": config.target.type,
|
|
163
|
+
"tables": tables_out, "views": views_out, "warnings": the_plan.warnings}
|
|
164
|
+
if fmt == "json":
|
|
165
|
+
click.echo(json.dumps(result, indent=2, default=str))
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
title = f" for {config.name}" if config.name else ""
|
|
169
|
+
click.echo(f"πΊοΈ Plan{title}: {config.source.type} β {config.target.type}\n")
|
|
170
|
+
click.echo(f"π¦ Tables ({len(tables_out)})")
|
|
171
|
+
for t in tables_out:
|
|
172
|
+
extras = []
|
|
173
|
+
if t["note"]:
|
|
174
|
+
extras.append(t["note"])
|
|
175
|
+
if t["excluded_columns"]:
|
|
176
|
+
extras.append("excluded: " + ", ".join(t["excluded_columns"]))
|
|
177
|
+
if t["masked_columns"]:
|
|
178
|
+
extras.append("masked: " + ", ".join(f"{k} ({v})" for k, v in t["masked_columns"].items()))
|
|
179
|
+
if t["fingerprint"] != "yes":
|
|
180
|
+
extras.append("no fingerprint: copied every run")
|
|
181
|
+
click.echo(f" β’ {t['source']} β {t['target']} [{t['columns']} columns]"
|
|
182
|
+
+ (f" ({'; '.join(extras)})" if extras else ""))
|
|
183
|
+
if show_sql:
|
|
184
|
+
click.echo(f" {t['sql']}")
|
|
185
|
+
click.echo(f"\nπͺ Views ({len(views_out)})")
|
|
186
|
+
icons = {"translates": "β
", "override": "π", "fails": "β"}
|
|
187
|
+
for v in views_out:
|
|
188
|
+
click.echo(f" {icons[v['status']]} {v['source']} β {v['target']}"
|
|
189
|
+
+ (f" β {v['error'].splitlines()[0]}" if v.get("error") else ""))
|
|
190
|
+
if show_sql and v.get("sql"):
|
|
191
|
+
click.echo(" " + v["sql"].replace("\n", "\n "))
|
|
192
|
+
if the_plan.warnings:
|
|
193
|
+
click.echo(f"\nβ οΈ Warnings ({len(the_plan.warnings)})")
|
|
194
|
+
for w in the_plan.warnings:
|
|
195
|
+
click.echo(f" β’ {w}")
|
|
196
|
+
failing = sum(v["status"] == "fails" for v in views_out)
|
|
197
|
+
click.echo(f"\n⨠{len(tables_out)} table(s) to copy, {len(views_out) - failing} view(s) ready"
|
|
198
|
+
+ (f", {failing} view(s) won't translate (add an override, or views.on_failure: materialize)"
|
|
199
|
+
if failing else ""))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@main.command("test-connection")
|
|
203
|
+
@CONFIG_OPTION
|
|
204
|
+
@ENV_FILE_OPTION
|
|
205
|
+
def test_connection(config_path, env_file) -> None:
|
|
206
|
+
"""Connect to the source and the target and report what each sees."""
|
|
207
|
+
try:
|
|
208
|
+
config = _load(config_path=config_path, env_file=env_file)
|
|
209
|
+
except ConfigError as e:
|
|
210
|
+
_fail(message=str(e))
|
|
211
|
+
replicator = Replicator(config=config)
|
|
212
|
+
ok = True
|
|
213
|
+
try:
|
|
214
|
+
started = time.monotonic()
|
|
215
|
+
conn = replicator.source.connect()
|
|
216
|
+
try:
|
|
217
|
+
catalog = replicator.source.current_catalog(conn=conn)
|
|
218
|
+
db_schemas = replicator.source.list_db_schemas(conn=conn, catalog=None)
|
|
219
|
+
finally:
|
|
220
|
+
conn.close()
|
|
221
|
+
click.echo(f"β
Source ({config.source.type}): connected in {fmt_seconds(time.monotonic() - started)}; "
|
|
222
|
+
f"catalog {catalog}, {len(db_schemas)} db_schema(s) visible")
|
|
223
|
+
except Exception as e:
|
|
224
|
+
ok = False
|
|
225
|
+
click.echo(f"β Source ({config.source.type}): {type(e).__name__}: {e}")
|
|
226
|
+
try:
|
|
227
|
+
started = time.monotonic()
|
|
228
|
+
conn = replicator.target.connect()
|
|
229
|
+
try:
|
|
230
|
+
version = replicator.target.query(conn=conn, sql="SELECT version()")[0][0]
|
|
231
|
+
finally:
|
|
232
|
+
conn.close()
|
|
233
|
+
click.echo(f"β
Target ({config.target.type}): connected in {fmt_seconds(time.monotonic() - started)}; "
|
|
234
|
+
f"catalog {replicator.target.catalog}, DuckDB {version}")
|
|
235
|
+
except Exception as e:
|
|
236
|
+
ok = False
|
|
237
|
+
click.echo(f"β Target ({config.target.type}): {type(e).__name__}: {e}")
|
|
238
|
+
sys.exit(EXIT_OK if ok else EXIT_FAILED)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@main.command()
|
|
242
|
+
@CONFIG_OPTION
|
|
243
|
+
@ENV_FILE_OPTION
|
|
244
|
+
def validate(config_path, env_file) -> None:
|
|
245
|
+
"""Check a config file without connecting to anything."""
|
|
246
|
+
try:
|
|
247
|
+
config = _load(config_path=config_path, env_file=env_file)
|
|
248
|
+
except ConfigError as e:
|
|
249
|
+
_fail(message=str(e))
|
|
250
|
+
click.echo(f"β
{config_path} is valid: {config.source.type} β {config.target.type}, "
|
|
251
|
+
f"{len(config.objects)} object rule(s), {len(config.columns)} column rule(s)")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@main.command()
|
|
255
|
+
def schema() -> None:
|
|
256
|
+
"""Print the config's JSON Schema (for editors, CI checks and UIs)."""
|
|
257
|
+
click.echo(json.dumps(json_schema(), indent=2))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
EXAMPLE_CONFIG = """\
|
|
261
|
+
# adbc-replicate config β see https://github.com/gizmodata/adbc-replicate
|
|
262
|
+
version: 1
|
|
263
|
+
name: oracle-to-gizmosql
|
|
264
|
+
|
|
265
|
+
source:
|
|
266
|
+
type: oracle
|
|
267
|
+
uri: oracle://oracle.example.com:1521/ORCLPDB1
|
|
268
|
+
username: ${ORACLE_USERNAME}
|
|
269
|
+
password: ${ORACLE_PASSWORD}
|
|
270
|
+
consistency: serializable # every table read from one point in time
|
|
271
|
+
|
|
272
|
+
target:
|
|
273
|
+
type: gizmosql
|
|
274
|
+
uri: gizmosql://gizmosql.example.com:31337
|
|
275
|
+
username: ${GIZMOSQL_USERNAME}
|
|
276
|
+
password: ${GIZMOSQL_PASSWORD}
|
|
277
|
+
|
|
278
|
+
objects:
|
|
279
|
+
- db_schemas: [HR, SALES]
|
|
280
|
+
exclude_tables: ["*_BAK", "/^TMP_/"]
|
|
281
|
+
|
|
282
|
+
columns:
|
|
283
|
+
- tables: [HR.EMPLOYEES]
|
|
284
|
+
exclude: [SSN]
|
|
285
|
+
mask:
|
|
286
|
+
PHONE_NUMBER: hash
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@main.command()
|
|
291
|
+
@click.argument("path", type=click.Path(dir_okay=False), default="replicate.yaml")
|
|
292
|
+
def init(path) -> None:
|
|
293
|
+
"""Write an example config to PATH (default replicate.yaml)."""
|
|
294
|
+
p = Path(path)
|
|
295
|
+
if p.exists():
|
|
296
|
+
_fail(message=f"{path} already exists")
|
|
297
|
+
p.write_text(EXAMPLE_CONFIG, encoding="utf-8")
|
|
298
|
+
click.echo(f"π Wrote {path}. Edit it, then: adbc-replicate plan --config {path}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
main()
|