Repository navigation
[RFC]: Named exception constructors and complete per-component exception namespaces #189
Description
Proposed Version
0.6.0
Basic Information
Stop building exception messages at the throw site. Every throw goes through a static named constructor on a component-owned exception class, with the message template beside it as a final public const string.
src/ has 119 throw new sites. Nearly all of them build the message on the spot, with concatenation, sprintf() or interpolation. Several build the same message in more than one class, and where they do the wording has already drifted apart.
The throw site should name the problem and nothing else:
namespace PhpDb\TableGateway\Exception;
use PhpDb\Exception;
use function sprintf;
class RuntimeException extends Exception\RuntimeException implements ExceptionInterface
{
final public const string NO_PRIMARY_KEY = 'No information was provided to the RowGatewayFeature'
. ' and/or no MetadataFeature could be consulted to find the primary key'
. ' necessary for RowGateway object creation.';
final public const string INVALID_MAGIC_CALL = 'Invalid method (%s) called, caught by %s::__call()';
public static function forNoPrimaryKey(): self
{
return new self(self::NO_PRIMARY_KEY);
}
public static function forInvalidMagicCall(string $method, string $class): self
{
return new self(sprintf(self::INVALID_MAGIC_CALL, $method, $class));
}
}- Named constructors are
static, returnselfand start withfor. - Each message is a single
final public const string. - Use
new self(self::CONST)when the template has no placeholder,new self(sprintf(self::CONST, <args>))when it does.
Convention
Naming a condition in a class instead of a method breaks the convention rather than following it. A condition earns its own class only when it carries something a named constructor can't:
- Extra state, with accessors.
Adapter\Exception\InvalidConnectionParametersExceptionholds$parameters.Exception\ErrorExceptionwraps a driver errno and message as a$previouscause, which is what the adapters build it for. - An external interface contract.
Exception\ContainerExceptionimplements PSR-11'sContainerExceptionInterface. - A condition a consumer can act on.
Adapter\Exception\InvalidQueryExceptionis thrown by all three adapters and caught to log the SQL or retry.
Everything else is a named constructor on the component's SPL-shaped class. Adapter\Exception\VunerablePlatformQuoteException meets none of the three.
Strategy
- Retype
TableGateway\Exception\RuntimeException. It extendsPhpDb\Exception\InvalidArgumentExceptiontoday, so it isn't a\RuntimeExceptionat all. It will extendException\RuntimeException. This breaks anyone catching it by the SPL type, and that's accepted. Exceptions need to be correct, and shipping aRuntimeExceptionthat is anInvalidArgumentExceptioninto 1.0 is worse. - Add
PhpDb\Metadata\Exception.Metadatais the only component with no exception namespace, and its 7 bare\Exceptionthrows are invisible tocatch (PhpDb\Exception\ExceptionInterface). It getsExceptionInterface,InvalidArgumentExceptionandRuntimeException, matching every other component. - Converge the drifted messages, quoting caller-supplied identifiers.
'The PDO param "%s" contains invalid characters.'wins over the unquoted variant, and'Table "%s" does not exist'wins over'"%s" does not exist'. The placeholder holds a name the caller passed in, so quoting shows where the name ends. Same rule everywhere: quote a caller-supplied identifier, leave a class name, method name or count unquoted. - Remove
VunerablePlatformQuoteExceptionrather than rename it. It fails all three tests above, so the condition becomes a method. Details under Considerations.
Background
Counts and line references are from 8bc38ea on 0.6.x.
| Component | Throw sites |
|---|---|
Sql |
51 |
TableGateway |
27 |
Adapter |
24 |
RowGateway |
7 |
Metadata |
7 |
Container |
2 |
ResultSet |
1 |
| Class thrown | Count |
|---|---|
Exception\RuntimeException |
44 |
Exception\InvalidArgumentException |
44 |
unqualified RuntimeException |
10 |
unqualified InvalidArgumentException |
7 |
bare \Exception |
7 |
LogicException |
3 |
Exception\InvalidQueryException |
2 |
ValueError |
1 |
ServiceNotFoundException |
1 |
Consolidation
'Identifier must be specified'appears four times.Sql\Predicate\In:43,IsNull:34andLike:41throw it asInvalidArgumentException;Between:50throws it as a bare\LogicException. Same precondition, same words, two unrelated types.'$prefix must be a valid table prefix or null, empty string given'and'$separator must be a valid table separator, empty string given'are each written twice, inSql\TableIdentifier:34,40andSql\TableIdentifierFactory:28,34.'%s expects parameter to be numeric, "%s" given'is written twice in one class,Sql\Select:318and:335.'Invalid magic property access in 'is concatenated twice, inTableGateway\AbstractTableGateway:451and:466.'"%s" does not exist'(Metadata\Source\AbstractSource:173) and'Table "%s" does not exist'(:380) report the same missing table two different ways.
Gaps
PhpDb\Metadatahas noExceptionnamespace.Metadata\Source\AbstractSourcethrows bare\Exceptionat lines 131, 173, 220, 380, 394, 474 and 554, two of them with an interpolated table, trigger or view name.Sql\AbstractSql:290throws\ValueError('Invalid Argument type').Sql\Predicate\Between:50,54,58throws\LogicException.
None of them implement PhpDb\Exception\ExceptionInterface, so a consumer catching the package's own marker interface misses them.
Defects
src/TableGateway/Exception/RuntimeException.phpextendsPhpDb\Exception\InvalidArgumentException.catch (\RuntimeException)misses it andcatch (\InvalidArgumentException)catches it. All 27TableGatewaythrows that use it are mis-typed as a result.Exception\ContainerExceptionimplements PSR-11'sContainerExceptionInterfacebut notPhpDb\Exception\ExceptionInterface, unlike every other exception in the package.
Other considerations
Why the constants are final. The component base classes stay non-final, because the adapter packages extend Adapter\Exception\RuntimeException and InvalidConnectionParametersException and InvalidQueryException already do. Without final, a subclass could quietly redefine a published message template. Typed constants need PHP 8.3 and final const needs 8.1; the floor is ~8.3.0, so nothing moves.
The constants become public API. Once published, RuntimeException::NO_PRIMARY_KEY is something a consumer can reference and a downstream package can assert on. That's what lets tests and adapters stop hard-coding literals, but it also puts the constant names under BC policy. Settle the naming in PR 1, not in PR 5.
Message text only moves where it had drifted. Every existing message stays byte-identical except the drifted pairs, which converge on one wording each: the PDO-param message on the quoted form, and Metadata\Source\AbstractSource's two "table does not exist" messages on 'Table "%s" does not exist', matching the Trigger "%s" and View "%s" wording already in that class. Everything else keeps the 128 message assertions green and keeps each PR reviewable as a mechanical change.
Five changes break a catch, all on purpose. Metadata's bare \Exception becomes typed. Between's \LogicException and AbstractSql's \ValueError become PhpDb types. TableGateway\Exception\RuntimeException changes parent. VunerablePlatformQuoteException goes. catch (\LogicException) and catch (\ValueError) around those call sites stop matching. 0.5.0 is the current tag and 0.6.0 is unreleased, so this is the window. If the TSC would rather keep the SPL parents, Sql\Exception\LogicException extends \LogicException and Sql\Exception\ValueError extends \ValueError both work, at the cost of two more classes.
VunerablePlatformQuoteException is removed, not renamed. It holds no state, implements no external contract, and reports a setup error a caller can't recover from: quoting without extension or driver support. It fails all three tests, so its two throw sites (Adapter\Platform\Sql92:43 and AbstractPlatform:147) move to Adapter\Exception\RuntimeException::forVulnerablePlatformQuote(string $platformName, string $methodName). The misspelling goes with the class.
There's no BC shim for this one. The class is released (0.4.1, 0.5.0) and final, so nobody can have subclassed it, but a class_alias() to Adapter\Exception\RuntimeException would be worse than the break: catch (VunerablePlatformQuoteException) would quietly start catching every adapter runtime failure. So remove it and note it in the changelog. It already extends Adapter\Exception\RuntimeException, so anyone catching that is unaffected. In-org cost is 15 test references: 10 in core, 3 in phpdb-pgsql, 2 in phpdb-sqlite, none in phpdb-mysql.
Adapter packages. phpdb-mysql, phpdb-pgsql and phpdb-sqlite can use core's new named constructors straight away, but adapter-specific messages don't belong in core constants. Each adapter needs its own Exception namespace and its own issue. This RFC covers core, and should land first so the adapters have a pattern to copy.
Tests. Moving 128 message assertions from literals to Exception::CONST is mechanical but large, and it's what makes the constants worth having. It can go in its own PR.
Coverage. The repo targets 100%. Every named constructor is a new public method and needs a test for both the type and the rendered message. A #[DataProvider] over [factory args, expected message] per exception class stops that becoming 119 near-identical test methods.
Baselines. analyzer-baseline.toml and lint-baseline.toml both move, so each PR regenerates them. #182's typing work touches some of the same files.
Proposal(s)
One PR per item, each against this issue. Items 2 to 6 are independent of each other and depend only on item 1.
- PR 1: convention and hierarchy fixes. Document the convention and the three tests for when a condition earns its own class, in
docs/book/. PointTableGateway\Exception\RuntimeExceptionatException\RuntimeException. MakeException\ContainerExceptionimplementException\ExceptionInterface. No throw sites change. - PR 2: add
PhpDb\Metadata\Exception. NewExceptionInterface,InvalidArgumentExceptionandRuntimeExceptionmatching the other components. Replace the 7 bare\Exceptionthrows inMetadata\Source\AbstractSourcewithforUnknownColumn(),forUnknownTable(),forUnknownConstraint(),forUnknownTrigger(),forUnknownView()andforUnsupportedTableType(), converging the two "table does not exist" messages on one template. - PR 3:
Sql, 51 sites. Named constructors acrossSql,Sql\PredicateandSql\Ddl. Hoist'Identifier must be specified'to one constant shared byIn,IsNull,LikeandBetween. MoveBetweenonto the sameInvalidArgumentExceptionas its siblings. GiveAbstractSql:290a typed exception. Dedupe the twoSelectnumeric messages and the fourTableIdentifierandTableIdentifierFactorymessages. - PR 4:
TableGatewayandRowGateway, 34 sites. Named constructors. Collapse the two'Invalid magic property access'concatenations and theRowGatewayFeaturefour-line concatenation into templates. - PR 5:
Adapter, 24 sites. Named constructors. Converge the two PDO-param messages on the quoted template. KeepInvalidConnectionParametersException's$parametersargument by giving it a named constructor that takes it. RemoveVunerablePlatformQuoteException, move its two throw sites toRuntimeException::forVulnerablePlatformQuote(), and update the 15 test references across core,phpdb-pgsqlandphpdb-sqlite. - PR 6:
ResultSet,Containerand the test migration. The remaining 3 sites, then move the 128 message assertions intest/onto the constants.
Test plan, per PR
- Every named constructor gets a unit test for the returned type and the rendered message, driven by a
#[DataProvider]per exception class. - Existing tests pass unchanged, since message text doesn't move. The exceptions are the PDO-param and
Betweentests, which change in their own PRs with the reason in the PR body. - Each PR runs
magolint and format and the analyzer, then regeneratesanalyzer-baseline.tomlandlint-baseline.toml. - One new test asserts that every class in
src/**/Exception/implementsPhpDb\Exception\ExceptionInterfaceand extends the SPL class its name claims, so theTableGateway\Exception\RuntimeExceptiondefect can't come back.
Appendix/Additional Info
Already in the tree: Adapter\Exception\VunerablePlatformQuoteException::forPlatformAndMethod() and Exception\ContainerException::forService().
Related issues: #182 typing inconsistency, which touches some of the same files; #22 1.0.0 scope of work; #8 document all members.
Out of scope: an Exception namespace and named constructors for phpdb-mysql, phpdb-pgsql and phpdb-sqlite. One issue each, after this lands.
Audit basis: 8bc38ea on 0.6.x.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
- StatusShow more project fieldsDone
- StatusShow more project fieldsDone
Activity
simon-mundy commentedon Sep 28, 2026
First two PRs are open:
Metadata\Exceptionnamespace (stacked on Introduce the named exception constructor convention #190)Two things found while building them, both recorded in the PR bodies:
AbstractSourcehad a second message drift the audit above missed:'"%s" does not exist'ingetColumnNames()against'Table "%s" does not exist'ingetTable(). Converged on the latter in Add the Metadata exception namespace #191, and the description above is updated.ErrorExceptionis misnamed in both namespaces: it extends\Exception, not\ErrorException. Left alone in Introduce the named exception constructor convention #190 with the reason in code, because\ErrorException's third constructor argument is$severitywhere\Exception's is$previous, and all three adapters construct it. Needs its own decision.tyrsson commentedon Sep 28, 2026
This RFC makes me realize that I need to update the RFC template to add a Accepted/Rejected pair of checkboxs for the RFC's
tyrsson commentedon Sep 28, 2026
I'm a +1
Introduce the named exception constructor convention
3 remaining items
Route the Adapter throw sites through named constructors and remove V…
simon-mundy commentedon Sep 28, 2026
All six PRs are open: #190, #191, #192, #193, #194, #195. Together they leave no
throw newanywhere insrc/.Two things from building #195 worth recording against the plan:
Container\AdapterInterfaceDelegatormust throw the ServiceManager's ownServiceNotFoundException, and Laminas annotates that class@final. It is notfinalin PHP today — reflection and the suite both accepted a subclass — but the annotation is intent, so extending it would break when Laminas enforces it. The template lives onException\ContainerException::MISSING_SERVICEand that one throw site renders it itself.docs/book/exceptions.mdrecords it.InvalidConnectionParametersExceptionkeeps its constructor rather than gaining a named constructor as the checklist said. Its message comes from the adapter that throws it, so there is no template for core to own.Also worth knowing for any future PR here: a PR that touches a method its test class has not declared in
#[CoversMethod]will failcodecov/patchwith passing tests, because PHPUnit attributes coverage only to declared methods. That is what failed #194 onAdapter::executeQuery(), and it is the same cause as the 3-of-36 reading noted on #192.Replace exception class scanning with an explicit class to parent map
Add the Metadata exception namespace
Route the Sql throw sites through named constructors
Route the Adapter throw sites through named constructors and remove V…
simon-mundy commentedon Sep 28, 2026
Sibling RFCs filed on the three adapters, so the convention lands consistently across the org:
ExceptionnamespaceExceptionnamespaceThe audit across all four packages turned up something none of them can see alone: nine conditions are worded slightly differently in each package, across roughly 25 throw sites. Examples —
Must be connected before you can rollbackis identical in core and pgsql but gains a full stop in mysql;This statement has been prepared alreadybecomesThis statement has already been preparedin mysql; the extension-not-loaded message saysadapterin core and mysql butDriverin pgsql; andConnection configuration must be an array of parameters passed via $options["connection"]exists four times across three adapters, once truncated.That makes the decision on this issue to publish templates as
final public const stringthe load-bearing one: where a condition is already named by core, the adapters should reuse the constant rather than redeclare the string. The open question the three RFCs all defer to is where a template shared between adapters but absent from core should live — the connection-configuration and connect-failure messages. phpdb-sqlite#41 is the smallest case and proposes settling it there.Also carried into the two affected RFCs: the
VunerablePlatformQuoteExceptionreferences in phpdb-pgsql and phpdb-sqlite tests, each as its own small PR sequenced after 0.6.0 ships.Route the Sql throw sites through named constructors
Make PhpDb\Exception\ExceptionInterface the only marker, extending Th…
Make PhpDb\Exception\ExceptionInterface the only marker, extending Th…
Add the Metadata exception namespace
Route the Sql throw sites through named constructors
Route the Adapter throw sites through named constructors and remove V…
simon-mundy commentedon Sep 29, 2026
All six PRs are merged. Verified against
0.6.x: format, lint and analyze all exit 0, 1934 tests pass, and the package is down to onethrow new—Container\AdapterInterfaceDelegator:40, the ServiceManager casedocs/book/exceptions.mdrecords as the single documented exception. 103 named constructors, one marker interface, and theEXCEPTION_CLASSESmap complete at 23/23 with every class reaching the marker and beingThrowable.The three adapter RFCs are amended to match, and unified in structure and language so they read as one set:
Each now states the single-marker rule rather than proposing an adapter-owned
ExceptionInterface, which no longer exists after #190. They share the same section structure, the same statement of the convention, the same considerations — including thetoo-many-methodsthreshold of 10, the#[CoversMethod]coverage-attribution trap that failed #194, and the data-provider coverage trap — and the same cross-package drift table.The one open decision they all defer to phpdb-sqlite#41: where a template shared between adapters but absent from core should live. Its three sites are all shared messages, so it is the cheapest place to settle it.