From e49300412d86f8c8595e9e53efae3e9a8e9f88c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:23:30 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E3=83=90=E3=82=B9=E8=B7=AF=E7=B7=9A?= =?UTF-8?q?=E3=81=A7GetStationsByLineId=E3=81=8C0=E4=BB=B6=E3=81=AB?= =?UTF-8?q?=E3=81=AA=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E7=A8=AE=E5=88=A5?= =?UTF-8?q?=E3=81=AA=E3=81=97=E9=A7=85=E4=B8=80=E8=A6=A7=E3=81=B8=E3=81=AE?= =?UTF-8?q?=E3=83=95=E3=82=A9=E3=83=BC=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=81=A7=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LM9j6JJciEV1jmhLTt7Hod --- AGENTS.md | 2 +- .../src/infrastructure/station_repository.rs | 337 +++++++++++++++++- 2 files changed, 337 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e386cd4e..051c6a97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ This guide explains how automation agents and human contributors should work wit - **IPA coverage audit** – Execute `make ipa-audit` when English or romanized CSV names change. This is a read-only report for `data/2!lines.csv`, `data/3!stations.csv`, and `data/4!types.csv`; it does not fail validation, but highlights unresolved tokens and example names so the IPA dictionary can be extended deliberately. ## gRPC Endpoint Overview -- **Stations** – `GetStationById`, `GetStationByIdList`, `GetStationsByGroupId`, `GetStationsByCoordinates`, `GetStationsByLineId`, `GetStationsByName`, `GetStationsByLineGroupId`. `QueryInteractor` enriches stations with lines, companies, station numbers, and train types. +- **Stations** – `GetStationById`, `GetStationByIdList`, `GetStationsByGroupId`, `GetStationsByCoordinates`, `GetStationsByLineId`, `GetStationsByName`, `GetStationsByLineGroupId`. `QueryInteractor` enriches stations with lines, companies, station numbers, and train types. `GetStationsByLineId` resolves the line's local train-type group (rail `kind` 0/1 or a `priority > 0` type); when no such group exists — bus lines only carry `BusRoute` (`kind` 7, `priority` 0) variants — it falls back to the line's plain typeless station list so bus stop listings never return empty. - **Lines** – `GetLineById`, `GetLinesByIdList`, `GetLinesByName`. Results include company data and computed line symbols based on repository helpers. - **Routes** – `GetRoutes`, `GetRoutesMinimal`, `GetConnectedRoutes`. The minimal variant returns `RouteMinimalResponse` with deduplicated `LineMinimal` data; paging tokens are currently empty (pagination not implemented). - **Train types** – `GetTrainTypesByStationId`, `GetRouteTypes`. Train types aggregate by line group and include related lines plus optional train type metadata. Rail variants use `TrainTypeKind::{Default, Branch, Rapid, Express, LimitedExpress, HighSpeedRapid, CommuterRapid}` (0-6); bus variants use `BusRoute` (7), which represents a `(route_id, shape_id)` operation pattern (e.g. 循環 / 短ターン / 支線) generated automatically from the configured GTFS bus feeds (Toei Bus, Seibu Bus, Keio Bus) and the converted Tokyu Bus JSON. diff --git a/stationapi/src/infrastructure/station_repository.rs b/stationapi/src/infrastructure/station_repository.rs index e46809e1..e980b12d 100644 --- a/stationapi/src/infrastructure/station_repository.rs +++ b/stationapi/src/infrastructure/station_repository.rs @@ -1022,13 +1022,114 @@ impl InternalStationRepository { let rows = sqlx::query_as::<_, StationRow>(&query_str) .bind(line_id as i32) .bind(station_id.map(|id| id as i32)) - .fetch_all(conn) + .fetch_all(&mut *conn) .await?; + + // バス路線の種別はBusRoute(kind=7, priority=0)のみで、target_line_groupの + // 鉄道向けフィルタ(kind 0/1・priority>0)に一致せず0件になる。 + // その場合は種別なしの駅一覧(路線の全停留所)へフォールバックする。 + if rows.is_empty() { + return Self::get_by_line_id_without_train_types(line_id, direction_id, conn).await; + } + let stations = rows.into_iter().map(|row| row.into()).collect(); Ok(stations) } + async fn get_by_line_id_without_train_types( + line_id: u32, + direction_id: Option, + conn: &mut PgConnection, + ) -> Result, DomainError> { + // When direction_id = 1 (上り) or 2 (下り), reverse the order + let order_clause = if matches!(direction_id, Some(1) | Some(2)) { + "ORDER BY s.e_sort DESC, s.station_cd DESC" + } else { + "ORDER BY s.e_sort ASC, s.station_cd ASC" + }; + + let query_str = format!( + r#"SELECT + s.station_cd, + s.station_g_cd, + s.station_name, + s.station_name_k, + s.station_name_r, + s.station_name_rn, + s.station_name_zh, + s.station_name_ko, + s.station_number1, + s.station_number2, + s.station_number3, + s.station_number4, + s.three_letter_code, + s.line_cd, + s.pref_cd, + s.post, + s.address, + s.lon, + s.lat, + s.open_ymd, + s.close_ymd, + s.e_status, + s.e_sort, + l.company_cd, + COALESCE(NULLIF(COALESCE(a.line_name, l.line_name), ''), NULL) AS line_name, + COALESCE(NULLIF(COALESCE(a.line_name_k, l.line_name_k), ''), NULL) AS line_name_k, + COALESCE(NULLIF(COALESCE(a.line_name_h, l.line_name_h), ''), NULL) AS line_name_h, + COALESCE(NULLIF(COALESCE(a.line_name_r, l.line_name_r), ''), NULL) AS line_name_r, + COALESCE(NULLIF(COALESCE(a.line_name_zh, l.line_name_zh), ''), NULL) AS line_name_zh, + COALESCE(NULLIF(COALESCE(a.line_name_ko, l.line_name_ko), ''), NULL) AS line_name_ko, + COALESCE(NULLIF(COALESCE(a.line_color_c, l.line_color_c), ''), NULL) AS line_color_c, + l.line_type, + l.line_symbol1, + l.line_symbol2, + l.line_symbol3, + l.line_symbol4, + l.line_symbol1_color, + l.line_symbol2_color, + l.line_symbol3_color, + l.line_symbol4_color, + l.line_symbol1_shape, + l.line_symbol2_shape, + l.line_symbol3_shape, + l.line_symbol4_shape, + COALESCE(l.average_distance, 0.0)::DOUBLE PRECISION AS average_distance, + NULL::int AS type_id, + NULL::int AS sst_id, + NULL::int AS type_cd, + NULL::int AS line_group_cd, + NULL::int AS pass, + NULL::text AS type_name, + NULL::text AS type_name_k, + NULL::text AS type_name_r, + NULL::text AS type_name_zh, + NULL::text AS type_name_ko, + NULL::text AS color, + NULL::int AS direction, + NULL::int AS kind, + s.transport_type + FROM stations AS s + JOIN lines AS l ON l.line_cd = s.line_cd + LEFT JOIN line_aliases AS la ON la.station_cd = s.station_cd + LEFT JOIN aliases AS a ON a.id = la.alias_cd + WHERE l.line_cd = $1 + AND s.e_status = 0 + AND l.e_status = 0 + {order_clause}"# + ); + + let rows = sqlx::query_as::<_, StationRow>(&query_str) + .bind(line_id as i32) + .fetch_all(conn) + .await?; + + let stations: Vec = rows.into_iter().map(|row| row.into()).collect(); + + Ok(stations) + } + async fn get_by_station_group_id( group_id: u32, conn: &mut PgConnection, @@ -2793,4 +2894,238 @@ mod tests { async fn test_get_route_stops_by_station_cd_reverse_order() { // direction_id=1または2で駅の並び順がDESCに反転されることを確認 } + + mod get_by_line_id_fixtures { + use sqlx::{Connection, Executor, PgConnection}; + use std::env; + use std::sync::atomic::{AtomicU64, Ordering}; + + static SCHEMA_COUNTER: AtomicU64 = AtomicU64::new(0); + + pub fn unique_schema_name() -> String { + let id = SCHEMA_COUNTER.fetch_add(1, Ordering::SeqCst); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("gbli_test_{nanos}_{id}") + } + + pub async fn open_conn() -> PgConnection { + let database_url = env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://test:test@localhost/stationapi_test".to_string()); + PgConnection::connect(&database_url) + .await + .expect("Failed to connect to test database. Set TEST_DATABASE_URL.") + } + + /// 隔離スキーマに get_by_line_id_with_train_type が参照する最小テーブル群を + /// 作成し、鉄道路線(種別あり)とバス路線(BusRoute種別のみ)を投入する。 + pub async fn setup_schema(conn: &mut PgConnection) -> String { + let schema = unique_schema_name(); + conn.execute(format!("CREATE SCHEMA \"{schema}\"").as_str()) + .await + .expect("create schema"); + conn.execute(format!("SET search_path TO \"{schema}\"").as_str()) + .await + .expect("set search_path"); + conn.execute( + r#" + CREATE TABLE stations ( + station_cd INTEGER PRIMARY KEY, + station_g_cd INTEGER NOT NULL, + station_name TEXT NOT NULL, + station_name_k TEXT NOT NULL, + station_name_r TEXT, + station_name_rn TEXT, + station_name_zh TEXT, + station_name_ko TEXT, + station_number1 TEXT, + station_number2 TEXT, + station_number3 TEXT, + station_number4 TEXT, + three_letter_code TEXT, + line_cd INTEGER NOT NULL, + pref_cd INTEGER NOT NULL DEFAULT 13, + post TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + lon DOUBLE PRECISION NOT NULL DEFAULT 139.0, + lat DOUBLE PRECISION NOT NULL DEFAULT 35.0, + open_ymd TEXT NOT NULL DEFAULT '', + close_ymd TEXT NOT NULL DEFAULT '', + e_status INTEGER NOT NULL DEFAULT 0, + e_sort INTEGER NOT NULL, + transport_type INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE lines ( + line_cd INTEGER PRIMARY KEY, + company_cd INTEGER, + line_name TEXT, + line_name_k TEXT, + line_name_h TEXT, + line_name_r TEXT, + line_name_zh TEXT, + line_name_ko TEXT, + line_color_c TEXT, + line_type INTEGER, + line_symbol1 TEXT, + line_symbol2 TEXT, + line_symbol3 TEXT, + line_symbol4 TEXT, + line_symbol1_color TEXT, + line_symbol2_color TEXT, + line_symbol3_color TEXT, + line_symbol4_color TEXT, + line_symbol1_shape TEXT, + line_symbol2_shape TEXT, + line_symbol3_shape TEXT, + line_symbol4_shape TEXT, + average_distance DOUBLE PRECISION, + e_status INTEGER NOT NULL DEFAULT 0, + transport_type INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE types ( + id SERIAL PRIMARY KEY, + type_cd INTEGER UNIQUE NOT NULL, + type_name TEXT NOT NULL, + type_name_k TEXT, + type_name_r TEXT, + type_name_zh TEXT, + type_name_ko TEXT, + color TEXT, + direction INTEGER, + kind INTEGER NOT NULL, + priority INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE station_station_types ( + id SERIAL PRIMARY KEY, + station_cd INTEGER NOT NULL, + type_cd INTEGER NOT NULL, + line_group_cd INTEGER NOT NULL, + pass INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE line_aliases ( + id SERIAL PRIMARY KEY, + station_cd INTEGER NOT NULL, + alias_cd INTEGER NOT NULL + ); + CREATE TABLE aliases ( + id INTEGER PRIMARY KEY, + line_name TEXT, + line_name_k TEXT, + line_name_h TEXT, + line_name_r TEXT, + line_name_zh TEXT, + line_name_ko TEXT, + line_color_c TEXT + ); + -- 鉄道路線: 各駅停車グループ(kind=0)を持つ + INSERT INTO lines (line_cd, company_cd, line_name, e_status, transport_type) + VALUES (100, 1, 'テスト鉄道線', 0, 0), (200, 2, 'テストバス路線', 0, 1); + INSERT INTO types (type_cd, type_name, kind, priority) + VALUES (100, '普通', 0, 0), (900, '循環', 7, 0); + INSERT INTO stations + (station_cd, station_g_cd, station_name, station_name_k, line_cd, e_sort, transport_type) + VALUES + (1, 1, '鉄道駅A', 'テツドウエキエー', 100, 1, 0), + (2, 2, '鉄道駅B', 'テツドウエキビー', 100, 2, 0), + (11, 11, 'バス停A', 'バステイエー', 200, 1, 1), + (12, 12, 'バス停B', 'バステイビー', 200, 2, 1), + (13, 13, 'バス停C', 'バステイシー', 200, 3, 1); + INSERT INTO station_station_types (station_cd, type_cd, line_group_cd, pass) + VALUES + (1, 100, 1000000100, 0), + (2, 100, 1000000100, 0), + (11, 900, 555, 0), + (12, 900, 555, 0), + (13, 900, 555, 0); + "#, + ) + .await + .expect("create get_by_line_id fixtures"); + schema + } + + pub async fn drop_schema(conn: &mut PgConnection, schema: &str) { + conn.execute(format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE").as_str()) + .await + .expect("drop schema"); + } + } + + /// バス路線(種別がBusRouteのみ)でも get_by_line_id が停留所一覧を返すこと、 + /// および鉄道路線では従来どおり種別グループの駅列を返すことを確認する。 + #[tokio::test] + #[cfg_attr(not(feature = "integration-tests"), ignore)] + async fn test_get_by_line_id_with_train_type_falls_back_for_bus_lines() { + let mut conn = get_by_line_id_fixtures::open_conn().await; + let schema = get_by_line_id_fixtures::setup_schema(&mut conn).await; + + // 鉄道路線: 各駅停車グループの駅列が返る + let rail_stations = InternalStationRepository::get_by_line_id_with_train_type( + 100, + Some(1), + None, + &mut conn, + ) + .await + .expect("rail stations"); + assert_eq!( + rail_stations + .iter() + .map(|s| (s.station_cd, s.line_group_cd)) + .collect::>(), + vec![(1, Some(1000000100)), (2, Some(1000000100))] + ); + + // バス路線 + 停留所指定: BusRoute種別はグループ選択にマッチしないため、 + // 種別なしの全停留所一覧にフォールバックする + let bus_stations = InternalStationRepository::get_by_line_id_with_train_type( + 200, + Some(11), + None, + &mut conn, + ) + .await + .expect("bus stations"); + assert_eq!( + bus_stations + .iter() + .map(|s| (s.station_cd, s.line_group_cd)) + .collect::>(), + vec![(11, None), (12, None), (13, None)] + ); + + // バス路線 + 停留所未指定(プリセット復元経路)でも同様に返る + let bus_stations_no_seed = + InternalStationRepository::get_by_line_id_with_train_type(200, None, None, &mut conn) + .await + .expect("bus stations without seed"); + assert_eq!( + bus_stations_no_seed + .iter() + .map(|s| s.station_cd) + .collect::>(), + vec![11, 12, 13] + ); + + // direction_id指定時は並び順が反転する + let bus_stations_reversed = InternalStationRepository::get_by_line_id_with_train_type( + 200, + Some(11), + Some(1), + &mut conn, + ) + .await + .expect("bus stations reversed"); + assert_eq!( + bus_stations_reversed + .iter() + .map(|s| s.station_cd) + .collect::>(), + vec![13, 12, 11] + ); + + get_by_line_id_fixtures::drop_schema(&mut conn, &schema).await; + } } From 0b021e6ecc717df30513ec1653b7ebaf0c08b029 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:32:54 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=E5=BE=A9=E5=85=83=E3=81=97=E3=81=9F?= =?UTF-8?q?=E3=83=95=E3=82=A9=E3=83=BC=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF?= =?UTF-8?q?=E9=96=A2=E6=95=B0=E3=81=A8=E3=83=86=E3=82=B9=E3=83=88=E3=83=95?= =?UTF-8?q?=E3=82=A3=E3=82=AF=E3=82=B9=E3=83=81=E3=83=A3=E3=81=ABdoc?= =?UTF-8?q?=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LM9j6JJciEV1jmhLTt7Hod --- stationapi/src/infrastructure/station_repository.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/stationapi/src/infrastructure/station_repository.rs b/stationapi/src/infrastructure/station_repository.rs index e980b12d..89e3afd4 100644 --- a/stationapi/src/infrastructure/station_repository.rs +++ b/stationapi/src/infrastructure/station_repository.rs @@ -1037,6 +1037,11 @@ impl InternalStationRepository { Ok(stations) } + /// 種別情報を使わずに路線の全駅を `e_sort` 順で返す。 + /// + /// `get_by_line_id_with_train_type` で種別グループが見つからない路線 + /// (BusRoute種別しか持たないバス路線)のフォールバックとして使う。 + /// 返される駅の種別関連フィールドはすべて `NULL` になる。 async fn get_by_line_id_without_train_types( line_id: u32, direction_id: Option, @@ -2902,6 +2907,7 @@ mod tests { static SCHEMA_COUNTER: AtomicU64 = AtomicU64::new(0); + /// 並列実行してもテスト同士が衝突しない一意なスキーマ名を生成する。 pub fn unique_schema_name() -> String { let id = SCHEMA_COUNTER.fetch_add(1, Ordering::SeqCst); let nanos = std::time::SystemTime::now() @@ -2911,6 +2917,7 @@ mod tests { format!("gbli_test_{nanos}_{id}") } + /// `TEST_DATABASE_URL`(未設定時は既定のローカルDB)へ接続する。 pub async fn open_conn() -> PgConnection { let database_url = env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://test:test@localhost/stationapi_test".to_string()); @@ -3046,6 +3053,7 @@ mod tests { schema } + /// テストで作成した隔離スキーマを破棄する。 pub async fn drop_schema(conn: &mut PgConnection, schema: &str) { conn.execute(format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE").as_str()) .await