etlplus 0.4.9__py3-none-any.whl → 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
etlplus/cli/app.py CHANGED
@@ -25,6 +25,7 @@ Subcommands
25
25
  - ``validate``: validate data against rules
26
26
  - ``transform``: transform records
27
27
  - ``load``: load data to files, databases, or REST APIs
28
+ - ``render``: render SQL DDL from table schema specs
28
29
 
29
30
  Notes
30
31
  -----
@@ -60,6 +61,7 @@ from .handlers import cmd_extract
60
61
  from .handlers import cmd_list
61
62
  from .handlers import cmd_load
62
63
  from .handlers import cmd_pipeline
64
+ from .handlers import cmd_render
63
65
  from .handlers import cmd_run
64
66
  from .handlers import cmd_transform
65
67
  from .handlers import cmd_validate
@@ -258,6 +260,67 @@ PipelineConfigOption = Annotated[
258
260
  ),
259
261
  ]
260
262
 
263
+ RenderConfigOption = Annotated[
264
+ str | None,
265
+ typer.Option(
266
+ '--config',
267
+ metavar='PATH',
268
+ help='Pipeline YAML that includes table_schemas for rendering.',
269
+ show_default=False,
270
+ ),
271
+ ]
272
+
273
+ RenderOutputOption = Annotated[
274
+ str | None,
275
+ typer.Option(
276
+ '--output',
277
+ '-o',
278
+ metavar='PATH',
279
+ help='Write rendered SQL to PATH (default: stdout).',
280
+ ),
281
+ ]
282
+
283
+ RenderSpecOption = Annotated[
284
+ str | None,
285
+ typer.Option(
286
+ '--spec',
287
+ metavar='PATH',
288
+ help='Standalone table spec file (.yml/.yaml/.json).',
289
+ show_default=False,
290
+ ),
291
+ ]
292
+
293
+ RenderTableOption = Annotated[
294
+ str | None,
295
+ typer.Option(
296
+ '--table',
297
+ metavar='NAME',
298
+ help='Filter to a single table name from table_schemas.',
299
+ ),
300
+ ]
301
+
302
+ RenderTemplateOption = Annotated[
303
+ str,
304
+ typer.Option(
305
+ '--template',
306
+ '-t',
307
+ metavar='KEY|PATH',
308
+ help='Template key (ddl/view) or path to a Jinja template file.',
309
+ show_default=True,
310
+ ),
311
+ ]
312
+
313
+ RenderTemplatePathOption = Annotated[
314
+ str | None,
315
+ typer.Option(
316
+ '--template-path',
317
+ metavar='PATH',
318
+ help=(
319
+ 'Explicit path to a Jinja template file (overrides template key).'
320
+ ),
321
+ ),
322
+ ]
323
+
261
324
 
262
325
  # SECTION: DATA CLASSES ===================================================== #
263
326
 
