├── workbench ├── app │ └── Models │ │ ├── .gitkeep │ │ └── User.php ├── database │ ├── factories │ │ ├── .gitkeep │ │ └── UserFactory.php │ ├── migrations │ │ └── .gitkeep │ └── entities │ │ ├── views │ │ ├── FooConnectionUserView.php │ │ ├── NewUserView.php │ │ └── UserView.php │ │ ├── triggers │ │ └── AccountAuditTrigger.php │ │ └── functions │ │ └── AddFunction.php ├── bootstrap │ ├── cache │ │ └── .gitignore │ └── app.php └── composer.json ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── tests.yml ├── art └── sql-entities.webp ├── testbench.yaml ├── tests ├── Pest.php ├── Feature │ ├── Facades │ │ └── SqlEntityTest.php │ ├── Commands │ │ ├── DropCommandTest.php │ │ └── CreateCommandTest.php │ ├── SqlEntityTest.php │ └── SqlEntityManagerTest.php ├── TestCase.php └── Unit │ ├── Grammars │ ├── GrammarTest.php │ ├── SQLiteGrammarTest.php │ ├── SqlServerGrammarTest.php │ ├── MariaDbGrammarTest.php │ ├── MySqlGrammarTest.php │ └── PostgresGrammarTest.php │ ├── Listeners │ └── SyncEntitiesSubscriberTest.php │ └── Concerns │ └── SortsTopologicallyTest.php ├── .gitignore ├── phpstan.neon.dist ├── phpunit.xml.dist ├── LICENSE ├── CONTRIBUTING.md ├── src ├── Trigger.php ├── Facades │ └── SqlEntity.php ├── Console │ └── Commands │ │ ├── DropCommand.php │ │ └── CreateCommand.php ├── Function_.php ├── Grammars │ ├── SQLiteGrammar.php │ ├── MariaDbGrammar.php │ ├── MySqlGrammar.php │ ├── SqlServerGrammar.php │ ├── PostgresGrammar.php │ └── Grammar.php ├── View.php ├── Contracts │ └── SqlEntity.php ├── Listeners │ └── SyncSqlEntities.php ├── ServiceProvider.php ├── Concerns │ ├── DefaultSqlEntityBehaviour.php │ └── SortsTopologically.php ├── Support │ └── Composer.php └── SqlEntityManager.php ├── composer.json ├── pint.json └── README.md /workbench/app/Models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /workbench/database/factories/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /workbench/database/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: calebdw 2 | -------------------------------------------------------------------------------- /workbench/bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /art/sql-entities.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/calebdw/laravel-sql-entities/HEAD/art/sql-entities.webp -------------------------------------------------------------------------------- /testbench.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | laravel: ./workbench 3 | providers: 4 | - CalebDW\SqlEntities\ServiceProvider 5 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in('Feature/'); 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .phpunit.cache/ 2 | coverage/ 3 | vendor/ 4 | workbench/vendor 5 | workbench/storage/ 6 | .phpunit.result.cache 7 | .pint.cache 8 | composer.lock 9 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/calebdw/larastan/extension.neon 3 | parameters: 4 | level: 8 5 | paths: 6 | - src 7 | # - workbench/app 8 | # - workbench/database 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | - package-ecosystem: "composer" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /tests/Feature/Facades/SqlEntityTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(SqlEntityManager::class); 10 | }); 11 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | withExceptions(function (Exceptions $exceptions) { 12 | })->create(); 13 | -------------------------------------------------------------------------------- /tests/Feature/Commands/DropCommandTest.php: -------------------------------------------------------------------------------- 1 | manager = test()->mock(SqlEntityManager::class); 9 | }); 10 | 11 | it('can drop entities', function () { 12 | test()->manager 13 | ->shouldReceive('dropAll') 14 | ->once() 15 | ->with(null, null); 16 | 17 | test()->artisan('sql-entities:drop') 18 | ->assertExitCode(0); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/Feature/Commands/CreateCommandTest.php: -------------------------------------------------------------------------------- 1 | manager = test()->mock(SqlEntityManager::class); 9 | }); 10 | 11 | it('can create entities', function () { 12 | test()->manager 13 | ->shouldReceive('createAll') 14 | ->once() 15 | ->with(null, null); 16 | 17 | test()->artisan('sql-entities:create') 18 | ->assertExitCode(0); 19 | }); 20 | -------------------------------------------------------------------------------- /workbench/database/entities/views/FooConnectionUserView.php: -------------------------------------------------------------------------------- 1 | NOW() - INTERVAL '1 day' 21 | SQL; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /workbench/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel/laravel", 3 | "description": "The Laravel Framework.", 4 | "keywords": [ 5 | "framework", 6 | "laravel" 7 | ], 8 | "license": "MIT", 9 | "type": "project", 10 | "autoload": { 11 | "psr-4": { 12 | "App\\": "app/", 13 | "Workbench\\Database\\Entities\\": "database/entities/", 14 | "Database\\Factories\\": "database/factories/", 15 | "Database\\Seeders\\": "database/seeders/" 16 | } 17 | }, 18 | "autoload-dev": { 19 | "psr-4": { 20 | "Tests\\": "tests/" 21 | } 22 | }, 23 | "minimum-stability": "dev" 24 | } 25 | -------------------------------------------------------------------------------- /workbench/database/entities/triggers/AccountAuditTrigger.php: -------------------------------------------------------------------------------- 1 | definition ?? <<<'SQL' 30 | EXECUTE FUNCTION record_account_audit(); 31 | SQL; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /workbench/database/entities/functions/AddFunction.php: -------------------------------------------------------------------------------- 1 | definition ?? <<<'SQL' 33 | RETURN $1 + $2; 34 | SQL; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Feature/SqlEntityTest.php: -------------------------------------------------------------------------------- 1 | entity = new FooEntity(); 12 | }); 13 | 14 | it('converts entity to string', function () { 15 | $expected = 'select "id", "name" from "foo"'; 16 | 17 | expect((string) test()->entity) 18 | ->toBe($expected); 19 | 20 | expect(test()->entity->toString()) 21 | ->toBe($expected); 22 | }); 23 | 24 | class Foo extends Model 25 | { 26 | protected $table = 'foo'; 27 | } 28 | 29 | class FooEntity implements SqlEntity 30 | { 31 | use DefaultSqlEntityBehaviour; 32 | 33 | public function definition(): Builder|string 34 | { 35 | return Foo::query() 36 | ->select('id', 'name') 37 | ->toBase(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /workbench/database/entities/views/UserView.php: -------------------------------------------------------------------------------- 1 | shouldCreate; 37 | } 38 | 39 | #[Override] 40 | public function dropping(Connection $connection): bool 41 | { 42 | return $this->shouldDrop; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | ./tests 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ./src 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2025 Caleb White 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING 2 | 3 | Contributions are welcome, and are accepted via pull requests. 4 | Please review these guidelines before submitting any pull requests. 5 | 6 | ## Process 7 | 8 | 1. Fork the project 9 | 1. Create a new branch 10 | 1. Code, test, commit and push 11 | 1. Open a pull request detailing your changes. 12 | 13 | ## Guidelines 14 | 15 | - Please follow the [PSR-12 Coding Style Guide](http://www.php-fig.org/psr/psr-12/), enforced by [Pint](https://github.com/laravel/pint). 16 | - Send a coherent commit history, making sure each individual commit in your pull request is meaningful. 17 | - You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts. 18 | - Please remember that we follow [SemVer](http://semver.org/). 19 | 20 | ## Setup 21 | 22 | Clone your fork, then install the dependencies: 23 | 24 | ```bash 25 | composer install 26 | ``` 27 | 28 | ## Tests 29 | 30 | Run all tests: 31 | 32 | ```bash 33 | composer test 34 | ``` 35 | 36 | Linting: 37 | 38 | ```bash 39 | composer test:lint 40 | ``` 41 | 42 | Static analysis: 43 | 44 | ```bash 45 | composer test:static 46 | ``` 47 | 48 | PHPUnit tests: 49 | 50 | ```bash 51 | composer test:coverage 52 | ``` 53 | -------------------------------------------------------------------------------- /workbench/app/Models/User.php: -------------------------------------------------------------------------------- 1 | */ 15 | use HasFactory; 16 | use Notifiable; 17 | 18 | /** 19 | * The attributes that are mass assignable. 20 | * 21 | * @var list 22 | */ 23 | protected $fillable = [ 24 | 'name', 25 | 'email', 26 | 'password', 27 | ]; 28 | 29 | /** 30 | * The attributes that should be hidden for serialization. 31 | * 32 | * @var list 33 | */ 34 | protected $hidden = [ 35 | 'password', 36 | 'remember_token', 37 | ]; 38 | 39 | /** 40 | * Get the attributes that should be cast. 41 | * 42 | * @return array 43 | */ 44 | protected function casts(): array 45 | { 46 | return [ 47 | 'email_verified_at' => 'datetime', 48 | 'password' => 'hashed', 49 | ]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Trigger.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | protected array $events; 23 | 24 | /** The table the trigger is associated with. */ 25 | protected string $table; 26 | 27 | /** The trigger timing. */ 28 | protected string $timing; 29 | 30 | /** If the trigger is a constraint trigger. */ 31 | public function constraint(): bool 32 | { 33 | return $this->constraint; 34 | } 35 | 36 | /** 37 | * The trigger events. 38 | * 39 | * @return list 40 | */ 41 | public function events(): array 42 | { 43 | return $this->events; 44 | } 45 | 46 | /** The table the trigger is associated with. */ 47 | public function table(): string 48 | { 49 | return $this->table; 50 | } 51 | 52 | /** The trigger timing. */ 53 | public function timing(): string 54 | { 55 | return $this->timing; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Facades/SqlEntity.php: -------------------------------------------------------------------------------- 1 | $entity) 17 | * @method static void drop(SqlEntityContract|class-string $entity) 18 | * @method static void createAll(array>|class-string|null $types = null, array|string|null $connections = null) 19 | * @method static void dropAll(array>|class-string|null $types = null, array|string|null $connections = null) 20 | * @method static void withoutEntities(Closure(Connection): mixed $callback, array>|class-string|null $types = null, array|string|null $connections = null) 21 | * 22 | * @see SqlEntityManager 23 | */ 24 | class SqlEntity extends Facade 25 | { 26 | #[Override] 27 | protected static function getFacadeAccessor(): string 28 | { 29 | return 'sql-entities'; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /workbench/database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class UserFactory extends Factory 18 | { 19 | /** 20 | * The current password being used by the factory. 21 | */ 22 | protected static ?string $password; 23 | 24 | /** 25 | * The name of the factory's corresponding model. 26 | * 27 | * @var class-string 28 | */ 29 | protected $model = User::class; 30 | 31 | /** 32 | * Define the model's default state. 33 | * 34 | * @return array 35 | */ 36 | public function definition(): array 37 | { 38 | return [ 39 | 'name' => fake()->name(), 40 | 'email' => fake()->unique()->safeEmail(), 41 | 'email_verified_at' => now(), 42 | 'password' => static::$password ??= Hash::make('password'), 43 | 'remember_token' => Str::random(10), 44 | ]; 45 | } 46 | 47 | /** 48 | * Indicate that the model's email address should be unverified. 49 | */ 50 | public function unverified(): static 51 | { 52 | return $this->state(fn (array $attributes) => [ 53 | 'email_verified_at' => null, 54 | ]); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Console/Commands/DropCommand.php: -------------------------------------------------------------------------------- 1 | option('connection'); 20 | $entities = $this->argument('entities'); 21 | 22 | /** @phpstan-ignore argument.type */ 23 | $manager->dropAll($entities, $connections); 24 | 25 | return self::SUCCESS; 26 | } 27 | 28 | /** @return array */ 29 | #[Override] 30 | protected function getArguments(): array 31 | { 32 | return [ 33 | new InputArgument( 34 | 'entities', 35 | InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 36 | 'The entities to create.', 37 | null, 38 | ), 39 | ]; 40 | } 41 | 42 | /** @return array */ 43 | #[Override] 44 | protected function getOptions(): array 45 | { 46 | return [ 47 | new InputOption( 48 | 'connection', 49 | 'c', 50 | InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 51 | 'The connection(s) to use.', 52 | ), 53 | ]; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Function_.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | protected array $arguments = []; 23 | 24 | /** The language the function is written in. */ 25 | protected string $language = 'SQL'; 26 | 27 | /** If the function is loadable. */ 28 | protected bool $loadable = false; 29 | 30 | /** The function return type. */ 31 | protected string $returns; 32 | 33 | /** The language the function is written in. */ 34 | public function aggregate(): bool 35 | { 36 | return $this->aggregate; 37 | } 38 | 39 | /** 40 | * The function arguments. 41 | * 42 | * @return list 43 | */ 44 | public function arguments(): array 45 | { 46 | return $this->arguments; 47 | } 48 | 49 | /** The language the function is written in. */ 50 | public function language(): string 51 | { 52 | return $this->language; 53 | } 54 | 55 | /** If the function is loadable. */ 56 | public function loadable(): bool 57 | { 58 | return $this->loadable; 59 | } 60 | 61 | /** The function return type. */ 62 | public function returns(): string 63 | { 64 | return $this->returns; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Console/Commands/CreateCommand.php: -------------------------------------------------------------------------------- 1 | option('connection'); 20 | $entities = $this->argument('entities'); 21 | 22 | /** @phpstan-ignore argument.type */ 23 | $manager->createAll($entities, $connections); 24 | 25 | return self::SUCCESS; 26 | } 27 | 28 | /** @return array */ 29 | #[Override] 30 | protected function getArguments(): array 31 | { 32 | return [ 33 | new InputArgument( 34 | 'entities', 35 | InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 36 | 'The entities to create.', 37 | null, 38 | ), 39 | ]; 40 | } 41 | 42 | /** @return array */ 43 | #[Override] 44 | protected function getOptions(): array 45 | { 46 | return [ 47 | new InputOption( 48 | 'connection', 49 | 'c', 50 | InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 51 | 'The connection(s) to use.', 52 | ), 53 | ]; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Grammars/SQLiteGrammar.php: -------------------------------------------------------------------------------- 1 | false, 21 | 22 | default => parent::supportsEntity($entity), 23 | }; 24 | } 25 | 26 | #[Override] 27 | protected function compileFunctionCreate(Function_ $entity): string 28 | { 29 | throw new RuntimeException('SQLite does not support user-defined functions.'); 30 | } 31 | 32 | #[Override] 33 | protected function compileTriggerCreate(Trigger $entity): string 34 | { 35 | $characteristics = implode("\n", $entity->characteristics()); 36 | 37 | return <<name()} 39 | {$entity->timing()} {$entity->events()[0]} 40 | ON {$entity->table()} 41 | {$characteristics} 42 | {$entity->toString()} 43 | SQL; 44 | } 45 | 46 | #[Override] 47 | protected function compileViewCreate(View $entity): string 48 | { 49 | $columns = $this->compileList($entity->columns()); 50 | 51 | return <<name()} {$columns} AS 53 | {$entity->toString()} 54 | SQL; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/View.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | protected ?array $columns = null; 29 | 30 | /** If the view is recursive. */ 31 | protected bool $recursive = false; 32 | 33 | /** 34 | * The check option for the view. 35 | * 36 | * @return 'cascaded'|'local'|true|null 37 | */ 38 | public function checkOption(): string|true|null 39 | { 40 | return $this->checkOption; 41 | } 42 | 43 | /** 44 | * The explicit column list for the view. 45 | * 46 | * @return ?list 47 | */ 48 | public function columns(): ?array 49 | { 50 | return $this->columns; 51 | } 52 | 53 | /** If the view is recursive. */ 54 | public function isRecursive(): bool 55 | { 56 | return $this->recursive; 57 | } 58 | 59 | public static function query(?string $as = null): Builder 60 | { 61 | $instance = app(static::class); 62 | 63 | return DB::connection($instance->connectionName()) 64 | ->table($instance->name(), $as); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Contracts/SqlEntity.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | public function characteristics(): array; 28 | 29 | /** 30 | * Any dependencies that need to be handled before this entity. 31 | * 32 | * @return array> 33 | */ 34 | public function dependencies(): array; 35 | 36 | /** 37 | * Hook before creating the entity. 38 | * 39 | * @return bool true to create the entity, false to skip. 40 | */ 41 | public function creating(Connection $connection): bool; 42 | 43 | /** Hook after creating the entity. */ 44 | public function created(Connection $connection): void; 45 | 46 | /** 47 | * Hook before dropping the entity. 48 | * 49 | * @return bool true to drop the entity, false to skip. 50 | */ 51 | public function dropping(Connection $connection): bool; 52 | 53 | /** Hook after dropping the entity. */ 54 | public function dropped(Connection $connection): void; 55 | 56 | /** Returns a string representation of the entity. */ 57 | public function toString(): string; 58 | } 59 | -------------------------------------------------------------------------------- /src/Listeners/SyncSqlEntities.php: -------------------------------------------------------------------------------- 1 | method !== 'up') { 22 | return; 23 | } 24 | 25 | if ($event->options['pretend'] ?? false) { 26 | return; 27 | } 28 | 29 | $this->manager->dropAll(); 30 | } 31 | 32 | public function handleEnded(MigrationsEnded $event): void 33 | { 34 | if ($event->method !== 'up') { 35 | return; 36 | } 37 | 38 | if ($event->options['pretend'] ?? false) { 39 | return; 40 | } 41 | 42 | $this->manager->createAll(); 43 | } 44 | 45 | public function handleNoPending(NoPendingMigrations $event): void 46 | { 47 | if ($event->method !== 'up') { 48 | return; 49 | } 50 | 51 | // We still need to create the entities if there are no pending 52 | // migrations because new entities may have been added to the code. 53 | $this->manager->createAll(); 54 | } 55 | 56 | /** 57 | * @codeCoverageIgnore 58 | * @return array 59 | */ 60 | public function subscribe(): array 61 | { 62 | return [ 63 | MigrationsStarted::class => 'handleStarted', 64 | MigrationsEnded::class => 'handleEnded', 65 | NoPendingMigrations::class => 'handleNoPending', 66 | ]; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Grammars/MariaDbGrammar.php: -------------------------------------------------------------------------------- 1 | compileList($entity->arguments()); 18 | $aggregate = $entity->aggregate() ? 'AGGREGATE' : ''; 19 | $characteristics = implode("\n", $entity->characteristics()); 20 | $definition = $entity->toString(); 21 | 22 | if ($entity->loadable()) { 23 | $arguments = ''; 24 | $definition = "SONAME {$definition}"; 25 | } 26 | 27 | return <<name()}{$arguments} 29 | RETURNS {$entity->returns()} 30 | {$characteristics} 31 | {$definition} 32 | SQL; 33 | } 34 | 35 | #[Override] 36 | protected function compileTriggerCreate(Trigger $entity): string 37 | { 38 | $characteristics = implode("\n", $entity->characteristics()); 39 | 40 | return <<name()} 42 | {$entity->timing()} {$entity->events()[0]} 43 | ON {$entity->table()} FOR EACH ROW 44 | {$characteristics} 45 | {$entity->toString()} 46 | SQL; 47 | } 48 | 49 | #[Override] 50 | protected function compileViewCreate(View $entity): string 51 | { 52 | $columns = $this->compileList($entity->columns()); 53 | $checkOption = $this->compileCheckOption($entity->checkOption()); 54 | 55 | return <<name()} {$columns} AS 57 | {$entity->toString()} 58 | {$checkOption} 59 | SQL; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Grammars/MySqlGrammar.php: -------------------------------------------------------------------------------- 1 | compileList($entity->arguments()); 18 | $aggregate = ''; 19 | $definition = $entity->toString(); 20 | $characteristics = implode("\n", $entity->characteristics()); 21 | 22 | if ($entity->loadable()) { 23 | $aggregate = $entity->aggregate() ? 'AGGREGATE' : ''; 24 | $arguments = ''; 25 | $definition = "SONAME {$definition}"; 26 | } 27 | 28 | return <<name()}{$arguments} 30 | RETURNS {$entity->returns()} 31 | {$characteristics} 32 | {$definition} 33 | SQL; 34 | } 35 | 36 | #[Override] 37 | protected function compileTriggerCreate(Trigger $entity): string 38 | { 39 | $characteristics = implode("\n", $entity->characteristics()); 40 | 41 | return <<name()} 43 | {$entity->timing()} {$entity->events()[0]} 44 | ON {$entity->table()} FOR EACH ROW 45 | {$characteristics} 46 | {$entity->toString()} 47 | SQL; 48 | } 49 | 50 | #[Override] 51 | protected function compileViewCreate(View $entity): string 52 | { 53 | $columns = $this->compileList($entity->columns()); 54 | $checkOption = $this->compileCheckOption($entity->checkOption()); 55 | 56 | return <<name()} {$columns} AS 58 | {$entity->toString()} 59 | {$checkOption} 60 | SQL; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Grammars/SqlServerGrammar.php: -------------------------------------------------------------------------------- 1 | compileList($entity->arguments()); 18 | $characteristics = implode("\n", $entity->characteristics()); 19 | $definition = $entity->toString(); 20 | 21 | if ($entity->loadable()) { 22 | $definition = "EXTERNAL NAME {$definition}"; 23 | } 24 | 25 | return <<name()} {$arguments} 27 | RETURNS {$entity->returns()} 28 | {$characteristics} 29 | {$definition} 30 | SQL; 31 | } 32 | 33 | #[Override] 34 | protected function compileTriggerCreate(Trigger $entity): string 35 | { 36 | $events = implode(', ', $entity->events()); 37 | $characteristics = implode("\n", $entity->characteristics()); 38 | 39 | return <<name()} 41 | ON {$entity->table()} 42 | {$entity->timing()} {$events} 43 | {$characteristics} 44 | {$entity->toString()} 45 | SQL; 46 | } 47 | 48 | #[Override] 49 | protected function compileViewCreate(View $entity): string 50 | { 51 | $checkOption = $this->compileCheckOption($entity->checkOption()); 52 | $columns = $this->compileList($entity->columns()); 53 | $characteristics = implode("\n", $entity->characteristics()); 54 | 55 | return <<name()} {$columns} 57 | {$characteristics} 58 | AS {$entity->toString()} 59 | {$checkOption} 60 | SQL; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | 4 | on: 5 | push: 6 | paths-ignore: 7 | - '**.md' 8 | - 'docs/**' 9 | branches: 10 | - '**' 11 | pull_request: 12 | types: [ready_for_review, synchronize, opened] 13 | paths-ignore: 14 | - '**.md' 15 | - 'docs/**' 16 | 17 | jobs: 18 | tests: 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | php: [8.4] 23 | laravel: ['^11.0', '^12.0'] 24 | name: PHP${{ matrix.php }} - Laravel${{ matrix.laravel }} 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v6 29 | 30 | - name: Setup PHP 31 | uses: shivammathur/setup-php@v2 32 | with: 33 | php-version: ${{ matrix.php }} 34 | extensions: dom, curl, libxml, mbstring, zip, fileinfo, pdo_sqlite, iconv 35 | tools: composer:v2 36 | coverage: xdebug 37 | 38 | - name: Check Composer configuration 39 | run: composer validate --strict 40 | 41 | - name: Get composer cache directory 42 | id: composer-cache 43 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 44 | 45 | - name: Cache composer dependencies 46 | uses: actions/cache@v5 47 | with: 48 | path: ${{ steps.composer-cache.outputs.dir }} 49 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 50 | restore-keys: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer- 51 | 52 | - name: Install dependencies from composer.json 53 | run: composer update --with='laravel/framework:${{ matrix.laravel }}' --no-interaction --no-progress 54 | 55 | - name: Check PSR-4 mapping 56 | run: composer dump-autoload --optimize --strict-psr --no-dev 57 | 58 | - name: Dump autoload 59 | run: composer dump-autoload --optimize 60 | 61 | - name: Execute tests 62 | run: composer test 63 | 64 | - name: Upload coverage reports to Codecov 65 | uses: codecov/codecov-action@v5.5.2 66 | with: 67 | token: ${{ secrets.CODECOV_TOKEN }} 68 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(SqlEntityManager::class, function (Application $app) { 24 | return (new ReflectionClass(SqlEntityManager::class)) 25 | ->newLazyGhost(fn ($m) => $m->__construct( 26 | $this->getEntities($app), 27 | $app->make('db'), 28 | )); 29 | }); 30 | 31 | $this->app->alias(SqlEntityManager::class, 'sql-entities'); 32 | } 33 | 34 | public function boot(): void 35 | { 36 | if ($this->app->runningInConsole()) { 37 | $this->commands([ 38 | CreateCommand::class, 39 | DropCommand::class, 40 | ]); 41 | } 42 | } 43 | 44 | /** @return Collection */ 45 | protected function getEntities(Application $app): Collection 46 | { 47 | $composer = new Composer($app->make('files'), $app->basePath()); 48 | 49 | return collect() 50 | ->wrap(iterator_to_array( 51 | Finder::create() 52 | ->files() 53 | ->in($app->basePath()) 54 | ->path('database/entities'), 55 | )) 56 | ->map(fn ($file) => (string) $file->getRealPath()) 57 | ->pipe(fn ($files) => collect($composer->classFromFile($files->all()))) 58 | ->filter(fn ($class) => is_subclass_of($class, SqlEntity::class)) 59 | ->map(fn ($class) => $app->make($class)) 60 | ->values(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "calebdw/laravel-sql-entities", 3 | "description": "Manage SQL entities in Laravel with ease.", 4 | "keywords": [ 5 | "php", 6 | "laravel", 7 | "database", 8 | "sql", 9 | "views", 10 | "materialized views", 11 | "functions", 12 | "triggers", 13 | "procedures", 14 | "entities" 15 | ], 16 | "type": "library", 17 | "license": "MIT", 18 | "authors": [ 19 | { 20 | "name": "Caleb White", 21 | "email": "cdwhite3@pm.me" 22 | } 23 | ], 24 | "homepage": "https://github.com/calebdw/laravel-sql-entities", 25 | "autoload": { 26 | "psr-4": { 27 | "CalebDW\\SqlEntities\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "CalebDW\\SqlEntities\\Tests\\": "tests/", 33 | "Workbench\\App\\": "workbench/app/", 34 | "Workbench\\Database\\Entities\\": "workbench/database/entities/", 35 | "Workbench\\Database\\Factories\\": "workbench/database/factories/" 36 | } 37 | }, 38 | "require": { 39 | "php": "^8.4", 40 | "illuminate/console": "^11.0 || ^12.0", 41 | "illuminate/contracts": "^11.0 || ^12.0", 42 | "illuminate/database": "^11.0 || ^12.0", 43 | "illuminate/support": "^11.0 || ^12.0" 44 | }, 45 | "require-dev": { 46 | "calebdw/larastan": "^3.0", 47 | "laravel/pint": "^1.16.2", 48 | "orchestra/testbench": "^9.0 || ^10.0", 49 | "pestphp/pest": "^3.0 || ^4.0" 50 | }, 51 | "extra": { 52 | "laravel": { 53 | "providers": [ 54 | "CalebDW\\SqlEntities\\ServiceProvider" 55 | ] 56 | } 57 | }, 58 | "config": { 59 | "sort-packages": true, 60 | "preferred-install": "dist", 61 | "allow-plugins": { 62 | "pestphp/pest-plugin": true 63 | } 64 | }, 65 | "scripts": { 66 | "test:lint": "pint --test", 67 | "test:lint-fix": "pint", 68 | "test:static": "phpstan analyze --ansi -v", 69 | "test:unit": "pest", 70 | "test:coverage": [ 71 | "@putenv XDEBUG_MODE=coverage", 72 | "pest --coverage" 73 | ], 74 | "test": [ 75 | "@test:lint", 76 | "@test:static", 77 | "@test:coverage" 78 | ], 79 | "lint": [ 80 | "@php vendor/bin/pint --ansi", 81 | "@php vendor/bin/phpstan analyse --verbose --ansi" 82 | ] 83 | }, 84 | "prefer-stable": true, 85 | "minimum-stability": "dev" 86 | } 87 | -------------------------------------------------------------------------------- /src/Concerns/DefaultSqlEntityBehaviour.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | protected array $characteristics = []; 24 | 25 | /** 26 | * Any dependencies that need to be handled before this entity. 27 | * 28 | * @var array> 29 | */ 30 | protected array $dependencies = []; 31 | 32 | /** The entity name. */ 33 | protected ?string $name = null; 34 | 35 | #[Override] 36 | public function name(): string 37 | { 38 | return $this->name ?? Str::snake(class_basename($this)); 39 | } 40 | 41 | #[Override] 42 | public function connectionName(): ?string 43 | { 44 | return $this->connection; 45 | } 46 | 47 | #[Override] 48 | public function characteristics(): array 49 | { 50 | return $this->characteristics; 51 | } 52 | 53 | #[Override] 54 | public function dependencies(): array 55 | { 56 | return $this->dependencies; 57 | } 58 | 59 | #[Override] 60 | public function creating(Connection $connection): bool 61 | { 62 | return true; 63 | } 64 | 65 | #[Override] 66 | public function created(Connection $connection): void 67 | { 68 | } 69 | 70 | #[Override] 71 | public function dropping(Connection $connection): bool 72 | { 73 | return true; 74 | } 75 | 76 | #[Override] 77 | public function dropped(Connection $connection): void 78 | { 79 | } 80 | 81 | #[Override] 82 | public function toString(): string 83 | { 84 | $definition = $this->definition(); 85 | 86 | if (is_string($definition)) { 87 | return $definition; 88 | } 89 | 90 | return $definition->toRawSql(); 91 | } 92 | 93 | #[Override] 94 | public function __toString(): string 95 | { 96 | return $this->toString(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Concerns/SortsTopologically.php: -------------------------------------------------------------------------------- 1 | $nodes The nodes to sort. 17 | * @param (callable(TNode): iterable) $edges A function that returns the edges of a node. 18 | * @param (callable(TNode): array-key)|null $getKey A function that returns the key of a node. 19 | * @return list The sorted nodes. 20 | * @throws RuntimeException if a circular reference is detected. 21 | */ 22 | public function sortTopologically( 23 | iterable $nodes, 24 | callable $edges, 25 | ?callable $getKey = null, 26 | ): array { 27 | $sorted = []; 28 | $visited = []; 29 | $getKey ??= fn ($node) => $node; 30 | 31 | foreach ($nodes as $node) { 32 | $this->visit($node, $edges, $sorted, $visited, $getKey); 33 | } 34 | 35 | return $sorted; 36 | } 37 | 38 | /** 39 | * Visits a node and its dependencies. 40 | * 41 | * @template TNode 42 | * 43 | * @param TNode $node The node to visit. 44 | * @param (callable(TNode): iterable) $edges A function that returns the edges of a node. 45 | * @param list $sorted The sorted nodes. 46 | * @param-out list $sorted The sorted nodes. 47 | * @param array $visited The visited nodes. 48 | * @param (callable(TNode): array-key) $getKey A function that returns the key of a node. 49 | * @throws RuntimeException if a circular reference is detected. 50 | */ 51 | protected function visit( 52 | mixed $node, 53 | callable $edges, 54 | array &$sorted, 55 | array &$visited, 56 | callable $getKey, 57 | ): void { 58 | $key = $getKey($node); 59 | 60 | if (isset($visited[$key])) { 61 | throw_if($visited[$key] === false, "Circular reference detected for [{$key}]."); 62 | 63 | return; 64 | } 65 | 66 | $visited[$key] = false; 67 | 68 | foreach ($edges($node) as $edge) { 69 | $this->visit($edge, $edges, $sorted, $visited, $getKey); 70 | } 71 | 72 | $visited[$key] = true; 73 | $sorted[] = $node; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/Unit/Grammars/GrammarTest.php: -------------------------------------------------------------------------------- 1 | grammar = new TestGrammar($connection); 21 | }); 22 | 23 | it('throws exception when creating unknown entity', function () { 24 | $entity = new UnknownSqlEntity(); 25 | 26 | test()->grammar->compileCreate($entity); 27 | })->throws(InvalidArgumentException::class, 'Unsupported entity [UnknownSqlEntity].'); 28 | 29 | it('throws exception when dropping unknown entity', function () { 30 | $entity = new UnknownSqlEntity(); 31 | 32 | test()->grammar->compileDrop($entity); 33 | })->throws(InvalidArgumentException::class, 'Unsupported entity [UnknownSqlEntity].'); 34 | 35 | it('compiles function drop', function () { 36 | $sql = test()->grammar->compileDrop(new AddFunction()); 37 | 38 | expect($sql)->toBe(<<<'SQL' 39 | DROP FUNCTION IF EXISTS add_function 40 | SQL); 41 | }); 42 | 43 | it('compiles trigger drop', function () { 44 | $sql = test()->grammar->compileDrop(new AccountAuditTrigger()); 45 | 46 | expect($sql)->toBe(<<<'SQL' 47 | DROP TRIGGER IF EXISTS account_audit_trigger 48 | SQL); 49 | }); 50 | 51 | it('compiles view drop', function () { 52 | $sql = test()->grammar->compileDrop(new UserView()); 53 | 54 | expect($sql)->toBe(<<<'SQL' 55 | DROP VIEW IF EXISTS user_view 56 | SQL); 57 | }); 58 | 59 | class TestGrammar extends Grammar 60 | { 61 | public function compileViewCreate(View $view): string 62 | { 63 | return ''; 64 | } 65 | 66 | protected function compileFunctionCreate(Function_ $entity): string 67 | { 68 | return ''; 69 | } 70 | 71 | protected function compileTriggerCreate(Trigger $entity): string 72 | { 73 | return ''; 74 | } 75 | } 76 | 77 | class UnknownSqlEntity implements SqlEntity 78 | { 79 | use DefaultSqlEntityBehaviour; 80 | 81 | public function definition(): Builder|string 82 | { 83 | return ''; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Support/Composer.php: -------------------------------------------------------------------------------- 1 | Keys are namespaces, values are directories. 18 | */ 19 | protected array $namespaces; 20 | 21 | /** 22 | * Get the current Composer config. 23 | * 24 | * @return array 25 | */ 26 | public function getConfig(): array 27 | { 28 | $contents = file_get_contents($this->findComposerFile()); 29 | throw_if($contents === false, 'Unable to read composer.json file.'); 30 | 31 | $config = json_decode( 32 | json: $contents, 33 | associative: true, 34 | depth: 512, 35 | flags: JSON_THROW_ON_ERROR, 36 | ); 37 | assert(is_array($config)); 38 | 39 | return $config; 40 | } 41 | 42 | /** 43 | * Get the Composer PSR-4 namespaces. 44 | * 45 | * @return array Keys are namespaces, values are directories. 46 | */ 47 | public function getNamespaces(): array 48 | { 49 | if (! isset($this->namespaces)) { 50 | $config = $this->getConfig(); 51 | 52 | $this->namespaces = [ 53 | ...Arr::get($config, 'autoload.psr-4', []), 54 | ...Arr::get($config, 'autoload-dev.psr-4', []), 55 | ]; 56 | } 57 | 58 | return $this->namespaces; 59 | } 60 | 61 | /** 62 | * Get the PSR-4 class name for a file. 63 | * 64 | * @param array|string $files The file(s) to convert. 65 | * @return ($files is string ? class-string : array) 66 | */ 67 | public function classFromFile(array|string $files): string|array 68 | { 69 | $namespaces = $this->getNamespaces(); 70 | 71 | $basePath = $this->workingPath ?? getcwd(); 72 | throw_if($basePath === false, 'Unable to get the base directory.'); 73 | 74 | $realBasePath = realpath($basePath); 75 | throw_if($realBasePath === false, 'Unable to get the real base directory.'); 76 | 77 | $realBasePath = Str::of($realBasePath) 78 | ->finish(DIRECTORY_SEPARATOR) 79 | ->toString(); 80 | 81 | /** @phpstan-ignore return.type */ 82 | return str_replace( 83 | search: [$realBasePath, ...array_values($namespaces), DIRECTORY_SEPARATOR, '.php'], 84 | replace: ['', ...array_keys($namespaces), '\\', ''], 85 | subject: $files, 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/Unit/Grammars/SQLiteGrammarTest.php: -------------------------------------------------------------------------------- 1 | grammar = new SQLiteGrammar($connection); 14 | }); 15 | 16 | describe('compiles create function', function () { 17 | it('throws exception', function () { 18 | test()->grammar->compileCreate(new AddFunction()); 19 | })->throws('SQLite does not support user-defined functions.'); 20 | }); 21 | 22 | describe('compiles trigger create', function () { 23 | beforeEach(function () { 24 | test()->entity = new AccountAuditTrigger(); 25 | 26 | test()->entity->characteristics = [ 27 | 'FOR EACH ROW', 28 | ]; 29 | 30 | test()->entity->definition = <<<'SQL' 31 | BEGIN 32 | INSERT INTO account_audits (account_id, old_balance, new_balance) 33 | VALUES (NEW.id, OLD.balance, NEW.balance); 34 | END; 35 | SQL; 36 | }); 37 | 38 | it('compiles successfully', function () { 39 | $sql = test()->grammar->compileCreate(test()->entity); 40 | 41 | expect($sql)->toBe(<<<'SQL' 42 | CREATE TRIGGER IF NOT EXISTS account_audit_trigger 43 | AFTER UPDATE 44 | ON accounts 45 | FOR EACH ROW 46 | BEGIN 47 | INSERT INTO account_audits (account_id, old_balance, new_balance) 48 | VALUES (NEW.id, OLD.balance, NEW.balance); 49 | END; 50 | SQL); 51 | }); 52 | }); 53 | describe('compiles create view', function () { 54 | beforeEach(function () { 55 | test()->entity = new UserView(); 56 | }); 57 | 58 | it('compiles successfully', function () { 59 | $sql = test()->grammar->compileCreate(test()->entity); 60 | 61 | expect($sql)->toBe(<<<'SQL' 62 | CREATE VIEW IF NOT EXISTS user_view AS 63 | SELECT id, name FROM users 64 | SQL); 65 | }); 66 | 67 | it('compiles columns', function (array $columns, string $expected) { 68 | test()->entity->columns = $columns; 69 | 70 | $sql = test()->grammar->compileCreate(test()->entity); 71 | 72 | expect($sql)->toBe(<<with([ 77 | 'one column' => [['id'], ' (id)'], 78 | 'two columns' => [['id', 'name'], ' (id, name)'], 79 | ]); 80 | }); 81 | -------------------------------------------------------------------------------- /tests/Unit/Listeners/SyncEntitiesSubscriberTest.php: -------------------------------------------------------------------------------- 1 | manager = Mockery::mock(SqlEntityManager::class); 13 | test()->listener = new SyncSqlEntities(test()->manager); 14 | }); 15 | 16 | afterEach(function () { 17 | Mockery::close(); 18 | }); 19 | 20 | describe('started', function () { 21 | it('does nothing if the method is not "up"', function () { 22 | test()->manager->shouldNotReceive('dropAll'); 23 | test()->listener->handleStarted( 24 | new MigrationsStarted(method: 'down'), 25 | ); 26 | }); 27 | it('does nothing if the pretend option is true', function () { 28 | test()->manager->shouldNotReceive('dropAll'); 29 | test()->listener->handleStarted( 30 | new MigrationsStarted(method: 'up', options: ['pretend' => true]), 31 | ); 32 | }); 33 | it('drops all entities', function () { 34 | test()->manager 35 | ->shouldReceive('dropAll') 36 | ->once(); 37 | 38 | test()->listener->handleStarted( 39 | new MigrationsStarted(method: 'up'), 40 | ); 41 | }); 42 | }); 43 | 44 | describe('ended', function () { 45 | it('does nothing if the method is not "up"', function () { 46 | test()->manager->shouldNotReceive('createAll'); 47 | test()->listener->handleEnded( 48 | new MigrationsEnded(method: 'down'), 49 | ); 50 | }); 51 | it('does nothing if the pretend option is true', function () { 52 | test()->manager->shouldNotReceive('createAll'); 53 | test()->listener->handleEnded( 54 | new MigrationsEnded(method: 'up', options: ['pretend' => true]), 55 | ); 56 | }); 57 | it('creates all entities', function () { 58 | test()->manager 59 | ->shouldReceive('createAll') 60 | ->once(); 61 | 62 | test()->listener->handleEnded( 63 | new MigrationsEnded(method: 'up'), 64 | ); 65 | }); 66 | }); 67 | 68 | describe('no pending', function () { 69 | it('does nothing if the method is not "up"', function () { 70 | test()->manager->shouldNotReceive('createAll'); 71 | test()->listener->handleNoPending( 72 | new NoPendingMigrations(method: 'down'), 73 | ); 74 | }); 75 | it('creates all entities', function () { 76 | test()->manager 77 | ->shouldReceive('createAll') 78 | ->once(); 79 | 80 | test()->listener->handleNoPending( 81 | new NoPendingMigrations(method: 'up'), 82 | ); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/Grammars/PostgresGrammar.php: -------------------------------------------------------------------------------- 1 | compileList($entity->arguments()); 18 | $language = $entity->language(); 19 | $definition = $entity->toString(); 20 | $characteristics = implode("\n", $entity->characteristics()); 21 | 22 | $definition = match (true) { 23 | $entity->loadable() => "AS {$definition}", 24 | strtolower($language) !== 'sql' => "AS \$function$\n{$definition}\n\$function$", 25 | default => $definition, 26 | }; 27 | 28 | return <<name()}{$arguments} 30 | RETURNS {$entity->returns()} 31 | LANGUAGE {$language} 32 | {$characteristics} 33 | {$definition} 34 | SQL; 35 | } 36 | 37 | #[Override] 38 | protected function compileFunctionDrop(Function_ $entity): string 39 | { 40 | $arguments = $this->compileList($entity->arguments()); 41 | 42 | return <<name()}{$arguments} 44 | SQL; 45 | } 46 | 47 | #[Override] 48 | protected function compileTriggerCreate(Trigger $entity): string 49 | { 50 | $contraint = $entity->constraint() ? 'CONSTRAINT' : ''; 51 | $events = implode(' OR ', $entity->events()); 52 | $characteristics = implode("\n", $entity->characteristics()); 53 | 54 | return <<name()} 56 | {$entity->timing()} {$events} 57 | ON {$entity->table()} 58 | {$characteristics} 59 | {$entity->toString()} 60 | SQL; 61 | } 62 | 63 | #[Override] 64 | protected function compileTriggerDrop(Trigger $entity): string 65 | { 66 | return <<name()} ON {$entity->table()} 68 | SQL; 69 | } 70 | 71 | #[Override] 72 | protected function compileViewCreate(View $entity): string 73 | { 74 | $checkOption = $this->compileCheckOption($entity->checkOption()); 75 | $columns = $this->compileList($entity->columns()); 76 | $recursive = $entity->isRecursive() ? ' RECURSIVE' : ''; 77 | $characteristics = implode("\n", $entity->characteristics()); 78 | 79 | return <<name()} {$columns} 81 | {$characteristics} 82 | AS {$entity->toString()} 83 | {$checkOption} 84 | SQL; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/Unit/Concerns/SortsTopologicallyTest.php: -------------------------------------------------------------------------------- 1 | harness = new TopologicalSortTestHarness(); 9 | }); 10 | 11 | it('sorts linear dependencies', function () { 12 | $graph = [ 13 | 'c' => ['b'], 14 | 'b' => ['a'], 15 | 'a' => [], 16 | ]; 17 | 18 | $sorted = test()->harness->sortTopologically( 19 | array_keys($graph), 20 | fn ($node) => $graph[$node], 21 | ); 22 | 23 | expect($sorted)->toBe(['a', 'b', 'c']); 24 | }); 25 | 26 | it('sorts complex DAG with branches', function () { 27 | $graph = [ 28 | 'd' => ['b', 'c'], 29 | 'c' => ['a'], 30 | 'b' => ['a'], 31 | 'a' => [], 32 | ]; 33 | 34 | $sorted = test()->harness->sortTopologically( 35 | array_keys($graph), 36 | fn (string $node) => $graph[$node], 37 | ); 38 | 39 | expect($sorted)->toBe(['a', 'b', 'c', 'd']); 40 | }); 41 | 42 | it('handles disconnected graphs', function () { 43 | $graph = [ 44 | 'b' => [], 45 | 'a' => [], 46 | 'c' => ['a'], 47 | ]; 48 | 49 | $sorted = test()->harness->sortTopologically( 50 | array_keys($graph), 51 | fn ($node) => $graph[$node], 52 | ); 53 | 54 | expect($sorted)->toContain('a', 'b', 'c'); 55 | expect(array_search('a', $sorted))->toBeLessThan(array_search('c', $sorted)); 56 | }); 57 | 58 | it('throws on circular reference', function () { 59 | $graph = [ 60 | 'a' => ['b'], 61 | 'b' => ['a'], 62 | ]; 63 | 64 | test()->harness->sortTopologically( 65 | array_keys($graph), 66 | fn ($node) => $graph[$node], 67 | ); 68 | })->throws('Circular reference detected for [a]'); 69 | 70 | it('works with object nodes', function () { 71 | $a = new TestNode('a'); 72 | $b = new TestNode('b', [$a]); 73 | $c = new TestNode('c', [$b]); 74 | $d = new TestNode('d', [$b, $c]); 75 | 76 | $sorted = test()->harness->sortTopologically( 77 | [$d, $c, $b, $a], 78 | fn ($n) => $n->deps, 79 | fn ($n) => $n->id, 80 | ); 81 | 82 | expect($sorted)->toBe([$a, $b, $c, $d]); 83 | }); 84 | 85 | it('detects cycles with object nodes', function () { 86 | $a = new TestNode('a'); 87 | $b = new TestNode('b'); 88 | $a->deps = [$b]; 89 | $b->deps = [$a]; 90 | 91 | test()->harness->sortTopologically( 92 | [$a, $b], 93 | fn (TestNode $n) => $n->deps, 94 | fn (TestNode $n) => $n->id, 95 | ); 96 | })->throws('Circular reference detected for [a]'); 97 | 98 | class TopologicalSortTestHarness 99 | { 100 | use SortsTopologically; 101 | } 102 | 103 | class TestNode 104 | { 105 | public function __construct( 106 | public string $id, 107 | /** @var list */ 108 | public array $deps = [], 109 | ) { 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Grammars/Grammar.php: -------------------------------------------------------------------------------- 1 | $this->compileFunctionCreate($entity), 27 | $entity instanceof Trigger => $this->compileTriggerCreate($entity), 28 | $entity instanceof View => $this->compileViewCreate($entity), 29 | 30 | default => throw new InvalidArgumentException( 31 | sprintf('Unsupported entity [%s].', $entity::class), 32 | ), 33 | }; 34 | 35 | return $this->clean($statement); 36 | } 37 | 38 | /** Compile the SQL statement to drop the entity. */ 39 | public function compileDrop(SqlEntity $entity): string 40 | { 41 | $statement = match (true) { 42 | $entity instanceof Function_ => $this->compileFunctionDrop($entity), 43 | $entity instanceof Trigger => $this->compileTriggerDrop($entity), 44 | $entity instanceof View => $this->compileViewDrop($entity), 45 | 46 | default => throw new InvalidArgumentException( 47 | sprintf('Unsupported entity [%s].', $entity::class), 48 | ), 49 | }; 50 | 51 | return $this->clean($statement); 52 | } 53 | 54 | /** Determine if the grammar supports the entity. */ 55 | public function supportsEntity(SqlEntity $entity): bool 56 | { 57 | return match (true) { 58 | $entity instanceof Function_ => true, 59 | $entity instanceof Trigger => true, 60 | $entity instanceof View => true, 61 | default => false, 62 | }; 63 | } 64 | 65 | abstract protected function compileFunctionCreate(Function_ $entity): string; 66 | 67 | protected function compileFunctionDrop(Function_ $entity): string 68 | { 69 | return <<name()} 71 | SQL; 72 | } 73 | 74 | abstract protected function compileTriggerCreate(Trigger $entity): string; 75 | 76 | protected function compileTriggerDrop(Trigger $entity): string 77 | { 78 | return <<name()} 80 | SQL; 81 | } 82 | 83 | abstract protected function compileViewCreate(View $entity): string; 84 | 85 | protected function compileViewDrop(View $entity): string 86 | { 87 | return <<name()} 89 | SQL; 90 | } 91 | 92 | /** @param list|null $values */ 93 | protected function compileList(?array $values): string 94 | { 95 | if ($values === null) { 96 | return ''; 97 | } 98 | 99 | return '(' . implode(', ', $values) . ')'; 100 | } 101 | 102 | protected function compileCheckOption(string|true|null $option): string 103 | { 104 | if ($option === null) { 105 | return ''; 106 | } 107 | 108 | if ($option === true) { 109 | return 'WITH CHECK OPTION'; 110 | } 111 | 112 | $option = strtoupper($option); 113 | 114 | return "WITH {$option} CHECK OPTION"; 115 | } 116 | 117 | protected function clean(string $value): string 118 | { 119 | return Str::of($value) 120 | // remove extra spaces in between words 121 | ->replaceMatches('/(?<=\S) {2,}(?=\S)/', ' ') 122 | // remove trailing spaces at end of line 123 | ->replaceMatches('/ +\n/', "\n") 124 | // remove duplicate new lines 125 | ->replaceMatches('/\n{2,}/', "\n") 126 | ->trim() 127 | ->value(); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /tests/Unit/Grammars/SqlServerGrammarTest.php: -------------------------------------------------------------------------------- 1 | grammar = new SqlServerGrammar($connection); 14 | }); 15 | 16 | describe('compiles function create', function () { 17 | beforeEach(function () { 18 | test()->entity = new AddFunction(); 19 | }); 20 | 21 | it('compiles successfully', function () { 22 | $sql = test()->grammar->compileCreate(test()->entity); 23 | 24 | expect($sql)->toBe(<<<'SQL' 25 | CREATE OR ALTER FUNCTION add_function (integer, integer) 26 | RETURNS INT 27 | RETURN $1 + $2; 28 | SQL); 29 | }); 30 | 31 | it('compiles aggregate', function () { 32 | test()->entity->aggregate = true; 33 | 34 | $sql = test()->grammar->compileCreate(test()->entity); 35 | 36 | expect($sql)->toBe(<<<'SQL' 37 | CREATE OR ALTER FUNCTION add_function (integer, integer) 38 | RETURNS INT 39 | RETURN $1 + $2; 40 | SQL); 41 | }); 42 | 43 | it('compiles loadable', function () { 44 | test()->entity->loadable = true; 45 | test()->entity->definition = "'c_add'"; 46 | 47 | $sql = test()->grammar->compileCreate(test()->entity); 48 | 49 | expect($sql)->toBe(<<<'SQL' 50 | CREATE OR ALTER FUNCTION add_function (integer, integer) 51 | RETURNS INT 52 | EXTERNAL NAME 'c_add' 53 | SQL); 54 | }); 55 | 56 | it('compiles characteristics', function () { 57 | test()->entity->characteristics = [ 58 | 'DETERMINISTIC', 59 | 'CONTAINS SQL', 60 | ]; 61 | 62 | $sql = test()->grammar->compileCreate(test()->entity); 63 | 64 | expect($sql)->toBe(<<<'SQL' 65 | CREATE OR ALTER FUNCTION add_function (integer, integer) 66 | RETURNS INT 67 | DETERMINISTIC 68 | CONTAINS SQL 69 | RETURN $1 + $2; 70 | SQL); 71 | }); 72 | }); 73 | 74 | describe('compiles trigger create', function () { 75 | beforeEach(function () { 76 | test()->entity = new AccountAuditTrigger(); 77 | 78 | test()->entity->definition = <<<'SQL' 79 | AS BEGIN 80 | INSERT INTO account_audits (account_id, old_balance, new_balance) 81 | VALUES (NEW.id, OLD.balance, NEW.balance); 82 | END; 83 | SQL; 84 | }); 85 | 86 | it('compiles successfully', function () { 87 | $sql = test()->grammar->compileCreate(test()->entity); 88 | 89 | expect($sql)->toBe(<<<'SQL' 90 | CREATE OR ALTER TRIGGER account_audit_trigger 91 | ON accounts 92 | AFTER UPDATE 93 | AS BEGIN 94 | INSERT INTO account_audits (account_id, old_balance, new_balance) 95 | VALUES (NEW.id, OLD.balance, NEW.balance); 96 | END; 97 | SQL); 98 | }); 99 | 100 | it('compiles characteristics', function () { 101 | test()->entity->characteristics[] = 'WITH APPEND'; 102 | 103 | $sql = test()->grammar->compileCreate(test()->entity); 104 | 105 | expect($sql)->toBe(<<<'SQL' 106 | CREATE OR ALTER TRIGGER account_audit_trigger 107 | ON accounts 108 | AFTER UPDATE 109 | WITH APPEND 110 | AS BEGIN 111 | INSERT INTO account_audits (account_id, old_balance, new_balance) 112 | VALUES (NEW.id, OLD.balance, NEW.balance); 113 | END; 114 | SQL); 115 | }); 116 | }); 117 | 118 | describe('compiles create view', function () { 119 | beforeEach(function () { 120 | test()->entity = new UserView(); 121 | }); 122 | 123 | it('compiles successfully', function () { 124 | $sql = test()->grammar->compileCreate(test()->entity); 125 | 126 | expect($sql)->toBe(<<<'SQL' 127 | CREATE OR ALTER VIEW user_view 128 | AS SELECT id, name FROM users 129 | SQL); 130 | }); 131 | 132 | it('compiles columns', function (array $columns, string $expected) { 133 | test()->entity->columns = $columns; 134 | 135 | $sql = test()->grammar->compileCreate(test()->entity); 136 | 137 | expect($sql)->toBe(<<with([ 142 | 'one column' => [['id'], ' (id)'], 143 | 'two columns' => [['id', 'name'], ' (id, name)'], 144 | ]); 145 | 146 | it('compiles check option', function (string|bool $option, string $expected) { 147 | test()->entity->checkOption = $option; 148 | 149 | $sql = test()->grammar->compileCreate(test()->entity); 150 | 151 | expect($sql)->toBe(<<with([ 157 | 'true' => [true, 'WITH CHECK OPTION'], 158 | ]); 159 | }); 160 | -------------------------------------------------------------------------------- /tests/Unit/Grammars/MariaDbGrammarTest.php: -------------------------------------------------------------------------------- 1 | grammar = new MariaDbGrammar($connection); 14 | }); 15 | 16 | describe('compiles function create', function () { 17 | beforeEach(function () { 18 | test()->entity = new AddFunction(); 19 | }); 20 | 21 | it('compiles successfully', function () { 22 | $sql = test()->grammar->compileCreate(test()->entity); 23 | 24 | expect($sql)->toBe(<<<'SQL' 25 | CREATE OR REPLACE FUNCTION add_function(integer, integer) 26 | RETURNS INT 27 | RETURN $1 + $2; 28 | SQL); 29 | }); 30 | 31 | it('compiles aggregate', function () { 32 | test()->entity->aggregate = true; 33 | 34 | $sql = test()->grammar->compileCreate(test()->entity); 35 | 36 | expect($sql)->toBe(<<<'SQL' 37 | CREATE OR REPLACE AGGREGATE FUNCTION add_function(integer, integer) 38 | RETURNS INT 39 | RETURN $1 + $2; 40 | SQL); 41 | }); 42 | 43 | it('compiles loadable', function () { 44 | test()->entity->loadable = true; 45 | test()->entity->definition = "'c_add'"; 46 | 47 | $sql = test()->grammar->compileCreate(test()->entity); 48 | 49 | expect($sql)->toBe(<<<'SQL' 50 | CREATE OR REPLACE FUNCTION add_function 51 | RETURNS INT 52 | SONAME 'c_add' 53 | SQL); 54 | }); 55 | 56 | it('compiles characteristics', function () { 57 | test()->entity->characteristics = [ 58 | 'DETERMINISTIC', 59 | 'CONTAINS SQL', 60 | ]; 61 | 62 | $sql = test()->grammar->compileCreate(test()->entity); 63 | 64 | expect($sql)->toBe(<<<'SQL' 65 | CREATE OR REPLACE FUNCTION add_function(integer, integer) 66 | RETURNS INT 67 | DETERMINISTIC 68 | CONTAINS SQL 69 | RETURN $1 + $2; 70 | SQL); 71 | }); 72 | }); 73 | 74 | describe('compiles trigger create', function () { 75 | beforeEach(function () { 76 | test()->entity = new AccountAuditTrigger(); 77 | 78 | test()->entity->definition = <<<'SQL' 79 | BEGIN 80 | INSERT INTO account_audits (account_id, old_balance, new_balance) 81 | VALUES (NEW.id, OLD.balance, NEW.balance); 82 | END; 83 | SQL; 84 | }); 85 | 86 | it('compiles successfully', function () { 87 | $sql = test()->grammar->compileCreate(test()->entity); 88 | 89 | expect($sql)->toBe(<<<'SQL' 90 | CREATE OR REPLACE TRIGGER account_audit_trigger 91 | AFTER UPDATE 92 | ON accounts FOR EACH ROW 93 | BEGIN 94 | INSERT INTO account_audits (account_id, old_balance, new_balance) 95 | VALUES (NEW.id, OLD.balance, NEW.balance); 96 | END; 97 | SQL); 98 | }); 99 | 100 | it('compiles characteristics', function () { 101 | test()->entity->characteristics[] = 'FOLLOWS other_trigger'; 102 | 103 | $sql = test()->grammar->compileCreate(test()->entity); 104 | 105 | expect($sql)->toBe(<<<'SQL' 106 | CREATE OR REPLACE TRIGGER account_audit_trigger 107 | AFTER UPDATE 108 | ON accounts FOR EACH ROW 109 | FOLLOWS other_trigger 110 | BEGIN 111 | INSERT INTO account_audits (account_id, old_balance, new_balance) 112 | VALUES (NEW.id, OLD.balance, NEW.balance); 113 | END; 114 | SQL); 115 | }); 116 | }); 117 | 118 | describe('compiles view create', function () { 119 | beforeEach(function () { 120 | test()->entity = new UserView(); 121 | }); 122 | 123 | it('compiles successfully', function () { 124 | $sql = test()->grammar->compileCreate(test()->entity); 125 | 126 | expect($sql)->toBe(<<<'SQL' 127 | CREATE OR REPLACE VIEW user_view AS 128 | SELECT id, name FROM users 129 | SQL); 130 | }); 131 | 132 | it('compiles columns', function (array $columns, string $expected) { 133 | test()->entity->columns = $columns; 134 | 135 | $sql = test()->grammar->compileCreate(test()->entity); 136 | 137 | expect($sql)->toBe(<<with([ 142 | 'one column' => [['id'], ' (id)'], 143 | 'two columns' => [['id', 'name'], ' (id, name)'], 144 | ]); 145 | 146 | it('compiles check option', function (string|bool $option, string $expected) { 147 | test()->entity->checkOption = $option; 148 | 149 | $sql = test()->grammar->compileCreate(test()->entity); 150 | 151 | expect($sql)->toBe(<<with([ 157 | 'local' => ['local', 'WITH LOCAL CHECK OPTION'], 158 | 'cascaded' => ['cascaded', 'WITH CASCADED CHECK OPTION'], 159 | 'true' => [true, 'WITH CHECK OPTION'], 160 | ]); 161 | }); 162 | -------------------------------------------------------------------------------- /tests/Unit/Grammars/MySqlGrammarTest.php: -------------------------------------------------------------------------------- 1 | grammar = new MySqlGrammar($connection); 14 | }); 15 | 16 | describe('compiles function create', function () { 17 | beforeEach(function () { 18 | test()->entity = new AddFunction(); 19 | }); 20 | 21 | it('compiles successfully', function () { 22 | $sql = test()->grammar->compileCreate(test()->entity); 23 | 24 | expect($sql)->toBe(<<<'SQL' 25 | CREATE FUNCTION IF NOT EXISTS add_function(integer, integer) 26 | RETURNS INT 27 | RETURN $1 + $2; 28 | SQL); 29 | }); 30 | 31 | it('compiles aggregate', function () { 32 | test()->entity->aggregate = true; 33 | 34 | $sql = test()->grammar->compileCreate(test()->entity); 35 | 36 | expect($sql)->toBe(<<<'SQL' 37 | CREATE FUNCTION IF NOT EXISTS add_function(integer, integer) 38 | RETURNS INT 39 | RETURN $1 + $2; 40 | SQL); 41 | }); 42 | 43 | it('compiles loadable', function () { 44 | test()->entity->loadable = true; 45 | test()->entity->definition = "'c_add'"; 46 | 47 | $sql = test()->grammar->compileCreate(test()->entity); 48 | 49 | expect($sql)->toBe(<<<'SQL' 50 | CREATE FUNCTION IF NOT EXISTS add_function 51 | RETURNS INT 52 | SONAME 'c_add' 53 | SQL); 54 | }); 55 | 56 | it('compiles characteristics', function () { 57 | test()->entity->characteristics = [ 58 | 'DETERMINISTIC', 59 | 'CONTAINS SQL', 60 | ]; 61 | 62 | $sql = test()->grammar->compileCreate(test()->entity); 63 | 64 | expect($sql)->toBe(<<<'SQL' 65 | CREATE FUNCTION IF NOT EXISTS add_function(integer, integer) 66 | RETURNS INT 67 | DETERMINISTIC 68 | CONTAINS SQL 69 | RETURN $1 + $2; 70 | SQL); 71 | }); 72 | }); 73 | 74 | describe('compiles trigger create', function () { 75 | beforeEach(function () { 76 | test()->entity = new AccountAuditTrigger(); 77 | 78 | test()->entity->definition = <<<'SQL' 79 | BEGIN 80 | INSERT INTO account_audits (account_id, old_balance, new_balance) 81 | VALUES (NEW.id, OLD.balance, NEW.balance); 82 | END; 83 | SQL; 84 | }); 85 | 86 | it('compiles successfully', function () { 87 | $sql = test()->grammar->compileCreate(test()->entity); 88 | 89 | expect($sql)->toBe(<<<'SQL' 90 | CREATE TRIGGER IF NOT EXISTS account_audit_trigger 91 | AFTER UPDATE 92 | ON accounts FOR EACH ROW 93 | BEGIN 94 | INSERT INTO account_audits (account_id, old_balance, new_balance) 95 | VALUES (NEW.id, OLD.balance, NEW.balance); 96 | END; 97 | SQL); 98 | }); 99 | 100 | it('compiles characteristics', function () { 101 | test()->entity->characteristics[] = 'FOLLOWS other_trigger'; 102 | 103 | $sql = test()->grammar->compileCreate(test()->entity); 104 | 105 | expect($sql)->toBe(<<<'SQL' 106 | CREATE TRIGGER IF NOT EXISTS account_audit_trigger 107 | AFTER UPDATE 108 | ON accounts FOR EACH ROW 109 | FOLLOWS other_trigger 110 | BEGIN 111 | INSERT INTO account_audits (account_id, old_balance, new_balance) 112 | VALUES (NEW.id, OLD.balance, NEW.balance); 113 | END; 114 | SQL); 115 | }); 116 | }); 117 | 118 | describe('compiles view create', function () { 119 | beforeEach(function () { 120 | test()->entity = new UserView(); 121 | }); 122 | 123 | it('compiles successfully', function () { 124 | $sql = test()->grammar->compileCreate(test()->entity); 125 | 126 | expect($sql)->toBe(<<<'SQL' 127 | CREATE OR REPLACE VIEW user_view AS 128 | SELECT id, name FROM users 129 | SQL); 130 | }); 131 | 132 | it('compiles columns', function (array $columns, string $expected) { 133 | test()->entity->columns = $columns; 134 | 135 | $sql = test()->grammar->compileCreate(test()->entity); 136 | 137 | expect($sql)->toBe(<<with([ 142 | 'one column' => [['id'], ' (id)'], 143 | 'two columns' => [['id', 'name'], ' (id, name)'], 144 | ]); 145 | 146 | it('compiles check option', function (string|bool $option, string $expected) { 147 | test()->entity->checkOption = $option; 148 | 149 | $sql = test()->grammar->compileCreate(test()->entity); 150 | 151 | expect($sql)->toBe(<<with([ 157 | 'local' => ['local', 'WITH LOCAL CHECK OPTION'], 158 | 'cascaded' => ['cascaded', 'WITH CASCADED CHECK OPTION'], 159 | 'true' => [true, 'WITH CHECK OPTION'], 160 | ]); 161 | }); 162 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "cache-file": ".pint.cache", 3 | "preset": "psr12", 4 | "rules": { 5 | "align_multiline_comment": { 6 | "comment_type": "phpdocs_like" 7 | }, 8 | "array_indentation": true, 9 | "array_push": true, 10 | "array_syntax": { 11 | "syntax": "short" 12 | }, 13 | "assign_null_coalescing_to_coalesce_equal": true, 14 | "binary_operator_spaces": { 15 | "default": "single_space", 16 | "operators": { 17 | "=": "align_single_space_minimal", 18 | "=>": "align_single_space_minimal" 19 | } 20 | }, 21 | "blank_line_before_statement": { 22 | "statements": [ 23 | "continue", 24 | "do", 25 | "if", 26 | "for", 27 | "foreach", 28 | "return", 29 | "while" 30 | ] 31 | }, 32 | "cast_spaces": true, 33 | "class_attributes_separation": { 34 | "elements": { 35 | "method": "one", 36 | "property": "one" 37 | } 38 | }, 39 | "clean_namespace": true, 40 | "combine_consecutive_issets": true, 41 | "combine_consecutive_unsets": true, 42 | "concat_space": { 43 | "spacing": "one" 44 | }, 45 | "control_structure_braces": true, 46 | "control_structure_continuation_position": true, 47 | "curly_braces_position": { 48 | "allow_single_line_anonymous_functions": false, 49 | "allow_single_line_empty_anonymous_classes": false, 50 | "anonymous_classes_opening_brace": "next_line_unless_newline_at_signature_end", 51 | "anonymous_functions_opening_brace": "same_line", 52 | "classes_opening_brace": "next_line_unless_newline_at_signature_end", 53 | "control_structures_opening_brace": "same_line", 54 | "functions_opening_brace": "next_line_unless_newline_at_signature_end" 55 | }, 56 | "declare_strict_types": true, 57 | "echo_tag_syntax": { 58 | "format": "short", 59 | "shorten_simple_statements_only": true 60 | }, 61 | "explicit_indirect_variable": true, 62 | "explicit_string_variable": true, 63 | "fully_qualified_strict_types": true, 64 | "function_typehint_space": true, 65 | "get_class_to_class_keyword": true, 66 | "global_namespace_import": { 67 | "import_classes": true 68 | }, 69 | "heredoc_indentation": true, 70 | "heredoc_to_nowdoc": true, 71 | "implode_call": true, 72 | "integer_literal_case": true, 73 | "lambda_not_used_import": true, 74 | "linebreak_after_opening_tag": true, 75 | "list_syntax": true, 76 | "magic_constant_casing": true, 77 | "magic_method_casing": true, 78 | "method_chaining_indentation": true, 79 | "modernize_strpos": true, 80 | "modernize_types_casting": true, 81 | "multiline_comment_opening_closing": true, 82 | "multiline_whitespace_before_semicolons": true, 83 | "native_function_casing": true, 84 | "native_function_type_declaration_casing": true, 85 | "new_with_braces": { 86 | "anonymous_class": false 87 | }, 88 | "no_blank_lines_after_phpdoc": true, 89 | "no_empty_comment": true, 90 | "no_empty_phpdoc": true, 91 | "no_empty_statement": true, 92 | "no_extra_blank_lines": { 93 | "tokens": [ 94 | "attribute", 95 | "break", 96 | "case", 97 | "continue", 98 | "curly_brace_block", 99 | "default", 100 | "extra", 101 | "parenthesis_brace_block", 102 | "return", 103 | "square_brace_block", 104 | "switch", 105 | "throw", 106 | "use", 107 | "use_trait" 108 | ] 109 | }, 110 | "no_leading_namespace_whitespace": true, 111 | "no_mixed_echo_print": { 112 | "use": "echo" 113 | }, 114 | "no_multiline_whitespace_around_double_arrow": true, 115 | "no_multiple_statements_per_line": true, 116 | "no_null_property_initialization": true, 117 | "no_singleline_whitespace_before_semicolons": true, 118 | "no_spaces_around_offset": true, 119 | "no_superfluous_phpdoc_tags": true, 120 | "no_trailing_comma_in_singleline": true, 121 | "no_unneeded_control_parentheses": true, 122 | "no_unneeded_curly_braces": true, 123 | "no_unneeded_import_alias": true, 124 | "no_unset_cast": true, 125 | "no_unused_imports": true, 126 | "no_useless_else": true, 127 | "no_useless_nullsafe_operator": true, 128 | "no_useless_return": true, 129 | "no_whitespace_before_comma_in_array": true, 130 | "normalize_index_brace": true, 131 | "not_operator_with_successor_space": true, 132 | "nullable_type_declaration_for_default_null_value": true, 133 | "object_operator_without_whitespace": true, 134 | "octal_notation": true, 135 | "operator_linebreak": true, 136 | "ordered_imports": { 137 | "sort_algorithm": "alpha", 138 | "imports_order": ["const", "class", "function"] 139 | }, 140 | "ordered_traits": true, 141 | "php_unit_construct": true, 142 | "php_unit_method_casing": true, 143 | "php_unit_test_case_static_method_calls": { 144 | "call_type": "this" 145 | }, 146 | "phpdoc_align": { 147 | "align": "left" 148 | }, 149 | "phpdoc_indent": true, 150 | "phpdoc_trim": true, 151 | "phpdoc_trim_consecutive_blank_line_separation": true, 152 | "pow_to_exponentiation": true, 153 | "return_assignment": true, 154 | "self_static_accessor": true, 155 | "semicolon_after_instruction": true, 156 | "simplified_if_return": true, 157 | "simplified_null_return": true, 158 | "simple_to_complex_string_variable": true, 159 | "single_line_comment_spacing": true, 160 | "single_line_comment_style": true, 161 | "single_quote": true, 162 | "single_space_after_construct": true, 163 | "standardize_increment": true, 164 | "standardize_not_equals": true, 165 | "statement_indentation": true, 166 | "ternary_to_null_coalescing": true, 167 | "trailing_comma_in_multiline": { 168 | "after_heredoc": true, 169 | "elements": ["arrays", "arguments", "parameters", "match"] 170 | }, 171 | "trim_array_spaces": true, 172 | "types_spaces": true, 173 | "unary_operator_spaces": true, 174 | "whitespace_after_comma_in_array": true, 175 | "yoda_style": { 176 | "equal": false, 177 | "identical": false, 178 | "less_and_greater": false 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /tests/Unit/Grammars/PostgresGrammarTest.php: -------------------------------------------------------------------------------- 1 | grammar = new PostgresGrammar($connection); 14 | }); 15 | 16 | describe('compiles function create', function () { 17 | beforeEach(function () { 18 | test()->entity = new AddFunction(); 19 | }); 20 | 21 | it('compiles successfully', function () { 22 | $sql = test()->grammar->compileCreate(test()->entity); 23 | 24 | expect($sql)->toBe(<<<'SQL' 25 | CREATE OR REPLACE FUNCTION add_function(integer, integer) 26 | RETURNS INT 27 | LANGUAGE SQL 28 | RETURN $1 + $2; 29 | SQL); 30 | }); 31 | 32 | it('compiles aggregate', function () { 33 | test()->entity->aggregate = true; 34 | 35 | $sql = test()->grammar->compileCreate(test()->entity); 36 | 37 | expect($sql)->toBe(<<<'SQL' 38 | CREATE OR REPLACE FUNCTION add_function(integer, integer) 39 | RETURNS INT 40 | LANGUAGE SQL 41 | RETURN $1 + $2; 42 | SQL); 43 | }); 44 | 45 | it('compiles loadable', function () { 46 | test()->entity->language = 'c'; 47 | test()->entity->loadable = true; 48 | test()->entity->definition = "'c_add'"; 49 | 50 | $sql = test()->grammar->compileCreate(test()->entity); 51 | 52 | expect($sql)->toBe(<<<'SQL' 53 | CREATE OR REPLACE FUNCTION add_function(integer, integer) 54 | RETURNS INT 55 | LANGUAGE c 56 | AS 'c_add' 57 | SQL); 58 | }); 59 | 60 | it('compiles plpgspl', function () { 61 | test()->entity->language = 'plpgsql'; 62 | test()->entity->definition = <<<'SQL' 63 | BEGIN 64 | RETURN $1 + $2; 65 | END; 66 | SQL; 67 | 68 | $sql = test()->grammar->compileCreate(test()->entity); 69 | 70 | expect($sql)->toBe(<<<'SQL' 71 | CREATE OR REPLACE FUNCTION add_function(integer, integer) 72 | RETURNS INT 73 | LANGUAGE plpgsql 74 | AS $function$ 75 | BEGIN 76 | RETURN $1 + $2; 77 | END; 78 | $function$ 79 | SQL); 80 | }); 81 | 82 | it('compiles characteristics', function () { 83 | test()->entity->characteristics = [ 84 | 'DETERMINISTIC', 85 | 'CONTAINS SQL', 86 | ]; 87 | 88 | $sql = test()->grammar->compileCreate(test()->entity); 89 | 90 | expect($sql)->toBe(<<<'SQL' 91 | CREATE OR REPLACE FUNCTION add_function(integer, integer) 92 | RETURNS INT 93 | LANGUAGE SQL 94 | DETERMINISTIC 95 | CONTAINS SQL 96 | RETURN $1 + $2; 97 | SQL); 98 | }); 99 | }); 100 | 101 | describe('compiles trigger create', function () { 102 | beforeEach(function () { 103 | test()->entity = new AccountAuditTrigger(); 104 | 105 | test()->entity->characteristics = [ 106 | 'FOR EACH ROW', 107 | ]; 108 | }); 109 | 110 | it('compiles successfully', function () { 111 | $sql = test()->grammar->compileCreate(test()->entity); 112 | 113 | expect($sql)->toBe(<<<'SQL' 114 | CREATE OR REPLACE TRIGGER account_audit_trigger 115 | AFTER UPDATE 116 | ON accounts 117 | FOR EACH ROW 118 | EXECUTE FUNCTION record_account_audit(); 119 | SQL); 120 | }); 121 | 122 | it('handles multiple events', function () { 123 | test()->entity->events = ['INSERT', 'UPDATE']; 124 | 125 | $sql = test()->grammar->compileCreate(test()->entity); 126 | 127 | expect($sql)->toBe(<<<'SQL' 128 | CREATE OR REPLACE TRIGGER account_audit_trigger 129 | AFTER INSERT OR UPDATE 130 | ON accounts 131 | FOR EACH ROW 132 | EXECUTE FUNCTION record_account_audit(); 133 | SQL); 134 | }); 135 | 136 | it('compiles characteristics', function () { 137 | test()->entity->characteristics[] = 'WHEN (NEW.id IS NOT NULL)'; 138 | 139 | $sql = test()->grammar->compileCreate(test()->entity); 140 | 141 | expect($sql)->toBe(<<<'SQL' 142 | CREATE OR REPLACE TRIGGER account_audit_trigger 143 | AFTER UPDATE 144 | ON accounts 145 | FOR EACH ROW 146 | WHEN (NEW.id IS NOT NULL) 147 | EXECUTE FUNCTION record_account_audit(); 148 | SQL); 149 | }); 150 | 151 | it('compiles constraint', function () { 152 | test()->entity->constraint = true; 153 | $sql = test()->grammar->compileCreate(test()->entity); 154 | 155 | expect($sql)->toBe(<<<'SQL' 156 | CREATE OR REPLACE CONSTRAINT TRIGGER account_audit_trigger 157 | AFTER UPDATE 158 | ON accounts 159 | FOR EACH ROW 160 | EXECUTE FUNCTION record_account_audit(); 161 | SQL); 162 | }); 163 | }); 164 | 165 | describe('compiles view create', function () { 166 | beforeEach(function () { 167 | test()->entity = new UserView(); 168 | }); 169 | 170 | it('compiles successfully', function () { 171 | $sql = test()->grammar->compileCreate(test()->entity); 172 | 173 | expect($sql)->toBe(<<<'SQL' 174 | CREATE OR REPLACE VIEW user_view 175 | AS SELECT id, name FROM users 176 | SQL); 177 | }); 178 | 179 | it('compiles recursive', function () { 180 | test()->entity->recursive = true; 181 | 182 | $sql = test()->grammar->compileCreate(test()->entity); 183 | 184 | expect($sql)->toBe(<<<'SQL' 185 | CREATE OR REPLACE RECURSIVE VIEW user_view 186 | AS SELECT id, name FROM users 187 | SQL); 188 | }); 189 | 190 | it('compiles columns', function (array $columns, string $expected) { 191 | test()->entity->columns = $columns; 192 | 193 | $sql = test()->grammar->compileCreate(test()->entity); 194 | 195 | expect($sql)->toBe(<<with([ 200 | 'one column' => [['id'], ' (id)'], 201 | 'two columns' => [['id', 'name'], ' (id, name)'], 202 | ]); 203 | 204 | it('compiles check option', function (string|bool $option, string $expected) { 205 | test()->entity->checkOption = $option; 206 | 207 | $sql = test()->grammar->compileCreate(test()->entity); 208 | 209 | expect($sql)->toBe(<<with([ 215 | 'local' => ['local', 'WITH LOCAL CHECK OPTION'], 216 | 'cascaded' => ['cascaded', 'WITH CASCADED CHECK OPTION'], 217 | 'true' => [true, 'WITH CHECK OPTION'], 218 | ]); 219 | }); 220 | 221 | it('drops function', function () { 222 | $entity = new AddFunction(); 223 | $sql = test()->grammar->compileDrop($entity); 224 | 225 | expect($sql)->toBe(<<<'SQL' 226 | DROP FUNCTION IF EXISTS add_function(integer, integer) 227 | SQL); 228 | }); 229 | 230 | it('drops trigger', function () { 231 | $entity = new AccountAuditTrigger(); 232 | $sql = test()->grammar->compileDrop($entity); 233 | 234 | expect($sql)->toBe(<<<'SQL' 235 | DROP TRIGGER IF EXISTS account_audit_trigger ON accounts 236 | SQL); 237 | }); 238 | -------------------------------------------------------------------------------- /tests/Feature/SqlEntityManagerTest.php: -------------------------------------------------------------------------------- 1 | 'mariadb', 19 | 'mysql' => 'mysql', 20 | 'pgsql' => 'pgsql', 21 | 'sqlite' => 'sqlite', 22 | 'sqlsrv' => 'sqlsrv', 23 | ]); 24 | 25 | dataset('typesAndConnections', [ 26 | 'default args' => ['types' => null, 'connections' => null, 'times' => 5], 27 | 'single specific type' => ['types' => UserView::class, 'connections' => null, 'times' => 1], 28 | 'single connection' => ['types' => null, 'connections' => 'default', 'times' => 4], 29 | 'multiple connections' => ['types' => null, 'connections' => ['default', 'foo'], 'times' => 5], 30 | 'single abstract type' => ['types' => View::class, 'connections' => null, 'times' => 3], 31 | 'multiple types' => ['types' => [UserView::class, FooConnectionUserView::class], 'connections' => null, 'times' => 2], 32 | ]); 33 | 34 | beforeEach(function () { 35 | test()->connection = test()->mock(Connection::class); 36 | 37 | $db = test()->mock(DatabaseManager::class) 38 | ->shouldReceive('getDefaultConnection')->andReturn('default') 39 | ->shouldReceive('connection')->andReturn(test()->connection) 40 | ->getMock(); 41 | app()->instance('db', $db); 42 | 43 | test()->manager = resolve(SqlEntityManager::class); 44 | }); 45 | 46 | afterEach(function () { 47 | Mockery::close(); 48 | }); 49 | 50 | it('loads the entities')->expect(test()->manager->entities)->not->toBeEmpty(); 51 | 52 | describe('get', function () { 53 | it('returns the entity by class', function (string $class) { 54 | $entity = test()->manager->get($class); 55 | 56 | expect($entity)->toBeInstanceOf($class); 57 | })->with([ 58 | UserView::class, 59 | FooConnectionUserView::class, 60 | ]); 61 | 62 | it('throws an exception for unknown entity', function () { 63 | $entity = test()->manager->get('unknown'); 64 | })->throws(ItemNotFoundException::class, 'Entity [unknown] not found.'); 65 | }); 66 | 67 | describe('create', function () { 68 | it('creates an entity', function (string $driver, string|SqlEntity $entity) { 69 | test()->connection 70 | ->shouldReceive('getDriverName')->once()->andReturn($driver) 71 | ->shouldReceive('statement') 72 | ->once() 73 | ->withArgs(fn ($sql) => str_contains($sql, 'CREATE')); 74 | 75 | test()->manager->create($entity); 76 | })->with('drivers')->with([ 77 | 'class' => UserView::class, 78 | 'entity' => new UserView(), 79 | ]); 80 | 81 | it('can skip creation', function () { 82 | $entity = new UserView(); 83 | 84 | test()->connection 85 | ->shouldNotReceive('getDriverName') 86 | ->shouldNotReceive('statement'); 87 | 88 | $entity->shouldCreate = false; 89 | test()->manager->create($entity); 90 | }); 91 | 92 | it('skips already created entities', function () { 93 | test()->connection 94 | ->shouldReceive('getDriverName')->once()->andReturn('sqlite') 95 | ->shouldReceive('statement') 96 | ->once() 97 | ->withArgs(fn ($sql) => str_contains($sql, 'CREATE')); 98 | 99 | test()->manager->create(UserView::class); 100 | test()->manager->create(UserView::class); 101 | }); 102 | 103 | it('creates an entity\'s dependencies', function () { 104 | test()->connection 105 | ->shouldReceive('getDriverName')->times(2)->andReturn('sqlite') 106 | ->shouldReceive('statement') 107 | ->times(2) 108 | ->withArgs(fn ($sql) => str_contains($sql, 'CREATE')); 109 | 110 | test()->manager->create(NewUserView::class); 111 | }); 112 | 113 | it('skips unsupported entities', function () { 114 | test()->connection 115 | ->shouldReceive('getDriverName')->andReturn('sqlite') 116 | ->shouldNotReceive('statement'); 117 | 118 | test()->manager->create(AddFunction::class); 119 | }); 120 | }); 121 | 122 | describe('drop', function () { 123 | it('drops an entity', function (string $driver, string|SqlEntity $entity) { 124 | test()->connection 125 | ->shouldReceive('getDriverName')->once()->andReturn($driver) 126 | ->shouldReceive('statement') 127 | ->once() 128 | ->withArgs(fn ($sql) => str_contains($sql, 'DROP')); 129 | 130 | test()->manager->drop($entity); 131 | })->with('drivers')->with([ 132 | 'class' => UserView::class, 133 | 'entity' => new UserView(), 134 | ]); 135 | 136 | it('can skip dropping', function () { 137 | $entity = new UserView(); 138 | 139 | test()->connection 140 | ->shouldNotReceive('getDriverName') 141 | ->shouldNotReceive('statement'); 142 | 143 | $entity->shouldDrop = false; 144 | test()->manager->drop($entity); 145 | }); 146 | 147 | it('skips already dropped entities', function () { 148 | test()->connection 149 | ->shouldReceive('getDriverName')->once()->andReturn('sqlite') 150 | ->shouldReceive('statement') 151 | ->once() 152 | ->withArgs(fn ($sql) => str_contains($sql, 'DROP')); 153 | 154 | test()->manager->drop(UserView::class); 155 | test()->manager->drop(UserView::class); 156 | }); 157 | 158 | it('skips unsupported entities', function () { 159 | test()->connection 160 | ->shouldReceive('getDriverName')->andReturn('sqlite') 161 | ->shouldNotReceive('statement'); 162 | 163 | test()->manager->drop(AddFunction::class); 164 | }); 165 | }); 166 | 167 | it('creates entities by type and connection', function (array|string|null $types, array|string|null $connections, int $times) { 168 | test()->connection 169 | ->shouldReceive('getDriverName')->andReturn('pgsql') 170 | ->shouldReceive('statement') 171 | ->times($times) 172 | ->withArgs(fn ($sql) => str_contains($sql, 'CREATE')); 173 | 174 | test()->manager->createAll($types, $connections); 175 | })->with('typesAndConnections'); 176 | 177 | it('drops entities by type and connection', function (array|string|null $types, array|string|null $connections, int $times) { 178 | test()->connection 179 | ->shouldReceive('getDriverName')->andReturn('pgsql') 180 | ->shouldReceive('statement') 181 | ->times($times) 182 | ->withArgs(fn ($sql) => str_contains($sql, 'DROP')); 183 | 184 | test()->manager->dropAll($types, $connections); 185 | })->with('typesAndConnections'); 186 | 187 | it('executes callbacks without entities', function ( 188 | bool $transactions, 189 | bool $grammarLoaded, 190 | array|string|null $types, 191 | array|string|null $connections, 192 | int $times, 193 | ) { 194 | $callback = fn () => null; 195 | 196 | $grammar = test()->mock(Grammar::class) 197 | ->shouldReceive('supportsSchemaTransactions')->andReturn($transactions) 198 | ->getMock(); 199 | 200 | if ($grammarLoaded) { 201 | test()->connection 202 | ->shouldReceive('getSchemaGrammar')->andReturn($grammar); 203 | } else { 204 | test()->connection 205 | ->shouldReceive('getSchemaGrammar')->andReturn(null, $grammar) 206 | ->shouldReceive('useDefaultSchemaGrammar'); 207 | } 208 | 209 | test()->connection 210 | ->shouldReceive('getDriverName')->andReturn('pgsql') 211 | ->shouldReceive('statement') 212 | ->times($times) 213 | ->withArgs(fn ($sql) => str_contains($sql, 'DROP')) 214 | ->shouldReceive('statement') 215 | ->times($times) 216 | ->withArgs(fn ($sql) => str_contains($sql, 'CREATE')); 217 | 218 | if ($transactions) { 219 | test()->connection 220 | ->shouldReceive('transaction') 221 | ->andReturnUsing(fn ($callback) => $callback()); 222 | } 223 | 224 | test()->manager->withoutEntities($callback, $types, $connections); 225 | })->with([ 226 | 'transactions' => true, 227 | 'no transactions' => false, 228 | ])->with([ 229 | 'grammar loaded' => true, 230 | 'grammar not loaded' => false, 231 | ])->with('typesAndConnections'); 232 | 233 | it('throws exception for unsupported driver', function () { 234 | test()->connection 235 | ->shouldReceive('getDriverName') 236 | ->andReturn('unknown'); 237 | 238 | test()->manager->create(new UserView()); 239 | })->throws(InvalidArgumentException::class, 'Unsupported driver [unknown].'); 240 | 241 | it('flushes the instance', function () { 242 | test()->connection 243 | ->shouldReceive('getDriverName')->times(2)->andReturn('sqlite') 244 | ->shouldReceive('statement') 245 | ->times(2) 246 | ->withArgs(fn ($sql) => str_contains($sql, 'CREATE')); 247 | 248 | test()->manager->create(UserView::class); 249 | test()->manager->flush(); 250 | test()->manager->create(UserView::class); 251 | }); 252 | -------------------------------------------------------------------------------- /src/SqlEntityManager.php: -------------------------------------------------------------------------------- 1 | , SqlEntity> 25 | */ 26 | class SqlEntityManager 27 | { 28 | use SortsTopologically; 29 | 30 | /** @var TEntities */ 31 | public Collection $entities; 32 | 33 | /** 34 | * The active connection instances. 35 | * 36 | * @var array 37 | */ 38 | protected array $connections = []; 39 | 40 | /** 41 | * The active grammar instances. 42 | * 43 | * @var array 44 | */ 45 | protected array $grammars = []; 46 | 47 | /** 48 | * The states of the entities. 49 | * 50 | * @var array, 'created'|'dropped'> 51 | */ 52 | protected array $states = []; 53 | 54 | /** @param Collection $entities */ 55 | public function __construct( 56 | Collection $entities, 57 | protected DatabaseManager $db, 58 | ) { 59 | $this->entities = $entities->keyBy(fn ($e) => $e::class); 60 | 61 | $sorted = $this->sortTopologically( 62 | $this->entities, 63 | fn ($e) => collect($e->dependencies())->map($this->get(...)), 64 | fn ($e) => $e::class, 65 | ); 66 | 67 | $this->entities = collect($sorted)->keyBy(fn ($e) => $e::class); 68 | } 69 | 70 | /** 71 | * Get the entity by class. 72 | * 73 | * @param class-string $class 74 | * @throws ItemNotFoundException 75 | */ 76 | public function get(string $class): SqlEntity 77 | { 78 | $entity = $this->entities->get($class); 79 | 80 | if ($entity === null) { 81 | throw new ItemNotFoundException("Entity [{$class}] not found."); 82 | } 83 | 84 | return $entity; 85 | } 86 | 87 | /** 88 | * Create an entity. 89 | * 90 | * @param class-string|SqlEntity $entity 91 | * @throws ItemNotFoundException 92 | */ 93 | public function create(SqlEntity|string $entity): void 94 | { 95 | if (is_string($entity)) { 96 | $entity = $this->get($entity); 97 | } 98 | 99 | if (($this->states[$entity::class] ?? null) === 'created') { 100 | return; 101 | } 102 | 103 | $connection = $this->connection($entity->connectionName()); 104 | 105 | if (! $entity->creating($connection)) { 106 | return; 107 | } 108 | 109 | foreach ($entity->dependencies() as $dependency) { 110 | $this->create($dependency); 111 | } 112 | 113 | $grammar = $this->grammar($connection); 114 | 115 | if (! $grammar->supportsEntity($entity)) { 116 | logger()->warning( 117 | sprintf( 118 | 'Skipping creation of entity [%s]: not supported by %s.', 119 | $entity::class, 120 | $grammar::class, 121 | ), 122 | ); 123 | 124 | return; 125 | } 126 | 127 | $connection->statement($grammar->compileCreate($entity)); 128 | $entity->created($connection); 129 | $this->states[$entity::class] = 'created'; 130 | } 131 | 132 | /** 133 | * Drop an entity. 134 | * 135 | * @param class-string|SqlEntity $entity 136 | * @throws ItemNotFoundException 137 | */ 138 | public function drop(SqlEntity|string $entity): void 139 | { 140 | if (is_string($entity)) { 141 | $entity = $this->get($entity); 142 | } 143 | 144 | if (($this->states[$entity::class] ?? null) === 'dropped') { 145 | return; 146 | } 147 | 148 | $connection = $this->connection($entity->connectionName()); 149 | 150 | if (! $entity->dropping($connection)) { 151 | return; 152 | } 153 | 154 | $grammar = $this->grammar($connection); 155 | 156 | if (! $grammar->supportsEntity($entity)) { 157 | logger()->warning( 158 | sprintf( 159 | 'Skipping dropping of entity [%s]: not supported by %s.', 160 | $entity::class, 161 | $grammar::class, 162 | ), 163 | ); 164 | 165 | return; 166 | } 167 | 168 | $connection->statement($grammar->compileDrop($entity)); 169 | $entity->dropped($connection); 170 | $this->states[$entity::class] = 'dropped'; 171 | } 172 | 173 | /** 174 | * Create all entities. 175 | * 176 | * @param array>|class-string|null $types 177 | * @param array|string|null $connections 178 | */ 179 | public function createAll( 180 | array|string|null $types = null, 181 | array|string|null $connections = null, 182 | ): void { 183 | $this->entities 184 | ->when($connections, $this->filterByConnections(...)) 185 | ->when($types, $this->filterByTypes(...)) 186 | ->each($this->create(...)); 187 | } 188 | 189 | /** 190 | * Drop all entities. 191 | * 192 | * @param array>|class-string|null $types 193 | * @param array|string|null $connections 194 | */ 195 | public function dropAll( 196 | array|string|null $types = null, 197 | array|string|null $connections = null, 198 | ): void { 199 | $this->entities 200 | ->reverse() 201 | ->when($connections, $this->filterByConnections(...)) 202 | ->when($types, $this->filterByTypes(...)) 203 | ->each($this->drop(...)); 204 | } 205 | 206 | /** 207 | * Execute a callback (in a transaction, if supported) without the specified entities. 208 | * 209 | * @param Closure(Connection): mixed $callback 210 | * @param array>|class-string|null $types 211 | * @param array|string|null $connections 212 | */ 213 | public function withoutEntities( 214 | Closure $callback, 215 | array|string|null $types = null, 216 | array|string|null $connections = null, 217 | ): void { 218 | $defaultConnection = $this->db->getDefaultConnection(); 219 | 220 | $groups = $this->entities 221 | ->when($connections, $this->filterByConnections(...)) 222 | ->when($types, $this->filterByTypes(...)) 223 | ->groupBy(fn ($e) => $e->connectionName() ?? $defaultConnection); 224 | 225 | foreach ($groups as $connectionName => $entities) { 226 | $connection = $this->connection($connectionName); 227 | 228 | $execute = function () use ($connection, $entities, $callback) { 229 | $entities 230 | ->reverse() 231 | ->each($this->drop(...)); 232 | 233 | $callback($connection); 234 | 235 | $entities->each($this->create(...)); 236 | }; 237 | 238 | /** @phpstan-ignore identical.alwaysFalse (bad phpdocs) */ 239 | if ($connection->getSchemaGrammar() === null) { 240 | $connection->useDefaultSchemaGrammar(); 241 | } 242 | 243 | $connection->getSchemaGrammar()->supportsSchemaTransactions() 244 | ? $connection->transaction($execute) 245 | : $execute(); 246 | } 247 | } 248 | 249 | /** Flush the entity manager instance. */ 250 | public function flush(): void 251 | { 252 | $this->connections = []; 253 | $this->grammars = []; 254 | $this->states = []; 255 | } 256 | 257 | /** 258 | * Filter entities by connection. 259 | * 260 | * @param TEntities $entities 261 | * @param array>|class-string $types 262 | * @return TEntities 263 | */ 264 | protected function filterByTypes( 265 | Collection $entities, 266 | array|string $types, 267 | ): Collection { 268 | return $entities->filter(function ($entity) use ($types) { 269 | foreach (Arr::wrap($types) as $type) { 270 | if (is_a($entity, $type, allow_string: false)) { 271 | return true; 272 | } 273 | } 274 | 275 | return false; 276 | }); 277 | } 278 | 279 | /** 280 | * Filter entities by connection. 281 | * 282 | * @param TEntities $entities 283 | * @param array|string $connections 284 | * @return TEntities 285 | */ 286 | protected function filterByConnections( 287 | Collection $entities, 288 | array|string $connections, 289 | ): Collection { 290 | $default = $this->db->getDefaultConnection(); 291 | 292 | return $entities->filter(function ($entity) use ($connections, $default) { 293 | $name = $entity->connectionName() ?? $default; 294 | 295 | return in_array($name, Arr::wrap($connections), strict: true); 296 | }); 297 | } 298 | 299 | protected function connection(?string $name): Connection 300 | { 301 | $name ??= $this->db->getDefaultConnection(); 302 | 303 | return $this->connections[$name] ??= $this->db->connection($name); 304 | } 305 | 306 | protected function grammar(Connection $connection): Grammar 307 | { 308 | $driver = $connection->getDriverName(); 309 | 310 | return $this->grammars[$driver] ??= $this->createGrammar($driver, $connection); 311 | } 312 | 313 | protected function createGrammar(string $driver, Connection $connection): Grammar 314 | { 315 | return match ($driver) { 316 | 'mariadb' => new MariaDbGrammar($connection), 317 | 'mysql' => new MySqlGrammar($connection), 318 | 'pgsql' => new PostgresGrammar($connection), 319 | 'sqlite' => new SQLiteGrammar($connection), 320 | 'sqlsrv' => new SqlServerGrammar($connection), 321 | default => throw new InvalidArgumentException("Unsupported driver [{$driver}]."), 322 | }; 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | SQL Entities 4 |

