Skip to content

Remove the last intermediate throws and guard against new ones - #75

Merged
veewee merged 15 commits into
mainfrom
throwless/03-dependencies-and-guard
Oct 15, 2026
Merged

veewee merged 15 commits into
mainfrom
throwless/03-dependencies-and-guard

Conversation

@veewee

@veewee veewee commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Stacked on #74 (#74), which sits on #73 (#73). This PR removes the last intermediate throws from the suite and adds a CI guard that fails as soon as one comes back.

Merging is blocked on one release. php-soap/engine#30 is released as 2.21.0 and this PR now requires ^2.21. veewee/reflecta still comes from a branch through a VCS repository: dev-feature/optional-lenses as 1.1.0 becomes ^1.1 once veewee/reflecta#39 is released, and the VCS repository entry goes away at the same time.

Soap-enc array item types

A soap-enc array with a builtin item type, such as xsd:int or xsd:anyType, looked that type up in the metadata, missed, and caught the MetadataException to fall back to xsd:anyType. php-soap/engine#30 adds TypeCollection::findByNameAndXmlNamespace(), which returns null on a miss, and SoapArrayAccess now uses it with the same fallback. The old try_catch also wrapped $context->metadata->getTypes(), so an exception while loading the types fell back to xsd:anyType as well. Now only a missing type falls back, and a loading error propagates.

Guard

composer throws:report now exits with 1 when the suite makes an intermediate throw, and the Throws CI job runs it after the no-throws group. It counts the same throws the report from #73 counts: the stack passes through an encode or decode in src/, and the test does not expect an exception.

Deliberate throws are listed in tests/Support/Throws/allowed-throws.php, one entry per test, exception class and the src file plus the function it calls there, each with the number of throws it may make and the reason. There are two entries, both in ObjectEncoderCustomLensTest, whose custom lenses throw on purpose to prove that they still get the optional() and runLens treatment. Any throw past the listed count fails the run, and so does an entry that no longer throws as often as listed, which keeps the list from growing stale. There is no general budget.

PHPUnit extensions cannot set the exit code, so the extension calls exit(1) from an Application\Finished subscriber. That runs after PHPUnit has printed its own output, and only when PHPUnit would have exited with 0.

To check that the guard catches a regression, I put optional(index($name)) back as the default decoder lens. The report then showed 57 ArrayAccessException throws in 19 tests and exited with 1.

The four Psl AssertException throws in ElementAwareEncoderTest did not belong on the allowlist. The test registers its own anyType encoder, which called veewee/xml's document_encode(), and its element builder checks @attributes, @namespaces, @cdata and @value through Psl unions that throw on null. The fixture now writes its element with the veewee/xml writer, and the test still checks that an element-aware simple type encoder writes its own element.

Throw report

composer throws:report on the suite of this stack, so including the scenarios #73 added. The suite of main on its own had 303 intermediate throws in 110 tests.

Site Main on reflecta 1.0.0 This PR
reflecta UnreflectableException on stdClass hydration 202 0
Psl CoercionException and RestrictionException in the untyped scalar chain 98 0
reflecta ArrayAccessException for an absent element or attribute 36 1, allowed
reflecta UnreflectableException and Error for an absent or uninitialised property 37 1, allowed
engine MetadataException for a builtin soap-enc item type 23 0
Psl AssertException from a test encoder that calls veewee/xml 4 0
Total 400 in 120 tests 0, plus 2 allowed

Benchmarks

vendor/bin/phpbench run benchmarks/ --report=aggregate with Xdebug off, PHP 8.4.13 on macOS, against the baseline stored for #73. The figures for this PR are the mean of three runs.

