Skip to content

Bring TableGateway and RowGateway to Mago compliance - #233

Open
simon-mundy wants to merge 13 commits into
php-db:0.6.xfrom
simon-mundy:qa/mago-gateways
Open

simon-mundy wants to merge 13 commits into
php-db:0.6.xfrom
simon-mundy:qa/mago-gateways

Conversation

@simon-mundy

@simon-mundy simon-mundy commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Takes src/TableGateway and src/RowGateway from 225 baselined findings to 12. It also fixes a composite-key bug in RowGateway::save().

Stacked on #232 (Sql DML), #231 (Ddl), #230 (Predicate) and #229 (baseline refresh). Only the last commit is new here.

Design: nullable dependencies with guards

$adapter, $table, $sql, $featureSet and $resultSetPrototype on AbstractTableGateway, and $table, $primaryKeyColumn and $sql on AbstractRowGateway, stay nullable. Subclasses can still fill them in before initialize() runs, so no subclass breaks. Every read now goes through a private guard that throws a catalogued exception when the value is unset:

Unset value Exception
adapter (getAdapter(), initialize()) TableGateway\Exception\RuntimeException::forMissingAdapter() (existing)
table (getTable(), initialize()) TableGateway\Exception\RuntimeException::forMissingTable() (existing)
featureSet, sql, resultSetPrototype after initialisation new TableGateway\Exception\RuntimeException::forUninitializedProperty()
row sql, primaryKeyColumn RowGateway\Exception\RuntimeException::forMissingSqlObject(), forMissingPrimaryKeyColumn() (existing)

Each of these used to end in a TypeError or a call on null.

Bug fixed

Before After
Saving a row with primary key ['one', 'two'] after changing only two reselected with one = NULL, and wrote an undefined-index warning Reselects with one = 1, two = 3

saveUpdateChangingOneColumnOfACompositeKeyReselectsByTheNewKey fails on #232.

Other behaviour changes

  • $statement->execute() returning null now throws forMissingStatementResult() from both gateways. It used to throw a TypeError.
  • RowGateway::save() throws forMissingSavedRow() when the saved row cannot be read back. It used to pass a non-array to populate().
  • AbstractRowGateway::offsetGet() returns null for an unknown column instead of raising an undefined-index warning. __get() still throws.
  • AbstractRowGateway::populate()'s first parameter is renamed from $rowData to $data, matching RowPrototypeInterface. A caller passing it by name as rowData: must update.
  • __clone() on a gateway that has not been initialised no longer calls clone null.
  • RowGatewayFeature checks that every primary key column it reads from MetadataFeature is a string, and throws the existing forUnusablePrimaryKey() otherwise. It also re-indexes the columns as a list, so RowGateway reads column [0] correctly for a keyed array.
  • RowGateway\Feature\FeatureSet is tagged @api instead of @final, matching TableGateway\Feature\FeatureSet.
  • executeDelete(), executeInsert() and executeUpdate() leave the statement's table as given. Delete, Insert and Update drop an alias when they render, so the SQL is unchanged, and a failed query no longer leaves the statement without its alias.

Types

  • New type aliases: GatewayTable and GatewayWhere on TableGatewayInterface, and GatewayJoin on AbstractTableGateway. They type the interface, both gateway classes, and the $where, $set and $joins arguments. $set uses the Sql ColumnValue alias.
  • Delete, Insert and Update::getRawState('table'), and Select::getRawState(Select::TABLE), get conditional return types. The gateways now read the table through these calls rather than indexing the whole raw state.
  • ResultInterface declares @extends Iterator<array-key, mixed>, which also clears four findings in Pdo\Result, ResultInterface and AbstractResultSet.
  • @throws tags carry the Sql RuntimeException from prepareStatementForSqlObject() through every gateway method it reaches.
  • Features use getAdapter() rather than the magic adapter property.

@api and mago.toml

  • Every gateway class, interface and exception is tagged @api, as in Bring Sql\Predicate to full Mago compliance #230 to Bring the Sql DML classes to Mago compliance #232.
  • AbstractTableGateway, AbstractRowGateway, both FeatureSets and EventFeature join the too-many-methods exclusions, with a justification comment. Their method count is the size of their public API: one method per gateway operation, hook or event.
  • offsetSet() and offsetUnset() return static for chaining where ArrayAccess declares void. Both carry a scoped @mago-expect analysis:incompatible-return-type.

