diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..97fbc59 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true + +[*.yml] +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bc1d796 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: [push] + +jobs: + build-test: + runs-on: ubuntu-latest + + strategy: + matrix: + php-versions: + - 7.2 + - 7.3 + - 7.4 + + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + - uses: php-actions/composer@v1 + - name: PHPUnit Tests + uses: php-actions/phpunit@v8 diff --git a/.gitignore b/.gitignore index 9f11b75..a1e77f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ .idea/ +composer.lock +/vendor +/.phpunit.result.cache diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..76fa960 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Contribution Guide + +1. Make sure to include unit tests + +## Unit tests + +You can run unit tests locally by executing `make` once +and then `make test` for consecutive runs (you will need to install docker before). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..23aa7b3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM composer AS composer +FROM php:7.2-cli +COPY --from=composer /usr/bin/composer /usr/bin/composer +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y zip unzip git \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c3615a8 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +all: build install test + +test: + docker run -it -v $(shell pwd):/app laravel-scout-mysql-driver vendor/bin/phpunit + +install: + docker run -it -v $(shell pwd):/app laravel-scout-mysql-driver composer install + +bash: + docker run -it -v $(shell pwd):/app -it laravel-scout-mysql-driver bash + +build: + docker build -t laravel-scout-mysql-driver:latest . diff --git a/README.md b/README.md index 2806936..72e4d8c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Install this package via **Composer** `composer require yab/laravel-scout-mysql-driver` -Next add the ServiceProvider to the Package Service Providers in `config/app.php` +Next if you are using laravel version 5.4, include the following ServiceProvider to the Providers array in `config/app.php` ```php /* diff --git a/composer.json b/composer.json index 9635a1b..8b53ecd 100644 --- a/composer.json +++ b/composer.json @@ -12,11 +12,14 @@ "email": "matt@yabhq.com" } ], - "minimum-stability": "dev", "require": { - "php": ">=5.6.4", - "laravel/framework": "5.3.*|5.4.*|5.5.*|5.6.*", - "laravel/scout": "^2.0|^3.0|^4.0" + "php": "^7.2|^8.0", + "laravel/scout": "~6.0|~7.0|~8.0|~9.0" + }, + "require-dev": { + "phpunit/phpunit": "8.5.x-dev", + "laravel/framework": "5.6.* || 5.7.* || 5.8.* || ^6.0 || ^7.0 || ^8.0", + "mockery/mockery": "^1.3" }, "autoload": { "psr-4": { @@ -26,6 +29,11 @@ "src/helpers.php" ] }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, "extra": { "laravel": { "providers": [ diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e8586c3 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + ./tests + + + + + ./src + + + diff --git a/src/Contracts/AlwaysUseFallbackSearch.php b/src/Contracts/AlwaysUseFallbackSearch.php new file mode 100644 index 0000000..f883027 --- /dev/null +++ b/src/Contracts/AlwaysUseFallbackSearch.php @@ -0,0 +1,11 @@ +modelService->setModel($builder->model)->getFullTextIndexFields()); + + return "*, MATCH($indexFields) AGAINST(? IN NATURAL LANGUAGE MODE) AS relevance"; + } + public function buildParams(Builder $builder) { $this->whereParams[] = $builder->query; diff --git a/src/Engines/Modes/Like.php b/src/Engines/Modes/Like.php index 6ee7181..4999882 100644 --- a/src/Engines/Modes/Like.php +++ b/src/Engines/Modes/Like.php @@ -11,6 +11,8 @@ class Like extends Mode public function buildWhereRawString(Builder $builder) { + $table = $builder->model->getTable(); + $queryString = ''; $this->fields = $this->modelService->setModel($builder->model)->getSearchableFields(); @@ -20,7 +22,7 @@ public function buildWhereRawString(Builder $builder) $queryString .= '('; foreach ($this->fields as $field) { - $queryString .= "`$field` LIKE ? OR "; + $queryString .= "`$table`.`$field` LIKE ? OR "; } $queryString = trim($queryString, 'OR '); diff --git a/src/Engines/Modes/LikeExpanded.php b/src/Engines/Modes/LikeExpanded.php index e4d711a..bfcd388 100644 --- a/src/Engines/Modes/LikeExpanded.php +++ b/src/Engines/Modes/LikeExpanded.php @@ -11,6 +11,8 @@ class LikeExpanded extends Mode public function buildWhereRawString(Builder $builder) { + $table = $builder->model->getTable(); + $queryString = ''; $this->fields = $this->modelService->setModel($builder->model)->getSearchableFields(); @@ -23,14 +25,14 @@ public function buildWhereRawString(Builder $builder) foreach ($this->fields as $field) { foreach ($words as $word) { - $queryString .= "`$field` LIKE ? OR "; + $queryString .= "`$table`.`$field` LIKE ? OR "; } } $queryString = trim($queryString, 'OR '); $queryString .= ')'; - return$queryString; + return $queryString; } public function buildParams(Builder $builder) diff --git a/src/Engines/Modes/Mode.php b/src/Engines/Modes/Mode.php index 6b04581..fb83262 100644 --- a/src/Engines/Modes/Mode.php +++ b/src/Engines/Modes/Mode.php @@ -9,11 +9,14 @@ abstract class Mode { protected $whereParams = []; + /** + * @var ModelService + */ protected $modelService; public function __construct() { - $this->modelService = resolve(ModelService::class); + $this->modelService = app(ModelService::class); } abstract public function buildWhereRawString(Builder $builder); @@ -35,9 +38,17 @@ protected function buildWheres(Builder $builder) $operator = $parsedWhere[1]; $value = $parsedWhere[2]; - $this->whereParams[$field] = $value; - - $queryString .= "$field $operator ? AND "; + if ($value !== null) { + $this->whereParams[$field] = $value; + $queryString .= "$field $operator ? AND "; + } else { + + if($operator === '!='){ + $queryString .= "$field IS NOT NULL AND "; + }else{ + $queryString .= "$field IS NULL AND "; + } + } } return $queryString; @@ -53,6 +64,17 @@ private function parseWheres($wheres) $result [] = !empty($matches) ? array($matches[1], $matches[2], $value) : array($field, '=', $value); } + /** + * Add support for where() on json columns using '->' syntax + * data->a->b->c translates to json_unquote(json_extract(`data`, '$."a"."b"."c"')) + */ + foreach ($result as $_k => $_v) { + if (($_v[0] ?? false) && stripos($_v[0],'->')!==false) { + list($root,$path) = explode('->',$_v[0],2); + $result[$_k][0] = "json_unquote(json_extract(`$root`, '$.\"".implode('"."',explode('->',$path))."\"'))"; + } + } + return $result; } } diff --git a/src/Engines/Modes/NaturalLanguage.php b/src/Engines/Modes/NaturalLanguage.php index 63a1ab8..21ed6b5 100644 --- a/src/Engines/Modes/NaturalLanguage.php +++ b/src/Engines/Modes/NaturalLanguage.php @@ -8,13 +8,21 @@ class NaturalLanguage extends Mode { public function buildWhereRawString(Builder $builder) { - $queryString = ''; + return $this->buildWheres($builder) . $this->buildMatchQuery($builder); + } - $queryString .= $this->buildWheres($builder); + public function buildSelectColumns(Builder $builder) + { + $matchQuery = $this->buildMatchQuery($builder); + return "*, $matchQuery as relevance"; + } + + private function buildMatchQuery(Builder $builder) + { $indexFields = implode(',', $this->modelService->setModel($builder->model)->getFullTextIndexFields()); - $queryString .= "MATCH($indexFields) AGAINST(? IN NATURAL LANGUAGE MODE"; + $queryString = "MATCH($indexFields) AGAINST(? IN NATURAL LANGUAGE MODE"; if (config('scout.mysql.query_expansion')) { $queryString .= ' WITH QUERY EXPANSION'; diff --git a/src/Engines/MySQLEngine.php b/src/Engines/MySQLEngine.php index f3a9e39..1f6d626 100644 --- a/src/Engines/MySQLEngine.php +++ b/src/Engines/MySQLEngine.php @@ -2,10 +2,13 @@ namespace Yab\MySQLScout\Engines; +use Illuminate\Support\LazyCollection; use Yab\MySQLScout\Engines\Modes\ModeContainer; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\DB; use Laravel\Scout\Builder; use Laravel\Scout\Engines\Engine; +use Yab\MySQLScout\Contracts\AlwaysUseFallbackSearch; class MySQLEngine extends Engine { @@ -27,8 +30,17 @@ public function delete($models) { } + /** + * Pluck and return the primary keys of the given results. + * + * @param mixed $results + * @return \Illuminate\Support\Collection + */ public function mapIds($results) { + return collect($results['results'])->map(function ($result) { + return $result->getKey(); + }); } /** @@ -53,9 +65,17 @@ public function search(Builder $builder) $whereRawString = $mode->buildWhereRawString($builder); $params = $mode->buildParams($builder); + ksort($params,SORT_NATURAL); $model = $builder->model; $query = $model::whereRaw($whereRawString, $params); + if ($mode->isFullText()) { + $query = $query->selectRaw(DB::raw($mode->buildSelectColumns($builder)), [$params[0]]); + } + + if ($builder->callback) { + $query = call_user_func($builder->callback, $query, $this); + } $result['count'] = $query->count(); @@ -98,12 +118,12 @@ public function paginate(Builder $builder, $perPage, $page) /** * Map the given results to instances of the given model. * - * @param mixed $results + * @param Laravel\Scout\Builder $builder * @param \Illuminate\Database\Eloquent\Model $model * * @return Collection */ - public function map($results, $model) + public function map(Builder $builder, $results, $model) { return $results['results']; } @@ -120,14 +140,71 @@ public function getTotalCount($results) return $results['count']; } + /** + * Flush all of the model's records from the engine. + * + * @param \Illuminate\Database\Eloquent\Model $model + * + * @return void + */ + public function flush($model) + { + } + protected function shouldNotRun($builder) { return strlen($builder->query) < config('scout.mysql.min_search_length'); } + protected function fallbackSearchShouldBeUsedForModel($builder) + { + return $builder->model instanceof AlwaysUseFallbackSearch; + } + protected function shouldUseFallback($builder) { - return $this->mode->isFullText() && - strlen($builder->query) < config('scout.mysql.min_fulltext_search_length'); + return ($this->mode->isFullText() && + strlen($builder->query) < config('scout.mysql.min_fulltext_search_length')) || + $this->fallbackSearchShouldBeUsedForModel($builder); + } + + /** + * Map the given results to instances of the given model via a lazy collection. + * + * @param \Laravel\Scout\Builder $builder + * @param mixed $results + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Support\LazyCollection + */ + public function lazyMap(Builder $builder, $results, $model) + { + if ($this->getTotalCount($results) === 0) { + return LazyCollection::empty(); + } + + return LazyCollection::make($results['results']->all()); + } + + /** + * Create a search index. + * + * @param string $name + * @param array $options + * @return mixed + */ + public function createIndex($name, array $options = []) + { + + } + + /** + * Delete a search index. + * + * @param string $name + * @return mixed + */ + public function deleteIndex($name) + { + } } diff --git a/src/Providers/MySQLScoutServiceProvider.php b/src/Providers/MySQLScoutServiceProvider.php index 6f85352..42e7343 100644 --- a/src/Providers/MySQLScoutServiceProvider.php +++ b/src/Providers/MySQLScoutServiceProvider.php @@ -2,6 +2,7 @@ namespace Yab\MySQLScout\Providers; +use Illuminate\Support\Str; use Yab\MySQLScout\Engines\Modes\ModeContainer; use Illuminate\Support\ServiceProvider; use Laravel\Scout\EngineManager; @@ -24,7 +25,7 @@ public function boot() } $this->app->make(EngineManager::class)->extend('mysql', function () { - return new MySQLEngine(resolve(ModeContainer::class)); + return new MySQLEngine(app(ModeContainer::class)); }); } @@ -43,8 +44,8 @@ public function register() $this->app->singleton(ModeContainer::class, function ($app) { $engineNamespace = 'Yab\\MySQLScout\\Engines\\Modes\\'; - $mode = $engineNamespace.studly_case(strtolower(config('scout.mysql.mode'))); - $fallbackMode = $engineNamespace.studly_case(strtolower(config('scout.mysql.min_fulltext_search_fallback'))); + $mode = $engineNamespace.Str::studly(strtolower(config('scout.mysql.mode'))); + $fallbackMode = $engineNamespace.Str::studly(strtolower(config('scout.mysql.min_fulltext_search_fallback'))); return new ModeContainer(new $mode(), new $fallbackMode()); }); diff --git a/src/Services/IndexService.php b/src/Services/IndexService.php index 0775c38..533d44a 100644 --- a/src/Services/IndexService.php +++ b/src/Services/IndexService.php @@ -40,7 +40,8 @@ public function getAllSearchableModels($directories) $connectionName = $modelInstance->getConnectionName() !== null ? $modelInstance->getConnectionName() : config('database.default'); - $isMySQL = config("database.connections.$connectionName.driver") === 'mysql'; + $isMySQL = config("database.connections.$connectionName.driver") === 'mysql' || + config("database.connections.$connectionName.driver") === 'mariadb'; if ($isMySQL) { $searchableModels[] = $class; @@ -77,7 +78,7 @@ protected function createIndex() } DB::connection($this->modelService->connectionName) - ->statement("CREATE FULLTEXT INDEX $indexName ON $tableName ($indexFields)"); + ->statement("CREATE FULLTEXT INDEX `$indexName` ON `$tableName` ($indexFields)"); event(new Events\ModelIndexCreated($indexName, $indexFields)); } @@ -88,7 +89,7 @@ protected function indexAlreadyExists() $indexName = $this->modelService->indexName; return !empty(DB::connection($this->modelService->connectionName)-> - select("SHOW INDEX FROM $tableName WHERE Key_name = ?", [$indexName])); + select("SHOW INDEX FROM `$tableName` WHERE `Key_name` = ?", [$indexName])); } protected function indexNeedsUpdate() @@ -105,7 +106,7 @@ protected function getIndexFields() $tableName = $this->modelService->tablePrefixedName; $index = DB::connection($this->modelService->connectionName)-> - select("SHOW INDEX FROM $tableName WHERE Key_name = ?", [$indexName]); + select("SHOW INDEX FROM `$tableName` WHERE `Key_name` = ?", [$indexName]); $indexFields = []; @@ -135,7 +136,7 @@ public function dropIndex() if ($this->indexAlreadyExists()) { DB::connection($this->modelService->connectionName) - ->statement("ALTER TABLE $tableName DROP INDEX $indexName"); + ->statement("ALTER TABLE `$tableName` DROP INDEX `$indexName`"); event(new Events\ModelIndexDropped($this->modelService->indexName)); } } diff --git a/src/Services/ModelService.php b/src/Services/ModelService.php index 3b0e0b5..7ac9877 100644 --- a/src/Services/ModelService.php +++ b/src/Services/ModelService.php @@ -48,7 +48,7 @@ public function getFullTextIndexFields() foreach ($searchableFields as $searchableField) { //@TODO cache this. - $sql = "SHOW FIELDS FROM $this->tablePrefixedName where Field = ?"; + $sql = "SHOW FIELDS FROM `$this->tablePrefixedName` where `Field` = ?"; $column = DB::connection($this->connectionName)->select($sql, [$searchableField]); if (!isset($column[0])) { diff --git a/src/helpers.php b/src/helpers.php index 25b1f09..94104de 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -94,6 +94,8 @@ function getClassNameFromFile($filePathName) $classes[] = $class_name; } } - + if (!isset($classes[0])) { + return 'no_class_found_in_file'; + } return $classes[0]; } diff --git a/tests/Engines/Modes/NaturalLanguageTest.php b/tests/Engines/Modes/NaturalLanguageTest.php new file mode 100644 index 0000000..02b3675 --- /dev/null +++ b/tests/Engines/Modes/NaturalLanguageTest.php @@ -0,0 +1,64 @@ +mockDb(); + + $mode = new NaturalLanguage(); + $builder = new Builder(new TestModel(), __METHOD__); + + $this->assertEquals( + '*, MATCH(first_name,last_name) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance', + $mode->buildSelectColumns($builder) + ); + $this->assertEquals( + 'MATCH(first_name,last_name) AGAINST(? IN NATURAL LANGUAGE MODE)', + $mode->buildWhereRawString($builder) + ); + $this->assertEquals([__METHOD__], $mode->buildParams($builder)); + } + + public function testWithQueryExpansion() + { + $this->mockDb(); + config()->set('scout.mysql.query_expansion', true); + + $mode = new NaturalLanguage(); + $builder = new Builder(new TestModel(), __METHOD__); + + $this->assertEquals( + '*, MATCH(first_name,last_name) AGAINST(? IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION) as relevance', + $mode->buildSelectColumns($builder) + ); + $this->assertEquals( + 'MATCH(first_name,last_name) AGAINST(? IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION)', + $mode->buildWhereRawString($builder) + ); + $this->assertEquals([__METHOD__], $mode->buildParams($builder)); + } + + private function mockDb(): void + { + DB::shouldReceive('connection')->andReturnSelf(); + DB::shouldReceive('getSchemaBuilder')->andReturnSelf(); + DB::shouldReceive('getColumnListing')->andReturn([ + 'first_name', + 'last_name', + 'age', + ]); + DB::shouldReceive('select')->andReturn([ + (object)['Type' => 'VARCHAR'], + ]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..2c991d1 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,31 @@ + [ + 'mysql' => [ + 'mode' => 'NATURAL_LANGUAGE', + 'model_directories' => [__DIR__], + 'min_search_length' => 0, + 'min_fulltext_search_length' => 4, + 'min_fulltext_search_fallback' => 'LIKE', + 'query_expansion' => false + ] + ] + ]); + + app()->instance('config', $config); + } +} diff --git a/tests/TestModel.php b/tests/TestModel.php new file mode 100644 index 0000000..d9ca4ca --- /dev/null +++ b/tests/TestModel.php @@ -0,0 +1,21 @@ + 'Steve', + 'last_name' => 'Broski', + ]; + } +}