-
Notifications
You must be signed in to change notification settings - Fork 6
Feature/sqlite db persistence docs #149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
96d16bc
8f21926
1ebc0e3
2af60dd
54b8af6
0314dba
84d5868
893742c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,138 @@ | ||||||
| # SQL Persistence | ||||||
|
|
||||||
| GDM supports persisting systems through high-level APIs on `DistributionSystem` and `CatalogSystem`. | ||||||
|
|
||||||
| Supported database targets: | ||||||
|
|
||||||
| - SQLite files (via `db_path` or SQLite `db_url`) | ||||||
| - PostgreSQL servers (via `db_url` DSN) | ||||||
|
|
||||||
| This is useful for: | ||||||
|
|
||||||
| - storing complete model snapshots, | ||||||
| - preserving component UUID identity across save/load cycles, | ||||||
| - loading distribution systems with `prefer_normalized=True`, | ||||||
| - keeping normalized distribution table structure aligned across SQLite and PostgreSQL. | ||||||
|
|
||||||
| ## DistributionSystem: write and load | ||||||
|
|
||||||
| ```python | ||||||
| from gdm.distribution import DistributionSystem | ||||||
|
|
||||||
| # write using a file path | ||||||
| system: DistributionSystem = ... | ||||||
| system.to_db("distribution.sqlite") | ||||||
|
|
||||||
| # load (default snapshot path) | ||||||
| loaded = DistributionSystem.from_db("distribution.sqlite") | ||||||
|
|
||||||
| # write/load using SQLite URL | ||||||
| sqlite_url = "sqlite:///distribution.sqlite" | ||||||
| system.to_db(db_url=sqlite_url) | ||||||
| loaded = DistributionSystem.from_db(db_url=sqlite_url) | ||||||
|
|
||||||
| # write/load using PostgreSQL DSN | ||||||
| postgres_url = "postgresql+psycopg://user:password@host:5432/database" | ||||||
| system.to_db(db_url=postgres_url) | ||||||
| loaded = DistributionSystem.from_db(db_url=postgres_url) | ||||||
| ``` | ||||||
|
|
||||||
| By default, `to_db` writes snapshot payloads. For distribution systems, normalized tables are also persisted. | ||||||
|
|
||||||
| ## Backend behavior | ||||||
|
|
||||||
| ### Distribution systems | ||||||
|
|
||||||
| - SQLite: writes snapshot payload + normalized distribution tables. | ||||||
| - PostgreSQL: writes snapshot payload + normalized distribution tables, with table names and relational layout aligned to SQLite. | ||||||
|
|
||||||
| `prefer_normalized=True` is supported on both backends for `DistributionSystem.from_db(...)`. | ||||||
|
|
||||||
| ### Catalog systems | ||||||
|
|
||||||
| - SQLite and PostgreSQL both use snapshot storage. | ||||||
|
|
||||||
| ### Load from normalized representation | ||||||
|
|
||||||
| Use `prefer_normalized=True` to reconstruct from normalized topology/component tables first. | ||||||
|
|
||||||
| ```python | ||||||
| loaded = DistributionSystem.from_db( | ||||||
| db_url="postgresql+psycopg://user:password@host:5432/database", | ||||||
| prefer_normalized=True, | ||||||
| ) | ||||||
| ``` | ||||||
|
|
||||||
| If normalized rows are unavailable for the stored system, loading falls back to snapshot reconstruction. | ||||||
|
|
||||||
| ## CatalogSystem: write and load | ||||||
|
|
||||||
| ```python | ||||||
| from gdm.distribution import CatalogSystem | ||||||
|
|
||||||
| catalog: CatalogSystem = ... | ||||||
| catalog.to_db("catalog.sqlite") | ||||||
|
|
||||||
| loaded_catalog = CatalogSystem.from_db("catalog.sqlite") | ||||||
|
|
||||||
| catalog.to_db(db_url="postgresql+psycopg://user:password@host:5432/database") | ||||||
| loaded_catalog = CatalogSystem.from_db( | ||||||
| db_url="postgresql+psycopg://user:password@host:5432/database" | ||||||
| ) | ||||||
| ``` | ||||||
|
|
||||||
| `CatalogSystem` persistence uses snapshot storage. | ||||||
|
|
||||||
| ## Replace semantics and schema initialization | ||||||
|
|
||||||
| For both system types, writes replace existing records for that `system_kind` by default. | ||||||
|
|
||||||
| - `replace=True` (default): replace previously persisted record(s) for that system kind. | ||||||
| - `initialize_schema=True` (default): bootstrap schema/tables when needed. | ||||||
|
|
||||||
| In repeated writes to an existing database, `initialize_schema=False` can be used once schema is already present. | ||||||
|
|
||||||
| ## Table-structure parity notes (SQLite vs PostgreSQL) | ||||||
|
|
||||||
| For distribution persistence, PostgreSQL now materializes the same normalized table set used in SQLite (for example, `distribution_buses`, `distribution_loads`, `matrix_impedance_branches`, and related component/equipment tables). This keeps SQL inspection and downstream table-based workflows consistent across backends. | ||||||
|
|
||||||
| GDM additive tables (`gdm_system_snapshots`, `gdm_metadata`, `gdm_component_uuid_map`) remain backend-managed and are available on both SQLite and PostgreSQL. | ||||||
|
|
||||||
| ## Time series behavior | ||||||
|
|
||||||
| When persisting a `DistributionSystem`, time-series associations are stored in DB metadata tables, and loading restores component time-series attachments from persisted snapshot data. | ||||||
|
||||||
| When persisting a `DistributionSystem`, time-series associations are stored in DB metadata tables, and loading restores component time-series attachments from persisted snapshot data. | |
| When persisting a `DistributionSystem`, time-series associations are stored in the `time_series_associations` table, and loading restores component time-series attachments from the persisted snapshot payload. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| """Database adapters for Grid Data Models.""" | ||
|
|
||
| from gdm.db.store import ( | ||
| DEFAULT_DB_FORMAT_VERSION, | ||
| inspect_snapshot_metadata, | ||
| load_snapshot_payload, | ||
| load_system_from_db, | ||
| write_system_to_db, | ||
| ) | ||
| from gdm.db.store import default_schema_path | ||
|
|
||
| __all__ = [ | ||
| "DEFAULT_DB_FORMAT_VERSION", | ||
| "default_schema_path", | ||
| "inspect_snapshot_metadata", | ||
| "load_snapshot_payload", | ||
| "load_system_from_db", | ||
| "write_system_to_db", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| """Database connection target helpers. | ||
|
|
||
| This module centralizes validation and resolution logic for DB targets while the | ||
| storage layer transitions from path-based SQLite APIs to DSN-based backends. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from pathlib import Path | ||
| from urllib.parse import urlparse | ||
|
|
||
|
|
||
| def resolve_db_url(db_path: str | Path | None = None, db_url: str | None = None) -> str: | ||
| """Resolve a canonical DB URL from compatibility inputs. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| db_path : str | Path | None | ||
| Legacy path input for SQLite files. | ||
| db_url : str | None | ||
| DSN/URL input. Examples: ``sqlite:////tmp/system.db``, | ||
| ``postgresql+psycopg://user:pass@host:5432/db``. | ||
| """ | ||
|
|
||
| if db_url and db_path: | ||
| raise ValueError("Provide either 'db_path' or 'db_url', not both.") | ||
|
|
||
| if db_url: | ||
| return db_url | ||
|
|
||
| if db_path is None: | ||
| raise ValueError("A database target is required. Provide 'db_url' or 'db_path'.") | ||
|
|
||
| return f"sqlite:///{Path(db_path)}" | ||
|
|
||
|
|
||
| def sqlite_path_from_target(db_path: str | Path | None = None, db_url: str | None = None) -> Path: | ||
| """Return a filesystem path for SQLite targets. | ||
|
|
||
| This helper supports both direct file paths and SQLite URLs. Non-SQLite | ||
| URLs are intentionally rejected in this module because PostgreSQL support is | ||
| added incrementally in subsequent milestones. | ||
| """ | ||
|
|
||
| resolved = resolve_db_url(db_path=db_path, db_url=db_url) | ||
| parsed = urlparse(resolved) | ||
|
|
||
| if parsed.scheme in {"", "sqlite"}: | ||
| if parsed.scheme == "": | ||
| return Path(resolved) | ||
|
|
||
| if parsed.netloc not in {"", "localhost"}: | ||
| raise ValueError(f"Unsupported SQLite URL host in '{resolved}'.") | ||
|
|
||
| if not parsed.path: | ||
| raise ValueError("SQLite URL must include a file path.") | ||
|
|
||
| return Path(parsed.path) | ||
|
Comment on lines
+31
to
+58
|
||
|
|
||
| raise NotImplementedError( | ||
| f"Database backend '{parsed.scheme}' is not supported yet in this module." | ||
| ) | ||
|
|
||
|
|
||
| def get_backend_name(db_path: str | Path | None = None, db_url: str | None = None) -> str: | ||
| """Return normalized backend name from DB target. | ||
|
|
||
| Returns | ||
| ------- | ||
| str | ||
| One of ``sqlite``, ``postgresql``, or the parsed scheme string for | ||
| other DSN types. | ||
| """ | ||
|
|
||
| resolved = resolve_db_url(db_path=db_path, db_url=db_url) | ||
| parsed = urlparse(resolved) | ||
| scheme = parsed.scheme or "sqlite" | ||
|
|
||
| if scheme == "sqlite": | ||
| return "sqlite" | ||
|
|
||
| if scheme.startswith("postgresql"): | ||
| return "postgresql" | ||
|
|
||
| return scheme | ||
|
|
||
|
|
||
| def create_db_engine(db_path: str | Path | None = None, db_url: str | None = None): | ||
| """Create a SQLAlchemy engine for the provided DB target.""" | ||
|
|
||
| try: | ||
| from sqlalchemy import create_engine | ||
| except ImportError as exc: | ||
| raise ImportError("SQLAlchemy is required for DSN-based database engine support.") from exc | ||
|
|
||
| resolved = resolve_db_url(db_path=db_path, db_url=db_url) | ||
| return create_engine(resolved) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The examples use relative SQLite targets (
system.to_db("distribution.sqlite")andsqlite:///distribution.sqlite). With the currentsqlite_path_from_target()implementation, these resolve to an absolute path at the filesystem root (leading/), so the examples won’t work as written. Either fix path resolution so these examples work, or update the docs to specify the URL/path form that is actually supported.