T08: Drift schema mirroring the Room baseline
Three tables with CASCADE foreign keys, indices, WAL, and a real MigrationStrategy from schema version 1. No destructive fallback, ever. Two things needed deliberate handling. SQLite defaults foreign_keys to OFF and Drift, unlike Room, does not enable it -- without the pragma every CASCADE is decorative, so there is now a test that reads the pragma back. And Drift both snake_cases columns and names row classes after tables; build.yaml sets case_from_dart_to_sql: preserve so the schema stays column-for-column identical to Room's, and @DataClassName keeps row types from colliding with the domain models. SchemaTest was instrumented and needed a device; the Drift version is a plain unit test that runs in under a second with no emulator. 16 tests. 95 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
13
build.yaml
Normal file
13
build.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
targets:
|
||||||
|
$default:
|
||||||
|
builders:
|
||||||
|
drift_dev:
|
||||||
|
options:
|
||||||
|
# Preserve Dart field names as SQL column names instead of snake_casing them.
|
||||||
|
#
|
||||||
|
# This keeps the schema column-for-column identical to the native app's Room
|
||||||
|
# schema (app/schemas/com.rippr.data.AppDatabase/2.json), which means the raw
|
||||||
|
# SQL in database.dart can stay byte-identical to the Kotlin DAO queries it was
|
||||||
|
# ported from -- much easier to review -- and leaves the door open to reading an
|
||||||
|
# old Room database file directly if an importer is ever wanted.
|
||||||
|
case_from_dart_to_sql: preserve
|
||||||
@@ -277,3 +277,43 @@ comment in `run.sh` says so explicitly.
|
|||||||
its ~965 lines of tests, and proven equivalent rather than assumed equivalent.
|
its ~965 lines of tests, and proven equivalent rather than assumed equivalent.
|
||||||
|
|
||||||
Next: **T08 (Drift schema)**, the first task that touches persistence.
|
Next: **T08 (Drift schema)**, the first task that touches persistence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T08 — Drift schema · **complete**
|
||||||
|
|
||||||
|
`lib/src/data/database.dart` (+ generated `database.g.dart`). **16 tests.**
|
||||||
|
|
||||||
|
Mirrors `app/schemas/com.rippr.data.AppDatabase/2.json`: three tables, CASCADE foreign
|
||||||
|
keys, indices on `tripId` / `segmentId` / `synced`, WAL with `synchronous = NORMAL`.
|
||||||
|
Starts at Dart schema version 1 with a real `MigrationStrategy` — the destructive
|
||||||
|
fallback never comes back.
|
||||||
|
|
||||||
|
### Foreign keys are OFF by default in SQLite
|
||||||
|
|
||||||
|
Room switched them on for us. **Drift does not.** Without `PRAGMA foreign_keys = ON` in
|
||||||
|
`beforeOpen`, every `CASCADE` in the schema is decorative and deleting a trip silently
|
||||||
|
orphans all of its points. There is now a test that reads the pragma back and asserts it
|
||||||
|
is `1`, because this is invisible until data is already wrong.
|
||||||
|
|
||||||
|
### Two collisions worth recording
|
||||||
|
|
||||||
|
**Drift generates row classes named after the table.** `Trips` → `Trip`, colliding with
|
||||||
|
the domain model of the same name and producing 21 confusing analyzer errors of the form
|
||||||
|
*"Trip can't be assigned to Trip"*. Fixed with `@DataClassName('TripRow')` etc. The
|
||||||
|
mapping functions `_toTrip` / `_toSegment` / `_toPoint` convert row → domain, so the
|
||||||
|
domain layer stays unaware Drift exists.
|
||||||
|
|
||||||
|
**Drift snake_cases column names.** `speedKmh` became `speed_kmh`, which broke the one
|
||||||
|
raw-SQL query (the live stats aggregate) with `no such column: speedKmh`. Rather than 30
|
||||||
|
`.named()` annotations, `build.yaml` sets `case_from_dart_to_sql: preserve`. That keeps
|
||||||
|
the schema column-for-column identical to Room's, lets the raw SQL stay byte-identical to
|
||||||
|
the Kotlin DAO query it was ported from, and leaves a Room-file importer possible later.
|
||||||
|
|
||||||
|
### The instrumented-to-unit win, realised
|
||||||
|
|
||||||
|
`SchemaTest` needed a device and an emulator. The Drift equivalent runs on the Dart VM in
|
||||||
|
well under a second with nothing booted. `TripRepositoryTest` and `MergeTest` (441 more
|
||||||
|
lines) should convert the same way in T09.
|
||||||
|
|
||||||
|
**95 tests passing, analyze clean.**
|
||||||
|
|||||||
429
lib/src/data/database.dart
Normal file
429
lib/src/data/database.dart
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
/// Drift schema, mirroring the Room schema at
|
||||||
|
/// `app/schemas/com.rippr.data.AppDatabase/2.json` in the native repo.
|
||||||
|
///
|
||||||
|
/// Ported from `com.rippr.data.AppDatabase` plus the three DAOs.
|
||||||
|
///
|
||||||
|
/// ## Two invariants carried over verbatim
|
||||||
|
///
|
||||||
|
/// **No destructive migration, ever.** v2 removed `fallbackToDestructiveMigration()`
|
||||||
|
/// because rides are real data. This starts at Dart schema version 1 with a real
|
||||||
|
/// [MigrationStrategy] from day one; reinstating a destructive fallback would silently
|
||||||
|
/// delete every stored ride on the next version bump.
|
||||||
|
///
|
||||||
|
/// **Foreign keys must be switched on explicitly.** SQLite defaults `foreign_keys` to
|
||||||
|
/// OFF. Room turned it on for us; Drift does not. Without the pragma in [beforeOpen] the
|
||||||
|
/// `CASCADE` deletes below are decorative, and deleting a trip would silently orphan
|
||||||
|
/// every one of its points. There is a test for exactly this.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../domain/models.dart' as domain;
|
||||||
|
|
||||||
|
part 'database.g.dart';
|
||||||
|
|
||||||
|
/// One ride, from pressing Start to pressing Stop.
|
||||||
|
///
|
||||||
|
/// The aggregate columns are denormalised on purpose — accumulated as points arrive and
|
||||||
|
/// recomputed authoritatively on completion, so the trips list never touches the point
|
||||||
|
/// table.
|
||||||
|
@DataClassName('TripRow')
|
||||||
|
@TableIndex(name: 'idx_trips_ended', columns: {#endedAt})
|
||||||
|
class Trips extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => 'trips';
|
||||||
|
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
IntColumn get startedAt => integer()();
|
||||||
|
|
||||||
|
/// Null while the ride is still active. This column *is* the fact of an in-progress
|
||||||
|
/// ride, which is why recording state survives process death.
|
||||||
|
IntColumn get endedAt => integer().nullable()();
|
||||||
|
|
||||||
|
/// Null means the UI derives a label from [startedAt]. Never store an empty string.
|
||||||
|
TextColumn get name => text().nullable()();
|
||||||
|
|
||||||
|
TextColumn get state => textEnum<domain.TripState>()();
|
||||||
|
|
||||||
|
RealColumn get distanceM => real().withDefault(const Constant(0))();
|
||||||
|
IntColumn get movingMillis => integer().withDefault(const Constant(0))();
|
||||||
|
RealColumn get maxSpeedKmh => real().withDefault(const Constant(0))();
|
||||||
|
RealColumn get elevationGainM => real().withDefault(const Constant(0))();
|
||||||
|
IntColumn get pointCount => integer().withDefault(const Constant(0))();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pause-free stretch of recording within a trip.
|
||||||
|
///
|
||||||
|
/// This layer is what makes pause correct rather than cosmetic.
|
||||||
|
@DataClassName('SegmentRow')
|
||||||
|
@TableIndex(name: 'idx_segments_trip', columns: {#tripId})
|
||||||
|
class Segments extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => 'segments';
|
||||||
|
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
IntColumn get tripId =>
|
||||||
|
integer().references(Trips, #id, onDelete: KeyAction.cascade)();
|
||||||
|
IntColumn get startedAt => integer()();
|
||||||
|
|
||||||
|
/// Null while this segment is still being recorded into.
|
||||||
|
IntColumn get endedAt => integer().nullable()();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single GPS fix.
|
||||||
|
@DataClassName('TrackPointRow')
|
||||||
|
@TableIndex(name: 'idx_points_trip', columns: {#tripId})
|
||||||
|
@TableIndex(name: 'idx_points_segment', columns: {#segmentId})
|
||||||
|
@TableIndex(name: 'idx_points_synced', columns: {#synced})
|
||||||
|
class TrackPoints extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => 'track_points';
|
||||||
|
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
IntColumn get tripId =>
|
||||||
|
integer().references(Trips, #id, onDelete: KeyAction.cascade)();
|
||||||
|
IntColumn get segmentId =>
|
||||||
|
integer().references(Segments, #id, onDelete: KeyAction.cascade)();
|
||||||
|
IntColumn get timestamp => integer()();
|
||||||
|
RealColumn get latitude => real()();
|
||||||
|
RealColumn get longitude => real()();
|
||||||
|
RealColumn get speedKmh => real()();
|
||||||
|
RealColumn get altitudeM => real()();
|
||||||
|
RealColumn get accuracyM => real().withDefault(const Constant(0))();
|
||||||
|
RealColumn get bearingDeg => real().withDefault(const Constant(0))();
|
||||||
|
|
||||||
|
/// Set once the point has been accepted by the remote endpoint.
|
||||||
|
BoolColumn get synced => boolean().withDefault(const Constant(false))();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DriftDatabase(tables: [Trips, Segments, TrackPoints])
|
||||||
|
class AppDatabase extends _$AppDatabase {
|
||||||
|
AppDatabase(super.e);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get schemaVersion => 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
|
onCreate: (m) => m.createAll(),
|
||||||
|
beforeOpen: (details) async {
|
||||||
|
// Non-negotiable: without this the CASCADE relationships above do nothing.
|
||||||
|
await customStatement('PRAGMA foreign_keys = ON');
|
||||||
|
// A ride is unrecoverable if a write is lost to a crash mid-flush, but full
|
||||||
|
// sync on every insert at 2 Hz burns battery. WAL with NORMAL sync is the
|
||||||
|
// standard compromise and survives app crashes; only an OS-level crash can
|
||||||
|
// lose the last few points.
|
||||||
|
await customStatement('PRAGMA journal_mode = WAL');
|
||||||
|
await customStatement('PRAGMA synchronous = NORMAL');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Trips ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The in-progress ride, or null. This is the source of truth for "are we recording" —
|
||||||
|
/// it survives process death, which an in-memory flag cannot.
|
||||||
|
Stream<domain.Trip?> watchActiveTrip() => (select(trips)
|
||||||
|
..where((t) => t.endedAt.isNull())
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.id)])
|
||||||
|
..limit(1))
|
||||||
|
.watchSingleOrNull()
|
||||||
|
.map((r) => r == null ? null : _toTrip(r));
|
||||||
|
|
||||||
|
Future<domain.Trip?> getActiveTrip() async {
|
||||||
|
final row = await (select(trips)
|
||||||
|
..where((t) => t.endedAt.isNull())
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.id)])
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row == null ? null : _toTrip(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<List<domain.Trip>> watchCompletedTrips() => (select(trips)
|
||||||
|
..where((t) => t.endedAt.isNotNull())
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.startedAt)]))
|
||||||
|
.watch()
|
||||||
|
.map((rows) => rows.map(_toTrip).toList());
|
||||||
|
|
||||||
|
Stream<domain.Trip?> watchTrip(int id) =>
|
||||||
|
(select(trips)..where((t) => t.id.equals(id)))
|
||||||
|
.watchSingleOrNull()
|
||||||
|
.map((r) => r == null ? null : _toTrip(r));
|
||||||
|
|
||||||
|
Future<domain.Trip?> getTrip(int id) async {
|
||||||
|
final row =
|
||||||
|
await (select(trips)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||||
|
return row == null ? null : _toTrip(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> insertTrip(domain.Trip trip) => into(trips).insert(
|
||||||
|
TripsCompanion.insert(
|
||||||
|
startedAt: trip.startedAt,
|
||||||
|
endedAt: Value(trip.endedAt),
|
||||||
|
name: Value(trip.name),
|
||||||
|
state: trip.state,
|
||||||
|
distanceM: Value(trip.distanceM),
|
||||||
|
movingMillis: Value(trip.movingMillis),
|
||||||
|
maxSpeedKmh: Value(trip.maxSpeedKmh),
|
||||||
|
elevationGainM: Value(trip.elevationGainM),
|
||||||
|
pointCount: Value(trip.pointCount),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> renameTrip(int id, String? name) =>
|
||||||
|
(update(trips)..where((t) => t.id.equals(id)))
|
||||||
|
.write(TripsCompanion(name: Value(name)));
|
||||||
|
|
||||||
|
Future<void> setTripState(int id, domain.TripState state) =>
|
||||||
|
(update(trips)..where((t) => t.id.equals(id)))
|
||||||
|
.write(TripsCompanion(state: Value(state)));
|
||||||
|
|
||||||
|
Future<void> closeTrip(int id, int endedAt,
|
||||||
|
{domain.TripState state = domain.TripState.completed}) =>
|
||||||
|
(update(trips)..where((t) => t.id.equals(id))).write(
|
||||||
|
TripsCompanion(endedAt: Value(endedAt), state: Value(state)));
|
||||||
|
|
||||||
|
/// Persists the running totals. Called once per writer flush (~every 2 s), so it stays
|
||||||
|
/// a narrow targeted update rather than a full row rewrite.
|
||||||
|
Future<void> updateAggregates({
|
||||||
|
required int id,
|
||||||
|
required double distanceM,
|
||||||
|
required int movingMillis,
|
||||||
|
required double maxSpeedKmh,
|
||||||
|
required double elevationGainM,
|
||||||
|
required int pointCount,
|
||||||
|
}) =>
|
||||||
|
(update(trips)..where((t) => t.id.equals(id))).write(TripsCompanion(
|
||||||
|
distanceM: Value(distanceM),
|
||||||
|
movingMillis: Value(movingMillis),
|
||||||
|
maxSpeedKmh: Value(maxSpeedKmh),
|
||||||
|
elevationGainM: Value(elevationGainM),
|
||||||
|
pointCount: Value(pointCount),
|
||||||
|
));
|
||||||
|
|
||||||
|
/// Segments and points go with it via CASCADE.
|
||||||
|
Future<void> deleteTrip(int id) =>
|
||||||
|
(delete(trips)..where((t) => t.id.equals(id))).go();
|
||||||
|
|
||||||
|
Future<int> countTrips() async =>
|
||||||
|
(await select(trips).get()).length;
|
||||||
|
|
||||||
|
/// Test/maintenance helper. Segments and points follow via CASCADE.
|
||||||
|
Future<void> deleteAllTrips() => delete(trips).go();
|
||||||
|
|
||||||
|
// --- Segments ------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<int> insertSegment(int tripId, int startedAt) =>
|
||||||
|
into(segments).insert(
|
||||||
|
SegmentsCompanion.insert(tripId: tripId, startedAt: startedAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<List<domain.Segment>> segmentsForTrip(int tripId) async {
|
||||||
|
final rows = await (select(segments)
|
||||||
|
..where((s) => s.tripId.equals(tripId))
|
||||||
|
..orderBy([(s) => OrderingTerm.asc(s.id)]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_toSegment).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The segment currently being recorded into, if any.
|
||||||
|
Future<domain.Segment?> openSegment(int tripId) async {
|
||||||
|
final row = await (select(segments)
|
||||||
|
..where((s) => s.tripId.equals(tripId) & s.endedAt.isNull())
|
||||||
|
..orderBy([(s) => OrderingTerm.desc(s.id)])
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row == null ? null : _toSegment(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> closeSegment(int id, int endedAt) =>
|
||||||
|
(update(segments)..where((s) => s.id.equals(id)))
|
||||||
|
.write(SegmentsCompanion(endedAt: Value(endedAt)));
|
||||||
|
|
||||||
|
/// Used by merge: re-parents a trip's segments onto the surviving trip.
|
||||||
|
Future<void> reparentSegments(int oldTripId, int newTripId) =>
|
||||||
|
(update(segments)..where((s) => s.tripId.equals(oldTripId)))
|
||||||
|
.write(SegmentsCompanion(tripId: Value(newTripId)));
|
||||||
|
|
||||||
|
Future<int> countSegmentsForTrip(int tripId) async =>
|
||||||
|
(await (select(segments)..where((s) => s.tripId.equals(tripId))).get())
|
||||||
|
.length;
|
||||||
|
|
||||||
|
// --- Track points --------------------------------------------------------
|
||||||
|
|
||||||
|
/// Batched insert — the recorder buffers points and flushes them in groups.
|
||||||
|
Future<void> insertPoints(List<domain.TrackPoint> points) async {
|
||||||
|
if (points.isEmpty) return;
|
||||||
|
await batch((b) {
|
||||||
|
b.insertAll(
|
||||||
|
trackPoints,
|
||||||
|
points.map((p) => TrackPointsCompanion.insert(
|
||||||
|
tripId: p.tripId,
|
||||||
|
segmentId: p.segmentId,
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
latitude: p.latitude,
|
||||||
|
longitude: p.longitude,
|
||||||
|
speedKmh: p.speedKmh,
|
||||||
|
altitudeM: p.altitudeM,
|
||||||
|
accuracyM: Value(p.accuracyM),
|
||||||
|
bearingDeg: Value(p.bearingDeg),
|
||||||
|
synced: Value(p.synced),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> insertPoint(domain.TrackPoint p) =>
|
||||||
|
into(trackPoints).insert(TrackPointsCompanion.insert(
|
||||||
|
tripId: p.tripId,
|
||||||
|
segmentId: p.segmentId,
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
latitude: p.latitude,
|
||||||
|
longitude: p.longitude,
|
||||||
|
speedKmh: p.speedKmh,
|
||||||
|
altitudeM: p.altitudeM,
|
||||||
|
accuracyM: Value(p.accuracyM),
|
||||||
|
bearingDeg: Value(p.bearingDeg),
|
||||||
|
synced: Value(p.synced),
|
||||||
|
));
|
||||||
|
|
||||||
|
/// Ordered by segment then id so consumers walk the ride in recording order with pause
|
||||||
|
/// boundaries intact. **Not** ordered by timestamp: that value is GPS-derived and can
|
||||||
|
/// jump, whereas id is monotonic in write order.
|
||||||
|
Future<List<domain.TrackPoint>> pointsForTrip(int tripId) async {
|
||||||
|
final rows = await (select(trackPoints)
|
||||||
|
..where((p) => p.tripId.equals(tripId))
|
||||||
|
..orderBy([
|
||||||
|
(p) => OrderingTerm.asc(p.segmentId),
|
||||||
|
(p) => OrderingTerm.asc(p.id),
|
||||||
|
]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_toPoint).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<domain.TrackPoint>> pointsForSegment(int segmentId) async {
|
||||||
|
final rows = await (select(trackPoints)
|
||||||
|
..where((p) => p.segmentId.equals(segmentId))
|
||||||
|
..orderBy([(p) => OrderingTerm.asc(p.id)]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_toPoint).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The anchor a restarted recorder needs to continue accumulating distance.
|
||||||
|
Future<domain.TrackPoint?> lastInSegment(int segmentId) async {
|
||||||
|
final row = await (select(trackPoints)
|
||||||
|
..where((p) => p.segmentId.equals(segmentId))
|
||||||
|
..orderBy([(p) => OrderingTerm.desc(p.id)])
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row == null ? null : _toPoint(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> countPointsForTrip(int tripId) async =>
|
||||||
|
(await (select(trackPoints)..where((p) => p.tripId.equals(tripId))).get())
|
||||||
|
.length;
|
||||||
|
|
||||||
|
/// Live stats for one trip.
|
||||||
|
///
|
||||||
|
/// The COALESCE guards in the Kotlin original existed because Room throws on null for
|
||||||
|
/// non-null fields; they are kept because the semantics are what matter — a trip with
|
||||||
|
/// no points yet must read as zeros, not as an error.
|
||||||
|
Stream<domain.RideStats> watchTripStats(int tripId) {
|
||||||
|
final q = customSelect(
|
||||||
|
'''
|
||||||
|
SELECT COUNT(*) AS pointCount,
|
||||||
|
COALESCE(MAX(speedKmh), 0.0) AS maxSpeedKmh,
|
||||||
|
COALESCE(AVG(speedKmh), 0.0) AS avgSpeedKmh,
|
||||||
|
COALESCE(MIN(timestamp), 0) AS firstTimestamp,
|
||||||
|
COALESCE(MAX(timestamp), 0) AS lastTimestamp,
|
||||||
|
COALESCE(SUM(CASE WHEN synced = 0 THEN 1 ELSE 0 END), 0) AS pendingUpload
|
||||||
|
FROM track_points
|
||||||
|
WHERE tripId = ?
|
||||||
|
''',
|
||||||
|
variables: [Variable.withInt(tripId)],
|
||||||
|
readsFrom: {trackPoints},
|
||||||
|
);
|
||||||
|
return q.watchSingle().map((row) => domain.RideStats(
|
||||||
|
pointCount: row.read<int>('pointCount'),
|
||||||
|
maxSpeedKmh: row.read<double>('maxSpeedKmh'),
|
||||||
|
avgSpeedKmh: row.read<double>('avgSpeedKmh'),
|
||||||
|
firstTimestamp: row.read<int>('firstTimestamp'),
|
||||||
|
lastTimestamp: row.read<int>('lastTimestamp'),
|
||||||
|
pendingUpload: row.read<int>('pendingUpload'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Upload backlog ------------------------------------------------------
|
||||||
|
// Batches are drawn by id and can straddle a segment or trip boundary, which is why
|
||||||
|
// the payload carries trip/segment identity per point rather than per batch.
|
||||||
|
|
||||||
|
Future<List<domain.TrackPoint>> unsyncedPoints(int limit) async {
|
||||||
|
final rows = await (select(trackPoints)
|
||||||
|
..where((p) => p.synced.equals(false))
|
||||||
|
..orderBy([(p) => OrderingTerm.asc(p.id)])
|
||||||
|
..limit(limit))
|
||||||
|
.get();
|
||||||
|
return rows.map(_toPoint).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markSynced(List<int> ids) async {
|
||||||
|
if (ids.isEmpty) return;
|
||||||
|
await (update(trackPoints)..where((p) => p.id.isIn(ids)))
|
||||||
|
.write(const TrackPointsCompanion(synced: Value(true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> countUnsynced() async =>
|
||||||
|
(await (select(trackPoints)..where((p) => p.synced.equals(false))).get())
|
||||||
|
.length;
|
||||||
|
|
||||||
|
// --- Maintenance ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// Used by merge: points carry tripId directly, so they re-parent alongside segments.
|
||||||
|
Future<void> reparentPoints(int oldTripId, int newTripId) =>
|
||||||
|
(update(trackPoints)..where((p) => p.tripId.equals(oldTripId)))
|
||||||
|
.write(TrackPointsCompanion(tripId: Value(newTripId)));
|
||||||
|
|
||||||
|
Future<List<domain.TrackPoint>> allPoints() async {
|
||||||
|
final rows = await (select(trackPoints)
|
||||||
|
..orderBy([(p) => OrderingTerm.asc(p.id)]))
|
||||||
|
.get();
|
||||||
|
return rows.map(_toPoint).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Row → domain mapping ---------------------------------------------------
|
||||||
|
// Kept as free functions rather than extension getters so the domain layer stays
|
||||||
|
// entirely unaware that Drift exists.
|
||||||
|
|
||||||
|
domain.Trip _toTrip(TripRow r) => domain.Trip(
|
||||||
|
id: r.id,
|
||||||
|
startedAt: r.startedAt,
|
||||||
|
endedAt: r.endedAt,
|
||||||
|
name: r.name,
|
||||||
|
state: r.state,
|
||||||
|
distanceM: r.distanceM,
|
||||||
|
movingMillis: r.movingMillis,
|
||||||
|
maxSpeedKmh: r.maxSpeedKmh,
|
||||||
|
elevationGainM: r.elevationGainM,
|
||||||
|
pointCount: r.pointCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
domain.Segment _toSegment(SegmentRow r) => domain.Segment(
|
||||||
|
id: r.id,
|
||||||
|
tripId: r.tripId,
|
||||||
|
startedAt: r.startedAt,
|
||||||
|
endedAt: r.endedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
domain.TrackPoint _toPoint(TrackPointRow r) => domain.TrackPoint(
|
||||||
|
id: r.id,
|
||||||
|
tripId: r.tripId,
|
||||||
|
segmentId: r.segmentId,
|
||||||
|
timestamp: r.timestamp,
|
||||||
|
latitude: r.latitude,
|
||||||
|
longitude: r.longitude,
|
||||||
|
speedKmh: r.speedKmh,
|
||||||
|
altitudeM: r.altitudeM,
|
||||||
|
accuracyM: r.accuracyM,
|
||||||
|
bearingDeg: r.bearingDeg,
|
||||||
|
synced: r.synced,
|
||||||
|
);
|
||||||
3019
lib/src/data/database.g.dart
Normal file
3019
lib/src/data/database.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
248
test/database_test.dart
Normal file
248
test/database_test.dart
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/data/database.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
|
||||||
|
/// Ported from `com.rippr.data.SchemaTest`.
|
||||||
|
///
|
||||||
|
/// **These were instrumented tests requiring a device.** Against Drift on the Dart VM
|
||||||
|
/// they are plain unit tests: faster, and runnable with no emulator booted. That is one
|
||||||
|
/// of the concrete wins of the port.
|
||||||
|
///
|
||||||
|
/// Every test uses an in-memory database. The native suite once wiped a real device's
|
||||||
|
/// rides by running against the production singleton in `setUp`, so this is not
|
||||||
|
/// negotiable — see `docs/TESTING.md` in the native repo.
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
db = AppDatabase(NativeDatabase.memory());
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async => db.close());
|
||||||
|
|
||||||
|
Future<(int tripId, int segmentId)> seedTrip({int startedAt = 1000}) async {
|
||||||
|
final tripId =
|
||||||
|
await db.insertTrip(Trip(startedAt: startedAt, state: TripState.recording));
|
||||||
|
final segmentId = await db.insertSegment(tripId, startedAt);
|
||||||
|
return (tripId, segmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackPoint point(int tripId, int segmentId, int ts, {double speed = 50.0}) =>
|
||||||
|
TrackPoint(
|
||||||
|
tripId: tripId,
|
||||||
|
segmentId: segmentId,
|
||||||
|
timestamp: ts,
|
||||||
|
latitude: 51.0447,
|
||||||
|
longitude: -114.0719,
|
||||||
|
speedKmh: speed,
|
||||||
|
altitudeM: 1045.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
group('cascade', () {
|
||||||
|
test('deleting a trip cascades to segments and points', () async {
|
||||||
|
final (tripId, segmentId) = await seedTrip();
|
||||||
|
await db.insertPoints(
|
||||||
|
List.generate(5, (i) => point(tripId, segmentId, 1000 + i)));
|
||||||
|
|
||||||
|
expect(await db.countPointsForTrip(tripId), 5);
|
||||||
|
expect(await db.countSegmentsForTrip(tripId), 1);
|
||||||
|
|
||||||
|
await db.deleteTrip(tripId);
|
||||||
|
|
||||||
|
expect(await db.countPointsForTrip(tripId), 0);
|
||||||
|
expect(await db.countSegmentsForTrip(tripId), 0);
|
||||||
|
expect(await db.getTrip(tripId), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('foreign keys are actually enforced', () async {
|
||||||
|
// SQLite defaults foreign_keys to OFF. Room enabled it; Drift does not, so the
|
||||||
|
// pragma in beforeOpen is load-bearing. Without it the CASCADE above is
|
||||||
|
// decorative and every delete silently orphans its points.
|
||||||
|
final result =
|
||||||
|
await db.customSelect('PRAGMA foreign_keys').getSingle();
|
||||||
|
expect(result.data.values.first, 1,
|
||||||
|
reason: 'foreign_keys pragma is off — CASCADE would do nothing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a point cannot reference a trip that does not exist', () async {
|
||||||
|
await expectLater(
|
||||||
|
db.insertPoint(point(9999, 9999, 1000)),
|
||||||
|
throwsA(anything),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('active trip', () {
|
||||||
|
test('an open trip is the active one and a closed trip is not', () async {
|
||||||
|
final (tripId, _) = await seedTrip();
|
||||||
|
|
||||||
|
expect((await db.getActiveTrip())?.id, tripId);
|
||||||
|
|
||||||
|
await db.closeTrip(tripId, 5000);
|
||||||
|
|
||||||
|
expect(await db.getActiveTrip(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watchActiveTrip emits the open trip', () async {
|
||||||
|
final (tripId, _) = await seedTrip();
|
||||||
|
expect((await db.watchActiveTrip().first)?.id, tripId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completed trips are ordered newest first', () async {
|
||||||
|
final a = await db.insertTrip(
|
||||||
|
const Trip(startedAt: 1000, state: TripState.recording));
|
||||||
|
final b = await db.insertTrip(
|
||||||
|
const Trip(startedAt: 3000, state: TripState.recording));
|
||||||
|
final c = await db.insertTrip(
|
||||||
|
const Trip(startedAt: 2000, state: TripState.recording));
|
||||||
|
for (final id in [a, b, c]) {
|
||||||
|
await db.closeTrip(id, 9000);
|
||||||
|
}
|
||||||
|
|
||||||
|
final listed = await db.watchCompletedTrips().first;
|
||||||
|
expect(listed.map((t) => t.startedAt).toList(), [3000, 2000, 1000]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('point ordering', () {
|
||||||
|
test('points come back by segment then id, not by timestamp', () async {
|
||||||
|
// Timestamps are GPS-derived and can jump; id is monotonic in write order. The
|
||||||
|
// ordering contract is what keeps a ride walkable with pauses intact.
|
||||||
|
final (tripId, seg1) = await seedTrip();
|
||||||
|
final seg2 = await db.insertSegment(tripId, 2000);
|
||||||
|
|
||||||
|
await db.insertPoints([
|
||||||
|
point(tripId, seg2, 500), // deliberately the earliest timestamp
|
||||||
|
point(tripId, seg1, 9000),
|
||||||
|
point(tripId, seg1, 1000),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final ordered = await db.pointsForTrip(tripId);
|
||||||
|
expect(ordered.map((p) => p.segmentId).toList(), [seg1, seg1, seg2]);
|
||||||
|
expect(ordered.first.timestamp, 9000,
|
||||||
|
reason: 'must not be re-sorted by timestamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lastInSegment returns the highest id, the recorder anchor', () async {
|
||||||
|
final (tripId, segmentId) = await seedTrip();
|
||||||
|
await db.insertPoints(
|
||||||
|
List.generate(5, (i) => point(tripId, segmentId, 1000 + i)));
|
||||||
|
|
||||||
|
final last = await db.lastInSegment(segmentId);
|
||||||
|
expect(last!.timestamp, 1004);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openSegment finds the one still being recorded into', () async {
|
||||||
|
final (tripId, seg1) = await seedTrip();
|
||||||
|
await db.closeSegment(seg1, 2000);
|
||||||
|
final seg2 = await db.insertSegment(tripId, 3000);
|
||||||
|
|
||||||
|
expect((await db.openSegment(tripId))?.id, seg2);
|
||||||
|
|
||||||
|
await db.closeSegment(seg2, 4000);
|
||||||
|
expect(await db.openSegment(tripId), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('stats guards', () {
|
||||||
|
test('a trip with no points reports zeros rather than failing', () async {
|
||||||
|
final (tripId, _) = await seedTrip();
|
||||||
|
|
||||||
|
final stats = await db.watchTripStats(tripId).first;
|
||||||
|
|
||||||
|
expect(stats.pointCount, 0);
|
||||||
|
expect(stats.maxSpeedKmh, 0.0);
|
||||||
|
expect(stats.avgSpeedKmh, 0.0);
|
||||||
|
expect(stats.firstTimestamp, 0);
|
||||||
|
expect(stats.durationMillis, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stats aggregate over the trip', () async {
|
||||||
|
final (tripId, segmentId) = await seedTrip();
|
||||||
|
await db.insertPoints([
|
||||||
|
point(tripId, segmentId, 1000, speed: 10),
|
||||||
|
point(tripId, segmentId, 2000, speed: 50),
|
||||||
|
point(tripId, segmentId, 3000, speed: 30),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final stats = await db.watchTripStats(tripId).first;
|
||||||
|
|
||||||
|
expect(stats.pointCount, 3);
|
||||||
|
expect(stats.maxSpeedKmh, 50.0);
|
||||||
|
expect(stats.avgSpeedKmh, closeTo(30.0, 1e-9));
|
||||||
|
expect(stats.durationMillis, 2000);
|
||||||
|
expect(stats.pendingUpload, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('upload backlog', () {
|
||||||
|
test('unsynced points come back oldest first and mark cleanly', () async {
|
||||||
|
final (tripId, segmentId) = await seedTrip();
|
||||||
|
await db.insertPoints(
|
||||||
|
List.generate(10, (i) => point(tripId, segmentId, 1000 + i)));
|
||||||
|
|
||||||
|
expect(await db.countUnsynced(), 10);
|
||||||
|
|
||||||
|
final batch = await db.unsyncedPoints(4);
|
||||||
|
expect(batch.length, 4);
|
||||||
|
expect(batch.first.timestamp, 1000);
|
||||||
|
|
||||||
|
await db.markSynced(batch.map((p) => p.id).toList());
|
||||||
|
|
||||||
|
expect(await db.countUnsynced(), 6);
|
||||||
|
expect((await db.unsyncedPoints(1)).first.timestamp, 1004);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marking an empty list is a no-op', () async {
|
||||||
|
final (tripId, segmentId) = await seedTrip();
|
||||||
|
await db.insertPoints([point(tripId, segmentId, 1000)]);
|
||||||
|
await db.markSynced(const []);
|
||||||
|
expect(await db.countUnsynced(), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('aggregates', () {
|
||||||
|
test('updateAggregates writes only the running totals', () async {
|
||||||
|
final (tripId, _) = await seedTrip();
|
||||||
|
await db.renameTrip(tripId, 'Morning loop');
|
||||||
|
|
||||||
|
await db.updateAggregates(
|
||||||
|
id: tripId,
|
||||||
|
distanceM: 1234.5,
|
||||||
|
movingMillis: 60000,
|
||||||
|
maxSpeedKmh: 88.5,
|
||||||
|
elevationGainM: 42.0,
|
||||||
|
pointCount: 99,
|
||||||
|
);
|
||||||
|
|
||||||
|
final trip = (await db.getTrip(tripId))!;
|
||||||
|
expect(trip.distanceM, 1234.5);
|
||||||
|
expect(trip.movingMillis, 60000);
|
||||||
|
expect(trip.maxSpeedKmh, 88.5);
|
||||||
|
expect(trip.elevationGainM, 42.0);
|
||||||
|
expect(trip.pointCount, 99);
|
||||||
|
expect(trip.name, 'Morning loop', reason: 'unrelated columns must survive');
|
||||||
|
expect(trip.endedAt, isNull, reason: 'must not close the trip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state round-trips through the enum column', () async {
|
||||||
|
final (tripId, _) = await seedTrip();
|
||||||
|
|
||||||
|
await db.setTripState(tripId, TripState.paused);
|
||||||
|
expect((await db.getTrip(tripId))!.state, TripState.paused);
|
||||||
|
|
||||||
|
await db.closeTrip(tripId, 5000);
|
||||||
|
expect((await db.getTrip(tripId))!.state, TripState.completed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renaming to null clears the name rather than storing empty', () async {
|
||||||
|
final (tripId, _) = await seedTrip();
|
||||||
|
await db.renameTrip(tripId, 'Something');
|
||||||
|
await db.renameTrip(tripId, null);
|
||||||
|
expect((await db.getTrip(tripId))!.name, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user