| 172 |
172 |
|
Ok(out)
|
| 173 |
173 |
|
}
|
| 174 |
174 |
|
|
|
175 |
+ |
/// True when any effort row of any kind references this exercise.
|
|
176 |
+ |
/// Checked across all three tables rather than just the current kind's,
|
|
177 |
+ |
/// so a row left behind by an earlier kind still counts as history.
|
|
178 |
+ |
fn has_logged_sets(&self, exercise_id: i64) -> Result<bool, Error> {
|
|
179 |
+ |
let exists: i64 = self.conn().query_row(
|
|
180 |
+ |
"SELECT EXISTS(
|
|
181 |
+ |
SELECT 1 FROM reps_sets WHERE exercise_id = ?1
|
|
182 |
+ |
UNION ALL
|
|
183 |
+ |
SELECT 1 FROM timed_sets WHERE exercise_id = ?1
|
|
184 |
+ |
UNION ALL
|
|
185 |
+ |
SELECT 1 FROM distance_sets WHERE exercise_id = ?1
|
|
186 |
+ |
)",
|
|
187 |
+ |
params![exercise_id],
|
|
188 |
+ |
|row| row.get(0),
|
|
189 |
+ |
)?;
|
|
190 |
+ |
Ok(exists != 0)
|
|
191 |
+ |
}
|
|
192 |
+ |
|
| 175 |
193 |
|
fn tag_ids_for(&self, exercise_id: i64) -> Result<Vec<i64>, Error> {
|
| 176 |
194 |
|
let mut stmt = self.conn().prepare(
|
| 177 |
195 |
|
"SELECT tag_id FROM exercise_tags WHERE exercise_id = ?1 ORDER BY tag_id",
|
| 224 |
242 |
|
return Err(Error::InvalidExerciseName);
|
| 225 |
243 |
|
}
|
| 226 |
244 |
|
let effort_kind = default_effort_kind(resistance_type);
|
|
245 |
+ |
|
|
246 |
+ |
// `effort_kind` decides which per-kind table holds this exercise's
|
|
247 |
+ |
// sets, and it is derived from `resistance_type`. So editing an
|
|
248 |
+ |
// exercise across a kind boundary silently repoints it at a table its
|
|
249 |
+ |
// history is not in: the history screen dispatches on `effort_kind`
|
|
250 |
+ |
// and would show nothing, and progression would read an empty series
|
|
251 |
+ |
// while the real rows sit in the old table. Block it the way
|
|
252 |
+ |
// `delete_exercise` blocks deleting an exercise that has history.
|
|
253 |
+ |
// Changing resistance type *within* a kind (freeweight -> machine)
|
|
254 |
+ |
// stays free, because the rows do not move.
|
|
255 |
+ |
let current_kind: EffortKind = self
|
|
256 |
+ |
.conn()
|
|
257 |
+ |
.query_row(
|
|
258 |
+ |
"SELECT effort_kind FROM exercises WHERE id = ?1",
|
|
259 |
+ |
params![id],
|
|
260 |
+ |
|row| row.get::<_, String>(0),
|
|
261 |
+ |
)
|
|
262 |
+ |
.optional()?
|
|
263 |
+ |
.ok_or_else(|| Error::NotFound(format!("exercise {id}")))
|
|
264 |
+ |
.and_then(|s| EffortKind::parse(&s))?;
|
|
265 |
+ |
if current_kind != effort_kind && self.has_logged_sets(id)? {
|
|
266 |
+ |
return Err(Error::EffortKindChangeBlocked {
|
|
267 |
+ |
exercise: id,
|
|
268 |
+ |
from: current_kind.as_str(),
|
|
269 |
+ |
to: effort_kind.as_str(),
|
|
270 |
+ |
});
|
|
271 |
+ |
}
|
|
272 |
+ |
|
| 227 |
273 |
|
let n = self.conn().execute(
|
| 228 |
274 |
|
"UPDATE exercises SET name = ?1, resistance_type = ?2, \
|
| 229 |
275 |
|
effort_kind = ?3, load_unit = ?4, increment = ?5 WHERE id = ?6",
|
| 409 |
455 |
|
assert!(db.delete_exercise(id).is_err(), "FK RESTRICT should block");
|
| 410 |
456 |
|
}
|
| 411 |
457 |
|
|
|
458 |
+ |
#[test]
|
|
459 |
+ |
fn update_blocked_when_effort_kind_would_change_with_history() {
|
|
460 |
+ |
let db = setup();
|
|
461 |
+ |
let id = db
|
|
462 |
+ |
.create_exercise("row", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
|
|
463 |
+ |
.unwrap();
|
|
464 |
+ |
db.conn()
|
|
465 |
+ |
.execute(
|
|
466 |
+ |
"INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe) \
|
|
467 |
+ |
VALUES ('2026-07-18', ?1, 1, 100.0, 5, 3)",
|
|
468 |
+ |
params![id],
|
|
469 |
+ |
)
|
|
470 |
+ |
.unwrap();
|
|
471 |
+ |
|
|
472 |
+ |
// Freeweight -> CardioTime crosses reps -> timed. The logged set stays
|
|
473 |
+ |
// in reps_sets, so letting this through would strand the history in a
|
|
474 |
+ |
// table the exercise no longer reads.
|
|
475 |
+ |
let err = db
|
|
476 |
+ |
.update_exercise(id, "row", ResistanceType::CardioTime, LoadUnit::Kg, 2.5, &[])
|
|
477 |
+ |
.unwrap_err();
|
|
478 |
+ |
assert!(
|
|
479 |
+ |
matches!(err, Error::EffortKindChangeBlocked { .. }),
|
|
480 |
+ |
"expected EffortKindChangeBlocked, got {err:?}"
|
|
481 |
+ |
);
|
|
482 |
+ |
|
|
483 |
+ |
// And the row is untouched.
|
|
484 |
+ |
let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == id).unwrap();
|
|
485 |
+ |
assert_eq!(ex.resistance_type, ResistanceType::Freeweight);
|
|
486 |
+ |
assert_eq!(ex.effort_kind, EffortKind::Reps);
|
|
487 |
+ |
}
|
|
488 |
+ |
|
|
489 |
+ |
#[test]
|
|
490 |
+ |
fn update_allowed_when_effort_kind_unchanged_despite_history() {
|
|
491 |
+ |
let db = setup();
|
|
492 |
+ |
let id = db
|
|
493 |
+ |
.create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
|
|
494 |
+ |
.unwrap();
|
|
495 |
+ |
db.conn()
|
|
496 |
+ |
.execute(
|
|
497 |
+ |
"INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe) \
|
|
498 |
+ |
VALUES ('2026-07-18', ?1, 1, 100.0, 5, 3)",
|
|
499 |
+ |
params![id],
|
|
500 |
+ |
)
|
|
501 |
+ |
.unwrap();
|
|
502 |
+ |
|
|
503 |
+ |
// Freeweight -> Machine is still reps, so history stays valid.
|
|
504 |
+ |
db.update_exercise(id, "incline", ResistanceType::Machine, LoadUnit::Kg, 2.5, &[])
|
|
505 |
+ |
.unwrap();
|
|
506 |
+ |
let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == id).unwrap();
|
|
507 |
+ |
assert_eq!(ex.resistance_type, ResistanceType::Machine);
|
|
508 |
+ |
assert_eq!(ex.effort_kind, EffortKind::Reps);
|
|
509 |
+ |
}
|
|
510 |
+ |
|
|
511 |
+ |
#[test]
|
|
512 |
+ |
fn update_allowed_across_kinds_when_no_history() {
|
|
513 |
+ |
let db = setup();
|
|
514 |
+ |
let id = db
|
|
515 |
+ |
.create_exercise("erg", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
|
|
516 |
+ |
.unwrap();
|
|
517 |
+ |
db.update_exercise(id, "erg", ResistanceType::CardioTime, LoadUnit::Kg, 2.5, &[])
|
|
518 |
+ |
.unwrap();
|
|
519 |
+ |
let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == id).unwrap();
|
|
520 |
+ |
assert_eq!(ex.effort_kind, EffortKind::Timed);
|
|
521 |
+ |
}
|
|
522 |
+ |
|
| 412 |
523 |
|
#[test]
|
| 413 |
524 |
|
fn create_rejects_empty_name_and_duplicate() {
|
| 414 |
525 |
|
let db = setup();
|