Skip to content

Commit f1d90f3

Browse files
committed
feat(ci): add staabm/phpstan-dba to type-check raw SQL (#1100)
Wires phpstan-dba into the existing PHPStan gate so column renames, schema drift, and statement-syntax bugs in `Database::query(...)` calls fail static analysis instead of waiting for runtime. The CI job spins up schema-only MariaDB, renders `web/install/includes/sql/struc.sql` the same way `docker/db-init/00-render-schema.sh` does locally, and points the bootstrap at it. `DBA_REQUIRE=1` keeps the gate from silently disabling itself on a creds drift. The project's `:prefix_<table>` placeholder convention is handled by `SbppPrefixAwareReflector` (resolves `:prefix` -> `sb` before the SQL hits MariaDB) and `SbppSyntaxErrorInQueryMethodRule` (replacement for phpstan-dba's built-in rule, which short-circuits on placeholder counting and would skip every query in this codebase). Bound values live in follow-up `bind()` calls out of scope, so we stub `:name` and `?` placeholders with neutral literals and validate structure only — table/column existence and SQL syntax — which is the bug class the issue calls out. Surfaced and fixed one shipped bug along the way: `Log.php`'s `case "date":` arm was building `... AND l.created :valueOther`, missing the comparison operator, so the admin log page threw SQLSTATE 42000 whenever someone filtered by date. The new rule flags it as a syntax error; the fix is a one-character `<`. Disable locally with `PHPSTAN_DBA_DISABLE=1`. Bootstrap also soft-fails if the DB is unreachable so a fresh checkout without `./sbpp.sh up` keeps working.
1 parent c7793c4 commit f1d90f3

11 files changed

Lines changed: 875 additions & 7 deletions

.github/workflows/phpstan.yml

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,35 @@ jobs:
1919
defaults:
2020
run:
2121
working-directory: web
22+
23+
# Schema-only MariaDB so phpstan-dba (#1100) can introspect column types.
24+
# Mirrors test.yml's service block deliberately — same image, same creds —
25+
# so a contributor's mental model of "MariaDB lives at 127.0.0.1:3306" works
26+
# for both jobs.
27+
services:
28+
mariadb:
29+
image: mariadb:10.11
30+
ports:
31+
- 3306:3306
32+
env:
33+
MARIADB_ROOT_PASSWORD: root
34+
MARIADB_DATABASE: sourcebans
35+
MARIADB_USER: sourcebans
36+
MARIADB_PASSWORD: sourcebans
37+
options: >-
38+
--health-cmd="healthcheck.sh --connect --innodb_initialized"
39+
--health-interval=5s
40+
--health-timeout=5s
41+
--health-retries=10
42+
2243
steps:
2344
- uses: actions/checkout@v4
2445

2546
- name: Setup PHP
2647
uses: shivammathur/setup-php@v2
2748
with:
2849
php-version: '8.2'
29-
extensions: pdo, openssl, gmp, mbstring, sodium
50+
extensions: pdo, pdo_mysql, openssl, gmp, mbstring, sodium
3051
coverage: none
3152
tools: composer:v2
3253

@@ -44,5 +65,42 @@ jobs:
4465
- name: Install dependencies
4566
run: composer install --no-progress --prefer-dist --no-interaction
4667

68+
- name: Wait for MariaDB
69+
run: |
70+
for i in {1..30}; do
71+
if mysqladmin ping -h 127.0.0.1 -P 3306 -u root -proot --silent; then
72+
echo "ready"; exit 0
73+
fi
74+
sleep 2
75+
done
76+
echo "MariaDB never came up" >&2; exit 1
77+
78+
# phpstan-dba doesn't execute queries, but it does call
79+
# information_schema for column metadata, so the schema needs to be
80+
# loaded against the same DB the bootstrap connects to. Render the
81+
# `{prefix}` / `{charset}` placeholders the same way docker/db-init
82+
# does locally so we can't drift.
83+
- name: Render and load schema for phpstan-dba
84+
run: |
85+
tmp=$(mktemp)
86+
sed -e 's/{prefix}/sb/g' -e 's/{charset}/utf8mb4/g' \
87+
install/includes/sql/struc.sql > "$tmp"
88+
mysql -h 127.0.0.1 -u root -proot sourcebans < "$tmp"
89+
# Sanity check: a known table should now exist.
90+
mysql -h 127.0.0.1 -u root -proot sourcebans -e 'SHOW TABLES LIKE "sb_admins"' \
91+
| grep -q sb_admins
92+
4793
- name: Run PHPStan
94+
env:
95+
DBA_HOST: 127.0.0.1
96+
DBA_PORT: 3306
97+
DBA_NAME: sourcebans
98+
DBA_USER: sourcebans
99+
DBA_PASS: sourcebans
100+
DBA_PREFIX: sb
101+
DBA_CHARSET: utf8mb4
102+
# The whole point of the new MariaDB service is to gate schema drift.
103+
# Refuse to run if the bootstrap can't connect — otherwise a creds
104+
# mismatch would silently disable phpstan-dba and CI would stay green.
105+
DBA_REQUIRE: '1'
48106
run: includes/vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G

