Skip to content

Commit 2ea62c1

Browse files
graylikemeclaude
andcommitted
feat: link mech data to construction reference tables via FK resolution
Add FK columns to unit_mech_data for all 7 component types, with alias mapping tables to resolve MegaMek free-text values. DataLoaders prevent N+1 queries when resolving component types in GraphQL. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d0e408c commit 2ea62c1

File tree

7 files changed

+571
-25
lines changed

7 files changed

+571
-25
lines changed

crates/api/src/db/models.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,13 @@ pub struct DbMechData {
165165
pub gyro_type: Option<String>,
166166
pub cockpit_type: Option<String>,
167167
pub myomer_type: Option<String>,
168+
pub engine_type_id: Option<i32>,
169+
pub armor_type_id: Option<i32>,
170+
pub structure_type_id: Option<i32>,
171+
pub heatsink_type_id: Option<i32>,
172+
pub gyro_type_id: Option<i32>,
173+
pub cockpit_type_id: Option<i32>,
174+
pub myomer_type_id: Option<i32>,
168175
}
169176

170177
// ── Construction Reference ───────────────────────────────────────────────

crates/api/src/db/units.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,14 +244,15 @@ pub async fn get_mech_data_batch(
244244
pool: &PgPool,
245245
unit_ids: &[i32],
246246
) -> Result<Vec<DbMechData>, AppError> {
247-
let rows = sqlx::query_as!(
248-
DbMechData,
247+
let rows = sqlx::query_as::<_, DbMechData>(
249248
r#"SELECT unit_id, config, is_omnimech, engine_rating, engine_type,
250249
walk_mp, jump_mp, heat_sink_count, heat_sink_type,
251-
structure_type, armor_type, gyro_type, cockpit_type, myomer_type
250+
structure_type, armor_type, gyro_type, cockpit_type, myomer_type,
251+
engine_type_id, armor_type_id, structure_type_id, heatsink_type_id,
252+
gyro_type_id, cockpit_type_id, myomer_type_id
252253
FROM unit_mech_data WHERE unit_id = ANY($1)"#,
253-
unit_ids
254254
)
255+
.bind(unit_ids)
255256
.fetch_all(pool)
256257
.await?;
257258
Ok(rows)

crates/api/src/graphql/loaders.rs

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ use std::collections::HashMap;
33
use async_graphql::dataloader::Loader;
44

55
use crate::db::{
6-
models::{DbEquipment, DbMechData},
6+
models::{
7+
DbArmorType, DbCockpitType, DbEngineType, DbEquipment, DbGyroType, DbHeatsinkType,
8+
DbMechData, DbMyomerType, DbStructureType,
9+
},
710
units,
811
};
912

@@ -94,3 +97,159 @@ impl Loader<i32> for AmmoTypesLoader {
9497
Ok(map)
9598
}
9699
}
100+
101+
// ── Component Type Loaders (for MechData FK resolution) ──────────────────────
102+
103+
pub struct EngineTypeLoader {
104+
pub pool: sqlx::PgPool,
105+
}
106+
107+
impl Loader<i32> for EngineTypeLoader {
108+
type Value = DbEngineType;
109+
type Error = async_graphql::Error;
110+
111+
async fn load(&self, keys: &[i32]) -> Result<HashMap<i32, DbEngineType>, async_graphql::Error> {
112+
let rows = sqlx::query_as::<_, DbEngineType>(
113+
r#"SELECT id, slug, name, tech_base::text AS tech_base,
114+
rules_level::text AS rules_level,
115+
weight_multiplier, ct_crits, st_crits, intro_year
116+
FROM engine_types WHERE id = ANY($1)"#,
117+
)
118+
.bind(keys)
119+
.fetch_all(&self.pool)
120+
.await?;
121+
Ok(rows.into_iter().map(|r| (r.id, r)).collect())
122+
}
123+
}
124+
125+
pub struct ArmorTypeLoader {
126+
pub pool: sqlx::PgPool,
127+
}
128+
129+
impl Loader<i32> for ArmorTypeLoader {
130+
type Value = DbArmorType;
131+
type Error = async_graphql::Error;
132+
133+
async fn load(&self, keys: &[i32]) -> Result<HashMap<i32, DbArmorType>, async_graphql::Error> {
134+
let rows = sqlx::query_as::<_, DbArmorType>(
135+
r#"SELECT id, slug, name, tech_base::text AS tech_base,
136+
rules_level::text AS rules_level,
137+
points_per_ton, crits, intro_year
138+
FROM armor_types WHERE id = ANY($1)"#,
139+
)
140+
.bind(keys)
141+
.fetch_all(&self.pool)
142+
.await?;
143+
Ok(rows.into_iter().map(|r| (r.id, r)).collect())
144+
}
145+
}
146+
147+
pub struct StructureTypeLoader {
148+
pub pool: sqlx::PgPool,
149+
}
150+
151+
impl Loader<i32> for StructureTypeLoader {
152+
type Value = DbStructureType;
153+
type Error = async_graphql::Error;
154+
155+
async fn load(&self, keys: &[i32]) -> Result<HashMap<i32, DbStructureType>, async_graphql::Error> {
156+
let rows = sqlx::query_as::<_, DbStructureType>(
157+
r#"SELECT id, slug, name, tech_base::text AS tech_base,
158+
rules_level::text AS rules_level,
159+
weight_fraction, crits, intro_year
160+
FROM structure_types WHERE id = ANY($1)"#,
161+
)
162+
.bind(keys)
163+
.fetch_all(&self.pool)
164+
.await?;
165+
Ok(rows.into_iter().map(|r| (r.id, r)).collect())
166+
}
167+
}
168+
169+
pub struct HeatsinkTypeLoader {
170+
pub pool: sqlx::PgPool,
171+
}
172+
173+
impl Loader<i32> for HeatsinkTypeLoader {
174+
type Value = DbHeatsinkType;
175+
type Error = async_graphql::Error;
176+
177+
async fn load(&self, keys: &[i32]) -> Result<HashMap<i32, DbHeatsinkType>, async_graphql::Error> {
178+
let rows = sqlx::query_as::<_, DbHeatsinkType>(
179+
r#"SELECT id, slug, name, tech_base::text AS tech_base,
180+
rules_level::text AS rules_level,
181+
dissipation, crits, weight, intro_year
182+
FROM heatsink_types WHERE id = ANY($1)"#,
183+
)
184+
.bind(keys)
185+
.fetch_all(&self.pool)
186+
.await?;
187+
Ok(rows.into_iter().map(|r| (r.id, r)).collect())
188+
}
189+
}
190+
191+
pub struct GyroTypeLoader {
192+
pub pool: sqlx::PgPool,
193+
}
194+
195+
impl Loader<i32> for GyroTypeLoader {
196+
type Value = DbGyroType;
197+
type Error = async_graphql::Error;
198+
199+
async fn load(&self, keys: &[i32]) -> Result<HashMap<i32, DbGyroType>, async_graphql::Error> {
200+
let rows = sqlx::query_as::<_, DbGyroType>(
201+
r#"SELECT id, slug, name, tech_base::text AS tech_base,
202+
rules_level::text AS rules_level,
203+
weight_multiplier, crits, is_superheavy_only, intro_year
204+
FROM gyro_types WHERE id = ANY($1)"#,
205+
)
206+
.bind(keys)
207+
.fetch_all(&self.pool)
208+
.await?;
209+
Ok(rows.into_iter().map(|r| (r.id, r)).collect())
210+
}
211+
}
212+
213+
pub struct CockpitTypeLoader {
214+
pub pool: sqlx::PgPool,
215+
}
216+
217+
impl Loader<i32> for CockpitTypeLoader {
218+
type Value = DbCockpitType;
219+
type Error = async_graphql::Error;
220+
221+
async fn load(&self, keys: &[i32]) -> Result<HashMap<i32, DbCockpitType>, async_graphql::Error> {
222+
let rows = sqlx::query_as::<_, DbCockpitType>(
223+
r#"SELECT id, slug, name, tech_base::text AS tech_base,
224+
rules_level::text AS rules_level,
225+
weight, crits, intro_year
226+
FROM cockpit_types WHERE id = ANY($1)"#,
227+
)
228+
.bind(keys)
229+
.fetch_all(&self.pool)
230+
.await?;
231+
Ok(rows.into_iter().map(|r| (r.id, r)).collect())
232+
}
233+
}
234+
235+
pub struct MyomerTypeLoader {
236+
pub pool: sqlx::PgPool,
237+
}
238+
239+
impl Loader<i32> for MyomerTypeLoader {
240+
type Value = DbMyomerType;
241+
type Error = async_graphql::Error;
242+
243+
async fn load(&self, keys: &[i32]) -> Result<HashMap<i32, DbMyomerType>, async_graphql::Error> {
244+
let rows = sqlx::query_as::<_, DbMyomerType>(
245+
r#"SELECT id, slug, name, tech_base::text AS tech_base,
246+
rules_level::text AS rules_level,
247+
intro_year, properties
248+
FROM myomer_types WHERE id = ANY($1)"#,
249+
)
250+
.bind(keys)
251+
.fetch_all(&self.pool)
252+
.await?;
253+
Ok(rows.into_iter().map(|r| (r.id, r)).collect())
254+
}
255+
}

crates/api/src/graphql/schema.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ use async_graphql::{dataloader::DataLoader, EmptyMutation, EmptySubscription, Sc
22

33
use crate::{
44
graphql::{
5-
loaders::{AmmoForLoader, AmmoTypesLoader, MechDataLoader},
5+
loaders::{
6+
AmmoForLoader, AmmoTypesLoader, ArmorTypeLoader, CockpitTypeLoader, EngineTypeLoader,
7+
GyroTypeLoader, HeatsinkTypeLoader, MechDataLoader, MyomerTypeLoader,
8+
StructureTypeLoader,
9+
},
610
query::QueryRoot,
711
},
812
state::AppState,
@@ -15,12 +19,26 @@ pub fn build(state: AppState) -> AppSchema {
1519
let mech_loader = DataLoader::new(MechDataLoader { pool: pool.clone() }, tokio::spawn);
1620
let ammo_for_loader = DataLoader::new(AmmoForLoader { pool: pool.clone() }, tokio::spawn);
1721
let ammo_types_loader = DataLoader::new(AmmoTypesLoader { pool: pool.clone() }, tokio::spawn);
22+
let engine_type_loader = DataLoader::new(EngineTypeLoader { pool: pool.clone() }, tokio::spawn);
23+
let armor_type_loader = DataLoader::new(ArmorTypeLoader { pool: pool.clone() }, tokio::spawn);
24+
let structure_type_loader = DataLoader::new(StructureTypeLoader { pool: pool.clone() }, tokio::spawn);
25+
let heatsink_type_loader = DataLoader::new(HeatsinkTypeLoader { pool: pool.clone() }, tokio::spawn);
26+
let gyro_type_loader = DataLoader::new(GyroTypeLoader { pool: pool.clone() }, tokio::spawn);
27+
let cockpit_type_loader = DataLoader::new(CockpitTypeLoader { pool: pool.clone() }, tokio::spawn);
28+
let myomer_type_loader = DataLoader::new(MyomerTypeLoader { pool: pool.clone() }, tokio::spawn);
1829

1930
Schema::build(QueryRoot, EmptyMutation, EmptySubscription)
2031
.data(state)
2132
.data(mech_loader)
2233
.data(ammo_for_loader)
2334
.data(ammo_types_loader)
35+
.data(engine_type_loader)
36+
.data(armor_type_loader)
37+
.data(structure_type_loader)
38+
.data(heatsink_type_loader)
39+
.data(gyro_type_loader)
40+
.data(cockpit_type_loader)
41+
.data(myomer_type_loader)
2442
.limit_depth(20)
2543
.limit_complexity(500)
2644
.finish()

0 commit comments

Comments
 (0)