diff --git a/lib/library/database.dart b/lib/library/database.dart index f3fc010..0e788cd 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -72,6 +72,10 @@ class Playlists extends Table { IntColumn get updatedAtMs => integer()(); BoolColumn get deleted => boolean().withDefault(const Constant(false))(); + /// ID derselben Playlist am Melo-Server, sobald sie einmal gesichert + /// wurde. `null` heißt: nur auf diesem Gerät. + TextColumn get cloudId => text().nullable()(); + @override Set get primaryKey => {id}; } @@ -148,7 +152,7 @@ class MeloDb extends _$MeloDb { MeloDb([QueryExecutor? executor]) : super(executor ?? _open()); @override - int get schemaVersion => 10; + int get schemaVersion => 11; @override MigrationStrategy get migration => MigrationStrategy( @@ -190,6 +194,9 @@ class MeloDb extends _$MeloDb { if (from < 10) { await m.createTable(downloads); } + if (from < 11) { + await m.addColumn(playlists, playlists.cloudId); + } }, ); @@ -284,6 +291,26 @@ class MeloDb extends _$MeloDb { ); } + Future playlistById(String id) => + (select(playlists)..where((p) => p.id.equals(id))).getSingleOrNull(); + + Future setPlaylistCloudId(String id, String cloudId) async { + await (update(playlists)..where((p) => p.id.equals(id))) + .write(PlaylistsCompanion(cloudId: Value(cloudId))); + } + + /// Wie viele Playlisten es hier gibt — **inklusive Grabsteinen**. + /// + /// Grundlage der Wiederherstellung: nur eine wirklich leere Tabelle gilt + /// als Neuinstallation. Wer alle Playlisten selbst gelöscht hat, soll sie + /// nicht vom Server zurückbekommen. + Future countPlaylists() async { + final zaehler = playlists.id.count(); + final zeile = await (selectOnly(playlists)..addColumns([zaehler])) + .getSingle(); + return zeile.read(zaehler) ?? 0; + } + // === PlaylistSongs === Stream> watchPlaylistSongs(String playlistId) { final query = select(songs).join([ diff --git a/lib/library/database.g.dart b/lib/library/database.g.dart index f3edabd..3880634 100644 --- a/lib/library/database.g.dart +++ b/lib/library/database.g.dart @@ -1322,6 +1322,17 @@ class $PlaylistsTable extends Playlists ), defaultValue: const Constant(false), ); + static const VerificationMeta _cloudIdMeta = const VerificationMeta( + 'cloudId', + ); + @override + late final GeneratedColumn cloudId = GeneratedColumn( + 'cloud_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -1330,6 +1341,7 @@ class $PlaylistsTable extends Playlists createdAtMs, updatedAtMs, deleted, + cloudId, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1393,6 +1405,12 @@ class $PlaylistsTable extends Playlists deleted.isAcceptableOrUnknown(data['deleted']!, _deletedMeta), ); } + if (data.containsKey('cloud_id')) { + context.handle( + _cloudIdMeta, + cloudId.isAcceptableOrUnknown(data['cloud_id']!, _cloudIdMeta), + ); + } return context; } @@ -1426,6 +1444,10 @@ class $PlaylistsTable extends Playlists DriftSqlType.bool, data['${effectivePrefix}deleted'], )!, + cloudId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cloud_id'], + ), ); } @@ -1442,6 +1464,10 @@ class Playlist extends DataClass implements Insertable { final int createdAtMs; final int updatedAtMs; final bool deleted; + + /// ID derselben Playlist am Melo-Server, sobald sie einmal gesichert + /// wurde. `null` heißt: nur auf diesem Gerät. + final String? cloudId; const Playlist({ required this.id, required this.name, @@ -1449,6 +1475,7 @@ class Playlist extends DataClass implements Insertable { required this.createdAtMs, required this.updatedAtMs, required this.deleted, + this.cloudId, }); @override Map toColumns(bool nullToAbsent) { @@ -1461,6 +1488,9 @@ class Playlist extends DataClass implements Insertable { map['created_at_ms'] = Variable(createdAtMs); map['updated_at_ms'] = Variable(updatedAtMs); map['deleted'] = Variable(deleted); + if (!nullToAbsent || cloudId != null) { + map['cloud_id'] = Variable(cloudId); + } return map; } @@ -1474,6 +1504,9 @@ class Playlist extends DataClass implements Insertable { createdAtMs: Value(createdAtMs), updatedAtMs: Value(updatedAtMs), deleted: Value(deleted), + cloudId: cloudId == null && nullToAbsent + ? const Value.absent() + : Value(cloudId), ); } @@ -1489,6 +1522,7 @@ class Playlist extends DataClass implements Insertable { createdAtMs: serializer.fromJson(json['createdAtMs']), updatedAtMs: serializer.fromJson(json['updatedAtMs']), deleted: serializer.fromJson(json['deleted']), + cloudId: serializer.fromJson(json['cloudId']), ); } @override @@ -1501,6 +1535,7 @@ class Playlist extends DataClass implements Insertable { 'createdAtMs': serializer.toJson(createdAtMs), 'updatedAtMs': serializer.toJson(updatedAtMs), 'deleted': serializer.toJson(deleted), + 'cloudId': serializer.toJson(cloudId), }; } @@ -1511,6 +1546,7 @@ class Playlist extends DataClass implements Insertable { int? createdAtMs, int? updatedAtMs, bool? deleted, + Value cloudId = const Value.absent(), }) => Playlist( id: id ?? this.id, name: name ?? this.name, @@ -1518,6 +1554,7 @@ class Playlist extends DataClass implements Insertable { createdAtMs: createdAtMs ?? this.createdAtMs, updatedAtMs: updatedAtMs ?? this.updatedAtMs, deleted: deleted ?? this.deleted, + cloudId: cloudId.present ? cloudId.value : this.cloudId, ); Playlist copyWithCompanion(PlaylistsCompanion data) { return Playlist( @@ -1533,6 +1570,7 @@ class Playlist extends DataClass implements Insertable { ? data.updatedAtMs.value : this.updatedAtMs, deleted: data.deleted.present ? data.deleted.value : this.deleted, + cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId, ); } @@ -1544,14 +1582,22 @@ class Playlist extends DataClass implements Insertable { ..write('description: $description, ') ..write('createdAtMs: $createdAtMs, ') ..write('updatedAtMs: $updatedAtMs, ') - ..write('deleted: $deleted') + ..write('deleted: $deleted, ') + ..write('cloudId: $cloudId') ..write(')')) .toString(); } @override - int get hashCode => - Object.hash(id, name, description, createdAtMs, updatedAtMs, deleted); + int get hashCode => Object.hash( + id, + name, + description, + createdAtMs, + updatedAtMs, + deleted, + cloudId, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -1561,7 +1607,8 @@ class Playlist extends DataClass implements Insertable { other.description == this.description && other.createdAtMs == this.createdAtMs && other.updatedAtMs == this.updatedAtMs && - other.deleted == this.deleted); + other.deleted == this.deleted && + other.cloudId == this.cloudId); } class PlaylistsCompanion extends UpdateCompanion { @@ -1571,6 +1618,7 @@ class PlaylistsCompanion extends UpdateCompanion { final Value createdAtMs; final Value updatedAtMs; final Value deleted; + final Value cloudId; final Value rowid; const PlaylistsCompanion({ this.id = const Value.absent(), @@ -1579,6 +1627,7 @@ class PlaylistsCompanion extends UpdateCompanion { this.createdAtMs = const Value.absent(), this.updatedAtMs = const Value.absent(), this.deleted = const Value.absent(), + this.cloudId = const Value.absent(), this.rowid = const Value.absent(), }); PlaylistsCompanion.insert({ @@ -1588,6 +1637,7 @@ class PlaylistsCompanion extends UpdateCompanion { required int createdAtMs, required int updatedAtMs, this.deleted = const Value.absent(), + this.cloudId = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), name = Value(name), @@ -1600,6 +1650,7 @@ class PlaylistsCompanion extends UpdateCompanion { Expression? createdAtMs, Expression? updatedAtMs, Expression? deleted, + Expression? cloudId, Expression? rowid, }) { return RawValuesInsertable({ @@ -1609,6 +1660,7 @@ class PlaylistsCompanion extends UpdateCompanion { if (createdAtMs != null) 'created_at_ms': createdAtMs, if (updatedAtMs != null) 'updated_at_ms': updatedAtMs, if (deleted != null) 'deleted': deleted, + if (cloudId != null) 'cloud_id': cloudId, if (rowid != null) 'rowid': rowid, }); } @@ -1620,6 +1672,7 @@ class PlaylistsCompanion extends UpdateCompanion { Value? createdAtMs, Value? updatedAtMs, Value? deleted, + Value? cloudId, Value? rowid, }) { return PlaylistsCompanion( @@ -1629,6 +1682,7 @@ class PlaylistsCompanion extends UpdateCompanion { createdAtMs: createdAtMs ?? this.createdAtMs, updatedAtMs: updatedAtMs ?? this.updatedAtMs, deleted: deleted ?? this.deleted, + cloudId: cloudId ?? this.cloudId, rowid: rowid ?? this.rowid, ); } @@ -1654,6 +1708,9 @@ class PlaylistsCompanion extends UpdateCompanion { if (deleted.present) { map['deleted'] = Variable(deleted.value); } + if (cloudId.present) { + map['cloud_id'] = Variable(cloudId.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -1669,6 +1726,7 @@ class PlaylistsCompanion extends UpdateCompanion { ..write('createdAtMs: $createdAtMs, ') ..write('updatedAtMs: $updatedAtMs, ') ..write('deleted: $deleted, ') + ..write('cloudId: $cloudId, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -4216,6 +4274,7 @@ typedef $$PlaylistsTableCreateCompanionBuilder = required int createdAtMs, required int updatedAtMs, Value deleted, + Value cloudId, Value rowid, }); typedef $$PlaylistsTableUpdateCompanionBuilder = @@ -4226,6 +4285,7 @@ typedef $$PlaylistsTableUpdateCompanionBuilder = Value createdAtMs, Value updatedAtMs, Value deleted, + Value cloudId, Value rowid, }); @@ -4291,6 +4351,11 @@ class $$PlaylistsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get cloudId => $composableBuilder( + column: $table.cloudId, + builder: (column) => ColumnFilters(column), + ); + Expression playlistSongsRefs( Expression Function($$PlaylistSongsTableFilterComposer f) f, ) { @@ -4355,6 +4420,11 @@ class $$PlaylistsTableOrderingComposer column: $table.deleted, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get cloudId => $composableBuilder( + column: $table.cloudId, + builder: (column) => ColumnOrderings(column), + ); } class $$PlaylistsTableAnnotationComposer @@ -4390,6 +4460,9 @@ class $$PlaylistsTableAnnotationComposer GeneratedColumn get deleted => $composableBuilder(column: $table.deleted, builder: (column) => column); + GeneratedColumn get cloudId => + $composableBuilder(column: $table.cloudId, builder: (column) => column); + Expression playlistSongsRefs( Expression Function($$PlaylistSongsTableAnnotationComposer a) f, ) { @@ -4450,6 +4523,7 @@ class $$PlaylistsTableTableManager Value createdAtMs = const Value.absent(), Value updatedAtMs = const Value.absent(), Value deleted = const Value.absent(), + Value cloudId = const Value.absent(), Value rowid = const Value.absent(), }) => PlaylistsCompanion( id: id, @@ -4458,6 +4532,7 @@ class $$PlaylistsTableTableManager createdAtMs: createdAtMs, updatedAtMs: updatedAtMs, deleted: deleted, + cloudId: cloudId, rowid: rowid, ), createCompanionCallback: @@ -4468,6 +4543,7 @@ class $$PlaylistsTableTableManager required int createdAtMs, required int updatedAtMs, Value deleted = const Value.absent(), + Value cloudId = const Value.absent(), Value rowid = const Value.absent(), }) => PlaylistsCompanion.insert( id: id, @@ -4476,6 +4552,7 @@ class $$PlaylistsTableTableManager createdAtMs: createdAtMs, updatedAtMs: updatedAtMs, deleted: deleted, + cloudId: cloudId, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/test/library/playlist_cloud_id_test.dart b/test/library/playlist_cloud_id_test.dart new file mode 100644 index 0000000..e067f7b --- /dev/null +++ b/test/library/playlist_cloud_id_test.dart @@ -0,0 +1,57 @@ +import 'package:drift/drift.dart' show Migrator; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; + +void main() { + test('Bestandsdaten überleben die neue Spalte', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + + // Den Stand von Schema 10 nachbauen: Tabelle ohne cloud_id, mit Daten. + await db.customStatement('DROP TABLE playlist_songs'); + await db.customStatement('DROP TABLE playlists'); + await db.customStatement( + 'CREATE TABLE playlists (' + 'id TEXT NOT NULL, ' + 'name TEXT NOT NULL, ' + 'description TEXT NULL, ' + 'created_at_ms INTEGER NOT NULL, ' + 'updated_at_ms INTEGER NOT NULL, ' + 'deleted INTEGER NOT NULL DEFAULT 0, ' + 'PRIMARY KEY (id))', + ); + await db.customStatement( + "INSERT INTO playlists (id, name, created_at_ms, updated_at_ms) " + "VALUES ('alt-1', 'Road Trip', 0, 0)", + ); + + await Migrator(db).addColumn(db.playlists, db.playlists.cloudId); + + final rows = await db.select(db.playlists).get(); + expect(rows.single.name, 'Road Trip'); + expect(rows.single.cloudId, isNull); + }); + + test('setPlaylistCloudId merkt sich die Server-ID', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + + final id = await db.createPlaylist('Mix'); + await db.setPlaylistCloudId(id, '42'); + + expect((await db.playlistById(id))!.cloudId, '42'); + }); + + test('countPlaylists zählt auch Grabsteine', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + + final id = await db.createPlaylist('Mix'); + await db.deletePlaylist(id); + + // Sonst hielte die Wiederherstellung ein Gerät, auf dem der Nutzer alle + // Playlisten gelöscht hat, für eine Neuinstallation. + expect(await db.countPlaylists(), 1); + }); +}