├── .php-cs-fixer.php ├── LICENSE ├── README.md ├── check ├── composer.json ├── phpunit.xml.bak └── src └── VirtualColumn.php /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | ['syntax' => 'short'], 8 | 'binary_operator_spaces' => [ 9 | 'default' => 'single_space', 10 | 'operators' => [ 11 | '=>' => null, 12 | '|' => 'no_space', 13 | ], 14 | ], 15 | 'blank_line_after_namespace' => true, 16 | 'blank_line_after_opening_tag' => true, 17 | 'no_superfluous_phpdoc_tags' => true, 18 | 'blank_line_before_statement' => [ 19 | 'statements' => ['return'], 20 | ], 21 | 'braces' => true, 22 | 'cast_spaces' => true, 23 | 'class_definition' => true, 24 | 'concat_space' => [ 25 | 'spacing' => 'one', 26 | ], 27 | 'declare_equal_normalize' => true, 28 | 'elseif' => true, 29 | 'encoding' => true, 30 | 'full_opening_tag' => true, 31 | 'declare_strict_types' => true, 32 | 'fully_qualified_strict_types' => true, // added by Shift 33 | 'function_declaration' => true, 34 | 'function_typehint_space' => true, 35 | 'heredoc_to_nowdoc' => true, 36 | 'include' => true, 37 | 'increment_style' => ['style' => 'post'], 38 | 'indentation_type' => true, 39 | 'linebreak_after_opening_tag' => true, 40 | 'line_ending' => true, 41 | 'lowercase_cast' => true, 42 | 'constant_case' => true, 43 | 'lowercase_keywords' => true, 44 | 'lowercase_static_reference' => true, // added from Symfony 45 | 'magic_method_casing' => true, // added from Symfony 46 | 'magic_constant_casing' => true, 47 | 'method_argument_space' => true, 48 | 'native_function_casing' => true, 49 | 'no_alias_functions' => true, 50 | 'no_extra_blank_lines' => [ 51 | 'tokens' => [ 52 | 'extra', 53 | 'throw', 54 | 'use', 55 | 'use_trait', 56 | ], 57 | ], 58 | 'no_blank_lines_after_class_opening' => true, 59 | 'no_blank_lines_after_phpdoc' => true, 60 | 'no_closing_tag' => true, 61 | 'no_empty_phpdoc' => true, 62 | 'no_empty_statement' => true, 63 | 'no_leading_import_slash' => true, 64 | 'no_leading_namespace_whitespace' => true, 65 | 'no_mixed_echo_print' => [ 66 | 'use' => 'echo', 67 | ], 68 | 'no_multiline_whitespace_around_double_arrow' => true, 69 | 'multiline_whitespace_before_semicolons' => [ 70 | 'strategy' => 'no_multi_line', 71 | ], 72 | 'no_short_bool_cast' => true, 73 | 'no_singleline_whitespace_before_semicolons' => true, 74 | 'no_spaces_after_function_name' => true, 75 | 'no_spaces_around_offset' => true, 76 | 'no_spaces_inside_parenthesis' => true, 77 | 'no_trailing_comma_in_list_call' => true, 78 | 'no_trailing_comma_in_singleline_array' => true, 79 | 'no_trailing_whitespace' => true, 80 | 'no_trailing_whitespace_in_comment' => true, 81 | 'no_unneeded_control_parentheses' => true, 82 | 'no_unreachable_default_argument_value' => true, 83 | 'no_useless_return' => true, 84 | 'no_whitespace_before_comma_in_array' => true, 85 | 'no_whitespace_in_blank_line' => true, 86 | 'normalize_index_brace' => true, 87 | 'not_operator_with_successor_space' => true, 88 | 'object_operator_without_whitespace' => true, 89 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 90 | 'phpdoc_indent' => true, 91 | 'general_phpdoc_tag_rename' => true, 92 | 'phpdoc_no_access' => true, 93 | 'phpdoc_no_package' => true, 94 | 'phpdoc_no_useless_inheritdoc' => true, 95 | 'phpdoc_scalar' => true, 96 | 'phpdoc_single_line_var_spacing' => true, 97 | 'phpdoc_summary' => true, 98 | 'phpdoc_to_comment' => false, 99 | 'phpdoc_trim' => true, 100 | 'phpdoc_types' => true, 101 | 'phpdoc_var_without_name' => true, 102 | 'psr_autoloading' => true, 103 | 'self_accessor' => true, 104 | 'short_scalar_cast' => true, 105 | 'simplified_null_return' => false, // disabled by Shift 106 | 'single_blank_line_at_eof' => true, 107 | 'single_blank_line_before_namespace' => true, 108 | 'single_class_element_per_statement' => true, 109 | 'single_import_per_statement' => false, 110 | 'single_line_after_imports' => true, 111 | 'no_unused_imports' => true, 112 | 'single_line_comment_style' => [ 113 | 'comment_types' => ['hash'], 114 | ], 115 | 'single_quote' => true, 116 | 'space_after_semicolon' => true, 117 | 'standardize_not_equals' => true, 118 | 'switch_case_semicolon_to_colon' => true, 119 | 'switch_case_space' => true, 120 | 'ternary_operator_spaces' => true, 121 | 'trailing_comma_in_multiline' => true, 122 | 'trim_array_spaces' => true, 123 | 'unary_operator_spaces' => true, 124 | 'whitespace_after_comma_in_array' => true, 125 | ]; 126 | 127 | $project_path = getcwd(); 128 | $finder = Finder::create() 129 | ->in([ 130 | $project_path . '/src', 131 | ]) 132 | ->name('*.php') 133 | ->notName('*.blade.php') 134 | ->ignoreDotFiles(true) 135 | ->ignoreVCS(true); 136 | 137 | return (new Config()) 138 | ->setFinder($finder) 139 | ->setRules($rules) 140 | ->setRiskyAllowed(true) 141 | ->setUsingCache(true); 142 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Samuel Štancl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eloquent Virtual Column 2 | 3 | ## Installation 4 | 5 | Supports Laravel 10, 11, and 12. 6 | 7 | ``` 8 | composer require stancl/virtualcolumn 9 | ``` 10 | 11 | ## Usage 12 | 13 | Use the `VirtualColumn` trait on your model: 14 | ```php 15 | use Illuminate\Database\Eloquent\Model; 16 | use Stancl\VirtualColumn\VirtualColumn; 17 | 18 | class MyModel extends Model 19 | { 20 | use VirtualColumn; 21 | 22 | public $guarded = []; 23 | 24 | public static function getCustomColumns(): array 25 | { 26 | return [ 27 | 'id', 28 | 'custom1', 29 | 'custom2', 30 | ]; 31 | } 32 | } 33 | ``` 34 | 35 | Create a migration: 36 | ```php 37 | public function up() 38 | { 39 | Schema::create('my_models', function (Blueprint $table) { 40 | $table->increments('id'); 41 | 42 | $table->string('custom1')->nullable(); 43 | $table->string('custom2')->nullable(); 44 | 45 | $table->json('data'); 46 | }); 47 | } 48 | ``` 49 | 50 | And store any data on your model: 51 | 52 | ```php 53 | $myModel = MyModel::create(['foo' => 'bar']); 54 | $myModel->update(['foo' => 'baz']); 55 | ``` 56 | -------------------------------------------------------------------------------- /check: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | offer_run() { 5 | read -p "For more output, run $1. Run it now (Y/n)? " run 6 | 7 | case ${run:0:1} in 8 | n|N ) 9 | exit 1 10 | ;; 11 | * ) 12 | $1 13 | ;; 14 | esac 15 | 16 | exit 1 17 | } 18 | 19 | if (php-cs-fixer fix --dry-run --config=.php-cs-fixer.php > /dev/null 2>/dev/null); then 20 | echo '✅ php-cs-fixer OK' 21 | else 22 | read -p "⚠️ php-cs-fixer found issues. Fix (Y/n)? " fix 23 | case ${fix:0:1} in 24 | n|N ) 25 | echo '❌ php-cs-fixer FAIL' 26 | offer_run 'php-cs-fixer fix --config=.php-cs-fixer.php' 27 | ;; 28 | * ) 29 | if (php-cs-fixer fix --config=.php-cs-fixer.php > /dev/null 2>/dev/null); then 30 | echo '✅ php-cs-fixer OK' 31 | else 32 | echo '❌ php-cs-fixer FAIL' 33 | offer_run 'php-cs-fixer fix --config=.php-cs-fixer.php' 34 | fi 35 | ;; 36 | esac 37 | fi 38 | 39 | if (./vendor/bin/phpunit > /dev/null 2>/dev/null); then 40 | echo '✅ PHPUnit OK' 41 | else 42 | echo '❌ PHPUnit FAIL' 43 | offer_run './vendor/bin/phpunit' 44 | fi 45 | 46 | echo '==================' 47 | echo '✅ Everything OK' 48 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stancl/virtualcolumn", 3 | "description": "Eloquent virtual column.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Samuel Štancl", 8 | "email": "samuel.stancl@gmail.com" 9 | } 10 | ], 11 | "autoload": { 12 | "psr-4": { 13 | "Stancl\\VirtualColumn\\": "src/" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "Stancl\\VirtualColumn\\Tests\\": "tests/" 19 | } 20 | }, 21 | "require": { 22 | "illuminate/support": ">=10.0", 23 | "illuminate/database": ">=10.0" 24 | }, 25 | "require-dev": { 26 | "orchestra/testbench": ">=8.0" 27 | }, 28 | "minimum-stability": "dev", 29 | "prefer-stable": true 30 | } 31 | -------------------------------------------------------------------------------- /phpunit.xml.bak: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./src 6 | 7 | 8 | ./src/routes.php 9 | 10 | 11 | 12 | 13 | ./tests 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/VirtualColumn.php: -------------------------------------------------------------------------------- 1 | dataEncoded) { 37 | return; 38 | } 39 | 40 | $encryptedCastables = array_merge( 41 | static::$customEncryptedCastables, 42 | ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object'], // Default encrypted castables 43 | ); 44 | 45 | foreach ($this->getAttribute(static::getDataColumn()) ?? [] as $key => $value) { 46 | $attributeHasEncryptedCastable = in_array(data_get($this->getCasts(), $key), $encryptedCastables); 47 | 48 | if ($value && $attributeHasEncryptedCastable && $this->valueEncrypted($value)) { 49 | $this->attributes[$key] = $value; 50 | } else { 51 | $this->setAttribute($key, $value); 52 | } 53 | 54 | $this->syncOriginalAttribute($key); 55 | } 56 | 57 | $this->setAttribute(static::getDataColumn(), null); 58 | 59 | $this->dataEncoded = false; 60 | } 61 | 62 | protected function encodeAttributes(): void 63 | { 64 | if ($this->dataEncoded) { 65 | return; 66 | } 67 | 68 | $dataColumn = static::getDataColumn(); 69 | $customColumns = static::getCustomColumns(); 70 | $attributes = array_filter($this->getAttributes(), fn ($key) => ! in_array($key, $customColumns), ARRAY_FILTER_USE_KEY); 71 | 72 | // Remove data column from the attributes 73 | unset($attributes[$dataColumn]); 74 | 75 | foreach ($attributes as $key => $value) { 76 | // Remove attribute from the model 77 | unset($this->attributes[$key]); 78 | unset($this->original[$key]); 79 | } 80 | 81 | // Add attribute to the data column 82 | $this->setAttribute($dataColumn, $attributes); 83 | 84 | $this->dataEncoded = true; 85 | } 86 | 87 | public function valueEncrypted(string $value): bool 88 | { 89 | try { 90 | Crypt::decryptString($value); 91 | 92 | return true; 93 | } catch (DecryptException) { 94 | return false; 95 | } 96 | } 97 | 98 | protected function decodeAttributes() 99 | { 100 | $this->dataEncoded = true; 101 | 102 | $this->decodeVirtualColumn(); 103 | } 104 | 105 | protected function getAfterListeners(): array 106 | { 107 | return [ 108 | 'retrieved' => [ 109 | function () { 110 | // Always decode after model retrieval 111 | $this->dataEncoded = true; 112 | 113 | $this->decodeVirtualColumn(); 114 | }, 115 | ], 116 | 'saving' => [ 117 | [$this, 'encodeAttributes'], 118 | ], 119 | 'creating' => [ 120 | [$this, 'encodeAttributes'], 121 | ], 122 | 'updating' => [ 123 | [$this, 'encodeAttributes'], 124 | ], 125 | ]; 126 | } 127 | 128 | protected function decodeIfEncoded() 129 | { 130 | if ($this->dataEncoded) { 131 | $this->decodeVirtualColumn(); 132 | } 133 | } 134 | 135 | protected function fireModelEvent($event, $halt = true) 136 | { 137 | $this->decodeIfEncoded(); 138 | 139 | $result = parent::fireModelEvent($event, $halt); 140 | 141 | $this->runAfterListeners($event, $halt); 142 | 143 | return $result; 144 | } 145 | 146 | public function runAfterListeners($event, $halt = true) 147 | { 148 | $listeners = $this->getAfterListeners()[$event] ?? []; 149 | 150 | if (! $event) { 151 | return; 152 | } 153 | 154 | foreach ($listeners as $listener) { 155 | if (is_string($listener)) { 156 | $listener = app($listener); 157 | $handle = [$listener, 'handle']; 158 | } else { 159 | $handle = $listener; 160 | } 161 | 162 | $handle($this); 163 | } 164 | } 165 | 166 | public function getCasts() 167 | { 168 | return array_merge(parent::getCasts(), [ 169 | static::getDataColumn() => 'array', 170 | ]); 171 | } 172 | 173 | /** 174 | * Get the name of the column that stores additional data. 175 | */ 176 | public static function getDataColumn(): string 177 | { 178 | return 'data'; 179 | } 180 | 181 | public static function getCustomColumns(): array 182 | { 183 | return [ 184 | 'id', 185 | ]; 186 | } 187 | 188 | /** 189 | * Get a column name for an attribute that can be used in SQL queries. 190 | * 191 | * (`foo` or `data->foo` depending on whether `foo` is in custom columns) 192 | */ 193 | public function getColumnForQuery(string $column): string 194 | { 195 | if (in_array($column, static::getCustomColumns(), true)) { 196 | return $column; 197 | } 198 | 199 | return static::getDataColumn() . '->' . $column; 200 | } 201 | } 202 | --------------------------------------------------------------------------------