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.
Files changed (145) hide show
  1. {etlplus-0.6.1/etlplus.egg-info → etlplus-0.7.0}/PKG-INFO +4 -1
  2. etlplus-0.7.0/etlplus/database/__init__.py +42 -0
  3. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/database/ddl.py +1 -1
  4. etlplus-0.7.0/etlplus/database/engine.py +146 -0
  5. etlplus-0.7.0/etlplus/database/orm.py +347 -0
  6. etlplus-0.7.0/etlplus/database/schema.py +273 -0
  7. {etlplus-0.6.1 → etlplus-0.7.0/etlplus.egg-info}/PKG-INFO +4 -1
  8. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus.egg-info/SOURCES.txt +6 -0
  9. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus.egg-info/requires.txt +3 -0
  10. {etlplus-0.6.1 → etlplus-0.7.0}/pyproject.toml +3 -0
  11. {etlplus-0.6.1 → etlplus-0.7.0}/setup.py +3 -0
  12. etlplus-0.7.0/tests/unit/database/test_u_database_engine.py +147 -0
  13. etlplus-0.7.0/tests/unit/database/test_u_database_orm.py +308 -0
  14. etlplus-0.7.0/tests/unit/database/test_u_database_schema.py +213 -0
  15. etlplus-0.6.1/etlplus/database/__init__.py +0 -23
  16. {etlplus-0.6.1 → etlplus-0.7.0}/.coveragerc +0 -0
  17. {etlplus-0.6.1 → etlplus-0.7.0}/.editorconfig +0 -0
  18. {etlplus-0.6.1 → etlplus-0.7.0}/.gitattributes +0 -0
  19. {etlplus-0.6.1 → etlplus-0.7.0}/.github/actions/python-bootstrap/action.yml +0 -0
  20. {etlplus-0.6.1 → etlplus-0.7.0}/.github/workflows/ci.yml +0 -0
  21. {etlplus-0.6.1 → etlplus-0.7.0}/.gitignore +0 -0
  22. {etlplus-0.6.1 → etlplus-0.7.0}/.pre-commit-config.yaml +0 -0
  23. {etlplus-0.6.1 → etlplus-0.7.0}/.ruff.toml +0 -0
  24. {etlplus-0.6.1 → etlplus-0.7.0}/CODE_OF_CONDUCT.md +0 -0
  25. {etlplus-0.6.1 → etlplus-0.7.0}/CONTRIBUTING.md +0 -0
  26. {etlplus-0.6.1 → etlplus-0.7.0}/DEMO.md +0 -0
  27. {etlplus-0.6.1 → etlplus-0.7.0}/LICENSE +0 -0
  28. {etlplus-0.6.1 → etlplus-0.7.0}/MANIFEST.in +0 -0
  29. {etlplus-0.6.1 → etlplus-0.7.0}/Makefile +0 -0
  30. {etlplus-0.6.1 → etlplus-0.7.0}/README.md +0 -0
  31. {etlplus-0.6.1 → etlplus-0.7.0}/REFERENCES.md +0 -0
  32. {etlplus-0.6.1 → etlplus-0.7.0}/docs/pipeline-guide.md +0 -0
  33. {etlplus-0.6.1 → etlplus-0.7.0}/docs/snippets/installation_version.md +0 -0
  34. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/__init__.py +0 -0
  35. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/__main__.py +0 -0
  36. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/__version__.py +0 -0
  37. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/README.md +0 -0
  38. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/__init__.py +0 -0
  39. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/auth.py +0 -0
  40. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/config.py +0 -0
  41. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/endpoint_client.py +0 -0
  42. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/errors.py +0 -0
  43. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/pagination/__init__.py +0 -0
  44. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/pagination/client.py +0 -0
  45. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/pagination/config.py +0 -0
  46. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/pagination/paginator.py +0 -0
  47. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/rate_limiting/__init__.py +0 -0
  48. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/rate_limiting/config.py +0 -0
  49. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
  50. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/request_manager.py +0 -0
  51. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/retry_manager.py +0 -0
  52. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/transport.py +0 -0
  53. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/api/types.py +0 -0
  54. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/cli/__init__.py +0 -0
  55. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/cli/app.py +0 -0
  56. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/cli/handlers.py +0 -0
  57. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/cli/main.py +0 -0
  58. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/__init__.py +0 -0
  59. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/connector.py +0 -0
  60. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/jobs.py +0 -0
  61. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/pipeline.py +0 -0
  62. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/profile.py +0 -0
  63. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/types.py +0 -0
  64. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/config/utils.py +0 -0
  65. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/enums.py +0 -0
  66. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/extract.py +0 -0
  67. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/file.py +0 -0
  68. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/load.py +0 -0
  69. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/mixins.py +0 -0
  70. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/py.typed +0 -0
  71. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/run.py +0 -0
  72. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/run_helpers.py +0 -0
  73. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/templates/__init__.py +0 -0
  74. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/templates/ddl.sql.j2 +0 -0
  75. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/templates/view.sql.j2 +0 -0
  76. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/transform.py +0 -0
  77. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/types.py +0 -0
  78. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/utils.py +0 -0
  79. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/validate.py +0 -0
  80. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/validation/__init__.py +0 -0
  81. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus/validation/utils.py +0 -0
  82. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus.egg-info/dependency_links.txt +0 -0
  83. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus.egg-info/entry_points.txt +0 -0
  84. {etlplus-0.6.1 → etlplus-0.7.0}/etlplus.egg-info/top_level.txt +0 -0
  85. {etlplus-0.6.1 → etlplus-0.7.0}/examples/README.md +0 -0
  86. {etlplus-0.6.1 → etlplus-0.7.0}/examples/configs/ddl_spec.yml +0 -0
  87. {etlplus-0.6.1 → etlplus-0.7.0}/examples/configs/pipeline.yml +0 -0
  88. {etlplus-0.6.1 → etlplus-0.7.0}/examples/data/sample.csv +0 -0
  89. {etlplus-0.6.1 → etlplus-0.7.0}/examples/data/sample.json +0 -0
  90. {etlplus-0.6.1 → etlplus-0.7.0}/examples/data/sample.xml +0 -0
  91. {etlplus-0.6.1 → etlplus-0.7.0}/examples/data/sample.xsd +0 -0
  92. {etlplus-0.6.1 → etlplus-0.7.0}/examples/data/sample.yaml +0 -0
  93. {etlplus-0.6.1 → etlplus-0.7.0}/examples/quickstart_python.py +0 -0
  94. {etlplus-0.6.1 → etlplus-0.7.0}/pytest.ini +0 -0
  95. {etlplus-0.6.1 → etlplus-0.7.0}/setup.cfg +0 -0
  96. {etlplus-0.6.1 → etlplus-0.7.0}/tests/__init__.py +0 -0
  97. {etlplus-0.6.1 → etlplus-0.7.0}/tests/conftest.py +0 -0
  98. {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/conftest.py +0 -0
  99. {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_cli.py +0 -0
  100. {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_examples_data_parity.py +0 -0
  101. {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_pagination_strategy.py +0 -0
  102. {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_pipeline_smoke.py +0 -0
  103. {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
  104. {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_run.py +0 -0
  105. {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
  106. {etlplus-0.6.1 → etlplus-0.7.0}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
  107. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/conftest.py +0 -0
  108. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_auth.py +0 -0
  109. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_config.py +0 -0
  110. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_endpoint_client.py +0 -0
  111. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_mocks.py +0 -0
  112. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_pagination_client.py +0 -0
  113. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_pagination_config.py +0 -0
  114. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_paginator.py +0 -0
  115. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_rate_limit_config.py +0 -0
  116. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_rate_limiter.py +0 -0
  117. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_request_manager.py +0 -0
  118. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_retry_manager.py +0 -0
  119. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_transport.py +0 -0
  120. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/api/test_u_types.py +0 -0
  121. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/cli/conftest.py +0 -0
  122. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/cli/test_u_cli_app.py +0 -0
  123. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/cli/test_u_cli_handlers.py +0 -0
  124. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/cli/test_u_cli_main.py +0 -0
  125. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/config/test_u_config_utils.py +0 -0
  126. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/config/test_u_connector.py +0 -0
  127. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/config/test_u_jobs.py +0 -0
  128. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/config/test_u_pipeline.py +0 -0
  129. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/conftest.py +0 -0
  130. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/database/test_u_database_ddl.py +0 -0
  131. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_enums.py +0 -0
  132. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_extract.py +0 -0
  133. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_file.py +0 -0
  134. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_load.py +0 -0
  135. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_main.py +0 -0
  136. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_mixins.py +0 -0
  137. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_run.py +0 -0
  138. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_run_helpers.py +0 -0
  139. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_transform.py +0 -0
  140. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_utils.py +0 -0
  141. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_validate.py +0 -0
  142. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/test_u_version.py +0 -0
  143. {etlplus-0.6.1 → etlplus-0.7.0}/tests/unit/validation/test_u_validation_utils.py +0 -0
  144. {etlplus-0.6.1 → etlplus-0.7.0}/tools/run_pipeline.py +0 -0
  145. {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.6.1
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
+ ]
@@ -1,5 +1,5 @@
1
1
  """
2
- :mod:`etlplus.ddl` module.
2
+ :mod:`etlplus.database.ddl` module.
3
3
 
4
4
  DDL rendering utilities for pipeline table schemas.
5
5
 
@@ -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()