sqlty 0.6.0__cp315-abi3.abi3t-win_amd64.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.
- sqlty/__init__.py +132 -0
- sqlty/__main__.py +5 -0
- sqlty/_breaking.py +482 -0
- sqlty/_check.py +1554 -0
- sqlty/_cli.py +1831 -0
- sqlty/_config.py +685 -0
- sqlty/_core.pyd +4 -0
- sqlty/_core.pyi +377 -0
- sqlty/_decode.py +62 -0
- sqlty/_drivers.py +515 -0
- sqlty/_gitignore.py +58 -0
- sqlty/_helpers.py +1095 -0
- sqlty/_json_shape.py +299 -0
- sqlty/_json_types.py +216 -0
- sqlty/_lint.py +441 -0
- sqlty/_lsp_breaking.py +151 -0
- sqlty/_lsp_bridge.py +136 -0
- sqlty/_params.py +228 -0
- sqlty/_psycopg.py +304 -0
- sqlty/_queries.py +125 -0
- sqlty/_sarif.py +74 -0
- sqlty/_snapshot.py +187 -0
- sqlty/_source.py +941 -0
- sqlty/_type_extractor/__init__.py +46 -0
- sqlty/_type_extractor/_stdlib.py +2722 -0
- sqlty/_type_extractor/ast_fields.py +993 -0
- sqlty/_type_extractor/compat.py +245 -0
- sqlty/_type_extractor/extract.py +233 -0
- sqlty/_type_extractor/generic.py +75 -0
- sqlty/_type_extractor/info.py +156 -0
- sqlty/_type_extractor/pydantic_types.py +133 -0
- sqlty/_type_extractor/stdlib.py +46 -0
- sqlty/_type_extractor/subtype.py +161 -0
- sqlty/_type_extractor/symbols.py +87 -0
- sqlty/_variants.py +941 -0
- sqlty/core.py +1020 -0
- sqlty/drivers/psycopg.py +150 -0
- sqlty/lexer.py +74 -0
- sqlty/magic.py +126 -0
- sqlty/py.typed +0 -0
- sqlty/rules/ambiguous-column.md +48 -0
- sqlty/rules/chasm-join.md +76 -0
- sqlty/rules/column-count-mismatch.md +51 -0
- sqlty/rules/column-name-mismatch.md +34 -0
- sqlty/rules/column-type-mismatch.md +75 -0
- sqlty/rules/duplicate-alias.md +52 -0
- sqlty/rules/dynamic-query.md +83 -0
- sqlty/rules/entity-mismatch.md +66 -0
- sqlty/rules/extra-param.md +53 -0
- sqlty/rules/fanout-aggregate.md +103 -0
- sqlty/rules/fstring-skipped.md +23 -0
- sqlty/rules/inline-sql-comment.md +27 -0
- sqlty/rules/insert-missing-column.md +63 -0
- sqlty/rules/insert-value-count.md +58 -0
- sqlty/rules/json-shape-mismatch.md +96 -0
- sqlty/rules/literal-mismatch.md +83 -0
- sqlty/rules/mysql-sql-marker.md +32 -0
- sqlty/rules/narrow-type.md +50 -0
- sqlty/rules/nullable-mismatch.md +76 -0
- sqlty/rules/param-count-mismatch.md +70 -0
- sqlty/rules/param-type-mismatch.md +84 -0
- sqlty/rules/params-type-mismatch.md +65 -0
- sqlty/rules/redundant-literal.md +68 -0
- sqlty/rules/redundant-optional.md +100 -0
- sqlty/rules/select-star-no-schema.md +24 -0
- sqlty/rules/select-star.md +78 -0
- sqlty/rules/sqruff.md +56 -0
- sqlty/rules/stale-rewrite.md +58 -0
- sqlty/rules/stale-type.md +56 -0
- sqlty/rules/tstring-skipped.md +68 -0
- sqlty/rules/typeddict-inheritance.md +35 -0
- sqlty/rules/typeddict-key-mismatch.md +38 -0
- sqlty/rules/unchecked-query.md +39 -0
- sqlty/rules/ungrouped-column.md +70 -0
- sqlty/rules/unknown-column.md +100 -0
- sqlty/rules/unknown-database.md +30 -0
- sqlty/rules/unknown-function.md +49 -0
- sqlty/rules/unknown-table.md +47 -0
- sqlty/rules/unknown-type.md +74 -0
- sqlty/rules/unparsed-query.md +48 -0
- sqlty/rules/unproven-join.md +55 -0
- sqlty/rules/unsupported-syntax.md +69 -0
- sqlty/rules/untyped-query.md +39 -0
- sqlty/rules/unused-ignore.md +36 -0
- sqlty-0.6.0.dist-info/METADATA +224 -0
- sqlty-0.6.0.dist-info/RECORD +91 -0
- sqlty-0.6.0.dist-info/WHEEL +5 -0
- sqlty-0.6.0.dist-info/entry_points.txt +4 -0
- sqlty-0.6.0.dist-info/licenses/LICENSE +21 -0
- sqlty-0.6.0.dist-info/sboms/sqlty-core.cyclonedx.json +6476 -0
- sqlty_magic.pth +1 -0
sqlty/__init__.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""SQLTy - Type-safe SQL queries for Python."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from string.templatelib import Template
|
|
5
|
+
from typing import TYPE_CHECKING, Any, NamedTuple, get_args, overload
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 15):
|
|
8
|
+
from typing import TypedDict, TypeForm
|
|
9
|
+
else:
|
|
10
|
+
from typing_extensions import Sentinel as sentinel
|
|
11
|
+
from typing_extensions import TypedDict, TypeForm
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def __getattr__(name: str) -> str:
|
|
15
|
+
# ``__version__`` is looked up lazily: importlib.metadata costs ~10 ms to import.
|
|
16
|
+
if name == "__version__":
|
|
17
|
+
import importlib.metadata
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
version = importlib.metadata.version(__name__)
|
|
21
|
+
except importlib.metadata.PackageNotFoundError:
|
|
22
|
+
version = "0.0.0" # Fallback for development mode
|
|
23
|
+
globals()["__version__"] = version
|
|
24
|
+
return version
|
|
25
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = ["SQL", "UNTYPED", "Bound", "NoParams", "SQLTemplate", "row_form"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SQL[R, P = Any](str):
|
|
32
|
+
"""A query whose rows are ``R`` and whose parameters are ``P``.
|
|
33
|
+
|
|
34
|
+
``P`` defaults to ``Any``, so ``SQL[R]`` takes any parameters. A query is typed like a
|
|
35
|
+
function from ``P`` to ``R``: ``SQL[R1, P1]`` fits ``SQL[R2, P2]`` when ``R1`` fits ``R2``
|
|
36
|
+
and ``P2`` fits ``P1``. Positional parameters are a ``tuple``, named ones a ``TypedDict``.
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
>>> query = SQL[tuple[str], tuple[int]]("--sql\\nSELECT name::TEXT FROM users WHERE id = %s")
|
|
40
|
+
>>> query.bind((1,)).params
|
|
41
|
+
(1,)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
|
|
46
|
+
def __sqlty_row__(self) -> R: ...
|
|
47
|
+
|
|
48
|
+
def bind(self, params: P, /) -> Bound[R]:
|
|
49
|
+
"""The query with ``params``, checked against ``P``: ``cursor.execute(*bound)`` runs it."""
|
|
50
|
+
return Bound(self, params)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _NoKeys(TypedDict, closed=True):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
type NoParams = tuple[()] | _NoKeys
|
|
58
|
+
"""Parameters of a query without placeholders, ``SQL[R, NoParams]``: ``()`` or ``{}``."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Bound[R](NamedTuple):
|
|
62
|
+
"""A query and its parameters, from ``SQL.bind``."""
|
|
63
|
+
|
|
64
|
+
query: SQL[R]
|
|
65
|
+
params: Any
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SQLTemplate[T]:
|
|
69
|
+
"""A t-string query, ``SQLTemplate[T](t"--sql\\n...")``, whose rows are ``T``.
|
|
70
|
+
|
|
71
|
+
``string.templatelib.Template`` cannot be subclassed, so the template is kept in
|
|
72
|
+
``template``: pass that to a driver that runs templates (psycopg >= 3.3), or the
|
|
73
|
+
whole query to the ``sqlty.drivers.psycopg`` helpers. ``row_form`` reads ``T``.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
>>> user_id = 1
|
|
77
|
+
>>> query = SQLTemplate[tuple[int]](t"--sql\\nSELECT id::INTEGER FROM users WHERE id = {user_id}")
|
|
78
|
+
>>> query.template.values
|
|
79
|
+
(1,)
|
|
80
|
+
>>> row_form(query)
|
|
81
|
+
tuple[int]
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
__slots__ = ("__orig_class__", "template")
|
|
85
|
+
|
|
86
|
+
def __init__(self, template: Template, /) -> None:
|
|
87
|
+
self.template = template
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
return f"{type(self).__name__}({self.template!r})"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
UNTYPED = sentinel("UNTYPED")
|
|
94
|
+
"""Row type of a query sqlty could not infer: ``SQL[UNTYPED]``.
|
|
95
|
+
|
|
96
|
+
Unlike ``Any``, type checkers reject using an ``UNTYPED`` row as a concrete
|
|
97
|
+
type. ``sqlty rewrite --untyped sentinel`` emits it; ``sqlty check`` flags it
|
|
98
|
+
(``untyped-query``) until casts or a schema make the row type known.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_MISSING: object = object()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@overload
|
|
106
|
+
def row_form[T](query: SQL[T] | SQLTemplate[T], /) -> TypeForm[T]: ...
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@overload
|
|
110
|
+
def row_form[T, D](query: SQL[T] | SQLTemplate[T], default: D, /) -> TypeForm[T] | D: ...
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def row_form(query: object, default: object = _MISSING, /) -> object:
|
|
114
|
+
"""Return the row type ``T`` a ``SQL[T](...)`` instance was built with.
|
|
115
|
+
|
|
116
|
+
The type is read from ``__orig_class__``, so it exists only for queries
|
|
117
|
+
constructed as ``SQL[T](...)``. Plain strings, annotation-only
|
|
118
|
+
``q: SQL[T] = "..."`` and stripped sources carry no runtime type: these
|
|
119
|
+
raise ``TypeError`` unless a ``default`` is given.
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
>>> row_form(SQL[tuple[int, str]]("--sql\\nSELECT 1, 'a'"))
|
|
123
|
+
tuple[int, str]
|
|
124
|
+
>>> row_form(SQL("--sql\\nSELECT 1"), None) is None
|
|
125
|
+
True
|
|
126
|
+
"""
|
|
127
|
+
orig = getattr(query, "__orig_class__", None)
|
|
128
|
+
if orig is not None and (args := get_args(orig)):
|
|
129
|
+
return args[0]
|
|
130
|
+
if default is not _MISSING:
|
|
131
|
+
return default
|
|
132
|
+
raise TypeError(f"{type(query).__name__} has no runtime row type; construct it as SQL[T](...)")
|
sqlty/__main__.py
ADDED
sqlty/_breaking.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""``sqlty breaking``: the queries and schema of two revisions, checked across.
|
|
2
|
+
|
|
3
|
+
During a rolling deploy the code of one revision runs against the schema of another.
|
|
4
|
+
``--against`` names the baseline ``A``; ``--head`` (the working tree by default) names
|
|
5
|
+
``B``. The backward check reads the queries of ``A`` against the schema of ``B`` (migrate
|
|
6
|
+
first), the forward check the queries of ``B`` against the schema of ``A`` (deploy
|
|
7
|
+
first). Each reports only what the other schema breaks: diagnostics a query does not
|
|
8
|
+
already get against its own schema.
|
|
9
|
+
|
|
10
|
+
Every revision but the working tree is checked out with ``git worktree add --detach``
|
|
11
|
+
into a temporary directory, removed afterwards, so the working tree is never touched.
|
|
12
|
+
Only the analyzed paths, the files of the directories above them and the schema are
|
|
13
|
+
checked out, unless a configuration reads more (see ``_checkout``).
|
|
14
|
+
A baseline can also be a snapshot ``--save`` wrote, which needs no git history, or a
|
|
15
|
+
schema file, which has no queries and so is only checked forward.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import glob
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
from collections.abc import Generator, Iterable
|
|
28
|
+
from dataclasses import dataclass, field, replace
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING, Literal, NamedTuple, Protocol
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
import argparse
|
|
34
|
+
|
|
35
|
+
from sqlty._cli import Settings, SettingsResolver
|
|
36
|
+
from sqlty._helpers import HelperIndex
|
|
37
|
+
from sqlty._lint import Diagnostic
|
|
38
|
+
from sqlty._snapshot import Snapshot
|
|
39
|
+
from sqlty._source import ParsedSource
|
|
40
|
+
from sqlty.core import Schema
|
|
41
|
+
|
|
42
|
+
_Work = tuple[Path, ParsedSource, Settings, Schema | None, HelperIndex]
|
|
43
|
+
|
|
44
|
+
Direction = Literal["backward", "forward", "both"]
|
|
45
|
+
SchemaKey = tuple[str | None, str, tuple[tuple[str, str], ...]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GitError(Exception):
|
|
49
|
+
"""A git command failed: not a repository, or an unknown revision."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Source(NamedTuple):
|
|
53
|
+
"""A Python file of a revision, with the module names its helpers are imported by, or a
|
|
54
|
+
``.sql`` file of named queries, which has none."""
|
|
55
|
+
|
|
56
|
+
text: str
|
|
57
|
+
module: str
|
|
58
|
+
names: tuple[str, ...]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Queries(Protocol):
|
|
62
|
+
"""A revision whose Python files are checked: a git revision or a snapshot."""
|
|
63
|
+
|
|
64
|
+
label: str
|
|
65
|
+
|
|
66
|
+
def sources(self) -> dict[Path, Source]: ...
|
|
67
|
+
|
|
68
|
+
def settings(self, relative: Path) -> Settings: ...
|
|
69
|
+
|
|
70
|
+
def roots(self) -> list[Path]: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Schemas(Protocol):
|
|
74
|
+
"""A revision whose schema the other revision's queries are checked against."""
|
|
75
|
+
|
|
76
|
+
label: str
|
|
77
|
+
|
|
78
|
+
def schema(self, relative: Path, own: Settings) -> Schema | None: ...
|
|
79
|
+
|
|
80
|
+
def schema_key(self, relative: Path, own: Settings) -> SchemaKey: ...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Revision:
|
|
85
|
+
"""A checkout of the project at one revision, read as ``sqlty check`` reads it."""
|
|
86
|
+
|
|
87
|
+
label: str
|
|
88
|
+
root: Path
|
|
89
|
+
resolver: SettingsResolver
|
|
90
|
+
files: list[Path]
|
|
91
|
+
paths: list[Path]
|
|
92
|
+
|
|
93
|
+
def settings(self, relative: Path) -> Settings:
|
|
94
|
+
return self.resolver.for_file(self.root / relative)
|
|
95
|
+
|
|
96
|
+
def schema(self, relative: Path, own: Settings) -> Schema | None:
|
|
97
|
+
return self.settings(relative).schema
|
|
98
|
+
|
|
99
|
+
def schema_key(self, relative: Path, own: Settings) -> SchemaKey:
|
|
100
|
+
return self.settings(relative).schema_key
|
|
101
|
+
|
|
102
|
+
def roots(self) -> list[Path]:
|
|
103
|
+
from sqlty._cli import _checked_roots
|
|
104
|
+
|
|
105
|
+
return _checked_roots([self.root / p for p in self.paths], self.files)
|
|
106
|
+
|
|
107
|
+
def sources(self) -> dict[Path, Source]:
|
|
108
|
+
from sqlty._cli import _module_names, read_source
|
|
109
|
+
|
|
110
|
+
roots = [self.root / p for p in self.paths]
|
|
111
|
+
sources: dict[Path, Source] = {}
|
|
112
|
+
for path in self.files:
|
|
113
|
+
if (text := read_source(path)) is None:
|
|
114
|
+
continue
|
|
115
|
+
if path.suffix == ".sql":
|
|
116
|
+
sources[path.relative_to(self.root)] = Source(text, "", ())
|
|
117
|
+
continue
|
|
118
|
+
names = _module_names(self.resolver.resolve(path), roots)
|
|
119
|
+
module = names[0] + (".__init__" if path.name == "__init__.py" else "")
|
|
120
|
+
sources[path.relative_to(self.root)] = Source(text, module, tuple(names))
|
|
121
|
+
return sources
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class SchemaFiles:
|
|
126
|
+
"""A schema without queries, such as a dump or migrations: a baseline only forward."""
|
|
127
|
+
|
|
128
|
+
label: str
|
|
129
|
+
sql: str
|
|
130
|
+
loaded: dict[tuple[str, tuple[tuple[str, str], ...]], Schema] = field(default_factory=dict)
|
|
131
|
+
|
|
132
|
+
def schema(self, relative: Path, own: Settings) -> Schema:
|
|
133
|
+
from sqlty.core import load_schema
|
|
134
|
+
|
|
135
|
+
key = (own.dialect, tuple(sorted((own.types or {}).items())))
|
|
136
|
+
if key not in self.loaded:
|
|
137
|
+
self.loaded[key] = load_schema(self.sql, dialect=own.dialect, types=own.types)
|
|
138
|
+
return self.loaded[key]
|
|
139
|
+
|
|
140
|
+
def schema_key(self, relative: Path, own: Settings) -> SchemaKey:
|
|
141
|
+
return replace(own, schema_sql=self.sql).schema_key
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class Finding:
|
|
146
|
+
direction: Literal["backward", "forward"]
|
|
147
|
+
against: str
|
|
148
|
+
path: Path
|
|
149
|
+
diagnostic: Diagnostic
|
|
150
|
+
source: ParsedSource
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run(args: argparse.Namespace) -> list[Finding]:
|
|
154
|
+
"""The findings of ``sqlty breaking``, for the paths and revisions ``args`` name; with
|
|
155
|
+
``--save``, the snapshot of ``--head`` is written first."""
|
|
156
|
+
from sqlty._snapshot import save
|
|
157
|
+
|
|
158
|
+
root = _root(args)
|
|
159
|
+
jobs = os.process_cpu_count() or 1
|
|
160
|
+
paths = [Path(p).resolve().relative_to(root) for p in (args.paths or [Path.cwd()])]
|
|
161
|
+
with contextlib.ExitStack() as stack:
|
|
162
|
+
baselines = _baselines(args.against, root, paths, stack) if args.against else []
|
|
163
|
+
head_root = stack.enter_context(_worktree(root, args.head, paths)) if args.head else root
|
|
164
|
+
head = _revision(args.head or "the working tree", head_root, paths)
|
|
165
|
+
if args.save:
|
|
166
|
+
save(head, args.save)
|
|
167
|
+
findings: list[Finding] = []
|
|
168
|
+
for baseline in baselines:
|
|
169
|
+
if args.direction in ("backward", "both"):
|
|
170
|
+
if isinstance(baseline, SchemaFiles):
|
|
171
|
+
print(
|
|
172
|
+
f"sqlty: {baseline.label} has no queries; skipping the backward check",
|
|
173
|
+
file=sys.stderr,
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
findings += _compare(
|
|
177
|
+
"backward", baseline.label, queries=baseline, schema=head, jobs=jobs
|
|
178
|
+
)
|
|
179
|
+
if args.direction in ("forward", "both"):
|
|
180
|
+
findings += _compare(
|
|
181
|
+
"forward", baseline.label, queries=head, schema=baseline, jobs=jobs
|
|
182
|
+
)
|
|
183
|
+
return findings
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def report(
|
|
187
|
+
findings: list[Finding],
|
|
188
|
+
head: str | None,
|
|
189
|
+
quiet: bool,
|
|
190
|
+
output_format: Literal["text", "json", "github", "sarif"] = "text",
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Print each finding: ``path:line: [rule] message (direction)``, a JSON array with a
|
|
193
|
+
``direction`` and ``against`` per finding, GitHub Actions annotations at each
|
|
194
|
+
finding's severity, or a SARIF log."""
|
|
195
|
+
import json
|
|
196
|
+
|
|
197
|
+
from sqlty._cli import github_annotation, json_diagnostic
|
|
198
|
+
from sqlty._sarif import sarif_log
|
|
199
|
+
|
|
200
|
+
if quiet:
|
|
201
|
+
return
|
|
202
|
+
head = head or "the working tree"
|
|
203
|
+
|
|
204
|
+
def message(finding: Finding) -> str:
|
|
205
|
+
if finding.direction == "backward":
|
|
206
|
+
title = f"the queries of {finding.against} against the schema of {head}"
|
|
207
|
+
else:
|
|
208
|
+
title = f"the queries of {head} against the schema of {finding.against}"
|
|
209
|
+
return f"{finding.diagnostic['message']} ({finding.direction}: {title})"
|
|
210
|
+
|
|
211
|
+
if output_format in ("json", "sarif"):
|
|
212
|
+
rows = [
|
|
213
|
+
{
|
|
214
|
+
"file": str(f.path),
|
|
215
|
+
**json_diagnostic(f.diagnostic, f.source),
|
|
216
|
+
**({"message": message(f)} if output_format == "sarif" else {}),
|
|
217
|
+
"direction": f.direction,
|
|
218
|
+
"against": f.against,
|
|
219
|
+
}
|
|
220
|
+
for f in findings
|
|
221
|
+
]
|
|
222
|
+
print(json.dumps(rows if output_format == "json" else sarif_log(rows), indent=2))
|
|
223
|
+
return
|
|
224
|
+
for finding in findings:
|
|
225
|
+
d = finding.diagnostic
|
|
226
|
+
if output_format == "github":
|
|
227
|
+
print(github_annotation(finding.path, {**d, "message": message(finding)}))
|
|
228
|
+
else:
|
|
229
|
+
print(f"{finding.path}:{d.get('line', '')}: [{d['rule']}] {message(finding)}")
|
|
230
|
+
if findings and output_format == "text":
|
|
231
|
+
print(f"Found {len(findings)} breaking change(s)")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _compare(
|
|
235
|
+
direction: Literal["backward", "forward"],
|
|
236
|
+
against: str,
|
|
237
|
+
*,
|
|
238
|
+
queries: Queries,
|
|
239
|
+
schema: Schemas,
|
|
240
|
+
jobs: int = 1,
|
|
241
|
+
) -> list[Finding]:
|
|
242
|
+
"""What ``schema`` breaks in ``queries``; up to ``jobs`` processes check the files."""
|
|
243
|
+
from sqlty._helpers import configured_helpers
|
|
244
|
+
from sqlty._source import ParsedSource
|
|
245
|
+
from sqlty.core import HelperIndex
|
|
246
|
+
|
|
247
|
+
sources: dict[Path, ParsedSource] = {}
|
|
248
|
+
index = HelperIndex()
|
|
249
|
+
for relative, source in queries.sources().items():
|
|
250
|
+
sources[relative] = ParsedSource(source.text, source.module or None)
|
|
251
|
+
for name in source.names:
|
|
252
|
+
index.add(name, sources[relative])
|
|
253
|
+
helpers_for = configured_helpers(index, queries.roots())
|
|
254
|
+
work: list[_Work] = []
|
|
255
|
+
for relative, parsed in sources.items():
|
|
256
|
+
own = queries.settings(relative)
|
|
257
|
+
if schema.schema_key(relative, own) != own.schema_key:
|
|
258
|
+
work.append((relative, parsed, own, schema.schema(relative, own), helpers_for(own)))
|
|
259
|
+
return [
|
|
260
|
+
Finding(direction, against, relative, diagnostic, parsed)
|
|
261
|
+
for (relative, parsed, *_), found in zip(work, _map(work, jobs), strict=True)
|
|
262
|
+
for diagnostic in found
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
_shared: list[_Work] = []
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _map(work: list[_Work], jobs: int) -> list[list[Diagnostic]]:
|
|
270
|
+
"""The diagnostics of each item of ``work``, checked by forked processes that inherit it.
|
|
271
|
+
|
|
272
|
+
The checks are mostly Python, so threads would wait on the GIL. Without ``fork`` (Windows),
|
|
273
|
+
or for one job, they run here.
|
|
274
|
+
"""
|
|
275
|
+
import multiprocessing
|
|
276
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
277
|
+
|
|
278
|
+
jobs = min(jobs, len(work))
|
|
279
|
+
if jobs <= 1 or "fork" not in multiprocessing.get_all_start_methods():
|
|
280
|
+
return [_check(item) for item in work]
|
|
281
|
+
_shared[:] = work
|
|
282
|
+
try:
|
|
283
|
+
context = multiprocessing.get_context("fork")
|
|
284
|
+
size = max(1, min(64, len(work) // (jobs * 4)))
|
|
285
|
+
chunks = [range(i, min(i + size, len(work))) for i in range(0, len(work), size)]
|
|
286
|
+
with ProcessPoolExecutor(jobs, mp_context=context) as pool:
|
|
287
|
+
return [found for chunk in pool.map(_check_shared, chunks) for found in chunk]
|
|
288
|
+
finally:
|
|
289
|
+
_shared.clear()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _check_shared(chunk: range) -> list[list[Diagnostic]]:
|
|
293
|
+
return [_check(_shared[i]) for i in chunk]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _check(item: _Work) -> list[Diagnostic]:
|
|
297
|
+
from sqlty._queries import breaking_query_file
|
|
298
|
+
from sqlty.core import breaking_source
|
|
299
|
+
|
|
300
|
+
relative, parsed, own, against, helpers = item
|
|
301
|
+
if relative.suffix == ".sql":
|
|
302
|
+
return breaking_query_file(
|
|
303
|
+
parsed.text,
|
|
304
|
+
name=relative.stem,
|
|
305
|
+
query_format=own.query_format,
|
|
306
|
+
dialect=own.dialect,
|
|
307
|
+
schema_sql=own.schema,
|
|
308
|
+
against_sql=against,
|
|
309
|
+
rules=own.rules,
|
|
310
|
+
)
|
|
311
|
+
return breaking_source(
|
|
312
|
+
parsed,
|
|
313
|
+
schema_sql=own.schema,
|
|
314
|
+
against_sql=against,
|
|
315
|
+
dialect=own.dialect,
|
|
316
|
+
rules=own.rules,
|
|
317
|
+
import_style=own.import_style,
|
|
318
|
+
untyped=own.untyped,
|
|
319
|
+
query_variants=own.query_variants,
|
|
320
|
+
helpers=helpers,
|
|
321
|
+
coerce_row_types=own.coerce_row_types,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _baselines(
|
|
326
|
+
against: str, root: Path, paths: list[Path], stack: contextlib.ExitStack
|
|
327
|
+
) -> list[Revision | Snapshot | SchemaFiles]:
|
|
328
|
+
"""What ``--against`` names: a git revision when no such path exists, else a snapshot,
|
|
329
|
+
each snapshot of a directory, or schema files."""
|
|
330
|
+
from sqlty._cli import _load_schema
|
|
331
|
+
from sqlty._config import ConfigError
|
|
332
|
+
from sqlty._snapshot import Snapshot
|
|
333
|
+
|
|
334
|
+
path = Path(against)
|
|
335
|
+
if not path.exists():
|
|
336
|
+
return [_revision(against, stack.enter_context(_worktree(root, against, paths)), paths)]
|
|
337
|
+
if path.suffix == ".json" and path.is_file():
|
|
338
|
+
return [Snapshot.load(path, against, paths)]
|
|
339
|
+
if path.is_dir() and (snapshots := sorted(path.glob("*.json"))):
|
|
340
|
+
return [Snapshot.load(p, p.stem, paths) for p in snapshots]
|
|
341
|
+
if (sql := _load_schema(str(path), Path.cwd())) is None:
|
|
342
|
+
raise ConfigError(f"{against} has no schema .sql files")
|
|
343
|
+
return [SchemaFiles(against, sql)]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _root(args: argparse.Namespace) -> Path:
|
|
347
|
+
"""The repository's top level; outside one, the current directory, unless a git
|
|
348
|
+
revision is needed."""
|
|
349
|
+
try:
|
|
350
|
+
return Path(_git(Path.cwd(), "rev-parse", "--show-toplevel")).resolve()
|
|
351
|
+
except GitError:
|
|
352
|
+
if args.head or (args.against and not Path(args.against).exists()):
|
|
353
|
+
raise
|
|
354
|
+
return Path.cwd().resolve()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _revision(label: str, root: Path, paths: list[Path]) -> Revision:
|
|
358
|
+
from sqlty._cli import (
|
|
359
|
+
SettingsResolver,
|
|
360
|
+
_collect_python_files,
|
|
361
|
+
_collect_query_files,
|
|
362
|
+
build_parser,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
args = build_parser().parse_args(["check", *(str(root / p) for p in paths)])
|
|
366
|
+
with contextlib.chdir(root):
|
|
367
|
+
resolver = SettingsResolver(args)
|
|
368
|
+
files = _collect_python_files([root / p for p in paths], resolver.source)
|
|
369
|
+
files += _collect_query_files([root / p for p in paths], files, resolver)
|
|
370
|
+
files = [f.resolve() for f in files]
|
|
371
|
+
return Revision(label, root, resolver, files, paths)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@contextlib.contextmanager
|
|
375
|
+
def _worktree(root: Path, ref: str, paths: list[Path]) -> Generator[Path]:
|
|
376
|
+
"""``ref`` checked out into a temporary worktree, removed on exit: the files
|
|
377
|
+
``paths`` need, or all of them."""
|
|
378
|
+
_git(root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
|
|
379
|
+
directory = Path(tempfile.mkdtemp(prefix="sqlty-breaking-"))
|
|
380
|
+
# Named like the repository, so its files have the module names they have there.
|
|
381
|
+
checkout = directory / root.name
|
|
382
|
+
try:
|
|
383
|
+
_git(root, "worktree", "add", "--detach", "--no-checkout", "--quiet", str(checkout), ref)
|
|
384
|
+
_checkout(checkout, paths)
|
|
385
|
+
yield checkout.resolve()
|
|
386
|
+
finally:
|
|
387
|
+
with contextlib.suppress(GitError):
|
|
388
|
+
_git(root, "worktree", "remove", "--force", str(checkout))
|
|
389
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
390
|
+
with contextlib.suppress(GitError):
|
|
391
|
+
_git(root, "worktree", "prune")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _checkout(checkout: Path, paths: list[Path]) -> None:
|
|
395
|
+
"""Check out what checking ``paths`` reads: each path, the files of the directories
|
|
396
|
+
above it (where its configuration is found) and the schema and query files the
|
|
397
|
+
configurations name. Everything when a path is the root or a configuration reads
|
|
398
|
+
more: ``functions`` modules, ``databases``, a glob or a file outside the repository."""
|
|
399
|
+
from sqlty._config import ConfigError, find_config
|
|
400
|
+
|
|
401
|
+
listed = [
|
|
402
|
+
n for n in _git(checkout, "ls-tree", "-r", "--name-only", "-z", "HEAD").split("\0") if n
|
|
403
|
+
]
|
|
404
|
+
above = {parent for path in paths for parent in path.parents}
|
|
405
|
+
full = ["reset", "--hard", "--quiet"]
|
|
406
|
+
if Path(".") in paths:
|
|
407
|
+
_git(checkout, *full)
|
|
408
|
+
return
|
|
409
|
+
_restore(checkout, [*paths, *(n for n in listed if Path(n).parent in above)], listed)
|
|
410
|
+
configs = {
|
|
411
|
+
Path(n).parent
|
|
412
|
+
for n in listed
|
|
413
|
+
if Path(n).name in _CONFIG_FILES
|
|
414
|
+
and (Path(n).parent in above or any(Path(n).is_relative_to(path) for path in paths))
|
|
415
|
+
}
|
|
416
|
+
needed: list[str | Path] = []
|
|
417
|
+
for directory in configs | set(paths):
|
|
418
|
+
try:
|
|
419
|
+
config, found = find_config(checkout / directory)
|
|
420
|
+
except ConfigError:
|
|
421
|
+
_git(checkout, *full)
|
|
422
|
+
return
|
|
423
|
+
if found is None:
|
|
424
|
+
continue
|
|
425
|
+
specs = [config.get("schema", []), config.get("queries", [])]
|
|
426
|
+
entries = [entry for spec in specs for entry in ([spec] if isinstance(spec, str) else spec)]
|
|
427
|
+
named = [os.path.relpath(found.parent / entry, checkout.resolve()) for entry in entries]
|
|
428
|
+
if (
|
|
429
|
+
{"functions", "databases"} & config.keys()
|
|
430
|
+
or any(glob.has_magic(entry) for entry in entries)
|
|
431
|
+
or any(name.startswith("..") for name in named)
|
|
432
|
+
):
|
|
433
|
+
_git(checkout, *full)
|
|
434
|
+
return
|
|
435
|
+
needed += named
|
|
436
|
+
_restore(checkout, needed, listed)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _restore(checkout: Path, names: Iterable[str | Path], listed: list[str]) -> None:
|
|
440
|
+
"""Check out the files and directories of ``names`` that HEAD has."""
|
|
441
|
+
directories = {str(parent) for name in listed for parent in Path(name).parents}
|
|
442
|
+
present = {str(name) for name in names} & ({*listed} | directories - {"."})
|
|
443
|
+
if present:
|
|
444
|
+
_git(
|
|
445
|
+
checkout,
|
|
446
|
+
"checkout",
|
|
447
|
+
"--quiet",
|
|
448
|
+
"HEAD",
|
|
449
|
+
"--",
|
|
450
|
+
*(f":(literal){n}" for n in sorted(present)),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
_CONFIG_FILES = ("sqlty.toml", "pyproject.toml", "Cargo.toml")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _git(cwd: Path, *args: str) -> str:
|
|
458
|
+
try:
|
|
459
|
+
done = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=False)
|
|
460
|
+
except FileNotFoundError as exc:
|
|
461
|
+
raise GitError("git is not installed") from exc
|
|
462
|
+
if done.returncode != 0:
|
|
463
|
+
message = done.stderr.strip() or f"git {' '.join(args)} failed"
|
|
464
|
+
if args[:2] == ("rev-parse", "--verify"):
|
|
465
|
+
message = f"unknown revision {args[-1].removesuffix('^{commit}')!r}"
|
|
466
|
+
raise GitError(message)
|
|
467
|
+
return done.stdout.strip()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def main(args: argparse.Namespace) -> None:
|
|
471
|
+
from sqlty._cli import EXIT_FOUND
|
|
472
|
+
from sqlty._config import ConfigError
|
|
473
|
+
|
|
474
|
+
if args.against is None and args.save is None:
|
|
475
|
+
raise ConfigError("sqlty breaking needs --against, --save or both")
|
|
476
|
+
try:
|
|
477
|
+
findings = run(args)
|
|
478
|
+
except GitError as exc:
|
|
479
|
+
raise ConfigError(str(exc)) from exc
|
|
480
|
+
report(findings, args.head, args.quiet, args.output_format)
|
|
481
|
+
if any(f.diagnostic["severity"] == "error" for f in findings):
|
|
482
|
+
sys.exit(EXIT_FOUND)
|