5 |

Manage SQL entities in Laravel with ease!

6 |

7 | Test Results 8 | Code Coverage 9 | License 10 | Packagist Version 11 | Total Downloads 12 |

13 |
14 | 15 | Laravel's schema builder and migration system are great for managing tables and 16 | indexes---but offer no built-in support for other SQL entities, such as 17 | (materialized) views, procedures, functions, and triggers. 18 | These often get handled via raw SQL in migrations, making them hard to manage, 19 | prone to unknown conflicts, and difficult to track over time. 20 | 21 | `laravel-sql-entities` solves this by offering: 22 | 23 | - 📦 Class-based definitions: bringing views, functions, triggers, and more into your application code. 24 | - 🧠 First-class source control: you can easily track changes, review diffs, and resolve conflicts. 25 | - 🧱 Decoupled grammars: letting you support multiple drivers without needing dialect-specific SQL. 26 | - 🔁 Lifecycle hooks: run logic at various points, enabling logging, auditing, and more. 27 | - 🚀 Batch operations: easily create or drop all entities in a single command or lifecycle event. 28 | - 🧪 Testability: definitions are just code so they’re easy to test, validate, and keep consistent. 29 | 30 | Whether you're managing reporting views, business logic functions, or automation 31 | triggers, this package helps you treat SQL entities like real, versioned parts 32 | of your codebase---no more scattered SQL in migrations! 33 | 34 | > [!NOTE] 35 | > Migration rollbacks are not supported since the definitions always reflect the latest state. 36 | > 37 | > ["We're never going backwards. You only go forward." -Taylor Otwell](https://www.twitch.tv/theprimeagen/clip/DrabAltruisticEggnogVoHiYo-f6CVkrqraPsWrEht) 38 | 39 | ## 📦 Installation 40 | 41 | First pull in the package using Composer: 42 | 43 | ```bash 44 | composer require calebdw/laravel-sql-entities 45 | ``` 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | The package looks for SQL entities under `database/entities/` so you might need to add 54 | a namespace to your `composer.json` file, for example: 55 | 56 | ```diff 57 | { 58 | "autoload": { 59 | "psr-4": { 60 | "App\\": "app/", 61 | + "Database\\Entities\\": "database/entities/", 62 | "Database\\Factories\\": "database/factories/", 63 | "Database\\Seeders\\": "database/seeders/" 64 | } 65 | } 66 | } 67 | ``` 68 | 69 | > [!TIP] 70 | > This package looks for any files matching `database/entities` in the application's 71 | > base path. This means it should automatically work for a modular setup where 72 | > the entities might be spread across multiple directories. 73 | 74 | 75 | 76 | ## 🛠️ Usage 77 | 78 | ### 🧱 SQL Entities 79 | 80 | To get started, create a new class in a `database/entities/` directory 81 | (structure is up to you) and extend the appropriate entity class (e.g. `View`, etc.). 82 | 83 | For example, to create a view for recent orders, you might create the following class: 84 | 85 | ```php 86 | select(['id', 'customer_id', 'status', 'created_at']) 103 | ->where('created_at', '>=', now()->subDays(30)) 104 | ->toBase(); 105 | 106 | // could also use raw SQL 107 | return <<<'SQL' 108 | SELECT id, customer_id, status, created_at 109 | FROM orders 110 | WHERE created_at >= NOW() - INTERVAL '30 days' 111 | SQL; 112 | } 113 | } 114 | ``` 115 | 116 | You can also override the name and connection: 117 | 118 | ```php 119 | connection->statement(<<name()} TO other_user; 160 | SQL); 161 | } 162 | 163 | #[Override] 164 | public function dropping(Connection $connection): bool 165 | { 166 | if (/** should not drop */) { 167 | return false; 168 | } 169 | 170 | /** other logic */ 171 | 172 | return true; 173 | } 174 | 175 | #[Override] 176 | public function dropped(Connection $connection): void 177 | { 178 | /** logic */ 179 | } 180 | } 181 | ``` 182 | 183 | #### ⚙️ Handling Dependencies 184 | 185 | Entities may depend on one another (e.g., a view that selects from another view). 186 | To support this, each entity can declare its dependencies using the `dependencies()` method: 187 | 188 | ```php 189 | where('created_at', '>=', now()->subDays(30)) 230 | ->get(); 231 | ``` 232 | 233 | 234 | 235 | #### 📐 Function 236 | 237 | The `Function_` class is used to create functions in the database. 238 | 239 | > [!TIP] 240 | > The class is named `Function_` as `function` is a reserved keyword in PHP. 241 | 242 | In addition to the options above, you can use the following options to further customize the function: 243 | 244 | ```php 245 | 310 | 311 | 312 | #### ⚡ Trigger 313 | 314 | The `Trigger` class is used to create triggers in the database. 315 | In addition to the options above, you can use the following options to further customize the trigger: 316 | 317 | ```php 318 | definition ?? <<<'SQL' 340 | EXECUTE FUNCTION record_account_audit(); 341 | SQL; 342 | } 343 | } 344 | ``` 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | ### 🧠 Manager 355 | 356 | The `SqlEntityManager` singleton is responsible for creating and dropping SQL entities at runtime. 357 | You can interact with it directly, or use the `SqlEntity` facade for convenience. 358 | 359 | ```php 360 | create(RecentOrdersView::class); 368 | resolve('sql-entities')->create(new RecentOrdersView()); 369 | 370 | // Similarly, you can drop a single entity using the class or instance 371 | SqlEntity::drop(RecentOrdersView::class); 372 | 373 | // Create or drop all entities 374 | SqlEntity::createAll(); 375 | SqlEntity::dropAll(); 376 | 377 | // You can also filter by type or connection 378 | SqlEntity::createAll(types: View::class, connections: 'reporting'); 379 | SqlEntity::dropAll(types: View::class, connections: 'reporting'); 380 | ``` 381 | 382 | #### ♻️ `withoutEntities()` 383 | 384 | Sometimes you need to run a block of logic (like renaming a table column) *without certain SQL entities present*. 385 | The `withoutEntities()` method temporarily drops the selected entities, executes your callback, and then recreates them afterward. 386 | 387 | If the database connection supports **schema transactions**, the entire operation is wrapped in one. 388 | 389 | ```php 390 | getSchemaBuilder()->table('orders', function ($table) { 396 | $table->renameColumn('old_customer_id', 'customer_id'); 397 | }); 398 | }); 399 | ``` 400 | 401 | You can also restrict the scope to certain entity types or connections: 402 | 403 | ```php 404 | getSchemaBuilder()->table('orders', function ($table) { 411 | $table->renameColumn('old_customer_id', 'customer_id'); 412 | }); 413 | }, 414 | types: [RecentOrdersView::class, RecentHighValueOrdersView::class], 415 | connections: ['reporting'], 416 | ); 417 | ``` 418 | 419 | After the callback, all affected entities are automatically recreated in dependency order. 420 | 421 | ### 💻 Console Commands 422 | 423 | The package provides console commands to create and drop your SQL entities. 424 | 425 | ```bash 426 | php artisan sql-entities:create [entities] [--connection=CONNECTION ...] 427 | 428 | # Create all entities 429 | php artisan sql-entities:create 430 | # Create a specific entity 431 | php artisan sql-entities:create 'Database\Entities\Views\RecentOrdersView' 432 | # Create all entities on a specific connection 433 | php artisan sql-entities:create -c reporting 434 | 435 | # Similarly, drop all entities 436 | php artisan sql-entities:drop 437 | ``` 438 | 439 | ### 🚀 Automatic syncing when migrating (Optional) 440 | 441 | You may want to automatically drop all SQL entities before migrating, and then 442 | recreate them after the migrations are complete. This is helpful when the entities 443 | depend on schema changes. To do this, register the built-in subscriber in a service provider: 444 | 445 | ```php 446 |