├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── multiplex.php ├── database └── migrations │ └── 2022_10_14_094240_create_meta_table.php ├── package-lock.json ├── package.json ├── pint.json └── src ├── DataType ├── ArrayHandler.php ├── BooleanHandler.php ├── DateHandler.php ├── DateTimeHandler.php ├── EnumHandler.php ├── FloatHandler.php ├── HandlerInterface.php ├── IntegerHandler.php ├── ModelCollectionHandler.php ├── ModelHandler.php ├── NullHandler.php ├── ObjectHandler.php ├── Registry.php ├── ScalarHandler.php ├── SerializableHandler.php └── StringHandler.php ├── Events ├── MetaHasBeenAdded.php └── MetaHasBeenRemoved.php ├── Exceptions ├── DataTypeException.php └── MetaException.php ├── HasConfigurableMorphType.php ├── HasMeta.php ├── Meta.php ├── MetaAttribute.php └── MultiplexServiceProvider.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-multiplex` will be documented in this file. 4 | 5 | ## v1.6.1 - 2025-02-21 6 | 7 | ### [1.6.1](https://github.com/kolossal-io/laravel-multiplex/compare/v1.6.0...v1.6.1) (2025-02-21) 8 | 9 | #### Bug Fixes 10 | 11 | * re-add `testbench` to dev dependencies ([27b3c14](https://github.com/kolossal-io/laravel-multiplex/commit/27b3c14d9efef0c68c51d57955501a29b8fa3596)) 12 | 13 | ## v1.6.0 - 2025-02-21 14 | 15 | ### [1.6.0](https://github.com/kolossal-io/laravel-multiplex/compare/v1.5.2...v1.6.0) (2025-02-21) 16 | 17 | ##### Bug Fixes 18 | 19 | * remove laravel/framework and testbench from testing ([3c7f722](https://github.com/kolossal-io/laravel-multiplex/commit/3c7f7222d9b18e52d1f1c2ca42d32496bf6683ab)) 20 | 21 | ##### Features 22 | 23 | * support Laravel 12 ([18863f9](https://github.com/kolossal-io/laravel-multiplex/commit/18863f9065f4bec985842105c3f3cb3f5eb75c82)) 24 | 25 | ## v1.5.2 - 2024-11-18 26 | 27 | ### [1.5.2](https://github.com/kolossal-io/laravel-multiplex/compare/v1.5.1...v1.5.2) (2024-11-18) 28 | 29 | #### Bug Fixes 30 | 31 | * add compatibility versions ([2eed556](https://github.com/kolossal-io/laravel-multiplex/commit/2eed556e26787f7b85ef57f3e88924b3e7105157)) 32 | * small phpstan improvements ([db73e6f](https://github.com/kolossal-io/laravel-multiplex/commit/db73e6ff058cf29f2011bc0fb07a1801a69495f9)) 33 | 34 | ## v1.5.1 - 2024-10-10 35 | 36 | ### [1.5.1](https://github.com/kolossal-io/laravel-multiplex/compare/v1.5.0...v1.5.1) (2024-10-10) 37 | 38 | #### Bug Fixes 39 | 40 | * support pest 3 ([0ac469e](https://github.com/kolossal-io/laravel-multiplex/commit/0ac469e78089d090491f124da195eac6aa4f0d21)) 41 | 42 | ## v1.5.0 - 2024-09-12 43 | 44 | ### [1.5.0](https://github.com/kolossal-io/laravel-multiplex/compare/v1.4.1...v1.5.0) (2024-09-12) 45 | 46 | ##### Features 47 | 48 | * add handler for `BackedEnum` type ([d4d6ed9](https://github.com/kolossal-io/laravel-multiplex/commit/d4d6ed9c236ae3f05abc835f5c7b3fd2ee257d93)), closes [#33](https://github.com/kolossal-io/laravel-multiplex/issues/33) 49 | 50 | ## v1.4.1 - 2024-08-21 51 | 52 | ### [1.4.1](https://github.com/kolossal-io/laravel-multiplex/compare/v1.4.0...v1.4.1) (2024-08-21) 53 | 54 | #### Bug Fixes 55 | 56 | * refactor configurable morph type and rewrite tests for pest ([801fbbe](https://github.com/kolossal-io/laravel-multiplex/commit/801fbbe5655557be137ac508a7a6456ccf5ca30e)) 57 | 58 | ## v1.4.0 - 2024-08-20 59 | 60 | ### [1.4.0](https://github.com/kolossal-io/laravel-multiplex/compare/v1.3.2...v1.4.0) (2024-08-20) 61 | 62 | ##### Features 63 | 64 | * add enum handler ([#43](https://github.com/kolossal-io/laravel-multiplex/issues/43)) ([59cbab9](https://github.com/kolossal-io/laravel-multiplex/commit/59cbab9073f73009d2a27053f001851d50d77853)), closes [#33](https://github.com/kolossal-io/laravel-multiplex/issues/33) 65 | 66 | ## v1.3.2 - 2024-06-06 67 | 68 | ### [1.3.2](https://github.com/kolossal-io/laravel-multiplex/compare/v1.3.1...v1.3.2) (2024-06-06) 69 | 70 | #### Bug Fixes 71 | 72 | * fix return type ([2c999d3](https://github.com/kolossal-io/laravel-multiplex/commit/2c999d3a86629fa399b72a315d9f424beaf99eab)) 73 | 74 | ## v1.3.1 - 2024-06-06 75 | 76 | ### [1.3.1](https://github.com/kolossal-io/laravel-multiplex/compare/v1.3.0...v1.3.1) (2024-06-06) 77 | 78 | #### Bug Fixes 79 | 80 | * add phpstan types and fix formatting ([2abdf93](https://github.com/kolossal-io/laravel-multiplex/commit/2abdf93430d2fdd3d3ef5ba225db48093b0c4396)) 81 | 82 | ## v1.3.0 - 2024-03-26 83 | 84 | ### [1.3.0](https://github.com/kolossal-io/laravel-multiplex/compare/v1.2.1...v1.3.0) (2024-03-26) 85 | 86 | ##### Bug Fixes 87 | 88 | * fix database migrations in test cases ([0cc05f1](https://github.com/kolossal-io/laravel-multiplex/commit/0cc05f129db434506a3d36f90a2649ac1d48631a)) 89 | 90 | ##### Features 91 | 92 | * add support for Laravel 11 ([00c0ebb](https://github.com/kolossal-io/laravel-multiplex/commit/00c0ebbc38c0b0aeeac7c26daf8d9e3892dea403)) 93 | 94 | ## v1.2.1 - 2024-01-30 95 | 96 | ### [1.2.1](https://github.com/kolossal-io/laravel-multiplex/compare/v1.2.0...v1.2.1) (2024-01-30) 97 | 98 | #### Bug Fixes 99 | 100 | * make `metable` nullable ([9041532](https://github.com/kolossal-io/laravel-multiplex/commit/9041532522c9cdb8376e1f3f9cafa617635e71c2)) 101 | 102 | ## v1.2.0 - 2024-01-24 103 | 104 | ### [1.2.0](https://github.com/kolossal-io/laravel-multiplex/compare/v1.1.1...v1.2.0) (2024-01-24) 105 | 106 | ##### Features 107 | 108 | * support UUIDs and ULIDs ([#31](https://github.com/kolossal-io/laravel-multiplex/issues/31)) ([fdaf720](https://github.com/kolossal-io/laravel-multiplex/commit/fdaf720bc38fcb3f2bcff915f57b357951fcfb1a)), closes [#26](https://github.com/kolossal-io/laravel-multiplex/issues/26) 109 | 110 | ## v1.1.1 - 2024-01-24 111 | 112 | ### [1.1.1](https://github.com/kolossal-io/laravel-multiplex/compare/v1.1.0...v1.1.1) (2024-01-24) 113 | 114 | #### Bug Fixes 115 | 116 | * add missing type hints ([84483b3](https://github.com/kolossal-io/laravel-multiplex/commit/84483b3a103b7271d4f333c8e44772fbc9106976)) 117 | * support nunomaduro/collision `^8.0` ([100c08c](https://github.com/kolossal-io/laravel-multiplex/commit/100c08c98e5758ce9584b9deeca8989b960781be)) 118 | 119 | ## v1.1.0 - 2024-01-23 120 | 121 | ### [1.1.0](https://github.com/kolossal-io/laravel-multiplex/compare/v1.0.2...v1.1.0) (2024-01-23) 122 | 123 | ##### Bug Fixes 124 | 125 | * try to parse `date` field as fallback ([cfc4f83](https://github.com/kolossal-io/laravel-multiplex/commit/cfc4f838cec385ec1d41ff707edaabebc886d987)) 126 | 127 | ##### Features 128 | 129 | * use meta accessor for fallback values ([52f5ed4](https://github.com/kolossal-io/laravel-multiplex/commit/52f5ed414ea129e48edcc88b8108e84c58ca97a7)) 130 | 131 | ## v1.0.2 - 2024-01-22 132 | 133 | ### [1.0.2](https://github.com/kolossal-io/laravel-multiplex/compare/v1.0.1...v1.0.2) (2024-01-22) 134 | 135 | #### Bug Fixes 136 | 137 | * fix nullable type declaration for default null values ([3e8d2d1](https://github.com/kolossal-io/laravel-multiplex/commit/3e8d2d1f8431c75eb2d90907c4d6b57961586899)) 138 | * use `larastan/larastan` instead of `nunomaduro/larastan` ([ca43ca0](https://github.com/kolossal-io/laravel-multiplex/commit/ca43ca07605bc14fc73eefa8e175c35dc0151500)) 139 | 140 | ## v1.0.1 - 2023-08-16 141 | 142 | ### [1.0.1](https://github.com/kolossal-io/laravel-multiplex/compare/v1.0.0...v1.0.1) (2023-08-16) 143 | 144 | #### Bug Fixes 145 | 146 | - use multiple cache keys ([3cdaa14](https://github.com/kolossal-io/laravel-multiplex/commit/3cdaa14f5f48e796b31998afb270a3845d783ce6)) 147 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | hello@kolossal.io. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) kolossal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | 4 | 5 | 6 | 7 | Multiplex 8 | 9 | 10 |

11 | 12 |

13 | A Laravel package to attach time-sliced meta data to Eloquent models. 14 |

15 | 16 |

17 | Laravel 18 | Latest Version on Packagist 19 | 20 | 21 | 22 | GitHub Tests Action Status 23 |

24 | 25 |

26 | View Table of Contents 27 |

28 | 29 | --- 30 | 31 | ## What it does 32 | 33 | Multiplex allows you to attach time-sliced metadata to Eloquent models in a convenient way. 34 | 35 | ```php 36 | $post = \App\Models\Post::first(); 37 | 38 | // Set meta fluently for any key – `likes` is no column of `Post`. 39 | $post->likes = 24; 40 | 41 | // Or use the `setMeta` method. 42 | $post->setMeta('likes', 24); 43 | 44 | // You may also schedule changes, for example change the meta in 2 years: 45 | $post->setMetaAt('likes', 6000, '+2 years'); 46 | ``` 47 | 48 | ## Features 49 | 50 | - Metadata is saved in versions: Schedule changes to metadata, change history or retrieve metadata for a specific point in time. 51 | - Supports fluent syntax: Use your model’s metadata as if they were properties. 52 | - Polymorphic relationship allows adding metadata to any Eloquent model without worrying about the database schema. 53 | - Easy to try: Extend existing database columns of your model with versionable metadata without touching or deleting your original columns. 54 | - Type conversion system heavily based on [Laravel-Metable](https://github.com/plank/laravel-metable) allows data of numerous different scalar and object types to be stored and retrieved. 55 | 56 | ## Why another Metadata Package? 57 | 58 | The main difference is that the metadata in Multiplex has a timestamp that defines validity. This allows changes to be tracked and planned. You can inspect all metadata on your model at a specific point in time and Multiplex will by default only give you the most current. 59 | 60 | Since Multiplex is storing the metadata in a [polymorphic](https://laravel.com/docs/9.x/eloquent-relationships#polymorphic-relationships) table, it can easily be plugged into existing projects to expand properties of your models. This even works without removing the relevant table columns of your model: They are used as a fallback. 61 | 62 | And it’s low profile: If you don't like it, just [remove the `HasMeta` Trait](#installation) and everything is back to normal. 63 | 64 | ## Table of Contents 65 | 66 | - [Installation](#installation) 67 | - [Attaching Metadata](#attaching-metadata) 68 | - [Retrieving Metadata](#retrieving-metadata) 69 | - [Query by Metadata](#query-by-metadata) 70 | - [Events](#events) 71 | - [Time Traveling](#time-traveling) 72 | - [Limit Meta Keys](#limit-meta-keys) 73 | - [Extending Database Columns](#extending-database-columns) 74 | - [Deleting Metadata](#deleting-metadata) 75 | - [Performance](#performance) 76 | - [Configuration](#configuration) 77 | - [Enum Support](#enum-support) 78 | - [UUID and ULID Support](#uuid-and-ulid-support) 79 | 80 | ## Installation 81 | 82 | You can install the package via composer: 83 | 84 | ```bash 85 | composer require kolossal-io/laravel-multiplex 86 | ``` 87 | 88 | Publish the migrations to create the `meta` table where metadata will be stored. 89 | 90 | ```bash 91 | php artisan migrate 92 | ``` 93 | 94 | Attach the `HasMeta` trait to any Eloquent model that needs meta attached. 95 | 96 | ```php 97 | use Illuminate\Database\Eloquent\Model; 98 | use Kolossal\Multiplex\HasMeta; 99 | 100 | class Post extends Model 101 | { 102 | use HasMeta; 103 | } 104 | ``` 105 | 106 | ## Attaching Metadata 107 | 108 | By default you can use any `key` for attaching metadata. You can [limit which keys can be used](#limit-meta-keys). 109 | 110 | ```php 111 | $model->setMeta('foo', 'bar'); 112 | // or 113 | $model->foo = 'bar'; 114 | ``` 115 | 116 | You may also set multiple meta values by passing an `array`. 117 | 118 | ```php 119 | $model->setMeta([ 120 | 'hide' => true, 121 | 'color' => '#000', 122 | 'likes' => 24, 123 | ]); 124 | ``` 125 | 126 | All metadata will be stored automatically when saving your model. 127 | 128 | ```php 129 | $model->foo = 'bar'; 130 | 131 | $model->isMetaDirty(); // true 132 | 133 | $model->save(); 134 | 135 | $model->isMetaDirty(); // false 136 | ``` 137 | 138 | You can also save your model without saving metadata. 139 | 140 | ```php 141 | $model->saveWithoutMeta(); 142 | 143 | $model->isMetaDirty(); // true 144 | 145 | $model->saveMeta(); 146 | ``` 147 | 148 | You can reset metadata changes that were not yet saved. 149 | 150 | ```php 151 | $model->resetMeta(); 152 | ``` 153 | 154 | Metadata can be stored right away without waiting for the parent model to be saved. 155 | 156 | ```php 157 | // Save the given meta value right now. 158 | $model->saveMeta('foo', 123.45); 159 | 160 | // Save only specific keys of the changed meta. 161 | $model->setMeta(['color' => '#fff', 'hide' => false]); 162 | $model->saveMeta('color'); 163 | $model->isMetaDirty('hide'); // true 164 | 165 | // Save multiple meta values at once. 166 | $model->saveMeta([ 167 | 'color' => '#fff', 168 | 'hide' => true, 169 | ]); 170 | ``` 171 | 172 | ### Schedule Metadata 173 | 174 | You can save metadata for a specific publishing date. 175 | 176 | ```php 177 | $user = Auth::user(); 178 | 179 | $user->saveMeta('favorite_band', 'The Mars Volta'); 180 | $user->saveMetaAt('favorite_band', 'Portishead', '+1 week'); 181 | 182 | // Changing taste in music: This will return `The Mars Volta` now but `Portishead` in a week. 183 | $user->favorite_band; 184 | ``` 185 | 186 | This way you can change historic data as well. 187 | 188 | ```php 189 | $user->saveMetaAt('favorite_band', 'Arctic Monkeys', '-5 years'); 190 | $user->saveMetaAt('favorite_band', 'Tool', '-1 year'); 191 | 192 | // This will return `Tool` – which is true since this is indeed a good band. 193 | $user->favorite_band; 194 | ``` 195 | 196 | You may also save multiple metadata records at once. 197 | 198 | ```php 199 | $user->setMeta('favorite_color', 'blue'); 200 | $user->setMeta('favorite_band', 'Jane’s Addiction'); 201 | $user->saveMetaAt('+1 week'); 202 | 203 | // or 204 | 205 | $user->saveMetaAt([ 206 | 'favorite_color' => 'blue', 207 | 'favorite_band' => 'Jane’s Addiction', 208 | ], '+1 week'); 209 | ``` 210 | 211 | ### How Metadata is stored 212 | 213 | Multiplex will store metadata in a polymorphic table and take care of serializing and unserializing datatypes for you. The underlying polymorphic `meta` table may look something like this: 214 | 215 | | metable_type | metable_id | key | value | type | published_at | 216 | | --------------- | ---------: | ----- | ----: | ------- | ------------------- | 217 | | App\Models\Post | `1` | color | #000 | string | 2022-11-29 13:13:45 | 218 | | App\Models\Post | `1` | likes | 24 | integer | 2020-01-01 00:00:00 | 219 | | App\Models\Post | `1` | hide | true | boolean | 2022-11-27 16:32:08 | 220 | | App\Models\Post | `1` | color | #fff | string | 2030-01-01 00:00:00 | 221 | 222 | The corresponding meta values would look like this: 223 | 224 | ```php 225 | $post = Post::find(1); 226 | 227 | $post->color; // string(4) "#000" 228 | $post->likes; // int(24) 229 | $post->hide; // bool(true) 230 | 231 | // In the year 2030 `$post->color` will be `#fff`. 232 | ``` 233 | 234 | ## Retrieving Metadata 235 | 236 | You can access metadata as if they were properties on your model. 237 | 238 | ```php 239 | $post->likes; // (int) 24 240 | $post->color; // (string) '#000' 241 | ``` 242 | 243 | Or use the `getMeta()` method to specify a fallback value for non-existent meta. 244 | 245 | ```php 246 | $post->getMeta('likes', 0); // Use `0` as a fallback. 247 | ``` 248 | 249 | You can also retrieve the `meta` relation on your model. This will only retrieve the most recent value per `key` that is released yet. 250 | 251 | ```php 252 | $post->saveMeta([ 253 | 'author' => 'Anthony Kiedis', 254 | 'color' => 'black', 255 | ]); 256 | 257 | $post->saveMetaAt('author', 'Jimi Hendrix', '1970-01-01'); 258 | $post->saveMetaAt('author', 'Omar Rodriguez', '+1 year'); 259 | 260 | $post->meta->pluck('value', 'key'); 261 | 262 | /** 263 | * Illuminate\Support\Collection { 264 | * all: [ 265 | * "author" => "Anthony Kiedis", 266 | * "color" => "black", 267 | * ], 268 | * } 269 | */ 270 | ``` 271 | 272 | There is a shorthand to pluck all the current meta data attached to the model. This will include all [explicitly defined meta keys](#limit-meta-keys) with a default of `null`. 273 | 274 | ```php 275 | // Allow any meta key and explicitly allow `foo` and `bar`. 276 | $post->metaKeys(['*', 'foo', 'bar']); 277 | 278 | $post->saveMeta('foo', 'a value'); 279 | $post->saveMeta('another', true); 280 | 281 | $post->pluckMeta(); 282 | /** 283 | * Illuminate\Support\Collection { 284 | * all: [ 285 | * "foo" => "a value", 286 | * "bar" => null, 287 | * "another" => true, 288 | * ], 289 | * } 290 | */ 291 | ``` 292 | 293 | If you instead want to retrieve all meta that was published yet, use the `publishedMeta` relation. 294 | 295 | ```php 296 | // This array will also include `Jimi Hendrix´. 297 | $post->publishedMeta->toArray(); 298 | ``` 299 | 300 | If you want to inspect _all_ metadata including unpublished records, use the `allMeta` relation. 301 | 302 | ```php 303 | $post->allMeta->toArray(); 304 | ``` 305 | 306 | You can determine if a `Meta` instance is the most recent published record for the related model or if it is not yet released. 307 | 308 | ```php 309 | $meta = $post->allMeta->first(); 310 | 311 | $meta->is_current; // (bool) 312 | $meta->is_planned; // (bool) 313 | ``` 314 | 315 | ### Querying `Meta` Model 316 | 317 | There are also some query scopes on the `Meta` model itself that may be helpful. 318 | 319 | ```php 320 | Meta::published()->get(); // Only current and historic meta. 321 | 322 | Meta::planned()->get(); // Only meta not yet published. 323 | 324 | Meta::publishedBefore('+1 week')->get(); // Only meta published by next week. 325 | 326 | Meta::publishedAfter('+1 week')->get(); // Only meta still unpublished in a week. 327 | 328 | Meta::onlyCurrent()->get(); // Only current meta without planned or historic data. 329 | 330 | Meta::withoutHistory()->get(); // Query without stale records. 331 | 332 | Meta::withoutCurrent()->get(); // Query without current records. 333 | ``` 334 | 335 | By default these functions will use `Carbon::now()` to determine what metadata is considered the most recent, but you can also pass a datetime to look from. 336 | 337 | ```php 338 | // Get records that have been current a month ago. 339 | Meta::onlyCurrent('-1 month')->get(); 340 | 341 | // Get records that will not be history by tommorow. 342 | Meta::withoutHistory(Carbon::now()->addDay())->get(); 343 | ``` 344 | 345 | ## Query by Metadata 346 | 347 | ### Querying Metadata Existence 348 | 349 | You can query records having meta data for the given key(s). 350 | 351 | ```php 352 | // Find posts having at least one meta records for `color` key. 353 | Post::whereHasMeta('color')->get(); 354 | 355 | // Or pass an array to find records having meta for at least one of the given keys. 356 | Post::whereHasMeta(['color', 'background_color'])->get(); 357 | ``` 358 | 359 | ### Querying Metadata Absence 360 | 361 | You can query records not having meta data for the given key(s). 362 | 363 | ```php 364 | // Find posts not having any meta records for `color` key. 365 | Post::whereDoesntHaveMeta('color')->get(); 366 | 367 | // Or find records not having meta for any of the given keys. 368 | Post::whereDoesntHaveMeta(['color', 'background_color'])->get(); 369 | ``` 370 | 371 | ### Querying Metadata by Value 372 | 373 | You can retrieve models having meta with the given key and value. 374 | 375 | ```php 376 | // Find posts where the current attached color is `black`. 377 | Post::whereMeta('color', 'black')->get(); 378 | 379 | // Find posts where the current attached color is not `black`. 380 | Post::whereMeta('color', '!=', 'black')->get(); 381 | 382 | // Find posts that are `visible`. 383 | Post::whereMeta('visible', true)->get(); 384 | 385 | // There are alternatives for building `or` clauses for all scopes. 386 | Post::whereMeta('visible', true)->orWhere('hidden', false)->get(); 387 | ``` 388 | 389 | Multiplex will take care of finding the right datatype for the passed query. 390 | 391 | ```php 392 | // Matches only meta records with type `boolean`. 393 | Post::whereMeta('hidden', false)->get(); 394 | 395 | // Matches only meta records with type `datetime`. 396 | Post::whereMeta('release_at', '<=', Carbon::now())->get(); 397 | ``` 398 | 399 | You may also query by an array if values. Each array value will be typecasted individually. 400 | 401 | ```php 402 | // Find posts where `color` is `black` (string) or `false` (boolean). 403 | Post::whereMetaIn('color', ['black', false])->get(); 404 | ``` 405 | 406 | If you would like to query without typecasting use `whereRawMeta()` instead. 407 | 408 | ```php 409 | Post::whereRawMeta('hidden', '')->get(); 410 | 411 | Post::whereRawMeta('likes', '>', '100')->get(); 412 | ``` 413 | 414 | You can also define which [datatype](config/multiplex.php) to use. 415 | 416 | ```php 417 | Post::whereMetaOfType('integer', 'count', '0')->get(); 418 | 419 | Post::whereMetaOfType('null', 'foo', '')->get(); 420 | ``` 421 | 422 | ### Querying empty or non-empty Metadata 423 | 424 | You can query for empty or non-empty metadata where `null` or empty strings would be considered being empty. 425 | 426 | ```php 427 | Post::whereMetaEmpty('favorite_band')->get(); 428 | 429 | // Get all posts having meta names `likes` and `comments` where *both* of them are not empty. 430 | Post::whereMetaNotEmpty(['likes', 'comments'])->get(); 431 | ``` 432 | 433 | ## Events 434 | 435 | You can listen for the following events that will be fired by Multiplex. 436 | 437 | ### `MetaHasBeenAdded` 438 | 439 | This event will be fired once a new version of meta is saved to the model. 440 | 441 | ```php 442 | use Kolossal\Multiplex\Events\MetaHasBeenAdded; 443 | 444 | class SomeListener 445 | { 446 | public function handle(MetaHasBeenAdded $event) 447 | { 448 | $event->meta; // The Meta model that was added. 449 | $event->model; // The parent model, same as $event->meta->metable. 450 | $event->type; // The class name of the parent model. 451 | } 452 | } 453 | ``` 454 | 455 | ### `MetaHasBeenRemoved` 456 | 457 | This event will be fired once metadata is removed by using [`deleteMeta`](#deleting-metadata). The event will fire only once per key and the `$meta` property on the event will contain the latest meta only. 458 | 459 | ```php 460 | use Kolossal\Multiplex\Events\MetaHasBeenRemoved; 461 | 462 | class SomeListener 463 | { 464 | public function handle(MetaHasBeenRemoved $event) 465 | { 466 | $event->meta; // The Meta model that was removed. 467 | $event->model; // The parent model, same as $event->meta->metable. 468 | $event->type; // The class name of the parent model. 469 | } 470 | } 471 | ``` 472 | 473 | ## Time Traveling 474 | 475 | You can get the metadata for a model at a specific point in time. 476 | 477 | ```php 478 | $user = Auth::user()->withMetaAt('-1 week'); 479 | $user->favorite_band; // Tool 480 | $user->withMetaAt(Carbon::now())->favorite_band; // The Mars Volta 481 | ``` 482 | 483 | This way you can inspect the whole set of metadata that was valid at the time. 484 | 485 | ```php 486 | Post::first()->withMetaAt('2022-10-01 15:00:00')->meta->pluck('value', 'key'); 487 | ``` 488 | 489 | You can also query by meta for a specific point in time. 490 | 491 | ```php 492 | Post::travelTo(Carbon::now()->subWeeks(2))->whereMetaIn('foo', [false, 0])->get(); 493 | 494 | Post::travelTo(Carbon::now()->addYears(2))->where('category', 'tech')->get(); 495 | ``` 496 | 497 | Remember to travel back if you want to perform further actions. 498 | 499 | ```php 500 | Post::travelTo(Carbon::now()->subYear())->where('category', 'tech')->get(); 501 | Post::where('category', 'tech')->get(); // Will still look for meta published last year. 502 | 503 | Post::travelBack(); 504 | Post::where('category', 'tech')->get(); // Find current meta. 505 | ``` 506 | 507 | ## Limit Meta Keys 508 | 509 | You can limit which keys can be used for metadata by setting `$metaKeys` on the model. 510 | 511 | ```php 512 | class Post extends Model 513 | { 514 | use HasMeta; 515 | 516 | protected array $metaKeys = [ 517 | 'color', 518 | 'hide', 519 | ]; 520 | } 521 | ``` 522 | 523 | By default all keys are allowed. 524 | 525 | ```php 526 | protected array $metaKeys = ['*']; 527 | ``` 528 | 529 | You can also change the allowed meta keys dynamically. 530 | 531 | ```php 532 | $model->metaKeys(['color', 'hide']); 533 | ``` 534 | 535 | You might as well cast your attributes using the `MetaAttribute` cast which will automatically allow the attribute being used as a meta key. 536 | 537 | ```php 538 | use Kolossal\Multiplex\MetaAttribute; 539 | 540 | class Post extends Model 541 | { 542 | use HasMeta; 543 | 544 | protected $metaKeys = []; 545 | 546 | protected $casts = [ 547 | 'body' => MetaAttribute::class, 548 | ]; 549 | } 550 | ``` 551 | 552 | Trying to assign a value to a meta key that is not allowed will throw a `Kolossal\Multiplex\Exceptions\MetaException`. 553 | 554 | If you have [Eloquent Strictness](https://laravel.com/docs/10.x/eloquent#configuring-eloquent-strictness) enabled it is recommended to [explicitely cast the meta attributes to `MetaAttribute`](https://github.com/kolossal-io/laravel-multiplex/issues/19#issuecomment-1584150675). 555 | 556 | ## Typecast Meta Keys 557 | 558 | Sometimes you may wish to force typecasting of meta attributes. You can bypass guessing the correct type and define which type should be used for specific meta keys. 559 | 560 | ```php 561 | protected array $metaKeys = [ 562 | 'foo', 563 | 'count' => 'integer', 564 | 'color' => 'string', 565 | 'hide' => 'boolean', 566 | ]; 567 | ``` 568 | 569 | ## Extending Database Columns 570 | 571 | By default Multiplex will not touch columns of your model. But sometimes it might be useful to have meta records as an extension for your existing table columns. 572 | 573 | Consider having an existing `Post` model with only a `title` and a `body` column. By explicitely adding `body` to our array of meta keys `body` will be handled by Multiplex from now on – not touching the `posts` table, but using the database column as a fallback. 574 | 575 | ```php 576 | class Post extends Model 577 | { 578 | use HasMeta; 579 | 580 | protected $metaKeys = [ 581 | '*', 582 | 'body', 583 | ]; 584 | } 585 | ``` 586 | 587 | ```php 588 | \DB::table('posts')->create(['title' => 'A title', 'body' => 'A body.']); 589 | 590 | $post = Post::first(); 591 | 592 | $post->body; // A body. 593 | 594 | $post->body = 'This. Is. Meta.'; 595 | $post->save(); 596 | 597 | $post->body; // This. Is. Meta. 598 | $post->deleteMeta('body'); 599 | 600 | $post->body; // A body. 601 | ``` 602 | 603 | In case of using Multiplex for extending table columns, Multiplex will remove the original column when retrieving models from the database so you don’t get stale data. 604 | 605 | ## Deleting Metadata 606 | 607 | You can delete any metadata associated with the model from the database. 608 | 609 | ```php 610 | // Delete all meta records for the `color` key. 611 | $post->deleteMeta('color'); 612 | 613 | // Or delete all meta records associated with the model. 614 | $post->purgeMeta(); 615 | ``` 616 | 617 | ## Performance 618 | 619 | Since Multiplex stores metadata in a polymorphic [One To Many](https://laravel.com/docs/9.x/eloquent-relationships#one-to-many-polymorphic-relations) relationship querying your models could easily result in a [`N+1` query problem](https://laravel.com/docs/9.x/eloquent-relationships#eager-loading). 620 | 621 | Depending on your use case you should consider eager loading the `meta` relation, for example using `$with` on your model. This might be especially useful if you are [extending database columns](#extending-database-columns). 622 | 623 | ```php 624 | // Worst case: 26 queries if `color` is a meta value. 625 | $colors = Post::take(25)->get()->map( 626 | fn ($post) => $post->color; 627 | ); 628 | 629 | // Same result with only 2 queries. 630 | $colors = Post::with('meta')->take(25)->get()->map( 631 | fn ($post) => $post->color; 632 | ); 633 | ``` 634 | 635 | ## Configuration 636 | 637 | There is no need to configure anything but if you like, you can publish the config file with: 638 | 639 | ```bash 640 | php artisan vendor:publish --tag="multiplex-config" 641 | ``` 642 | 643 | ## Enum Support 644 | 645 | Multiplex supports [backed enumerations](https://www.php.net/manual/en/language.enumerations.backed.php) introduced in PHP 8.1 whereas basic enumerations would not work. 646 | 647 | ```php 648 | enum SampleEnum: string 649 | { 650 | case Hearts = 'hearts'; 651 | case Diamonds = 'diamonds'; 652 | } 653 | 654 | $model->saveMeta('some_key', SampleEnum::Diamonds); 655 | 656 | // true 657 | $model->some_key === SampleEnum::Diamonds; 658 | ``` 659 | 660 | ## UUID and ULID Support 661 | 662 | If your application uses UUIDs or ULIDs for the model(s) using metadata, you may set the `multiplex.morph_type` setting to `uuid` or `ulid` **before** running the migrations. You might as well set the `MULTIPLEX_MORPH_TYPE` environment variable instead, if you don’t want to publish the configuration file. 663 | 664 | This will ensure `Meta` models will use UUID/ULID and that proper keys and foreign keys are used when running the migrations. 665 | 666 | ## Credits 667 | 668 | This package is heavily based on and inspired by [Laravel-Metable](https://github.com/plank/laravel-metable) by [Sean Fraser](https://github.com/frasmage) as well as [laravel-meta](https://github.com/kodeine/laravel-meta) by [Kodeine](https://github.com/kodeine). The [Package Skeleton](https://github.com/spatie/package-skeleton-laravel) by the great [Spatie](https://spatie.be/) was used as a starting point. 669 | 670 | ## License 671 | 672 |

