sqlakit 0.21.0__tar.gz → 0.22.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sqlakit-0.21.0 → sqlakit-0.22.1}/PKG-INFO +1 -1
- {sqlakit-0.21.0 → sqlakit-0.22.1}/pyproject.toml +1 -1
- {sqlakit-0.21.0 → sqlakit-0.22.1}/pyproject.toml.orig +1 -1
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_project.py +33 -11
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_sql.py +0 -17
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_sqruff.py +70 -8
- {sqlakit-0.21.0 → sqlakit-0.22.1}/LICENSE +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/README.md +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/__init__.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_base.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_cli.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_db.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_model.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_pycharm.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_query.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_recording.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_registry.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_routing.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/_static.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/asyncio/_db.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/asyncio/orm.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/exceptions.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/orm.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/py.typed +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/pytest_plugin.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/sql.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/testing.py +0 -0
- {sqlakit-0.21.0 → sqlakit-0.22.1}/sqlakit/types.py +0 -0
|
@@ -48,7 +48,7 @@ from .exceptions import (
|
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
if TYPE_CHECKING:
|
|
51
|
-
from collections.abc import Iterator
|
|
51
|
+
from collections.abc import Collection, Iterable, Iterator
|
|
52
52
|
|
|
53
53
|
__all__ = ["Problem", "Project", "load_project"]
|
|
54
54
|
|
|
@@ -70,11 +70,13 @@ RESERVED = RESERVED_WORDS | POSTGRESQL_RESERVED_WORDS
|
|
|
70
70
|
"""Words a parameter cannot stand in for unquoted, on the databases linted most."""
|
|
71
71
|
"""Names SQL keeps for itself, which a linter cannot read a parameter as."""
|
|
72
72
|
|
|
73
|
-
LINT_EXCLUDED = ("RF01", "AL05", "ST03")
|
|
73
|
+
LINT_EXCLUDED = ("RF01", "RF02", "RF03", "AL05", "ST03")
|
|
74
74
|
"""Rules a `tpl.` call trips without anything being wrong with the template.
|
|
75
75
|
|
|
76
|
-
`RF01` reads `tpl.if_set` as a column of a table named `tpl
|
|
77
|
-
`
|
|
76
|
+
`RF01` reads `tpl.if_set` as a column of a table named `tpl`. `RF02` and `RF03`,
|
|
77
|
+
outside `sqruff`'s default rules, read a table a macro takes, `tpl.paid(o)`, as
|
|
78
|
+
a column. `AL05` and `ST03` miss an alias or a CTE used only inside a macro's
|
|
79
|
+
argument.
|
|
78
80
|
"""
|
|
79
81
|
|
|
80
82
|
|
|
@@ -212,7 +214,23 @@ class Project:
|
|
|
212
214
|
if found.group("macro")
|
|
213
215
|
]
|
|
214
216
|
|
|
215
|
-
def
|
|
217
|
+
def parameter_names(self) -> set[str]:
|
|
218
|
+
"""Return the name of every `:parameter` the templates and macros read."""
|
|
219
|
+
names: set[str] = set()
|
|
220
|
+
for path in self._linted_files():
|
|
221
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
222
|
+
names.update(
|
|
223
|
+
found.group("param")
|
|
224
|
+
for found in _PARAMETER_IN_TEXT.finditer(text)
|
|
225
|
+
if found.group("param")
|
|
226
|
+
)
|
|
227
|
+
return names
|
|
228
|
+
|
|
229
|
+
def _linted_files(self) -> set[Path]:
|
|
230
|
+
paths = [self.path_of(name) for name in self.templates.names()]
|
|
231
|
+
return {*(one for one in paths if one is not None), *self.macro_files()}
|
|
232
|
+
|
|
233
|
+
def placeholder_values(self, reserved: Iterable[str] = ()) -> dict[str, str]:
|
|
216
234
|
"""Return a value for each parameter a linter cannot read by its name.
|
|
217
235
|
|
|
218
236
|
With no value, the `placeholder` templater writes a parameter's name in
|
|
@@ -221,12 +239,14 @@ class Project:
|
|
|
221
239
|
writes to, a linter reads only `@stage/path`,
|
|
222
240
|
and inside `SAMPLE (...)` only a number. A name SQL reserves reads as
|
|
223
241
|
neither, `LIMIT :limit` reading `LIMIT limit`, so it gets `1`, or its
|
|
224
|
-
name with `_` after it when a path follows, `:order.id`.
|
|
242
|
+
name with `_` after it when a path follows, `:order.id`. ``reserved``
|
|
243
|
+
adds the words a linter's dialect keeps beside those.
|
|
225
244
|
"""
|
|
226
245
|
values: dict[str, str] = {}
|
|
227
|
-
|
|
228
|
-
for path in
|
|
229
|
-
|
|
246
|
+
words = RESERVED | {word.lower() for word in reserved}
|
|
247
|
+
for path in self._linted_files():
|
|
248
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
249
|
+
placeholders_of(text, values, words)
|
|
230
250
|
return dict(sorted(values.items()))
|
|
231
251
|
|
|
232
252
|
def macro_files(self) -> list[Path]:
|
|
@@ -258,7 +278,9 @@ class Project:
|
|
|
258
278
|
return found
|
|
259
279
|
|
|
260
280
|
|
|
261
|
-
def placeholders_of(
|
|
281
|
+
def placeholders_of(
|
|
282
|
+
text: str, values: dict[str, str], reserved: Collection[str] = RESERVED
|
|
283
|
+
) -> dict[str, str]:
|
|
262
284
|
"""Add to ``values`` what a linter needs for each parameter of the text.
|
|
263
285
|
|
|
264
286
|
See `Project.placeholder_values`, which reads every template with it.
|
|
@@ -276,7 +298,7 @@ def placeholders_of(text: str, values: dict[str, str]) -> dict[str, str]:
|
|
|
276
298
|
values[param] = "@stage/path"
|
|
277
299
|
elif SAMPLE_AFTER.search(before):
|
|
278
300
|
values[param] = "10"
|
|
279
|
-
elif param.lower() in
|
|
301
|
+
elif param.lower() in reserved:
|
|
280
302
|
# `COPY INTO :table` needs a name, and other positions a value.
|
|
281
303
|
named = dotted or inline_position(before)
|
|
282
304
|
values.setdefault(param, f"{param}_" if named else "1")
|
|
@@ -1954,22 +1954,6 @@ def unless_set(value: Param, expr: Sql, otherwise: Sql = Sql("TRUE")) -> str: #
|
|
|
1954
1954
|
return _grouped(str(otherwise if _is_set(value.value) else expr))
|
|
1955
1955
|
|
|
1956
1956
|
|
|
1957
|
-
@sql_macro(optional=True, lazy=True)
|
|
1958
|
-
def when(value: Param, sql: Sql, *more: Sql) -> str:
|
|
1959
|
-
"""Write `sql` when the parameter holds a value, and nothing when it does not.
|
|
1960
|
-
|
|
1961
|
-
For a clause that is there or not, rather than a condition that is true or
|
|
1962
|
-
not: `tpl.when(:team, JOIN teams AS t ON t.id = u.team_id)`. `if_set`
|
|
1963
|
-
writes `TRUE` in its place, which only a condition can stand.
|
|
1964
|
-
|
|
1965
|
-
Everything after the parameter is the clause, commas and all:
|
|
1966
|
-
`tpl.when(:limit, ORDER BY score DESC, created_at DESC LIMIT :limit)`.
|
|
1967
|
-
"""
|
|
1968
|
-
if not _is_set(value.value):
|
|
1969
|
-
return ""
|
|
1970
|
-
return ", ".join(str(part) for part in (sql, *more))
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
1957
|
@sql_macro(optional=True)
|
|
1974
1958
|
def array(ctx: Context, values: Param, type_name: Sql = Sql("")) -> str: # noqa: B008
|
|
1975
1959
|
"""Write a list as an array, one parameter per value.
|
|
@@ -2580,7 +2564,6 @@ BUILTIN_MACROS: Mapping[str, Macro] = {
|
|
|
2580
2564
|
for macro in (
|
|
2581
2565
|
if_set,
|
|
2582
2566
|
unless_set,
|
|
2583
|
-
when,
|
|
2584
2567
|
order_by,
|
|
2585
2568
|
icontains,
|
|
2586
2569
|
icollate,
|
|
@@ -2,15 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
The `placeholder` templater writes each `:name` as `name`. Where that reads as
|
|
4
4
|
something else, `settings` gives the parameter a value; see
|
|
5
|
-
`Project.placeholder_values`.
|
|
5
|
+
`Project.placeholder_values`. Which names a dialect keeps for itself is asked
|
|
6
|
+
of `sqruff`, when it is installed, since its dialects keep words SQLAlchemy's
|
|
7
|
+
do not: `exclude` and `row` on SQLite.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
from __future__ import annotations
|
|
9
11
|
|
|
10
12
|
import json
|
|
11
13
|
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
12
17
|
import tomllib
|
|
13
|
-
from typing import TYPE_CHECKING
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
14
19
|
|
|
15
20
|
from ._project import LINT_EXCLUDED
|
|
16
21
|
from .exceptions import ProjectConfigError
|
|
@@ -33,7 +38,7 @@ def stale(project: Project, pyproject: str) -> list[str]:
|
|
|
33
38
|
"""Return the tables of `pyproject.toml` that no longer read the templates."""
|
|
34
39
|
sqruff = tomllib.loads(pyproject).get("tool", {}).get("sqruff", {})
|
|
35
40
|
written = sqruff.get("templater", {}).get("placeholder", {})
|
|
36
|
-
values = project.placeholder_values()
|
|
41
|
+
values = project.placeholder_values(_kept(project, _dialect(sqruff, None, project)))
|
|
37
42
|
checked = (
|
|
38
43
|
("[tool.sqruff.core]", "core" in sqruff),
|
|
39
44
|
(
|
|
@@ -59,22 +64,21 @@ def settings(project: Project, pyproject: str, dialect: str | None) -> str:
|
|
|
59
64
|
written = dict(sqruff.get("templater", {}).get("placeholder", {}))
|
|
60
65
|
written.pop("param_style", None)
|
|
61
66
|
text = pyproject
|
|
67
|
+
chosen = _dialect(sqruff, dialect, project)
|
|
62
68
|
if "core" not in sqruff:
|
|
63
|
-
chosen = dialect or project.dialect
|
|
64
|
-
chosen = _DIALECTS.get(chosen or "", chosen)
|
|
65
69
|
core = [
|
|
66
70
|
"[tool.sqruff.core]",
|
|
67
71
|
*([f'dialect = "{chosen}"'] if chosen else []),
|
|
68
72
|
'templater = "placeholder"',
|
|
73
|
+
'rules = "all"',
|
|
69
74
|
f'exclude_rules = "{",".join(LINT_EXCLUDED)}"',
|
|
70
75
|
]
|
|
71
76
|
text = text.rstrip("\n") + "\n\n" + "\n".join(core) + "\n"
|
|
72
|
-
|
|
77
|
+
values = project.placeholder_values(_kept(project, chosen))
|
|
78
|
+
merged = dict(sorted({**written, **values}.items()))
|
|
73
79
|
table = "\n".join(
|
|
74
80
|
[
|
|
75
81
|
"[tool.sqruff.templater.placeholder]",
|
|
76
|
-
"# `sqlakit export sqruff` writes what the templates need, and keeps",
|
|
77
|
-
"# what you add.",
|
|
78
82
|
'param_style = "colon"',
|
|
79
83
|
*(f"{name} = {_toml_value(value)}" for name, value in merged.items()),
|
|
80
84
|
]
|
|
@@ -97,6 +101,64 @@ def settings(project: Project, pyproject: str, dialect: str | None) -> str:
|
|
|
97
101
|
return written_out
|
|
98
102
|
|
|
99
103
|
|
|
104
|
+
def _dialect(sqruff: dict, given: str | None, project: Project) -> str | None:
|
|
105
|
+
"""Return the dialect `sqruff` reads the templates in, by its name."""
|
|
106
|
+
if "core" in sqruff:
|
|
107
|
+
# sqruff reads a table without a dialect as ANSI, and so does this.
|
|
108
|
+
written = sqruff["core"].get("dialect")
|
|
109
|
+
return written if isinstance(written, str) else None
|
|
110
|
+
chosen = given or project.dialect
|
|
111
|
+
return _DIALECTS.get(chosen or "", chosen)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _kept(project: Project, dialect: str | None) -> set[str]:
|
|
115
|
+
"""Return the parameter names `sqruff` cannot read as a column in the dialect.
|
|
116
|
+
|
|
117
|
+
Each name is linted as `SELECT <name>;`, one to a line, in one run. Without
|
|
118
|
+
`sqruff` installed, the dialects of SQLAlchemy decide alone.
|
|
119
|
+
"""
|
|
120
|
+
binary = shutil.which("sqruff")
|
|
121
|
+
names = sorted(project.parameter_names())
|
|
122
|
+
if binary is None or not names:
|
|
123
|
+
return set()
|
|
124
|
+
# JSON, as the report of its own is another shape on GitHub Actions.
|
|
125
|
+
command = [binary, "lint", "--parsing-errors", "--format", "json", "-"]
|
|
126
|
+
if dialect:
|
|
127
|
+
command[2:2] = ["--dialect", dialect]
|
|
128
|
+
source = "".join(f"SELECT {name};\n" for name in names)
|
|
129
|
+
# Away from the project, so no setting of its own changes the reading.
|
|
130
|
+
with tempfile.TemporaryDirectory() as empty:
|
|
131
|
+
found = subprocess.run( # noqa: S603 - sqruff, found on the PATH
|
|
132
|
+
command,
|
|
133
|
+
input=source,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
cwd=empty,
|
|
137
|
+
check=False,
|
|
138
|
+
)
|
|
139
|
+
lines = {
|
|
140
|
+
problem["range"]["start"]["line"]
|
|
141
|
+
for problems in _report(found.stdout, found.stderr).values()
|
|
142
|
+
for problem in problems
|
|
143
|
+
# The name starts after `SELECT `, at the eighth character.
|
|
144
|
+
if problem.get("message") == "Unparsable section"
|
|
145
|
+
and problem["range"]["start"]["character"] == len("SELECT ") + 1
|
|
146
|
+
}
|
|
147
|
+
return {name for number, name in enumerate(names, 1) if number in lines}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _report(*outputs: str) -> dict[str, list[dict[str, Any]]]:
|
|
151
|
+
"""Return the JSON report, from whichever stream `sqruff` wrote it to."""
|
|
152
|
+
for output in outputs:
|
|
153
|
+
try:
|
|
154
|
+
report = json.loads(output)
|
|
155
|
+
except json.JSONDecodeError:
|
|
156
|
+
continue
|
|
157
|
+
if isinstance(report, dict):
|
|
158
|
+
return report
|
|
159
|
+
return {}
|
|
160
|
+
|
|
161
|
+
|
|
100
162
|
def _toml_value(value: object) -> str:
|
|
101
163
|
"""Return a value as TOML writes it: a string quoted, a flag in lower case."""
|
|
102
164
|
if isinstance(value, bool):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|