Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
"Bash(composer show:*)",
"Bash(php -r \":*)",
"Bash(gh pr:*)",
"Bash(xargs wc:*)"
"Bash(xargs wc:*)",
"Bash(tee /tmp/phpunit-output.txt)",
"Read(//tmp/**)"
]
}
}
44 changes: 39 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ user to resolve it.

Rules:

- Ask all foreseeable clarifying questions upfront in a single batch before work begins.
- When there are multiple clarifying questions to ask, ask them **one at a time**, in order of
dependency (earlier answers may resolve later questions). Wait for the answer before asking the
next question. This allows the user to discuss each point in depth without being overwhelmed by
a wall of questions.
- If new ambiguities emerge during execution that were not foreseeable upfront, pause and ask
follow-up questions before proceeding past that decision point.
- For high-stakes decisions (architecture, scope, data model, API shape, behaviour changes) always
Expand All @@ -19,6 +22,9 @@ Rules:
visible so the user can correct it.
- There must be no silent interpretation or interpolation of under-specified tasks. If something is
unclear, ask. Do not guess and proceed.
- For multi-phase implementations, **never start the next phase without an explicit go-ahead from
the user**. After completing a phase, summarise what was done and wait for confirmation before
proceeding.

When generating a new CLAUDE.md for a repository, include this clarification policy verbatim as a
preamble before all other content.
Expand Down Expand Up @@ -48,6 +54,20 @@ composer update

Tests write generated PHP classes to `sys_get_temp_dir()/PHPModelGeneratorTest/Models/` and dump failed classes to `./failed-classes/` (auto-cleaned on bootstrap).

### Running the full test suite

When running the full test suite, always save output to a file so the complete
output is available for analysis without re-running. Use `--display-warnings` to capture warning
details and `--no-coverage` to skip slow coverage collection:

```bash
php -d memory_limit=128M ./vendor/bin/phpunit --no-coverage --display-warnings 2>&1 | sed 's/\x1b\[[0-9;]*m//g' > /tmp/phpunit-output.txt; tail -5 /tmp/phpunit-output.txt
```

Then analyse with: `grep -E "FAIL|ERROR|WARN|Tests:" /tmp/phpunit-output.txt`

After analysis is complete, delete the file: `rm /tmp/phpunit-output.txt`

## Architecture

This library generates PHP model classes from JSON Schema files. The process is a 4-step pipeline:
Expand All @@ -72,15 +92,25 @@ This library generates PHP model classes from JSON Schema files. The process is
### Schema Processing

`SchemaProcessor` (`src/SchemaProcessor/SchemaProcessor.php`) orchestrates property parsing:
- Uses `PropertyProcessorFactory` to instantiate the correct processor by JSON type (String, Integer, Number, Boolean, Array, Object, Null, Const, Any, Reference)
- Convention: processor class name is `PHPModelGenerator\PropertyProcessor\Property\{Type}Processor`
- Uses `PropertyFactory` (`src/PropertyProcessor/PropertyFactory.php`) to create and configure each property
- `PropertyFactory` resolves `$ref` references, delegates `object` types to `processSchema`, and for all other types constructs a `Property` directly and applies Draft modifiers
- `ComposedValueProcessorFactory` handles `allOf`, `anyOf`, `oneOf`, `if/then/else`, `not`
- `SchemaDefinitionDictionary` tracks `$ref` definitions to avoid duplicate processing

### Draft System (`src/Draft/`)

The Draft system defines per-type modifier and validator registrations:
- **`DraftInterface`** / **`DraftBuilder`** / **`Draft`** — Draft definition, builder, and built (immutable) registry
- **`Draft_07.php`** — The JSON Schema Draft 7 definition; registers all types, modifiers, and validator factories
- **`Element/Type`** — One entry per JSON Schema type; holds an ordered list of `ModifierInterface` instances
- **`Modifier/`** — `TypeCheckModifier`, `ConstModifier`, `NumberModifier`, `NullModifier`, `ObjectType/ObjectModifier`, `DefaultValueModifier`, `DefaultArrayToEmptyArrayModifier`; each implements `ModifierInterface::modify()`
- **`Model/Validator/Factory/`** — `AbstractValidatorFactory` subclasses keyed to schema keywords (e.g. `MinLengthPropertyValidatorFactory` for `minLength`); run as modifiers when a matching key exists in the schema

`PropertyFactory::applyDraftModifiers` resolves `getCoveredTypes($type)` (which always includes `'any'`) and runs every modifier for each covered type in order.

### Property Processors (`src/PropertyProcessor/`)

