Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ jobs:
matrix:
php: [8.5, 8.4, 8.3, 8.2]
laravel: ["11.*", "12.*"]
db: [mysql, sqlite]
dependency-version: [prefer-lowest, prefer-stable]
include:
- laravel: 11.*
testbench: ^9.10
- laravel: 12.*
testbench: ^10.0

name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }}
name: P${{ matrix.php }} - L${{ matrix.laravel }} - DB ${{ matrix.db }} - ${{ matrix.dependency-version }}

services:
mysql:
Expand Down Expand Up @@ -57,10 +58,18 @@ jobs:
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest

- name: Execute tests
- name: Execute tests (MySQL)
run: vendor/bin/phpunit
if: ${{ matrix.db == 'mysql' }}
env:
DB_DATABASE: protone_media_db_test
DB_USERNAME: protone_media_db_test
DB_PASSWORD: secret
DB_PORT: ${{ job.services.mysql.ports[3306] }}

- name: Execute tests (SQLite)
run: vendor/bin/phpunit
if: ${{ matrix.db == 'sqlite' }}
env:
DB_CONNECTION: sqlite
DB_DATABASE: ":memory:"
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ This Laravel package allows you to search through multiple Eloquent models. It s
## Requirements

* PHP 8.2 or higher
* MySQL 8.0+
* Laravel 10.0+
* MySQL 8.0+ or SQLite
* Laravel 11.0+

## Features

