Skip to content
Open
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
25 changes: 18 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,19 +215,19 @@ Markdown files.
Rules:

- Create the directory and at least a stub `implementation-plan.md` (or `analysis.md`) before
writing any code, so the plan is committed alongside the first code change.
writing any code. The plan is working context for the current session, not a git artefact.
- Every implementation plan must include a dedicated documentation update step. Before finalising
the plan, audit `docs/source/` (RST), `README.md`, and any other user-facing docs for content
that would be affected by the change, and add a plan phase that updates those docs. Do not skip
this even if the doc changes appear minor.
- Commit the plan files together with related code changes so the reasoning is always traceable in
git history.
- **Never add planning documents to git — not even on feature branches.** Files under
`.claude/issues/` and `.claude/topics/` are working notes for Claude's use only. Never stage or
commit them. If they appear in `git status`, run `git restore --staged <file>` immediately.
- Update the plan file(s) as the work progresses — record decisions made, phases completed, and
any pivots in approach.
- Once a topic is **ready to merge**, delete the entire `.claude/issues/<number>/` or
`.claude/topics/<slug>/` directory and commit that deletion as the final commit on the branch,
**before** merging to `master`. The tracking files are working notes and must never land on
`master`.
- Once a topic is **complete**, delete the entire `.claude/issues/<number>/` or
`.claude/topics/<slug>/` directory. These files must never land on `master` — delete before
merging.

Example layout for issue #110:

Expand Down Expand Up @@ -291,6 +291,17 @@ Every identified edge case must have a corresponding test. During planning, enum
cases explicitly (in the implementation plan). Before marking work done, verify that each
enumerated edge case is covered by at least one test.

#### Exception message assertions

Always assert the **complete** exception message, not just a substring. Construct the expected
message in full using the same inputs the code under test uses. Use regex (via
`expectExceptionMessageMatches` or `assertMatchesRegularExpression`) only for genuinely dynamic
parts that cannot be predicted upfront (e.g. file paths, uniqid suffixes).

Never use multiple `assertStringContainsString` calls on the same exception message when the full
message can be constructed. A single `assertSame($expectedMessage, $exception->getMessage())` is
both stronger and self-documenting.

For pull requests, check the qlty.sh coverage report by constructing the URL from the current PR
number:

Expand Down
10 changes: 10 additions & 0 deletions docs/source/combinedSchemas/allOf.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\All

When combining multiple nested objects with an `allOf` composition a `merged property <mergedProperty.html>`__ will be generated

.. note::

``allOf`` branches can be the boolean literals ``true`` or ``false``.

- ``true`` branch — treated as an empty schema; any value satisfies it and it adds no constraint.
- ``false`` branch — makes the whole composition unsatisfiable; any provided value raises an
``AllOfException`` at runtime (the false branch is represented as an always-failing composition
element). The generator also emits a warning at generation time. Absent optional properties
are still allowed.

.. note::

