Skip to content

Commit

Permalink
add support for casting meta
Browse files Browse the repository at this point in the history
  • Loading branch information
frasmage committed Apr 24, 2024
1 parent 40e4471 commit 080a912
Show file tree
Hide file tree
Showing 17 changed files with 677 additions and 47 deletions.
23 changes: 17 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ Version 6 contains a number of changes to improve the security and performance o
### Data Types

- Added `SignedSerializeHandler` as a catch-all datatype, which will attempt to serialize the data using PHP's `serialize()` function. The payload is cryptographically signed with an HMAC before being stored in the database to prevent PHP object injection attacks.

- Deprecated `SerializableHandler` in favor of the new `SignedSerializeHandler` datatype. The `SerializableHandler` will be removed in a future release. In the interim, added the `metable.options.serializable.allowedClasses` config to protect against unserializing untrusted data.
- Deprecated `ArrayHandler` and `ObjectHandler`, due to the ambiguity of nested array/objects switching type. These will be removed in a future release. The `SignedSerializeHandler` should be used instead.
- Added `PureEnumHandler` and `BackedEnumHandler` which adds support for storing enum values as Meta.
- Added `StringableHandler` which adds support for storing `Illuminate\Support\Stringable` objects as Meta.
- Added `DateTimeImmutableHandler` which adds support for storing `DateTimeImmutable`/`CarbonImmutable` objects as Meta.
- `ModelHandler` will now validate that the encoded class is a valid Eloquent Model before attempting to instantiate it during unserialization. If the class is invalid, the meta value will return `null`.
- `ModelHandler` will no longer throw a model not found exception if the model no longer exists. Instead, the meta value will return `null`. This is more in line with the existing behavior of the `ModelCollectionHandler`.
- `ModelCollectionHandler` will now validate that the encoded collection class is a valid Eloquent collection before attempting to instantiate it during unserialization. If the class is invalid, an instance of `Illuminate\Database\Eloquent\Collection` will be used instead.
Expand All @@ -28,13 +29,13 @@ Version 6 contains a number of changes to improve the security and performance o
- Added `isIdempotent(): bool` method to `HandlerInterface` which should indicate whether multiple calls to the `serialize()` method with the same value will produce the same serialized output. This is used to determine if the complete serialized value can be used when searching for meta values.
- Added `useHmacVerification(): bool` method to `HandlerInterface` which should indicate whether the integrity of the serialized data should be verified with an HMAC.

### Commands
### New Commands

- Added `metable:refresh` artisan command which will decode and re-encode all meta values in the database. This is useful if you have changed the data type handlers and need to update the serialized data and indexes in the database.

### Mediable trait
### Searching Metables By Meta Value

- `whereMeta()`, `whereMetaIn()`, and `orderByMeta()` query scopes will now scan the indexed `string_value` column instead of the serialized `value` column. This greatly improves performance when searching for meta values against larger datasets.
- the Metable `whereMeta()`, `whereMetaIn()`, and `orderByMeta()` query scopes will now scan the indexed `string_value` column instead of the serialized `value` column. This greatly improves performance when searching for meta values against larger datasets.
- `whereMetaNumeric()` and `orderByMetaNumeric()` query scopes will now scan the indexed `numeric_value` column instead of the serialized `value` column. This greatly improves performance when searching for meta values against larger datasets.
- `whereMetaNumeric()` query scope will now accept a value of any type. It will be converted to an integer or float by the handler. This is more consistent with the behaviour of the other query scopes.
- Added additional query scopes to more easily search meta values based on different criteria:
Expand All @@ -47,10 +48,20 @@ Version 6 contains a number of changes to improve the security and performance o
- `whereMetaNotBetweenNumeric()`
- `whereMetaIsNull()`
- `whereMetaIsModel()`
- If the data type handlers cannot convert the search value provided to a whereMeta* query scope to a string or numeric value (as appropriate for the scope), then an exception will be thrown.
- If the data type handlers cannot convert the search value provided to a whereMeta* query scope to a string or numeric value (as appropriate for the method), then an exception will be thrown.

### Metable Casting

- Added support for casting meta values to specific types by defining the `$castMeta` property or `castMeta(): array` method on the model. This is similar to the `casts` property of Eloquent Models used for attributes. All cast types supported by Eloquent Models are also available for Meta values.Values will be cast before values are stored in the database to ensure that they are indexed consistently
- the `encrypted:` cast prefix can be combined with any other cast type to cast to the desired type before the value is encrypted to be stored it in the database. Encrypted values are not searchable.
- A value of `null` is ignored by all cast types.
- Added `mergeMetaCasts()` method which can be used to override the defined cast on a meta key at runtime.

