sqlty 0.1.0__cp315-cp315-win_arm64.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.
Files changed (71) hide show
  1. sqlty/__init__.py +99 -0
  2. sqlty/__main__.py +5 -0
  3. sqlty/_breaking.py +191 -0
  4. sqlty/_check.py +880 -0
  5. sqlty/_cli.py +1219 -0
  6. sqlty/_config.py +666 -0
  7. sqlty/_core.cp315-win_arm64.pyd +0 -0
  8. sqlty/_core.pyi +108 -0
  9. sqlty/_decode.py +62 -0
  10. sqlty/_drivers.py +274 -0
  11. sqlty/_gitignore.py +58 -0
  12. sqlty/_helpers.py +613 -0
  13. sqlty/_json_shape.py +299 -0
  14. sqlty/_json_types.py +225 -0
  15. sqlty/_lint.py +350 -0
  16. sqlty/_params.py +177 -0
  17. sqlty/_psycopg.py +269 -0
  18. sqlty/_source.py +654 -0
  19. sqlty/_type_extractor/__init__.py +51 -0
  20. sqlty/_type_extractor/ast_fields.py +823 -0
  21. sqlty/_type_extractor/compat.py +252 -0
  22. sqlty/_type_extractor/extract.py +179 -0
  23. sqlty/_type_extractor/generic.py +95 -0
  24. sqlty/_type_extractor/info.py +161 -0
  25. sqlty/_type_extractor/sandbox.py +385 -0
  26. sqlty/_type_extractor/subtype.py +156 -0
  27. sqlty/_widening.py +89 -0
  28. sqlty/core.py +566 -0
  29. sqlty/drivers/psycopg.py +104 -0
  30. sqlty/magic.py +126 -0
  31. sqlty/py.typed +0 -0
  32. sqlty/rules/ambiguous-column.md +48 -0
  33. sqlty/rules/column-count-mismatch.md +30 -0
  34. sqlty/rules/column-name-mismatch.md +34 -0
  35. sqlty/rules/column-type-mismatch.md +75 -0
  36. sqlty/rules/fstring-skipped.md +23 -0
  37. sqlty/rules/inline-sql-comment.md +27 -0
  38. sqlty/rules/insert-missing-column.md +58 -0
  39. sqlty/rules/json-shape-mismatch.md +96 -0
  40. sqlty/rules/literal-mismatch.md +83 -0
  41. sqlty/rules/mysql-sql-marker.md +32 -0
  42. sqlty/rules/nullable-mismatch.md +76 -0
  43. sqlty/rules/param-count-mismatch.md +66 -0
  44. sqlty/rules/param-type-mismatch.md +73 -0
  45. sqlty/rules/redundant-literal.md +68 -0
  46. sqlty/rules/redundant-optional.md +100 -0
  47. sqlty/rules/select-star-no-schema.md +24 -0
  48. sqlty/rules/select-star.md +77 -0
  49. sqlty/rules/stale-rewrite.md +33 -0
  50. sqlty/rules/stale-type.md +50 -0
  51. sqlty/rules/tstring-skipped.md +68 -0
  52. sqlty/rules/typeddict-inheritance.md +35 -0
  53. sqlty/rules/typeddict-key-mismatch.md +38 -0
  54. sqlty/rules/unchecked-query.md +34 -0
  55. sqlty/rules/ungrouped-column.md +67 -0
  56. sqlty/rules/unknown-column.md +68 -0
  57. sqlty/rules/unknown-function.md +49 -0
  58. sqlty/rules/unknown-table.md +47 -0
  59. sqlty/rules/unknown-type.md +74 -0
  60. sqlty/rules/unparsed-query.md +46 -0
  61. sqlty/rules/unsupported-syntax.md +69 -0
  62. sqlty/rules/untyped-query.md +39 -0
  63. sqlty/rules/unused-ignore.md +36 -0
  64. sqlty/skill/SKILL.md +110 -0
  65. sqlty-0.1.0.dist-info/METADATA +210 -0
  66. sqlty-0.1.0.dist-info/RECORD +71 -0
  67. sqlty-0.1.0.dist-info/WHEEL +4 -0
  68. sqlty-0.1.0.dist-info/entry_points.txt +2 -0
  69. sqlty-0.1.0.dist-info/licenses/LICENSE +21 -0
  70. sqlty-0.1.0.dist-info/sboms/sqlty-core.cyclonedx.json +1891 -0
  71. 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
+ )