etlplus 0.6.1__tar.gz → 0.7.0__tar.gz
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.
- {etlplus-0.6.1/etlplus.egg-info → etlplus-0.7.0}/PKG-INFO +4 -1
- etlplus-0.7.0/etlplus/database/__init__.py +42 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/database/ddl.py +1 -1
- etlplus-0.7.0/etlplus/database/engine.py +146 -0
- etlplus-0.7.0/etlplus/database/orm.py +347 -0
- etlplus-0.7.0/etlplus/database/schema.py +273 -0
- {etlplus-0.6.1 → etlplus-0.7.0/etlplus.egg-info}/PKG-INFO +4 -1
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus.egg-info/SOURCES.txt +6 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus.egg-info/requires.txt +3 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/pyproject.toml +3 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/setup.py +3 -0
- etlplus-0.7.0/tests/unit/database/test_u_database_engine.py +147 -0
- etlplus-0.7.0/tests/unit/database/test_u_database_orm.py +308 -0
- etlplus-0.7.0/tests/unit/database/test_u_database_schema.py +213 -0
- etlplus-0.6.1/etlplus/database/__init__.py +0 -23
- {etlplus-0.6.1 → etlplus-0.7.0}/.coveragerc +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/.editorconfig +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/.gitattributes +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/.github/actions/python-bootstrap/action.yml +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/.github/workflows/ci.yml +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/.gitignore +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/.pre-commit-config.yaml +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/.ruff.toml +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/CODE_OF_CONDUCT.md +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/CONTRIBUTING.md +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/DEMO.md +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/LICENSE +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/MANIFEST.in +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/Makefile +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/README.md +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/REFERENCES.md +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/docs/pipeline-guide.md +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/docs/snippets/installation_version.md +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/__init__.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/__main__.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/__version__.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/README.md +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/__init__.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/auth.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/config.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/endpoint_client.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/errors.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/pagination/__init__.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/pagination/client.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/pagination/config.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/pagination/paginator.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/rate_limiting/__init__.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/rate_limiting/config.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/request_manager.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/retry_manager.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/transport.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/types.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/cli/__init__.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/cli/app.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/cli/handlers.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/cli/main.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/__init__.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/connector.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/jobs.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/pipeline.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/profile.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/types.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/utils.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/enums.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/extract.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/file.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/load.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/mixins.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/py.typed +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/run.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/run_helpers.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/templates/__init__.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/templates/ddl.sql.j2 +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/templates/view.sql.j2 +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/transform.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/types.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/utils.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/validate.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/validation/__init__.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/validation/utils.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus.egg-info/dependency_links.txt +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus.egg-info/entry_points.txt +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/etlplus.egg-info/top_level.txt +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/examples/README.md +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/examples/configs/ddl_spec.yml +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/examples/configs/pipeline.yml +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/examples/data/sample.csv +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/examples/data/sample.json +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/examples/data/sample.xml +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/examples/data/sample.xsd +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/examples/data/sample.yaml +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/examples/quickstart_python.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/pytest.ini +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/setup.cfg +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/__init__.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/conftest.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/conftest.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_cli.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_examples_data_parity.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_pagination_strategy.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_pipeline_smoke.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_run.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/conftest.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_auth.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_config.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_endpoint_client.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_mocks.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_pagination_client.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_pagination_config.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_paginator.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_rate_limit_config.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_rate_limiter.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_request_manager.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_retry_manager.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_transport.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_types.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/cli/conftest.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/cli/test_u_cli_app.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/cli/test_u_cli_handlers.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/cli/test_u_cli_main.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/config/test_u_config_utils.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/config/test_u_connector.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/config/test_u_jobs.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/config/test_u_pipeline.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/conftest.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/database/test_u_database_ddl.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_enums.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_extract.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_file.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_load.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_main.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_mixins.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_run.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_run_helpers.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_transform.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_utils.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_validate.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_version.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/validation/test_u_validation_utils.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tools/run_pipeline.py +0 -0
- {etlplus-0.6.1 → etlplus-0.7.0}/tools/update_demo_snippets.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: etlplus
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: A Swiss Army knife for simple ETL operations
|
|
5
5
|
Home-page: https://github.com/Dagitali/ETLPlus
|
|
6
6
|
Author: ETLPlus Team
|
|
@@ -21,7 +21,10 @@ Requires-Dist: jinja2>=3.1.6
|
|
|
21
21
|
Requires-Dist: pyodbc>=5.3.0
|
|
22
22
|
Requires-Dist: python-dotenv>=1.2.1
|
|
23
23
|
Requires-Dist: pandas>=2.3.3
|
|
24
|
+
Requires-Dist: pydantic>=2.12.5
|
|
25
|
+
Requires-Dist: PyYAML>=6.0.3
|
|
24
26
|
Requires-Dist: requests>=2.32.5
|
|
27
|
+
Requires-Dist: SQLAlchemy>=2.0.45
|
|
25
28
|
Requires-Dist: typer>=0.21.0
|
|
26
29
|
Provides-Extra: dev
|
|
27
30
|
Requires-Dist: black>=25.9.0; extra == "dev"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:mod:`etlplus.database` package.
|
|
3
|
+
|
|
4
|
+
Database utilities for:
|
|
5
|
+
- DDL rendering and schema management.
|
|
6
|
+
- Schema parsing from configuration files.
|
|
7
|
+
- Dynamic ORM generation.
|
|
8
|
+
- Database engine/session management.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .ddl import load_table_spec
|
|
14
|
+
from .ddl import render_table_sql
|
|
15
|
+
from .ddl import render_tables
|
|
16
|
+
from .ddl import render_tables_to_string
|
|
17
|
+
from .engine import engine
|
|
18
|
+
from .engine import load_database_url_from_config
|
|
19
|
+
from .engine import make_engine
|
|
20
|
+
from .engine import session
|
|
21
|
+
from .orm import build_models
|
|
22
|
+
from .orm import load_and_build_models
|
|
23
|
+
from .schema import load_table_specs
|
|
24
|
+
|
|
25
|
+
# SECTION: EXPORTS ========================================================== #
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
# Functions
|
|
30
|
+
'build_models',
|
|
31
|
+
'load_and_build_models',
|
|
32
|
+
'load_database_url_from_config',
|
|
33
|
+
'load_table_spec',
|
|
34
|
+
'load_table_specs',
|
|
35
|
+
'make_engine',
|
|
36
|
+
'render_table_sql',
|
|
37
|
+
'render_tables',
|
|
38
|
+
'render_tables_to_string',
|
|
39
|
+
# Singletons
|
|
40
|
+
'engine',
|
|
41
|
+
'session',
|
|
42
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:mod:`etlplus.database.engine` module.
|
|
3
|
+
|
|
4
|
+
Lightweight engine/session factory with optional config-driven URL loading.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import create_engine
|
|
15
|
+
from sqlalchemy.engine import Engine
|
|
16
|
+
from sqlalchemy.orm import sessionmaker
|
|
17
|
+
|
|
18
|
+
from ..file import File
|
|
19
|
+
|
|
20
|
+
# SECTION: EXPORTS ========================================================== #
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
# Functions
|
|
25
|
+
'load_database_url_from_config',
|
|
26
|
+
'make_engine',
|
|
27
|
+
# Singletons
|
|
28
|
+
'engine',
|
|
29
|
+
'session',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# SECTION: INTERNAL CONSTANTS =============================================== #
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
DATABASE_URL: str = (
|
|
37
|
+
os.getenv('DATABASE_URL')
|
|
38
|
+
or os.getenv('DATABASE_DSN')
|
|
39
|
+
or 'sqlite+pysqlite:///:memory:'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# SECTION: INTERNAL FUNCTIONS =============================================== #
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_url_from_mapping(cfg: Mapping[str, Any]) -> str | None:
|
|
47
|
+
"""
|
|
48
|
+
Return a URL/DSN from a mapping if present.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
cfg : Mapping[str, Any]
|
|
53
|
+
Configuration mapping potentially containing connection fields.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
str | None
|
|
58
|
+
Resolved URL/DSN string, if present.
|
|
59
|
+
"""
|
|
60
|
+
conn = cfg.get('connection_string') or cfg.get('url') or cfg.get('dsn')
|
|
61
|
+
if isinstance(conn, str) and conn.strip():
|
|
62
|
+
return conn.strip()
|
|
63
|
+
|
|
64
|
+
# Some configs nest defaults.
|
|
65
|
+
# E.g., databases: { mssql: { default: {...} } }
|
|
66
|
+
default_cfg = cfg.get('default')
|
|
67
|
+
if isinstance(default_cfg, Mapping):
|
|
68
|
+
return _resolve_url_from_mapping(default_cfg)
|
|
69
|
+
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# SECTION: FUNCTIONS ======================================================== #
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_database_url_from_config(
|
|
77
|
+
path: str | Path,
|
|
78
|
+
*,
|
|
79
|
+
name: str | None = None,
|
|
80
|
+
) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Extract a database URL/DSN from a YAML/JSON config file.
|
|
83
|
+
|
|
84
|
+
The loader is schema-tolerant: it looks for a top-level "databases" map
|
|
85
|
+
and then for a named entry (``name``). Each entry may contain either a
|
|
86
|
+
``connection_string``/``url``/``dsn`` or a nested ``default`` block with
|
|
87
|
+
those fields.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
path : str | Path
|
|
92
|
+
Location of the configuration file.
|
|
93
|
+
name : str | None, optional
|
|
94
|
+
Named database entry under the ``databases`` map (default:
|
|
95
|
+
``default``).
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
str
|
|
100
|
+
Resolved database URL/DSN string.
|
|
101
|
+
|
|
102
|
+
Raises
|
|
103
|
+
------
|
|
104
|
+
KeyError
|
|
105
|
+
If the specified database entry is not found.
|
|
106
|
+
TypeError
|
|
107
|
+
If the config structure is invalid.
|
|
108
|
+
ValueError
|
|
109
|
+
If no connection string/URL/DSN is found for the specified entry.
|
|
110
|
+
"""
|
|
111
|
+
cfg = File.read_file(Path(path))
|
|
112
|
+
if not isinstance(cfg, Mapping):
|
|
113
|
+
raise TypeError('Database config must be a mapping')
|
|
114
|
+
|
|
115
|
+
databases = cfg.get('databases') if isinstance(cfg, Mapping) else None
|
|
116
|
+
if not isinstance(databases, Mapping):
|
|
117
|
+
raise KeyError('Config missing top-level "databases" mapping')
|
|
118
|
+
|
|
119
|
+
target = name or 'default'
|
|
120
|
+
entry = databases.get(target)
|
|
121
|
+
if entry is None:
|
|
122
|
+
raise KeyError(f'Database entry "{target}" not found in config')
|
|
123
|
+
if not isinstance(entry, Mapping):
|
|
124
|
+
raise TypeError(f'Database entry "{target}" must be a mapping')
|
|
125
|
+
|
|
126
|
+
url = _resolve_url_from_mapping(entry)
|
|
127
|
+
if not url:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f'Database entry "{target}" lacks connection_string/url/dsn',
|
|
130
|
+
)
|
|
131
|
+
return url
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def make_engine(url: str | None = None, **engine_kwargs: Any) -> Engine:
|
|
135
|
+
"""Create a SQLAlchemy Engine, defaulting to env config if no URL given."""
|
|
136
|
+
|
|
137
|
+
resolved_url = url or DATABASE_URL
|
|
138
|
+
return create_engine(resolved_url, pool_pre_ping=True, **engine_kwargs)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# SECTION: SINGLETONS ======================================================= #
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Default engine/session for callers that rely on module-level singletons.
|
|
145
|
+
engine = make_engine()
|
|
146
|
+
session = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:mod:`etlplus.database.orm` module.
|
|
3
|
+
|
|
4
|
+
Dynamic SQLAlchemy model generation from YAML table specs.
|
|
5
|
+
|
|
6
|
+
Usage
|
|
7
|
+
-----
|
|
8
|
+
>>> from etlplus.database.orm import load_and_build_models
|
|
9
|
+
>>> registry = load_and_build_models('examples/configs/ddl_spec.yml')
|
|
10
|
+
>>> Player = registry['dbo.Customers']
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from sqlalchemy import Boolean
|
|
21
|
+
from sqlalchemy import CheckConstraint
|
|
22
|
+
from sqlalchemy import Date
|
|
23
|
+
from sqlalchemy import DateTime
|
|
24
|
+
from sqlalchemy import Enum
|
|
25
|
+
from sqlalchemy import Float
|
|
26
|
+
from sqlalchemy import ForeignKey
|
|
27
|
+
from sqlalchemy import ForeignKeyConstraint
|
|
28
|
+
from sqlalchemy import Index
|
|
29
|
+
from sqlalchemy import Integer
|
|
30
|
+
from sqlalchemy import LargeBinary
|
|
31
|
+
from sqlalchemy import Numeric
|
|
32
|
+
from sqlalchemy import PrimaryKeyConstraint
|
|
33
|
+
from sqlalchemy import String
|
|
34
|
+
from sqlalchemy import Text
|
|
35
|
+
from sqlalchemy import Time
|
|
36
|
+
from sqlalchemy import UniqueConstraint
|
|
37
|
+
from sqlalchemy import text
|
|
38
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
39
|
+
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
40
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
41
|
+
from sqlalchemy.orm import mapped_column
|
|
42
|
+
from sqlalchemy.types import TypeEngine
|
|
43
|
+
|
|
44
|
+
from .schema import ForeignKeySpec
|
|
45
|
+
from .schema import TableSpec
|
|
46
|
+
from .schema import load_table_specs
|
|
47
|
+
|
|
48
|
+
# SECTION: INTERNAL CONSTANTS =============================================== #
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
# Classes
|
|
52
|
+
'Base',
|
|
53
|
+
# Functions
|
|
54
|
+
'build_models',
|
|
55
|
+
'load_and_build_models',
|
|
56
|
+
'resolve_type',
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_TYPE_MAPPING: dict[str, Callable[[list[int]], TypeEngine]] = {
|
|
61
|
+
'int': lambda _: Integer(),
|
|
62
|
+
'integer': lambda _: Integer(),
|
|
63
|
+
'bigint': lambda _: Integer(),
|
|
64
|
+
'smallint': lambda _: Integer(),
|
|
65
|
+
'bool': lambda _: Boolean(),
|
|
66
|
+
'boolean': lambda _: Boolean(),
|
|
67
|
+
'uuid': lambda _: PG_UUID(as_uuid=True),
|
|
68
|
+
'uniqueidentifier': lambda _: PG_UUID(as_uuid=True),
|
|
69
|
+
'rowversion': lambda _: LargeBinary(),
|
|
70
|
+
'varbinary': lambda _: LargeBinary(),
|
|
71
|
+
'blob': lambda _: LargeBinary(),
|
|
72
|
+
'text': lambda _: Text(),
|
|
73
|
+
'string': lambda _: Text(),
|
|
74
|
+
'varchar': lambda p: String(length=p[0]) if p else String(),
|
|
75
|
+
'nvarchar': lambda p: String(length=p[0]) if p else String(),
|
|
76
|
+
'char': lambda p: String(length=p[0] if p else 1),
|
|
77
|
+
'nchar': lambda p: String(length=p[0] if p else 1),
|
|
78
|
+
'numeric': lambda p: Numeric(
|
|
79
|
+
precision=p[0] if p else None,
|
|
80
|
+
scale=p[1] if len(p) > 1 else None,
|
|
81
|
+
),
|
|
82
|
+
'decimal': lambda p: Numeric(
|
|
83
|
+
precision=p[0] if p else None,
|
|
84
|
+
scale=p[1] if len(p) > 1 else None,
|
|
85
|
+
),
|
|
86
|
+
'float': lambda _: Float(),
|
|
87
|
+
'real': lambda _: Float(),
|
|
88
|
+
'double': lambda _: Float(),
|
|
89
|
+
'datetime': lambda _: DateTime(timezone=True),
|
|
90
|
+
'datetime2': lambda _: DateTime(timezone=True),
|
|
91
|
+
'timestamp': lambda _: DateTime(timezone=True),
|
|
92
|
+
'date': lambda _: Date(),
|
|
93
|
+
'time': lambda _: Time(),
|
|
94
|
+
'json': lambda _: JSONB(),
|
|
95
|
+
'jsonb': lambda _: JSONB(),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# SECTION: CLASSES ========================================================== #
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Base(DeclarativeBase):
|
|
103
|
+
"""Base class for all ORM models."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# SECTION: INTERNAL FUNCTIONS =============================================== #
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _class_name(
|
|
110
|
+
table: str,
|
|
111
|
+
) -> str:
|
|
112
|
+
"""
|
|
113
|
+
Convert table name to PascalCase class name.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
table : str
|
|
118
|
+
Table name.
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
str
|
|
123
|
+
PascalCase class name.
|
|
124
|
+
"""
|
|
125
|
+
parts = re.split(r'[^A-Za-z0-9]+', table)
|
|
126
|
+
return ''.join(p.capitalize() for p in parts if p)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_type_decl(
|
|
130
|
+
type_str: str,
|
|
131
|
+
) -> tuple[str, list[int]]:
|
|
132
|
+
"""
|
|
133
|
+
Parse a type declaration string into its name and parameters.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
type_str : str
|
|
138
|
+
Type declaration string, e.g., "varchar(255)".
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
tuple[str, list[int]]
|
|
143
|
+
A tuple containing the type name and a list of integer parameters.
|
|
144
|
+
"""
|
|
145
|
+
m = re.match(
|
|
146
|
+
r'^(?P<name>[A-Za-z0-9_]+)(?:\((?P<params>[^)]*)\))?$',
|
|
147
|
+
type_str.strip(),
|
|
148
|
+
)
|
|
149
|
+
if not m:
|
|
150
|
+
return type_str.lower(), []
|
|
151
|
+
name = m.group('name').lower()
|
|
152
|
+
params_raw = m.group('params')
|
|
153
|
+
if not params_raw:
|
|
154
|
+
return name, []
|
|
155
|
+
params = [p.strip() for p in params_raw.split(',') if p.strip()]
|
|
156
|
+
parsed: list[int] = []
|
|
157
|
+
for p in params:
|
|
158
|
+
try:
|
|
159
|
+
parsed.append(int(p))
|
|
160
|
+
except ValueError:
|
|
161
|
+
continue
|
|
162
|
+
return name, parsed
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _table_kwargs(
|
|
166
|
+
spec: TableSpec,
|
|
167
|
+
) -> dict[str, str]:
|
|
168
|
+
"""
|
|
169
|
+
Generate table keyword arguments based on the table specification.
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
spec : TableSpec
|
|
174
|
+
Table specification.
|
|
175
|
+
|
|
176
|
+
Returns
|
|
177
|
+
-------
|
|
178
|
+
dict[str, str]
|
|
179
|
+
Dictionary of table keyword arguments.
|
|
180
|
+
"""
|
|
181
|
+
kwargs: dict[str, str] = {}
|
|
182
|
+
if spec.schema_name:
|
|
183
|
+
kwargs['schema'] = spec.schema_name
|
|
184
|
+
return kwargs
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# SECTION: FUNCTIONS ======================================================== #
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def build_models(
|
|
191
|
+
specs: list[TableSpec],
|
|
192
|
+
*,
|
|
193
|
+
base: type[DeclarativeBase] = Base,
|
|
194
|
+
) -> dict[str, type[DeclarativeBase]]:
|
|
195
|
+
"""
|
|
196
|
+
Build SQLAlchemy ORM models from table specifications.
|
|
197
|
+
Parameters
|
|
198
|
+
----------
|
|
199
|
+
specs : list[TableSpec]
|
|
200
|
+
List of table specifications.
|
|
201
|
+
base : type[DeclarativeBase], optional
|
|
202
|
+
Base class for the ORM models (default: :class:`Base`).
|
|
203
|
+
Returns
|
|
204
|
+
-------
|
|
205
|
+
dict[str, type[DeclarativeBase]]
|
|
206
|
+
Registry mapping fully qualified table names to ORM model classes.
|
|
207
|
+
"""
|
|
208
|
+
registry: dict[str, type[DeclarativeBase]] = {}
|
|
209
|
+
|
|
210
|
+
for spec in specs:
|
|
211
|
+
table_args: list[object] = []
|
|
212
|
+
table_kwargs = _table_kwargs(spec)
|
|
213
|
+
pk_cols = set(spec.primary_key.columns) if spec.primary_key else set()
|
|
214
|
+
|
|
215
|
+
# Pre-handle multi-column constraints.
|
|
216
|
+
if spec.primary_key and len(spec.primary_key.columns) > 1:
|
|
217
|
+
table_args.append(
|
|
218
|
+
PrimaryKeyConstraint(
|
|
219
|
+
*spec.primary_key.columns,
|
|
220
|
+
name=spec.primary_key.name,
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
for uc in spec.unique_constraints:
|
|
224
|
+
table_args.append(UniqueConstraint(*uc.columns, name=uc.name))
|
|
225
|
+
for idx in spec.indexes:
|
|
226
|
+
table_args.append(
|
|
227
|
+
Index(
|
|
228
|
+
idx.name,
|
|
229
|
+
*idx.columns,
|
|
230
|
+
unique=idx.unique,
|
|
231
|
+
postgresql_where=text(idx.where) if idx.where else None,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
composite_fks = [fk for fk in spec.foreign_keys if len(fk.columns) > 1]
|
|
235
|
+
for fk in composite_fks:
|
|
236
|
+
table_args.append(
|
|
237
|
+
ForeignKeyConstraint(
|
|
238
|
+
fk.columns,
|
|
239
|
+
[f'{fk.ref_table}.{c}' for c in fk.ref_columns],
|
|
240
|
+
ondelete=fk.ondelete,
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
fk_by_column = {
|
|
245
|
+
fk.columns[0]: fk
|
|
246
|
+
for fk in spec.foreign_keys
|
|
247
|
+
if len(fk.columns) == 1 and len(fk.ref_columns) == 1
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
attrs: dict[str, object] = {'__tablename__': spec.table}
|
|
251
|
+
|
|
252
|
+
for col in spec.columns:
|
|
253
|
+
col_fk: ForeignKeySpec | None = fk_by_column.get(col.name)
|
|
254
|
+
fk_arg = (
|
|
255
|
+
ForeignKey(
|
|
256
|
+
f'{col_fk.ref_table}.{col_fk.ref_columns[0]}',
|
|
257
|
+
ondelete=col_fk.ondelete,
|
|
258
|
+
)
|
|
259
|
+
if col_fk
|
|
260
|
+
else None
|
|
261
|
+
)
|
|
262
|
+
col_type: TypeEngine = (
|
|
263
|
+
Enum(*col.enum, name=f'{spec.table}_{col.name}_enum')
|
|
264
|
+
if col.enum
|
|
265
|
+
else resolve_type(col.type)
|
|
266
|
+
)
|
|
267
|
+
fk_args: list[ForeignKey] = []
|
|
268
|
+
if fk_arg:
|
|
269
|
+
fk_args.append(fk_arg)
|
|
270
|
+
|
|
271
|
+
kwargs: dict[str, Any] = {
|
|
272
|
+
'nullable': col.nullable,
|
|
273
|
+
'primary_key': col.name in pk_cols and len(pk_cols) == 1,
|
|
274
|
+
'unique': col.unique,
|
|
275
|
+
}
|
|
276
|
+
if col.default:
|
|
277
|
+
kwargs['server_default'] = text(col.default)
|
|
278
|
+
if col.identity:
|
|
279
|
+
kwargs['autoincrement'] = True
|
|
280
|
+
|
|
281
|
+
attrs[col.name] = mapped_column(*fk_args, type_=col_type, **kwargs)
|
|
282
|
+
|
|
283
|
+
if col.check:
|
|
284
|
+
table_args.append(
|
|
285
|
+
CheckConstraint(
|
|
286
|
+
col.check,
|
|
287
|
+
name=f'ck_{spec.table}_{col.name}',
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if table_args or table_kwargs:
|
|
292
|
+
args_tuple = tuple(table_args)
|
|
293
|
+
attrs['__table_args__'] = (
|
|
294
|
+
(*args_tuple, table_kwargs) if table_kwargs else args_tuple
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
cls_name = _class_name(spec.table)
|
|
298
|
+
model_cls = type(cls_name, (base,), attrs)
|
|
299
|
+
registry[spec.fq_name] = model_cls
|
|
300
|
+
|
|
301
|
+
return registry
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def load_and_build_models(
|
|
305
|
+
path: str | Path,
|
|
306
|
+
*,
|
|
307
|
+
base: type[DeclarativeBase] = Base,
|
|
308
|
+
) -> dict[str, type[DeclarativeBase]]:
|
|
309
|
+
"""
|
|
310
|
+
Load table specifications from a file and build SQLAlchemy models.
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
path : str | Path
|
|
315
|
+
Path to the YAML file containing table specifications.
|
|
316
|
+
base : type[DeclarativeBase], optional
|
|
317
|
+
Base class for the ORM models (default: :class:`Base`).
|
|
318
|
+
|
|
319
|
+
Returns
|
|
320
|
+
-------
|
|
321
|
+
dict[str, type[DeclarativeBase]]
|
|
322
|
+
Registry mapping fully qualified table names to ORM model classes.
|
|
323
|
+
"""
|
|
324
|
+
return build_models(load_table_specs(path), base=base)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def resolve_type(
|
|
328
|
+
type_str: str,
|
|
329
|
+
) -> TypeEngine:
|
|
330
|
+
"""
|
|
331
|
+
Resolve a string type declaration to a SQLAlchemy :class:`TypeEngine`.
|
|
332
|
+
|
|
333
|
+
Parameters
|
|
334
|
+
----------
|
|
335
|
+
type_str : str
|
|
336
|
+
String representation of the type declaration.
|
|
337
|
+
|
|
338
|
+
Returns
|
|
339
|
+
-------
|
|
340
|
+
TypeEngine
|
|
341
|
+
SQLAlchemy type engine instance corresponding to the type declaration.
|
|
342
|
+
"""
|
|
343
|
+
name, params = _parse_type_decl(type_str)
|
|
344
|
+
factory = _TYPE_MAPPING.get(name)
|
|
345
|
+
if factory:
|
|
346
|
+
return factory(params)
|
|
347
|
+
return Text()
|