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/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)