├── .gitignore ├── tests ├── TestCase.php ├── ColumnsDetectorTest.php └── RelationMethodDetectorTest.php ├── src ├── Contracts │ └── Detector.php ├── Analyzer.php ├── Detectors │ ├── ColumnsDetector.php │ └── RelationMethodDetector.php ├── Column.php ├── Traits │ └── InteractsWithRelationMethods.php └── RelationMethod.php ├── .github ├── FUNDING.yml └── workflows │ ├── cs-fixer.yml │ ├── run-tests-L8.yml │ └── run-tests-L9.yml ├── composer.json ├── LICENSE.md ├── phpunit.xml ├── CONTRIBUTING.md ├── CHANGELOG.md ├── .php-cs-fixer.cache ├── readme.md └── .php-cs-fixer.dist.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | .env 3 | .phpunit.result.cache 4 | composer.lock -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | discover(); 15 | } 16 | 17 | public static function columns(Model $model): Collection 18 | { 19 | return (new ColumnsDetector($model))->discover(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [naoray] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "naoray/eloquent-model-analyzer", 3 | "description": "Helpful methods to gain more information about eloquent model classes.", 4 | "type": "pakage", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Krishan Koenig", 9 | "email": "krishan.koenig@googlemail.com" 10 | } 11 | ], 12 | "require": { 13 | "illuminate/database": "^8.40.0|^9.0|^10.0", 14 | "illuminate/support": "^8.24.0|^9.0|^10.0", 15 | "doctrine/dbal": "^2.6|^3.0" 16 | }, 17 | "require-dev": { 18 | "orchestra/testbench": "^6.0|^7.0|^8.0" 19 | }, 20 | "minimum-stability": "dev", 21 | "prefer-stable": true, 22 | "autoload": { 23 | "psr-4": { 24 | "Naoray\\EloquentModelAnalyzer\\": "./src" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "Naoray\\EloquentModelAnalyzer\\Tests\\": "tests" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Check & fix styling 2 | 3 | on: [push] 4 | 5 | jobs: 6 | style: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | 13 | - name: Fix style 14 | uses: docker://oskarstark/php-cs-fixer-ga 15 | with: 16 | args: --config=.php-cs-fixer.dist.php --allow-risky=yes 17 | 18 | - name: Extract branch name 19 | shell: bash 20 | run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" 21 | id: extract_branch 22 | 23 | - name: Commit changes 24 | uses: stefanzweifel/git-auto-commit-action@v2.3.0 25 | with: 26 | commit_message: Fix styling 27 | branch: ${{ steps.extract_branch.outputs.branch }} 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Krishan Koenig 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. -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src/ 6 | 7 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/naoray/eloquent-model-analyzer). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | ## Running Tests 26 | 27 | ``` bash 28 | $ composer test 29 | ``` 30 | 31 | 32 | **Happy coding**! -------------------------------------------------------------------------------- /src/Detectors/ColumnsDetector.php: -------------------------------------------------------------------------------- 1 | model = is_string($model) ? new $model() : $model; 28 | 29 | // MySQL 5.7 backward compability 30 | $databasePlatform = DB::connection()->getDoctrineSchemaManager()->getDatabasePlatform(); 31 | if (get_class($databasePlatform) === 'Doctrine\DBAL\Platforms\MySQL57Platform') { 32 | $databasePlatform->registerDoctrineTypeMapping('enum', 'string'); 33 | } 34 | } 35 | 36 | public function discover(): Collection 37 | { 38 | $tableName = $this->model->getTable(); 39 | 40 | return collect(Schema::getColumnListing($tableName)) 41 | ->mapWithKeys(function ($column) use ($tableName) { 42 | return [$column => new Column($column, $tableName)]; 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/run-tests-L8.yml: -------------------------------------------------------------------------------- 1 | name: "Run Tests - Old" 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | php: [8.0, 8.1] 12 | laravel: [8.*, 10.*] 13 | dependency-version: [prefer-stable] 14 | include: 15 | - laravel: 10.* 16 | testbench: 8.* 17 | - laravel: 8.* 18 | testbench: 6.* 19 | 20 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v2 25 | 26 | - name: Cache dependencies 27 | uses: actions/cache@v2 28 | with: 29 | path: ~/.composer/cache/files 30 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 31 | 32 | - name: Setup PHP 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: ${{ matrix.php }} 36 | extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv 37 | coverage: none 38 | 39 | - name: Install dependencies 40 | run: | 41 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "symfony/console:>=4.3.4" --no-interaction --no-update 42 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 43 | 44 | - name: Execute tests 45 | run: vendor/bin/phpunit 46 | -------------------------------------------------------------------------------- /.github/workflows/run-tests-L9.yml: -------------------------------------------------------------------------------- 1 | name: "Run Tests - Current" 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | php: [8.0, 8.1] 12 | laravel: [9.*, 10.*] 13 | dependency-version: [prefer-stable] 14 | include: 15 | - laravel: 10.* 16 | testbench: 8.* 17 | - laravel: 9.* 18 | testbench: 7.* 19 | 20 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v2 25 | 26 | - name: Cache dependencies 27 | uses: actions/cache@v2 28 | with: 29 | path: ~/.composer/cache/files 30 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 31 | 32 | - name: Setup PHP 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: ${{ matrix.php }} 36 | extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv 37 | coverage: none 38 | 39 | - name: Install dependencies 40 | run: | 41 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "symfony/console:>=4.3.4" --no-interaction --no-update 42 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 43 | 44 | - name: Execute tests 45 | run: vendor/bin/phpunit 46 | -------------------------------------------------------------------------------- /src/Detectors/RelationMethodDetector.php: -------------------------------------------------------------------------------- 1 | model = is_string($model) ? new $model() : $model; 34 | $this->reflection = new ReflectionObject($this->model); 35 | } 36 | 37 | /** 38 | * Analyzes given model with reflection to gain all methods which return 39 | * Relation instances e.g. belongsTo, hasMany, hasOne, etc. 40 | * 41 | * @return Collection 42 | */ 43 | public function discover(): Collection 44 | { 45 | return collect($this->reflection->getMethods(ReflectionMethod::IS_PUBLIC)) 46 | ->filter(function (ReflectionMethod $method) { 47 | return $this->isRelationMethod($method); 48 | }) 49 | ->map(function ($method) { 50 | return new RelationMethod($method, $this->model, $this->reflection); 51 | }); 52 | } 53 | 54 | protected function isRelationMethod(ReflectionMethod $method): bool 55 | { 56 | if (method_exists(Model::class, $method->getName())) { 57 | return false; 58 | } 59 | 60 | if ($method->hasReturnType()) { 61 | return $this->isRelationReturnType($method->getReturnType()); 62 | } 63 | 64 | if ($method->getDocComment() && $this->hasReturnTypeInDoc($method)) { 65 | return $this->hasRelationTypeInDoc($method); 66 | } 67 | 68 | if ($method->getNumberOfParameters() > 0) { 69 | return false; 70 | } 71 | 72 | // Don't try to invoke the method if it doesn't contains "$this->{relationship method} 73 | if (! $this->methodContentCallsRelationshipMethod($method)) { 74 | return false; 75 | } 76 | try { 77 | $relationObject = $this->model->{$method->getName()}(); 78 | 79 | return $relationObject instanceof Relation; 80 | } catch (\Throwable $e) { 81 | return false; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Column.php: -------------------------------------------------------------------------------- 1 | table = $table; 39 | $this->column = $column; 40 | $this->data = DB::connection()->getDoctrineColumn($table, $column); 41 | } 42 | 43 | public function toArray(): array 44 | { 45 | return [ 46 | 'name' => $this->column, 47 | 'type' => $this->typeClass(), 48 | 'unsigned' => $this->data->getUnsigned(), 49 | 'unique' => $this->isUnique(), 50 | 'isForeignKey' => $this->isForeignKey(), 51 | 'nullable' => ! $this->data->getNotnull(), 52 | 'autoincrement' => $this->data->getAutoincrement(), 53 | ]; 54 | } 55 | 56 | public function typeClass(): string 57 | { 58 | return get_class($this->data->getType()); 59 | } 60 | 61 | public function isUnique(): bool 62 | { 63 | return (bool) Arr::first($this->indexes(), function (Index $index) { 64 | return $index->isUnique(); 65 | }); 66 | } 67 | 68 | public function isForeignKey(): bool 69 | { 70 | return (bool) Arr::where(array_keys($this->indexes()), function ($key) { 71 | return Str::contains($key, '_foreign') && Str::contains($key, '_'.$this->column.'_'); 72 | }); 73 | } 74 | 75 | public function indexes(): array 76 | { 77 | $allIndexes = DB::getDoctrineSchemaManager()->listTableIndexes($this->table); 78 | 79 | return Arr::where($allIndexes, function (Index $index) { 80 | return in_array($this->column, $index->getColumns()); 81 | }); 82 | } 83 | 84 | /** 85 | * Dynamically pass method calls to the underlying column. 86 | * 87 | * @param string $method 88 | * @param array $parameters 89 | * 90 | * @return mixed 91 | */ 92 | public function __call($method, $parameters) 93 | { 94 | return $this->forwardCallTo($this->data, $method, $parameters); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/ColumnsDetectorTest.php: -------------------------------------------------------------------------------- 1 | id(); 24 | $table->enum('name', ['test']); 25 | $table->string('email')->unique(); 26 | $table->json('bio')->nullable(); 27 | }); 28 | } 29 | }; 30 | $userMigration->up(); 31 | 32 | $fields = (new ColumnsDetector(User::class))->discover(); 33 | 34 | $this->assertCount(4, $fields); 35 | $this->assertEquals([ 36 | 'name' => 'id', 37 | 'type' => IntegerType::class, 38 | 'unsigned' => false, 39 | 'unique' => true, 40 | 'isForeignKey' => false, 41 | 'nullable' => false, 42 | 'autoincrement' => true, 43 | ], $fields->get('id')->toArray()); 44 | $this->assertEquals([ 45 | 'name' => 'name', 46 | 'type' => StringType::class, 47 | 'unsigned' => false, 48 | 'unique' => false, 49 | 'isForeignKey' => false, 50 | 'nullable' => false, 51 | 'autoincrement' => false, 52 | ], $fields->get('name')->toArray()); 53 | $this->assertEquals([ 54 | 'name' => 'email', 55 | 'type' => StringType::class, 56 | 'unsigned' => false, 57 | 'unique' => true, 58 | 'isForeignKey' => false, 59 | 'nullable' => false, 60 | 'autoincrement' => false, 61 | ], $fields->get('email')->toArray()); 62 | $this->assertEquals([ 63 | 'name' => 'bio', 64 | 'type' => TextType::class, 65 | 'unsigned' => false, 66 | 'unique' => false, 67 | 'isForeignKey' => false, 68 | 'nullable' => true, 69 | 'autoincrement' => false, 70 | ], $fields->get('bio')->toArray()); 71 | } 72 | } 73 | 74 | class User extends Model 75 | { 76 | } 77 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v3.0.1](https://github.com/Naoray/eloquent-model-analyzer/tree/v3.0.1) (2022-08-22) 4 | 5 | **Fixed** 6 | - #24 fix UnionReturnType results in exception 7 | - #20 BelongsToMany relation causes exception 8 | 9 | [Full Changelog](https://github.com/naoray/eloquent-model-analyzer/compare/v3.0.0..v3.0.1) 10 | 11 | ## [v3.0.0](https://github.com/Naoray/eloquent-model-analyzer/tree/v3.0.0) (2022-02-08) 12 | 13 | **Added** 14 | - support for Laravel 9 (#23) 15 | 16 | [Full Changelog](https://github.com/naoray/eloquent-model-analyzer/compare/v2.1.3..v3.0.0) 17 | 18 | ## [v2.1.3](https://github.com/Naoray/eloquent-model-analyzer/tree/v2.1.3) (2021-08-09) 19 | 20 | **Fixes** 21 | - Only invoke relation methods https://github.com/Naoray/eloquent-model-analyzer/pull/22/commits/5a79919b073afec7940d8f9d75a1eb6e30466a2f - thanks to @mortenscheel 22 | 23 | [Full Changelog](https://github.com/naoray/eloquent-model-analyzer/compare/v2.1.2..v2.1.3) 24 | 25 | ## [v2.1.2](https://github.com/Naoray/eloquent-model-analyzer/tree/v2.1.2) (2021-05-31) 26 | 27 | **Fixes** 28 | - allow `doctrine/dbal:^3.0` to solve version conflicts for package users 29 | 30 | [Full Changelog](https://github.com/naoray/eloquent-model-analyzer/compare/v2.1.1..v2.1.2) 31 | 32 | ## [v2.1.1](https://github.com/Naoray/eloquent-model-analyzer/tree/v2.1.1) (2021-02-09) 33 | 34 | **Fixed** 35 | - security dependency updates 36 | 37 | [Full Changelog](https://github.com/naoray/eloquent-model-analyzer/compare/v2.1.0..v2.1.1) 38 | 39 | ## [v2.1.0](https://github.com/Naoray/eloquent-model-analyzer/tree/v2.1.0) (2020-12-10) 40 | 41 | **Added** 42 | - support for MySQL 5.7 enum detections 43 | 44 | [Full Changelog](https://github.com/naoray/eloquent-model-analyzer/compare/v2.0.1..v2.1.0) 45 | 46 | ## [v2.0.1](https://github.com/Naoray/eloquent-model-analyzer/tree/v2.0.1) (2020-09-24) 47 | 48 | **Fixed** 49 | - removed discovering not used service provider 50 | 51 | [Full Changelog](https://github.com/naoray/eloquent-model-analyzer/compare/v2.0.0..v2.0.1) 52 | 53 | ## [v2.0.0](https://github.com/Naoray/eloquent-model-analyzer/tree/v2.0.0) (2020-09-23) 54 | 55 | **Added** 56 | - support for Laravel 8 (b328fefb987ee87ae04aea501d08671b5c3b5b06) 57 | 58 | **Changed** 59 | - renamed `Naoray\EloquentModelAnalyzer\Contracts\Detector`'s `analyze()` method into `discover()` 60 | 61 | [Full Changelog](https://github.com/naoray/eloquent-model-analyzer/compare/v1.0.2..v2.0.0) 62 | 63 | ## [v1.0.2](https://github.com/Naoray/eloquent-model-analyzer/tree/v1.0.2) (2020-04-05) 64 | 65 | **Fixed** 66 | - removed use of `Str::of()` since it is not compatible with Laravel v6 usage 67 | 68 | [Full Changelog](https://github.com/naoray/eloquent-model-analyzer/compare/v1.0.1..v1.0.2) 69 | 70 | ## [v1.0.1](https://github.com/Naoray/eloquent-model-analyzer/tree/v1.0.1) (2020-04-05) 71 | 72 | **Initial Release** 73 | - `relations()` method to retrieve all relation methods of a model 74 | - `columns()` method to retrieve all columns of a model 75 | -------------------------------------------------------------------------------- /.php-cs-fixer.cache: -------------------------------------------------------------------------------- 1 | {"php":"8.1.14","version":"3.14.3","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"single_space","operators":{"=>":null}},"blank_line_after_namespace":true,"blank_line_after_opening_tag":true,"blank_line_before_statement":{"statements":["return"]},"braces":true,"cast_spaces":true,"class_attributes_separation":{"elements":{"method":"one"}},"class_definition":true,"concat_space":{"spacing":"none"},"declare_equal_normalize":true,"elseif":true,"encoding":true,"full_opening_tag":true,"fully_qualified_strict_types":true,"function_declaration":true,"function_typehint_space":true,"heredoc_to_nowdoc":true,"include":true,"increment_style":{"style":"post"},"indentation_type":true,"linebreak_after_opening_tag":true,"line_ending":true,"lowercase_cast":true,"constant_case":true,"lowercase_keywords":true,"lowercase_static_reference":true,"magic_method_casing":true,"magic_constant_casing":true,"method_argument_space":true,"native_function_casing":true,"no_alias_functions":true,"no_extra_blank_lines":{"tokens":["extra","throw","use","use_trait"]},"no_blank_lines_after_class_opening":true,"no_blank_lines_after_phpdoc":true,"no_closing_tag":true,"no_empty_phpdoc":true,"no_empty_statement":true,"no_leading_import_slash":true,"no_leading_namespace_whitespace":true,"no_mixed_echo_print":{"use":"echo"},"no_multiline_whitespace_around_double_arrow":true,"multiline_whitespace_before_semicolons":{"strategy":"no_multi_line"},"no_short_bool_cast":true,"no_singleline_whitespace_before_semicolons":true,"no_spaces_after_function_name":true,"no_spaces_around_offset":true,"no_spaces_inside_parenthesis":true,"no_trailing_comma_in_list_call":true,"no_trailing_comma_in_singleline_array":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"no_unneeded_control_parentheses":true,"no_unreachable_default_argument_value":true,"no_useless_return":true,"no_whitespace_before_comma_in_array":true,"no_whitespace_in_blank_line":true,"normalize_index_brace":true,"not_operator_with_successor_space":true,"object_operator_without_whitespace":true,"ordered_imports":{"sort_algorithm":"alpha"},"phpdoc_indent":true,"general_phpdoc_tag_rename":{"fix_inline":true},"phpdoc_no_access":true,"phpdoc_no_package":true,"phpdoc_no_useless_inheritdoc":true,"phpdoc_scalar":true,"phpdoc_single_line_var_spacing":true,"phpdoc_summary":true,"phpdoc_to_comment":true,"phpdoc_trim":true,"phpdoc_types":true,"phpdoc_var_without_name":true,"psr_autoloading":true,"self_accessor":true,"short_scalar_cast":true,"single_blank_line_at_eof":true,"single_blank_line_before_namespace":true,"single_class_element_per_statement":true,"single_import_per_statement":true,"single_line_after_imports":true,"single_line_comment_style":{"comment_types":["hash"]},"single_quote":true,"space_after_semicolon":true,"standardize_not_equals":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"ternary_operator_spaces":true,"trailing_comma_in_multiline":true,"trim_array_spaces":true,"unary_operator_spaces":true,"visibility_required":{"elements":["method","property"]},"whitespace_after_comma_in_array":true},"hashes":{"src\/Detectors\/ColumnsDetector.php":"602d35bdc15146d2f14ce686ded3d893","src\/Detectors\/RelationMethodDetector.php":"3f2bda79f9b4612ab1c18e9dd4516429","src\/Column.php":"dead8e18174d92b560336ff52457905e","src\/Analyzer.php":"63fb3afaa58f473ea8e27d444d9b758c","src\/Traits\/InteractsWithRelationMethods.php":"cd71ec8769a2ce9a82496ab5af2f4d6c","src\/Contracts\/Detector.php":"10558ac83854d94f87f33c94b8dc0b79","src\/RelationMethod.php":"8cfba855fba161ec1837a751eb6f9ab1","tests\/TestCase.php":"40be9debf169b730cc0d2bedc2f4a429","tests\/RelationMethodDetectorTest.php":"76bb6540a84f8a5190853c414cb61a99","tests\/ColumnsDetectorTest.php":"78def17c1f8149b52fa0a11f109a4e0e"}} -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # eloquent-model-analyzer 2 | 3 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/naoray/eloquent-model-analyzer.svg?style=flat-square)](https://packagist.org/packages/naoray/eloquent-model-analyzer) 5 | ![Tests](https://github.com/naoray/eloquent-model-analyzer/workflows/Run%20Tests%20-%20Current/badge.svg?branch=master) 6 | 7 | Analyzing an Eloquent Model for its relations and columns can be overwhelming. This little library aims to make it as simple as possible. 8 | 9 | You probably wonder why you would ever need to analyze your Models at runtime?! All scenarios I can think of are related to analyzing the codebase to generate some code bits. Here are some scenarios where this might come in handy: 10 | - automatically create factories for your models as shown in [laravel-prefill-factory](https://github.com/naoray/laravel-factory-prefill) or [factory-generator](https://github.com/laravel-shift/factory-generator) 11 | - it could be use to create something like the `trace` command in [laravel-shift/blueprint](https://github.com/laravel-shift/blueprint) 12 | 13 | ## Install 14 | `composer require naoray/eloquent-model-analyzer` 15 | 16 | ## Usage 17 | ### Getting all relations of a Model 18 | There are three different strategies for getting all relation methods of an Eloquent Model: 19 | - checking the return types of the methods 20 | - extracting the return types from the doc method 21 | - call the method directly and check the instance of what is returned 22 | 23 | ```php 24 | // User.php 25 | class User extends Model 26 | { 27 | public function parent() 28 | { 29 | return $this->belongsTo(self::class); 30 | } 31 | 32 | public function posts() 33 | { 34 | return $this->hasMany(Post::class, 'user_id'); 35 | } 36 | } 37 | 38 | // get relations 39 | // type of $columns is \Illuminate\Support\Collection 40 | $relations = Analyzer::relations(User::class); 41 | 42 | // get the first relation 43 | $relation = $relations->first(); 44 | 45 | // all relations implement the Arrayable interface 46 | $relation->toArray(); 47 | // [ 48 | // 'relatedClass' => User::class, 49 | // 'type' => \Illuminate\Database\Eloquent\Relations\BelongsTo::class, 50 | // 'foreignKey' => 'parent_id', 51 | // 'ownerKey' => 'id', 52 | // 'methodName' => 'parent', 53 | // ] 54 | ``` 55 | 56 | The `RelationMethod` Class forwards all method calls which aren't present on the class directly to the underlying `ReflectionMethod` class. 57 | 58 | ### Getting all Columns of a Model 59 | ```php 60 | // CreateUserTable.php 61 | public function up() 62 | { 63 | Schema::create('users', function (Blueprint $table) { 64 | $table->id(); 65 | $table->string('name'); 66 | $table->string('email')->unique(); 67 | $table->json('bio')->nullable(); 68 | }); 69 | } 70 | 71 | // get columns 72 | // type of $columns is \Illuminate\Support\Collection 73 | $columns = Analyzer::columns(User::class); 74 | 75 | // get a single column by column name 76 | $column = $columns->get('name'); 77 | 78 | // all columns implement the Arrayable interface 79 | $column->toArray(); 80 | // [ 81 | // 'name' => 'name', 82 | // 'type' => \Doctrine\DBAL\Types\StringType::class, 83 | // 'unsigned' => false, 84 | // 'unique' => false, 85 | // 'isForeignKey' => false, 86 | // 'nullable' => false, 87 | // 'autoincrement' => false, 88 | // ] 89 | ``` 90 | 91 | The `Column` class forwards all method calls which aren't present on the class directly to the underlying `DBAL\Schema\Column` Class. 92 | 93 | ## Testing 94 | Run the tests with: 95 | 96 | ``` bash 97 | vendor/bin/phpunit 98 | ``` 99 | 100 | ## Changelog 101 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 102 | 103 | ## Contributing 104 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 105 | 106 | ## Security 107 | If you discover any security-related issues, please email krishan.koenig@googlemail.com instead of using the issue tracker. 108 | 109 | ## License 110 | The MIT License (MIT). Please see [License File](/LICENSE.md) for more information. 111 | -------------------------------------------------------------------------------- /src/Traits/InteractsWithRelationMethods.php: -------------------------------------------------------------------------------- 1 | getTypes()) 50 | ->contains(fn (ReflectionType $type) => $this->isRelationReturnType($type)); 51 | } 52 | 53 | return in_array($type->getName(), $this->getRelationTypes()); 54 | } 55 | 56 | /** 57 | * @param ReflectionMethod $method 58 | * 59 | * @return bool 60 | */ 61 | protected function hasReturnTypeInDoc(ReflectionMethod $method): bool 62 | { 63 | return Str::contains($method->getDocComment(), '@return'); 64 | } 65 | 66 | /** 67 | * @param ReflectionMethod $method 68 | * 69 | * @return bool 70 | */ 71 | protected function hasRelationTypeInDoc(ReflectionMethod $method): bool 72 | { 73 | return (bool) $this->extractReturnTypeFromDocs( 74 | $method->getDocComment() 75 | ); 76 | } 77 | 78 | /** 79 | * @param string $docComment 80 | * 81 | * @return string 82 | */ 83 | protected function extractReturnTypeFromDocs(string $docComment) 84 | { 85 | return Arr::first(static::getRelationTypes(), function ($type) use ($docComment) { 86 | return Str::contains($docComment, '@return '.class_basename($type)) 87 | || Str::contains($docComment, "@return \\$type"); 88 | }); 89 | } 90 | 91 | /** 92 | * Get relation method written code. 93 | * 94 | * @return string 95 | */ 96 | protected function getRelationMethodContent(ReflectionMethod $method) 97 | { 98 | $file = new SplFileObject($method->getFileName()); 99 | $file->seek($method->getStartLine() - 1); 100 | 101 | $code = ''; 102 | while ($file->key() < $method->getEndLine()) { 103 | $code .= $file->current(); 104 | $file->next(); 105 | } 106 | 107 | return Str::of($code) 108 | ->replaceMatches('/\s\s+/', '') 109 | ->after('function') 110 | ->before('}') 111 | ->trim(); 112 | } 113 | 114 | protected function methodContentCallsRelationshipMethod(ReflectionMethod $method): bool 115 | { 116 | $content = (string) $this->getRelationMethodContent($method); 117 | $relationshipMethodNames = [ 118 | 'hasMany', 119 | 'hasManyThrough', 120 | 'hasOneThrough', 121 | 'belongsToMany', 122 | 'hasOne', 123 | 'belongsTo', 124 | 'morphOne', 125 | 'morphTo', 126 | 'morphMany', 127 | 'morphToMany', 128 | 'morphedByMany', 129 | ]; 130 | $regex = '#\$this->('.implode('|', $relationshipMethodNames).')\(#'; 131 | 132 | return (bool) preg_match($regex, $content); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/RelationMethod.php: -------------------------------------------------------------------------------- 1 | method = $method; 46 | $this->model = $model; 47 | $this->reflection = $reflection; 48 | } 49 | 50 | public function toArray(): array 51 | { 52 | return [ 53 | 'relatedClass' => $this->getRelatedClass(), 54 | 'type' => $this->returnType(), 55 | 'foreignKey' => $this->foreignKey(), 56 | 'ownerKey' => $this->ownerKey(), 57 | 'methodName' => $this->getName(), 58 | ]; 59 | } 60 | 61 | public function getRelatedClass(): string 62 | { 63 | $methodContent = $this->getRelationMethodContent($this->method); 64 | 65 | /** 66 | * 'author(): BelongsTo {return $this->belongsTo(User::class, 'author_id');'. 67 | */ 68 | $className = Str::of($methodContent) 69 | ->after('{return') 70 | ->after('(') 71 | ->before(',') 72 | ->trim("(');"); 73 | 74 | /* 75 | * If classname does not use ::class notation 76 | * we consider it as a full class string reference. 77 | */ 78 | if (! $className->is('*::class')) { 79 | return $className; 80 | } 81 | 82 | $className = $className->before('::class') 83 | ->__toString(); 84 | 85 | return $className === 'self' || $className === class_basename($this->reflection->getName()) 86 | ? $this->reflection->getName() 87 | : $this->reflection->getNamespaceName().'\\'.$className; 88 | } 89 | 90 | public function returnType(): string 91 | { 92 | if ($this->hasReturnType()) { 93 | return $this->getReturnType()->getName(); 94 | } 95 | 96 | if ($this->hasReturnTypeInDoc($this->method)) { 97 | return $this->extractReturnTypeFromDocs($this->getDocComment()); 98 | } 99 | 100 | return get_class($this->getRelation()); 101 | } 102 | 103 | public function foreignKey(): string 104 | { 105 | $relationObj = $this->getRelation(); 106 | 107 | return match (true) { 108 | $relationObj instanceof BelongsToMany => $relationObj->getForeignPivotKeyName(), 109 | default => $relationObj->getForeignKeyName(), 110 | }; 111 | } 112 | 113 | public function ownerKey(): string 114 | { 115 | $relationObj = $this->getRelation(); 116 | 117 | return match (true) { 118 | $relationObj instanceof BelongsToMany => $relationObj->getRelatedPivotKeyName(), 119 | $relationObj instanceof BelongsTo => $relationObj->getOwnerKeyName(), 120 | $relationObj instanceof HasManyThrough, $relationObj instanceof HasOneOrMany => $relationObj->getLocalKeyName(), 121 | }; 122 | } 123 | 124 | public function getRelation(): Relation 125 | { 126 | return $this->model->{$this->getName()}(); 127 | } 128 | 129 | /** 130 | * Dynamically pass method calls to the underlying method. 131 | * 132 | * @param string $method 133 | * @param array $parameters 134 | * 135 | * @return mixed 136 | */ 137 | public function __call($method, $parameters) 138 | { 139 | return $this->forwardCallTo($this->method, $method, $parameters); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | [ 8 | 'default' => 'single_space', 9 | 'operators' => ['=>' => null], 10 | ], 11 | 'blank_line_after_namespace' => true, 12 | 'blank_line_after_opening_tag' => true, 13 | 'blank_line_before_statement' => [ 14 | 'statements' => ['return'], 15 | ], 16 | 'braces' => true, 17 | 'cast_spaces' => true, 18 | 'class_attributes_separation' => [ 19 | 'elements' => ['method' => 'one'], 20 | ], 21 | 'class_definition' => true, 22 | 'concat_space' => [ 23 | 'spacing' => 'none', 24 | ], 25 | 'declare_equal_normalize' => true, 26 | 'elseif' => true, 27 | 'encoding' => true, 28 | 'full_opening_tag' => true, 29 | 'fully_qualified_strict_types' => true, // added by Shift 30 | 'function_declaration' => true, 31 | 'function_typehint_space' => true, 32 | 'heredoc_to_nowdoc' => true, 33 | 'include' => true, 34 | 'increment_style' => ['style' => 'post'], 35 | 'indentation_type' => true, 36 | 'linebreak_after_opening_tag' => true, 37 | 'line_ending' => true, 38 | 'lowercase_cast' => true, 39 | 'constant_case' => true, 40 | 'lowercase_keywords' => true, 41 | 'lowercase_static_reference' => true, // added from Symfony 42 | 'magic_method_casing' => true, // added from Symfony 43 | 'magic_constant_casing' => true, 44 | 'method_argument_space' => true, 45 | 'native_function_casing' => true, 46 | 'no_alias_functions' => true, 47 | 'no_extra_blank_lines' => [ 48 | 'tokens' => [ 49 | 'extra', 50 | 'throw', 51 | 'use', 52 | 'use_trait', 53 | ], 54 | ], 55 | 'no_blank_lines_after_class_opening' => true, 56 | 'no_blank_lines_after_phpdoc' => true, 57 | 'no_closing_tag' => true, 58 | 'no_empty_phpdoc' => true, 59 | 'no_empty_statement' => true, 60 | 'no_leading_import_slash' => true, 61 | 'no_leading_namespace_whitespace' => true, 62 | 'no_mixed_echo_print' => [ 63 | 'use' => 'echo', 64 | ], 65 | 'no_multiline_whitespace_around_double_arrow' => true, 66 | 'multiline_whitespace_before_semicolons' => [ 67 | 'strategy' => 'no_multi_line', 68 | ], 69 | 'no_short_bool_cast' => true, 70 | 'no_singleline_whitespace_before_semicolons' => true, 71 | 'no_spaces_after_function_name' => true, 72 | 'no_spaces_around_offset' => true, 73 | 'no_spaces_inside_parenthesis' => true, 74 | 'no_trailing_comma_in_list_call' => true, 75 | 'no_trailing_comma_in_singleline_array' => true, 76 | 'no_trailing_whitespace' => true, 77 | 'no_trailing_whitespace_in_comment' => true, 78 | 'no_unneeded_control_parentheses' => true, 79 | 'no_unreachable_default_argument_value' => true, 80 | 'no_useless_return' => true, 81 | 'no_whitespace_before_comma_in_array' => true, 82 | 'no_whitespace_in_blank_line' => true, 83 | 'normalize_index_brace' => true, 84 | 'not_operator_with_successor_space' => true, 85 | 'object_operator_without_whitespace' => true, 86 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 87 | 'phpdoc_indent' => true, 88 | 'general_phpdoc_tag_rename' => ['fix_inline' => true], 89 | 'phpdoc_no_access' => true, 90 | 'phpdoc_no_package' => true, 91 | 'phpdoc_no_useless_inheritdoc' => true, 92 | 'phpdoc_scalar' => true, 93 | 'phpdoc_single_line_var_spacing' => true, 94 | 'phpdoc_summary' => true, 95 | 'phpdoc_to_comment' => true, 96 | 'phpdoc_trim' => true, 97 | 'phpdoc_types' => true, 98 | 'phpdoc_var_without_name' => true, 99 | 'psr_autoloading' => true, 100 | 'self_accessor' => true, 101 | 'short_scalar_cast' => true, 102 | 'simplified_null_return' => false, // disabled by Shift 103 | 'single_blank_line_at_eof' => true, 104 | 'single_blank_line_before_namespace' => true, 105 | 'single_class_element_per_statement' => true, 106 | 'single_import_per_statement' => true, 107 | 'single_line_after_imports' => true, 108 | 'single_line_comment_style' => [ 109 | 'comment_types' => ['hash'], 110 | ], 111 | 'single_quote' => true, 112 | 'space_after_semicolon' => true, 113 | 'standardize_not_equals' => true, 114 | 'switch_case_semicolon_to_colon' => true, 115 | 'switch_case_space' => true, 116 | 'ternary_operator_spaces' => true, 117 | 'trailing_comma_in_multiline' => true, 118 | 'trim_array_spaces' => true, 119 | 'unary_operator_spaces' => true, 120 | 'visibility_required' => [ 121 | 'elements' => ['method', 'property'], 122 | ], 123 | 'whitespace_after_comma_in_array' => true, 124 | ]; 125 | 126 | $project_path = getcwd(); 127 | $finder = Finder::create() 128 | ->notPath('vendor') 129 | ->in([ 130 | __DIR__ . '/src', 131 | __DIR__ . '/tests', 132 | ]) 133 | ->name('*.php') 134 | ->ignoreDotFiles(true) 135 | ->ignoreVCS(true); 136 | 137 | $config = new Config(); 138 | return $config->setFinder($finder) 139 | ->setRules($rules) 140 | ->setRiskyAllowed(true) 141 | ->setUsingCache(true); 142 | -------------------------------------------------------------------------------- /tests/RelationMethodDetectorTest.php: -------------------------------------------------------------------------------- 1 | discover(); 17 | 18 | $this->assertCount(2, $relationMethods); 19 | $this->assertEquals([ 20 | 'relatedClass' => UserWithReturnTypes::class, 21 | 'type' => BelongsTo::class, 22 | 'foreignKey' => 'parent_id', 23 | 'ownerKey' => 'id', 24 | 'methodName' => 'parent', 25 | ], $relationMethods->first()->toArray()); 26 | $this->assertEquals([ 27 | 'relatedClass' => Post::class, 28 | 'type' => HasMany::class, 29 | 'foreignKey' => 'user_id', 30 | 'ownerKey' => 'id', 31 | 'methodName' => 'posts', 32 | ], $relationMethods->get(1)->toArray()); 33 | } 34 | 35 | /** @test */ 36 | public function it_can_get_relation_methods_of_a_model_by_doc_comment() 37 | { 38 | $user = new UserWithDocComments(); 39 | 40 | $relationMethods = (new RelationMethodDetector($user))->discover(); 41 | 42 | $this->assertCount(2, $relationMethods); 43 | $this->assertEquals([ 44 | 'relatedClass' => UserWithDocComments::class, 45 | 'type' => BelongsTo::class, 46 | 'foreignKey' => 'parent_id', 47 | 'ownerKey' => 'id', 48 | 'methodName' => 'parent', 49 | ], $relationMethods->first()->toArray()); 50 | $this->assertEquals([ 51 | 'relatedClass' => Post::class, 52 | 'type' => HasMany::class, 53 | 'foreignKey' => 'user_id', 54 | 'ownerKey' => 'id', 55 | 'methodName' => 'posts', 56 | ], $relationMethods->get(1)->toArray()); 57 | } 58 | 59 | /** @test */ 60 | public function it_can_get_relation_methods_of_a_model_by_method_content() 61 | { 62 | $user = new UserWithoutAnyHints(); 63 | 64 | $relationMethods = (new RelationMethodDetector($user))->discover(); 65 | 66 | $this->assertCount(2, $relationMethods); 67 | $this->assertEquals([ 68 | 'relatedClass' => UserWithoutAnyHints::class, 69 | 'type' => BelongsTo::class, 70 | 'foreignKey' => 'parent_id', 71 | 'ownerKey' => 'id', 72 | 'methodName' => 'parent', 73 | ], $relationMethods->first()->toArray()); 74 | $this->assertEquals([ 75 | 'relatedClass' => Post::class, 76 | 'type' => HasMany::class, 77 | 'foreignKey' => 'user_id', 78 | 'ownerKey' => 'id', 79 | 'methodName' => 'posts', 80 | ], $relationMethods->get(1)->toArray()); 81 | } 82 | 83 | /** @test */ 84 | public function it_can_detect_many_to_many_relation_methods() 85 | { 86 | $book = new Book(); 87 | 88 | $relationMethods = (new RelationMethodDetector($book))->discover(); 89 | 90 | $this->assertCount(1, $relationMethods); 91 | $this->assertEquals([ 92 | 'relatedClass' => Author::class, 93 | 'type' => BelongsToMany::class, 94 | 'foreignKey' => 'book_id', 95 | 'ownerKey' => 'author_id', 96 | 'methodName' => 'authors', 97 | ], $relationMethods->first()->toArray()); 98 | } 99 | 100 | /** @test */ 101 | public function it_can_handle_multiple_return_types() 102 | { 103 | $author = new Author(); 104 | 105 | $relationMethods = (new RelationMethodDetector($author))->discover(); 106 | $this->assertCount(1, $relationMethods); 107 | $this->assertEquals([ 108 | 'relatedClass' => Book::class, 109 | 'type' => BelongsToMany::class, 110 | 'foreignKey' => 'author_id', 111 | 'ownerKey' => 'book_id', 112 | 'methodName' => 'books', 113 | ], $relationMethods->first()->toArray()); 114 | } 115 | } 116 | 117 | class UserWithReturnTypes extends Model 118 | { 119 | public function parent(): BelongsTo 120 | { 121 | return $this->belongsTo(self::class); 122 | } 123 | 124 | public function posts(): HasMany 125 | { 126 | return $this->hasMany(Post::class, 'user_id'); 127 | } 128 | } 129 | 130 | class UserWithDocComments extends Model 131 | { 132 | /** 133 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 134 | */ 135 | public function parent() 136 | { 137 | return $this->belongsTo(self::class); 138 | } 139 | 140 | /** 141 | * @return HasMany 142 | */ 143 | public function posts() 144 | { 145 | return $this->hasMany(Post::class, 'user_id'); 146 | } 147 | } 148 | 149 | class UserWithoutAnyHints extends Model 150 | { 151 | public function parent() 152 | { 153 | return $this->belongsTo(self::class); 154 | } 155 | 156 | public function posts() 157 | { 158 | return $this->hasMany(Post::class, 'user_id'); 159 | } 160 | } 161 | 162 | class Post extends Model 163 | { 164 | } 165 | 166 | class Book extends Model 167 | { 168 | public function authors(): BelongsToMany 169 | { 170 | return $this->belongsToMany(Author::class); 171 | } 172 | } 173 | 174 | class Author extends Model 175 | { 176 | public function books(): BelongsToMany 177 | { 178 | return $this->belongsToMany(Book::class); 179 | } 180 | 181 | public function stringOrInt(bool $returnInt): int|string 182 | { 183 | return $returnInt ? 1 : '1'; 184 | } 185 | } 186 | --------------------------------------------------------------------------------