diff --git a/CHANGELOG.md b/CHANGELOG.md index b1142ff..988ee6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to `model-required-fields` will be documented in this file. +## 3.2.0 - 2025-11-07 + +### What's Changed + +* Exclude event filled fields by model observers, boot events, and event listeners to know really what are required fields and what are application defatult fields by @WatheqAlshowaiter in https://github.com/WatheqAlshowaiter/model-fields/pull/36 + +**Full Changelog**: https://github.com/WatheqAlshowaiter/model-fields/compare/3.1.8...3.2.0 + ## 3.1.8 - 2025-10-05 * Update Readme diff --git a/README.md b/README.md index 4cd44c8..706e56e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ [link-palestine]: https://github.com/TheBSD/StandWithPalestine/blob/main/docs/README.md -Quickly retrieve **required**, **nullable**, and **default** fields for any Laravel model. Think that's simple? You probably haven’t faced the legacy projects I have. :). +Quickly retrieve **required**, **nullable**, and **default** fields for any Laravel model. Think that's simple? You +probably haven’t faced the legacy projects I have. :). > [!Note] > This is the documentation for version 3, if you want the version 1 or version 2 documentations go @@ -153,6 +154,62 @@ Post::requiredFields(); // returns ['user_id', 'ulid', 'title', 'description'] php artisan model:fields App\\Models\\Post --required # or -r ``` +#### Observer and Event-Filled Fields + +Fields that are automatically filled when creating by model observers, boot events, and event listeners are +automatically excluded from required fields. + +The package supports three patterns: + +- **Boot method closures:** `self::creating()`, `self::saving()` +- **Observer pattern:** `PostObserver` class +- **Dispatched events:** `$dispatchesEvents` property + +For example, given this model: + +```php +class Post extends Model +{ + protected $dispatchesEvents = [ + // a dispatched event trigger listener that fills the `user_id` field + 'creating' => PostCreatingEvent::class, + ]; + + protected static function boot() + { + parent::boot(); + self::observe(PostObserver::class); + + self::creating(function ($model) { + $model->ulid = Str::ulid(); + }); + + self::saving(function ($model) { + $model->title = 'default title' + }); + } +} + +class PostObserver +{ + public function creating(Post $model): void + { + $model->description = 'default description'; + } + + public function saving(Post $model): void + { + $model->description = 'default saving description'; + } +} +``` + +```php +Post::requiredFields(); + +// returns [] because it excludes auto-filled fields +``` + ### And more We have the flexibility to get all fields, required fields, nullable fields, primary key, database default fields, @@ -236,18 +293,54 @@ Fields::model(Post::class)->applicationDefaultFields(); //or Post::applicationDefaultFields(); -// If there is default attributes in the model +// If there are default attributes in the model class Post extends Model { protected $attributes = [ 'title' => 'default title', - 'description' => 'default description', + 'description' => null, // will be ignored + ]; + + protected $dispatchesEvents = [ + // if there is a field autofilled by this event, + // then it will be added to the application default fields + 'creating' => PostCreatingEvent::class, ]; + + // or any event-filled fields + protected static function boot(): void + { + parent::boot(); + self::observe(PostObserver::class); + + self::creating(function ($model) { + $model->uuid = Str::uuid(); + }); + + self::saving(function ($model) { + $model->ulid = Str::ulid(); + }); + } +} + +// the same in the observer class +class PostObserver +{ + + public function creating(Post $model): void + { + // .. + } + + public function saving(Post $model): void + { + // .. + } } // returns // [ -// 'title', 'description', +// 'title', 'uuid', 'ulid', // ] ``` @@ -269,8 +362,16 @@ class Post extends Model { protected $attributes = [ 'title' => 'default title', - 'description' => 'default description', ]; + + protected static function boot(): void + { + parent::boot(); + + self::creating(function ($model) { + $model->description = 'default description'; + }); + } } // returns @@ -307,7 +408,7 @@ php artisan model:fields \\Modules\\Order\\src\\Models\\Order php artisan model:fields "Modules\Order\src\Models\Order" ``` -- You have 3 output formats: list, json and table. the list is the default +- You have 3 output formats: list, json, and table. the list is the default ```sh php artisan model:fields User --format=json @@ -378,8 +479,10 @@ them. ## Related Packages -- **[Backup Tables](https://github.com/WatheqAlshowaiter/backup-tables)** - Backup single or multiple database tables with ease. -- **[Filament Sticky Table Header](https://github.com/WatheqAlshowaiter/filament-sticky-table-header)** - Make Filament table headers stick when scrolling for better UX. +- **[Backup Tables](https://github.com/WatheqAlshowaiter/backup-tables)** - Backup single or multiple database tables + with ease. +- **[Filament Sticky Table Header](https://github.com/WatheqAlshowaiter/filament-sticky-table-header)** - Make Filament + table headers stick when scrolling for better UX. ## Credits diff --git a/src/FieldsService.php b/src/FieldsService.php index 7631ea3..b19ed03 100644 --- a/src/FieldsService.php +++ b/src/FieldsService.php @@ -83,6 +83,7 @@ public function requiredFields() } $modelDefaultAttributes = Helpers::getModelDefaultAttributes($this->modelClass); + $observerDefaultAttributes = Helpers::getObserverFilledFields($this->modelClass); $primaryIndex = $this->primaryField(); @@ -105,6 +106,9 @@ public function requiredFields() ->reject(function ($column) use ($modelDefaultAttributes) { return in_array($column['name'], $modelDefaultAttributes); }) + ->reject(function ($column) use ($observerDefaultAttributes) { + return in_array($column['name'], $observerDefaultAttributes); + }) ->pluck('name') ->unique() ->values() @@ -256,12 +260,13 @@ public function applicationDefaultFields() $this->throwIfNotUsingModelMethodFirst(); $modelInstance = new $this->modelClass; - $attributes = $modelInstance->getAttributes(); + $attributes = collect($modelInstance->getAttributes())->filter()->keys()->toArray(); // ignore null values + $observerDefaultAttributes = Helpers::getObserverFilledFields($this->modelClass); $allFields = $this->allFields(); return collect($attributes) - ->keys() + ->merge($observerDefaultAttributes) ->filter(function ($field) use ($allFields) { return in_array($field, $allFields); }) @@ -374,6 +379,7 @@ protected function requiredFieldsForSqlite() { $table = Helpers::getTableFromThisModel($this->modelClass); $modelDefaultAttributes = Helpers::getModelDefaultAttributes($this->modelClass); + $observerDefaultAttributes = Helpers::getObserverFilledFields($this->modelClass); $queryResult = DB::select(/** @lang SQLite */ "PRAGMA table_info($table)"); @@ -389,6 +395,9 @@ protected function requiredFieldsForSqlite() ->reject(function ($column) use ($modelDefaultAttributes) { return in_array($column['name'], $modelDefaultAttributes); }) + ->reject(function ($column) use ($observerDefaultAttributes) { + return in_array($column['name'], $observerDefaultAttributes); + }) ->pluck('name') ->toArray(); } @@ -481,6 +490,7 @@ protected function requiredFieldsForMysqlAndMariaDb() { $table = Helpers::getTableFromThisModel($this->modelClass); $modelDefaultAttributes = Helpers::getModelDefaultAttributes($this->modelClass); + $observerDefaultAttributes = Helpers::getObserverFilledFields($this->modelClass); $queryResult = DB::select( /** @lang SQLite */ " @@ -519,6 +529,9 @@ protected function requiredFieldsForMysqlAndMariaDb() ->reject(function ($column) use ($modelDefaultAttributes) { return in_array($column['name'], $modelDefaultAttributes); }) + ->reject(function ($column) use ($observerDefaultAttributes) { + return in_array($column['name'], $observerDefaultAttributes); + }) ->pluck('name') ->toArray(); } @@ -803,6 +816,7 @@ protected function requiredFieldsForPostgres() { $table = Helpers::getTableFromThisModel($this->modelClass); $modelDefaultAttributes = Helpers::getModelDefaultAttributes($this->modelClass); + $observerDefaultAttributes = Helpers::getObserverFilledFields($this->modelClass); $primaryIndex = DB::select(/** @lang PostgreSQL */ " SELECT @@ -868,6 +882,9 @@ protected function requiredFieldsForPostgres() ->reject(function ($column) use ($modelDefaultAttributes) { return in_array($column['name'], $modelDefaultAttributes); }) + ->reject(function ($column) use ($observerDefaultAttributes) { + return in_array($column['name'], $observerDefaultAttributes); + }) ->pluck('name') ->unique() ->toArray(); @@ -956,6 +973,7 @@ protected function requiredFieldsForSqlServer() { $table = Helpers::getTableFromThisModel($this->modelClass); $modelDefaultAttributes = Helpers::getModelDefaultAttributes($this->modelClass); + $observerDefaultAttributes = Helpers::getObserverFilledFields($this->modelClass); $primaryIndex = DB::select(/** @lang TSQL */ ' SELECT @@ -1006,6 +1024,9 @@ protected function requiredFieldsForSqlServer() ->reject(function ($column) use ($modelDefaultAttributes) { return in_array($column['name'], $modelDefaultAttributes); }) + ->reject(function ($column) use ($observerDefaultAttributes) { + return in_array($column['name'], $observerDefaultAttributes); + }) ->pluck('name') ->toArray(); } diff --git a/src/ModelFieldsServiceProvider.php b/src/ModelFieldsServiceProvider.php index de754a8..3c5e599 100644 --- a/src/ModelFieldsServiceProvider.php +++ b/src/ModelFieldsServiceProvider.php @@ -169,6 +169,7 @@ public function boot() $table = Helpers::getTableFromThisModel($this->getModel()); $modelDefaultAttributes = Helpers::getModelDefaultAttributes($this->getModel()); + $observerDefaultAttributes = Helpers::getObserverFilledFields($this->getModel()); $primaryIndex = $this->primaryField(); @@ -191,6 +192,9 @@ public function boot() ->reject(function ($column) use ($modelDefaultAttributes) { return in_array($column['name'], $modelDefaultAttributes); }) + ->reject(function ($column) use ($observerDefaultAttributes) { + return in_array($column['name'], $observerDefaultAttributes); + }) ->pluck('name') ->unique() ->values() @@ -590,12 +594,13 @@ public function boot() Builder::macro('applicationDefaultFields', function () { $modelClass = $this->getModel(); $modelInstance = new $modelClass; - $attributes = $modelInstance->getAttributes(); + $attributes = collect($modelInstance->getAttributes())->filter()->keys()->toArray(); // ignore null values + $observerDefaultAttributes = Helpers::getObserverFilledFields($this->getModel()); $allFields = $this->allFields(); return collect($attributes) - ->keys() + ->merge($observerDefaultAttributes) ->filter(function ($field) use ($allFields) { return in_array($field, $allFields); }) @@ -755,6 +760,7 @@ public function boot() Builder::macro('requiredFieldsForSqlite', function () { $table = Helpers::getTableFromThisModel($this->getModel()); $modelDefaultAttributes = Helpers::getModelDefaultAttributes($this->getModel()); + $observerDefaultAttributes = Helpers::getObserverFilledFields($this->getModel()); $queryResult = DB::select(/** @lang SQLite */ "PRAGMA table_info($table)"); @@ -770,6 +776,9 @@ public function boot() ->reject(function ($column) use ($modelDefaultAttributes) { return in_array($column['name'], $modelDefaultAttributes); }) + ->reject(function ($column) use ($observerDefaultAttributes) { + return in_array($column['name'], $observerDefaultAttributes); + }) ->pluck('name') ->toArray(); }); @@ -777,6 +786,7 @@ public function boot() Builder::macro('requiredFieldsForMysqlAndMariaDb', function () { $table = Helpers::getTableFromThisModel($this->getModel()); $modelDefaultAttributes = Helpers::getModelDefaultAttributes($this->getModel()); + $observerDefaultAttributes = Helpers::getObserverFilledFields($this->getModel()); $queryResult = DB::select( /** @lang SQLite */ " @@ -815,6 +825,9 @@ public function boot() ->reject(function ($column) use ($modelDefaultAttributes) { return in_array($column['name'], $modelDefaultAttributes); }) + ->reject(function ($column) use ($observerDefaultAttributes) { + return in_array($column['name'], $observerDefaultAttributes); + }) ->pluck('name') ->toArray(); }); @@ -822,6 +835,7 @@ public function boot() Builder::macro('requiredFieldsForPostgres', function () { $table = Helpers::getTableFromThisModel($this->getModel()); $modelDefaultAttributes = Helpers::getModelDefaultAttributes($this->getModel()); + $observerDefaultAttributes = Helpers::getObserverFilledFields($this->getModel()); $primaryIndex = DB::select(/** @lang PostgreSQL */ " SELECT @@ -887,6 +901,9 @@ public function boot() ->reject(function ($column) use ($modelDefaultAttributes) { return in_array($column['name'], $modelDefaultAttributes); }) + ->reject(function ($column) use ($observerDefaultAttributes) { + return in_array($column['name'], $observerDefaultAttributes); + }) ->pluck('name') ->unique() ->toArray(); @@ -895,6 +912,7 @@ public function boot() Builder::macro('requiredFieldsForSqlServer', function () { $table = Helpers::getTableFromThisModel($this->getModel()); $modelDefaultAttributes = Helpers::getModelDefaultAttributes($this->getModel()); + $observerDefaultAttributes = Helpers::getObserverFilledFields($this->getModel()); $primaryIndex = DB::select(/** @lang TSQL */ ' SELECT @@ -945,6 +963,9 @@ public function boot() ->reject(function ($column) use ($modelDefaultAttributes) { return in_array($column['name'], $modelDefaultAttributes); }) + ->reject(function ($column) use ($observerDefaultAttributes) { + return in_array($column['name'], $observerDefaultAttributes); + }) ->pluck('name') ->toArray(); }); diff --git a/src/Support/Helpers.php b/src/Support/Helpers.php index c64ddd3..d3926db 100644 --- a/src/Support/Helpers.php +++ b/src/Support/Helpers.php @@ -2,10 +2,12 @@ namespace WatheqAlshowaiter\ModelFields\Support; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Event; /** - * Here are the shared logic across multiple files, now are ModelFieldsService & ModelFieldsServiceProvider + * Here are the shared logic across multiple files, now are FieldsService & ModelFieldsServiceProvider */ class Helpers { @@ -31,4 +33,34 @@ public static function getTableFromThisModel($model) return str_replace('.', '__', $table); } + + /** + * Get fields that are automatically filled by model observers/events + * during 'creating' and 'saving' events + * + * + * @return string[] + */ + public static function getObserverFilledFields($modelOrClass) + { + if ($modelOrClass instanceof Model) { + $model = $modelOrClass->newInstance(); // fresh instance of same model + $modelClass = get_class($modelOrClass); + } else { + $model = new $modelOrClass; + $modelClass = $modelOrClass; + } + + // ensure clean baseline + $model->syncOriginal(); + + // fire the creating events (observer + model booted events) + Event::dispatch("eloquent.creating: {$modelClass}", $model); + Event::dispatch("eloquent.saving: {$modelClass}", $model); + + $dirty = $model->getDirty(); + $dirtyNoNull = array_filter($dirty); // exclude null values + + return array_keys($dirtyNoNull); + } } diff --git a/tests/FieldsTest.php b/tests/FieldsTest.php index 457bce2..0c28abc 100644 --- a/tests/FieldsTest.php +++ b/tests/FieldsTest.php @@ -17,6 +17,7 @@ use WatheqAlshowaiter\ModelFields\Tests\Models\Mother; use WatheqAlshowaiter\ModelFields\Tests\Models\Someone; use WatheqAlshowaiter\ModelFields\Tests\Models\Son; +use WatheqAlshowaiter\ModelFields\Tests\Models\Uncle; class FieldsTest extends TestCase { @@ -219,6 +220,16 @@ public function test_required_fields_for_brother_model() $this->assertEquals($expected, Brother::requiredFieldsForOlderVersions()); } + public function test_required_fields_for_uncle_model() + { + $expected = []; + + $this->assertEquals($expected, Fields::model(Uncle::class)->requiredFields()); + $this->assertEquals($expected, Fields::model(Uncle::class)->requiredFieldsForOlderVersions()); + $this->assertEquals($expected, Uncle::requiredFields()); + $this->assertEquals($expected, Uncle::requiredFieldsForOlderVersions()); + } + public function test_nullable_fields_for_father_model() { $expected = [ @@ -345,6 +356,22 @@ public function test_application_default_fields_for_brother_model() $this->assertEquals($expected, Brother::applicationDefaultFields()); } + public function test_application_default_fields_for_uncle_model() + { + $expected = [ + 'attribute_field', + 'event_creating', + 'observer_creating', + 'boot_creating', + 'event_saving', + 'observer_saving', + 'boot_saving', + ]; + + $this->assertEquals($expected, Fields::model(Uncle::class)->applicationDefaultFields()); + $this->assertEquals($expected, Uncle::applicationDefaultFields()); + } + public function test_default_fields_for_mother_model() { $expected = [ @@ -374,6 +401,22 @@ public function test_default_fields_for_brother_model() $this->assertEquals($expected, Brother::defaultFields()); } + public function test_default_fields_for_uncle_model() + { + $expected = [ + 'attribute_field', + 'event_creating', + 'observer_creating', + 'boot_creating', + 'event_saving', + 'observer_saving', + 'boot_saving', + ]; + + $this->assertEquals($expected, Fields::model(Uncle::class)->defaultFields()); + $this->assertEquals($expected, Uncle::defaultFields()); + } + /** * @throws ReflectionException */ diff --git a/tests/Listeners/UncleCreatingListener.php b/tests/Listeners/UncleCreatingListener.php new file mode 100644 index 0000000..08dcedf --- /dev/null +++ b/tests/Listeners/UncleCreatingListener.php @@ -0,0 +1,13 @@ +model->event_creating = 'creating'; + } +} diff --git a/tests/Listeners/UncleSavingListener.php b/tests/Listeners/UncleSavingListener.php new file mode 100644 index 0000000..0d65494 --- /dev/null +++ b/tests/Listeners/UncleSavingListener.php @@ -0,0 +1,13 @@ +model->event_saving = 'saving'; + } +} diff --git a/tests/Models/Brother.php b/tests/Models/Brother.php index c7df1e3..a4ad4ea 100644 --- a/tests/Models/Brother.php +++ b/tests/Models/Brother.php @@ -11,5 +11,6 @@ class Brother extends Model 'another' => '', // non-valid default 'number' => '0000', // default for nullable field 'non-existed-field' => 'some-random-value', // non-existed field in the database + 'address' => null, // non useful default - should be ignored ]; } diff --git a/tests/Models/Uncle.php b/tests/Models/Uncle.php new file mode 100644 index 0000000..fbcf78c --- /dev/null +++ b/tests/Models/Uncle.php @@ -0,0 +1,65 @@ + 'default-value', + ]; + + protected $dispatchesEvents = [ + 'creating' => UncleCreating::class, // fill `event_creating` field + 'saving' => UncleSaving::class, // fill `event_saving` field + ]; + + protected static function boot(): void + { + parent::boot(); + + self::observe(UncleObserver::class); + + self::creating(function ($model) { + $model->boot_creating = 'creating'; + }); + + self::saving(function ($model) { + $model->boot_saving = 'saving'; + }); + } +} + +class UncleObserver +{ + public function creating(Uncle $model): void + { + $model->observer_creating = 'creating'; + } + + public function saving(Uncle $model): void + { + $model->observer_saving = 'saving'; + } +} + +class UncleCreating +{ + public Uncle $model; + + public function __construct(Uncle $model) + { + $this->model = $model; + } +} + +class UncleSaving +{ + public Uncle $model; + + public function __construct(Uncle $model) + { + $this->model = $model; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index e6b4793..af204c4 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,14 +2,37 @@ namespace WatheqAlshowaiter\ModelFields\Tests; +use Illuminate\Support\Facades\Event; use Orchestra\Testbench\TestCase as Orchestra; use WatheqAlshowaiter\ModelFields\ModelFieldsServiceProvider; +use WatheqAlshowaiter\ModelFields\Tests\Listeners\UncleCreatingListener; +use WatheqAlshowaiter\ModelFields\Tests\Listeners\UncleSavingListener; +use WatheqAlshowaiter\ModelFields\Tests\Models\UncleCreating; +use WatheqAlshowaiter\ModelFields\Tests\Models\UncleSaving; class TestCase extends Orchestra { protected function setUp(): void { parent::setUp(); + + // Register event listeners for Uncle model events + Event::listen(UncleCreating::class, UncleCreatingListener::class); + Event::listen(UncleSaving::class, UncleSavingListener::class); + + // Register listeners for the eloquent events that the package uses for detection + Event::listen( + 'eloquent.creating: WatheqAlshowaiter\ModelFields\Tests\Models\Uncle', + function ($model) { + (new UncleCreatingListener)->handle(new UncleCreating($model)); + } + ); + Event::listen( + 'eloquent.saving: WatheqAlshowaiter\ModelFields\Tests\Models\Uncle', + function ($model) { + (new UncleSavingListener)->handle(new UncleSaving($model)); + } + ); } protected function getPackageProviders($app) diff --git a/tests/database/migrations/2025_02_14_100247_create_brothers_table.php b/tests/database/migrations/2025_02_14_100247_create_brothers_table.php index 86bd79d..a1f0a61 100644 --- a/tests/database/migrations/2025_02_14_100247_create_brothers_table.php +++ b/tests/database/migrations/2025_02_14_100247_create_brothers_table.php @@ -13,6 +13,7 @@ public function up(): void $table->string('email'); // required $table->string('name'); // default => ignored because it has the default value in the model $attributes $table->string('number')->nullable(); // nullable, with default in model + $table->string('address'); }); } diff --git a/tests/database/migrations/2025_11_07_100247_create_uncles_table.php b/tests/database/migrations/2025_11_07_100247_create_uncles_table.php new file mode 100644 index 0000000..7df9e1f --- /dev/null +++ b/tests/database/migrations/2025_11_07_100247_create_uncles_table.php @@ -0,0 +1,27 @@ +string('boot_creating'); // filled by creating boot method in model + $table->string('boot_saving'); // filled by saving boot method in model + $table->string('observer_creating'); // filled by observer creating + $table->string('observer_saving'); // filled by observer saving + $table->string('event_creating'); // filled by event creating + $table->string('event_saving'); // filled by event saving + $table->string('attribute_field'); // filled by event saving + }); + } + + public function down(): void + { + Schema::dropIfExists('uncles'); + } +} diff --git a/todos.md b/todos.md index cff7cd7..0936275 100644 --- a/todos.md +++ b/todos.md @@ -67,5 +67,5 @@ - [x] test opening the url (mocking with reflectionClass) - [x] make sure $this->input->isInteractive() it enough - [ ] add new changes in using the terminal to the docs -- [ ] (next version) exclude from required fields that are filled in "creating" observers/events and add test cases for +- [x] exclude from required fields that are filled in "creating/saving" observers/events and add test cases for that