etlplus 0.5.5__tar.gz → 0.6.1__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.5.5/etlplus.egg-info → etlplus-0.6.1}/PKG-INFO +1 -1
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/cli/handlers.py +2 -2
- etlplus-0.6.1/etlplus/database/__init__.py +23 -0
- {etlplus-0.5.5/etlplus → etlplus-0.6.1/etlplus/database}/ddl.py +160 -46
- {etlplus-0.5.5 → etlplus-0.6.1/etlplus.egg-info}/PKG-INFO +1 -1
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus.egg-info/SOURCES.txt +3 -1
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/cli/test_u_cli_app.py +1 -1
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/cli/test_u_cli_handlers.py +1 -1
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/cli/test_u_cli_main.py +1 -1
- etlplus-0.6.1/tests/unit/database/test_u_database_ddl.py +265 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/.coveragerc +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/.editorconfig +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/.gitattributes +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/.github/actions/python-bootstrap/action.yml +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/.github/workflows/ci.yml +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/.gitignore +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/.pre-commit-config.yaml +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/.ruff.toml +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/CODE_OF_CONDUCT.md +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/CONTRIBUTING.md +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/DEMO.md +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/LICENSE +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/MANIFEST.in +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/Makefile +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/README.md +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/REFERENCES.md +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/docs/pipeline-guide.md +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/docs/snippets/installation_version.md +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/__init__.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/__main__.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/__version__.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/README.md +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/__init__.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/auth.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/config.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/endpoint_client.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/errors.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/pagination/__init__.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/pagination/client.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/pagination/config.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/pagination/paginator.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/rate_limiting/__init__.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/rate_limiting/config.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/request_manager.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/retry_manager.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/transport.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/api/types.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/cli/__init__.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/cli/app.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/cli/main.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/__init__.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/connector.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/jobs.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/pipeline.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/profile.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/types.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/config/utils.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/enums.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/extract.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/file.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/load.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/mixins.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/py.typed +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/run.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/run_helpers.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/templates/__init__.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/templates/ddl.sql.j2 +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/templates/view.sql.j2 +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/transform.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/types.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/utils.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/validate.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/validation/__init__.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus/validation/utils.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus.egg-info/dependency_links.txt +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus.egg-info/entry_points.txt +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus.egg-info/requires.txt +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/etlplus.egg-info/top_level.txt +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/examples/README.md +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/examples/configs/ddl_spec.yml +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/examples/configs/pipeline.yml +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/examples/data/sample.csv +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/examples/data/sample.json +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/examples/data/sample.xml +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/examples/data/sample.xsd +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/examples/data/sample.yaml +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/examples/quickstart_python.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/pyproject.toml +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/pytest.ini +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/setup.cfg +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/setup.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/__init__.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/conftest.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/conftest.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_cli.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_examples_data_parity.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_pagination_strategy.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_pipeline_smoke.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_run.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/conftest.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_auth.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_config.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_endpoint_client.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_mocks.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_pagination_client.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_pagination_config.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_paginator.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_rate_limit_config.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_rate_limiter.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_request_manager.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_retry_manager.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_transport.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/api/test_u_types.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/cli/conftest.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/config/test_u_config_utils.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/config/test_u_connector.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/config/test_u_jobs.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/config/test_u_pipeline.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/conftest.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_enums.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_extract.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_file.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_load.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_main.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_mixins.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_run.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_run_helpers.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_transform.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_utils.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_validate.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/test_u_version.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tests/unit/validation/test_u_validation_utils.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tools/run_pipeline.py +0 -0
- {etlplus-0.5.5 → etlplus-0.6.1}/tools/update_demo_snippets.py +0 -0
|
@@ -18,8 +18,8 @@ from typing import cast
|
|
|
18
18
|
|
|
19
19
|
from ..config import PipelineConfig
|
|
20
20
|
from ..config import load_pipeline_config
|
|
21
|
-
from ..
|
|
22
|
-
from ..
|
|
21
|
+
from ..database import load_table_spec
|
|
22
|
+
from ..database import render_tables
|
|
23
23
|
from ..enums import FileFormat
|
|
24
24
|
from ..extract import extract
|
|
25
25
|
from ..file import File
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:mod:`etlplus.database` package.
|
|
3
|
+
|
|
4
|
+
This package defines database-related utilities for ETLPlus, including:
|
|
5
|
+
- DDL rendering and schema management.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .ddl import load_table_spec
|
|
11
|
+
from .ddl import render_table_sql
|
|
12
|
+
from .ddl import render_tables
|
|
13
|
+
from .ddl import render_tables_to_string
|
|
14
|
+
|
|
15
|
+
# SECTION: EXPORTS ========================================================== #
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
'load_table_spec',
|
|
20
|
+
'render_table_sql',
|
|
21
|
+
'render_tables',
|
|
22
|
+
'render_tables_to_string',
|
|
23
|
+
]
|
|
@@ -11,18 +11,20 @@ can emit DDLs without shelling out to that script.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import importlib.resources
|
|
14
|
-
import json
|
|
15
14
|
import os
|
|
16
15
|
from collections.abc import Iterable
|
|
17
16
|
from collections.abc import Mapping
|
|
18
17
|
from pathlib import Path
|
|
19
18
|
from typing import Any
|
|
19
|
+
from typing import Final
|
|
20
20
|
|
|
21
21
|
from jinja2 import DictLoader
|
|
22
22
|
from jinja2 import Environment
|
|
23
23
|
from jinja2 import FileSystemLoader
|
|
24
24
|
from jinja2 import StrictUndefined
|
|
25
25
|
|
|
26
|
+
from ..file import File
|
|
27
|
+
|
|
26
28
|
# SECTION: EXPORTS ========================================================== #
|
|
27
29
|
|
|
28
30
|
|
|
@@ -31,13 +33,26 @@ __all__ = [
|
|
|
31
33
|
'load_table_spec',
|
|
32
34
|
'render_table_sql',
|
|
33
35
|
'render_tables',
|
|
36
|
+
'render_tables_to_string',
|
|
34
37
|
]
|
|
35
38
|
|
|
36
39
|
|
|
40
|
+
# SECTION: INTERNAL CONSTANTS =============================================== #
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_SUPPORTED_SPEC_SUFFIXES: Final[frozenset[str]] = frozenset(
|
|
44
|
+
{
|
|
45
|
+
'.json',
|
|
46
|
+
'.yml',
|
|
47
|
+
'.yaml',
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
37
52
|
# SECTION: CONSTANTS ======================================================== #
|
|
38
53
|
|
|
39
54
|
|
|
40
|
-
TEMPLATES = {
|
|
55
|
+
TEMPLATES: Final[dict[str, str]] = {
|
|
41
56
|
'ddl': 'ddl.sql.j2',
|
|
42
57
|
'view': 'view.sql.j2',
|
|
43
58
|
}
|
|
@@ -46,12 +61,68 @@ TEMPLATES = {
|
|
|
46
61
|
# SECTION: INTERNAL FUNCTIONS =============================================== #
|
|
47
62
|
|
|
48
63
|
|
|
49
|
-
def
|
|
64
|
+
def _load_template_text(
|
|
65
|
+
filename: str,
|
|
66
|
+
) -> str:
|
|
67
|
+
"""Return the bundled template text.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
filename : str
|
|
72
|
+
Template filename located inside the package data folder.
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
str
|
|
77
|
+
Raw template contents.
|
|
78
|
+
|
|
79
|
+
Raises
|
|
80
|
+
------
|
|
81
|
+
FileNotFoundError
|
|
82
|
+
If the template file cannot be located in package data.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
return (
|
|
87
|
+
importlib.resources.files(
|
|
88
|
+
'etlplus.templates',
|
|
89
|
+
)
|
|
90
|
+
.joinpath(filename)
|
|
91
|
+
.read_text(encoding='utf-8')
|
|
92
|
+
)
|
|
93
|
+
except FileNotFoundError as exc: # pragma: no cover - deployment guard
|
|
94
|
+
raise FileNotFoundError(
|
|
95
|
+
f'Could not load template {filename} '
|
|
96
|
+
f'from etlplus.templates package data.',
|
|
97
|
+
) from exc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_template(
|
|
50
101
|
*,
|
|
51
102
|
template_key: str | None,
|
|
52
103
|
template_path: str | None,
|
|
53
|
-
) -> Environment:
|
|
54
|
-
"""Return
|
|
104
|
+
) -> tuple[Environment, str]:
|
|
105
|
+
"""Return environment and template name for rendering.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
template_key : str | None
|
|
110
|
+
Named template key bundled with the package.
|
|
111
|
+
template_path : str | None
|
|
112
|
+
Explicit template file override.
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
tuple[Environment, str]
|
|
117
|
+
Pair of configured Jinja environment and the template identifier.
|
|
118
|
+
|
|
119
|
+
Raises
|
|
120
|
+
------
|
|
121
|
+
FileNotFoundError
|
|
122
|
+
If the provided template path does not exist.
|
|
123
|
+
ValueError
|
|
124
|
+
If the template key is unknown.
|
|
125
|
+
"""
|
|
55
126
|
file_override = template_path or os.environ.get('TEMPLATE_NAME')
|
|
56
127
|
if file_override:
|
|
57
128
|
path = Path(file_override)
|
|
@@ -64,8 +135,7 @@ def _build_env(
|
|
|
64
135
|
trim_blocks=True,
|
|
65
136
|
lstrip_blocks=True,
|
|
66
137
|
)
|
|
67
|
-
env
|
|
68
|
-
return env
|
|
138
|
+
return env, path.name
|
|
69
139
|
|
|
70
140
|
key = (template_key or 'ddl').strip()
|
|
71
141
|
if key not in TEMPLATES:
|
|
@@ -84,51 +154,59 @@ def _build_env(
|
|
|
84
154
|
trim_blocks=True,
|
|
85
155
|
lstrip_blocks=True,
|
|
86
156
|
)
|
|
87
|
-
env
|
|
88
|
-
return env
|
|
157
|
+
return env, key
|
|
89
158
|
|
|
90
159
|
|
|
91
|
-
|
|
92
|
-
"""Return the raw template text bundled with the package."""
|
|
160
|
+
# SECTION: FUNCTIONS ======================================================== #
|
|
93
161
|
|
|
94
|
-
try:
|
|
95
|
-
return (
|
|
96
|
-
importlib.resources.files(
|
|
97
|
-
'etlplus.templates',
|
|
98
|
-
)
|
|
99
|
-
.joinpath(filename)
|
|
100
|
-
.read_text(encoding='utf-8')
|
|
101
|
-
)
|
|
102
|
-
except FileNotFoundError as exc: # pragma: no cover - deployment guard
|
|
103
|
-
raise FileNotFoundError(
|
|
104
|
-
f'Could not load template {filename} '
|
|
105
|
-
f'from etlplus.templates package data.',
|
|
106
|
-
) from exc
|
|
107
162
|
|
|
163
|
+
def load_table_spec(
|
|
164
|
+
path: Path | str,
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
"""
|
|
167
|
+
Load a table specification from disk.
|
|
108
168
|
|
|
109
|
-
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
path : Path | str
|
|
172
|
+
Path to the JSON or YAML specification file.
|
|
110
173
|
|
|
174
|
+
Returns
|
|
175
|
+
-------
|
|
176
|
+
dict[str, Any]
|
|
177
|
+
Parsed table specification mapping.
|
|
111
178
|
|
|
112
|
-
|
|
113
|
-
|
|
179
|
+
Raises
|
|
180
|
+
------
|
|
181
|
+
ImportError
|
|
182
|
+
If the file cannot be read due to missing dependencies.
|
|
183
|
+
RuntimeError
|
|
184
|
+
If the YAML dependency is missing for YAML specs.
|
|
185
|
+
TypeError
|
|
186
|
+
If the loaded spec is not a mapping.
|
|
187
|
+
ValueError
|
|
188
|
+
If the file suffix is not supported.
|
|
189
|
+
"""
|
|
114
190
|
|
|
115
191
|
spec_path = Path(path)
|
|
116
|
-
text = spec_path.read_text(encoding='utf-8')
|
|
117
192
|
suffix = spec_path.suffix.lower()
|
|
118
193
|
|
|
119
|
-
if suffix
|
|
120
|
-
|
|
194
|
+
if suffix not in _SUPPORTED_SPEC_SUFFIXES:
|
|
195
|
+
raise ValueError('Spec must be .json, .yml, or .yaml')
|
|
121
196
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
197
|
+
try:
|
|
198
|
+
spec = File.read_file(spec_path)
|
|
199
|
+
except ImportError as e:
|
|
200
|
+
if suffix in {'.yml', '.yaml'}:
|
|
126
201
|
raise RuntimeError(
|
|
127
202
|
'Missing dependency: pyyaml is required for YAML specs.',
|
|
128
|
-
) from
|
|
129
|
-
|
|
203
|
+
) from e
|
|
204
|
+
raise
|
|
130
205
|
|
|
131
|
-
|
|
206
|
+
if not isinstance(spec, Mapping):
|
|
207
|
+
raise TypeError('Table spec must be a mapping')
|
|
208
|
+
|
|
209
|
+
return dict(spec)
|
|
132
210
|
|
|
133
211
|
|
|
134
212
|
def render_table_sql(
|
|
@@ -153,16 +231,11 @@ def render_table_sql(
|
|
|
153
231
|
-------
|
|
154
232
|
str
|
|
155
233
|
Rendered SQL string.
|
|
156
|
-
|
|
157
|
-
Raises
|
|
158
|
-
------
|
|
159
|
-
TypeError
|
|
160
|
-
If the loaded template name is not a string.
|
|
161
234
|
"""
|
|
162
|
-
env =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
235
|
+
env, template_name = _resolve_template(
|
|
236
|
+
template_key=template,
|
|
237
|
+
template_path=template_path,
|
|
238
|
+
)
|
|
166
239
|
tmpl = env.get_template(template_name)
|
|
167
240
|
return tmpl.render(spec=spec).rstrip() + '\n'
|
|
168
241
|
|
|
@@ -195,3 +268,44 @@ def render_tables(
|
|
|
195
268
|
render_table_sql(spec, template=template, template_path=template_path)
|
|
196
269
|
for spec in specs
|
|
197
270
|
]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def render_tables_to_string(
|
|
274
|
+
spec_paths: Iterable[Path | str],
|
|
275
|
+
*,
|
|
276
|
+
template: str | None = 'ddl',
|
|
277
|
+
template_path: Path | str | None = None,
|
|
278
|
+
) -> str:
|
|
279
|
+
"""
|
|
280
|
+
Render one or more specs and concatenate the SQL payloads.
|
|
281
|
+
|
|
282
|
+
Parameters
|
|
283
|
+
----------
|
|
284
|
+
spec_paths : Iterable[Path | str]
|
|
285
|
+
Paths to table specification files.
|
|
286
|
+
template : str | None, optional
|
|
287
|
+
Template key bundled with ETLPlus. Defaults to ``'ddl'``.
|
|
288
|
+
template_path : Path | str | None, optional
|
|
289
|
+
Custom Jinja template to override the bundled templates.
|
|
290
|
+
|
|
291
|
+
Returns
|
|
292
|
+
-------
|
|
293
|
+
str
|
|
294
|
+
Concatenated SQL payload suitable for writing to disk or stdout.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
resolved_template_path = (
|
|
298
|
+
str(template_path) if template_path is not None else None
|
|
299
|
+
)
|
|
300
|
+
rendered_sql: list[str] = []
|
|
301
|
+
for spec_path in spec_paths:
|
|
302
|
+
spec = load_table_spec(spec_path)
|
|
303
|
+
rendered_sql.append(
|
|
304
|
+
render_table_sql(
|
|
305
|
+
spec,
|
|
306
|
+
template=template,
|
|
307
|
+
template_path=resolved_template_path,
|
|
308
|
+
),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
return ''.join(rendered_sql)
|
|
@@ -22,7 +22,6 @@ docs/snippets/installation_version.md
|
|
|
22
22
|
etlplus/__init__.py
|
|
23
23
|
etlplus/__main__.py
|
|
24
24
|
etlplus/__version__.py
|
|
25
|
-
etlplus/ddl.py
|
|
26
25
|
etlplus/enums.py
|
|
27
26
|
etlplus/extract.py
|
|
28
27
|
etlplus/file.py
|
|
@@ -69,6 +68,8 @@ etlplus/config/pipeline.py
|
|
|
69
68
|
etlplus/config/profile.py
|
|
70
69
|
etlplus/config/types.py
|
|
71
70
|
etlplus/config/utils.py
|
|
71
|
+
etlplus/database/__init__.py
|
|
72
|
+
etlplus/database/ddl.py
|
|
72
73
|
etlplus/templates/__init__.py
|
|
73
74
|
etlplus/templates/ddl.sql.j2
|
|
74
75
|
etlplus/templates/view.sql.j2
|
|
@@ -129,6 +130,7 @@ tests/unit/config/test_u_config_utils.py
|
|
|
129
130
|
tests/unit/config/test_u_connector.py
|
|
130
131
|
tests/unit/config/test_u_jobs.py
|
|
131
132
|
tests/unit/config/test_u_pipeline.py
|
|
133
|
+
tests/unit/database/test_u_database_ddl.py
|
|
132
134
|
tests/unit/validation/test_u_validation_utils.py
|
|
133
135
|
tools/run_pipeline.py
|
|
134
136
|
tools/update_demo_snippets.py
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:mod:`tests.unit.database.test_u_database_ddl` module.
|
|
3
|
+
|
|
4
|
+
Unit tests for :mod:`etlplus.database.ddl`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from etlplus.database import ddl
|
|
16
|
+
|
|
17
|
+
# SECTION: HELPERS ========================================================== #
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
pytestmark = pytest.mark.unit
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# SECTION: FIXTURES ========================================================= #
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(name='sample_spec')
|
|
27
|
+
def fixture_sample_spec() -> dict[str, object]:
|
|
28
|
+
"""Sample table specification for testing."""
|
|
29
|
+
return {
|
|
30
|
+
'schema': 'dbo',
|
|
31
|
+
'table': 'widgets',
|
|
32
|
+
'create_schema': False,
|
|
33
|
+
'columns': [
|
|
34
|
+
{
|
|
35
|
+
'name': 'id',
|
|
36
|
+
'type': 'INT',
|
|
37
|
+
'nullable': False,
|
|
38
|
+
'identity': {'seed': 1, 'increment': 1},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
'name': 'name',
|
|
42
|
+
'type': 'NVARCHAR(255)',
|
|
43
|
+
'nullable': True,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
'primary_key': {
|
|
47
|
+
'columns': ['id'],
|
|
48
|
+
},
|
|
49
|
+
'indexes': [
|
|
50
|
+
{
|
|
51
|
+
'name': 'IX_widgets_name',
|
|
52
|
+
'columns': ['name'],
|
|
53
|
+
'unique': True,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
'foreign_keys': [],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# SECTION: TESTS ============================================================ #
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestLoadTableSpec:
|
|
64
|
+
"""Unit test suite for :func:`load_table_spec`."""
|
|
65
|
+
|
|
66
|
+
def test_missing_yaml_dependency(
|
|
67
|
+
self,
|
|
68
|
+
tmp_path: Path,
|
|
69
|
+
sample_spec: dict[str, object],
|
|
70
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Test that loading a YAML spec without PyYAML raises RuntimeError."""
|
|
73
|
+
yaml = pytest.importorskip('yaml')
|
|
74
|
+
spec_path = tmp_path / 'spec.yaml'
|
|
75
|
+
spec_path.write_text(
|
|
76
|
+
yaml.safe_dump(sample_spec, sort_keys=False),
|
|
77
|
+
encoding='utf-8',
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
import etlplus.file as file_mod
|
|
81
|
+
|
|
82
|
+
file_mod._YAML_CACHE.clear()
|
|
83
|
+
|
|
84
|
+
def _raise_import_error() -> None:
|
|
85
|
+
raise ImportError('forced failure for test')
|
|
86
|
+
|
|
87
|
+
monkeypatch.setattr(file_mod, '_get_yaml', _raise_import_error)
|
|
88
|
+
|
|
89
|
+
with pytest.raises(RuntimeError):
|
|
90
|
+
ddl.load_table_spec(spec_path)
|
|
91
|
+
|
|
92
|
+
def test_requires_mapping(self, tmp_path: Path) -> None:
|
|
93
|
+
"""Test that loading a spec requires a mapping at the top level."""
|
|
94
|
+
spec_path = tmp_path / 'array.json'
|
|
95
|
+
spec_path.write_text(
|
|
96
|
+
json.dumps([{'not': 'mapping'}]),
|
|
97
|
+
encoding='utf-8',
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
with pytest.raises(TypeError):
|
|
101
|
+
ddl.load_table_spec(spec_path)
|
|
102
|
+
|
|
103
|
+
@pytest.mark.parametrize(
|
|
104
|
+
'extension',
|
|
105
|
+
['json', 'yaml'],
|
|
106
|
+
ids=['json', 'yaml'],
|
|
107
|
+
)
|
|
108
|
+
def test_roundtrip(
|
|
109
|
+
self,
|
|
110
|
+
tmp_path: Path,
|
|
111
|
+
extension: str,
|
|
112
|
+
sample_spec: dict[str, object],
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Test loading a table spec from JSON and YAML formats."""
|
|
115
|
+
spec_path = tmp_path / f'spec.{extension}'
|
|
116
|
+
materialized = deepcopy(sample_spec)
|
|
117
|
+
if extension == 'json':
|
|
118
|
+
spec_path.write_text(json.dumps(materialized), encoding='utf-8')
|
|
119
|
+
else:
|
|
120
|
+
yaml = pytest.importorskip('yaml')
|
|
121
|
+
spec_path.write_text(
|
|
122
|
+
yaml.safe_dump(materialized, sort_keys=False),
|
|
123
|
+
encoding='utf-8',
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
loaded = ddl.load_table_spec(spec_path)
|
|
127
|
+
assert loaded == materialized
|
|
128
|
+
|
|
129
|
+
def test_unsupported_suffix(self, tmp_path: Path) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Test that loading a spec with an unsupported suffix raises ValueError.
|
|
132
|
+
"""
|
|
133
|
+
spec_path = tmp_path / 'spec.txt'
|
|
134
|
+
spec_path.write_text('{}', encoding='utf-8')
|
|
135
|
+
|
|
136
|
+
with pytest.raises(ValueError):
|
|
137
|
+
ddl.load_table_spec(spec_path)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TestRenderTableSql:
|
|
141
|
+
"""Unit test suite for :func:`render_table_sql`."""
|
|
142
|
+
|
|
143
|
+
def test_custom_template_path(
|
|
144
|
+
self,
|
|
145
|
+
tmp_path: Path,
|
|
146
|
+
sample_spec: dict[str, object],
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Test rendering SQL with a custom template path."""
|
|
149
|
+
template_path = tmp_path / 'custom.sql.j2'
|
|
150
|
+
template_path.write_text('{{ spec.table }}', encoding='utf-8')
|
|
151
|
+
|
|
152
|
+
sql = ddl.render_table_sql(
|
|
153
|
+
sample_spec,
|
|
154
|
+
template_path=str(template_path),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert sql == f'{sample_spec["table"]}\n'
|
|
158
|
+
|
|
159
|
+
def test_default_template(
|
|
160
|
+
self,
|
|
161
|
+
sample_spec: dict[str, object],
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Test rendering SQL with the default template."""
|
|
164
|
+
sql = ddl.render_table_sql(sample_spec)
|
|
165
|
+
assert f'CREATE TABLE [dbo].[{sample_spec["table"]}' in sql
|
|
166
|
+
assert '[id] INT' in sql
|
|
167
|
+
|
|
168
|
+
def test_env_override(
|
|
169
|
+
self,
|
|
170
|
+
tmp_path: Path,
|
|
171
|
+
sample_spec: dict[str, object],
|
|
172
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""
|
|
175
|
+
Test rendering SQL with an environment variable override for the
|
|
176
|
+
template path.
|
|
177
|
+
"""
|
|
178
|
+
template_path = tmp_path / 'env_template.sql.j2'
|
|
179
|
+
template_path.write_text(
|
|
180
|
+
'{{ spec.schema }}.{{ spec.table }}',
|
|
181
|
+
encoding='utf-8',
|
|
182
|
+
)
|
|
183
|
+
monkeypatch.setenv('TEMPLATE_NAME', str(template_path))
|
|
184
|
+
|
|
185
|
+
sql = ddl.render_table_sql(sample_spec, template=None)
|
|
186
|
+
|
|
187
|
+
assert sql == 'dbo.widgets\n'
|
|
188
|
+
|
|
189
|
+
def test_missing_template_path(
|
|
190
|
+
self,
|
|
191
|
+
sample_spec: dict[str, object],
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Test that a missing template path raises FileNotFoundError."""
|
|
194
|
+
missing = Path('/nonexistent/template.sql.j2')
|
|
195
|
+
with pytest.raises(FileNotFoundError):
|
|
196
|
+
ddl.render_table_sql(sample_spec, template_path=str(missing))
|
|
197
|
+
|
|
198
|
+
def test_unknown_template_key(
|
|
199
|
+
self,
|
|
200
|
+
sample_spec: dict[str, object],
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Test that an unknown template key raises ValueError."""
|
|
203
|
+
with pytest.raises(ValueError):
|
|
204
|
+
ddl.render_table_sql(sample_spec, template='does_not_exist')
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class TestRenderTablesToString:
|
|
208
|
+
"""
|
|
209
|
+
Unit test suite for :func:`render_tables_to_string`.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def test_custom_template(
|
|
213
|
+
self,
|
|
214
|
+
tmp_path: Path,
|
|
215
|
+
sample_spec: dict[str, object],
|
|
216
|
+
) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Test rendering multiple table specs to a string with a custom template.
|
|
219
|
+
"""
|
|
220
|
+
template_path = tmp_path / 'concat_template.sql.j2'
|
|
221
|
+
template_path.write_text('{{ spec.table }}', encoding='utf-8')
|
|
222
|
+
|
|
223
|
+
path = tmp_path / 'spec.json'
|
|
224
|
+
path.write_text(json.dumps(sample_spec), encoding='utf-8')
|
|
225
|
+
|
|
226
|
+
sql = ddl.render_tables_to_string(
|
|
227
|
+
[path],
|
|
228
|
+
template_path=template_path,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
assert sql == f'{sample_spec["table"]}\n'
|
|
232
|
+
|
|
233
|
+
def test_from_paths(
|
|
234
|
+
self,
|
|
235
|
+
tmp_path: Path,
|
|
236
|
+
sample_spec: dict[str, object],
|
|
237
|
+
) -> None:
|
|
238
|
+
"""
|
|
239
|
+
Test rendering multiple table specs from file paths into a single SQL
|
|
240
|
+
string.
|
|
241
|
+
"""
|
|
242
|
+
spec_paths: list[Path] = []
|
|
243
|
+
for idx, table_name in enumerate(('widgets', 'widgets_history')):
|
|
244
|
+
materialized = deepcopy(sample_spec)
|
|
245
|
+
materialized['table'] = table_name
|
|
246
|
+
path = tmp_path / f'spec_{idx}.json'
|
|
247
|
+
path.write_text(json.dumps(materialized), encoding='utf-8')
|
|
248
|
+
spec_paths.append(path)
|
|
249
|
+
|
|
250
|
+
sql = ddl.render_tables_to_string(spec_paths)
|
|
251
|
+
|
|
252
|
+
assert 'widgets' in sql
|
|
253
|
+
assert 'widgets_history' in sql
|
|
254
|
+
|
|
255
|
+
def test_templates_constant_exposes_builtin_keys(self) -> None:
|
|
256
|
+
"""Test that TEMPLATES constant includes expected built-in keys."""
|
|
257
|
+
assert {'ddl', 'view'}.issubset(set(ddl.TEMPLATES))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class TestTemplate:
|
|
261
|
+
"""Unit test suite for ``TEMPLATE``."""
|
|
262
|
+
|
|
263
|
+
def test_builtin_keys_exposure(self) -> None:
|
|
264
|
+
"""Test that TEMPLATES constant includes expected built-in keys."""
|
|
265
|
+
assert {'ddl', 'view'}.issubset(set(ddl.TEMPLATES))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|