목록 조회 성능 축(깊은 OFFSET·정렬·색인)과 그 검증 계층에서 계획서 전수검수와 3회에 걸친 감사가 지적한 항목을 처리했다. 세부 경위는 의 각 회차 문서에 있다. 검증 계층 — 컨트롤러가 base Request 를 직접 주입받던 확장 13곳을 전용 FormRequest 로 옮겼다. 옮기면서 기존 동작 계약은 그대로 두었다: 상한 초과 limit 을 거부하지 않고 상한까지 반환하던 공개 API, per_page 를 범위로 조정하던 관리자 목록, 미지원 period 를 year 로 해석하던 인기글 목록 모두 종전과 같은 응답을 낸다. 상한/폴백을 rules 로 승격시키면 200 이던 응답이 422 가 되어 기존 링크가 깨지므로, 규칙은 타입만 닫고 클램프·폴백은 접근자가 맡는다. period 는 접근자가 닫힌 집합만 반환해 캐시 키 공간도 함께 닫힌다. ckeditor5 이미지 업로드만 ResponseHelper 봉투를 쓰지 않는다. 응답을 파싱하는 주체가 CDN 으로 로드되는 상위 CKEditor5 43.3.1 의 SimpleUploadAdapter 라 규약을 바꿀 수 없어, 각 응답 지점에 사유를 명시한 면제를 부착하고 근거를 API 문서에 남겼다. 하네스 — 룰 5개의 대상 경로 패턴이 매처와 맞지 않아 번들 확장 컨트롤러가 검사 대상에서 통째로 빠져 있었다. 패턴을 고치자 확장 위반 17건이 드러나 전건 처리했다. severity 오타가 요약 집계 양쪽에 안 잡혀 "0 error" 로 보고되던 문제도 런너 사전 검증으로 막았다. 정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다. 탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
176 lines
6.1 KiB
PHP
176 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Repositories;
|
|
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 페이지네이션 정렬의 **전순서(total order) 보장** 회귀 가드.
|
|
*
|
|
* 목록을 `ORDER BY created_at DESC` 처럼 **비고유 컬럼만으로** 정렬하고 페이지를 나누면,
|
|
* 동률 구간의 행 순서가 페이지마다 달라질 수 있다(MySQL 은 동률의 순서를 보장하지 않는다).
|
|
* 그 결과 인접 페이지에 **같은 행이 두 번 나오고 다른 행은 사라진다** — 사용자에게는
|
|
* "목록에 있는데 넘기면 없다" 로 보이고, 전 페이지를 모아도 총건수와 개수가 맞지 않는다.
|
|
*
|
|
* 실측(#492 D-24): 관리자 역할 목록 110건 / per_page 20 / 6페이지를 전수 순회했더니
|
|
* 수집 키 110개, 고유 키 **109개**, 인접 페이지 중복 1건이었다. `roles.created_at` 은
|
|
* 한 타임스탬프에 최대 10행이 몰려 있다(시더 일괄 생성).
|
|
*
|
|
* 불변식: **페이지네이션 쿼리의 정렬은 마지막에 고유 키(기본키)를 덧붙여 전순서를 만든다.**
|
|
*
|
|
* `ResolvesSortSpec` / `PaginatesWithDeferredJoin` 경유 목록은 트레이트가 키 컬럼을 자동으로
|
|
* 덧붙이므로 이 규율을 이미 만족한다. 손으로 `orderBy` 를 조립한 뒤 `paginate()` 하는 지점만
|
|
* 위험하다 — 그 지점을 소스에서 도출해 전수 검사한다(손으로 나열하지 않는다).
|
|
*/
|
|
class PaginationKeyTiebreakTest extends TestCase
|
|
{
|
|
/**
|
|
* 검사 대상 Repository 파일 목록.
|
|
*
|
|
* @return array<string, array{0: string}>
|
|
*/
|
|
public static function repositoryFileProvider(): array
|
|
{
|
|
// 데이터 프로바이더는 앱 부팅 전에 평가되므로 base_path() 를 쓸 수 없다.
|
|
$root = dirname(__DIR__, 3);
|
|
|
|
$roots = [
|
|
$root.'/app/Repositories',
|
|
$root.'/modules/_bundled',
|
|
$root.'/plugins/_bundled',
|
|
];
|
|
|
|
$cases = [];
|
|
|
|
foreach ($roots as $root) {
|
|
if (! is_dir($root)) {
|
|
continue;
|
|
}
|
|
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
$path = str_replace('\\', '/', $file->getPathname());
|
|
|
|
if (! str_ends_with($path, 'Repository.php')) {
|
|
continue;
|
|
}
|
|
|
|
if (preg_match('#/(vendor|node_modules|tests|docs)/#', $path)) {
|
|
continue;
|
|
}
|
|
|
|
$cases[str_replace(str_replace('\\', '/', $root).'/', '', $path)] = [$path];
|
|
}
|
|
}
|
|
|
|
ksort($cases);
|
|
|
|
return $cases;
|
|
}
|
|
|
|
#[DataProvider('repositoryFileProvider')]
|
|
public function test_페이지네이션_메서드는_고유키_타이브레이크를_갖는다(string $path): void
|
|
{
|
|
$source = file_get_contents($path);
|
|
$violations = [];
|
|
$methods = self::splitMethods($source);
|
|
|
|
foreach ($methods as $name => $rawBody) {
|
|
if (! preg_match('/->paginate\(|->simplePaginate\(/', $rawBody)) {
|
|
continue;
|
|
}
|
|
|
|
// 같은 클래스의 헬퍼(`$this->applySort(...)` 등)에 정렬을 위임한 경우 그 본문까지 함께 본다.
|
|
$body = $rawBody;
|
|
foreach (self::calledHelpers($rawBody) as $helper) {
|
|
if (isset($methods[$helper])) {
|
|
$body .= "\n".$methods[$helper];
|
|
}
|
|
}
|
|
|
|
// 트레이트 경유 목록은 키 컬럼이 자동으로 덧붙는다.
|
|
if (preg_match('/paginateWithDeferredJoin\(|resolveSortSpec|applySortSpec/', $body)) {
|
|
continue;
|
|
}
|
|
|
|
// 면제 선언 (사유 필수)
|
|
if (preg_match('/pagination-key-tiebreak\s+reason:/', $body)) {
|
|
continue;
|
|
}
|
|
|
|
if (! self::hasKeyTiebreak($body)) {
|
|
$violations[] = $name;
|
|
}
|
|
}
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$violations,
|
|
sprintf(
|
|
"%s 의 다음 메서드가 페이지네이션 정렬에 고유 키 타이브레이크를 갖지 않는다:\n - %s\n".
|
|
'동률 구간에서 인접 페이지가 같은 행을 중복 노출하고 다른 행을 누락한다. '.
|
|
"정렬 마지막에 기본키(`->orderBy('id', ...)`)를 덧붙이거나 ResolvesSortSpec 을 경유할 것.",
|
|
basename($path),
|
|
implode("\n - ", $violations)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 클래스 소스를 메서드 단위로 분할합니다.
|
|
*
|
|
* @param string $source PHP 소스
|
|
* @return array<string, string> 메서드명 => 본문
|
|
*/
|
|
private static function splitMethods(string $source): array
|
|
{
|
|
$parts = preg_split('/\n (?:public|protected|private) function /', $source);
|
|
|
|
if ($parts === false) {
|
|
return [];
|
|
}
|
|
|
|
$methods = [];
|
|
|
|
foreach (array_slice($parts, 1) as $part) {
|
|
if (preg_match('/^(\w+)/', $part, $m) === 1) {
|
|
$methods[$m[1]] = $part;
|
|
}
|
|
}
|
|
|
|
return $methods;
|
|
}
|
|
|
|
/**
|
|
* 메서드 본문이 호출하는 같은 클래스 헬퍼 이름을 추출합니다.
|
|
*
|
|
* @param string $body 메서드 본문
|
|
* @return list<string> 헬퍼 메서드명
|
|
*/
|
|
private static function calledHelpers(string $body): array
|
|
{
|
|
preg_match_all('/\$this->(\w+)\(/', $body, $m);
|
|
|
|
return array_values(array_unique($m[1] ?? []));
|
|
}
|
|
|
|
/**
|
|
* 메서드 본문의 정렬이 고유 키로 끝나는지 판정합니다.
|
|
*
|
|
* @param string $body 메서드 본문
|
|
* @return bool 키 타이브레이크 존재 여부
|
|
*/
|
|
private static function hasKeyTiebreak(string $body): bool
|
|
{
|
|
// orderBy('id'|'uuid'|'x.id', ...) / orderByDesc('id') / orderByRaw('... id ...')
|
|
return preg_match(
|
|
'/orderBy(?:Desc)?\(\s*[\'"][\w.]*\b(?:id|uuid)[\'"]|orderByRaw\([^)]*\bid\b/',
|
|
$body
|
|
) === 1;
|
|
}
|
|
}
|