- `Property/` — One processor per JSON Schema type
- `ComposedValue/` — Processors for composition keywords
- `ComposedValue/` — Processors for composition keywords (`allOf`, `anyOf`, `oneOf`, `if/then/else`, `not`)
- `Filter/` — Custom filter processing
- `Decorator/` — Property decorators (ObjectInstantiation, PropertyTransfer, IntToFloatCast, etc.)

Expand Down Expand Up @@ -131,6 +161,10 @@ property, duplicate property names with unresolvable type conflicts, and any oth
that cannot produce a correct PHP model. Fail loudly at generation time so the developer sees the
problem immediately rather than receiving silently incorrect generated code.

### Reading files

Always use the dedicated `Read` tool to read file contents. Never use `sed`, `head`, `tail`, `cat`, or `awk` to read or extract portions of files. The `Read` tool supports `offset` and `limit` parameters for reading partial files when needed.

### PHP import style

Always add `use` imports for every class referenced in a file, including global PHP classes such as
Expand Down
Binary file modified docs/requirements.txt
Binary file not shown.
2 changes: 1 addition & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
language = 'en'

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
Expand Down
126 changes: 126 additions & 0 deletions docs/source/generator/custom/customDraft.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
Custom Drafts
=============

The *Draft* system defines which JSON Schema keywords are recognised during model generation and
how each keyword is translated into PHP code. Every ``GeneratorConfiguration`` has a
*draft* — by default ``AutoDetectionDraft``, which inspects the ``$schema`` keyword in each
schema file and picks the appropriate draft automatically.

You can replace the default draft with a concrete ``DraftInterface`` implementation to pin all
schemas to a single set of rules, or supply a ``DraftFactoryInterface`` implementation to choose
the draft dynamically per schema file.

Concepts
--------

Type
A named JSON Schema type (``"object"``, ``"string"``, ``"integer"``, ``"number"``,
``"boolean"``, ``"array"``, ``"null"``, or the virtual ``"any"`` type that applies to every
property regardless of its declared type).

Each type entry holds an ordered list of *modifiers*.

Modifier (``ModifierInterface``)
A unit of work that reads the raw JSON Schema for a property and modifies the in-memory
``PropertyInterface`` object — adding validators, decorators, type hints, or any other
enrichment. Modifiers are executed in registration order, and the ``"any"`` modifiers run
for every property.

Validator factory (``AbstractValidatorFactory``)
A special modifier that is keyed to a single JSON Schema keyword (e.g. ``"minLength"``).
It checks whether that keyword is present in the schema and, if so, adds the corresponding
validator to the property. Validator factories are registered via ``Type::addValidator()``.

Implementing a custom draft
---------------------------

The simplest approach is to extend an existing draft (e.g. ``Draft_07``) via the builder API and
override the parts you need.

**Example: add a custom keyword modifier to Draft 7**

.. code-block:: php

use PHPModelGenerator\Draft\Draft_07;
use PHPModelGenerator\Draft\DraftBuilder;
use PHPModelGenerator\Draft\DraftInterface;
use PHPModelGenerator\Draft\Modifier\ModifierInterface;
use PHPModelGenerator\Model\Property\PropertyInterface;
use PHPModelGenerator\Model\Schema;
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
use PHPModelGenerator\SchemaProcessor\SchemaProcessor;

class DeprecatedModifier implements ModifierInterface
{
public function modify(
SchemaProcessor $schemaProcessor,
Schema $schema,
PropertyInterface $property,
JsonSchema $propertySchema,
): void {
if (!($propertySchema->getJson()['deprecated'] ?? false)) {
return;
}

// Add a PHPDoc annotation or any other enrichment here.
}
}

class MyDraft implements DraftInterface
{
public function getDefinition(): DraftBuilder
{
// Obtain the standard Draft 7 builder and append a modifier to the 'any' type.
$builder = (new Draft_07())->getDefinition();
$builder->getType('any')->addModifier(new DeprecatedModifier());

return $builder;
}
}

Register the custom draft in your generator configuration:

.. code-block:: php

use PHPModelGenerator\Model\GeneratorConfiguration;

$configuration = (new GeneratorConfiguration())
->setDraft(new MyDraft());

Implementing a draft factory
-----------------------------

If you need to select the draft dynamically per schema file, implement
``DraftFactoryInterface``:

.. code-block:: php

use PHPModelGenerator\Draft\AutoDetectionDraft;
use PHPModelGenerator\Draft\DraftFactoryInterface;
use PHPModelGenerator\Draft\DraftInterface;
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;

class MyDraftFactory implements DraftFactoryInterface
{
private AutoDetectionDraft $autoDetect;
private MyDraft $myDraft;

public function __construct()
{
$this->autoDetect = new AutoDetectionDraft();
$this->myDraft = new MyDraft();
}

public function getDraftForSchema(JsonSchema $schema): DraftInterface
{
// Use the custom draft only for schemas that opt in via a custom flag.
if ($schema->getJson()['x-use-my-draft'] ?? false) {
return $this->myDraft;
}

return $this->autoDetect->getDraftForSchema($schema);
}
}

