Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` キーワードを使用
Expand Down
2 changes: 0 additions & 2 deletions docs/technical_debt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/` | テストカバレッジ |
11 changes: 11 additions & 0 deletions stationapi/src/domain/repository/train_type_repository.rs
Original file line number Diff line number Diff line change
@@ -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<HashMap<u32, Vec<u32>>, DomainError> {
Ok(HashMap::new())
}
async fn get_by_line_group_id(&self, line_group_id: u32)
-> Result<Vec<TrainType>, DomainError>;
async fn get_by_station_id(&self, station_id: u32) -> Result<Vec<TrainType>, DomainError>;
Expand Down
113 changes: 112 additions & 1 deletion stationapi/src/infrastructure/train_type_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,6 +23,12 @@ pub struct TrainTypeRow {
kind: Option<i32>,
}

#[derive(sqlx::FromRow)]
struct ConnectionLineGroupRow {
station_g_cd: i32,
line_group_cd: i32,
}

impl From<TrainTypeRow> for TrainType {
fn from(row: TrainTypeRow) -> Self {
let TrainTypeRow {
Expand Down Expand Up @@ -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<HashMap<u32, Vec<u32>>, 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,
Expand Down Expand Up @@ -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<HashMap<u32, Vec<u32>>, DomainError> {
if station_group_ids.is_empty() {
return Ok(HashMap::new());
}

let station_group_ids: Vec<i32> = 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,
Expand Down Expand Up @@ -531,6 +589,10 @@ mod tests {
.execute(pool)
.await
.unwrap();
sqlx::query("DROP TABLE IF EXISTS stations CASCADE")
.execute(pool)
.await
.unwrap();

// テーブル作成
sqlx::query(
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -596,6 +682,10 @@ mod tests {
.execute(pool)
.await
.unwrap();
sqlx::query("DROP TABLE IF EXISTS stations CASCADE")
.execute(pool)
.await
.unwrap();
}

#[tokio::test]
Expand Down Expand Up @@ -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() {
Expand Down
21 changes: 9 additions & 12 deletions stationapi/src/presentation/controller/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Vec<Station>, UseCaseError> {
_from_station_group_id: u32,
_to_station_group_id: u32,
) -> Result<Vec<Route>, UseCaseError> {
Ok(vec![])
}

Expand Down
Loading
Loading