coop-sql-review 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.
- coop_sql_review/__init__.py +3 -0
- coop_sql_review/__main__.py +4 -0
- coop_sql_review/cli.py +321 -0
- coop_sql_review/data/standards.md +564 -0
- coop_sql_review/diagnostics.py +47 -0
- coop_sql_review/engine.py +87 -0
- coop_sql_review/finding.py +54 -0
- coop_sql_review/identifiers.py +45 -0
- coop_sql_review/parser.py +214 -0
- coop_sql_review/report.py +125 -0
- coop_sql_review/rules/__init__.py +32 -0
- coop_sql_review/rules/base.py +68 -0
- coop_sql_review/rules/helpers.py +89 -0
- coop_sql_review/rules/sql_alias_descriptive.py +44 -0
- coop_sql_review/rules/sql_bronze_raw_names.py +85 -0
- coop_sql_review/rules/sql_ctas_explicit_cast.py +115 -0
- coop_sql_review/rules/sql_cte_prefix.py +44 -0
- coop_sql_review/rules/sql_date_filter_param.py +61 -0
- coop_sql_review/rules/sql_distinct_smell.py +48 -0
- coop_sql_review/rules/sql_exists_comment.py +49 -0
- coop_sql_review/rules/sql_exists_why_quality.py +52 -0
- coop_sql_review/rules/sql_filter_upstream.py +49 -0
- coop_sql_review/rules/sql_header_comment.py +55 -0
- coop_sql_review/rules/sql_implicit_convert.py +123 -0
- coop_sql_review/rules/sql_insert_alias_match.py +59 -0
- coop_sql_review/rules/sql_join_filter.py +110 -0
- coop_sql_review/rules/sql_no_alter_column.py +51 -0
- coop_sql_review/rules/sql_no_select_star.py +43 -0
- coop_sql_review/rules/sql_order_by_in_view.py +95 -0
- coop_sql_review/rules/sql_prefer_cte.py +50 -0
- coop_sql_review/rules/sql_query_label.py +85 -0
- coop_sql_review/rules/sql_sargability.py +89 -0
- coop_sql_review/rules/sql_scd2_correct.py +92 -0
- coop_sql_review/rules/sql_silver_pascalcase.py +96 -0
- coop_sql_review/rules/sql_singleton_insert.py +51 -0
- coop_sql_review/rules/sql_table_layer_name.py +45 -0
- coop_sql_review/rules/sql_try_cast_bronze.py +79 -0
- coop_sql_review/rules/sql_txn_short.py +50 -0
- coop_sql_review/rules/sql_type_datetime.py +39 -0
- coop_sql_review/rules/sql_type_deprecated.py +45 -0
- coop_sql_review/rules/sql_type_money.py +39 -0
- coop_sql_review/rules/sql_type_nvarchar.py +39 -0
- coop_sql_review/rules/sql_upsert_choice.py +45 -0
- coop_sql_review/sql_common.py +160 -0
- coop_sql_review/sql_model.py +139 -0
- coop_sql_review/standards.py +84 -0
- coop_sql_review/upgrade.py +322 -0
- coop_sql_review-0.1.0.dist-info/METADATA +244 -0
- coop_sql_review-0.1.0.dist-info/RECORD +52 -0
- coop_sql_review-0.1.0.dist-info/WHEEL +4 -0
- coop_sql_review-0.1.0.dist-info/entry_points.txt +2 -0
- coop_sql_review-0.1.0.dist-info/licenses/LICENSE +21 -0
coop_sql_review/cli.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
Thin wrapper over the pipeline (discover -> parse -> run rules -> render).
|
|
4
|
+
Advisory by default: exit code 0 no matter what is found. ``--strict`` is the
|
|
5
|
+
opt-in CI gate — exit 2 when any reported finding remains after the
|
|
6
|
+
``--min-severity`` filter.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from coop_sql_review import __version__
|
|
18
|
+
from coop_sql_review.diagnostics import FILE_UNREADABLE, Diagnostic
|
|
19
|
+
from coop_sql_review.engine import run_rules
|
|
20
|
+
from coop_sql_review.finding import SEVERITIES
|
|
21
|
+
from coop_sql_review.parser import parse_sql
|
|
22
|
+
from coop_sql_review.report import console_lines, json_text, log_text
|
|
23
|
+
from coop_sql_review.rules import all_rules
|
|
24
|
+
from coop_sql_review.sql_model import ParsedFile
|
|
25
|
+
from coop_sql_review.standards import (
|
|
26
|
+
RuleConfig,
|
|
27
|
+
StandardsError,
|
|
28
|
+
apply_config,
|
|
29
|
+
default_config_path,
|
|
30
|
+
resolve_standards_path,
|
|
31
|
+
standards_info,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_SEVERITY_CHOICE = click.Choice(SEVERITIES)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _display_path(path: Path) -> str:
|
|
38
|
+
"""POSIX-style path, relative to cwd when possible (deterministic, OS-stable)."""
|
|
39
|
+
try:
|
|
40
|
+
return path.resolve().relative_to(Path.cwd()).as_posix()
|
|
41
|
+
except ValueError:
|
|
42
|
+
return path.resolve().as_posix()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def discover_sql_files(paths: tuple[str, ...]) -> list[Path]:
|
|
46
|
+
"""Expand the given paths into a sorted list of ``.sql`` files.
|
|
47
|
+
|
|
48
|
+
Files are taken as-is; directories are searched recursively, skipping
|
|
49
|
+
hidden directories. Defaults to the current directory when none given.
|
|
50
|
+
"""
|
|
51
|
+
roots = [Path(p) for p in paths] or [Path(".")]
|
|
52
|
+
found: set[Path] = set()
|
|
53
|
+
for root in roots:
|
|
54
|
+
if root.is_file():
|
|
55
|
+
found.add(root)
|
|
56
|
+
elif root.is_dir():
|
|
57
|
+
for candidate in root.rglob("*.sql"):
|
|
58
|
+
rel = candidate.relative_to(root)
|
|
59
|
+
if any(part.startswith(".") for part in rel.parts):
|
|
60
|
+
continue
|
|
61
|
+
if candidate.is_file():
|
|
62
|
+
found.add(candidate)
|
|
63
|
+
return sorted(found, key=lambda p: _display_path(p))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_files(files: list[Path], dialect: str) -> tuple[list[ParsedFile], list[Diagnostic]]:
|
|
67
|
+
"""Parse each file; an unreadable file becomes a diagnostic, not a crash."""
|
|
68
|
+
parsed: list[ParsedFile] = []
|
|
69
|
+
read_diagnostics: list[Diagnostic] = []
|
|
70
|
+
for path in files:
|
|
71
|
+
try:
|
|
72
|
+
text = path.read_text(encoding="utf-8-sig", errors="replace")
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
read_diagnostics.append(
|
|
75
|
+
Diagnostic(
|
|
76
|
+
severity="error",
|
|
77
|
+
category=FILE_UNREADABLE,
|
|
78
|
+
file=_display_path(path),
|
|
79
|
+
line=0,
|
|
80
|
+
message=f"could not read file: {exc}",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
continue
|
|
84
|
+
parsed.append(parse_sql(_display_path(path), text, dialect=dialect))
|
|
85
|
+
return parsed, read_diagnostics
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@click.group(invoke_without_command=True)
|
|
89
|
+
@click.version_option(version=__version__, prog_name="coop-sql-review")
|
|
90
|
+
@click.pass_context
|
|
91
|
+
def cli(ctx: click.Context) -> None:
|
|
92
|
+
"""Offline, advisory SQL standards linter for Microsoft Fabric DW.
|
|
93
|
+
|
|
94
|
+
Reports deviations from the SQL standards; never edits or blocks.
|
|
95
|
+
Processing problems (parse failures, rule errors) are reported as
|
|
96
|
+
diagnostics in every run; use ``check --log-file`` to capture them.
|
|
97
|
+
"""
|
|
98
|
+
ctx.ensure_object(dict)
|
|
99
|
+
# sqlglot logs a warning for every statement it falls back to a Command on;
|
|
100
|
+
# we already surface that as a controlled parse_degraded diagnostic, so keep
|
|
101
|
+
# its noise out of the report (stderr).
|
|
102
|
+
logging.getLogger("sqlglot").setLevel(logging.ERROR)
|
|
103
|
+
if ctx.invoked_subcommand is None:
|
|
104
|
+
click.echo(ctx.get_help())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@cli.command()
|
|
108
|
+
@click.argument("paths", nargs=-1, type=click.Path())
|
|
109
|
+
@click.option(
|
|
110
|
+
"--standards", "standards_path", default=None, help="Path to the standards file (default: bundled)."
|
|
111
|
+
)
|
|
112
|
+
@click.option(
|
|
113
|
+
"--config", "config_path", default=None, help="Path to a rules.yml (default: alongside standards)."
|
|
114
|
+
)
|
|
115
|
+
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text", show_default=True)
|
|
116
|
+
@click.option(
|
|
117
|
+
"--min-severity",
|
|
118
|
+
type=_SEVERITY_CHOICE,
|
|
119
|
+
default="info",
|
|
120
|
+
show_default=True,
|
|
121
|
+
help="Hide findings below this severity.",
|
|
122
|
+
)
|
|
123
|
+
@click.option("--dialect", default="tsql", show_default=True, help="sqlglot dialect to parse with.")
|
|
124
|
+
@click.option(
|
|
125
|
+
"--log-file",
|
|
126
|
+
"log_file",
|
|
127
|
+
type=click.Path(),
|
|
128
|
+
default=None,
|
|
129
|
+
help="Write a diagnostics log (parse problems, rule errors) to this file.",
|
|
130
|
+
)
|
|
131
|
+
@click.option("--strict", is_flag=True, help="Exit 2 if any reported finding remains (opt-in CI gate).")
|
|
132
|
+
@click.pass_context
|
|
133
|
+
def check(
|
|
134
|
+
ctx: click.Context,
|
|
135
|
+
paths: tuple[str, ...],
|
|
136
|
+
standards_path: str | None,
|
|
137
|
+
config_path: str | None,
|
|
138
|
+
fmt: str,
|
|
139
|
+
min_severity: str,
|
|
140
|
+
dialect: str,
|
|
141
|
+
log_file: str | None,
|
|
142
|
+
strict: bool,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Check SQL files (or directories) against the standards."""
|
|
145
|
+
try:
|
|
146
|
+
std_path = resolve_standards_path(standards_path)
|
|
147
|
+
except StandardsError as exc:
|
|
148
|
+
raise click.ClickException(str(exc)) from exc
|
|
149
|
+
|
|
150
|
+
config = RuleConfig.load(Path(config_path) if config_path else default_config_path(std_path))
|
|
151
|
+
rules = apply_config(all_rules(), config)
|
|
152
|
+
|
|
153
|
+
files = discover_sql_files(paths)
|
|
154
|
+
if not files:
|
|
155
|
+
click.echo("No .sql files found.", err=True)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
parsed, read_diagnostics = _parse_files(files, dialect)
|
|
159
|
+
result = run_rules(parsed, rules)
|
|
160
|
+
result.diagnostics.extend(read_diagnostics)
|
|
161
|
+
result.diagnostics.sort(key=lambda d: d.sort_key())
|
|
162
|
+
result = result.filtered(min_severity)
|
|
163
|
+
|
|
164
|
+
if fmt == "json":
|
|
165
|
+
click.echo(json_text(result, version=__version__, standards=standards_info(std_path)), nl=False)
|
|
166
|
+
else:
|
|
167
|
+
for line in console_lines(result):
|
|
168
|
+
click.echo(line)
|
|
169
|
+
|
|
170
|
+
if log_file:
|
|
171
|
+
try:
|
|
172
|
+
Path(log_file).write_text(log_text(result), encoding="utf-8", newline="\n")
|
|
173
|
+
click.echo(f"Diagnostics log written to {log_file}", err=True)
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
raise click.ClickException(f"could not write log file {log_file}: {exc}") from exc
|
|
176
|
+
|
|
177
|
+
if strict and result.findings:
|
|
178
|
+
sys.exit(2)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@cli.command(name="rules")
|
|
182
|
+
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text", show_default=True)
|
|
183
|
+
def rules_cmd(fmt: str) -> None:
|
|
184
|
+
"""List every rule: id, severity, tier, and whether it needs the agent."""
|
|
185
|
+
rules = all_rules()
|
|
186
|
+
if fmt == "json":
|
|
187
|
+
import json
|
|
188
|
+
|
|
189
|
+
payload = [
|
|
190
|
+
{
|
|
191
|
+
"id": r.id,
|
|
192
|
+
"title": r.title,
|
|
193
|
+
"severity": r.severity,
|
|
194
|
+
"category": r.category,
|
|
195
|
+
"standard_ref": r.standard_ref,
|
|
196
|
+
"tier": r.tier,
|
|
197
|
+
"kind": r.kind,
|
|
198
|
+
}
|
|
199
|
+
for r in rules
|
|
200
|
+
]
|
|
201
|
+
click.echo(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=True))
|
|
202
|
+
return
|
|
203
|
+
click.echo(f"{len(rules)} rule(s):\n")
|
|
204
|
+
for r in rules:
|
|
205
|
+
tag = "agent" if r.kind == "agent" else r.severity
|
|
206
|
+
click.echo(f" {r.id:26} [{tag:7}] T{r.tier} {r.standard_ref:5} {r.title}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _run_upgrade(check_only: bool, yes: bool) -> None:
|
|
210
|
+
"""Shared self-update behind both `upgrade` and `update` (the only networked path)."""
|
|
211
|
+
from coop_sql_review.upgrade import UpgradeError, apply_plan, build_plan
|
|
212
|
+
|
|
213
|
+
plan = build_plan()
|
|
214
|
+
click.echo(f"coop-sql-review {plan.tool_installed} ({plan.install_method}) — {plan.tool_note}")
|
|
215
|
+
if plan.dependencies:
|
|
216
|
+
click.echo("\nDependencies:")
|
|
217
|
+
for dep in plan.dependencies:
|
|
218
|
+
latest = dep.latest or "?"
|
|
219
|
+
label = {
|
|
220
|
+
"current": "up to date",
|
|
221
|
+
"safe": f"update available -> {latest}",
|
|
222
|
+
"major": f"MAJOR update available -> {latest} (review before applying)",
|
|
223
|
+
"unknown": "could not check (offline?)",
|
|
224
|
+
}[dep.kind]
|
|
225
|
+
click.echo(f" {dep.name:20} {dep.installed:12} {label}")
|
|
226
|
+
if check_only:
|
|
227
|
+
return
|
|
228
|
+
if not yes:
|
|
229
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
230
|
+
click.echo("\nRe-run with --yes to apply in non-interactive environments.", err=True)
|
|
231
|
+
return
|
|
232
|
+
if not click.confirm("\nApply the update and any non-breaking dependency updates?", default=True):
|
|
233
|
+
click.echo("Nothing changed.")
|
|
234
|
+
return
|
|
235
|
+
try:
|
|
236
|
+
executed = apply_plan(plan)
|
|
237
|
+
except UpgradeError as exc:
|
|
238
|
+
raise click.ClickException(str(exc)) from exc
|
|
239
|
+
for command in executed:
|
|
240
|
+
click.echo(f"ran: {' '.join(command)}", err=True)
|
|
241
|
+
click.echo("Done. Run `coop-sql-review --version` to confirm.")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
_UPGRADE_OPTIONS = [
|
|
245
|
+
click.option("--check", "check_only", is_flag=True, help="Report available updates; change nothing."),
|
|
246
|
+
click.option("--yes", is_flag=True, help="Apply without asking for confirmation."),
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _with_upgrade_options(func):
|
|
251
|
+
for option in reversed(_UPGRADE_OPTIONS):
|
|
252
|
+
func = option(func)
|
|
253
|
+
return func
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@cli.command()
|
|
257
|
+
@_with_upgrade_options
|
|
258
|
+
def upgrade(check_only: bool, yes: bool) -> None:
|
|
259
|
+
"""Update coop-sql-review to the latest version (and safe dependency bumps).
|
|
260
|
+
|
|
261
|
+
The ONLY command that uses the network. Major dependency jumps are
|
|
262
|
+
reported but never auto-applied.
|
|
263
|
+
"""
|
|
264
|
+
_run_upgrade(check_only, yes)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@cli.command()
|
|
268
|
+
@_with_upgrade_options
|
|
269
|
+
def update(check_only: bool, yes: bool) -> None:
|
|
270
|
+
"""Alias for `upgrade` — update coop-sql-review to the latest version."""
|
|
271
|
+
_run_upgrade(check_only, yes)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@cli.command(name="help")
|
|
275
|
+
@click.argument("command_name", required=False)
|
|
276
|
+
@click.pass_context
|
|
277
|
+
def help_cmd(ctx: click.Context, command_name: str | None) -> None:
|
|
278
|
+
"""Show help. `help` for everything, or `help <command>` (e.g. `help check`)."""
|
|
279
|
+
parent = ctx.parent
|
|
280
|
+
if command_name is None:
|
|
281
|
+
click.echo(parent.get_help())
|
|
282
|
+
return
|
|
283
|
+
command = cli.get_command(ctx, command_name)
|
|
284
|
+
if command is None:
|
|
285
|
+
raise click.UsageError(f"unknown command '{command_name}' — try `coop-sql-review help`", ctx=parent)
|
|
286
|
+
sub_ctx = click.Context(command, info_name=command_name, parent=parent)
|
|
287
|
+
click.echo(command.get_help(sub_ctx))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _force_utf8_console() -> None:
|
|
291
|
+
"""Emit UTF-8 on every platform so non-ASCII in messages (the § section
|
|
292
|
+
marks, em-dashes) never raise UnicodeEncodeError on a legacy Windows
|
|
293
|
+
console (cp1252/cp437). errors='replace' guarantees we never crash on
|
|
294
|
+
output; worst case an old console shows a replacement glyph."""
|
|
295
|
+
for stream in (sys.stdout, sys.stderr):
|
|
296
|
+
try:
|
|
297
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
298
|
+
except (AttributeError, ValueError, OSError):
|
|
299
|
+
pass # not a reconfigurable text stream (e.g. under test capture)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main() -> None:
|
|
303
|
+
"""Console-script entrypoint: friendly one-line errors, 130 on Ctrl-C."""
|
|
304
|
+
_force_utf8_console()
|
|
305
|
+
try:
|
|
306
|
+
cli(obj={}, standalone_mode=False)
|
|
307
|
+
except click.exceptions.Abort:
|
|
308
|
+
click.echo("\nInterrupted.", err=True)
|
|
309
|
+
sys.exit(130)
|
|
310
|
+
except click.exceptions.Exit as exc: # --help / --version
|
|
311
|
+
sys.exit(exc.exit_code)
|
|
312
|
+
except click.ClickException as exc:
|
|
313
|
+
exc.show()
|
|
314
|
+
sys.exit(exc.exit_code)
|
|
315
|
+
except KeyboardInterrupt:
|
|
316
|
+
click.echo("\nInterrupted.", err=True)
|
|
317
|
+
sys.exit(130)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
if __name__ == "__main__":
|
|
321
|
+
main()
|