feat: add migration v5 and structured condition query support to DatabaseService

Adds the conditions column to smart_playlists via migration v5, removes the
temporary encode(to:) override now that GRDB Codable synthesis handles the JSON
column automatically, and wires up fetchTracks(conditions:), createSmartPlaylist
(name:conditions:), and updateSmartPlaylistConditions with parameterized WHERE
clause building. 17/17 SmartPlaylistTests pass.
feat/music-streaming
Laurent 1 month ago
parent 50daf23e11
commit e3930821f4
  1. 15
      Music/Models/SmartPlaylist.swift
  2. 90
      Music/Services/DatabaseService.swift
  3. 143
      MusicTests/SmartPlaylistTests.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
}

@ -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<String> {
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])

@ -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)
}
}

Loading…
Cancel
Save