performSearch($builder); } /** * 페이지네이션 적용 FULLTEXT 검색을 수행합니다. * * @param Builder $builder Scout 빌더 * @param int $perPage 페이지당 결과 수 * @param int $page 페이지 번호 * @return array{query: \Illuminate\Database\Eloquent\Builder, total: int} */ public function paginate(Builder $builder, $perPage, $page): array { return $this->performSearch($builder, $perPage, $page); } /** * 검색 결과에서 모델 ID를 추출합니다. * * @param array $results 검색 결과 */ public function mapIds($results): \Illuminate\Support\Collection { $query = $results['query'] ?? null; if (! $query) { return collect(); } return $query->pluck($query->getModel()->getKeyName()); } /** * 검색 결과를 모델 인스턴스로 매핑합니다. * * @param Builder $builder Scout 빌더 * @param array $results 검색 결과 * @param Model $model 기준 모델 */ public function map(Builder $builder, $results, $model): Collection { $query = $results['query'] ?? null; if (! $query) { return $model->newCollection(); } return $query->get(); } /** * 검색 결과를 LazyCollection으로 매핑합니다. * * @param Builder $builder Scout 빌더 * @param array $results 검색 결과 * @param Model $model 기준 모델 */ public function lazyMap(Builder $builder, $results, $model): LazyCollection { $query = $results['query'] ?? null; if (! $query) { return LazyCollection::empty(); } return $query->cursor(); } /** * 검색 결과의 전체 건수를 반환합니다. * * @param array $results 검색 결과 */ public function getTotalCount($results): int { return $results['total'] ?? 0; } /** * 모델의 모든 레코드를 검색 인덱스에서 제거합니다. * * MySQL FULLTEXT는 테이블 자체가 인덱스 소스이므로 no-op. * * @param Model $model 모델 */ public function flush($model): void { // MySQL FULLTEXT는 테이블 데이터가 곧 인덱스 소스 — no-op } /** * 검색 인덱스를 생성합니다. * * MySQL FULLTEXT는 마이그레이션에서 인덱스를 관리하므로 no-op. * * @param string $name 인덱스명 * @param array $options 옵션 */ public function createIndex($name, array $options = []): void { // MySQL FULLTEXT 인덱스는 마이그레이션에서 관리 — no-op } /** * 검색 인덱스를 삭제합니다. * * MySQL FULLTEXT는 마이그레이션에서 인덱스를 관리하므로 no-op. * * @param string $name 인덱스명 */ public function deleteIndex($name): void { // MySQL FULLTEXT 인덱스는 마이그레이션에서 관리 — no-op } /** * 현재 DBMS가 FULLTEXT 검색을 지원하는지 판단합니다. * * @return bool FULLTEXT 지원 여부 */ public static function supportsFulltext(): bool { $driver = DB::getDriverName(); return in_array($driver, ['mysql', 'mariadb'], true); } /** * 현재 DBMS가 ngram 파서를 지원하는지 판단합니다. * * MySQL 8.0+에서만 ngram 파서를 지원합니다. * MariaDB는 FULLTEXT를 지원하지만 ngram 파서는 지원하지 않습니다. * DB_CONNECTION=mysql로 MariaDB에 연결하는 경우도 감지합니다. * * @return bool ngram 파서 지원 여부 */ public static function supportsNgramParser(): bool { if (static::isMariaDb()) { return false; } return DB::getDriverName() === 'mysql'; } /** * 현재 연결된 DBMS가 MariaDB인지 판단합니다. * * DB_CONNECTION=mysql 드라이버로 MariaDB에 연결한 경우에도 * 서버 버전 문자열에서 'MariaDB'를 감지합니다. * * @return bool MariaDB 여부 */ public static function isMariaDb(): bool { if (static::$isMariaDbCache !== null) { return static::$isMariaDbCache; } $driver = DB::getDriverName(); if ($driver === 'mariadb') { return static::$isMariaDbCache = true; } if ($driver === 'mysql') { try { $version = DB::selectOne('SELECT VERSION() as version'); return static::$isMariaDbCache = $version && str_contains(strtolower($version->version), 'mariadb'); } catch (\Throwable) { return static::$isMariaDbCache = false; } } return static::$isMariaDbCache = false; } /** * FULLTEXT BOOLEAN MODE 검색어에서 연산자 문자를 제거하고 안전한 구문으로 변환합니다. * * BOOLEAN MODE 연산자(+, -, <, >, (, ), ~, *, ", @)는 잘못된 구문일 때 * MySQL 파싱 오류(ER_PARSE_ERROR)를 유발하므로, 사용자 입력을 일반 검색어로만 * 취급하도록 연산자를 제거하고 남은 토큰을 따옴표 구문으로 묶습니다. * * @param string $keyword 원본 검색어 * @return string 안전하게 정제된 BOOLEAN MODE 검색식 (정제 결과가 없으면 빈 문자열) */ public static function sanitizeBooleanModeKeyword(string $keyword): string { // 1. BOOLEAN MODE 연산자 + 제어성 문자를 공백으로 치환 // 연산자 집합: + - < > ( ) ~ * " @ (백슬래시 미포함 — Windows 경로 가드 규율과 무관하나 일관) $cleaned = preg_replace('/[+\-<>()~*"@]/u', ' ', $keyword); // 제어문자 제거 $cleaned = preg_replace('/[\x00-\x1F\x7F]/u', ' ', $cleaned); // 2. 토큰 분리 (연속 공백 정규화) $tokens = preg_split('/\s+/u', trim($cleaned), -1, PREG_SPLIT_NO_EMPTY); // 3. 빈 문자열 방어 if (empty($tokens)) { return ''; } // 4. 각 토큰을 따옴표 phrase로 묶어 공백 결합 (따옴표는 1단계에서 제거됨 → 균형 깨질 위험 없음) return implode(' ', array_map(fn ($t) => '"'.$t.'"', $tokens)); } /** * 관계 검색 등 Scout 직접 사용이 불가한 곳에서 FULLTEXT 검색 조건을 추가합니다. * * DBMS별로 MATCH...AGAINST 또는 LIKE fallback을 자동 적용합니다. * * @param \Illuminate\Database\Eloquent\Builder $query Eloquent 쿼리 빌더 * @param string $column 검색 대상 컬럼명 * @param string $keyword 검색어 * @param string $boolean 조건 결합 방식 ('and' 또는 'or') */ public static function whereFulltext( \Illuminate\Database\Eloquent\Builder $query, string $column, string $keyword, string $boolean = 'and' ): void { if (static::supportsFulltext()) { $ftKeyword = static::sanitizeBooleanModeKeyword($keyword); if ($ftKeyword === '') { // 정제 결과 없음 → 항상 false 조건으로 빈 결과 (void 반환 계약 유지) $falseMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw'; $query->$falseMethod('1 = 0'); return; } $method = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw'; $query->$method("MATCH(`{$column}`) AGAINST(? IN BOOLEAN MODE)", [$ftKeyword]); } else { $method = $boolean === 'or' ? 'orWhere' : 'where'; $query->$method($column, 'LIKE', "%{$keyword}%"); } } /** * 마이그레이션에서 FULLTEXT 인덱스를 DBMS별 조건부로 생성합니다. * * FULLTEXT 미지원 DBMS에서는 스킵합니다. * MySQL에서는 ngram 파서를, MariaDB에서는 기본 파서를 사용합니다. * * @param string $table 테이블명 (prefix 제외) * @param string $indexName 인덱스명 * @param string|array $columns 대상 컬럼 (단일 또는 복수) */ public static function addFulltextIndex(string $table, string $indexName, string|array $columns): void { if (! static::supportsFulltext()) { return; } $prefix = DB::getTablePrefix(); $columnList = is_array($columns) ? implode('`, `', $columns) : $columns; $parserClause = static::supportsNgramParser() ? ' WITH PARSER ngram' : ''; DB::statement( "ALTER TABLE `{$prefix}{$table}` " ."ADD FULLTEXT INDEX `{$indexName}` (`{$columnList}`){$parserClause}" ); } /** * FULLTEXT 검색 쿼리를 생성하고 실행합니다. * * DBMS별로 MATCH...AGAINST 또는 LIKE fallback을 자동 적용합니다. * * @param Builder $builder Scout 빌더 * @param int|null $perPage 페이지당 결과 수 * @param int|null $page 페이지 번호 * @return array{query: \Illuminate\Database\Eloquent\Builder, total: int} */ protected function performSearch(Builder $builder, ?int $perPage = null, ?int $page = null): array { $model = $builder->model; $keyword = $builder->query; // 빈 검색어인 경우 빈 결과 반환 if (empty(trim($keyword))) { return [ 'query' => null, 'total' => 0, ]; } $query = $model->newQuery(); // FulltextSearchable 인터페이스 구현 확인 if (! ($model instanceof FulltextSearchable)) { return [ 'query' => null, 'total' => 0, ]; } $columns = $model->searchableColumns(); $weights = $model->searchableWeights(); if (empty($columns)) { return [ 'query' => null, 'total' => 0, ]; } $useFulltext = static::supportsFulltext(); // prefix 포함 테이블명 (selectRaw에서 사용) $prefix = DB::getTablePrefix(); $qualifiedTable = $prefix.$model->getTable(); if ($useFulltext) { // BOOLEAN MODE 연산자/제어문자 정제 (특수문자 입력 시 ER_PARSE_ERROR → 500 방지) $ftKeyword = static::sanitizeBooleanModeKeyword($keyword); if ($ftKeyword === '') { // 연산자만 입력 → 500 대신 빈 결과 (빈 검색어와 동일 반환 형태) return [ 'query' => null, 'total' => 0, ]; } // MATCH...AGAINST WHERE 조건 생성 (MySQL, MariaDB) $matchClauses = []; $bindings = []; foreach ($columns as $column) { $matchClauses[] = "MATCH(`{$column}`) AGAINST(? IN BOOLEAN MODE)"; $bindings[] = $ftKeyword; } $query->whereRaw('('.implode(' OR ', $matchClauses).')', $bindings); // 가중치 기반 스코어 계산 SELECT $scoreExpressions = []; $scoreBindings = []; foreach ($columns as $column) { $weight = $weights[$column] ?? 1.0; $scoreExpressions[] = "MATCH(`{$column}`) AGAINST(? IN BOOLEAN MODE) * {$weight}"; $scoreBindings[] = $ftKeyword; } $scoreRaw = '('.implode(' + ', $scoreExpressions).') as _ft_score'; $query->selectRaw($qualifiedTable.'.*, '.$scoreRaw, $scoreBindings); } else { // LIKE fallback (PostgreSQL, SQLite 등) $query->where(function ($q) use ($columns, $keyword) { foreach ($columns as $column) { $q->orWhere($column, 'LIKE', "%{$keyword}%"); } }); // 스코어 고정 0 (관련성 순위 불가) $query->selectRaw($qualifiedTable.'.*, 0 as _ft_score'); } // Scout Builder 콜백 적용 (추가 where 조건 등) if ($builder->callback) { $query = call_user_func($builder->callback, $query, $keyword, $builder); } // Scout Builder query() 콜백 적용 (Eloquent 쿼리 수정) if ($builder->queryCallback) { call_user_func($builder->queryCallback, $query); } // 추가 where 조건 적용 (Scout v10+ 배열 구조) foreach ($builder->wheres as $where) { // __soft_deleted는 Scout 내부 필터 — Eloquent 스코프가 처리하므로 스킵 if (is_array($where)) { if (($where['field'] ?? '') === '__soft_deleted') { continue; } $query->where($where['field'], $where['operator'] ?? '=', $where['value']); } else { // 레거시 키-값 형태 fallback // $where는 이 경우 값, 키는 필드명 } } // 추가 whereIn 조건 적용 foreach ($builder->whereIns as $whereIn) { if (is_array($whereIn) && isset($whereIn['field'])) { $query->whereIn($whereIn['field'], $whereIn['values'] ?? []); } } // 추가 whereNotIn 조건 적용 foreach ($builder->whereNotIns as $whereNotIn) { if (is_array($whereNotIn) && isset($whereNotIn['field'])) { $query->whereNotIn($whereNotIn['field'], $whereNotIn['values'] ?? []); } } // 정렬: Scout Builder에 정렬이 있으면 사용, 없으면 스코어 DESC if (! empty($builder->orders)) { foreach ($builder->orders as $order) { $query->orderBy($order['column'], $order['direction']); } } else { $query->orderByDesc('_ft_score'); } // 전체 건수 계산 (페이지네이션 전) $total = $query->toBase()->getCountForPagination(); // 페이지네이션 적용 if ($perPage !== null) { $offset = ($page - 1) * $perPage; $query->limit($perPage)->offset($offset); } elseif ($builder->limit !== null) { $query->limit($builder->limit); } return [ 'query' => $query, 'total' => $total, ]; } }