### Meta
- Added `$meta->raw_value` property which exposes the raw serialized value of the meta key. This is useful for debugging purposes.
- Added `$meta->string_value` and `$meta->numeric_value` attributes, which are used for optimizing queries filtering by meta value
- Added `$meta->hmac` attribute, which is used by some data type handlers to validate that the payload has not been tampered with.
- Added `$meta->raw_value` virtual attribute, which exposes the raw serialized value of the meta key. This is useful for debugging purposes.
- Added `encrypt()` method, used internally by the `Metable::setMetaEncrypted()` method

# 5.0.1 - 2021-09-19
- Fixed `setManyMeta()` not properly serializing certain types of data.
Expand Down
24 changes: 21 additions & 3 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,37 @@

## 5.X -> 6.X

### Compatibility

* Minimum PHP version moved to 8.1
* Minimum Laravel version moved to 10
* Some methods have had their signatures adjusted to use PHP 8+ mixed and union types. If extending any class or implementing any interface from this package, method signatures may need to be updated.

### Schema Changes

* A new schema migration has been added which adds three new columns to the meta table and improves indexing for querying by meta values.
* Add the `PureEnumHandler` and `BackedEnumHandler` classes to the `datatypes` config. These handlers provide support for storing enum values as Meta.
* Recommended to add the `SignedSerializeHandler` to the end of `datatypes` config (catch-all).

### Configuration Changes

* Add the `Plank\Metable\DateType\PureEnumHandler`, `Plank\Metable\DateType\BackedEnumHandler`, `Plank\Metable\DateType\DateTimeImmutableHandler`, `Plank\Metable\DateType\StringableHandler` classes to the `datatypes` config. The order of these handlers is not important, except for `DateTimeImmutableHandler` which must come before `DateTimeHandler` if both are used.
* Recommended to add the `Plank\Metable\DateType\SignedSerializeHandler` class to the end of `datatypes` config list (catch-all).
* The `SerializableHandler`, `ArrayHandler`, and `ObjectHandler` data types have been deprecated in favor of the new `SignedSerializeHandler`. If you have any Meta encoded using any of these data types, you should continue to include them in the `datatypes` config _after_ the `SignedSerializeHandler` to ensure that existing values will continue to be properly decoded, but new values will use the new encoding. Once all old values have been migrated, you may remove the deprecated data types from the `datatypes` config.
* For security reasons, if you have any existing Meta encoded using `SerializableHandler`, you must configure the `metable.serializableHandlerAllowedClasses` config to list classes that are allowed to be unserialized. Otherwise, all objects will be returned as `__PHP_Incomplete_Class`. This config may be set to `true` to disable this security check and allow any class, but this is not recommended.

### Handlers

* If you have any custom data types, you will need to implement the new methods from the `HandlerInterface`:
* `getStringValue(): ?string` and `getNumericValue(): null|int|float`: These are used to populate the new indexed search columns. You may return `null` if the value cannot be converted into the specified format or does not need to be searchable.
* `isIdempotent(): bool`: This method should indicate whether multiple calls to the `serialize()` method with the same value will produce the same serialized output. This is used to determine if the complete serialized value can be used when searching for meta values.
* `useHmacVerification(): bool`: if the integrity of the serialized data should be verified with a HMAC, return `true`. If unserializing this data type is safe without HMAC verification, you may return `false`.
* Once you have applied the schema migration and configured the changes to the `datatypes` config, you should run the `metable:refresh` Artisan command to update all existing meta values to use the new types and populate the index columns. After this command has been run, you may remove the deprecated data types from the `datatypes` config.

### Update Existing Data

* Once you have applied the schema migration and updated the `datatypes` config, you should run the `metable:refresh` Artisan command to update all existing meta values to use the new types and populate the index columns.
* After this command has been run, you may remove the deprecated data types from the `datatypes` config.

### Query Scopes

* Review the documentation about which data types can be queried with the various `whereMeta*` and `whereMeta*Numeric` query scopes. If you are querying the serialized `value` column directly, be aware that the formatting of array/object data types may have changed.

