From 41e9b10073d0592b37437cdd06eea40a2b86f3e0 Mon Sep 17 00:00:00 2001 From: Jarek Tkaczyk Date: Fri, 13 Oct 2017 22:26:50 +0800 Subject: [PATCH] init eloquence-base --- .gitignore | 4 + .scrutinizer.yml | 7 + .travis.yml | 20 + LICENSE | 21 + README.md | 35 ++ composer.json | 60 +++ phpunit.xml | 22 + src/AttributeCleaner/Observer.php | 38 ++ src/BaseServiceProvider.php | 70 +++ src/Builder.php | 556 +++++++++++++++++++++ src/Contracts/Attribute.php | 49 ++ src/Contracts/AttributeBag.php | 10 + src/Contracts/CleansAttributes.php | 9 + src/Contracts/Mutator.php | 17 + src/Contracts/Relations/Joiner.php | 15 + src/Contracts/Relations/JoinerFactory.php | 17 + src/Contracts/Searchable/Parser.php | 31 ++ src/Contracts/Searchable/ParserFactory.php | 17 + src/Contracts/Searchable/Searchable.php | 8 + src/Contracts/Validable.php | 18 + src/Eloquence.php | 179 +++++++ src/Query/Builder.php | 106 ++++ src/Relations/Joiner.php | 201 ++++++++ src/Relations/JoinerFactory.php | 27 + src/Searchable/Column.php | 92 ++++ src/Searchable/ColumnCollection.php | 152 ++++++ src/Searchable/Parser.php | 124 +++++ src/Searchable/ParserFactory.php | 20 + src/Searchable/Subquery.php | 10 + src/Subquery.php | 137 +++++ src/helpers.php | 54 ++ tests/BuilderTest.php | 87 ++++ tests/CleansAttributesObserverTest.php | 37 ++ tests/EloquenceTest.php | 94 ++++ tests/JoinerTest.php | 163 ++++++ tests/SearchableBuilderTest.php | 383 ++++++++++++++ tests/SubqueryTest.php | 65 +++ 37 files changed, 2955 insertions(+) create mode 100644 .gitignore create mode 100644 .scrutinizer.yml create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 src/AttributeCleaner/Observer.php create mode 100644 src/BaseServiceProvider.php create mode 100644 src/Builder.php create mode 100644 src/Contracts/Attribute.php create mode 100644 src/Contracts/AttributeBag.php create mode 100644 src/Contracts/CleansAttributes.php create mode 100644 src/Contracts/Mutator.php create mode 100644 src/Contracts/Relations/Joiner.php create mode 100644 src/Contracts/Relations/JoinerFactory.php create mode 100644 src/Contracts/Searchable/Parser.php create mode 100644 src/Contracts/Searchable/ParserFactory.php create mode 100644 src/Contracts/Searchable/Searchable.php create mode 100644 src/Contracts/Validable.php create mode 100644 src/Eloquence.php create mode 100644 src/Query/Builder.php create mode 100644 src/Relations/Joiner.php create mode 100644 src/Relations/JoinerFactory.php create mode 100644 src/Searchable/Column.php create mode 100644 src/Searchable/ColumnCollection.php create mode 100644 src/Searchable/Parser.php create mode 100644 src/Searchable/ParserFactory.php create mode 100644 src/Searchable/Subquery.php create mode 100644 src/Subquery.php create mode 100644 src/helpers.php create mode 100644 tests/BuilderTest.php create mode 100644 tests/CleansAttributesObserverTest.php create mode 100644 tests/EloquenceTest.php create mode 100644 tests/JoinerTest.php create mode 100644 tests/SearchableBuilderTest.php create mode 100644 tests/SubqueryTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c8de41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/coverage/ +/vendor/ +/node_modules/ +composer.lock diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..98efbcb --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,7 @@ +tools: + php_sim: true + php_pdepend: true + php_analyzer: true +filter: + excluded_paths: + - 'tests/*' diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..efacb1e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +language: php + +php: + - 7.0 + - 7.1 + +install: + - travis_retry composer require satooshi/php-coveralls:~0.6@stable + +before_script: + - mkdir -p build/logs + - travis_retry composer self-update + - travis_retry composer install --prefer-source --no-interaction --dev + +script: + - ./vendor/bin/phpunit --coverage-clover build/logs/clover.xml + - ./vendor/bin/phpcs src --standard=psr2 + +after_success: + - sh -c 'if [ "$TRAVIS_PHP_VERSION" != "hhvm" ]; then php vendor/bin/coveralls -v; fi;' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..98c77f2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2017 Jarek Tkaczyk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6278a2 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Sofa/Eloquence + +[![Build Status](https://travis-ci.org/jarektkaczyk/eloquence-base.svg)](https://travis-ci.org/jarektkaczyk/eloquence-base) [![Coverage Status](https://coveralls.io/repos/jarektkaczyk/eloquence-base/badge.svg)](https://coveralls.io/r/jarektkaczyk/eloquence-base) [![Code Quality](https://scrutinizer-ci.com/g/jarektkaczyk/eloquence-base/badges/quality-score.png)](https://scrutinizer-ci.com/g/jarektkaczyk/eloquence-base) [![Downloads](https://poser.pugx.org/sofa/eloquence-base/downloads)](https://packagist.org/packages/sofa/eloquence-base) [![stable](https://poser.pugx.org/sofa/eloquence-base/v/stable.svg)](https://packagist.org/packages/sofa/eloquence-base) + +Easy and flexible extensions for the [Eloquent ORM](https://laravel.com/docs/eloquent). + +**If I'm saving you some time with my work, you can back me up on [Patreon page](https://patreon.com/jarektkaczyk).** + +Currently available extensions: + +1. [Base - Searchable](https://github.com/jarektkaczyk/eloquence-base) query - crazy-simple fulltext search through any related model +1. [Validable](https://github.com/jarektkaczyk/eloquence-validable) - self-validating models +2. [Mappable](https://github.com/jarektkaczyk/eloquence-mappable) -map attributes to table fields and/or related models +3. [Metable](https://github.com/jarektkaczyk/eloquence-metable) - meta attributes made easy +4. [Mutable](https://github.com/jarektkaczyk/eloquence-mutable) - flexible attribute get/set mutators with quick setup +5. [Mutator](https://github.com/jarektkaczyk/eloquence-mutable) - pipe-based mutating + +## Installation + +```bash +composer require sofa/eloquence-base +``` + +**Check the [documentation](https://github.com/jarektkaczyk/eloquence/wiki) for installation and usage info, [website](http://softonsofa.com/tag/eloquence/) for examples and [API reference](http://jarektkaczyk.github.io/eloquence-api)** + +## Contribution + +Shout out to all the Contributors! + +All contributions are welcome, PRs must be **tested** and **PSR-2 compliant**. + +To validate your builds before committing use the following composer command: +```bash +composer test +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..678fde3 --- /dev/null +++ b/composer.json @@ -0,0 +1,60 @@ +{ + "name": "sofa/eloquence-base", + "description": "Flexible Searchable, Mappable, Metable, Validation and more extensions for Laravel Eloquent ORM.", + "license": "MIT", + "support": { + "issues": "https://github.com/jarektkaczyk/eloquence-base/issues", + "source": "https://github.com/jarektkaczyk/eloquence-base" + }, + "keywords": [ + "laravel", + "eloquent", + "metable", + "searchable", + "mappable", + "mutable" + ], + "authors": [ + { + "name": "Jarek Tkaczyk", + "email": "jarek@softonsofa.com", + "homepage": "https://softonsofa.com/", + "role": "Developer" + } + ], + "require": { + "php": ">=7.0.0", + "sofa/hookable": "5.5.*", + "illuminate/database": "5.5.*" + }, + "require-dev": { + "phpunit/phpunit": "4.5.0", + "squizlabs/php_codesniffer": "2.3.3", + "mockery/mockery": "0.9.4" + }, + "autoload": { + "psr-4": { + "Sofa\\Eloquence\\": "src" + }, + "files": [ + "src/helpers.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Sofa\\Eloquence\\Tests\\": "tests" + } + }, + "extra": { + "laravel": { + "providers": [ + "Sofa\\Eloquence\\BaseServiceProvider" + ] + } + }, + "minimum-stability": "stable", + "scripts": { + "test": "phpunit && ./vendor/bin/phpcs src --standard=psr2 --report=diff --colors", + "phpcs": "./vendor/bin/phpcs src --standard=psr2 --report=diff --colors" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..fb10e92 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + ./tests/ + + + + + ./src + + + diff --git a/src/AttributeCleaner/Observer.php b/src/AttributeCleaner/Observer.php new file mode 100644 index 0000000..379e67e --- /dev/null +++ b/src/AttributeCleaner/Observer.php @@ -0,0 +1,38 @@ +cleanAttributes($model); + } + } + + /** + * Get rid of attributes that are not correct columns on this model's table. + * + * @param \Sofa\Eloquence\Contracts\CleansAttributes $model + * @return void + */ + protected function cleanAttributes(CleansAttributes $model) + { + $dirty = array_keys($model->getDirty()); + + $invalidColumns = array_diff($dirty, $model->getColumnListing()); + + foreach ($invalidColumns as $column) { + unset($model->{$column}); + } + } +} diff --git a/src/BaseServiceProvider.php b/src/BaseServiceProvider.php new file mode 100644 index 0000000..13047d6 --- /dev/null +++ b/src/BaseServiceProvider.php @@ -0,0 +1,70 @@ +registerJoiner(); + $this->registerParser(); + } + + /** + * Register relation joiner factory. + * + * @return void + */ + protected function registerJoiner() + { + $this->app->singleton('eloquence.joiner', function () { + return new JoinerFactory; + }); + + $this->app->alias('eloquence.joiner', 'Sofa\Eloquence\Contracts\Relations\JoinerFactory'); + } + + /** + * Register serachable parser factory. + * + * @return void + */ + protected function registerParser() + { + $this->app->singleton('eloquence.parser', function () { + return new ParserFactory; + }); + + $this->app->alias('eloquence.parser', 'Sofa\Eloquence\Contracts\Relations\ParserFactory'); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return ['eloquence.joiner', 'eloquence.parser']; + } +} diff --git a/src/Builder.php b/src/Builder.php new file mode 100644 index 0000000..cc600eb --- /dev/null +++ b/src/Builder.php @@ -0,0 +1,556 @@ +query->from instanceof Subquery) { + $this->wheresToSubquery($this->query->from); + } + + return parent::get($columns); + } + + /** + * Search through any columns on current table or any defined relations + * and return results ordered by search relevance. + * + * @param array|string $query + * @param array $columns + * @param boolean $fulltext + * @param float $threshold + * @return $this + */ + public function search($query, $columns = null, $fulltext = true, $threshold = null) + { + if (is_bool($columns)) { + list($fulltext, $columns) = [$columns, []]; + } + + $parser = static::$parser->make(); + + $words = is_array($query) ? $query : $parser->parseQuery($query, $fulltext); + + $columns = $parser->parseWeights($columns ?: $this->model->getSearchableColumns()); + + if (count($words) && count($columns)) { + $this->query->from($this->buildSubquery($words, $columns, $threshold)); + } + + return $this; + } + + /** + * Build the search subquery. + * + * @param array $words + * @param array $mappings + * @param float $threshold + * @return \Sofa\Eloquence\Searchable\Subquery + */ + protected function buildSubquery(array $words, array $mappings, $threshold) + { + $subquery = new SearchableSubquery($this->query->newQuery(), $this->model->getTable()); + + $columns = $this->joinForSearch($mappings, $subquery); + + $threshold = (is_null($threshold)) + ? array_sum($columns->getWeights()) / 4 + : (float) $threshold; + + $subquery->select($this->model->getTable() . '.*') + ->from($this->model->getTable()) + ->groupBy($this->model->getQualifiedKeyName()); + + $this->addSearchClauses($subquery, $columns, $words, $threshold); + + return $subquery; + } + + /** + * Add select and where clauses on the subquery. + * + * @param \Sofa\Eloquence\Searchable\Subquery $subquery + * @param \Sofa\Eloquence\Searchable\ColumnCollection $columns + * @param array $words + * @param float $threshold + * @return void + */ + protected function addSearchClauses( + SearchableSubquery $subquery, + ColumnCollection $columns, + array $words, + $threshold + ) { + $whereBindings = $this->searchSelect($subquery, $columns, $words, $threshold); + + // For morphOne/morphMany support we need to port the bindings from JoinClauses. + $joinBindings = collect($subquery->getQuery()->joins)->flatMap(function ($join) { + return $join->getBindings(); + })->all(); + + $this->addBinding($joinBindings, 'select'); + + // Developer may want to skip the score threshold filtering by passing zero + // value as threshold in order to simply order full result by relevance. + // Otherwise we are going to add where clauses for speed improvement. + if ($threshold > 0) { + $this->searchWhere($subquery, $columns, $words, $whereBindings); + } + + $this->query->where('relevance', '>=', new Expression($threshold)); + + $this->query->orders = array_merge( + [['column' => 'relevance', 'direction' => 'desc']], + (array) $this->query->orders + ); + } + + /** + * Apply relevance select on the subquery. + * + * @param \Sofa\Eloquence\Searchable\Subquery $subquery + * @param \Sofa\Eloquence\Searchable\ColumnCollection $columns + * @param array $words + * @return array + */ + protected function searchSelect(SearchableSubquery $subquery, ColumnCollection $columns, array $words) + { + $cases = $bindings = []; + + foreach ($columns as $column) { + list($cases[], $binding) = $this->buildCase($column, $words); + + $bindings = array_merge_recursive($bindings, $binding); + } + + $select = implode(' + ', $cases); + + $subquery->selectRaw("max({$select}) as relevance"); + + $this->addBinding($bindings['select'], 'select'); + + return $bindings['where']; + } + + /** + * Apply where clauses on the subquery. + * + * @param \Sofa\Eloquence\Searchable\Subquery $subquery + * @param \Sofa\Eloquence\Searchable\ColumnCollection $columns + * @param array $words + * @return void + */ + protected function searchWhere( + SearchableSubquery $subquery, + ColumnCollection $columns, + array $words, + array $bindings + ) { + $operator = $this->getLikeOperator(); + + $wheres = []; + + foreach ($columns as $column) { + $wheres[] = implode( + ' or ', + array_fill(0, count($words), sprintf('%s %s ?', $column->getWrapped(), $operator)) + ); + } + + $where = implode(' or ', $wheres); + + $subquery->whereRaw("({$where})"); + + $this->addBinding($bindings, 'select'); + } + + /** + * Move where clauses to subquery to improve performance. + * + * @param \Sofa\Eloquence\Searchable\Subquery $subquery + * @return void + */ + protected function wheresToSubquery(SearchableSubquery $subquery) + { + $bindingKey = 0; + + $typesToMove = [ + 'basic', 'in', 'notin', 'between', 'null', + 'notnull', 'date', 'day', 'month', 'year', + ]; + + // Here we are going to move all the where clauses that we might apply + // on the subquery in order to improve performance, since this way + // we can drastically reduce number of joined rows on subquery. + foreach ((array) $this->query->wheres as $key => $where) { + $type = strtolower($where['type']); + + $bindingsCount = $this->countBindings($where, $type); + + if (in_array($type, $typesToMove) && $this->model->hasColumn($where['column'])) { + unset($this->query->wheres[$key]); + + $where['column'] = $this->model->getTable() . '.' . $where['column']; + + $subquery->getQuery()->wheres[] = $where; + + $whereBindings = $this->query->getRawBindings()['where']; + + $bindings = array_splice($whereBindings, $bindingKey, $bindingsCount); + + $this->query->setBindings($whereBindings, 'where'); + + $this->query->addBinding($bindings, 'select'); + + // if where is not to be moved onto the subquery, let's increment + // binding key appropriately, so we can reliably move binding + // for the next where clauses in the loop that is running. + } else { + $bindingKey += $bindingsCount; + } + } + } + + /** + * Get number of bindings provided for a where clause. + * + * @param array $where + * @param string $type + * @return integer + */ + protected function countBindings(array $where, $type) + { + if ($this->isHasWhere($where, $type)) { + return substr_count($where['column'] . $where['value'], '?'); + } elseif ($type === 'basic') { + return (int) !$where['value'] instanceof Expression; + } elseif (in_array($type, ['basic', 'date', 'year', 'month', 'day'])) { + return (int) !$where['value'] instanceof Expression; + } elseif (in_array($type, ['null', 'notnull'])) { + return 0; + } elseif ($type === 'between') { + return 2; + } elseif (in_array($type, ['in', 'notin'])) { + return count($where['values']); + } elseif ($type === 'raw') { + return substr_count($where['sql'], '?'); + } elseif (in_array($type, ['nested', 'sub', 'exists', 'notexists', 'insub', 'notinsub'])) { + return count($where['query']->getBindings()); + } + } + + /** + * Determine whether where clause is eloquent has subquery. + * + * @param array $where + * @param string $type + * @return boolean + */ + protected function isHasWhere($where, $type) + { + return $type === 'basic' + && $where['column'] instanceof Expression + && $where['value'] instanceof Expression; + } + + /** + * Build case clause from all words for a single column. + * + * @param \Sofa\Eloquence\Searchable\Column $column + * @param array $words + * @return array + */ + protected function buildCase(Column $column, array $words) + { + // THIS IS BAD + // @todo refactor + + $operator = $this->getLikeOperator(); + + $bindings['select'] = $bindings['where'] = array_map(function ($word) { + return $this->caseBinding($word); + }, $words); + + $case = $this->buildEqualsCase($column, $words); + + if (strpos(implode('', $words), '*') !== false) { + $leftMatching = []; + + foreach ($words as $key => $word) { + if ($this->isLeftMatching($word)) { + $leftMatching[] = sprintf('%s %s ?', $column->getWrapped(), $operator); + $bindings['select'][] = $bindings['where'][$key] = $this->caseBinding($word) . '%'; + } + } + + if (count($leftMatching)) { + $leftMatching = implode(' or ', $leftMatching); + $score = 5 * $column->getWeight(); + $case .= " + case when {$leftMatching} then {$score} else 0 end"; + } + + $wildcards = []; + + foreach ($words as $key => $word) { + if ($this->isWildcard($word)) { + $wildcards[] = sprintf('%s %s ?', $column->getWrapped(), $operator); + $bindings['select'][] = $bindings['where'][$key] = '%'.$this->caseBinding($word) . '%'; + } + } + + if (count($wildcards)) { + $wildcards = implode(' or ', $wildcards); + $score = 1 * $column->getWeight(); + $case .= " + case when {$wildcards} then {$score} else 0 end"; + } + } + + return [$case, $bindings]; + } + + /** + * Replace '?' with single character SQL wildcards. + * + * @param string $word + * @return string + */ + protected function caseBinding($word) + { + $parser = static::$parser->make(); + + return str_replace('?', '_', $parser->stripWildcards($word)); + } + + /** + * Build basic search case for 'equals' comparison. + * + * @param \Sofa\Eloquence\Searchable\Column $column + * @param array $words + * @return string + */ + protected function buildEqualsCase(Column $column, array $words) + { + $equals = implode(' or ', array_fill(0, count($words), sprintf('%s = ?', $column->getWrapped()))); + + $score = 15 * $column->getWeight(); + + return "case when {$equals} then {$score} else 0 end"; + } + + /** + * Determine whether word ends with wildcard. + * + * @param string $word + * @return boolean + */ + protected function isLeftMatching($word) + { + return ends_with($word, '*'); + } + + /** + * Determine whether word starts and ends with wildcards. + * + * @param string $word + * @return boolean + */ + protected function isWildcard($word) + { + return ends_with($word, '*') && starts_with($word, '*'); + } + + /** + * Get driver-specific case insensitive like operator. + * + * @return string + */ + public function getLikeOperator() + { + $grammar = $this->query->getGrammar(); + + if ($grammar instanceof PostgresGrammar) { + return 'ilike'; + } + + return 'like'; + } + + /** + * Join related tables on the search subquery. + * + * @param array $mappings + * @param \Sofa\Eloquence\Searchable\Subquery $subquery + * @return \Sofa\Eloquence\Searchable\ColumnCollection + */ + protected function joinForSearch($mappings, $subquery) + { + $mappings = is_array($mappings) ? $mappings : (array) $mappings; + + $columns = new ColumnCollection; + + $grammar = $this->query->getGrammar(); + + $joiner = static::$joinerFactory->make($subquery->getQuery(), $this->model); + + // Here we loop through the search mappings in order to join related tables + // appropriately and build a searchable column collection, which we will + // use to build select and where clauses with correct table prefixes. + foreach ($mappings as $mapping => $weight) { + if (strpos($mapping, '.') !== false) { + list($relation, $column) = $this->model->parseMappedColumn($mapping); + + $related = $joiner->leftJoin($relation); + + $columns->add( + new Column($grammar, $related->getTable(), $column, $mapping, $weight) + ); + } else { + $columns->add( + new Column($grammar, $this->model->getTable(), $mapping, $mapping, $weight) + ); + } + } + + return $columns; + } + + /** + * Prefix selected columns with table name in order to avoid collisions. + * + * @return $this + */ + public function prefixColumnsForJoin() + { + if (!$columns = $this->query->columns) { + return $this->select($this->model->getTable() . '.*'); + } + + foreach ($columns as $key => $column) { + if ($this->model->hasColumn($column)) { + $columns[$key] = $this->model->getTable() . '.' . $column; + } + } + + $this->query->columns = $columns; + + return $this; + } + + /** + * Join related tables. + * + * @param array|string $relations + * @param string $type + * @return $this + */ + public function joinRelations($relations, $type = 'inner') + { + if (is_null($this->joiner)) { + $this->joiner = static::$joinerFactory->make($this); + } + + if (!is_array($relations)) { + list($relations, $type) = [func_get_args(), 'inner']; + } + + foreach ($relations as $relation) { + $this->joiner->join($relation, $type); + } + + return $this; + } + + /** + * Left join related tables. + * + * @param array|string $relations + * @return $this + */ + public function leftJoinRelations($relations) + { + $relations = is_array($relations) ? $relations : func_get_args(); + + return $this->joinRelations($relations, 'left'); + } + + /** + * Right join related tables. + * + * @param array|string $relations + * @return $this + */ + public function rightJoinRelations($relations) + { + $relations = is_array($relations) ? $relations : func_get_args(); + + return $this->joinRelations($relations, 'right'); + } + + /** + * Set search query parser factory instance. + * + * @param \Sofa\Eloquence\Contracts\Searchable\ParserFactory $factory + */ + public static function setParserFactory(ParserFactory $factory) + { + static::$parser = $factory; + } + + /** + * Set the relations joiner factory instance. + * + * @param \Sofa\Eloquence\Contracts\Relations\JoinerFactory $factory + */ + public static function setJoinerFactory(JoinerFactory $factory) + { + static::$joinerFactory = $factory; + } +} diff --git a/src/Contracts/Attribute.php b/src/Contracts/Attribute.php new file mode 100644 index 0000000..c92f18a --- /dev/null +++ b/src/Contracts/Attribute.php @@ -0,0 +1,49 @@ +isWhereNullByArgs($args); + } + + /** + * Determine whether where is a whereNull by the arguments passed to where method. + * + * @param ArgumentBag $args + * @return boolean + */ + protected function isWhereNullByArgs(ArgumentBag $args) + { + return is_null($args->get('operator')) + || is_null($args->get('value')) && !in_array($args->get('operator'), ['<>', '!=']); + } + + /** + * Extract real name and alias from the sql select clause. + * + * @param string $column + * @return array + */ + protected function extractColumnAlias($column) + { + $alias = $column; + + if (strpos($column, ' as ') !== false) { + list($column, $alias) = explode(' as ', $column); + } + + return [$column, $alias]; + } + + /** + * Get the target relation and column from the mapping. + * + * @param string $mapping + * @return array + */ + public function parseMappedColumn($mapping) + { + $segments = explode('.', $mapping); + + $column = array_pop($segments); + + $target = implode('.', $segments); + + return [$target, $column]; + } + + /** + * Determine whether the key is meta attribute or actual table field. + * + * @param string $key + * @return boolean + */ + public static function hasColumn($key) + { + static::loadColumnListing(); + + return in_array((string) $key, static::$columnListing); + } + + /** + * Get searchable columns defined on the model. + * + * @return array + */ + public function getSearchableColumns() + { + return (property_exists($this, 'searchableColumns')) ? $this->searchableColumns : []; + } + + /** + * Get model table columns. + * + * @return array + */ + public static function getColumnListing() + { + static::loadColumnListing(); + + return static::$columnListing; + } + + /** + * Fetch model table columns. + * + * @return void + */ + protected static function loadColumnListing() + { + if (empty(static::$columnListing)) { + $instance = new static; + + static::$columnListing = $instance->getConnection() + ->getSchemaBuilder() + ->getColumnListing($instance->getTable()); + } + } + + /** + * Create new Eloquence query builder for the instance. + * + * @param \Sofa\Eloquence\Query\Builder $query + * @return \Sofa\Eloquence\Builder + */ + public function newEloquentBuilder($query) + { + return new Builder($query); + } + + /** + * Get a new query builder instance for the connection. + * + * @return \Sofa\Eloquence\Query\Builder + */ + protected function newBaseQueryBuilder() + { + $conn = $this->getConnection(); + + $grammar = $conn->getQueryGrammar(); + + return new QueryBuilder($conn, $grammar, $conn->getPostProcessor()); + } +} diff --git a/src/Query/Builder.php b/src/Query/Builder.php new file mode 100644 index 0000000..f461e48 --- /dev/null +++ b/src/Query/Builder.php @@ -0,0 +1,106 @@ +aggregate = compact('function', 'columns'); + + $previousColumns = $this->columns; + + if (!$this->from instanceof Subquery) { + // We will also back up the select bindings since the select clause will be + // removed when performing the aggregate function. Once the query is run + // we will add the bindings back onto this query so they can get used. + $previousSelectBindings = $this->bindings['select']; + + $this->bindings['select'] = []; + } + + $results = $this->get($columns); + + // Once we have executed the query, we will reset the aggregate property so + // that more select queries can be executed against the database without + // the aggregate value getting in the way when the grammar builds it. + $this->aggregate = null; + + $this->columns = $previousColumns; + + if (!$this->from instanceof Subquery) { + $this->bindings['select'] = $previousSelectBindings; + } + + if (isset($results[0])) { + $result = array_change_key_case((array) $results[0]); + + return $result['aggregate']; + } + } + + /** + * Backup some fields for the pagination count. + * + * @return void + */ + protected function backupFieldsForCount() + { + foreach (['orders', 'limit', 'offset', 'columns'] as $field) { + $this->backups[$field] = $this->{$field}; + + $this->{$field} = null; + } + + $bindings = ($this->from instanceof Subquery) ? ['order'] : ['order', 'select']; + + foreach ($bindings as $key) { + $this->bindingBackups[$key] = $this->bindings[$key]; + + $this->bindings[$key] = []; + } + } + + /** + * Restore some fields after the pagination count. + * + * @return void + */ + protected function restoreFieldsForCount() + { + foreach ($this->backups as $field => $value) { + $this->{$field} = $value; + } + + foreach ($this->bindingBackups as $key => $value) { + $this->bindings[$key] = $value; + } + + $this->backups = $this->bindingBackups = []; + } + + /** + * Run a pagination count query. + * + * @param array $columns + * @return array + */ + protected function runPaginationCountQuery($columns = ['*']) + { + $bindings = $this->from instanceof Subquery ? ['order'] : ['select', 'order']; + + return $this->cloneWithout(['columns', 'orders', 'limit', 'offset']) + ->cloneWithoutBindings($bindings) + ->setAggregate('count', $this->withoutSelectAliases($columns)) + ->get()->all(); + } +} diff --git a/src/Relations/Joiner.php b/src/Relations/Joiner.php new file mode 100644 index 0000000..7d5501e --- /dev/null +++ b/src/Relations/Joiner.php @@ -0,0 +1,201 @@ +query = $query; + $this->model = $model; + } + + /** + * Join related tables. + * + * @param string $target + * @param string $type + * @return \Illuminate\Database\Eloquent\Model + */ + public function join($target, $type = 'inner') + { + $related = $this->model; + + foreach (explode('.', $target) as $segment) { + $related = $this->joinSegment($related, $segment, $type); + } + + return $related; + } + + /** + * Left join related tables. + * + * @param string $target + * @return \Illuminate\Database\Eloquent\Model + */ + public function leftJoin($target) + { + return $this->join($target, 'left'); + } + + /** + * Right join related tables. + * + * @param string $target + * @return \Illuminate\Database\Eloquent\Model + */ + public function rightJoin($target) + { + return $this->join($target, 'right'); + } + + /** + * Join relation's table accordingly. + * + * @param \Illuminate\Database\Eloquent\Model $parent + * @param string $segment + * @param string $type + * @return \Illuminate\Database\Eloquent\Model + */ + protected function joinSegment(Model $parent, $segment, $type) + { + $relation = $parent->{$segment}(); + $related = $relation->getRelated(); + $table = $related->getTable(); + + if ($relation instanceof BelongsToMany || $relation instanceof HasManyThrough) { + $this->joinIntermediate($parent, $relation, $type); + } + + if (!$this->alreadyJoined($join = $this->getJoinClause($parent, $relation, $table, $type))) { + $this->query->joins[] = $join; + } + + return $related; + } + + /** + * Determine whether the related table has been already joined. + * + * @param \Illuminate\Database\Query\JoinClause $join + * @return boolean + */ + protected function alreadyJoined(Join $join) + { + return in_array($join, (array) $this->query->joins); + } + + /** + * Get the join clause for related table. + * + * @param \Illuminate\Database\Eloquent\Model $parent + * @param \Illuminate\Database\Eloquent\Relations\Relation $relation + * @param string $type + * @param string $table + * @return \Illuminate\Database\Query\JoinClause + */ + protected function getJoinClause(Model $parent, Relation $relation, $table, $type) + { + list($fk, $pk) = $this->getJoinKeys($relation); + + $join = (new Join($this->query, $type, $table))->on($fk, '=', $pk); + + if ($relation instanceof MorphOneOrMany) { + $join->where($relation->getQualifiedMorphType(), '=', $parent->getMorphClass()); + } + + return $join; + } + + /** + * Join pivot or 'through' table. + * + * @param \Illuminate\Database\Eloquent\Model $parent + * @param \Illuminate\Database\Eloquent\Relations\Relation $relation + * @param string $type + * @return void + */ + protected function joinIntermediate(Model $parent, Relation $relation, $type) + { + if ($relation instanceof BelongsToMany) { + $table = $relation->getTable(); + $fk = $relation->getQualifiedForeignPivotKeyName(); + } else { + $table = $relation->getParent()->getTable(); + $fk = $relation->getQualifiedFirstKeyName(); + } + + $pk = $parent->getQualifiedKeyName(); + + if (!$this->alreadyJoined($join = (new Join($this->query, $type, $table))->on($fk, '=', $pk))) { + $this->query->joins[] = $join; + } + } + + /** + * Get pair of the keys from relation in order to join the table. + * + * @param \Illuminate\Database\Eloquent\Relations\Relation $relation + * @return array + * + * @throws \LogicException + */ + protected function getJoinKeys(Relation $relation) + { + if ($relation instanceof MorphTo) { + throw new LogicException("MorphTo relation cannot be joined."); + } + + if ($relation instanceof HasOneOrMany) { + return [$relation->getQualifiedForeignKeyName(), $relation->getQualifiedParentKeyName()]; + } + + if ($relation instanceof BelongsTo) { + return [$relation->getQualifiedForeignKey(), $relation->getQualifiedOwnerKeyName()]; + } + + if ($relation instanceof BelongsToMany) { + return [$relation->getQualifiedRelatedPivotKeyName(), $relation->getRelated()->getQualifiedKeyName()]; + } + + if ($relation instanceof HasManyThrough) { + $fk = $relation->getQualifiedFarKeyName(); + + return [$fk, $relation->getParent()->getQualifiedKeyName()]; + } + } +} diff --git a/src/Relations/JoinerFactory.php b/src/Relations/JoinerFactory.php new file mode 100644 index 0000000..019b8ee --- /dev/null +++ b/src/Relations/JoinerFactory.php @@ -0,0 +1,27 @@ +getModel(); + $query = $query->getQuery(); + } + + return new Joiner($query, $model); + } +} diff --git a/src/Searchable/Column.php b/src/Searchable/Column.php new file mode 100644 index 0000000..08b966b --- /dev/null +++ b/src/Searchable/Column.php @@ -0,0 +1,92 @@ +grammar = $grammar; + $this->table = $table; + $this->name = $name; + $this->mapping = $mapping; + $this->weight = $weight; + } + + /** + * Get qualified name wrapped by the grammar. + * + * @return string + */ + public function getWrapped() + { + return $this->grammar->wrap($this->getQualifiedName()); + } + + /** + * Get column name with table prefix. + * + * @return string + */ + public function getQualifiedName() + { + return $this->getTable().'.'.$this->getName(); + } + + /** + * @return string + */ + public function getTable() + { + return $this->table; + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @return string + */ + public function getMapping() + { + return $this->mapping; + } + + /** + * @return integer + */ + public function getWeight() + { + return $this->weight; + } +} diff --git a/src/Searchable/ColumnCollection.php b/src/Searchable/ColumnCollection.php new file mode 100644 index 0000000..e81386f --- /dev/null +++ b/src/Searchable/ColumnCollection.php @@ -0,0 +1,152 @@ +add($column); + } + } + + /** + * Get columns as plain array. + * + * @return array + */ + public function getColumns() + { + return $this->columns; + } + + /** + * Add column to the collection. + * + * @param \Sofa\Eloquence\Searchable\Column $column + */ + public function add(Column $column) + { + $this->columns[$column->getMapping()] = $column; + } + + /** + * Get array of qualified columns names. + * + * @return array + */ + public function getQualifiedNames() + { + return array_map(function ($column) { + return $column->getQualifiedName(); + }, $this->columns); + } + + /** + * Get array of tables names. + * + * @return array + */ + public function getTables() + { + return array_unique(array_map(function ($column) { + return $column->getTable(); + }, $this->columns)); + } + + /** + * Get array of columns mappings and weights. + * + * @return array + */ + public function getWeights() + { + $weights = []; + + foreach ($this->columns as $column) { + $weights[$column->getMapping()] = $column->getWeight(); + } + + return $weights; + } + + /** + * Get array of columns mappings. + * + * @return array + */ + public function getMappings() + { + return array_map(function ($column) { + return $column->getMapping(); + }, $this->columns); + } + + /** + * Check if element exists at given offset. + * + * @param string $key + * @return boolean + */ + public function offsetExists($key) + { + return array_key_exists($key, $this->columns); + } + + /** + * Get element at given offset. + * + * @param string $key + * @return \Sofa\Eloquence\Searchable\Column + */ + public function offsetGet($key) + { + return $this->columns[$key]; + } + + /** + * Set element at given offset. + * + * @param string $key [description] + * @param \Sofa\Eloquence\Searchable\Column $column + * @return void + */ + public function offsetSet($key, $column) + { + $this->add($column); + } + + /** + * Unset element at given offset. + * + * @param string $key + * @return \Sofa\Eloquence\Searchable\Column + */ + public function offsetUnset($key) + { + unset($this->columns[$key]); + } + + /** + * Get an iterator for the columns. + * + * @return \ArrayIterator + */ + public function getIterator() + { + return new ArrayIterator($this->columns); + } +} diff --git a/src/Searchable/Parser.php b/src/Searchable/Parser.php new file mode 100644 index 0000000..1d47a8e --- /dev/null +++ b/src/Searchable/Parser.php @@ -0,0 +1,124 @@ +weight = $weight; + $this->wildcard = $wildcard; + } + + /** + * Parse searchable columns. + * + * @param array|string $columns + * @return array + */ + public function parseWeights($columns) + { + if (is_string($columns)) { + $columns = func_get_args(); + } + + return $this->addMissingWeights($columns); + } + + /** + * Add search weight to the columns if missing. + * + * @param array $columns + */ + protected function addMissingWeights(array $columns) + { + $parsed = []; + + foreach ($columns as $column => $weight) { + if (is_numeric($column)) { + list($column, $weight) = [$weight, $this->weight]; + } + + $parsed[$column] = $weight; + } + + return $parsed; + } + + /** + * Strip wildcard tokens from the word. + * + * @param string $word + * @return string + */ + public function stripWildcards($word) + { + return str_replace($this->wildcard, '%', trim($word, $this->wildcard)); + } + + /** + * Parse query string into separate words with wildcards if applicable. + * + * @param string $query + * @param boolean $fulltext + * @return array + */ + public function parseQuery($query, $fulltext = true) + { + $words = $this->splitString($query); + + if ($fulltext) { + $words = $this->addWildcards($words); + } + + return $words; + } + + /** + * Split query string into words/phrases to be searched. + * + * @param string $query + * @return array + */ + protected function splitString($query) + { + preg_match_all('/(?<=")[\w ][^"]+(?=")|(?<=\s|^)[^\s"]+(?=\s|$)/u', $query, $matches); + + return reset($matches); + } + + /** + * Add wildcard tokens to the words. + * + * @param array $words + */ + protected function addWildcards(array $words) + { + $token = $this->wildcard; + + return array_map(function ($word) use ($token) { + return preg_replace('/\*+/', '*', "{$token}{$word}{$token}"); + }, $words); + } +} diff --git a/src/Searchable/ParserFactory.php b/src/Searchable/ParserFactory.php new file mode 100644 index 0000000..bdbf736 --- /dev/null +++ b/src/Searchable/ParserFactory.php @@ -0,0 +1,20 @@ +getQuery(); + } + + $this->setQuery($query); + + $this->alias = $alias; + } + + /** + * Set underlying query builder. + * + * @param \Illuminate\Database\Query\Builder $query + */ + public function setQuery(QueryBuilder $query) + { + $this->query = $query; + } + + /** + * Get underlying query builder. + * + * @return \Illuminate\Database\Query\Builder + */ + public function getQuery() + { + return $this->query; + } + + /** + * Evaluate query as string. + * + * @return string + */ + public function getValue() + { + $sql = '('.$this->query->toSql().')'; + + if ($this->alias) { + $alias = $this->query->getGrammar()->wrapTable($this->alias); + + $sql .= ' as '.$alias; + } + + return $sql; + } + + /** + * Get subquery alias. + * + * @return string + */ + public function getAlias() + { + return $this->alias; + } + + /** + * Set subquery alias. + * + * @param string $alias + * @return $this + */ + public function setAlias($alias) + { + $this->alias = $alias; + + return $this; + } + + /** + * Pass property calls to the underlying builder. + * + * @param string $property + * @param mixed $value + * @return mixed + */ + public function __set($property, $value) + { + return $this->query->{$property} = $value; + } + + /** + * Pass property calls to the underlying builder. + * + * @param string $property + * @return mixed + */ + public function __get($property) + { + return $this->query->{$property}; + } + + /** + * Pass method calls to the underlying builder. + * + * @param string $method + * @param array $params + * @return mixed + */ + public function __call($method, $params) + { + return call_user_func_array([$this->query, $method], $params); + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..0bf6caf --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,54 @@ + + */ + +use Illuminate\Database\Eloquent\Model; + +if (!function_exists('rules_for_update')) { + /** + * Adjust unique rules for update so it doesn't treat updated model's row as duplicate. + * + * @link http://laravel.com/docs/5.0/validation#rule-unique + * + * @param array $rules + * @param \Illuminate\Database\Eloquent\Model|integer|string $id + * @param string $primaryKey + * @return array + */ + function rules_for_update(array $rules, $id, $primaryKey = 'id') + { + if ($id instanceof Model) { + list($primaryKey, $id) = [$id->getKeyName(), $id->getKey()]; + } + + // We want to update each unique rule so it ignores this model's row + // during unique check in order to avoid faulty non-unique errors + // in accordance to the linked Laravel Validator documentation. + array_walk($rules, function (&$fieldRules, $field) use ($id, $primaryKey) { + if (is_string($fieldRules)) { + $fieldRules = explode('|', $fieldRules); + } + + array_walk($fieldRules, function (&$rule) use ($field, $id, $primaryKey) { + if (strpos($rule, 'unique') === false) { + return; + } + + list(,$argsString) = explode(':', $rule); + + $args = explode(',', $argsString); + + $args[1] = isset($args[1]) ? $args[1] : $field; + $args[2] = $id; + $args[3] = $primaryKey; + + $rule = 'unique:'.implode(',', $args); + }); + }); + + return $rules; + } +} diff --git a/tests/BuilderTest.php b/tests/BuilderTest.php new file mode 100644 index 0000000..ace6ec2 --- /dev/null +++ b/tests/BuilderTest.php @@ -0,0 +1,87 @@ +getBuilder(); + + $builder->leftJoinRelations('foo', 'bar'); + $builder->rightJoinRelations(['foo', 'bar']); + $builder->joinRelations('foo', 'bar'); + $builder->joinRelations(['foo', 'bar']); + } + + /** + * @test + * + * @expectedException \InvalidArgumentException + */ + public function it_takes_exactly_two_values_for_whereBetween() + { + $builder = $this->getBuilder(); + + $builder->whereBetween('size', [1,2,3]); + } + + /** + * @test + */ + public function it_calls_eloquent_method_if_called() + { + $builder = $this->getBuilder(); + + $sql = $builder->callParent('where', ['foo', 'value'])->toSql(); + + $this->assertEquals('select * from "table" where "foo" = ?', $sql); + } + + protected function getBuilder() + { + $grammar = new \Illuminate\Database\Query\Grammars\Grammar; + $connection = m::mock('\Illuminate\Database\ConnectionInterface'); + $processor = m::mock('\Illuminate\Database\Query\Processors\Processor'); + $query = new Query($connection, $grammar, $processor); + $builder = new Builder($query); + + $joiner = m::mock('stdClass'); + $joiner->shouldReceive('join')->with('foo', m::any()); + $joiner->shouldReceive('join')->with('bar', m::any()); + $factory = m::mock('\Sofa\Eloquence\Relations\JoinerFactory'); + $factory->shouldReceive('make')->andReturn($joiner); + Builder::setJoinerFactory($factory); + + Builder::setParserFactory(new \Sofa\Eloquence\Searchable\ParserFactory); + + $model = new BuilderModelStub; + $builder->setModel($model); + + return $builder; + } +} + +class BuilderModelStub extends Model { + + use Eloquence; + + protected $table = 'table'; +} diff --git a/tests/CleansAttributesObserverTest.php b/tests/CleansAttributesObserverTest.php new file mode 100644 index 0000000..82e7e59 --- /dev/null +++ b/tests/CleansAttributesObserverTest.php @@ -0,0 +1,37 @@ + 'Jarek Tkaczyk', '_method' => 'patch', 'incorrect_field' => 'value']; + + $validable = m::mock('\Sofa\Eloquence\Contracts\CleansAttributes'); + $validable->shouldReceive('getDirty')->once()->andReturn($dirty); + $validable->shouldReceive('getColumnListing')->once()->andReturn(['id', 'name']); + + foreach ($dirty as $key => $value) { + $validable->{$key} = $value; + } + + $observer = new Observer; + $observer->saving($validable); + + $this->assertFalse(isset($validable->_method)); + $this->assertFalse(isset($validable->incorrect_field)); + $this->assertEquals('Jarek Tkaczyk', $validable->name); + } +} diff --git a/tests/EloquenceTest.php b/tests/EloquenceTest.php new file mode 100644 index 0000000..c716666 --- /dev/null +++ b/tests/EloquenceTest.php @@ -0,0 +1,94 @@ +newEloquentBuilder($query); + + $this->assertInstanceOf('\Sofa\Eloquence\Builder', $builder); + } + + /** + * @test + * @covers \Sofa\Eloquence\Eloquence::hasColumn + * @covers \Sofa\Eloquence\Eloquence::getColumnListing + * @covers \Sofa\Eloquence\Eloquence::loadColumnListing + */ + public function it_loads_and_checks_the_column_listing() + { + $schema = m::mock('StdClass'); + $schema->shouldReceive('getColumnListing')->once()->andReturn(['foo', 'bar', 'baz']); + + $connection = m::mock('StdClass'); + $connection->shouldReceive('getSchemaBuilder')->once()->andReturn($schema); + + $resolver = m::mock('\Illuminate\Database\ConnectionResolverInterface'); + $resolver->shouldReceive('connection')->once()->andReturn($connection); + + EloquenceStub::setConnectionResolver($resolver); + + $model = new EloquenceStub; + + $this->assertTrue($model->hasColumn('foo')); + $this->assertFalse($model->hasColumn('wrong')); + $this->assertEquals(['foo', 'bar', 'baz'], $model->getColumnListing()); + } + + /** + * @test + * @covers \Sofa\Eloquence\Eloquence::hook + */ + public function it_registers_and_call_hooks_on_eloquent_methods() + { + $model = new EloquenceStub; + + EloquenceStub::hook('__isset', $model->__issetExtensionStub()); + + $this->assertFalse(isset($model->foo)); + + $model->foo = 1; + $this->assertFalse(isset($model->foo)); + } +} + +class EloquenceStub extends Model +{ + use Eloquence, ExtensionStub; + + public static function clearHooks() + { + static::$hooks = []; + } +} + +trait ExtensionStub +{ + public function __issetExtensionStub() + { + return function () { + return false; + }; + } +} diff --git a/tests/JoinerTest.php b/tests/JoinerTest.php new file mode 100644 index 0000000..9d50f6d --- /dev/null +++ b/tests/JoinerTest.php @@ -0,0 +1,163 @@ +factory = new JoinerFactory; + } + + public function tearDown() + { + m::close(); + } + + /** + * @test + */ + public function it_joins_dot_nested_relations() + { + $sql = 'select * from "users" '. + 'inner join "profiles" on "users"."profile_id" = "profiles"."id" '. + 'inner join "companies" on "companies"."morphable_id" = "profiles"."id" and "companies"."morphable_type" = ?'; + + $query = $this->getQuery(); + $joiner = $this->factory->make($query); + + $joiner->join('profile.company'); + + $this->assertEquals($sql, $query->toSql()); + } + + /** + * @test + * + * @expectedException \LogicException + */ + public function it_cant_join_morphTo() + { + $query = $this->getQuery(); + $joiner = $this->factory->make($query); + + $joiner->join('morphs'); + } + + /** + * @test + */ + public function it_joins_relations_on_query_builder() + { + $sql = 'select * from "users" '. + 'right join "company_user" on "company_user"."user_id" = "users"."id" '. + 'right join "companies" on "company_user"."company_id" = "companies"."id"'; + + $eloquent = $this->getQuery(); + $model = $eloquent->getModel(); + $query = $eloquent->getQuery(); + $joiner = $this->factory->make($query, $model); + + $joiner->rightJoin('companies'); + + $this->assertEquals($sql, $query->toSql()); + } + + /** + * @test + */ + public function it_joins_relations_on_eloquent_builder() + { + $sql = 'select * from "users" '. + 'left join "companies" on "companies"."user_id" = "users"."id" '. + 'left join "profiles" on "profiles"."company_id" = "companies"."id"'; + + $query = $this->getQuery(); + $joiner = $this->factory->make($query); + + $joiner->leftJoin('profiles'); + + $this->assertEquals($sql, $query->toSql()); + } + + public function getQuery() + { + $model = new JoinerUserStub; + $grammarClass = "Illuminate\Database\Query\Grammars\SQLiteGrammar"; + $processorClass = "Illuminate\Database\Query\Processors\SQLiteProcessor"; + $grammar = new $grammarClass; + $processor = new $processorClass; + $schema = m::mock('StdClass'); + $connection = m::mock('Illuminate\Database\ConnectionInterface', ['getQueryGrammar' => $grammar, 'getPostProcessor' => $processor]); + $resolver = m::mock('Illuminate\Database\ConnectionResolverInterface', ['connection' => $connection]); + $class = get_class($model); + $class::setConnectionResolver($resolver); + return $model->newQuery(); + } +} + +class JoinerUserStub extends Model { + + protected $table = 'users'; + + public function profile() + { + return $this->belongsTo('Sofa\Eloquence\Tests\JoinerProfileStub', 'profile_id'); + } + + public function companies() + { + return $this->belongsToMany('Sofa\Eloquence\Tests\JoinerCompanyStub', 'company_user', 'user_id', 'company_id'); + } + + public function profiles() + { + // due to lack of getters on HasManyThrough this relation works only with default fk! + $related = 'Sofa\Eloquence\Tests\JoinerProfileStub'; + $through = 'Sofa\Eloquence\Tests\JoinerCompanyStub'; + return $this->hasManyThrough($related, $through, 'user_id', 'company_id'); + } + + public function posts() + { + return $this->hasMany('Sofa\Eloquence\Tests\JoinerPostStub', 'user_id'); + } + + public function morphed() + { + return $this->morphOne('Sofa\Eloquence\Tests\MorphOneStub'); + } + + public function morphs() + { + return $this->morphTo(); + } +} + +class JoinerProfileStub extends Model { + protected $table = 'profiles'; + + public function company() + { + return $this->morphOne('Sofa\Eloquence\Tests\JoinerCompanyStub', 'morphable'); + } +} + +class JoinerCompanyStub extends Model { + protected $table = 'companies'; +} + +class JoinerPostStub extends Model { + protected $table = 'posts'; +} + +class MorphOneStub extends Model { + protected $table = 'morphs'; +} diff --git a/tests/SearchableBuilderTest.php b/tests/SearchableBuilderTest.php new file mode 100644 index 0000000..e55b6fe --- /dev/null +++ b/tests/SearchableBuilderTest.php @@ -0,0 +1,383 @@ += 2.5 order by `relevance` desc'; + + $bindings = ['jaros_aw', 'jaros_aw']; + + $model = $this->getModel(); + + $query = $model->search(' jaros?aw ', ['last_name' => 10], false); + + $this->assertEquals($sql, $query->toSql()); + $this->assertEquals($bindings, $query->getBindings()); + } + + /** + * @test + */ + public function it_moves_wheres_with_bindings_to_subquery_correctly() + { + $innerBindings = [ + 'jarek', 'jarek', + 'inner_1', 'inner_2', 'inner_3', 'inner_4', + 'inner_5', 'inner_6', 'inner_7', 'inner_8', + ]; + + $outerBindings = [ + 'outer_1', 'outer_2', 'outer_3', 'outer_4', + 'outer_5', 'outer_6', 'outer_7', 'outer_8', + ]; + + $model = $this->getModel(); + $model->getConnection()->shouldReceive('select')->once()->andReturn([]); + + $query = $model + ->search('jarek', 'first_name', false) + ->where('id', 'inner_1') + ->where('profiles.id', '<', 'outer_1') + ->whereBetween('id', ['inner_2','inner_3']) + ->whereRaw('users.first_name = ?', ['outer_2']) + ->whereRaw('users.first_name in (?, ?, ?)', ['outer_3', 'outer_4', 'outer_5']) + ->whereIn('id', ['inner_4', 'inner_5', 'inner_6', 'inner_7']) + ->whereNotNull('id') + ->whereExists(function ($q) {$q->whereIn('id', ['outer_6', 'outer_7']);}) + ->whereRaw('first_name = ?', ['outer_8']) + ->whereDate('id', '=', ['inner_8']) + ->where('last_name', new Expression('tkaczyk')); + + $query->get(); + + $this->assertEquals($innerBindings, $query->getQuery()->getRawBindings()['select']); + $this->assertEquals($outerBindings, $query->getQuery()->getRawBindings()['where']); + } + + /** + * @test + */ + public function it_moves_wheres_to_subquery_for_performance_if_possible() + { + $query = 'select * from (select `users`.*, '. + 'max(case when `users`.`first_name` = ? then 15 else 0 end) as relevance from `users` '. + 'where (`users`.`first_name` like ?) and `users`.`last_name` = ? and `users`.`id` > ? group by `users`.`primary_key`) '. + 'as `users` where exists (select * from `profiles` where `users`.`profile_id` = `profiles`.`id` '. + 'and `id` = ?) and `relevance` >= 0.25 order by `relevance` desc'; + + $bindings = ['jarek', 'jarek', 'tkaczyk', 10, 5]; + + $model = $this->getModel(); + $model->getConnection()->shouldReceive('select')->once() + ->with($query, $bindings, m::any()) + ->andReturn([]); + + $model->whereHas('profile', function ($q) { $q->where('id', 5); }) // where with subquery - not moved + ->where('last_name', 'tkaczyk') // where on this table's field - moved + ->search('jarek', ['first_name'], false) + ->where('id', '>', 10) // where on this table's field - moved + ->get(); + } + + /** + * @test + */ + public function table_prefixed_correctly() + { + $sql = 'select * from (select `PREFIX_users`.*, max(case when `PREFIX_users`.`first_name` = ? then 15 else 0 end) '. + 'as relevance from `PREFIX_users` where (`PREFIX_users`.`first_name` like ?) '. + 'group by `PREFIX_users`.`primary_key`) as `PREFIX_users` where `relevance` >= 0.25 order by `relevance` desc'; + + $bindings = ['jarek', 'jarek']; + + $query = $this->getModel()->newQuery(); + $query->getQuery()->getGrammar()->setTablePrefix('PREFIX_'); + $query->search('jarek', ['first_name'], false); + + $this->assertEquals($sql, $query->toSql()); + $this->assertEquals($bindings, $query->getBindings()); + } + + /** + * @test + */ + public function quoted_string_treated_as_one_word() + { + $sql = 'select * from (select `users`.*, max(case when `users`.`first_name` = ? then 15 else 0 end '. + '+ case when `users`.`first_name` like ? then 5 else 0 end) as relevance from `users` '. + 'where (`users`.`first_name` like ?) group by `users`.`primary_key`) '. + 'as `users` where `relevance` >= 0.25 order by `relevance` desc'; + + $bindings = ['jarek tkaczyk', 'jarek tkaczyk%', 'jarek tkaczyk%']; + + $query = $this->getModel()->search('"jarek tkaczyk*"', ['first_name'], false); + + $this->assertEquals($sql, $query->toSql()); + $this->assertEquals($bindings, $query->getBindings()); + } + + /** + * @test + */ + public function additional_order_clauses() + { + $sql = 'select * from (select `users`.*, max(case when `users`.`first_name` = ? then 15 else 0 end '. + '+ case when `profiles`.`name` = ? then 15 else 0 end) as relevance '. + 'from `users` left join `profiles` on `users`.`profile_id` = `profiles`.`id` '. + 'where (`users`.`first_name` like ? or `profiles`.`name` like ?) group by `users`.`primary_key`) '. + 'as `users` where `relevance` >= 0.5 order by `relevance` desc, `first_name` asc'; + + $bindings = ['jarek', 'jarek', 'jarek', 'jarek']; + + $query = $this->getModel()->orderBy('first_name')->search('jarek', ['first_name', 'profile.name'], false); + + $this->assertEquals($sql, $query->toSql()); + $this->assertEquals($bindings, $query->getBindings()); + } + + /** + * @test + */ + public function length_aware_pagination() + { + $query = 'select count(*) as aggregate from (select `users`.*, max(case when `users`.`last_name` = ? then 150 else 0 end '. + '+ case when `users`.`last_name` like ? then 50 else 0 end '. + '+ case when `users`.`last_name` like ? then 10 else 0 end) '. + 'as relevance from `users` where (`users`.`last_name` like ?) '. + 'group by `users`.`primary_key`) as `users` where `relevance` >= 2.5'; + + $bindings = ['jarek', 'jarek%', '%jarek%', '%jarek%']; + + $model = $this->getModel(); + $model->getConnection()->shouldReceive('select')->once()->with($query, $bindings, m::any())->andReturn([]); + + $model->search(' jarek ', ['last_name' => 10])->getCountForPagination(); + } + + /** + * @test + */ + public function case_insensitive_operator_in_postgres() + { + $sql = 'select * from (select "users".*, max(case when "users"."last_name" = ? then 150 else 0 end '. + '+ case when "users"."last_name" ilike ? then 50 else 0 end '. + '+ case when "users"."last_name" ilike ? then 10 else 0 end) '. + 'as relevance from "users" where ("users"."last_name" ilike ?) '. + 'group by "users"."primary_key") as "users" where "relevance" >= 2.5 order by "relevance" desc'; + + $bindings = ['jarek', 'jarek%', '%jarek%', '%jarek%']; + + $model = $this->getModel('Postgres'); + + $query = $model->search(' jarek ', ['last_name' => 10]); + + $this->assertEquals($sql, $query->toSql()); + $this->assertEquals($bindings, $query->getBindings()); + } + + /** + * @test + */ + public function it_fails_silently_if_no_words_or_columns_were_provided() + { + $sql = 'select * from `users`'; + + $query = $this->getModel()->search(' '); + + $this->assertEquals($sql, $query->toSql()); + } + + /** + * @test + */ + public function wildcard_search_by_default() + { + $sql = 'select * from (select `users`.*, max(case when `users`.`last_name` = ? then 150 else 0 end '. + '+ case when `users`.`last_name` like ? then 50 else 0 end '. + '+ case when `users`.`last_name` like ? then 10 else 0 end) '. + 'as relevance from `users` where (`users`.`last_name` like ?) '. + 'group by `users`.`primary_key`) as `users` where `relevance` >= 2.5 order by `relevance` desc'; + + $bindings = ['jarek', 'jarek%', '%jarek%', '%jarek%']; + + $model = $this->getModel(); + + $query = $model->search(' jarek ', ['last_name' => 10]); + + $this->assertEquals($sql, $query->toSql()); + $this->assertEquals($bindings, $query->getBindings()); + } + + /** + * @test + */ + public function wildcard_search() + { + $sql = 'select * from (select `users`.*, max(case when `users`.`last_name` = ? or `users`.`last_name` = ? or `users`.`last_name` = ? then 150 else 0 end '. + '+ case when `users`.`last_name` like ? or `users`.`last_name` like ? then 50 else 0 end '. + '+ case when `users`.`last_name` like ? then 10 else 0 end '. + '+ case when `companies`.`name` = ? or `companies`.`name` = ? or `companies`.`name` = ? then 75 else 0 end '. + '+ case when `companies`.`name` like ? or `companies`.`name` like ? then 25 else 0 end '. + '+ case when `companies`.`name` like ? then 5 else 0 end) '. + 'as relevance from `users` left join `company_user` on `company_user`.`user_id` = `users`.`primary_key` '. + 'left join `companies` on `company_user`.`company_id` = `companies`.`id` '. + 'where (`users`.`last_name` like ? or `users`.`last_name` like ? or `users`.`last_name` like ? '. + 'or `companies`.`name` like ? or `companies`.`name` like ? or `companies`.`name` like ?) '. + 'group by `users`.`primary_key`) as `users` where `relevance` >= 3.75 order by `relevance` desc'; + + $bindings = [ + // select + 'jarek', 'tkaczyk', 'sofa', 'jarek%', 'tkaczyk%', '%jarek%', + 'jarek', 'tkaczyk', 'sofa', 'jarek%', 'tkaczyk%', '%jarek%', + // where + '%jarek%', 'tkaczyk%', 'sofa', '%jarek%', 'tkaczyk%', 'sofa', + ]; + + $query = $this->getModel()->search('*jarek* tkaczyk* sofa', ['last_name' => 10, 'companies.name' => 5], false); + + $this->assertEquals($sql, $query->toSql()); + $this->assertEquals($bindings, $query->getBindings()); + } + + /** + * @test + */ + public function left_matching_search() + { + $sql = 'select * from (select `users`.*, max(case when `users`.`first_name` = ? then 15 else 0 end '. + '+ case when `users`.`first_name` like ? then 5 else 0 end) '. + 'as relevance from `users` where (`users`.`first_name` like ?) '. + 'group by `users`.`primary_key`) as `users` where `relevance` >= 0.25 order by `relevance` desc'; + + $bindings = ['jarek', 'jarek%', 'jarek%']; + + $query = $this->getModel()->search('jarek*', ['first_name'], false); + + $this->assertEquals($sql, $query->toSql()); + $this->assertEquals($bindings, $query->getBindings()); + } + + /** + * @test + */ + public function explicit_search_on_joined_table() + { + $sql = 'select * from (select `users`.*, max(case when `users`.`first_name` = ? or `users`.`first_name` = ? then 15 else 0 end '. + '+ case when `users`.`last_name` = ? or `users`.`last_name` = ? then 75 else 0 end '. + '+ case when `users`.`email` = ? or `users`.`email` = ? then 150 else 0 end '. + '+ case when `profiles`.`name` = ? or `profiles`.`name` = ? then 30 else 0 end) '. + 'as relevance from `users` left join `profiles` on `users`.`profile_id` = `profiles`.`id` '. + 'where (`users`.`first_name` like ? or `users`.`first_name` like ? or `users`.`last_name` like ? or `users`.`last_name` like ? '. + 'or `users`.`email` like ? or `users`.`email` like ? or `profiles`.`name` like ? or `profiles`.`name` like ?) '. + 'group by `users`.`primary_key`) as `users` where `relevance` >= 4.5 order by `relevance` desc'; + + $bindings = [ + 'jarek', 'tkaczyk', 'jarek', 'tkaczyk', 'jarek', 'tkaczyk', 'jarek', 'tkaczyk', + 'jarek', 'tkaczyk', 'jarek', 'tkaczyk', 'jarek', 'tkaczyk', 'jarek', 'tkaczyk', + ]; + + $query = $this->getModel()->search('jarek tkaczyk', false); + + $this->assertEquals($sql, $query->toSql()); + $this->assertEquals($bindings, $query->getBindings()); + } + + /** + * @test + */ + public function explicit_search_on_single_table_with_provided_columns() + { + $sql = 'select * from (select `users`.*, max(case when `users`.`first_name` = ? or `users`.`first_name` = ? then 15 else 0 end '. + '+ case when `users`.`last_name` = ? or `users`.`last_name` = ? then 30 else 0 end) as relevance from `users` '. + 'where (`users`.`first_name` like ? or `users`.`first_name` like ? or `users`.`last_name` like ? or `users`.`last_name` like ?) '. + 'group by `users`.`primary_key`) as `users` where `relevance` >= 0.75 order by `relevance` desc'; + + $bindings = ['jarek', 'tkaczyk', 'jarek', 'tkaczyk', 'jarek', 'tkaczyk', 'jarek', 'tkaczyk']; + + $query = $this->getModel()->search('jarek tkaczyk', ['first_name', 'last_name' => 2], false); + + $this->assertEquals($sql, $query->toSql()); + $this->assertEquals($bindings, $query->getBindings()); + } + + public function getModel($driver = 'MySql') + { + $model = new SearchableBuilderUserStub; + $grammarClass = "Illuminate\Database\Query\Grammars\\{$driver}Grammar"; + $processorClass = "Illuminate\Database\Query\Processors\\{$driver}Processor"; + $grammar = new $grammarClass; + $processor = new $processorClass; + $schema = m::mock('StdClass'); + $schema->shouldReceive('getColumnListing')->andReturn(['id', 'first_name', 'last_name']); + $connection = m::mock('Illuminate\Database\ConnectionInterface', ['getQueryGrammar' => $grammar, 'getPostProcessor' => $processor]); + $connection->shouldReceive('getSchemaBuilder')->andReturn($schema); + $connection->shouldReceive('getName')->andReturn($driver); + $resolver = m::mock('Illuminate\Database\ConnectionResolverInterface', ['connection' => $connection]); + $class = get_class($model); + $class::setConnectionResolver($resolver); + return $model; + } +} + +class SearchableBuilderUserStub extends Model { + use Eloquence; + + protected $table = 'users'; + protected $primaryKey = 'primary_key'; + protected $searchableColumns = [ + 'first_name', + 'last_name' => 5, + 'email' => 10, + 'profile.name' => 2, + ]; + + public function profile() + { + return $this->belongsTo('Sofa\Eloquence\Tests\SearchableProfileStub', 'profile_id'); + } + + public function companies() + { + return $this->belongsToMany('Sofa\Eloquence\Tests\SearchableCompanyStub', 'company_user', 'user_id', 'company_id'); + } +} + +class SearchableProfileStub extends Model { + protected $table = 'profiles'; +} + +class SearchableCompanyStub extends Model { + protected $table = 'companies'; +} diff --git a/tests/SubqueryTest.php b/tests/SubqueryTest.php new file mode 100644 index 0000000..6993b1a --- /dev/null +++ b/tests/SubqueryTest.php @@ -0,0 +1,65 @@ +shouldReceive('where')->once()->with('foo', 'bar')->andReturn($builder); + + $sub = new Subquery($builder); + $sub->from = 'table'; + $sub->where('foo', 'bar'); + + $this->assertFalse(property_exists($sub, 'from')); + $this->assertEquals('table', $sub->getQuery()->from); + $this->assertEquals('table', $sub->from); + } + + /** + * @test + */ + public function it_prints_as_aliased_query_in_parentheses() + { + $grammar = m::mock('StdClass'); + $grammar->shouldReceive('wrapTable')->with('table_alias')->once()->andReturn('"table_alias"'); + $builder = m::mock('\Illuminate\Database\Query\Builder'); + $builder->shouldReceive('getGrammar')->once()->andReturn($grammar); + $sub = new Subquery($builder); + $sub->getQuery()->shouldReceive('toSql')->andReturn('select * from "table" where id = ?'); + + $this->assertEquals('(select * from "table" where id = ?)', (string) $sub); + + $sub->setAlias('table_alias'); + + $this->assertEquals('(select * from "table" where id = ?) as "table_alias"', (string) $sub); + $this->assertEquals('table_alias', $sub->getAlias()); + } + + /** + * @test + */ + public function it_accepts_eloquent_and_query_builder() + { + $builder = m::mock('\Illuminate\Database\Query\Builder'); + $sub = new Subquery($builder); + + $eloquent = m::mock('\Illuminate\Database\Eloquent\Builder'); + $eloquent->shouldReceive('getQuery')->andReturn($builder); + $sub = new Subquery($eloquent); + } +}