diff --git a/Music/Models/SmartPlaylist.swift b/Music/Models/SmartPlaylist.swift index c507f40..e19cdb3 100644 --- a/Music/Models/SmartPlaylist.swift +++ b/Music/Models/SmartPlaylist.swift @@ -12,21 +12,6 @@ nonisolated struct SmartPlaylist: Codable, Identifiable, Equatable, Hashable, Se 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 } diff --git a/Music/Services/DatabaseService.swift b/Music/Services/DatabaseService.swift index 7a18344..fc00464 100644 --- a/Music/Services/DatabaseService.swift +++ b/Music/Services/DatabaseService.swift @@ -116,6 +116,12 @@ nonisolated final class DatabaseService: Sendable { } } + migrator.registerMigration("v5-add-smart-playlist-conditions") { db in + try db.alter(table: "smart_playlists") { t in + t.add(column: "conditions", .text) + } + } + try migrator.migrate(db) } @@ -216,6 +222,26 @@ nonisolated final class DatabaseService: Sendable { ) } + func fetchTracks(conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] { + try dbPool.read { db in + try self.fetchTracks(db: db, conditions: conditions, sortColumn: sortColumn, ascending: ascending) + } + } + + func fetchTracks(db: Database, conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] { + let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title" + let order = ascending ? "ASC" : "DESC" + let (whereSQL, args) = buildWhereClause(conditions) + if whereSQL.isEmpty { + return try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)") + } + return try Track.fetchAll( + db, + sql: "SELECT * FROM tracks WHERE \(whereSQL) ORDER BY \(col) COLLATE NOCASE \(order)", + arguments: args + ) + } + func allFileURLs() throws -> Set { try dbPool.read { db in let urls = try String.fetchAll(db, sql: "SELECT fileURL FROM tracks") @@ -457,6 +483,49 @@ nonisolated final class DatabaseService: Sendable { // MARK: - Smart Playlists + /// Builds a parameterized SQL WHERE clause from an array of conditions. + /// Column names come from `TrackField.rawValue` (an enum, not user input) — + /// safe to interpolate. Values are always bound via `StatementArguments` (`?`) + /// to prevent SQL injection. + private func buildWhereClause(_ conditions: [SmartPlaylistCondition]) -> (sql: String, arguments: StatementArguments) { + guard !conditions.isEmpty else { return ("", StatementArguments()) } + var fragments: [String] = [] + var args: [DatabaseValueConvertible?] = [] + + for condition in conditions { + let col = condition.field.rawValue + switch (condition.op, condition.value) { + case (.equals, .string(let s)): + fragments.append("LOWER(\(col)) = LOWER(?)") + args.append(s) + case (.startsWith, .string(let s)): + fragments.append("LOWER(\(col)) LIKE LOWER(?) || '%'") + args.append(s) + case (.equals, .int(let i)): + fragments.append("\(col) = ?"); args.append(i) + case (.greaterThan, .int(let i)): + fragments.append("\(col) > ?"); args.append(i) + case (.lessThan, .int(let i)): + fragments.append("\(col) < ?"); args.append(i) + case (.equals, .double(let d)): + fragments.append("\(col) = ?"); args.append(d) + case (.greaterThan, .double(let d)): + fragments.append("\(col) > ?"); args.append(d) + case (.lessThan, .double(let d)): + fragments.append("\(col) < ?"); args.append(d) + case (.equals, .date(let date)): + fragments.append("\(col) = ?"); args.append(date) + case (.greaterThan, .date(let date)): + fragments.append("\(col) > ?"); args.append(date) + case (.lessThan, .date(let date)): + fragments.append("\(col) < ?"); args.append(date) + default: + break + } + } + return (fragments.joined(separator: " AND "), StatementArguments(args)) + } + func createSmartPlaylist(name: String, searchQuery: String) throws -> SmartPlaylist { try dbPool.write { db in var smartPlaylist = SmartPlaylist( @@ -467,6 +536,16 @@ nonisolated final class DatabaseService: Sendable { } } + func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws -> SmartPlaylist { + try dbPool.write { db in + var smartPlaylist = SmartPlaylist( + id: nil, name: name, searchQuery: "", createdAt: Date(), conditions: conditions + ) + try smartPlaylist.insert(db) + return smartPlaylist + } + } + func renameSmartPlaylist(id: Int64, name: String) throws { try dbPool.write { db in try db.execute( @@ -485,6 +564,17 @@ nonisolated final class DatabaseService: Sendable { } } + func updateSmartPlaylistConditions(id: Int64, conditions: [SmartPlaylistCondition]) throws { + let data = try JSONEncoder().encode(conditions) + let json = String(data: data, encoding: .utf8) + try dbPool.write { db in + try db.execute( + sql: "UPDATE smart_playlists SET conditions = ? WHERE id = ?", + arguments: [json, id] + ) + } + } + func deleteSmartPlaylist(id: Int64) throws { try dbPool.write { db in try db.execute(sql: "DELETE FROM smart_playlists WHERE id = ?", arguments: [id]) diff --git a/MusicTests/SmartPlaylistTests.swift b/MusicTests/SmartPlaylistTests.swift index 5d33112..2155e0c 100644 --- a/MusicTests/SmartPlaylistTests.swift +++ b/MusicTests/SmartPlaylistTests.swift @@ -122,4 +122,147 @@ struct SmartPlaylistTests { if case .int(let y) = decoded[1].value { #expect(y == 1960) } else { Issue.record("Expected int") } if case .date(let d) = decoded[2].value { #expect(d == Date(timeIntervalSince1970: 0)) } else { Issue.record("Expected date") } } + + // Creates an in-memory DB (which runs all migrations including v5) and verifies + // that existing FTS smart playlists (conditions = nil) still load correctly. + @Test func existingFTSPlaylistSurvivesMigration() throws { + // Step 1: Create DB — migration v5 runs automatically, adding the conditions column + // Step 2: Create a FTS smart playlist using the old searchQuery path + // Step 3: Fetch it back and verify conditions is nil, searchQuery is intact + let db = try DatabaseService(inMemory: true) + _ = try db.createSmartPlaylist(name: "Jazz", searchQuery: "jazz") + let all = try db.fetchSmartPlaylists() + #expect(all.count == 1) + #expect(all[0].searchQuery == "jazz") + #expect(all[0].conditions == nil) + } + + // Inserts two tracks with different artists, fetches with an equals condition + // on artist, and verifies only the matching track is returned. + @Test func fetchTracksWithEqualsCondition() throws { + // Step 1: Insert two tracks with different artists + // Step 2: Build a condition: artist equals "Miles Davis" + // Step 3: Fetch tracks with that condition and verify only one returned + let db = try DatabaseService(inMemory: true) + var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis") + var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles") + try db.insert(&t1) + try db.insert(&t2) + let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))] + let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) + #expect(results.count == 1) + #expect(results[0].artist == "Miles Davis") + } + + // Verifies that string equals matching is case-insensitive. + @Test func fetchTracksEqualsIsCaseInsensitive() throws { + // Step 1: Insert a track with mixed-case artist "Miles Davis" + // Step 2: Fetch using lowercase "miles davis" + // Step 3: Verify the track is returned + let db = try DatabaseService(inMemory: true) + var t = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis") + try db.insert(&t) + let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("miles davis"))] + let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) + #expect(results.count == 1) + } + + // Verifies that startsWith matches case-insensitively on the leading prefix. + @Test func fetchTracksWithStartsWithCondition() throws { + // Step 1: Insert a Miles Davis track and an Eagles track + // Step 2: Fetch with artist startsWith "miles" (lowercase) + // Step 3: Verify only Miles Davis is returned + let db = try DatabaseService(inMemory: true) + var t1 = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis") + var t2 = Track.fixture(fileURL: "/b.mp3", artist: "Eagles") + try db.insert(&t1) + try db.insert(&t2) + let conditions = [SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("miles"))] + let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) + #expect(results.count == 1) + #expect(results[0].artist == "Miles Davis") + } + + // Verifies that greaterThan on an integer field returns only tracks strictly + // above the threshold value. + @Test func fetchTracksWithGreaterThanCondition() throws { + // Step 1: Insert tracks with years 1990, 2010, 2020 + // Step 2: Fetch with year > 2000 + // Step 3: Verify 2010 and 2020 are returned; 1990 is not + let db = try DatabaseService(inMemory: true) + var t1 = Track.fixture(fileURL: "/a.mp3", year: 1990) + var t2 = Track.fixture(fileURL: "/b.mp3", year: 2010) + var t3 = Track.fixture(fileURL: "/c.mp3", year: 2020) + try db.insert(&t1) + try db.insert(&t2) + try db.insert(&t3) + let conditions = [SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(2000))] + let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) + #expect(results.count == 2) + #expect(results.allSatisfy { ($0.year ?? 0) > 2000 }) + } + + // Verifies that multiple conditions are AND-ed: only tracks matching all + // conditions are returned. + @Test func fetchTracksWithMultipleAndConditions() throws { + // Step 1: Insert three tracks — two Miles Davis (1959, 1970) and one Eagles (1975) + // Step 2: Fetch with artist = "Miles Davis" AND year > 1960 + // Step 3: Verify only the 1970 Miles Davis track is returned + let db = try DatabaseService(inMemory: true) + var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis", year: 1959) + var t2 = Track.fixture(fileURL: "/b.mp3", title: "Bitches Brew", artist: "Miles Davis", year: 1970) + var t3 = Track.fixture(fileURL: "/c.mp3", title: "Hotel California", artist: "Eagles", year: 1975) + try db.insert(&t1) + try db.insert(&t2) + try db.insert(&t3) + let conditions = [ + SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")), + SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)) + ] + let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) + #expect(results.count == 1) + #expect(results[0].title == "Bitches Brew") + } + + // Creates a conditions-based smart playlist, fetches it back, and verifies the + // conditions survive the JSON round-trip through GRDB's Codable synthesis. + @Test func createSmartPlaylistWithConditionsPersists() throws { + // Step 1: Create a conditions-based playlist with artist equals and year > conditions + // Step 2: Fetch all smart playlists + // Step 3: Verify both conditions survived the DB round-trip intact + let db = try DatabaseService(inMemory: true) + let conditions = [ + SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")), + SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)) + ] + _ = try db.createSmartPlaylist(name: "Late Miles", conditions: conditions) + let all = try db.fetchSmartPlaylists() + #expect(all.count == 1) + #expect(all[0].conditions?.count == 2) + #expect(all[0].conditions?[0].field == .artist) + #expect(all[0].conditions?[1].op == .greaterThan) + if case .int(let y) = all[0].conditions?[1].value { + #expect(y == 1960) + } else { + Issue.record("Expected int value") + } + } + + // Updates the conditions of a structured smart playlist and verifies the updated + // conditions are persisted and fetch back correctly. + @Test func updateSmartPlaylistConditionsPersists() throws { + // Step 1: Create a playlist with artist = "Eagles" + // Step 2: Update its conditions to genre startsWith "Jazz" + // Step 3: Fetch and verify the updated conditions are stored + let db = try DatabaseService(inMemory: true) + let sp = try db.createSmartPlaylist( + name: "Test", + conditions: [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Eagles"))] + ) + let newConditions = [SmartPlaylistCondition(field: .genre, op: .startsWith, value: .string("Jazz"))] + try db.updateSmartPlaylistConditions(id: sp.id!, conditions: newConditions) + let all = try db.fetchSmartPlaylists() + #expect(all[0].conditions?.count == 1) + #expect(all[0].conditions?[0].field == .genre) + } }