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 searchQuery: String
var createdAt: Date
var conditions: [SmartPlaylistCondition]?
}
nonisolated extension SmartPlaylist: FetchableRecord, MutablePersistableRecord {
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) {
id = inserted.rowID
}
@ -26,9 +42,13 @@ extension SmartPlaylist {
id: Int64? = nil,
name: String = "Test Smart Playlist",
searchQuery: String = "test query",
createdAt: Date = Date()
createdAt: Date = Date(),
conditions: [SmartPlaylistCondition]? = nil
) -> SmartPlaylist {
SmartPlaylist(id: id, name: name, searchQuery: searchQuery, createdAt: createdAt)
SmartPlaylist(
id: id, name: name, searchQuery: searchQuery,
createdAt: createdAt, conditions: conditions
)
}
}
#endif

@ -90,7 +90,7 @@ enum ConditionOperator: String, Codable, Identifiable, Sendable {
// Tagged union storing the actual filter value with its type.
// Uses custom Codable to survive JSON round-trips cleanly.
enum ConditionValue: Equatable, Sendable {
enum ConditionValue: Equatable, Hashable, Sendable {
case string(String)
case int(Int)
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 op: ConditionOperator
var value: ConditionValue

@ -121,11 +121,24 @@ nonisolated final class DatabaseService: Sendable {
// MARK: - Maintenance
/// Create a self-contained copy of the database at the given path using
/// SQLite's online backup API. The copy includes all WAL data and is safe
/// to serve or transfer without additional files.
/// Create a self-contained copy of the database at the given path.
///
/// 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 {
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
@ -447,7 +460,7 @@ nonisolated final class DatabaseService: Sendable {
func createSmartPlaylist(name: String, searchQuery: String) throws -> SmartPlaylist {
try dbPool.write { db in
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)
return smartPlaylist

@ -9,7 +9,8 @@ struct SmartPlaylistTests {
id: nil,
name: "Miles Davis",
searchQuery: "miles davis",
createdAt: Date()
createdAt: Date(),
conditions: nil
)
#expect(sp.name == "Miles Davis")
#expect(sp.searchQuery == "miles davis")
@ -77,6 +78,16 @@ struct SmartPlaylistTests {
#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,
// verifying that all fields survive the round-trip.
@Test func conditionCodableRoundTrip() throws {

Loading…
Cancel
Save