diff --git a/.vscode/settings.json b/.vscode/settings.json index 859b9149..46ba6341 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -43,6 +43,7 @@ ], "cSpell.words": [ "embla", + "joinedload", "shadcn", "shinkansen" ], diff --git a/server/app/cruds/blocks.py b/server/app/cruds/blocks.py index ce0e35cd..bdcfc62b 100644 --- a/server/app/cruds/blocks.py +++ b/server/app/cruds/blocks.py @@ -1,6 +1,6 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import joinedload from app.cruds import locations as locations_cruds from app.models import Block @@ -9,9 +9,25 @@ def _block_with_relations(): + """Block を location / destination_location と一緒に 1 クエリで取る。 + + どちらも 1-to-1 なので joinedload で重複が増えない。 + """ + return select(Block).options( + joinedload(Block.location), + joinedload(Block.destination_location), + ) + + +def _block_with_page_and_relations(): + """Block + Page (trip_id 取得用) + location / destination_location を 1 クエリで取る。 + + 認可 (Page.trip_id が必要) と本体取得を 1 ラウンドトリップに統合するため。 + """ return select(Block).options( - selectinload(Block.location), - selectinload(Block.destination_location), + joinedload(Block.page), + joinedload(Block.location), + joinedload(Block.destination_location), ) @@ -62,8 +78,8 @@ async def create_block(db: AsyncSession, block: BlockCreate, page_id: int) -> Bl stmt = ( select(Block) .options( - selectinload(Block.location), - selectinload(Block.destination_location) + joinedload(Block.location), + joinedload(Block.destination_location), ) .where(Block.id == db_block.id) ) @@ -84,7 +100,7 @@ async def find_blocks(db: AsyncSession, page_id: int) -> list[Block]: list[Block]: ブロックリスト """ result = await db.execute( - _block_with_relations().where(Block.page_id == page_id) + _block_with_relations().where(Block.page_id == page_id).order_by(Block.id) ) return list(result.scalars().all()) @@ -104,6 +120,17 @@ async def get_block(db: AsyncSession, block_id: int) -> Block | None: return result.scalar_one_or_none() +async def get_block_with_page(db: AsyncSession, block_id: int) -> Block | None: + """ブロックを Page (trip_id 用) ごと 1 クエリで取得する。 + + ルーターで認可 + 本体取得を 1 ラウンドトリップで済ませるためのヘルパー。 + """ + result = await db.execute( + _block_with_page_and_relations().where(Block.id == block_id) + ) + return result.scalar_one_or_none() + + async def _replace_block_location( db: AsyncSession, db_block: Block, diff --git a/server/app/cruds/pages.py b/server/app/cruds/pages.py index 7bdbe11c..68fa7648 100644 --- a/server/app/cruds/pages.py +++ b/server/app/cruds/pages.py @@ -1,16 +1,20 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import joinedload from app.models import Block, Page from app.schemas.page import PageCreate, PageUpdate def _page_with_relations(): + """Page → Blocks → Locations を 1 クエリの LEFT JOIN で取得する。 + + 1-to-many の連鎖により重複行が出るので、呼び出し側で `result.unique()` を挟む。 + """ return select(Page).options( - selectinload(Page.blocks).options( - selectinload(Block.location), - selectinload(Block.destination_location), + joinedload(Page.blocks).options( + joinedload(Block.location), + joinedload(Block.destination_location), ) ) @@ -45,9 +49,9 @@ async def find_pages(db: AsyncSession, trip_id: int) -> list[Page]: list[Page]: ページリスト """ result = await db.execute( - _page_with_relations().where(Page.trip_id == trip_id) + _page_with_relations().where(Page.trip_id == trip_id).order_by(Page.id) ) - return list(result.scalars().all()) + return list(result.unique().scalars().all()) async def get_page(db: AsyncSession, page_id: int) -> Page | None: @@ -62,7 +66,7 @@ async def get_page(db: AsyncSession, page_id: int) -> Page | None: Page | None: 特定のページ、見つからない場合はNone """ result = await db.execute(_page_with_relations().where(Page.id == page_id)) - return result.scalar_one_or_none() + return result.unique().scalar_one_or_none() async def update_page(db: AsyncSession, page_id: int, page: PageUpdate) -> Page | None: diff --git a/server/app/cruds/trips.py b/server/app/cruds/trips.py index 2b8d0ecc..422433a2 100644 --- a/server/app/cruds/trips.py +++ b/server/app/cruds/trips.py @@ -1,16 +1,23 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import joinedload from app.models import Block, Page, Trip from app.schemas.trip import TripCreateIn, TripUpdate def _trip_with_relations(): + """Trip → Pages → Blocks → Locations を 1 クエリの LEFT JOIN で取得する。 + + 1-to-many を joinedload で連鎖させるため重複行が発生する。呼び出し側で + `result.unique()` を挟むこと。クエリ数を 4 から 1 に減らす目的。 + """ return select(Trip).options( - selectinload(Trip.pages).selectinload(Page.blocks).options( - selectinload(Block.location), - selectinload(Block.destination_location), + joinedload(Trip.pages) + .joinedload(Page.blocks) + .options( + joinedload(Block.location), + joinedload(Block.destination_location), ) ) @@ -43,8 +50,8 @@ async def find_trips(db: AsyncSession) -> list[Trip]: Returns: list[Trip]: すべての旅行プラン """ - result = await db.execute(_trip_with_relations()) - return list(result.scalars().all()) + result = await db.execute(_trip_with_relations().order_by(Trip.id)) + return list(result.unique().scalars().all()) async def get_trip(db: AsyncSession, trip_id: int) -> Trip | None: @@ -59,7 +66,7 @@ async def get_trip(db: AsyncSession, trip_id: int) -> Trip | None: Trip | None: 特定の旅行プラン、見つからない場合はNone """ result = await db.execute(_trip_with_relations().where(Trip.id == trip_id)) - return result.scalar_one_or_none() + return result.unique().scalar_one_or_none() async def get_trip_by_url_id(db: AsyncSession, url_id: str) -> Trip | None: @@ -74,7 +81,7 @@ async def get_trip_by_url_id(db: AsyncSession, url_id: str) -> Trip | None: Trip | None: 特定の旅行プラン、見つからない場合はNone """ result = await db.execute(_trip_with_relations().where(Trip.url_id == url_id)) - return result.scalar_one_or_none() + return result.unique().scalar_one_or_none() async def update_trip(db: AsyncSession, trip_id: int, trip: TripUpdate) -> Trip | None: diff --git a/server/app/db_connection.py b/server/app/db_connection.py index 6170ee2c..2b6b9dd0 100644 --- a/server/app/db_connection.py +++ b/server/app/db_connection.py @@ -21,10 +21,18 @@ # 非同期エンジンの作成 engine: AsyncEngine = create_async_engine( settings.get_database_url(), - echo=False, # 本番環境では False - pool_pre_ping=True, # 接続の健全性チェック - pool_size=5, # 接続プールサイズ - max_overflow=10, # 最大オーバーフロー接続数 + echo=False, + # Neon Free は 5 分アイドルで compute がサスペンドされ、復帰時に + # アプリ側のプール内コネクションが dead 化することがある。checkout 時の + # SELECT 1 (Pessimistic Disconnect Handling) で死活確認するのが安全。 + # 1 RTT 分のコストが発生するが、クエリ最適化で減らせる RTT 数より接続 + # 失敗による 500 のほうがコスト高なので有効のままにする。 + pool_pre_ping=True, + pool_size=5, + max_overflow=10, + # 30 分超過した接続は次回 checkout 時にリサイクル。pool_pre_ping と + # 二重防御。短すぎると無駄な再確立が増え、長すぎると古い接続を掴むリスク。 + pool_recycle=1800, connect_args={"ssl": True} if settings.ssl_required else {}, ) diff --git a/server/app/models.py b/server/app/models.py index 6637c468..01d2957a 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -87,6 +87,7 @@ class Trip(Base): cascade="all, delete-orphan", lazy="raise", passive_deletes=True, + order_by="Page.id", ) @@ -117,6 +118,7 @@ class Page(Base): cascade="all, delete-orphan", lazy="raise", passive_deletes=True, + order_by="Block.id", ) diff --git a/server/app/routers/blocks.py b/server/app/routers/blocks.py index 34b9cc2d..64eac210 100644 --- a/server/app/routers/blocks.py +++ b/server/app/routers/blocks.py @@ -1,10 +1,15 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import require_block_access, require_page_access +from app.auth import ( + get_allowed_trip_ids, + require_block_access, + require_page_access, +) from app.cruds import blocks as blocks_cruds +from app.cruds import pages as pages_cruds from app.db_connection import get_db_session -from app.errors import NotFound +from app.errors import Forbidden, NotFound from app.schemas.block import Block, BlockCreate, BlockUpdate router = APIRouter(tags=["Blocks"]) @@ -39,15 +44,22 @@ async def create_block( ) async def get_blocks( page_id: int, - _: int = Depends(require_page_access), + request: Request, db: AsyncSession = Depends(get_db_session), ) -> list[Block]: """ 説明: - 特定のページに関連するすべてのブロックを取得する + - 認可と本体取得を 1 クエリで済ませるため、Page を blocks 込みで取得し + その page.trip_id を Cookie で検証する """ - return await blocks_cruds.find_blocks(db=db, page_id=page_id) + db_page = await pages_cruds.get_page(db=db, page_id=page_id) + if db_page is None: + raise NotFound(message="Page not found") + if db_page.trip_id not in get_allowed_trip_ids(request): + raise Forbidden() + return db_page.blocks @router.get( @@ -58,18 +70,21 @@ async def get_blocks( ) async def get_block( block_id: int, - _: int = Depends(require_block_access), + request: Request, db: AsyncSession = Depends(get_db_session), ) -> Block: """ 説明: - IDで指定された単一のブロックを取得する + - 認可と本体取得を 1 クエリで済ませるため、Block を Page (trip_id 取得用) と + locations 込みで取得し、その page.trip_id を Cookie で検証する """ - db_block = await blocks_cruds.get_block(db, block_id=block_id) + db_block = await blocks_cruds.get_block_with_page(db=db, block_id=block_id) if db_block is None: raise NotFound(message="Block not found") - + if db_block.page.trip_id not in get_allowed_trip_ids(request): + raise Forbidden() return db_block diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py index cfe4ac28..c22abb74 100644 --- a/server/app/routers/pages.py +++ b/server/app/routers/pages.py @@ -1,10 +1,14 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import require_trip_access, require_page_access +from app.auth import ( + get_allowed_trip_ids, + require_page_access, + require_trip_access, +) from app.cruds import pages as pages_cruds from app.db_connection import get_db_session -from app.errors import NotFound +from app.errors import Forbidden, NotFound from app.schemas.page import Page, PageCreate, PageCreateResponse, PageUpdate # /trips/{trip_id}/pages で作成と一覧取得 @@ -59,18 +63,21 @@ async def get_pages( ) async def get_page( page_id: int, - _: int = Depends(require_page_access), + request: Request, db: AsyncSession = Depends(get_db_session), ) -> Page: """ 説明: - IDで指定された単一のページを取得する + - 認可と本体取得を 1 クエリで済ませるため、Page を blocks / locations 込みで + 取得し、その page.trip_id を Cookie で検証する """ db_page = await pages_cruds.get_page(db, page_id=page_id) if db_page is None: raise NotFound(message="Page not found") - + if db_page.trip_id not in get_allowed_trip_ids(request): + raise Forbidden() return db_page