Skip to content

fix: stop silently dropping input that a dialect cannot honour - #18

Open
abnegate wants to merge 2 commits into
refactor/rename-parser-to-classifierfrom
refactor/remove-silent-drops
Open

fix: stop silently dropping input that a dialect cannot honour#18
abnegate wants to merge 2 commits into
refactor/rename-parser-to-classifierfrom
refactor/remove-silent-drops

Conversation

@abnegate

Copy link
Copy Markdown
Member

Stacked on #17#16#15. This is the audit you asked for: a sweep for more of the pattern from #16, in every form.

Method

Not grep — grep gave me false positives and false negatives (some properties are read in compileColumnType(), which a per-method scan misses). Instead I diffed generated output with and without each modifier, per dialect. If the DDL is byte-identical, the input was discarded.

🔴 Data-isolation bug: MongoDB dropped Hook\Filter

Hook\Filter::filter() returns a Condition — a raw SQL expression plus bindings. A MongoDB operation document has nowhere to put one, so addHook() stored the hook and nothing ever read it:

(new MySQL())->addHook(new Tenant(['7']))->from('docs')->select(['a'])->build()->query;
// SELECT `a` FROM `docs` WHERE tenant_id IN (?)          ← scoped

(new MongoDB())->addHook(new Tenant(['7']))->from('docs')->select(['a'])->build()->query;
// {"collection":"docs","operation":"find","projection":{"a":1,"_id":0}}
//                                                        ↑ no tenant filter at all

A multi-tenant app on the MongoDB builder got every tenant's documents. Adding a real filter doesn't help — only that filter appears; tenant_id is nowhere.

MongoDB::addHook() now rejects Hook\Filter and Hook\Join\Filter. Hook\Attribute and Hook\Write are dialect-neutral and still apply (verified). No test covered this, which is why it survived — there are four now, in SecurityRegressionTest.

The deeper fix is to make Hook\Filter return Query objects instead of a SQL Condition, which would let MongoDB honour it. That's a public-contract change and belongs in its own PR — flagging it rather than doing it here.

Dead API: collation() did nothing, anywhere

The setter stored a value, no compiler read it, and the test asserted only that the property round-tripped:

$col->collation('utf8mb4_unicode_ci');
$this->assertSame('utf8mb4_unicode_ci', $col->collation);   // passes, emits nothing

The single COLLATE emission in the codebase is for index collations — an unrelated path. It's now emitted where it's supported, and gone where it isn't:

MySQL      CREATE TABLE `t` (`s` VARCHAR(255) COLLATE utf8mb4_bin NOT NULL)
PostgreSQL CREATE TABLE "t" ("s" VARCHAR(255) COLLATE "C" NOT NULL)     ← quoted via a hook
SQLite     CREATE TABLE `t` (`s` VARCHAR(255) COLLATE NOCASE NOT NULL)

Modifiers moved to the dialects that emit them

Modifier Now on Previously dropped by
collation() MySQL, MariaDB, PostgreSQL, SQLite all five
unique() MySQL, MariaDB, PostgreSQL, SQLite ClickHouse, MongoDB
after() MySQL, MariaDB, SQLite PostgreSQL, ClickHouse, MongoDB
comment() all but PostgreSQL PostgreSQL

PostgreSQL's inline comment() had a pre-existing test named testCreateTableNoInlineComment asserting the output contained no COMMENT — the drop was known. commentOnColumn() is the supported path, so the test becomes a type-level assertion plus a check that COMMENT ON COLUMN really is emitted.

The instanceof self-gate

Schema::compileCreate() did this:

if ($this instanceof Schema\Feature\Partitioning) {
    $partitioning = $this->compileCreatePartitioning($table);
    ...
}

The base class enumerating which features its subclasses might have is the same gate one level up. It now calls a protected compileCreateSuffix() returning '', overridden by Trait\Partitioning. MySQL and PostgreSQL emit identical partitioning DDL (verified, including PARTITIONS 4).

Deliberately left alone

  • unsigned() renders nothing on PostgreSQL and SQLite — but via an explicit overridable compileUnsigned() hook returning ''. That's an intentional dialect mapping, not an accident, so I left it and documented it.
  • srid(), autoIncrement(), vector()'s $dimensions are accepted on dialects that can't express them, but the library's own factories (Table::point(), Table::id(), Trait\Serial, the three vector() methods) call them internally. Moving them needs those factories restructured — separate change.
  • Builder side, MongoDB: cursorAfter()/cursorBefore(), fetch() and window() are silently dropped. cursorAfter is the concerning one — pagination silently doesn't advance. Each needs an implement-or-reject decision I'd rather you weigh in on than pick unilaterally.

All four are now in a "Known gaps" note in the README.

Test plan

  • composer test — 5318 tests, 12364 assertions, all pass (was 5311)
  • composer check — PHPStan level max, no errors
  • composer lint — pass
  • Re-ran the output-diff detector afterwards: every fixed drop is now either honoured or a type error

Integration tests need Docker and were not run locally; CI covers them.

🤖 Generated with Claude Code

