dbly 0.0.1__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.
- dbly/__init__.py +7 -0
- dbly/adapters/__init__.py +35 -0
- dbly/adapters/base.py +93 -0
- dbly/adapters/mssql.py +112 -0
- dbly/adapters/oracle.py +160 -0
- dbly/adapters/postgres.py +103 -0
- dbly/adapters/sqlite.py +94 -0
- dbly/cli.py +245 -0
- dbly/config.py +101 -0
- dbly/engine.py +138 -0
- dbly/hooks.py +62 -0
- dbly/initializer.py +19 -0
- dbly/model.py +119 -0
- dbly/parsing.py +173 -0
- dbly/planner.py +143 -0
- dbly/py.typed +0 -0
- dbly/repo.py +113 -0
- dbly/report.py +135 -0
- dbly-0.0.1.dist-info/METADATA +149 -0
- dbly-0.0.1.dist-info/RECORD +22 -0
- dbly-0.0.1.dist-info/WHEEL +4 -0
- dbly-0.0.1.dist-info/entry_points.txt +2 -0
dbly/cli.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""dbly command-line interface (CONCEPT.md §14).
|
|
2
|
+
|
|
3
|
+
dbly plan --to <ref> [--from <ref>] --target <profile>
|
|
4
|
+
dbly apply [<plan.yaml>] [--to <ref>] --target <profile> [--allow-destructive]
|
|
5
|
+
dbly bootstrap --to <ref> --target <profile>
|
|
6
|
+
dbly check --target <profile> [--to <ref>]
|
|
7
|
+
dbly status --target <profile>
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from dbly import __version__, hooks, initializer, report
|
|
18
|
+
from dbly.adapters import get_adapter
|
|
19
|
+
from dbly.config import resolve_target
|
|
20
|
+
from dbly.engine import detect_dialect
|
|
21
|
+
from dbly.parsing import sqlglot_dialect
|
|
22
|
+
from dbly.planner import build_plan
|
|
23
|
+
from dbly.model import Plan, Severity
|
|
24
|
+
from dbly.repo import Repo
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="dbly",
|
|
28
|
+
help="State-based, cross-engine database deployment — git-driven, parser-assisted.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
add_completion=False,
|
|
31
|
+
)
|
|
32
|
+
console = Console()
|
|
33
|
+
err = Console(stderr=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _version(value: bool) -> None:
|
|
37
|
+
if value:
|
|
38
|
+
console.print(f"dbly {__version__}")
|
|
39
|
+
raise typer.Exit()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.callback()
|
|
43
|
+
def _main(
|
|
44
|
+
version: bool = typer.Option( # noqa: ARG001
|
|
45
|
+
False, "--version", callback=_version, is_eager=True, help="Show version and exit."
|
|
46
|
+
),
|
|
47
|
+
) -> None:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _make_plan(repo_path: Path, target: str, from_ref: Optional[str], to_ref: str) -> Plan:
|
|
52
|
+
repo = Repo(repo_path)
|
|
53
|
+
cfg = resolve_target(target)
|
|
54
|
+
dialect = sqlglot_dialect(detect_dialect(cfg))
|
|
55
|
+
adapter = get_adapter(cfg)
|
|
56
|
+
try:
|
|
57
|
+
resolved_to = repo.resolve_ref(to_ref)
|
|
58
|
+
if from_ref is not None:
|
|
59
|
+
resolved_from = repo.resolve_ref(from_ref)
|
|
60
|
+
else:
|
|
61
|
+
resolved_from = adapter.get_deployed_ref() # already a SHA, or None (bootstrap)
|
|
62
|
+
return build_plan(
|
|
63
|
+
repo, adapter,
|
|
64
|
+
from_ref=resolved_from, to_ref=resolved_to,
|
|
65
|
+
target=target, dialect=dialect,
|
|
66
|
+
)
|
|
67
|
+
finally:
|
|
68
|
+
adapter.dispose()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command()
|
|
72
|
+
def plan(
|
|
73
|
+
to: str = typer.Option("HEAD", "--to", help="git ref to deploy (release tag/branch)."),
|
|
74
|
+
from_ref: Optional[str] = typer.Option(
|
|
75
|
+
None, "--from", help="baseline ref (default: deployed ref from dbly_state)."
|
|
76
|
+
),
|
|
77
|
+
target: str = typer.Option(..., "--target", help="connection profile or env name."),
|
|
78
|
+
repo_path: Path = typer.Option(Path("."), "--repo", help="repository root."),
|
|
79
|
+
out: Optional[Path] = typer.Option(None, "--out", help="write the plan as YAML."),
|
|
80
|
+
sql: Optional[Path] = typer.Option(
|
|
81
|
+
None, "--sql", help="write an executable SQL script for a hand/offline deploy."
|
|
82
|
+
),
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Compute and show the deployment plan."""
|
|
85
|
+
plan_obj = _make_plan(repo_path, target, from_ref, to)
|
|
86
|
+
report.render_plan(plan_obj, console)
|
|
87
|
+
if out:
|
|
88
|
+
out.write_text(report.plan_to_yaml(plan_obj), encoding="utf-8")
|
|
89
|
+
console.print(f"\n[dim]plan (YAML) written to {out}[/dim]")
|
|
90
|
+
if sql:
|
|
91
|
+
# state_table_ddl / record_deploy_sql are pure string builders — no DB connection.
|
|
92
|
+
adapter = get_adapter(resolve_target(target))
|
|
93
|
+
try:
|
|
94
|
+
script = report.plan_to_sql(
|
|
95
|
+
plan_obj,
|
|
96
|
+
state_ddl=adapter.state_table_ddl(),
|
|
97
|
+
record_sql=adapter.record_deploy_sql(plan_obj.to_ref),
|
|
98
|
+
)
|
|
99
|
+
finally:
|
|
100
|
+
adapter.dispose()
|
|
101
|
+
sql.write_text(script, encoding="utf-8")
|
|
102
|
+
console.print(f"[dim]deploy SQL written to {sql}[/dim]")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def apply(
|
|
107
|
+
plan_file: Optional[Path] = typer.Argument(None, help="a YAML plan from `dbly plan`."),
|
|
108
|
+
to: str = typer.Option("HEAD", "--to"),
|
|
109
|
+
from_ref: Optional[str] = typer.Option(None, "--from"),
|
|
110
|
+
target: str = typer.Option(..., "--target"),
|
|
111
|
+
repo_path: Path = typer.Option(Path("."), "--repo"),
|
|
112
|
+
allow_destructive: bool = typer.Option(
|
|
113
|
+
False, "--allow-destructive", help="execute destructive steps too."
|
|
114
|
+
),
|
|
115
|
+
py_interpreter: str = typer.Option(
|
|
116
|
+
"python", "--py-interpreter", help="interpreter for .py hooks (e.g. ArcGIS propy)."
|
|
117
|
+
),
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Apply a plan to the target database (re-computes one unless a file is given)."""
|
|
120
|
+
if plan_file is not None:
|
|
121
|
+
plan_obj = report.plan_from_yaml(plan_file.read_text(encoding="utf-8"))
|
|
122
|
+
target = plan_obj.target
|
|
123
|
+
else:
|
|
124
|
+
plan_obj = _make_plan(repo_path, target, from_ref, to)
|
|
125
|
+
|
|
126
|
+
report.render_plan(plan_obj, console)
|
|
127
|
+
|
|
128
|
+
destructive = [s for s in plan_obj.steps if s.severity is Severity.DESTRUCTIVE]
|
|
129
|
+
if destructive and not allow_destructive:
|
|
130
|
+
err.print(
|
|
131
|
+
"[red]aborting:[/red] plan has destructive steps; pass --allow-destructive "
|
|
132
|
+
"to proceed."
|
|
133
|
+
)
|
|
134
|
+
raise typer.Exit(code=1)
|
|
135
|
+
|
|
136
|
+
statements = [
|
|
137
|
+
s.sql for s in plan_obj.steps
|
|
138
|
+
if allow_destructive or s.severity is not Severity.DESTRUCTIVE
|
|
139
|
+
]
|
|
140
|
+
if not statements:
|
|
141
|
+
console.print("[green]nothing to apply.[/green]")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
repo = Repo(repo_path)
|
|
145
|
+
cfg = resolve_target(target)
|
|
146
|
+
adapter = get_adapter(cfg)
|
|
147
|
+
try:
|
|
148
|
+
_run_hooks(repo, "pre", py_interpreter)
|
|
149
|
+
adapter.ensure_state_table()
|
|
150
|
+
adapter.apply(statements)
|
|
151
|
+
adapter.record_deploy(plan_obj.to_ref, migration_ids=[])
|
|
152
|
+
_run_hooks(repo, "post", py_interpreter)
|
|
153
|
+
finally:
|
|
154
|
+
adapter.dispose()
|
|
155
|
+
console.print(f"[green]applied[/green] {len(statements)} step(s); "
|
|
156
|
+
f"deployed ref → {plan_obj.to_ref}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command()
|
|
160
|
+
def init(
|
|
161
|
+
init_target: str = typer.Option(
|
|
162
|
+
..., "--init-target",
|
|
163
|
+
help="privileged connection profile (superuser / maintenance DB).",
|
|
164
|
+
),
|
|
165
|
+
repo_path: Path = typer.Option(Path("."), "--repo"),
|
|
166
|
+
init_dir: str = typer.Option("init", "--dir", help="folder of ordered init SQL scripts."),
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Run privileged greenfield groundwork (CREATE DATABASE/ROLE/EXTENSION).
|
|
169
|
+
|
|
170
|
+
Greenfield only — brownfield (a handed-over database) skips this entirely.
|
|
171
|
+
"""
|
|
172
|
+
repo = Repo(repo_path)
|
|
173
|
+
scripts = initializer.discover_init_scripts(repo.root, init_dir)
|
|
174
|
+
if not scripts:
|
|
175
|
+
console.print(f"[yellow]no init scripts in {init_dir}/ — nothing to do.[/yellow]")
|
|
176
|
+
return
|
|
177
|
+
adapter = get_adapter(resolve_target(init_target))
|
|
178
|
+
try:
|
|
179
|
+
for s in scripts:
|
|
180
|
+
adapter.run_init_script(s.read_text(encoding="utf-8"))
|
|
181
|
+
console.print(f"[green]ran[/green] {init_dir}/{s.name}")
|
|
182
|
+
finally:
|
|
183
|
+
adapter.dispose()
|
|
184
|
+
console.print(f"[green]init complete[/green] — {len(scripts)} script(s).")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command()
|
|
188
|
+
def bootstrap(
|
|
189
|
+
to: str = typer.Option("HEAD", "--to"),
|
|
190
|
+
target: str = typer.Option(..., "--target"),
|
|
191
|
+
repo_path: Path = typer.Option(Path("."), "--repo"),
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Install into an empty database (no baseline — full apply)."""
|
|
194
|
+
plan_obj = _make_plan(repo_path, target, None, to)
|
|
195
|
+
report.render_plan(plan_obj, console)
|
|
196
|
+
console.print("\n[dim]review, then run `dbly apply` to execute.[/dim]")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command()
|
|
200
|
+
def status(target: str = typer.Option(..., "--target")) -> None:
|
|
201
|
+
"""Show the deployed ref recorded on the target."""
|
|
202
|
+
cfg = resolve_target(target)
|
|
203
|
+
adapter = get_adapter(cfg)
|
|
204
|
+
try:
|
|
205
|
+
ref = adapter.get_deployed_ref()
|
|
206
|
+
finally:
|
|
207
|
+
adapter.dispose()
|
|
208
|
+
if ref:
|
|
209
|
+
console.print(f"deployed ref: [cyan]{ref}[/cyan]")
|
|
210
|
+
else:
|
|
211
|
+
console.print("[yellow]no deploy recorded — database is unmanaged or empty.[/yellow]")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.command()
|
|
215
|
+
def check(
|
|
216
|
+
target: str = typer.Option(..., "--target"),
|
|
217
|
+
to: str = typer.Option("HEAD", "--to"),
|
|
218
|
+
repo_path: Path = typer.Option(Path("."), "--repo"),
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Detect drift: compare desired state at <to> against the live database."""
|
|
221
|
+
plan_obj = _make_plan(repo_path, target, None, to)
|
|
222
|
+
drift = [s for s in plan_obj.steps] + plan_obj.warnings
|
|
223
|
+
if not drift:
|
|
224
|
+
console.print("[green]no drift — database matches desired state.[/green]")
|
|
225
|
+
return
|
|
226
|
+
report.render_plan(plan_obj, console)
|
|
227
|
+
console.print("\n[yellow]drift detected (see steps/warnings above).[/yellow]")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _run_hooks(repo: Repo, phase: str, py_interpreter: str) -> None:
|
|
231
|
+
for hook in hooks.discover_hooks(repo.root, phase):
|
|
232
|
+
if hook.suffix.lower() == ".py":
|
|
233
|
+
res = hooks.run_py_hook(hook, interpreter=py_interpreter)
|
|
234
|
+
if not res.ok:
|
|
235
|
+
raise hooks.HookError(res)
|
|
236
|
+
console.print(f"[dim]hook ok: {hook.name}[/dim]")
|
|
237
|
+
# NOTE: .sql hooks are applied via the adapter in a later iteration.
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def main() -> None:
|
|
241
|
+
app()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
main()
|
dbly/config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Connection profiles — reuses dbression's DBFit-compatible ``connection.properties``.
|
|
2
|
+
|
|
3
|
+
Format (same as dbression, so existing profiles work unchanged):
|
|
4
|
+
|
|
5
|
+
# 1) full connection string
|
|
6
|
+
connection-string=postgresql://user:pw@host:5432/db
|
|
7
|
+
|
|
8
|
+
# 2) OR separate parts
|
|
9
|
+
service=host:5432
|
|
10
|
+
username=app
|
|
11
|
+
password=${DB_PASSWORD} # ${ENV} placeholders expand from os.environ
|
|
12
|
+
database=appdb
|
|
13
|
+
|
|
14
|
+
dbly adds one key:
|
|
15
|
+
|
|
16
|
+
environment=postgres # postgres | oracle | sqlserver | sqlite
|
|
17
|
+
|
|
18
|
+
``${VAR}`` placeholders make profiles CI/CD-safe — keep credentials in pipeline secrets
|
|
19
|
+
(e.g. Bitbucket), never in the repo. A profile may also be supplied entirely via env:
|
|
20
|
+
``DBLY_TARGET`` (a path) or inline ``DBLY_CONNECTION_STRING`` + ``DBLY_ENVIRONMENT``.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _expand_vars(value: str) -> str:
|
|
33
|
+
def repl(m: re.Match[str]) -> str:
|
|
34
|
+
name = m.group(1)
|
|
35
|
+
try:
|
|
36
|
+
return os.environ[name]
|
|
37
|
+
except KeyError as exc:
|
|
38
|
+
raise KeyError(
|
|
39
|
+
f"connection profile references undefined environment variable: ${{{name}}}"
|
|
40
|
+
) from exc
|
|
41
|
+
|
|
42
|
+
return _VAR_RE.sub(repl, value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class ConnectionConfig:
|
|
47
|
+
environment: str | None = None
|
|
48
|
+
connection_string: str | None = None
|
|
49
|
+
service: str | None = None
|
|
50
|
+
username: str | None = None
|
|
51
|
+
password: str | None = None
|
|
52
|
+
extra: dict[str, str] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_profile(path: Path) -> ConnectionConfig:
|
|
56
|
+
"""Parse a Java-style ``.properties`` connection profile."""
|
|
57
|
+
cfg = ConnectionConfig()
|
|
58
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
59
|
+
line = raw.strip()
|
|
60
|
+
if not line or line.startswith("#") or line.startswith("!"):
|
|
61
|
+
continue
|
|
62
|
+
if "=" not in line:
|
|
63
|
+
continue
|
|
64
|
+
key, _, value = line.partition("=")
|
|
65
|
+
key = key.strip().lower()
|
|
66
|
+
value = _expand_vars(value.strip())
|
|
67
|
+
if key == "connection-string":
|
|
68
|
+
cfg.connection_string = value
|
|
69
|
+
elif key in ("environment", "databaseenvironment", "engine"):
|
|
70
|
+
cfg.environment = value
|
|
71
|
+
elif key == "service":
|
|
72
|
+
cfg.service = value
|
|
73
|
+
elif key == "username":
|
|
74
|
+
cfg.username = value
|
|
75
|
+
elif key == "password":
|
|
76
|
+
cfg.password = value
|
|
77
|
+
else:
|
|
78
|
+
cfg.extra[key] = value
|
|
79
|
+
return cfg
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_target(target: str | None) -> ConnectionConfig:
|
|
83
|
+
"""Resolve a ``--target`` into a ConnectionConfig.
|
|
84
|
+
|
|
85
|
+
Order: explicit file path → ``DBLY_TARGET`` (file) → inline env vars.
|
|
86
|
+
"""
|
|
87
|
+
if target:
|
|
88
|
+
return load_profile(Path(target))
|
|
89
|
+
env_path = os.environ.get("DBLY_TARGET")
|
|
90
|
+
if env_path:
|
|
91
|
+
return load_profile(Path(env_path))
|
|
92
|
+
cs = os.environ.get("DBLY_CONNECTION_STRING")
|
|
93
|
+
if cs:
|
|
94
|
+
return ConnectionConfig(
|
|
95
|
+
environment=os.environ.get("DBLY_ENVIRONMENT"),
|
|
96
|
+
connection_string=cs,
|
|
97
|
+
)
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"no target given — pass --target <profile> or set DBLY_TARGET / "
|
|
100
|
+
"DBLY_CONNECTION_STRING (+ DBLY_ENVIRONMENT)"
|
|
101
|
+
)
|
dbly/engine.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Build SQLAlchemy engines from connection profiles + detect the dialect.
|
|
2
|
+
|
|
3
|
+
Mirrors dbression's engine builder (same URL conventions) so profiles are interchangeable.
|
|
4
|
+
The ``environment`` key (postgres | oracle | sqlserver | sqlite) selects the driver; for a
|
|
5
|
+
full ``connection-string`` we sniff the scheme when ``environment`` is absent.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import URL, Engine, create_engine
|
|
13
|
+
|
|
14
|
+
from dbly.config import ConnectionConfig
|
|
15
|
+
|
|
16
|
+
_POSTGRES = {"postgres", "postgresql", "pg"}
|
|
17
|
+
_ORACLE = {"oracle"}
|
|
18
|
+
_MSSQL = {"sqlserver", "mssql", "ms-sql"}
|
|
19
|
+
_SQLITE = {"sqlite", "sqlite3"}
|
|
20
|
+
|
|
21
|
+
_THICK_LOCK = threading.Lock()
|
|
22
|
+
_THICK_DONE = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_dialect(cfg: ConnectionConfig) -> str:
|
|
26
|
+
if cfg.environment:
|
|
27
|
+
return cfg.environment.strip().lower()
|
|
28
|
+
cs = (cfg.connection_string or "").lower()
|
|
29
|
+
if cs.startswith(("postgres", "jdbc:postgresql")):
|
|
30
|
+
return "postgres"
|
|
31
|
+
if cs.startswith(("oracle", "jdbc:oracle")):
|
|
32
|
+
return "oracle"
|
|
33
|
+
if cs.startswith(("mssql", "sqlserver", "jdbc:sqlserver")):
|
|
34
|
+
return "sqlserver"
|
|
35
|
+
if cs.startswith("sqlite"):
|
|
36
|
+
return "sqlite"
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"cannot determine database environment — set `environment=` in the profile"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def make_engine(cfg: ConnectionConfig) -> Engine:
|
|
43
|
+
"""Build a SQLAlchemy engine. No autocommit — adapters manage transactions."""
|
|
44
|
+
env = detect_dialect(cfg)
|
|
45
|
+
if env in _POSTGRES:
|
|
46
|
+
url = _postgres_url(cfg)
|
|
47
|
+
elif env in _ORACLE:
|
|
48
|
+
_maybe_init_oracle_thick()
|
|
49
|
+
url = _oracle_url(cfg)
|
|
50
|
+
elif env in _MSSQL:
|
|
51
|
+
url = _mssql_url(cfg)
|
|
52
|
+
elif env in _SQLITE:
|
|
53
|
+
url = _sqlite_url(cfg)
|
|
54
|
+
else:
|
|
55
|
+
raise ValueError(f"unknown environment: {env!r}")
|
|
56
|
+
return create_engine(url, pool_pre_ping=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _maybe_init_oracle_thick() -> None:
|
|
60
|
+
global _THICK_DONE
|
|
61
|
+
if _THICK_DONE:
|
|
62
|
+
return
|
|
63
|
+
lib_dir = os.environ.get("DBLY_ORACLE_CLIENT_LIB_DIR")
|
|
64
|
+
if not lib_dir:
|
|
65
|
+
return
|
|
66
|
+
with _THICK_LOCK:
|
|
67
|
+
if _THICK_DONE:
|
|
68
|
+
return
|
|
69
|
+
import oracledb # noqa: PLC0415
|
|
70
|
+
|
|
71
|
+
oracledb.init_oracle_client(lib_dir=lib_dir)
|
|
72
|
+
_THICK_DONE = True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _postgres_url(cfg: ConnectionConfig) -> URL | str:
|
|
76
|
+
if cfg.connection_string:
|
|
77
|
+
cs = cfg.connection_string
|
|
78
|
+
if cs.startswith("jdbc:postgresql://"):
|
|
79
|
+
return f"postgresql+psycopg://{cs[len('jdbc:postgresql://'):]}"
|
|
80
|
+
if cs.startswith(("postgresql://", "postgres://")):
|
|
81
|
+
_, _, rest = cs.partition("://")
|
|
82
|
+
return f"postgresql+psycopg://{rest}"
|
|
83
|
+
return cs
|
|
84
|
+
host, port = _split_host_port(cfg.service)
|
|
85
|
+
return URL.create(
|
|
86
|
+
"postgresql+psycopg",
|
|
87
|
+
username=cfg.username,
|
|
88
|
+
password=cfg.password,
|
|
89
|
+
host=host,
|
|
90
|
+
port=port,
|
|
91
|
+
database=cfg.extra.get("database"),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _oracle_url(cfg: ConnectionConfig) -> URL | str:
|
|
96
|
+
if cfg.connection_string:
|
|
97
|
+
cs = cfg.connection_string.replace("jdbc:oracle:thin:@", "")
|
|
98
|
+
return f"oracle+oracledb://{cfg.username or ''}:{cfg.password or ''}@{cs}"
|
|
99
|
+
return URL.create(
|
|
100
|
+
"oracle+oracledb",
|
|
101
|
+
username=cfg.username,
|
|
102
|
+
password=cfg.password,
|
|
103
|
+
query={"dsn": cfg.service or ""},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _mssql_url(cfg: ConnectionConfig) -> URL | str:
|
|
108
|
+
if cfg.connection_string:
|
|
109
|
+
return cfg.connection_string
|
|
110
|
+
host, port = _split_host_port(cfg.service)
|
|
111
|
+
return URL.create(
|
|
112
|
+
"mssql+pymssql",
|
|
113
|
+
username=cfg.username,
|
|
114
|
+
password=cfg.password,
|
|
115
|
+
host=host,
|
|
116
|
+
port=port,
|
|
117
|
+
database=cfg.extra.get("database"),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _sqlite_url(cfg: ConnectionConfig) -> URL | str:
|
|
122
|
+
if cfg.connection_string:
|
|
123
|
+
return cfg.connection_string
|
|
124
|
+
path = cfg.service or cfg.extra.get("database") or ":memory:"
|
|
125
|
+
return f"sqlite:///{path}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _split_host_port(service: str | None) -> tuple[str | None, int | None]:
|
|
129
|
+
if not service:
|
|
130
|
+
return None, None
|
|
131
|
+
if ":" in service:
|
|
132
|
+
host, _, port_str = service.partition(":")
|
|
133
|
+
port_str = port_str.split("/", 1)[0]
|
|
134
|
+
try:
|
|
135
|
+
return host, int(port_str)
|
|
136
|
+
except ValueError:
|
|
137
|
+
return service, None
|
|
138
|
+
return service, None
|
dbly/hooks.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Global pre-/post-deploy hooks (CONCEPT.md §11).
|
|
2
|
+
|
|
3
|
+
Hooks accept ``.sql`` and ``.py`` files. ``.py`` hooks run as an isolated subprocess under
|
|
4
|
+
a **configurable interpreter** — crucial for ArcPy, which lives in ArcGIS's bundled Python,
|
|
5
|
+
not in dbly's uv environment. Robust error handling: timeout, full stdout/stderr capture,
|
|
6
|
+
explicit failure semantics.
|
|
7
|
+
|
|
8
|
+
Layout convention (best practice, not enforced)::
|
|
9
|
+
|
|
10
|
+
hooks/pre/*.sql|*.py
|
|
11
|
+
hooks/post/*.sql|*.py
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class HookResult:
|
|
22
|
+
hook: Path
|
|
23
|
+
ok: bool
|
|
24
|
+
stdout: str
|
|
25
|
+
stderr: str
|
|
26
|
+
returncode: int | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HookError(RuntimeError):
|
|
30
|
+
def __init__(self, result: HookResult):
|
|
31
|
+
self.result = result
|
|
32
|
+
super().__init__(
|
|
33
|
+
f"hook failed: {result.hook} (rc={result.returncode})\n{result.stderr}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def discover_hooks(repo_root: Path, phase: str) -> list[Path]:
|
|
38
|
+
d = repo_root / "hooks" / phase
|
|
39
|
+
if not d.is_dir():
|
|
40
|
+
return []
|
|
41
|
+
return sorted(p for p in d.iterdir() if p.suffix.lower() in (".sql", ".py"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_py_hook(
|
|
45
|
+
hook: Path,
|
|
46
|
+
*,
|
|
47
|
+
interpreter: str,
|
|
48
|
+
timeout: float | None = None,
|
|
49
|
+
env: dict[str, str] | None = None,
|
|
50
|
+
) -> HookResult:
|
|
51
|
+
"""Run a ``.py`` hook under an external interpreter (e.g. ArcGIS ``propy``)."""
|
|
52
|
+
try:
|
|
53
|
+
proc = subprocess.run(
|
|
54
|
+
[interpreter, str(hook)],
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
env=env,
|
|
59
|
+
)
|
|
60
|
+
except subprocess.TimeoutExpired as exc:
|
|
61
|
+
return HookResult(hook, False, exc.stdout or "", "timeout exceeded", None)
|
|
62
|
+
return HookResult(hook, proc.returncode == 0, proc.stdout, proc.stderr, proc.returncode)
|
dbly/initializer.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Privileged greenfield groundwork (CONCEPT.md §6).
|
|
2
|
+
|
|
3
|
+
Init scripts live in ``init/`` (ordered by filename) and are run **verbatim** — they are
|
|
4
|
+
imperative groundwork (``CREATE DATABASE`` / roles / extensions / base schemas), not
|
|
5
|
+
declarative objects, so they are neither parsed nor classified. Run under a separate
|
|
6
|
+
privileged profile (``--init-target``), explicitly via ``dbly init``, never as part of
|
|
7
|
+
``apply``. Greenfield runs this once; brownfield skips it entirely.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def discover_init_scripts(repo_root: Path, dirname: str = "init") -> list[Path]:
|
|
15
|
+
"""Ordered ``.sql`` init scripts (lexicographic, so prefix ``01_``, ``02_`` …)."""
|
|
16
|
+
d = repo_root / dirname
|
|
17
|
+
if not d.is_dir():
|
|
18
|
+
return []
|
|
19
|
+
return sorted(p for p in d.iterdir() if p.is_file() and p.suffix.lower() == ".sql")
|
dbly/model.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Core data model shared across parsing, planning and applying.
|
|
2
|
+
|
|
3
|
+
Two object classes drive everything (CONCEPT.md §3):
|
|
4
|
+
|
|
5
|
+
* **REPLACEABLE** (Klasse 1) — views, functions, procedures, packages, triggers, types,
|
|
6
|
+
grants. Deployed by re-applying the object wholesale (``CREATE OR REPLACE`` /
|
|
7
|
+
drop-and-create). Idempotent, no ledger needed.
|
|
8
|
+
* **STATEFUL** (Klasse 2) — tables. Never blindly re-applied; the desired ``CREATE TABLE``
|
|
9
|
+
is diffed against the live schema and an additive ``ALTER`` is generated. Destructive
|
|
10
|
+
deltas are flagged, never auto-applied.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ObjectClass(str, Enum):
|
|
20
|
+
REPLACEABLE = "replaceable" # Klasse 1
|
|
21
|
+
STATEFUL = "stateful" # Klasse 2 (tables)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ObjectKind(str, Enum):
|
|
25
|
+
TABLE = "table"
|
|
26
|
+
VIEW = "view"
|
|
27
|
+
FUNCTION = "function"
|
|
28
|
+
PROCEDURE = "procedure"
|
|
29
|
+
PACKAGE = "package"
|
|
30
|
+
TRIGGER = "trigger"
|
|
31
|
+
TYPE = "type"
|
|
32
|
+
GRANT = "grant"
|
|
33
|
+
UNKNOWN = "unknown"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def object_class(self) -> ObjectClass:
|
|
37
|
+
return ObjectClass.STATEFUL if self is ObjectKind.TABLE else ObjectClass.REPLACEABLE
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class Column:
|
|
42
|
+
"""A table column — used both for the desired state (parsed from ``CREATE TABLE``)
|
|
43
|
+
and the actual state (introspected from the live database)."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
type: str
|
|
47
|
+
nullable: bool = True
|
|
48
|
+
default: str | None = None
|
|
49
|
+
|
|
50
|
+
def key(self) -> str:
|
|
51
|
+
return self.name.lower()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class ObjectId:
|
|
56
|
+
"""Schema-qualified identity of a database object."""
|
|
57
|
+
|
|
58
|
+
schema: str | None
|
|
59
|
+
name: str
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
return f"{self.schema}.{self.name}" if self.schema else self.name
|
|
63
|
+
|
|
64
|
+
def key(self) -> str:
|
|
65
|
+
return str(self).lower()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class ParsedObject:
|
|
70
|
+
"""A single database object discovered by parsing a source file."""
|
|
71
|
+
|
|
72
|
+
id: ObjectId
|
|
73
|
+
kind: ObjectKind
|
|
74
|
+
sql: str
|
|
75
|
+
source_file: Path
|
|
76
|
+
depends_on: set[str] = field(default_factory=set) # ObjectId.key() of referenced objects
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def object_class(self) -> ObjectClass:
|
|
80
|
+
return self.kind.object_class
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ChangeType(str, Enum):
|
|
84
|
+
ADDED = "added"
|
|
85
|
+
MODIFIED = "modified"
|
|
86
|
+
DELETED = "deleted"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Severity(str, Enum):
|
|
90
|
+
ADDITIVE = "additive" # safe to auto-apply
|
|
91
|
+
DESTRUCTIVE = "destructive" # requires --allow-destructive or an explicit ALTER
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(slots=True)
|
|
95
|
+
class Step:
|
|
96
|
+
"""One ordered unit of work in a plan."""
|
|
97
|
+
|
|
98
|
+
title: str
|
|
99
|
+
object_id: ObjectId | None
|
|
100
|
+
kind: ObjectKind
|
|
101
|
+
severity: Severity
|
|
102
|
+
sql: str
|
|
103
|
+
source_file: Path | None = None
|
|
104
|
+
note: str | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(slots=True)
|
|
108
|
+
class Plan:
|
|
109
|
+
"""An ordered, reviewable set of steps — the artifact of ``dbly plan``."""
|
|
110
|
+
|
|
111
|
+
target: str
|
|
112
|
+
from_ref: str | None
|
|
113
|
+
to_ref: str
|
|
114
|
+
steps: list[Step] = field(default_factory=list)
|
|
115
|
+
warnings: list[str] = field(default_factory=list)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def has_destructive(self) -> bool:
|
|
119
|
+
return any(s.severity is Severity.DESTRUCTIVE for s in self.steps)
|