sql2sqlx 0.1.0__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.
- sql2sqlx/__init__.py +83 -0
- sql2sqlx/__main__.py +14 -0
- sql2sqlx/cli.py +323 -0
- sql2sqlx/converter.py +1241 -0
- sql2sqlx/emitter.py +275 -0
- sql2sqlx/errors.py +93 -0
- sql2sqlx/keywords.py +169 -0
- sql2sqlx/lexer.py +494 -0
- sql2sqlx/model.py +487 -0
- sql2sqlx/parser.py +2193 -0
- sql2sqlx/py.typed +2 -0
- sql2sqlx/refs.py +902 -0
- sql2sqlx/splitter.py +360 -0
- sql2sqlx/version.py +15 -0
- sql2sqlx-0.1.0.dist-info/METADATA +733 -0
- sql2sqlx-0.1.0.dist-info/RECORD +20 -0
- sql2sqlx-0.1.0.dist-info/WHEEL +5 -0
- sql2sqlx-0.1.0.dist-info/entry_points.txt +2 -0
- sql2sqlx-0.1.0.dist-info/licenses/LICENSE +202 -0
- sql2sqlx-0.1.0.dist-info/top_level.txt +1 -0
sql2sqlx/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Copyright (c) Soumyadip Sarkar.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
#
|
|
4
|
+
# This source code is licensed under the Apache-style license found in the
|
|
5
|
+
# LICENSE file in the root directory of this source tree.
|
|
6
|
+
|
|
7
|
+
"""sql2sqlx - convert BigQuery SQL into Dataform SQLX.
|
|
8
|
+
|
|
9
|
+
A zero-dependency, robust converter that turns plain
|
|
10
|
+
``.sql`` pipelines (DDL, DML, scripts) into a Dataform project:
|
|
11
|
+
``type: "table"|"view"|"incremental"|"operations"|"declaration"``
|
|
12
|
+
actions with ``${ref(...)}`` dependency wiring, metadata mapping
|
|
13
|
+
(``PARTITION BY``, ``CLUSTER BY``, ``OPTIONS``), and a detailed
|
|
14
|
+
machine-readable conversion report.
|
|
15
|
+
|
|
16
|
+
Quick start::
|
|
17
|
+
|
|
18
|
+
from sql2sqlx import convert_string
|
|
19
|
+
|
|
20
|
+
result = convert_string(
|
|
21
|
+
"CREATE OR REPLACE TABLE analytics.daily AS "
|
|
22
|
+
"SELECT * FROM raw.events;"
|
|
23
|
+
)
|
|
24
|
+
print(result.files[0].content)
|
|
25
|
+
|
|
26
|
+
See :func:`convert_string`, :func:`convert_file` and
|
|
27
|
+
:func:`convert_directory` for the three entry points, and
|
|
28
|
+
:class:`ConversionOptions` for every knob. Full documentation lives in
|
|
29
|
+
the project's ``docs/`` (Sphinx) tree.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from sql2sqlx.converter import (
|
|
33
|
+
convert_directory,
|
|
34
|
+
convert_file,
|
|
35
|
+
convert_string,
|
|
36
|
+
parse_source,
|
|
37
|
+
write_result,
|
|
38
|
+
)
|
|
39
|
+
from sql2sqlx.errors import ConversionError, LexError, SplitError, Sql2SqlxError
|
|
40
|
+
from sql2sqlx.model import (
|
|
41
|
+
ActionType,
|
|
42
|
+
ConversionOptions,
|
|
43
|
+
ConversionReport,
|
|
44
|
+
ConversionResult,
|
|
45
|
+
IfNotExistsStrategy,
|
|
46
|
+
InsertStrategy,
|
|
47
|
+
Layout,
|
|
48
|
+
MergeStrategy,
|
|
49
|
+
PlainCreateStrategy,
|
|
50
|
+
ReportWarning,
|
|
51
|
+
SqlxFile,
|
|
52
|
+
TableName,
|
|
53
|
+
)
|
|
54
|
+
from sql2sqlx.version import __version__
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"__version__",
|
|
58
|
+
# entry points
|
|
59
|
+
"convert_string",
|
|
60
|
+
"convert_file",
|
|
61
|
+
"convert_directory",
|
|
62
|
+
"write_result",
|
|
63
|
+
"parse_source",
|
|
64
|
+
# options & enums
|
|
65
|
+
"ConversionOptions",
|
|
66
|
+
"InsertStrategy",
|
|
67
|
+
"MergeStrategy",
|
|
68
|
+
"PlainCreateStrategy",
|
|
69
|
+
"IfNotExistsStrategy",
|
|
70
|
+
"Layout",
|
|
71
|
+
# results
|
|
72
|
+
"ConversionResult",
|
|
73
|
+
"ConversionReport",
|
|
74
|
+
"ReportWarning",
|
|
75
|
+
"SqlxFile",
|
|
76
|
+
"ActionType",
|
|
77
|
+
"TableName",
|
|
78
|
+
# errors
|
|
79
|
+
"Sql2SqlxError",
|
|
80
|
+
"LexError",
|
|
81
|
+
"SplitError",
|
|
82
|
+
"ConversionError",
|
|
83
|
+
]
|
sql2sqlx/__main__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Copyright (c) Soumyadip Sarkar.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
#
|
|
4
|
+
# This source code is licensed under the Apache-style license found in the
|
|
5
|
+
# LICENSE file in the root directory of this source tree.
|
|
6
|
+
|
|
7
|
+
"""Enable ``python -m sql2sqlx`` as an alias for the ``sql2sqlx`` command."""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from sql2sqlx.cli import main
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
sys.exit(main())
|
sql2sqlx/cli.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# Copyright (c) Soumyadip Sarkar.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
#
|
|
4
|
+
# This source code is licensed under the Apache-style license found in the
|
|
5
|
+
# LICENSE file in the root directory of this source tree.
|
|
6
|
+
|
|
7
|
+
"""Command-line interface: ``sql2sqlx INPUT [-o OUTPUT] [options]``.
|
|
8
|
+
|
|
9
|
+
The CLI is a thin veneer over :func:`sql2sqlx.convert_file` and
|
|
10
|
+
:func:`sql2sqlx.convert_directory`:
|
|
11
|
+
|
|
12
|
+
* a **file** input with no ``--output`` prints the generated ``.sqlx``
|
|
13
|
+
to stdout (handy for piping and quick inspection);
|
|
14
|
+
* a **directory** input requires ``--output`` (unless ``--dry-run``) and
|
|
15
|
+
writes the converted tree beneath it;
|
|
16
|
+
* ``--report FILE`` writes the full machine-readable JSON report;
|
|
17
|
+
* ``--init-project`` additionally scaffolds a ``workflow_settings.yaml``
|
|
18
|
+
next to the output ``definitions/`` directory.
|
|
19
|
+
|
|
20
|
+
Exit codes: ``0`` success, ``1`` when any input file failed to convert,
|
|
21
|
+
``2`` for usage errors.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import List, Optional
|
|
31
|
+
|
|
32
|
+
from sql2sqlx.converter import convert_directory, convert_file, write_result
|
|
33
|
+
from sql2sqlx.errors import Sql2SqlxError
|
|
34
|
+
from sql2sqlx.model import (
|
|
35
|
+
ConversionOptions,
|
|
36
|
+
ConversionResult,
|
|
37
|
+
IfNotExistsStrategy,
|
|
38
|
+
InsertStrategy,
|
|
39
|
+
Layout,
|
|
40
|
+
MergeStrategy,
|
|
41
|
+
PlainCreateStrategy,
|
|
42
|
+
)
|
|
43
|
+
from sql2sqlx.version import __version__
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_arg_parser() -> argparse.ArgumentParser:
|
|
47
|
+
"""Build the CLI argument parser.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A fully configured :class:`argparse.ArgumentParser`.
|
|
51
|
+
"""
|
|
52
|
+
p = argparse.ArgumentParser(
|
|
53
|
+
prog="sql2sqlx",
|
|
54
|
+
description="Convert BigQuery SQL files into Dataform SQLX actions.",
|
|
55
|
+
epilog="Docs and conversion rules: " "https://github.com/neuralsorcerer/sql2sqlx",
|
|
56
|
+
)
|
|
57
|
+
p.add_argument("input", help="a .sql file or a directory of .sql files")
|
|
58
|
+
p.add_argument(
|
|
59
|
+
"-o",
|
|
60
|
+
"--output",
|
|
61
|
+
metavar="DIR",
|
|
62
|
+
help="output directory for generated .sqlx files "
|
|
63
|
+
"(typically your Dataform 'definitions/' folder)",
|
|
64
|
+
)
|
|
65
|
+
p.add_argument("--report", metavar="FILE", help="write the JSON conversion report to FILE")
|
|
66
|
+
p.add_argument(
|
|
67
|
+
"--default-project", metavar="ID", help="project assumed for unqualified table paths"
|
|
68
|
+
)
|
|
69
|
+
p.add_argument(
|
|
70
|
+
"--default-dataset", metavar="ID", help="dataset assumed for unqualified table paths"
|
|
71
|
+
)
|
|
72
|
+
p.add_argument(
|
|
73
|
+
"--default-location",
|
|
74
|
+
metavar="LOCATION",
|
|
75
|
+
default="US",
|
|
76
|
+
help="location for generated workflow settings " "(default: %(default)s)",
|
|
77
|
+
)
|
|
78
|
+
p.add_argument(
|
|
79
|
+
"--layout",
|
|
80
|
+
choices=[layout.value for layout in Layout],
|
|
81
|
+
default=Layout.MIRROR.value,
|
|
82
|
+
help="mirror the input tree or flatten output " "(default: %(default)s)",
|
|
83
|
+
)
|
|
84
|
+
p.add_argument(
|
|
85
|
+
"--insert-strategy",
|
|
86
|
+
choices=[s.value for s in InsertStrategy],
|
|
87
|
+
default=InsertStrategy.OPERATIONS.value,
|
|
88
|
+
help="how INSERT ... SELECT converts " "(default: %(default)s)",
|
|
89
|
+
)
|
|
90
|
+
p.add_argument(
|
|
91
|
+
"--merge-strategy",
|
|
92
|
+
choices=[s.value for s in MergeStrategy],
|
|
93
|
+
default=MergeStrategy.OPERATIONS.value,
|
|
94
|
+
help="how MERGE converts (default: %(default)s)",
|
|
95
|
+
)
|
|
96
|
+
p.add_argument(
|
|
97
|
+
"--plain-create",
|
|
98
|
+
choices=[s.value for s in PlainCreateStrategy],
|
|
99
|
+
default=PlainCreateStrategy.OPERATIONS.value,
|
|
100
|
+
help="how CREATE TABLE without AS converts " "(default: %(default)s)",
|
|
101
|
+
)
|
|
102
|
+
p.add_argument(
|
|
103
|
+
"--if-not-exists",
|
|
104
|
+
choices=[s.value for s in IfNotExistsStrategy],
|
|
105
|
+
default=IfNotExistsStrategy.OPERATIONS.value,
|
|
106
|
+
help="how guarded CREATE TABLE/VIEW ... AS converts " "(default: %(default)s)",
|
|
107
|
+
)
|
|
108
|
+
p.add_argument(
|
|
109
|
+
"--declare-external",
|
|
110
|
+
action="store_true",
|
|
111
|
+
help="emit declarations for referenced-but-not-produced " "tables and ref() them too",
|
|
112
|
+
)
|
|
113
|
+
p.add_argument(
|
|
114
|
+
"--no-protected",
|
|
115
|
+
action="store_true",
|
|
116
|
+
help="do not mark converted incrementals as " "protected: true",
|
|
117
|
+
)
|
|
118
|
+
p.add_argument(
|
|
119
|
+
"--no-annotate",
|
|
120
|
+
action="store_true",
|
|
121
|
+
help="omit the '-- source: file:line' provenance comments",
|
|
122
|
+
)
|
|
123
|
+
p.add_argument(
|
|
124
|
+
"--tags", metavar="TAG[,TAG...]", help="comma-separated Dataform tags added to every action"
|
|
125
|
+
)
|
|
126
|
+
p.add_argument(
|
|
127
|
+
"--include",
|
|
128
|
+
metavar="GLOB",
|
|
129
|
+
default="*.sql",
|
|
130
|
+
help="filename glob for directory scans " "(default: %(default)s)",
|
|
131
|
+
)
|
|
132
|
+
p.add_argument("--encoding", default="utf-8", help="input file encoding (default: %(default)s)")
|
|
133
|
+
p.add_argument(
|
|
134
|
+
"-j",
|
|
135
|
+
"--jobs",
|
|
136
|
+
type=int,
|
|
137
|
+
default=0,
|
|
138
|
+
metavar="N",
|
|
139
|
+
help="worker processes for directory conversion; " "0 = auto (default)",
|
|
140
|
+
)
|
|
141
|
+
p.add_argument("--dry-run", action="store_true", help="convert and report, but write nothing")
|
|
142
|
+
p.add_argument(
|
|
143
|
+
"--overwrite",
|
|
144
|
+
action="store_true",
|
|
145
|
+
help="allow writing into an output directory that " "already contains .sqlx files",
|
|
146
|
+
)
|
|
147
|
+
p.add_argument(
|
|
148
|
+
"--init-project",
|
|
149
|
+
action="store_true",
|
|
150
|
+
help="also scaffold a workflow_settings.yaml next to " "the output directory",
|
|
151
|
+
)
|
|
152
|
+
p.add_argument("-q", "--quiet", action="store_true", help="suppress the summary")
|
|
153
|
+
p.add_argument(
|
|
154
|
+
"-v", "--verbose", action="store_true", help="also list warnings and generated files"
|
|
155
|
+
)
|
|
156
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
157
|
+
return p
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _options_from_args(args: argparse.Namespace) -> ConversionOptions:
|
|
161
|
+
"""Translate parsed CLI arguments into :class:`ConversionOptions`.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
args: The parsed namespace.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The options object.
|
|
168
|
+
"""
|
|
169
|
+
tags = [t.strip() for t in (args.tags or "").split(",") if t.strip()]
|
|
170
|
+
return ConversionOptions(
|
|
171
|
+
default_project=args.default_project,
|
|
172
|
+
default_dataset=args.default_dataset,
|
|
173
|
+
default_location=args.default_location,
|
|
174
|
+
insert_strategy=InsertStrategy(args.insert_strategy),
|
|
175
|
+
merge_strategy=MergeStrategy(args.merge_strategy),
|
|
176
|
+
plain_create_strategy=PlainCreateStrategy(args.plain_create),
|
|
177
|
+
if_not_exists_strategy=IfNotExistsStrategy(args.if_not_exists),
|
|
178
|
+
declare_external=args.declare_external,
|
|
179
|
+
protect_incrementals=not args.no_protected,
|
|
180
|
+
annotate=not args.no_annotate,
|
|
181
|
+
tags=tags,
|
|
182
|
+
layout=Layout(args.layout),
|
|
183
|
+
encoding=args.encoding,
|
|
184
|
+
include_glob=args.include,
|
|
185
|
+
jobs=args.jobs,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _write_workflow_settings(
|
|
190
|
+
output_dir: Path, opts: ConversionOptions, overwrite: bool
|
|
191
|
+
) -> Optional[Path]:
|
|
192
|
+
"""Scaffold a Dataform-core-3.x ``workflow_settings.yaml``.
|
|
193
|
+
|
|
194
|
+
The file is written next to a ``definitions/`` output directory (or
|
|
195
|
+
inside the output directory otherwise) and never silently replaced.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
output_dir: The CLI output directory.
|
|
199
|
+
opts: Conversion options (supplies project/dataset defaults).
|
|
200
|
+
overwrite: Whether an existing file may be replaced.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
The written path, or ``None`` when skipped.
|
|
204
|
+
"""
|
|
205
|
+
root = output_dir.parent if output_dir.name == "definitions" else output_dir
|
|
206
|
+
path = root / "workflow_settings.yaml"
|
|
207
|
+
if path.exists() and not overwrite:
|
|
208
|
+
print(f"note: {path} already exists; not overwritten", file=sys.stderr)
|
|
209
|
+
return None
|
|
210
|
+
content = (
|
|
211
|
+
f"defaultProject: {json.dumps(opts.default_project or 'your-gcp-project')}\n"
|
|
212
|
+
f"defaultLocation: {json.dumps(opts.default_location)}\n"
|
|
213
|
+
f"defaultDataset: {json.dumps(opts.default_dataset or 'dataform')}\n"
|
|
214
|
+
'defaultAssertionDataset: "dataform_assertions"\n'
|
|
215
|
+
'dataformCoreVersion: "3.0.61"\n'
|
|
216
|
+
)
|
|
217
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
path.write_text(content, encoding="utf-8")
|
|
219
|
+
return path
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _print_summary(result: ConversionResult, verbose: bool) -> None:
|
|
223
|
+
"""Print a human-readable run summary to stderr.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
result: The conversion result.
|
|
227
|
+
verbose: Also list warnings and every generated file.
|
|
228
|
+
"""
|
|
229
|
+
r = result.report
|
|
230
|
+
actions = " ".join(f"{k}={v}" for k, v in sorted(r.actions_by_type.items()))
|
|
231
|
+
mb = r.input_bytes / 1_000_000
|
|
232
|
+
err = sys.stderr
|
|
233
|
+
print(f"sql2sqlx v{__version__}", file=err)
|
|
234
|
+
print(f" files read: {r.files_read}", file=err)
|
|
235
|
+
print(f" statements: {r.statements}", file=err)
|
|
236
|
+
print(f" actions: {actions or '-'}", file=err)
|
|
237
|
+
print(
|
|
238
|
+
f" refs rewritten: {r.refs_rewritten}"
|
|
239
|
+
f" ({len(r.refs_unresolved)} external left as literals)",
|
|
240
|
+
file=err,
|
|
241
|
+
)
|
|
242
|
+
print(f" warnings: {len(r.warnings)}", file=err)
|
|
243
|
+
print(f" failures: {len(r.failures)}", file=err)
|
|
244
|
+
print(f" elapsed: {r.elapsed_seconds}s ({mb:.1f} MB)", file=err)
|
|
245
|
+
if verbose:
|
|
246
|
+
for w in r.warnings:
|
|
247
|
+
loc = f"{w.path}:{w.line}" if w.path else "-"
|
|
248
|
+
print(f" [{w.code}] {loc}: {w.message}", file=err)
|
|
249
|
+
for f in result.files:
|
|
250
|
+
print(f" wrote {f.relpath} ({f.action_type.value})", file=err)
|
|
251
|
+
for path, message in r.failures.items():
|
|
252
|
+
print(f" FAILED {path}: {message}", file=err)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
256
|
+
"""CLI entry point.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
argv: Argument list (defaults to ``sys.argv[1:]``).
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Process exit code (``0`` ok, ``1`` conversion failures,
|
|
263
|
+
``2`` usage error).
|
|
264
|
+
"""
|
|
265
|
+
parser = build_arg_parser()
|
|
266
|
+
args = parser.parse_args(argv)
|
|
267
|
+
if args.jobs < 0:
|
|
268
|
+
parser.error("--jobs must be zero (auto) or a positive integer")
|
|
269
|
+
try:
|
|
270
|
+
opts = _options_from_args(args)
|
|
271
|
+
except ValueError as exc:
|
|
272
|
+
parser.error(str(exc))
|
|
273
|
+
in_path = Path(args.input)
|
|
274
|
+
try:
|
|
275
|
+
if in_path.is_dir():
|
|
276
|
+
if args.output is None and not args.dry_run:
|
|
277
|
+
parser.error(
|
|
278
|
+
"--output is required when converting a directory " "(or pass --dry-run)"
|
|
279
|
+
)
|
|
280
|
+
result = convert_directory(str(in_path), None, opts)
|
|
281
|
+
elif in_path.is_file():
|
|
282
|
+
result = convert_file(str(in_path), opts)
|
|
283
|
+
else:
|
|
284
|
+
parser.error(f"input not found: {args.input}")
|
|
285
|
+
return 2 # pragma: no cover - parser.error raises
|
|
286
|
+
except Sql2SqlxError as exc:
|
|
287
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
288
|
+
return 2
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
if args.output and not args.dry_run:
|
|
292
|
+
out = Path(args.output)
|
|
293
|
+
if out.exists() and not args.overwrite and any(out.rglob("*.sqlx")):
|
|
294
|
+
print(
|
|
295
|
+
f"error: {out} already contains .sqlx files; " "pass --overwrite to proceed",
|
|
296
|
+
file=sys.stderr,
|
|
297
|
+
)
|
|
298
|
+
return 2
|
|
299
|
+
write_result(result, str(out))
|
|
300
|
+
if args.init_project:
|
|
301
|
+
_write_workflow_settings(out, opts, args.overwrite)
|
|
302
|
+
elif args.output is None and in_path.is_file() and not args.dry_run:
|
|
303
|
+
for i, sqlx in enumerate(result.files):
|
|
304
|
+
if len(result.files) > 1:
|
|
305
|
+
if i:
|
|
306
|
+
print()
|
|
307
|
+
label = sqlx.relpath.encode("unicode_escape").decode("ascii")
|
|
308
|
+
print(f"-- ===== {label} =====")
|
|
309
|
+
print(sqlx.content, end="")
|
|
310
|
+
if args.report:
|
|
311
|
+
Path(args.report).write_text(
|
|
312
|
+
json.dumps(result.report.to_dict(), indent=2, sort_keys=False), encoding="utf-8"
|
|
313
|
+
)
|
|
314
|
+
except (OSError, Sql2SqlxError) as exc:
|
|
315
|
+
print(f"error: could not write output: {exc}", file=sys.stderr)
|
|
316
|
+
return 2
|
|
317
|
+
if not args.quiet:
|
|
318
|
+
_print_summary(result, verbose=args.verbose)
|
|
319
|
+
return 1 if result.report.failures else 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__": # pragma: no cover
|
|
323
|
+
sys.exit(main())
|