├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src ├── Builder.php ├── Collection.php ├── Commands │ ├── RedisModelMakerCommand.php │ └── stubs │ │ └── model.stub ├── Exceptions │ ├── ErrorConnectToRedisException.php │ ├── ErrorTransactionException.php │ ├── KeyExistException.php │ ├── MassAssignmentException.php │ ├── MissingAttributeException.php │ └── RedisModelException.php ├── Model.php ├── RedisModelServiceProvider.php ├── RedisRepository.php └── config │ └── redis-model.php └── tests ├── ModelTest.php └── Models └── User.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Chau Lam Dinh Ai - Alvin0 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redis Model 2 | 3 | The Redis Model will help create multiple keys with the same prefix in Redis and group those keys together as a table in a SQL database. The Redis Model will create an instance similar to the Eloquent Model in Laravel. It will also provide complete methods for adding, deleting, updating, and retrieving data arrays with methods that are similar to those used in Eloquent. 4 | 5 | > No Relationship : 6 | Redis is not the place to store complex relational data and Redis emphasizes its ability to access data very quickly, so building Relationship methods between models is not necessary and it will take a lot of time to retrieve data. 7 | 8 | ## Supports 9 | 10 | ### Laravel version supports 11 | 12 | | Redis Version | Laravel version(s) | 13 | | :---: | :---: | 14 | | 0.x | 8 / 9 / 10 / 11 | 15 | 16 | ### Model Supports 17 | 18 | | Method | Is Working | 19 | | --- | :---: | 20 | | CURD | Yes | 21 | | Condition Select | Yes | 22 | | Chunking | Yes | 23 | | Transaction | Yes | 24 | | Insert a lot of data | Yes | 25 | | Delete a lot of data | Yes | 26 | | Update a lot of data | Think.. | 27 | | Relationship | No | 28 | 29 | ### Key structure 30 | 31 | Sample key structure for a Redis model in Laravel: 32 | 33 | `{redis_prefix}{redis_database_name}{model_name}:{primary_key}:{sub_key_1}:{sub_key_2}:...:{sub_key_n}` 34 | 35 | - redis_prefix: The Redis prefix set in the Redis configuration file. 36 | - redis_database_name: The name of the Redis database used for the model. 37 | - model_name: The name of the model. 38 | - primary_key: The primary key of the model. 39 | - sub_keys: Additional keys that belong to the model, which can be defined in the model 'subKey' property. 40 | 41 | Example key: 42 | 43 | `laravel_redis_model_users:email:email@example:name:alvin:role:admin` 44 | 45 | In this example: 46 | 47 | - The Redis prefix is 'laravel' 48 | - The Redis database name is 'redis_model' 49 | - The model name is 'users' 50 | - The primary key of the model is 'email' 51 | - The sub-keys of the model are 'name' and 'role'. 52 | 53 | > Note: The Redis prefix and database name can be configured in the 'redis-model' configuration file. The model's primary key and sub-keys can be defined in the model's 'primaryKey' and 'subKeys' property, respectively. 54 | 55 | ## Installation 56 | 57 | You may install Redis Model via the Composer package manager: 58 | ``` 59 | composer require alvin0/redis-model 60 | ``` 61 | 62 | You should publish the RedisModel configuration and migration files using the `vendor:publish` Artisan command. The `redis-model` configuration file will be placed in your application's `config` directory: 63 | 64 | ``` 65 | php artisan vendor:publish --provider="Alvin0\RedisModel\RedisModelServiceProvider" 66 | ``` 67 | ## Generate Model 68 | ``` 69 | php artisan redis-model:model User 70 | ``` 71 | ## Model Conventions 72 | ### Primary Keys, Sub-Keys And Fillable 73 | 74 | The primary key is an attribute assigned by default by uuid, and they determine the search behavior of the model. 75 | Make sure that the values of primary keys are unique to avoid confusion when retrieving data based on conditions. 76 | Sub keys are keys that can be duplicated, and they will help you search using the where method in the model. 77 | To declare attributes for a model, you need to declare them in the `fillable` variable. You should also declare the `primaryKey` and `subKeys` in this variable. 78 | 79 | > 80 | ```php 81 | use Alvin0\RedisModel\Model; 82 | 83 | class User extends Model { 84 | 85 | /** 86 | * The model's sub keys for the model. 87 | * 88 | * @var array 89 | */ 90 | protected $subKeys = [ 91 | 'name', 92 | 'role', 93 | ]; 94 | 95 | /** 96 | * The attributes that are mass assignable. 97 | * 98 | * @var array 99 | */ 100 | protected $fillable = [ 101 | 'id', 102 | 'email', 103 | 'name', 104 | 'role', 105 | 'address' 106 | ]; 107 | } 108 | ``` 109 | 110 | > If possible, please turn off the automatic UUID generation feature for model keys and choose keys for data to ensure easy data search with keys. Make sure the primary key is unique for the model. 111 | 112 | ```php 113 | use Alvin0\RedisModel\Model; 114 | 115 | class User extends Model { 116 | /** 117 | * The primary key for the model. 118 | * 119 | * @var bool 120 | */ 121 | protected $primaryKey = 'email'; 122 | 123 | /** 124 | * Indicates if the IDs are auto-incrementing. 125 | * 126 | * @var bool 127 | */ 128 | public $incrementing = false; 129 | 130 | /** 131 | * The model's sub keys for the model. 132 | * 133 | * @var array 134 | */ 135 | protected $subKeys = [ 136 | 'name', 137 | 'role', 138 | ]; 139 | 140 | /** 141 | * The attributes that are mass assignable. 142 | * 143 | * @var array 144 | */ 145 | protected $fillable = [ 146 | 'email', 147 | 'name', 148 | 'role', 149 | 'address' 150 | ]; 151 | } 152 | ``` 153 | 154 | ### Table Names 155 | So, in this case, RedisModel will assume the `User` model stores records in the `users` table. 156 | 157 | ```php 158 | use Alvin0\RedisModel\Model; 159 | 160 | class User extends Model { 161 | // ... 162 | } 163 | ``` 164 | 165 | The final name before creating the hash code is based on the `prefix of the table + table name`. 166 | If your model's corresponding database table does not fit this convention, you may manually specify the model's table name by defining a table property on the model: 167 | ```php 168 | use Alvin0\RedisModel\Model; 169 | 170 | class User extends Model { 171 | /** 172 | * The model's table. 173 | * 174 | * @var array 175 | */ 176 | protected $table = ""; 177 | 178 | /** 179 | * The model's prefixTable. 180 | * 181 | * @var array 182 | */ 183 | protected $prefixTable = null; 184 | } 185 | ``` 186 | ### Timestamps 187 | By default, RedisModel expects `created_at` and `updated_at` columns to exist on your model's corresponding database table. RedisModel will automatically set these column's values when models are created or updated. If you do not want these columns to be automatically managed by RedisModel, you should define a `$timestamps` property on your model with a value of `false`: 188 | 189 | ```php 190 | use Alvin0\RedisModel\Model; 191 | 192 | class User extends Model { 193 | /** 194 | * Indicates if the model should be timestamped. 195 | * 196 | * @var bool 197 | */ 198 | public $timestamps = false; 199 | } 200 | ``` 201 | ### Configuring Connection Model 202 | You can change the connection name for the model's connection. Make sure it is declared in the `redis-model` configuration file. By default, the model will use the `redis_model_default` connection name. 203 | ```php 204 | use Alvin0\RedisModel\Model; 205 | 206 | class User extends Model { 207 | 208 | /** 209 | * @var string|null 210 | */ 211 | protected $connectionName = null; 212 | } 213 | ``` 214 | 215 | ## Retrieving Models 216 | 217 | ### Building 218 | Due to limitations in searching model properties, where is the only supported method. The where method will facilitate the search for primary key and sub-key in the model's table easily. You can add additional constraints to the query and then call the `get` method to retrieve the results: 219 | The where method can only search for fields that are `primary key` and `sub keys`. 220 | 221 | ```php 222 | use App\RedisModels\User; 223 | 224 | User::where('email', 'email@gmail.com') 225 | ->where('role', 'admin') 226 | ->get(); 227 | ``` 228 | > Tip: where("field", "something_*") 229 | > You can use * to match any keywords following the required keywords. Looks like the same place as in SQL 230 | 231 | ```php 232 | use App\RedisModels\User; 233 | 234 | User::where('name', "user_*")->get(); 235 | // result collection 236 | // [ 237 | // ["name" => "user_1"], 238 | // ["name" => "user_2"], 239 | // ["name" => "user_3"], 240 | // ["name" => "user_4"], 241 | // ] 242 | ``` 243 | ### Collection 244 | As we have seen, Eloquent methods like `all` and `get` retrieve multiple records from the redis. However, these methods don't return a plain PHP array. Instead, an instance of `Alvin0\RedisModel\Collection` is returned. 245 | 246 | - Method all() 247 | 248 | ```php 249 | use App\RedisModels\User; 250 | 251 | User::all(); 252 | ``` 253 | - Method get() 254 | 255 | ```php 256 | use App\RedisModels\User; 257 | 258 | User::where('name', "user_*")->get(); 259 | ``` 260 | 261 | ## Chunking Results 262 | Your application may run out of memory if you attempt to load tens of thousands of Eloquent records via the `all` or `get` methods. Instead of using these methods, the chunk method may be used to process large numbers of models more efficiently. 263 | 264 | - Method chunk 265 | ```php 266 | use App\RedisModels\User; 267 | use Alvin0\RedisModel\Collection; 268 | 269 | User::where('user_id', 1) 270 | ->chunk(10, function (Collection $items) { 271 | foreach ($items as $item) { 272 | dump($item); 273 | } 274 | }); 275 | ``` 276 | ### Retrieving Single Models 277 | In addition to retrieving all of the records matching a given query, you may also retrieve single records using the `find`, `first` methods.Instead of returning a collection of models, these methods return a single model instance: 278 | 279 | ```php 280 | use App\RedisModels\User; 281 | 282 | // Retrieve a model by its primary key... 283 | $user = User::find('value_primary_key'); 284 | 285 | // Retrieve the first model matching the query constraints... 286 | $user = User::where('email', 'email@gmail.com')->first(); 287 | 288 | ``` 289 | ## Inserting & Updating Models 290 | ### Inserts 291 | We also need to insert new records. Thankfully, Eloquent makes it simple. To insert a new record into the database, you should instantiate a new model instance and set attributes on the model. Then, call the save method on the model instance: 292 | ```php 293 | use App\RedisModels\User; 294 | 295 | $user = new User; 296 | $user->email = 'email@gmail.com'; 297 | $user->name = 'Alvin0'; 298 | $user->token = '8f8e847890354d23b9a762f4d2612ce5'; 299 | $user->token = now(); 300 | $user->save() 301 | ``` 302 | ### Create Model 303 | Alternatively, you may use the create method to `save` a new model using a single PHP statement. The inserted model instance will be returned to you by the create method: 304 | ```php 305 | use App\RedisModels\User; 306 | 307 | $user = User::create([ 308 | 'email' => 'email@gmail.com', 309 | 'name' => 'Alvin0' 310 | 'token' => '8f8e847890354d23b9a762f4d2612ce5', 311 | 'expire_at' => now(), 312 | ]) 313 | $user->email //email@gmail.com 314 | ``` 315 | 316 | ### Force Create Model 317 | By default, the create method will automatically throw an error if the primary key is duplicated (`Alvin0\RedisModel\Exceptions\KeyExistException`). If you want to ignore this error, you can try the following approaches: 318 | - Change property `preventCreateForce` to `false` 319 | ```php 320 | use Alvin0\RedisModel\Model; 321 | 322 | class User extends Model { 323 | /** 324 | * Indicates when generating but key exists 325 | * 326 | * @var bool 327 | */ 328 | protected $preventCreateForce = true; 329 | } 330 | ``` 331 | 332 | - Use method forceCreate 333 | ```php 334 | use App\RedisModels\User; 335 | 336 | User::forceCreate([ 337 | 'email' => 'email@gmail.com', 338 | 'name' => 'Alvin0' 339 | 'token' => '8f8e847890354d23b9a762f4d2612ce5', 340 | 'expire_at' => now(), 341 | ]); 342 | 343 | $user->email //email@gmail.com 344 | 345 | ``` 346 | 347 | ### Insert Statements 348 | To solve the issue of inserting multiple items into a table, you can use the inserts function. It is recommended to use array chunk to ensure performance. 349 | ```php 350 | use App\RedisModels\User; 351 | use Illuminate\Support\Facades\Hash; 352 | use Illuminate\Support\Str; 353 | 354 | $seed = function ($limit) { 355 | $users = []; 356 | for ($i = 0; $i < $limit; $i++) { 357 | $users[] = [ 358 | 'email' => Str::random(10) . '@gmail.com', 359 | 'name' => Str::random(8), 360 | 'token' => md5(Str::random(10)), 361 | 'expire_at' => now(), 362 | ]; 363 | } 364 | 365 | return $users; 366 | }; 367 | 368 | User::insert($seed(10)); 369 | ``` 370 | 371 | > I think you should use the transaction method to ensure that your data will be rolled back if an error occurs during the process of inserting multiple data. 372 | ```php 373 | use App\RedisModels\User; 374 | use Illuminate\Support\Facades\Hash; 375 | use Illuminate\Support\Str; 376 | 377 | $seed = function ($limit) { 378 | $users = []; 379 | for ($i = 0; $i < $limit; $i++) { 380 | $users[] = [ 381 | 'email' => Str::random(10) . '@gmail.com', 382 | 'name' => Str::random(8), 383 | 'token' => md5(Str::random(10)), 384 | 'expire_at' => now(), 385 | ]; 386 | } 387 | 388 | return $users; 389 | }; 390 | 391 | User::transaction(function ($conTransaction) use ($data) { 392 | User::insert($seed(10), $conTransaction); 393 | }); 394 | ``` 395 | 396 | ### Update Model 397 | The `save` method may also be used to update models that already exist in the database. To update a model, you should retrieve it and set any attributes you wish to update. Then, you should call the model's `save` method. Again, the `updated_at` timestamp will automatically be updated, so there is no need to manually set its value: 398 | 399 | ```php 400 | use App\RedisModels\User; 401 | 402 | $user = User::find('email@gmail.com'); 403 | 404 | $user->name = 'Alvin1'; 405 | 406 | $user->save(); 407 | ``` 408 | 409 | Method update is not supported for making changes on a collection. Please use it with an existing instance instead: 410 | 411 | ```php 412 | $user = User::find('email@gmail.com')->update(['name' => 'Alvin1']); 413 | ``` 414 | 415 | ### Deleting Models 416 | To delete a model, you may call the delete method on the model instance: 417 | 418 | ```php 419 | use App\RedisModels\User; 420 | 421 | $user = User::find('email@gmail.com')->delete(); 422 | ``` 423 | 424 | ### Delete Statements 425 | The query builder's delete method may be used to delete records from the redis model. 426 | ```php 427 | User::where('email', '*@gmail.com')->destroy(); 428 | 429 | //or remove all data model 430 | User::destroy(); 431 | ``` 432 | 433 | ## Expire 434 | The special thing when working with `Redis` is that you can set the expiration time of a key, and with a model instance, there will be a method to `set` and `get` the expiration time for it. 435 | ### Set Expire Model 436 | 437 | ```php 438 | use App\RedisModels\User; 439 | 440 | $user = User::find('email@gmail.com')->setExpire(60); // The instance will have a lifespan of 60 seconds 441 | ``` 442 | 443 | ### Get Expire Model 444 | 445 | ```php 446 | use App\RedisModels\User; 447 | 448 | $user = User::find('email@gmail.com')->getExpire(); // The remaining time to live of the instance is 39 seconds. 449 | ``` 450 | 451 | ### Transaction 452 | 453 | The Redis model's transaction method provides a convenient wrapper around Redis' native `MULTI` and `EXEC` commands. The transaction method accepts a closure as its only argument. This closure will receive a Redis connection instance and may issue any commands it would like to this instance. All of the Redis commands issued within the closure will be executed in a single, atomic transaction: 454 | 455 | > When defining a Redis transaction, you may not retrieve any values from the Redis connection. Remember, your transaction is executed as a single, atomic operation and that operation is not executed until your entire closure has finished executing its commands. 456 | 457 | ```php 458 | use App\RedisModels\User; 459 | use Redis; 460 | 461 | $data [ 462 | 'users:id:1:email:email@example:name:alvin:role:admin' => [ 463 | 'id' => 1, 464 | 'email'=>'email@example' 465 | 'name' => 'alvin', 466 | 'role'=>'admin' 467 | ] 468 | 'users:id:2:email:email@example:name:alvin:role:admin' => [ 469 | 'id' => 2, 470 | 'email'=>'email@example' 471 | 'name' => 'alvin', 472 | 'role'=>'admin' 473 | ] 474 | 'users:id:3:email:email@example:name:alvin:role:admin' => [ 475 | 'id' => 3, 476 | 'email'=>'email@example' 477 | 'name' => 'alvin', 478 | 'role'=>'admin' 479 | ] 480 | ]; 481 | 482 | $user = User::transaction(function (Redis $conTransaction) use($data) { 483 | foreach($data as $key => $value) { 484 | $conTransaction->hMSet($key, $value); 485 | } 486 | // $conTransaction->discard(); 487 | }); 488 | 489 | ``` 490 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alvin0/redis-model", 3 | "description": "Introducing Redis Model - a Laravel package that connects to Redis and functions similarly to Eloquent Model, offering efficient data manipulation and retrieval capabilities.", 4 | "keywords": [ 5 | "laravel", 6 | "redis", 7 | "redis-model", 8 | "model", 9 | "orm" 10 | ], 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Chau Lam Dinh Ai", 15 | "email": "chaulamdinhai@gmail.com" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "illuminate/database": "^10|^11", 21 | "illuminate/console": "^10|^11", 22 | "predis/predis": "^2.2" 23 | }, 24 | "require-dev": { 25 | "pestphp/pest": "^2.2" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Alvin0\\RedisModel\\": "src/" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Alvin0\\RedisModel\\Tests\\": "tests/" 35 | } 36 | }, 37 | "extra": { 38 | "laravel": { 39 | "providers": [ 40 | "Alvin0\\RedisModel\\RedisModelServiceProvider" 41 | ] 42 | } 43 | }, 44 | "config": { 45 | "sort-packages": true 46 | }, 47 | "minimum-stability": "dev", 48 | "prefer-stable": true 49 | } 50 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Builder.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 42 | $this->repository = new RedisRepository($connection); 43 | } 44 | 45 | /** 46 | * Get the model instance being queried. 47 | * 48 | * @return \Alvin0\RedisModel\RedisRepository|static 49 | */ 50 | public function getRepository() 51 | { 52 | return $this->repository; 53 | } 54 | 55 | /** 56 | * Get the model instance being queried. 57 | * 58 | * @return \Alvin0\RedisModel\Model|static 59 | */ 60 | public function getModel() 61 | { 62 | return $this->model; 63 | } 64 | 65 | /** 66 | * Set a model instance for the model being queried. 67 | * 68 | * @param \Alvin0\RedisModel\Model $model 69 | * 70 | * @return $this 71 | */ 72 | public function setModel(Model $model) 73 | { 74 | $this->model = $model; 75 | $this->setHashPattern($model->getTable() . ":*"); 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * Set the hash pattern to search for in Redis 82 | * 83 | * @param string $hashPattern The hash pattern to search for 84 | * 85 | * @return $this 86 | */ 87 | public function setHashPattern(string $hashPattern) 88 | { 89 | $this->hashPattern = $hashPattern; 90 | 91 | return $this; 92 | } 93 | 94 | /** 95 | *Get the hash pattern that is being searched for in Redis 96 | 97 | *@return string The hash pattern being searched for 98 | */ 99 | public function getHashPattern() 100 | { 101 | return $this->hashPattern; 102 | } 103 | 104 | /** 105 | * Set the session condition for the search 106 | * 107 | * @param array $condition An array of conditions to search for 108 | * 109 | *@return void 110 | */ 111 | public function setConditionSession(array $condition) 112 | { 113 | $this->conditionSession = $condition; 114 | } 115 | 116 | /** 117 | * @param array $condition 118 | * 119 | * @return array 120 | */ 121 | public function getConditionSession() 122 | { 123 | return $this->conditionSession; 124 | } 125 | 126 | /** 127 | * Add a basic where clause to the query. 128 | * 129 | * @param string|array $column 130 | * @param string $value 131 | * @return $this 132 | */ 133 | public function where(string | array $column, string | int $value = null) 134 | { 135 | if ($value && gettype($column) == 'string') { 136 | $this->setConditionSession(array_merge($this->getConditionSession(), [$column => $value])); 137 | } else if (gettype($column) == 'array') { 138 | $this->setConditionSession(array_merge($this->getConditionSession(), $column)); 139 | } 140 | 141 | $this->setHashPattern($this->compileHashByFields($this->getConditionSession())); 142 | 143 | return $this; 144 | } 145 | /** 146 | * Add a where clause on the primary key to the query. 147 | * 148 | * @param string $id 149 | * 150 | * @return $this 151 | */ 152 | public function whereKey($id) 153 | { 154 | if ($id !== null && $this->model->getKeyType() === 'string') { 155 | $id = (string) $id; 156 | } 157 | 158 | return $this->where($this->model->getQualifiedKeyName(), $id); 159 | } 160 | 161 | /** 162 | * Add a basic where clause to the query, and return the first result. 163 | * 164 | * @param string|array $column 165 | * 166 | * @param string $value 167 | * 168 | * @return \Alvin0\RedisModel\Model|static|null 169 | */ 170 | public function firstWhere(string | array $column, string | int $value = null) 171 | { 172 | return $this->where(...func_get_args())->first(); 173 | } 174 | 175 | /** 176 | * Execute the query and get the first result. 177 | * 178 | * @param array|string $columns 179 | * 180 | * @return \Alvin0\RedisModel\Model|null 181 | */ 182 | public function first() 183 | { 184 | return $this->get()->first(); 185 | } 186 | 187 | /** 188 | * Execute the destroy data for pattern keys. 189 | * 190 | * @return bool 191 | */ 192 | public function destroy() 193 | { 194 | $keys = $this->getRepository()->getHashByPattern($this->getHashPattern()); 195 | 196 | return $this->getRepository()->destroyHash($keys); 197 | } 198 | 199 | /** 200 | * Execute the fetch properties for keys. 201 | * 202 | * @param array|string $columns 203 | * The columns to be fetched from Redis. Can be an array of string or a string. 204 | * 205 | * @return \Alvin0\RedisModel\Collection|static[] 206 | * Returns a collection of model instances or an empty collection if no result is found. 207 | */ 208 | public function get() 209 | { 210 | $models = []; 211 | 212 | foreach ($this->getRepository()->fetchHashDataByPattern($this->getHashPattern()) as $hash => $attributes) { 213 | $models[] = $this->model->newInstance($attributes, true, $hash, true)->syncOriginal(); 214 | } 215 | 216 | return $this->getModel()->newCollection($models); 217 | } 218 | 219 | /** 220 | * Counts the number of records that match the hash pattern of the model. 221 | * 222 | * @return int The number of records that match the hash pattern. 223 | */ 224 | public function count() 225 | { 226 | return $this->getRepository()->countByPattern($this->getHashPattern()); 227 | } 228 | 229 | /** 230 | * Create a new Collection instance with the given models. 231 | * 232 | * @param array $models 233 | * 234 | * @return \Alvin0\RedisModel\Collection 235 | */ 236 | public function newCollection(array $models = []) 237 | { 238 | return new Collection($models); 239 | } 240 | 241 | /** 242 | * Create a new instance of the model being queried. 243 | * 244 | * @param array $attributes 245 | * @return \Illuminate\Database\Eloquent\Model|static 246 | */ 247 | public function newModelInstance($attributes = []) 248 | { 249 | return $this->model->newInstance($attributes); 250 | } 251 | 252 | /** 253 | * Find a model by its primary key. 254 | * 255 | * @param mixed $id The primary key value of the model to find. 256 | * @return \Alvin0\RedisModel\Model|null The found model or null if not found. 257 | */ 258 | public function find($id) 259 | { 260 | // Retrieves the first model that matches the specified primary key. 261 | $model = $this->whereKey($id)->first(); 262 | 263 | // If the model is found, sync its original state and return a clone of it. 264 | if ($model instanceof Model) { 265 | $model->syncOriginal(); 266 | 267 | return clone ($model); 268 | } 269 | 270 | return null; 271 | } 272 | 273 | /** 274 | * Save a new model and return the instance. 275 | * 276 | * @param array $attributes - The attributes to create the model with. 277 | * 278 | * @return \Alvin0\RedisModel\Model|$this - The newly created model instance. 279 | */ 280 | public function create(array $attributes = []) 281 | { 282 | return tap($this->newModelInstance($attributes), function ($instance) { 283 | $instance->save(); 284 | }); 285 | } 286 | 287 | /** 288 | * Save a new model and return the instance. Allow mass-assignment. 289 | * 290 | * @param array $attributes The attributes to be saved. 291 | * 292 | * @return \Alvin0\RedisModel\Model|$this The created model instance. 293 | */ 294 | public function forceCreate(array $attributes) 295 | { 296 | return tap($this->newModelInstance($attributes), function ($instance) { 297 | $instance->setPrioritizeForceSave(); 298 | $instance->save(); 299 | }); 300 | } 301 | 302 | /** 303 | * Chunk the results of the query. 304 | * 305 | * @param int $count The number of models to retrieve per chunk 306 | * @param callable|null $callback Optional callback function to be executed on each chunk 307 | * @return \Alvin0\RedisModel\Collection A collection of the retrieved models, chunked 308 | */ 309 | public function chunk($count, callable $callback = null) 310 | { 311 | $resultData = $this->newCollection([]); 312 | 313 | // Scan for the models in the Redis database, and execute the provided callback function (if any) on each chunk 314 | $this->getRepository() 315 | ->scanByHash( 316 | $this->getHashPattern(), 317 | $count, 318 | function ($keys) use ($callback, $resultData) { 319 | $modelsChunk = []; 320 | 321 | // Fetch the attributes of the models in the current chunk, and create new model instances with 322 | // these attributes 323 | foreach ($this->getRepository()->fetchProperByListHash($keys) as $hash => $attributes) { 324 | $modelsChunk[] = $this->model->newInstance($attributes, true, $hash, true)->syncOriginal(); 325 | } 326 | 327 | $resultData->push($modelsChunk); 328 | 329 | // Execute the provided callback function (if any) on the current chunk of models 330 | $callback == null ?: $callback($modelsChunk); 331 | } 332 | ); 333 | 334 | return $resultData; 335 | } 336 | 337 | /** 338 | * Checks if a hash record exists in Redis based on the given model attributes. 339 | * 340 | * @param array $attributes The attributes to check in the hash record. 341 | * 342 | * @return bool Returns true if a hash record exists in Redis for the given attributes, false otherwise. 343 | */ 344 | public function isExists(array $attributes) 345 | { 346 | return empty($this->getRepository()->getHashByPattern($this->compileHashByFields($attributes))) ? false : true; 347 | } 348 | 349 | /** 350 | * Compile a hash key by fields of the given attributes array. 351 | * 352 | * @param array $attributes The array of attributes. 353 | * 354 | * @return string The compiled hash key. 355 | */ 356 | public function compileHashByFields(array $attributes) 357 | { 358 | $listKey = array_merge([$this->model->getKeyName()], $this->model->getSubKeys()); 359 | $stringKey = ''; 360 | 361 | foreach ($listKey as $key) { 362 | $attributeValue = $attributes[$key] ?? '*'; 363 | $stringKey .= $key . ':' . ($attributeValue === '*' ? '*' : $this->model->castAttributeBeforeSave($key, $attributeValue)) . ':'; 364 | } 365 | 366 | return $this->model->getTable() . ":" . rtrim($stringKey, ':'); 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /src/Collection.php: -------------------------------------------------------------------------------- 1 | |TModel|TFindDefault 17 | */ 18 | public function find($key, $default = null) 19 | { 20 | if ($key instanceof Model) { 21 | $key = $key->getKey(); 22 | } 23 | 24 | if ($key instanceof Arrayable) { 25 | $key = $key->toArray(); 26 | } 27 | 28 | if (is_array($key)) { 29 | if ($this->isEmpty()) { 30 | return new static; 31 | } 32 | 33 | return $this->whereIn($this->first()->getKeyName(), $key); 34 | } 35 | 36 | return Arr::first($this->items, fn($model) => $model->getKey() == $key, $default); 37 | } 38 | 39 | /** 40 | * Determine if a key exists in the collection. 41 | * 42 | * @param (callable(TModel, TKey): bool)|TModel|string|int $key 43 | * @param mixed $operator 44 | * @param mixed $value 45 | * @return bool 46 | */ 47 | public function contains($key, $operator = null, $value = null) 48 | { 49 | if (func_num_args() > 1 || $this->useAsCallable($key)) { 50 | return parent::contains(...func_get_args()); 51 | } 52 | 53 | if ($key instanceof Model) { 54 | return parent::contains(fn($model) => $model->is($key)); 55 | } 56 | 57 | return parent::contains(fn($model) => $model->getKey() == $key); 58 | } 59 | 60 | /** 61 | * Get the array of primary keys. 62 | * 63 | * @return array 64 | */ 65 | public function modelKeys() 66 | { 67 | return array_map(fn($model) => $model->getKey(), $this->items); 68 | } 69 | 70 | /** 71 | * Run a map over each of the items. 72 | * 73 | * @template TMapValue 74 | * 75 | * @param callable(TModel, TKey): TMapValue $callback 76 | * @return \Illuminate\Support\Collection|static 77 | */ 78 | public function map(callable $callback) 79 | { 80 | $result = parent::map($callback); 81 | 82 | return $result->contains(fn($item) => !$item instanceof Model) ? $result->toBase() : $result; 83 | } 84 | 85 | /** 86 | * Run an associative map over each of the items. 87 | * 88 | * The callback should return an associative array with a single key / value pair. 89 | * 90 | * @template TMapWithKeysKey of array-key 91 | * @template TMapWithKeysValue 92 | * 93 | * @param callable(TModel, TKey): array $callback 94 | * @return \Illuminate\Support\Collection|static 95 | */ 96 | public function mapWithKeys(callable $callback) 97 | { 98 | $result = parent::mapWithKeys($callback); 99 | 100 | return $result->contains(fn($item) => !$item instanceof Model) ? $result->toBase() : $result; 101 | } 102 | 103 | /** 104 | * Diff the collection with the given items. 105 | * 106 | * @param iterable $items 107 | * @return static 108 | */ 109 | public function diff($items) 110 | { 111 | $diff = new static; 112 | 113 | $dictionary = $this->getDictionary($items); 114 | 115 | foreach ($this->items as $item) { 116 | if (!isset($dictionary[$this->getDictionaryKey($item->getKey())])) { 117 | $diff->add($item); 118 | } 119 | } 120 | 121 | return $diff; 122 | } 123 | 124 | /** 125 | * Return only unique items from the collection. 126 | * 127 | * @param (callable(TModel, TKey): mixed)|string|null $key 128 | * @param bool $strict 129 | * @return static 130 | */ 131 | public function unique($key = null, $strict = false) 132 | { 133 | if (null === $key) { 134 | return parent::unique($key, $strict); 135 | } 136 | 137 | return new static(array_values($this->getDictionary())); 138 | } 139 | 140 | /** 141 | * Get a dictionary key attribute - casting it to a string if necessary. 142 | * 143 | * @param mixed $attribute 144 | * @return mixed 145 | * 146 | * @throws \Doctrine\Instantiator\Exception\InvalidArgumentException 147 | */ 148 | protected function getDictionaryKey($attribute) 149 | { 150 | if (is_object($attribute)) { 151 | if (method_exists($attribute, '__toString')) { 152 | return $attribute->__toString(); 153 | } 154 | 155 | if ($attribute instanceof UnitEnum) { 156 | return $attribute instanceof BackedEnum ? $attribute->value : $attribute->name; 157 | } 158 | 159 | throw new InvalidArgumentException('Model attribute value is an object but does not have a __toString method.'); 160 | } 161 | 162 | return $attribute; 163 | } 164 | 165 | /** 166 | * Returns only the models from the collection with the specified keys. 167 | * 168 | * @param array|null $keys 169 | * @return static 170 | */ 171 | public function only($keys) 172 | { 173 | if (null === $keys) { 174 | return new static($this->items); 175 | } 176 | 177 | $dictionary = Arr::only($this->getDictionary(), array_map($this->getDictionaryKey(...), (array) $keys)); 178 | 179 | return new static(array_values($dictionary)); 180 | } 181 | 182 | /** 183 | * Returns all models in the collection except the models with specified keys. 184 | * 185 | * @param array|null $keys 186 | * @return static 187 | */ 188 | public function except($keys) 189 | { 190 | $dictionary = Arr::except($this->getDictionary(), array_map($this->getDictionaryKey(...), (array) $keys)); 191 | 192 | return new static(array_values($dictionary)); 193 | } 194 | 195 | /** 196 | * Append an attribute across the entire collection. 197 | * 198 | * @param array|string $attributes 199 | * @return $this 200 | */ 201 | public function append($attributes) 202 | { 203 | return $this->each->append($attributes); 204 | } 205 | 206 | /** 207 | * Get a dictionary keyed by primary keys. 208 | * 209 | * @param iterable|null $items 210 | * @return array 211 | */ 212 | public function getDictionary($items = null) 213 | { 214 | $items = null === $items ? $this->items : $items; 215 | 216 | $dictionary = []; 217 | 218 | foreach ($items as $value) { 219 | $dictionary[$this->getDictionaryKey($value->getKey())] = $value; 220 | } 221 | 222 | return $dictionary; 223 | } 224 | 225 | /** 226 | * The following methods are intercepted to always return base collections. 227 | */ 228 | 229 | /** 230 | * Count the number of items in the collection by a field or using a callback. 231 | * 232 | * @param (callable(TModel, TKey): array-key)|string|null $countBy 233 | * @return \Illuminate\Support\Collection 234 | */ 235 | public function countBy($countBy = null) 236 | { 237 | return $this->toBase()->countBy($countBy); 238 | } 239 | 240 | /** 241 | * Collapse the collection of items into a single array. 242 | * 243 | * @return \Illuminate\Support\Collection 244 | */ 245 | public function collapse() 246 | { 247 | return $this->toBase()->collapse(); 248 | } 249 | 250 | /** 251 | * Get a flattened array of the items in the collection. 252 | * 253 | * @param int $depth 254 | * @return \Illuminate\Support\Collection 255 | */ 256 | public function flatten($depth = INF) 257 | { 258 | return $this->toBase()->flatten($depth); 259 | } 260 | 261 | /** 262 | * Flip the items in the collection. 263 | * 264 | * @return \Illuminate\Support\Collection 265 | */ 266 | public function flip() 267 | { 268 | return $this->toBase()->flip(); 269 | } 270 | 271 | /** 272 | * Get the keys of the collection items. 273 | * 274 | * @return \Illuminate\Support\Collection 275 | */ 276 | public function keys() 277 | { 278 | return $this->toBase()->keys(); 279 | } 280 | 281 | /** 282 | * Pad collection to the specified length with a value. 283 | * 284 | * @template TPadValue 285 | * 286 | * @param int $size 287 | * @param TPadValue $value 288 | * @return \Illuminate\Support\Collection 289 | */ 290 | public function pad($size, $value) 291 | { 292 | return $this->toBase()->pad($size, $value); 293 | } 294 | 295 | /** 296 | * Get an array with the values of a given key. 297 | * 298 | * @param string|array $value 299 | * @param string|null $key 300 | * @return \Illuminate\Support\Collection 301 | */ 302 | public function pluck($value, $key = null) 303 | { 304 | return $this->toBase()->pluck($value, $key); 305 | } 306 | 307 | /** 308 | * Zip the collection together with one or more arrays. 309 | * 310 | * @template TZipValue 311 | * 312 | * @param \Illuminate\Contracts\Support\Arrayable|iterable ...$items 313 | * @return \Illuminate\Support\Collection> 314 | */ 315 | public function zip($items) 316 | { 317 | return $this->toBase()->zip(...func_get_args()); 318 | } 319 | 320 | /** 321 | * Get the comparison function to detect duplicates. 322 | * 323 | * @param bool $strict 324 | * @return callable(TModel, TModel): bool 325 | */ 326 | protected function duplicateComparator($strict) 327 | { 328 | return fn($a, $b) => $a->is($b); 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/Commands/RedisModelMakerCommand.php: -------------------------------------------------------------------------------- 1 | rootNamespace(), '', $name); 59 | 60 | return $this->getGenerationPath() . str_replace('\\', '/', $name) . '.php'; 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/Commands/stubs/model.stub: -------------------------------------------------------------------------------- 1 | 34 | */ 35 | protected $fillable = []; 36 | } 37 | -------------------------------------------------------------------------------- /src/Exceptions/ErrorConnectToRedisException.php: -------------------------------------------------------------------------------- 1 | incrementing; 149 | } 150 | 151 | /** 152 | * Set whether IDs are incrementing. 153 | * 154 | * @param bool $value 155 | * @return $this 156 | */ 157 | public function setIncrementing($value) 158 | { 159 | $this->incrementing = $value; 160 | 161 | return $this; 162 | } 163 | 164 | /** 165 | * Get the value of the model's primary key. 166 | * 167 | * @return mixed 168 | */ 169 | public function getKey() 170 | { 171 | return $this->getAttribute($this->getKeyName()); 172 | } 173 | 174 | /** 175 | * Get the value of the model's primary key. 176 | * 177 | * @return $this 178 | */ 179 | public function setKey($value) 180 | { 181 | $this->setAttribute($this->getKeyName(), $value); 182 | 183 | return $this; 184 | } 185 | 186 | /** 187 | * @return array 188 | */ 189 | public function getSubKeys() 190 | { 191 | return $this->subKeys ?? []; 192 | } 193 | 194 | /** 195 | * set sub keys. 196 | * 197 | * @return $this 198 | */ 199 | public function setSubKeys(array $subKeys) 200 | { 201 | $this->subKeys = $subKeys; 202 | 203 | return $this; 204 | } 205 | 206 | /** 207 | * @return bool 208 | */ 209 | public function getPreventCreateForce() 210 | { 211 | return $this->preventCreateForce; 212 | } 213 | /** 214 | * @return $this 215 | */ 216 | public function initialInfoTable() 217 | { 218 | $this->setPrefixTable(); 219 | $this->setTable(); 220 | 221 | return $this; 222 | } 223 | 224 | /** 225 | * Get the table associated with the model. 226 | * 227 | * @return mixed 228 | */ 229 | public function setPrefixTable() 230 | { 231 | $this->prefixTable = $this->prefixTable ?? ''; 232 | } 233 | 234 | /** 235 | * Get the prefix table associated with the model. 236 | * 237 | * @return mixed 238 | */ 239 | public function getPrefixTable() 240 | { 241 | return $this->prefixTable; 242 | } 243 | 244 | /** 245 | * Get the table associated with the model. 246 | * 247 | * @return string 248 | */ 249 | public function getTable() 250 | { 251 | return $this->table; 252 | } 253 | 254 | /** 255 | * set the table associated with the model. 256 | * 257 | * @return $this 258 | */ 259 | public function setTable($table = null) 260 | { 261 | $defaultTableName = Str::snake(Str::pluralStudly(class_basename($this))); 262 | 263 | $this->table = $this->getPrefixTable() . ($table ?? $this->table ?? $defaultTableName); 264 | 265 | return $this; 266 | } 267 | 268 | /** 269 | * Get the primary key for the model. 270 | * 271 | * @return string 272 | */ 273 | public function getKeyName() 274 | { 275 | return $this->primaryKey; 276 | } 277 | 278 | /** 279 | * Set the primary key for the model. 280 | * 281 | * @param string $key 282 | * @return $this 283 | */ 284 | public function setKeyName($key) 285 | { 286 | $this->primaryKey = $key; 287 | 288 | return $this; 289 | } 290 | 291 | /** 292 | * Get the data type for the primary key. 293 | * 294 | * @return string 295 | */ 296 | public function getKeyType() 297 | { 298 | return $this->keyType; 299 | } 300 | 301 | /** 302 | * Set the data type for the primary key. 303 | * 304 | * @param string $type 305 | * @return $this 306 | */ 307 | public function setKeyType($type) 308 | { 309 | $this->keyType = $type; 310 | 311 | return $this; 312 | } 313 | 314 | /** 315 | * Get connection name 316 | * 317 | * @return string 318 | */ 319 | public function getConnectionName() 320 | { 321 | $defaultConnectionName = config('redis-model.redis_model_options.database_default', 'default'); 322 | 323 | return $this->connectionName = $this->connectionName ?? $defaultConnectionName; 324 | } 325 | 326 | /** 327 | * @return void 328 | */ 329 | public function setPrefixConnector(): void 330 | { 331 | $this->getConnection()->client()->setOption(self::REDIS_CLIENT_PREFIX, $this->getRedisPrefix()); 332 | } 333 | 334 | /** 335 | * @return string 336 | */ 337 | public function getRedisPrefix() 338 | { 339 | $defaultPrefix = config('database.redis.options.prefix', 'redis_model_'); 340 | 341 | return config('redis-model.redis_model_options.prefix', $defaultPrefix); 342 | } 343 | 344 | /** 345 | * Set connection 346 | * 347 | * @param string|null $nameConnect 348 | * 349 | * @return $this 350 | */ 351 | public function setConnection(string $connectionName) 352 | { 353 | try { 354 | $this->connection = RedisFacade::connection($connectionName); 355 | $this->setPrefixConnector(); 356 | } catch (Exception $e) { 357 | throw new ErrorConnectToRedisException($e->getMessage()); 358 | } 359 | 360 | return $this; 361 | } 362 | 363 | /** 364 | * Join a Redis transaction with the current connection. 365 | * 366 | * @param Redis $connection 367 | * 368 | * @return $this 369 | */ 370 | public function joinTransaction(Redis $clientTransaction) 371 | { 372 | tap($this->connection, function ($connect) use ($clientTransaction) { 373 | $reflectionClass = new ReflectionClass(\get_class($connect)); 374 | $client = $reflectionClass->getProperty('client'); 375 | $client->setAccessible(true); 376 | $client->setValue($connect, $clientTransaction); 377 | $this->connection = $connect; 378 | }); 379 | 380 | return $this; 381 | } 382 | 383 | /** 384 | * Get connection 385 | * 386 | * @return mixed 387 | */ 388 | public function getConnection() 389 | { 390 | return $this->connection; 391 | } 392 | 393 | /** 394 | * Fill the model with an array of attributes. 395 | * 396 | * @param array $attributes 397 | * @return self 398 | * 399 | * @throws \Alvin0\RedisModel\MassAssignmentException 400 | */ 401 | public function fill(array $attributes) 402 | { 403 | $totallyGuarded = $this->totallyGuarded(); 404 | 405 | $fillable = $this->fillableFromArray($attributes); 406 | 407 | foreach ($fillable as $key => $value) { 408 | // The developers may choose to place some attributes in the "fillable" array 409 | // which means only those attributes may be set through mass assignment to 410 | // the model, and all others will just get ignored for security reasons. 411 | if ($this->isFillable($key)) { 412 | $this->setAttribute($key, $value); 413 | } elseif ($totallyGuarded) { 414 | 415 | throw new MassAssignmentException(sprintf( 416 | 'Add [%s] to fillable property to allow mass assignment on [%s].', 417 | $key, get_class($this) 418 | )); 419 | } 420 | } 421 | 422 | if (count($attributes) !== count($fillable)) { 423 | $keys = array_diff(array_keys($attributes), array_keys($fillable)); 424 | if ($this->flexibleFill) { 425 | foreach ($keys as $key) { 426 | $this->setAttribute($key, $attributes[$key]); 427 | } 428 | } else { 429 | throw new MassAssignmentException(sprintf( 430 | 'Add fillable property [%s] to allow mass assignment on [%s].', 431 | implode(', ', $keys), 432 | get_class($this) 433 | )); 434 | } 435 | } 436 | 437 | return $this; 438 | } 439 | 440 | /** 441 | * Save the model to the database. 442 | * 443 | * @param array $options 444 | * @return bool 445 | */ 446 | public function save() 447 | { 448 | $this->mergeAttributesFromCachedCasts(); 449 | // If the model already exists in the database we can just update our record 450 | // that is already in this database using the current IDs in this "where" 451 | // clause to only update this model. Otherwise, we'll just insert them. 452 | 453 | $query = $this->newQuery(); 454 | 455 | if ($this->exists) { 456 | $saved = $this->isDirty() ? 457 | $this->performUpdate($query) : true; 458 | } 459 | 460 | // If the model is brand new, we'll insert it into our database and set the 461 | // ID attribute on the model to the value of the newly inserted row's ID 462 | // which is typically an auto-increment value managed by the database. 463 | else { 464 | $saved = $this->performInsert($query); 465 | } 466 | 467 | // If the model is successfully saved, we need to do a few more things once 468 | // that is done. We will call the "saved" method here to run any actions 469 | // we need to happen after a model gets successfully saved right here. 470 | if ($saved) { 471 | $this->finishSave(); 472 | } 473 | 474 | return $saved; 475 | } 476 | 477 | /** 478 | * Perform any actions that are necessary after the model is saved. 479 | * 480 | * @param array $options 481 | * @return self 482 | */ 483 | protected function finishSave() 484 | { 485 | $this->syncOriginal(); 486 | } 487 | 488 | /** 489 | * Perform a model update operation. 490 | * 491 | * @return bool 492 | */ 493 | protected function performUpdate(Builder $build) 494 | { 495 | // First we need to create a fresh query instance and touch the creation and 496 | // update timestamp on the model which are maintained by us for developer 497 | // convenience. Then we will just continue saving the model instances. 498 | if ($this->usesTimestamps()) { 499 | $this->updateTimestamps(); 500 | } 501 | 502 | // Once we have run the update operation, we will fire the "updated" event for 503 | // this model instance. This will allow developers to hook into these after 504 | // models are updated, giving them a chance to do any special processing. 505 | $dirty = $this->getDirty(); 506 | 507 | if (count($dirty) > 0) { 508 | $attributes = $this->getAttributesForInsert(); 509 | if ($this->isValidationKeyAndSubKeys($attributes)) { 510 | $attributes = collect($attributes)->map(function ($item, $key) { 511 | return (string) $this->castAttributeBeforeSave($key, $item); 512 | })->toArray(); 513 | 514 | $keyOrigin = $build->compileHashByFields($this->parseAttributeKeyBooleanToInt(($this->getOriginal()))); 515 | $keyNew = $build->compileHashByFields($this->parseAttributeKeyBooleanToInt($attributes)); 516 | $build->getRepository()->updateRedisHashes($keyOrigin, $attributes, $keyNew); 517 | 518 | $this->exists = true; 519 | $this->redisKey = $keyNew; 520 | 521 | return true; 522 | } else { 523 | throw new RedisModelException("Primary key and sub key values are required"); 524 | } 525 | } 526 | 527 | return false; 528 | } 529 | 530 | /** 531 | * Casts and prepares an attribute value before saving it to the database. 532 | * 533 | * @param string $key 534 | * @param mixed $value 535 | * 536 | * @return mixed The 537 | */ 538 | public function castAttributeBeforeSave($key, $value) { 539 | // Cast the attribute if necessary 540 | $value = $this->hasCast($key) ? $this->castAttribute($key, $value) : $value; 541 | 542 | // If the attribute is a Carbon instance, format it using the model's date format 543 | if ($value instanceof Carbon) { 544 | $value = $value->format($this->getDateFormat()); 545 | } 546 | 547 | // If the attribute is an array, encode it to JSON 548 | if (is_array($value)) { 549 | $value = json_encode($value); 550 | } 551 | 552 | // If the attribute is a boolean, cast it to an integer 553 | if (is_bool($value)) { 554 | $value = (int) filter_var($value, FILTER_VALIDATE_BOOLEAN); 555 | } 556 | 557 | // If the attribute is enum castable, extract its value 558 | if ($this->isEnumCastable($key)) { 559 | $value = $value->value; 560 | } 561 | 562 | // Return the transformed and casted attribute value 563 | return $value; 564 | } 565 | 566 | /** 567 | * Perform a model insert operation. 568 | * 569 | * @return bool 570 | */ 571 | protected function performInsert(Builder $build) 572 | { 573 | // First we'll need to create a fresh query instance and touch the creation and 574 | // update timestamps on this model, which are maintained by us for developer 575 | // convenience. After, we will just continue saving these model instances. 576 | if ($this->usesTimestamps()) { 577 | $this->updateTimestamps(); 578 | } 579 | 580 | if ($this->getIncrementing() && $this->getKeyType() && $this->getKey() == null) { 581 | $this->setKey(Str::uuid()); 582 | } 583 | 584 | if (!$this->isPrioritizeForceSave()) { 585 | if ($this->getPreventCreateForce() && $this->isKeyExist()) { 586 | throw new KeyExistException( 587 | "Key " . $this->getKeyName() . " " . $this->{$this->getKeyName()} . " already exists." 588 | ); 589 | } 590 | } 591 | 592 | // If the model has an incrementing key, we can use the "insertGetId" method on 593 | // the query builder, which will give us back the final inserted ID for this 594 | // table from the database. Not all tables have to be incrementing though. 595 | $attributes = $this->getAttributesForInsert(); 596 | 597 | if (empty($attributes)) { 598 | return true; 599 | } 600 | 601 | if ($this->isValidationKeyAndSubKeys($attributes)) { 602 | $attributes = collect($attributes)->map(function ($item, $key) { 603 | return (string) $this->castAttributeBeforeSave($key, $item); 604 | })->toArray(); 605 | 606 | $keyInsert = $build->compileHashByFields($this->parseAttributeKeyBooleanToInt($attributes)); 607 | $build->getRepository()->insertRedisHashes($keyInsert, $attributes); 608 | } else { 609 | throw new RedisModelException("Primary key and sub key values are required"); 610 | } 611 | 612 | // We will go ahead and set the exists property to true, so that it is set when 613 | // the created event is fired, just in case the developer tries to update it 614 | // during the event. This will allow them to do so and run an update here. 615 | $this->exists = true; 616 | $this->redisKey = $keyInsert; 617 | 618 | return true; 619 | } 620 | 621 | /** 622 | * Deletes the current model from Redis if the primary key and sub-keys are valid. 623 | * If the delete operation is successful, it returns true. Otherwise, it returns false. 624 | * 625 | * @return bool Returns true if the deletion is successful; otherwise, false. 626 | */ 627 | public function performDeleteOnModel() 628 | { 629 | if ($this->isValidationKeyAndSubKeys($this->getOriginal())) { 630 | $build = $this->query(); 631 | $keyRemove = $build->compileHashByFields($this->getOriginal()); 632 | $build->getRepository()->destroyHash($keyRemove); 633 | } else { 634 | return false; 635 | } 636 | 637 | $this->exists = false; 638 | 639 | return true; 640 | } 641 | 642 | /** 643 | * Inserts multiple data into Redis hashes. 644 | * 645 | * @param array $dataInsert An array of data to insert into Redis hashes. 646 | * @param Redis $hasTransaction a redis client. 647 | * 648 | * @return mixed Returns the result of inserting multiple Redis hashes, or false if the data is invalid. 649 | */ 650 | public static function insert(array $dataInsert, Redis $hasTransaction = null) 651 | { 652 | $inserts = []; 653 | $build = static::query(); 654 | $model = $build->getModel(); 655 | 656 | if ($hasTransaction) { 657 | $model->joinTransaction($hasTransaction); 658 | } 659 | 660 | foreach ($dataInsert as $attributes) { 661 | if ($model->getIncrementing() && $model->getKeyType() && !isset($attributes[$model->getKeyName()])) { 662 | $attributes[$model->getKeyName()] = Str::uuid(); 663 | } 664 | 665 | if ($model->isValidationKeyAndSubKeys($attributes)) { 666 | $key = $build->compileHashByFields($attributes); 667 | 668 | // If the model uses timestamps, update them in the attributes 669 | if ($model->usesTimestamps()) { 670 | $model->updateTimestamps(); 671 | $attributes = array_merge($attributes, $model->getAttributes()); 672 | } 673 | 674 | $inserts[$key] = collect($attributes)->map(function ($item, $key) use ($model) { 675 | return (string) $model->castAttributeBeforeSave($key, $item); 676 | })->toArray(); 677 | } else { 678 | return false; 679 | } 680 | } 681 | 682 | return $build->getRepository()->insertMultipleRedisHashes($inserts); 683 | } 684 | 685 | /** 686 | * Update the model in the database. 687 | * 688 | * @param array $attributes 689 | * 690 | * @return bool 691 | */ 692 | public function update(array $attributes = []) 693 | { 694 | if (!$this->exists) { 695 | return false; 696 | } 697 | 698 | return $this->fill($attributes)->save(); 699 | } 700 | 701 | /** 702 | * Set the number of seconds expire key model 703 | * 704 | * @param int|Carbon $seconds 705 | * 706 | * @return bool 707 | */ 708 | public function setExpire(int | Carbon $seconds) 709 | { 710 | if (!$this->exists) { 711 | return false; 712 | } 713 | 714 | if ($seconds instanceof Carbon) { 715 | $seconds = now()->diffInSeconds($seconds); 716 | } 717 | 718 | if ($this->isValidationKeyAndSubKeys($this->getOriginal())) { 719 | $build = $this->query(); 720 | $key = $build->compileHashByFields($this->getOriginal()); 721 | 722 | return $build->getRepository()->setExpireByHash($key, $seconds); 723 | } else { 724 | return false; 725 | } 726 | } 727 | 728 | /** 729 | * Get the number of seconds expire key model 730 | * 731 | * @return bool 732 | */ 733 | public function getExpire() 734 | { 735 | if (!$this->exists) { 736 | return false; 737 | } 738 | 739 | if ($this->isValidationKeyAndSubKeys($this->getOriginal())) { 740 | $build = $this->query(); 741 | $key = $build->compileHashByFields($this->getOriginal()); 742 | 743 | return $build->getRepository()->getExpireByHash($key); 744 | } else { 745 | return false; 746 | } 747 | } 748 | 749 | /** 750 | * Delete the model from the database. 751 | * 752 | * @return bool|null 753 | * 754 | * @throws \LogicException 755 | */ 756 | public function delete() 757 | { 758 | $this->mergeAttributesFromCachedCasts(); 759 | 760 | if (null === $this->getKeyName()) { 761 | throw new LogicException('No primary key defined on model.'); 762 | } 763 | 764 | // If the model doesn't exist, there is nothing to delete so we'll just return 765 | // immediately and not do anything else. Otherwise, we will continue with a 766 | // deletion process on the model, firing the proper events, and so forth. 767 | if (!$this->exists) { 768 | return; 769 | } 770 | 771 | $this->performDeleteOnModel(); 772 | 773 | return true; 774 | } 775 | 776 | /** 777 | * Get all of the models from the database. 778 | * 779 | * @return \Alvin0\RedisModel\Collection 780 | */ 781 | public static function all() 782 | { 783 | return static::query()->get(); 784 | } 785 | 786 | /** 787 | * Create a new Eloquent Collection instance. 788 | * 789 | * @param array $models 790 | * @return \Alvin0\RedisModel\Collection 791 | */ 792 | public function newCollection(array $models = []) 793 | { 794 | return new Collection($models); 795 | } 796 | 797 | /** 798 | * Begin querying the model. 799 | * 800 | * @return \Alvin0\RedisModel\Builder 801 | */ 802 | public static function query() 803 | { 804 | return (new static )->newQuery(); 805 | } 806 | 807 | /** 808 | * Run a transaction with the given callback. 809 | * 810 | * @param callable $callback 811 | * 812 | * @return mixed The result of the callback 813 | */ 814 | public static function transaction(callable $callback) 815 | { 816 | $build = static::query(); 817 | 818 | return $build->getRepository()->transaction($callback); 819 | } 820 | 821 | /** 822 | * Get a new query builder for the model's table. 823 | * 824 | * @return \Alvin0\RedisModel\Builder 825 | */ 826 | public function newQuery() 827 | { 828 | return $this->newBuilder($this->getConnection())->setModel($this); 829 | } 830 | 831 | /** 832 | * Create a new Eloquent query builder for the model. 833 | * 834 | * @param \Alvin0\RedisModel $query 835 | * @return Alvin0\RedisModel\Builder 836 | */ 837 | public function newBuilder($connection) 838 | { 839 | return new Builder($connection); 840 | } 841 | 842 | /** 843 | * Create a new instance of the given model. 844 | * 845 | * @param array $attributes 846 | * @param bool $exists 847 | * @param string $redisKey 848 | * @param bool $isCastAttribute 849 | * @return static 850 | */ 851 | public function newInstance($attributes = [], $exists = false, string $redisKey = null, $isCastAttribute = false) 852 | { 853 | // This method just provides a convenient way for us to generate fresh model 854 | // instances of this current model. It is particularly useful during the 855 | // hydration of new objects via the Eloquent query builder instances. 856 | $model = new static; 857 | 858 | $model->exists = $exists; 859 | 860 | $model->redisKey = $redisKey; 861 | 862 | $this->setDateFormat("Y-m-d\\TH:i:sP"); 863 | 864 | $model->setTable($this->getTable()); 865 | 866 | $model->mergeCasts($this->casts); 867 | 868 | if ($isCastAttribute) { 869 | $castAttributes = collect($attributes)->mapWithKeys(function ($value, $key) use ($model) { 870 | return [$key => $model->transformModelValue($key, $value)]; 871 | })->all(); 872 | 873 | $model->fill((array) $castAttributes); 874 | } else { 875 | $model->fill((array) $attributes); 876 | } 877 | 878 | return $model; 879 | } 880 | 881 | /** 882 | * Create a new Eloquent model instance. 883 | * 884 | * @param array $attributes 885 | * @return void 886 | */ 887 | public function __construct(array $attributes = []) 888 | { 889 | $this->setDateFormat("Y-m-d\\TH:i:sP"); 890 | $this->initialInfoTable(); 891 | $this->setConnection($this->getConnectionName()); 892 | $this->syncOriginal(); 893 | 894 | $this->fill($attributes); 895 | } 896 | 897 | /** 898 | * Dynamically retrieve attributes on the model. 899 | * 900 | * @param string $key 901 | * @return mixed 902 | */ 903 | public function __get($key) 904 | { 905 | return $this->getAttribute($key); 906 | } 907 | 908 | /** 909 | * Dynamically set attributes on the model. 910 | * 911 | * @param string $key 912 | * @param mixed $value 913 | * @return void 914 | */ 915 | public function __set($key, $value) 916 | { 917 | $this->setAttribute($key, $value); 918 | } 919 | 920 | /** 921 | * Determine if an attribute or relation exists on the model. 922 | * 923 | * @param string $key 924 | * @return bool 925 | */ 926 | public function __isset($key) 927 | { 928 | return $this->offsetExists($key); 929 | } 930 | 931 | /** 932 | * Unset an attribute on the model. 933 | * 934 | * @param string $key 935 | * @return void 936 | */ 937 | public function __unset($key) 938 | { 939 | $this->offsetUnset($key); 940 | } 941 | 942 | /** 943 | * Handle dynamic method calls into the model. 944 | * 945 | * @param string $method 946 | * @param array $parameters 947 | * @return mixed 948 | */ 949 | public function __call($method, $parameters) 950 | { 951 | return $this->forwardCallTo($this->query(), $method, $parameters); 952 | } 953 | 954 | /** 955 | * Handle dynamic static method calls into the model. 956 | * 957 | * @param string $method 958 | * @param array $parameters 959 | * @return mixed 960 | */ 961 | public static function __callStatic($method, $parameters) 962 | { 963 | return (new static )->$method(...$parameters); 964 | } 965 | 966 | /** 967 | * Convert the model to its string representation. 968 | * 969 | * @return string 970 | */ 971 | public function __toString() 972 | { 973 | return $this->escapeWhenCastingToString 974 | ? e($this->toJson()) 975 | : $this->toJson(); 976 | } 977 | 978 | /** 979 | * Indicate that the object's string representation should be escaped when __toString is invoked. 980 | * 981 | * @param bool $escape 982 | * @return $this 983 | */ 984 | public function escapeWhenCastingToString($escape = true) 985 | { 986 | $this->escapeWhenCastingToString = $escape; 987 | 988 | return $this; 989 | } 990 | /** 991 | * Prepare the object for serialization. 992 | * 993 | * @return array 994 | */ 995 | public function __sleep() 996 | { 997 | $this->mergeAttributesFromCachedCasts(); 998 | 999 | $this->classCastCache = []; 1000 | $this->attributeCastCache = []; 1001 | 1002 | return array_keys(get_object_vars($this)); 1003 | } 1004 | 1005 | /** 1006 | * Convert the model instance to an array. 1007 | * 1008 | * @return array 1009 | */ 1010 | public function toArray(): array 1011 | { 1012 | return $this->attributesToArray(); 1013 | } 1014 | 1015 | /** 1016 | * Convert the model instance to JSON. 1017 | * 1018 | * @param int $options 1019 | * @return string 1020 | */ 1021 | public function toJson($options = 0): string 1022 | { 1023 | $json = json_encode($this->jsonSerialize(), $options); 1024 | 1025 | return $json; 1026 | } 1027 | 1028 | /** 1029 | * Convert the object into something JSON serializable. 1030 | * 1031 | * @return array 1032 | */ 1033 | public function jsonSerialize() 1034 | { 1035 | return $this->toArray(); 1036 | } 1037 | 1038 | /** 1039 | * When a model is being unserialized, check if it needs to be booted. 1040 | * 1041 | * @return void 1042 | */ 1043 | public function __wakeup() 1044 | { 1045 | } 1046 | 1047 | /** 1048 | * Determine if the given attribute exists. 1049 | * 1050 | * @param mixed $offset 1051 | * @return bool 1052 | */ 1053 | public function offsetExists($offset): bool 1054 | { 1055 | try { 1056 | return null === $this->getAttribute($offset); 1057 | } catch (MissingAttributeException) { 1058 | return false; 1059 | } 1060 | } 1061 | 1062 | /** 1063 | * Get the value for a given offset. 1064 | * 1065 | * @param mixed $offset 1066 | * @return mixed 1067 | */ 1068 | public function offsetGet($offset): mixed 1069 | { 1070 | return $this->getAttribute($offset); 1071 | } 1072 | 1073 | /** 1074 | * Set the value for a given offset. 1075 | * 1076 | * @param mixed $offset 1077 | * @param mixed $value 1078 | * @return void 1079 | */ 1080 | public function offsetSet($offset, $value): void 1081 | { 1082 | $this->setAttribute($offset, $value); 1083 | } 1084 | 1085 | /** 1086 | * Unset the value for a given offset. 1087 | * 1088 | * @param mixed $offset 1089 | * @return void 1090 | */ 1091 | public function offsetUnset($offset): void 1092 | { 1093 | unset($this->attributes[$offset]); 1094 | } 1095 | 1096 | /** 1097 | * Determine if the given key is a relationship method on the model. 1098 | * 1099 | * @param string $key 1100 | * @return bool 1101 | */ 1102 | public function isRelation($key) 1103 | { 1104 | return false; 1105 | } 1106 | 1107 | /** 1108 | * Determine if the given relation is loaded. 1109 | * 1110 | * @param string $key 1111 | * @return bool 1112 | */ 1113 | public function relationLoaded($key) 1114 | { 1115 | return false; 1116 | } 1117 | 1118 | /** 1119 | * Determine if two models are not the same. 1120 | * 1121 | * @param \Alvin0\RedisModel\Model|null $model 1122 | * @return bool 1123 | */ 1124 | public function isNot($model) 1125 | { 1126 | return !$this->is($model); 1127 | } 1128 | 1129 | /** 1130 | * Get the table qualified key name. 1131 | * 1132 | * @return string 1133 | */ 1134 | public function getQualifiedKeyName() 1135 | { 1136 | return $this->qualifyColumn($this->getKeyName()); 1137 | } 1138 | 1139 | /** 1140 | * Qualify the given column name by the model's table. 1141 | * 1142 | * @param string $column 1143 | * @return string 1144 | */ 1145 | public function qualifyColumn($column) 1146 | { 1147 | if (str_contains($column, '.')) { 1148 | return $column; 1149 | } 1150 | 1151 | return $column; 1152 | } 1153 | 1154 | /** 1155 | * Qualify the given columns with the model's table. 1156 | * 1157 | * @param array $columns 1158 | * @return array 1159 | */ 1160 | public function qualifyColumns($columns) 1161 | { 1162 | return collect($columns)->map(function ($column) { 1163 | return $this->qualifyColumn($column); 1164 | })->all(); 1165 | } 1166 | 1167 | /** 1168 | * Set flag force insert of model 1169 | * 1170 | * @return self 1171 | */ 1172 | public function setPrioritizeForceSave() 1173 | { 1174 | $this->prioritizeForceSave = true; 1175 | 1176 | return $this; 1177 | } 1178 | 1179 | /** 1180 | * Get flag force insert of model 1181 | * 1182 | * @return bool 1183 | */ 1184 | protected function isPrioritizeForceSave() 1185 | { 1186 | if (isset($this->attributes['prioritizeForceSave'])) { 1187 | unset($this->attributes['prioritizeForceSave']); 1188 | 1189 | return true; 1190 | } 1191 | 1192 | return false; 1193 | } 1194 | 1195 | /** 1196 | * @return boolean 1197 | */ 1198 | protected function isKeyExist() 1199 | { 1200 | return $this->query()->isExists($this->getAttributesForInsert()); 1201 | } 1202 | 1203 | /** 1204 | * @return bool 1205 | */ 1206 | protected function isValidationKeyAndSubKeys($attributes) 1207 | { 1208 | $listKey = array_merge([$this->getKeyName()], $this->getSubKeys()); 1209 | 1210 | foreach ($listKey as $key) { 1211 | if (!isset($attributes[$key]) || 1212 | (isset($this->getCasts()[$key]) && $this->getCasts()[$key] != 'boolean' && empty($attributes[$key]))) { 1213 | return false; 1214 | } 1215 | } 1216 | 1217 | return true; 1218 | } 1219 | 1220 | /** 1221 | * @param array $value 1222 | * 1223 | * @return int 1224 | */ 1225 | private function parseAttributeKeyBooleanToInt($attributes) 1226 | { 1227 | foreach ($attributes as $key => $value) { 1228 | if (isset($this->getCasts()[$key]) && $this->getCasts()[$key] == 'boolean') { 1229 | $attributes[$key] = (int) filter_var($value, FILTER_VALIDATE_BOOLEAN); 1230 | } 1231 | } 1232 | 1233 | return $attributes; 1234 | } 1235 | } 1236 | -------------------------------------------------------------------------------- /src/RedisModelServiceProvider.php: -------------------------------------------------------------------------------- 1 | configurationIsCached()) { 16 | $this->mergeConfigFrom(__DIR__ . '/config/redis-model.php', 'redis-model'); 17 | } 18 | 19 | config([ 20 | 'database.redis' => array_merge(config('redis-model.database'), config('database.redis', [])), 21 | ]); 22 | } 23 | 24 | /** 25 | * Bootstrap any application services. 26 | * 27 | * @return void 28 | */ 29 | public function boot() 30 | { 31 | $this->commands([ 32 | 'Alvin0\RedisModel\Commands\RedisModelMakerCommand', 33 | ]); 34 | 35 | if (app()->runningInConsole()) { 36 | $this->publishes([ 37 | __DIR__ . '/config/redis-model.php' => config_path('redis-model.php'), 38 | ], 'redis-model'); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/RedisRepository.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 18 | } 19 | 20 | /** 21 | * Get connection 22 | * 23 | * @return PhpRedisConnection 24 | */ 25 | public function getConnection(): PhpRedisConnection 26 | { 27 | return $this->connection; 28 | } 29 | 30 | /** 31 | *Gets the Redis prefix for keys used by the Redis model. 32 | *The prefix is determined by looking at the redis_model_options.prefix configuration value first, 33 | *Then falling back to the database.redis.options.prefix configuration value. 34 | * 35 | *@return string The Redis key prefix for this model. 36 | */ 37 | public function getRedisPrefix() 38 | { 39 | $defaultPrefix = config('database.redis.options.prefix', 'redis_model_'); 40 | 41 | return config('redis-model.redis_model_options.prefix', $defaultPrefix); 42 | } 43 | 44 | /** 45 | * Retrieves all Redis keys matching a given pattern, after removing the database prefix. 46 | * 47 | * @param string|null $hash The pattern to match the Redis keys against, or null to match all keys. 48 | * 49 | * @return array An array of Redis keys matching the pattern, with the database prefix removed. 50 | */ 51 | public function getHashByPattern(string | null $hash) 52 | { 53 | return self::removeSlugDatabaseFromRedisKeys($this->getConnection()->keys($hash), $this->getRedisPrefix()); 54 | } 55 | 56 | /** 57 | * Counts the number of Redis hash keys that match the given pattern. 58 | * 59 | * @param string $pattern The Redis hash key pattern to match. 60 | * 61 | * @return int The number of Redis hash keys that match the given pattern 62 | * 63 | */ 64 | public function countByPattern(string $pattern): int 65 | { 66 | return count($this->getHashByPattern($pattern)); 67 | } 68 | 69 | /** 70 | * Fetches all fields and their values for a given Redis hash key 71 | * 72 | * @param string $hash The Redis hash key 73 | * 74 | * @return array An array containing all fields and their corresponding values for the given Redis hash key 75 | */ 76 | public function fetchProperByHash($hash) 77 | { 78 | return $this->getConnection()->hGetAll($hash); 79 | } 80 | 81 | /** 82 | * Retrieves hash data from multiple Redis keys using a pipeline approach and returns it in an associative array 83 | * 84 | * @param array $keys An array of Redis keys 85 | * 86 | * @return array An associative array containing hash data retrieved from multiple Redis keys 87 | */ 88 | public function fetchProperByListHash(array $keys) 89 | { 90 | $result = []; 91 | 92 | $fetch = $this->getConnection()->pipeline(function ($pipe) use ($keys) { 93 | foreach (self::removeSlugDatabaseFromRedisKeys($keys, $this->getRedisPrefix()) as $key) { 94 | $pipe->hGetAll($key); 95 | } 96 | }); 97 | 98 | foreach ($keys as $cursor => $key) { 99 | $result[$key] = $fetch[$cursor]; 100 | } 101 | 102 | return $result; 103 | } 104 | 105 | /** 106 | * Retrieves all data from Redis hashes that match the given pattern. 107 | * 108 | * @param string $pattern The pattern to match against Redis hashes. 109 | * 110 | * @return array The data from Redis hashes that match the given pattern. 111 | */ 112 | public function fetchHashDataByPattern(string $pattern) 113 | { 114 | $keys = $this->getHashByPattern($pattern); 115 | 116 | foreach ($keys as $key) { 117 | $data[$key] = $this->fetchProperByHash($key); 118 | } 119 | 120 | return $data ?? []; 121 | } 122 | 123 | /** 124 | * Inserts a Redis hash with the given key and data. 125 | * 126 | * @param string $key The key of the Redis hash. 127 | * @param array $data An associative array of fields and their values. 128 | * @return bool Returns `true` on success, `false` on failure. 129 | */ 130 | public function insertRedisHashes(string $key, array $data) 131 | { 132 | return $this->getConnection()->hMSet($key, $data); 133 | } 134 | 135 | /** 136 | * Update the values of a Redis hash with the given data, and optionally rename the hash. 137 | * 138 | * @param string $oldHash The name of the Redis hash to update. 139 | * @param array $data An array of key-value pairs to update the Redis hash with. 140 | * @param string $newHash The new name for the Redis hash. If set, the old hash will be renamed to this new name. 141 | * @return bool True on success, false on failure. 142 | */ 143 | public function renameRedisHash(string $oldHash, string $newHash) 144 | { 145 | return $this->getConnection()->rename($oldHash, $newHash); 146 | } 147 | 148 | /** 149 | * Update the data of a Redis hash. 150 | * 151 | * @param string $oldHash The old hash key to update. 152 | * @param array $data An associative array containing the field-value pairs to update. 153 | * @param string|null $newHash The new hash key to rename the old hash key to, if provided. 154 | * 155 | * @return bool Returns true if the update was successful, false otherwise. 156 | */ 157 | public function updateRedisHashes(string $oldHash, array $data, string $newHash = null) 158 | { 159 | return $this->transaction(function ($conTransaction) use ($oldHash, $newHash, $data) { 160 | try { 161 | if ($newHash != null && $oldHash != $newHash) { 162 | $conTransaction->rename($oldHash, $newHash); 163 | } 164 | 165 | $conTransaction->hMSet($newHash, $data); 166 | 167 | return true; 168 | } catch (Exception $e) { 169 | $transaction->discard(); 170 | 171 | return false; 172 | } 173 | }); 174 | } 175 | 176 | /** 177 | * Insert multiple Redis hashes with key-value pairs in bulk 178 | * 179 | * @param array $hashes Array of Redis hashes with key-value pairs to insert in bulk. 180 | * Format: [Key => [Field => Value, ...], ...] 181 | * 182 | * @return bool Returns true if all hashes were inserted successfully, false otherwise. 183 | */ 184 | public function insertMultipleRedisHashes(array $hashes) 185 | { 186 | foreach ($hashes as $key => $data) { 187 | $this->getConnection()->hMSet($key, $data); 188 | } 189 | 190 | return true; 191 | } 192 | 193 | /** 194 | * Destroy a hash from Redis by given key or keys. 195 | * 196 | * @param string|array $keys The key or keys to delete from Redis. 197 | * 198 | * @return bool True if the hash was deleted successfully, false otherwise. 199 | */ 200 | public function destroyHash(string | array $keys) 201 | { 202 | if (is_string($keys)) { 203 | $deleted = (bool) $this->getConnection()->del($keys); 204 | } elseif (is_array($keys)) { 205 | $deleted = (bool) $this->getConnection()->del($keys); 206 | } else { 207 | $deleted = false; 208 | } 209 | 210 | return $deleted; 211 | } 212 | 213 | /** 214 | * Set a time-to-live on a hash key. 215 | * 216 | * @param string $keyHash The key to set the time-to-live. 217 | * @param int $seconds The number of seconds until the key should expire. 218 | * 219 | * @return bool True if the timeout was set successfully, false otherwise. 220 | */ 221 | public function setExpireByHash(string $keyHash, int $seconds) 222 | { 223 | return (bool) $this->getConnection()->expire($keyHash, $seconds); 224 | } 225 | 226 | /** 227 | * Get the time-to-live of a hash key. 228 | * 229 | * @param string $keyHash The key of the hash to get the time-to-live for. 230 | * 231 | * @return int|null The number of seconds until the key will expire, or null if the key does not exist or has no timeout. 232 | */ 233 | public function getExpireByHash(string $keyHash) 234 | { 235 | return $this->getConnection()->ttl($keyHash); 236 | } 237 | 238 | /** 239 | * guaranteedScan function scans Redis keys matching the given pattern using the given cursor and retrieves a set number of keys. 240 | * 241 | * @param string $keyPattern The pattern to match Redis keys with 242 | * @param int $take The number of keys to retrieve 243 | * @param int $cursor The cursor used to continue a scan (default: 0) 244 | * @param array $keyResultRemaining Array of remaining keys from a previous scan (default: empty array) 245 | * 246 | * @return array Returns an array containing the retrieved keys, cursor for the next scan, a boolean indicating if there are more keys available for scanning, and any remaining keys from the scan. 247 | */ 248 | public function guaranteedScan(string $keyPattern, int $take, int $cursor = 0, $keyResultRemaining = []) 249 | { 250 | $cursor = $cursor === 0 ? ((string) $cursor) : $cursor; 251 | $keys = $keyResultRemaining; 252 | 253 | do { 254 | list($cursor, $result) = $this->getConnection()->scan($cursor, [ 255 | 'match' => $this->getRedisPrefix() . $keyPattern, 256 | 'count' => $take, 257 | ]); 258 | 259 | $keys = array_merge($keys, ($result ?? [])); 260 | 261 | if (sizeof($keys) > $take || $cursor == null) { 262 | break; 263 | } 264 | } while ($cursor != '0'); 265 | 266 | // creates an array of the first $take keys from the $keys array. 267 | $keyResult = array_slice($keys, 0, $take); 268 | // creates an array of the remaining keys after the first $take keys in the $keys array. 269 | $keyResultRemaining = array_slice($keys, $take); 270 | 271 | return [ 272 | 'keys' => $keyResult, 273 | 'cursorNext' => $cursor, 274 | 'isNext' => $cursor != '0' ? true : false, 275 | 'keyResultRemaining' => $keyResultRemaining, 276 | ]; 277 | } 278 | 279 | /** 280 | * scanByHash function scans a Redis hash and retrieves its keys and values by calling a callback function for each batch of keys. 281 | * 282 | * @param string $keyHash The Redis hash to scan 283 | * @param int $limit The maximum number of keys to retrieve per batch 284 | * @param callable $callback A callback function to process each batch of keys 285 | * 286 | * @return bool Returns a boolean indicating if the scan was successful or not. 287 | */ 288 | public function scanByHash(string $keyHash, int $limit, callable $callback) 289 | { 290 | $amountOfDataCommit = $this->countByPattern($keyHash); 291 | 292 | //Check the total amount of data that can be retrieved with the hash pattern 293 | if ($amountOfDataCommit == 0) { 294 | call_user_func_array($callback, [[], false]); 295 | } else { 296 | $cursor = 0; 297 | $scan = ['cursorNext' => 0, 'isNext' => false, 'keyResultRemaining' => []]; 298 | 299 | do { 300 | $scan = $this->guaranteedScan($keyHash, $limit, $cursor, $scan['keyResultRemaining'] ?? []); 301 | 302 | call_user_func_array($callback, [$scan['keys'], $scan['isNext']]); 303 | $cursor = $scan['cursorNext']; 304 | } while ($scan['isNext']); 305 | 306 | // This will ensure that no callback function is missed with remaining data 307 | // because when the guaranteedScan function notifies that the cursor has been fully iterated, 308 | // the loop controlled by the while statement will stop and skip any remaining data. 309 | if ($scan['cursorNext'] === 0 && $scan['isNext'] === false && !empty($scan['keyResultRemaining'])) { 310 | call_user_func_array($callback, [$scan['keyResultRemaining'], false]); 311 | } 312 | } 313 | 314 | return true; 315 | } 316 | 317 | /** 318 | * Run a Redis transaction with the given callback. 319 | * 320 | * @param callable $callback The closure to be executed as part of the transaction 321 | * 322 | * @return bool Returns a boolean indicating if the transaction was successful or not. 323 | */ 324 | public function transaction(callable $callback) 325 | { 326 | return $this->getConnection()->transaction(function ($conTransaction) use ($callback) { 327 | try { 328 | $callback($conTransaction); 329 | $result = $conTransaction->exec(); 330 | 331 | if ($result === false) { 332 | throw new ErrorTransactionException("Transaction failed to execute"); 333 | } 334 | } catch (Exception $e) { 335 | $conTransaction->discard(); 336 | 337 | return false; 338 | } 339 | }); 340 | 341 | return true; 342 | } 343 | 344 | /** 345 | * Removes the Redis prefix from an array of keys. 346 | * 347 | * @param array $keys An array of keys with Redis prefix 348 | * @param string $prefix The Redis key prefix for this model. 349 | * 350 | * @return array An array of keys with Redis prefix removed 351 | */ 352 | public static function removeSlugDatabaseFromRedisKeys(array $keys, string $prefix) 353 | { 354 | return array_map(function ($key) use ($prefix) { 355 | return str_replace($prefix, '', $key); 356 | }, $keys); 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /src/config/redis-model.php: -------------------------------------------------------------------------------- 1 | [ 7 | 'database_default' => 'redis_model_default', 8 | 'prefix' => env('REDIS_MODEL_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_redis_model_'), 9 | ], 10 | 11 | 'commands' => [ 12 | 'generate_path' => app_path('RedisModels'), 13 | 'rootNamespace' => 'App\\RedisModels', 14 | ], 15 | 16 | 'database' => [ 17 | 'redis_model_default' => [ 18 | 'url' => env('REDIS_URL'), 19 | 'host' => env('REDIS_HOST', '127.0.0.1'), 20 | 'username' => env('REDIS_USERNAME'), 21 | 'password' => env('REDIS_PASSWORD'), 22 | 'port' => env('REDIS_PORT', '6379'), 23 | 'database' => env('REDIS_DB', '0'), 24 | ], 25 | ], 26 | ]; 27 | -------------------------------------------------------------------------------- /tests/ModelTest.php: -------------------------------------------------------------------------------- 1 | name)->toEqual($expect['name']); 13 | expect($user->email)->toEqual($expect['email']); 14 | })->with([ 15 | [ 16 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 17 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 18 | ], 19 | [ 20 | ['name' => 'Luke Downing', 'email' => 'luke_downing@example.com'], 21 | ['name' => 'Luke Downing', 'email' => 'luke_downing@example.com'], 22 | ], 23 | [ 24 | ['name' => 'Freek Van Der Herten', 'email' => 'freek_van_der@example.com'], 25 | ['name' => 'Freek Van Der Herten', 'email' => 'freek_van_der@example.com'], 26 | ], 27 | ]); 28 | 29 | it('can insert multiple users without id', function ($data) { 30 | 31 | User::insert($data); 32 | 33 | $users = User::get(); 34 | expect($users->count())->toBe(10); 35 | })->with([ 36 | function () { 37 | $data = []; 38 | 39 | for ($i = 1; $i <= 10; $i++) { 40 | $data[] = [ 41 | 'name' => 'User ' . $i, 42 | 'email' => 'user' . $i . '@example.com', 43 | ]; 44 | } 45 | 46 | return $data; 47 | } 48 | ]); 49 | 50 | it('a user can be force created', function ($userInput, $expect) { 51 | $user = User::create($userInput); 52 | 53 | expect($user->name)->toEqual($expect['name']); 54 | expect($user->email)->toEqual($expect['email']); 55 | 56 | expect(User::count())->toBe(1); 57 | 58 | $userForceCreate = User::forceCreate([ 59 | 'id' => $user->id, 60 | 'name' => $user->name, 61 | 'email' => $user->email, 62 | ]); 63 | 64 | expect($userForceCreate->id)->toEqual($user['id']); 65 | expect($userForceCreate->name)->toEqual($user['name']); 66 | expect($userForceCreate->email)->toEqual($user['email']); 67 | 68 | expect(User::count())->toBe(1); 69 | })->with([ 70 | [ 71 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 72 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 73 | ], 74 | [ 75 | ['name' => 'Luke Downing', 'email' => 'luke_downing@example.com'], 76 | ['name' => 'Luke Downing', 'email' => 'luke_downing@example.com'], 77 | ], 78 | [ 79 | ['name' => 'Freek Van Der Herten', 'email' => 'freek_van_der@example.com'], 80 | ['name' => 'Freek Van Der Herten', 'email' => 'freek_van_der@example.com'], 81 | ], 82 | ]); 83 | 84 | it('a user can be created, updated, and deleted', function ($userInput, $expect) { 85 | $user = User::create($userInput); 86 | 87 | expect($user->name)->toEqual($expect['name']); 88 | expect($user->email)->toEqual($expect['email']); 89 | 90 | // Update the user's name and email 91 | $user->name = 'New Name'; 92 | $user->email = 'new_email@example.com'; 93 | $user->save(); 94 | 95 | // Reload the user from the database and assert that the name and email were updated 96 | $updatedUser = User::find($user->id); 97 | expect($updatedUser->name)->toEqual('New Name'); 98 | expect($updatedUser->email)->toEqual('new_email@example.com'); 99 | 100 | // Delete the user from the database 101 | $updatedUser->delete(); 102 | 103 | // Assert that the user was deleted by checking that it can no longer be found in the database 104 | $deletedUser = User::find($user->id); 105 | expect($deletedUser)->toBeNull(); 106 | })->with([ 107 | [ 108 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 109 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 110 | ], 111 | [ 112 | ['name' => 'Luke Downing', 'email' => 'luke_downing@example.com'], 113 | ['name' => 'Luke Downing', 'email' => 'luke_downing@example.com'], 114 | ], 115 | [ 116 | ['name' => 'Freek Van Der Herten', 'email' => 'freek_van_der@example.com'], 117 | ['name' => 'Freek Van Der Herten', 'email' => 'freek_van_der@example.com'], 118 | ], 119 | ]); 120 | 121 | it('can retrieve all users', function () { 122 | expect(User::all()->count())->toBeGreaterThan(0); 123 | })->with([ 124 | [ 125 | function () { 126 | $data = []; 127 | 128 | for ($i = 1; $i <= 10; $i++) { 129 | $data[] = [ 130 | 'name' => 'User ' . $i, 131 | 'email' => 'user' . $i . '@example.com', 132 | ]; 133 | } 134 | 135 | return User::insert($data); 136 | } 137 | ], 138 | [ 139 | fn() => User::create(['id' => 1, 'name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com']), 140 | ], 141 | ]); 142 | 143 | it('can retrieve a single user by ID', function ($expect) { 144 | $user = User::find(1); 145 | 146 | expect($user->name)->toEqual($expect['name']); 147 | expect($user->email)->toEqual($expect['email']); 148 | })->with([ 149 | [ 150 | fn() => User::create(['id' => 1, 'name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com']), 151 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 152 | ], 153 | [ 154 | fn() => User::create(['id' => 1, 'name' => 'Nuno Madurop', 'email' => 'nuno_nadurop@example.com']), 155 | ['name' => 'Nuno Madurop', 'email' => 'nuno_nadurop@example.com'], 156 | ], 157 | ]); 158 | 159 | it('can retrieve users matching a given criteria', function ($expect) { 160 | $users = User::where('name', 'Nuno*')->get(); 161 | expect($users->count())->toBeGreaterThan(0); 162 | 163 | foreach ($users as $user) { 164 | expect($user->name)->toContain($expect['name']); 165 | expect($user->email)->toContain($expect['email']); 166 | } 167 | })->with([ 168 | [ 169 | fn() => User::create(['id' => 1, 'name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com']), 170 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 171 | ], 172 | [ 173 | fn() => User::create(['id' => 1, 'name' => 'Nuno Madurop', 'email' => 'nuno_nadurop@example.com']), 174 | ['name' => 'Nuno Madurop', 'email' => 'nuno_nadurop@example.com'], 175 | ], 176 | ]); 177 | 178 | it('can insert multiple users', function ($data) { 179 | User::insert($data); 180 | 181 | $users = User::get(); 182 | expect($users->count())->toBe(10); 183 | 184 | foreach ($data as $userInput) { 185 | $user = User::where('email', $userInput['email'])->first(); 186 | expect($user->id)->toBeString(); 187 | expect($user->name)->toEqual($userInput['name']); 188 | expect($user->email)->toEqual($userInput['email']); 189 | } 190 | })->with([ 191 | function () { 192 | $data = []; 193 | 194 | for ($i = 1; $i <= 10; $i++) { 195 | $data[] = [ 196 | 'name' => 'User ' . $i, 197 | 'email' => 'user' . $i . '@example.com', 198 | 'id' => $i, 199 | ]; 200 | } 201 | 202 | return $data; 203 | } 204 | ]); 205 | 206 | it('can remove multiple users', function ($data) { 207 | User::insert($data); 208 | 209 | $users = User::get(); 210 | expect($users->count())->toBe(10); 211 | 212 | User::where('email', 'user' . rand(1, 3) . '@example.com')->destroy(); 213 | User::where('email', 'user' . rand(4, 6) . '@example.com')->destroy(); 214 | User::where('email', 'user' . rand(7, 10) . '@example.com')->destroy(); 215 | 216 | expect(User::count())->toBe(7); 217 | 218 | User::destroy(); 219 | 220 | expect(User::get()->count())->toBe(0); 221 | })->with([ 222 | function () { 223 | $data = []; 224 | 225 | for ($i = 1; $i <= 10; $i++) { 226 | $data[] = [ 227 | 'name' => 'User ' . $i, 228 | 'email' => 'user' . $i . '@example.com', 229 | 'id' => $i, 230 | ]; 231 | } 232 | 233 | return $data; 234 | } 235 | ]); 236 | 237 | it('it can insert multiple users with transaction', function ($data) { 238 | User::transaction(function ($conTransaction) use ($data) { 239 | User::insert($data, $conTransaction); 240 | }); 241 | 242 | expect(User::get()->count())->toBe(10); 243 | })->with([ 244 | function () { 245 | $data = []; 246 | 247 | for ($i = 1; $i <= 10; $i++) { 248 | $data[] = [ 249 | 'name' => 'User ' . $i, 250 | 'email' => 'user' . $i . '@example.com', 251 | 'id' => $i, 252 | ]; 253 | } 254 | 255 | return $data; 256 | } 257 | ]); 258 | 259 | it('it cant insert multiple users with transaction', function ($data) { 260 | User::transaction(function ($conTransaction) use ($data) { 261 | User::insert($data, $conTransaction); 262 | 263 | throw new \Exception('Something went wrong'); 264 | }); 265 | 266 | expect(User::get()->count())->toBe(0); 267 | })->with([ 268 | function () { 269 | $data = []; 270 | 271 | for ($i = 1; $i <= 10; $i++) { 272 | $data[] = [ 273 | 'name' => 'User ' . $i, 274 | 'email' => 'user' . $i . '@example.com', 275 | 'id' => $i, 276 | ]; 277 | } 278 | 279 | return $data; 280 | } 281 | ]); 282 | 283 | it('can retrieve users by email', function () { 284 | $users = User::query()->where('email', 'nuno_naduro@example.com')->get(); 285 | 286 | expect(2)->toEqual($users->count()); 287 | })->with([ 288 | [ 289 | fn() => User::insert([ 290 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 291 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 292 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.net'], 293 | ]), 294 | ], 295 | [ 296 | fn() => User::insert([ 297 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.net'], 298 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 299 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 300 | ]), 301 | ] 302 | ]); 303 | 304 | it('can create user assigning model property values', function ($userData, $expected) { 305 | $user = User::query()->where('email', 'nuno_naduro@example.com')->first(); 306 | 307 | expect($expected['name'])->toEqual($user->name); 308 | expect($expected['email'])->toEqual($user->email); 309 | })->with([ 310 | [ 311 | function () { 312 | $user = new User; 313 | $user->name = 'Nuno Maduro'; 314 | $user->email = 'nuno_naduro@example.com'; 315 | $user->save(); 316 | }, 317 | ['name' => 'Nuno Maduro', 'email' => 'nuno_naduro@example.com'], 318 | ] 319 | ]); 320 | 321 | it('can update user subKey without duplication', function () { 322 | expect(1)->toEqual(User::query()->count()); 323 | })->with([ 324 | [ 325 | function () { 326 | $user = new User; 327 | $user->name = 'Nuno Maduro'; 328 | $user->email = 'nuno_naduro@example.com'; 329 | $user->save(); 330 | $user->email = 'nuno_naduro@example.net'; 331 | $user->save(); 332 | }, 333 | ], 334 | [ 335 | function () { 336 | $user = new User; 337 | $user->name = 'Nuno Maduro'; 338 | $user->email = 'nuno_naduro@example.com'; 339 | $user->save(); 340 | $user->name = 'Nuno'; 341 | $user->email = 'nuno_naduro@example.net'; 342 | $user->save(); 343 | }, 344 | ] 345 | ]); 346 | 347 | it('can update user primaryKey without duplication', function () { 348 | expect(1)->toEqual(User::query()->count()); 349 | })->with([ 350 | [ 351 | function () { 352 | $user = new User; 353 | $user->id = '1'; 354 | $user->name = 'Nuno Maduro'; 355 | $user->email = 'nuno_naduro@example.com'; 356 | $user->save(); 357 | $user->id = 2; 358 | $user->save(); 359 | }, 360 | ], 361 | [ 362 | function () { 363 | $user = new User; 364 | $user->id = 1; 365 | $user->name = 'Nuno Maduro'; 366 | $user->email = 'nuno_naduro@example.com'; 367 | $user->save(); 368 | $user->id = 2; 369 | $user->save(); 370 | }, 371 | ] 372 | ]); -------------------------------------------------------------------------------- /tests/Models/User.php: -------------------------------------------------------------------------------- 1 | 38 | */ 39 | protected $fillable = [ 40 | 'id', 41 | 'email', 42 | 'name', 43 | ]; 44 | } 45 | --------------------------------------------------------------------------------