sqlty 0.1.0__cp315-cp315-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 +99 -0
- sqlty/__main__.py +5 -0
- sqlty/_breaking.py +191 -0
- sqlty/_check.py +880 -0
- sqlty/_cli.py +1219 -0
- sqlty/_config.py +666 -0
- sqlty/_core.cp315-win_amd64.pyd +0 -0
- sqlty/_core.pyi +108 -0
- sqlty/_decode.py +62 -0
- sqlty/_drivers.py +274 -0
- sqlty/_gitignore.py +58 -0
- sqlty/_helpers.py +613 -0
- sqlty/_json_shape.py +299 -0
- sqlty/_json_types.py +225 -0
- sqlty/_lint.py +350 -0
- sqlty/_params.py +177 -0
- sqlty/_psycopg.py +269 -0
- sqlty/_source.py +654 -0
- sqlty/_type_extractor/__init__.py +51 -0
- sqlty/_type_extractor/ast_fields.py +823 -0
- sqlty/_type_extractor/compat.py +252 -0
- sqlty/_type_extractor/extract.py +179 -0
- sqlty/_type_extractor/generic.py +95 -0
- sqlty/_type_extractor/info.py +161 -0
- sqlty/_type_extractor/sandbox.py +385 -0
- sqlty/_type_extractor/subtype.py +156 -0
- sqlty/_widening.py +89 -0
- sqlty/core.py +566 -0
- sqlty/drivers/psycopg.py +104 -0
- sqlty/magic.py +126 -0
- sqlty/py.typed +0 -0
- sqlty/rules/ambiguous-column.md +48 -0
- sqlty/rules/column-count-mismatch.md +30 -0
- sqlty/rules/column-name-mismatch.md +34 -0
- sqlty/rules/column-type-mismatch.md +75 -0
- sqlty/rules/fstring-skipped.md +23 -0
- sqlty/rules/inline-sql-comment.md +27 -0
- sqlty/rules/insert-missing-column.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/nullable-mismatch.md +76 -0
- sqlty/rules/param-count-mismatch.md +66 -0
- sqlty/rules/param-type-mismatch.md +73 -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 +77 -0
- sqlty/rules/stale-rewrite.md +33 -0
- sqlty/rules/stale-type.md +50 -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 +34 -0
- sqlty/rules/ungrouped-column.md +67 -0
- sqlty/rules/unknown-column.md +68 -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 +46 -0
- sqlty/rules/unsupported-syntax.md +69 -0
- sqlty/rules/untyped-query.md +39 -0
- sqlty/rules/unused-ignore.md +36 -0
- sqlty/skill/SKILL.md +110 -0
- sqlty-0.1.0.dist-info/METADATA +210 -0
- sqlty-0.1.0.dist-info/RECORD +71 -0
- sqlty-0.1.0.dist-info/WHEEL +4 -0
- sqlty-0.1.0.dist-info/entry_points.txt +2 -0
- sqlty-0.1.0.dist-info/licenses/LICENSE +21 -0
- sqlty-0.1.0.dist-info/sboms/sqlty-core.cyclonedx.json +1891 -0
- sqlty_magic.pth +1 -0
sqlty/_check.py
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
"""The Python checks of ``check_source``: row types (TypedDict, NamedTuple, dataclass,
|
|
2
|
+
Pydantic, tuples) and helper call parameters against the query columns.
|
|
3
|
+
|
|
4
|
+
``check_source`` in ``sqlty.core`` runs the engine's checks, then these passes over one
|
|
5
|
+
``_Check`` context, then the inline suppressions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import re
|
|
12
|
+
import typing
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
from typing import TYPE_CHECKING, NamedTuple, cast
|
|
15
|
+
|
|
16
|
+
from sqlty._core import analyze_columns as _analyze_columns
|
|
17
|
+
from sqlty._core import check_source as _check_source
|
|
18
|
+
from sqlty._core import composite_fields as _composite_fields
|
|
19
|
+
from sqlty._core import parse_error as _parse_error
|
|
20
|
+
from sqlty._core import query_diagnostics as _query_diagnostics
|
|
21
|
+
from sqlty._core import select_star_finding as _select_star_finding
|
|
22
|
+
from sqlty._core import wrapped_queries as _wrapped_queries
|
|
23
|
+
from sqlty._drivers import find_driver_calls
|
|
24
|
+
from sqlty._helpers import HelperIndex, find_helper_calls
|
|
25
|
+
from sqlty._lint import NAME_RULES, Diagnostic
|
|
26
|
+
from sqlty._params import mismatch
|
|
27
|
+
from sqlty._source import ParsedSource, is_any
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
|
|
32
|
+
from sqlty._core import Schema
|
|
33
|
+
from sqlty._helpers import HelperCall
|
|
34
|
+
from sqlty._lint import RuleConfig
|
|
35
|
+
from sqlty._type_extractor import TypeInfo
|
|
36
|
+
from sqlty.core import ParamInfo
|
|
37
|
+
|
|
38
|
+
# The `--sql` marker that opens a query string, as the engine reads it.
|
|
39
|
+
MARKER = re.compile(r"\s*--\s?sql(?:\s|$)")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_any_row(call: HelperCall) -> bool:
|
|
43
|
+
"""Whether the row type is ``typing.Any``: an explicit opt-out of checks."""
|
|
44
|
+
return call.row is not None and is_any(call.row, call.typing_aliases)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_CONTAINERS = (dict, list, set, frozenset, tuple)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _RowColumn(NamedTuple):
|
|
51
|
+
"""A result column with whether it can be NULL (``None`` when unknown), for row type checks."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
python_type: str | None
|
|
55
|
+
sql_type: str | None
|
|
56
|
+
nullable: bool | None
|
|
57
|
+
json_shape: str | None = None
|
|
58
|
+
"""The shape of the JSON the column builds, as the engine spells it (see ``_json_shape``)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RowUsage:
|
|
62
|
+
"""Which fields of the row types a run checks some query needs ``| None`` for.
|
|
63
|
+
|
|
64
|
+
``redundant-optional`` is a question about a row type, not a query: a field is
|
|
65
|
+
redundant only when no query that loads the type can return NULL for it.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self.needed: set[tuple[str | None, str, str]] = set()
|
|
70
|
+
self.redundant: list[tuple[tuple[str | None, str, str], Diagnostic]] = []
|
|
71
|
+
|
|
72
|
+
def filter(self, diagnostics: list[Diagnostic]) -> list[Diagnostic]:
|
|
73
|
+
"""``diagnostics`` without the ``redundant-optional`` reports another query refutes."""
|
|
74
|
+
refuted = {id(diag) for key, diag in self.redundant if key in self.needed}
|
|
75
|
+
return [diag for diag in diagnostics if id(diag) not in refuted]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Check:
|
|
79
|
+
"""Inputs, derived call sites and the ``report`` sink shared by the check passes."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
parsed: ParsedSource,
|
|
84
|
+
diagnostics: list[Diagnostic],
|
|
85
|
+
*,
|
|
86
|
+
dialect: str,
|
|
87
|
+
schema_sql: str | Schema | None,
|
|
88
|
+
rule_config: RuleConfig,
|
|
89
|
+
found: list[HelperCall],
|
|
90
|
+
driver_calls: list[HelperCall],
|
|
91
|
+
queries: list[tuple[int, str, str | None]],
|
|
92
|
+
type_modules: Iterable[str] | None,
|
|
93
|
+
helpers: HelperIndex | None,
|
|
94
|
+
module: str | None,
|
|
95
|
+
) -> None:
|
|
96
|
+
from sqlty._type_extractor import extract_type_definitions
|
|
97
|
+
|
|
98
|
+
self.parsed = parsed
|
|
99
|
+
self.diagnostics = diagnostics
|
|
100
|
+
self.dialect = dialect
|
|
101
|
+
self.schema_sql = schema_sql
|
|
102
|
+
self.rule_config = rule_config
|
|
103
|
+
self.found = found
|
|
104
|
+
self.driver_calls = driver_calls
|
|
105
|
+
self.calls = [call for call in found if call.query is not None]
|
|
106
|
+
self._unparsed: dict[str, str] = {}
|
|
107
|
+
self.rows = [self._unparse(call.row) if call.row else None for call in self.calls]
|
|
108
|
+
named = [
|
|
109
|
+
(call.line, row, _bound(call, dialect))
|
|
110
|
+
for call, row in zip(self.calls, self.rows, strict=True)
|
|
111
|
+
if row
|
|
112
|
+
]
|
|
113
|
+
self.type_defs: dict[str, TypeInfo] = (
|
|
114
|
+
extract_type_definitions(parsed, helpers, module, type_modules)
|
|
115
|
+
if queries or named
|
|
116
|
+
else {}
|
|
117
|
+
)
|
|
118
|
+
# The row types of the module an imported row type comes from, by local name.
|
|
119
|
+
self.imported_defs: dict[str, dict[str, TypeInfo]] = {}
|
|
120
|
+
self.row_usage = RowUsage()
|
|
121
|
+
if helpers is not None:
|
|
122
|
+
extra = frozenset(type_modules or ())
|
|
123
|
+
for name in {q[1] for q in [*queries, *named]} - self.type_defs.keys():
|
|
124
|
+
if (imported := helpers.row_type(parsed, name, extra)) is not None:
|
|
125
|
+
self.type_defs[name], self.imported_defs[name] = imported
|
|
126
|
+
# A row type named through an alias (`type Row = User`) is checked as what it names.
|
|
127
|
+
self.aliased: list[tuple[int, str, str | None, ast.expr]] = []
|
|
128
|
+
resolved = []
|
|
129
|
+
for line, name, query in [*queries, *named]:
|
|
130
|
+
target = _alias_target(name, parsed.type_aliases)
|
|
131
|
+
if target is None:
|
|
132
|
+
if name not in self.type_defs:
|
|
133
|
+
self._specialize(name)
|
|
134
|
+
resolved.append((line, name, query))
|
|
135
|
+
elif (base := ast.unparse(target)) in self.type_defs:
|
|
136
|
+
resolved.append((line, base, query))
|
|
137
|
+
else:
|
|
138
|
+
self.aliased.append((line, name, query, target))
|
|
139
|
+
self.user_queries = [q for q in resolved if q[1] in self.type_defs]
|
|
140
|
+
|
|
141
|
+
def _specialize(self, name: str) -> None:
|
|
142
|
+
"""Add ``Row[int]`` to the row types when ``Row`` is a generic class of this source."""
|
|
143
|
+
from sqlty._type_extractor import specialize
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
row = ast.parse(name, mode="eval").body
|
|
147
|
+
except SyntaxError:
|
|
148
|
+
return
|
|
149
|
+
if not (isinstance(row, ast.Subscript) and isinstance(row.value, ast.Name)):
|
|
150
|
+
return
|
|
151
|
+
info = self.type_defs.get(row.value.id)
|
|
152
|
+
node = next(
|
|
153
|
+
(c for c in self.parsed.classes if info is not None and c.lineno == info.lineno),
|
|
154
|
+
None,
|
|
155
|
+
)
|
|
156
|
+
if info is None or node is None:
|
|
157
|
+
return
|
|
158
|
+
args = list(row.slice.elts) if isinstance(row.slice, ast.Tuple) else [row.slice]
|
|
159
|
+
if (generic := specialize(info, node, args, self.parsed.typing_aliases)) is not None:
|
|
160
|
+
self.type_defs[name] = generic
|
|
161
|
+
|
|
162
|
+
def composite(self, sql_type: str) -> list[_RowColumn] | None:
|
|
163
|
+
"""The fields of the composite type ``sql_type`` as columns; None when it is not one."""
|
|
164
|
+
fields = _composite_fields(sql_type, dialect=self.dialect, schema_sql=self.schema_sql)
|
|
165
|
+
return None if fields is None else [_RowColumn(*field) for field in fields]
|
|
166
|
+
|
|
167
|
+
def _unparse(self, row: ast.expr) -> str:
|
|
168
|
+
"""``ast.unparse(row)``, once per distinct source span in the file."""
|
|
169
|
+
if (key := self.parsed.segment(row)) is None:
|
|
170
|
+
return ast.unparse(row)
|
|
171
|
+
if key not in self._unparsed:
|
|
172
|
+
self._unparsed[key] = ast.unparse(row)
|
|
173
|
+
return self._unparsed[key]
|
|
174
|
+
|
|
175
|
+
def report(self, rule: str, message: str, line: int) -> Diagnostic | None:
|
|
176
|
+
if not self.rule_config.is_enabled(rule):
|
|
177
|
+
return None
|
|
178
|
+
diagnostic: Diagnostic = {
|
|
179
|
+
"rule": rule,
|
|
180
|
+
"message": message,
|
|
181
|
+
"severity": self.rule_config.severity(rule).value,
|
|
182
|
+
"line": line,
|
|
183
|
+
}
|
|
184
|
+
self.diagnostics.append(diagnostic)
|
|
185
|
+
return diagnostic
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _check_unchecked_queries(ctx: _Check) -> None:
|
|
189
|
+
for call in ctx.found:
|
|
190
|
+
if call.query is None and not is_any_row(call):
|
|
191
|
+
ctx.report(
|
|
192
|
+
"unchecked-query",
|
|
193
|
+
"psycopg query is not built from literals only, so it is not checked",
|
|
194
|
+
call.line,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _check_params(ctx: _Check) -> None:
|
|
199
|
+
if not (
|
|
200
|
+
ctx.rule_config.is_enabled("param-type-mismatch")
|
|
201
|
+
or ctx.rule_config.is_enabled("param-count-mismatch")
|
|
202
|
+
):
|
|
203
|
+
return
|
|
204
|
+
for call in [*ctx.calls, *ctx.driver_calls]:
|
|
205
|
+
if call.params is not None:
|
|
206
|
+
for rule, message in _param_diagnostics(call, ctx.dialect, ctx.schema_sql):
|
|
207
|
+
ctx.report(rule, message, call.line)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _check_typeddict_inheritance(ctx: _Check) -> None:
|
|
211
|
+
if not ctx.rule_config.is_enabled("typeddict-inheritance"):
|
|
212
|
+
return
|
|
213
|
+
names = [name for _, name, _ in ctx.user_queries if name not in ctx.imported_defs]
|
|
214
|
+
for info in _typeddict_lineage(names, ctx.type_defs):
|
|
215
|
+
for message in info.errors:
|
|
216
|
+
ctx.report("typeddict-inheritance", message, info.lineno)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _check_select_star(ctx: _Check) -> None:
|
|
220
|
+
for call in ctx.calls:
|
|
221
|
+
if call.query and (
|
|
222
|
+
found := _select_star_finding(
|
|
223
|
+
call.query, dialect=ctx.dialect, has_schema=ctx.schema_sql is not None
|
|
224
|
+
)
|
|
225
|
+
):
|
|
226
|
+
ctx.report(*found, call.line)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _check_unparsed(ctx: _Check) -> None:
|
|
230
|
+
if not ctx.rule_config.is_enabled("unparsed-query"):
|
|
231
|
+
return
|
|
232
|
+
for call in ctx.calls:
|
|
233
|
+
if (
|
|
234
|
+
call.query is not None
|
|
235
|
+
and not MARKER.match(call.query)
|
|
236
|
+
and (error := _parse_error(call.query, dialect=ctx.dialect))
|
|
237
|
+
):
|
|
238
|
+
ctx.report("unparsed-query", f"query does not parse: {error}", call.line)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _check_names(ctx: _Check) -> None:
|
|
242
|
+
if not any(ctx.rule_config.is_enabled(name) for name in NAME_RULES):
|
|
243
|
+
return
|
|
244
|
+
for call in ctx.calls:
|
|
245
|
+
if call.query is None or MARKER.match(call.query):
|
|
246
|
+
continue
|
|
247
|
+
for rule, message in _query_diagnostics(
|
|
248
|
+
call.query, dialect=ctx.dialect, schema_sql=ctx.schema_sql
|
|
249
|
+
):
|
|
250
|
+
ctx.report(rule, message, call.line)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _check_structural(ctx: _Check) -> None:
|
|
254
|
+
"""Rows named by a type defined in this source (or an imported one)."""
|
|
255
|
+
for line, name, query in ctx.user_queries:
|
|
256
|
+
if (columns := _columns(query, ctx.dialect, ctx.schema_sql)) is None:
|
|
257
|
+
continue
|
|
258
|
+
info = ctx.type_defs[name]
|
|
259
|
+
defs = ctx.imported_defs.get(name, ctx.type_defs)
|
|
260
|
+
# A field this query can fill with NULL needs `| None`, whatever other queries do.
|
|
261
|
+
pairs = (
|
|
262
|
+
zip((field for field, _ in info.fields), columns, strict=False)
|
|
263
|
+
if info.kind == "NamedTuple"
|
|
264
|
+
else ((info.field_for(col.name), col) for col in columns)
|
|
265
|
+
)
|
|
266
|
+
for field, col in pairs:
|
|
267
|
+
if field is not None and col.nullable is not False:
|
|
268
|
+
ctx.row_usage.needed.add((info.module, info.name, field))
|
|
269
|
+
for rule, message in _structural_diagnostics(
|
|
270
|
+
name, info, columns, ctx.rule_config, defs, ctx.composite
|
|
271
|
+
):
|
|
272
|
+
diagnostic = ctx.report(rule, message, line)
|
|
273
|
+
if diagnostic is not None and rule == "redundant-optional":
|
|
274
|
+
field = message.split(" is ", 1)[0].removeprefix(f"{name}.")
|
|
275
|
+
ctx.row_usage.redundant.append(((info.module, info.name, field), diagnostic))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
_PSYCOPG_PLACEHOLDER = re.compile(r"%%|%\((?P<name>[^)]+)\)s|%s")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _bound(call: HelperCall, dialect: str) -> str | None:
|
|
282
|
+
"""The query of ``call`` with the placeholders it binds to non-``None`` values marked so.
|
|
283
|
+
|
|
284
|
+
A bare placeholder reads as a value that may be NULL, since a caller may bind ``None``.
|
|
285
|
+
Where the call passes an argument whose type excludes ``None`` (a literal, an
|
|
286
|
+
annotated variable), the placeholder is respelled as one whose nullability is not
|
|
287
|
+
judged, so the column or JSON member it fills is not reported as nullable.
|
|
288
|
+
"""
|
|
289
|
+
if call.query is None or not call.params:
|
|
290
|
+
return call.query
|
|
291
|
+
non_null = {arg.key for arg in call.params if arg.type not in (None, "None")}
|
|
292
|
+
if not non_null:
|
|
293
|
+
return call.query
|
|
294
|
+
marker = "$0" if dialect.lower() in ("postgres", "postgresql") else "?"
|
|
295
|
+
position = 0
|
|
296
|
+
|
|
297
|
+
def respell(match: re.Match[str]) -> str:
|
|
298
|
+
nonlocal position
|
|
299
|
+
if match[0] == "%%":
|
|
300
|
+
return match[0]
|
|
301
|
+
key: int | str
|
|
302
|
+
if match["name"] is not None:
|
|
303
|
+
key = match["name"]
|
|
304
|
+
else:
|
|
305
|
+
position += 1
|
|
306
|
+
key = position
|
|
307
|
+
return marker if key in non_null else match[0]
|
|
308
|
+
|
|
309
|
+
return _PSYCOPG_PLACEHOLDER.sub(respell, call.query)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _row_type_of(annotation: ast.expr | None, type_defs: Mapping[str, TypeInfo]) -> TypeInfo | None:
|
|
313
|
+
"""The row type in ``type_defs`` an annotation names, through ``X | None`` and ``Optional[X]``."""
|
|
314
|
+
match annotation:
|
|
315
|
+
case ast.BinOp(op=ast.BitOr(), left=left, right=ast.Constant(value=None)):
|
|
316
|
+
return _row_type_of(left, type_defs)
|
|
317
|
+
case ast.BinOp(op=ast.BitOr(), left=ast.Constant(value=None), right=right):
|
|
318
|
+
return _row_type_of(right, type_defs)
|
|
319
|
+
case ast.Subscript(value=value, slice=inner) if ast.unparse(value).endswith("Optional"):
|
|
320
|
+
return _row_type_of(inner, type_defs)
|
|
321
|
+
case ast.Name() | ast.Attribute():
|
|
322
|
+
return type_defs.get(ast.unparse(annotation))
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _check_aliases(ctx: _Check) -> None:
|
|
327
|
+
"""Rows named by a module-level alias of a tuple or scalar type (``type Row = tuple[int, str]``)."""
|
|
328
|
+
shapes: dict[str, _RowShape | None] = {}
|
|
329
|
+
for line, name, query, target in ctx.aliased:
|
|
330
|
+
if (columns := _columns(query, ctx.dialect, ctx.schema_sql)) is None:
|
|
331
|
+
continue
|
|
332
|
+
if name not in shapes:
|
|
333
|
+
shapes[name] = _row_shape(target, name, ctx.parsed.typing_aliases)
|
|
334
|
+
for rule, message in _value_diagnostics(
|
|
335
|
+
shapes[name], columns, ctx.rule_config, ctx.type_defs
|
|
336
|
+
):
|
|
337
|
+
ctx.report(rule, message, line)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _alias_target(name: str, aliases: Mapping[str, ast.expr]) -> ast.expr | None:
|
|
341
|
+
"""What the alias ``name`` stands for, through aliases of aliases; ``None`` for no alias."""
|
|
342
|
+
target = None
|
|
343
|
+
for _ in range(16):
|
|
344
|
+
if name not in aliases:
|
|
345
|
+
break
|
|
346
|
+
target = aliases[name]
|
|
347
|
+
name = ast.unparse(target)
|
|
348
|
+
return target
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _check_values(ctx: _Check) -> None:
|
|
352
|
+
"""Rows spelled as a tuple, ``list[...]`` etc. rather than a named type."""
|
|
353
|
+
shapes: dict[str, _RowShape | None] = {}
|
|
354
|
+
for call, row in zip(ctx.calls, ctx.rows, strict=True):
|
|
355
|
+
if call.row is None or row is None or row in ctx.type_defs:
|
|
356
|
+
continue
|
|
357
|
+
if _alias_target(row, ctx.parsed.type_aliases) is not None:
|
|
358
|
+
continue
|
|
359
|
+
if (columns := _columns(_bound(call, ctx.dialect), ctx.dialect, ctx.schema_sql)) is None:
|
|
360
|
+
continue
|
|
361
|
+
if row not in shapes:
|
|
362
|
+
shapes[row] = _row_shape(call.row, row, call.typing_aliases)
|
|
363
|
+
for rule, message in _value_diagnostics(
|
|
364
|
+
shapes[row], columns, ctx.rule_config, ctx.type_defs
|
|
365
|
+
):
|
|
366
|
+
ctx.report(rule, message, call.line)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
_CHECK_PASSES = (
|
|
370
|
+
_check_unchecked_queries,
|
|
371
|
+
_check_params,
|
|
372
|
+
_check_typeddict_inheritance,
|
|
373
|
+
_check_select_star,
|
|
374
|
+
_check_unparsed,
|
|
375
|
+
_check_names,
|
|
376
|
+
_check_structural,
|
|
377
|
+
_check_aliases,
|
|
378
|
+
_check_values,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def diagnostics(
|
|
383
|
+
parsed: ParsedSource,
|
|
384
|
+
*,
|
|
385
|
+
dialect: str,
|
|
386
|
+
schema_sql: str | Schema | None,
|
|
387
|
+
rules: dict[str, str] | None,
|
|
388
|
+
import_style: str | None,
|
|
389
|
+
untyped: bool,
|
|
390
|
+
helpers: HelperIndex | None,
|
|
391
|
+
module: str | None,
|
|
392
|
+
type_modules: Iterable[str] | None = None,
|
|
393
|
+
row_usage: RowUsage | None = None,
|
|
394
|
+
) -> list[Diagnostic]:
|
|
395
|
+
"""Every diagnostic before inline suppression: the Rust pass, then the Python passes."""
|
|
396
|
+
from sqlty._lint import RuleConfig
|
|
397
|
+
|
|
398
|
+
diagnostics: list[Diagnostic] = []
|
|
399
|
+
if parsed.may_need_rust_check(import_style):
|
|
400
|
+
diagnostics = cast(
|
|
401
|
+
"list[Diagnostic]",
|
|
402
|
+
_check_source(
|
|
403
|
+
parsed.text,
|
|
404
|
+
dialect=dialect,
|
|
405
|
+
schema_sql=schema_sql,
|
|
406
|
+
rules=rules,
|
|
407
|
+
import_style=import_style,
|
|
408
|
+
untyped=untyped,
|
|
409
|
+
any_calls=parsed.any_calls(import_style),
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
found = find_helper_calls(parsed, helpers, module)
|
|
413
|
+
driver_calls = find_driver_calls(parsed)
|
|
414
|
+
queries = [
|
|
415
|
+
(line, name, query)
|
|
416
|
+
for line, name, query in (
|
|
417
|
+
_wrapped_queries(parsed.text, import_style=import_style)
|
|
418
|
+
if parsed.may_wrap(import_style)
|
|
419
|
+
else []
|
|
420
|
+
)
|
|
421
|
+
if not name.startswith("tuple[")
|
|
422
|
+
]
|
|
423
|
+
if not (queries or found or driver_calls):
|
|
424
|
+
return diagnostics
|
|
425
|
+
ctx = _Check(
|
|
426
|
+
parsed,
|
|
427
|
+
diagnostics,
|
|
428
|
+
dialect=dialect,
|
|
429
|
+
schema_sql=schema_sql,
|
|
430
|
+
rule_config=RuleConfig(overrides=rules),
|
|
431
|
+
found=found,
|
|
432
|
+
driver_calls=driver_calls,
|
|
433
|
+
queries=queries,
|
|
434
|
+
type_modules=type_modules,
|
|
435
|
+
helpers=helpers,
|
|
436
|
+
module=module,
|
|
437
|
+
)
|
|
438
|
+
ctx.row_usage = row_usage or RowUsage()
|
|
439
|
+
for check_pass in _CHECK_PASSES:
|
|
440
|
+
check_pass(ctx)
|
|
441
|
+
return diagnostics
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _param_diagnostics(
|
|
445
|
+
call: HelperCall, dialect: str, schema_sql: str | Schema | None
|
|
446
|
+
) -> list[tuple[str, str]]:
|
|
447
|
+
"""``(rule, message)`` for the arguments a call passes for its query's placeholders."""
|
|
448
|
+
assert call.query is not None and call.params is not None
|
|
449
|
+
from sqlty.core import analyze_params
|
|
450
|
+
|
|
451
|
+
params = analyze_params(call.query, dialect=dialect, schema_sql=schema_sql)
|
|
452
|
+
if params is None:
|
|
453
|
+
return []
|
|
454
|
+
named = bool(params) and all(p.name is not None for p in params)
|
|
455
|
+
positional = all(p.name is None for p in params)
|
|
456
|
+
passed = {arg.key: arg for arg in call.params}
|
|
457
|
+
by_key: dict[int | str, ParamInfo] = {
|
|
458
|
+
(p.name if named and p.name else p.number): p for p in params
|
|
459
|
+
}
|
|
460
|
+
by_name = bool(passed) and all(isinstance(key, str) for key in passed)
|
|
461
|
+
if (named and passed and not by_name) or (positional and by_name):
|
|
462
|
+
return []
|
|
463
|
+
found: list[tuple[str, str]] = []
|
|
464
|
+
if positional and len(passed) != len(by_key):
|
|
465
|
+
found.append(
|
|
466
|
+
(
|
|
467
|
+
"param-count-mismatch",
|
|
468
|
+
f"query has {len(by_key)} parameter{'' if len(by_key) == 1 else 's'}, "
|
|
469
|
+
f"the call passes {len(passed)}",
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
elif named and (missing := [f"'{key}'" for key in by_key if key not in passed]):
|
|
473
|
+
found.append(
|
|
474
|
+
(
|
|
475
|
+
"param-count-mismatch",
|
|
476
|
+
f"call is missing parameter{'' if len(missing) == 1 else 's'} {', '.join(missing)}",
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
# sqlite3 is dynamically typed, and a cast converts whatever it is given.
|
|
480
|
+
typed = dialect.lower() != "sqlite"
|
|
481
|
+
postgres = dialect.lower() in ("postgres", "postgresql")
|
|
482
|
+
for key, arg in passed.items():
|
|
483
|
+
if (param := by_key.get(key)) is None:
|
|
484
|
+
continue
|
|
485
|
+
label = f"parameter {key}" if isinstance(key, int) else f"parameter '{key}'"
|
|
486
|
+
if arg.type == "None" or (typed and not param.cast):
|
|
487
|
+
if problem := mismatch(param.python_type, param.nullable, arg.type, postgres=postgres):
|
|
488
|
+
found.append(("param-type-mismatch", f"{label} {problem}"))
|
|
489
|
+
return found
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _columns(
|
|
493
|
+
query: str | None, dialect: str, schema_sql: str | Schema | None
|
|
494
|
+
) -> list[_RowColumn] | None:
|
|
495
|
+
"""Columns of a query and whether each can be NULL; ``None`` when it has none or cannot be analysed."""
|
|
496
|
+
if query is None:
|
|
497
|
+
return None
|
|
498
|
+
try:
|
|
499
|
+
columns = _analyze_columns(query, dialect=dialect, schema_sql=schema_sql)
|
|
500
|
+
except RuntimeError:
|
|
501
|
+
return None
|
|
502
|
+
return [_RowColumn(*column) for column in columns] if columns else None
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _nullability_diagnostics(
|
|
506
|
+
label: str,
|
|
507
|
+
declared: typing.Any,
|
|
508
|
+
col: _RowColumn,
|
|
509
|
+
rules: RuleConfig,
|
|
510
|
+
item: str = "query column",
|
|
511
|
+
) -> list[tuple[str, str]]:
|
|
512
|
+
"""``nullable-mismatch`` and ``redundant-optional`` for one field and its column.
|
|
513
|
+
|
|
514
|
+
``label`` reads ``<field>`` or ``<row> item <i>``, and is followed by ``is <annotation>``;
|
|
515
|
+
``declared`` is the resolved annotation. A column of unknown nullability, or an
|
|
516
|
+
annotation that can't be resolved, is never reported.
|
|
517
|
+
"""
|
|
518
|
+
from sqlty._type_extractor import admits_none, is_optional
|
|
519
|
+
from sqlty._type_extractor.subtype import _type_repr
|
|
520
|
+
|
|
521
|
+
if col.nullable is None or declared is None:
|
|
522
|
+
return []
|
|
523
|
+
if col.nullable and rules.is_enabled("nullable-mismatch") and admits_none(declared) is False:
|
|
524
|
+
return [
|
|
525
|
+
(
|
|
526
|
+
"nullable-mismatch",
|
|
527
|
+
f"{label} is {_type_repr(declared)} but {item} '{col.name}' can be NULL",
|
|
528
|
+
)
|
|
529
|
+
]
|
|
530
|
+
if not col.nullable and rules.is_enabled("redundant-optional") and is_optional(declared):
|
|
531
|
+
return [
|
|
532
|
+
(
|
|
533
|
+
"redundant-optional",
|
|
534
|
+
f"{label} is {_type_repr(declared)} but {item} '{col.name}' is never NULL",
|
|
535
|
+
)
|
|
536
|
+
]
|
|
537
|
+
return []
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _json_diagnostics(
|
|
541
|
+
label: str,
|
|
542
|
+
annotation: ast.expr | None,
|
|
543
|
+
col: _RowColumn,
|
|
544
|
+
type_defs: Mapping[str, TypeInfo],
|
|
545
|
+
aliases: Mapping[str, str],
|
|
546
|
+
rules: RuleConfig,
|
|
547
|
+
) -> list[tuple[str, str]]:
|
|
548
|
+
"""``json-shape-mismatch`` for one field and a JSON column of known shape.
|
|
549
|
+
|
|
550
|
+
``label`` reads ``<field>`` or ``<row> item <i>`` and is followed by ``is <annotation>``.
|
|
551
|
+
An annotation that can't be read is never reported.
|
|
552
|
+
"""
|
|
553
|
+
from sqlty._json_shape import json_problems, summarize
|
|
554
|
+
|
|
555
|
+
if col.json_shape is None or annotation is None or not rules.is_enabled("json-shape-mismatch"):
|
|
556
|
+
return []
|
|
557
|
+
problems = json_problems(col.json_shape, annotation, type_defs, aliases, col.name)
|
|
558
|
+
if not problems:
|
|
559
|
+
return []
|
|
560
|
+
return [
|
|
561
|
+
(
|
|
562
|
+
"json-shape-mismatch",
|
|
563
|
+
f"{label} is {ast.unparse(annotation)} but query column '{col.name}' builds JSON "
|
|
564
|
+
f"that does not fit: {summarize(problems)}",
|
|
565
|
+
)
|
|
566
|
+
]
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _literal_diagnostics(
|
|
570
|
+
label: str,
|
|
571
|
+
declared: typing.Any,
|
|
572
|
+
col: _RowColumn,
|
|
573
|
+
rules: RuleConfig,
|
|
574
|
+
item: str = "query column",
|
|
575
|
+
) -> list[tuple[str, str]]:
|
|
576
|
+
"""``literal-mismatch`` and ``redundant-literal`` for one field and its column.
|
|
577
|
+
|
|
578
|
+
Reported only when the annotation is made of ``Literal`` members (and ``None``) and the
|
|
579
|
+
column's values are known: ``str``, ``int``, ``Any`` or an enum take any value, so
|
|
580
|
+
there is nothing to compare.
|
|
581
|
+
"""
|
|
582
|
+
from sqlty._type_extractor import column_literal_values, declared_literal_values
|
|
583
|
+
from sqlty._type_extractor.subtype import _type_repr
|
|
584
|
+
|
|
585
|
+
if declared is None or not col.python_type:
|
|
586
|
+
return []
|
|
587
|
+
inferred = column_literal_values(col.python_type)
|
|
588
|
+
if inferred is None:
|
|
589
|
+
return []
|
|
590
|
+
admitted = declared_literal_values(declared, inferred)
|
|
591
|
+
if admitted is None:
|
|
592
|
+
return []
|
|
593
|
+
missing = [v for v in inferred if v not in admitted]
|
|
594
|
+
if missing and rules.is_enabled("literal-mismatch"):
|
|
595
|
+
return [
|
|
596
|
+
(
|
|
597
|
+
"literal-mismatch",
|
|
598
|
+
f"{label} is {_type_repr(declared)} but {item} '{col.name}' "
|
|
599
|
+
f"can be {', '.join(map(repr, missing))}",
|
|
600
|
+
)
|
|
601
|
+
]
|
|
602
|
+
extra = sorted((v for v in admitted if v not in inferred), key=repr)
|
|
603
|
+
if not missing and extra and rules.is_enabled("redundant-literal"):
|
|
604
|
+
return [
|
|
605
|
+
(
|
|
606
|
+
"redundant-literal",
|
|
607
|
+
f"{label} is {_type_repr(declared)} but {item} '{col.name}' "
|
|
608
|
+
f"is never {', '.join(map(repr, extra))}",
|
|
609
|
+
)
|
|
610
|
+
]
|
|
611
|
+
return []
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _field_diagnostics(
|
|
615
|
+
label: str,
|
|
616
|
+
declared: typing.Any,
|
|
617
|
+
col: _RowColumn,
|
|
618
|
+
rules: RuleConfig,
|
|
619
|
+
item: str = "query column",
|
|
620
|
+
) -> list[tuple[str, str]]:
|
|
621
|
+
"""Nullability and value-set diagnostics for one field and its column (or composite field)."""
|
|
622
|
+
return _nullability_diagnostics(label, declared, col, rules, item) + _literal_diagnostics(
|
|
623
|
+
label, declared, col, rules, item
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
class _RowShape(NamedTuple):
|
|
628
|
+
"""The row type of helper calls, resolved once per file for every call that spells it."""
|
|
629
|
+
|
|
630
|
+
text: str
|
|
631
|
+
is_tuple: bool
|
|
632
|
+
targets: list[tuple[str, str, typing.Any, ast.expr]]
|
|
633
|
+
accepts: dict[tuple[int, str], bool]
|
|
634
|
+
aliases: Mapping[str, str]
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _row_shape(row: ast.expr, text: str, aliases: dict[str, str]) -> _RowShape | None:
|
|
638
|
+
"""What each column of a helper call's row type ``text`` must be; ``None`` when unchecked.
|
|
639
|
+
|
|
640
|
+
A scalar type takes one column, ``tuple[A, B]`` one column per item. Other
|
|
641
|
+
row types (an unknown class, ``dict``, ``tuple[int, ...]``) are not checked.
|
|
642
|
+
"""
|
|
643
|
+
from sqlty._type_extractor.ast_fields import _ast_type
|
|
644
|
+
|
|
645
|
+
if isinstance(row, ast.Subscript) and _ast_type(row.value, aliases) is tuple:
|
|
646
|
+
items = list(row.slice.elts) if isinstance(row.slice, ast.Tuple) else [row.slice]
|
|
647
|
+
if not items or any(isinstance(i, ast.Constant) and i.value is ... for i in items):
|
|
648
|
+
return None
|
|
649
|
+
annotations = [ast.unparse(item) for item in items]
|
|
650
|
+
targets = [
|
|
651
|
+
(f"{text} item {i} is {annotation}", annotation, _ast_type(item, aliases), item)
|
|
652
|
+
for i, (item, annotation) in enumerate(zip(items, annotations, strict=True))
|
|
653
|
+
]
|
|
654
|
+
return _RowShape(text, True, targets, {}, aliases)
|
|
655
|
+
resolved = _ast_type(row, aliases)
|
|
656
|
+
if resolved is None or resolved in _CONTAINERS or typing.get_args(resolved):
|
|
657
|
+
return None
|
|
658
|
+
return _RowShape(text, False, [(f"{text} is expected", text, resolved, row)], {}, aliases)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _value_diagnostics(
|
|
662
|
+
shape: _RowShape | None,
|
|
663
|
+
columns: list[_RowColumn],
|
|
664
|
+
rules: RuleConfig,
|
|
665
|
+
type_defs: Mapping[str, TypeInfo],
|
|
666
|
+
) -> list[tuple[str, str]]:
|
|
667
|
+
"""Compare the row type of a helper call with the query columns."""
|
|
668
|
+
from sqlty._type_extractor.compat import _accepts
|
|
669
|
+
|
|
670
|
+
if shape is None:
|
|
671
|
+
return []
|
|
672
|
+
if len(shape.targets) != len(columns):
|
|
673
|
+
takes = f"has {len(shape.targets)} items" if shape.is_tuple else "takes 1 column"
|
|
674
|
+
return [
|
|
675
|
+
(
|
|
676
|
+
"column-count-mismatch",
|
|
677
|
+
f"{shape.text} {takes} but query returns {len(columns)} columns",
|
|
678
|
+
)
|
|
679
|
+
]
|
|
680
|
+
found = []
|
|
681
|
+
for i, ((label, annotation, resolved, node), col) in enumerate(
|
|
682
|
+
zip(shape.targets, columns, strict=True)
|
|
683
|
+
):
|
|
684
|
+
item = f"{shape.text} item {i}" if shape.is_tuple else "row type"
|
|
685
|
+
found.extend(_json_diagnostics(item, node, col, type_defs, shape.aliases, rules))
|
|
686
|
+
if resolved is None or not col.python_type:
|
|
687
|
+
continue
|
|
688
|
+
key = (i, col.python_type)
|
|
689
|
+
if key not in shape.accepts:
|
|
690
|
+
shape.accepts[key] = _accepts(resolved, annotation, col.python_type)
|
|
691
|
+
if not shape.accepts[key]:
|
|
692
|
+
found.append(
|
|
693
|
+
(
|
|
694
|
+
"column-type-mismatch",
|
|
695
|
+
f"{label} but query column '{col.name}' infers {col.python_type}",
|
|
696
|
+
)
|
|
697
|
+
)
|
|
698
|
+
else:
|
|
699
|
+
found.extend(_field_diagnostics(item, resolved, col, rules))
|
|
700
|
+
return found
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def apply_suppressions(
|
|
704
|
+
parsed: ParsedSource,
|
|
705
|
+
diagnostics: list[Diagnostic],
|
|
706
|
+
rules: dict[str, str] | None,
|
|
707
|
+
) -> list[Diagnostic]:
|
|
708
|
+
"""Drop diagnostics suppressed by a comment on their line; add ``unused-ignore``."""
|
|
709
|
+
from sqlty._lint import RuleConfig
|
|
710
|
+
|
|
711
|
+
ignores = parsed.ignores
|
|
712
|
+
if not ignores:
|
|
713
|
+
return diagnostics
|
|
714
|
+
|
|
715
|
+
kept: list[Diagnostic] = []
|
|
716
|
+
used: set[tuple[int, str | None]] = set()
|
|
717
|
+
for diag in diagnostics:
|
|
718
|
+
ignore = ignores.get(diag["line"])
|
|
719
|
+
if ignore is None or (ignore.rules is not None and diag["rule"] not in ignore.rules):
|
|
720
|
+
kept.append(diag)
|
|
721
|
+
else:
|
|
722
|
+
used.add((ignore.line, None if ignore.rules is None else diag["rule"]))
|
|
723
|
+
|
|
724
|
+
rule_config = RuleConfig(overrides=rules)
|
|
725
|
+
if rule_config.is_enabled("unused-ignore"):
|
|
726
|
+
severity = rule_config.severity("unused-ignore").value
|
|
727
|
+
for ignore in ignores.values():
|
|
728
|
+
unused: list[str | None]
|
|
729
|
+
if ignore.rules is None:
|
|
730
|
+
unused = [None] if (ignore.line, None) not in used else []
|
|
731
|
+
else:
|
|
732
|
+
unused = [name for name in ignore.rules if (ignore.line, name) not in used]
|
|
733
|
+
for name in unused:
|
|
734
|
+
written = "# sqlty: ignore" if name is None else f"# sqlty: ignore[{name}]"
|
|
735
|
+
kept.append(
|
|
736
|
+
{
|
|
737
|
+
"rule": "unused-ignore",
|
|
738
|
+
"message": f"'{written}' suppresses no diagnostic",
|
|
739
|
+
"severity": severity,
|
|
740
|
+
"line": ignore.line,
|
|
741
|
+
}
|
|
742
|
+
)
|
|
743
|
+
return kept
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _typeddict_lineage(names: list[str], type_defs: dict[str, TypeInfo]) -> list[TypeInfo]:
|
|
747
|
+
"""``names`` and their same-source TypedDict bases, bases first, each once."""
|
|
748
|
+
seen: dict[str, TypeInfo] = {}
|
|
749
|
+
|
|
750
|
+
def visit(name: str) -> None:
|
|
751
|
+
if name in seen or (info := type_defs.get(name)) is None:
|
|
752
|
+
return
|
|
753
|
+
for base in info.bases:
|
|
754
|
+
visit(base)
|
|
755
|
+
seen[name] = info
|
|
756
|
+
|
|
757
|
+
for name in names:
|
|
758
|
+
visit(name)
|
|
759
|
+
return list(seen.values())
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _structural_diagnostics(
|
|
763
|
+
type_name: str,
|
|
764
|
+
info: TypeInfo,
|
|
765
|
+
columns: list[_RowColumn],
|
|
766
|
+
rules: RuleConfig,
|
|
767
|
+
type_defs: Mapping[str, TypeInfo],
|
|
768
|
+
composite: Callable[[str], list[_RowColumn] | None] | None = None,
|
|
769
|
+
source: str = "query",
|
|
770
|
+
) -> list[tuple[str, str]]:
|
|
771
|
+
"""Compare a user type with query columns, returning ``(rule, message)`` pairs.
|
|
772
|
+
|
|
773
|
+
TypedDicts, dataclasses and Pydantic models match columns to fields by
|
|
774
|
+
name, as ``dict_row`` and ``class_row`` pass them: fields with a default
|
|
775
|
+
(``NotRequired`` keys) may be absent, and a column without a field is a
|
|
776
|
+
violation, unless a Pydantic model allows it (``extra="allow"``) or
|
|
777
|
+
``extra_items`` admits it (type-checked against it). Key-set violations are
|
|
778
|
+
``typeddict-key-mismatch``. NamedTuples match by position.
|
|
779
|
+
A reported count mismatch suppresses the per-column checks. A column that builds JSON
|
|
780
|
+
of a known shape is also checked against the field's annotation, nested types looked up
|
|
781
|
+
in ``type_defs`` (``json-shape-mismatch``). A column of a composite type whose field is
|
|
782
|
+
annotated with a row type in ``type_defs`` is compared with the fields ``composite``
|
|
783
|
+
gives for its SQL type, the same way.
|
|
784
|
+
"""
|
|
785
|
+
from sqlty._type_extractor import check_type_compatibility, field_type
|
|
786
|
+
|
|
787
|
+
fields = info.fields
|
|
788
|
+
declared = dict(fields)
|
|
789
|
+
item = "column" if source == "query" else "field"
|
|
790
|
+
counted = (
|
|
791
|
+
f"{source} returns {len(columns)} columns"
|
|
792
|
+
if source == "query"
|
|
793
|
+
else f"{source} has {len(columns)} fields"
|
|
794
|
+
)
|
|
795
|
+
by_name = info.kind != "NamedTuple"
|
|
796
|
+
by_alias = info.kind == "BaseModel"
|
|
797
|
+
unit = {"TypedDict": "keys", "Protocol": "attributes"}.get(info.kind, "fields")
|
|
798
|
+
open_ended = bool(info.extra_items) or by_alias or info.allows_extra
|
|
799
|
+
lo = len(info.required - info.opaque) if by_name else len(fields)
|
|
800
|
+
hi = len(columns) if open_ended else len(fields)
|
|
801
|
+
if not lo <= len(columns) <= hi and rules.is_enabled("column-count-mismatch"):
|
|
802
|
+
expected = f"{len(fields)} fields"
|
|
803
|
+
if open_ended:
|
|
804
|
+
expected = f"{lo} required {unit}"
|
|
805
|
+
elif lo < hi:
|
|
806
|
+
expected = f"{lo} required and {hi} total {unit}"
|
|
807
|
+
return [
|
|
808
|
+
(
|
|
809
|
+
"column-count-mismatch",
|
|
810
|
+
f"{type_name} has {expected} but {counted}",
|
|
811
|
+
)
|
|
812
|
+
]
|
|
813
|
+
|
|
814
|
+
def type_mismatch(field: str, col: _RowColumn) -> list[tuple[str, str]]:
|
|
815
|
+
declared_type = declared.get(field) or f"extra_items {info.extra_items}"
|
|
816
|
+
if col.python_type and not check_type_compatibility(info, field, col.python_type):
|
|
817
|
+
return [
|
|
818
|
+
(
|
|
819
|
+
"column-type-mismatch",
|
|
820
|
+
f"{type_name}.{field} is {declared_type} but {source} {item} "
|
|
821
|
+
f"'{col.name}' infers {col.python_type}",
|
|
822
|
+
)
|
|
823
|
+
]
|
|
824
|
+
found = _field_diagnostics(
|
|
825
|
+
f"{type_name}.{field}", field_type(info, field), col, rules, f"{source} {item}"
|
|
826
|
+
) + _json_diagnostics(
|
|
827
|
+
f"{type_name}.{field}", info.annotations.get(field), col, type_defs, info.aliases, rules
|
|
828
|
+
)
|
|
829
|
+
nested = _row_type_of(info.annotations.get(field), type_defs)
|
|
830
|
+
if col.python_type is None and col.sql_type and composite and nested:
|
|
831
|
+
if (fields := composite(col.sql_type)) is not None:
|
|
832
|
+
found += _structural_diagnostics(
|
|
833
|
+
f"{type_name}.{field}",
|
|
834
|
+
nested,
|
|
835
|
+
fields,
|
|
836
|
+
rules,
|
|
837
|
+
type_defs,
|
|
838
|
+
composite,
|
|
839
|
+
f"composite {col.sql_type.lower()}",
|
|
840
|
+
)
|
|
841
|
+
return found
|
|
842
|
+
|
|
843
|
+
if not by_name:
|
|
844
|
+
pairs = list(zip(fields, columns, strict=False))
|
|
845
|
+
return [d for (field, _), col in pairs for d in type_mismatch(field, col)] + [
|
|
846
|
+
(
|
|
847
|
+
"column-name-mismatch",
|
|
848
|
+
f"{type_name}.{field} doesn't match {source} {item} '{col.name}'",
|
|
849
|
+
)
|
|
850
|
+
for (field, _), col in pairs
|
|
851
|
+
if field != col.name
|
|
852
|
+
]
|
|
853
|
+
|
|
854
|
+
admits_extra = info.extra_items or info.allows_extra or info.opaque
|
|
855
|
+
owners = [(col, info.field_for(col.name)) for col in columns]
|
|
856
|
+
known = [(col, field or col.name) for col, field in owners if field or info.extra_items]
|
|
857
|
+
unknown = [col for col, field in owners if field is None and not admits_extra]
|
|
858
|
+
names = {col.name for col in columns}
|
|
859
|
+
missing = [
|
|
860
|
+
info.column_names(key)[0]
|
|
861
|
+
for key, _ in fields
|
|
862
|
+
if key in info.required
|
|
863
|
+
and key not in info.opaque
|
|
864
|
+
and not names.intersection(info.column_names(key))
|
|
865
|
+
]
|
|
866
|
+
noun = {"TypedDict": "key", "Protocol": "attribute"}.get(info.kind, "field")
|
|
867
|
+
return (
|
|
868
|
+
[d for col, field in known for d in type_mismatch(field, col)]
|
|
869
|
+
+ [
|
|
870
|
+
(
|
|
871
|
+
"typeddict-key-mismatch",
|
|
872
|
+
f"{type_name} has no {noun} for {source} {item} '{col.name}'",
|
|
873
|
+
)
|
|
874
|
+
for col in unknown
|
|
875
|
+
]
|
|
876
|
+
+ [
|
|
877
|
+
("typeddict-key-mismatch", f"{type_name}.{key} is required but missing from {source}")
|
|
878
|
+
for key in missing
|
|
879
|
+
]
|
|
880
|
+
)
|