Still baselined (12)

  • Class-level cyclomatic-complexity and kan-defect on AbstractTableGateway and AbstractRowGateway, and kan-defect on TableGateway\Feature\FeatureSet. Clearing these needs the classes split up.
  • Class-level cyclomatic-complexity on RowGatewayFeature. This one is new: the per-column string check pushes the class over the threshold, and every shorter form I tried still crossed it.
  • no-boolean-flag-parameter on populate(array $data, bool $rowExistsInDatabase), a public signature.
  • Five less-specific-nested-argument-type findings where row data reaches Delete::where(), Select::where(), Insert::values(), Update::set() and Update::where(). populate() accepts any array under RowPrototypeInterface, so the values can't be narrowed to ColumnValue without validating each one at runtime.

Result

Before (#232) After
Lint baseline (issues) 207 179
Analyzer baseline (issues) 492 303
src/TableGateway + src/RowGateway, both baselines 225 12
PHPUnit (unit) 2182 tests 2233 tests
Gateway line coverage 100% 100%
Infection covered MSI, gateways 100%* 100% (519 killed by tests, 20 by static analysis)

* Before this PR, 67 of the gateway mutants were "killed" only because they raised analyzer findings the baseline didn't cover. With the files clean, 39 of those survived at first. The new tests kill all of them: feature-event arguments, initialise-before-use for each operation, overridable execute hooks, closure paths and explicit columns.

mago format --check, lint, analyze and guard are clean.

The gateway dependencies stay nullable so subclasses can still fill them
in before initialize(). Each read now goes through a private guard that
throws a catalogued exception when the value is unset:
RuntimeException::forUninitializedProperty() for featureSet, sql and
resultSetPrototype, and the existing forMissingAdapter(),
forMissingTable(), forMissingSqlObject() and forMissingPrimaryKeyColumn()
elsewhere. A statement that returns no result now throws
forMissingStatementResult() instead of a TypeError, and RowGateway::save()
throws forMissingSavedRow() when the saved row cannot be read back.

Saving a row whose composite primary key changed in only one column
reselected with null for the unchanged columns. It now reselects by the
new key.

getRawState('table') on Delete, Insert and Update, and
getRawState(Select::TABLE), get conditional return types so the
gateways read a typed table. ResultInterface declares its Iterator
template, which also clears four Adapter and ResultSet findings.
RowGatewayFeature checks that each primary key column from metadata is a
string.

Every gateway class and interface is tagged @api. AbstractTableGateway,
AbstractRowGateway, both FeatureSets and EventFeature join the
too-many-methods exclusions.

Twelve gateway findings stay baselined:

- class-level cyclomatic-complexity and kan-defect on the two abstract
  gateways and TableGateway\Feature\FeatureSet, and cyclomatic-complexity
  on RowGatewayFeature;
- no-boolean-flag-parameter on populate(), a public signature;
- five less-specific-nested-argument-type findings where row data reaches
  the Sql builders: populate() accepts any array per RowPrototypeInterface,
  so the values cannot be narrowed to ColumnValue without validating them.
@codecov

codecov Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@simon-mundy simon-mundy self-assigned this Oct 11, 2026
@simon-mundy simon-mundy added bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request qa Improvements in quality assurance of the project labels Oct 11, 2026
@simon-mundy simon-mundy added this to the 0.6.0 milestone Oct 11, 2026
simon-mundy and others added 12 commits October 12, 2026 13:17
The protected property is restored to match its TableGateway twin, so
subclasses that read it keep working. The class was the only one in
src tagged @Final, which made the analyzer treat the protected property
as private and report it unused. It is tagged @api instead, like the
TableGateway FeatureSet and every other gateway class.
executeDelete(), executeInsert() and executeUpdate() replaced an aliased
table on the statement with the bare table before preparing it, then
put the alias back afterwards. Delete, Insert and Update now drop the
alias themselves when they render, through
AbstractPreparableSql::unaliasTable(), so the swap no longer changes
the SQL. It also left the caller's statement holding the bare table if
preparing or executing threw, because the restore was not in a finally
block.

The swap and the private unaliasTable() helper are removed, so the
gateway no longer modifies the statement it is given. The three
*ShouldResetTableToUnaliasedTable tests, which asserted the swapped
state, now assert that the statement keeps its alias.
dataChangingCallOnAliasedTableRendersTheBareTable still covers the
rendered SQL.
GatewayTable and GatewayWhere describe the TableGatewayInterface
contract, so the interface defines them. AbstractTableGateway and
TableGateway import them from the interface, and GatewayJoin, used only
by AbstractTableGateway, stays there.
…sTable()

PHPUnit reports a CoversMethod target that no longer exists as a
warning, which fails the coverage run.
PHPUnit on PHP 8.4 rejects the private static unaliasTable() as a
CoversMethod target, which failed the coverage run. CoversClass also
attributes the private helpers without naming each one.
@simon-mundy
simon-mundy requested a review from tyrsson October 12, 2026 05:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request qa Improvements in quality assurance of the project

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

1 participant