Repository: kirkbushell/eloquence Branch: master Commit: 2da9afe047a5 Files: 69 Total size: 80.6 KB Directory structure: gitextract_qf5a7nt9/ ├── .github/ │ └── workflows/ │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config/ │ └── eloquence.php ├── phpunit.xml ├── src/ │ ├── Behaviours/ │ │ ├── CacheConfig.php │ │ ├── Cacheable.php │ │ ├── CountCache/ │ │ │ ├── CountCache.php │ │ │ ├── CountedBy.php │ │ │ ├── HasCounts.php │ │ │ └── Observer.php │ │ ├── HasCamelCasing.php │ │ ├── HasSlugs.php │ │ ├── ReadOnly/ │ │ │ ├── HasReadOnly.php │ │ │ └── WriteAccessDenied.php │ │ ├── Slug.php │ │ ├── SumCache/ │ │ │ ├── HasSums.php │ │ │ ├── Observer.php │ │ │ ├── SumCache.php │ │ │ ├── Summable.php │ │ │ └── SummedBy.php │ │ └── ValueCache/ │ │ ├── HasValues.php │ │ ├── Observer.php │ │ ├── ValueCache.php │ │ └── ValuedBy.php │ ├── Database/ │ │ └── Model.php │ ├── EloquenceServiceProvider.php │ ├── Exceptions/ │ │ └── UnableToCreateSlugException.php │ └── Utilities/ │ ├── DBQueryLog.php │ └── RebuildCaches.php └── tests/ ├── Acceptance/ │ ├── AcceptanceTestCase.php │ ├── ChainedAggregatesTest.php │ ├── CountCacheTest.php │ ├── GuardedColumnsTest.php │ ├── HasSlugsTest.php │ ├── Models/ │ │ ├── Category.php │ │ ├── CategoryFactory.php │ │ ├── Comment.php │ │ ├── CommentFactory.php │ │ ├── GuardedUser.php │ │ ├── Item.php │ │ ├── ItemFactory.php │ │ ├── Order.php │ │ ├── OrderFactory.php │ │ ├── Post.php │ │ ├── PostFactory.php │ │ ├── Role.php │ │ ├── User.php │ │ └── UserFactory.php │ ├── RebuildCacheTest.php │ ├── RebuildCachesCommandTest.php │ ├── SumCacheTest.php │ └── ValueCacheTest.php └── Unit/ ├── Behaviours/ │ ├── ReadOnly/ │ │ └── HasReadOnlyTest.php │ └── SlugTest.php ├── Database/ │ └── Traits/ │ └── HasCamelCasingTest.php ├── Stubs/ │ ├── CountCache/ │ │ ├── Comment.php │ │ ├── Post.php │ │ └── User.php │ ├── ModelStub.php │ ├── ParentModelStub.php │ ├── PivotModelStub.php │ ├── ReadOnlyModelStub.php │ ├── RealModelStub.php │ └── SumCache/ │ ├── Item.php │ └── Order.php └── TestCase.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: ['push', 'pull_request'] jobs: ci: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest] php: ['8.1', '8.2'] name: PHP ${{ matrix.php }} - ${{ matrix.os }} - ${{ matrix.dependency-version }} steps: - name: Checkout uses: actions/checkout@v2 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} tools: composer:v2 coverage: none - name: Install PHP dependencies run: composer update --prefer-stable --no-interaction --no-progress - name: Unit Tests run: composer test ================================================ FILE: .gitignore ================================================ /vendor composer.phar composer.lock .phpunit.result.cache .DS_Store .idea* .php-cs-fixer.cache ================================================ FILE: LICENSE ================================================ Copyright (c) 2014-2015 Kirk Bushell Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Eloquence ![Version](https://img.shields.io/packagist/v/kirkbushell/eloquence.svg) ![Downloads](https://img.shields.io/packagist/dt/kirkbushell/eloquence.svg) [![Test](https://github.com/kirkbushell/eloquence/actions/workflows/test.yml/badge.svg)](https://github.com/kirkbushell/eloquence/actions/workflows/test.yml) Eloquence is a package to extend Laravel's base Eloquent models and functionality. It provides a number of utilities and attributes to work with Eloquent in new and useful ways, such as camel cased attributes (such as for JSON apis and code style cohesion), data aggregation and more. ## Installation Install the package via composer: composer require kirkbushell/eloquence ## Usage Eloquence is automatically discoverable by Laravel, and shouldn't require any further steps. For those on earlier versions of Laravel, you can add the package as per normal in your config/app.php file: 'Eloquence\EloquenceServiceProvider', The service provider doesn't do much, other than enable the query log, if configured. ## Readonly models Eloquence supports the protection of models by ensuring that they can only be loaded from the database, and not written to, or have their values changed. This is useful for data you do not wish to be altered, or in cases where you may be sharing models across domain boundaries. To use, simply add the HasReadOnly trait to your model: ```php use \Eloquence\Behaviours\Readonly\HasReadOnly; class Log extends Model { use HasReadOnly; } ``` ## Camel case all the things! For those of us who prefer to work with a single coding style right across our applications, using the CamelCased trait will ensure you can do exactly that. It transforms all attribute access from camelCase to snake_case in real-time, providing a unified coding style across your application. This means everything from attribute access to JSON API responses will all be camelCased. To use, simply add the CamelCased trait to your model: use \Eloquence\Behaviours\HasCamelCasing; ### Note! Eloquence ***DOES NOT CHANGE*** how you write your schema migrations. You should still be using snake_case when setting up your columns and tables in your database schema migrations. This is a good thing - snake_case of columns names is the defacto standard within the Laravel community and is widely-used across database schemas, as well. ## Behaviours Eloquence comes with a system for setting up behaviours, which are really just small libraries that you can use with your Eloquent models. The first of these is the count cache. ### Count cache Count caching is where you cache the result of a count on a related model's record. A simple example of this is where you have posts that belong to authors. In this situation, you may want to count the number of posts an author has regularly, and perhaps even order by this count. In SQL, ordering by an aggregated value is unable to be indexed and therefore - slow. You can get around this by caching the count of the posts the author has created on the author's model record. To get this working, you need to do two steps: 1. Use the HasCounts trait on the child model (in this, case Post) and 2. Configure the count cache settings by using the CountedBy attribute. #### Configuring a count cache To setup a count cache configuration, we add the HasCounts trait, and setup the CountedBy attribute: ```php use Eloquence\Behaviours\CountCache\CountedBy; use Eloquence\Behaviours\CountCache\HasCounts; use Illuminate\Database\Eloquent\Model; class Post extends Model { use HasCounts; #[CountedBy] public function author(): BelongsTo { return $this->belongsTo(Author::class); } } ``` This tells the count cache behaviour that the model has an aggregate count cache on the Author model. So, whenever a post is added, modified or deleted, the count cache behaviour will update the appropriate author's count cache for their posts. In this case, it would update `post_count` field on the author model. The example above uses the following standard conventions: * `post_count` is a defined field on the User model table It uses your own relationship to find the related record, so no other configuration is required! Of course, if you have a different setup, or different field names, you can alter the count cache behaviour by defining the appropriate field to update: ```php class Post extends Model { use HasCounts; #[CountedBy(as: 'total_posts')] public function author(): BelongsTo { return $this->belongsTo(Author::class); } } ``` When setting the as: value (using named parameters here from PHP 8.0 for illustrative and readability purposes), you're telling the count cache that the aggregate field on the Author model is actually called `total_posts`. HasCounts is not limited to just one count cache configuration. You can define as many as you need for each BelongsTo relationship, like so: ```php #[CountedBy(as: 'total_posts')] public function author(): BelongsTo { return $this->belongsTo(Author::class); } #[CountedBy(as: 'num_posts')] public function category(): BelongsTo { return $this->belongsTo(Category::class); } ``` ### Sum cache Sum caching is similar to count caching, except that instead of caching a _count_ of the related model objects, you cache a _sum_ of a particular field on the child model's object. A simple example of this is where you have an order that has many items. Using sum caching, you can cache the sum of all the items' prices, and store that as a cached sum on the Order model. To get this working -- just like count caching -- you need to do two steps: 1. Add the HasSums to your child model and 2. Add SummedBy attribute to each relationship method that requires it. #### Configure the sum cache To setup the sum cache configuration, simply do the following: ```php use Eloquence\Behaviours\SumCache\HasSums; use Eloquence\Behaviours\SumCache\SummedBy; use Illuminate\Database\Eloquent\Model; class Item extends Model { use HasSums; #[SummedBy(from: 'amount', as: 'total_amount')] public function order(): BelongsTo { return $this->belongsTo(Order::class); } } ``` Unlike the count cache which can assume sensible defaults, the sum cache needs a bit more guidance. The example above tells the sum cache that there is an `amount` field on Item that needs to be summed to the `total_amount` field on Order. ### Cache recommendations Because the cache system works directly with other model objects and requires multiple writes to the database, it is strongly recommended that you wrap your model saves that utilise caches in a transaction. In databases like Postgres, this is automatic, but for databases like MySQL you need to make sure you're using a transactional database engine like InnoDB. The reason for needing transactions is that if any one of your queries fail, your caches will end up out of sync. It's better for the entire operation to fail, than to have this happen. Below is an example of using a database transaction using Laravel's DB facade: ```php DB::transaction(function() { $post = new Post; $post->authorId = $author->id; $post->save(); }); ``` If we return to the example above with posts having authors - if this save was not wrapped in a transaction, and the post was created but for some reason the database failed immediately after, you would never see the count cache update in the parent Author model, you'll end up with erroneous data that can be quite difficult to debug. ### Sluggable Sluggable is another behaviour that allows for the easy addition of model slugs. To use, implement the Sluggable trait: ```php class User extends Model { use HasSlugs; public function slugStrategy(): string { return 'username'; } } ``` In the example above, a slug will be created based on the username field of the User model. There are two other slugs that are supported, as well: * id and * uuid The only difference between the two above, is that if you're using UUIDs, the slug will be generated prior to the model being saved, based on the uuid field. With ids, which are generally auto-increase strategies - the slug has to be generated after the record has been saved - which results in a secondary save call to the database. That's it! Easy huh? # Upgrading from v10 Version 11 of Eloquence is a complete rebuild and departure from the original codebase, utilising instead PHP 8.1 attributes and moving away from traits/class extensions where possible. This means that in some projects many updates will need to be made to ensure that your use of Eloquence continues to work. ## 1. Class renames * Camelcasing has been renamed to HasCamelCasing * Sluggable renamed to HasSlugs ## 2. Updates to how caches work All your cache implementations will need to be modified following the guide above. But in short, you'll need to import and apply the provided attributes to the relationship methods on your models that require aggregated cache values. The best part about the new architecture with Eloquence, is that you can define your relationships however you want! If you have custom where clauses or other conditions that restrict the relationship, Eloquence will respect that. This makes Eloquence now considerably more powerful and supportive of individual domain requirements than ever before. Let's use a real case. This is the old approach, using Countable as an example: ```php class Post extends Model { use Countable; public function countCaches() { return [ 'num_posts' => ['User', 'users_id', 'id'] ]; } } ``` To migrate that to v11, we would do the following: ```php use Eloquence\Behaviours\CountCache\CountedBy; class Post extends Model { use \Eloquence\Behaviours\CountCache\HasCounts; #[CountedBy(as: 'num_posts')] public function user(): BelongsTo { return $this->belongsTo(User::class); } } ``` Note the distinct lack of required configuration. The same applies to the sum behaviour - simply migrate your configuration away from the cache functions, and into the attributes above the relationships you wish to have an aggregated cache value for. ## Changelog #### 12.0.1 * Added Readonly model support. #### 12.0.0 * Added Laravel 12 support #### 11.0.4 * Bug fix provided by #120 addressing the creation of new models without related model objects #### 11.0.3 * Bug fix for count cache when relation is removed (#118) * Identified and applied a similar bugfix for the sum cache #### 11.0.2 * Fixed a bug where relationships were not being returned #### 11.0.1 * Fixed dependency error to support Laravel 11 #### 11.0.0 * Complete rework of the Eloquent library - version 11 is **_not_** backwards-compatible * UUID support removed - both UUIDs and ULIDs are now natively supported in Laravel and have been for some time * Cache system now works directly with models and their relationships, allowing for fine-grained control over the models it works with * Console commands removed - model caches can be rebuilt using Model::rebuildCache() if something goes awry * Fixed a number of bugs across both count and sum caches * CamelCasing renamed to CamelCased * Syntax, styling, and standards all modernised #### 10.0.0 * Boost in version number to match Laravel * Support for Laravel 10.0+ * Replace date casting with standard Laravel casting (https://laravel.com/docs/10.x/upgrade#model-dates-property) #### 9.0.0 * Boost in version number to match Laravel * Support for Laravel 9.0+ * Updated to require PHP 8.1+ * Resolved method deprecation warnings #### 8.0.0 * Boost in version number to match Laravel * Support for Laravel 7.3+ * Fixes a bug that resulted with the new guarded attributes logic in eloquent #### 4.0.1 * Fixes a bug that resulted with the new guarded attributes logic in eloquent #### 4.0.0 * Laravel 7 support (thanks, @msiemens!) #### 3.0.0 * Laravel 6 support * Better slug creation and handling #### 2.0.7 * Slug uniqueness check upon slug creation for id-based slugs. #### 2.0.6 * Bug fix when restoring models that was resulting in incorrect count cache values. #### 2.0.3 * Slugs now implement Jsonable, making them easier to handle in API responses * New artisan command for rebuilding caches (beta, use at own risk) #### 2.0.2 * Updated PHP dependency to 5.6+ * CountCache and SumCache behaviours now supported via a service layer #### 2.0.0 * Sum cache model behaviour added * Booting of behaviours now done via Laravel trait booting * Simplification of all behaviours and their uses * Updated readme/configuration guide #### 1.4.0 * Slugs when retrieved from a model now return Slug value objects. #### 1.3.4 * More random, less predictable slugs for id strategies #### 1.3.3 * Fixed a bug with relationships not being accessible via model properties #### 1.3.2 * Slugged behaviour * Fix for fillable attributes #### 1.3.1 * Relationship fixes * Fillable attributes bug fix * Count cache update for changing relationships fix * Small update for implementing count cache observer #### 1.3.0 * Count cache model behaviour added * Many-many relationship casing fix * Fixed an issue when using ::create #### 1.2.0 * Laravel 5 support * Readme updates #### 1.1.5 * UUID model trait now supports custom UUIDs (instead of only generating them for you) #### 1.1.4 * UUID fix #### 1.1.3 * Removed the schema binding on the service provider #### 1.1.2 * Removed the uuid column creation via custom blueprint #### 1.1.1 * Dependency bug fix #### 1.1.0 * UUIDModel trait added * CamelCaseModel trait added * Model class updated to use CamelCaseModel trait - deprecated, backwards-compatibility support only * Eloquence now its own namespace (breaking change) * EloquenceServiceProvider added use this if you want to overload the base model automatically (required for pivot model camel casing). #### 1.0.2 * Relationships now support camelCasing for retrieval (thanks @linxgws) #### 1.0.1 * Fixed an issue with dependency resolution #### 1.0.0 * Initial implementation * Camel casing of model attributes now available for both setters and getters ## License The Laravel framework is open-sourced software licensed under the MIT license. ================================================ FILE: composer.json ================================================ { "name": "kirkbushell/eloquence", "description": "A set of extensions adding additional functionality and consistency to Laravel's awesome Eloquent library.", "keywords": [ "aggregates", "cache", "camelcase", "camel", "case", "count", "eloquent", "laravel", "snake_case", "snake", "sum" ], "authors": [ { "name": "Kirk Bushell", "email": "torm3nt@gmail.com" } ], "require": { "php": "^8.1", "hashids/hashids": "^4.1||^5.0", "illuminate/database": "^10.0||^11.0||^12.0", "illuminate/support": "^10.0||^11.0||^12.0", "hanneskod/classtools": "^0.1.0", "symfony/finder": "^6.3||^7.0" }, "require-dev": { "illuminate/events": "^10.0||^12.0", "mockery/mockery": "^1.4.4", "orchestra/testbench": "^8.0||^10.0", "phpunit/phpunit": "^9.5.10||^11.5.3", "friendsofphp/php-cs-fixer": "^3.48" }, "autoload": { "psr-4": { "Eloquence\\": "src/", "Tests\\": "tests/" } }, "scripts": { "test": "phpunit --colors=always", "post-autoload-dump": [ "@clear", "@prepare" ], "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", "prepare": "@php vendor/bin/testbench package:discover --ansi", "build": "@php vendor/bin/testbench workbench:build --ansi", "serve": [ "@build", "@php vendor/bin/testbench serve" ] }, "minimum-stability": "dev", "prefer-stable": true, "extra": { "laravel": { "providers": [ "Eloquence\\EloquenceServiceProvider" ] } }, "autoload-dev": { "psr-4": { "Workbench\\App\\": "workbench/app/", "Workbench\\Database\\Factories\\": "workbench/database/factories/", "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" } }, "config": { "allow-plugins": { "bamarni/composer-bin-plugin": true } } } ================================================ FILE: config/eloquence.php ================================================ [ 'enabled' => env('ELOQUENCE_LOGGING_ENABLED', false), 'driver' => env('ELOQUENCE_LOGGING_DRIVER', env('LOG_CHANNEL', 'stack')), ] ]; ================================================ FILE: phpunit.xml ================================================ ./tests/Unit ./tests/Acceptance ================================================ FILE: src/Behaviours/CacheConfig.php ================================================ {$this->relationName}(); } /** * Returns the current related model. */ public function relatedModel(Model $model): ?Model { return $model->{$this->relationName}; } /** * Returns -a- related model object - this object is actually empty, and is found on the query builder, used to * infer certain information abut the relationship that cannot be found on CacheConfig::relation. */ public function emptyRelatedModel(Model $model): Model { return $this->relation($model)->getModel(); } /** * Returns the related model class name. */ public function relatedModelClass($model): string { return get_class($this->emptyRelatedModel($model)); } public function foreignKeyName(Model $model): string { return $this->relation($model)->getForeignKeyName(); } } ================================================ FILE: src/Behaviours/Cacheable.php ================================================ model); // This behemoth cycles through all valid methods, and then gets only the attributes we care about, // formatting it in a way that is usable by our various aggregate service classes. return collect($reflect->getMethods()) ->filter(fn (ReflectionMethod $method) => count($method->getAttributes($attributeClass)) > 0) ->flatten() ->map(function (ReflectionMethod $method) use ($attributeClass) { return collect($method->getAttributes($attributeClass))->map(fn (\ReflectionAttribute $attribute) => [ 'name' => $method->name, 'attribute' => $attribute->newInstance(), ])->toArray(); }) ->flatten(1) ->mapWithKeys($fn) ->toArray(); } /** * Applies the provided function using the relevant configuration to all configured relations. Configuration * would be one of countedBy, summedBy, averagedBy.etc. */ protected function apply(Closure $function): void { foreach ($this->configuration() as $key => $value) { $function($this->config($key, $value)); } } /** * Updates a table's record based on the query information provided in the $config variable. * * @param string $operation Whether to increase or decrease a value. Valid values: +/- */ protected function updateCacheRecord(Model $model, CacheConfig $config, string $operation, int $amount): void { $this->updateCacheValue($model, $config, $amount); } /** * It's a bit hard to read what's going on in this method, so let's elaborate. * * 1. Get the foreign key of the model that needs to be queried. * 2. Get the aggregate value for all records with that foreign key. * 3. Update the related model wth the relevant aggregate value. */ public function rebuildCacheRecord(CacheConfig $config, Model $model, $command): void { $foreignKey = $config->foreignKeyName($model); $related = $config->emptyRelatedModel($model); $updateSql = sprintf( 'UPDATE %s SET %s = COALESCE((SELECT %s(%s) FROM %s WHERE %s = %s.%s), 0)', $related->getTable(), $config->aggregateField, $command, $config->sourceField, $model->getTable(), $foreignKey, $related->getTable(), $related->getKeyName() ); DB::update($updateSql); } /** * Update the cache value for the model. */ protected function updateCacheValue(?Model $model, CacheConfig $config, $value): void { if(!$model){ return; } $model->{$config->aggregateField} = $model->{$config->aggregateField} + $value; $model->save(); } } ================================================ FILE: src/Behaviours/CountCache/CountCache.php ================================================ reflect(CountedBy::class, function (array $config) { $aggregateField = $config['attribute']->as ?? Str::lower(Str::snake(class_basename($this->model))).'_count'; return [$config['name'] => $aggregateField]; }); } /** * When a model is updated, its foreign keys may have changed. In this situation, we need to update both the original * related model, and the new one.The original would be deducted the value, whilst the new one is increased. */ public function update(): void { $this->apply(function (CacheConfig $config) { $foreignKey = $config->foreignKeyName($this->model); // We only do updates if the foreign key was actually changed if (!$this->model->wasChanged($foreignKey)) { return; } // for the minus operation, we first have to get the model that is no longer associated with this one. $originalRelatedModel = $config->emptyRelatedModel($this->model)->find($this->model->getOriginal($foreignKey)); $this->updateCacheValue($originalRelatedModel, $config, -1); // If there is no longer a relation, nothing more to do. if (null === $this->model->{$foreignKey}) return; $this->updateCacheValue($config->relatedModel($this->model), $config, 1); }); } /** * Rebuild the count caches from the database for each matching model. */ public function rebuild(): void { $this->apply(function (CacheConfig $config) { $this->rebuildCacheRecord($config, $this->model, 'count'); }); } public function increment(): void { $this->apply(function (CacheConfig $config) { $this->updateCacheValue($config->relatedModel($this->model), $config, 1); }); } public function decrement(): void { $this->apply(function (CacheConfig $config) { $this->updateCacheValue($config->relatedModel($this->model), $config, -1); }); } } ================================================ FILE: src/Behaviours/CountCache/CountedBy.php ================================================ rebuild(); } } ================================================ FILE: src/Behaviours/CountCache/Observer.php ================================================ increment(); } /** * When the model is deleted, decrement the count cache by 1. * * @param $model */ public function deleted($model): void { CountCache::for($model)->decrement(); } /** * When the model is updated, update the count cache. * * @param $model */ public function updated($model): void { CountCache::for($model)->update(); } /** * When the model is restored, again increment the count cache by 1. * * @param $model */ public function restored($model): void { CountCache::for($model)->increment(); } } ================================================ FILE: src/Behaviours/HasCamelCasing.php ================================================ getSnakeKey($key)); } /** * Overloads the eloquent setAttribute method to ensure that fields accessed * in any case are converted to snake_case, which is the defacto standard * for field names in databases. * * @param string $key * @param mixed $value * @return mixed */ public function setAttribute($key, $value) { return parent::setAttribute($this->getSnakeKey($key), $value); } /** * Retrieve a given attribute but allow it to be accessed via alternative case methods (such as camelCase). * * @param string $key * @return mixed */ public function getAttribute($key): mixed { return $this->isRelation($key) ? parent::getAttribute($key) : parent::getAttribute($this->getSnakeKey($key)); } /** * Return the attributes for the model, converting field casing if necessary. * * @return array */ public function attributesToArray() { return $this->toCamelCase(parent::attributesToArray()); } /** * Get the model's relationships, converting field casing if necessary. * * @return array */ public function relationsToArray() { return $this->toCamelCase(parent::relationsToArray()); } /** * Overloads eloquent's getHidden method to ensure that hidden fields declared * in camelCase are actually hidden and not exposed when models are turned * into arrays. * * @return array */ public function getHidden() { return array_map(Str::class.'::snake', $this->hidden); } /** * Overloads the eloquent getCasts method to ensure that cast field declarations * can be made in camelCase but mapped to/from DB in snake_case. * * @return array */ public function getCasts() { return collect(parent::getCasts()) ->mapWithKeys(function ($cast, $key) { return [Str::snake($key) => $cast]; }) ->toArray(); } /** * Converts a given array of attribute keys to the casing required by CamelCased. * * @param mixed $attributes * @return array */ public function toCamelCase($attributes) { $convertedAttributes = []; foreach ($attributes as $key => $value) { $key = $this->getTrueKey($key); $convertedAttributes[$key] = $value; } return $convertedAttributes; } /** * Converts a given array of attribute keys to the casing required by CamelCased. * * @param $attributes * @return array */ public function toSnakeCase($attributes) { $convertedAttributes = []; foreach ($attributes as $key => $value) { $convertedAttributes[$this->getSnakeKey($key)] = $value; } return $convertedAttributes; } /** * Retrieves the true key name for a key. * * @param $key * @return string */ public function getTrueKey($key) { // If the key is a pivot key, leave it alone - this is required internal behaviour // of Eloquent for dealing with many:many relationships. if ($this->isCamelCase() && strpos($key, 'pivot_') === false) { $key = Str::camel($key); } return $key; } /** * Determines whether the model (or its parent) requires camelcasing. This is required * for pivot models whereby they actually depend on their parents for this feature. * * @return bool */ public function isCamelCase() { return $this->enforceCamelCase or (isset($this->parent) && method_exists($this->parent, 'isCamelCase') && $this->parent->isCamelCase()); } /** * If the field names need to be converted so that they can be accessed by camelCase, then we can do that here. * * @param $key * @return string */ protected function getSnakeKey($key) { return Str::snake($key); } /** * Because we are changing the case of keys and want to use camelCase throughout the application, whenever * we do isset checks we need to ensure that we check using snake_case. * * @param $key * @return mixed */ public function __isset($key) { return parent::__isset($key) || parent::__isset($this->getSnakeKey($key)); } /** * Because we are changing the case of keys and want to use camelCase throughout the application, whenever * we do unset variables we need to ensure that we unset using snake_case. * * @param $key * @return void */ public function __unset($key) { return parent::__unset($this->getSnakeKey($key)); } } ================================================ FILE: src/Behaviours/HasSlugs.php ================================================ generateSlug(); }); } /** * Generate a slug based on the main model key. */ public function generateIdSlug(): void { $slug = Slug::fromId($this->getKey() ?? rand()); // Ensure slug is unique (since the fromId() algorithm doesn't produce unique slugs) $attempts = 10; while ($this->slugExists($slug)) { if ($attempts <= 0) { throw new UnableToCreateSlugException( "Unable to find unique slug for record '{$this->getKey()}', tried 10 times..." ); } $slug = Slug::random(); $attempts--; } $this->setSlugValue($slug); } /** * Generate a slug string based on the fields required. */ public function generateTitleSlug(array $fields): void { static $attempts = 0; $titleSlug = Slug::fromTitle(implode('-', $this->getTitleFields($fields))); // This is not the first time we've attempted to create a title slug, so - let's make it more unique if ($attempts > 0) { $titleSlug . "-{$attempts}"; } $this->setSlugValue($titleSlug); $attempts++; } /** * Because a title slug can be created from multiple sources (such as an article title, a category title.etc.), * this allows us to search out those fields from related objects and return the combined values. */ public function getTitleFields(array $fields): array { return array_map(function ($field) { if (Str::contains($field, '.')) { return object_get($this, $field); // this acts as a delimiter, which we can replace with / } else { return $this->{$field}; } }, $fields); } /** * Generate the slug for the model based on the model's slug strategy. */ public function generateSlug(): void { $strategy = $this->slugStrategy(); if (in_array($strategy, ['uuid', 'id'])) { $this->generateIdSlug(); } elseif ($strategy != 'id') { $this->generateTitleSlug((array) $strategy); } } public function setSlugValue(Slug $value): void { $this->{$this->slugField()} = $value; } /** * Allows laravel to start using the slug field as the string for routes. */ public function getRouteKey(): mixed { $slug = $this->slugField(); return $this->$slug; } /** * Return the name of the field you wish to use for the slug. */ protected function slugField(): string { return 'slug'; } /** * Return the strategy to use for the slug. * * When using id or uuid, simply return 'id' or 'uuid' from the method below. However, * for creating a title-based slug - simply return the field you want it to be based on * * Eg: * * return 'id'; * return 'uuid'; * return 'name'; * * If you'd like your slug to be based on more than one field, return it in dot-notation: * * return 'first_name.last_name'; * * If you're using the camelcase model trait, then you can use that format: * * return 'firstName.lastName'; * * @return string */ public function slugStrategy(): string { return 'id'; } private function slugExists(Slug $slug): bool { return $this->newQuery() ->where($this->slugField(), (string) $slug) ->where($this->getQualifiedKeyName(), '!=', $this->getKey()) ->exists(); } } ================================================ FILE: src/Behaviours/ReadOnly/HasReadOnly.php ================================================ message = "Write access denied for model {$model}."; } } ================================================ FILE: src/Behaviours/Slug.php ================================================ slug = $slug; } /** * Generate a new 8-character slug. * * @param integer $id * @return Slug */ public static function fromId($id) { $salt = md5(uniqid().$id); $alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; $slug = with(new Hashids($salt, 8, $alphabet))->encode($id); return new Slug($slug); } /** * Generate a new entirely random 8-character slug */ public static function random(): Slug { $exclude = ['/', '+', '=', 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; $length = 8; $string = ''; while (($len = strlen($string)) < $length) { $size = $length - $len; $bytes = random_bytes($size); $string .= substr(str_replace($exclude, '', base64_encode($bytes)), 0, $size); } return new Slug($string); } public static function fromTitle($title): Slug { return new Slug(Str::slug($title)); } public function __toString(): string { return $this->slug; } public function toJson($options = 0): string { return $this->__toString(); } public static function castUsing(array $arguments): CastsAttributes { return new class implements CastsAttributes { public function get($model, string $key, $value, array $attributes): ?Slug { return null === $value ? $value : new Slug($value); } public function set($model, string $key, $value, array $attributes): array { return [ $key => null === $value ? $value : (string) $value ]; } }; } } ================================================ FILE: src/Behaviours/SumCache/HasSums.php ================================================ rebuild(); } } ================================================ FILE: src/Behaviours/SumCache/Observer.php ================================================ increase(); } public function updated($model) { SumCache::for($model)->update(); } public function deleted($model) { SumCache::for($model)->decrease(); } public function restored($model) { SumCache::for($model)->increase(); } } ================================================ FILE: src/Behaviours/SumCache/SumCache.php ================================================ reflect(SummedBy::class, function (array $config) { return [$config['name'] => [$config['attribute']->as => $config['attribute']->from]]; }); } /** * Rebuild the count caches from the database */ public function rebuild(): void { $this->apply(function ($config) { $this->rebuildCacheRecord($config, $this->model, 'sum'); }); } public function increase(): void { $this->apply(function (CacheConfig $config) { $this->updateCacheValue($config->relatedModel($this->model), $config, (int) $this->model->{$config->sourceField}); }); } public function decrease(): void { $this->apply(function (CacheConfig $config) { $this->updateCacheValue($config->relatedModel($this->model), $config, -(int) $this->model->{$config->sourceField}); }); } /** * Update the cache for all operations. */ public function update(): void { $this->apply(function (CacheConfig $config) { $foreignKey = $config->foreignKeyName($this->model); if ($this->model->wasChanged($foreignKey)) { // for the minus operation, we first have to get the model that is no longer associated with this one. $originalRelatedModel = $config->emptyRelatedModel($this->model)->find($this->model->getOriginal($foreignKey)); $this->updateCacheValue($originalRelatedModel, $config, -$this->model->getOriginal($config->sourceField)); if (null === $this->model->{$foreignKey}) return; $this->updateCacheValue($config->relatedModel($this->model), $config, $this->model->{$config->sourceField}); } else { $difference = $this->model->{$config->sourceField} - $this->model->getOriginal($config->sourceField); $this->updateCacheValue($config->relatedModel($this->model), $config, $difference); } }); } /** * Takes a registered sum cache, and setups up defaults. */ protected function config($relationName, $sourceField): CacheConfig { $keys = array_keys($sourceField); $aggregateField = $keys[0]; $sourceField = $sourceField[$aggregateField]; return new CacheConfig($relationName, $aggregateField, $sourceField); } } ================================================ FILE: src/Behaviours/SumCache/Summable.php ================================================ value array of the relationship you want to utilise to update the sum, followed * by the source field you wish to sum. For example, if you have an order model that has many items * and you wish to sum the item amount, you can return the following: * * ['order' => 'amount'] * * Of course, if you want to customise the field saving the total as well, you can do that too: * * ['relationship' => ['aggregate_field' => 'source_field']] * * In real-world terms: * * ['order' => ['total_amount' => 'amount']] * * By default, the sum cache will take the source field, and add "_total" to it on the related model. * * @return array */ public function summedBy(): array; } ================================================ FILE: src/Behaviours/SumCache/SummedBy.php ================================================ rebuild(); } } ================================================ FILE: src/Behaviours/ValueCache/Observer.php ================================================ updateRelated(true); } public function updated($model): void { ValueCache::for($model)->updateRelated(false); } } ================================================ FILE: src/Behaviours/ValueCache/ValueCache.php ================================================ apply(function(CacheConfig $config) use ($new) { $foreignKey = $config->foreignKeyName($this->model); // We only do work if the model previously existed and the source field has changed, or the model was newly created in the database. if (!($new || $this->model->wasChanged($config->sourceField))) { return; } if(!$relatedModel = $config->emptyRelatedModel($this->model)->find($this->model->$foreignKey)){ return; } $relatedModel->{$config->aggregateField} = $this->model->{$config->sourceField}; $relatedModel->save(); }); } private function configuration(): array { return $this->reflect(ValuedBy::class, function (array $config) { return [$config['name'] => [$config['attribute']->as => $config['attribute']->from]]; }); } } ================================================ FILE: src/Behaviours/ValueCache/ValuedBy.php ================================================ publishes([ __DIR__.'/../config/eloquence.php' => config_path('eloquence.php'), ], 'config'); $this->initialiseDbQueryLog(); $this->initialiseCommands(); } protected function initialiseDbQueryLog(): void { DBQueryLog::initialise(); } private function initialiseCommands(): void { $this->commands([ Utilities\RebuildCaches::class, ]); } } ================================================ FILE: src/Exceptions/UnableToCreateSlugException.php ================================================ debug("[{$query->time}ms] $query->sql", $query->bindings); }); } } ================================================ FILE: src/Utilities/RebuildCaches.php ================================================ 'rebuildCountCache', HasSums::class => 'rebuildSumCache', ]; public function handle(): void { $path = $this->argument('path') ?? app_path(); $this->allModelsUsingCaches($path)->each(function (string $class) { $traits = class_uses_recursive($class); foreach ($this->caches as $trait => $method) { if (!in_array($trait, $traits)) { continue; } $class::$method(); } }); } /** * Returns only those models that are utilising eloquence cache mechanisms. * * @param string $path * @return Collection */ private function allModelsUsingCaches(string $path): Collection { return collect(Finder::create()->files()->in($path)->name('*.php')) ->filter(fn (SplFileInfo $file) => $file->getFilename()[0] === Str::upper($file->getFilename()[0])) ->map(fn (SplFileInfo $file) => $this->fullyQualifiedClassName($file)) ->filter(fn (string $class) => is_subclass_of($class, Model::class)) ->filter(fn (string $class) => $this->usesCaches($class)); } /** * Determines the fully qualified class name of the provided file. * * @param SplFileInfo $file * @return string */ private function fullyQualifiedClassName(SplFileInfo $file) { $tokens = \PhpToken::tokenize($file->getContents()); $namespace = null; $class = null; foreach ($tokens as $i => $token) { if ($token->is(T_NAMESPACE)) { $namespace = $tokens[$i + 2]->text; } if ($token->is(T_CLASS)) { $class = $tokens[$i + 2]->text; } if ($namespace && $class) { break; } } if (!$namespace || !$class) { $this->error(sprintf('Could not find namespace or class in %s', $file->getRealPath())); } return sprintf('%s\\%s', $namespace, $class); } /** * Returns true if the provided class uses any of the caches provided by Eloquence. * * @param string $class * @return bool */ private function usesCaches(string $class): bool { return (bool) array_intersect(class_uses_recursive($class), array_keys($this->caches)); } } ================================================ FILE: tests/Acceptance/AcceptanceTestCase.php ================================================ migrate(); $this->init(); } protected function getPackageProviders($app) { return [ EloquenceServiceProvider::class, ]; } protected function getEnvironmentSetUp($app) { $app['config']->set('database.default', 'test'); $app['config']->set('database.connections.test', array( 'driver' => 'sqlite', 'database' => ':memory:' )); } protected function init() { // Overload } private function migrate() { Schema::create('users', function (Blueprint $table) { $table->increments('id'); $table->string('first_name')->nullable(); $table->string('last_name')->nullable(); $table->string('slug')->nullable(); $table->integer('comment_count')->default(0); $table->integer('post_count')->default(0); $table->timestamps(); }); Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->integer('category_id')->nullable(); $table->integer('user_id')->nullable(); $table->string('slug')->nullable(); $table->integer('comment_count')->default(0); $table->dateTime('publish_at')->nullable(); $table->timestamps(); }); Schema::create('comments', function (Blueprint $table) { $table->increments('id'); $table->integer('user_id'); $table->integer('post_id'); $table->timestamps(); $table->softDeletes(); }); Schema::create('orders', function (Blueprint $table) { $table->increments('id'); $table->integer('total_amount')->default(0); $table->timestamps(); }); Schema::create('items', function (Blueprint $table) { $table->increments('id'); $table->integer('order_id')->nullable(); $table->integer('amount'); $table->timestamps(); $table->softDeletes(); }); Schema::create('categories', function (Blueprint $table) { $table->increments('id'); $table->integer('post_count')->default(0); $table->integer('total_comments')->default(0); $table->dateTime('last_activity_at')->nullable(); $table->timestamps(); }); } } ================================================ FILE: tests/Acceptance/ChainedAggregatesTest.php ================================================ create(); $this->assertSame(1, Category::first()->postCount); $this->assertSame(1, Category::first()->totalComments); } } ================================================ FILE: tests/Acceptance/CountCacheTest.php ================================================ create(); $this->assertEquals(1, User::first()->postCount); } function test_whenRelatedModelsAreSwitchedBothCountCachesAreUpdated() { $user1 = User::factory()->create(); $user2 = User::factory()->create(); $posts = Post::factory()->count(2)->for($user1)->create(); $comment = Comment::factory()->for($user1)->for($posts->first())->create(); $this->assertEquals(2, $user1->fresh()->postCount); $this->assertEquals(1, $user1->fresh()->commentCount); $this->assertEquals(1, $posts->first()->fresh()->commentCount); $comment = $comment->fresh(); $comment->userId = $user2->id; $comment->save(); $this->assertEquals(0, $user1->fresh()->commentCount); $this->assertEquals(1, $user2->fresh()->commentCount); } public function test_itCanHandleModelRestoration() { $post = Post::factory()->create(); $comment = Comment::factory()->for($post)->create(); $comment->delete(); $comment->restore(); $this->assertEquals(1, $post->fresh()->commentCount); } public function test_cacheIsNotUsedWhenRelatedFieldIsNull() { $user1 = User::factory()->create(); $posts = Post::factory()->count(2)->for($user1)->create(); $this->assertEquals(2, $user1->fresh()->postCount); $firstPost = $posts->first()->fresh(); $firstPost->userId = null; $firstPost->save(); $this->assertEquals(1, $user1->fresh()->postCount); } public function test_canCreateModelWithoutRelatedBehavioursModels() { $post = new Post(); $post->save(); $this->assertModelExists($post); } } ================================================ FILE: tests/Acceptance/GuardedColumnsTest.php ================================================ 'Stuart', 'last_name' => 'Jones', ]); $this->assertEquals('Stuart', $user->firstName); $this->assertEquals('Jones', $user->lastName); } } ================================================ FILE: tests/Acceptance/HasSlugsTest.php ================================================ 'Kirk', 'lastName' => 'Bushell'])->create(); $this->assertEquals('kirk-bushell', $user->slug); } function test_slugsCanBeGeneratedUsingRandomValues() { $post = Post::factory()->create(); $this->assertMatchesRegularExpression('/^[a-z0-9]{8}$/i', $post->slug); } } ================================================ FILE: tests/Acceptance/Models/Category.php ================================================ belongsTo(Post::class); } #[CountedBy] public function user(): BelongsTo { return $this->belongsTo(User::class); } protected static function newFactory(): Factory { return CommentFactory::new(); } } ================================================ FILE: tests/Acceptance/Models/CommentFactory.php ================================================ Post::factory(), 'user_id' => User::factory(), ]; } } ================================================ FILE: tests/Acceptance/Models/GuardedUser.php ================================================ belongsTo(Order::class); } protected static function newFactory(): Factory { return ItemFactory::new(); } } ================================================ FILE: tests/Acceptance/Models/ItemFactory.php ================================================ Order::factory(), 'amount' => fake()->numberBetween(0, 100), ]; } } ================================================ FILE: tests/Acceptance/Models/Order.php ================================================ hasMany(Item::class); } protected static function newFactory(): Factory { return OrderFactory::new(); } } ================================================ FILE: tests/Acceptance/Models/OrderFactory.php ================================================ belongsTo(User::class); } public function slugStrategy() { return 'id'; } #[CountedBy] #[SummedBy(from: 'comment_count', as: 'total_comments')] #[ValuedBy(from: 'publish_at', as: 'last_activity_at')] public function category(): BelongsTo { return $this->belongsTo(Category::class); } protected static function newFactory(): Factory { return PostFactory::new(); } } ================================================ FILE: tests/Acceptance/Models/PostFactory.php ================================================ Category::factory(), 'user_id' => User::factory(), ]; } } ================================================ FILE: tests/Acceptance/Models/Role.php ================================================ hasMany(Post::class); } public function slugStrategy() { return ['firstName', 'lastName']; } protected static function newFactory(): Factory { return UserFactory::new(); } } ================================================ FILE: tests/Acceptance/Models/UserFactory.php ================================================ fake()->firstName(), 'lastName' => fake()->lastName(), ]; } } ================================================ FILE: tests/Acceptance/RebuildCacheTest.php ================================================ create(); $user2 = User::factory()->create(); Post::factory()->count(5)->for($user1)->create(); Post::factory()->count(2)->for($user2)->create(); $user1->postCount = 0; $user1->save(); $user2->postCount = 3; $user2->save(); Post::rebuildCountCache(); $this->assertEquals(5, $user1->fresh()->postCount); $this->assertEquals(2, $user2->fresh()->postCount); } function test_sumCachesCanBeRebuilt() { $order = Order::factory()->create(); Item::factory()->count(3)->for($order)->create(['amount' => 10]); $order->totalAmount = 50; $order->save(); $this->assertEquals(50, $order->fresh()->totalAmount); Item::rebuildSumCache(); $this->assertEquals(30, $order->fresh()->totalAmount); } } ================================================ FILE: tests/Acceptance/RebuildCachesCommandTest.php ================================================ create(['total_amount' => 0]); $order2 = Order::factory()->create(['total_amount' => 0]); Item::factory()->for($order1)->count(10)->create(['amount' => 10]); Item::factory()->for($order2)->count(5)->create(['amount' => 5]); $user1 = User::factory()->create(); $user2 = User::factory()->create(); Post::factory()->for($user1)->count(10)->create(); Post::factory()->for($user2)->count(5)->create(); $order1->totalAmount = 0; $order1->save(); $order2->totalAmount = 0; $order2->save(); $result = $this->artisan('eloquence:rebuild-caches '.__DIR__.'/../../tests/Acceptance/Models'); $result->assertExitCode(0); $this->assertDatabaseHas('users', ['post_count' => 10]); $this->assertDatabaseHas('users', ['post_count' => 5]); $this->assertDatabaseHas('orders', ['total_amount' => 100]); $this->assertDatabaseHas('orders', ['total_amount' => 25]); } } ================================================ FILE: tests/Acceptance/SumCacheTest.php ================================================ create(['amount' => 34]); $this->assertEquals(34, Order::first()->totalAmount); } function test_relatedModelSumCacheIsDecreasedWhenModelIsDeleted() { $item = Item::factory()->create(['amount' => 19]); $item->delete(); $this->assertEquals(0, Order::first()->totalAmount); } function test_whenAnAggregatedModelValueSwitchesContext() { $item = Item::factory()->create(); $newOrder = Order::factory()->create(); $item = $item->fresh(); $item->orderId = $newOrder->id; $item->save(); $this->assertEquals(0, Order::first()->totalAmount); $this->assertEquals($item->amount, $newOrder->fresh()->totalAmount); } function test_aggregateValuesAreUpdatedWhenModelsAreRestored() { $item = Item::factory()->create(); $item->delete(); // Triggers decrease in order total $item->restore(); // Restores order total $this->assertEquals($item->amount, Order::first()->totalAmount); } function test_aggregateValueIsSetToCorrectAmountWhenSourceFieldChanges() { $item = Item::factory()->create(); $item->amount = 20; $item->save(); $this->assertEquals(20, Order::first()->totalAmount); } function test_aggregateValueOnOriginalRelatedModelIsUpdatedCorrectlyWhenTheForeignKeyAndAmountIsChanged() { $item = Item::factory()->create(); $newOrder = Order::factory()->create(); $item = $item->fresh(); $item->amount = 20; $item->orderId = $newOrder->id; $item->save(); $this->assertEquals(0, Order::first()->totalAmount); $this->assertEquals(20, $newOrder->fresh()->totalAmount); } public function test_cacheIsNotUsedWhenRelatedFieldIsNull() { $order = Order::factory()->create(); $items = Item::factory()->count(5)->for($order)->create(['amount' => 1]); $this->assertEquals(5, Order::first()->totalAmount); $items->first()->order_id = null; $items->first()->save(); $this->assertEquals(4, $order->fresh()->totalAmount); } } ================================================ FILE: tests/Acceptance/ValueCacheTest.php ================================================ create(); $this->assertNull($category->last_activity_at); $post = Post::factory()->create(['category_id' => $category->id, 'publish_at' => now()->subDays(mt_rand(1, 10))]); $this->assertEquals($category->fresh()->last_activity_at, $post->publish_at); } } ================================================ FILE: tests/Unit/Behaviours/ReadOnly/HasReadOnlyTest.php ================================================ expectException(WriteAccessDenied::class); new ReadOnlyModelStub(['value' => 1]); } function test_model_cannot_be_saved() { $this->expectException(WriteAccessDenied::class); $model = new ReadOnlyModelStub; $model->save(); } } ================================================ FILE: tests/Unit/Behaviours/SlugTest.php ================================================ assertNotEquals(Slug::random(), Slug::random()); } public function test_slugs_are_8_characters_long() { $this->assertEquals(8, strlen((string) Slug::random())); } } ================================================ FILE: tests/Unit/Database/Traits/HasCamelCasingTest.php ================================================ model = new ModelStub; } public function test_attributes_as_array() { $attributes = $this->model->attributesToArray(); $this->assertArrayHasKey('firstName', $attributes); $this->assertArrayHasKey('lastName', $attributes); $this->assertArrayHasKey('address', $attributes); $this->assertArrayHasKey('firstName', $attributes); } public function test_attribute_declaration() { $this->model->setAttribute('firstName', 'Andrew'); $this->assertEquals('Andrew', $this->model->getAttribute('firstName')); } public function test_attribute_retrieval() { $this->assertEquals('Kirk', $this->model->getAttribute('firstName')); } public function test_attribute_conversion() { $expectedAttributes = [ 'address' => 'Home', 'countryOfOrigin' => 'Australia', 'firstName' => 'Kirk', 'lastName' => 'Bushell' ]; $this->assertEquals($expectedAttributes, $this->model->attributesToArray()); } public function test_attribute_conversion_leaves_pivots() { $model = new PivotModelStub; $expectedAttributes = [ 'firstName' => 'Kirk', 'pivot_field' => 'whatever' ]; $this->assertEquals($expectedAttributes, $model->attributesToArray()); } public function test_model_filling() { $model = new RealModelStub([ 'myField' => 'value', 'anotherField' => 'yeah', 'someField' => 'whatever' ]); $this->assertEquals($model->myField, 'value'); $this->assertEquals($model->anotherField, 'yeah'); $this->assertNull($model->someField); } public function test_isset_unset() { $model = new RealModelStub; // initial check $this->assertFalse(isset($model->my_field) || isset($model->myField)); // snake_case set $model->my_field = 'value'; $this->assertTrue(isset($model->my_field) && isset($model->myField)); // snake_case unset unset($model->my_field); $this->assertFalse(isset($model->my_field) || isset($model->myField)); // camelCase set $model->myField = 'value'; $this->assertTrue(isset($model->my_field) && isset($model->myField)); // camelCase unset unset($model->myField); $this->assertFalse(isset($model->my_field) || isset($model->myField)); } public function test_model_hidden_fields() { $model = new RealModelStub([ 'myField' => 'value', 'anotherField' => 'yeah', 'someField' => 'whatever', 'hiddenField' => 'secrets!', 'passwordHash' => '1234', ]); $modelArray = $model->toArray(); $this->assertFalse(isset($modelArray['hiddenField'])); $this->assertFalse(isset($modelArray['passwordHash'])); $this->assertEquals('secrets!', $model->getAttribute('hiddenField')); $this->assertEquals('1234', $model->getAttribute('passwordHash')); } public function test_model_date_handling() { $model = new RealModelStub([ 'myField' => '2011-11-11T11:11:11Z', 'dateField' => '2011-11-11T11:11:11Z', ]); $this->assertFalse($model->myField instanceof Carbon); $this->assertTrue($model->dateField instanceof Carbon); } } ================================================ FILE: tests/Unit/Stubs/CountCache/Comment.php ================================================ 'Tests\Unit\Stubs\CountCache\Post', 'Tests\Unit\Stubs\CountCache\User' ]; } } ================================================ FILE: tests/Unit/Stubs/CountCache/Post.php ================================================ ['Tests\Unit\Stubs\CountCache\User', 'user_id', 'id'], [ 'model' => 'Tests\Unit\Stubs\CountCache\User', 'countField' => 'posts_count_explicit', 'foreignKey' => 'user_id', 'key' => 'id' ] ]; } } ================================================ FILE: tests/Unit/Stubs/CountCache/User.php ================================================ 'Kirk', 'last_name' => 'Bushell', 'address' => 'Home', 'country_of_origin' => 'Australia' ]; } ================================================ FILE: tests/Unit/Stubs/ParentModelStub.php ================================================ attributes; } public function getAttribute($key) { return $this->attributes[$key]; } public function setAttribute($key, $value) { $this->attributes[$key] = $value; } public function isRelation($key) { return false; } } ================================================ FILE: tests/Unit/Stubs/PivotModelStub.php ================================================ 'Kirk', 'pivot_field' => 'whatever' ]; } ================================================ FILE: tests/Unit/Stubs/ReadOnlyModelStub.php ================================================ 'datetime', ]; public $hidden = ['hiddenField', 'passwordHash']; public $fillable = ['myField', 'anotherField', 'some_field', 'hiddenField', 'passwordHash', 'dateField']; public function fakeRelationship() { return 'nothing'; } /** * Should return an array of the count caches that need to be updated when this * model's state changes. Use the following array below as an example when a User * needs to update a Role's user count cache. These represent the default values used * by the behaviour. * * return [ 'user_count' => [ 'Role', 'role_id', 'id' ] ]; * * So, to extend, the first argument should be an index representing the counter cache * field on the associated model. Next is a numerical array: * * 0 = The model to be used for the update * 1 = The foreign_key for the relationship that RelatedCount will watch *optional * 2 = The remote field that represents the key *optional * * If the latter 2 options are not provided, or if the counter cache option is a string representing * the model, then RelatedCount will assume the ID fields based on conventional standards. * * Ie. another way to setup a counter cache is like below. This is an identical configuration to above. * * return [ 'user_count' => 'Role' ]; * * This can be simplified even further, like this: * * return [ 'Role' ]; * * @return array */ public function countCaches() { return [ 'users_count' => ['Role', 'role_id', 'id'], 'comments_count' => 'Post', 'User' ]; } } ================================================ FILE: tests/Unit/Stubs/SumCache/Item.php ================================================ 'Tests\Unit\Stubs\SumCache\Order', 'sumField' => 'itemTotalExplicit', 'columnToSum' => 'total', 'foreignKey' => 'itemId', 'key' => 'id', ] ]; } } ================================================ FILE: tests/Unit/Stubs/SumCache/Order.php ================================================ init(); } public function tearDown(): void { m::close(); } public function init() { // Nothing to do - for children to implement. } }