From f19ae7ce0b5d82a0dd2081cdb5515d7d60f2b206 Mon Sep 17 00:00:00 2001 From: Hosmel Quintana Date: Sun, 10 Aug 2025 12:36:50 -0600 Subject: [PATCH] driver based implementation --- config/model-preferences.php | 37 +++- .../migrations/create_preferences_table.php | 4 +- phpstan.neon.dist | 3 + pint.json | 5 +- src/Contracts/HasPreferences.php | 22 -- src/Contracts/PreferenceDriver.php | 37 ++++ src/Contracts/PreferenceRepository.php | 66 ------ src/Drivers/ColumnDriver.php | 125 +++++++++++ src/Drivers/SeparateTableDriver.php | 128 ++++++++++++ src/Drivers/SharedTableDriver.php | 129 ++++++++++++ src/Facades/Preferences.php | 28 +++ .../Concerns/InteractsWithPreferences.php | 197 +++--------------- src/Models/Preference.php | 56 ----- src/PendingPreferenceInteraction.php | 125 +++++++++++ src/PreferencesManager.php | 142 +++++++++++++ src/PreferencesServiceProvider.php | 14 +- src/PreferencesStore.php | 26 +++ src/Repositories/PreferenceRepository.php | 94 --------- tests/Features/EnumSupportTest.php | 85 ++++++++ .../Concerns/InteractsWithPreferencesTest.php | 164 +++++---------- tests/Models/PreferenceTest.php | 16 -- .../Repositories/PreferenceRepositoryTest.php | 145 ------------- workbench/app/Models/Team.php | 3 +- workbench/app/Models/User.php | 3 +- 24 files changed, 957 insertions(+), 697 deletions(-) delete mode 100644 src/Contracts/HasPreferences.php create mode 100644 src/Contracts/PreferenceDriver.php delete mode 100644 src/Contracts/PreferenceRepository.php create mode 100644 src/Drivers/ColumnDriver.php create mode 100644 src/Drivers/SeparateTableDriver.php create mode 100644 src/Drivers/SharedTableDriver.php create mode 100644 src/Facades/Preferences.php delete mode 100644 src/Models/Preference.php create mode 100644 src/PendingPreferenceInteraction.php create mode 100644 src/PreferencesManager.php create mode 100644 src/PreferencesStore.php delete mode 100644 src/Repositories/PreferenceRepository.php create mode 100644 tests/Features/EnumSupportTest.php delete mode 100644 tests/Models/PreferenceTest.php delete mode 100644 tests/Repositories/PreferenceRepositoryTest.php diff --git a/config/model-preferences.php b/config/model-preferences.php index 75c14cc..f670108 100644 --- a/config/model-preferences.php +++ b/config/model-preferences.php @@ -2,35 +2,50 @@ declare(strict_types=1); -use HosmelQ\ModelPreferences\Models\Preference; - return [ /* |-------------------------------------------------------------------------- - | Preferences Table Name + | Default Preference Driver |-------------------------------------------------------------------------- | - | This is the name of the table that will be used to store preferences. - | You can change this to any table name you prefer. + | This option controls the default preference store that will be used. + | This connection is utilized if no other store is explicitly specified + | when running a preference operation within the application. + | + | Supported: "column", "separate", "shared" | */ - 'table' => env('MODEL_PREFERENCES_TABLE', 'preferences'), + 'default' => env('MODEL_PREFERENCES_DRIVER', 'shared'), /* |-------------------------------------------------------------------------- - | Model Configuration + | Preference Stores |-------------------------------------------------------------------------- | - | Specify the model classes to use for preferences. You can extend - | the default models to add custom functionality if needed. + | Here, you can define all the preference stores for your application + | along with their respective drivers. | */ - 'models' => [ + 'stores' => [ + + 'column' => [ + 'driver' => 'column', + 'name' => env('MODEL_PREFERENCES_COLUMN_NAME', 'preferences'), + ], + + 'separate' => [ + 'connection' => env('MODEL_PREFERENCES_DB_CONNECTION'), + 'driver' => 'separate', + ], - 'preference' => Preference::class, + 'shared' => [ + 'connection' => env('MODEL_PREFERENCES_DB_CONNECTION'), + 'driver' => 'shared', + 'table' => env('MODEL_PREFERENCES_TABLE', 'preferences'), + ], ], diff --git a/database/migrations/create_preferences_table.php b/database/migrations/create_preferences_table.php index 58bb2fc..8ad157e 100644 --- a/database/migrations/create_preferences_table.php +++ b/database/migrations/create_preferences_table.php @@ -13,7 +13,7 @@ */ public function down(): void { - Schema::dropIfExists(Config::string('model-preferences.table')); + Schema::dropIfExists(Config::string('model-preferences.stores.shared.table')); } /** @@ -21,7 +21,7 @@ public function down(): void */ public function up(): void { - Schema::create(Config::string('model-preferences.table'), function (Blueprint $table) { + Schema::create(Config::string('model-preferences.stores.shared.table'), function (Blueprint $table) { $table->id(); $table->morphs('preferable'); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index f694fc7..7c668a0 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,10 +5,13 @@ includes: - vendor/spaze/phpstan-disallowed-calls/disallowed-loose-calls.neon parameters: + checkAuthCallsWhenInRequestScope: true checkBenevolentUnionTypes: true + checkConfigTypes: true checkModelProperties: true checkOctaneCompatibility: true editorUrl: 'phpstorm://open?file=%%file%%&line=%%line%%' + noUnnecessaryEnumerableToArrayCalls: true errorFormat: ticketswap level: max noEnvCallsOutsideOfConfig: true diff --git a/pint.json b/pint.json index 60a0f2c..d75c19e 100644 --- a/pint.json +++ b/pint.json @@ -54,6 +54,9 @@ "no_useless_else": true, "no_whitespace_before_comma_in_array": true, "not_operator_with_successor_space": true, + "nullable_type_declaration": { + "syntax": "union" + }, "object_operator_without_whitespace": true, "ordered_class_elements": { "order": [ @@ -85,6 +88,7 @@ }, "ordered_interfaces": true, "ordered_traits": true, + "ordered_types": true, "phpdoc_add_missing_param_annotation": true, "phpdoc_align": { "align": "left", @@ -93,7 +97,6 @@ "phpdoc_indent": true, "phpdoc_inline_tag_normalizer": true, "phpdoc_line_span": true, - "phpdoc_list_type": false, "phpdoc_no_useless_inheritdoc": true, "phpdoc_order": { "order": ["param", "return", "throws"] diff --git a/src/Contracts/HasPreferences.php b/src/Contracts/HasPreferences.php deleted file mode 100644 index d614690..0000000 --- a/src/Contracts/HasPreferences.php +++ /dev/null @@ -1,22 +0,0 @@ - - */ - public function preferences(): MorphMany; -} diff --git a/src/Contracts/PreferenceDriver.php b/src/Contracts/PreferenceDriver.php new file mode 100644 index 0000000..566bced --- /dev/null +++ b/src/Contracts/PreferenceDriver.php @@ -0,0 +1,37 @@ + + */ + public function all(Model $model): array; + + /** + * Delete a preference for a model. + */ + public function delete(Model $model, string $key): void; + + /** + * Get a preference value for a model. + */ + public function get(Model $model, string $key): mixed; + + /** + * Check if a preference exists for a model. + */ + public function has(Model $model, string $key): bool; + + /** + * Set a preference value for a model. + */ + public function set(Model $model, string $key, mixed $value): void; +} diff --git a/src/Contracts/PreferenceRepository.php b/src/Contracts/PreferenceRepository.php deleted file mode 100644 index f17b9fc..0000000 --- a/src/Contracts/PreferenceRepository.php +++ /dev/null @@ -1,66 +0,0 @@ - $model - * - * @return array - */ - public function all(HasPreferences $model): array; - - /** - * Delete a preference by key for a model. - * - * @param HasPreferences $model - */ - public function delete(HasPreferences $model, string $key): void; - - /** - * Delete multiple preferences by keys for a model. - * - * @param HasPreferences $model - * @param list $keys - */ - public function deleteMany(HasPreferences $model, array $keys): void; - - /** - * Get a preference value for a model. - * - * @param HasPreferences $model - */ - public function get(HasPreferences $model, string $key): mixed; - - /** - * Check if a preference key exists for a model. - * - * @param HasPreferences $model - */ - public function has(HasPreferences $model, string $key): bool; - - /** - * Set a preference for a model. - * - * @param HasPreferences $model - */ - public function set(HasPreferences $model, string $key, mixed $value): void; - - /** - * Set multiple preferences for a model. - * - * @param HasPreferences $model - * @param array $preferences - */ - public function setMany(HasPreferences $model, array $preferences): void; -} diff --git a/src/Drivers/ColumnDriver.php b/src/Drivers/ColumnDriver.php new file mode 100644 index 0000000..f6ec116 --- /dev/null +++ b/src/Drivers/ColumnDriver.php @@ -0,0 +1,125 @@ + + */ + public function all(Model $model): array + { + return $this->getPreferences($model); + } + + /** + * Delete a preference for a model. + */ + public function delete(Model $model, string $key): void + { + $preferences = $this->getPreferences($model); + + unset($preferences[$key]); + + $this->savePreferences($model, $preferences); + } + + /** + * Get a preference value for a model. + */ + public function get(Model $model, string $key): mixed + { + $preferences = $this->getPreferences($model); + + return $preferences[$key] ?? null; + } + + /** + * Check if a preference exists for a model. + */ + public function has(Model $model, string $key): bool + { + $preferences = $this->getPreferences($model); + + return array_key_exists($key, $preferences); + } + + /** + * Set a preference value for a model. + */ + public function set(Model $model, string $key, mixed $value): void + { + $preferences = $this->getPreferences($model); + + $preferences[$key] = $value; + + $this->savePreferences($model, $preferences); + } + + /** + * Get the default column name from config. + */ + protected function getDefaultColumnName(): string + { + $columnName = $this->config->get(sprintf('model-preferences.stores.%s.name', $this->name)); + + return is_string($columnName) ? $columnName : 'preferences'; + } + + /** + * Get all preferences from the model's JSON column. + * + * @return array + */ + protected function getPreferences(Model $model): array + { + $columnName = method_exists($model, 'getPreferencesColumn') + ? $model->getPreferencesColumn() + : $this->getDefaultColumnName(); + + if (! is_string($columnName)) { + $columnName = $this->getDefaultColumnName(); + } + + $value = $model->getAttribute($columnName); + + return is_array($value) ? $value : []; + } + + /** + * Save preferences to the model's JSON column. + * + * @param array $preferences + */ + protected function savePreferences(Model $model, array $preferences): void + { + $columnName = method_exists($model, 'getPreferencesColumn') + ? $model->getPreferencesColumn() + : $this->getDefaultColumnName(); + + if (! is_string($columnName)) { + $columnName = $this->getDefaultColumnName(); + } + + $model->update([ + $columnName => $preferences, + ]); + } +} diff --git a/src/Drivers/SeparateTableDriver.php b/src/Drivers/SeparateTableDriver.php new file mode 100644 index 0000000..0ca7221 --- /dev/null +++ b/src/Drivers/SeparateTableDriver.php @@ -0,0 +1,128 @@ + + */ + public function all(Model $model): array + { + /** @var Collection $preferences */ + $preferences = $this->newQuery($model) + ->where($model->getKeyName(), $model->getKey()) + ->pluck('value', 'key'); + + /** @var array */ + return $preferences->map(function (mixed $value): mixed { + $stringValue = is_string($value) ? $value : (string) $value; // @phpstan-ignore-line + + return json_decode($stringValue, true); + })->toArray(); + } + + /** + * Delete a preference for a model. + */ + public function delete(Model $model, string $key): void + { + $this->newQuery($model) + ->where($model->getKeyName(), $model->getKey()) + ->where('key', $key) + ->delete(); + } + + /** + * Get a preference value for a model. + */ + public function get(Model $model, string $key): mixed + { + $value = $this->newQuery($model) + ->where($model->getKeyName(), $model->getKey()) + ->where('key', $key) + ->value('value'); + + if ($value === null) { + return null; + } + + $stringValue = is_string($value) ? $value : (string) $value; // @phpstan-ignore-line + + return json_decode($stringValue, true); + } + + /** + * Check if a preference exists for a model. + */ + public function has(Model $model, string $key): bool + { + return $this->newQuery($model) + ->where($model->getKeyName(), $model->getKey()) + ->where('key', $key) + ->exists(); + } + + /** + * Set a preference value for a model. + */ + public function set(Model $model, string $key, mixed $value): void + { + $this->newQuery($model)->updateOrInsert( + [ + $model->getKeyName() => $model->getKey(), + 'key' => $key, + ], + [ + 'value' => json_encode($value), + ] + ); + } + + /** + * Get the database connection for this driver. + */ + protected function connection(): Connection + { + $connectionName = $this->config->get(sprintf('model-preferences.stores.%s.connection', $this->name)); + + return $this->db->connection(is_string($connectionName) ? $connectionName : null); + } + + /** + * Get a new query builder for the model's preferences table. + */ + protected function newQuery(Model $model): Builder + { + $tableName = method_exists($model, 'getPreferencesTable') + ? $model->getPreferencesTable() + : $model->getTable().'_preferences'; + + return $this->connection()->table(is_string($tableName) ? $tableName : $model->getTable().'_preferences'); + } +} diff --git a/src/Drivers/SharedTableDriver.php b/src/Drivers/SharedTableDriver.php new file mode 100644 index 0000000..efdc2c0 --- /dev/null +++ b/src/Drivers/SharedTableDriver.php @@ -0,0 +1,129 @@ + + */ + public function all(Model $model): array + { + $preferences = $this->newQuery() + ->where('preferable_type', $model->getMorphClass()) + ->where('preferable_id', $model->getKey()) + ->pluck('value', 'key'); + + /** @var array */ + return $preferences->map(function (mixed $value): mixed { + $stringValue = is_string($value) ? $value : (string) $value; // @phpstan-ignore-line + + return json_decode($stringValue, true); + })->toArray(); + } + + /** + * Delete a preference for a model. + */ + public function delete(Model $model, string $key): void + { + $this->newQuery() + ->where('preferable_type', $model->getMorphClass()) + ->where('preferable_id', $model->getKey()) + ->where('key', $key) + ->delete(); + } + + /** + * Get a preference value for a model. + */ + public function get(Model $model, string $key): mixed + { + $value = $this->newQuery() + ->where('preferable_type', $model->getMorphClass()) + ->where('preferable_id', $model->getKey()) + ->where('key', $key) + ->value('value'); + + if ($value === null) { + return null; + } + + $stringValue = is_string($value) ? $value : (string) $value; // @phpstan-ignore-line + + return json_decode($stringValue, true); + } + + /** + * Check if a preference exists for a model. + */ + public function has(Model $model, string $key): bool + { + return $this->newQuery() + ->where('preferable_type', $model->getMorphClass()) + ->where('preferable_id', $model->getKey()) + ->where('key', $key) + ->exists(); + } + + /** + * Set a preference value for a model. + */ + public function set(Model $model, string $key, mixed $value): void + { + $this->newQuery()->updateOrInsert( + [ + 'preferable_type' => $model->getMorphClass(), + 'preferable_id' => $model->getKey(), + 'key' => $key, + ], + [ + 'value' => json_encode($value), + ] + ); + } + + /** + * Get the database connection for this driver. + */ + protected function connection(): Connection + { + $connectionName = $this->config->get(sprintf('model-preferences.stores.%s.connection', $this->name)); + + return $this->db->connection(is_string($connectionName) ? $connectionName : null); + } + + /** + * Get a new query builder for the preferences table. + */ + protected function newQuery(): Builder + { + $tableName = $this->config->get(sprintf('model-preferences.stores.%s.table', $this->name), 'model_preferences'); + + return $this->connection()->table(is_string($tableName) ? $tableName : 'model_preferences'); + } +} diff --git a/src/Facades/Preferences.php b/src/Facades/Preferences.php new file mode 100644 index 0000000..adc1656 --- /dev/null +++ b/src/Facades/Preferences.php @@ -0,0 +1,28 @@ +preferences()->delete(); - }); + return 'shared'; } /** - * Get all preferences merged with defaults. - * - * @return array + * Get the preferences column name. */ - public function allPreferences(): array + public function getPreferencesColumn(): string { - return array_merge($this->preferenceDefaults(), $this->getRepository()->all($this)); + return 'preferences'; } /** - * Delete a preference. + * Get the preferences table name. */ - public function deletePreference(BackedEnum|string $key): static + public function getPreferencesTable(): string { - $this->getRepository()->delete($this, enum_value($key)); - - return $this; - } - - /** - * Delete multiple preferences. - * - * @param list $keys - */ - public function deletePreferences(array $keys): static - { - $this->getRepository()->deleteMany( - $this, - array_map(fn (BackedEnum|string $key) => enum_value($key), $keys) - ); - - return $this; - } - - /** - * Check if a preference exists. - */ - public function hasPreference(BackedEnum|string $key): bool - { - return $this->getRepository()->has($this, enum_value($key)); - } - - /** - * Load specific preferences into the relationship. - * - * @param list $keys - */ - public function loadPreferences(array $keys = []): static - { - $query = $this->preferences(); - - if ($keys !== []) { - $query->whereIn( - 'key', - array_map(fn (BackedEnum|string $key) => enum_value($key), $keys) - ); - } - - return $this->setRelation('preferences', $query->get()); - } - - /** - * Get a preference value. - */ - public function preference(BackedEnum|string $key, mixed $default = null): mixed - { - $value = $this->getRepository()->get($this, enum_value($key)); - - if (is_null($value)) { - return $this->getDefaultPreference($key, $default); - } - - return $value; + return $this->getTable().'_preferences'; } /** @@ -120,7 +50,7 @@ public function preferenceDefaults(): array /** * Define preference validation rules. * - * @return array|string|ValidationRule> + * @return array */ public function preferenceRules(): array { @@ -128,105 +58,34 @@ public function preferenceRules(): array } /** - * Get the preferences for the model. + * Get the preferences scoped interaction. */ - public function preferences(): MorphMany + public function preferences(null|string $driver = null): PendingPreferenceInteraction { - return $this->morphMany(Config::string('model-preferences.models.preference'), 'preferable'); + return Preferences::driver($driver ?? $this->getPreferenceDriver())->for($this); } /** - * Set a preference value. - * - * @throws PreferenceValidationException + * Boot the trait. */ - public function setPreference(BackedEnum|string $key, mixed $value): static + protected static function bootInteractsWithPreferences(): void { - $this->validatePreferences([enum_value($key) => $value]); - - $this->getRepository()->set($this, enum_value($key), $value); - - return $this; - } - - /** - * Set multiple preferences. - * - * @param array $preferences - * - * @throws PreferenceValidationException - * @throws Throwable - */ - public function setPreferences(array $preferences): static - { - $this->validatePreferences($preferences); - - $this->getRepository()->setMany($this, $preferences); - - return $this; - } - - /** - * Get default preference value. - */ - protected function getDefaultPreference(BackedEnum|string $key, mixed $default = null): mixed - { - $defaults = $this->preferenceDefaults(); - - if (! array_key_exists(enum_value($key), $defaults)) { - return value($default); - } - - return value($defaults[enum_value($key)]); - } - - /** - * Get validation rules for a specific preference key. - * - * @return list|string|ValidationRule - */ - protected function getPreferenceRules(BackedEnum|string $key): mixed - { - $rules = $this->preferenceRules(); - - return $rules[enum_value($key)] ?? []; - } - - /** - * Get the preference repository instance. - */ - protected function getRepository(): PreferenceRepository - { - return resolve(PreferenceRepository::class); + static::deleted(function (Model $model): void { + if (in_array($model->getPreferenceDriver(), ['separate', 'shared'], true)) { + $model->preferences()->delete(); + } + }); } /** - * Validate multiple preferences against their rules. - * - * @param array $preferences - * - * @throws PreferenceValidationException + * Initialize the trait. */ - protected function validatePreferences(array $preferences): void + protected function initializeInteractsWithPreferences(): void { - $rules = []; - - foreach (array_keys($preferences) as $key) { - $preferenceRules = $this->getPreferenceRules($key); - - if (! empty($preferenceRules)) { - $rules[enum_value($key)] = $preferenceRules; - } - } - - if ($rules === []) { - return; - } - - $validator = Validator::make($preferences, $rules); - - if ($validator->fails()) { - throw new PreferenceValidationException($validator); + if ($this->getPreferenceDriver() === 'column') { + $this->mergeCasts([ + $this->getPreferencesColumn() => 'json', + ]); } } } diff --git a/src/Models/Preference.php b/src/Models/Preference.php deleted file mode 100644 index e32b364..0000000 --- a/src/Models/Preference.php +++ /dev/null @@ -1,56 +0,0 @@ - - */ - public function preferable(): MorphTo - { - return $this->morphTo(); - } - - /** - * {@inheritDoc} - */ - protected function casts(): array - { - return [ - 'value' => 'json', - ]; - } -} diff --git a/src/PendingPreferenceInteraction.php b/src/PendingPreferenceInteraction.php new file mode 100644 index 0000000..97f67c3 --- /dev/null +++ b/src/PendingPreferenceInteraction.php @@ -0,0 +1,125 @@ + + */ + public function all(): array + { + return $this->driver->all($this->model); + } + + /** + * Delete a preference. + */ + public function delete(BackedEnum|string $key): void + { + $enumValue = enum_value($key); + $keyValue = is_string($enumValue) ? $enumValue : (string) $enumValue; // @phpstan-ignore-line + $this->driver->delete($this->model, $keyValue); + } + + /** + * Get a preference value. + */ + public function get(BackedEnum|string $key, mixed $default = null): mixed + { + $enumValue = enum_value($key); + $keyValue = is_string($enumValue) ? $enumValue : (string) $enumValue; // @phpstan-ignore-line + + if (! is_null($value = $this->driver->get($this->model, $keyValue))) { + return $value; + } + + if (! is_null($default)) { + return value($default); + } + + if (method_exists($this->model, 'preferenceDefaults')) { + $defaults = $this->model->preferenceDefaults(); + + if (is_array($defaults)) { + return $defaults[$keyValue] ?? null; + } + } + + return null; + } + + /** + * Check if a preference exists. + */ + public function has(BackedEnum|string $key): bool + { + $enumValue = enum_value($key); + $keyValue = is_string($enumValue) ? $enumValue : (string) $enumValue; // @phpstan-ignore-line + + return $this->driver->has($this->model, $keyValue); + } + + /** + * Set a preference value. + * + * @throws PreferenceValidationException + */ + public function set(BackedEnum|string $key, mixed $value): void + { + $enumValue = enum_value($key); + $keyValue = is_string($enumValue) ? $enumValue : (string) $enumValue; // @phpstan-ignore-line + + if (method_exists($this->model, 'preferenceRules')) { + $this->validatePreference($keyValue, $value); + } + + $this->driver->set($this->model, $keyValue, $value); + } + + /** + * Validate a single preference against model rules. + * + * @throws PreferenceValidationException + */ + protected function validatePreference(string $key, mixed $value): void + { + /** @var mixed */ + $allRules = $this->model->preferenceRules(); // @phpstan-ignore-line + + if (! is_array($allRules)) { + return; + } + + $rules = $allRules[$key] ?? null; + + if (is_null($rules)) { + return; + } + + $validator = Validator::make([$key => $value], [$key => $rules]); + + if ($validator->fails()) { + throw new PreferenceValidationException($validator); + } + } +} diff --git a/src/PreferencesManager.php b/src/PreferencesManager.php new file mode 100644 index 0000000..c3f684c --- /dev/null +++ b/src/PreferencesManager.php @@ -0,0 +1,142 @@ + + */ + protected array $customCreators = []; + + /** + * The array of resolved preference stores. + * + * @var array + */ + protected array $stores = []; + + /** + * Create a new preferences manager instance. + */ + public function __construct(protected Container $container) + { + } + + /** + * Get a store instance. + */ + public function driver(null|string $driver = null): PreferencesStore + { + $driver = is_null($driver) ? $this->getDefaultDriver() : $driver; + + return $this->stores[$driver] ??= $this->createStore($driver); + } + + /** + * Register a custom driver creator Closure. + */ + public function extend(string $driver, Closure $callback): static + { + $this->customCreators[$driver] = $callback; + + return $this; + } + + /** + * Create a scoped instance for a model using a default driver. + */ + public function for(Model $model): PendingPreferenceInteraction + { + return $this->driver()->for($model); + } + + /** + * Get the default driver name. + */ + public function getDefaultDriver(): string + { + $config = $this->container->make('config'); + $default = $config->get('model-preferences.default'); + + return is_string($default) ? $default : 'shared'; + } + + /** + * Create the column driver. + */ + protected function createColumnDriver(): ColumnDriver + { + return new ColumnDriver( + $this->container->make('config'), + 'column' + ); + } + + /** + * Create a driver instance. + * + * @throws InvalidArgumentException + */ + protected function createDriver(string $driver): PreferenceDriver + { + if (isset($this->customCreators[$driver])) { + /** @var PreferenceDriver */ + return $this->customCreators[$driver]($this->container); + } + + $method = 'create'.ucfirst($driver).'Driver'; + + if (method_exists($this, $method)) { + /** @var PreferenceDriver */ + return $this->{$method}(); // @phpstan-ignore-line + } + + throw new InvalidArgumentException(sprintf('Driver [%s] is not supported.', $driver)); + } + + /** + * Create the separate table driver. + */ + protected function createSeparateDriver(): SeparateTableDriver + { + return new SeparateTableDriver( + $this->container->make('db'), + $this->container->make('config'), + 'separate' + ); + } + + /** + * Create the shared table driver. + */ + protected function createSharedDriver(): SharedTableDriver + { + return new SharedTableDriver( + $this->container->make('db'), + $this->container->make('config'), + 'shared' + ); + } + + /** + * Create a store instance. + */ + protected function createStore(string $driver): PreferencesStore + { + return new PreferencesStore($this->createDriver($driver)); + } +} diff --git a/src/PreferencesServiceProvider.php b/src/PreferencesServiceProvider.php index dd209e3..48e185f 100644 --- a/src/PreferencesServiceProvider.php +++ b/src/PreferencesServiceProvider.php @@ -4,8 +4,7 @@ namespace HosmelQ\ModelPreferences; -use HosmelQ\ModelPreferences\Contracts\PreferenceRepository as PreferenceRepositoryContract; -use HosmelQ\ModelPreferences\Repositories\PreferenceRepository; +use Illuminate\Contracts\Container\Container; use Spatie\LaravelPackageTools\Commands\InstallCommand; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -35,6 +34,15 @@ public function configurePackage(Package $package): void */ public function packageRegistered(): void { - $this->app->bind(PreferenceRepositoryContract::class, PreferenceRepository::class); + $this->app->singleton('preferences', $this->createPreferencesManager(...)); + $this->app->alias('preferences', PreferencesManager::class); + } + + /** + * Create preferences manager instance. + */ + private function createPreferencesManager(Container $app): PreferencesManager + { + return new PreferencesManager($app); } } diff --git a/src/PreferencesStore.php b/src/PreferencesStore.php new file mode 100644 index 0000000..66d4ce0 --- /dev/null +++ b/src/PreferencesStore.php @@ -0,0 +1,26 @@ +driver, $model); + } +} diff --git a/src/Repositories/PreferenceRepository.php b/src/Repositories/PreferenceRepository.php deleted file mode 100644 index fe882dc..0000000 --- a/src/Repositories/PreferenceRepository.php +++ /dev/null @@ -1,94 +0,0 @@ - - */ -class PreferenceRepository implements PreferenceRepositoryContract -{ - /** - * Get all preferences for a model. - * - * @param HasPreferences $model - * - * @return array - */ - public function all(HasPreferences $model): array - { - /** @var array */ - return $model->preferences()->pluck('value', 'key')->toArray(); - } - - /** - * Delete a preference by key for a model. - * - * @param HasPreferences $model - */ - public function delete(HasPreferences $model, string $key): void - { - $model->preferences()->where('key', $key)->delete(); - } - - /** - * Delete multiple preferences by keys for a model. - * - * @param HasPreferences $model - * @param list $keys - */ - public function deleteMany(HasPreferences $model, array $keys): void - { - $model->preferences()->whereIn('key', $keys)->delete(); - } - - /** - * Get a preference value for a model. - * - * @param HasPreferences $model - */ - public function get(HasPreferences $model, string $key): mixed - { - return $model->preferences()->where('key', $key)->value('value'); - } - - /** - * Check if a preference key exists for a model. - * - * @param HasPreferences $model - */ - public function has(HasPreferences $model, string $key): bool - { - return $model->preferences()->where('key', $key)->exists(); - } - - /** - * Set a preference for a model. - * - * @param HasPreferences $model - */ - public function set(HasPreferences $model, string $key, mixed $value): void - { - $model->preferences()->updateOrCreate(['key' => $key], ['value' => $value]); - } - - /** - * Set multiple preferences for a model. - * - * @param HasPreferences $model - * @param array $preferences - */ - public function setMany(HasPreferences $model, array $preferences): void - { - foreach ($preferences as $key => $value) { - $this->set($model, $key, $value); - } - } -} diff --git a/tests/Features/EnumSupportTest.php b/tests/Features/EnumSupportTest.php new file mode 100644 index 0000000..594468c --- /dev/null +++ b/tests/Features/EnumSupportTest.php @@ -0,0 +1,85 @@ +preferences()->set(UserPreference::Theme, 'dark'); + + // Verify the underlying storage uses string key + $allPreferences = $user->preferences()->all(); + expect($allPreferences)->toHaveKey('theme'); // string key, not enum + expect($allPreferences['theme'])->toBe('dark'); + + // Verify both enum and string keys access the same preference + expect($user->preferences()->get(UserPreference::Theme))->toBe('dark'); + expect($user->preferences()->get('theme'))->toBe('dark'); +}); + +it('supports mixed enum and string keys in the same operation', function (): void { + $user = User::create(); + + // Set with enum + $user->preferences()->set(UserPreference::Theme, 'dark'); + + // Set with string + $user->preferences()->set('notifications', false); + + // Get with opposite key types + expect($user->preferences()->get('theme'))->toBe('dark'); // string key for enum-set value + expect($user->preferences()->has('notifications'))->toBeTrue(); // string key exists + + // Check existence with mixed types + expect($user->preferences()->has(UserPreference::Theme))->toBeTrue(); + expect($user->preferences()->has('notifications'))->toBeTrue(); +}); + +it('demonstrates enum value conversion through enum_value helper', function (): void { + // Test the enum_value helper function directly + expect(enum_value(UserPreference::Theme))->toBe('theme'); + expect(enum_value('string_key'))->toBe('string_key'); + expect(enum_value('mixed'))->toBe('mixed'); +}); + +it('works with facade API using enum keys', function (): void { + $user = User::create(); + + // Using facade directly + Preferences::for($user)->set(UserPreference::Theme, 'light'); + + expect(Preferences::for($user)->get(UserPreference::Theme))->toBe('light'); + expect(Preferences::for($user)->has(UserPreference::Theme))->toBeTrue(); + + Preferences::for($user)->delete(UserPreference::Theme); + expect(Preferences::for($user)->has(UserPreference::Theme))->toBeFalse(); +}); + +it('preserves type safety with enum keys throughout the stack', function (): void { + $user = User::create(); + + // Enum key flows through all layers: + // 1. PendingPreferenceInteraction receives BackedEnum + // 2. Converts to string using enum_value() + // 3. Driver stores as string key + // 4. Retrieval works with both enum and string + + $user->preferences()->set(UserPreference::Theme, 'system'); + + // Storage layer sees string + $rawData = $user->preferences()->all(); + expect(array_keys($rawData))->toContain('theme'); // string key in storage + + // But API layer accepts enum + expect($user->preferences()->get(UserPreference::Theme))->toBe('system'); + + // And model defaults work with string keys + expect($user->preferences()->get('nonexistent_key'))->toBeNull(); +}); diff --git a/tests/Models/Concerns/InteractsWithPreferencesTest.php b/tests/Models/Concerns/InteractsWithPreferencesTest.php index b6cb5f9..5ec2831 100644 --- a/tests/Models/Concerns/InteractsWithPreferencesTest.php +++ b/tests/Models/Concerns/InteractsWithPreferencesTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use HosmelQ\ModelPreferences\Exceptions\PreferenceValidationException; -use HosmelQ\ModelPreferences\Models\Preference; use Workbench\App\Enums\UserPreference; use Workbench\App\Models\Team; use Workbench\App\Models\User; @@ -11,163 +10,112 @@ it('deletes preference', function (BackedEnum|string $key, mixed $value): void { $user = User::create(); - $user->setPreference($key, $value); + $user->preferences()->set($key, $value); - expect($user->hasPreference($key))->toBeTrue(); + expect($user->preferences()->has($key))->toBeTrue(); - $user->deletePreference($key); + $user->preferences()->delete($key); - expect($user->hasPreference($key))->toBeFalse(); + expect($user->preferences()->has($key))->toBeFalse(); })->with('key variations'); -it('deletes multiple preferences', function (): void { +it('checks if preference exists', function (BackedEnum|string $key, mixed $value): void { $user = User::create(); - $user->setPreferences([ - 'notifications' => false, - 'theme' => 'light', - ]); + expect($user->preferences()->has($key))->toBeFalse(); - $user->deletePreferences(['notifications', UserPreference::Theme]); + $user->preferences()->set($key, $value); - expect($user) - ->hasPreference('notifications')->toBeFalse() - ->hasPreference('theme')->toBeFalse(); -}); + expect($user->preferences()->has($key))->toBeTrue(); +})->with('key variations'); -it('deletes preferences when model is deleted', function (): void { +it('gets preference value', function (BackedEnum|string $key, mixed $value): void { $user = User::create(); - $user->setPreferences([ - 'notifications' => false, - 'theme' => 'light', - ]); + $user->preferences()->set($key, $value); - expect($user->preferences)->toHaveCount(2); + expect($user->preferences()->get($key))->toBe($value); +})->with('key variations'); - $user->delete(); +it('returns defaults for unset preferences', function (): void { + $user = User::create(); - expect(Preference::query() - ->where('preferable_id', $user->id) - ->where('preferable_type', User::class) - ->get())->toHaveCount(0); + expect($user->preferences()->get('notifications'))->toBeTrue(); + expect($user->preferences()->get('theme'))->toBe('system'); }); -it('gets all preferences merged with defaults', function (): void { +it('returns custom default when preference does not exist', function (): void { $user = User::create(); - $user->setPreferences([ - 'theme' => 'light', - ]); - - $allPreferences = $user->allPreferences(); - - expect($allPreferences)->toEqual([ - 'notifications' => true, - 'theme' => 'light', - ]); + expect($user->preferences()->get('nonexistent', 'default'))->toBe('default'); }); -it('checks if preference exists', function (BackedEnum|string $key, mixed $value): void { +it('handles array preferences', function (string $key, array $value): void { $user = User::create(); - expect($user->hasPreference($key))->toBeFalse(); + $user->preferences()->set($key, $value); - $user->setPreference($key, $value); - - expect($user->hasPreference($key))->toBeTrue(); -})->with('key variations'); + expect($user->preferences()->get($key))->toEqual($value); +})->with('complex values'); -it('loads specific preferences', function (): void { +it('gets all preferences', function (): void { $user = User::create(); - $user->setPreferences([ - 'extra' => 'value', - 'notifications' => false, - 'theme' => 'light', - ]); - - $user->loadPreferences(['notifications', UserPreference::Theme]); + $user->preferences()->set('notifications', false); + $user->preferences()->set('theme', 'light'); - expect($user) - ->relationLoaded('preferences')->toBeTrue() - ->preferences->toHaveCount(2) - ->preferences->pluck('key')->toArray()->toEqualCanonicalizing(['notifications', 'theme']); -}); - -it('loads all preferences when no keys specified', function (): void { - $user = User::create(); + $allPreferences = $user->preferences()->all(); - $user->setPreferences([ + expect($allPreferences)->toEqual([ 'notifications' => false, 'theme' => 'light', ]); - - $user->loadPreferences(); - - expect($user->preferences)->toHaveCount(2); }); -it('gets preference value', function (BackedEnum|string $key, mixed $value): void { +it('validates preferences against rules', function (): void { $user = User::create(); - $user->setPreference($key, $value); + $user->preferences()->set('theme', 'invalid'); +})->throws(PreferenceValidationException::class); - expect($user->preference($key))->toBe($value); -})->with('key variations'); - -it('returns defaults for unset preferences', function (): void { +it('handles preferences for different models independently', function (): void { + $team = Team::create(); $user = User::create(); - expect($user) - ->preference('notifications')->toBeTrue() - ->preference('theme')->toBe('system'); -}); - -it('returns custom default when preference does not exist', function (): void { - $user = User::create(); + $team->preferences()->set('max_members', 50); + $user->preferences()->set('theme', 'light'); - expect($user->preference('nonexistent', 'default'))->toBe('default'); + expect($team->preferences()->get('max_members'))->toBe(50); + expect($team->preferences()->get('theme', 'default'))->toBe('default'); + expect($user->preferences()->get('theme'))->toBe('light'); + expect($user->preferences()->get('max_members', 'default'))->toBe('default'); }); -it('handles array preferences', function (string $key, array $value): void { +it('supports enum keys with type safety', function (): void { $user = User::create(); - $user->setPreference($key, $value); + // Set with enum + $user->preferences()->set(UserPreference::Theme, 'dark'); - expect($user->preference($key))->toEqual($value); -})->with('complex values'); + // Get with enum + expect($user->preferences()->get(UserPreference::Theme))->toBe('dark'); -it('sets multiple preferences', function (): void { - $user = User::create(); + // Has with enum + expect($user->preferences()->has(UserPreference::Theme))->toBeTrue(); - $user->setPreferences([ - 'notifications' => false, - 'theme' => 'light', - ]); - - expect($user) - ->preference('notifications')->toBeFalse() - ->preference('theme')->toBe('light'); + // Delete with enum + $user->preferences()->delete(UserPreference::Theme); + expect($user->preferences()->has(UserPreference::Theme))->toBeFalse(); }); -it('validates preferences against rules', function (): void { - $user = User::create(); - - $user->setPreference('theme', 'invalid'); -})->throws(PreferenceValidationException::class, 'The given preference data was invalid.'); - -it('handles preferences for different models independently', function (): void { - $team = Team::create(); +it('allows runtime driver switching', function (): void { $user = User::create(); - $team->setPreference('max_members', 50); - $user->setPreference('theme', 'light'); + // Use default driver + $user->preferences()->set('theme', 'dark'); + expect($user->preferences()->get('theme'))->toBe('dark'); - expect($team) - ->preference('max_members')->toBe(50) - ->preference('theme', 'default')->toBe('default') - ->and($user) - ->preference('theme')->toBe('light') - ->preference('max_members', 'default')->toBe('default'); + // Use specific driver (if available) + $user->preferences('shared')->set('temp_setting', 'value'); + expect($user->preferences('shared')->get('temp_setting'))->toBe('value'); }); diff --git a/tests/Models/PreferenceTest.php b/tests/Models/PreferenceTest.php deleted file mode 100644 index 205f986..0000000 --- a/tests/Models/PreferenceTest.php +++ /dev/null @@ -1,16 +0,0 @@ -setPreference('theme', 'light'); - - $preference = Preference::first(); - - expect($preference->preferable->is($user))->toBeTrue(); -}); diff --git a/tests/Repositories/PreferenceRepositoryTest.php b/tests/Repositories/PreferenceRepositoryTest.php deleted file mode 100644 index 5a2c3d9..0000000 --- a/tests/Repositories/PreferenceRepositoryTest.php +++ /dev/null @@ -1,145 +0,0 @@ -set($user, 'language', 'en'); - $repository->set($user, 'notifications', true); - $repository->set($user, 'theme', 'light'); - - expect($repository->all($user))->toEqual([ - 'language' => 'en', - 'notifications' => true, - 'theme' => 'light', - ]); -}); - -it('returns empty array when no preferences exist', function (): void { - $repository = new PreferenceRepository(); - $user = User::create(); - - expect($repository->all($user))->toEqual([]); -}); - -it('deletes existing preference', function (): void { - $repository = new PreferenceRepository(); - $user = User::create(); - - $repository->set($user, 'theme', 'light'); - - expect($repository->has($user, 'theme'))->toBeTrue(); - - $repository->delete($user, 'theme'); - - expect($repository->has($user, 'theme'))->toBeFalse(); -}); - -it('deletes multiple preferences', function (): void { - $repository = new PreferenceRepository(); - $user = User::create(); - - $repository->set($user, 'language', 'en'); - $repository->set($user, 'notifications', true); - $repository->set($user, 'theme', 'light'); - - $repository->deleteMany($user, ['language', 'theme']); - - expect($repository->has($user, 'language'))->toBeFalse() - ->and($repository->has($user, 'notifications'))->toBeTrue() - ->and($repository->has($user, 'theme'))->toBeFalse(); -}); - -it('returns existing preference value', function (): void { - $repository = new PreferenceRepository(); - $user = User::create(); - - $repository->set($user, 'theme', 'light'); - - expect($repository->get($user, 'theme'))->toBe('light'); -}); - -it('returns null for nonexistent key', function (): void { - $repository = new PreferenceRepository(); - $user = User::create(); - - expect($repository->get($user, 'nonexistent'))->toBeNull(); -}); - -it('returns complex data types correctly', function (): void { - $repository = new PreferenceRepository(); - $user = User::create(); - - $data = [ - 'array' => [1, 2, 3], - 'boolean' => true, - 'null' => null, - 'number' => 42, - 'object' => ['key' => 'value'], - 'string' => 'test', - ]; - - $repository->set($user, 'complex', $data); - - expect($repository->get($user, 'complex'))->toEqual($data); -}); - -it('returns true when key exists', function (): void { - $repository = new PreferenceRepository(); - $user = User::create(); - - $repository->set($user, 'theme', 'light'); - - expect($repository->has($user, 'theme'))->toBeTrue(); -}); - -it('returns false when key does not exist', function (): void { - $repository = new PreferenceRepository(); - $user = User::create(); - - expect($repository->has($user, 'nonexistent'))->toBeFalse(); -}); - -it('creates new preference', function (): void { - $repository = new PreferenceRepository(); - $user = User::create(); - - $repository->set($user, 'theme', 'light'); - - expect($repository->get($user, 'theme'))->toBe('light') - ->and($user->preferences()->get())->toHaveCount(1); -}); - -it('updates existing preference', function (): void { - $repository = new PreferenceRepository(); - $user = User::create(); - - $repository->set($user, 'theme', 'light'); - - expect($repository->get($user, 'theme'))->toBe('light'); - - $repository->set($user, 'theme', 'light'); - - expect($repository->get($user, 'theme'))->toBe('light') - ->and($user->preferences()->get())->toHaveCount(1); -}); - -it('sets multiple preferences', function (): void { - $repository = new PreferenceRepository(); - $user = User::create(); - - $preferences = [ - 'language' => 'en', - 'notifications' => true, - 'theme' => 'light', - ]; - - $repository->setMany($user, $preferences); - - expect($repository->all($user))->toEqual($preferences); -}); diff --git a/workbench/app/Models/Team.php b/workbench/app/Models/Team.php index 4bb9079..64bbcb8 100644 --- a/workbench/app/Models/Team.php +++ b/workbench/app/Models/Team.php @@ -4,11 +4,10 @@ namespace Workbench\App\Models; -use HosmelQ\ModelPreferences\Contracts\HasPreferences; use HosmelQ\ModelPreferences\Models\Concerns\InteractsWithPreferences; use Illuminate\Database\Eloquent\Model; -class Team extends Model implements HasPreferences +class Team extends Model { use InteractsWithPreferences; diff --git a/workbench/app/Models/User.php b/workbench/app/Models/User.php index 0bd015c..09b9055 100644 --- a/workbench/app/Models/User.php +++ b/workbench/app/Models/User.php @@ -4,12 +4,11 @@ namespace Workbench\App\Models; -use HosmelQ\ModelPreferences\Contracts\HasPreferences; use HosmelQ\ModelPreferences\Models\Concerns\InteractsWithPreferences; use Illuminate\Database\Eloquent\Model; use Illuminate\Validation\Rule; -class User extends Model implements HasPreferences +class User extends Model { use InteractsWithPreferences;