Benchmark Subject Main This PR Diff
EncodeBench benchLiteral_x1 73.205μs 71.827μs -1.88%
EncodeBench benchLiteral_x500 35.620ms 36.208ms +1.65%
EncodeBench benchEncoded_x1 116.652μs 119.125μs +2.12%
EncodeBench benchEncoded_x500 57.515ms 59.139ms +2.82%
EncodeBench benchExtSoapLiteral_x1 7.117μs 7.680μs +7.91%
EncodeBench benchExtSoapLiteral_x500 3.768ms 3.848ms +2.13%
EncodeBench benchExtSoapEncoded_x1 11.428μs 12.623μs +10.46%
EncodeBench benchExtSoapEncoded_x500 5.904ms 6.359ms +7.71%
DecodeBench benchLiteral_x1 85.347μs 64.570μs -24.34%
DecodeBench benchLiteral_x500 39.891ms 33.015ms -17.24%
DecodeBench benchLiteralSparse_x1 76.028μs 50.109μs -34.09%
DecodeBench benchLiteralSparse_x500 38.164ms 25.487ms -33.22%
DecodeBench benchEncoded_x1 116.398μs 100.479μs -13.68%
DecodeBench benchEncoded_x500 57.570ms 50.033ms -13.09%
DecodeBench benchExtSoapLiteral_x1 10.434μs 11.450μs +9.74%
DecodeBench benchExtSoapLiteral_x500 5.532ms 5.768ms +4.27%
DecodeBench benchExtSoapEncoded_x1 15.006μs 16.546μs +10.26%
DecodeBench benchExtSoapEncoded_x500 8.055ms 8.427ms +4.62%

The ExtSoap subjects run ext-soap and none of this package's code. Their +2% to +10% is how far this machine has drifted since the baseline was stored, and #74 measured in the same session shows the same drift. For that reason I report without a phpbench --assert: with a 5% tolerance it fails on the ExtSoap subjects alone, while every subject that runs this package's code passes it. Against #74 this PR is flat within noise, which is expected because the benchmark fixture has no soap-enc arrays. Decode is 13% to 24% faster than main on the complete fixture and 33% to 34% on the sparse one. Encode stays within 3%.

3MB responses

The benchmark fixture has 12 elements, too small to show what #70 reported (6s to 0.6s on a 3MB response). So I built a synthetic list of items with 30 optional elements and 5 optional attributes each and ran it through main, the head of #70 and this PR, with reflecta 1.1.0 and engine 2.21.0 installed in all three. Sparse has 3 elements and 1 attribute present per item (41,000 items, 3.04MB), dense has 28 and 4 (5,400 items, 3.02MB). Seconds, mean of two interleaved rounds of medians, Xdebug off. All three produce identical XML and identical decoded values.

Decode main #70 This PR vs main vs #70
sparse, stdClass 4.218 1.424 0.675 6.2x 2.1x
sparse, class map 4.687 1.877 1.091 4.3x 1.7x
dense, stdClass 0.450 0.398 0.294 1.5x 1.4x
dense, class map 0.509 0.468 0.358 1.4x 1.3x
Encode main #70 This PR vs main vs #70
sparse, stdClass 2.882 2.902 1.595 1.8x 1.8x
sparse, class map 2.856 2.835 1.740 1.6x 1.6x
dense, stdClass 0.929 0.929 0.745 1.2x 1.2x
dense, class map 0.920 0.926 0.758 1.2x 1.2x

Main already benefits from reflecta 1.1.0 here, because ^1.0 resolves to it: with reflecta 1.0.0 the sparse stdClass decode on main took 7.05s, so against that the stack is 10.4x faster. The gain grows with the share of absent fields.

Summary by CodeRabbit

  • Bug Fixes
    • Improved object encoding and decoding with custom accessors when XML elements or object properties are missing.
    • Improved untyped scalar decoding for integers, numeric values, and boolean literals while preserving other strings.
    • Improved SOAP array handling when matching type information is unavailable.

veewee/reflecta#39 adds optional_index() and optional_property(), stacked on #37. Until 1.1.0 is tagged the branch comes in through a VCS repository aliased as 1.1.0, so this cannot be merged before the constraint is back on a release.
The built-in lenses were index() and property(), which throw for every absent element, attribute or property, and the exception was caught again by optional() or runLens. reflecta's optional_index() and optional_property() return null in those cases without throwing, which is what the caught exception turned into before, so they replace the built-in lenses unwrapped whether or not the property is optional.

