nolikein/objectable-struct

A way the use structs with the soft typing of phpa

Installs: 1 608

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Forks: 0

Type:librairy

2.2.1 2023-07-04 09:01 UTC

README

PHP 1.0 Packagist link Test Pass

Do you search a way to create kind of struct feature ? We are not in the C langage but i hope with this package to have something similar.

In other hand, Laravel models are cool for flexibility and simplicity to be used as array/object and to cast values, i really love them. The current librairy add a Struct class that let you the choice to do every of these things and a little more...

The concept is simple, you create a class or an instance and you define your constraints, then each time you set a value, this one will be checked or casted depending of what you selected. If something is not okey, it will throw and exception. You'll see that bellow !

Table of contents

  1. Installation
  2. Simple use
  3. Bases
  4. Constraints
    1. Casting
      1. Available casters
      2. Adding your own caster
      3. Notes about casting
    2. Instance matching
  5. Inheritence
  6. Hooks
    1. Introduction
    2. Disabling hooks
    3. Avoid a property to be updated manually
    4. Hooks example
  7. Available methods
  8. Todo
  9. Licence

Installation

You need to use composer to install the librairy.

composer require nolikein/objectable-struct ^2.0

Simple use

A struct is a simple array/object

use Nolikein\Objectable\Struct;

$chad = new Struct([
    'can_be_used_as_array' => true,
    'can_be_used_as_object' => true,
]);

$chad['can_be_used_as_array'] === true;
$chad->can_be_used_as_object === true;
$chad->doesnt_exists === null; // Doesnt throws any Exception because strict mode is disabled
// Strict mode will be showed further, by default it is disabled

// We can check if an item is set
isset($s['item'])
isset($s->item)

// We can unset an item
unset($s['item'])
unset($s->item)

You can force types with casters Constraint

use Nolikein\Objectable\Struct;

$metaChad = new Struct([
    'value' => 123, // Attributes parameter
], [
    'value' => 'string', // Casters parameter
]);
$metaChad->value === '123'; // Casted value are automatically casted even at creation
$metaChad->value = 123; // The setted value is casted
$metaChad->value === '123'; // And default_string will ever be a string

You can force instances with Instance Constraint

use Nolikein\Objectable\Struct;
use Nolikein\Objectable\Constraints\Instance;

$igniChad = new Struct([
    // Instances can be equal to null
    'igni' => null
    'aqua' => new \Model
], [
    'igni' => 'instanceof:' . \Model::class,
    'aqua' => Instance::of(\Model::class)->notNullable(),
]);

$igniChad->igni = new \Model(); // Allowed
$igniChad->igni = null; // Allowed
$igniChad->igni = new \OtherModel; // Not allowed

$igniChad->aqua = new \Model(); // Allowed
$igniChad->aqua = null; // Not allowed
$igniChad->aqua = new \OtherModel; // Not allowed

Bases

Structs are array-like:

use Nolikein\Objectable\Struct;

$notAnArray = new Struct([
    'but' => 'works as :D',
]);

echo $notAnArray['but'];
// 'works as :D'

Also, they are dark containers:

use Nolikein\Objectable\Struct;

$darkContainer = new Struct([
    'propertyNotDeclared' => 'Truly !',
]);

echo $darkContainer->propertyNotDeclared;
// 'Truly !'

What happen when a property does not exists? You'll get null.

use Nolikein\Objectable\Struct;

$darkContainer = new Struct();

echo $darkContainer->notDefined;
// null

Constraints

Constraint: Casting

Casting is the ability to force an item to have a certain type.

Here is an example:

use Nolikein\Objectable\Struct;

// Here is a classical struct with one attribute
$castChad = new Struct([
    'defined' => 'its value',
]);

// We'll apply a constraint. Our 'defined' attribute must be a string
$castChad->setConstraint('defined', 'string');

// Now, if we try to use a different type than string, it will be casted
$castChad->defined = 123;
$castChad->defined === '123';

// We have a scalar that could be an integer, so nothing block us to do it now
$castChad->setConstraint('defined', 'integer');
// Our value was casted when calling setConstraint().
$castChad->defined === 123;

// Now... what happen if the attribute does not exists yet?
// It will have a default value !
$castChad->setConstraint('notDefined', 'int');
$castChad->notDefined === 0;

Caster notations

Casters are instance of Nolikein\Objectable\Constraints\Cast that can be registered in the constraints bag. However, you saw that we used names such as string or integer, but theses names are converted into instance under the hood. Thus, the code bellow works:

use Nolikein\Objectable\Struct;
use Nolikein\Objectable\Casters\StringCast;