An audit for the pattern behind the previous commits turned up its quieter
half. Rather than gating with a throw, these paths accepted input and
discarded it, which is harder to notice and in one case unsafe.

The MongoDB builder dropped Hook\Filter. Hook\Filter::filter() returns a
Condition -- a raw SQL expression plus bindings -- and an operation
document has nowhere to put one, so addHook() stored the hook and nothing
ever read it. A Hook\Filter\Tenant that correctly emits
`WHERE tenant_id IN (?)` on MySQL produced an unscoped MongoDB query
returning every tenant's documents. MongoDB::addHook() now rejects
Hook\Filter and Hook\Join\Filter; Hook\Attribute and Hook\Write are
dialect-neutral and still apply. No test covered this, which is why it
survived; there are four now, in SecurityRegressionTest.

Column::collation() was dead on all five dialects: the setter stored a
value, no compiler ever read it, and the only test asserted the property
round-tripped rather than that any DDL changed. The one COLLATE emission
in the codebase is for index collations, a separate path. It is now
emitted for MySQL, MariaDB, PostgreSQL and SQLite -- PostgreSQL quotes the
name via a quoteCollation() hook -- and removed from ClickHouse (which
collates in ORDER BY) and MongoDB (per collection).

Three more modifiers moved to the dialects that emit them, verified by
diffing DDL with and without each one rather than by reading the source:

  unique()   MySQL, MariaDB, PostgreSQL, SQLite
  after()    MySQL, MariaDB, SQLite   (PostgreSQL cannot order columns)
  comment()  all but PostgreSQL, which needs a separate COMMENT ON --
             commentOnColumn() already emits it, and a test asserting the
             inline form produced nothing becomes a type-level assertion

Schema::compileCreate() branched on `$this instanceof Feature\Partitioning`
to decide whether to append a partitioning clause -- the same gate, one
level up. It now calls a protected compileCreateSuffix() hook that returns
'' by default and is overridden by Trait\Partitioning, so the base class
no longer enumerates the features that might exist. MySQL and PostgreSQL
emit identical partitioning DDL.

Left alone and documented as known gaps, because the library's own column
factories set them internally and unpicking that is a separate change:
unsigned() renders nothing on PostgreSQL and SQLite, and srid(),
autoIncrement() and vector()'s $dimensions are accepted on dialects that
cannot express them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

📊 Coverage

Metric Covered Ratio
Lines 91.92% 7466 / 8122
Methods 84.28% 1110 / 1317
Classes 65.90% 143 / 217

Full per-file breakdown in the job summary.

@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR prevents dialect-specific schema and builder inputs from being silently discarded.

  • Rejects SQL filter hooks on MongoDB rather than allowing unscoped operations.
  • Limits column modifiers to dialects that emit them and adds column collation support.
  • Replaces the schema partitioning type gate with an overridable CREATE TABLE suffix hook.
  • Removes SQLite’s unsupported after() API and adds executable SQLite regression coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/Query/Schema/Column/SQLite.php Removes the positioning capability that previously allowed SQLite to generate unsupported AFTER syntax, fully resolving the prior finding.
src/Query/Builder/MongoDB.php Rejects SQL-oriented filter hooks that MongoDB cannot represent instead of silently dropping their constraints.
src/Query/Schema.php Emits supported column collations and delegates dialect-specific CREATE TABLE suffix generation through an override hook.
src/Query/Schema/Column.php Moves dialect-dependent fluent modifiers out of the common column API.
src/Query/Schema/Trait/Partitioning.php Preserves partitioning output through the new CREATE TABLE suffix extension point.
tests/Query/Schema/SQLiteTest.php Verifies SQLite columns no longer expose after() and executes emitted ALTER TABLE DDL against an in-memory SQLite database.
tests/Query/Regression/SecurityRegressionTest.php Covers MongoDB filter-hook rejection while confirming supported attribute hooks and SQL tenant filtering remain functional.

Fix All in Greploop

Reviews (2): Last reviewed commit: "fix(schema): drop after() from SQLite, w..." | Re-trigger Greptile

Comment thread src/Query/Schema/Column/SQLite.php Outdated
Exposing after() on Column\SQLite carried forward a pre-existing bug: base
Schema::compileAlter() emits `AFTER <col>`, SQLite uses that base path, and
SQLite's ALTER TABLE ADD COLUMN has no AFTER clause, so the statement was a
syntax error at execution:

  ALTER TABLE `t` ADD COLUMN `b` INTEGER NOT NULL AFTER `a`
  -> SQLSTATE[HY000]: General error: 1 near "AFTER": syntax error

after() is now on MySQL and MariaDB only, the two dialects that accept it.

My audit missed this because the detector compared generated output with
and without each modifier and treated "the string changed" as honoured,
which says nothing about whether the result is valid DDL. Two tests close
that gap by executing the emitted statement instead of matching it: one
runs the SQLite ALTER against an in-memory database and checks the column
list via pragma_table_info, the other runs the SQLite COLLATE CREATE.

For the same reason the new collation() emission now has integration
coverage on MySQL and PostgreSQL, asserting COLLATION_NAME/collation_name
from information_schema rather than trusting the emitted string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant