Files
Gnuboard7/tests/Unit/Search/DatabaseFulltextEngineTest.php
T
HeuJung 2ae1972a3b perf(core): 대용량 목록 상한 총 건수·커서 계약 신설 및 요청당 반복 실행 비용 정리
공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다.
응답·행의 무게를 다룬 · 과 겹치지 않는다.

공통 계약 — 총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이
기능이 깎인다. 총 건수만 상한을 받고(파생 테이블 COUNT), "다음" 이동은
per_page + 1 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지
번호 하나뿐이며 그 사실은 last_page: null 이 알린다. 최신순처럼 실제 컬럼으로
정렬하는 목록은 커서로 전환해 깊이와 무관하게 일정 속도로 이동한다. 관련도순은
계산값 정렬이라 커서 키로 쓸 수 없어 offset 을 유지한다.

계약의 입구는 표준 paginate 와 같은 폭이어야 한다. 관계·쿼리 빌더를 받지 못하면
그 좁은 만큼이 그대로 운영 500 이 되고, 실제로 관리자 알림 목록에서 그렇게 터졌다.
응답 조립도 컬렉션마다 손으로 하면 형태가 늘어나는 순간 없는 값을 부르거나 새
필드를 흘리므로, 형태 판정을 paginationMeta 한 곳에 모았다. 표준 paginate
응답은 필드 단위로 이전과 동일하다.

요청당 반복 비용 — 훅 구독마다 남기던 로그 400줄, 요청당 스무 번 넘던 설정 파일
재읽기, 이미 캐시된 목록의 DB 재조회를 없앴다. 권한 판정은 요청 스코프 메모를 두어
화면 요소마다 나가던 조회를 한 번으로 줄였고, 크로스 요청 캐시는 두지 않아 권한
변경이 종전처럼 다음 요청에 반영된다.

검색 질의는 활성 엔진이 만든다. 저장소가 구체 엔진을 지목하면 플러그인이 등록한
엔진은 호출될 기회 자체를 잃고 오류 없이 다른 방식으로 동작한다. 해석기를 두어
활성 엔진에 위임하고, 전문검색이 없는 DBMS 의 부분일치 폴백도 드라이버명 하드코딩
대신 선언형 config 로 옮겼다.

총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을
돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
2026-08-06 11:13:51 +09:00

489 lines
17 KiB
PHP

