Skip to content

Deduplicate the per-draft constraint classes #991

Open

Description

@Roman3349

Each draft from Draft-06 on has its own copy of the constraint set in src/JsonSchema/Constraints/Drafts/<Draft>/. Most of these files differ only in their namespace:

Compared sets Identical except namespace Different
Draft06 vs Draft07 30 2 (Factory, FormatConstraint)
Draft07 vs Draft2019 27 6 (AdditionalItems, Contains, Content, Factory, Format, MultipleOf)
Draft2019 vs Draft2020 (#971) 30 5 (Content, Factory, Format, Items, UnevaluatedProperties)

Together the four sets have about 8,700 lines in 142 files. Only a handful of files actually differ.

A fix to a shared keyword therefore has to be applied to every copy by hand, and copies are easy to miss:

Tests are duplicated the same way. For example, RefConstraintTest exists once per draft and differs only in the dialect URI.

Proposal

  1. Move the constraints that are identical across drafts into a shared namespace, e.g. JsonSchema\Constraints\Drafts\Common. Their constructors take the generic \JsonSchema\Constraints\Factory, so they work with each draft's factory.
  2. Keep the per-draft classes as thin subclasses, e.g. class MinLengthConstraint extends Common\MinLengthConstraint {}. They are public and can be referenced or replaced via Factory::setConstraintClass(), so removing them would be a breaking change. They could be deprecated later.
  3. Keep classes with real differences per draft (FormatConstraint, ItemsConstraint/PrefixItemsConstraint, ContentConstraint, …). Where the difference is small, share a base class and override only what differs.
  4. Merge per-draft tests that differ only in the dialect into one test with a data provider over the dialects.

The Draft*Constraint dispatchers could follow in a separate step, together with or on top of #926.

Draft-04 in strict mode

Once the shared classes exist, a Draft-04 constraint set becomes cheap to add, and strict mode could support Draft-04 as well. Today a Draft-04 schema in strict mode throws InvalidArgumentException: Unknown constraint draft04, so strict mode cannot be enabled globally when validating schemas of mixed drafts. Draft-04 is still widely used.

The legacy constraints used without strict mode are not a pure Draft-04 implementation either. For a schema with "$schema": "http://json-schema.org/draft-04/schema#":

Schema Instance Result today Draft-04
{"const": 1} 2 invalid valid (const does not exist in Draft-04)
{"exclusiveMinimum": 5} 5 invalid valid (exclusiveMinimum is a boolean modifier of minimum in Draft-04)
{"items": false} [1] TypeError not a valid Draft-04 schema

A Draft-04 set would differ from Draft-06 only in a few keywords: boolean exclusiveMinimum/exclusiveMaximum, no const, contains, propertyNames or boolean schemas, and id instead of $id. Draft-03 is not worth the effort.

Suggested split

To keep the reviews manageable and the conflicts small:

  1. Constraints that are identical in all drafts.
  2. Constraints that are identical in a subset of drafts, or differ only slightly.
  3. The Draft*Constraint dispatchers (coordinated with Avoid unconditional constraint instantiation in Draft06/07/2019 dispatch #926).
  4. The deduplicated tests.
  5. A Draft-04 constraint set for strict mode, built on the shared classes.

Timing

Several open PRs change the files this would move (#971, #983, #984, #987, #973). I'd suggest starting after #971 is merged, so Draft 2020-12 is covered from the start, and preferably once the other open PRs are in.

Questions

  • Is this direction acceptable, or would you prefer the drafts to extend each other (e.g. Draft2020 extends Draft2019) instead of a shared namespace?
  • Should the per-draft subclasses stay permanently, or be deprecated for removal in the next major version?
  • Would strict mode support for Draft-04 be welcome?

I'm happy to work on this.

Activity

  1. DannyvdSluijs commented on Oct 26, 2026

    @DannyvdSluijs
    Collaborator

    Hi @Roman3349 thanks for the extensive report. I'm aware there is duplication between the drafts, which is intentional or at least the result of the Draft 3 and Draft 4 being intertwined. I've chosen explicitly not sharing code between draft elements unless the draft refers to a complex format which was captured in an RFC. You're absolutely right on the downside that during fixes or improvements we need to consider multiple classes/paths.

    For now I would still think keeping the duplication and not having to do a mayor version bump is the way to go. As I would rather give priority to getting the drafts complete (you can check the badges in the readme on the progress).

    Beside the organisational issues there is no impact on timings/performance right?

  2. Roman3349 commented on Oct 28, 2026

    @Roman3349
    ContributorAuthor

    Hi @DannyvdSluijs,

    that makes sense, and I agree that completing the drafts comes first.

    No, there's no runtime impact. Validation runs the same code either way, and the copies only cost a bit of extra opcache memory and autoloading when one process validates schemas of several drafts. It's purely a maintenance issue. For the record, the proposal wouldn't need a major version bump either, because the per-draft classes would stay as thin subclasses.

    For illustration, the thin subclasses from the proposal would look like this:

    src/JsonSchema/Constraints/Drafts/
    ├── Common/
    │   ├── MinLengthConstraint.php     # the implementation, moved from Draft06/
    │   └── ...
    ├── Draft06/
    │   ├── MinLengthConstraint.php     # extends Common\MinLengthConstraint
    │   ├── FormatConstraint.php        # stays a full per-draft implementation
    │   └── Factory.php                 # unchanged, still maps to Draft06\* classes
    ├── Draft07/
    │   ├── MinLengthConstraint.php     # extends Common\MinLengthConstraint
    │   ├── FormatConstraint.php        # stays a full per-draft implementation
    │   └── Factory.php                 # unchanged, still maps to Draft07\* classes
    └── Draft2019/
        ├── MinLengthConstraint.php     # extends Common\MinLengthConstraint
        ├── FormatConstraint.php        # stays a full per-draft implementation
        └── Factory.php                 # unchanged, still maps to Draft2019\* classes
    
    <?php
    
    declare(strict_types=1);
    
    namespace JsonSchema\Constraints\Drafts\Common;
    
    use JsonSchema\ConstraintError;
    use JsonSchema\Constraints\ConstraintInterface;
    use JsonSchema\Constraints\Factory;
    use JsonSchema\Entity\ErrorBagProxy;
    use JsonSchema\Entity\JsonPointer;
    
    class MinLengthConstraint implements ConstraintInterface
    {
        use ErrorBagProxy;
    
        public function __construct(?Factory $factory = null)
        {
            $this->initialiseErrorBag($factory ?: new Factory());
        }
    
        public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void
        {
            // the body of today's Draft07\MinLengthConstraint::check(), unchanged
        }
    }
    <?php
    
    declare(strict_types=1);
    
    namespace JsonSchema\Constraints\Drafts\Draft07;
    
    class MinLengthConstraint extends \JsonSchema\Constraints\Drafts\Common\MinLengthConstraint
    {
    }

    The per-draft class names, the factories' constraintMap and Factory::setConstraintClass() keep working as before, which is why no major version bump is needed. A fix to minLength then goes into Common once.

    Strict mode support for Draft-04 doesn't depend on the deduplication either. It could be done as one more copied set later, if it's welcome. I'm fine with closing this issue or leaving it open for later, whichever you prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions