feat: add DatabaseService with schema, FTS5 search, and query methods

feat/music-streaming
Laurent 1 month ago
parent a8978f7eae
commit e0e03daf3f
  1. 164
      Music/Services/DatabaseService.swift
  2. 101
      MusicTests/DatabaseServiceTests.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<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…
Cancel
Save