673 |
674 | 675 | 676 | 677 | 678 | Multiplex 679 | 680 | 681 |
682 |
683 |

684 | 685 | Copyright © [kolossal](https://kolossal.io). Released under [MIT License](LICENSE.md). 686 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kolossal-io/laravel-multiplex", 3 | "description": "A Laravel package to attach versioned meta data to Eloquent models.", 4 | "keywords": [ 5 | "kolossal", 6 | "laravel", 7 | "laravel-multiplex", 8 | "eloquent", 9 | "meta", 10 | "metadata" 11 | ], 12 | "homepage": "https://github.com/Kolossal-io/laravel-multiplex", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Marijan Barkic", 17 | "email": "marijan@kolossal.io", 18 | "role": "Developer" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.0", 23 | "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0" 24 | }, 25 | "require-dev": { 26 | "larastan/larastan": "^2.0.1|^3.0", 27 | "laravel/pint": "^1.0", 28 | "mattiasgeniar/phpunit-query-count-assertions": "^1.1", 29 | "mockery/mockery": "^1.6", 30 | "nunomaduro/collision": "^6.1|^7.0|^8.0", 31 | "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", 32 | "pestphp/pest": "^1.1|^2.35|^3.0", 33 | "pestphp/pest-plugin-laravel": "^1.1|^2.0|^3.0", 34 | "phpstan/extension-installer": "^1.1", 35 | "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", 36 | "phpstan/phpstan-phpunit": "^1.0|^2.0" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "Kolossal\\Multiplex\\": "src", 41 | "Kolossal\\Multiplex\\Tests\\Factories\\": "tests/factories" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Kolossal\\Multiplex\\Tests\\": "tests" 47 | } 48 | }, 49 | "scripts": { 50 | "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", 51 | "analyse": "vendor/bin/phpstan analyse", 52 | "test": "vendor/bin/pest", 53 | "test-coverage": "vendor/bin/pest --coverage", 54 | "format": "vendor/bin/pint" 55 | }, 56 | "config": { 57 | "sort-packages": true, 58 | "allow-plugins": { 59 | "pestphp/pest-plugin": true, 60 | "phpstan/extension-installer": true 61 | } 62 | }, 63 | "extra": { 64 | "laravel": { 65 | "providers": [ 66 | "Kolossal\\Multiplex\\MultiplexServiceProvider" 67 | ] 68 | } 69 | }, 70 | "minimum-stability": "dev", 71 | "prefer-stable": true 72 | } 73 | -------------------------------------------------------------------------------- /config/multiplex.php: -------------------------------------------------------------------------------- 1 | Kolossal\Multiplex\Meta::class, 8 | 9 | /** 10 | * Determine wethere packages migrations should be loaded automatically. 11 | * Disable this if you want to create your own migrations based on the ones 12 | * located in `database/migrations`. 13 | */ 14 | 'migrations' => true, 15 | 16 | /** 17 | * The type of primary key your models using the `HasMeta` trait are using. 18 | * Must be one of `integer`, `uuid` or `ulid`. 19 | * ATTENTION: This must be changed before running the database migrations. 20 | */ 21 | 'morph_type' => env('MULTIPLEX_MORPH_TYPE', 'integer'), 22 | 23 | /** 24 | * List of handlers for recognized data types. 25 | * 26 | * Handlers will be evaluated in order, so a value will be handled 27 | * by the first appropriate handler in the list. 28 | * 29 | * @copyright Plank Multimedia Inc. 30 | * 31 | * @link https://github.com/plank/laravel-metable 32 | */ 33 | 'datatypes' => [ 34 | Kolossal\Multiplex\DataType\BooleanHandler::class, 35 | Kolossal\Multiplex\DataType\NullHandler::class, 36 | Kolossal\Multiplex\DataType\IntegerHandler::class, 37 | Kolossal\Multiplex\DataType\FloatHandler::class, 38 | Kolossal\Multiplex\DataType\StringHandler::class, 39 | Kolossal\Multiplex\DataType\DateTimeHandler::class, 40 | Kolossal\Multiplex\DataType\DateHandler::class, 41 | Kolossal\Multiplex\DataType\ArrayHandler::class, 42 | Kolossal\Multiplex\DataType\EnumHandler::class, 43 | Kolossal\Multiplex\DataType\ModelHandler::class, 44 | Kolossal\Multiplex\DataType\ModelCollectionHandler::class, 45 | Kolossal\Multiplex\DataType\SerializableHandler::class, 46 | Kolossal\Multiplex\DataType\ObjectHandler::class, 47 | ], 48 | ]; 49 | -------------------------------------------------------------------------------- /database/migrations/2022_10_14_094240_create_meta_table.php: -------------------------------------------------------------------------------- 1 | uuid('id'); 13 | $table->uuidMorphs('metable'); 14 | 15 | return; 16 | } 17 | 18 | if (config('multiplex.morph_type') === 'ulid') { 19 | $table->ulid('id'); 20 | $table->ulidMorphs('metable'); 21 | 22 | return; 23 | } 24 | 25 | if (config('multiplex.morph_type') === 'integer') { 26 | $table->increments('id'); 27 | $table->morphs('metable'); 28 | 29 | return; 30 | } 31 | 32 | throw new Exception('Please use a valid option for `morph_type` inside the multiplex config file. Must be one of `integer`, `uuid` or `ulid`.'); 33 | } 34 | 35 | public function up(): void 36 | { 37 | if (!Schema::hasTable('meta')) { 38 | Schema::create('meta', function (Blueprint $table) { 39 | $this->addKeys($table); 40 | 41 | $table->string('key'); 42 | $table->longtext('value')->nullable(); 43 | $table->string('type')->nullable(); 44 | $table->dateTimeTz('published_at')->nullable(); 45 | 46 | $table->timestamps(); 47 | 48 | $table->index(['metable_id', 'metable_type', 'published_at']); 49 | $table->index(['metable_id', 'metable_type', 'key', 'published_at']); 50 | }); 51 | } 52 | } 53 | 54 | public function down(): void 55 | { 56 | Schema::dropIfExists('meta'); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-multiplex", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "A Laravel package to attach versioned meta data to Eloquent models.", 6 | "scripts": { 7 | "release": "semantic-release" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/kolossal-io/laravel-multiplex.git" 12 | }, 13 | "author": "kolossal.io", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/kolossal-io/laravel-multiplex/issues" 17 | }, 18 | "homepage": "https://github.com/kolossal-io/laravel-multiplex#readme", 19 | "devDependencies": { 20 | "semantic-release": "^24.0" 21 | }, 22 | "release": { 23 | "branches": [ 24 | "main" 25 | ] 26 | }, 27 | "publishConfig": { 28 | "access": "restricted" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "not_operator_with_successor_space": false, 5 | "concat_space": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/DataType/ArrayHandler.php: -------------------------------------------------------------------------------- 1 | format($this->format); 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | */ 61 | public function unserializeValue(?string $value): mixed 62 | { 63 | if (is_null($value) || $value === '') { 64 | return null; 65 | } 66 | 67 | try { 68 | return tap(Carbon::createFromFormat($this->format, $value), fn ($date) => $this->setTime($date)); 69 | } catch (\Exception $e) { 70 | return tap(Carbon::parse($value), fn ($date) => $this->setTime($date)); 71 | } 72 | } 73 | 74 | protected function setTime(mixed &$date): void 75 | { 76 | if ($date instanceof Carbon) { 77 | $date->startOfDay(); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/DataType/DateTimeHandler.php: -------------------------------------------------------------------------------- 1 | format($this->format); 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public function unserializeValue(?string $value): mixed 58 | { 59 | if (is_null($value) || $value === '') { 60 | return null; 61 | } 62 | 63 | try { 64 | return Carbon::createFromFormat($this->format, $value); 65 | } catch (\Exception $e) { 66 | return Carbon::parse($value); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/DataType/EnumHandler.php: -------------------------------------------------------------------------------- 1 | canHandleValue($enum)) { 38 | return ''; 39 | } 40 | 41 | return get_class($enum) . '::' . $enum->value; 42 | } 43 | 44 | /** 45 | * Convert a serialized string back to its original value. 46 | * 47 | * @return BackedEnum|null 48 | */ 49 | public function unserializeValue(?string $value): mixed 50 | { 51 | // @codeCoverageIgnoreStart 52 | if (!class_exists(ReflectionEnum::class)) { 53 | throw new Exception('Cannot unserialize enum value since \ReflectionEnum is not available. This will only work in PHP >= 8.1.'); 54 | } 55 | // @codeCoverageIgnoreEnd 56 | 57 | if (is_null($value)) { 58 | return $value; 59 | } 60 | 61 | if (strpos($value, '::') === false) { 62 | return null; 63 | } 64 | 65 | [$class, $value] = explode('::', $value, 2); 66 | 67 | if (!enum_exists($class)) { 68 | return null; 69 | } 70 | 71 | if (!(new ReflectionEnum($class))->isBacked()) { 72 | return null; 73 | } 74 | 75 | /** 76 | * @var class-string<\BackedEnum> $class 77 | */ 78 | return $class::tryFrom($value); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/DataType/FloatHandler.php: -------------------------------------------------------------------------------- 1 | $value 37 | */ 38 | public function serializeValue($value): string 39 | { 40 | if (!($value instanceof Collection)) { 41 | return ''; 42 | } 43 | 44 | $items = $value->mapWithKeys( 45 | fn ($model, $key) => [$key => [ 46 | 'class' => get_class($model), 47 | 'key' => $model->exists ? $model->getKey() : null, 48 | ]] 49 | ); 50 | 51 | return json_encode(['class' => get_class($value), 'items' => $items]) ?: ''; 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public function unserializeValue(?string $value): mixed 58 | { 59 | if (!is_string($value) || empty($value)) { 60 | return null; 61 | } 62 | 63 | /** @var null|array>> */ 64 | $data = json_decode($value, true); 65 | 66 | if ( 67 | is_null($data) 68 | || !is_array($data) 69 | || !isset($data['items']) 70 | || !is_array($data['items']) 71 | || !isset($data['class']) 72 | ) { 73 | return null; 74 | } 75 | 76 | /** @var Collection */ 77 | $collection = new $data['class']; 78 | 79 | /** @var array> */ 80 | $items = $data['items']; 81 | 82 | $models = $this->loadModels($items); 83 | 84 | // Repopulate collection keys with loaded models. 85 | foreach ($items as $key => $item) { 86 | if (is_null($item['key']) && ($model = new $item['class']) instanceof Model) { 87 | $collection->put($key, $model); 88 | } elseif (isset($models[$item['class']][$item['key']])) { 89 | $collection->put($key, $models[$item['class']][$item['key']]); 90 | } 91 | } 92 | 93 | return $collection; 94 | } 95 | 96 | /** 97 | * Load each model instance, grouped by class. 98 | * 99 | * @param array> $items 100 | * @return array> 101 | */ 102 | private function loadModels(array $items): array 103 | { 104 | $classes = []; 105 | $results = []; 106 | 107 | // Retrieve a list of keys to load from each class. 108 | foreach ($items as $item) { 109 | if (!is_null($item['key'])) { 110 | $classes[$item['class']][] = $item['key']; 111 | } 112 | } 113 | 114 | // Iterate list of classes and load all records matching a key. 115 | foreach ($classes as $class => $keys) { 116 | /** @var \Illuminate\Database\Eloquent\Model */ 117 | $model = new $class; 118 | 119 | $results[$class] = $model 120 | ->whereIn($model->getKeyName(), $keys) 121 | ->get() 122 | ->keyBy($model->getKeyName()); 123 | } 124 | 125 | return $results; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/DataType/ModelHandler.php: -------------------------------------------------------------------------------- 1 | exists && (is_string($value->getKey()) || is_int($value->getKey()))) { 40 | return get_class($value) . '#' . $value->getKey(); 41 | } 42 | 43 | return get_class($value) ?: ''; 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function unserializeValue(?string $value): mixed 50 | { 51 | if (is_null($value)) { 52 | return $value; 53 | } 54 | 55 | // Return blank instances. 56 | if (strpos($value, '#') === false) { 57 | return new $value; 58 | } 59 | 60 | // Fetch specific instances. 61 | [$class, $id] = explode('#', $value); 62 | 63 | return $class::findOrFail($id); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/DataType/NullHandler.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | protected $handlers = []; 22 | 23 | /** 24 | * Append a Handler to use for a given type identifier. 25 | */ 26 | public function addHandler(HandlerInterface $handler): void 27 | { 28 | $this->handlers[$handler->getDataType()] = $handler; 29 | } 30 | 31 | /** 32 | * Retrieve the handler assigned to a given type identifier. 33 | * 34 | * @throws DataTypeException if no handler is found. 35 | */ 36 | public function getHandlerForType(string $type): HandlerInterface 37 | { 38 | if ($this->hasHandlerForType($type)) { 39 | return $this->handlers[$type]; 40 | } 41 | 42 | throw DataTypeException::handlerNotFound($type); 43 | } 44 | 45 | /** 46 | * Check if a handler has been set for a given type identifier. 47 | */ 48 | public function hasHandlerForType(string $type): bool 49 | { 50 | return array_key_exists($type, $this->handlers); 51 | } 52 | 53 | /** 54 | * Removes the handler with a given type identifier. 55 | */ 56 | public function removeHandlerForType(string $type): void 57 | { 58 | unset($this->handlers[$type]); 59 | } 60 | 61 | /** 62 | * Find a data type Handler that is able to operate on the value, return the type identifier associated with it. 63 | * 64 | * @throws DataTypeException if no handler can handle the value. 65 | */ 66 | public function getTypeForValue(mixed $value): string 67 | { 68 | foreach ($this->handlers as $type => $handler) { 69 | if ($handler->canHandleValue($value)) { 70 | return $type; 71 | } 72 | } 73 | 74 | throw DataTypeException::handlerNotFoundForValue($value); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/DataType/ScalarHandler.php: -------------------------------------------------------------------------------- 1 | type; 29 | } 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | public function canHandleValue($value): bool 35 | { 36 | return gettype($value) === $this->type; 37 | } 38 | 39 | /** 40 | * {@inheritdoc} 41 | */ 42 | public function serializeValue($value): string 43 | { 44 | if (!is_null($value) && !is_bool($value) && !is_float($value) && !is_int($value) && !is_resource($value) && !is_string($value)) { 45 | throw new Exception('Invalid value passed as scalar value. Use a boolean, float, int, resource, string value or null.'); 46 | } 47 | 48 | return strval($value); 49 | } 50 | 51 | /** 52 | * {@inheritdoc} 53 | */ 54 | public function unserializeValue(?string $value): mixed 55 | { 56 | if (is_null($value)) { 57 | return $value; 58 | } 59 | 60 | settype($value, $this->type); 61 | 62 | return $value; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/DataType/SerializableHandler.php: -------------------------------------------------------------------------------- 1 | type = $meta->metable_type; 20 | 21 | /** @var \Illuminate\Database\Eloquent\Model */ 22 | $model = $meta->metable; 23 | $this->model = $model; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Events/MetaHasBeenRemoved.php: -------------------------------------------------------------------------------- 1 | type = $meta->metable_type; 17 | 18 | /** @var \Illuminate\Database\Eloquent\Model */ 19 | $model = $meta->metable; 20 | $this->model = $model; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Exceptions/DataTypeException.php: -------------------------------------------------------------------------------- 1 | usesUniqueIdsInMorphType()) { 16 | return; 17 | } 18 | 19 | // @codeCoverageIgnoreStart 20 | // @phpstan-ignore function.alreadyNarrowedType 21 | if (property_exists($this, 'usesUniqueIds')) { 22 | $this->usesUniqueIds = true; 23 | 24 | return; 25 | } 26 | 27 | static::creating(function (self $model) { 28 | foreach ($model->uniqueIds() as $column) { 29 | if (empty($model->{$column})) { 30 | $model->{$column} = $model->newUniqueId(); 31 | } 32 | } 33 | }); 34 | // @codeCoverageIgnoreEnd 35 | } 36 | 37 | /** 38 | * Get the morph key type. 39 | */ 40 | protected function morphType(): string 41 | { 42 | if (is_string(config('multiplex.morph_type')) && in_array(config('multiplex.morph_type'), ['uuid', 'ulid'])) { 43 | return config('multiplex.morph_type'); 44 | } 45 | 46 | return 'integer'; 47 | } 48 | 49 | /** 50 | * Determine if unique ids are used in morphTo relation. 51 | */ 52 | protected function usesUniqueIdsInMorphType(): bool 53 | { 54 | return in_array($this->morphType(), ['uuid', 'ulid']); 55 | } 56 | 57 | /** 58 | * Determine if the given value is a valid unique id. 59 | */ 60 | protected function isValidUniqueMorphId(string $value): bool 61 | { 62 | if ($this->morphType() === 'ulid') { 63 | return Str::isUlid($value); 64 | } 65 | 66 | return Str::isUuid($value); 67 | } 68 | 69 | /** 70 | * Get the columns that should receive a unique identifier. 71 | * 72 | * @return array 73 | */ 74 | public function uniqueIds(): array 75 | { 76 | if (!$this->usesUniqueIdsInMorphType()) { 77 | return []; 78 | } 79 | 80 | return [$this->getKeyName()]; 81 | } 82 | 83 | /** 84 | * Generate a new UUID for the model. 85 | */ 86 | public function newUniqueId(): ?string 87 | { 88 | if (!$this->usesUniqueIdsInMorphType()) { 89 | return null; 90 | } 91 | 92 | if ($this->morphType() === 'ulid') { 93 | return strtolower((string) Str::ulid()); 94 | } 95 | 96 | return (string) Str::orderedUuid(); 97 | } 98 | 99 | /** 100 | * Retrieve the model for a bound value. 101 | * 102 | * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model, \Kolossal\Multiplex\Meta> $query 103 | * @param mixed $value 104 | * @param string|null $field 105 | * @return \Illuminate\Contracts\Database\Eloquent\Builder 106 | * 107 | * @throws \Illuminate\Database\Eloquent\ModelNotFoundException 108 | */ 109 | public function resolveRouteBindingQuery($query, $value, $field = null) 110 | { 111 | if (!$this->usesUniqueIdsInMorphType()) { 112 | return parent::resolveRouteBindingQuery($query, $value, $field); 113 | } 114 | 115 | if ($field && is_string($value) && in_array($field, $this->uniqueIds()) && !$this->isValidUniqueMorphId($value)) { 116 | /** @var class-string<\Illuminate\Database\Eloquent\Model> $class */ 117 | $class = get_class($this); 118 | throw (new ModelNotFoundException)->setModel($class, $value); 119 | } 120 | 121 | if (!$field && is_string($value) && in_array($this->getRouteKeyName(), $this->uniqueIds()) && !$this->isValidUniqueMorphId($value)) { 122 | /** @var class-string<\Illuminate\Database\Eloquent\Model> $class */ 123 | $class = get_class($this); 124 | throw (new ModelNotFoundException)->setModel($class, $value); 125 | } 126 | 127 | return parent::resolveRouteBindingQuery($query, $value, $field); 128 | } 129 | 130 | /** 131 | * Get the auto-incrementing key type. 132 | */ 133 | public function getKeyType(): string 134 | { 135 | if (in_array($this->getKeyName(), $this->uniqueIds())) { 136 | return 'string'; 137 | } 138 | 139 | return $this->keyType; 140 | } 141 | 142 | /** 143 | * Get the value indicating whether the IDs are incrementing. 144 | */ 145 | public function getIncrementing(): bool 146 | { 147 | if (in_array($this->getKeyName(), $this->uniqueIds())) { 148 | return false; 149 | } 150 | 151 | return $this->incrementing; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/HasMeta.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | protected array $_metaKeys = ['*']; 27 | 28 | /** 29 | * Cached array of explicitly allowed meta keys. 30 | * 31 | * @var array 32 | */ 33 | protected ?array $explicitlyAllowedMetaKeys = null; 34 | 35 | /** 36 | * Collection of the changed meta data for this model. 37 | */ 38 | protected ?Collection $metaChanges = null; 39 | 40 | /** 41 | * Collection database columns overridden by meta. 42 | */ 43 | protected ?Collection $fallbackValues = null; 44 | 45 | /** 46 | * Cache storage for table column names. 47 | */ 48 | protected static array $metaSchemaColumnsCache = []; 49 | 50 | /** 51 | * Auto-save meta data when model is saved. 52 | */ 53 | protected bool $autosaveMeta = true; 54 | 55 | /** 56 | * Static timestamp used to determine which meta is published yet. 57 | */ 58 | protected static ?Carbon $staticMetaTimestamp = null; 59 | 60 | /** 61 | * The timestamp used to determine which meta is published yet for this model. 62 | */ 63 | protected ?Carbon $metaTimestamp = null; 64 | 65 | /** 66 | * Indicates if all meta assignment is unguarded. 67 | * 68 | * @var bool 69 | */ 70 | protected static $metaUnguarded = false; 71 | 72 | /** 73 | * Boot the model trait. 74 | */ 75 | public static function bootHasMeta(): void 76 | { 77 | static::retrieved(function (Model $model) { 78 | foreach ($model->getExplicitlyAllowedMetaKeys() as $key) { 79 | if (isset($model->attributes[$key])) { 80 | $model->setFallbackValue($key, Arr::pull($model->attributes, $key)); 81 | } 82 | } 83 | }); 84 | 85 | static::saved(function (Model $model) { 86 | if ($model->autosaveMeta === true) { 87 | $model->saveMeta(); 88 | } 89 | }); 90 | 91 | static::deleted(function (Model $model) { 92 | if ( 93 | $model->autosaveMeta === true 94 | && !in_array(SoftDeletes::class, class_uses($model)) 95 | ) { 96 | $model->purgeMeta(); 97 | } 98 | }); 99 | 100 | if (method_exists(__CLASS__, 'forceDeleted')) { 101 | static::forceDeleted(function (Model $model) { 102 | if ($model->autosaveMeta === true) { 103 | $model->purgeMeta(); 104 | } 105 | }); 106 | } 107 | } 108 | 109 | /** 110 | * Disable all meta key restrictions. 111 | */ 112 | public static function unguardMeta(bool $state = true): void 113 | { 114 | static::$metaUnguarded = $state; 115 | } 116 | 117 | /** 118 | * Re-enable the meta key restrictions. 119 | */ 120 | public static function reguardMeta(): void 121 | { 122 | static::$metaUnguarded = false; 123 | } 124 | 125 | /** 126 | * Determine if meta keys are unguarded 127 | */ 128 | public static function isMetaUnguarded(): bool 129 | { 130 | return static::$metaUnguarded; 131 | } 132 | 133 | /** 134 | * Add value to the list of columns overridden by meta. 135 | * 136 | * @param mixed $value 137 | */ 138 | public function setFallbackValue(string $key, $value = null): self 139 | { 140 | ($this->fallbackValues ??= new Collection)->put($key, $value); 141 | 142 | return $this; 143 | } 144 | 145 | /** 146 | * Get the fallback value for the given key. 147 | * 148 | * @return mixed|null 149 | */ 150 | public function getFallbackValue(string $key) 151 | { 152 | return with($this->fallbackValues?->get($key), function ($value) use ($key) { 153 | if ($value && ($type = $this->getCastForMetaKey($key))) { 154 | return $this->getMetaClassName()::getDataTypeRegistry() 155 | ->getHandlerForType($type) 156 | ->unserializeValue($value); 157 | } 158 | 159 | return $value; 160 | }); 161 | } 162 | 163 | /** 164 | * Enable or disable auto-saving of meta data. 165 | */ 166 | public function autosaveMeta(bool $enable = true): self 167 | { 168 | $this->autosaveMeta = $enable; 169 | 170 | return $this; 171 | } 172 | 173 | /** 174 | * Get the value from the $metaKeys property if set or a fallback. 175 | */ 176 | protected function getMetaKeysProperty(): array 177 | { 178 | if (property_exists($this, 'metaKeys') && is_array($this->metaKeys)) { 179 | return $this->metaKeys; 180 | } 181 | 182 | return $this->_metaKeys; 183 | } 184 | 185 | /** 186 | * Get the allowed meta keys for the model. 187 | * 188 | * @return array 189 | */ 190 | public function getMetaKeys(): array 191 | { 192 | return collect($this->getMetaKeysProperty())->map( 193 | fn ($value, $key) => is_string($key) ? $key : $value 194 | )->toArray(); 195 | } 196 | 197 | /** 198 | * Get the forced typecast for the given meta key if there is any. 199 | */ 200 | public function getCastForMetaKey(string $key): ?string 201 | { 202 | /** @var ?string $cast */ 203 | $cast = with( 204 | $this->getMetaKeysProperty(), 205 | fn ($metaKeys) => isset($metaKeys[$key]) ? $metaKeys[$key] : null 206 | ); 207 | 208 | return $cast; 209 | } 210 | 211 | /** 212 | * Get or set the allowed meta keys for the model. 213 | * 214 | * @param array|null $fillable 215 | * @return $this 216 | */ 217 | public function metaKeys(?array $metaKeys = null): array 218 | { 219 | if (!$metaKeys) { 220 | return $this->getMetaKeysProperty(); 221 | } 222 | 223 | if (property_exists($this, 'metaKeys')) { 224 | $this->metaKeys = $metaKeys; 225 | } else { 226 | $this->_metaKeys = $metaKeys; 227 | } 228 | 229 | $this->getExplicitlyAllowedMetaKeys(false); 230 | 231 | return $this->getMetaKeysProperty(); 232 | } 233 | 234 | /** 235 | * Determine if the meta key wildcard (*) is set. 236 | */ 237 | public function isMetaWildcardSet(): bool 238 | { 239 | return in_array('*', $this->getMetaKeys()); 240 | } 241 | 242 | /** 243 | * Determine if the given key is an allowed meta key. 244 | */ 245 | public function isModelAttribute(string $key): bool 246 | { 247 | return 248 | $this->isRelation($key) || 249 | $this->hasSetMutator($key) || 250 | $this->hasAttributeSetMutator($key) || 251 | $this->isEnumCastable($key) || 252 | $this->isClassCastable($key) || 253 | str_contains($key, '->') || 254 | $this->hasColumn($key) || 255 | array_key_exists($key, parent::getAttributes()); 256 | } 257 | 258 | /** 259 | * Get the meta keys explicitly allowed by using `$metaKeys` 260 | * or by typecasting to `MetaAttribute::class`. 261 | */ 262 | public function getExplicitlyAllowedMetaKeys(bool $fromCache = true): array 263 | { 264 | if ($this->explicitlyAllowedMetaKeys && $fromCache) { 265 | return $this->explicitlyAllowedMetaKeys; 266 | } 267 | 268 | return $this->explicitlyAllowedMetaKeys = collect($this->getCasts()) 269 | ->filter(fn ($cast) => $cast === MetaAttribute::class) 270 | ->keys() 271 | ->concat($this->getMetaKeys()) 272 | ->filter(fn ($key) => $key !== '*') 273 | ->unique() 274 | ->toArray(); 275 | } 276 | 277 | /** 278 | * Determine if the given key was explicitly allowed. 279 | */ 280 | public function isExplicitlyAllowedMetaKey(string $key): bool 281 | { 282 | return in_array($key, $this->getExplicitlyAllowedMetaKeys()); 283 | } 284 | 285 | /** 286 | * Determine if the given key is an allowed meta key. 287 | */ 288 | public function isValidMetaKey(string $key): bool 289 | { 290 | if ($this->isMetaUnguarded()) { 291 | return true; 292 | } 293 | 294 | if ($this->isExplicitlyAllowedMetaKey($key)) { 295 | return true; 296 | } 297 | 298 | if ($this->isModelAttribute($key)) { 299 | return false; 300 | } 301 | 302 | return $this->isMetaWildcardSet(); 303 | } 304 | 305 | /** 306 | * Determine if model table has a given column. 307 | * 308 | * @param [string] $column 309 | */ 310 | public function hasColumn($column): bool 311 | { 312 | $class = get_class($this); 313 | 314 | if (!isset(static::$metaSchemaColumnsCache[$class])) { 315 | static::$metaSchemaColumnsCache[$class] = collect( 316 | $this->getConnection() 317 | ->getSchemaBuilder() 318 | ->getColumnListing($this->getTable()) ?? [] 319 | )->map(fn ($item) => strtolower($item))->toArray(); 320 | } 321 | 322 | return in_array(strtolower($column), static::$metaSchemaColumnsCache[$class]); 323 | } 324 | 325 | /** 326 | * Set the timestamp to take as `now` when looking up and storing meta data. 327 | */ 328 | public function setMetaTimestamp(?Carbon $timestamp = null): self 329 | { 330 | $this->metaTimestamp = $timestamp; 331 | $this->refreshMetaRelations(); 332 | 333 | return $this; 334 | } 335 | 336 | /** 337 | * Get the timestamp to take as `now` when looking up and storing meta data. 338 | */ 339 | public function getMetaTimestamp(): Carbon 340 | { 341 | if ($this->metaTimestamp) { 342 | return $this->metaTimestamp; 343 | } 344 | 345 | return static::$staticMetaTimestamp ?? Carbon::now(); 346 | } 347 | 348 | /** 349 | * Relationship to all `Meta` models associated with this model. 350 | */ 351 | public function allMeta(): MorphMany 352 | { 353 | return $this->morphMany($this->getMetaClassName(), 'metable'); 354 | } 355 | 356 | /** 357 | * Relationship to only published `Meta` models associated with this model. 358 | */ 359 | public function publishedMeta(): MorphMany 360 | { 361 | return $this->allMeta()->publishedBefore($this->getMetaTimestamp()); 362 | } 363 | 364 | /** 365 | * Relationship to the `Meta` model. 366 | * Groups by `key` and only shows the latest item that is published yet. 367 | */ 368 | public function meta(): MorphMany 369 | { 370 | return $this->allMeta()->onlyCurrent($this->getMetaTimestamp()); 371 | } 372 | 373 | /** 374 | * Get Meta model class name. 375 | */ 376 | protected function getMetaClassName(): string 377 | { 378 | return config('multiplex.model', Meta::class); 379 | } 380 | 381 | /** 382 | * Get meta value for key. 383 | * 384 | * @param mixed $default 385 | * @return mixed 386 | */ 387 | public function getMeta(string $key, $default = null) 388 | { 389 | return $this->findMeta($key)?->value ?? $default; 390 | } 391 | 392 | /** 393 | * Get all meta values as a key => value collection. 394 | */ 395 | public function pluckMeta(bool $withFallbackValues = false): Collection 396 | { 397 | return collect($this->getExplicitlyAllowedMetaKeys()) 398 | ->mapWithKeys(fn ($key) => [$key => $withFallbackValues ? $this->getFallbackValue($key) : null]) 399 | ->merge($this->meta->pluck('value', 'key')); 400 | } 401 | 402 | /** 403 | * Get the attribute for the given key and run it through the meta accessor. 404 | */ 405 | protected function getAttributeFromMetaAccessor($key, $value = null) 406 | { 407 | $value ??= $this->getMeta($key); 408 | 409 | $accessor = Str::camel('get_' . $key . '_meta'); 410 | 411 | if (!method_exists($this, $accessor)) { 412 | return $value; 413 | } 414 | 415 | return $this->{$accessor}($value); 416 | } 417 | 418 | /** 419 | * {@inheritDoc} 420 | */ 421 | public function getAttribute($key) 422 | { 423 | if (!$this->isValidMetaKey($key)) { 424 | return parent::getAttribute($key); 425 | } 426 | 427 | /** 428 | * If the given key is not explicitly allowed but exists as a real attribute 429 | * let’s not try to find a meta value for the given key. 430 | */ 431 | if ( 432 | !$this->isExplicitlyAllowedMetaKey($key) 433 | && ($attr = parent::getAttribute($key)) !== null 434 | ) { 435 | return $attr; 436 | } 437 | 438 | /** 439 | * There seems to be no attribute given and no relation so we either have a key 440 | * explicitly listed as a meta key or the wildcard (*) was used. Let’s get the meta 441 | * value for the given key and pipe the result through an accessor if possible. 442 | * If the value is still `null` check if there is a fallback value which typically 443 | * means there is an equal named database column which we pulled the value from earlier. 444 | */ 445 | $value = $this->getAttributeFromMetaAccessor($key); 446 | 447 | if ($value === null && !$this->hasMeta($key)) { 448 | $value = $this->getAttributeFromMetaAccessor($key, $this->getFallbackValue($key)); 449 | } 450 | 451 | /** 452 | * Finally delegate back to `parent::getAttribute()` if no meta exists. 453 | */ 454 | return $value ?? value( 455 | fn () => !$this->hasMeta($key) ? parent::getAttribute($key) : null 456 | ); 457 | } 458 | 459 | /** 460 | * Determine wether the given meta exists. 461 | */ 462 | public function hasMeta(string $key): bool 463 | { 464 | return (bool) $this->findMeta($key); 465 | } 466 | 467 | /** 468 | * Find current Meta model for the given key. 469 | * 470 | * @param string $key 471 | */ 472 | public function findMeta($key): ?Meta 473 | { 474 | if (!$this->exists || !isset($this->id)) { 475 | return null; 476 | } 477 | 478 | return $this->meta?->first(fn ($meta) => $meta->key === $key); 479 | } 480 | 481 | /** 482 | * Get the dirty meta collection. 483 | */ 484 | public function getDirtyMeta(): Collection 485 | { 486 | return $this->getMetaChanges(); 487 | } 488 | 489 | /** 490 | * Determine if meta is dirty. 491 | */ 492 | public function isMetaDirty(?string $key = null): bool 493 | { 494 | return (bool) with( 495 | $this->getMetaChanges(), 496 | fn ($meta) => $key ? $meta->has($key) : $meta->isNotEmpty() 497 | ); 498 | } 499 | 500 | /** 501 | * Add or update the value of the `Meta` at a given key. 502 | * 503 | * @param string|array $key 504 | * @param mixed $value 505 | * 506 | * @throws MetaException if invalid key is used. 507 | */ 508 | public function setMeta($key, $value = null) 509 | { 510 | if (is_array($key)) { 511 | return $this->setMetaFromArray($key); 512 | } 513 | 514 | return $this->setMetaFromString($key, $value); 515 | } 516 | 517 | /** 518 | * Publish the given meta data at the specified time. 519 | * 520 | * @param string|array $key 521 | * @param mixed $value 522 | * @param string|DateTimeInterface|null $publishAt 523 | * 524 | * @throws MetaException if invalid key is used. 525 | */ 526 | public function setMetaAt($key, $value = null, $publishAt = null) 527 | { 528 | if (func_num_args() === 2 && is_array($key)) { 529 | return $this->setMetaFromArray($key, Carbon::parse($value)); 530 | } 531 | 532 | return $this->setMetaFromString($key, $value, Carbon::parse($publishAt)); 533 | } 534 | 535 | /** 536 | * Set meta values from array of $key => $value pairs. 537 | * 538 | * 539 | * @throws MetaException if invalid keys are used. 540 | */ 541 | protected function setMetaFromArray(array $metas, ?Carbon $publishAt = null): Collection 542 | { 543 | return collect($metas)->map(function ($value, $key) use ($publishAt) { 544 | return $this->setMetaFromString($key, $value, $publishAt); 545 | }); 546 | } 547 | 548 | /** 549 | * Add or update the value of the `Meta` at a given string key. 550 | * 551 | * @param string $key 552 | * @param mixed $value 553 | * 554 | * @throws MetaException if invalid key is used. 555 | */ 556 | protected function setMetaFromString($key, $value, ?Carbon $publishAt = null): Meta 557 | { 558 | $key = strtolower($key); 559 | 560 | /** 561 | * If one is trying to set a model attribute as meta without explicitly 562 | * whitelisting the attribute throw an exception. 563 | */ 564 | if ($this->isModelAttribute($key) && !$this->isExplicitlyAllowedMetaKey($key)) { 565 | throw MetaException::modelAttribute($key); 566 | } 567 | 568 | /** 569 | * Check if the given key was whitelisted. 570 | */ 571 | if (!$this->isValidMetaKey($key)) { 572 | throw MetaException::invalidKey($key); 573 | } 574 | 575 | /** 576 | * Get all changed meta from our cache collection. 577 | */ 578 | $meta = $this->getMetaChanges(); 579 | 580 | /** 581 | * Let’s check if there is a mutator for the given meta key and pipe 582 | * the given value through it if so. 583 | */ 584 | $value = with(Str::camel('set_' . $key . '_meta'), function ($mutator) use ($value) { 585 | if (!method_exists($this, $mutator)) { 586 | return $value; 587 | } 588 | 589 | return $this->{$mutator}($value); 590 | }); 591 | 592 | $attributes = ['value' => $value]; 593 | 594 | /** 595 | * If `$publishAt` is set the meta should probably be published in the future 596 | * or one is trying to create a historic record. Set `published_at` accordingly. 597 | * If `published_at` is `null` it will be set to the current date in the `Meta` model. 598 | */ 599 | if ($publishAt) { 600 | $attributes['published_at'] = $publishAt; 601 | } elseif ($this->metaTimestamp) { 602 | $attributes['published_at'] = $this->metaTimestamp; 603 | } 604 | 605 | if (($model = $this->findMeta($key))) { 606 | $model 607 | ->forceType($this->getCastForMetaKey($key)) 608 | ->forceFill($attributes); 609 | 610 | /** 611 | * If there already is a persisted meta for the given key, let’s check if the 612 | * given value would result in a dirty model – if not skip here. 613 | */ 614 | if ($model->isClean()) { 615 | return $model; 616 | } 617 | 618 | $model->forceFill($model->getOriginal()); 619 | } 620 | 621 | /** 622 | * Fill the meta with the given attributes and save the changes in our collection. 623 | * This will not persist the given meta to the database. 624 | */ 625 | $modelClassName = $this->getMetaClassName(); 626 | 627 | return $meta[$key] = (new $modelClassName(['key' => $key])) 628 | ->forceType($this->getCastForMetaKey($key)) 629 | ->forceFill($attributes); 630 | } 631 | 632 | /** 633 | * Reset the meta changes collection for the given key. 634 | * Resets the entire collection if nothing is passed. 635 | */ 636 | public function resetMetaChanges(?string $key = null): Collection 637 | { 638 | if ($key && $this->metaChanges) { 639 | $this->metaChanges->forget($key); 640 | 641 | return $this->metaChanges; 642 | } 643 | 644 | return $this->metaChanges = new Collection; 645 | } 646 | 647 | /** 648 | * Reset the meta changes for the given key. 649 | */ 650 | public function resetMeta(string $key): Collection 651 | { 652 | return $this->resetMetaChanges($key); 653 | } 654 | 655 | /** 656 | * Delete the given meta key or keys. 657 | * 658 | * @param string|array $key 659 | * 660 | * @throws MetaException if invalid key is used. 661 | */ 662 | public function deleteMeta($key): bool 663 | { 664 | // @codeCoverageIgnoreStart 665 | if (!app()->environment('testing')) { 666 | DB::beginTransaction(); 667 | } 668 | // @codeCoverageIgnoreEnd 669 | 670 | $keys = collect(is_array($key) ? $key : [$key]); 671 | 672 | /** 673 | * If one of the given keys is invalid throw an exception. Otherwise delete all 674 | * meta records for the given keys from the database. 675 | */ 676 | $deleted = $keys 677 | ->each(function ($key) { 678 | if (!$this->isValidMetaKey($key)) { 679 | throw MetaException::invalidKey($key); 680 | } 681 | }) 682 | ->filter(function ($key) { 683 | $latest = $this->findMeta($key); 684 | 685 | return tap( 686 | $this->allMeta()->where('key', $key)->delete(), 687 | fn ($deleted) => $deleted && $latest && event(new MetaHasBeenRemoved($latest)) 688 | ); 689 | }); 690 | 691 | // @codeCoverageIgnoreStart 692 | if (!app()->environment('testing')) { 693 | DB::commit(); 694 | } 695 | // @codeCoverageIgnoreEnd 696 | 697 | /** 698 | * Remove the deleted meta models from the collection of changes 699 | * and refresh the meta relations to prevent having stale data. 700 | */ 701 | if ($deleted) { 702 | $deleted->each(fn ($key) => $this->resetMetaChanges($key)); 703 | $this->refreshMetaRelations(); 704 | } 705 | 706 | /** Check if all given keys could be deleted. */ 707 | return $deleted->count() === $keys->count(); 708 | } 709 | 710 | /** 711 | * Delete all meta for the given model. 712 | */ 713 | public function purgeMeta(): self 714 | { 715 | $this->allMeta()->delete(); 716 | 717 | return $this; 718 | } 719 | 720 | /** 721 | * Get the locally collected meta data. 722 | */ 723 | public function getMetaChanges(): Collection 724 | { 725 | if (!is_null($this->metaChanges)) { 726 | return $this->metaChanges; 727 | } 728 | 729 | return $this->resetMetaChanges(); 730 | } 731 | 732 | /** 733 | * {@inheritDoc} 734 | */ 735 | public function setAttribute($key, $value) 736 | { 737 | if (!$this->isValidMetaKey($key)) { 738 | return parent::setAttribute($key, $value); 739 | } 740 | 741 | return $this->setMetaFromString($key, $value); 742 | } 743 | 744 | /** 745 | * Refresh the meta relations. 746 | */ 747 | public function refreshMetaRelations(): self 748 | { 749 | if ($this->relationLoaded('allMeta')) { 750 | $this->unsetRelation('allMeta'); 751 | } 752 | 753 | if ($this->relationLoaded('publishedMeta')) { 754 | $this->unsetRelation('publishedMeta'); 755 | } 756 | 757 | if ($this->relationLoaded('meta')) { 758 | $this->unsetRelation('meta'); 759 | } 760 | 761 | return $this; 762 | } 763 | 764 | /** 765 | * Store a single Meta model. 766 | * 767 | * @return Meta|false 768 | */ 769 | protected function storeMeta(Meta $meta) 770 | { 771 | /** 772 | * If `$metaTimestamp` is set we probably are storing meta for the future or past. 773 | */ 774 | if ($currentTime = $this->getMetaTimestamp()) { 775 | $meta->published_at ??= $currentTime; 776 | } 777 | 778 | return tap( 779 | $this->allMeta()->save($meta), 780 | fn ($model) => $model && event(new MetaHasBeenAdded($model)) 781 | ); 782 | } 783 | 784 | /** 785 | * Store the meta data from the Meta Collection. 786 | * Returns `true` if all meta was saved successfully 787 | * or the `Meta` model if only one key value pair was submitted. 788 | * 789 | * @param string|array|null $key 790 | * @param mixed|null $value 791 | * @return bool|Meta 792 | * 793 | * @throws MetaException if invalid key is used. 794 | */ 795 | public function saveMeta($key = null, $value = null) 796 | { 797 | /** 798 | * If we have exactly two arguments set and save the value for the given key. 799 | */ 800 | if (func_num_args() === 2) { 801 | $this->setMeta($key, $value); 802 | 803 | return $this->saveMeta($key); 804 | } 805 | 806 | /** 807 | * Get all pending meta changes. 808 | */ 809 | $changes = $this->getMetaChanges(); 810 | 811 | /** 812 | * If no arguments were passed, all changes should be persisted. 813 | */ 814 | if (func_num_args() === 0) { 815 | return tap($changes->every(function (Meta $meta, $key) use ($changes) { 816 | return tap($this->storeMeta($meta), fn ($saved) => $saved && $changes->forget($key)); 817 | }), fn () => $this->refreshMetaRelations()); 818 | } 819 | 820 | /** 821 | * If only one argument was passed and it’s an array, let’s assume it 822 | * is a key => value pair that should be stored. 823 | */ 824 | if (is_array($key)) { 825 | return collect($key)->every(fn ($value, $name) => $this->saveMeta($name, $value)); 826 | } 827 | 828 | /** 829 | * Otherwise pull and delete the given key from the array of changes and 830 | * persist the change. Refresh the relations afterwards to prevent stale data. 831 | */ 832 | if (!$changes->has($key)) { 833 | return false; 834 | } 835 | 836 | /** @var Meta $meta */ 837 | $meta = $changes->pull($key); 838 | 839 | return tap($this->storeMeta($meta), function ($result) { 840 | if ((bool) $result) { 841 | $this->refreshMetaRelations(); 842 | } 843 | }); 844 | } 845 | 846 | /** 847 | * Immediately save the given meta for a specific publishing time. 848 | * 849 | * @param string|array $key 850 | * @param mixed $value 851 | * @param string|DateTimeInterface|null $publishAt 852 | * @return bool|Meta 853 | * 854 | * @throws MetaException if invalid key is used. 855 | */ 856 | public function saveMetaAt($key = null, $value = null, $publishAt = null) 857 | { 858 | $args = func_get_args(); 859 | 860 | $previousTimestamp = $this->metaTimestamp; 861 | $this->setMetaTimestamp(Carbon::parse(array_pop($args))); 862 | 863 | return tap( 864 | $this->saveMeta(...$args), 865 | fn () => $this->setMetaTimestamp($previousTimestamp) 866 | ); 867 | } 868 | 869 | /** 870 | * Store the model without saving attached meta data. 871 | */ 872 | public function saveWithoutMeta(): bool 873 | { 874 | $previousSetting = $this->autosaveMeta; 875 | 876 | $this->autosaveMeta = false; 877 | 878 | return tap($this->save(), fn () => $this->autosaveMeta = $previousSetting); 879 | } 880 | 881 | /** 882 | * Travel to the specified point in time for storing or retrieving meta. 883 | * 884 | * @param string|DateTimeInterface|null $time 885 | */ 886 | public function withMetaAt($time = null): self 887 | { 888 | $time = $time ? Carbon::parse($time) : null; 889 | 890 | if ( 891 | gettype($this->metaTimestamp) !== gettype($time) 892 | || !$this->metaTimestamp?->equalTo($time) 893 | ) { 894 | $this->refreshMetaRelations(); 895 | } 896 | 897 | $this->setMetaTimestamp($time); 898 | 899 | return $this; 900 | } 901 | 902 | /** 903 | * Travel to the current time for storing or retrieving meta. 904 | */ 905 | public function withCurrentMeta(): self 906 | { 907 | return $this->withMetaAt(null); 908 | } 909 | 910 | /** 911 | * Travel to the specified point in time for storing or retrieving meta. 912 | * 913 | * @param string|DateTimeInterface|null $time 914 | */ 915 | public function scopeTravelTo(Builder $query, $time = null): void 916 | { 917 | static::$staticMetaTimestamp = $time ? Carbon::parse($time) : null; 918 | } 919 | 920 | /** 921 | * Travel to the current time for storing or retrieving meta. 922 | */ 923 | public function scopeTravelBack(Builder $query): void 924 | { 925 | $query->travelTo(); 926 | } 927 | 928 | /** 929 | * Query records having meta data for the given key. 930 | * Pass an array to find records having meta for at least one of the given keys. 931 | * 932 | * @param string|array $key 933 | */ 934 | public function scopeWhereHasMeta(Builder $query, $key, string $boolean = 'and'): void 935 | { 936 | $keys = is_array($key) ? $key : [$key]; 937 | $method = $boolean === 'or' ? 'orWhereHas' : 'whereHas'; 938 | 939 | $query->{$method}('allMeta', function (Builder $query) use ($keys) { 940 | $query->publishedBefore($this->getMetaTimestamp())->whereIn('key', $keys); 941 | }); 942 | } 943 | 944 | /** 945 | * Query records having meta data for the given key with "or" where clause. 946 | * Pass an array to find records having meta for at least one of the given keys. 947 | * 948 | * @param string|array $key 949 | */ 950 | public function scopeOrWhereHasMeta(Builder $query, $key): void 951 | { 952 | $query->whereHasMeta($key, 'or'); 953 | } 954 | 955 | /** 956 | * Query records not having meta data for the given key. 957 | * Pass an array to find records not having meta for any of the given keys. 958 | * 959 | * @param string|array $key 960 | */ 961 | public function scopeWhereDoesntHaveMeta(Builder $query, $key, string $boolean = 'and'): void 962 | { 963 | $keys = is_array($key) ? $key : [$key]; 964 | $method = $boolean === 'or' ? 'orWhereDoesntHave' : 'whereDoesntHave'; 965 | 966 | $query->{$method}('allMeta', function (Builder $query) use ($keys) { 967 | $query->publishedBefore($this->getMetaTimestamp())->whereIn('key', $keys); 968 | }, '=', count($keys)); 969 | } 970 | 971 | /** 972 | * Query records not having meta data for the given key with "or" where clause.. 973 | * Pass an array to find records not having meta for any of the given keys. 974 | * 975 | * @param string|array $key 976 | */ 977 | public function scopeOrWhereDoesntHaveMeta(Builder $query, $key): void 978 | { 979 | $query->whereDoesntHaveMeta($key, 'or'); 980 | } 981 | 982 | /** 983 | * Query records having meta with a specific key and value. 984 | * If the `$value` parameter is omitted, the $operator parameter will be considered the value. 985 | * 986 | * @param string|\Closure $key 987 | * @param mixed $operator 988 | * @param mixed $value 989 | * @param string $boolean 990 | */ 991 | public function scopeWhereMeta(Builder $query, $key, $operator = null, $value = null, $boolean = 'and'): void 992 | { 993 | if (!isset($value)) { 994 | $value = $operator; 995 | $operator = '='; 996 | } 997 | 998 | $method = $boolean === 'or' ? 'orWhereHas' : 'whereHas'; 999 | 1000 | $query->{$method}('allMeta', function (Builder $query) use ($key, $operator, $value) { 1001 | $query->onlyCurrent($this->getMetaTimestamp()) 1002 | ->where( 1003 | $key instanceof Closure 1004 | ? $key 1005 | : fn ($q) => $q->where('meta.key', $key)->whereValue($value, $operator) 1006 | ); 1007 | }); 1008 | } 1009 | 1010 | /** 1011 | * Query records having meta with a specific key and value with "or" clause. 1012 | * If the `$value` parameter is omitted, the $operator parameter will be considered the value. 1013 | * 1014 | * @param string|\Closure $key 1015 | * @param mixed $operator 1016 | * @param mixed $value 1017 | */ 1018 | public function scopeOrWhereMeta(Builder $query, $key, $operator = null, $value = null): void 1019 | { 1020 | $query->whereMeta($key, $operator, $value, 'or'); 1021 | } 1022 | 1023 | /** 1024 | * Query records having raw meta with a specific key and value without checking type. 1025 | * Make sure that the supplied $value is a string or string castable. 1026 | * If the `$value` parameter is omitted, the $operator parameter will be considered the value. 1027 | * 1028 | * @param mixed $operator 1029 | * @param mixed $value 1030 | * @param string $boolean 1031 | */ 1032 | public function scopeWhereRawMeta(Builder $query, string $key, $operator, $value = null, $boolean = 'and'): void 1033 | { 1034 | if (!isset($value)) { 1035 | $value = $operator; 1036 | $operator = '='; 1037 | } 1038 | 1039 | $method = $boolean === 'or' ? 'orWhereHas' : 'whereHas'; 1040 | 1041 | $query->{$method}('allMeta', function (Builder $query) use ($key, $operator, $value) { 1042 | $query->onlyCurrent($this->getMetaTimestamp()) 1043 | ->where('meta.key', $key)->where('value', $operator, $value); 1044 | }); 1045 | } 1046 | 1047 | /** 1048 | * Query records having raw meta with a specific key and value without checking type with "or" clause. 1049 | * Make sure that the supplied $value is a string or string castable. 1050 | * If the `$value` parameter is omitted, the $operator parameter will be considered the value. 1051 | * 1052 | * @param mixed $operator 1053 | * @param mixed $value 1054 | */ 1055 | public function scopeOrWhereRawMeta(Builder $query, string $key, $operator, $value = null): void 1056 | { 1057 | $query->whereRawMeta($key, $operator, $value, 'or'); 1058 | } 1059 | 1060 | /** 1061 | * Query records having meta with a specific value and the given type. 1062 | * If the `$value` parameter is omitted, the $operator parameter will be considered the value. 1063 | * 1064 | * Available types can be found in `config('multiplex.datatypes')`. 1065 | * 1066 | * @param mixed $operator 1067 | * @param mixed $value 1068 | * @param string $boolean 1069 | */ 1070 | public function scopeWhereMetaOfType(Builder $query, string $type, string $key, $operator, $value = null, $boolean = 'and'): void 1071 | { 1072 | if (!isset($value)) { 1073 | $value = $operator; 1074 | $operator = '='; 1075 | } 1076 | 1077 | $method = $boolean === 'or' ? 'orWhereHas' : 'whereHas'; 1078 | 1079 | $query->{$method}('allMeta', function (Builder $query) use ($type, $key, $operator, $value) { 1080 | $query->onlyCurrent($this->getMetaTimestamp()) 1081 | ->where('meta.key', $key)->whereValue($value, $operator, $type); 1082 | }); 1083 | } 1084 | 1085 | /** 1086 | * Query records having meta with a specific value and the given type with "or" clause. 1087 | * If the `$value` parameter is omitted, the $operator parameter will be considered the value. 1088 | * 1089 | * Available types can be found in `config('multiplex.datatypes')`. 1090 | * 1091 | * @param mixed $operator 1092 | * @param mixed $value 1093 | */ 1094 | public function scopeOrWhereMetaOfType(Builder $query, string $type, string $key, $operator, $value = null): void 1095 | { 1096 | $query->whereMetaOfType($type, $key, $operator, $value, 'or'); 1097 | } 1098 | 1099 | /** 1100 | * Query records having one of the given values for the given key. 1101 | * 1102 | * @param string $boolean 1103 | */ 1104 | public function scopeWhereMetaIn(Builder $query, string $key, array $values, $boolean = 'and'): void 1105 | { 1106 | $method = $boolean === 'or' ? 'orWhereHas' : 'whereHas'; 1107 | 1108 | $query->{$method}('allMeta', function (Builder $query) use ($key, $values) { 1109 | $query->onlyCurrent($this->getMetaTimestamp()) 1110 | ->where('meta.key', $key)->whereValueIn($values); 1111 | }); 1112 | } 1113 | 1114 | /** 1115 | * Query records having one of the given values for the given key with "or" clause. 1116 | */ 1117 | public function scopeOrWhereMetaIn(Builder $query, string $key, array $values): void 1118 | { 1119 | $query->whereMetaIn($key, $values, 'or'); 1120 | } 1121 | 1122 | /** 1123 | * Query records where meta does not exist or is empty. 1124 | * 1125 | * @param string|array $key 1126 | */ 1127 | public function scopeWhereMetaEmpty(Builder $query, $key, string $boolean = 'and'): void 1128 | { 1129 | $keys = is_array($key) ? $key : [$key]; 1130 | 1131 | $query->where(function (Builder $query) use ($keys) { 1132 | $query->whereDoesntHaveMeta($keys)->orWhereMeta( 1133 | fn (Builder $q) => $q->whereIn('meta.key', $keys)->whereValueEmpty() 1134 | ); 1135 | }, null, null, $boolean); 1136 | } 1137 | 1138 | /** 1139 | * Query records where meta does not exist or is empty with "or" clause. 1140 | * 1141 | * @param string|array $key 1142 | */ 1143 | public function scopeOrWhereMetaEmpty(Builder $query, $key): void 1144 | { 1145 | $query->whereMetaEmpty($key, 'or'); 1146 | } 1147 | 1148 | /** 1149 | * Query records where meta exists and is not empty. 1150 | * 1151 | * @param string|array $key 1152 | */ 1153 | public function scopeWhereMetaNotEmpty(Builder $query, $key, string $boolean = 'and'): void 1154 | { 1155 | $keys = is_array($key) ? $key : [$key]; 1156 | $method = $boolean === 'or' ? 'orWhereHas' : 'whereHas'; 1157 | 1158 | $query->{$method}('allMeta', function (Builder $query) use ($keys) { 1159 | $query->onlyCurrent($this->getMetaTimestamp()) 1160 | ->whereIn('meta.key', $keys) 1161 | ->whereValueNotEmpty(); 1162 | }, '=', count($keys)); 1163 | } 1164 | 1165 | /** 1166 | * Query records where meta exists and is not empty with "or" clause. 1167 | * 1168 | * @param string|array $key 1169 | */ 1170 | public function scopeOrWhereMetaNotEmpty(Builder $query, $key): void 1171 | { 1172 | $query->whereMetaNotEmpty($key, 'or'); 1173 | } 1174 | } 1175 | -------------------------------------------------------------------------------- /src/Meta.php: -------------------------------------------------------------------------------- 1 | $metable 30 | * 31 | * @method static Builder|Meta joinLatest($now = null) 32 | * @method static Builder|Meta newModelQuery() 33 | * @method static Builder|Meta newQuery() 34 | * @method static Builder|Meta onlyCurrent($now = null) 35 | * @method static Builder|Meta published() 36 | * @method static Builder|Meta planned() 37 | * @method static Builder|Meta publishedBefore($time = null) 38 | * @method static Builder|Meta publishedAfter($time = null) 39 | * @method static Builder|Meta query() 40 | * @method static Builder|Meta whereCreatedAt($value) 41 | * @method static Builder|Meta whereId($value) 42 | * @method static Builder|Meta whereKey($value) 43 | * @method static Builder|Meta whereMetableId($value) 44 | * @method static Builder|Meta whereMetableType($value) 45 | * @method static Builder|Meta wherePublishedAt($value) 46 | * @method static Builder|Meta whereType($value) 47 | * @method static Builder|Meta whereUpdatedAt($value) 48 | * @method static Builder|Meta whereValue($value) 49 | * @method static Builder|Meta whereValueEmpty() 50 | * @method static Builder|Meta whereValueIn(array $values, ?string $type = null) 51 | * @method static Builder|Meta whereValueNotEmpty() 52 | * @method static Builder|Meta withoutCurrent($now = null) 53 | * @method static Builder|Meta withoutHistory($now = null) 54 | * 55 | * @mixin \Eloquent 56 | */ 57 | class Meta extends Model 58 | { 59 | use HasConfigurableMorphType; 60 | 61 | /** @use HasFactory<\Kolossal\Multiplex\Tests\Factories\MetaFactory> */ 62 | use HasFactory; 63 | 64 | use HasTimestamps; 65 | 66 | protected $guarded = [ 67 | 'id', 68 | 'metable_type', 69 | 'metable_id', 70 | 'type', 71 | ]; 72 | 73 | /** 74 | * Hide the aggregate columns from our custom join scope `scopeJoinLatest()`. 75 | * 76 | * @var list 77 | */ 78 | protected $hidden = [ 79 | 'id_aggregate', 80 | 'published_at_aggregate', 81 | 'key_aggregate', 82 | ]; 83 | 84 | /** 85 | * @var array 86 | */ 87 | protected $casts = [ 88 | 'published_at' => 'datetime', 89 | ]; 90 | 91 | protected $table = 'meta'; 92 | 93 | protected mixed $cachedValue = null; 94 | 95 | protected ?string $forceType = null; 96 | 97 | public static function boot(): void 98 | { 99 | parent::boot(); 100 | 101 | static::saving(function ($model): void { 102 | /** @var Meta $model */ 103 | $model->attributes['published_at'] ??= Carbon::now(); 104 | }); 105 | } 106 | 107 | /** 108 | * Metable Relation. 109 | * 110 | * @return MorphTo 111 | */ 112 | public function metable(): MorphTo 113 | { 114 | return $this->morphTo(); 115 | } 116 | 117 | /** 118 | * Set forced type to be used. 119 | */ 120 | public function forceType(?string $value): self 121 | { 122 | $this->forceType = $value; 123 | 124 | return $this; 125 | } 126 | 127 | /** 128 | * Accessor for value. 129 | * 130 | * Will unserialize the value before returning it. 131 | * 132 | * Successive access will be loaded from cache. 133 | * 134 | * 135 | * @throws Exceptions\DataTypeException 136 | */ 137 | public function getValueAttribute(): mixed 138 | { 139 | if ($this->cachedValue) { 140 | return $this->cachedValue; 141 | } 142 | 143 | if (!isset($this->attributes['type']) || !isset($this->attributes['value'])) { 144 | return null; 145 | } 146 | 147 | /** @var string $type */ 148 | $type = $this->attributes['type']; 149 | 150 | /** @var string $value */ 151 | $value = $this->attributes['value']; 152 | 153 | return $this->cachedValue = $this->getDataTypeRegistry() 154 | ->getHandlerForType($type) 155 | ->unserializeValue($value); 156 | } 157 | 158 | /** 159 | * Mutator for value. 160 | * 161 | * The `type` attribute will be automatically updated to match the datatype of the input. 162 | * 163 | * @param mixed $value 164 | * 165 | * @throws Exceptions\DataTypeException 166 | */ 167 | public function setValueAttribute($value): void 168 | { 169 | $registry = $this->getDataTypeRegistry(); 170 | 171 | $this->attributes['type'] = $this->forceType ?? $registry->getTypeForValue($value); 172 | 173 | $this->attributes['value'] = is_null($value) 174 | ? $value 175 | : $registry->getHandlerForType($this->attributes['type'])->serializeValue($value); 176 | 177 | $this->cachedValue = null; 178 | } 179 | 180 | /** 181 | * Determine if this is the most recent meta for this key. 182 | */ 183 | public function getIsCurrentAttribute(): bool 184 | { 185 | /** 186 | * @disregard P1014 187 | * 188 | * @phpstan-ignore property.notFound,nullsafe.neverNull 189 | * */ 190 | return $this->metable?->meta 191 | ?->first(fn (Meta $meta) => $meta->key === $this->key) 192 | ?->is($this) ?? false; 193 | } 194 | 195 | /** 196 | * Determine if this is a planned record not yet published. 197 | */ 198 | public function getIsPlannedAttribute(): bool 199 | { 200 | return $this->published_at?->isFuture() ?? false; 201 | } 202 | 203 | /** 204 | * Retrieve the underlying serialized value. 205 | */ 206 | public function getRawValueAttribute(): mixed 207 | { 208 | return $this->attributes['value'] ?? null; 209 | } 210 | 211 | /** 212 | * Load the datatype Registry from the container. 213 | */ 214 | public static function getDataTypeRegistry(): Registry 215 | { 216 | return app('multiplex.datatype.registry'); 217 | } 218 | 219 | /** 220 | * Query records where value is considered empty. 221 | * 222 | * @param Builder $query 223 | */ 224 | public function scopeWhereValueEmpty(Builder $query): void 225 | { 226 | $query->where(fn ($q) => $q->whereNull('value')->orWhere('value', '=', '')); 227 | } 228 | 229 | /** 230 | * Query records where value is considered not empty. 231 | * 232 | * @param Builder $query 233 | */ 234 | public function scopeWhereValueNotEmpty(Builder $query): void 235 | { 236 | $query->where(fn ($q) => $q->whereNotNull('value')->where('value', '!=', '')); 237 | } 238 | 239 | /** 240 | * Query records where value equals the serialized version of the given value. 241 | * If `$type` is omited the type will be taken from the data type registry. 242 | * 243 | * @param Builder $query 244 | * @param mixed $value 245 | * @param mixed $operator 246 | */ 247 | public function scopeWhereValue(Builder $query, $value, $operator = '=', ?string $type = null): void 248 | { 249 | $registry = $this->getDataTypeRegistry(); 250 | 251 | $type ??= $registry->getTypeForValue($value); 252 | 253 | $serializedValue = is_null($value) 254 | ? $value 255 | : $registry->getHandlerForType($type)->serializeValue($value); 256 | 257 | $query->where('type', $type)->where('value', $operator, $serializedValue); 258 | } 259 | 260 | /** 261 | * Query records where value equals the serialized version of one of the given values. 262 | * If `$type` is omited the type will be taken from the data type registry. 263 | * 264 | * @param Builder $query 265 | * @param array $values 266 | */ 267 | public function scopeWhereValueIn(Builder $query, array $values, ?string $type = null): void 268 | { 269 | $registry = $this->getDataTypeRegistry(); 270 | 271 | $serializedValues = collect($values)->map(function ($value) use ($registry, $type) { 272 | $type = $type ?? $registry->getTypeForValue($value); 273 | 274 | return [ 275 | 'type' => $type, 276 | 'value' => $registry->getHandlerForType($type)->serializeValue($value), 277 | ]; 278 | }); 279 | 280 | $query->where(function ($query) use ($serializedValues): void { 281 | $serializedValues->groupBy('type')->each(function ($values, $type) use ($query) { 282 | $query->orWhere(fn ($q) => $q->where('type', $type)->whereIn('value', $values->pluck('value'))); 283 | }); 284 | }); 285 | } 286 | 287 | /** 288 | * Query published meta only. 289 | * 290 | * @param Builder $query 291 | */ 292 | public function scopePublished(Builder $query): void 293 | { 294 | $query->publishedBefore(); 295 | } 296 | 297 | /** 298 | * Query meta published before given timestamp. 299 | * 300 | * @param Builder $query 301 | * @param string|\DateTimeInterface|null $time 302 | */ 303 | public function scopePublishedBefore(Builder $query, $time = null): void 304 | { 305 | $query->where('meta.published_at', '<=', $time ? Carbon::parse($time) : Carbon::now()); 306 | } 307 | 308 | /** 309 | * Query planned meta only. 310 | * 311 | * @param Builder $query 312 | */ 313 | public function scopePlanned(Builder $query): void 314 | { 315 | $query->publishedAfter(); 316 | } 317 | 318 | /** 319 | * Query meta published after given timestamp. 320 | * 321 | * @param Builder $query 322 | * @param string|\DateTimeInterface|null $time 323 | */ 324 | public function scopePublishedAfter(Builder $query, $time = null): void 325 | { 326 | $query->where('meta.published_at', '>', $time ? Carbon::parse($time) : Carbon::now()); 327 | } 328 | 329 | /** 330 | * Query records not being the latest meta for any key. 331 | * 332 | * @param Builder $query 333 | * @param string|\DateTimeInterface|null $now 334 | */ 335 | public function scopeWithoutCurrent(Builder $query, $now = null): void 336 | { 337 | $query->whereNotIn('id', static::query()->joinLatest($now)->select('meta.id')); 338 | } 339 | 340 | /** 341 | * Query records not being the latest meta for any key. 342 | * 343 | * @param Builder $query 344 | * @param string|\DateTimeInterface|null $now 345 | */ 346 | public function scopeWithoutHistory(Builder $query, $now = null): void 347 | { 348 | $query->where(function ($query) use ($now) { 349 | $query->publishedAfter($now) 350 | ->orWhereIn('id', static::query()->joinLatest($now)->select('meta.id')); 351 | }); 352 | } 353 | 354 | /** 355 | * Query only the latest meta for any key. 356 | * 357 | * @param Builder $query 358 | * @param string|\DateTimeInterface|null $now 359 | */ 360 | public function scopeOnlyCurrent(Builder $query, $now = null): void 361 | { 362 | $query->joinLatest($now); 363 | } 364 | 365 | /** 366 | * Add a join to find only records matching or not matching the latest published record per key. 367 | * Will only query for meta records from the past by default. 368 | * 369 | * @param Builder $query 370 | * @param string|\DateTimeInterface|null $now 371 | */ 372 | public function scopeJoinLatest(Builder $query, $now = null): void 373 | { 374 | /** 375 | * Create a subquery based on the given query and find the most recent publishing 376 | * date by getting the most recent `published_at` timestamp in the past. 377 | */ 378 | $latestPublishAt = static::query() 379 | ->select( 380 | DB::raw('MAX(published_at) as published_at_aggregate'), 381 | 'key', 382 | 'metable_id', 383 | 'metable_type', 384 | ) 385 | ->publishedBefore($now) 386 | ->groupBy('metable_type', 'metable_id', 'key'); 387 | 388 | /** 389 | * There may be multiple meta data with the exact same `published_at` timestamp 390 | * so let's find the record that was last saved by querying for the maximum `id` in a join. 391 | */ 392 | $maxId = static::query() 393 | ->select( 394 | 'key AS key_aggregate', 395 | DB::raw('MAX(id) as id_aggregate'), 396 | 'metable_id', 397 | 'metable_type', 398 | 'published_at', 399 | ) 400 | ->groupBy('metable_type', 'metable_id', 'key', 'published_at'); 401 | 402 | /** 403 | * Now that we have subqueries to join let’s build the complete query 404 | * and look for the record that matches the most recent entry for every `key`. 405 | */ 406 | $query->joinSub($maxId, 'max_id', function ($join) use ($latestPublishAt): void { 407 | $join->on('meta.id', '=', 'max_id.id_aggregate') 408 | ->on('meta.metable_type', '=', 'max_id.metable_type') 409 | ->on('meta.metable_id', '=', 'max_id.metable_id') 410 | ->joinSub($latestPublishAt, 'max_published_at', function ($join) { 411 | $join->on('max_id.published_at', '=', 'max_published_at.published_at_aggregate') 412 | ->on('max_id.key_aggregate', '=', 'max_published_at.key') 413 | ->on('max_id.metable_type', '=', 'max_published_at.metable_type') 414 | ->on('max_id.metable_id', '=', 'max_published_at.metable_id'); 415 | }); 416 | }); 417 | } 418 | } 419 | -------------------------------------------------------------------------------- /src/MetaAttribute.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class MetaAttribute implements CastsAttributes 11 | { 12 | /** 13 | * Transform the attribute from the underlying model values. 14 | * 15 | * @param \Illuminate\Database\Eloquent\Model $model 16 | * @param string $key 17 | * @param mixed $value 18 | * @param array $attributes 19 | */ 20 | public function get($model, $key, $value, $attributes): mixed 21 | { 22 | if (method_exists($model, 'getMeta') && method_exists($model, 'getFallbackValue')) { 23 | return $model->getMeta($key, $value ?? $model->getFallbackValue($key)); 24 | } 25 | 26 | return $value; 27 | } 28 | 29 | // @codeCoverageIgnoreStart 30 | /** 31 | * Transform the attribute to its underlying model values. 32 | * 33 | * @param \Illuminate\Database\Eloquent\Model $model 34 | * @param string $key 35 | * @param mixed $value 36 | * @param array $attributes 37 | */ 38 | public function set($model, $key, $value, $attributes): mixed 39 | { 40 | return $value; 41 | } 42 | // @codeCoverageIgnoreEnd 43 | } 44 | -------------------------------------------------------------------------------- /src/MultiplexServiceProvider.php: -------------------------------------------------------------------------------- 1 | runningInConsole()) { 13 | $this->publishes([ 14 | __DIR__ . '/../config/multiplex.php' => config_path('multiplex.php'), 15 | ], 'multiplex-config'); 16 | } 17 | 18 | if (config('multiplex.migrations', true)) { 19 | $this->loadMultiplexMigrations(); 20 | } 21 | } 22 | 23 | protected function runningInConsole(): bool 24 | { 25 | return $this->app->runningInConsole(); 26 | } 27 | 28 | protected function loadMultiplexMigrations(): void 29 | { 30 | $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); 31 | } 32 | 33 | public function register(): void 34 | { 35 | $this->mergeConfigFrom(__DIR__ . '/../config/multiplex.php', 'multiplex'); 36 | 37 | $this->registerDataTypeRegistry(); 38 | } 39 | 40 | /** 41 | * Add the DataType Registry to the service container. 42 | * 43 | * @copyright Plank Multimedia Inc. 44 | * 45 | * @link https://github.com/plank/laravel-metable 46 | */ 47 | protected function registerDataTypeRegistry(): void 48 | { 49 | $this->app->singleton(Registry::class, function (): Registry { 50 | $registry = new Registry; 51 | $datatypes = (array) config('multiplex.datatypes', []); 52 | 53 | foreach ($datatypes as $handler) { 54 | /** @var \Kolossal\Multiplex\DataType\HandlerInterface */ 55 | $handler = new $handler; 56 | $registry->addHandler($handler); 57 | } 58 | 59 | return $registry; 60 | }); 61 | 62 | $this->app->alias(Registry::class, 'multiplex.datatype.registry'); 63 | } 64 | } 65 | --------------------------------------------------------------------------------