From 7909a6947cf90ccd5794426545786d94cdc01185 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 3 Aug 2026 11:50:40 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=E6=9C=AA=E8=A8=AD=E5=AE=9A=E3=81=AE?= =?UTF-8?q?=E9=89=84=E9=81=93=E8=B7=AF=E7=B7=9A=E3=81=AB=E5=90=84=E5=81=9C?= =?UTF-8?q?=E7=A8=AE=E5=88=A5=E3=82=92=E8=B5=B7=E5=8B=95=E6=99=82=E8=A3=9C?= =?UTF-8?q?=E5=AE=8C=20(#1612)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 未設定の鉄道路線に各停種別を補完 * Rust 1.97のClippy警告を修正 --- AGENTS.md | 1 + stationapi/src/import.rs | 258 ++++++++++++++++++++ stationapi/src/use_case/interactor/query.rs | 2 +- 3 files changed, 260 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 67127edb..c309f7ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ This guide explains how automation agents and human contributors should work wit - **Lines** – `GetLineById`, `GetLinesByIdList`, `GetLinesByName`. Results include company data and computed line symbols based on repository helpers. - **Routes** – `GetRoutes`, `GetRoutesMinimal`. 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. +- **Default rail train types** – After the canonical CSV import, startup fills every active rail line containing at least one station with no `station_station_types` row with a deterministic, complete all-stop group. The generated rows exist only in PostgreSQL; canonical CSV files remain unchanged. `type_cd=100` represents 「普通」 and `type_cd=101` represents 「各駅停車」. An existing 100/101 assignment on the line takes precedence; otherwise startup selects the label per line through `LOCAL_SERVICE_RAIL_LINE_IDS` in `src/import.rs`. Generated `line_group_cd` values use `1,000,000,000 + line_cd`; startup fails on a collision. Bus lines are excluded and continue to use their GTFS-derived `BusRoute` groups. - **GTFS bus integration** – At startup, `src/import.rs::integrate_gtfs_to_stations()` ingests GTFS feeds into `gtfs_*` tables and then projects them onto the shared `stations` / `lines` / `types` / `station_station_types` tables. Every configured GTFS feed is imported, including Seibu Bus and Keio Bus (both downloaded from ODPT with `ODPT_ACCESS_TOKEN`). Tokyu Bus ordinary-route `BusroutePattern`, `BusstopPole`, and `BusTimetable` JSON are converted into the same `gtfs_*` representation; pattern IDs become `shape_id` values so route variants remain queryable as bus TrainTypes. The Tokyu-operated Ota, Shinagawa, and Meguro community buses use their official GTFS feeds and matching JSON routes are excluded to prevent duplicates. `ODPT_ACCESS_TOKEN` is required for authenticated sources. Stops whose Tokyu JSON records omit coordinates remain available to name and route queries but not coordinate searches. `transport_type` (0: rail, 1: bus) on both `stations` and `lines` keeps rail and bus records queryable side by side. GTFS IDs are namespaced per feed before import to avoid cross-operator collisions. `line_cd` (100,000,000+), `station_cd` / `station_g_cd` (200,000,000+), and bus `type_cd` / `line_group_cd` (100,000,000+) are all deterministic fnv1a hashes that stay clear of the rail data ranges. Disable the entire bus pipeline with `DISABLE_BUS_FEATURE=true`. - **Bus stop translations (readings & English)** – GTFS-JP `translations.txt` layouts differ per feed, so `load_gtfs_translations` resolves columns by header name (Seibu ships 6 columns without `record_sub_id`; Keio and the Tokyu community feeds ship 7) and indexes each `stop_name` translation under both keys it may use: `record_id` (== the stop_id, Seibu — with the "-NN" pole suffix also mapped to the parent stop_id) and `field_value` (== the Japanese stop_name, Keio / Tokyu community, where `record_id` is left empty). `import_gtfs_stops` then looks a stop's translation up by stop_id first, then by name. Keying only by `record_id` (the previous behavior) silently dropped every field_value-keyed feed, leaving `station_name_k` filled with the kanji stop_name and `station_name_r` empty. Readings arriving as half-width katakana (`ニシハチオウジ`, Keio / Tokyu community) are folded to full-width via `romaji::to_fullwidth_katakana()` before storage. - **Bus English-name fallback** – When a feed provides no English (`en`) translation for a stop — e.g. Tokyu Bus ordinary-route JSON, which carries only `dc:title` and `odpt:kana` — `src/domain/romaji.rs::romaji_display_name()` derives a modified-Hepburn romanization (with macrons for long vowels, matching the curated rail style: Tōkyō / Kyōto / Shin-Ōsaka) from the kana reading, and `import.rs` fills `stop_name_r` with it. The fallback never overwrites a real `en` value, and a reading with no convertible kana stays `NULL` rather than emitting a partial transcription. Because `stop_name_r` is the single upstream source that fans out into the `stations` projection, `search_by_name`, and the romanized bus route/headsign names, this supplements every English-facing surface at once. When projecting into `stations`, `station_name_rn` is filled with the plain-ASCII spelling via `romaji::strip_macrons()` (Tōkyō → Tokyo), mirroring the rail dataset's `_r` (macron) / `_rn` (macron-free) column pair. diff --git a/stationapi/src/import.rs b/stationapi/src/import.rs index 7a313527..429cca21 100644 --- a/stationapi/src/import.rs +++ b/stationapi/src/import.rs @@ -93,6 +93,29 @@ const PERFORMANCE_INDEXES: &[(&str, &str)] = &[ ), ]; +const DEFAULT_RAIL_TYPE_CD: i32 = 100; +const LOCAL_RAIL_TYPE_CD: i32 = 101; +const VIRTUAL_RAIL_LINE_GROUP_BASE: i32 = 1_000_000_000; + +/// Lines whose operator-facing local-service label is 「各駅停車」 rather than +/// 「普通」. Every other uncovered rail line uses `DEFAULT_RAIL_TYPE_CD`. +/// +/// This is intentionally line-based instead of company-based: operators such as +/// JR East use both labels depending on the line. Keep this list in sync when a +/// newly-added line uses 「各駅停車」 as its Japanese service name. +const LOCAL_SERVICE_RAIL_LINE_IDS: &[i32] = &[ + 11309, // JR East Sagami Line + 11318, // JR East Hachiko Line + 11345, // Disney Resort Line + 99101, // Sapporo Subway Tozai Line + 99102, // Sapporo Subway Namboku Line + 99103, // Sapporo Subway Toho Line + 99301, // Toei Oedo Line + 99305, // Tokyo Sakura Tram + 99342, // Nippori-Toneri Liner + 99649, // Rokko Liner +]; + /// Create required extensions and tables before running data imports. /// Must be called before `import_csv` and `import_gtfs` can run in parallel. pub async fn create_schema() -> Result<(), Box> { @@ -251,6 +274,8 @@ pub async fn import_csv() -> Result<(), Box> { .await?; } + generate_virtual_local_rail_services(&mut conn).await?; + sqlx::query("ANALYZE;").execute(&mut conn).await?; info!("CSV import completed successfully."); @@ -258,6 +283,127 @@ pub async fn import_csv() -> Result<(), Box> { Ok(()) } +/// Fill rail lines containing at least one station with no train type with one +/// deterministic, complete local service. The canonical CSV files remain +/// untouched; generated rows live only in the database rebuilt at startup. +/// +/// `station_station_types.id` determines stop order in repository queries, so +/// the INSERT is ordered by line and station order. Bus lines are excluded both +/// at the line and station level and continue to be managed by GTFS integration. +async fn generate_virtual_local_rail_services( + conn: &mut PgConnection, +) -> Result<(), Box> { + let missing_type_ids: Vec = sqlx::query_scalar( + "SELECT required.type_cd + FROM unnest($1::int[]) AS required(type_cd) + WHERE NOT EXISTS ( + SELECT 1 FROM types AS t WHERE t.type_cd = required.type_cd + ) + ORDER BY required.type_cd", + ) + .bind(vec![DEFAULT_RAIL_TYPE_CD, LOCAL_RAIL_TYPE_CD]) + .fetch_all(&mut *conn) + .await?; + if !missing_type_ids.is_empty() { + return Err(format!( + "cannot generate virtual rail services: missing types.type_cd values {missing_type_ids:?}" + ) + .into()); + } + + let colliding_group_ids: Vec = sqlx::query_scalar( + "WITH lines_needing_local_service AS ( + SELECT l.line_cd + FROM lines AS l + WHERE l.e_status = 0 + AND l.transport_type = 0 + AND EXISTS ( + SELECT 1 FROM stations AS s + WHERE s.line_cd = l.line_cd + AND s.e_status = 0 + AND s.transport_type = 0 + AND NOT EXISTS ( + SELECT 1 FROM station_station_types AS sst + WHERE sst.station_cd = s.station_cd + ) + ) + ) + SELECT DISTINCT sst.line_group_cd + FROM lines_needing_local_service AS ul + JOIN station_station_types AS sst + ON sst.line_group_cd = $1 + ul.line_cd + ORDER BY sst.line_group_cd", + ) + .bind(VIRTUAL_RAIL_LINE_GROUP_BASE) + .fetch_all(&mut *conn) + .await?; + if !colliding_group_ids.is_empty() { + return Err(format!( + "cannot generate virtual rail services: line_group_cd collision for {colliding_group_ids:?}" + ) + .into()); + } + + let result = sqlx::query( + "WITH lines_needing_local_service AS MATERIALIZED ( + SELECT + l.line_cd, + COALESCE( + ( + SELECT sst.type_cd + FROM stations AS typed_station + JOIN station_station_types AS sst + ON sst.station_cd = typed_station.station_cd + WHERE typed_station.line_cd = l.line_cd + AND typed_station.e_status = 0 + AND typed_station.transport_type = 0 + AND sst.type_cd IN ($2, $3) + GROUP BY sst.type_cd + ORDER BY COUNT(*) DESC, sst.type_cd + LIMIT 1 + ), + CASE + WHEN l.line_cd = ANY($1::int[]) THEN $3 + ELSE $2 + END + ) AS type_cd, + $4 + l.line_cd AS line_group_cd + FROM lines AS l + WHERE l.e_status = 0 + AND l.transport_type = 0 + AND EXISTS ( + SELECT 1 FROM stations AS s + WHERE s.line_cd = l.line_cd + AND s.e_status = 0 + AND s.transport_type = 0 + AND NOT EXISTS ( + SELECT 1 FROM station_station_types AS sst + WHERE sst.station_cd = s.station_cd + ) + ) + ) + INSERT INTO station_station_types (station_cd, type_cd, line_group_cd, pass) + SELECT s.station_cd, ul.type_cd, ul.line_group_cd, 0 + FROM lines_needing_local_service AS ul + JOIN stations AS s ON s.line_cd = ul.line_cd + WHERE s.e_status = 0 + AND s.transport_type = 0 + ORDER BY ul.line_cd, s.e_sort, s.station_cd", + ) + .bind(LOCAL_SERVICE_RAIL_LINE_IDS) + .bind(DEFAULT_RAIL_TYPE_CD) + .bind(LOCAL_RAIL_TYPE_CD) + .bind(VIRTUAL_RAIL_LINE_GROUP_BASE) + .execute(&mut *conn) + .await?; + + info!( + "Generated {} virtual local-service station rows for rail lines with untyped stations.", + result.rows_affected() + ); + Ok(()) +} + /// Represents a translation entry from translations.txt #[derive(Debug, Clone, Default)] struct Translation { @@ -4383,6 +4529,118 @@ mod tests { assert_eq!(parse_gtfs_time("00:00:01"), Some("00:00:01".to_string())); } + #[tokio::test] + #[cfg_attr(not(feature = "integration-tests"), ignore)] + async fn test_generate_virtual_local_rail_services_covers_partial_lines_and_excludes_bus() { + use sqlx::{Executor, Row}; + + let mut conn = stop_route_mapping_fixtures::open_conn().await; + let schema = stop_route_mapping_fixtures::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 types (type_cd INTEGER PRIMARY KEY); + CREATE TABLE lines ( + line_cd INTEGER PRIMARY KEY, + e_status INTEGER NOT NULL, + transport_type INTEGER NOT NULL + ); + CREATE TABLE stations ( + station_cd INTEGER PRIMARY KEY, + line_cd INTEGER NOT NULL, + e_status INTEGER NOT NULL, + transport_type INTEGER NOT NULL, + e_sort INTEGER NOT NULL + ); + 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 + ); + INSERT INTO types VALUES (100), (101), (555); + INSERT INTO lines VALUES + (11309, 0, 0), + (500, 0, 0), + (600, 0, 0), + (700, 0, 1); + INSERT INTO stations VALUES + (1, 11309, 0, 0, 1), + (2, 11309, 0, 0, 2), + (3, 11309, 0, 0, 3), + (4, 500, 0, 0, 1), + (5, 500, 0, 0, 2), + (6, 600, 0, 0, 1), + (7, 600, 0, 0, 2), + (8, 700, 0, 1, 1); + INSERT INTO station_station_types + (station_cd, type_cd, line_group_cd, pass) + VALUES + (1, 555, 50, 0), + (4, 100, 60, 0), + (6, 100, 70, 0), + (7, 100, 70, 0); + "#, + ) + .await + .expect("create virtual rail service fixtures"); + + generate_virtual_local_rail_services(&mut conn) + .await + .expect("generate virtual rail services"); + generate_virtual_local_rail_services(&mut conn) + .await + .expect("second generation is idempotent"); + + let generated = sqlx::query( + "SELECT station_cd, type_cd, line_group_cd, pass + FROM station_station_types + WHERE line_group_cd >= $1 + ORDER BY line_group_cd, id", + ) + .bind(VIRTUAL_RAIL_LINE_GROUP_BASE) + .fetch_all(&mut conn) + .await + .expect("read generated rows"); + let actual: Vec<(i32, i32, i32, i32)> = generated + .iter() + .map(|row| { + ( + row.get("station_cd"), + row.get("type_cd"), + row.get("line_group_cd"), + row.get("pass"), + ) + }) + .collect(); + + assert_eq!( + actual, + vec![ + (4, 100, VIRTUAL_RAIL_LINE_GROUP_BASE + 500, 0), + (5, 100, VIRTUAL_RAIL_LINE_GROUP_BASE + 500, 0), + (1, 101, VIRTUAL_RAIL_LINE_GROUP_BASE + 11309, 0), + (2, 101, VIRTUAL_RAIL_LINE_GROUP_BASE + 11309, 0), + (3, 101, VIRTUAL_RAIL_LINE_GROUP_BASE + 11309, 0), + ] + ); + + let bus_generated: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM station_station_types WHERE station_cd = 8") + .fetch_one(&mut conn) + .await + .expect("count bus rows"); + assert_eq!(bus_generated, 0); + + stop_route_mapping_fixtures::drop_schema(&mut conn, &schema).await; + } + // ============================================================================ // build_stop_route_mapping regression tests // diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index 7836f833..5c79052e 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -1206,7 +1206,7 @@ where let params = EstimationParams::default(); let mut result: Vec = Vec::new(); - for (_line_group_cd, group_stops) in route_row_tree_map.iter() { + for group_stops in route_row_tree_map.values() { // 先頭駅が末尾にも重複格納された「閉じた」環状データ(ポートライナー等)は、 // そのままだとラップ時に閉じ駅が二重になるため重複終端を除いてから // 環状判定・弧選択する。 From 3acd4e45927f694a48a9b07eec1a04ad6a82529f Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 3 Aug 2026 13:05:42 +0900 Subject: [PATCH 2/7] =?UTF-8?q?GetConnectedRoutes=E3=81=A7=E8=A4=87?= =?UTF-8?q?=E6=95=B0=E5=88=97=E8=BB=8A=E7=A8=AE=E5=88=A5=E3=82=92=E8=B7=A8?= =?UTF-8?q?=E3=81=90=E7=B5=8C=E8=B7=AF=E3=82=92=E8=BF=94=E3=81=99=20(#1615?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 複数列車種別を接続する経路探索を実装 * 接続経路の候補上限と性能資料を改善 * レビュー指摘に基づき接続経路探索の上限と命名を改善 --- AGENTS.md | 4 +- docs/architecture.md | 27 ++ docs/technical_debt.md | 2 - .../repository/train_type_repository.rs | 11 + .../infrastructure/train_type_repository.rs | 113 ++++- .../src/presentation/controller/grpc.rs | 21 +- stationapi/src/use_case/interactor/query.rs | 427 +++++++++++++++++- stationapi/src/use_case/traits/query.rs | 8 +- 8 files changed, 585 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c309f7ea..eefc387c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,14 +52,14 @@ This guide explains how automation agents and human contributors should work wit ## gRPC Endpoint Overview - **Stations** – `GetStationById`, `GetStationByIdList`, `GetStationsByGroupId`, `GetStationsByCoordinates`, `GetStationsByLineId`, `GetStationsByName`, `GetStationsByLineGroupId`. `QueryInteractor` enriches stations with lines, companies, station numbers, and train types. - **Lines** – `GetLineById`, `GetLinesByIdList`, `GetLinesByName`. Results include company data and computed line symbols based on repository helpers. -- **Routes** – `GetRoutes`, `GetRoutesMinimal`. The minimal variant returns `RouteMinimalResponse` with deduplicated `LineMinimal` data; paging tokens are currently empty (pagination not implemented). +- **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. - **Default rail train types** – After the canonical CSV import, startup fills every active rail line containing at least one station with no `station_station_types` row with a deterministic, complete all-stop group. The generated rows exist only in PostgreSQL; canonical CSV files remain unchanged. `type_cd=100` represents 「普通」 and `type_cd=101` represents 「各駅停車」. An existing 100/101 assignment on the line takes precedence; otherwise startup selects the label per line through `LOCAL_SERVICE_RAIL_LINE_IDS` in `src/import.rs`. Generated `line_group_cd` values use `1,000,000,000 + line_cd`; startup fails on a collision. Bus lines are excluded and continue to use their GTFS-derived `BusRoute` groups. - **GTFS bus integration** – At startup, `src/import.rs::integrate_gtfs_to_stations()` ingests GTFS feeds into `gtfs_*` tables and then projects them onto the shared `stations` / `lines` / `types` / `station_station_types` tables. Every configured GTFS feed is imported, including Seibu Bus and Keio Bus (both downloaded from ODPT with `ODPT_ACCESS_TOKEN`). Tokyu Bus ordinary-route `BusroutePattern`, `BusstopPole`, and `BusTimetable` JSON are converted into the same `gtfs_*` representation; pattern IDs become `shape_id` values so route variants remain queryable as bus TrainTypes. The Tokyu-operated Ota, Shinagawa, and Meguro community buses use their official GTFS feeds and matching JSON routes are excluded to prevent duplicates. `ODPT_ACCESS_TOKEN` is required for authenticated sources. Stops whose Tokyu JSON records omit coordinates remain available to name and route queries but not coordinate searches. `transport_type` (0: rail, 1: bus) on both `stations` and `lines` keeps rail and bus records queryable side by side. GTFS IDs are namespaced per feed before import to avoid cross-operator collisions. `line_cd` (100,000,000+), `station_cd` / `station_g_cd` (200,000,000+), and bus `type_cd` / `line_group_cd` (100,000,000+) are all deterministic fnv1a hashes that stay clear of the rail data ranges. Disable the entire bus pipeline with `DISABLE_BUS_FEATURE=true`. - **Bus stop translations (readings & English)** – GTFS-JP `translations.txt` layouts differ per feed, so `load_gtfs_translations` resolves columns by header name (Seibu ships 6 columns without `record_sub_id`; Keio and the Tokyu community feeds ship 7) and indexes each `stop_name` translation under both keys it may use: `record_id` (== the stop_id, Seibu — with the "-NN" pole suffix also mapped to the parent stop_id) and `field_value` (== the Japanese stop_name, Keio / Tokyu community, where `record_id` is left empty). `import_gtfs_stops` then looks a stop's translation up by stop_id first, then by name. Keying only by `record_id` (the previous behavior) silently dropped every field_value-keyed feed, leaving `station_name_k` filled with the kanji stop_name and `station_name_r` empty. Readings arriving as half-width katakana (`ニシハチオウジ`, Keio / Tokyu community) are folded to full-width via `romaji::to_fullwidth_katakana()` before storage. - **Bus English-name fallback** – When a feed provides no English (`en`) translation for a stop — e.g. Tokyu Bus ordinary-route JSON, which carries only `dc:title` and `odpt:kana` — `src/domain/romaji.rs::romaji_display_name()` derives a modified-Hepburn romanization (with macrons for long vowels, matching the curated rail style: Tōkyō / Kyōto / Shin-Ōsaka) from the kana reading, and `import.rs` fills `stop_name_r` with it. The fallback never overwrites a real `en` value, and a reading with no convertible kana stays `NULL` rather than emitting a partial transcription. Because `stop_name_r` is the single upstream source that fans out into the `stations` projection, `search_by_name`, and the romanized bus route/headsign names, this supplements every English-facing surface at once. When projecting into `stations`, `station_name_rn` is filled with the plain-ASCII spelling via `romaji::strip_macrons()` (Tōkyō → Tokyo), mirroring the rail dataset's `_r` (macron) / `_rn` (macron-free) column pair. - **TTS metadata** – `Station`, `StationMinimal`, `Line`, and `TrainType` expose `name_ipa` / `name_roman_ipa` plus `name_tts_segments` for multi-segment pronunciation output. Use `name_tts_segments` when clients need per-token SSML construction for mixed-language names such as `Kasai-Rinkai Park`. -- **Connected routes** – `GetConnectedRoutes`. `QueryInteractor::get_connected_stations` is not implemented yet and returns an empty vector; update the use-case and infrastructure layers together when adding real logic. +- **Connected routes** – `GetConnectedRoutes` performs a bounded breadth-first search across train-type line groups. Transfers join at a shared station group, route order and per-stop pass metadata are preserved, and each returned candidate receives a deterministic virtual line-group ID in the upper half of the `uint32` range. Revisiting station groups and already-used train types is rejected to prevent cycles. The search is additionally capped at eight train types, 4,096 expanded states, 65,536 evaluated candidates, and 32 results to bound computation and result size. - Changes to the service contract require coordinated updates to `proto/stationapi.proto`, regenerated code via `tonic-build`, and corresponding adjustments in both presentation and use-case layers. ## Contribution Guidelines diff --git a/docs/architecture.md b/docs/architecture.md index 114d8148..0c36a180 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -193,6 +193,33 @@ CREATE INDEX idx_performance_station_name_trgm ON stations | 経路検索 | `GetRoutes`, `GetRoutesMinimal`, `GetConnectedRoutes` | | 列車種別 | `GetTrainTypesByStationId`, `GetRouteTypes` | +### 接続経路探索 + +`GetConnectedRoutes` は、始点の駅グループに停車する列車種別から幅優先で探索し、 +同じ駅グループに停車する別の列車種別へ接続します。Repository は探索階層ごとの +駅グループをまとめて問い合わせ、該当する `line_group_cd` の駅列も一括取得するため、 +候補ごとの N+1 クエリを発生させません。 + +探索中は通過駅を乗換地点にせず、利用済みの列車種別および訪問済みの駅グループを +再訪しません。探索は最大 8 列車種別、4,096 展開状態、65,536 評価候補、 +32 返却候補に制限します。 +完成した経路は接続駅を一度だけ含む駅列へ連結し、各区間の `stop_condition` を保持します。 +返却時には経路の列車種別列と駅グループ列から決定的な仮想 `lineGroupId` を生成し、 +経路内の全 `station.train_type.group_id` に同じ値を設定します。仮想 ID は既存の +PostgreSQL `INTEGER` ID と衝突しない `uint32` 上位半分を使用します。 + +各探索階層では未取得の駅グループを 1 回のクエリへまとめ、そこで判明した未取得の +`line_group_cd` の駅列も 1 回で取得します。このため Repository 呼び出しは最大でも +階層あたり 2 回で、候補経路ごとの N+1 クエリや同じ列車種別の再取得はありません。 +取得後は駅グループと `line_group_cd` を `HashMap` に一度だけ分類し、状態と駅列を +毎回総当たりする O(n×m) の処理を、入力件数に比例する O(n+m) の参照へ置き換えます。 +探索そのものの最悪計算量は各状態の始点候補数と駅列長の積にも依存するため、状態数とは +独立した評価候補数の上限で `start_indices × pattern.len()` の走査も制御します。 + +SQL は `stations.station_g_cd`、`station_station_types.station_cd`、 +`station_station_types.line_group_cd` の既存 btree index を利用できます。列車種別の存在確認に +必要な `types` との JOIN のみを行い、路線・会社など探索に不要なテーブルは JOIN しません。 + ### Proto 更新時の注意点 1. **後方互換性**: 新フィールドには `optional` キーワードを使用 diff --git a/docs/technical_debt.md b/docs/technical_debt.md index ca1d277c..20528af9 100644 --- a/docs/technical_debt.md +++ b/docs/technical_debt.md @@ -93,7 +93,6 @@ pub struct Station { |----------|--------|------| | `stationapi/src/use_case/interactor/query.rs` | 604 | `// TODO: SQLで同等の処理を行う` - 経路検証がアプリケーション側で実行 | | `stationapi/src/use_case/interactor/query.rs` | 702 | `// TODO: SQLで同等の処理を行う` - 経路フィルタリングがアプリケーション層で処理 | -| `stationapi/src/use_case/interactor/query.rs` | 843 | `// TODO: 未実装` - `get_connected_stations()` が空配列を返却 | ```rust // query.rs:604-610 @@ -319,5 +318,4 @@ let station_numbers_raw = [ | **中** | Row 構造体のコード生成検討 | `src/infrastructure/*.rs` | メンテナンス性 | | **中** | メソッド命名の改善 | `src/domain/repository/line_repository.rs:23` | 可読性 | | **中** | ハードコード値の定数化 | 複数ファイル | 保守性 | -| **低** | get_connected_stations の実装 | `src/use_case/interactor/query.rs:843` | 機能完成度 | | **低** | UI レイヤーのテスト追加 | `src/presentation/` | テストカバレッジ | diff --git a/stationapi/src/domain/repository/train_type_repository.rs b/stationapi/src/domain/repository/train_type_repository.rs index 3f418fad..b89ee8fb 100644 --- a/stationapi/src/domain/repository/train_type_repository.rs +++ b/stationapi/src/domain/repository/train_type_repository.rs @@ -1,9 +1,20 @@ use async_trait::async_trait; +use std::collections::HashMap; use crate::domain::{entity::train_type::TrainType, error::DomainError}; #[async_trait] pub trait TrainTypeRepository: Send + Sync + 'static { + /// Return train-type line groups that stop at each requested station group. + /// + /// The default keeps existing lightweight test repositories source-compatible; + /// repositories used by connected-route search must override it. + async fn get_line_group_ids_by_station_group_ids( + &self, + _station_group_ids: &[u32], + ) -> Result>, DomainError> { + Ok(HashMap::new()) + } async fn get_by_line_group_id(&self, line_group_id: u32) -> Result, DomainError>; async fn get_by_station_id(&self, station_id: u32) -> Result, DomainError>; diff --git a/stationapi/src/infrastructure/train_type_repository.rs b/stationapi/src/infrastructure/train_type_repository.rs index 3890e78a..03c12f11 100644 --- a/stationapi/src/infrastructure/train_type_repository.rs +++ b/stationapi/src/infrastructure/train_type_repository.rs @@ -4,7 +4,7 @@ use crate::domain::{ }; use async_trait::async_trait; use sqlx::{PgConnection, Pool, Postgres}; -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; #[derive(sqlx::FromRow, Clone)] pub struct TrainTypeRow { @@ -23,6 +23,12 @@ pub struct TrainTypeRow { kind: Option, } +#[derive(sqlx::FromRow)] +struct ConnectionLineGroupRow { + station_g_cd: i32, + line_group_cd: i32, +} + impl From for TrainType { fn from(row: TrainTypeRow) -> Self { let TrainTypeRow { @@ -72,6 +78,21 @@ impl MyTrainTypeRepository { #[async_trait] impl TrainTypeRepository for MyTrainTypeRepository { + async fn get_line_group_ids_by_station_group_ids( + &self, + station_group_ids: &[u32], + ) -> Result>, DomainError> { + if station_group_ids.is_empty() { + return Ok(HashMap::new()); + } + let mut conn = self.pool.acquire().await?; + InternalTrainTypeRepository::get_line_group_ids_by_station_group_ids( + station_group_ids, + &mut conn, + ) + .await + } + async fn get_by_line_group_id( &self, line_group_id: u32, @@ -146,6 +167,43 @@ impl TrainTypeRepository for MyTrainTypeRepository { pub struct InternalTrainTypeRepository {} impl InternalTrainTypeRepository { + async fn get_line_group_ids_by_station_group_ids( + station_group_ids: &[u32], + conn: &mut PgConnection, + ) -> Result>, DomainError> { + if station_group_ids.is_empty() { + return Ok(HashMap::new()); + } + + let station_group_ids: Vec = station_group_ids + .iter() + .filter_map(|id| i32::try_from(*id).ok()) + .collect(); + let rows = sqlx::query_as::<_, ConnectionLineGroupRow>( + r#"SELECT DISTINCT s.station_g_cd, sst.line_group_cd + FROM stations AS s + JOIN station_station_types AS sst ON sst.station_cd = s.station_cd + JOIN types AS t ON t.type_cd = sst.type_cd + WHERE s.station_g_cd = ANY($1) + AND s.e_status = 0 + AND sst.pass <> 1 + AND sst.line_group_cd IS NOT NULL + ORDER BY s.station_g_cd, sst.line_group_cd"#, + ) + .bind(&station_group_ids) + .fetch_all(conn) + .await?; + + let mut result = HashMap::new(); + for row in rows { + result + .entry(row.station_g_cd as u32) + .or_insert_with(Vec::new) + .push(row.line_group_cd as u32); + } + Ok(result) + } + async fn get_by_line_group_id( line_group_id: u32, conn: &mut PgConnection, @@ -531,6 +589,10 @@ mod tests { .execute(pool) .await .unwrap(); + sqlx::query("DROP TABLE IF EXISTS stations CASCADE") + .execute(pool) + .await + .unwrap(); // テーブル作成 sqlx::query( @@ -550,6 +612,18 @@ mod tests { .await .unwrap(); + sqlx::query( + "CREATE TABLE stations ( + station_cd INTEGER PRIMARY KEY, + station_g_cd INTEGER NOT NULL, + line_cd INTEGER, + e_status INTEGER NOT NULL DEFAULT 0 + )", + ) + .execute(pool) + .await + .unwrap(); + sqlx::query( "CREATE TABLE station_station_types ( id SERIAL PRIMARY KEY, @@ -563,6 +637,18 @@ mod tests { .await .unwrap(); + sqlx::query( + "INSERT INTO stations (station_cd, station_g_cd, e_status) VALUES + (101, 1001, 0), + (102, 1001, 0), + (103, 1002, 0), + (104, 1002, 1), + (105, 1003, 0)", + ) + .execute(pool) + .await + .unwrap(); + // テストデータの挿入 sqlx::query( "INSERT INTO types (type_cd, type_name, type_name_k, type_name_r, type_name_zh, type_name_ko, color, direction, kind) VALUES @@ -596,6 +682,10 @@ mod tests { .execute(pool) .await .unwrap(); + sqlx::query("DROP TABLE IF EXISTS stations CASCADE") + .execute(pool) + .await + .unwrap(); } #[tokio::test] @@ -804,6 +894,27 @@ mod tests { cleanup_test_data(&pool).await; } + #[tokio::test] + #[cfg_attr(not(feature = "integration-tests"), ignore)] + async fn test_get_line_group_ids_by_station_group_ids_filters_pass_and_inactive_stations() { + let pool = setup_test_db().await; + setup_test_data(&pool).await; + + let mut conn = pool.acquire().await.unwrap(); + let result = InternalTrainTypeRepository::get_line_group_ids_by_station_group_ids( + &[1001, 1002, 1003], + &mut conn, + ) + .await + .unwrap(); + + assert_eq!(result.get(&1001), Some(&vec![301, 302])); + assert!(!result.contains_key(&1002)); + assert_eq!(result.get(&1003), Some(&vec![305])); + + cleanup_test_data(&pool).await; + } + #[tokio::test] #[cfg_attr(not(feature = "integration-tests"), ignore)] async fn test_get_by_line_group_id_vec_excludes_pass() { diff --git a/stationapi/src/presentation/controller/grpc.rs b/stationapi/src/presentation/controller/grpc.rs index ec1e9e00..9d97069a 100644 --- a/stationapi/src/presentation/controller/grpc.rs +++ b/stationapi/src/presentation/controller/grpc.rs @@ -13,7 +13,7 @@ use crate::{ GetStationByIdRequest, GetStationByLineIdListRequest, GetStationByLineIdRequest, GetStationsByLineGroupIdListRequest, GetStationsByLineGroupIdRequest, GetStationsByNameRequest, GetTrainRouteRequest, GetTrainTypesByStationIdRequest, - MultipleLineResponse, MultipleStationResponse, MultipleTrainTypeResponse, Route, + MultipleLineResponse, MultipleStationResponse, MultipleTrainTypeResponse, RouteMinimalResponse, RouteResponse, RouteTypeResponse, SingleLineResponse, SingleStationResponse, TrainRouteResponse, TransportType as GrpcTransportType, }, @@ -411,14 +411,11 @@ impl StationApi for MyApi { match self .query_use_case - .get_connected_stations(from_station_group_id, to_station_group_id) + .get_connected_routes(from_station_group_id, to_station_group_id) .await { - Ok(stations) => Ok(Response::new(RouteResponse { - routes: vec![Route { - id: 0, - stops: stations.into_iter().map(|station| station.into()).collect(), - }], + Ok(routes) => Ok(Response::new(RouteResponse { + routes, next_page_token: "".to_string(), })), Err(err) => { @@ -507,7 +504,7 @@ mod tests { station::Station, station_number::StationNumber, train_type::TrainType, }, }, - proto::RouteMinimalResponse, + proto::{Route, RouteMinimalResponse}, use_case::{error::UseCaseError, traits::query::QueryUseCase}, }; use async_trait::async_trait; @@ -925,11 +922,11 @@ mod tests { Ok(vec![]) } - async fn get_connected_stations( + async fn get_connected_routes( &self, - _from_station_id: u32, - _to_station_id: u32, - ) -> Result, UseCaseError> { + _from_station_group_id: u32, + _to_station_group_id: u32, + ) -> Result, UseCaseError> { Ok(vec![]) } diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index 5c79052e..1aaeeec9 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -1,4 +1,17 @@ -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +const CONNECTED_ROUTE_MAX_SEGMENTS: usize = 8; +const CONNECTED_ROUTE_MAX_STATES: usize = 4096; +const CONNECTED_ROUTE_MAX_CANDIDATES: usize = 65_536; +const CONNECTED_ROUTE_MAX_RESULTS: usize = 32; + +#[derive(Clone)] +struct ConnectedRouteState { + current_group_id: u32, + line_group_ids: Vec, + visited_station_groups: HashSet, + stops: Vec, +} /// Maximum distance in meters to search for nearby bus stops from a rail station const NEARBY_BUS_STOP_RADIUS_METERS: f64 = 300.0; @@ -1173,13 +1186,225 @@ where Ok(lines) } - // TODO: 未実装 - async fn get_connected_stations( + async fn get_connected_routes( &self, - _from_station_id: u32, - _to_station_id: u32, - ) -> Result, UseCaseError> { - Ok(vec![]) + from_station_group_id: u32, + to_station_group_id: u32, + ) -> Result, UseCaseError> { + if from_station_group_id == to_station_group_id { + return Ok(vec![]); + } + + let mut states = vec![ConnectedRouteState { + current_group_id: from_station_group_id, + line_group_ids: vec![], + visited_station_groups: HashSet::from([from_station_group_id]), + stops: vec![], + }]; + let mut line_groups_by_station: HashMap> = HashMap::new(); + let mut stops_by_line_group: HashMap> = HashMap::new(); + let mut completed = Vec::new(); + let mut completed_signatures = HashSet::new(); + let mut expanded_states = 0usize; + let mut evaluated_candidates = 0usize; + + for _ in 0..CONNECTED_ROUTE_MAX_SEGMENTS { + if states.is_empty() + || completed.len() >= CONNECTED_ROUTE_MAX_RESULTS + || expanded_states >= CONNECTED_ROUTE_MAX_STATES + || evaluated_candidates >= CONNECTED_ROUTE_MAX_CANDIDATES + { + break; + } + + let missing_station_groups: Vec = states + .iter() + .map(|state| state.current_group_id) + .filter(|id| !line_groups_by_station.contains_key(id)) + .collect::>() + .into_iter() + .collect(); + let discovered = self + .train_type_repository + .get_line_group_ids_by_station_group_ids(&missing_station_groups) + .await?; + for station_group_id in missing_station_groups { + line_groups_by_station.insert( + station_group_id, + discovered + .get(&station_group_id) + .cloned() + .unwrap_or_default(), + ); + } + + let missing_line_groups: Vec = states + .iter() + .flat_map(|state| { + line_groups_by_station + .get(&state.current_group_id) + .into_iter() + .flatten() + }) + .copied() + .filter(|id| !stops_by_line_group.contains_key(id)) + .collect::>() + .into_iter() + .collect(); + let fetched_stops = self + .station_repository + .get_by_line_group_id_vec(&missing_line_groups) + .await?; + for line_group_id in &missing_line_groups { + stops_by_line_group.insert(*line_group_id, vec![]); + } + for stop in fetched_stops { + if let Some(line_group_id) = stop.line_group_cd.map(|id| id as u32) { + stops_by_line_group + .entry(line_group_id) + .or_default() + .push(stop); + } + } + + let mut next_states = Vec::new(); + 'expand_states: for state in states { + if expanded_states >= CONNECTED_ROUTE_MAX_STATES { + break; + } + expanded_states += 1; + let Some(available_line_groups) = + line_groups_by_station.get(&state.current_group_id) + else { + continue; + }; + + for &line_group_id in available_line_groups { + if state.line_group_ids.contains(&line_group_id) { + continue; + } + let Some(pattern) = stops_by_line_group.get(&line_group_id) else { + continue; + }; + let start_indices: Vec = pattern + .iter() + .enumerate() + .filter(|(_, stop)| { + stop.station_g_cd as u32 == state.current_group_id + && stop.pass != Some(1) + }) + .map(|(index, _)| index) + .collect(); + + for start_index in start_indices { + for end_index in 0..pattern.len() { + if evaluated_candidates >= CONNECTED_ROUTE_MAX_CANDIDATES { + break 'expand_states; + } + evaluated_candidates += 1; + let destination = &pattern[end_index]; + let destination_group_id = destination.station_g_cd as u32; + if end_index == start_index + || destination.pass == Some(1) + || state.visited_station_groups.contains(&destination_group_id) + { + continue; + } + + let segment: Vec = if start_index < end_index { + pattern[start_index..=end_index].to_vec() + } else { + pattern[end_index..=start_index] + .iter() + .rev() + .cloned() + .collect() + }; + if segment.iter().skip(1).any(|stop| { + state + .visited_station_groups + .contains(&(stop.station_g_cd as u32)) + }) { + continue; + } + let mut visited_station_groups = state.visited_station_groups.clone(); + visited_station_groups.extend( + segment.iter().skip(1).map(|stop| stop.station_g_cd as u32), + ); + let mut stops = state.stops.clone(); + stops.extend(segment.into_iter().skip(usize::from(!stops.is_empty()))); + let mut line_group_ids = state.line_group_ids.clone(); + line_group_ids.push(line_group_id); + + let candidate = ConnectedRouteState { + current_group_id: destination_group_id, + line_group_ids, + visited_station_groups, + stops, + }; + if destination_group_id == to_station_group_id { + let signature = connected_route_signature(&candidate); + if completed_signatures.insert(signature) { + completed.push(candidate); + } + if completed.len() >= CONNECTED_ROUTE_MAX_RESULTS { + break 'expand_states; + } + } else if next_states.len() + expanded_states + < CONNECTED_ROUTE_MAX_STATES + { + next_states.push(candidate); + } + } + } + } + } + states = next_states; + } + + let mut used_virtual_ids = HashSet::new(); + let mut routes = Vec::new(); + for candidate in completed { + let signature = connected_route_signature(&candidate); + let virtual_line_group_id = + connected_route_virtual_id(&signature, &mut used_virtual_ids); + let stops = candidate + .stops + .into_iter() + .map(|row| { + let extracted_line = self.extract_line_from_station(&row); + let train_type = TrainType { + id: row.type_id, + station_cd: Some(row.station_cd), + type_cd: row.type_cd, + line_group_cd: Some(virtual_line_group_id as i32), + pass: row.pass, + type_name: row.type_name.clone().unwrap_or_default(), + type_name_k: row.type_name_k.clone().unwrap_or_default(), + type_name_r: row.type_name_r.clone(), + type_name_zh: row.type_name_zh.clone(), + type_name_ko: row.type_name_ko.clone(), + color: row.color.clone().unwrap_or_default(), + direction: row.direction, + kind: row.kind, + line: Some(Box::new(extracted_line.clone())), + lines: vec![extracted_line.clone()], + }; + let mut stop = self.build_station_from_row( + &row, + &extracted_line, + Some(Box::new(train_type)), + ); + stop.line_group_cd = Some(virtual_line_group_id as i32); + proto::Station::from(stop) + }) + .collect(); + routes.push(Route { + id: virtual_line_group_id, + stops, + }); + } + Ok(routes) } /// `from_station_id` から `to_station_id` までの区間の各駅について、始点からの @@ -1698,6 +1923,40 @@ where } } +fn connected_route_signature(route: &ConnectedRouteState) -> Vec { + let mut signature = Vec::with_capacity( + (route.line_group_ids.len() + route.stops.len() + 2) * std::mem::size_of::(), + ); + signature.extend_from_slice(&(route.line_group_ids.len() as u32).to_le_bytes()); + for line_group_id in &route.line_group_ids { + signature.extend_from_slice(&line_group_id.to_le_bytes()); + } + signature.extend_from_slice(&(route.stops.len() as u32).to_le_bytes()); + for stop in &route.stops { + signature.extend_from_slice(&(stop.station_g_cd as u32).to_le_bytes()); + } + signature +} + +fn connected_route_virtual_id(signature: &[u8], used_ids: &mut HashSet) -> u32 { + const FNV_OFFSET_BASIS: u32 = 2_166_136_261; + const FNV_PRIME: u32 = 16_777_619; + + let mut hash = FNV_OFFSET_BASIS; + for byte in signature { + hash ^= u32::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + + // Persisted line_group_cd is a signed PostgreSQL integer. Reserving the + // upper half of u32 therefore guarantees that virtual IDs cannot overlap it. + let mut candidate = hash | 0x8000_0000; + while !used_ids.insert(candidate) { + candidate = candidate.wrapping_add(1) | 0x8000_0000; + } + candidate +} + /// Build a signature describing the stations a train type actually stops at within the /// requested from→to segment. /// @@ -3548,6 +3807,7 @@ mod tests { struct ConfigurableMockTrainTypeRepository { train_types: Vec, expected_line_group_id: Option, + connection_line_groups: HashMap>, } impl ConfigurableMockTrainTypeRepository { @@ -3555,6 +3815,7 @@ mod tests { Self { train_types, expected_line_group_id: None, + connection_line_groups: HashMap::new(), } } @@ -3562,6 +3823,14 @@ mod tests { self.expected_line_group_id = line_group_id; self } + + fn with_connection_line_groups( + mut self, + connection_line_groups: HashMap>, + ) -> Self { + self.connection_line_groups = connection_line_groups; + self + } } /// Check if a TrainType matches the given (line_group_id, line_id) pair. @@ -3579,6 +3848,20 @@ mod tests { #[async_trait::async_trait] impl TrainTypeRepository for ConfigurableMockTrainTypeRepository { + async fn get_line_group_ids_by_station_group_ids( + &self, + station_group_ids: &[u32], + ) -> Result>, DomainError> { + Ok(station_group_ids + .iter() + .filter_map(|id| { + self.connection_line_groups + .get(id) + .cloned() + .map(|groups| (*id, groups)) + }) + .collect()) + } async fn find_by_line_group_id_and_line_id( &self, line_group_id: u32, @@ -4564,6 +4847,136 @@ mod tests { assert!(result.is_empty()); } + + fn create_connected_stop( + station_cd: i32, + station_group_id: i32, + line_group_id: i32, + pass: i32, + ) -> Station { + let mut station = create_test_station( + station_cd, + station_group_id, + line_group_id, + Some(line_group_id), + ); + station.type_id = Some(line_group_id); + station.type_cd = Some(line_group_id); + station.type_name = Some(format!("種別{line_group_id}")); + station.type_name_k = Some(format!("シュベツ{line_group_id}")); + station.pass = Some(pass); + station.stop_condition = if pass == 1 { + StopCondition::Not + } else { + StopCondition::All + }; + station + } + + fn create_connected_route_interactor() -> QueryInteractor< + ConfigurableMockStationRepository, + ConfigurableMockLineRepository, + ConfigurableMockTrainTypeRepository, + ConfigurableMockCompanyRepository, + > { + let stops = vec![ + create_connected_stop(101, 1, 100, 0), + create_connected_stop(109, 9, 100, 1), + create_connected_stop(102, 2, 100, 0), + create_connected_stop(202, 2, 200, 0), + create_connected_stop(203, 3, 200, 0), + create_connected_stop(303, 3, 300, 0), + create_connected_stop(304, 4, 300, 0), + // This group closes a cycle back to the origin. The search must + // reject it because station group 1 was already visited. + create_connected_stop(402, 2, 400, 0), + create_connected_stop(401, 1, 400, 0), + // A direct candidate verifies that one-segment routes remain valid. + create_connected_stop(501, 1, 500, 0), + create_connected_stop(504, 4, 500, 0), + ]; + let connection_line_groups = HashMap::from([ + (1, vec![100, 400, 500]), + (2, vec![100, 200, 400]), + (3, vec![200, 300]), + (4, vec![300, 500]), + ]); + + QueryInteractor { + station_repository: ConfigurableMockStationRepository::new(vec![], vec![]) + .with_line_group_stations(stops), + line_repository: ConfigurableMockLineRepository::new(vec![]), + train_type_repository: ConfigurableMockTrainTypeRepository::new(vec![]) + .with_connection_line_groups(connection_line_groups), + company_repository: ConfigurableMockCompanyRepository::new(vec![]), + } + } + + #[tokio::test] + async fn test_get_connected_routes_returns_direct_and_three_segment_routes() { + let interactor = create_connected_route_interactor(); + + let routes = interactor.get_connected_routes(1, 4).await.unwrap(); + + assert!(routes.iter().any(|route| route.stops.len() == 2)); + let connected = routes + .iter() + .find(|route| { + route + .stops + .iter() + .map(|stop| stop.group_id) + .collect::>() + == vec![1, 9, 2, 3, 4] + }) + .expect("three-segment route should be returned"); + assert_eq!( + connected + .stops + .iter() + .filter(|stop| stop.group_id == 2 || stop.group_id == 3) + .count(), + 2, + "connection stations must not be duplicated" + ); + assert_eq!(connected.stops[1].stop_condition, StopCondition::Not as i32); + assert!(connected.id >= 0x8000_0000); + assert!(connected.stops.iter().all(|stop| { + stop.train_type + .as_ref() + .is_some_and(|train_type| train_type.group_id == connected.id) + })); + } + + #[tokio::test] + async fn test_get_connected_routes_is_deterministic_and_handles_cycles_and_no_route() { + let interactor = create_connected_route_interactor(); + + let first = interactor.get_connected_routes(1, 4).await.unwrap(); + let second = interactor.get_connected_routes(1, 4).await.unwrap(); + assert_eq!( + first.iter().map(|route| route.id).collect::>(), + second.iter().map(|route| route.id).collect::>() + ); + assert_eq!( + first.len(), + first + .iter() + .map(|route| route.id) + .collect::>() + .len() + ); + for route in &first { + assert_eq!( + route.stops.iter().filter(|stop| stop.group_id == 1).count(), + 1, + "origin station group must appear once per route" + ); + } + + let unreachable = interactor.get_connected_routes(1, 99).await.unwrap(); + assert!(unreachable.is_empty()); + } } // ======================================== diff --git a/stationapi/src/use_case/traits/query.rs b/stationapi/src/use_case/traits/query.rs index 90047a88..e4f446c2 100644 --- a/stationapi/src/use_case/traits/query.rs +++ b/stationapi/src/use_case/traits/query.rs @@ -124,11 +124,11 @@ pub trait QueryUseCase: Send + Sync + 'static { line_name: String, limit: Option, ) -> Result, UseCaseError>; - async fn get_connected_stations( + async fn get_connected_routes( &self, - from_station_id: u32, - to_station_id: u32, - ) -> Result, UseCaseError>; + from_station_group_id: u32, + to_station_group_id: u32, + ) -> Result, UseCaseError>; async fn estimate_route_arrival_times( &self, from_station_id: u32, From 1a4acc6bce623230390ac4b5b61e16db77a6664d Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Tue, 4 Aug 2026 13:53:43 +0900 Subject: [PATCH 3/7] =?UTF-8?q?GetConnectedRoutes=E3=81=AE=E3=83=A1?= =?UTF-8?q?=E3=83=A2=E3=83=AA=E8=B2=A0=E8=8D=B7=E3=82=92=E5=89=8A=E6=B8=9B?= =?UTF-8?q?=20(#1616)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- docs/architecture.md | 5 + .../domain/repository/station_repository.rs | 31 ++++ .../src/infrastructure/station_repository.rs | 54 ++++++- stationapi/src/use_case/interactor/query.rs | 133 +++++++++++++----- 5 files changed, 191 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eefc387c..0412c136 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ This guide explains how automation agents and human contributors should work wit - **Bus stop translations (readings & English)** – GTFS-JP `translations.txt` layouts differ per feed, so `load_gtfs_translations` resolves columns by header name (Seibu ships 6 columns without `record_sub_id`; Keio and the Tokyu community feeds ship 7) and indexes each `stop_name` translation under both keys it may use: `record_id` (== the stop_id, Seibu — with the "-NN" pole suffix also mapped to the parent stop_id) and `field_value` (== the Japanese stop_name, Keio / Tokyu community, where `record_id` is left empty). `import_gtfs_stops` then looks a stop's translation up by stop_id first, then by name. Keying only by `record_id` (the previous behavior) silently dropped every field_value-keyed feed, leaving `station_name_k` filled with the kanji stop_name and `station_name_r` empty. Readings arriving as half-width katakana (`ニシハチオウジ`, Keio / Tokyu community) are folded to full-width via `romaji::to_fullwidth_katakana()` before storage. - **Bus English-name fallback** – When a feed provides no English (`en`) translation for a stop — e.g. Tokyu Bus ordinary-route JSON, which carries only `dc:title` and `odpt:kana` — `src/domain/romaji.rs::romaji_display_name()` derives a modified-Hepburn romanization (with macrons for long vowels, matching the curated rail style: Tōkyō / Kyōto / Shin-Ōsaka) from the kana reading, and `import.rs` fills `stop_name_r` with it. The fallback never overwrites a real `en` value, and a reading with no convertible kana stays `NULL` rather than emitting a partial transcription. Because `stop_name_r` is the single upstream source that fans out into the `stations` projection, `search_by_name`, and the romanized bus route/headsign names, this supplements every English-facing surface at once. When projecting into `stations`, `station_name_rn` is filled with the plain-ASCII spelling via `romaji::strip_macrons()` (Tōkyō → Tokyo), mirroring the rail dataset's `_r` (macron) / `_rn` (macron-free) column pair. - **TTS metadata** – `Station`, `StationMinimal`, `Line`, and `TrainType` expose `name_ipa` / `name_roman_ipa` plus `name_tts_segments` for multi-segment pronunciation output. Use `name_tts_segments` when clients need per-token SSML construction for mixed-language names such as `Kasai-Rinkai Park`. -- **Connected routes** – `GetConnectedRoutes` performs a bounded breadth-first search across train-type line groups. Transfers join at a shared station group, route order and per-stop pass metadata are preserved, and each returned candidate receives a deterministic virtual line-group ID in the upper half of the `uint32` range. Revisiting station groups and already-used train types is rejected to prevent cycles. The search is additionally capped at eight train types, 4,096 expanded states, 65,536 evaluated candidates, and 32 results to bound computation and result size. +- **Connected routes** – `GetConnectedRoutes` performs a bounded breadth-first search across train-type line groups. Transfers join at a shared station group, route order and per-stop pass metadata are preserved, and each returned candidate receives a deterministic virtual line-group ID in the upper half of the `uint32` range. Revisiting station groups and already-used train types is rejected to prevent cycles. Exploration loads only line-group ID, station-station-type ID, station-group ID, and pass metadata; full station rows are fetched after the result set is fixed. The search is additionally capped at eight train types, 4,096 expanded states, 65,536 evaluated candidates, and 32 results to bound computation and result size. - Changes to the service contract require coordinated updates to `proto/stationapi.proto`, regenerated code via `tonic-build`, and corresponding adjustments in both presentation and use-case layers. ## Contribution Guidelines diff --git a/docs/architecture.md b/docs/architecture.md index 0c36a180..1d39f4c9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -211,6 +211,11 @@ PostgreSQL `INTEGER` ID と衝突しない `uint32` 上位半分を使用しま 各探索階層では未取得の駅グループを 1 回のクエリへまとめ、そこで判明した未取得の `line_group_cd` の駅列も 1 回で取得します。このため Repository 呼び出しは最大でも 階層あたり 2 回で、候補経路ごとの N+1 クエリや同じ列車種別の再取得はありません。 +探索用の駅列は `line_group_cd`、`station_station_types.id`、`station_g_cd`、`pass` だけを取得し、経路状態にも +この軽量な参照だけを保持します。駅名、住所、座標、路線属性、列車種別属性を含む完全な +`Station` は探索中に生成・複製せず、返却候補が確定した後、その候補が実際に使用する +`line_group_cd` に限定して一括取得します。これにより探索状態数と駅エンティティの大きさの +積に比例していたメモリ使用量を避けます。 取得後は駅グループと `line_group_cd` を `HashMap` に一度だけ分類し、状態と駅列を 毎回総当たりする O(n×m) の処理を、入力件数に比例する O(n+m) の参照へ置き換えます。 探索そのものの最悪計算量は各状態の始点候補数と駅列長の積にも依存するため、状態数とは diff --git a/stationapi/src/domain/repository/station_repository.rs b/stationapi/src/domain/repository/station_repository.rs index f9dfee05..83d10a82 100644 --- a/stationapi/src/domain/repository/station_repository.rs +++ b/stationapi/src/domain/repository/station_repository.rs @@ -5,6 +5,14 @@ use crate::domain::{ error::DomainError, }; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConnectedRoutePatternStop { + pub line_group_id: u32, + pub station_station_type_id: i32, + pub station_group_id: u32, + pub pass: Option, +} + #[async_trait] pub trait StationRepository: Send + Sync + 'static { async fn find_by_id(&self, id: u32) -> Result, DomainError>; @@ -51,6 +59,29 @@ pub trait StationRepository: Send + Sync + 'static { &self, line_group_ids: &[u32], ) -> Result, DomainError>; + /// Fetch only the fields needed while exploring connected routes. + /// + /// The default keeps lightweight test repositories source-compatible. + /// Production repositories should override this to avoid materializing full + /// `Station` entities for every explored line group. + async fn get_connected_route_pattern_stops( + &self, + line_group_ids: &[u32], + ) -> Result, DomainError> { + Ok(self + .get_by_line_group_id_vec(line_group_ids) + .await? + .into_iter() + .filter_map(|stop| { + Some(ConnectedRoutePatternStop { + line_group_id: stop.line_group_cd? as u32, + station_station_type_id: stop.sst_id?, + station_group_id: stop.station_g_cd as u32, + pass: stop.pass, + }) + }) + .collect()) + } async fn get_bus_stops_near_stations( &self, coords: &[(u32, f64, f64)], // (station_g_cd, lat, lon) diff --git a/stationapi/src/infrastructure/station_repository.rs b/stationapi/src/infrastructure/station_repository.rs index 99868af0..56d3bef1 100644 --- a/stationapi/src/infrastructure/station_repository.rs +++ b/stationapi/src/infrastructure/station_repository.rs @@ -7,7 +7,7 @@ use crate::{ entity::{gtfs::TransportType, station::Station}, error::DomainError, normalize::normalize_for_search, - repository::station_repository::StationRepository, + repository::station_repository::{ConnectedRoutePatternStop, StationRepository}, }, proto::StopCondition, }; @@ -17,6 +17,14 @@ struct TrainTypesCountRow { train_types_count: Option, } +#[derive(sqlx::FromRow)] +struct ConnectedRoutePatternStopRow { + line_group_cd: i32, + station_station_type_id: i32, + station_g_cd: i32, + pass: Option, +} + #[derive(sqlx::FromRow, Clone)] struct StationRow { pub station_cd: i32, @@ -430,6 +438,15 @@ impl StationRepository for MyStationRepository { InternalStationRepository::get_by_line_group_id_vec(line_group_ids, &mut conn).await } + async fn get_connected_route_pattern_stops( + &self, + line_group_ids: &[u32], + ) -> Result, DomainError> { + let mut conn = self.pool.acquire().await?; + InternalStationRepository::get_connected_route_pattern_stops(line_group_ids, &mut conn) + .await + } + async fn get_bus_stops_near_stations( &self, coords: &[(u32, f64, f64)], @@ -481,6 +498,41 @@ impl StationRepository for MyStationRepository { struct InternalStationRepository {} impl InternalStationRepository { + async fn get_connected_route_pattern_stops( + line_group_ids: &[u32], + conn: &mut PgConnection, + ) -> Result, DomainError> { + if line_group_ids.is_empty() { + return Ok(vec![]); + } + + let line_group_ids: Vec = line_group_ids.iter().map(|id| *id as i32).collect(); + let rows = sqlx::query_as::<_, ConnectedRoutePatternStopRow>( + r#"SELECT sst.line_group_cd, sst.id AS station_station_type_id, + s.station_g_cd, sst.pass + FROM station_station_types AS sst + JOIN stations AS s ON s.station_cd = sst.station_cd + JOIN lines AS l ON l.line_cd = s.line_cd + WHERE sst.line_group_cd = ANY($1) + AND s.e_status = 0 + AND l.e_status = 0 + ORDER BY array_position($1, sst.line_group_cd), sst.id"#, + ) + .bind(&line_group_ids) + .fetch_all(conn) + .await?; + + Ok(rows + .into_iter() + .map(|row| ConnectedRoutePatternStop { + line_group_id: row.line_group_cd as u32, + station_station_type_id: row.station_station_type_id, + station_group_id: row.station_g_cd as u32, + pass: row.pass, + }) + .collect()) + } + async fn fetch_has_local_train_types_by_station_id( id: u32, conn: &mut PgConnection, diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index 1aaeeec9..603b4aaa 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -10,7 +10,14 @@ struct ConnectedRouteState { current_group_id: u32, line_group_ids: Vec, visited_station_groups: HashSet, - stops: Vec, + stops: Vec, +} + +#[derive(Clone, Copy)] +struct ConnectedRouteStopRef { + line_group_id: u32, + station_station_type_id: i32, + station_group_id: u32, } /// Maximum distance in meters to search for nearby bus stops from a rail station @@ -51,8 +58,10 @@ use crate::{ }, normalize::normalize_for_search, repository::{ - company_repository::CompanyRepository, line_repository::LineRepository, - station_repository::StationRepository, train_type_repository::TrainTypeRepository, + company_repository::CompanyRepository, + line_repository::LineRepository, + station_repository::{ConnectedRoutePatternStop, StationRepository}, + train_type_repository::TrainTypeRepository, }, segment_speed_table::{segment_override_applies_to_kind, segment_speed_override_kmh}, }, @@ -1202,7 +1211,7 @@ where stops: vec![], }]; let mut line_groups_by_station: HashMap> = HashMap::new(); - let mut stops_by_line_group: HashMap> = HashMap::new(); + let mut stops_by_line_group: HashMap> = HashMap::new(); let mut completed = Vec::new(); let mut completed_signatures = HashSet::new(); let mut expanded_states = 0usize; @@ -1253,18 +1262,16 @@ where .collect(); let fetched_stops = self .station_repository - .get_by_line_group_id_vec(&missing_line_groups) + .get_connected_route_pattern_stops(&missing_line_groups) .await?; for line_group_id in &missing_line_groups { stops_by_line_group.insert(*line_group_id, vec![]); } for stop in fetched_stops { - if let Some(line_group_id) = stop.line_group_cd.map(|id| id as u32) { - stops_by_line_group - .entry(line_group_id) - .or_default() - .push(stop); - } + stops_by_line_group + .entry(stop.line_group_id) + .or_default() + .push(stop); } let mut next_states = Vec::new(); @@ -1290,8 +1297,7 @@ where .iter() .enumerate() .filter(|(_, stop)| { - stop.station_g_cd as u32 == state.current_group_id - && stop.pass != Some(1) + stop.station_group_id == state.current_group_id && stop.pass != Some(1) }) .map(|(index, _)| index) .collect(); @@ -1303,7 +1309,7 @@ where } evaluated_candidates += 1; let destination = &pattern[end_index]; - let destination_group_id = destination.station_g_cd as u32; + let destination_group_id = destination.station_group_id; if end_index == start_index || destination.pass == Some(1) || state.visited_station_groups.contains(&destination_group_id) @@ -1311,28 +1317,56 @@ where continue; } - let segment: Vec = if start_index < end_index { - pattern[start_index..=end_index].to_vec() + let intersects_visited = if start_index < end_index { + pattern[start_index + 1..=end_index].iter().any(|stop| { + state + .visited_station_groups + .contains(&stop.station_group_id) + }) } else { - pattern[end_index..=start_index] - .iter() - .rev() - .cloned() - .collect() + pattern[end_index..start_index].iter().any(|stop| { + state + .visited_station_groups + .contains(&stop.station_group_id) + }) }; - if segment.iter().skip(1).any(|stop| { - state - .visited_station_groups - .contains(&(stop.station_g_cd as u32)) - }) { + if intersects_visited { continue; } let mut visited_station_groups = state.visited_station_groups.clone(); - visited_station_groups.extend( - segment.iter().skip(1).map(|stop| stop.station_g_cd as u32), - ); let mut stops = state.stops.clone(); - stops.extend(segment.into_iter().skip(usize::from(!stops.is_empty()))); + let append_stop = + |stops: &mut Vec, pattern_index: usize| { + stops.push(ConnectedRouteStopRef { + line_group_id, + station_station_type_id: pattern[pattern_index] + .station_station_type_id, + station_group_id: pattern[pattern_index].station_group_id, + }); + }; + if start_index < end_index { + visited_station_groups.extend( + pattern[start_index + 1..=end_index] + .iter() + .map(|stop| stop.station_group_id), + ); + let append_from = start_index + usize::from(!stops.is_empty()); + for pattern_index in append_from..=end_index { + append_stop(&mut stops, pattern_index); + } + } else { + visited_station_groups.extend( + pattern[end_index..start_index] + .iter() + .map(|stop| stop.station_group_id), + ); + if stops.is_empty() { + append_stop(&mut stops, start_index); + } + for pattern_index in (end_index..start_index).rev() { + append_stop(&mut stops, pattern_index); + } + } let mut line_group_ids = state.line_group_ids.clone(); line_group_ids.push(line_group_id); @@ -1362,14 +1396,48 @@ where states = next_states; } + if completed.is_empty() { + return Ok(vec![]); + } + + let detailed_line_group_ids: Vec = completed + .iter() + .flat_map(|candidate| candidate.line_group_ids.iter().copied()) + .collect::>() + .into_iter() + .collect(); + let detailed_stops = self + .station_repository + .get_by_line_group_id_vec(&detailed_line_group_ids) + .await?; + let mut detailed_stops_by_id: HashMap<(u32, i32), Station> = HashMap::new(); + for stop in detailed_stops { + if let (Some(line_group_id), Some(station_station_type_id)) = + (stop.line_group_cd.map(|id| id as u32), stop.sst_id) + { + detailed_stops_by_id.insert((line_group_id, station_station_type_id), stop); + } + } + let mut used_virtual_ids = HashSet::new(); let mut routes = Vec::new(); for candidate in completed { let signature = connected_route_signature(&candidate); let virtual_line_group_id = connected_route_virtual_id(&signature, &mut used_virtual_ids); - let stops = candidate + let Some(detailed_stops): Option> = candidate .stops + .iter() + .map(|stop| { + detailed_stops_by_id + .get(&(stop.line_group_id, stop.station_station_type_id)) + .cloned() + }) + .collect() + else { + continue; + }; + let stops = detailed_stops .into_iter() .map(|row| { let extracted_line = self.extract_line_from_station(&row); @@ -1933,7 +2001,7 @@ fn connected_route_signature(route: &ConnectedRouteState) -> Vec { } signature.extend_from_slice(&(route.stops.len() as u32).to_le_bytes()); for stop in &route.stops { - signature.extend_from_slice(&(stop.station_g_cd as u32).to_le_bytes()); + signature.extend_from_slice(&stop.station_group_id.to_le_bytes()); } signature } @@ -4861,6 +4929,7 @@ mod tests { Some(line_group_id), ); station.type_id = Some(line_group_id); + station.sst_id = Some(station_cd); station.type_cd = Some(line_group_id); station.type_name = Some(format!("種別{line_group_id}")); station.type_name_k = Some(format!("シュベツ{line_group_id}")); From 65ed0052decf0c3fe88f69adb65f92268a46f84c Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Tue, 4 Aug 2026 22:14:05 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=E7=89=B9=E6=80=A5=E3=81=B2=E3=81=A0?= =?UTF-8?q?=E3=81=AE=E5=90=8D=E5=8F=A4=E5=B1=8B=E5=8F=A3=E5=81=9C=E8=BB=8A?= =?UTF-8?q?=E9=A7=85=E3=82=92=E4=BF=AE=E6=AD=A3=20(#1617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/5!station_station_types.csv | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/data/5!station_station_types.csv b/data/5!station_station_types.csv index 64f56d7f..e572d2bf 100644 --- a/data/5!station_station_types.csv +++ b/data/5!station_station_types.csv @@ -13854,12 +13854,11 @@ DEFAULT,1141604,225,348,1,蘇原 DEFAULT,1141603,225,348,1,那加 DEFAULT,1141602,225,348,1,長森 DEFAULT,1141601,225,348,0,岐阜 -DEFAULT,1150238,225,348,0,岐阜 -DEFAULT,1150237,225,348,1,木曽川 -DEFAULT,1150236,225,348,0,尾張一宮 -DEFAULT,1150235,225,348,1,稲沢 -DEFAULT,1150234,225,348,1,清洲 -DEFAULT,1141101,225,348,1,枇杷島 +DEFAULT,1150238,225,348,1,木曽川 +DEFAULT,1150237,225,348,2,尾張一宮 +DEFAULT,1150236,225,348,1,稲沢 +DEFAULT,1150235,225,348,1,清洲 +DEFAULT,1150234,225,348,1,枇杷島 DEFAULT,1150233,225,348,0,名古屋 DEFAULT,9940101,306,349,0,大月 DEFAULT,9940102,306,349,1,上大月 From e11c2089f94732ed33afa8226a7432f4709a41f0 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Tue, 4 Aug 2026 22:17:10 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=E5=88=97=E8=BB=8A=E7=A8=AE=E5=88=A5?= =?UTF-8?q?=E3=81=AA=E3=81=97=E3=81=AE=E7=B5=8C=E8=B7=AF=E5=88=86=E5=B2=90?= =?UTF-8?q?=E3=82=92=E5=89=8A=E9=99=A4=20(#1614)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 未設定の鉄道路線に各停種別を補完 * Rust 1.97のClippy警告を修正 * 列車種別なしの経路分岐を削除 --- .../src/infrastructure/station_repository.rs | 195 +++--------------- stationapi/src/use_case/interactor/query.rs | 90 +++----- 2 files changed, 60 insertions(+), 225 deletions(-) diff --git a/stationapi/src/infrastructure/station_repository.rs b/stationapi/src/infrastructure/station_repository.rs index 56d3bef1..e46809e1 100644 --- a/stationapi/src/infrastructure/station_repository.rs +++ b/stationapi/src/infrastructure/station_repository.rs @@ -12,11 +12,6 @@ use crate::{ proto::StopCondition, }; -#[derive(sqlx::FromRow)] -struct TrainTypesCountRow { - train_types_count: Option, -} - #[derive(sqlx::FromRow)] struct ConnectedRoutePatternStopRow { line_group_cd: i32, @@ -330,25 +325,13 @@ impl StationRepository for MyStationRepository { direction_id: Option, ) -> Result, DomainError> { let mut conn = self.pool.acquire().await?; - match station_id { - Some(station_id) => { - InternalStationRepository::get_by_line_id_and_station_id( - line_id, - station_id, - direction_id, - &mut conn, - ) - .await - } - None => { - InternalStationRepository::get_by_line_id_without_train_types( - line_id, - direction_id, - &mut conn, - ) - .await - } - } + InternalStationRepository::get_by_line_id_with_train_type( + line_id, + station_id, + direction_id, + &mut conn, + ) + .await } async fn get_by_line_id_vec(&self, line_ids: &[u32]) -> Result, DomainError> { let mut conn = self.pool.acquire().await?; @@ -532,29 +515,6 @@ impl InternalStationRepository { }) .collect()) } - - async fn fetch_has_local_train_types_by_station_id( - id: u32, - conn: &mut PgConnection, - ) -> Result { - let row: TrainTypesCountRow = sqlx::query_as!( - TrainTypesCountRow, - "SELECT COUNT(sst.line_group_cd)::integer AS train_types_count - FROM station_station_types AS sst - JOIN types AS t ON t.type_cd = sst.type_cd - WHERE sst.station_cd = $1 - AND ( - t.kind IN (0, 1) - OR t.priority > 0 - )", - id as i32, - ) - .fetch_one(conn) - .await?; - - Ok(row.train_types_count.unwrap_or(0) > 0) - } - async fn find_by_id(id: u32, conn: &mut PgConnection) -> Result, DomainError> { let rows: Option = sqlx::query_as!( StationRow, @@ -759,99 +719,6 @@ impl InternalStationRepository { 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_line_id_vec( line_ids: &[u32], conn: &mut PgConnection, @@ -1054,31 +921,27 @@ impl InternalStationRepository { Ok(stations) } - async fn get_by_line_id_and_station_id( + async fn get_by_line_id_with_train_type( line_id: u32, - station_id: u32, + station_id: Option, direction_id: Option, conn: &mut PgConnection, ) -> Result, DomainError> { - let stations: Vec = match Self::fetch_has_local_train_types_by_station_id( - station_id, conn, - ) - .await? - { - true => { - // When direction_id = 1 (上り) or 2 (下り), reverse the order - let order_clause = if matches!(direction_id, Some(1) | Some(2)) { - "ORDER BY sst.id DESC" - } else { - "ORDER BY sst.id ASC" - }; - - let query_str = format!( - r#"WITH target_line_group AS ( + // When direction_id = 1 (上り) or 2 (下り), reverse the order + let order_clause = if matches!(direction_id, Some(1) | Some(2)) { + "ORDER BY sst.id DESC" + } else { + "ORDER BY sst.id ASC" + }; + + let query_str = format!( + r#"WITH target_line_group AS ( SELECT sst_inner.line_group_cd FROM station_station_types AS sst_inner LEFT JOIN types AS t_inner ON sst_inner.type_cd = t_inner.type_cd - WHERE sst_inner.station_cd = $1 + JOIN stations AS seed_station ON seed_station.station_cd = sst_inner.station_cd + WHERE seed_station.line_cd = $1 + AND ($2::int IS NULL OR sst_inner.station_cd = $2) AND ( (t_inner.priority > 0 AND sst_inner.pass <> 1 AND sst_inner.type_cd = t_inner.type_cd) OR (NOT (t_inner.priority > 0 AND sst_inner.pass <> 1) AND t_inner.kind IN (0,1)) @@ -1154,16 +1017,14 @@ impl InternalStationRepository { WHERE s.e_status = 0 AND l.e_status = 0 {order_clause}"# - ); + ); - let rows = sqlx::query_as::<_, StationRow>(&query_str) - .bind(station_id as i32) - .fetch_all(conn) - .await?; - rows.into_iter().map(|row| row.into()).collect() - } - false => Self::get_by_line_id_without_train_types(line_id, direction_id, conn).await?, - }; + let rows = sqlx::query_as::<_, StationRow>(&query_str) + .bind(line_id as i32) + .bind(station_id.map(|id| id as i32)) + .fetch_all(conn) + .await?; + let stations = rows.into_iter().map(|row| row.into()).collect(); Ok(stations) } diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index 603b4aaa..88760099 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -1059,31 +1059,13 @@ where to_station_id: u32, line_group_id: Option, ) -> Result, UseCaseError> { - // line_group_id 未指定は種別なし(各駅停車)の単一路線走行。 - // from駅の所属路線の駅列をそのまま経路として扱う。 - let stations = match line_group_id { - Some(line_group_id) => { - self.get_stations_by_line_group_id(line_group_id, TransportTypeFilter::RailAndBus) - .await? - } - None => { - let from_station = self - .station_repository - .find_by_id(from_station_id) - .await? - .ok_or_else(|| UseCaseError::NotFound { - entity_type: "station", - entity_id: from_station_id.to_string(), - })?; - self.get_stations_by_line_id( - from_station.line_cd as u32, - None, - None, - TransportTypeFilter::RailAndBus, - ) - .await? - } - }; + let line_group_id = line_group_id.ok_or_else(|| UseCaseError::NotFound { + entity_type: "line group", + entity_id: "unspecified".to_string(), + })?; + let stations = self + .get_stations_by_line_group_id(line_group_id, TransportTypeFilter::RailAndBus) + .await?; let from_idx = stations .iter() @@ -1902,17 +1884,22 @@ where } fn build_route_tree_map<'a>(&self, stops: &'a [Station]) -> BTreeMap> { - stops.iter().fold( - BTreeMap::new(), - |mut acc: BTreeMap>, value| { - if let Some(line_group_cd) = value.line_group_cd { - acc.entry(line_group_cd).or_default().push(value); - } else { - acc.entry(value.line_cd).or_default().push(value); - }; - acc - }, - ) + stops + .iter() + .map(|stop| { + ( + stop.line_group_cd + .expect("route stop must belong to a train type group"), + stop, + ) + }) + .fold( + BTreeMap::new(), + |mut acc: BTreeMap>, (line_group_cd, stop)| { + acc.entry(line_group_cd).or_default().push(stop); + acc + }, + ) } fn build_station_from_row( @@ -3028,7 +3015,7 @@ mod tests { data.iter() .enumerate() .map(|(i, &(cd, lat, lon))| { - let mut s = create_test_station(cd, 9930100, 99301, None); + let mut s = create_test_station(cd, 9930100, 99301, Some(9930100)); s.lat = lat; s.lon = lon; s.e_sort = 9930101 + i as i32; @@ -3107,7 +3094,7 @@ mod tests { data.iter() .enumerate() .map(|(i, &(cd, lat, lon))| { - let mut s = create_test_station(cd, cd, 28008, None); + let mut s = create_test_station(cd, cd, 28008, Some(2800800)); s.lat = lat; s.lon = lon; s.e_sort = 2800801 + i as i32; @@ -3126,7 +3113,8 @@ mod tests { /// 修正前は種別倍率(×1.15)が掛かり駅間別較正も外れて約15分に縮んでいた。 #[tokio::test] async fn test_estimate_route_arrival_times_through_express_all_stops_matches_local() { - let local = build_interactor(hanzomon_stops(None), vec![], vec![], vec![]); + let default_kind = Some(proto::TrainTypeKind::Default as i32); + let local = build_interactor(hanzomon_stops(default_kind), vec![], vec![], vec![]); let local_est = local .estimate_route_arrival_times(2800813, 2800807, &[], None) .await @@ -3464,18 +3452,12 @@ mod tests { } #[test] - fn test_build_route_tree_map_groups_by_line_cd_when_no_line_group() { + #[should_panic(expected = "route stop must belong to a train type group")] + fn test_build_route_tree_map_requires_line_group() { let interactor = create_interactor(); - let stops = vec![ - create_test_station(1, 1, 100, None), - create_test_station(2, 2, 100, None), - create_test_station(3, 3, 200, None), - ]; - let result = interactor.build_route_tree_map(&stops); + let stops = vec![create_test_station(1, 1, 100, None)]; - assert_eq!(result.len(), 2); - assert_eq!(result.get(&100).unwrap().len(), 2); - assert_eq!(result.get(&200).unwrap().len(), 1); + interactor.build_route_tree_map(&stops); } #[test] @@ -5425,9 +5407,6 @@ mod tests { // line_group 300: 発着駅を含まない → 除外 create_route_stop(3105, 5, 33, Some(300)), create_route_stop(3106, 6, 33, Some(300)), - // line_group_cdなし: line_cd(44)でグループ化され種別なし - create_route_stop(4101, 1, 44, None), - create_route_stop(4103, 3, 44, None), ]; let lines = vec![ create_route_line(11, 100), @@ -5441,7 +5420,7 @@ mod tests { // 発着駅を含まないline_group 300は除外され、BTreeMapのキー順に並ぶ let route_ids: Vec = routes.iter().map(|r| r.id).collect(); - assert_eq!(route_ids, vec![44, 100, 200]); + assert_eq!(route_ids, vec![100, 200]); // 路線の取得は経路候補ごとではなく一括1回で、 // 除外されたグループ(300)のIDは要求されない @@ -5473,11 +5452,6 @@ mod tests { let line_ids: Vec = tt.lines.iter().map(|l| l.id).collect(); assert_eq!(line_ids, vec![22]); } - - // line_group_cdなしのグループは種別を持たない - let route44 = routes.iter().find(|r| r.id == 44).unwrap(); - assert_eq!(route44.stops.len(), 2); - assert!(route44.stops.iter().all(|s| s.train_type.is_none())); } #[tokio::test] From afd5a2f4339b38bccceaf0ddc6eb3f6a9c666b53 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Wed, 5 Aug 2026 09:22:17 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=E7=89=B9=E6=80=A5=E3=81=B2=E3=81=A0?= =?UTF-8?q?=E3=81=AB=E6=9D=B1=E6=B5=B7=E9=81=93=E6=9C=AC=E7=B7=9A=E5=81=B4?= =?UTF-8?q?=E3=81=AE=E5=B2=90=E9=98=9C=E9=A7=85=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20(#1619)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/5!station_station_types.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/data/5!station_station_types.csv b/data/5!station_station_types.csv index e572d2bf..2330d4ae 100644 --- a/data/5!station_station_types.csv +++ b/data/5!station_station_types.csv @@ -13854,6 +13854,7 @@ DEFAULT,1141604,225,348,1,蘇原 DEFAULT,1141603,225,348,1,那加 DEFAULT,1141602,225,348,1,長森 DEFAULT,1141601,225,348,0,岐阜 +DEFAULT,1150239,225,348,0,岐阜 DEFAULT,1150238,225,348,1,木曽川 DEFAULT,1150237,225,348,2,尾張一宮 DEFAULT,1150236,225,348,1,稲沢 From 5c82996e3ed5c295cdf812ebbdac21f790228680 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Wed, 5 Aug 2026 09:29:49 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=E5=88=97=E8=BB=8A=E7=A8=AE=E5=88=A5?= =?UTF-8?q?=E3=83=BBGit-flow=E3=83=BBPR=E4=BD=9C=E6=88=90=E3=81=AE?= =?UTF-8?q?=E9=81=8B=E7=94=A8=E3=83=AB=E3=83=BC=E3=83=AB=E3=82=92=E6=98=8E?= =?UTF-8?q?=E8=A8=98=20(#1620)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 直通列車の接続駅登録ルールを明記 * Git-flowの運用ルールを明記 * PR作成ルールを明記 --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 0412c136..e386cd4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ This guide explains how automation agents and human contributors should work wit ## Data Management - CSV import order depends on the numeric prefix (`1!`, `2!`, ...). When adding datasets, choose a prefix that preserves foreign-key dependencies. - `data/create_table.sql` drops and recreates tables, indexes, and foreign keys. Update this script alongside any schema or CSV column changes. +- **Through-service junction stations** – When a train type runs through a station where its lines connect, add a `5!station_station_types.csv` row for every line-specific `station_cd` at that station, even when those rows share one `station_g_cd`. The only exception is when the train type explicitly identifies a direction or line-specific operation that excludes one side. Omitting either ID makes the train type selectable from only one line in the app. For example, Hida at Gifu must include both the Takayama Main Line station (`1141601`) and the Tokaido Main Line station (`1150239`). Audit both sides whenever adding or editing a through-service pattern. - `data_validator` currently verifies that `5!station_station_types.csv` references valid station and type IDs, and that order-sensitive station sequences in `3!stations.csv` stay intact under `ORDER BY e_sort, station_cd` (e.g. the Toei Oedo Line's Tochomae rows, whose misordering silently drops the station from ETA estimation). Extend the validator when new cross-references or order-sensitive spots are introduced and keep the process fail-fast (panic on invalid data). ## Testing and Quality @@ -63,6 +64,8 @@ This guide explains how automation agents and human contributors should work wit - Changes to the service contract require coordinated updates to `proto/stationapi.proto`, regenerated code via `tonic-build`, and corresponding adjustments in both presentation and use-case layers. ## Contribution Guidelines +- **Git-flow** – Follow Git-flow with `dev` serving as this repository's `develop` branch. Create ordinary work branches from the latest `origin/dev`, use the `feature/` naming convention, and target their pull requests to `dev`. Do not create or target a branch named `develop`. +- **Pull requests** – Assign every pull request to `@TinyKitten` when creating it, open it as ready for review rather than as a draft, and use `.github/pull_request_template.md` without omitting or replacing its sections or checklists. - **Prioritize quality and performance over implementation speed** – Always favor code quality and runtime performance over velocity. Be mindful of algorithmic complexity and look for opportunities to replace O(n×m) linear scans with O(n+m) indexed lookups (e.g., HashMaps). Avoid unnecessary JOINs and redundant queries at the SQL level. When a change affects performance, document the before/after complexity and query plan impact in the pull request. - Document the commands you executed (for example, ``cargo fmt && cargo clippy --all-targets --all-features && make test-unit``) and their outcomes in every pull request. - For database, gRPC, or schema updates, add architectural notes under `docs/` and synchronize README references so onboarding materials stay accurate.