목록 조회 성능 축(깊은 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" 로 보고되던 문제도 런너 사전 검증으로 막았다. 정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다. 탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
241 lines
8.5 KiB
PHP
241 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Repositories\Concerns;
|
|
|
|
use App\Repositories\Concerns\ResolvesSortSpec;
|
|
use App\Repositories\Concerns\SortsByRelatedColumn;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\DB;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 관계 테이블 컬럼 정렬 Trait 단위 테스트
|
|
*
|
|
* 이 Trait 이 존재하는 이유는 "조인 없이" 관계 컬럼으로 정렬하기 위해서다. 조인+GROUP BY 로
|
|
* 구현하면 1:N 조인이 원 행을 부풀려 총 건수와 페이지 경계가 깨지고, 지연 조인의 inner 에
|
|
* 집계를 붙이면 건너뛸 행 전체에 집계가 돌아 지연 조인 도입 이유가 사라진다.
|
|
*
|
|
* 따라서 여기서 고정하는 계약은 (1) 관계 키만 서브쿼리로 해석되고 나머지는 일반 경로로
|
|
* 폴백하는지, (2) 만들어진 SQL 이 상관 서브쿼리 형태이며 JOIN·GROUP BY 를 쓰지 않는지,
|
|
* (3) 서브쿼리 정렬 방향이 요청 방향과 일치하는지다.
|
|
*
|
|
* DB 에 접근하지 않는다 — 쿼리를 조립해 SQL 문자열만 검사한다.
|
|
*/
|
|
class SortsByRelatedColumnTest extends TestCase
|
|
{
|
|
/**
|
|
* 관계 정렬 스펙 맵 (주문 → 배송의 발송일)
|
|
*
|
|
* @return array<string, array{model: class-string<Model>, foreign_key: string, column: string}>
|
|
*/
|
|
private function relatedMap(): array
|
|
{
|
|
return [
|
|
'shipped_at' => [
|
|
'model' => SortsByRelatedColumnShipping::class,
|
|
'foreign_key' => 'order_id',
|
|
'column' => 'shipped_at',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Trait 을 노출하는 테스트용 리포지토리를 만듭니다.
|
|
*
|
|
* @return object 리포지토리 대역
|
|
*/
|
|
private function repository(): object
|
|
{
|
|
return new class
|
|
{
|
|
use ResolvesSortSpec;
|
|
use SortsByRelatedColumn;
|
|
|
|
/**
|
|
* 정렬 스펙 해석을 그대로 노출합니다. (Trait 메서드는 protected)
|
|
*
|
|
* @param array $args resolveSortSpecWithRelated 인자
|
|
* @return array 정렬 스펙
|
|
*/
|
|
public function resolve(...$args): array
|
|
{
|
|
return $this->resolveSortSpecWithRelated(...$args);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 프리픽스가 적용된 실제 테이블명을 돌려줍니다.
|
|
*
|
|
* 테스트 단언에도 테이블명·프리픽스를 문자열로 조립하지 않는다는 규정을 적용합니다 —
|
|
* 프리픽스 설정이 바뀌면 단언이 조용히 깨지거나(혹은 통과해) 검사 의미를 잃습니다.
|
|
*
|
|
* @param Model $model 대상 모델
|
|
* @return string 프리픽스 포함 테이블명
|
|
*/
|
|
private function table(Model $model): string
|
|
{
|
|
return DB::getTablePrefix().$model->getTable();
|
|
}
|
|
|
|
/**
|
|
* 정렬 스펙을 해석합니다.
|
|
*
|
|
* @param array<string, mixed> $filters 요청 유래 필터
|
|
* @return array<int, array{column: string|Builder, direction: string}> 정렬 스펙
|
|
*/
|
|
private function resolve(array $filters): array
|
|
{
|
|
return $this->repository()->resolve(
|
|
$filters,
|
|
['id', 'ordered_at', 'created_at'],
|
|
$this->relatedMap(),
|
|
new SortsByRelatedColumnOrder,
|
|
'ordered_at',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 관계 맵에 있는 정렬 키는 상관 서브쿼리로 해석됩니다.
|
|
*/
|
|
#[Test]
|
|
public function 관계_키는_상관_서브쿼리로_해석된다(): void
|
|
{
|
|
$spec = $this->resolve(['sort_by' => 'shipped_at', 'sort_order' => 'desc']);
|
|
|
|
$this->assertCount(1, $spec);
|
|
$this->assertInstanceOf(Builder::class, $spec[0]['column']);
|
|
$this->assertSame('desc', $spec[0]['direction']);
|
|
}
|
|
|
|
/**
|
|
* 서브쿼리가 상관 조건·단건 제한을 갖춘 형태로 조립됩니다.
|
|
*
|
|
* 원 행마다 관계 행 한 건만 참조하므로 원 행 수가 바뀌지 않습니다.
|
|
*/
|
|
#[Test]
|
|
public function 서브쿼리가_상관조건과_단건제한을_갖춘다(): void
|
|
{
|
|
$spec = $this->resolve(['sort_by' => 'shipped_at', 'sort_order' => 'desc']);
|
|
$sql = str_replace('`', '"', $spec[0]['column']->toSql());
|
|
|
|
$related = $this->table(new SortsByRelatedColumnShipping);
|
|
$parent = $this->table(new SortsByRelatedColumnOrder);
|
|
|
|
$this->assertStringContainsString("select \"{$related}\".\"shipped_at\"", $sql);
|
|
$this->assertStringContainsString("from \"{$related}\"", $sql);
|
|
// 상관 조건: 관계 외래키 = 원 모델 기본키
|
|
$this->assertStringContainsString("\"{$related}\".\"order_id\" = \"{$parent}\".\"id\"", $sql);
|
|
$this->assertStringContainsString('limit 1', $sql);
|
|
}
|
|
|
|
/**
|
|
* 조인·집계를 쓰지 않습니다.
|
|
*
|
|
* 1:N 조인은 원 행을 부풀려 총 건수와 페이지 경계를 깨뜨리므로, 이 Trait 이 조인으로
|
|
* 회귀하는 것을 막는 것이 존재 이유입니다.
|
|
*/
|
|
#[Test]
|
|
public function 조인과_집계를_쓰지_않는다(): void
|
|
{
|
|
$spec = $this->resolve(['sort_by' => 'shipped_at', 'sort_order' => 'desc']);
|
|
$sql = strtolower($spec[0]['column']->toSql());
|
|
|
|
$this->assertStringNotContainsString(' join ', $sql);
|
|
$this->assertStringNotContainsString('group by', $sql);
|
|
$this->assertStringNotContainsString('max(', $sql);
|
|
$this->assertStringNotContainsString('min(', $sql);
|
|
}
|
|
|
|
/**
|
|
* 서브쿼리 정렬 방향이 요청 방향과 같습니다.
|
|
*
|
|
* 같은 방향으로 정렬해 한 건만 취하므로 `desc` 는 가장 늦은 값, `asc` 는 가장 이른 값이
|
|
* 기준이 됩니다 — 운영자가 "최근 발송순" 에서 기대하는 값과 일치합니다.
|
|
*/
|
|
#[Test]
|
|
public function 서브쿼리_정렬_방향이_요청과_일치한다(): void
|
|
{
|
|
$desc = $this->resolve(['sort_by' => 'shipped_at', 'sort_order' => 'desc']);
|
|
$asc = $this->resolve(['sort_by' => 'shipped_at', 'sort_order' => 'asc']);
|
|
|
|
$related = $this->table(new SortsByRelatedColumnShipping);
|
|
|
|
$this->assertStringContainsString(
|
|
"order by \"{$related}\".\"shipped_at\" desc",
|
|
str_replace('`', '"', $desc[0]['column']->toSql())
|
|
);
|
|
$this->assertStringContainsString(
|
|
"order by \"{$related}\".\"shipped_at\" asc",
|
|
str_replace('`', '"', $asc[0]['column']->toSql())
|
|
);
|
|
$this->assertSame('asc', $asc[0]['direction']);
|
|
}
|
|
|
|
/**
|
|
* 관계 맵에 없는 정렬 키는 일반 컬럼 경로로 폴백합니다.
|
|
*/
|
|
#[Test]
|
|
public function 관계_맵에_없는_키는_일반_경로로_폴백한다(): void
|
|
{
|
|
$spec = $this->resolve(['sort_by' => 'ordered_at', 'sort_order' => 'asc']);
|
|
|
|
$this->assertSame([['column' => 'ordered_at', 'direction' => 'asc']], $spec);
|
|
}
|
|
|
|
/**
|
|
* 화이트리스트 밖 정렬 키는 기본값으로 폴백합니다.
|
|
*
|
|
* 관계 맵에도 없고 허용 컬럼에도 없는 값이 그대로 orderBy 로 새면 안 됩니다.
|
|
*/
|
|
#[Test]
|
|
public function 허용_목록_밖_키는_기본값으로_폴백한다(): void
|
|
{
|
|
$spec = $this->resolve(['sort_by' => 'admin_memo', 'sort_order' => 'desc']);
|
|
|
|
$this->assertSame('ordered_at', $spec[0]['column']);
|
|
}
|
|
|
|
/**
|
|
* 정렬 키 미지정 시 기본값으로 해석합니다.
|
|
*/
|
|
#[Test]
|
|
public function 정렬_키_미지정은_기본값이다(): void
|
|
{
|
|
$this->assertSame('ordered_at', $this->resolve([])[0]['column']);
|
|
$this->assertSame('ordered_at', $this->resolve(['sort_by' => ' '])[0]['column']);
|
|
}
|
|
|
|
/**
|
|
* 잘못된 방향값은 기본 방향으로 정규화됩니다.
|
|
*
|
|
* 요청 값이 그대로 SQL 방향으로 새면 안 됩니다.
|
|
*/
|
|
#[Test]
|
|
public function 잘못된_방향값은_기본_방향으로_정규화된다(): void
|
|
{
|
|
$spec = $this->resolve(['sort_by' => 'shipped_at', 'sort_order' => 'sideways']);
|
|
|
|
$this->assertSame('desc', $spec[0]['direction']);
|
|
$this->assertStringContainsString('desc', strtolower($spec[0]['column']->toSql()));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테스트용 원 모델 (주문)
|
|
*/
|
|
class SortsByRelatedColumnOrder extends Model
|
|
{
|
|
protected $table = 'orders';
|
|
}
|
|
|
|
/**
|
|
* 테스트용 관계 모델 (배송)
|
|
*/
|
|
class SortsByRelatedColumnShipping extends Model
|
|
{
|
|
protected $table = 'order_shippings';
|
|
}
|