<?php
namespace Tests\Unit\Search;
use App\Search\Contracts\FulltextSearchable;
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\LazyCollection;
use Laravel\Scout\Builder;
use Laravel\Scout\Searchable;
use Mockery;
use Tests\TestCase;
/**
* DatabaseFulltextEngine 단위 테스트
*
* MySQL FULLTEXT + ngram 커스텀 Scout 엔진의 동작을 검증합니다.
*/
class DatabaseFulltextEngineTest extends TestCase
{
private DatabaseFulltextEngine $engine;
protected function setUp(): void
{
parent::setUp();
$this->engine = new DatabaseFulltextEngine;
}
/**
* update()가 no-op으로 동작하는지 테스트합니다.
*/
public function test_update_is_noop(): void
{
$models = new Collection;
// 예외 없이 정상 실행되어야 함
$this->engine->update($models);
$this->assertTrue(true);
}
/**
* delete()가 no-op으로 동작하는지 테스트합니다.
*/
public function test_delete_is_noop(): void
{
$models = new Collection;
$this->engine->delete($models);
$this->assertTrue(true);
}
/**
* flush()가 no-op으로 동작하는지 테스트합니다.
*/
public function test_flush_is_noop(): void
{
$model = Mockery::mock(Model::class);
$this->engine->flush($model);
$this->assertTrue(true);
}
/**
* createIndex()가 no-op으로 동작하는지 테스트합니다.
*/
public function test_create_index_is_noop(): void
{
$this->engine->createIndex('test_index');
$this->assertTrue(true);
}
/**
* deleteIndex()가 no-op으로 동작하는지 테스트합니다.
*/
public function test_delete_index_is_noop(): void
{
$this->engine->deleteIndex('test_index');
$this->assertTrue(true);
}
/**
* 빈 검색어로 검색 시 빈 결과를 반환하는지 테스트합니다.
*/
public function test_search_returns_empty_for_blank_query(): void
{
$model = $this->createSearchableModel();
$builder = new Builder($model, ' ');
$results = $this->engine->search($builder);
$this->assertNull($results['query']);
$this->assertEquals(0, $results['total']);
}
/**
* 빈 검색어로 검색 시 getTotalCount가 0을 반환하는지 테스트합니다.
*/
public function test_get_total_count_returns_zero_for_empty_results(): void
{
$results = ['total' => 0];
$this->assertEquals(0, $this->engine->getTotalCount($results));
}
/**
* getTotalCount가 올바른 값을 반환하는지 테스트합니다.
*/
public function test_get_total_count_returns_correct_value(): void
{
$results = ['total' => 42];
$this->assertEquals(42, $this->engine->getTotalCount($results));
}
/**
* mapIds가 빈 결과에 대해 빈 컬렉션을 반환하는지 테스트합니다.
*/
public function test_map_ids_returns_empty_collection_for_null_query(): void
{
$results = ['query' => null];
$ids = $this->engine->mapIds($results);
$this->assertInstanceOf(\Illuminate\Support\Collection::class, $ids);
$this->assertTrue($ids->isEmpty());
}
/**
* map()이 빈 결과에 대해 빈 컬렉션을 반환하는지 테스트합니다.
*/
public function test_map_returns_empty_collection_for_null_query(): void
{
$model = $this->createSearchableModel();
$builder = new Builder($model, 'test');
$results = ['query' => null];
$mapped = $this->engine->map($builder, $results, $model);
$this->assertInstanceOf(Collection::class, $mapped);
$this->assertTrue($mapped->isEmpty());
}
/**
* lazyMap()이 빈 결과에 대해 빈 LazyCollection을 반환하는지 테스트합니다.
*/
public function test_lazy_map_returns_empty_for_null_query(): void
{
$model = $this->createSearchableModel();
$builder = new Builder($model, 'test');
$results = ['query' => null];
$mapped = $this->engine->lazyMap($builder, $results, $model);
$this->assertInstanceOf(LazyCollection::class, $mapped);
$this->assertTrue($mapped->isEmpty());
}
/**
* FulltextSearchable 미구현 모델로 검색 시 빈 결과를 반환하는지 테스트합니다.
*/
public function test_search_returns_empty_for_non_fulltext_searchable_model(): void
{
$model = new NonFulltextModel;
$builder = new Builder($model, '검색어');
$results = $this->engine->search($builder);
$this->assertNull($results['query']);
$this->assertEquals(0, $results['total']);
}
/**
* searchableColumns()가 빈 배열인 모델로 검색 시 빈 결과를 반환하는지 테스트합니다.
*/
public function test_search_returns_empty_for_empty_searchable_columns(): void
{
$model = $this->createSearchableModelWithEmptyColumns();
$builder = new Builder($model, '검색어');
$results = $this->engine->search($builder);
$this->assertNull($results['query']);
$this->assertEquals(0, $results['total']);
}
/**
* supportsFulltext()가 MySQL/MariaDB에서 true를 반환하는지 테스트합니다.
*/
public function test_supports_fulltext_returns_true_for_mysql(): void
{
// 현재 테스트 환경의 드라이버를 확인
$driver = DB::getDriverName();
if (in_array($driver, ['mysql', 'mariadb'])) {
$this->assertTrue(DatabaseFulltextEngine::supportsFulltext());
} else {
$this->assertFalse(DatabaseFulltextEngine::supportsFulltext());
}
}
/**
* supportsNgramParser()가 MySQL에서만 true를 반환하는지 테스트합니다.
* MariaDB를 mysql 드라이버로 연결한 경우에도 false를 반환해야 합니다.
*/
public function test_supports_ngram_parser_returns_true_only_for_mysql(): void
{
$driver = DB::getDriverName();
if ($driver === 'mariadb') {
$this->assertFalse(DatabaseFulltextEngine::supportsNgramParser());
} elseif ($driver === 'mysql') {
// mysql 드라이버라도 실제 MariaDB이면 false
if (DatabaseFulltextEngine::isMariaDb()) {
$this->assertFalse(DatabaseFulltextEngine::supportsNgramParser());
} else {
$this->assertTrue(DatabaseFulltextEngine::supportsNgramParser());
}
} else {
$this->assertFalse(DatabaseFulltextEngine::supportsNgramParser());
}
}
/**
* isMariaDb()가 mariadb 드라이버에서 true를 반환하는지 테스트합니다.
*/
public function test_is_mariadb_detects_mariadb_driver(): void
{
$driver = DB::getDriverName();
if ($driver === 'mariadb') {
$this->assertTrue(DatabaseFulltextEngine::isMariaDb());
} elseif ($driver === 'mysql') {
// mysql 드라이버로 MariaDB에 연결한 경우 서버 버전으로 감지
$version = DB::selectOne('SELECT VERSION() as version');
$isActuallyMariaDb = $version && str_contains(strtolower($version->version), 'mariadb');
$this->assertEquals($isActuallyMariaDb, DatabaseFulltextEngine::isMariaDb());
} else {
$this->assertFalse(DatabaseFulltextEngine::isMariaDb());
}
}
/**
* whereFulltext()가 FULLTEXT 지원 DBMS에서 MATCH...AGAINST를 생성하는지 테스트합니다.
*/
public function test_where_fulltext_generates_match_against_on_mysql(): void
{
$driver = DB::getDriverName();
if (! in_array($driver, ['mysql', 'mariadb'])) {
$this->markTestSkipped('FULLTEXT 미지원 DBMS에서는 LIKE fallback 테스트로 대체');
}
$model = $this->createSearchableModel();
$query = $model->newQuery();
DatabaseFulltextEngine::whereFulltext($query, 'name', '검색어');
$sql = $query->toSql();
$this->assertStringContainsString('MATCH(`name`) AGAINST(? IN BOOLEAN MODE)', $sql);
}
/**
* whereFulltext()가 OR 조건을 올바르게 생성하는지 테스트합니다.
*/
public function test_where_fulltext_generates_or_condition(): void
{
$driver = DB::getDriverName();
if (! in_array($driver, ['mysql', 'mariadb'])) {
$this->markTestSkipped('FULLTEXT 미지원 DBMS');
}
$model = $this->createSearchableModel();
$query = $model->newQuery()->where('id', 1);
DatabaseFulltextEngine::whereFulltext($query, 'name', '검색어', 'or');
$sql = $query->toSql();
$this->assertStringContainsString('MATCH(`name`) AGAINST(? IN BOOLEAN MODE)', $sql);
}
/**
* whereFulltext()가 FULLTEXT 미지원 DBMS에서 LIKE fallback을 생성하는지 테스트합니다.
*/
public function test_where_fulltext_like_fallback_on_non_fulltext_dbms(): void
{
$driver = DB::getDriverName();
if (in_array($driver, ['mysql', 'mariadb'])) {
$this->markTestSkipped('FULLTEXT 지원 DBMS에서는 MATCH 테스트로 대체');
}
$model = $this->createSearchableModel();
$query = $model->newQuery();
DatabaseFulltextEngine::whereFulltext($query, 'name', '검색어');
$sql = $query->toSql();
$this->assertStringContainsString('LIKE', $sql);
}
/**
* addFulltextIndex()가 FULLTEXT 미지원 DBMS에서 예외 없이 스킵하는지 테스트합니다.
*/
public function test_add_fulltext_index_skips_on_non_fulltext_dbms(): void
{
$driver = DB::getDriverName();
if (in_array($driver, ['mysql', 'mariadb'])) {
$this->markTestSkipped('FULLTEXT 지원 DBMS에서는 스킵 테스트 불가');
}
// 예외 없이 정상 스킵되어야 함
DatabaseFulltextEngine::addFulltextIndex('non_existent_table', 'ft_test', 'name');
$this->assertTrue(true);
}
/**
* sanitizeBooleanModeKeyword()가 BOOLEAN MODE 연산자를 제거하고 phrase로 변환하는지 테스트합니다.
*/
public function test_sanitize_boolean_mode_keyword_wraps_tokens_in_phrases(): void
{
$this->assertSame('"셔츠"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('셔츠'));
$this->assertSame('"shirt"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('shirt'));
// 다중 토큰 → 각각 phrase
$this->assertSame('"셔츠" "면"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('셔츠 면'));
}
/**
* sanitizeBooleanModeKeyword()가 각 BOOLEAN MODE 연산자를 제거하는지 테스트합니다.
*/
public function test_sanitize_boolean_mode_keyword_strips_operators(): void
{
// 연산자가 제거되고 남은 토큰만 phrase 로 묶임
$this->assertSame('"상품"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('+상품'));
$this->assertSame('"바지"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('-바지'));
$this->assertSame('"상품"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('상품)'));
$this->assertSame('"셔츠"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('(셔츠'));
$this->assertSame('"키워드"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('~키워드'));
$this->assertSame('"상품"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('상품*'));
$this->assertSame('"셔츠"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('"셔츠'));
$this->assertSame('"user"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('@user'));
// <script> → 연산자/태그 제거 후 'script'
$this->assertSame('"script"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('<script>'));
}
/**
* sanitizeBooleanModeKeyword()가 연산자만 입력 시 빈 문자열을 반환하는지 테스트합니다.
*/
public function test_sanitize_boolean_mode_keyword_returns_empty_for_operators_only(): void
{
$this->assertSame('', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('< >'));
$this->assertSame('', DatabaseFulltextEngine::sanitizeBooleanModeKeyword('+-~*'));
$this->assertSame('', DatabaseFulltextEngine::sanitizeBooleanModeKeyword(' '));
$this->assertSame('', DatabaseFulltextEngine::sanitizeBooleanModeKeyword(''));
}
/**
* sanitizeBooleanModeKeyword()가 제어문자를 제거하는지 테스트합니다.
*/
public function test_sanitize_boolean_mode_keyword_removes_control_chars(): void
{
$this->assertSame('"셔츠"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword("셔츠\x00"));
$this->assertSame('"a" "b"', DatabaseFulltextEngine::sanitizeBooleanModeKeyword("a\tb"));
}
/**
* whereFulltext()가 특수문자 입력 시 정제된 안전한 phrase를 바인딩하는지 테스트합니다.
*/
public function test_where_fulltext_binds_sanitized_keyword(): void
{
$driver = DB::getDriverName();
if (! in_array($driver, ['mysql', 'mariadb'])) {
$this->markTestSkipped('FULLTEXT 미지원 DBMS');
}
$model = $this->createSearchableModel();
$query = $model->newQuery();
DatabaseFulltextEngine::whereFulltext($query, 'name', '<script>');
$sql = $query->toSql();
$this->assertStringContainsString('MATCH(`name`) AGAINST(? IN BOOLEAN MODE)', $sql);
// 원본 특수문자가 아닌 정제된 phrase 가 바인딩
$this->assertContains('"script"', $query->getBindings());
}
/**
* whereFulltext()가 연산자만 입력 시 항상 거짓인 조건으로 빈 결과를 만드는지 테스트합니다.
*
* 조건이 어떤 문자열로 렌더되는지(`1 = 0` / `0 = 1`)는 빌더 구현 사항이므로 단언하지
* 않는다. 여기서 고정할 것은 **매칭을 시도하지 않고(AGAINST 없음) 결과가 비어야 한다**
* 는 동작이다. 리터럴을 박아 두면 빈 whereIn 처럼 동등한 구현으로 바꿀 때 깨진다.
*/
public function test_where_fulltext_uses_false_condition_for_operators_only(): void
{
$driver = DB::getDriverName();
if (! in_array($driver, ['mysql', 'mariadb'])) {
$this->markTestSkipped('FULLTEXT 미지원 DBMS');
}
$model = $this->createSearchableModel();
$query = $model->newQuery();
DatabaseFulltextEngine::whereFulltext($query, 'name', '< >');
$sql = $query->toSql();
// 매칭을 시도하지 않는다
$this->assertStringNotContainsString('AGAINST', $sql);
// 조건이 아예 없으면 전체 행이 나온다 — 조건은 반드시 붙어야 한다
$this->assertStringContainsString('where', strtolower($sql));
// 그 조건은 어떤 행도 통과시키지 않는 상수 거짓이어야 한다.
// `1 = 0`(raw)과 `0 = 1`(빈 whereIn) 은 같은 뜻이므로 둘 다 허용한다.
$this->assertMatchesRegularExpression('/\b(?:1\s*=\s*0|0\s*=\s*1)\b/', $sql);
// 상수 거짓 조건에는 바인딩이 없다 (사용자 입력이 새어 들어가지 않았다는 뜻)
$this->assertSame([], $query->getBindings());
}
/**
* Searchable + FulltextSearchable 모델을 생성합니다.
*
* @return Model&FulltextSearchable
*/
private function createSearchableModel(): Model
{
return new class extends Model implements FulltextSearchable
{
use Searchable;
protected $table = 'test_table';
public function searchableColumns(): array
{
return ['name', 'description'];
}
public function searchableWeights(): array
{
return ['name' => 2.0, 'description' => 1.0];
}
};
}
/**
* 빈 컬럼을 가진 FulltextSearchable 모델을 생성합니다.
*
* @return Model&FulltextSearchable
*/
private function createSearchableModelWithEmptyColumns(): Model
{
return new class extends Model implements FulltextSearchable
{
use Searchable;
protected $table = 'test_table';
public function searchableColumns(): array
{
return [];
}
public function searchableWeights(): array
{
return [];
}
};
}
}
/**
* FulltextSearchable을 구현하지 않은 테스트용 모델
*/
class NonFulltextModel extends Model
{
use Searchable;
protected $table = 'test_non_fulltext';
public function toSearchableArray(): array
{
return ['title' => 'test'];
}
}