@@ -1001,6 +1064,55 @@ def pipeline_cmd(
1001
1064
  return int(cmd_pipeline(ns))
1002
1065
 
1003
1066
 
1067
+ @app.command('render')
1068
+ def render_cmd(
1069
+ ctx: typer.Context,
1070
+ config: RenderConfigOption = None,
1071
+ spec: RenderSpecOption = None,
1072
+ table: RenderTableOption = None,
1073
+ template: RenderTemplateOption = 'ddl',
1074
+ template_path: RenderTemplatePathOption = None,
1075
+ output: RenderOutputOption = None,
1076
+ ) -> int:
1077
+ """
1078
+ Render SQL DDL from table schemas defined in YAML/JSON configs.
1079
+
1080
+ Parameters
1081
+ ----------
1082
+ ctx : typer.Context
1083
+ Typer execution context provided to the command.
1084
+ config : RenderConfigOption, optional
1085
+ Pipeline YAML containing ``table_schemas`` entries.
1086
+ spec : RenderSpecOption, optional
1087
+ Standalone table spec file (.yml/.yaml/.json).
1088
+ table : RenderTableOption, optional
1089
+ Filter to a single table name within the available specs.
1090
+ template : RenderTemplateOption, optional
1091
+ Built-in template key or template file path.
1092
+ template_path : RenderTemplatePathOption, optional
1093
+ Explicit template file path to render with.
1094
+ output : RenderOutputOption, optional
1095
+ Path to write SQL to (stdout when omitted).
1096
+
1097
+ Returns
1098
+ -------
1099
+ int
1100
+ Zero on success.
1101
+ """
1102
+ state = _ensure_state(ctx)
1103
+ ns = _stateful_namespace(
1104
+ state,
1105
+ command='render',
1106
+ config=config,
1107
+ spec=spec,
1108
+ table=table,
1109
+ template=template,
1110
+ template_path=template_path,
1111
+ output=output,
1112
+ )
1113
+ return int(cmd_render(ns))
1114
+
1115
+
1004
1116
  @app.command('run')
1005
1117
  def run_cmd(
1006
1118
  ctx: typer.Context,
etlplus/cli/handlers.py CHANGED
@@ -18,6 +18,8 @@ from typing import cast
18
18
 
19
19
  from ..config import PipelineConfig
20
20
  from ..config import load_pipeline_config
21
+ from ..ddl import load_table_spec
22
+ from ..ddl import render_tables
21
23
  from ..enums import FileFormat
22
24
  from ..extract import extract
23
25
  from ..file import File
@@ -38,6 +40,7 @@ __all__ = [
38
40
  'cmd_list',
39
41
  'cmd_load',
40
42
  'cmd_pipeline',
43
+ 'cmd_render',
41
44
  'cmd_run',
42
45
  'cmd_transform',
43
46
  'cmd_validate',
@@ -47,6 +50,37 @@ __all__ = [
47
50
  # SECTION: INTERNAL FUNCTIONS =============================================== #
48
51
 
49
52
 
53
+ def _collect_table_specs(
54
+ config_path: str | None,
55
+ spec_path: str | None,
56
+ ) -> list[dict[str, Any]]:
57
+ """
58
+ Load table schemas from a pipeline config and/or standalone spec.
59
+
60
+ Parameters
61
+ ----------
62
+ config_path : str | None
63
+ Path to a pipeline YAML config file.
64
+ spec_path : str | None
65
+ Path to a standalone table spec file.
66
+
67
+ Returns
68
+ -------
69
+ list[dict[str, Any]]
70
+ Collected table specification mappings.
71
+ """
72
+ specs: list[dict[str, Any]] = []
73
+
74
+ if spec_path:
75
+ specs.append(load_table_spec(Path(spec_path)))
76
+
77
+ if config_path:
78
+ cfg = load_pipeline_config(config_path, substitute=True)
79
+ specs.extend(getattr(cfg, 'table_schemas', []))
80
+
81
+ return specs
82
+
83
+
50
84
  def _emit_json(
51
85
  data: Any,
52
86
  *,
@@ -75,6 +109,23 @@ def _emit_json(
75
109
  print(dumped)
76
110
 
77
111
 
112
+ def _explicit_cli_format(
113
+ args: argparse.Namespace,
114
+ ) -> str | None:
115
+ """Return the explicit CLI format hint when provided."""
116
+
117
+ if not getattr(args, '_format_explicit', False):
118
+ return None
119
+ for attr in ('format', 'target_format', 'source_format'):
120
+ value = getattr(args, attr, None)
121
+ if value is None:
122
+ continue
123
+ normalized = value.strip().lower()
124
+ if normalized:
125
+ return normalized
126
+ return None
127
+
128
+
78
129
  def _infer_payload_format(
79
130
  text: str,
80
131
  ) -> str:
@@ -134,23 +185,6 @@ def _list_sections(
134
185
  return sections
135
186
 
136
187
 
137
- def _explicit_cli_format(
138
- args: argparse.Namespace,
139
- ) -> str | None:
140
- """Return the explicit CLI format hint when provided."""
141
-
142
- if not getattr(args, '_format_explicit', False):
143
- return None
144
- for attr in ('format', 'target_format', 'source_format'):
145
- value = getattr(args, attr, None)
146
- if value is None:
147
- continue
148
- normalized = value.strip().lower()
149
- if normalized:
150
- return normalized
151
- return None
152
-
153
-
154
188
  def _materialize_file_payload(
155
189
  source: object,
156
190
  *,
@@ -224,7 +258,6 @@ def _parse_text_payload(
224
258
  JSONData | str
225
259
  The parsed payload as JSON data or raw text.
226
260
  """
227
-
228
261
  effective = (fmt or '').strip().lower() or _infer_payload_format(text)
229
262
  if effective == 'json':
230
263
  return cast(JSONData, json_type(text))
@@ -265,7 +298,8 @@ def _pipeline_summary(
265
298
  def _presentation_flags(
266
299
  args: argparse.Namespace,
267
300
  ) -> tuple[bool, bool]:
268
- """Return presentation toggles from the parsed namespace.
301
+ """
302
+ Return presentation toggles from the parsed namespace.
269
303
 
270
304
  Parameters
271
305
  ----------
@@ -342,7 +376,6 @@ def _resolve_cli_payload(
342
376
  Parsed payload or the original source value when hydration is
343
377
  disabled.
344
378
  """
345
-
346
379
  if isinstance(source, (os.PathLike, str)) and str(source) == '-':
347
380
  text = _read_stdin_text()
348
381
  return _parse_text_payload(text, format_hint)
@@ -628,6 +661,67 @@ def cmd_pipeline(
628
661
  return 0
629
662
 
630
663
 
664
+ def cmd_render(
665
+ args: argparse.Namespace,
666
+ ) -> int:
667
+ """Render SQL DDL statements from table schema specs."""
668
+
669
+ _pretty, quiet = _presentation_flags(args)
670
+
671
+ template_value = getattr(args, 'template', 'ddl') or 'ddl'
672
+ template_path = getattr(args, 'template_path', None)
673
+ table_filter = getattr(args, 'table', None)
674
+ spec_path = getattr(args, 'spec', None)
675
+ config_path = getattr(args, 'config', None)
676
+
677
+ # If the provided template points to a file, treat it as a path override.
678
+ file_override = template_path
679
+ template_key = template_value
680
+ if template_path is None:
681
+ candidate_path = Path(template_value)
682
+ if candidate_path.exists():
683
+ file_override = str(candidate_path)
684
+ template_key = None
685
+
686
+ specs = _collect_table_specs(config_path, spec_path)
687
+ if table_filter:
688
+ specs = [
689
+ spec
690
+ for spec in specs
691
+ if str(spec.get('table')) == table_filter
692
+ or str(spec.get('name', '')) == table_filter
693
+ ]
694
+
695
+ if not specs:
696
+ target_desc = table_filter or 'table_schemas'
697
+ print(
698
+ 'No table schemas found for '
699
+ f'{target_desc}. Provide --spec or a pipeline --config with '
700
+ 'table_schemas.',
701
+ file=sys.stderr,
702
+ )
703
+ return 1
704
+
705
+ rendered_chunks = render_tables(
706
+ specs,
707
+ template=template_key,
708
+ template_path=file_override,
709
+ )
710
+ sql_text = (
711
+ '\n'.join(chunk.rstrip() for chunk in rendered_chunks).rstrip() + '\n'
712
+ )
713
+
714
+ output_path = getattr(args, 'output', None)
715
+ if output_path and output_path != '-':
716
+ Path(output_path).write_text(sql_text, encoding='utf-8')
717
+ if not quiet:
718
+ print(f'Rendered {len(specs)} schema(s) to {output_path}')
719
+ return 0
720
+
721
+ print(sql_text)
722
+ return 0
723
+
724
+
631
725
  def cmd_list(args: argparse.Namespace) -> int:
632
726
  """
633
727
  Print requested pipeline sections from a YAML configuration.
etlplus/cli/main.py CHANGED
@@ -28,6 +28,7 @@ from .handlers import cmd_extract
28
28
  from .handlers import cmd_list
29
29
  from .handlers import cmd_load
30
30
  from .handlers import cmd_pipeline
31
+ from .handlers import cmd_render
31
32
  from .handlers import cmd_run
32
33
  from .handlers import cmd_transform
33
34
  from .handlers import cmd_validate
@@ -441,6 +442,42 @@ def create_parser() -> argparse.ArgumentParser:
441
442
  )
442
443
  pipe_parser.set_defaults(func=cmd_pipeline)
443
444
 
445
+ render_parser = subparsers.add_parser(
446
+ 'render',
447
+ help='Render SQL DDL from table schema specs',
448
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
449
+ )
450
+ render_parser.add_argument(
451
+ '--config',
452
+ help='Pipeline YAML containing table_schemas',
453
+ )
454
+ render_parser.add_argument(
455
+ '-o',
456
+ '--output',
457
+ help='Write SQL to this path (stdout when omitted)',
458
+ )
459
+ render_parser.add_argument(
460
+ '--spec',
461
+ help='Standalone table spec file (.yml/.yaml/.json)',
462
+ )
463
+ render_parser.add_argument(
464
+ '--table',
465
+ help='Render only the table matching this name',
466
+ )
467
+ render_parser.add_argument(
468
+ '--template',
469
+ default='ddl',
470
+ help='Template key (ddl/view) or path to a Jinja template file',
471
+ )
472
+ render_parser.add_argument(
473
+ '--template-path',
474
+ dest='template_path',
475
+ help=(
476
+ 'Explicit path to a Jinja template file (overrides template key).'
477
+ ),
478
+ )
479
+ render_parser.set_defaults(func=cmd_render)
480
+
444
481
  list_parser = subparsers.add_parser(
445
482
  'list',
446
483
  help='List ETL pipeline metadata',
@@ -190,6 +190,8 @@ class PipelineConfig:
190
190
  Target connectors, parsed tolerantly.
191
191
  jobs : list[JobConfig]
192
192
  Job orchestration definitions.
193
+ table_schemas : list[dict[str, Any]]
194
+ Optional DDL-style table specifications used by the render command.
193
195
  """
194
196
 
195
197
  # -- Attributes -- #
@@ -208,6 +210,7 @@ class PipelineConfig:
208
210
  transforms: dict[str, dict[str, Any]] = field(default_factory=dict)
209
211
  targets: list[Connector] = field(default_factory=list)
210
212
  jobs: list[JobConfig] = field(default_factory=list)
213
+ table_schemas: list[dict[str, Any]] = field(default_factory=list)
211
214
 
212
215
  # -- Class Methods -- #
213
216
 
@@ -312,6 +315,13 @@ class PipelineConfig:
312
315
  # Jobs
313
316
  jobs = _build_jobs(raw)
314
317
 
318
+ # Table schemas (optional, tolerant pass-through structures).
319
+ table_schemas: list[dict[str, Any]] = []
320
+ for entry in raw.get('table_schemas', []) or []:
321
+ spec = maybe_mapping(entry)
322
+ if spec is not None:
323
+ table_schemas.append(dict(spec))
324
+
315
325
  return cls(
316
326
  name=name,
317
327
  version=version,
@@ -325,4 +335,5 @@ class PipelineConfig:
325
335
  transforms=transforms,
326
336
  targets=targets,
327
337
  jobs=jobs,
338
+ table_schemas=table_schemas,
328
339
  )
etlplus/ddl.py ADDED
@@ -0,0 +1,197 @@
1
+ """
2
+ :mod:`etlplus.ddl` module.
3
+
4
+ DDL rendering utilities for pipeline table schemas.
5
+
6
+ Exposes helpers to load YAML/JSON table specs and render them into SQL via
7
+ Jinja templates. Mirrors the behavior of ``tools/render_ddl.py`` so the CLI
8
+ can emit DDLs without shelling out to that script.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib.resources
14
+ import json
15
+ import os
16
+ from collections.abc import Iterable
17
+ from collections.abc import Mapping
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from jinja2 import DictLoader
22
+ from jinja2 import Environment
23
+ from jinja2 import FileSystemLoader
24
+ from jinja2 import StrictUndefined
25
+
26
+ # SECTION: EXPORTS ========================================================== #
27
+
28
+
29
+ __all__ = [
30
+ 'TEMPLATES',
31
+ 'load_table_spec',
32
+ 'render_table_sql',
33
+ 'render_tables',
34
+ ]
35
+
36
+
37
+ # SECTION: CONSTANTS ======================================================== #
38
+
39
+
40
+ TEMPLATES = {
41
+ 'ddl': 'ddl.sql.j2',
42
+ 'view': 'view.sql.j2',
43
+ }
44
+
45
+
46
+ # SECTION: INTERNAL FUNCTIONS =============================================== #
47
+
48
+
49
+ def _build_env(
50
+ *,
51
+ template_key: str | None,
52
+ template_path: str | None,
53
+ ) -> Environment:
54
+ """Return a Jinja2 environment using a built-in or file template."""
55
+ file_override = template_path or os.environ.get('TEMPLATE_NAME')
56
+ if file_override:
57
+ path = Path(file_override)
58
+ if not path.exists():
59
+ raise FileNotFoundError(f'Template file not found: {path}')
60
+ loader = FileSystemLoader(str(path.parent))
61
+ env = Environment(
62
+ loader=loader,
63
+ undefined=StrictUndefined,
64
+ trim_blocks=True,
65
+ lstrip_blocks=True,
66
+ )
67
+ env.globals['TEMPLATE_NAME'] = path.name
68
+ return env
69
+
70
+ key = (template_key or 'ddl').strip()
71
+ if key not in TEMPLATES:
72
+ choices = ', '.join(sorted(TEMPLATES))
73
+ raise ValueError(
74
+ f'Unknown template key "{key}". Choose from: {choices}',
75
+ )
76
+
77
+ # Load template from package data
78
+ template_filename = TEMPLATES[key]
79
+ template_source = _load_template_text(template_filename)
80
+
81
+ env = Environment(
82
+ loader=DictLoader({key: template_source}),
83
+ undefined=StrictUndefined,
84
+ trim_blocks=True,
85
+ lstrip_blocks=True,
86
+ )
87
+ env.globals['TEMPLATE_NAME'] = key
88
+ return env
89
+
90
+
91
+ def _load_template_text(filename: str) -> str:
92
+ """Return the raw template text bundled with the package."""
93
+
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
+
108
+
109
+ # SECTION: FUNCTIONS ======================================================== #
110
+
111
+
112
+ def load_table_spec(path: Path | str) -> dict[str, Any]:
113
+ """Load a table spec from JSON or YAML."""
114
+
115
+ spec_path = Path(path)
116
+ text = spec_path.read_text(encoding='utf-8')
117
+ suffix = spec_path.suffix.lower()
118
+
119
+ if suffix == '.json':
120
+ return json.loads(text)
121
+
122
+ if suffix in {'.yml', '.yaml'}:
123
+ try:
124
+ import yaml # type: ignore
125
+ except Exception as exc: # pragma: no cover
126
+ raise RuntimeError(
127
+ 'Missing dependency: pyyaml is required for YAML specs.',
128
+ ) from exc
129
+ return yaml.safe_load(text)
130
+
131
+ raise ValueError('Spec must be .json, .yml, or .yaml')
132
+
133
+
134
+ def render_table_sql(
135
+ spec: Mapping[str, Any],
136
+ *,
137
+ template: str | None = 'ddl',
138
+ template_path: str | None = None,
139
+ ) -> str:
140
+ """
141
+ Render a single table spec into SQL text.
142
+
143
+ Parameters
144
+ ----------
145
+ spec : Mapping[str, Any]
146
+ Table specification mapping.
147
+ template : str | None, optional
148
+ Template key to use (default: 'ddl').
149
+ template_path : str | None, optional
150
+ Path to a custom template file (overrides ``template``).
151
+
152
+ Returns
153
+ -------
154
+ str
155
+ Rendered SQL string.
156
+
157
+ Raises
158
+ ------
159
+ TypeError
160
+ If the loaded template name is not a string.
161
+ """
162
+ env = _build_env(template_key=template, template_path=template_path)
163
+ template_name = env.globals.get('TEMPLATE_NAME')
164
+ if not isinstance(template_name, str):
165
+ raise TypeError('TEMPLATE_NAME must be a string.')
166
+ tmpl = env.get_template(template_name)
167
+ return tmpl.render(spec=spec).rstrip() + '\n'
168
+
169
+
170
+ def render_tables(
171
+ specs: Iterable[Mapping[str, Any]],
172
+ *,
173
+ template: str | None = 'ddl',
174
+ template_path: str | None = None,
175
+ ) -> list[str]:
176
+ """
177
+ Render multiple table specs into a list of SQL payloads.
178
+
179
+ Parameters
180
+ ----------
181
+ specs : Iterable[Mapping[str, Any]]
182
+ Table specification mappings.
183
+ template : str | None, optional
184
+ Template key to use (default: 'ddl').
185
+ template_path : str | None, optional
186
+ Path to a custom template file (overrides ``template``).
187
+
188
+ Returns
189
+ -------
190
+ list[str]
191
+ Rendered SQL strings for each table spec.
192
+ """
193
+
194
+ return [
195
+ render_table_sql(spec, template=template, template_path=template_path)
196
+ for spec in specs
197
+ ]
@@ -0,0 +1,5 @@
1
+ """
2
+ :mod:`etlplus.templates` package.
3
+
4
+ This package defines bundled Jinja2 templates for ``etlplus``.
5
+ """
@@ -0,0 +1,128 @@
1
+ {# ---------- helpers ---------- #}
2
+ {% macro qid(name) -%}[{{ name | replace(']', ']]') }}]{%- endmacro %}
3
+ {% macro qname(schema, name) -%}{{ qid(schema) }}.{{ qid(name) }}{%- endmacro %}
4
+ {% macro df_name(table, col) -%}DF_{{ table }}_{{ col }}{%- endmacro %}
5
+ {% macro ck_name(table, col) -%}CK_{{ table }}_{{ col }}{%- endmacro %}
6
+
7
+ {# ---------- create schema (optional) ---------- #}
8
+ {% if spec.create_schema | default(true) %}
9
+ IF
10
+ NOT EXISTS (
11
+ SELECT 1
12
+ FROM sys.schemas
13
+ WHERE name = N'{{ spec.schema }}'
14
+ )
15
+ EXEC ('CREATE SCHEMA {{ qid(spec.schema) }}');
16
+ GO
17
+ {% endif %}
18
+
19
+ {# ---------- create table guarded ---------- #}
20
+ {% set fqtn = qname(spec.schema, spec.table) %}
21
+ IF OBJECT_ID(N'{{ spec.schema }}.{{ spec.table }}', N'U') IS NULL
22
+ BEGIN
23
+ CREATE TABLE {{ fqtn }} (
24
+ {% set ns = namespace(maxlen=0) %}
25
+ {% for c in spec.columns %}
26
+ {% set nlen = (c.name|string)|length %}
27
+ {% if nlen > ns.maxlen %}{% set ns.maxlen = nlen %}{% endif %}
28
+ {% endfor %}
29
+ {% set indent_cols = ' ' %}
30
+ {% for c in spec.columns %}
31
+ {# Map-safe lookups so StrictUndefined never bites on missing keys #}
32
+ {%- set ctype = c.get('type') -%}
33
+ {%- set ident = c.get('identity') -%}
34
+ {%- set computed = c.get('computed') -%}
35
+ {%- set nullable = c.get('nullable', true) -%}
36
+ {%- set defv = c.get('default') -%}
37
+ {%- set checkv = c.get('check') -%}
38
+ {{- indent_cols -}}{{ qid(c.name) }} {{ ctype }}
39
+ {%- if computed %} AS ({{ computed }}) PERSISTED{%- endif -%}
40
+ {%- if nullable %} NULL{%- else %} NOT NULL{%- endif -%}
41
+ {%- if ident %} IDENTITY({{ ident.get('seed', 1) }}, {{ ident.get('increment', 1) }}){%- endif -%}
42
+ {%- if defv %} CONSTRAINT {{ qid(df_name(spec.table, c.name)) }} DEFAULT ({{ defv }}){%- endif -%}
43
+ {%- if checkv %} CONSTRAINT {{ qid(ck_name(spec.table, c.name)) }} CHECK ({{ checkv }}){%- endif -%}
44
+ {{ "," if not loop.last }}
45
+ {% endfor %}
46
+
47
+ {%- if spec.primary_key is defined and spec.primary_key %}
48
+ , CONSTRAINT {{ qid(spec.primary_key.name
49
+ | default('PK_' ~ spec.table)) }}
50
+ PRIMARY KEY CLUSTERED (
51
+ {%- for col in spec.primary_key.columns -%}
52
+ {{ qid(col) }}{{ ", " if not loop.last }}
53
+ {%- endfor -%}
54
+ )
55
+ {%- endif %}
56
+
57
+ {%- for uq in spec.unique_constraints | default([]) %}
58
+ , CONSTRAINT {{ qid(uq.name) }} UNIQUE (
59
+ {%- for col in uq.columns -%}
60
+ {{ qid(col) }}{{ ", " if not loop.last }}
61
+ {%- endfor -%}
62
+ )
63
+ {%- endfor %}
64
+ );
65
+ END
66
+ GO
67
+
68
+ {# ---------- indexes ---------- #}
69
+ {% for ix in spec.indexes | default([]) %}
70
+ {%- set ix_unique = ix.get('unique', false) -%}
71
+ {%- set ix_include = ix.get('include') -%}
72
+ {%- set ix_where = ix.get('where') -%}
73
+ IF
74
+ NOT EXISTS (
75
+ SELECT 1
76
+ FROM sys.indexes
77
+ WHERE name = N'{{ ix.name }}'
78
+ AND object_id = OBJECT_ID(N'{{ spec.schema }}.{{ spec.table }}')
79
+ )
80
+ BEGIN
81
+ CREATE{% if ix_unique %} UNIQUE{% endif %} INDEX {{ qid(ix.name) }}
82
+ ON {{ fqtn }} (
83
+ {%- for col in ix.columns -%}
84
+ {{ qid(col) }}{{ ", " if not loop.last }}
85
+ {%- endfor -%}
86
+ )
87
+ {%- if ix_include %}
88
+ INCLUDE (
89
+ {%- for col in ix_include -%}
90
+ {{ qid(col) }}{{ ", " if not loop.last }}
91
+ {%- endfor -%}
92
+ )
93
+ {%- endif -%}
94
+ {%- if ix_where %} WHERE {{ ix_where }}{% endif %};
95
+ END
96
+ GO
97
+ {% endfor %}
98
+
99
+ {# ---------- foreign keys ---------- #}
100
+ {% for fk in spec.foreign_keys | default([]) %}
101
+ IF
102
+ NOT EXISTS (
103
+ SELECT 1
104
+ FROM sys.foreign_keys
105
+ WHERE
106
+ name = N'{{ fk.name }}'
107
+ AND parent_object_id = OBJECT_ID(N'{{ spec.schema }}.{{ spec.table }}'
108
+ )
109
+ )
110
+ BEGIN
111
+ ALTER TABLE {{ fqtn }} WITH CHECK
112
+ ADD CONSTRAINT {{ qid(fk.name) }} FOREIGN KEY (
113
+ {%- for col in fk.columns -%}
114
+ {{ qid(col) }}{{ ", " if not loop.last }}
115
+ {%- endfor -%}
116
+ ) REFERENCES
117
+ {{ qname(fk.ref_schema | default('dbo'), fk.ref_table) }} (
118
+ {%- for col in fk.ref_columns -%}
119
+ {{ qid(col) }}{{ ", " if not loop.last }}
120
+ {%- endfor -%}
121
+ )
122
+ {%- set on_upd = fk.get('on_update') -%}
123
+ {%- set on_del = fk.get('on_delete') -%}
124
+ {%- if on_upd %} ON UPDATE {{ on_upd }}{% endif -%}
125
+ {%- if on_del %} ON DELETE {{ on_del }}{% endif %};
126
+ END
127
+ GO
128
+ {% endfor %}
@@ -0,0 +1,69 @@
1
+ {# -- Helpers -- #}
2
+ {% macro qid(name) -%}
3
+ [{{ name | replace(']', ']]') }}]
4
+ {%- endmacro %}
5
+
6
+ {% macro qname(schema, name) -%}
7
+ {{ qid(schema) }}.{{ qid(name) }}
8
+ {%- endmacro %}
9
+
10
+ {# Convert any expression to nvarchar(MAX) with N'' for NULLs #}
11
+ {% macro to_str(expr) -%}
12
+ ISNULL(CONVERT(NVARCHAR(MAX), {{ expr }}), N'')
13
+ {%- endmacro %}
14
+
15
+ {# Normalize truthy values to 'TRUE' / 'FALSE' #}
16
+ {% macro truthy(expr) -%}
17
+ IIF(CONVERT(VARCHAR(5), {{ expr }}) IN ('TRUE', '1', 'YES'), 'TRUE', 'FALSE')
18
+ {%- endmacro %}
19
+
20
+ {# -- Create schema (optional) -- #}
21
+ {% if spec.create_schema | default(true) %}
22
+ IF
23
+ NOT EXISTS (
24
+ SELECT 1
25
+ FROM sys.schemas
26
+ WHERE name = N'{{ spec.schema }}'
27
+ )
28
+ EXEC ('CREATE SCHEMA {{ qid(spec.schema) }}');
29
+ GO
30
+ {% endif %}
31
+
32
+ {# -- SET options (match your source view) -- #}
33
+ SET QUOTED_IDENTIFIER ON
34
+ SET ANSI_NULLS ON
35
+ GO
36
+
37
+ {# -- Derive names with fallbacks so table-style specs also work -- #}
38
+ {% set view_name = spec.view if spec.view is defined else ('vw_' ~ spec.table) %}
39
+ {% set src_schema = (spec.from_schema if spec.from_schema is defined else (spec.schema if spec.schema is defined else 'dbo')) %}
40
+ {% set src_table = (
41
+ spec.from_table if spec.from_table is defined else
42
+ (spec.source_table if spec.source_table is defined else spec.table)
43
+ ) %}
44
+ {% set indent_cols = ' ' %}
45
+
46
+ {# -- Create view -- #}
47
+ CREATE VIEW {{ qname(spec.schema | default('dbo'), view_name) }} AS
48
+ SELECT
49
+ {% if spec.projections is defined and spec.projections %}
50
+ {% for p in spec.projections %}
51
+ {%- if p.type is defined and (p.type | lower)[:8] == 'nvarchar' -%}
52
+ {{- indent_cols -}}{{ to_str(p.expr) }} AS {{ qid(p.alias) }}
53
+ {%- else -%}
54
+ {{- indent_cols -}}{{ p.expr }}
55
+ {%- endif -%}
56
+ {{ "," if not loop.last }}
57
+ {% endfor %}
58
+ {% else %}
59
+ {% for c in spec.columns | default([]) %}
60
+ {%- if c.type is defined and (c.type | lower)[:8] == 'nvarchar' -%}
61
+ {{- indent_cols -}}{{ to_str(qid(c.name)) }} AS {{ qid(c.name) }}
62
+ {%- else -%}
63
+ {{- indent_cols -}}{{ qid(c.name) }}
64
+ {%- endif -%}
65
+ {{ "," if not loop.last }}
66
+ {% endfor %}
67
+ {%- endif %}
68
+ FROM {{ qname(src_schema, src_table) }};
69
+ GO
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.4.9
3
+ Version: 0.5.1
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
@@ -1,6 +1,7 @@
1
1
  etlplus/__init__.py,sha256=M2gScnyir6WOMAh_EuoQIiAzdcTls0_5hbd_Q6of8I0,1021
2
2
  etlplus/__main__.py,sha256=btoROneNiigyfBU7BSzPKZ1R9gzBMpxcpsbPwmuHwTM,479
3
3
  etlplus/__version__.py,sha256=1E0GMK_yUWCMQFKxXjTvyMwofi0qT2k4CDNiHWiymWE,327
4
+ etlplus/ddl.py,sha256=uYkiMTx1uDlUypnXCYy0K5ARnHRMHFVzzg8PizBQRLg,5306
4
5
  etlplus/enums.py,sha256=V_j18Ud2BCXpFsBk2pZGrvCVrvAMJ7uja1z9fppFGso,10175
5
6
  etlplus/extract.py,sha256=f44JdHhNTACxgn44USx05paKTwq7LQY-V4wANCW9hVM,6173
6
7
  etlplus/file.py,sha256=RxIAsGDN4f_vNA2B5-ct88JNd_ISAyYbooIRE5DstS8,17972
@@ -31,21 +32,24 @@ etlplus/api/rate_limiting/__init__.py,sha256=ZySB1dZettEDnWvI1EHf_TZ9L08M_kKsNR-
31
32
  etlplus/api/rate_limiting/config.py,sha256=2b4wIynblN-1EyMqI4aXa71SljzSjXYh5N1Nngr3jOg,9406
32
33
  etlplus/api/rate_limiting/rate_limiter.py,sha256=Uxozqd_Ej5Lsj-M-mLT2WexChgWh7x35_YP10yqYPQA,7159
33
34
  etlplus/cli/__init__.py,sha256=J97-Rv931IL1_b4AXnB7Fbbd7HKnHBpx18NQfC_kE6c,299
34
- etlplus/cli/app.py,sha256=A8MxgKpJs9_KeyAgkopLrdJnB0SH24Mlxituh9Uwmrw,32991
35
- etlplus/cli/handlers.py,sha256=mObms9k-3XoImECNW-CpOqQkJzlRtDlobU3sHMu4itM,16190
36
- etlplus/cli/main.py,sha256=rl0NmZdzWwlGQSVyzWYutbBwHWOmBFYJcEjl6fYhwm0,15395
35
+ etlplus/cli/app.py,sha256=buGIIoSIu5cxbYTdPcA_iaxJaPG-eHj-LPD9OgZ0h9w,35824
36
+ etlplus/cli/handlers.py,sha256=O7Mh9nowdMCzaV36KASWZVC4fNMEg9xnVZXE7NHW6P8,18873
37
+ etlplus/cli/main.py,sha256=5qWAKqlRtnb4VEpBfGT45q-LBxi_2hSMnw23jNyYA_Q,16497
37
38
  etlplus/config/__init__.py,sha256=VZWzOg7d2YR9NT6UwKTv44yf2FRUMjTHynkm1Dl5Qzo,1486
38
39
  etlplus/config/connector.py,sha256=0-TIwevHbKRHVmucvyGpPd-3tB1dKHB-dj0yJ6kq5eY,9809
39
40
  etlplus/config/jobs.py,sha256=hmzRCqt0OvCEZZR4ONKrd3lvSv0OmayjLc4yOBk3ug8,7399
40
- etlplus/config/pipeline.py,sha256=7w0WWe21vvPJvTlJoRTZh2j92I1_GOcdwan6xyKfQII,8973
41
+ etlplus/config/pipeline.py,sha256=Va4MQY6KEyKqHGMKPmh09ZcGpx95br-iNUjpkqtzVbw,9500
41
42
  etlplus/config/profile.py,sha256=Ss2zedQGjkaGSpvBLTD4SZaWViMJ7TJPLB8Q2_BTpPg,1898
42
43
  etlplus/config/types.py,sha256=a0epJ3z16HQ5bY3Ctf8s_cQPa3f0HHcwdOcjCP2xoG4,4954
43
44
  etlplus/config/utils.py,sha256=4SUHMkt5bKBhMhiJm-DrnmE2Q4TfOgdNCKz8PJDS27o,3443
45
+ etlplus/templates/__init__.py,sha256=tsniN7XJYs3NwYxJ6c2HD5upHP3CDkLx-bQCMt97UOM,106
46
+ etlplus/templates/ddl.sql.j2,sha256=s8fMWvcb4eaJVXkifuib1aQPljtZ8buuyB_uA-ZdU3Q,4734
47
+ etlplus/templates/view.sql.j2,sha256=Iy8DHfhq5yyvrUKDxqp_aHIEXY4Tm6j4wT7YDEFWAhk,2180
44
48
  etlplus/validation/__init__.py,sha256=Pe5Xg1_EA4uiNZGYu5WTF3j7odjmyxnAJ8rcioaplSQ,1254
45
49
  etlplus/validation/utils.py,sha256=Mtqg449VIke0ziy_wd2r6yrwJzQkA1iulZC87FzXMjo,10201
46
- etlplus-0.4.9.dist-info/licenses/LICENSE,sha256=MuNO63i6kWmgnV2pbP2SLqP54mk1BGmu7CmbtxMmT-U,1069
47
- etlplus-0.4.9.dist-info/METADATA,sha256=hEyIXQ7XwoLu5Y3fJwslHtyfLhChIxHV_BaWCz22LT4,17635
48
- etlplus-0.4.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
49
- etlplus-0.4.9.dist-info/entry_points.txt,sha256=6w-2-jzuPa55spzK34h-UKh2JTEShh38adFRONNP9QE,45
50
- etlplus-0.4.9.dist-info/top_level.txt,sha256=aWWF-udn_sLGuHTM6W6MLh99ArS9ROkUWO8Mi8y1_2U,8
51
- etlplus-0.4.9.dist-info/RECORD,,
50
+ etlplus-0.5.1.dist-info/licenses/LICENSE,sha256=MuNO63i6kWmgnV2pbP2SLqP54mk1BGmu7CmbtxMmT-U,1069
51
+ etlplus-0.5.1.dist-info/METADATA,sha256=_jyHbbKnTHnDCxcFjzKjUbPlgJVTHfonDBExSFPGzZU,17635
52
+ etlplus-0.5.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
53
+ etlplus-0.5.1.dist-info/entry_points.txt,sha256=6w-2-jzuPa55spzK34h-UKh2JTEShh38adFRONNP9QE,45
54
+ etlplus-0.5.1.dist-info/top_level.txt,sha256=aWWF-udn_sLGuHTM6W6MLh99ArS9ROkUWO8Mi8y1_2U,8
55
+ etlplus-0.5.1.dist-info/RECORD,,