Note
This repository is archived, but the project is not. Development continues over here: https://codeberg.org/PackageFactory/extractor
A fluent interface that allows to validate primitive PHP data structures while also reading them
composer require --dev packagefactory/extractor
Let's say, you have a PHP-native array structure like this one:
$configuration = [
'mailer' => [
'transport' => 'smtp',
'host' => 'smtp.example.com',
'port' => 465
]
];
It contains configuration for a mailing service. In a lot of PHP projects, configuration comes in this format, usually by being parsed from YAML or JSON sources. While these formats are nicely readable and writable, the result PHP array data structure is completely exempt from type safety.
It is much more desirable to handle the given configuration using a value object like this one:
final class MailerConfiguration
{
private function __construct(
public readonly MailerTransport $transport,
public readonly string $host,
public readonly int $port
) {
}
}
To convert the array structure into this object, it may be suitable to write a static factory method:
final class MailerConfiguration
{
/* ... */
public static function fromArray(array $array): self
{
if (!isset($array['transport']) || !is_string($array['transport'])) {
throw new \Exception('Transport must be a string!');
}
if (!isset($array['host']) || !is_string($array['host'])) {
throw new \Exception('Host must be a string!');
}
if (!isset($array['port']) || !is_int($array['port'])) {
throw new \Exception('Port must be an integer!');
}
return new self(
transport: MailerTransport::from($array['transport']),
host: $array['host'],
port: $array['port']
);
}
}
Unfortunately, this is a lot of code to write and it would become even more, if we'd actually like to have more helpful error messages.
This is where the Extractor
comes in. Using the Extractor
API, we can write a static factory method like this:
final class MailerConfiguration
{
/* ... */
public static function fromExtractor(Extractor $extractor): self
{
return new self(
transport: MailerTransport::from($extractor['transport']->string()),
host: $extractor['host']->string(),
port: $extractor['port']->int()
);
}
}
The extractor handles the runtime type checks for us and throws helpful error messages, if the datastructure doesn't follow our assumptions.
To complete the example from the beginning:
$configuration = [
'mailer' => [
'transport' => 'smtp',
'host' => 'smtp.example.com',
'port' => 465
]
];
$mailerConfiguration = MailerConfiguration::fromExtractor(
Extractor::for($configuration)['mailer']
);
Extractor::for(true)->bool(); // returns `true`
Extractor::for(false)->bool(); // returns `false`
Extractor::for(true)->boolOrNull(); // returns `true`
Extractor::for(false)->boolOrNull(); // returns `false`
Extractor::for(null)->boolOrNull(); // returns `null`
Checks if the data given to the extractor is a boolean and returns it if thats the case. When boolOrNull
is used, null
will pass as well.
Extractor::for(42)->int(); // returns `42`
Extractor::for(42)->intOrNull(); // returns `42`
Extractor::for(null)->intOrNull(); // returns `null`
Checks if the data given to the extractor is an integer and returns it if thats the case. When intOrNull
is used, null
will pass as well.
Extractor::for(47.11)->float(); // returns `47.11`
Extractor::for(47.11)->floatOrNull(); // returns `47.11`
Extractor::for(null)->floatOrNull(); // returns `null`
Checks if the data given to the extractor is a float and returns it if thats the case. When floatOrNull
is used, null
will pass as well.
Extractor::for(42)->intOrFloat(); // returns `42`
Extractor::for(47.11)->intOrFloat(); // returns `47.11`
Extractor::for(42)->intOrfloatOrNull(); // returns `42`
Extractor::for(47.11)->intOrfloatOrNull(); // returns `47.11`
Extractor::for(null)->intOrfloatOrNull(); // returns `null`
In JSON
there's no distinction between integer and float types. Everything is just a number
. These two methods check if the data given to the extractor is a float or an integer (and therefore a number
) and returns it if thats the case. When intOrfloatOrNull
is used, null
will pass as well.
Extractor::for('string')->string(); // returns `"string"`
Extractor::for('string')->stringOrNull(); // returns `"string"`
Extractor::for(null)->stringOrNull(); // returns `null`
Checks if the data given to the extractor is a string and returns it if thats the case. When stringOrNull
is used, null
will pass as well.
Extractor::for([])->array(); // returns `[]`
Extractor::for([])->arrayOrNull(); // returns `[]`
Extractor::for(null)->arrayOrNull(); // returns `null`
Checks if the data given to the extractor is an array and returns it if thats the case. When stringOrNull
is used, null
will pass as well.
In order to deal with nested array structures, Extractor
implements the \ArrayAccess
interface.
Given you have an Extractor
that wraps an array, when you access a key, you'll receive the value for that key wrapped in another Extractor
instance:
$extractor = Extractor::for([ 'key' => 'value' ]);
$extractor['key']->string(); // returns `"value"`
$extractor['key']->int(); // throws
If you access an unknown key, it'll be treated like Extractor::for(null)
:
$extractor['unknown key']->stringOrNull(); // returns `null`
$extractor['unknown key']->string(); // throws
If you access a key on something other than an array, Extractor
will throw:
$extractor = Extractor::for('This is not an array...');
$extractor['key']; // throws
Each Extractor
instance provides you with the access path by which it has been retrieved:
$extractor = Extractor::for([
'some' => [
'deep' => [
'path' => '1234'
]
]
]);
$nested = $extractor['some']['deep']['path'];
var_dump($nested->getPath());
// Output:
// array(3) {
// [0] =>
// string(4) "some"
// [1] =>
// string(4) "deep"
// [2] =>
// string(4) "path"
// }
Extractor
implements the \IterableAggregate
interface, which allows you to loop over it using foreach
:
foreach (Extractor::for([ 'key' => 'value' ]) as $key => $value) {
$key->string(); // returns `"key"`
$value->string(); // returns `"value"`
$key->int(); // throws
}
As you see, both $key
and $value
are themselves instances of Extractor
.
If you try to iterate over an Extractor
that wraps something other than an array, the Extractor
will throw:
foreach (Extractor::for('This is not an array...') as $key => $value) { // throws
}
Extractor
may throw instances of ExtractorException
. Each ExtractorException
carries the access path by which the throwing Extractor
has been retrieved and tries to provide a helpful error message:
$extractor = Extractor::for([
'some' => [
'deep' => [
'path' => '1234'
]
]
]);
try {
$extractor['some']['deep']['path']->int();
} catch (ExtractorException $e) {
var_dump($e->getPath());
// Output:
// array(3) {
// [0] =>
// string(4) "some"
// [1] =>
// string(4) "deep"
// [2] =>
// string(4) "path"
// }
var_dump($e->getMessage());
// Output:
// string(65) "Value was expected to be of type int, got string("1234") instead."
}
We will gladly accept contributions. Please send us pull requests.
see LICENSE