$castChad = new Struct();
// Faster way to write, slower way to guess
$castChad->setConstraint('defined', 'string');
// Slow way to write, fast way to guess
$castChad->setConstraint('defined', StringCast::class);
// Very slow way to write, very fast way to guess
$castChad->setConstraint('defined', new StringCast());

Available casters

Here is the list of supported casts:

  • int|integer
  • float|double
  • string
  • bool|boolean
  • array
  • object
  • json
  • datetime
  • struct

You can also use the Nolikein\Objectable\Enums\StructCasters PHP 8 enumeration to set types such as:

use Nolikein\Objectable\Struct;
use Nolikein\Objectable\Enums\StructCasters;

$s = new Struct([], [
    'myInt' => StructCasters::Integer,
    'myFloat' => StructCasters::Float,
    'myString' => StructCasters::String,
    'myBoolean' => StructCasters::Boolean,
    'myArray' => StructCasters::Array,
    'myStdObject' => StructCasters::Object,
    'myDatetime' => StructCasters::Datetime,
    'myStruct' => StructCasters::Struct,
]);

Note: You can define you own casters (see bellow) then use your own Enumerations to set caster types.

Adding your own caster

First, you need to create a class that extends the Nolikein\Objectable\Constraints\Cast class. This class implements two interfaces: Constraint and CastPattern. The first allows you to create a new Constraint and the second to create a Caster.

Here is the code that defines a boolean.

use Nolikein\Objectable\Constraints\Cast;

class BooleanCast extends Cast
{
    public function getTypeName(): string
    {
        return 'boolean';
    }

    /**
     * @return array<int, string>
     */
    public function getTypeAliases(): array
    {
        return [
            'bool',
        ];
    }

    public function canCast(mixed $value): bool
    {
        return null !== filter_var($value, FILTER_VALIDATE_BOOLEAN, [
            'flags' => FILTER_NULL_ON_FAILURE
        ]);
    }

    public function performCast(mixed $value): mixed
    {
        return (bool) $value;
    }

    public function getDefaultValue(): mixed
    {
        return false;
    }
}

Then, add your class with the addCaster static method or Struct.

Struct::addCaster(MyNewType::class);

Note: the method can also take an instance as argument (from anonymous class by example).

Notes about casting

  • A jsonable content is either an array or a scalar.
  • A datetimable content is either an instance of \DateTimeInterface or a string where the format is accepted by DateTimeImmutable::__construct().
  • The casts 'object' and 'datetime' return null by default.

Constraint: Instance matching

You can force an attribute to have be an instance of something. If you try to set an instance that is not allowed, you'll trigger a \Nolikein\Objectable\Exceptions\NotInstanceOf exception.

Here is an example:

use Nolikein\Objectable\Struct;
use Nolikein\Objectable\Constraints\Instance;

class Model {}
class OtherModel {}

$rockAndStone = new Struct([
    // Instances can be equal to null
    'igni' => null,
], [
    // Fast method
    'igni' => 'instanceof:' . Model::class
    // What is really done backside
    'igni' => Instance::of(Model::class),
    // Alternative method to pass instance
    'igni' => Instance::ofObject(new Model()),
]);

$rockAndStone->igni = new Model(); // Allowed
$rockAndStone->igni = null; // Allowed
$rockAndStone->igni = new OtherModel; // Will throw a NotInstanceOf exception

// Relationships work
class Child extends Model {}
$rockAndStone->igni = new Child; // Allowed by relationship
// Note, interfaces also work

However, if instances can be null by default, this can be disabled:

use Nolikein\Objectable\Struct;
use Nolikein\Objectable\Constraints\Instance;

$rockAndStone = new Struct([
    // Not allowed
    'terra' => null
], [
    'terra' => Instance::of(\Model::class)->notNullable(),
]);
use Nolikein\Objectable\Struct;
use Nolikein\Objectable\Constraints\Instance;

$rockAndStone = new Struct([
    'terra' => new \Model(),
], [
    'terra' => Instance::of(\Model::class)->notNullable(),
]);

$rockAndStone->terra = new \Model; // Allowed
$rockAndStone->terra = null; // Not allowed

Strict mode

You know that you can add new attributes that was not defined first and you don't have to apply constraint on each attribute. But in Strict mode you cannot get or set attribute not defined first or having an attribute without a constraint or even change a constraint !

The Strict mode allows you to have a pattern that never change, in the C language spirit.

  • If an attribute is not declared, you'll get a \Nolikein\Objectable\Exceptions\AttributeDoesntExists exception.
  • If an attribute miss a constraint, you'll get a \Nolikein\Objectable\Exceptions\ConstraintNotDeclared exception.
  • If you try to add/modify/remove any constraint, you'll get a \Nolikein\Objectable\Exceptions\CannotChangeConstraint exception.

