$options 목록 프로파일 옵션 * @return array 선행 컬럼 순서 (등치 → deleted_at → 정렬 → 기본키) */ public function requiredColumns(array $options, string $keyName = 'id'): array { $leading = []; foreach (($options['filters'] ?? []) as $column => $declared) { if (is_array($declared) && count($declared) === 2 && is_string($declared[0])) { // [연산자, 값] 형태 — 등치만 선행 컬럼 자격이 있다 if (in_array(strtolower($declared[0]), self::EQUALITY_OPERATORS, true)) { $leading[] = (string) $column; } continue; } // 값만 준 형태는 등가 비교 $leading[] = (string) $column; } if ($options['soft_delete'] ?? false) { $leading[] = 'deleted_at'; } foreach (($options['order'] ?? []) as $spec) { $column = is_array($spec) ? ($spec[0] ?? null) : $spec; if (is_string($column) && $column !== '') { $leading[] = $column; } } // 정렬 끝에 기본키가 없으면 동률 구간에서 filesort 가 남는다 if (end($leading) !== $keyName) { $leading[] = $keyName; } return array_values(array_unique($leading)); } /** * 도출한 색인이 실제 스키마에 있는지 대조합니다. * * @param array $options 목록 프로파일 옵션 * @return array{ * table: string|null, * required: array, * satisfied_by: string|null, * partial_by: string|null, * status: string * } status: satisfied | tiebreak_missing | missing | table_absent | not_applicable */ public function inspect(array $options, string $keyName = 'id'): array { $table = $options['table'] ?? null; $required = $this->requiredColumns($options, $keyName); $result = [ 'table' => $table, 'required' => $required, 'satisfied_by' => null, 'partial_by' => null, 'status' => 'not_applicable', ]; if (! is_string($table) || $table === '') { return $result; } if (! Schema::hasTable($table)) { $result['status'] = 'table_absent'; return $result; } // 기본키를 뺀 형태 — 색인은 있으나 tie-break 만 빠진 경우를 구분한다. // 이 둘은 처방이 다르다: 전자는 색인 신설, 후자는 기존 색인에 기본키를 덧붙이는 교체. $withoutKey = $required; if (end($withoutKey) === $keyName) { array_pop($withoutKey); } foreach (Schema::getIndexes($table) as $index) { $columns = $index['columns'] ?? []; if ($result['satisfied_by'] === null && $this->hasPrefix($columns, $required)) { $result['satisfied_by'] = $index['name']; } if ($this->hasPrefix($columns, $withoutKey)) { // UNIQUE 색인은 값이 중복되지 않으므로 그 컬럼만으로 이미 전순서다. // 기본키를 덧붙일 필요가 없고, 붙여도 filesort 가 줄지 않는다. if ($result['satisfied_by'] === null && ($index['unique'] ?? false)) { $result['satisfied_by'] = $index['name']; } if ($result['partial_by'] === null) { $result['partial_by'] = $index['name']; } } } $result['status'] = match (true) { $result['satisfied_by'] !== null => 'satisfied', $result['partial_by'] !== null => 'tiebreak_missing', default => 'missing', }; return $result; } /** * 프로파일에 선언된 면제 사유를 돌려줍니다. * * 행 수가 고정되거나 상한이 작아 색인이 불필요한 목록은 프로파일에 사유를 적어 면제한다. * 사유 없는 면제를 허용하지 않는 이유는, 면제가 쌓이면 검사 자체가 무의미해지는데 * 사유가 없으면 나중에 그 판단이 여전히 옳은지 확인할 방법이 없기 때문이다. * * @param array $options 목록 프로파일 옵션 * @return string|null 면제 사유 (없으면 null) */ public function exemptionReason(array $options): ?string { $reason = $options['index_exempt'] ?? null; return is_string($reason) && trim($reason) !== '' ? trim($reason) : null; } /** * 프로파일 목록을 한 번에 점검합니다. * * @param array $profiles 목록 축 프로파일 * @return array> 프로파일별 점검 결과 */ public function inspectAll(array $profiles): array { $rows = []; foreach ($profiles as $key => $profile) { $options = $profile->options ?? []; $rows[] = array_merge( ['profile' => is_string($key) ? $key : $profile->qualifiedKey()], $this->inspect($options), ['exemption' => $this->exemptionReason($options)] ); } return $rows; } /** * 인덱스 컬럼이 요구 목록을 선행 프리픽스로 포함하는지 판정합니다. * * @param array $columns 인덱스 컬럼 순서 * @param array $required 요구 선행 컬럼 */ private function hasPrefix(array $columns, array $required): bool { if ($required === [] || count($columns) < count($required)) { return false; } return array_slice(array_values($columns), 0, count($required)) === array_values($required); } }