diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b8b0b71 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,237 @@ +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +insert_final_newline = false +tab_width = 4 +trim_trailing_whitespace = false +ij_continuation_indent_size = 8 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = false +ij_smart_tabs = false +ij_visual_guides = none +ij_wrap_on_typing = false + +[{*.ctp,*.hphp,*.inc,*.module,*.php,*.php4,*.php5,*.phtml}] +indent_style = tab +ij_continuation_indent_size = 4 +ij_php_align_assignments = false +ij_php_align_class_constants = false +ij_php_align_group_field_declarations = false +ij_php_align_inline_comments = false +ij_php_align_key_value_pairs = false +ij_php_align_match_arm_bodies = false +ij_php_align_multiline_array_initializer_expression = false +ij_php_align_multiline_binary_operation = false +ij_php_align_multiline_chained_methods = false +ij_php_align_multiline_extends_list = false +ij_php_align_multiline_for = true +ij_php_align_multiline_parameters = true +ij_php_align_multiline_parameters_in_calls = false +ij_php_align_multiline_ternary_operation = false +ij_php_align_named_arguments = false +ij_php_align_phpdoc_comments = false +ij_php_align_phpdoc_param_names = false +ij_php_anonymous_brace_style = end_of_line +ij_php_api_weight = 28 +ij_php_array_initializer_new_line_after_left_brace = false +ij_php_array_initializer_right_brace_on_new_line = false +ij_php_array_initializer_wrap = off +ij_php_assignment_wrap = off +ij_php_attributes_wrap = off +ij_php_author_weight = 28 +ij_php_binary_operation_sign_on_next_line = false +ij_php_binary_operation_wrap = off +ij_php_blank_lines_after_class_header = 0 +ij_php_blank_lines_after_function = 1 +ij_php_blank_lines_after_imports = 1 +ij_php_blank_lines_after_opening_tag = 0 +ij_php_blank_lines_after_package = 1 +ij_php_blank_lines_around_class = 1 +ij_php_blank_lines_around_constants = 0 +ij_php_blank_lines_around_field = 0 +ij_php_blank_lines_around_method = 1 +ij_php_blank_lines_before_class_end = 0 +ij_php_blank_lines_before_imports = 1 +ij_php_blank_lines_before_method_body = 0 +ij_php_blank_lines_before_package = 1 +ij_php_blank_lines_before_return_statement = 0 +ij_php_blank_lines_between_imports = 0 +ij_php_block_brace_style = end_of_line +ij_php_call_parameters_new_line_after_left_paren = false +ij_php_call_parameters_right_paren_on_new_line = false +ij_php_call_parameters_wrap = off +ij_php_catch_on_new_line = false +ij_php_category_weight = 28 +ij_php_class_brace_style = next_line +ij_php_comma_after_last_array_element = true +ij_php_concat_spaces = true +ij_php_copyright_weight = 28 +ij_php_deprecated_weight = 28 +ij_php_do_while_brace_force = never +ij_php_else_if_style = as_is +ij_php_else_on_new_line = true +ij_php_example_weight = 28 +ij_php_extends_keyword_wrap = off +ij_php_extends_list_wrap = off +ij_php_fields_default_visibility = protected +ij_php_filesource_weight = 28 +ij_php_finally_on_new_line = false +ij_php_for_brace_force = never +ij_php_for_statement_new_line_after_left_paren = false +ij_php_for_statement_right_paren_on_new_line = false +ij_php_for_statement_wrap = off +ij_php_force_empty_methods_in_one_line = false +ij_php_force_short_declaration_array_style = false +ij_php_getters_setters_naming_style = camel_case +ij_php_getters_setters_order_style = getters_first +ij_php_global_weight = 28 +ij_php_group_use_wrap = on_every_item +ij_php_if_brace_force = never +ij_php_if_lparen_on_next_line = false +ij_php_if_rparen_on_next_line = false +ij_php_ignore_weight = 28 +ij_php_import_sorting = by_length +ij_php_indent_break_from_case = true +ij_php_indent_case_from_switch = true +ij_php_indent_code_in_php_tags = false +ij_php_internal_weight = 28 +ij_php_keep_blank_lines_after_lbrace = 2 +ij_php_keep_blank_lines_before_right_brace = 2 +ij_php_keep_blank_lines_in_code = 2 +ij_php_keep_blank_lines_in_declarations = 2 +ij_php_keep_control_statement_in_one_line = true +ij_php_keep_first_column_comment = false +ij_php_keep_indents_on_empty_lines = true +ij_php_keep_line_breaks = true +ij_php_keep_rparen_and_lbrace_on_one_line = false +ij_php_keep_simple_classes_in_one_line = false +ij_php_keep_simple_methods_in_one_line = false +ij_php_lambda_brace_style = end_of_line +ij_php_license_weight = 28 +ij_php_line_comment_add_space = false +ij_php_line_comment_at_first_column = false +ij_php_link_weight = 28 +ij_php_lower_case_boolean_const = false +ij_php_lower_case_keywords = true +ij_php_lower_case_null_const = false +ij_php_method_brace_style = end_of_line +ij_php_method_call_chain_wrap = off +ij_php_method_parameters_new_line_after_left_paren = false +ij_php_method_parameters_right_paren_on_new_line = false +ij_php_method_parameters_wrap = off +ij_php_method_weight = 28 +ij_php_modifier_list_wrap = false +ij_php_multiline_chained_calls_semicolon_on_new_line = false +ij_php_namespace_brace_style = 1 +ij_php_new_line_after_php_opening_tag = false +ij_php_null_type_position = in_the_end +ij_php_package_weight = 28 +ij_php_param_weight = 0 +ij_php_parameters_attributes_wrap = off +ij_php_parentheses_expression_new_line_after_left_paren = false +ij_php_parentheses_expression_right_paren_on_new_line = false +ij_php_phpdoc_blank_line_before_tags = false +ij_php_phpdoc_blank_lines_around_parameters = false +ij_php_phpdoc_keep_blank_lines = true +ij_php_phpdoc_param_spaces_between_name_and_description = 1 +ij_php_phpdoc_param_spaces_between_tag_and_type = 1 +ij_php_phpdoc_param_spaces_between_type_and_name = 1 +ij_php_phpdoc_use_fqcn = false +ij_php_phpdoc_wrap_long_lines = false +ij_php_place_assignment_sign_on_next_line = false +ij_php_place_parens_for_constructor = 0 +ij_php_property_read_weight = 28 +ij_php_property_weight = 28 +ij_php_property_write_weight = 28 +ij_php_return_type_on_new_line = false +ij_php_return_weight = 1 +ij_php_see_weight = 28 +ij_php_since_weight = 28 +ij_php_sort_phpdoc_elements = true +ij_php_space_after_colon = true +ij_php_space_after_colon_in_enum_backed_type = true +ij_php_space_after_colon_in_named_argument = true +ij_php_space_after_colon_in_return_type = true +ij_php_space_after_comma = true +ij_php_space_after_for_semicolon = true +ij_php_space_after_quest = true +ij_php_space_after_type_cast = false +ij_php_space_after_unary_not = true +ij_php_space_before_array_initializer_left_brace = false +ij_php_space_before_catch_keyword = true +ij_php_space_before_catch_left_brace = true +ij_php_space_before_catch_parentheses = true +ij_php_space_before_class_left_brace = true +ij_php_space_before_closure_left_parenthesis = true +ij_php_space_before_colon = true +ij_php_space_before_colon_in_enum_backed_type = false +ij_php_space_before_colon_in_named_argument = false +ij_php_space_before_colon_in_return_type = false +ij_php_space_before_comma = false +ij_php_space_before_do_left_brace = true +ij_php_space_before_else_keyword = true +ij_php_space_before_else_left_brace = true +ij_php_space_before_finally_keyword = true +ij_php_space_before_finally_left_brace = true +ij_php_space_before_for_left_brace = true +ij_php_space_before_for_parentheses = true +ij_php_space_before_for_semicolon = false +ij_php_space_before_if_left_brace = true +ij_php_space_before_if_parentheses = true +ij_php_space_before_method_call_parentheses = false +ij_php_space_before_method_left_brace = true +ij_php_space_before_method_parentheses = false +ij_php_space_before_quest = true +ij_php_space_before_short_closure_left_parenthesis = false +ij_php_space_before_switch_left_brace = true +ij_php_space_before_switch_parentheses = true +ij_php_space_before_try_left_brace = true +ij_php_space_before_unary_not = false +ij_php_space_before_while_keyword = true +ij_php_space_before_while_left_brace = true +ij_php_space_before_while_parentheses = true +ij_php_space_between_ternary_quest_and_colon = false +ij_php_spaces_around_additive_operators = true +ij_php_spaces_around_arrow = false +ij_php_spaces_around_assignment_in_declare = false +ij_php_spaces_around_assignment_operators = true +ij_php_spaces_around_bitwise_operators = true +ij_php_spaces_around_equality_operators = true +ij_php_spaces_around_logical_operators = true +ij_php_spaces_around_multiplicative_operators = true +ij_php_spaces_around_null_coalesce_operator = true +ij_php_spaces_around_pipe_in_union_type = false +ij_php_spaces_around_relational_operators = true +ij_php_spaces_around_shift_operators = true +ij_php_spaces_around_unary_operator = false +ij_php_spaces_around_var_within_brackets = false +ij_php_spaces_within_array_initializer_braces = false +ij_php_spaces_within_brackets = false +ij_php_spaces_within_catch_parentheses = false +ij_php_spaces_within_for_parentheses = false +ij_php_spaces_within_if_parentheses = true +ij_php_spaces_within_method_call_parentheses = true +ij_php_spaces_within_method_parentheses = false +ij_php_spaces_within_parentheses = false +ij_php_spaces_within_short_echo_tags = true +ij_php_spaces_within_switch_parentheses = false +ij_php_spaces_within_while_parentheses = false +ij_php_special_else_if_treatment = false +ij_php_subpackage_weight = 28 +ij_php_ternary_operation_signs_on_next_line = false +ij_php_ternary_operation_wrap = off +ij_php_throws_weight = 2 +ij_php_todo_weight = 28 +ij_php_unknown_tag_weight = 28 +ij_php_upper_case_boolean_const = false +ij_php_upper_case_null_const = false +ij_php_uses_weight = 28 +ij_php_var_weight = 28 +ij_php_variable_naming_style = camel_case +ij_php_version_weight = 28 +ij_php_while_brace_force = never +ij_php_while_on_new_line = false diff --git a/.gitignore b/.gitignore index 8011596..58599aa 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ Thumbs.db *.dump *.mmdb *.history +/vendor/ +/.phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..648eb7f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Release Notes + +## v2.0.0 + +### Added + +* Added `setAttribute` method. +* Added `hasMeta` method. +* Added `hasDefaultMetaValue` method. +* Added ability to disable fluent meta access by setting `$disableFluentMeta` to `true`. + +### Changed + +* Removed laravel 7 and bellow support. +* Removed `__get` method. +* Removed `__set` method. +* Removed `__isset` method. +* Removed legacy getter. +* Removed `whereMeta` method in favor of `scopeWhereMeta`. +* Renamed `getMetaDefaultValue` method to `getDefaultMetaValue`. +* You can now set meta names starting with `meta`. +* Changed `saveMeta` method's visibility to public. +* Changed `getMetaData` method's visibility to public. +* Fluent setter will now check for any cast or mutator. +* Passing an array to `getMeta()` will now return a Collection with all the requested metas, even if they don't exist. non-existent metas value would be based on second parameter or `null` if nothing is provided. + +### Fixed + +* Fixed `getMeta` method's second parameter. +* Fixed duplicate queries executed when retrieving the models stored as meta. +* Fixed fluent getter treating relations as meta when the result is null. diff --git a/IdeHelper.php b/IdeHelper.php new file mode 100644 index 0000000..63b18a8 --- /dev/null +++ b/IdeHelper.php @@ -0,0 +1,10 @@ +something`. this is no longer the case, and you have to call `$model->getSomething()`. +4. Added new method `setAttribute` that overrides parent method. +5. Renamed `getMetaDefaultValue` method to `getDefaultMetaValue`. +6. Second parameter of `getMeta` method is now default value when meta is null. +7. Removed `whereMeta` method in favor of `scopeWhereMeta`. example: `User::whereMeta($key,$value)->get();` +8. Removed `getModelKey` method. #### Migration Table Schema + +This is an example migration. you need change parts of it. + +In this example we assume you have a model named `Post`. + +Meta table name should be your model's table name + `_meta` which in this case, model's table name is pluralized form of the model name. so the table name becomes `posts_meta`. + +If you don't want to follow this naming convention and use something else for table name, make sure you add this name to your model's body: + +```php +protected $metaTable = 'custom_meta_table'; +``` + +the foreign key name should be your model's name + `_id` = `post_id` + +If you used something else for foreign key, make sure you add this to your model's body: + +```php +protected $metaKeyName = 'custom_foreign_key'; +``` + ```php /** * Run the migrations. @@ -29,9 +95,9 @@ After that, run composer install to install the package. public function up() { Schema::create('posts_meta', function (Blueprint $table) { - $table->increments('id'); + $table->bigIncrements('id'); - $table->integer('post_id')->unsigned()->index(); + $table->bigInteger('post_id')->unsigned(); $table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade'); $table->string('type')->default('null'); @@ -53,8 +119,8 @@ public function down() Schema::drop('posts_meta'); } ``` -## Configuration +## Configuration #### Model Setup @@ -70,7 +136,7 @@ class Post extends Eloquent ``` Metable Trait will automatically set the meta table based on your model name. -Default meta table name would be, `model_meta`. +Default meta table name would be, `models_meta` where `models` is pluralized form of the model name. In case you need to define your own meta table name, you can specify in model: ```php @@ -82,15 +148,14 @@ class Post extends Eloquent #### Default Model Attribute values -Additionally, you can set default values by setting an array called `$defaultMetaValues` on the model. Setting default has two side-effects: - - 1. If a meta attribute does not exist, the default value will be returned instead of `null`. +Additionally, you can set default values by setting an array called `$defaultMetaValues` on the model. Setting default has two side effects: - 2. if you attempt to set a meta attribute to the default value, the row in the meta table will be removed, which will cause the default value to be returned, as per rule 1. +1. If a meta attribute does not exist, the default value will be returned instead of `null`. +2. if you attempt to set a meta attribute to the default value, the row in the meta table will be removed, which will cause the default value to be returned, as per rule 1. -This is be the desired and expected functionality for most projects, but be aware that you may need to reimplement default functionality with your own custom accessors and mutators if this functionality does not fit your needs. +This is being the desired and expected functionality for most projects, but be aware that you may need to reimplement default functionality with your own custom accessors and mutators if this functionality does not fit your needs. -This functionality is most suited for meta entries that note exceptions to rules. For example: employees sick out of office (default value: in office), nodes taken down for maintance (default value: node up), etc. This means the table doesn't need to store data on every entry which is in the expected state, only those rows in the exceptional state, and allows the rows to have a default state upon creation without needing to add code to write it. +This functionality is most suited for meta entries that note exceptions to rules. For example: employees sick out of office (default value: in office), nodes taken down for maintenance (default value: node up), etc. This means the table doesn't need to store data on every entry which is in the expected state, only those rows in the exceptional state, and allows the rows to have a default state upon creation without needing to add code to write it. ``` public $defaultMetaValues = [ @@ -99,6 +164,7 @@ This functionality is most suited for meta entries that note exceptions to rules ``` #### Gotcha + When you extend a model and still want to use the same meta table you must override `getMetaKeyName` function. ``` @@ -116,8 +182,6 @@ class Slideshow extends Post } ``` - - ## Working With Meta #### Setting Content Meta @@ -125,8 +189,8 @@ class Slideshow extends Post To set a meta value on an existing piece of content or create a new data: > **Fluent way**, You can **set meta flawlessly** as you do on your regular eloquent models. -Metable checks if attribute belongs to model, if not it will -access meta model to append or set a new meta. +> Metable checks if attribute belongs to model, if not it will +> access meta model to append or set a new meta. ```php $post = Post::find(1); @@ -169,6 +233,13 @@ $post->save(); > **Note:** If a piece of content already has a meta the existing value will be updated. +You can also save metas with `saveMeta` without saving the model itself: + +```php +$post->content = 'some content goes here'; // meta data attribute +$post->saveMeta(); // will save metas to database but won't save the model itself +``` + #### Unsetting Content Meta Similarly, you may unset meta from an existing piece of content: @@ -214,15 +285,29 @@ To see if a piece of content has a meta: ```php if (isset($post->content)) { +} +// or +if ($post->hasMeta('content')){ + } ``` +You may also check if model has multiple metas: + +```php +$post->hasMeta(['content','views']); // returns true only if all the metas exist +// or +$post->hasMeta('content|views'); +// or +$post->hasMeta('content,views'); +``` + #### Retrieving Meta To retrieve a meta value on a piece of content, use the `getMeta` method: > **Fluent way**, You can access meta data as if it is a property on your model. -Just like you do on your regular eloquent models. +> Just like you do on your regular eloquent models. ```php $post = Post::find(1); @@ -242,6 +327,8 @@ Or specify a default value, if not set: $post = $post->getMeta('content', 'Something'); ``` +> **Note:** default values set in defaultMetaValues property take precedence over default value passed to this method. + You may also retrieve more than one meta at a time and get an illuminate collection: ```php @@ -249,6 +336,29 @@ You may also retrieve more than one meta at a time and get an illuminate collect $post = $post->getMeta('content|views'); // or an array $post = $post->getMeta(['content', 'views']); +// specify default values +$post->getMeta(['content', 'views'],['content'=>'something','views'=>0]); +// or specify one default value for all missing metas +$post->getMeta(['content', 'views'],'none');// result if the metas are missing: ['content'=>'none','views'=>'none'] +// without specifying default value result will be null +$post->getMeta(['content', 'views']);// result if the metas are missing: ['content'=>null,'views'=>null] +``` + +#### Disable fluent access + +If you don't want to access metas in fluent way, you can disable it by adding following property to your model: + +```php +protected $disableFluentMeta = true; +``` + +By setting that property, this package will no longer handle metas in the following ways: + +```php +$post->content='something';// will not set meta. original laravel action will be taken +$post->content;// will not retrieve meta +unset($post->content);// will not unset meta +isset($post->content);// will not check if meta exists ``` #### Retrieving All Metas @@ -283,7 +393,7 @@ $post = Post::meta() #### Eager Loading -When you need to retrive multiple results from your model, you can eager load `metas` +When you need to retrieve multiple results from your model, you can eager load `metas` ```php $post = Post::with(['metas'])->get(); diff --git a/bootstrapTest.php b/bootstrapTest.php new file mode 100644 index 0000000..a999c2c --- /dev/null +++ b/bootstrapTest.php @@ -0,0 +1,2 @@ +=5.4.0", - "illuminate/support": "~5.0|~5.1|^6.0|^7.0|^8.0|^9.0" - }, - "autoload": { - "classmap": [], - "psr-0": { - "Kodeine\\Metable\\": "src/" - } - }, - "minimum-stability": "stable", - "license": "MIT" + "name": "kodeine/laravel-meta", + "description": "Fluent Meta Data for Eloquent Models, as if it is a property on your model.", + "keywords": [ + "laravel", + "meta", + "metas", + "meta data", + "data", + "metadata", + "metable", + "model", + "eloquent", + "kodeine" + ], + "authors": [ + { + "name": "Ahsen M.", + "homepage": "https://github.com/kodeine", + "role": "Developer" + } + ], + "require": { + "php": ">=7.3", + "illuminate/support": "^8.0|^9.0", + "illuminate/database": "^8.0|^9.0", + "illuminate/events": "^8.0|^9.0", + "ext-json": "*" + }, + "autoload": { + "classmap": [], + "psr-0": { + "Kodeine\\Metable\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Kodeine\\Metable\\Tests\\": "tests/" + } + }, + "minimum-stability": "stable", + "license": "MIT", + "require-dev": { + "phpunit/phpunit": "^9.5" + } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..05bd30f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,14 @@ + + + + tests + + + + + ./src + + + \ No newline at end of file diff --git a/src/Kodeine/Metable/MetaData.php b/src/Kodeine/Metable/MetaData.php index 802d204..cef334e 100644 --- a/src/Kodeine/Metable/MetaData.php +++ b/src/Kodeine/Metable/MetaData.php @@ -5,98 +5,117 @@ use DateTime; use Illuminate\Database\Eloquent\Model; +/** + * @property string $type + */ class MetaData extends Model { - /** - * @var array - */ - protected $fillable = ['key', 'value']; - - /** - * @var array - */ - protected $dataTypes = ['boolean', 'integer', 'double', 'float', 'string', 'NULL']; - - /** - * Whether or not to delete the Data on save. - * - * @var bool - */ - protected $markForDeletion = false; - - /** - * Whether or not to delete the Data on save. - * - * @param bool $bool - */ - public function markForDeletion($bool = true) - { - $this->markForDeletion = $bool; - } - - /** - * Check if the model needs to be deleted. - * - * @return bool - */ - public function isMarkedForDeletion() - { - return (bool) $this->markForDeletion; - } - - /** - * Set the value and type. - * - * @param $value - */ - public function setValueAttribute($value) - { - $type = gettype($value); - - if (is_array($value)) { - $this->type = 'array'; - $this->attributes['value'] = json_encode($value); - } elseif ($value instanceof DateTime) { - $this->type = 'datetime'; - $this->attributes['value'] = $this->fromDateTime($value); - } elseif ($value instanceof Model) { - $this->type = 'model'; - $this->attributes['value'] = get_class($value).(!$value->exists ? '' : '#'.$value->getKey()); - } elseif (is_object($value)) { - $this->type = 'object'; - $this->attributes['value'] = json_encode($value); - } else { - $this->type = in_array($type, $this->dataTypes) ? $type : 'string'; - $this->attributes['value'] = $value; - } - } - - public function getValueAttribute($value) - { - $type = $this->type ?: 'null'; - - switch ($type) { - case 'array': - return json_decode($value, true); - case 'object': - return json_decode($value); - case 'datetime': - return $this->asDateTime($value); - case 'model': { - if (strpos($value, '#') === false) { - return new $value(); - } - - list($class, $id) = explode('#', $value); - - return with(new $class())->findOrFail($id); - } - } - - if (in_array($type, $this->dataTypes)) { - settype($value, $type); - } - - return $value; - } + /** + * @var array + */ + protected $fillable = ['key', 'value']; + + /** + * @var array + */ + protected $dataTypes = ['boolean', 'integer', 'double', 'float', 'string', 'NULL']; + + /** + * Whether or not to delete the Data on save. + * + * @var bool + */ + protected $markForDeletion = false; + + protected $modelCache = []; + + /** + * Whether or not to delete the Data on save. + * + * @param bool $bool + */ + public function markForDeletion(bool $bool = true) { + $this->markForDeletion = $bool; + } + + /** + * Check if the model needs to be deleted. + * + * @return bool + */ + public function isMarkedForDeletion(): bool { + return $this->markForDeletion; + } + + /** + * Set the value and type. + * + * @param $value + */ + public function setValueAttribute($value) { + $type = gettype( $value ); + + if ( is_array( $value ) ) { + $this->type = 'array'; + $this->attributes['value'] = json_encode( $value ); + } + elseif ( $value instanceof DateTime ) { + $this->type = 'datetime'; + $this->attributes['value'] = $this->fromDateTime( $value ); + } + elseif ( $value instanceof Model ) { + $this->type = 'model'; + $class = get_class( $value ); + $this->attributes['value'] = $class . (! $value->exists ? '' : '#' . $value->getKey()); + // Update the cache + $this->modelCache[$class][$value->getKey()] = $value; + } + elseif ( is_object( $value ) ) { + $this->type = 'object'; + $this->attributes['value'] = json_encode( $value ); + } + else { + $this->type = in_array( $type, $this->dataTypes ) ? $type : 'string'; + $this->attributes['value'] = $value; + } + } + + public function getValueAttribute($value) { + $type = $this->type ?: 'null'; + + switch ($type) { + case 'array': + return json_decode( $value, true ); + case 'object': + return json_decode( $value ); + case 'datetime': + return $this->asDateTime( $value ); + case 'model': + { + if ( strpos( $value, '#' ) === false ) { + return new $value(); + } + + list( $class, $id ) = explode( '#', $value ); + + return $this->resolveModelInstance( $class, $id ); + } + } + + if ( in_array( $type, $this->dataTypes ) ) { + settype( $value, $type ); + } + + return $value; + } + + protected function resolveModelInstance($model, $Key) { + if ( ! isset( $this->modelCache[$model] ) ) { + $this->modelCache[$model] = []; + } + if ( ! isset( $this->modelCache[$model][$Key] ) ) { + $this->modelCache[$model][$Key] = (new $model())->findOrFail( $Key ); + } + return $this->modelCache[$model][$Key]; + } } diff --git a/src/Kodeine/Metable/Metable.php b/src/Kodeine/Metable/Metable.php index 05a2312..61d9239 100644 --- a/src/Kodeine/Metable/Metable.php +++ b/src/Kodeine/Metable/Metable.php @@ -7,469 +7,475 @@ use Illuminate\Support\Str; use Illuminate\Database\Eloquent\Relations\HasMany; +/** + * @property Collection $metas + */ trait Metable { - public static $_columnNames; - - /** - * whereMeta scope for easier join - * ------------------------- - */ - public function scopeWhereMeta($query, $key, $value, $alias = null) - { - $alias = (empty($alias)) ? $this->getMetaTable() : $alias; - return $query->join($this->getMetaTable() . ' AS ' . $alias, $this->getQualifiedKeyName(), '=', $alias . '.' . $this->getMetaKeyName())->where('key', '=', $key)->where('value', '=', $value)->select($this->getTable() . '.*'); - } - - /** - * Meta scope for easier join - * ------------------------- - */ - public function scopeMeta($query, $alias = null) - { - $alias = (empty($alias)) ? $this->getMetaTable() : $alias; - return $query->join($this->getMetaTable() . ' AS ' . $alias, $this->getQualifiedKeyName(), '=', $alias . '.' . $this->getMetaKeyName())->select($this->getTable() . '.*'); - } - - /** - * Set Meta Data functions - * -------------------------. - */ - public function setMeta($key, $value = null) - { - $setMeta = 'setMeta'.ucfirst(gettype($key)); - - return $this->$setMeta($key, $value); - } - - protected function setMetaString($key, $value) - { - $key = strtolower($key); - if ($this->metaData->has($key)) { - - // Make sure deletion marker is not set - $this->metaData[$key]->markForDeletion(false); - - $this->metaData[$key]->value = $value; - - return $this->metaData[$key]; - } - - return $this->metaData[$key] = $this->getModelStub([ - 'key' => $key, - 'value' => $value, - ]); - } - - protected function setMetaArray() - { - list($metas) = func_get_args(); - - foreach ($metas as $key => $value) { - $this->setMetaString($key, $value); - } - - return $this->metaData->sortByDesc('id') - ->take(count($metas)); - } - - /** - * Unset Meta Data functions - * -------------------------. - */ - public function unsetMeta($key) - { - $unsetMeta = 'unsetMeta'.ucfirst(gettype($key)); - - return $this->$unsetMeta($key); - } - - protected function unsetMetaString($key) - { - $key = strtolower($key); - if ($this->metaData->has($key)) { - $this->metaData[$key]->markForDeletion(); - } - } - - protected function unsetMetaArray() - { - list($keys) = func_get_args(); - - foreach ($keys as $key) { - $key = strtolower($key); - $this->unsetMetaString($key); - } - } - - /** - * Get Meta Data functions - * -------------------------. - */ - - public function getMeta($key = null, $raw = false) - { - if (is_string($key) && preg_match('/[,|]/is', $key, $m)) { - $key = preg_split('/ ?[,|] ?/', $key); - } - - $getMeta = 'getMeta'.ucfirst(strtolower(gettype($key))); - - // Default value is used if getMeta is null - return $this->$getMeta($key, $raw) ?? $this->getMetaDefaultValue($key); - } - - // Returns either the default value or null if default isn't set - private function getMetaDefaultValue($key) { - if(isset($this->defaultMetaValues) && array_key_exists($key, $this->defaultMetaValues)) { - return $this->defaultMetaValues[$key]; - } else { - return null; - } - } - - protected function getMetaString($key, $raw = false) - { - $meta = $this->metaData->get($key, null); - - if (is_null($meta) || $meta->isMarkedForDeletion()) { - return; - } - - return ($raw) ? $meta : $meta->value; - } - - protected function getMetaArray($keys, $raw = false) - { - $collection = new BaseCollection(); - - foreach ($this->metaData as $meta) { - if (!$meta->isMarkedForDeletion() && in_array($meta->key, $keys)) { - $collection->put($meta->key, $raw ? $meta : $meta->value); - } - } - - return $collection; - } - - protected function getMetaNull() - { - list($keys, $raw) = func_get_args(); - - $collection = new BaseCollection(); - - foreach ($this->metaData as $meta) { - if (!$meta->isMarkedForDeletion()) { - $collection->put($meta->key, $raw ? $meta : $meta->value); - } - } - - return $collection; - } - - /** - * Relationship for meta tables - */ - public function metas() - { - $classname = $this->getMetaClass(); - $model = new $classname; - $model->setTable($this->getMetaTable()); - - return new HasMany($model->newQuery(), $this, $this->getMetaKeyName(), $this->getKeyName()); - } - - /** - * Query Meta Table functions - * -------------------------. - */ - public function whereMeta($key, $value) - { - return $this->getModelStub() - ->whereKey(strtolower($key)) - ->whereValue($value) - ->get(); - } - - protected function getModelStub() - { - // get new meta model instance - $classname = $this->getMetaClass(); - $model = new $classname; - $model->setTable($this->getMetaTable()); - - // model fill with attributes. - if (func_num_args() > 0) { - array_filter(func_get_args(), [$model, 'fill']); - } - - return $model; - } - - protected function saveMeta() - { - foreach ($this->metaData as $meta) { - $meta->setTable($this->getMetaTable()); - - if ($meta->isMarkedForDeletion()) { - $meta->delete(); - continue; - } - - if ($meta->isDirty()) { - // set meta and model relation id's into meta table. - $meta->setAttribute($this->metaKeyName, $this->modelKey); - $meta->save(); - } - } - } - - protected function getMetaData() - { - if (!isset($this->metaLoaded)) { - - if ($this->exists) { - $objects = $this->metas - ->where($this->metaKeyName, $this->modelKey); - - if (!is_null($objects)) { - $this->metaLoaded = true; - - return $this->metaData = $objects->keyBy('key'); - } - } - $this->metaLoaded = true; - - return $this->metaData = new Collection(); - } - } - - /** - * Return the key for the model. - * - * @return string - */ - protected function getModelKey() - { - return $this->getKey(); - } - - /** - * Return the foreign key name for the meta table. - * - * @return string - */ - public function getMetaKeyName() - { - return isset($this->metaKeyName) ? $this->metaKeyName : $this->getForeignKey(); - } - - /** - * Return the table name. - * - * @return string - */ - public function getMetaTable() - { - return isset($this->metaTable) ? $this->metaTable : $this->getTable().'_meta'; - } - - /** - * Return the model class name. - * - * @return string - */ - protected function getMetaClass() - { - return isset($this->metaClass) ? $this->metaClass : MetaData::class; - } - - /** - * Convert the model instance to an array. - * - * @return array - */ - public function toArray() - { - return $this->hideMeta ? - parent::toArray() : - array_merge(parent::toArray(), [ - 'meta_data' => $this->getMeta()->toArray(), - ]); - } - - /** - * Model Override functions - * -------------------------. - */ - - /** - * Get an attribute from the model. - * - * @param string $key - * - * @return mixed - */ - public function getAttribute($key) - { - // parent call first. - if (($attr = parent::getAttribute($key)) !== null) { - return $attr; - } - - // there was no attribute on the model - // retrieve the data from meta relationship - return $this->getMeta($key); - } - - /** - * Set attributes for the model - * - * @param array $attributes - * - * @return void - */ - public function setAttributes(array $attributes) { - foreach ($attributes as $key => $value) { - $this->$key = $value; - } - } - - /** - * Determine if model table has a given column. - * - * @param [string] $column - * - * @return boolean - */ - public function hasColumn($column) { - if(empty(self::$_columnNames)) self::$_columnNames = array_map('strtolower',\Schema::connection($this->getConnectionName())->getColumnListing($this->getTable())); - return in_array(strtolower($column), self::$_columnNames); - } - - public static function bootMetable(){ - static::saved(function ($model) { + protected $__metaData = null; + + /** + * whereMeta scope for easier join + * ------------------------- + */ + public function scopeWhereMeta($query, $key, $value, $alias = null) { + $alias = (empty( $alias )) ? $this->getMetaTable() : $alias; + return $query->join( $this->getMetaTable() . ' AS ' . $alias, $this->getQualifiedKeyName(), '=', $alias . '.' . $this->getMetaKeyName() )->where( $alias . '.key', '=', $key )->where( $alias . '.value', '=', $value )->select( $this->getTable() . '.*' ); + } + + /** + * Meta scope for easier join + * ------------------------- + */ + public function scopeMeta($query, $alias = null) { + $alias = (empty( $alias )) ? $this->getMetaTable() : $alias; + return $query->join( $this->getMetaTable() . ' AS ' . $alias, $this->getQualifiedKeyName(), '=', $alias . '.' . $this->getMetaKeyName() )->select( $this->getTable() . '.*' ); + } + + /** + * Set Meta Data functions + * -------------------------. + */ + public function setMeta($key, $value = null) { + $setMeta = 'setMeta' . ucfirst( gettype( $key ) ); + + return $this->$setMeta( $key, $value ); + } + + protected function setMetaString($key, $value) { + $key = strtolower( $key ); + + // If there is a default value, remove the meta row instead - future returns of + // this value will be handled via the default logic in the accessor + if ( + property_exists( $this, 'defaultMetaValues' ) && + array_key_exists( $key, $this->defaultMetaValues ) && + $this->defaultMetaValues[$key] == $value + ) { + $this->unsetMeta( $key ); + + return $this; + } + + if ( $this->getMetaData()->has( $key ) ) { + + // Make sure deletion marker is not set + $this->getMetaData()[$key]->markForDeletion( false ); + + $this->getMetaData()[$key]->value = $value; + + return $this->getMetaData()[$key]; + } + + return $this->getMetaData()[$key] = $this->getModelStub( [ + 'key' => $key, + 'value' => $value, + ] ); + } + + protected function setMetaArray(): Collection { + list( $metas ) = func_get_args(); + + $collection = new Collection(); + + foreach ($metas as $key => $value) { + $collection[] = $this->setMetaString( $key, $value ); + } + + return $collection; + } + + /** + * check if meta exists + * + * @param string|array $key + * @return bool + */ + public function hasMeta($key): bool { + if ( is_string( $key ) && preg_match( '/[,|]/is', $key ) ) { + $key = preg_split( '/ ?[,|] ?/', $key ); + } + $setMeta = 'hasMeta' . ucfirst( gettype( $key ) ); + + return $this->$setMeta( $key ); + } + + protected function hasMetaString($key): bool { + $key = strtolower( $key ); + if ( $this->getMetaData()->has( $key ) ) { + return ! $this->getMetaData()[$key]->isMarkedForDeletion(); + } + return false; + } + + protected function hasMetaArray($keys): bool { + foreach ($keys as $key) { + if ( ! $this->hasMeta( $key ) ) { + return false; + } + } + return true; + } + + /** + * Unset Meta Data functions + * -------------------------. + */ + public function unsetMeta($key) { + $unsetMeta = 'unsetMeta' . ucfirst( gettype( $key ) ); + + return $this->$unsetMeta( $key ); + } + + protected function unsetMetaString($key) { + $key = strtolower( $key ); + if ( $this->getMetaData()->has( $key ) ) { + $this->getMetaData()[$key]->markForDeletion(); + } + } + + protected function unsetMetaArray() { + list( $keys ) = func_get_args(); + + foreach ($keys as $key) { + $key = strtolower( $key ); + $this->unsetMetaString( $key ); + } + } + + /** + * Get Meta Data functions + * -------------------------. + */ + + public function getMeta($key = null, $default = null) { + if ( is_string( $key ) && preg_match( '/[,|]/is', $key ) ) { + $key = preg_split( '/ ?[,|] ?/', $key ); + } + + $getMeta = 'getMeta' . ucfirst( strtolower( gettype( $key ) ) ); + + // Default value is used if getMeta is null + return $this->$getMeta( $key, $default ); + } + + /** + * Check if meta has default value + * @param $key + * @return bool + */ + public function hasDefaultMetaValue($key): bool { + if ( property_exists( $this, 'defaultMetaValues' ) ) { + return array_key_exists( $key, $this->defaultMetaValues ); + } + return false; + } + + // Returns either the default value or null if default isn't set + public function getDefaultMetaValue($key) { + if ( property_exists( $this, 'defaultMetaValues' ) && array_key_exists( $key, $this->defaultMetaValues ) ) { + return $this->defaultMetaValues[$key]; + } + else { + return null; + } + } + + protected function getMetaString($key, $default = null) { + $meta = $this->getMetaData()->get( $key ); + + if ( is_null( $meta ) || $meta->isMarkedForDeletion() ) { + // Default values set in defaultMetaValues property take precedence over default value passed to this method + return $this->getDefaultMetaValue( $key ) ?? $default; + } + + return $meta->value; + } + + protected function getMetaArray($keys, $default = null): BaseCollection { + $collection = new BaseCollection(); + $flipped = array_flip( $keys ); + foreach ($this->getMetaData() as $meta) { + if ( ! $meta->isMarkedForDeletion() && isset( $flipped[$meta->key] ) ) { + unset( $flipped[$meta->key] ); + $collection->put( $meta->key, $meta->value ); + } + } + // If there are any keys left in $flipped, it means they are not set. so fill them with default values. + // Default values set in defaultMetaValues property take precedence over default values passed to this method + foreach ($flipped as $key => $value) { + $defaultValue = $this->getDefaultMetaValue( $key ); + if ( is_null( $defaultValue ) ) { + if ( is_array( $default ) ) { + $defaultValue = $default[$key] ?? null; + } + else { + $defaultValue = $default; + } + } + + $collection->put( $key, $defaultValue ); + } + + return $collection; + } + + protected function getMetaNull(): BaseCollection { + /** @noinspection PhpUnusedLocalVariableInspection */ + list( $keys, $raw ) = func_get_args(); + + $collection = new BaseCollection(); + + foreach ($this->getMetaData() as $meta) { + if ( ! $meta->isMarkedForDeletion() ) { + $collection->put( $meta->key, $raw ? $meta : $meta->value ); + } + } + + return $collection; + } + + /** + * Relationship for meta tables + */ + public function metas(): HasMany { + $classname = $this->getMetaClass(); + $model = new $classname; + $model->setTable( $this->getMetaTable() ); + + return new HasMany( $model->newQuery(), $this, $this->getMetaKeyName(), $this->getKeyName() ); + } + + protected function getModelStub() { + // get new meta model instance + $classname = $this->getMetaClass(); + $model = new $classname; + $model->setTable( $this->getMetaTable() ); + + // model fill with attributes. + if ( func_num_args() > 0 ) { + array_filter( func_get_args(), [$model, 'fill'] ); + } + + return $model; + } + + public function saveMeta() { + foreach ($this->getMetaData() as $meta) { + $meta->setTable( $this->getMetaTable() ); + + if ( $meta->isMarkedForDeletion() ) { + $meta->delete(); + unset( $this->getMetaData()[$meta->key] ); + continue; + } + + if ( $meta->isDirty() ) { + // set meta and model relation id's into meta table. + $meta->setAttribute( $this->getMetaKeyName(), $this->getKey() ); + $meta->save(); + } + } + } + + public function getMetaData() { + if ( is_null( $this->__metaData ) ) { + + if ( $this->exists && ! is_null( $this->metas ) ) { + $this->__metaData = $this->metas->keyBy( 'key' ); + } + else { + $this->__metaData = new Collection(); + } + } + return $this->__metaData; + } + + /** + * Return the foreign key name for the meta table. + * + * @return string + */ + public function getMetaKeyName(): string { + return property_exists( $this, 'metaKeyName' ) ? $this->metaKeyName : $this->getForeignKey(); + } + + /** + * Return the table name. + * + * @return string + */ + public function getMetaTable(): string { + return property_exists( $this, 'metaTable' ) ? $this->metaTable : $this->getTable() . '_meta'; + } + + /** + * Return the model class name. + * + * @return string + */ + protected function getMetaClass(): string { + return property_exists( $this, 'metaClass' ) ? $this->metaClass : MetaData::class; + } + + /** + * Convert the model instance to an array. + * + * @return array + */ + public function toArray(): array { + return (property_exists( $this, 'hideMeta' ) && $this->hideMeta) ? + parent::toArray() : + array_merge( parent::toArray(), [ + 'meta_data' => $this->getMeta()->toArray(), + ] ); + } + + /** + * Model Override functions + * -------------------------. + */ + + /** + * Get an attribute from the model. + * + * @param string $key + * + * @return mixed + */ + public function getAttribute($key) { + // parent call first. + if ( ($attr = parent::getAttribute( $key )) !== null ) { + return $attr; + } + + // Don't get meta data if fluent access is disabled. + if ( property_exists( $this, 'disableFluentMeta' ) && $this->disableFluentMeta ) { + return $attr; + } + + // If key is a relation name, then return parent value. + // The reason for this is that it's possible that the relation does not exist and parent call returns null for that. + if ( $this->isRelation( $key ) && $this->relationLoaded( $key ) ) { + return $attr; + } + + // there was no attribute on the model + // retrieve the data from meta relationship + $meta = $this->getMeta( $key ); + + // Check for meta accessor + $accessor = Str::camel( 'get_' . $key . '_meta' ); + + if ( method_exists( $this, $accessor ) ) { + return $this->{$accessor}( $meta ); + } + return $meta; + } + + /** + * @inheritDoc + */ + public function setAttribute($key, $value) { + // Don't set meta data if fluent access is disabled. + if ( property_exists( $this, 'disableFluentMeta' ) && $this->disableFluentMeta ) { + return parent::setAttribute( $key, $value ); + } + + // First we will check for the presence of a mutator + // or if key is a model attribute or has a column named to the key + if ( $this->hasSetMutator( $key ) || + $this->hasAttributeSetMutator( $key ) || + $this->isEnumCastable( $key ) || + $this->isClassCastable( $key ) || + (! is_null( $value ) && $this->isJsonCastable( $key )) || + str_contains( $key, '->' ) || + $this->hasColumn( $key ) || + array_key_exists( $key, parent::getAttributes() ) + ) { + return parent::setAttribute( $key, $value ); + } + + // If there is a default value, remove the meta row instead - future returns of + // this value will be handled via the default logic in the accessor + if ( + property_exists( $this, 'defaultMetaValues' ) && + array_key_exists( $key, $this->defaultMetaValues ) && + $this->defaultMetaValues[$key] == $value + ) { + $this->unsetMeta( $key ); + + return $this; + } + + // if the key has a mutator execute it + $mutator = Str::camel( 'set_' . $key . '_meta' ); + + if ( method_exists( $this, $mutator ) ) { + return $this->{$mutator}( $value ); + } + + // key doesn't belong to model, lets create a new meta relationship + return $this->setMetaString( $key, $value ); + } + + /** + * Set attributes for the model + * + * @param array $attributes + * + * @return void + */ + public function setAttributes(array $attributes) { + foreach ($attributes as $key => $value) { + $this->$key = $value; + } + } + + /** + * Determine if model table has a given column. + * + * @param [string] $column + * + * @return boolean + */ + public function hasColumn($column): bool { + static $columns; + $class = get_class( $this ); + if ( ! isset( $columns[$class] ) ) { + $columns[$class] = $this->getConnection()->getSchemaBuilder()->getColumnListing( $this->getTable() ); + if ( empty( $columns[$class] ) ) { + $columns[$class] = []; + } + $columns[$class] = array_map( + 'strtolower', + $columns[$class] + ); + } + return in_array( strtolower( $column ), $columns[$class] ); + } + + public static function bootMetable() { + static::saved( function ($model) { $model->saveMeta(); - }); + } ); + } + + public function __unset($key) { + // unset attributes and relations + parent::__unset( $key ); + + // Don't unset meta data if fluent access is disabled. + if ( property_exists( $this, 'disableFluentMeta' ) && $this->disableFluentMeta ) { + return; + } + + // delete meta, only if pivot-prefix is not detected in order to avoid unnecessary (N+1) queries + // since Eloquent tries to "unset" pivot-prefixed attributes in m2m queries on pivot tables. + // N.B. Regular unset of pivot-prefixed keys is thus compromised. + if ( strpos( $key, 'pivot_' ) !== 0 ) { + $this->unsetMeta( $key ); + } } - - public function __unset($key) - { - // unset attributes and relations - parent::__unset($key); - - // delete meta, only if pivot-prefix is not detected in order to avoid unnecessary (N+1) queries - // since Eloquent tries to "unset" pivot-prefixed attributes in m2m queries on pivot tables. - // N.B. Regular unset of pivot-prefixed keys is thus compromised. - if (strpos($key, 'pivot_') !== 0) { - $this->unsetMeta($key); - } - } - - public function __get($attr) - { - // Check for meta accessor - $accessor = Str::camel('get_'.$attr.'_meta'); - - if (method_exists($this, $accessor)) { - return $this->{$accessor}(); - } - - // Check for legacy getter - $getter = 'get'.ucfirst($attr); - - // leave model relation methods for parent:: - $isRelationship = method_exists($this, $attr); - - if (method_exists($this, $getter) && !$isRelationship) { - return $this->{$getter}(); - } - - return parent::__get($attr); - } - - public function __set($key, $value) - { - // ignore the trait properties being set. - if (Str::startsWith($key, 'meta') || $key == 'query') { - $this->$key = $value; - - return; - } - - // if key is a model attribute, set as is - if (array_key_exists($key, parent::getAttributes())) { - parent::setAttribute($key, $value); - - return; - } - - // If there is a default value, remove the meta row instead - future returns of - // this value will be handled via the default logic in the accessor - if( - isset($this->defaultMetaValues) && - array_key_exists($key, $this->defaultMetaValues) && - $this->defaultMetaValues[$key] == $value - ) { - $this->unsetMeta($key); - - return; - } - - // if the key has a mutator execute it - $mutator = Str::camel('set_'.$key.'_meta'); - - if (method_exists($this, $mutator)) { - $this->{$mutator}($value); - - return; - } - - // if key belongs to meta data, append its value. - if ($this->metaData->has($key)) { - /*if ( is_null($value) ) { - $this->metaData[$key]->markForDeletion(); - return; - }*/ - $this->metaData[$key]->value = $value; - - return; - } - - // if model table has the column named to the key - if ($this->hasColumn($key)) { - parent::setAttribute($key, $value); - - return; - } - - // key doesn't belong to model, lets create a new meta relationship - //if ( ! is_null($value) ) { - $this->setMetaString($key, $value); - //} - } - - public function __isset($key) - { - // trait properties. - if (Str::startsWith($key, 'meta') || $key == 'query') { - return isset($this->{$key}); - } - - // check parent first. - if (parent::__isset($key) === true) { - return true; - } - - - // Keys with default values always "exist" from the perspective - // of the end calling function, even if the DB row doesn't exist - if(isset($this->defaultMetaValues) && array_key_exists($key, $this->defaultMetaValues)) { - return true; - } - - // lets check meta data. - return isset($this->getMetaData()[$key]); - } } diff --git a/src/Kodeine/Metable/Migrations/create_posts_meta_table.php b/src/Kodeine/Metable/Migrations/create_posts_meta_table.php index 405b57d..c75553b 100644 --- a/src/Kodeine/Metable/Migrations/create_posts_meta_table.php +++ b/src/Kodeine/Metable/Migrations/create_posts_meta_table.php @@ -13,9 +13,9 @@ class CreatePostsMetaTable extends Migration public function up() { Schema::create('posts_meta', function (Blueprint $table) { - $table->increments('id'); + $table->bigIncrements('id'); - $table->integer('post_id')->unsigned()->index(); + $table->bigInteger('post_id')->unsigned(); $table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade'); $table->string('type')->default('null'); diff --git a/tests/MetableTest.php b/tests/MetableTest.php new file mode 100644 index 0000000..f902e8e --- /dev/null +++ b/tests/MetableTest.php @@ -0,0 +1,346 @@ +addConnection( [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => '', + 'foreign_key_constraints' => true, + ] ); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + Capsule::schema()->enableForeignKeyConstraints(); + Capsule::schema()->create( 'users', function ($table) { + $table->id(); + $table->string( 'name' )->default( 'john' ); + $table->string( 'email' )->default( 'john@doe.com' ); + $table->string( 'password' )->nullable(); + $table->integer( 'user_id' )->unsigned()->nullable(); + $table->foreign( 'user_id' )->references( 'id' )->on( 'users' ); + $table->timestamps(); + } ); + Capsule::schema()->create( 'users_meta', function ($table) { + $table->id(); + $table->integer( 'user_id' )->unsigned(); + $table->foreign( 'user_id' )->references( 'id' )->on( 'users' )->onDelete( 'cascade' ); + $table->string( 'type' )->default( 'null' ); + $table->string( 'key' )->index(); + $table->text( 'value' )->nullable(); + + $table->timestamps(); + } ); + /*Capsule::schema()->table( 'users_meta', function ($table) { + $table->foreign( 'user_id' )->references( 'id' )->on( 'users' )->onDelete( 'cascade' ); + } );*/ + } + + public function testFluentMeta() { + $user = new User; + + $this->assertNull( $user->foo, 'Meta should be null by default' ); + + $user->foo = 'bar'; + + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertTrue( isset( $user->foo ), 'Fluent meta should be set before save.' ); + $this->assertEquals( 'bar', $user->foo, 'Fluent setter not working before save.' ); + $this->assertEquals( 0, Capsule::table( 'users_meta' )->count(), 'Fluent setter should not save to database before save.' ); + + $this->assertNull( $user->dummy, 'Dummy relation should be null by default' ); + $this->dummy = 'dummy'; + + $user->save(); + + $this->assertNull( $user->dummy, 'Dummy relation should be null after setting meta named dummy' ); + + $metaData = Capsule::table( 'users_meta' )->where( $user->getMetaKeyName(), $user->getKey() )->where( 'key', 'foo' ); + + $this->assertTrue( isset( $user->foo ), 'Fluent meta should be set.' ); + $this->assertEquals( 'bar', $user->foo, 'Fluent setter not working.' ); + $this->assertEquals( 'bar', is_null( $meta = $metaData->first() ) ? null : $meta->value, 'Fluent setter did not save meta to database.' ); + + $user->foo = 'baz'; + + $this->assertEquals( 'baz', $user->foo, 'Fluent setter did not update existing meta before save.' ); + $this->assertEquals( 'bar', is_null( $meta = $metaData->first() ) ? null : $meta->value, 'Fluent setter should not update meta in database before save.' ); + + $user->save(); + + $this->assertEquals( 'baz', $user->foo, 'Fluent setter did not update existing meta.' ); + $this->assertEquals( 'baz', is_null( $meta = $metaData->first() ) ? null : $meta->value, 'Fluent setter did not update meta in database.' ); + $this->assertEquals( 1, $metaData->count(), 'Fluent setter created multiple rows for one meta data.' ); + + unset( $user->foo ); + + $this->assertNull( $user->foo, 'Unsetter did not work before save.' ); + $this->assertEquals( 'baz', is_null( $meta = $metaData->first() ) ? null : $meta->value, 'Fluent unsetter should not remove meta from database before save.' ); + $this->assertEquals( 1, $metaData->count(), 'Fluent unsetter should not remove meta from database before save.' ); + $this->assertFalse( isset( $user->foo ), 'Fluent meta should not be set before save.' ); + + $user->save(); + + $this->assertNull( $user->foo, 'Unsetter did not work.' ); + $this->assertNull( $metaData->first(), 'Fluent unsetter did not remove meta from database.' ); + $this->assertEquals( 0, $metaData->count(), 'Fluent unsetter did not remove meta from database.' ); + $this->assertFalse( isset( $user->foo ), 'Fluent meta should not be set.' ); + + $user->foo = 'bar'; + $user->save(); + $user->delete(); + + $this->assertEquals( 0, $metaData->count(), 'Meta should be deleted from database after deleting user.' ); + } + + public function testDisableFluentMeta() { + $user = new User; + $user->disableFluentMeta = true; + + $user->foo = 'bar'; + + $this->assertNull( $user->getMeta( 'foo' ), 'Meta should be null.' ); + $this->assertFalse( $user->hasMeta( 'foo' ), 'Meta should not be set.' ); + + $user->setMeta( 'foo', 'baz' ); + + $this->assertNotEquals( 'baz', $user->foo, 'Fluent getter should not be changed.' ); + $this->assertEquals( 'baz', $user->getMeta( 'foo' ), 'meta should be set.' ); + + unset( $user->foo ); + + $this->assertEquals( 'baz', $user->getMeta( 'foo' ), 'meta should be set.' ); + + $user->foo = 'bar'; + $user->unsetMeta( 'foo' ); + + $this->assertNull( $user->getMeta( 'foo' ), 'meta should not be set.' ); + $this->assertEquals( 'bar', $user->foo, 'Fluent getter should not be changed.' ); + } + + public function testScopes() { + $user1 = new User; + $user1->foo = 'bar'; + $user1->save(); + + $user2 = new User; + $user2->foo = 'baz'; + $user2->save(); + + $scope = User::meta()->where( 'users_meta.key', 'foo' )->where( 'users_meta.value', 'baz' ); + $user = $scope->first(); + $this->assertEquals( $user2->getKey(), $user->getKey(), 'Meta scope found wrong user' ); + $this->assertEquals( $user->foo, $user2->foo, 'Meta scope found wrong user' ); + $this->assertNotNull( $user->metas, 'Metas relation should not be null' ); + + $scope = User::whereMeta( 'foo', 'baz' ); + $user = $scope->first(); + $this->assertEquals( $user2->getKey(), $user->getKey(), 'WhereMeta scope found wrong user' ); + $this->assertEquals( $user->foo, $user2->foo, 'WhereMeta scope found wrong user' ); + + $user1->delete(); + $user2->delete(); + } + + public function testDefaultMetaValues() { + $user = new User; + + $this->assertTrue( isset( $user->default_meta_key ), 'Default meta key should be set' ); + $this->assertTrue( $user->hasDefaultMetaValue( 'default_meta_key' ), 'Default meta key should be set' ); + $this->assertFalse( $user->hasDefaultMetaValue( 'foo' ), 'Non Default meta key should not be set' ); + $this->assertEquals( 'default_meta_value', $user->default_meta_key, 'Default meta value should be set' ); + $user->default_meta_key = 'foo'; + $this->assertEquals( 'foo', $user->default_meta_key, 'Default meta value should be changed' ); + + $user->save(); + $metaData = Capsule::table( 'users_meta' )->where( $user->getMetaKeyName(), $user->getKey() )->where( 'key', 'default_meta_key' ); + + $this->assertEquals( 'foo', is_null( $meta = $metaData->first() ) ? null : $meta->value, 'Default value should be changed in database.' ); + + $user->default_meta_key = 'default_meta_value'; + $user->save(); + + $this->assertNull( $metaData->first(), 'Default value should be removed from database.' ); + + $user->setMeta( 'default_meta_key', 'foo' ); + $user->save(); + + $this->assertEquals( 'foo', is_null( $meta = $metaData->first() ) ? null : $meta->value, 'Default value should be changed in database.' ); + + $user->setMeta( 'default_meta_key', 'default_meta_value' ); + $user->save(); + + $this->assertNull( $metaData->first(), 'Default value should be removed from database.' ); + + $user->delete(); + } + + public function testAccessorAndMutator() { + $user = new User; + + $this->assertTrue( isset( $user->accessor ), 'Meta accessor key should be set' ); + $this->assertEquals( 'accessed_', $user->accessor, 'Meta accessor value should be set' ); + + $user->accessor = 'foo'; + $this->assertEquals( 'accessed_foo', $user->accessor, 'Meta accessor value should be changed' ); + + $this->assertFalse( isset( $user->mutator ), 'Meta mutator key should not be set' ); + + $user->mutator = 'foo'; + $this->assertEquals( 'mutated_foo', $user->mutator, 'Meta mutator value should be changed' ); + } + + public function testMetaMethods() { + $user = new User; + + $user->setMeta( 'foo', 'bar' ); + $this->assertEquals( 'bar', $user->getMeta( 'foo' ), 'Meta method getMeta did not return correct value' ); + + $user->setMeta( [ + 'foo' => 'baz', + 'bas' => 'bar', + ] ); + + $user->save(); + + // re retrieve user to make sure meta is saved + $user = User::with( ['metas'] )->find( $user->getKey() ); + + $this->assertTrue( $user->relationLoaded( 'metas' ), 'Metas relation should be loaded' ); + + $this->assertEquals( 'baz', $user->getMeta( 'foo' ), 'Meta method getMeta did not return correct value' ); + $this->assertEquals( 'bar', $user->getMeta( 'bas' ), 'Meta method getMeta did not return correct value' ); + $this->assertSame( ['foo' => 'baz', 'bas' => 'bar'], $user->getMeta()->toArray(), 'Meta method getMeta did not return correct value' ); + $this->assertSame( ['foo' => 'baz', 'bas' => 'bar'], $user->getMeta( ['foo', 'bas'] )->toArray(), 'Meta method getMeta did not return correct value' ); + $this->assertSame( ['foo' => 'baz', 'bas' => 'bar'], $user->getMeta( 'foo|bas' )->toArray(), 'Meta method getMeta did not return correct value' ); + $this->assertSame( ['foo' => 'baz'], $user->getMeta( ['foo'] )->toArray(), 'Meta method getMeta did not return correct value' ); + + $this->assertTrue( $user->hasMeta( 'foo' ), 'Meta method hasMeta did not return correct value' ); + $this->assertFalse( $user->hasMeta( 'bar' ), 'Meta method hasMeta did not return correct value' ); + $this->assertTrue( $user->hasMeta( ['foo', 'bas'] ), 'Meta method hasMeta did not return correct value' ); + $this->assertTrue( $user->hasMeta( ['foo|bas'] ), 'Meta method hasMeta did not return correct value' ); + $this->assertFalse( $user->hasMeta( ['foo', 'bar'] ), 'Meta method hasMeta did not return correct value' ); + + $user->unsetMeta( 'foo' ); + $this->assertFalse( $user->hasMeta( 'foo' ), 'Meta method hasMeta did not return correct value' ); + $this->assertNull( $user->getMeta( 'foo' ), 'Meta method getMeta did not return correct value' ); + + $user->setMeta( 'foo', 'bar' ); + $user->setMeta( 'bar', 'baz' ); + $user->unsetMeta( ['foo', 'bas'] ); + + $this->assertFalse( $user->hasMeta( 'foo' ), 'Meta method hasMeta did not return correct value' ); + $this->assertFalse( $user->hasMeta( 'bas' ), 'Meta method hasMeta did not return correct value' ); + $this->assertFalse( $user->hasMeta( ['foo', 'bas'] ), 'Meta method hasMeta did not return correct value' ); + $this->assertFalse( $user->hasMeta( ['foo|bas'] ), 'Meta method hasMeta did not return correct value' ); + $this->assertFalse( $user->hasMeta( ['foo', 'bar'] ), 'Meta method hasMeta did not return correct value' ); + $this->assertTrue( $user->hasMeta( ['bar'] ), 'Meta method hasMeta did not return correct value' ); + + $user->delete(); + } + + public function testDefaultParameterInGetMeta() { + $user = new User; + + $this->assertEquals( 'default_value', $user->getMeta( 'foo', 'default_value' ), 'Default parameter should be returned when meta is null' ); + $this->assertSame( ['foo' => 'foo_value', 'bar' => 'bar_value'], $user->getMeta( ['foo', 'bar'], ['foo' => 'foo_value', 'bar' => 'bar_value'] )->toArray(), 'Default parameter should be returned when meta is null' ); + $this->assertSame( ['foo' => 'default_value', 'bar' => 'default_value'], $user->getMeta( ['foo', 'bar'], 'default_value' )->toArray(), 'Default parameter should be returned when meta is null' ); + + $this->assertEquals( 'default_meta_value', $user->getMeta( 'default_meta_key', 'bar' ), 'Default value set in defaultMetaValues property should be returned when meta is null' ); + $this->assertSame( ['default_meta_key' => 'default_meta_value', 'foo' => 'bar'], $user->getMeta( ['default_meta_key', 'foo'], ['default_meta_key' => 'bar', 'foo' => 'bar'] )->toArray(), 'Default value set in defaultMetaValues property should be returned when meta is null' ); + $this->assertSame( ['default_meta_key' => 'default_meta_value', 'foo' => 'bar'], $user->getMeta( ['default_meta_key', 'foo'], 'bar' )->toArray(), 'Default value set in defaultMetaValues property should be returned when meta is null' ); + } + + public function testHasColumn() { + $user = new User; + $this->assertTrue( $user->hasColumn( 'name' ), 'User does not have "name" column' ); + $this->assertFalse( $user->hasColumn( 'foo' ), 'User should not have "foo" column' ); + } + + public function testHideMeta() { + $user = new User; + $user->setMeta( 'foo', 'bar' ); + $user->hideMeta = false; + + $this->assertArrayHasKey( 'meta_data', $user->toArray(), 'Metas should be included in array' ); + $this->assertArrayHasKey( 'foo', $user->toArray()['meta_data'], 'Meta should be included in array' ); + + $user->hideMeta = true; + $this->assertArrayNotHasKey( 'meta_data', $user->toArray(), 'Metas should not be included in array' ); + + } + + public function testMetaDataTypeStoredCorrectly() { + $user = new User; + $user->setMeta( 'string', 'string' ); + $user->setMeta( 'integer', 1 ); + $user->setMeta( 'double', 1.1 ); + $user->setMeta( 'boolean', true ); + $user->setMeta( 'array', [1, 2, 3] ); + $user->setMeta( 'object', new stdClass ); + $user->setMeta( 'null' ); + $user->setMeta( 'datetime', new DateTime ); + + $user2 = new User; + $user2->save(); + $user->setMeta( 'model', $user2 ); + $user->save(); + // reload user + $user = User::find( $user->id ); + + $this->assertEquals( 'string', $user->getMetaData()->get( 'string' )->type ); + $this->assertEquals( 'string', gettype( $user->getMeta( 'string' ) ) ); + + $this->assertEquals( 'integer', $user->getMetaData()->get( 'integer' )->type ); + $this->assertEquals( 'integer', gettype( $user->getMeta( 'integer' ) ) ); + + $this->assertEquals( 'double', $user->getMetaData()->get( 'double' )->type ); + $this->assertEquals( 'double', gettype( $user->getMeta( 'double' ) ) ); + + $this->assertEquals( 'boolean', $user->getMetaData()->get( 'boolean' )->type ); + $this->assertEquals( 'boolean', gettype( $user->getMeta( 'boolean' ) ) ); + + $this->assertEquals( 'array', $user->getMetaData()->get( 'array' )->type ); + $this->assertEquals( 'array', gettype( $user->getMeta( 'array' ) ) ); + + $this->assertEquals( 'object', $user->getMetaData()->get( 'object' )->type ); + $this->assertEquals( 'object', gettype( $user->getMeta( 'object' ) ) ); + + $this->assertEquals( 'NULL', $user->getMetaData()->get( 'null' )->type ); + $this->assertEquals( 'NULL', gettype( $user->getMeta( 'null' ) ) ); + + $this->assertEquals( 'datetime', $user->getMetaData()->get( 'datetime' )->type ); + $this->assertInstanceOf( DateTime::class, $user->getMeta( 'datetime' ) ); + + $this->assertEquals( 'model', $user->getMetaData()->get( 'model' )->type ); + $this->assertInstanceOf( User::class, $user->getMeta( 'model' ) ); + $this->assertEquals( $user2->id, $user->getMeta( 'model' )->id ); + + $hash1 = spl_object_hash( $user->getMeta( 'model' ) ); + $hash2 = spl_object_hash( $user->getMeta( 'model' ) ); + $this->assertEquals( $hash1, $hash2 ); + + $user2->delete(); + // reload user + $user = User::find( $user->id ); + + $this->expectException( ModelNotFoundException::class ); + $user->getMeta( 'model' ); + + $user->delete(); + } +} \ No newline at end of file diff --git a/tests/Models/User.php b/tests/Models/User.php new file mode 100644 index 0000000..965d896 --- /dev/null +++ b/tests/Models/User.php @@ -0,0 +1,44 @@ + 'default_meta_value', + ]; + + public $hideMeta = false; + + public $disableFluentMeta = false; + + /** + * This is dummy relation to itself. + * + * @return HasOne + */ + public function dummy(): HasOne { + return $this->hasOne( static::class, 'user_id', 'id' ); + } + + public function getAccessorMeta($value): string { + return 'accessed_' . $value; + } + + public function setMutatorMeta($value) { + $this->setMeta( 'mutator', 'mutated_' . $value ); + } + + public static function boot() { + + static::setEventDispatcher( new Dispatcher() ); + parent::boot(); + } +} \ No newline at end of file