Skip to content

Commit f724472

Browse files
committed
feat: add validator cloning
1 parent fe5a989 commit f724472

9 files changed

Lines changed: 123 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ A comprehensive, fluent validation library for PHP inspired by Valibot and Zod.
8282
- **Task Tracking** - Use GitHub-style checkboxes (`- [ ]` for pending, `- [x]` for completed) in ROADMAP.md for clear progress tracking
8383
- **Commit Standards** - Follow Conventional Commits spec (e.g., `fix:`, `refactor:`, `docs:`)
8484
- **Commit Scope** - Each commit should address a single concern; tests and implementation can ship together, but unrelated formatting belongs elsewhere
85+
- **Git Tags** - Prefer annotated tags for releases (author, date, message/signing) over lightweight tags
8586

8687
### Development Tools
8788
- **Debugging** - `symfony/var-dumper` integration

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
## [0.9.0] - 2025-11-18
8+
9+
### Added
10+
- **Validator cloning**: Added `clone()` support to all validators for safe reuse of prebuilt pipelines without shared state. Pipelines are rebound to the cloned instance and nested schemas/item validators deep-clone to avoid cross-contamination between validators.
11+
712
## [0.8.0] - 2025-11-06
813
### Changed
914
- Migrated all runtime classes to the `Lemmon\Validator` namespace and aligned documentation/examples with the new structure.

ROADMAP.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ This roadmap outlines the strategic development plan for future releases, priori
6161
- Ensured all internal navigation works properly
6262

6363
### Critical API Improvements
64-
- [ ] **Create `clone()` function** - Provide a first-class way to duplicate validator pipelines for reuse without rebuilding chains manually
64+
- [x] **Create `clone()` function** - Provide a first-class way to duplicate validator pipelines for reuse without rebuilding chains manually
65+
- Added deep clone support with pipeline closure rebinding to cloned instances
66+
- Nested schemas/item validators now clone recursively to prevent shared state
67+
- Added regression tests covering pipeline state isolation and schema deep copying
6568
- [x] **Rename `addValidation()` to `satisfies()`** - Intuitive, descriptive method name that clearly indicates validation intent
6669
- Added new `satisfies(callable $validation, ?string $message = null)` method with optional error message
6770
- Maintains backward compatibility with deprecated `addValidation()` method

docs/api-reference/validator-factory.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,24 @@ $email2 = $emailValidator->validate('user2@example.com');
214214
$email3 = $emailValidator->validate('user3@example.com');
215215
```
216216

217+
### Cloning Validators for Variations
218+
219+
Use `clone()` to fork a base pipeline without sharing state:
220+
221+
```php
222+
$baseEmail = Validator::isString()
223+
->email()
224+
->pipe('trim', 'strtolower');
225+
226+
$requiredEmail = $baseEmail->clone()->required();
227+
$nullableEmail = $baseEmail->clone()->default(null);
228+
229+
$requiredEmail->validate('USER@EXAMPLE.COM'); // 'user@example.com'
230+
$nullableEmail->validate(null); // null (default applied)
231+
```
232+
233+
`clone()` performs a deep copy, including nested schemas/item validators, so modifications on the clone never leak back to the original.
234+
217235
### Complex Schema Validation
218236

219237
```php

src/Lemmon/Validator/ArrayValidator.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,13 @@ protected function validateType(mixed $value, string $key): mixed
102102

103103
return $value;
104104
}
105+
106+
public function __clone()
107+
{
108+
parent::__clone();
109+
110+
if ($this->itemValidator !== null) {
111+
$this->itemValidator = $this->itemValidator->clone();
112+
}
113+
}
105114
}

src/Lemmon/Validator/AssociativeValidator.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,13 @@ protected function validateType(mixed $value, string $key): mixed
8888

8989
return $data;
9090
}
91+
92+
public function __clone()
93+
{
94+
parent::__clone();
95+
96+
foreach ($this->schema as $fieldKey => $validator) {
97+
$this->schema[$fieldKey] = $validator->clone();
98+
}
99+
}
91100
}

src/Lemmon/Validator/FieldValidator.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ abstract class FieldValidator
1010
protected bool $required = false;
1111
protected ?string $requiredMessage = null;
1212

13+
/**
14+
* Creates a deep copy of the validator, including pipeline closures bound to the clone.
15+
*
16+
* @return static
17+
*/
18+
public function clone(): static
19+
{
20+
return clone $this;
21+
}
22+
1323
/**
1424
* @var array<array{type: PipelineType, operation: callable}>
1525
*/
@@ -453,4 +463,22 @@ protected function coerceToAssociativeArray(mixed $value): mixed
453463
}
454464
return $value; // Preserve keys - no reindexing!
455465
}
466+
467+
public function __clone()
468+
{
469+
$rebuiltPipeline = [];
470+
foreach ($this->pipeline as $step) {
471+
$operation = $step['operation'];
472+
if ($operation instanceof \Closure) {
473+
$operation = $operation->bindTo($this);
474+
}
475+
476+
$rebuiltPipeline[] = [
477+
'type' => $step['type'],
478+
'operation' => $operation,
479+
];
480+
}
481+
482+
$this->pipeline = $rebuiltPipeline;
483+
}
456484
}

src/Lemmon/Validator/ObjectValidator.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,13 @@ protected function validateType(mixed $value, string $key): mixed
8888

8989
return $data;
9090
}
91+
92+
public function __clone()
93+
{
94+
parent::__clone();
95+
96+
foreach ($this->schema as $fieldKey => $validator) {
97+
$this->schema[$fieldKey] = $validator->clone();
98+
}
99+
}
91100
}

tests/FieldValidatorTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,46 @@
193193
expect($errors)->toBe(['Value must be a string']);
194194
});
195195

196+
it('should clone validators without sharing pipeline state', function () {
197+
$original = Validator::isString()
198+
->transform(fn ($v) => explode(',', trim($v))) // String → Array
199+
->pipe('array_reverse') // Array operations use current type
200+
->transform(fn ($v) => implode('-', $v)); // Array → String
201+
202+
$clone = $original->clone();
203+
204+
$result = $clone->validate('a,b,c');
205+
expect($result)->toBe('c-b-a');
206+
207+
$currentType = new ReflectionProperty($original, 'currentType');
208+
$currentType->setAccessible(true);
209+
210+
expect($currentType->getValue($original))->toBeNull(); // Original instance untouched
211+
});
212+
213+
it('should deep clone nested schemas for associative validators', function () {
214+
$original = Validator::isAssociative([
215+
'nickname' => Validator::isString(),
216+
]);
217+
218+
$clone = $original->clone();
219+
220+
$schemaProperty = new ReflectionProperty($clone, 'schema');
221+
$schemaProperty->setAccessible(true);
222+
$cloneSchema = $schemaProperty->getValue($clone);
223+
224+
$cloneSchema['nickname']->default('buddy');
225+
226+
$originalSchemaProperty = new ReflectionProperty($original, 'schema');
227+
$originalSchemaProperty->setAccessible(true);
228+
$originalSchema = $originalSchemaProperty->getValue($original);
229+
230+
expect($cloneSchema['nickname'])->not->toBe($originalSchema['nickname']); // No shared instances
231+
232+
expect($original->validate([]))->toBe([]);
233+
expect($clone->validate([]))->toBe(['nickname' => 'buddy']);
234+
});
235+
196236
it('should fail fast in single pipeline - first validation error stops execution', function () {
197237
$validator = Validator::isString()->minLength(10)->maxLength(3)->email();
198238

0 commit comments

Comments
 (0)