Skip to content

Add #[JsonProperty] attribute and runtime improvements#8

Merged
thiagocordeiro merged 8 commits intomainfrom
feature/json-property-attribute
May 3, 2026
Merged

Add #[JsonProperty] attribute and runtime improvements#8
thiagocordeiro merged 8 commits intomainfrom
feature/json-property-attribute

Conversation

@thiagocordeiro
Copy link
Copy Markdown
Contributor

Summary

Stacks four related improvements toward a stable v1, ending in a Jackson-style #[JsonProperty] attribute. Each commit is self-contained and reviewable on its own.

  • Improve error paths and reject invalid bool inputreadList/readArrayMap and RuntimeWriter now append the concrete index/key to the error path instead of a generic []. readBool switches to FILTER_NULL_ON_FAILURE and throws UnableToParseValue for unparseable input rather than silently coercing to false.
  • Move RuntimeTypeNodeFactory cache to instance state — was process-wide static, now per-instance. Tests no longer leak through it.
  • Share a single TypeNodeFactory across reader, writer and specArrayObjectMapper/JsonObjectMapper accept an optional TypeNodeFactory and wire defaults to share it, collapsing three duplicate caches into one. defaultTypeReader/defaultTypeWriter become nullable.
  • Add #[JsonProperty] attribute — pin the JSON wire name independently of the PHP identifier on both read and write paths. Error traces and the specification factory output use the wire name. New SnakeCaseDto fixture and JsonPropertyTest cover read, write, error path, and spec.

Test plan

  • composer test:unit — existing suite stays green (no fixture used key, so defaults preserve current behavior)
  • composer test:unit — new JsonPropertyTest passes (read, write, error path, spec)
  • composer test:stan at level=max stays clean
  • Manual round-trip of {"created_at": "2025-01-01"} through a class with #[JsonProperty('created_at')] DateTimeImmutable $createdAt — confirms DateTime mapper composition still works

Suite cannot run locally until tcds-io/php-better-generics is tagged with TypeParser and the generic/shape helpers under fn/functions.php.

🤖 Generated with Claude Code

thiagocordeiro and others added 8 commits May 3, 2026 08:57
- RuntimeWriter: fix "valur" typo in exception message
- RuntimeReader: rename shadowed $node in readValues catch block
- RuntimeTypeNodeFactory: tighten $nodes cache to private
- SerializerTestCase: remove unused initializeReadNodes/initializeNode
  helpers (referenced a node property that no longer exists on InputNode)
- CI: drop deprecated --no-suggest flag from composer install
- Bump PHPUnit to 12.5.24 to clear CVE-2026-24765

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- RuntimeReader::readList/readArrayMap: append item index to error
  path (e.g. .addresses.[2].place) so failures inside collections are
  pinpointable instead of being collapsed onto the parent path.
- RuntimeWriter: same treatment when writing arrays — replace the
  generic '[]' segment with the concrete key.
- RuntimeReader::readBool: switch from FILTER_VALIDATE_BOOL (which
  silently coerces unparseable input to false) to FILTER_NULL_ON_FAILURE
  and throw UnableToParseValue when the value cannot be coerced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The $nodes cache was a private static array, which made it process-wide
shared. That meant test cases leaked state between each other and two
factories with different intended scopes silently saw each other's
entries.

Switch to a private instance array. Each factory now owns its own cache
for its lifetime. Behavior for a single long-lived mapper is unchanged;
fresh mappers (and tests) start with a clean slate as expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously a default ArrayObjectMapper instantiated three separate
RuntimeTypeNodeFactory instances — one inside the default RuntimeReader,
one inside the default RuntimeWriter, and one inside the default
RuntimeTypeNodeSpecificationFactory. Each kept its own cache for the
same types, so identical TypeNodes were rebuilt up to three times per
mapper.

Add an optional TypeNodeFactory parameter to ArrayObjectMapper (and
JsonObjectMapper, which delegates to it). When the caller doesn't pass
explicit reader/writer, we wire them up to share that single factory,
collapsing the three caches into one. Callers that pass their own
reader/writer keep full control as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors Jackson's @JsonProperty so users can pin a JSON key
independently of the PHP identifier. Supports both read and write
directions and propagates the wire name into error paths and the
specification factory output.

Implementation:
- New attribute Tcds\Io\Jackson\Node\JsonProperty (TARGET_PARAMETER |
  TARGET_PROPERTY) so it works on promoted constructor params and on
  plain properties alike.
- InputNode gains an optional ?string $key with a key() accessor that
  falls back to $name. RuntimeReader uses key() for both data lookup
  and error path; the named-arg key into `new $class(...$values)`
  stays as $name (constructor still expects PHP names).
- OutputNode::property/method gain an optional $key; when set, $name
  becomes the wire key and $accessor stays as the PHP identifier used
  by read().
- RuntimeTypeNodeFactory::fromClass reads JsonProperty off both
  ReflectionMethodParameter and ReflectionProperty via a small
  jsonKey() helper and threads it into the node constructors.
- RuntimeTypeNodeSpecificationFactory now keys the spec by the wire
  name so users see the JSON shape they will actually send.

Tests:
- New SnakeCaseDto fixture mixing two #[JsonProperty('snake_case')]
  fields with one untouched field.
- JsonPropertyTest covers read (array + JSON), write (array + JSON),
  error path containing the wire key, and the specification output.

Out of scope: shape-type keys (already explicit), multi-alias support,
duplicate-wire-key validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PHPStan level=max wants explicit T on Reader/Writer property
declarations. The promoted-property version inherited these from
the constructor docblock; the explicit declarations introduced in the
shared-factory commit dropped them. Restore @var Reader<mixed> and
Writer<mixed> on the class properties.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a required field is missing at the top level, readClass catches
the constructor TypeError and rethrows UnableToParseValue with the
*incoming* path (empty for the root) — same behavior as
ErrorHandlingTest::missing_root_value. So the wire key only enters
the path when the failure happens inside a nested class.

Add SnakeCaseWrapper fixture (a class with one #[JsonProperty] field
that itself holds a SnakeCaseDto) and assert the wire key appears in
the path when the inner DTO fails.

Also split out a separate test for $exception->expected so coverage
of "spec uses wire keys" is exercised both at factory level and via
the live exception.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SnakeCaseWrapper had a single field, so readValues took the
count===1 shortcut into readValueObject, which doesn't accumulate
the wire key onto the path before recursing. The inner DTO failure
therefore surfaced with path=[].

Add a second field on the wrapper so the main foreach in readValues
runs and threads the wire key into innerpath as expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thiagocordeiro thiagocordeiro merged commit e6a1f91 into main May 3, 2026
1 check passed
@thiagocordeiro thiagocordeiro deleted the feature/json-property-attribute branch May 3, 2026 15:32
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