Skip to content

Commit 486a6ae

Browse files
committed
Improve Schema API
1 parent e551b0f commit 486a6ae

29 files changed

Lines changed: 463 additions & 208 deletions

docs/9.0/connections/instantiation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Alternatively, you can use the <code>fromStream</code> method.</p>
6161
```php
6262
public static AbstractCsv::fromStream(SplFileObject|resource $stream): self
6363
```
64+
6465
Creates a new object from a stream resource or a streaming object.
6566

6667
```php

docs/9.0/reader/record-mapping.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ description: Converts your CSV records into PHP objects using PHP's powerful Ref
88

99
<p class="message-notice">New in version <code>9.12.0</code></p>
1010

11-
If you are working with a class which implements the `TabularDataReader` interface you can now deserialize
11+
If you are working with a class which implements the `TabularData` interface you can now deserialize
1212
your data using the `TabularDataReader::getRecordsAsObject` method. The method will convert your document records
1313
into objects using PHP's powerful Reflection API.
1414

src/Buffer.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,14 @@ public function reduce(callable $callback, mixed $initial = null): mixed
224224
return $initial;
225225
}
226226

227-
public function schema(?Inspector $inspector = null): Schema
227+
public function inferSchema(?Inspector $inspector = null, array $header = []): Schema
228228
{
229-
return ($inspector ?? Inspector::default())->schema($this);
229+
return ($inspector ?? Inspector::default())->schema($this, $header);
230+
}
231+
232+
public function inferRecords(?Inspector $inspector = null, array $header = []): Iterator
233+
{
234+
return $this->inferSchema($inspector, $header)->parse($this);
230235
}
231236

232237
/**

src/Reader.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,9 +418,14 @@ public function map(callable $callback): Iterator
418418
return MapIterator::fromIterable($this, $callback);
419419
}
420420

421-
public function schema(?Inspector $inspector = null): Schema
421+
public function inferSchema(?Inspector $inspector = null, array $header = []): Schema
422422
{
423-
return ($inspector ?? Inspector::default())->schema($this);
423+
return ($inspector ?? Inspector::default())->schema($this, $header);
424+
}
425+
426+
public function inferRecords(?Inspector $inspector = null, array $header = []): Iterator
427+
{
428+
return $this->inferSchema($inspector, $header)->parse($this);
424429
}
425430

426431
/**

src/ResultSet.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,14 @@ public function map(callable $callback): Iterator
208208
return MapIterator::fromIterable($this, $callback);
209209
}
210210

211-
public function schema(?Inspector $inspector = null): Schema
211+
public function inferSchema(?Inspector $inspector = null, array $header = []): Schema
212212
{
213-
return ($inspector ?? Inspector::default())->schema($this);
213+
return ($inspector ?? Inspector::default())->schema($this, $header);
214+
}
215+
216+
public function inferRecords(?Inspector $inspector = null, array $header = []): Iterator
217+
{
218+
return $this->inferSchema($inspector, $header)->parse($this);
214219
}
215220

216221
/**

src/Schema/BooleanField.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
use const FILTER_NULL_ON_FAILURE;
2323
use const FILTER_VALIDATE_BOOLEAN;
2424

25-
final class BooleanField extends AbstractField
25+
final class BooleanField extends FieldEvaluator implements Field
2626
{
2727
public function type(): FieldType
2828
{
@@ -51,4 +51,9 @@ public function parse(mixed $value): ?bool
5151

5252
return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
5353
}
54+
55+
public function metadata(): FieldMetadata
56+
{
57+
return new FieldMetadata();
58+
}
5459
}

src/Schema/CallbackFieldParser.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
/**
4+
* League.Csv (https://csv.thephpleague.com)
5+
*
6+
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace League\Csv\Schema;
15+
16+
use Closure;
17+
18+
/**
19+
* @template T
20+
*/
21+
final class CallbackFieldParser implements FieldParser
22+
{
23+
/** @var Closure(mixed): ?T */
24+
private Closure $callback;
25+
26+
/**
27+
* @param (Closure(mixed): ?T)|(callable(mixed): ?T) $callback
28+
*/
29+
public function __construct(Closure|callable $callback)
30+
{
31+
if (!$callback instanceof Closure) {
32+
$callback = $callback(...);
33+
}
34+
35+
$this->callback = $callback;
36+
}
37+
38+
/**
39+
* @returns ?T
40+
*/
41+
public function parse(mixed $value): mixed
42+
{
43+
return ($this->callback)($value);
44+
}
45+
}

src/Schema/CustomField.php

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,32 +18,31 @@
1818

1919
use function preg_match;
2020

21-
final class CustomField extends AbstractField
21+
/**
22+
* @template T
23+
*/
24+
final class CustomField extends FieldEvaluator implements Field
2225
{
23-
/**
24-
* The closure MUST return null if it fails to parse the value.
25-
* It should return the parsed value according to
26-
* the callable logic.
27-
*
28-
* @var Closure(mixed): ?mixed
29-
*/
30-
private readonly Closure $parser;
26+
private readonly FieldParser $fieldParser;
3127
/** @var non-empty-string */
32-
public readonly string $name;
28+
private readonly string $fieldTypeName;
3329

3430
public function __construct(
35-
callable $parser,
36-
string $name,
31+
FieldParser|Closure|callable $fieldParser,
32+
string $fieldTypeName,
3733
float $confidenceThreshold = 0.8
3834
) {
39-
('' !== $name && 1 === preg_match('/^[a-z]+(?:_[a-z0-9]+)*$/', $name)) || throw new ValueError('The name "'.$name.'" is not a valid snake case variable name.');
35+
('' !== $fieldTypeName && 1 === preg_match('/^[a-z]+(?:_[a-z0-9]+)*$/', $fieldTypeName)) || throw new ValueError('The name "'.$fieldTypeName.'" is not a valid snake case variable name.');
36+
$fieldParser = self::resolveFieldParser($fieldParser);
4037
parent::__construct($confidenceThreshold);
4138

42-
$this->parser = !$parser instanceof Closure
43-
? $parser(...)
44-
: $parser;
39+
$this->fieldParser = $fieldParser;
40+
$this->fieldTypeName = $fieldTypeName;
41+
}
4542

46-
$this->name = $name;
43+
private static function resolveFieldParser(FieldParser|Closure|callable $parser): FieldParser
44+
{
45+
return $parser instanceof FieldParser ? $parser : new CallbackFieldParser($parser);
4746
}
4847

4948
public function type(): FieldType
@@ -53,11 +52,19 @@ public function type(): FieldType
5352

5453
public function name(): string
5554
{
56-
return $this->name;
55+
return $this->fieldTypeName;
5756
}
5857

58+
/**
59+
* @return ?T
60+
*/
5961
public function parse(mixed $value): mixed
6062
{
61-
return ($this->parser)($value);
63+
return $this->fieldParser->parse($value);
64+
}
65+
66+
public function metadata(): FieldMetadata
67+
{
68+
return new FieldMetadata();
6269
}
6370
}

src/Schema/DateField.php

Lines changed: 90 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,30 +15,97 @@
1515

1616
use DateTimeImmutable;
1717
use DateTimeInterface;
18+
use DateTimeZone;
1819
use Exception;
1920
use ValueError;
2021

22+
use function array_map;
23+
use function array_values;
2124
use function is_string;
25+
use function iterator_to_array;
2226
use function trim;
2327

24-
final class DateField extends AbstractField
28+
final class DateField extends FieldEvaluator implements Field
2529
{
26-
public function __construct(public readonly string $format = '', float $confidenceThreshold = 0.8)
30+
/** @var non-empty-string */
31+
public readonly string $format;
32+
public readonly ?DateTimeZone $timezone;
33+
34+
public function __construct(string $format, DateTimeZone|string|null $timezone = null, float $confidenceThreshold = 0.8)
2735
{
36+
$format = trim($format);
37+
'' !== $format || throw new ValueError('The date field format can not be empty.');
38+
$timezone = self::filterTimezone($timezone);
39+
2840
parent::__construct($confidenceThreshold);
41+
$this->format = $format;
42+
$this->timezone = $timezone;
2943
}
3044

31-
public static function native(float $confidenceThreshold = 0.8): self
45+
public static function common(DateTimeZone|string|null $timezone = null): FieldList
3246
{
33-
return new self(format: '', confidenceThreshold: $confidenceThreshold);
47+
return self::machine($timezone)->append(self::localized($timezone));
3448
}
3549

36-
public static function withFormat(string $format, float $confidenceThreshold = 0.8): self
50+
public static function machine(DateTimeZone|string|null $timezone = null): FieldList
3751
{
38-
$format = trim($format);
39-
'' !== $format || throw new ValueError('The date field strategy format can not be empty.');
52+
$formats = [
53+
'Y-m-d',
54+
'Y-m-d H:i:s',
55+
'Y-m-d\TH:i:s',
56+
DateTimeInterface::RFC3339,
57+
DateTimeInterface::RFC3339_EXTENDED,
58+
DateTimeInterface::ISO8601_EXPANDED,
59+
];
60+
61+
return self::fromFormat($formats, $timezone, .8);
62+
}
63+
64+
public static function localized(DateTimeZone|string|null $timezone = null): FieldList
65+
{
66+
$formats = [
67+
// Europe Dates
68+
'd/m/Y',
69+
'd-m-Y',
70+
'd.m.Y',
71+
// American Dates
72+
'm/d/Y',
73+
'm-d-Y',
74+
'm.d.Y',
75+
];
76+
77+
return self::fromFormat($formats, $timezone, .7);
78+
}
79+
80+
/**
81+
* @param iterable<non-empty-string> $formats
82+
*/
83+
public static function fromFormat(
84+
iterable $formats,
85+
DateTimeZone|string|null $timezone = null,
86+
float $confidenceThreshold = 0.8
87+
): FieldList {
88+
return new FieldList(...array_values(array_map(
89+
fn (string $format): DateField => new DateField($format, $timezone, $confidenceThreshold),
90+
iterator_to_array($formats)
91+
)));
92+
}
93+
94+
private static function filterTimezone(DateTimeZone|string|null $timeZone): ?DateTimeZone
95+
{
96+
if (null === $timeZone) {
97+
return null;
98+
}
4099

41-
return new self(format: $format, confidenceThreshold: $confidenceThreshold);
100+
if ($timeZone instanceof DateTimeZone) {
101+
return $timeZone;
102+
}
103+
104+
try {
105+
return new DateTimeZone($timeZone);
106+
} catch (Exception $exception) {
107+
throw new ValueError('The date field timezone value `'.$timeZone.'` is invalid.', previous: $exception);
108+
}
42109
}
43110

44111
public function type(): FieldType
@@ -51,11 +118,6 @@ public function name(): string
51118
return FieldType::Date->value;
52119
}
53120

54-
public function format(): string
55-
{
56-
return $this->format;
57-
}
58-
59121
public function parse(mixed $value): ?DateTimeImmutable
60122
{
61123
if ($value instanceof DateTimeInterface) {
@@ -72,22 +134,30 @@ public function parse(mixed $value): ?DateTimeImmutable
72134
}
73135

74136
try {
75-
if ('' !== $this->format) {
76-
$value = DateTimeImmutable::createFromFormat($this->format, $value);
137+
$value = DateTimeImmutable::createFromFormat($this->format, $value, $this->timezone);
138+
if (false === $value) {
139+
return null;
140+
}
77141

78-
return false === $value ? null : $value;
142+
$errors = DateTimeImmutable::getLastErrors();
143+
if (
144+
(isset($errors['warning_count']) && 0 < $errors['warning_count']) ||
145+
(isset($errors['error_count']) && 0 < $errors['error_count'])
146+
) {
147+
return null;
79148
}
80149

81-
return new DateTimeImmutable($value);
82-
} catch (Exception) {
150+
return $value;
151+
} catch (ValueError) {
83152
return null;
84153
}
85154
}
86155

87-
public function metadata(): Metadata
156+
public function metadata(): FieldMetadata
88157
{
89-
return new Metadata([
158+
return new FieldMetadata([
90159
'format' => $this->format,
160+
'timezone' => $this->timezone?->getName(),
91161
]);
92162
}
93163
}

src/Schema/DateFieldTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ final class DateFieldTest extends TestCase
2525

2626
protected function setUp(): void
2727
{
28-
$this->field = DateField::native();
28+
$this->field = new DateField('Y-m-d');
2929
}
3030

3131
public function testParseUsesNativeConstructorWhenFormatIsEmpty(): void
@@ -38,7 +38,7 @@ public function testParseUsesNativeConstructorWhenFormatIsEmpty(): void
3838

3939
public function testParseUsesCreateFromFormatWhenFormatIsProvided(): void
4040
{
41-
$field = DateField::withFormat('d-m-Y');
41+
$field = new DateField('d-m-Y');
4242
$result = $field->parse('01-01-2024');
4343

4444
self::assertInstanceOf(DateTimeImmutable::class, $result);

0 commit comments

Comments
 (0)