Let's see an example:

use Nolikein\Objectable\Struct;

// !!! Attribute and constraints bag are reversed when using strict()
$gigaChad = Struct::strict([
    'myBoolean' => 'bool',
    'myString' => 'string',
], [
    'myBoolean' => true,
    'myString' => '123',
]);

// Can only access to defined items
$gigaChad->myBoolean === true;
$gigaChad->myString === '123';

// This will throw an exception : Nolikein\Objectable\Exceptions\AttributeDoesntExists
$gigaChad->notDefined === null;
$gigaChad->notDefined = 'any value';

$gigaChad = Struct::strict([], [
    'myString' => 'string',
]); // Throws ConstraintNotDeclared

Inheritence

You can define a class to write a Struct declaration then use it at multiple places.

To be very fast, this is how to define our features:

  • Attributes -> $attributes
  • Constraints -> $constraints or constraints(): array to use instances.
  • Strict mode -> $strictMode

Here is an example of all these features at the same time:

use App\Models\Phone;
use Nolikein\Objectable\Struct;
use Nolikein\Objectable\Casters\IntegerCast;
use Nolikein\Objectable\Casters\StringCast;
use Nolikein\Objectable\Constraints\Instance;

class User extends Struct
{
    protected array $attributes = [
        'firstname' => 'John',
        'lastname' => 'NotDoe',
        'description' => 'What did you expect ?',
    ];

    protected array $constraints = [
        'firstname' => 'string',
        'age' => 'integer',
        'phone' => 'instanceof:App\Models\Phone',
    ];

    // Alternative way, allows to use instances
    protected function constraints(): array
    {
        return [
            'firstname' => StringCast::class,
            'age' => IntegerCast::class,
            'phone' => Instance::of(Phone::class),
            // Alternative way
            'phone' => Instance::ofObject(new Phone),
        ];
    }

    // In this example, all attributes was not defined so the program will trigger an exception.
    protected bool $strictMode = true;
}

Hooks

Hooks introduction

The feature 2.1.0 added hooks to perform actions when you update data from class inheritence:

/**
 * Before updating an attribute.
 */
protected function updatingAttribute(string $name, mixed $value): void
{
}

/**
 * After updating a attribute.
 */
protected function updatedAttribute(string $name, mixed $value): void
{
}

/**
 * Before updating an constraint.
 */
protected function updatingConstraint(string $name, Constraint $constraint): void
{
}

/**
 * After updating a constraint.
 */
protected function updatedConstraint(string $name, Constraint $constraint): void
{
}

Disabling hooks

Sometimes, you will need to disable hooks temporary. Imagine having a cascade effect because you updated some data. To avoid it, you can disable hooks...

  • manually: disableHooks(), enableHooks()
  • with a decorator : withoutHooks()

Here is an example:

/**
 * After updating a attribute.
 */
protected function updatedAttribute(string $name, mixed $value): void
{
    $this->disableHooks();
    $this->doSomething();
    $this->enableHooks();

    $this->withoutHooks(fn () => $this->doSomething());
}

All these methods are public, so if you need to disable hooks from a higher scope, you can !

Avoid a property to be updated manually

Sometimes too, you will need to avoid anyone to update a data that must only be automatically updated. You need to be in a "updating" method then to call the skipFilling() method.

if ('data_automatically_updated' === $name) {
    $this->skipFilling();
}

Under the hoods, the setAttribute method catch a Nolikein\Objectable\Exceptions\SkipFilling exception that just pass the filling actions.

Hooks example

Here is an example of what you can do with the hooks feature:

use Nolikein\Objectable\Struct;
use Nolikein\Objectable\Exceptions\SkipFilling;

class PricingResult extends Struct
{
    /** @var array<string, mixed> The struct attributes */
    protected array $attributes = [
        'price' => 0.0,
        'quantity' => 0.0,
        'total' => 0.0,
    ];

    /**
     * The data will be updated.
     */
    protected function updatingAttribute(string $name, mixed $value): void
    {
        // Avoid any developper to update the total manually
        if ('total' === $name) {
            $this->skipFilling();
        }
    }

    /**
     * The data has already been updated.
     */
    protected function updatedAttribute(string $name, mixed $value): void
    {
        // If the attribute that has been updated is named "price" or "quantity"
        if ('price' === $name || 'quantity' === $name) {
            // Then calculate the total from the price and quantity.
            // But you need to disable hooks, otherwise the attribute "total" will be skipped.
            $this->withoutHooks(fn () => $this->total = $this->price * $this->quantity);
        }
    }
}

