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); + } +}