$configuration = (new GeneratorConfiguration())
->setDraft(new MyDraftFactory());
3 changes: 2 additions & 1 deletion docs/source/generator/postProcessor.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ All added post processors will be executed after a schema was processed and befo
builtin/patternPropertiesAccessorPostProcessor

.. toctree::
:caption: Custom Post Processors
:caption: Custom Extensions
:maxdepth: 1

custom/customPostProcessor
custom/customDraft
28 changes: 28 additions & 0 deletions docs/source/gettingStarted.rst
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,34 @@ The output of a generation process may look like:
Rendered class MyApp\User\Response\Login
Rendered class MyApp\User\Response\Register

JSON Schema draft version
^^^^^^^^^^^^^^^^^^^^^^^^^

.. code-block:: php

setDraft(DraftInterface|DraftFactoryInterface $draft);

Controls which JSON Schema draft version is used during generation. Accepts either a concrete
draft instance (``DraftInterface``) to pin all schemas to one draft, or a factory
(``DraftFactoryInterface``) to select the draft per schema file.

By default ``AutoDetectionDraft`` is used. It implements ``DraftFactoryInterface`` and inspects
the ``$schema`` keyword of each schema file to select the appropriate draft automatically. When
the keyword is absent or unrecognised, it falls back to JSON Schema Draft 7 behaviour, so schemas
with different ``$schema`` declarations in the same generation run can use different drafts.

Available draft classes:

============= ================================
Draft class Description
============= ================================
``Draft_07`` JSON Schema Draft 7 (default)
============= ================================

.. seealso::

:doc:`generator/custom/customDraft` — how to implement a custom draft or modifier.

Custom filter
^^^^^^^^^^^^^

Expand Down
1 change: 1 addition & 0 deletions docs/source/toc-generator.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
:maxdepth: 2

generator/postProcessor
generator/custom/customDraft
2 changes: 2 additions & 0 deletions phpcs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@
<exclude name="PSR2.Classes.ClassDeclaration.ImplementsLine"/>
<!-- Allow snake_case for internal utility methods -->
<exclude name="PSR1.Methods.CamelCapsMethodName.NotCamelCaps"/>
<!-- Allow underscore-separated class names (e.g. Draft_07, Draft_2020_12) -->
<exclude name="Squiz.Classes.ValidClassName.NotPascalCase"/>
</rule>
</ruleset>
21 changes: 21 additions & 0 deletions src/Draft/AutoDetectionDraft.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace PHPModelGenerator\Draft;

use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;

class AutoDetectionDraft implements DraftFactoryInterface
{
/** @var DraftInterface[] Keyed by draft class name; reused across schemas */
private array $draftInstances = [];

public function getDraftForSchema(JsonSchema $jsonSchema): DraftInterface
{
// Only Draft_07 is currently supported; all schemas (including unrecognised
// or absent $schema keywords) fall back to it. Additional drafts will be
// detected here in later phases (e.g. draft-04, draft 2020-12).
return $this->draftInstances[Draft_07::class] ??= new Draft_07();
}
}
67 changes: 67 additions & 0 deletions src/Draft/Draft.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace PHPModelGenerator\Draft;

use PHPModelGenerator\Draft\Element\Type;
use PHPModelGenerator\Exception\SchemaException;

final class Draft
{
/**
* @param Type[] $types
*/
public function __construct(private readonly array $types)
{
}

/**
* @return Type[]
*/
public function getTypes(): array
{
return $this->types;
}

public function hasType(string $type): bool
{
return isset($this->types[$type]);
}

/**
* Returns the Type entries whose modifiers apply to a property of the given type(s).
* The special type 'any' always applies to every property; passing 'any' returns all types.
*
* @param string|string[] $type
*
* @return Type[]
*
* @throws SchemaException
*/
public function getCoveredTypes(string | array $type): array
{
if (!is_array($type)) {
$type = [$type];
}

if (in_array('any', $type, true)) {
return $this->types;
}

// 'any' modifiers always apply regardless of the concrete type
$type[] = 'any';

$unknownTypes = array_diff($type, array_keys($this->types));
if ($unknownTypes) {
throw new SchemaException(sprintf(
'Unsupported property type %s',
count($unknownTypes) === 1
? reset($unknownTypes)
: '[' . implode(',', $unknownTypes) . ']',
));
}

return array_intersect_key($this->types, array_fill_keys($type, null));
}
}
Loading
Loading