parent
a8978f7eae
commit
e0e03daf3f
@ -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<String>) 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<String> = [ |
||||||
|
"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<String> { |
||||||
|
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) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue