bmsdna-devtools 0.32.0__tar.gz → 0.34.0__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.
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/PKG-INFO +8 -3
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/README.md +7 -2
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/cli.py +2 -1
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/find_injection.py +1 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint_sql.py +123 -4
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/skills/bmsdna-devtools/SKILL.md +1 -1
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_lint_sql.py +231 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/uv.lock +1 -1
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/.gitignore +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/.python-version +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/_api_dump.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/api_usage.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/dead_code.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/env_files.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/issue_do.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint_sql_files.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/pr_info.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/translate.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/justfile +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_api_usage.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_env_files.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_find_injection.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_issue_do.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_lint_sql_files.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pr_info.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_translate.py +0 -0
- {bmsdna_devtools-0.32.0 → bmsdna_devtools-0.34.0}/tests/test_worktree_clean.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.34.0
|
|
4
4
|
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
@@ -630,7 +630,8 @@ bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-c
|
|
|
630
630
|
bdt lint --no-tooling-check # skip the tooling-config check for this run
|
|
631
631
|
```
|
|
632
632
|
|
|
633
|
-
Every `.execute()`/`.executemany()` call
|
|
633
|
+
Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
|
|
634
|
+
`PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
|
|
634
635
|
literal or f-string/concatenation/`%`-format expression is checked (an
|
|
635
636
|
unresolvable argument, e.g. a plain function parameter, is silently skipped --
|
|
636
637
|
this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
|
|
@@ -647,6 +648,10 @@ it can't see through):
|
|
|
647
648
|
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
648
649
|
instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
|
|
649
650
|
usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
|
|
651
|
+
- **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
|
|
652
|
+
sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
|
|
653
|
+
The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
|
|
654
|
+
Use `exp.column()`/`exp.to_identifier()` for names and `exp.Placeholder` + bound params for values.
|
|
650
655
|
- **`sql-positional-param`** — positional `%s` instead of named `%(name)s`.
|
|
651
656
|
- **`sql-forbidden-join`** — `RIGHT JOIN`/`LATERAL JOIN`/`CROSS APPLY` (same
|
|
652
657
|
patterns the `prek` skill's `check_files.py` forbids in `.sql` files).
|
|
@@ -830,7 +835,7 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
|
|
|
830
835
|
(depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
|
|
831
836
|
code 0 unless `--strict`).
|
|
832
837
|
|
|
833
|
-
- **SQL** (Python `.execute()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
838
|
+
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
834
839
|
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
|
|
835
840
|
a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
|
|
836
841
|
call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
|
|
@@ -616,7 +616,8 @@ bdt lint backend/db/a.py b.py # scan only these files -- e.g. from a prek/pre-c
|
|
|
616
616
|
bdt lint --no-tooling-check # skip the tooling-config check for this run
|
|
617
617
|
```
|
|
618
618
|
|
|
619
|
-
Every `.execute()`/`.executemany()` call
|
|
619
|
+
Every `.execute()`/`.executemany()` call -- and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
|
|
620
|
+
`PostgresJsonResponse`, which only accept a literal string, sqlglot expression, `psycopg.sql` or t-string -- whose SQL argument can be resolved to a
|
|
620
621
|
literal or f-string/concatenation/`%`-format expression is checked (an
|
|
621
622
|
unresolvable argument, e.g. a plain function parameter, is silently skipped --
|
|
622
623
|
this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
|
|
@@ -633,6 +634,10 @@ it can't see through):
|
|
|
633
634
|
with an f-string, `+` concatenation, the `%` operator, or `str.format()`
|
|
634
635
|
instead of a psycopg t-string (3.14+), `psycopg.sql`, or bound params. For an f-string the fix is
|
|
635
636
|
usually just `f"..."` -> `t"..."` (`{value}` is bound, `{name:i}` quotes an identifier).
|
|
637
|
+
- **`sql-sqlglot-string-injection`** — an f-string / concatenation / `%` / `.format()` string passed to a
|
|
638
|
+
sqlglot builder that parses it as SQL (`select(...)`, `.from_()`, `.where()`, `.order_by()`, `parse_one()`, ...).
|
|
639
|
+
The result is a sqlglot expression, which the rules above otherwise trust, so this is the hole they'd miss.
|
|
640
|
+
Use `exp.column()`/`exp.to_identifier()` for names and `exp.Placeholder` + bound params for values.
|
|
636
641
|
- **`sql-positional-param`** — positional `%s` instead of named `%(name)s`.
|
|
637
642
|
- **`sql-forbidden-join`** — `RIGHT JOIN`/`LATERAL JOIN`/`CROSS APPLY` (same
|
|
638
643
|
patterns the `prek` skill's `check_files.py` forbids in `.sql` files).
|
|
@@ -816,7 +821,7 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
|
|
|
816
821
|
(depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
|
|
817
822
|
code 0 unless `--strict`).
|
|
818
823
|
|
|
819
|
-
- **SQL** (Python `.execute()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
824
|
+
- **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
|
|
820
825
|
SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
|
|
821
826
|
a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
|
|
822
827
|
call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
|
|
@@ -1092,7 +1092,8 @@ def lint(
|
|
|
1092
1092
|
"[tool.bdt.lint] skip_tooling_check = true in pyproject.toml.",
|
|
1093
1093
|
),
|
|
1094
1094
|
) -> None:
|
|
1095
|
-
"""Static checks (bmsuisse/skills#52): postgres/psycopg SQL rules on every `.execute()` call
|
|
1095
|
+
"""Static checks (bmsuisse/skills#52): postgres/psycopg SQL rules on every `.execute()` call and pgdevkit
|
|
1096
|
+
`fetch_all`/`fetch_one`/`fetch_scalar`/`PostgresJsonResponse` call
|
|
1096
1097
|
(must use load_sql()/a .sql file, a t-string, or psycopg.sql for anything beyond a trivial
|
|
1097
1098
|
query; never an f-string/concatenation/`%`-formatting), pydantic-model placement under api/
|
|
1098
1099
|
directories, hand-wired HTTP in TypeScript, and that the repo declares/configures ty, ruff, pytest and prek.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""AST rule engine for `bdt lint`'s postgres/psycopg checks (bmsuisse/skills#52):
|
|
2
|
-
every `.execute()`/`.executemany()` call
|
|
2
|
+
every `.execute()`/`.executemany()` call (and pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and
|
|
3
|
+
`PostgresJsonResponse`) is classified by how its SQL argument was
|
|
3
4
|
built, then checked against the `postgres-best-practices` skill's rules
|
|
4
5
|
(../../skills/postgres-best-practices in a checkout of bmsuisse/skills).
|
|
5
6
|
|
|
@@ -25,7 +26,8 @@ from __future__ import annotations
|
|
|
25
26
|
import ast
|
|
26
27
|
import re
|
|
27
28
|
import sys
|
|
28
|
-
from
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
from dataclasses import dataclass, field
|
|
29
31
|
from pathlib import Path
|
|
30
32
|
|
|
31
33
|
from .lint_findings import Finding
|
|
@@ -124,6 +126,7 @@ class _Trust:
|
|
|
124
126
|
|
|
125
127
|
sqlglot_names: frozenset[str] = frozenset()
|
|
126
128
|
literal_funcs: frozenset[str] = frozenset()
|
|
129
|
+
sqlglot_aliases: Mapping[str, str] = field(default_factory=dict) # local name -> name it was imported as
|
|
127
130
|
|
|
128
131
|
|
|
129
132
|
_NO_TRUST = _Trust()
|
|
@@ -185,6 +188,7 @@ def _unwrap_str_call(node: ast.Call) -> ast.expr | None:
|
|
|
185
188
|
def _file_trust(tree: ast.AST) -> _Trust:
|
|
186
189
|
literal_funcs: set[str] = set()
|
|
187
190
|
sqlglot_names: set[str] = set()
|
|
191
|
+
sqlglot_aliases: dict[str, str] = {}
|
|
188
192
|
for node in ast.walk(tree):
|
|
189
193
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None and _is_literal_string_ref(node.returns):
|
|
190
194
|
literal_funcs.add(node.name)
|
|
@@ -192,7 +196,8 @@ def _file_trust(tree: ast.AST) -> _Trust:
|
|
|
192
196
|
sqlglot_names.update((a.asname or a.name).split(".")[0] for a in node.names if a.name.split(".")[0] == "sqlglot")
|
|
193
197
|
elif isinstance(node, ast.ImportFrom) and (node.module or "").split(".")[0] == "sqlglot":
|
|
194
198
|
sqlglot_names.update(a.asname or a.name for a in node.names)
|
|
195
|
-
|
|
199
|
+
sqlglot_aliases.update({a.asname or a.name: a.name for a in node.names})
|
|
200
|
+
return _Trust(frozenset(sqlglot_names), frozenset(literal_funcs), sqlglot_aliases)
|
|
196
201
|
|
|
197
202
|
|
|
198
203
|
def _resolve_single(expr: ast.expr, lookup) -> ast.expr:
|
|
@@ -450,14 +455,119 @@ def _is_sql_named(name: str) -> bool:
|
|
|
450
455
|
return lowered == "sql" or lowered.endswith("_sql")
|
|
451
456
|
|
|
452
457
|
|
|
458
|
+
# pgdevkit's `fetch_all`/`fetch_one`/`fetch_scalar` and `pgdevkit.fastapi.PostgresJsonResponse` run their first
|
|
459
|
+
# argument as SQL, exactly like `.execute()`, so they get the same rules (plain functions / a class, so matched by
|
|
460
|
+
# bare name too). Matched by name only, so not covered: an alias (`as PJR`), a subclass under another name (the
|
|
461
|
+
# documented `class AppJson(PostgresJsonResponse): expose_errors = ...`), and the pre-pgdevkit
|
|
462
|
+
# `PostgresJsonResponse("postgres", sql, ...)` copy in CCMT2, whose first argument is a connection source -- its SQL
|
|
463
|
+
# only gets checked once that call site moves to pgdevkit.
|
|
464
|
+
_PGDEVKIT_SQL_CALLEES = frozenset({"fetch_all", "fetch_one", "fetch_scalar", "PostgresJsonResponse"})
|
|
465
|
+
|
|
466
|
+
|
|
453
467
|
def _execute_query_arg(call: ast.Call) -> ast.expr | None:
|
|
454
|
-
|
|
468
|
+
func = call.func
|
|
469
|
+
is_execute = isinstance(func, ast.Attribute) and func.attr in ("execute", "executemany")
|
|
470
|
+
is_fetch_helper = (isinstance(func, ast.Name) and func.id in _PGDEVKIT_SQL_CALLEES) or (
|
|
471
|
+
isinstance(func, ast.Attribute) and func.attr in _PGDEVKIT_SQL_CALLEES
|
|
472
|
+
)
|
|
473
|
+
if not (is_execute or is_fetch_helper):
|
|
455
474
|
return None
|
|
456
475
|
if call.args:
|
|
457
476
|
return call.args[0]
|
|
458
477
|
return next((kw.value for kw in call.keywords if kw.arg == "query"), None)
|
|
459
478
|
|
|
460
479
|
|
|
480
|
+
# sqlglot's builders parse plain strings as SQL (`select(f"a, {x}")`, `.where(f"id = {x}")`), so text
|
|
481
|
+
# interpolated into one is as dangerous as text passed straight to `.execute()` -- even though the
|
|
482
|
+
# result is a sqlglot expression the rules above otherwise trust. Value arguments (`exp.column`,
|
|
483
|
+
# `exp.Placeholder`, `exp.convert`, `exp.to_identifier`, ...) quote/bind safely and aren't listed.
|
|
484
|
+
_SQLGLOT_STRING_BUILDERS = frozenset(
|
|
485
|
+
{"select", "from_", "where", "join", "order_by", "group_by", "having", "with_", "parse_one", "parse", "condition", "and_", "or_", "maybe_parse"}
|
|
486
|
+
)
|
|
487
|
+
# Keyword arguments of those builders that are options, not SQL text.
|
|
488
|
+
_SQLGLOT_NON_SQL_KEYWORDS = frozenset(
|
|
489
|
+
{"dialect", "read", "write", "copy", "append", "alias", "join_alias", "join_type", "into", "opts", "dialect_name", "table"}
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _concat_leaves(node: ast.expr) -> list[ast.expr]:
|
|
494
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
495
|
+
return [*_concat_leaves(node.left), *_concat_leaves(node.right)]
|
|
496
|
+
return [node]
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _is_str_constant(node: ast.expr) -> bool:
|
|
500
|
+
return isinstance(node, ast.Constant) and isinstance(node.value, str)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _interpolation_kind(node: ast.expr) -> str | None:
|
|
504
|
+
"""How `node` interpolates non-literal text into a string, or None if it doesn't. An
|
|
505
|
+
interpolated *constant* (`f"{1}"`, `f"{'x'}"`) can't carry user text, so it doesn't count."""
|
|
506
|
+
if isinstance(node, ast.JoinedStr):
|
|
507
|
+
dynamic = any(isinstance(v, ast.FormattedValue) and not isinstance(v.value, ast.Constant) for v in node.values)
|
|
508
|
+
return "an f-string" if dynamic else None
|
|
509
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
510
|
+
leaves = _concat_leaves(node)
|
|
511
|
+
if any(_is_str_constant(x) for x in leaves) and not all(isinstance(x, ast.Constant) for x in leaves):
|
|
512
|
+
return "string concatenation"
|
|
513
|
+
return None
|
|
514
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mod):
|
|
515
|
+
return "the `%` string-formatting operator" if _is_str_constant(node.left) else None
|
|
516
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
517
|
+
if node.func.attr == "format" and _is_str_constant(node.func.value):
|
|
518
|
+
return "`str.format()`"
|
|
519
|
+
if node.func.attr == "join" and _is_str_constant(node.func.value) and node.args:
|
|
520
|
+
# `" and ".join(f"{c} = 1" for c in cols)`: the element is what carries the text
|
|
521
|
+
elements = _elements(node.args[0])
|
|
522
|
+
return next((k for e in elements if (k := _interpolation_kind(e)) is not None), None)
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _elements(node: ast.expr) -> list[ast.expr]:
|
|
527
|
+
"""The element expression(s) of a literal list/tuple or a comprehension/generator."""
|
|
528
|
+
if isinstance(node, (ast.ListComp, ast.GeneratorExp, ast.SetComp)):
|
|
529
|
+
return [node.elt]
|
|
530
|
+
if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
|
531
|
+
return list(node.elts)
|
|
532
|
+
return []
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _sqlglot_builder_findings(
|
|
536
|
+
call: ast.Call, path: Path, trust: _Trust, lookup, sqlglot_vars: set[str]
|
|
537
|
+
) -> list[Finding]:
|
|
538
|
+
if not trust.sqlglot_names:
|
|
539
|
+
return []
|
|
540
|
+
func = call.func
|
|
541
|
+
name = func.attr if isinstance(func, ast.Attribute) else func.id if isinstance(func, ast.Name) else None
|
|
542
|
+
root = _call_root_name(func)
|
|
543
|
+
if isinstance(func, ast.Name):
|
|
544
|
+
name = trust.sqlglot_aliases.get(func.id, func.id) # `from sqlglot import select as s`
|
|
545
|
+
# Only chains rooted at a sqlglot name or at a variable holding a sqlglot expression: `.where()`/
|
|
546
|
+
# `.group_by()` on a polars/Django/SQLAlchemy object in the same file is none of our business.
|
|
547
|
+
if name not in _SQLGLOT_STRING_BUILDERS or not (root in trust.sqlglot_names or root in sqlglot_vars):
|
|
548
|
+
return []
|
|
549
|
+
arguments: list[ast.expr] = []
|
|
550
|
+
for arg in call.args:
|
|
551
|
+
arguments.append(arg.value if isinstance(arg, ast.Starred) else arg)
|
|
552
|
+
arguments.extend(kw.value for kw in call.keywords if kw.arg not in _SQLGLOT_NON_SQL_KEYWORDS)
|
|
553
|
+
for arg in arguments:
|
|
554
|
+
candidates = [c for top in [arg, *_elements(arg)] for c in _resolve_candidates(top, lookup)]
|
|
555
|
+
for candidate in candidates:
|
|
556
|
+
kind = _interpolation_kind(candidate)
|
|
557
|
+
if kind is not None:
|
|
558
|
+
return [
|
|
559
|
+
Finding(
|
|
560
|
+
path,
|
|
561
|
+
arg.lineno,
|
|
562
|
+
"sql-sqlglot-string-injection",
|
|
563
|
+
f"`{name}(...)` parses its string argument as SQL, and it is built with {kind} -- injection risk. "
|
|
564
|
+
"Build it from sqlglot nodes instead (`exp.column()`/`exp.to_identifier()` for names, `exp.Placeholder` "
|
|
565
|
+
"+ bound params for values).",
|
|
566
|
+
)
|
|
567
|
+
]
|
|
568
|
+
return []
|
|
569
|
+
|
|
570
|
+
|
|
461
571
|
class _ExecuteCallVisitor(ast.NodeVisitor):
|
|
462
572
|
"""Walks a module, tracking a stack of (function/class/module) local-variable scopes so a
|
|
463
573
|
`cur.execute(query, ...)` call can resolve `query` back to every assignment it had in that
|
|
@@ -471,6 +581,7 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
|
|
|
471
581
|
self.review = review
|
|
472
582
|
self.findings: list[Finding] = []
|
|
473
583
|
self._scopes: list[dict[str, list[ast.expr]]] = [{}]
|
|
584
|
+
self._sqlglot_vars: set[str] = set() # names bound from a sqlglot expression chain
|
|
474
585
|
|
|
475
586
|
def _lookup(self, name: str) -> list[ast.expr]:
|
|
476
587
|
for scope in reversed(self._scopes):
|
|
@@ -487,14 +598,21 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
|
|
|
487
598
|
visit_AsyncFunctionDef = _visit_scoped
|
|
488
599
|
visit_ClassDef = _visit_scoped
|
|
489
600
|
|
|
601
|
+
def _track_sqlglot_var(self, name: str, value: ast.expr) -> None:
|
|
602
|
+
if isinstance(value, ast.Call) and (root := _call_root_name(value.func)) is not None:
|
|
603
|
+
if root in self.trust.sqlglot_names or root in self._sqlglot_vars:
|
|
604
|
+
self._sqlglot_vars.add(name)
|
|
605
|
+
|
|
490
606
|
def visit_Assign(self, node: ast.Assign) -> None:
|
|
491
607
|
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
|
608
|
+
self._track_sqlglot_var(node.targets[0].id, node.value)
|
|
492
609
|
self._scopes[-1].setdefault(node.targets[0].id, []).append(node.value)
|
|
493
610
|
self._check_sql_named_literal(node.targets[0].id, node.value, node.lineno)
|
|
494
611
|
self.generic_visit(node)
|
|
495
612
|
|
|
496
613
|
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
497
614
|
if isinstance(node.target, ast.Name) and node.value is not None:
|
|
615
|
+
self._track_sqlglot_var(node.target.id, node.value)
|
|
498
616
|
self._scopes[-1].setdefault(node.target.id, []).append(node.value)
|
|
499
617
|
self._check_sql_named_literal(node.target.id, node.value, node.lineno)
|
|
500
618
|
self.generic_visit(node)
|
|
@@ -515,6 +633,7 @@ class _ExecuteCallVisitor(ast.NodeVisitor):
|
|
|
515
633
|
return bool(values) and all(isinstance(v, ast.Constant) and isinstance(v.value, str) for v in values)
|
|
516
634
|
|
|
517
635
|
def visit_Call(self, node: ast.Call) -> None:
|
|
636
|
+
self.findings.extend(_sqlglot_builder_findings(node, self.path, self.trust, self._lookup, self._sqlglot_vars))
|
|
518
637
|
query_arg = _execute_query_arg(node)
|
|
519
638
|
if query_arg is not None:
|
|
520
639
|
found = _check_query_arg(query_arg, self._lookup, self.path, node.lineno, self.trust, self.review)
|
|
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "bmsdna-devtools"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.34.0"
|
|
14
14
|
description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects"
|
|
15
15
|
readme = "README.md"
|
|
16
16
|
requires-python = ">=3.14" # pgdevkit>=0.7.1 requires 3.14; was >=3.11 before adding it as a dependency
|
|
@@ -123,7 +123,7 @@ vendor a git submodule under that path.
|
|
|
123
123
|
|
|
124
124
|
## Static checks
|
|
125
125
|
|
|
126
|
-
- `bdt lint [paths]` -- SQL/psycopg rules, pydantic-model placement, hand-wired HTTP in TypeScript, baseline tooling.
|
|
126
|
+
- `bdt lint [paths]` -- SQL/psycopg rules (`.execute()`, pgdevkit `fetch_*`, `PostgresJsonResponse`), pydantic-model placement, hand-wired HTTP in TypeScript, baseline tooling.
|
|
127
127
|
- `uv run bdt dead-code` -- dead code a linter can't see, configured under `[tool.bdt.dead_code]` in pyproject.toml:
|
|
128
128
|
`.sql` files no Python code loads (`sql_roots`) and FastAPI routes that neither non-generated frontend code nor a
|
|
129
129
|
`url_for(...)` call uses (`[[tool.bdt.dead_code.apps]]`: app or openapi file, frontends, excludes, optional `baseline`;
|
|
@@ -377,3 +377,234 @@ async def f(cur):
|
|
|
377
377
|
await cur.execute(q_sql)
|
|
378
378
|
"""
|
|
379
379
|
assert "sql-inline-too-complex" in _rules(_findings(source, tmp_path / "a.py"))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def test_fetch_all_with_fstring_is_flagged_like_execute(tmp_path: Path) -> None:
|
|
383
|
+
source = '''
|
|
384
|
+
from pgdevkit.db import fetch_all
|
|
385
|
+
|
|
386
|
+
async def f(table):
|
|
387
|
+
return await fetch_all(f"SELECT id FROM dim.{table} WHERE active")
|
|
388
|
+
'''
|
|
389
|
+
assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_fetch_helpers_flag_concat_format_and_percent(tmp_path: Path) -> None:
|
|
393
|
+
source = '''
|
|
394
|
+
async def f(db, value):
|
|
395
|
+
await fetch_one("select * from t where id = " + str(value))
|
|
396
|
+
await fetch_scalar("select count(*) from t where id = %s" % value)
|
|
397
|
+
await db.fetch_all("select * from t where id = {}".format(value))
|
|
398
|
+
'''
|
|
399
|
+
assert _rules(_findings(source, tmp_path / "a.py")) == {
|
|
400
|
+
"sql-concat-injection",
|
|
401
|
+
"sql-percent-format-injection",
|
|
402
|
+
"sql-format-injection",
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def test_fetch_all_accepts_literal_load_sql_sqlglot_and_template(tmp_path: Path) -> None:
|
|
407
|
+
source = '''
|
|
408
|
+
from sqlglot import select
|
|
409
|
+
from pgdevkit.db import fetch_all
|
|
410
|
+
|
|
411
|
+
async def f(sql_loader, model, user_id):
|
|
412
|
+
await fetch_all(sql_loader.load_sql("users", "list_active"), {"limit": 5}, model=model)
|
|
413
|
+
await fetch_all(select("id").from_("t").sql(dialect="postgres"))
|
|
414
|
+
await fetch_all(t"select id from t where user_id = {user_id}")
|
|
415
|
+
await fetch_all("select id from t where user_id = %(id)s", {"id": user_id}, model=model)
|
|
416
|
+
'''
|
|
417
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_fetch_all_applies_inline_complexity_and_positional_rules(tmp_path: Path) -> None:
|
|
421
|
+
source = '''
|
|
422
|
+
async def f(model):
|
|
423
|
+
await fetch_all("select a.id from a join b on b.a_id = a.id", model=model)
|
|
424
|
+
await fetch_all("select id from t where x = %s", (1,))
|
|
425
|
+
'''
|
|
426
|
+
assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-inline-too-complex", "sql-positional-param"}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def test_unrelated_fetch_all_name_with_non_sql_text_is_not_flagged(tmp_path: Path) -> None:
|
|
430
|
+
source = '''
|
|
431
|
+
async def f(client, name):
|
|
432
|
+
return await client.fetch_all(f"users/{name}")
|
|
433
|
+
'''
|
|
434
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_sqlglot_builder_with_interpolated_string_is_flagged(tmp_path: Path) -> None:
|
|
438
|
+
source = '''
|
|
439
|
+
from sqlglot import select
|
|
440
|
+
import sqlglot
|
|
441
|
+
|
|
442
|
+
async def f(con, user_filter, col):
|
|
443
|
+
q1 = select("id").from_("t").where(f"name = {user_filter}")
|
|
444
|
+
q2 = sqlglot.parse_one("select " + col + " from t")
|
|
445
|
+
q3 = select("id").from_("t").order_by("{} desc".format(col))
|
|
446
|
+
await fetch_all(q1.sql(dialect="postgres"))
|
|
447
|
+
'''
|
|
448
|
+
findings = _findings(source, tmp_path / "a.py")
|
|
449
|
+
assert _rules(findings) == {"sql-sqlglot-string-injection"}
|
|
450
|
+
assert len(findings) == 3
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def test_sqlglot_builders_with_literals_and_nodes_are_clean(tmp_path: Path) -> None:
|
|
454
|
+
source = '''
|
|
455
|
+
from sqlglot import exp, select
|
|
456
|
+
|
|
457
|
+
async def f(col):
|
|
458
|
+
q = select("id", "name").from_("dim.customer").where("active = true")
|
|
459
|
+
q = q.where(exp.column(col).eq(exp.Placeholder(this="id")))
|
|
460
|
+
q = q.order_by(exp.to_identifier(col).sql())
|
|
461
|
+
return q
|
|
462
|
+
'''
|
|
463
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_sqlglot_rule_ignores_files_that_do_not_use_sqlglot(tmp_path: Path) -> None:
|
|
467
|
+
source = '''
|
|
468
|
+
def f(qs, name):
|
|
469
|
+
return qs.where(f"name = {name}").select(f"x{name}")
|
|
470
|
+
'''
|
|
471
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def test_sqlglot_method_on_a_variable_is_flagged_but_generic_join_is_not(tmp_path: Path) -> None:
|
|
475
|
+
source = '''
|
|
476
|
+
from sqlglot import select
|
|
477
|
+
|
|
478
|
+
def f(user_filter, parts):
|
|
479
|
+
q = select("id").from_("t")
|
|
480
|
+
q = q.where(f"name = {user_filter}")
|
|
481
|
+
label = ", ".join(f"{p}!" for p in parts) + "".join(f"{p}" for p in parts)
|
|
482
|
+
return q, label
|
|
483
|
+
'''
|
|
484
|
+
findings = _findings(source, tmp_path / "a.py")
|
|
485
|
+
assert [f.rule for f in findings] == ["sql-sqlglot-string-injection"]
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def test_sqlglot_rule_gaps_found_in_review(tmp_path: Path) -> None:
|
|
489
|
+
source = '''
|
|
490
|
+
from sqlglot import select as s, parse_one as p
|
|
491
|
+
import sqlglot
|
|
492
|
+
|
|
493
|
+
def f(x, y, cols, xs, m):
|
|
494
|
+
w = f"a={x}"
|
|
495
|
+
q = s("a").from_("t").where("a = " + x + y) # 3-part concatenation
|
|
496
|
+
q = q.where(w) # through a variable
|
|
497
|
+
p(f"select {x}") # aliased import
|
|
498
|
+
s(*[f"{c} as {a}" for c, a in m]) # starred comprehension
|
|
499
|
+
q.where(" and ".join(f"{c}=1" for c in cols)) # join of interpolated elements
|
|
500
|
+
return q
|
|
501
|
+
'''
|
|
502
|
+
findings = _findings(source, tmp_path / "a.py")
|
|
503
|
+
assert [f.rule for f in findings] == ["sql-sqlglot-string-injection"] * 5
|
|
504
|
+
assert [f.line for f in findings] == [7, 8, 9, 10, 11]
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def test_sqlglot_rule_false_positive_guards(tmp_path: Path) -> None:
|
|
508
|
+
source = '''
|
|
509
|
+
import sqlglot
|
|
510
|
+
from sqlglot import select
|
|
511
|
+
|
|
512
|
+
class A:
|
|
513
|
+
def go(self, c):
|
|
514
|
+
return self.where(f"{c}")
|
|
515
|
+
|
|
516
|
+
def f(df, Model, session, x, d, i):
|
|
517
|
+
df.group_by(f"{x}_id")
|
|
518
|
+
Model.objects.order_by(f"-{x}")
|
|
519
|
+
session.query(A).group_by(f"{x}").having(f"{x}")
|
|
520
|
+
sqlglot.parse_one("select 1", dialect=f"{d}")
|
|
521
|
+
select("a").from_("t", alias=f"t{i}")
|
|
522
|
+
select("a").where(f"{1}")
|
|
523
|
+
select("a").where(f"a={'x'}")
|
|
524
|
+
select("a").where(f"b = {i}", dialect=d) # the one real finding: positional f-string with a name
|
|
525
|
+
'''
|
|
526
|
+
findings = _findings(source, tmp_path / "a.py")
|
|
527
|
+
assert [f.line for f in findings] == [17]
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def test_sqlglot_finding_line_is_the_offending_argument_in_a_multiline_chain(tmp_path: Path) -> None:
|
|
531
|
+
source = '''
|
|
532
|
+
from sqlglot import select
|
|
533
|
+
|
|
534
|
+
def f(y):
|
|
535
|
+
return (
|
|
536
|
+
select("a")
|
|
537
|
+
.from_("t")
|
|
538
|
+
.where(f"a={y}")
|
|
539
|
+
)
|
|
540
|
+
'''
|
|
541
|
+
assert [f.line for f in _findings(source, tmp_path / "a.py")] == [8]
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def test_postgres_json_response_with_fstring_is_flagged_like_execute(tmp_path: Path) -> None:
|
|
545
|
+
source = '''
|
|
546
|
+
from pgdevkit.fastapi import PostgresJsonResponse
|
|
547
|
+
|
|
548
|
+
async def f(table):
|
|
549
|
+
return PostgresJsonResponse(f"SELECT id FROM dim.{table} WHERE active")
|
|
550
|
+
'''
|
|
551
|
+
assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def test_postgres_json_response_flags_every_interpolation_form_and_call_shape(tmp_path: Path) -> None:
|
|
555
|
+
source = '''
|
|
556
|
+
import pgdevkit.fastapi as pgf
|
|
557
|
+
|
|
558
|
+
def f(value, table):
|
|
559
|
+
PostgresJsonResponse("select * from t where id = " + str(value))
|
|
560
|
+
PostgresJsonResponse("select * from t where id = %s" % value)
|
|
561
|
+
pgf.PostgresJsonResponse("select * from t where id = {}".format(value))
|
|
562
|
+
PostgresJsonResponse(query=f"select id from {table}", pool=None)
|
|
563
|
+
'''
|
|
564
|
+
findings = _findings(source, tmp_path / "a.py")
|
|
565
|
+
assert _rules(findings) == {
|
|
566
|
+
"sql-concat-injection",
|
|
567
|
+
"sql-percent-format-injection",
|
|
568
|
+
"sql-format-injection",
|
|
569
|
+
"sql-fstring-injection",
|
|
570
|
+
}
|
|
571
|
+
assert len(findings) == 4
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def test_postgres_json_response_follows_a_variable_to_its_unsafe_assignment(tmp_path: Path) -> None:
|
|
575
|
+
source = '''
|
|
576
|
+
def f(table):
|
|
577
|
+
query = f"select id from dim.{table} where active"
|
|
578
|
+
return PostgresJsonResponse(query, {"x": 1})
|
|
579
|
+
'''
|
|
580
|
+
assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-fstring-injection"}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def test_postgres_json_response_accepts_literal_load_sql_and_named_params(tmp_path: Path) -> None:
|
|
584
|
+
source = '''
|
|
585
|
+
def f(sql_loader, lng):
|
|
586
|
+
PostgresJsonResponse(sql_loader.load_sql("articles", "list_articles"), {"lng": lng})
|
|
587
|
+
PostgresJsonResponse("select id, name from articles where lng = %(lng)s", {"lng": lng})
|
|
588
|
+
PostgresJsonResponse("select 1 as a", query_produces_json=False, batch_size=10, statement_timeout=5)
|
|
589
|
+
PostgresJsonResponse(t"select id from articles where lng = {lng}")
|
|
590
|
+
'''
|
|
591
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def test_postgres_json_response_applies_inline_complexity_and_positional_rules(tmp_path: Path) -> None:
|
|
595
|
+
source = '''
|
|
596
|
+
def f():
|
|
597
|
+
PostgresJsonResponse("select a.id from a join b on b.a_id = a.id")
|
|
598
|
+
PostgresJsonResponse("select id from t where x = %s", (1,))
|
|
599
|
+
'''
|
|
600
|
+
assert _rules(_findings(source, tmp_path / "a.py")) == {"sql-inline-too-complex", "sql-positional-param"}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def test_legacy_ccmt_postgres_json_response_with_connection_first_is_not_flagged(tmp_path: Path) -> None:
|
|
604
|
+
# CCMT2's own pre-pgdevkit class takes `("postgres", sql, ...)`: its first argument is no SQL, so it stays
|
|
605
|
+
# unchecked (silently skipped, never a false positive) until that call site moves to pgdevkit's class.
|
|
606
|
+
source = '''
|
|
607
|
+
def f(table):
|
|
608
|
+
return PostgresJsonResponse("postgres", f"select id from dim.{table}", parameters={})
|
|
609
|
+
'''
|
|
610
|
+
assert _findings(source, tmp_path / "a.py") == []
|
|
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
|
|
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
|
|
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
|
|
File without changes
|