Skip to content

Commit ddf937c

Browse files
authored
StopAtFirstError (opis#147)
* Fixed date-time filters * Added PHP 8.4 to tests * Updated isMultipleOf helper * Fixed error formatter * Added stopAtFirstError
1 parent d138972 commit ddf937c

File tree

9 files changed

+171
-36
lines changed

9 files changed

+171
-36
lines changed

.github/workflows/tests.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
strategy:
1313
fail-fast: true
1414
matrix:
15-
php: [7.4, 8.0, 8.1, 8.2, 8.3]
15+
php: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4]
1616

1717
name: PHP ${{ matrix.php }}
1818

@@ -32,4 +32,4 @@ jobs:
3232
run: composer update --no-interaction --no-progress
3333

3434
- name: Execute tests
35-
run: vendor/bin/phpunit --verbose
35+
run: composer run tests

composer.json

+3
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
"Opis\\JsonSchema\\Test\\": "tests/"
4242
}
4343
},
44+
"scripts": {
45+
"tests": "./vendor/bin/phpunit --verbose --color"
46+
},
4447
"extra": {
4548
"branch-alias": {
4649
"dev-master": "2.x-dev"

src/CompliantValidator.php

+6-2
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,13 @@ class CompliantValidator extends Validator
3838
'keepAdditionalItemsKeyword' => false,
3939
];
4040

41-
public function __construct(?SchemaLoader $loader = null, int $max_errors = 1)
41+
public function __construct(
42+
?SchemaLoader $loader = null,
43+
int $max_errors = 1,
44+
bool $stop_at_first_error = true
45+
)
4246
{
43-
parent::__construct($loader, $max_errors);
47+
parent::__construct($loader, $max_errors, $stop_at_first_error);
4448

4549
// Set parser options
4650
$parser = $this->parser();

src/Errors/ErrorFormatter.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ public function formatErrorMessage(ValidationError $error, ?string $message = nu
218218
return preg_replace_callback(
219219
'~{([^}]+)}~imu',
220220
static function (array $m) use ($args) {
221-
if (!isset($args[$m[1]])) {
221+
if (!array_key_exists($m[1], $args)) {
222222
return $m[0];
223223
}
224224

src/Filters/DateTimeFilters.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
namespace Opis\JsonSchema\Filters;
1919

20-
use DateTime;
20+
use DateTime, DateTimeZone;
2121

2222
final class DateTimeFilters
2323
{
@@ -91,7 +91,7 @@ public static function MaxTime(string $time, array $args): bool
9191

9292
private static function CreateDate(string $value, ?string $timezone = null, bool $time = true): DateTime
9393
{
94-
$date = new DateTime($value, $timezone);
94+
$date = new DateTime($value, $timezone ? new DateTimeZone($timezone) : null);
9595
if (!$time) {
9696
return $date->setTime(0, 0, 0, 0);
9797
}

src/Helper.php

+57-18
Original file line numberDiff line numberDiff line change
@@ -235,40 +235,79 @@ public static function equals($a, $b): bool
235235
return false;
236236
}
237237

238+
239+
/**
240+
* @var bool|null True if bcmath extension is available
241+
*/
242+
private static ?bool $hasBCMath = null;
243+
244+
/**
245+
* @var bool True to use bcmath
246+
*/
247+
public static bool $useBCMath = true;
248+
249+
/**
250+
* @var int Number scale to used when using comparisons
251+
*/
252+
public static int $numberScale = 14;
253+
238254
/**
239255
* @param $number
240256
* @param $divisor
241-
* @param int $scale
257+
* @param int|null $scale
242258
* @return bool
243259
*/
244-
public static function isMultipleOf($number, $divisor, int $scale = 14): bool
260+
public static function isMultipleOf($number, $divisor, ?int $scale = null): bool
245261
{
246-
static $bcMath = null;
247-
if ($bcMath === null) {
248-
$bcMath = extension_loaded('bcmath');
262+
if ($number == $divisor) {
263+
return true;
249264
}
265+
250266
if ($divisor == 0) {
251267
return $number == 0;
252268
}
253269

254-
if ($bcMath) {
255-
$number = number_format($number, $scale, '.', '');
256-
$divisor = number_format($divisor, $scale, '.', '');
270+
if ($divisor == 1 && !is_string($number)) {
271+
return is_int($number) || !fmod($number, 1);
272+
}
273+
274+
// maybe we get lucky
275+
if (!fmod($number, $divisor)) {
276+
return true;
277+
}
278+
279+
// int mod
280+
if (is_int($number) && is_int($divisor)) {
281+
return !($number % $divisor);
282+
}
283+
284+
// Use global scale if null
285+
$scale ??= self::$numberScale;
286+
287+
if (
288+
!self::$useBCMath ||
289+
!(self::$hasBCMath ??= extension_loaded('bcmath'))
290+
) {
291+
// use an approximation
292+
$div = $number / $divisor;
293+
return abs($div - round($div)) < (10 ** -$scale);
294+
}
295+
296+
// use bcmath
257297

258-
/** @noinspection PhpComposerExtensionStubsInspection */
259-
$x = bcdiv($number, $divisor, 0);
260-
/** @noinspection PhpComposerExtensionStubsInspection */
261-
$x = bcmul($divisor, $x, $scale);
262-
/** @noinspection PhpComposerExtensionStubsInspection */
263-
$x = bcsub($number, $x, $scale);
298+
$number = number_format($number, $scale, '.', '');
299+
$divisor = number_format($divisor, $scale, '.', '');
264300

265-
/** @noinspection PhpComposerExtensionStubsInspection */
266-
return 0 === bccomp($x, 0, $scale);
301+
// number can be zero after formatting
302+
if (!(float)$divisor) {
303+
return $number === $divisor;
267304
}
268305

269-
$div = $number / $divisor;
306+
$x = bcdiv($number, $divisor, 0);
307+
$x = bcmul($divisor, $x, $scale);
308+
$x = bcsub($number, $x, $scale);
270309

271-
return $div == (int)$div;
310+
return 0 === bccomp($x, 0, $scale);
272311
}
273312

274313
/**

src/Schemas/ObjectSchema.php

+31-3
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
namespace Opis\JsonSchema\Schemas;
1919

2020
use Opis\JsonSchema\{Helper, Keyword, ValidationContext, KeywordValidator};
21-
use Opis\JsonSchema\Info\SchemaInfo;
21+
use Opis\JsonSchema\Info\{DataInfo, SchemaInfo};
2222
use Opis\JsonSchema\Errors\ValidationError;
2323
use Opis\JsonSchema\KeywordValidators\CallbackKeywordValidator;
2424

@@ -109,12 +109,40 @@ public function doValidate(ValidationContext $context): ?ValidationError
109109
*/
110110
protected function applyKeywords(array $keywords, ValidationContext $context): ?ValidationError
111111
{
112+
if ($context->stopAtFirstError()) {
113+
foreach ($keywords as $keyword) {
114+
if ($error = $keyword->validate($context, $this)) {
115+
return $error;
116+
}
117+
}
118+
return null;
119+
}
120+
121+
/** @var null|ValidationError[] $error_list */
122+
$error_list = null;
123+
112124
foreach ($keywords as $keyword) {
113125
if ($error = $keyword->validate($context, $this)) {
114-
return $error;
126+
$error_list ??= [];
127+
$error_list[] = $error;
115128
}
116129
}
117130

118-
return null;
131+
if (!$error_list) {
132+
return null;
133+
}
134+
135+
if (count($error_list) === 1) {
136+
return $error_list[0];
137+
}
138+
139+
return new ValidationError(
140+
'',
141+
$this,
142+
DataInfo::fromContext($context),
143+
'Data must match schema',
144+
[],
145+
$error_list
146+
);
119147
}
120148
}

src/ValidationContext.php

+33-6
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ class ValidationContext
5454

5555
protected int $maxErrors = 1;
5656

57+
protected bool $stopAtFirstError = true;
58+
5759
/**
5860
* @param $data
5961
* @param SchemaLoader $loader
@@ -70,7 +72,8 @@ public function __construct(
7072
?Schema $sender = null,
7173
array $globals = [],
7274
?array $slots = null,
73-
int $max_errors = 1
75+
int $max_errors = 1,
76+
bool $stop_at_first_error = true
7477
) {
7578
$this->sender = $sender;
7679
$this->rootData = $data;
@@ -79,6 +82,7 @@ public function __construct(
7982
$this->globals = $globals;
8083
$this->slots = null;
8184
$this->maxErrors = $max_errors;
85+
$this->stopAtFirstError = $stop_at_first_error;
8286
$this->currentData = [
8387
[$data, false],
8488
];
@@ -101,18 +105,28 @@ public function newInstance(
101105
?Schema $sender,
102106
?array $globals = null,
103107
?array $slots = null,
104-
?int $max_errors = null
108+
?int $max_errors = null,
109+
?bool $stop_at_first_error = null
105110
): self {
106-
return new self($data, $this->loader, $this, $sender, $globals ?? $this->globals, $slots ?? $this->slots,
107-
$max_errors ?? $this->maxErrors);
111+
return new self(
112+
$data,
113+
$this->loader,
114+
$this,
115+
$sender,
116+
$globals ?? $this->globals,
117+
$slots ?? $this->slots,
118+
$max_errors ?? $this->maxErrors,
119+
$stop_at_first_error ?? $this->stopAtFirstError
120+
);
108121
}
109122

110123
public function create(
111124
Schema $sender,
112125
?Variables $mapper = null,
113126
?Variables $globals = null,
114127
?array $slots = null,
115-
?int $maxErrors = null
128+
?int $maxErrors = null,
129+
?bool $stop_at_first_error = null
116130
): self {
117131
if ($globals) {
118132
$globals = $globals->resolve($this->rootData(), $this->currentDataPath());
@@ -131,7 +145,7 @@ public function create(
131145
}
132146

133147
return new self($data, $this->loader, $this, $sender, $globals, $slots ?? $this->slots,
134-
$maxErrors ?? $this->maxErrors);
148+
$maxErrors ?? $this->maxErrors, $stop_at_first_error ?? $this->stopAtFirstError);
135149
}
136150

137151
public function sender(): ?Schema
@@ -359,6 +373,19 @@ public function setMaxErrors(int $max): self
359373
return $this;
360374
}
361375

376+
377+
public function stopAtFirstError(): bool
378+
{
379+
return $this->stopAtFirstError;
380+
}
381+
382+
public function setStopAtFirstError(bool $stop): self
383+
{
384+
$this->stopAtFirstError = $stop;
385+
386+
return $this;
387+
}
388+
362389
/* --------------------- */
363390

364391
/**

src/Validator.php

+36-2
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,22 @@ class Validator
2626
{
2727
protected SchemaLoader $loader;
2828
protected int $maxErrors = 1;
29+
protected bool $stopAtFirstError = true;
2930

3031
/**
3132
* @param SchemaLoader|null $loader
3233
* @param int $max_errors
34+
* @param bool $stop_at_first_error
3335
*/
34-
public function __construct(?SchemaLoader $loader = null, int $max_errors = 1)
36+
public function __construct(
37+
?SchemaLoader $loader = null,
38+
int $max_errors = 1,
39+
bool $stop_at_first_error = true
40+
)
3541
{
3642
$this->loader = $loader ?? new SchemaLoader(new SchemaParser(), new SchemaResolver(), true);
3743
$this->maxErrors = $max_errors;
44+
$this->stopAtFirstError = $stop_at_first_error;
3845
}
3946

4047
/**
@@ -170,7 +177,16 @@ public function createContext($data, ?array $globals = null, ?array $slots = nul
170177
$slots = $this->parseSlots($slots);
171178
}
172179

173-
return new ValidationContext($data, $this->loader, null, null, $globals ?? [], $slots, $this->maxErrors);
180+
return new ValidationContext(
181+
$data,
182+
$this->loader,
183+
null,
184+
null,
185+
$globals ?? [],
186+
$slots,
187+
$this->maxErrors,
188+
$this->stopAtFirstError,
189+
);
174190
}
175191

176192
/**
@@ -249,6 +265,24 @@ public function setMaxErrors(int $max_errors): self
249265
return $this;
250266
}
251267

268+
/**
269+
* @return bool
270+
*/
271+
public function getStopAtFirstError(): bool
272+
{
273+
return $this->stopAtFirstError;
274+
}
275+
276+
/**
277+
* @param bool $stop
278+
* @return $this
279+
*/
280+
public function setStopAtFirstError(bool $stop): self
281+
{
282+
$this->stopAtFirstError = $stop;
283+
return $this;
284+
}
285+
252286
/**
253287
* @param array $slots
254288
* @return array

0 commit comments

Comments
 (0)