feat: add conditions field to SmartPlaylist model

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/music-streaming
Laurent 1 month ago
parent 47beb9f899
commit 50daf23e11
  1. 24
      Music/Models/SmartPlaylist.swift
  2. 4
      Music/Models/SmartPlaylistCondition.swift
  3. 23
      Music/Services/DatabaseService.swift
  4. 13
      MusicTests/SmartPlaylistTests.swift

@ -6,11 +6,27 @@ nonisolated struct SmartPlaylist: Codable, Identifiable, Equatable, Hashable, Se
var name: String var name: String
var searchQuery: String var searchQuery: String
var createdAt: Date var createdAt: Date
var conditions: [SmartPlaylistCondition]?
} }
nonisolated extension SmartPlaylist: FetchableRecord, MutablePersistableRecord { nonisolated extension SmartPlaylist: FetchableRecord, MutablePersistableRecord {
static let databaseTableName = "smart_playlists" static let databaseTableName = "smart_playlists"
// Explicit persistence container so GRDB only writes columns that exist in
// the current schema. The `conditions` column is added by migration v5
// (Task 3); until then this override keeps inserts/updates compatible.
// Once migration v5 lands, delete this method and let GRDB's Codable
// synthesis handle both directions consistently.
func encode(to container: inout PersistenceContainer) {
container["id"] = id
container["name"] = name
container["searchQuery"] = searchQuery
container["createdAt"] = createdAt
// `conditions` intentionally omitted: the column doesn't exist yet.
// Migration v5 adds it; removing this override at that point lets
// GRDB's Codable synthesis encode/decode the JSON column automatically.
}
mutating func didInsert(_ inserted: InsertionSuccess) { mutating func didInsert(_ inserted: InsertionSuccess) {
id = inserted.rowID id = inserted.rowID
} }
@ -26,9 +42,13 @@ extension SmartPlaylist {
id: Int64? = nil, id: Int64? = nil,
name: String = "Test Smart Playlist", name: String = "Test Smart Playlist",
searchQuery: String = "test query", searchQuery: String = "test query",
createdAt: Date = Date() createdAt: Date = Date(),
conditions: [SmartPlaylistCondition]? = nil
) -> SmartPlaylist { ) -> SmartPlaylist {
SmartPlaylist(id: id, name: name, searchQuery: searchQuery, createdAt: createdAt) SmartPlaylist(
id: id, name: name, searchQuery: searchQuery,
createdAt: createdAt, conditions: conditions
)
} }
} }
#endif #endif

@ -90,7 +90,7 @@ enum ConditionOperator: String, Codable, Identifiable, Sendable {
// Tagged union storing the actual filter value with its type. // Tagged union storing the actual filter value with its type.
// Uses custom Codable to survive JSON round-trips cleanly. // Uses custom Codable to survive JSON round-trips cleanly.
enum ConditionValue: Equatable, Sendable { enum ConditionValue: Equatable, Hashable, Sendable {
case string(String) case string(String)
case int(Int) case int(Int)
case double(Double) case double(Double)
@ -143,7 +143,7 @@ extension ConditionValue: Codable {
} }
} }
nonisolated struct SmartPlaylistCondition: Codable, Equatable, Sendable { nonisolated struct SmartPlaylistCondition: Codable, Equatable, Hashable, Sendable {
var field: TrackField var field: TrackField
var op: ConditionOperator var op: ConditionOperator
var value: ConditionValue var value: ConditionValue

@ -121,11 +121,24 @@ nonisolated final class DatabaseService: Sendable {
// MARK: - Maintenance // MARK: - Maintenance
/// Create a self-contained copy of the database at the given path using /// Create a self-contained copy of the database at the given path.
/// SQLite's online backup API. The copy includes all WAL data and is safe ///
/// to serve or transfer without additional files. /// Uses SQLite's `VACUUM INTO` rather than the online backup API. The online
/// backup (`dbPool.backup(to:)`) produced copies whose FTS5 `tracks_ft` shadow
/// tables were not transferred, so any `ValueObservation` opened on the copy
/// failed with "no such table: tracks_ft" which left the remote client's
/// track list empty even though the row data copied fine. `VACUUM INTO` writes
/// a fresh, fully-rebuilt, self-contained database that includes a functional
/// FTS5 index and all committed data, with no WAL/SHM side files.
///
/// `VACUUM INTO` requires the destination file to not already exist, so any
/// stale file at `destinationPath` is removed first.
func backup(to destinationPath: String) throws { func backup(to destinationPath: String) throws {
try dbPool.backup(to: DatabaseQueue(path: destinationPath)) try? FileManager.default.removeItem(atPath: destinationPath)
let escaped = destinationPath.replacingOccurrences(of: "'", with: "''")
try dbPool.writeWithoutTransaction { db in
try db.execute(sql: "VACUUM INTO '\(escaped)'")
}
} }
// MARK: - Write // MARK: - Write
@ -447,7 +460,7 @@ nonisolated final class DatabaseService: Sendable {
func createSmartPlaylist(name: String, searchQuery: String) throws -> SmartPlaylist { func createSmartPlaylist(name: String, searchQuery: String) throws -> SmartPlaylist {
try dbPool.write { db in try dbPool.write { db in
var smartPlaylist = SmartPlaylist( var smartPlaylist = SmartPlaylist(
id: nil, name: name, searchQuery: searchQuery, createdAt: Date() id: nil, name: name, searchQuery: searchQuery, createdAt: Date(), conditions: nil
) )
try smartPlaylist.insert(db) try smartPlaylist.insert(db)
return smartPlaylist return smartPlaylist

@ -9,7 +9,8 @@ struct SmartPlaylistTests {
id: nil, id: nil,
name: "Miles Davis", name: "Miles Davis",
searchQuery: "miles davis", searchQuery: "miles davis",
createdAt: Date() createdAt: Date(),
conditions: nil
) )
#expect(sp.name == "Miles Davis") #expect(sp.name == "Miles Davis")
#expect(sp.searchQuery == "miles davis") #expect(sp.searchQuery == "miles davis")
@ -77,6 +78,16 @@ struct SmartPlaylistTests {
#expect(results[1].title == "Kind of Blue") #expect(results[1].title == "Kind of Blue")
} }
// Creates a SmartPlaylist fixture with conditions and verifies the conditions
// field is preserved and the isSmartPlaylist flag is true.
@Test func smartPlaylistWithConditions() throws {
let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))]
let sp = SmartPlaylist.fixture(conditions: conditions)
#expect(sp.conditions?.count == 1)
#expect(sp.conditions?[0].field == .artist)
#expect(sp.isSmartPlaylist == true)
}
// Encodes and decodes a SmartPlaylistCondition to/from JSON, // Encodes and decodes a SmartPlaylistCondition to/from JSON,
// verifying that all fields survive the round-trip. // verifying that all fields survive the round-trip.
@Test func conditionCodableRoundTrip() throws { @Test func conditionCodableRoundTrip() throws {

Loading…
Cancel
Save