docker/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,35 @@ don't leak onto the host filesystem.
6767
./sbpp.sh rebuild # `--no-cache` rebuild of the web image
6868
```
6969

70+
## Static analysis with phpstan-dba
71+
72+
`./sbpp.sh phpstan` runs PHPStan inside the web container with
73+
[`staabm/phpstan-dba`](https://github.com/staabm/phpstan-dba) wired up against
74+
the running `db` service. The wrapper exports `DBA_HOST=db` (plus `DBA_USER`,
75+
`DBA_PASS`, `DBA_NAME`, `DBA_PREFIX`) so `web/phpstan-dba-bootstrap.php` can
76+
introspect the live schema and type-check raw SQL strings — column names,
77+
table names, and statement syntax in every `Database::query(...)` call get
78+
validated against `web/install/includes/sql/struc.sql` as it would be loaded
79+
by the seed script.
80+
81+
To skip the DBA pass (useful when the DB container is down or you're
82+
iterating on unrelated rules), set `PHPSTAN_DBA_DISABLE=1`:
83+
84+
```sh
85+
PHPSTAN_DBA_DISABLE=1 ./sbpp.sh phpstan
86+
```
87+
88+
The bootstrap also degrades gracefully if it can't reach the DB at all, so a
89+
fresh checkout without `./sbpp.sh up` won't break the gate — it just runs the
90+
non-DBA rules.
91+
92+
CI mirrors this: `.github/workflows/phpstan.yml` spins up MariaDB 10.11,
93+
renders `struc.sql` (no `data.sql` — phpstan-dba only needs structure), and
94+
points the same env vars at it. Renaming or removing a column in `struc.sql`
95+
without updating its callers will fail the PHPStan job. CI also sets
96+
`DBA_REQUIRE=1` so a missing service or credentials drift fails the job
97+
loudly instead of silently disabling the SQL checks.
98+
7099
## How the bootstrap works
71100

72101
1. **DB**: MariaDB only runs `/docker-entrypoint-initdb.d/*` on the **first**

sbpp.sh

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,19 @@ case "$cmd" in
8383
# found" warnings disappear and triggers PHPStan's
8484
# `reportUnmatchedIgnoredErrors`. Stash config.php for the duration of
8585
# the analysis so locally we get the same result CI does.
86-
dc exec web bash -lc '
86+
#
87+
# phpstan-dba (#1100) needs a live MariaDB to introspect schema; the
88+
# dev `db` service is reachable from inside the web container as
89+
# `db:3306`. Set PHPSTAN_DBA_DISABLE=1 to bypass when working
90+
# offline — the bootstrap also degrades gracefully if the connection
91+
# fails for any other reason.
92+
dc exec \
93+
-e DBA_HOST=db -e DBA_PORT=3306 \
94+
-e DBA_NAME=sourcebans \
95+
-e DBA_USER=sourcebans -e DBA_PASS=sourcebans \
96+
-e DBA_PREFIX=sb -e DBA_CHARSET=utf8mb4 \
97+
-e PHPSTAN_DBA_DISABLE="${PHPSTAN_DBA_DISABLE:-}" \
98+
web bash -lc '
8799
cd /var/www/html/web
88100
cleanup() { [ -f config.php.devstash ] && mv config.php.devstash config.php; }
89101
trap cleanup EXIT

web/composer.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,13 @@
3434
"Sbpp\\": "includes/"
3535
}
3636
},
37+
"autoload-dev": {
38+
"classmap": ["phpstan/"]
39+
},
3740
"require-dev": {
3841
"phpstan/phpstan": "^2.1",
39-
"phpunit/phpunit": "^11"
42+
"phpunit/phpunit": "^11",
43+
"staabm/phpstan-dba": "^0.4"
4044
},
4145
"scripts": {
4246
"test": "phpunit",

0 commit comments

Comments
 (0)