From e0e03daf3f9f2d81f79973db26247dda8384f781 Mon Sep 17 00:00:00 2001 From: Laurent Date: Sat, 23 May 2026 23:38:10 +0200 Subject: [PATCH] feat: add DatabaseService with schema, FTS5 search, and query methods --- Music/Services/DatabaseService.swift | 164 ++++++++++++++++++++++++++ MusicTests/DatabaseServiceTests.swift | 101 ++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 Music/Services/DatabaseService.swift create mode 100644 MusicTests/DatabaseServiceTests.swift diff --git a/Music/Services/DatabaseService.swift b/Music/Services/DatabaseService.swift new file mode 100644 index 0000000..e74851e --- /dev/null +++ b/Music/Services/DatabaseService.swift @@ -0,0 +1,164 @@ +import Foundation +import GRDB + +// `nonisolated` opts this class out of the project-wide `default-isolation = MainActor` +// setting so database operations run off the main actor and can be used from Sendable contexts. +nonisolated final class DatabaseService: Sendable { + let dbPool: DatabaseWriter + + init(path: String) throws { + let dbPool = try DatabasePool(path: path) + self.dbPool = dbPool + try Self.migrate(dbPool) + } + + init(inMemory: Bool) throws { + let dbQueue = try DatabaseQueue() + self.dbPool = dbQueue + try Self.migrate(dbQueue) + } + + private static func migrate(_ db: DatabaseWriter) throws { + var migrator = DatabaseMigrator() + + migrator.registerMigration("v1-create-tracks") { db in + try db.create(table: "tracks") { t in + t.autoIncrementedPrimaryKey("id") + t.column("fileURL", .text).notNull().unique() + t.column("title", .text).notNull() + t.column("artist", .text).notNull() + t.column("albumArtist", .text).notNull() + t.column("album", .text).notNull() + t.column("genre", .text).notNull() + t.column("year", .integer) + t.column("trackNumber", .integer) + t.column("discNumber", .integer) + t.column("duration", .double).notNull() + t.column("bpm", .integer) + t.column("composer", .text).notNull() + t.column("fileFormat", .text).notNull() + t.column("bitrate", .integer) + t.column("sampleRate", .integer) + t.column("fileSize", .integer).notNull() + t.column("artworkData", .blob) + t.column("playCount", .integer).notNull().defaults(to: 0) + t.column("lastPlayedAt", .datetime) + t.column("rating", .integer).notNull().defaults(to: 0) + t.column("dateAdded", .datetime).notNull() + t.column("dateModified", .datetime).notNull() + t.column("fileHash", .text).notNull() + } + + try db.create(index: "idx_tracks_artist", on: "tracks", columns: ["artist"]) + try db.create(index: "idx_tracks_album", on: "tracks", columns: ["album"]) + try db.create(index: "idx_tracks_genre", on: "tracks", columns: ["genre"]) + try db.create(index: "idx_tracks_year", on: "tracks", columns: ["year"]) + try db.create( + index: "idx_tracks_album_order", + on: "tracks", + columns: ["albumArtist", "album", "discNumber", "trackNumber"] + ) + + try db.create(virtualTable: "tracks_ft", using: FTS5()) { t in + t.synchronize(withTable: "tracks") + t.tokenizer = .unicode61() + t.column("title") + t.column("artist") + t.column("albumArtist") + t.column("album") + t.column("genre") + t.column("composer") + } + } + + try migrator.migrate(db) + } + + // MARK: - Write + + func insert(_ track: inout Track) throws { + try dbPool.write { db in + try track.insert(db) + } + } + + func insertBatch(_ tracks: [Track]) throws { + try dbPool.write { db in + for var track in tracks { + try track.insert(db, onConflict: .ignore) + } + } + } + + func updatePlayStats(trackId: Int64, playCount: Int, lastPlayedAt: Date) throws { + try dbPool.write { db in + try db.execute( + sql: "UPDATE tracks SET playCount = ?, lastPlayedAt = ? WHERE id = ?", + arguments: [playCount, lastPlayedAt, trackId] + ) + } + } + + func deleteTracksWithURLs(_ urls: Set) throws { + try dbPool.write { db in + let placeholders = databaseQuestionMarks(count: urls.count) + try db.execute( + sql: "DELETE FROM tracks WHERE fileURL IN (\(placeholders))", + arguments: StatementArguments(Array(urls)) + ) + } + } + + // MARK: - Read + + private static let validSortColumns: Set = [ + "title", "artist", "albumArtist", "album", "genre", "year", "duration", + "trackNumber", "dateAdded", "playCount", "rating", "bpm" + ] + + func fetchTracks(search: String, sortColumn: String, ascending: Bool) throws -> [Track] { + try dbPool.read { db in + try self.fetchTracks(db: db, search: search, sortColumn: sortColumn, ascending: ascending) + } + } + + // Used by ValueObservation which already holds a Database access + func fetchTracks(db: Database, search: String, sortColumn: String, ascending: Bool) throws -> [Track] { + let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title" + let order = ascending ? "ASC" : "DESC" + + if search.trimmingCharacters(in: .whitespaces).isEmpty { + return try Track.fetchAll( + db, + sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)" + ) + } + + let terms = search.split(separator: " ").map { "\($0)*" } + let pattern = terms.joined(separator: " ") + + return try Track.fetchAll( + db, + sql: """ + SELECT tracks.* FROM tracks + JOIN tracks_ft ON tracks_ft.rowid = tracks.id + WHERE tracks_ft MATCH ? + ORDER BY \(col) COLLATE NOCASE \(order) + """, + arguments: [pattern] + ) + } + + func allFileURLs() throws -> Set { + try dbPool.read { db in + let urls = try String.fetchAll(db, sql: "SELECT fileURL FROM tracks") + return Set(urls) + } + } + + func trackCount() throws -> Int { + try dbPool.read { db in + try Track.fetchCount(db) + } + } +} diff --git a/MusicTests/DatabaseServiceTests.swift b/MusicTests/DatabaseServiceTests.swift new file mode 100644 index 0000000..1438112 --- /dev/null +++ b/MusicTests/DatabaseServiceTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing +import GRDB +@testable import Music + +struct DatabaseServiceTests { + // Creates an in-memory DatabaseService, inserts a track, and fetches it back. + @Test func insertAndFetchTrack() throws { + let db = try DatabaseService(inMemory: true) + var track = Track.fixture(title: "Test Song", artist: "Test Artist") + try db.insert(&track) + + let tracks = try db.fetchTracks(search: "", sortColumn: "title", ascending: true) + #expect(tracks.count == 1) + #expect(tracks[0].title == "Test Song") + } + + // Inserts multiple tracks and verifies sorting by different columns. + @Test func fetchTracksSortedByArtist() throws { + let db = try DatabaseService(inMemory: true) + var t1 = Track.fixture(fileURL: "/a.mp3", title: "Zebra", artist: "Alpha") + var t2 = Track.fixture(fileURL: "/b.mp3", title: "Alpha", artist: "Zebra") + try db.insert(&t1) + try db.insert(&t2) + + let ascending = try db.fetchTracks(search: "", sortColumn: "artist", ascending: true) + #expect(ascending[0].artist == "Alpha") + #expect(ascending[1].artist == "Zebra") + + let descending = try db.fetchTracks(search: "", sortColumn: "artist", ascending: false) + #expect(descending[0].artist == "Zebra") + } + + // Searches using FTS5 and verifies only matching tracks are returned. + @Test func fts5Search() throws { + let db = try DatabaseService(inMemory: true) + var t1 = Track.fixture(fileURL: "/a.mp3", title: "Bohemian Rhapsody", artist: "Queen") + var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles") + var t3 = Track.fixture(fileURL: "/c.mp3", title: "Stairway to Heaven", artist: "Led Zeppelin") + try db.insert(&t1) + try db.insert(&t2) + try db.insert(&t3) + + let results = try db.fetchTracks(search: "queen", sortColumn: "title", ascending: true) + #expect(results.count == 1) + #expect(results[0].title == "Bohemian Rhapsody") + } + + // Searches with a prefix to verify autocomplete-style matching. + @Test func fts5PrefixSearch() throws { + let db = try DatabaseService(inMemory: true) + var t1 = Track.fixture(fileURL: "/a.mp3", title: "Bohemian Rhapsody", artist: "Queen") + var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles") + try db.insert(&t1) + try db.insert(&t2) + + let results = try db.fetchTracks(search: "boh", sortColumn: "title", ascending: true) + #expect(results.count == 1) + #expect(results[0].title == "Bohemian Rhapsody") + } + + // Inserts a batch of tracks and verifies duplicates are silently ignored. + @Test func batchInsertIgnoresDuplicates() throws { + let db = try DatabaseService(inMemory: true) + let tracks = [ + Track.fixture(fileURL: "/a.mp3", title: "Song A"), + Track.fixture(fileURL: "/b.mp3", title: "Song B"), + Track.fixture(fileURL: "/a.mp3", title: "Song A Duplicate"), + ] + try db.insertBatch(tracks) + + let all = try db.fetchTracks(search: "", sortColumn: "title", ascending: true) + #expect(all.count == 2) + } + + // Updates play stats and verifies they persist. + @Test func updatePlayStats() throws { + let db = try DatabaseService(inMemory: true) + var track = Track.fixture() + try db.insert(&track) + + let now = Date() + try db.updatePlayStats(trackId: track.id!, playCount: 5, lastPlayedAt: now) + + let fetched = try db.fetchTracks(search: "", sortColumn: "title", ascending: true) + #expect(fetched[0].playCount == 5) + #expect(fetched[0].lastPlayedAt != nil) + } + + // Validates that an invalid sort column falls back to "title". + @Test func invalidSortColumnFallsBackToTitle() throws { + let db = try DatabaseService(inMemory: true) + var t1 = Track.fixture(fileURL: "/a.mp3", title: "Zebra") + var t2 = Track.fixture(fileURL: "/b.mp3", title: "Alpha") + try db.insert(&t1) + try db.insert(&t2) + + let result = try db.fetchTracks(search: "", sortColumn: "DROP TABLE tracks", ascending: true) + #expect(result[0].title == "Alpha") + } +}