├── resources └── views │ └── .gitkeep ├── CHANGELOG.md ├── src ├── Contracts │ └── ProjectionContract.php ├── Exceptions │ ├── MissingProjectionNameException.php │ ├── MissingProjectionPeriodException.php │ ├── OverlappingFillBetweenDatesException.php │ ├── MultiplePeriodsException.php │ ├── MultipleProjectionsException.php │ └── EmptyProjectionCollectionException.php ├── Commands │ ├── stubs │ │ ├── Projection.php.stub │ │ └── KeyedProjection.php.stub │ ├── CreateProjectionCommand.php │ ├── DropProjectionsCommand.php │ └── ProjectModelsCommand.php ├── Models │ ├── Scopes │ │ └── ProjectionScope.php │ ├── Traits │ │ └── Projectable.php │ └── Projection.php ├── Jobs │ └── ComputeProjection.php ├── TimeSeriesServiceProvider.php ├── TimeSeries.php ├── Projector.php └── Collections │ └── ProjectionCollection.php ├── config └── time-series.php ├── database └── migrations │ ├── 2021_09_01_020000_create_time_series_projectables_table.php │ └── 2021_09_01_010000_create_time_series_projections_table.php ├── LICENSE.md ├── .php_cs.dist.php ├── composer.json ├── README.md └── static ├── logo.svg └── logo_white.svg /resources/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-time-series` will be documented in this file. 4 | 5 | ## 1.0.0 - 202X-XX-XX 6 | 7 | - initial release 8 | -------------------------------------------------------------------------------- /src/Contracts/ProjectionContract.php: -------------------------------------------------------------------------------- 1 | 'App\\Models', 11 | 12 | /* 13 | * When enabled, TimeSeries will process the projections on a queue. 14 | */ 15 | 'queue' => false, 16 | 17 | /* 18 | * The specific queue name used by TimeSeries. 19 | * Leave empty to use the default queue. 20 | */ 21 | 'queue_name' => '', 22 | 23 | /* 24 | * The first day of the week. 25 | */ 26 | 'beginning_of_the_week' => CarbonInterface::MONDAY, 27 | ]; 28 | -------------------------------------------------------------------------------- /src/Commands/stubs/Projection.php.stub: -------------------------------------------------------------------------------- 1 | foreignId('projection_id') 12 | ->constrained('time_series_projections') 13 | ->onDelete('cascade'); 14 | 15 | $table->unsignedBigInteger('projectable_id'); 16 | $table->string('projectable_type'); 17 | 18 | // add composite key? 19 | }); 20 | } 21 | 22 | public function down() 23 | { 24 | Schema::dropIfExists('time_series_projectables'); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /database/migrations/2021_09_01_010000_create_time_series_projections_table.php: -------------------------------------------------------------------------------- 1 | id(); 12 | 13 | $table->string('projection_name'); 14 | $table->string('key')->nullable(); 15 | $table->string('period'); 16 | $table->timestamp('start_date')->nullable(); 17 | $table->json('content'); 18 | 19 | $table->timestamps(); 20 | }); 21 | } 22 | 23 | public function down() 24 | { 25 | Schema::dropIfExists('time_series_projections'); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/Models/Scopes/ProjectionScope.php: -------------------------------------------------------------------------------- 1 | isAbstractProjection($model)) { 18 | $builder->whereRaw('projection_name = ?', [$model::class]); 19 | } 20 | } 21 | 22 | /** 23 | * Determines either the given model is the abstract projection one or not. 24 | */ 25 | private function isAbstractProjection(Model $model): bool 26 | { 27 | return $model::class === Projection::class; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Jobs/ComputeProjection.php: -------------------------------------------------------------------------------- 1 | onQueue(config('time-series.queue_name')); 25 | } 26 | 27 | /** 28 | * Execute the job. 29 | */ 30 | public function handle() 31 | { 32 | $this->model->bootProjectors($this->eventName); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Commands/stubs/KeyedProjection.php.stub: -------------------------------------------------------------------------------- 1 | id; 30 | } 31 | 32 | /** 33 | * The "created" hook for projectable models. 34 | */ 35 | public function projectableCreated(array $content, Model $model): array 36 | { 37 | return []; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Laravel Time Series 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.php_cs.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->notName('*.blade.php') 10 | ->ignoreDotFiles(true) 11 | ->ignoreVCS(true); 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@PSR12' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 18 | 'no_unused_imports' => true, 19 | 'not_operator_with_successor_space' => true, 20 | 'trailing_comma_in_multiline' => true, 21 | 'phpdoc_scalar' => true, 22 | 'unary_operator_spaces' => true, 23 | 'binary_operator_spaces' => true, 24 | 'blank_line_before_statement' => [ 25 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 26 | ], 27 | 'phpdoc_single_line_var_spacing' => true, 28 | 'phpdoc_var_without_name' => true, 29 | 'class_attributes_separation' => [ 30 | 'elements' => [ 31 | 'method' => 'one', 32 | ], 33 | ], 34 | 'method_argument_space' => [ 35 | 'on_multiline' => 'ensure_fully_multiline', 36 | 'keep_multiple_spaces_after_comma' => true, 37 | ], 38 | 'single_trait_insert_per_statement' => true, 39 | ]) 40 | ->setFinder($finder); 41 | -------------------------------------------------------------------------------- /src/TimeSeriesServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 18 | __DIR__ . '/../config/time-series.php' => config_path('time-series.php'), 19 | ], 'time-series-config'); 20 | 21 | $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); 22 | 23 | if ($this->app->runningInConsole()) { 24 | $this->commands([ 25 | CreateProjectionCommand::class, 26 | DropProjectionsCommand::class, 27 | ProjectModelsCommand::class, 28 | ]); 29 | } 30 | } 31 | 32 | /** 33 | * Registers any application services. 34 | */ 35 | public function register(): void 36 | { 37 | $this->mergeConfigFrom( 38 | __DIR__ . '/../config/time-series.php', 39 | 'time-series' 40 | ); 41 | 42 | $this->app->singleton(TimeSeries::class, function () { 43 | return new TimeSeries(); 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Commands/CreateProjectionCommand.php: -------------------------------------------------------------------------------- 1 | option('key') ? 30 | __DIR__ . '/stubs/KeyedProjection.php.stub' : 31 | __DIR__ . '/stubs/Projection.php.stub'; 32 | } 33 | 34 | /** 35 | * Get the default namespace of the generated class. 36 | */ 37 | protected function getDefaultNamespace($rootNamespace) 38 | { 39 | return $rootNamespace . '\Models\Projections'; 40 | } 41 | 42 | /** 43 | * Get the options of the command. 44 | */ 45 | protected function getOptions() 46 | { 47 | return [ 48 | ['key', null, InputOption::VALUE_NONE, 'Add a key method to the generated class'], 49 | ]; 50 | } 51 | 52 | /** 53 | * Executes the command operations. 54 | */ 55 | public function handle() 56 | { 57 | parent::handle(); 58 | 59 | $class = $this->qualifyClass($this->getNameInput()); 60 | $path = $this->getPath($class); 61 | $content = file_get_contents($path); 62 | 63 | file_put_contents($path, $content); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Commands/DropProjectionsCommand.php: -------------------------------------------------------------------------------- 1 | askConfirmation()) { 48 | return; 49 | } 50 | 51 | if (empty($this->argument('projection'))) { 52 | Projection::query()->delete(); 53 | 54 | return; 55 | } 56 | 57 | collect($this->argument('projection'))->each(function (string $projectionName) { 58 | $projection = app(TimeSeries::class)->resolveProjectionModel($projectionName); 59 | 60 | Projection::name($projection)->delete(); 61 | }); 62 | 63 | $this->info('The projections have been dropped!'); 64 | } 65 | 66 | private function askConfirmation() 67 | { 68 | if (config('app.env') === 'production' && ! $this->option('force')) { 69 | return $this->confirm("Projections will be deleted. Do you wish to continue?"); 70 | } 71 | 72 | return true; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timothepearce/laravel-time-series", 3 | "description": "Laravel Time Series provides an API to create and maintain projected data from you Eloquent models, and represent them as time-series.", 4 | "type": "library", 5 | "keywords": [ 6 | "LaravelTimeSeries", 7 | "laravel-time-series", 8 | "laravel", 9 | "time-series", 10 | "stats", 11 | "statistics", 12 | "projections", 13 | "projectors", 14 | "aggregator" 15 | ], 16 | "homepage": "https://github.com/timothepearce/laravel-time-series", 17 | "license": "MIT", 18 | "authors": [ 19 | { 20 | "name": "Timothé Pearce", 21 | "email": "timothe.pearce@gmail.com", 22 | "role": "Developer" 23 | } 24 | ], 25 | "require": { 26 | "php": "^8.1" 27 | }, 28 | "require-dev": { 29 | "brianium/paratest": "^6.11", 30 | "nunomaduro/collision": "^6.0", 31 | "nunomaduro/larastan": "^2.0.1", 32 | "orchestra/testbench": "^7.0", 33 | "phpstan/extension-installer": "^1.1", 34 | "phpstan/phpstan-deprecation-rules": "^1.0", 35 | "phpstan/phpstan-phpunit": "^1.0", 36 | "phpunit/phpunit": "^9.5", 37 | "spatie/laravel-ray": "^1.26", 38 | "vimeo/psalm": "^4.8" 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "TimothePearce\\TimeSeries\\": "src" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "TimothePearce\\TimeSeries\\Tests\\": "tests", 48 | "TimothePearce\\TimeSeries\\Tests\\Database\\Factories\\": "tests/database/factories" 49 | } 50 | }, 51 | "scripts": { 52 | "psalm": "vendor/bin/psalm", 53 | "test": "./vendor/bin/testbench package:test --parallel --no-coverage", 54 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 55 | }, 56 | "config": { 57 | "sort-packages": true, 58 | "allow-plugins": { 59 | "composer/package-versions-deprecated": true, 60 | "pestphp/pest-plugin": false, 61 | "phpstan/extension-installer": true 62 | } 63 | }, 64 | "extra": { 65 | "laravel": { 66 | "providers": [ 67 | "TimothePearce\\TimeSeries\\TimeSeriesServiceProvider" 68 | ] 69 | } 70 | }, 71 | "minimum-stability": "dev", 72 | "prefer-stable": true 73 | } 74 | -------------------------------------------------------------------------------- /src/TimeSeries.php: -------------------------------------------------------------------------------- 1 | getModels(app_path('Models')); 20 | 21 | return $models->filter(function ($model) { 22 | $rc = new ReflectionClass($model); 23 | $classes = $rc->getTraits(); 24 | 25 | return isset($classes[Projectable::class]); 26 | }); 27 | } 28 | 29 | /** 30 | * Resolves the projection model from the given name. 31 | */ 32 | public function resolveProjectionModel(string $projectionName): string 33 | { 34 | return "App\\Models\\Projections\\$projectionName"; 35 | } 36 | 37 | /** 38 | * Resolves the floor date from the given period. 39 | */ 40 | public function resolveFloorDate(Carbon $date, string $period): CarbonInterface 41 | { 42 | [$quantity, $periodType] = Str::of($period)->split('/[\s]+/'); 43 | 44 | $startDate = $date->floorUnit($periodType, $quantity); 45 | 46 | if (in_array($periodType, ['week', 'weeks'])) { 47 | $startDate->startOfWeek(config('time-series.beginning_of_the_week')); 48 | } 49 | 50 | return $startDate; 51 | } 52 | 53 | /** 54 | * Gets the models from the given path. 55 | */ 56 | private function getModels(string $path): Collection 57 | { 58 | $results = scandir($path); 59 | $models = collect(); 60 | 61 | foreach ($results as $result) { 62 | if ($result === '.' or $result === '..') { 63 | continue; 64 | } 65 | 66 | $filename = $path . '/' . $result; 67 | 68 | is_dir($filename) ? 69 | $models = $models->concat($this->getModels($filename)) : 70 | $models->push($this->getModelNamespace($filename)); 71 | } 72 | 73 | return collect($models); 74 | } 75 | 76 | /** 77 | * Gets the model namespace from the given filename. 78 | */ 79 | private function getModelNamespace(string $filename): string 80 | { 81 | $relativePath = explode( 82 | base_path() . '/', 83 | substr($filename, 0, -4) 84 | )[1]; 85 | 86 | return collect(explode('/', $relativePath)) 87 | ->map(fn ($pathSegment) => Str::ucfirst($pathSegment)) 88 | ->join('\\'); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Commands/ProjectModelsCommand.php: -------------------------------------------------------------------------------- 1 | askConfirmation()) { 42 | return; 43 | } 44 | 45 | Projection::query()->delete(); 46 | 47 | $this->getProjectableModels() 48 | ->map(fn (string $modelName) => $this->resolveModels($modelName)) 49 | ->flatten() 50 | ->sortBy('created_at') 51 | ->each 52 | ->projectModel('created'); 53 | 54 | $this->info('Projections have been refreshed!'); 55 | } 56 | 57 | /** 58 | * Resolves the models. 59 | */ 60 | private function resolveModels(string $modelName): Collection 61 | { 62 | return $this->option('with-trashed') && method_exists($modelName, 'trashed') ? 63 | $modelName::withTrashed()->get() : 64 | $modelName::all(); 65 | } 66 | 67 | /** 68 | * Asks the user confirmation before running the command. 69 | */ 70 | private function askConfirmation(): bool 71 | { 72 | if (! Projection::exists() || $this->option('force')) { 73 | return true; 74 | } 75 | 76 | return $this->confirm("Existing projections will be deleted. Do you wish to continue?"); 77 | } 78 | 79 | /** 80 | * Get the provided projectable models or resolve them. 81 | */ 82 | private function getProjectableModels(): Collection 83 | { 84 | return empty($this->argument('model')) ? 85 | app(TimeSeries::class)->resolveProjectableModels() : 86 | $this->resolveModelFromArgument(); 87 | } 88 | 89 | /** 90 | * Resolve the model from the given argument. 91 | */ 92 | private function resolveModelFromArgument(): Collection 93 | { 94 | return collect($this->argument('model'))->map( 95 | fn (string $modelName) => config('time-series.models_namespace') . '\\' . $modelName 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Models/Traits/Projectable.php: -------------------------------------------------------------------------------- 1 | $model->projectModel('created')); 19 | static::updating(fn (Model $model) => $model->projectModel('updating')); 20 | static::updated(fn (Model $model) => $model->projectModel('updated')); 21 | static::deleting(fn (Model $model) => $model->projectModel('deleting')); 22 | static::deleted(fn (Model $model) => $model->projectModel('deleted')); 23 | } 24 | 25 | /** 26 | * Projects the model. 27 | */ 28 | public function projectModel(string $eventName): void 29 | { 30 | config('time-series.queue') ? 31 | ComputeProjection::dispatch($this, $eventName) : 32 | $this->bootProjectors($eventName); 33 | } 34 | 35 | /** 36 | * Boots the projectors. 37 | */ 38 | public function bootProjectors(string $eventName): void 39 | { 40 | collect($this->projections)->each( 41 | fn (string $projection) => (new Projector($this, $projection, $eventName))->handle() 42 | ); 43 | } 44 | 45 | /** 46 | * Gets all the projections of the model. 47 | */ 48 | public function projections( 49 | string|null $projectionName = null, 50 | string|array|null $periods = null, 51 | ): MorphToMany { 52 | $query = $this->morphToMany(Projection::class, 'projectable', 'time_series_projectables'); 53 | 54 | if (isset($projectionName)) { 55 | $query->whereRaw('projection_name = ?', [$projectionName]); 56 | } 57 | 58 | if (isset($periods) && is_string($periods)) { 59 | $query->where('period', $periods); 60 | } elseif (isset($periods) && is_array($periods)) { 61 | $query->where(function ($query) use (&$periods) { 62 | collect($periods)->each(function (string $period, $key) use (&$query) { 63 | $key === 0 ? 64 | $query->where('period', $period) : 65 | $query->orWhere('period', $period); 66 | }); 67 | }); 68 | } 69 | 70 | return $query; 71 | } 72 | 73 | /** 74 | * Gets the first projection. 75 | */ 76 | public function firstProjection( 77 | string|null $projectionName = null, 78 | string|array|null $periods = null, 79 | ): null|Projection { 80 | return $this->projections($projectionName, $periods)->first(); 81 | } 82 | 83 | /** 84 | * Sets the projectors. 85 | */ 86 | public function setProjections(array $projections) 87 | { 88 | $this->projections = $projections; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](./static/logo.svg#gh-light-mode-only)![Logo](./static/logo_white.svg#gh-dark-mode-only) 2 | 3 |

4 | 5 | Latest unstable Version 6 | 7 | 8 | Download count 9 | 10 | 11 | 12 | 13 |

14 | 15 |

16 | Build your time series with ease 17 |

18 | 19 | ## About 20 | 21 | Laravel Time Series provides an API to projects data from your Eloquent models, and convert them to time series. 22 | 23 | ## Documentation 24 | 25 | The full documentation can be found [here](https://timothepearce.github.io/laravel-time-series-docs). 26 | 27 | ## Usage 28 | 29 | ### Installation 30 | 31 | ```bash 32 | composer require timothepearce/laravel-time-series 33 | ``` 34 | 35 | ### Migrate the tables 36 | 37 | ```bash 38 | php artisan migrate 39 | ``` 40 | 41 | ### Create a Projection 42 | 43 | ```bash 44 | php artisan make:projection MyProjection 45 | ``` 46 | 47 | ### Make a model projectable 48 | 49 | When you want to make your model projectable, you must add it the `Projectable` trait and define the `$projections` class attribute: 50 | 51 | ```php 52 | use App\Models\Projections\MyProjection; 53 | use TimothePearce\TimeSeries\Projectable; 54 | 55 | class MyProjectableModel extends Model 56 | { 57 | use Projectable; 58 | 59 | protected array $projections = [ 60 | MyProjection::class, 61 | ]; 62 | } 63 | ``` 64 | If you want to use a different date field from your Model instead of created_at then do the following : 65 | 1) Make sure the field is casted to Carbon 66 | 67 | ```php 68 | use App\Models\Projections\MyProjection; 69 | use TimothePearce\TimeSeries\Projectable; 70 | 71 | class MyProjectableModel extends Model 72 | { 73 | use Projectable; 74 | 75 | protected $casts = [ 76 | 'other_date_time' => 'datetime:Y-m-d H:00', 77 | ]; 78 | 79 | protected array $projections = [ 80 | MyProjection::class, 81 | ]; 82 | } 83 | ``` 84 | 2) Add the dateColumn field in your Projection 85 | ```php 86 | namespace App\Models\Projections; 87 | 88 | use Illuminate\Database\Eloquent\Model; 89 | use TimothePearce\TimeSeries\Contracts\ProjectionContract; 90 | use TimothePearce\TimeSeries\Models\Projection; 91 | 92 | class MyProjection extends Projection implements ProjectionContract 93 | { 94 | /** 95 | * The projected periods. 96 | */ 97 | public array $periods = []; 98 | 99 | public string $dateColumn = 'other_date_time'; 100 | .... 101 | 102 | ``` 103 | 104 | 105 | ### Implement a Projection 106 | 107 | When you're implementing a projection, follow theses three steps: 108 | * [Define your projection periods](https://timothepearce.github.io/laravel-time-series-docs/getting-started/implement-a-projection#define-your-projection-periods) 109 | * [Add a default content](https://timothepearce.github.io/laravel-time-series-docs/getting-started/implement-a-projection#define-the-default-content-of-your-projection) 110 | * [Bind your projection to the projectable models](https://timothepearce.github.io/laravel-time-series-docs/getting-started/implement-a-projection#implement-the-binding) 111 | 112 | ### Query a Projection 113 | 114 | A Projection is an Eloquent model and is queried the same way, but keep in mind that the projections are all stored in a single table. 115 | That means you'll have to use scope methods to get the correct projections regarding the period you defined earlier: 116 | 117 | ```php 118 | MyProjection::period('1 day') 119 | ->between( 120 | today()->subDay(), // start date 121 | today(), // end date 122 | ) 123 | ->get(); 124 | ``` 125 | 126 | ### Query a time series 127 | 128 | To get a time series from a projection model, use the toTimeSeries method: 129 | 130 | ```php 131 | MyProjection::period('1 day') 132 | ->toTimeSeries( 133 | today()->subDay(), 134 | today(), 135 | ); 136 | ``` 137 | 138 | Note that this method **fill the missing projections between the given dates** with the default content you defined earlier. 139 | 140 | ## Credits 141 | 142 | - [Timothé Pearce](https://github.com/timothepearce) 143 | - [All contributors](https://github.com/timothepearce/laravel-time-series/contributors) 144 | 145 | ## License 146 | 147 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 148 | -------------------------------------------------------------------------------- /static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 12 | 31 | 32 | 33 | 34 | 39 | 40 | 41 | 42 | 43 | 45 | 48 | 51 | 54 | 57 | 59 | 61 | 64 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /static/logo_white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 12 | 13 | 32 | 33 | 34 | 35 | 41 | 42 | 43 | 44 | 45 | 47 | 50 | 53 | 56 | 59 | 61 | 63 | 66 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /src/Models/Projection.php: -------------------------------------------------------------------------------- 1 | 'json', 32 | 'start_date' => 'datetime', 33 | ]; 34 | 35 | /** 36 | * The projection name used in query. 37 | */ 38 | protected string|null $projectionName = null; 39 | 40 | /** 41 | * The projection's period used in query. 42 | */ 43 | protected string|null $queryPeriod = null; 44 | 45 | /** 46 | * The "booted" method of the model. 47 | */ 48 | protected static function booted(): void 49 | { 50 | static::addGlobalScope(new ProjectionScope()); 51 | } 52 | 53 | /** 54 | * Creates a new Eloquent Collection instance. 55 | */ 56 | public function newCollection(array $models = []): Collection 57 | { 58 | return new ProjectionCollection($models); 59 | } 60 | 61 | /** 62 | * Gets the relationship with the projectable models. 63 | */ 64 | public function from(string $modelName): MorphToMany 65 | { 66 | return $this->morphedByMany($modelName, 'projectable', 'time_series_projectables'); 67 | } 68 | 69 | /** 70 | * Converts the projection to a time series segment. 71 | */ 72 | public function toSegment(): array 73 | { 74 | return [ 75 | 'projection_name' => $this->projection_name, 76 | 'period' => $this->period, 77 | 'start_date' => $this->start_date->toDateTimeString(), 78 | 'end_date' => $this->end_date->toDateTimeString(), 79 | 'content' => $this->content, 80 | ]; 81 | } 82 | 83 | /** 84 | * Scopes a query to filter by name. 85 | */ 86 | public function scopeName(Builder $query, string $projectorName): Builder 87 | { 88 | $this->projectionName = $projectorName; 89 | 90 | return $query->whereRaw('projection_name = ?', [$projectorName]); 91 | } 92 | 93 | /** 94 | * Scopes a query to filter by period. 95 | */ 96 | public function scopePeriod(Builder $query, string $period): Builder 97 | { 98 | $this->queryPeriod = $period; 99 | 100 | return $query->where('period', $period); 101 | } 102 | 103 | /** 104 | * Scopes a query to filter by key. 105 | */ 106 | public function scopeForKey(Builder $query, array|string|int $keys): Builder 107 | { 108 | if (is_array($keys)) { 109 | return $query->where(function ($query) use (&$keys) { 110 | collect($keys)->each(function ($key, $index) use (&$query) { 111 | return $index === 0 ? 112 | $query->where('key', (string)$key) : 113 | $query->orWhere('key', (string)$key); 114 | }); 115 | }); 116 | } 117 | 118 | return $query->where('key', (string)$keys); 119 | } 120 | 121 | /** 122 | * Scopes a query to filter by the given dates 123 | * @throws MissingProjectionNameException 124 | * @throws MissingProjectionPeriodException 125 | */ 126 | public function scopeBetween(Builder $query, Carbon $startDate, Carbon $endDate): Builder 127 | { 128 | $this->resolveProjectionName(); 129 | 130 | if (is_null($this->queryPeriod)) { 131 | throw new MissingProjectionPeriodException(); 132 | } 133 | 134 | [$betweenStartDate, $betweenEndDate] = [$this->resolveFloorDate($startDate), $this->resolveFloorDate($endDate)]; 135 | 136 | return $query->whereBetween('start_date', [ 137 | $betweenStartDate, 138 | $betweenEndDate, 139 | ])->where('start_date', '!=', $betweenEndDate); 140 | } 141 | 142 | /** 143 | * Scopes a query to filter by the given dates and fill with empty period if necessary. 144 | * @throws MissingProjectionNameException 145 | * @throws MissingProjectionPeriodException 146 | */ 147 | public function scopeFillBetween(Builder $query, Carbon $startDate, Carbon $endDate): ProjectionCollection 148 | { 149 | $projections = $query->between($startDate, $endDate)->get(); 150 | 151 | return $projections->fillBetween( 152 | $startDate, 153 | $endDate, 154 | $this->resolveProjectionName(), 155 | $this->queryPeriod, 156 | ); 157 | } 158 | 159 | /** 160 | * Constraints the query to the fill between scope, then executes it and converts the results to a time series. 161 | * @throws MissingProjectionNameException 162 | * @throws MissingProjectionPeriodException 163 | */ 164 | public function scopeToTimeSeries(Builder $query, Carbon $startDate, Carbon $endDate): ProjectionCollection 165 | { 166 | return $query->fillBetween($startDate, $endDate) 167 | ->toTimeSeries($startDate, $endDate); 168 | } 169 | 170 | /** 171 | * Gets the end_date attribute. 172 | */ 173 | public function getEndDateAttribute(): Carbon 174 | { 175 | return $this->start_date->add($this->period)->subSecond(); 176 | } 177 | 178 | /** 179 | * Resolves the projection name. 180 | * @throws MissingProjectionNameException 181 | */ 182 | private function resolveProjectionName(): string 183 | { 184 | if (! is_null($this->projectionName)) { 185 | return $this->projectionName; 186 | } 187 | 188 | if ($this->callFromChild()) { 189 | return get_called_class(); 190 | } 191 | 192 | throw new MissingProjectionNameException(); 193 | } 194 | 195 | /** 196 | * Resolves the floor date. 197 | */ 198 | private function resolveFloorDate(Carbon $date): Carbon 199 | { 200 | return app(TimeSeries::class)->resolveFloorDate($date->copy(), $this->queryPeriod); 201 | } 202 | 203 | /** 204 | * Asserts the call is made from the child class. 205 | */ 206 | private function callFromChild(): bool 207 | { 208 | return self::class !== get_called_class(); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Projector.php: -------------------------------------------------------------------------------- 1 | projectionName()); 20 | if (isset($projection->dateColumn)) { 21 | $this->dateColumn = $projection->dateColumn; 22 | } 23 | } 24 | 25 | /** 26 | * Handles the projection. 27 | */ 28 | public function handle(): void 29 | { 30 | if (! $this->hasCallableMethod()) { 31 | return; 32 | } 33 | 34 | $this->parsePeriods(); 35 | } 36 | 37 | /** 38 | * Parses the periods defined as class attribute. 39 | */ 40 | public function parsePeriods(): void 41 | { 42 | $periods = (new $this->projectionName())->periods; 43 | 44 | collect($periods)->each(function ($period) { 45 | $this->isGlobalPeriod($period) ? 46 | $this->createOrUpdateGlobalPeriod() : 47 | $this->parsePeriod($period); 48 | }); 49 | } 50 | 51 | /** 52 | * The key used to query the projection. 53 | */ 54 | public function key(): bool|int|string 55 | { 56 | return (new $this->projectionName())->key($this->projectedModel); 57 | } 58 | 59 | /** 60 | * Is the given period a global one or not. 61 | */ 62 | private function isGlobalPeriod($period): bool 63 | { 64 | return $period === '*'; 65 | } 66 | 67 | /** 68 | * Handles the global period case. 69 | */ 70 | private function createOrUpdateGlobalPeriod(): void 71 | { 72 | $projection = $this->findGlobalProjection(); 73 | 74 | is_null($projection) ? 75 | $this->createGlobalProjection() : 76 | $this->updateProjection($projection, '*'); 77 | } 78 | 79 | /** 80 | * Parses the given period. 81 | */ 82 | private function parsePeriod(string $period): void 83 | { 84 | $projection = $this->findProjection($period); 85 | 86 | is_null($projection) ? 87 | $this->createProjection($period) : 88 | $this->updateProjection($projection, $period); 89 | } 90 | 91 | /** 92 | * Finds the global projection if it exists. 93 | */ 94 | private function findGlobalProjection(): Projection|null 95 | { 96 | return Projection::whereRaw('projection_name = ?', [$this->projectionName]) 97 | ->where([ 98 | ['key', $this->hasKey() ? $this->key() : null], 99 | ['period', '*'], 100 | ['start_date', null], 101 | ]) 102 | ->first(); 103 | } 104 | 105 | /** 106 | * Finds the projection if it exists. 107 | */ 108 | private function findProjection(string $period): Projection|null 109 | { 110 | return Projection::whereRaw('projection_name = ?', [$this->projectionName]) 111 | ->where([ 112 | ['key', $this->hasKey() ? $this->key() : null], 113 | ['period', $period], 114 | ['start_date', app(TimeSeries::class)->resolveFloorDate($this->projectedModel->{$this->dateColumn}, $period), 115 | ], 116 | ]) 117 | ->first(); 118 | } 119 | 120 | /** 121 | * Creates the projection. 122 | */ 123 | private function createProjection(string $period): void 124 | { 125 | $this->projectedModel->projections()->create([ 126 | 'projection_name' => $this->projectionName, 127 | 'key' => $this->hasKey() ? $this->key() : null, 128 | 'period' => $period, 129 | 'start_date' => app(TimeSeries::class)->resolveFloorDate($this->projectedModel->{$this->dateColumn}, $period), 130 | 'content' => $this->mergeProjectedContent((new $this->projectionName())->defaultContent(), $period), 131 | ]); 132 | } 133 | 134 | /** 135 | * Creates the global projection. 136 | */ 137 | private function createGlobalProjection() 138 | { 139 | $this->projectedModel->projections()->create([ 140 | 'projection_name' => $this->projectionName, 141 | 'key' => $this->hasKey() ? $this->key() : null, 142 | 'period' => '*', 143 | 'start_date' => null, 144 | 'content' => $this->mergeProjectedContent((new $this->projectionName())->defaultContent(), '*'), 145 | ]); 146 | } 147 | 148 | /** 149 | * Updates the projection. 150 | */ 151 | private function updateProjection(Projection $projection, string $period): void 152 | { 153 | $projection->content = $this->mergeProjectedContent($projection->content, $period); 154 | 155 | $projection->save(); 156 | } 157 | 158 | /** 159 | * Determines whether the class has a key. 160 | */ 161 | private function hasKey(): bool 162 | { 163 | return method_exists($this->projectionName, 'key'); 164 | } 165 | 166 | /** 167 | * Merges the projected content with the given one. 168 | */ 169 | private function mergeProjectedContent(array $content, string $period): array 170 | { 171 | return array_merge($content, $this->resolveCallableMethod($content, $period)); 172 | } 173 | 174 | /** 175 | * Asserts the projection has a callable method for the event name. 176 | */ 177 | private function hasCallableMethod(): bool 178 | { 179 | $modelName = Str::of($this->projectedModel::class)->explode('\\')->last(); 180 | $callableMethod = lcfirst($modelName) . ucfirst($this->eventName); 181 | $defaultCallable = 'projectable' . ucfirst($this->eventName); 182 | 183 | return method_exists($this->projectionName, $callableMethod) || method_exists($this->projectionName, $defaultCallable); 184 | } 185 | 186 | /** 187 | * Resolves the callable method. 188 | */ 189 | private function resolveCallableMethod(array $content, string $period): array 190 | { 191 | $modelName = Str::of($this->projectedModel::class)->explode('\\')->last(); 192 | $callableMethod = lcfirst($modelName) . ucfirst($this->eventName); 193 | $defaultCallable = 'projectable' . ucfirst($this->eventName); 194 | 195 | return method_exists($this->projectionName, $callableMethod) ? 196 | (new $this->projectionName())->$callableMethod($content, $this->projectedModel, $period) : 197 | (new $this->projectionName())->$defaultCallable($content, $this->projectedModel, $period); 198 | } 199 | 200 | /** 201 | * Resolves the projection start date. 202 | */ 203 | private function resolveStartDate(string $periodType, int $quantity): Carbon 204 | { 205 | $startDate = $this->projectedModel->{$this->dateColumn}->floorUnit($periodType, $quantity); 206 | 207 | if (in_array($periodType, ['week', 'weeks'])) { 208 | $startDate->startOfWeek(config('time-series.beginning_of_the_week')); 209 | } 210 | 211 | return $startDate; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/Collections/ProjectionCollection.php: -------------------------------------------------------------------------------- 1 | fillBetween($startDate, $endDate, $projectionName, $period); 28 | 29 | return new self($projections->map->toSegment()); 30 | } 31 | 32 | /** 33 | * Fills the collection with empty projection between the given dates. 34 | * 35 | * @throws MultipleProjectionsException|MultiplePeriodsException|EmptyProjectionCollectionException|OverlappingFillBetweenDatesException 36 | */ 37 | public function fillBetween( 38 | Carbon $startDate, 39 | Carbon $endDate, 40 | string|null $projectionName = null, 41 | string|null $period = null, 42 | callable|null $fillCallable = null, 43 | ): self { 44 | [$projectionName, $period] = $this->resolveTypeParameters($projectionName, $period); 45 | [$startDate, $endDate] = $this->resolveDatesParameters($period, $startDate, $endDate); 46 | 47 | $allPeriods = $this->getAllPeriods($startDate, $endDate, $period); 48 | $allProjections = new self([]); 49 | $lastProjection = null; 50 | 51 | $allPeriods->each(function (string $currentPeriod) use (&$projectionName, &$period, &$allProjections, &$fillCallable, &$lastProjection) { 52 | $projection = $this->firstWhere('start_date', $currentPeriod); 53 | 54 | $allProjections->push( 55 | is_null($projection) ? 56 | $this->makeProjection($projectionName, $period, $currentPeriod, is_null($fillCallable) ? null : $fillCallable($lastProjection)) : 57 | $projection 58 | ); 59 | 60 | $lastProjection = $allProjections->last(); 61 | }); 62 | 63 | return $allProjections; 64 | } 65 | 66 | /** 67 | * Converts the projections to segments. 68 | */ 69 | public function toSegments(): self 70 | { 71 | $segments = new self([]); 72 | 73 | $this->each(function ($projection) use (&$segments) { 74 | $segments->push($projection->toSegment()); 75 | }); 76 | 77 | return $segments; 78 | } 79 | 80 | /** 81 | * Validates and resolves the type parameters. 82 | * 83 | * @throws EmptyProjectionCollectionException|MultipleProjectionsException|MultiplePeriodsException 84 | */ 85 | private function resolveTypeParameters(string|null $projectionName, string|null $period): array 86 | { 87 | if ($this->count() === 0 && $this->shouldResolveTypeParameters($projectionName, $period)) { 88 | throw new EmptyProjectionCollectionException(); 89 | } 90 | 91 | return [$this->resolveProjectionName($projectionName), $this->resolvePeriod($period)]; 92 | } 93 | 94 | /** 95 | * Validates and resolves the dates parameters. 96 | * 97 | * @throws OverlappingFillBetweenDatesException 98 | */ 99 | private function resolveDatesParameters(string $period, Carbon $startDate, Carbon $endDate): array 100 | { 101 | [$periodQuantity, $periodType] = Str::of($period)->split('/[\s]+/'); 102 | 103 | $startDate->floorUnit($periodType, $periodQuantity); 104 | $endDate->floorUnit($periodType, $periodQuantity); 105 | 106 | if ($startDate->greaterThanOrEqualTo($endDate)) { 107 | throw new OverlappingFillBetweenDatesException(); 108 | } 109 | 110 | return [$startDate, $endDate]; 111 | } 112 | 113 | /** 114 | * Asserts the parameters should be resolved. 115 | */ 116 | private function shouldResolveTypeParameters(string|null $projectionName, string|null $period): bool 117 | { 118 | return is_null($projectionName) || is_null($period); 119 | } 120 | 121 | /** 122 | * Resolves the projection name. 123 | * 124 | * @throws MultipleProjectionsException 125 | */ 126 | private function resolveProjectionName(string|null $projectionName): string 127 | { 128 | $this->assertUniqueProjectionName(); 129 | 130 | return $projectionName ?? $this->guessProjectionName(); 131 | } 132 | 133 | /** 134 | * Resolve the period. 135 | * 136 | * @throws MultiplePeriodsException 137 | */ 138 | private function resolvePeriod(string|null $period): string 139 | { 140 | $this->assertUniquePeriod(); 141 | 142 | return $period ?? $this->guessPeriod(); 143 | } 144 | 145 | /** 146 | * Asserts it is composed of a single type of projection. 147 | * 148 | * @throws MultipleProjectionsException 149 | */ 150 | private function assertUniqueProjectionName() 151 | { 152 | $projectionNames = $this->unique('projection_name'); 153 | 154 | if ($projectionNames->count() > 1) { 155 | throw new MultipleProjectionsException(); 156 | } 157 | } 158 | 159 | /** 160 | * Asserts the given projections has a single type of period. 161 | * 162 | * @throws MultiplePeriodsException 163 | */ 164 | private function assertUniquePeriod() 165 | { 166 | $periodNames = $this->unique('period'); 167 | 168 | if ($periodNames->count() > 1) { 169 | throw new MultiplePeriodsException(); 170 | } 171 | } 172 | 173 | /** 174 | * Guesses the projector name. 175 | */ 176 | private function guessProjectionName(): string 177 | { 178 | return $this->first()->projection_name; 179 | } 180 | 181 | /** 182 | * Guesses the period. 183 | */ 184 | private function guessPeriod(): string 185 | { 186 | return $this->first()->period; 187 | } 188 | 189 | /** 190 | * Get the projections dates. 191 | */ 192 | private function getAllPeriods(Carbon $startDate, Carbon $endDate, string $period): \Illuminate\Support\Collection 193 | { 194 | $cursorDate = clone $startDate; 195 | $allProjectionsDates = collect([$startDate]); 196 | [$periodQuantity, $periodType] = Str::of($period)->split('/[\s]+/'); 197 | 198 | while ($cursorDate->notEqualTo($endDate)): 199 | $cursorDate->add((float)$periodQuantity, $periodType); 200 | 201 | if ($cursorDate->notEqualTo($endDate)) { 202 | $allProjectionsDates->push(clone $cursorDate); 203 | } 204 | endwhile; 205 | 206 | return $allProjectionsDates; 207 | } 208 | 209 | /** 210 | * Makes a projection from the given projector name. 211 | */ 212 | private function makeProjection( 213 | string $projectionName, 214 | string $period, 215 | string $startDate, 216 | array|null $content = null 217 | ): Projection { 218 | return Projection::make([ 219 | 'projection_name' => $projectionName, 220 | 'key' => null, 221 | 'period' => $period, 222 | 'start_date' => $startDate, 223 | 'content' => $content ?? (new $projectionName())->defaultContent(), 224 | ]); 225 | } 226 | } 227 | --------------------------------------------------------------------------------