## 4.X -> 5.X
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
"require": {
"php": ">=8.1",
"ext-json": "*",
"illuminate/support": "^10.0|^11.0",
"illuminate/database": "^10.0|^11.0",
"illuminate/support": "^10.10|^11.0",
"illuminate/database": "^10.10|^11.0",
"phpoption/phpoption": "^1.8"
},
"require-dev": {
Expand Down
8 changes: 5 additions & 3 deletions config/metable.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,18 @@
* If you change this list, it may be necessary to refresh the meta table with the `artisan metable:refresh` command.
*/
'datatypes' => [
Plank\Metable\DataType\BooleanHandler::class,
Plank\Metable\DataType\NullHandler::class,
Plank\Metable\DataType\BooleanHandler::class,
Plank\Metable\DataType\IntegerHandler::class,
Plank\Metable\DataType\FloatHandler::class,
Plank\Metable\DataType\StringHandler::class,
Plank\Metable\DataType\StringableHandler::class,
Plank\Metable\DataType\DateTimeImmutableHandler::class,
Plank\Metable\DataType\DateTimeHandler::class,
Plank\Metable\DataType\ModelHandler::class,
Plank\Metable\DataType\ModelCollectionHandler::class,
Plank\Metable\DataType\BackedEnumHandler::class,
Plank\Metable\DataType\PureEnumHandler::class,
Plank\Metable\DataType\ModelHandler::class,
Plank\Metable\DataType\ModelCollectionHandler::class,

/*
* The following handler is a catch-all that will encode anything.
Expand Down
37 changes: 35 additions & 2 deletions docs/source/datatypes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,39 @@ Any object implementing the ``DateTimeInterface``. Object will be converted to
<?php
$metable->setMeta('last_viewed', \Carbon\Carbon::now());

DateTimeImmutable & CarbonImmutable
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

+----------------------+-----+
| Handler | ``\Plank\Metable\DataType\DateTimeImmutableHandler`` |
| String Query Scopes | Yes (UTC format) |
| Numeric Query Scopes | Yes (timestamp) |
| Other Query Scopes | |
+----------------------+-----+

Any object extending the ``DateTimeImmutable`` class. Object will be converted to a ``CarbonImmutable`` instance when unserialized.

::

<?php
$metable->setMeta('completed_at', \Carbon\CarbonImmutable::now());

Stringable
^^^^^^^^^^
+----------------------+-----+
| Handler | ``\Plank\Metable\DataType\StringableHandler`` |
| String Query Scopes | Yes |
| Numeric Query Scopes | If numeric string |
| Other Query Scopes | |
+----------------------+-----+

Strings wrapped in Laravel's ``Illuminate\Support\Stringable`` fluent interface.

::

<?php
$metable->setMeta('address', Str::of('123 Somewhere St.'));

Enums
^^^^^^^^
+----------------------+-----+
Expand All @@ -181,15 +214,15 @@ Objects and Arrays
| Other Query Scopes | |
+----------------------+-----+

Objects and arrays will be serialized using PHP's ``serialize()`` function, to allow for the storage and retrieval of complex data structures. The serialized value is cryptographically signed with an HMAC which is verified before the data is unserialized to prevent PHP object injection attacks. The application's ``APP_KEY`` is used as the HMAC signing key.
Objects and arrays will be serialized using PHP's ``serialize()`` function, to allow for the storage and retrieval of complex data structures.

::

<?php
$metable->setMeta('data', ['key' => 'value']);
$metable->setMeta('data', new MyValueObject(123));

HMAC verification is generally sufficient for preventing PHP object injection attacks, but it possible to further restrict what can be unserialized by specifying an array or class name in the ``metable.serializableHandlerAllowedClasses`` config in the ``config/metable.php`` file.
The serialized value is cryptographically signed with an HMAC which is verified before the data is unserialized to prevent PHP object injection attacks. The application's ``APP_KEY`` is used as the HMAC signing key. HMAC verification is generally sufficient for preventing PHP object injection attacks, but it possible to further restrict what can be unserialized by specifying an array or class name in the ``metable.SignedSerializeHandlerAllowedClasses`` config in the ``config/metable.php`` file.

.. note:: The ``Plank\Metable\DataType\SignedSerializeHandler`` class should generally be the last entry the ``config/metable.php`` datatypes array, as it will accept data of any type, causing any handlers below it to be ignored for serializing new meta values. Any handlers defined below it will still be used for unserializing existing meta values. This can be used to temporarily provide backwards compatibility for deprecated data types.

Expand Down
45 changes: 45 additions & 0 deletions docs/source/handling_meta.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ To replace existing meta with a new set of meta, you can pass an associative arr
'age' => 18,
]);

Encrypting Meta
---------------

If storing sensitive data, you can instruct the package to encrypt a meta value when it is stored in the database. Encrypted values are automatically decrypted when retrieved. To encrypt a value, use the ``setMetaEncrypted()`` method or pass ``true`` as the third argument to the ``setMeta()`` method.

::

<?php
$model->setMetaEncrypted('secret', 'sensitive data');
$model->setMeta('secret', 'sensitive data', true);

Data of any type can be encrypted. Encrypted values are never searchable or sortable with query scopes.

Retrieving Meta
---------------

Expand Down Expand Up @@ -106,6 +119,7 @@ Alternatively, you may set default values as key-value pairs on the model itself

//...
}

::

<?php
Expand All @@ -116,6 +130,37 @@ Alternatively, you may set default values as key-value pairs on the model itself

.. note:: If a falsey value (e.g. ``0``, ``false``, ``null``, ``''``) has been manually set for the key, that value will be returned instead of the default value. The default value will only be returned if no meta exists at the key.

Casting Meta
------------

You can enforce that any meta attached to a particular key is always of a particular data type by specifying casts on the Metable model. Casts can be defined by specifying a $metaCasts attribute, or by adding a ``metaCasts(): array`` methods to the model.

::

<?php
class ExampleMetable extends Model {
use Metable;

protected $metaCasts = [
'optin' => 'boolean',
'age' => 'integer',
'secret' => 'encrypted:string',
'parent' => ExampleMetable::class,
'children' => 'collection:\App\ExampleMetable',
];

//...
}

All `cast types supported by Eloquent<https://laravel.com/docs/11.x/eloquent-mutators#attribute-casting>`_ are supported, with the following modifications:

- Casts are applied on write, not read. This means that the value will be serialized and stored in the database in the specified format, and indexes will be populated in a consistent manner. However, old data prior to the addition of the cast will not be automatically converted.
- All casts ignore values of ``null``. If a value is set to ``null``, it will be stored as ``null`` in the database, and will not be cast to the specified type.
- The ``encrypted`` cast will tell the package to always encrypt the value of that key, even if the 3rd parameter of ``setMeta()`` is not set to ``true``.
- The ``encrypted:`` cast prefix can be combined with any other cast type to convert the value to the specified type before encrypting it.
- when a class name is provided as a cast, if it implements ``\Illuminate\Contracts\Database\Eloquent\Castable``, it will be used to cast the value per the interface. Otherwise, it will enforce that the value is an instance of that class. If an instance of a different class is provided, an exception will be thrown. If the class is an Eloquent model, and an an integer or string is provided, it will attempt to retrieve the model from the database.
- The ``collection`` cast will preserve ``Illuminate\Database\Eloquent\Collection`` instances and contents, using the ``Plank\Metable\DataType\ModelCollection`` data type to store them. If passed a single model instance, it will be wrapped in an eloquent collection. A class name can be provided as an argument to enforce that the collection contains only instances of that class.

Retrieving All Meta
-------------------

Expand Down
2 changes: 1 addition & 1 deletion src/DataType/DateTimeHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class DateTimeHandler implements HandlerInterface
*
* @var string
*/
const FORMAT = 'Y-m-d H:i:s.uO';
public const FORMAT = 'Y-m-d H:i:s.uO';

/**
* {@inheritdoc}
Expand Down
76 changes: 76 additions & 0 deletions src/DataType/DateTimeImmutableHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

namespace Plank\Metable\DataType;

use Carbon\Carbon;
use Carbon\CarbonImmutable;
use DateTimeInterface;

/**
* Handle serialization of DateTimeInterface objects.
*/
class DateTimeImmutableHandler implements HandlerInterface
{
/**
* The date format to use for serializing.
*
* @var string
*/
public const FORMAT = 'Y-m-d H:i:s.uO';

/**
* {@inheritdoc}
*/
public function getDataType(): string
{
return 'datetime_immutable';
}

/**
* {@inheritdoc}
*/
public function canHandleValue(mixed $value): bool
{
return $value instanceof \DateTimeImmutable;
}

/**
* {@inheritdoc}
*/
public function serializeValue(mixed $value): string
{
return $value->format(self::FORMAT);
}

/**
* {@inheritdoc}
*/
public function unserializeValue(string $serializedValue): mixed
{
return CarbonImmutable::createFromFormat(self::FORMAT, $serializedValue);
}

public function getNumericValue(mixed $value): null|int|float
{
return $value instanceof DateTimeInterface
? $value->getTimestamp()
: null;
}

public function getStringValue(mixed $value): null|string
{
return $value instanceof DateTimeInterface
? $value->copy()->setTimezone('UTC')->format(self::FORMAT)
: null;
}

public function isIdempotent(): bool
{
return true;
}

public function useHmacVerification(): bool
{
return false;
}
}
Loading

0 comments on commit 080a912

Please sign in to comment.