Expand Down
3 changes: 2 additions & 1 deletion src/ModelToSearchThrough.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ public function getModel(): Model
*/
public function getModelKey($suffix = 'key'): string
{
return implode('_', [
// prefix with _ for SQLite support
return '_' . implode('_', [
$this->key,
Str::snake(class_basename($this->getModel())),
$suffix,
Expand Down
99 changes: 92 additions & 7 deletions src/Searcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Database\Query\Builder as BaseBuilder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\Grammars\MySqlGrammar;
use Illuminate\Database\SQLiteConnection;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
Expand Down Expand Up @@ -509,15 +510,21 @@ private function addRelevanceQueryToBuilder($builder, $modelToSearchThrough)
throw OrderByRelevanceException::new();
}

$expressionsAndBindings = $modelToSearchThrough->getQualifiedColumns()->flatMap(function ($field) use ($modelToSearchThrough) {
$lengthFunctionName = $this->usesSQLiteConnection() ? 'LENGTH' : 'CHAR_LENGTH';

$expressionsAndBindings = $modelToSearchThrough->getQualifiedColumns()->flatMap(function ($field) use ($modelToSearchThrough, $lengthFunctionName) {
$connection = $modelToSearchThrough->getModel()->getConnection();
$prefix = $connection->getTablePrefix();
$field = (new MySqlGrammar($connection))->wrap($prefix . $field);
$field = $connection->getQueryGrammar()->wrap($prefix . $field);

return $this->termsWithoutWildcards->map(function ($term) use ($field) {
return $this->termsWithoutWildcards->map(function ($term) use ($field, $lengthFunctionName) {
return [
'expression' => "COALESCE(CHAR_LENGTH(LOWER({$field})) - CHAR_LENGTH(REPLACE(LOWER({$field}), ?, ?)), 0)",
'bindings' => [Str::lower($term), Str::substr(Str::lower($term), 1)],
'expression' => sprintf(
'COALESCE(%1$s(LOWER(%2$s)) - %1$s(REPLACE(LOWER(%2$s), ?, ?)), 0)',
$lengthFunctionName,
$field
),
'bindings' => [Str::lower($term), Str::substr(Str::lower($term), 1)],
];
});
});
Expand Down Expand Up @@ -576,7 +583,35 @@ protected function makeOrderBy(): string
{
$modelOrderKeys = $this->modelsToSearchThrough->map->getModelKey('order')->implode(',');

return "COALESCE({$modelOrderKeys})";
// SQLite has stricter column resolution in UNION queries,
// so we use a different approach that's more compatible
if ($this->isSQLiteConnection()) {
return $this->makeSQLiteOrderBy($modelOrderKeys);
}

return "COALESCE({$modelOrderKeys}, NULL)";
}

/**
* Creates an SQLite-compatible ORDER BY expression using COALESCE.
* This should work in a subquery context.
*
* @param string $modelOrderKeys
* @return string
*/
protected function makeSQLiteOrderBy(string $modelOrderKeys): string
{
return "COALESCE({$modelOrderKeys}, NULL)";
}

/**
* Check if the current connection is SQLite.
*
* @return bool
*/
protected function isSQLiteConnection(): bool
{
return $this->usesSQLiteConnection();
}

/**
Expand All @@ -589,7 +624,13 @@ protected function makeOrderByModel(): string
{
$modelOrderKeys = $this->modelsToSearchThrough->map->getModelKey('model_order')->implode(',');

return "COALESCE({$modelOrderKeys})";
// SQLite has stricter column resolution in UNION queries,
// so we use a different approach that's more compatible
if ($this->isSQLiteConnection()) {
return $this->makeSQLiteOrderBy($modelOrderKeys);
}

return "COALESCE({$modelOrderKeys}, NULL)";
}

/**
Expand Down Expand Up @@ -637,6 +678,11 @@ protected function getCompiledQueryBuilder(): QueryBuilder
// union the other queries together
$queries->each(fn (Builder $query) => $firstQuery->union($query));

// For SQLite, we need to wrap the UNION query in a subquery to apply ORDER BY
if ($this->isSQLiteConnection()) {
return $this->applySQLiteOrdering($firstQuery);
}

if ($this->orderByModel) {
$firstQuery->orderBy(
DB::raw($this->makeOrderByModel()),
Expand All @@ -655,6 +701,35 @@ protected function getCompiledQueryBuilder(): QueryBuilder
);
}

/**
* Apply SQLite-specific ordering by wrapping the query in a subquery.
*
* @param QueryBuilder $unionQuery
* @return QueryBuilder
*/
protected function applySQLiteOrdering(QueryBuilder $unionQuery): QueryBuilder
{
// Create a new query that selects from the UNION as a subquery
$subQuery = DB::query()->fromSub($unionQuery, 'union_results');

if ($this->orderByModel) {
$subQuery->orderBy(
DB::raw($this->makeOrderByModel()),
$this->isOrderingByRelevance() ? 'asc' : $this->orderByDirection
);
}

if ($this->isOrderingByRelevance() && $this->termsWithoutWildcards->isNotEmpty()) {
return $subQuery->orderBy('terms_count', 'desc');
}

// sort by the given columns and direction
return $subQuery->orderBy(
DB::raw($this->makeOrderBy()),
$this->isOrderingByRelevance() ? 'asc' : $this->orderByDirection
);
}

/**
* Paginates the compiled query or fetches all results.
*
Expand Down Expand Up @@ -778,4 +853,14 @@ public function search(string $terms = null)
->pipe(fn (Collection $models) => new EloquentCollection($models))
->when($this->pageName, fn (EloquentCollection $models) => $results->setCollection($models));
}

/**
* Returns true if the current connection driver is SQLite, otherwise false.
*
* @return bool
*/
private function usesSQLiteConnection(): bool
{
return $this->modelsToSearchThrough->first()->getModel()->getConnection() instanceof SQLiteConnection;
}
}
20 changes: 20 additions & 0 deletions tests/SearchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ public function it_has_an_option_to_dont_split_the_search_term()
/** @test */
public function it_has_an_option_to_ignore_the_case()
{
// Skip JSON column tests on SQLite due to different JSON function support
if (config('database.default') === 'sqlite') {
$this->markTestSkipped('JSON column operations not supported on SQLite.');
}

Post::create(['title' => 'foo']);
Post::create(['title' => 'bar bar']);

Expand Down Expand Up @@ -223,6 +228,11 @@ public function it_can_search_on_the_left_side_of_the_term()
/** @test */
public function it_can_use_the_sounds_like_operator()
{
// Skip SOUNDS LIKE test on SQLite - operator not supported
if (config('database.default') === 'sqlite') {
$this->markTestSkipped('SOUNDS LIKE operator not supported on SQLite.');
}

Video::create(['title' => 'laravel']);

$this->assertCount(0, Search::add(Video::class, 'title')->search('larafel'));
Expand Down Expand Up @@ -620,6 +630,11 @@ public function it_includes_a_custom_model_identifier_to_search_results()
/** @test */
public function it_supports_full_text_search()
{
// Skip fulltext search tests on SQLite - feature not supported
if (config('database.default') === 'sqlite') {
$this->markTestSkipped('Fulltext search not supported on SQLite.');
}

$postA = Post::create(['title' => 'Laravel Framework']);
$postB = Post::create(['title' => 'Tailwind Framework']);

Expand Down Expand Up @@ -647,6 +662,11 @@ public function it_supports_full_text_search()
/** @test */
public function it_supports_full_text_search_on_relations()
{
// Skip fulltext search tests on SQLite - feature not supported
if (config('database.default') === 'sqlite') {
$this->markTestSkipped('Fulltext search not supported on SQLite.');
}

$videoA = Video::create(['title' => 'Page A']);
$videoB = Video::create(['title' => 'Page B']);
$videoC = Video::create(['title' => 'Page C']);
Expand Down
78 changes: 55 additions & 23 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,61 @@ public function setUp(): void

protected function initDatabase($prefix = '')
{
DB::purge('mysql');

$this->app['config']->set('database.connections.mysql', [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'search_test'),
'username' => env('DB_USERNAME', 'homestead'),
'password' => env('DB_PASSWORD', 'secret'),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => $prefix,
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
]);

DB::setDefaultConnection('mysql');
$connection = env('DB_CONNECTION', 'mysql');

DB::purge($connection);

// Configure the appropriate database connection
switch ($connection) {
case 'sqlite':
$this->app['config']->set('database.connections.sqlite', [
'driver' => 'sqlite',
'database' => env('DB_DATABASE', ':memory:'),
'prefix' => $prefix,
]);
break;

case 'pgsql':
$this->app['config']->set('database.connections.pgsql', [
'driver' => 'pgsql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'search_test'),
'username' => env('DB_USERNAME', 'homestead'),
'password' => env('DB_PASSWORD', 'secret'),
'charset' => 'utf8',
'prefix' => $prefix,
'prefix_indexes' => true,
'schema' => 'public',
'sslmode' => 'prefer',
]);
break;

case 'mysql':
default:
$this->app['config']->set('database.connections.mysql', [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'search_test'),
'username' => env('DB_USERNAME', 'homestead'),
'password' => env('DB_PASSWORD', 'secret'),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => $prefix,
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
]);
break;
}

DB::setDefaultConnection($connection);

$this->artisan('migrate:fresh');

Expand Down
16 changes: 10 additions & 6 deletions tests/create_tables.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ public function up()
$table->string('subtitle');
$table->string('body');

$table->fullText('title');
$table->fullText(['title', 'subtitle']);
$table->fullText(['title', 'subtitle', 'body']);
if (config('database.default') === 'mysql') {
$table->fullText('title');
$table->fullText(['title', 'subtitle']);
$table->fullText(['title', 'subtitle', 'body']);
}

$table->unsignedInteger('video_id')->nullable();

Expand All @@ -58,9 +60,11 @@ public function up()
$table->string('subtitle')->nullable();
$table->string('body')->nullable();

$table->fullText('title');
$table->fullText(['title', 'subtitle']);
$table->fullText(['title', 'subtitle', 'body']);
if (config('database.default') === 'mysql') {
$table->fullText('title');
$table->fullText(['title', 'subtitle']);
$table->fullText(['title', 'subtitle', 'body']);
}

$table->unsignedInteger('video_id')->nullable();

Expand Down