$s = new PricingResult([
    'price' => 5,
    'quantity' => 2,
]);

echo $s->total; // Gives you 10

In this example, we create a struct that represent a pricing result. The struct observe "price" and "quantity" properties, if any of them is updated so the total is automatically updated. This action is performed by the "updatedAttribute" hook.

This should be the only way the total is updated so we perform a "protection" in case of a developper try to update the "total" field. This action is performed by the "updatingAttribute" hook.

Notes

Be carefull, a bug can happen if you try to set only $constraints and not the $attributes that are automatically updated. This only happen when the Struct is created ; Since the constraint bag set default values when $attributes bag is not defined, the not defined attributes in the $attributes bag will be written again even.

I'll take the PricingResult example: If you update "total" from "price", since the "total" attribute in defined bellow "price" in the $attributes array, the "total" will be calculated first then replaced by the default constraint value because the .

Available methods

Struct methods

use Nolikein\Objectable\Struct;

/**
 * List of available struct methods
 */
$s = new Struct();

// Checks if an attribute has been set
$s->hasAttribute($name);

// Retrieve an attribute
$s->getAttribute($name);

// Set the value for an attribute
$s->setAttribute($name, $value);

// Set value for multiple items at the same time
$s->fill([$name => $value]);

// Print the content as json
$s->toJson();
$s->__toString();
echo $s; // use __toString()

// Print the content as array
$s->toArray();
$s->jsonSerialize();

/**
 * Constraint feature
 */
// Checks if a constraint had been set
$s->hasConstraint($name);

// Retrieve a constraint as instance
$s->getConstraint($name);

// Set a new constraint value -> does not work in strict mode
$s->setConstraint($name, 'string' || StringCast::class || $castInstance || StructCasters::String);
// Note: $value can be the string name of a caster, its class name as class-string, an instance of Constraint. Is also supported a string that represent the cast or a BackEnum instance (PHP 8 enumerations).

// Set constraint value for multiple items at the same time -> does not work in strict mode
$s->fillConstraints([$name => $value]);

// Remove a constraint from the constraint bag -> does not work in strict mode
$s->removeConstraint($name);

// Create a new instance of constraint from its name
$s->newConstraintFromString('string' || StringCast::class || 'instanceof:model');


// Universal way to check if a caster is supported
$s->hasCaster('string' || StringCast::class || $instanceOfCast)

// Checks if a caster is supported from name
$s->hasCasterFromName('string');

// Checks if a caster is supported from class-string
$s->hasCasterFromClassString(StringCast::class);

// Checks if a caster is supported from an instance
$s->hasCasterFromInstance($instanceOfCast);


// Universal way to register a new caster
Struct::addCaster(CustomCast::class || $casterInstance);

// Register a new caster from a class-string
Struct::addCasterFromClassString(CustomCast::class);

// Register a new caster from a fresh instance
Struct::addCasterFromInstance($casterInstance);

/**
 * Strict feature
 */

// Create a fresh instance of strict Struct
$s = Struct::strict($constraints, $attributes);

// Checks if a struct use strict mode
$s->isStrict();

// Enable the strict mode
$s->enableStrict();

// Enable the strict mode
$s->disableStrict();

/**
 * Hooks feature
 */

// Checks if the hooks are enabled (yes by default)
$s->usesHooks()

// Disable the hooks
$s->disableHooks()

// Enable the hooks
$s->enableHooks()

// Perform action without hooks but enable it after that
$s->withoutHooks()

Casters methods

use Nolikein\Objectable\Casters\IntegerCast;

// Create a new instance
$int = new IntegerCast();

// Checks if a cast is possible
$int->canCast('123'); // True

// Perform a cast
$result = $int->performCast('123');
echo $result; // 123

// Retrieve the main caster name
echo $int->getTypeName(); // integer

// Retrieve potential name aliases
echo implode(', ', $int->getTypeAliases()); // int

// Retrieve the default value
echo $int->getDefaultValue(); // 0

/**
 * Special methods
 */
// JSON
use Nolikein\Objectable\Casters\JsonCast;

$json = new JsonCast();

// Is the content can be encoded
$json->isJsonable($mabeJsonable);

// Is the content can be decoded
$json->isJson($maybeJson);

// DATETIME
use Nolikein\Objectable\Casters\DatetimeCast;

$datetimeCast = new DatetimeCast();
$datetimeCast->isDatetimable(new Datetime()); // true
$datetimeCast->isDatetimable('now'); // true
$datetimeCast->isDatetimable('2023-02-02T16:07:27+01:00'); // true

Licence

MIT