When a property is defined in multiple ``allOf`` branches with conflicting types (e.g. one branch
Expand Down
10 changes: 10 additions & 0 deletions docs/source/combinedSchemas/anyOf.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\Any
// get the value provided to the property
public function getProvidedValue()

.. note::

``anyOf`` branches can be the boolean literals ``true`` or ``false``.

- ``true`` branch — always satisfies the branch; treated as an empty schema.
- ``false`` branch — can never be satisfied; always-failing branches participate in the
composition but never succeed. If all branches are ``false``, any provided value raises an
``AnyOfException`` at runtime, and the generator emits a warning at generation time.
Absent optional properties are still allowed.

.. hint::

When combining multiple nested objects with an `anyOf` composition a `merged property <mergedProperty.html>`__ will be generated
Expand Down
18 changes: 18 additions & 0 deletions docs/source/combinedSchemas/if.rst
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,24 @@ When only a ``then`` block is present (no ``else``), the branch may not apply at
public function setAge(?int $age): static;
public function getAge(): ?int;

.. note::

Any of the three branches (``if``, ``then``, ``else``) can be the boolean literal ``true`` or
``false``. The generator resolves these statically at generation time:

- ``if: false`` — condition never matches; ``else`` (if present) is applied unconditionally,
``then`` is ignored.
- ``if: true`` — condition always matches; ``then`` (if present) is applied unconditionally,
``else`` is ignored.
- ``if: false, else: false`` or ``if: true, then: false`` — the composition is always
unsatisfiable; providing any value raises a ``ConditionalException`` at runtime. The generator
also emits a warning at generation time.
- ``then: false`` / ``else: false`` (with a real schema for ``if``) — when the relevant
branch is entered, the value would always be invalid; the generator throws a
``SchemaException`` at generation time.
- ``then: true`` / ``else: true`` — when the relevant branch is entered, any value is
accepted; treated as absent (no additional constraint).

.. hint::

The union-widening and nullability rules for ``if``/``then``/``else`` follow the same logic as
Expand Down
8 changes: 8 additions & 0 deletions docs/source/combinedSchemas/not.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,11 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\Not
public function getPropertyName(): string
// get the value provided to the property
public function getProvidedValue()

.. note::

The ``not`` schema can be the boolean literal ``true`` or ``false``.

- ``not: false`` — negation of the impossible schema; always valid. No validator is generated.
- ``not: true`` — negation of the always-valid schema; always invalid. Providing any value
raises a ``NotException`` at runtime. The generator also emits a warning at generation time.
10 changes: 10 additions & 0 deletions docs/source/combinedSchemas/oneOf.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\One
// get the value provided to the property
public function getProvidedValue()

.. note::

``oneOf`` branches can be the boolean literals ``true`` or ``false``.

- ``true`` branch — treated as an empty schema; always satisfies the branch.
- ``false`` branch — can never be satisfied; always-failing branches participate in the
composition but never succeed. If all branches are ``false``, any provided value raises a
``OneOfException`` at runtime, and the generator emits a warning at generation time.
Absent optional properties are still allowed.

.. hint::

When combining multiple nested objects with an `oneOf` composition a `merged property <mergedProperty.html>`__ will be generated
Expand Down
15 changes: 15 additions & 0 deletions docs/source/complexTypes/array.rst
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ The *getMembers* function of the class *Family* is type hinted with *@returns Me

Arrays with item validation don't accept elements which contain `null`. If your array needs to accept `null` entries you have to add null to the type of your items explicitly (eg. "type": ["object", "null"]).

The ``items`` keyword also accepts the boolean literals ``true`` and ``false``.

``items: true`` — any array element is accepted; equivalent to not specifying ``items``.

``items: false`` — the array must be empty. Providing a non-empty array throws a ``MaxItemsException``
(Array X must not contain more than 0 items).

Tuples
------

Expand Down Expand Up @@ -307,6 +314,14 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\Arrays\\ContainsEx
// get the value provided to the property
public function getProvidedValue()

The ``contains`` keyword also accepts the boolean literals ``true`` and ``false``.

``contains: true`` — the array must contain at least one element (since ``true`` validates everything,
any element satisfies the constraint).

``contains: false`` — no element could ever satisfy the constraint; any array value raises a
``ContainsException`` at runtime. The generator also emits a warning at generation time.

Size validation
---------------

Expand Down
47 changes: 46 additions & 1 deletion docs/source/complexTypes/object.rst
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,35 @@ Possible exceptions:

Properties defined in the `required` array but not defined in the `properties` will be added to the interface of the generated class.

A schema defining only the required property `example` consequently will provide the methods `getExample(): mixed` and `setExample(mixed $value): static`.
Boolean property schemas
^^^^^^^^^^^^^^^^^^^^^^^^

A property schema can be the boolean literal ``true`` or ``false`` instead of a JSON object schema.

``true`` — the property accepts any value without restriction. A getter is generated with return type ``mixed``.

``false`` — the property is explicitly forbidden. Providing it throws a ``DeniedPropertyException``. Listing a forbidden property in ``required`` is a schema error detected at generation time.

.. code-block:: json

{
"type": "object",
"properties": {
"name": {
"type": "string"
},
"anything": true,
"forbidden": false
}
}

Generated interface:

.. code-block:: php

public function getName(): ?string;
public function getAnything(): mixed;
// No getter is generated for 'forbidden'; providing it throws DeniedPropertyException

Size
----
Expand Down Expand Up @@ -580,3 +608,20 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\Object\\InvalidPat

This also applies to properties transferred from composition branches (``anyOf``,
``oneOf``, ``allOf``, ``if``/``then``/``else``).

A pattern schema can also be the boolean literal ``true`` or ``false``.

``true`` — properties matching the pattern are accepted without restriction (useful when combining
with ``additionalProperties: false`` to be explicit about intent).

``false`` — properties matching the pattern are forbidden. Providing any such property throws a
``DeniedPropertyException``.

.. code-block:: json

{
"type": "object",
"patternProperties": {
"^internal_.*": false
}
}
12 changes: 12 additions & 0 deletions src/Model/Property/CompositionPropertyDecorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class CompositionPropertyDecorator extends PropertyProxy
*/
protected $affectedObjectProperties = [];

private bool $alwaysTrueBranch = false;

/**
* CompositionPropertyDecorator constructor.
*
Expand Down Expand Up @@ -60,6 +62,16 @@ public function getAffectedObjectProperties(): array
return $this->affectedObjectProperties;
}

public function markAsAlwaysTrueBranch(): void
{
$this->alwaysTrueBranch = true;
}

public function isAlwaysTrueBranch(): bool
{
return $this->alwaysTrueBranch;
}

/**
* Return the branch-level JSON schema (the composition element schema, which may contain
* additionalProperties constraints). This is distinct from getJsonSchema(), which proxies
Expand Down
29 changes: 25 additions & 4 deletions src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,18 @@
namespace PHPModelGenerator\Model\Validator\Factory\Arrays;

use PHPModelGenerator\Exception\Arrays\ContainsException;
use PHPModelGenerator\Exception\SchemaException;
use PHPModelGenerator\Model\Property\PropertyInterface;
use PHPModelGenerator\Model\Schema;
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory;
use PHPModelGenerator\Model\Validator\PropertyTemplateValidator;
use PHPModelGenerator\Model\Validator\PropertyValidator;
use PHPModelGenerator\PropertyProcessor\PropertyFactory;
use PHPModelGenerator\SchemaProcessor\SchemaProcessor;
use PHPModelGenerator\Utils\RenderHelper;

class ContainsValidatorFactory extends AbstractValidatorFactory
{
/**
* @throws SchemaException
*/
public function modify(
SchemaProcessor $schemaProcessor,
Schema $schema,
Expand All @@ -32,6 +29,30 @@ public function modify(
return;
}

if (is_bool($json[$this->key])) {
if ($json[$this->key] === false) {
if ($schemaProcessor->getGeneratorConfiguration()->isOutputEnabled()) {
// @codeCoverageIgnoreStart
echo "Warning: contains: false for property '{$property->getName()}'"
. " can never be satisfied; any array will fail\n";
// @codeCoverageIgnoreEnd
}

$property->addValidator(
new PropertyValidator(
$property,
'is_array($value)',
ContainsException::class,
)
);
return;
}

$propertySchema = $propertySchema->withJson(
array_merge($json, [$this->key => []]),
);
}

$nestedProperty = (new PropertyFactory())
->create(
$schemaProcessor,
Expand Down
16 changes: 16 additions & 0 deletions src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace PHPModelGenerator\Model\Validator\Factory\Arrays;

use PHPModelGenerator\Exception\Arrays\AdditionalTupleItemsException;
use PHPModelGenerator\Exception\Arrays\MaxItemsException;
use PHPModelGenerator\Exception\SchemaException;
use PHPModelGenerator\Model\Property\PropertyInterface;
use PHPModelGenerator\Model\Schema;
Expand Down Expand Up @@ -35,6 +36,21 @@ public function modify(

$itemsSchema = $json[$this->key];

if (is_bool($itemsSchema)) {
if ($itemsSchema === false) {
$property->addValidator(
new PropertyValidator(
$property,
'count($value) > 0',
MaxItemsException::class,
[0],
),
);
}

return;
}

// tuple validation: items is a sequential array
if (
is_array($itemsSchema) &&
Expand Down
Loading
Loading