Custom lenses from ProvidesObjectEncoderLens and ProvidesObjectDecoderLens keep the optional() wrapping and the runLens catch.
An untyped value went through Psl's int, float and bool coercion in turn and every miss threw a CoercionException, so a plain string threw three or four times. The decoder now checks the same rules up front: the int and float checks repeat Psl's string coercion, including its trimming of leading zeros that decodes '0-5' as -5. The test compares the result with Psl itself over every short string, so a Psl upgrade that changes those rules shows up as a failure.
php-soap/engine#30 adds TypeCollection::findByNameAndXmlNamespace(). Until it is released, the branch is aliased as 2.21.0 so php-soap/wsdl-reader's ^2.20 constraint still resolves.
Every soap-enc array of a builtin item type, such as xsd:int or xsd:anyType, missed the metadata lookup and threw a MetadataException before falling back to xsd:anyType. The engine's findByNameAndXmlNamespace() returns null instead, so the fallback no longer needs an exception.
veewee/xml's document_encode() checks every element for @attributes, @namespaces, @cdata and @value through Psl unions that throw on null, so the fixture threw four times per encode. Writing the element directly keeps what the test proves: an element-aware simple type encoder writes its own element.
composer throws:report now exits with 1 when an encode or decode throws internally in a test that does not expect an exception. The custom lens tests throw on purpose, so they are listed per test and site in allowed-throws.php with the number of throws they may make; an entry that no longer throws as listed fails the run too, so the list only shrinks.
A line number in allowed-throws.php broke the guard on any unrelated edit above the call. The entry is already scoped to one test with an exact count, so the file and the function it calls are precise enough.
@veewee

veewee commented Oct 14, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8752fbb2-5c07-4aa7-bede-fd8d897b7750
📥 Commits

Reviewing files that changed from the base of the PR and between bb3ad0e and c59f0de.

📒 Files selected for processing: 17
  • .github/workflows/throws.yaml
  • composer.json
  • src/Encoder/ObjectAccess.php
  • src/Encoder/SimpleType/ScalarTypeEncoder.php
  • src/Encoder/SoapEnc/SoapArrayAccess.php
  • tests/Fixture/Encoder/ThrowingLensEncoder.php
  • tests/Integration/IntermediateThrowScenariosTest.php
  • tests/Integration/NoIntermediateThrowsTest.php
  • tests/Support/Throws/AllowedThrows.php
  • tests/Support/Throws/ThrowAttribution.php
  • tests/Support/Throws/ThrowAttributionExtension.php
  • tests/Support/Throws/ThrowEvent.php
  • tests/Support/Throws/allowed-throws.php
  • tests/Unit/Encoder/Feature/ElementAwareEncoderTest.php
  • tests/Unit/Encoder/ObjectEncoderCustomLensTest.php
  • tests/Unit/Encoder/SimpleType/ScalarTypeEncoderUntypedDecodeTest.php
  • tests/Unit/Support/Throws/AllowedThrowsTest.php
+--------------------------------------+
| All those GPUs aren't just for show. |
+--------------------------------------+
  \
   \     /)/)
        ( '')
        /  |
      o(___)
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@veewee

veewee commented Oct 14, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Pull request base or head changed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Arrays of complex types, arrays of arrays, empty arrays, nil items, an
xsi:type per item, apache maps, soap-enc structs, SOAP 1.2 encoded arrays
and SOAP 1.2 envelopes all encode and decode without intermediate throws.
These scenarios keep it that way.
@veewee

veewee commented Oct 14, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@veewee
veewee added this pull request to stack #77 October 15, 2026 06:54
Base automatically changed from throwless/02-throwless-own-code to main October 15, 2026 07:28
# Conflicts:
#	src/Encoder/SimpleType/ScalarTypeEncoder.php
#	tests/Integration/NoIntermediateThrowsTest.php
#	tests/Unit/Encoder/SimpleType/ScalarTypeEncoderUntypedDecodeTest.php
@veewee
veewee merged commit 0eaf510 into main Oct 15, 2026
23 checks passed
@veewee
veewee deleted the throwless/03-dependencies-and-guard branch October 16, 2026 08:49
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