diff --git a/Modules/DataModel/Package.swift b/Modules/DataModel/Package.swift index 3de04ebd7a..eadb58ce02 100644 --- a/Modules/DataModel/Package.swift +++ b/Modules/DataModel/Package.swift @@ -17,6 +17,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/ccgus/fmdb.git", from: "2.0.0"), + .package(url: "https://github.com/groue/GRDB.swift.git", from: "6.29.3"), .package(path: "../Utils/") ], targets: [ @@ -24,6 +25,7 @@ let package = Package( name: "PocketCastsDataModel", dependencies: [ .product(name: "FMDB", package: "fmdb"), + .product(name: "GRDB", package: "GRDB.swift"), .product(name: "PocketCastsUtils", package: "Utils") ], path: "Sources" diff --git a/Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/Util/DatabaseHelper.swift b/Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/Util/DatabaseHelper.swift index 12ec22cef2..08726a6726 100644 --- a/Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/Util/DatabaseHelper.swift +++ b/Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/Util/DatabaseHelper.swift @@ -1,8 +1,39 @@ import FMDB +import GRDB import Foundation import PocketCastsUtils class DatabaseHelper { + // GRDB setup + class func setup(dbPool: DatabasePool) { + do { + try dbPool.write { db in + try db.execute(sql: "PRAGMA busy_timeout = 10000") + } + + var startingSchemaVersion: Int32 = 0 + var newSchemaVersion: Int32 = 0 + try dbPool.read { db in + + let rows = try Row.fetchCursor(db, sql: "PRAGMA user_version") + if let row = try? rows.next() { startingSchemaVersion = row["user_version"] } + + newSchemaVersion = startingSchemaVersion + + upgradeIfRequired(schemaVersion: &newSchemaVersion, dbPool: dbPool) + } + + try dbPool.write { db in + if newSchemaVersion != startingSchemaVersion { + try db.execute(sql: "PRAGMA user_version = \(newSchemaVersion)") + } + } + } catch { + FileLog.shared.addMessage("Failed to setup database, error: \(error)") + } + } + + // FMDB setup class func setup(db: FMDatabase) { do { try db.executeQuery("PRAGMA busy_timeout = 10000", values: nil).close() @@ -24,6 +55,564 @@ class DatabaseHelper { } } + // GRDB upgradeIfRequired + private class func upgradeIfRequired(schemaVersion: inout Int32, dbPool: DatabasePool) { + do { + try dbPool.writeInTransaction { db in + + if schemaVersion < 1 { + try db.execute(sql: """ + CREATE TABLE SJPodcast ( + id INTEGER PRIMARY KEY, + addedDate REAL NOT NULL, + autoDownloadSetting INTEGER NOT NULL DEFAULT 0, + episodeKeepSetting INTEGER NOT NULL DEFAULT 0, + backgroundColor TEXT, + detailColor TEXT, + primaryColor TEXT, + imageURL TEXT, + secondaryColor TEXT, + latestEpisodeUuid TEXT, + latestEpisodeDate REAL, + lastThumbnailDownloadDate REAL, + thumbnailStatus INTEGER NOT NULL DEFAULT 1, + mediaType TEXT, + playbackSpeed REAL NOT NULL DEFAULT 1, + podcastCategory TEXT, + podcastDescription TEXT, + podcastUrl TEXT, + author TEXT, + sortOrder INTEGER NOT NULL DEFAULT 0, + startFrom INTEGER NOT NULL DEFAULT 0, + subscribed INTEGER NOT NULL DEFAULT 1, + thumbnailURL TEXT, + title TEXT, + uuid TEXT NOT NULL, + syncStatus INTEGER NOT NULL DEFAULT 0, + wasDeleted INTEGER NOT NULL DEFAULT 0 + ); + """) + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS podcast_uuid ON SJPodcast (uuid);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS podcast_sync_status ON SJPodcast (syncStatus);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS podcast_was_deleted ON SJPodcast (wasDeleted);") + + try db.execute(sql: """ + CREATE TABLE SJEpisode ( + id INTEGER PRIMARY KEY, + addedDate REAL NOT NULL, + detailedDescription TEXT, + downloadErrorDetails TEXT, + downloadTaskId TEXT, + downloadUrl TEXT, + duration REAL NOT NULL DEFAULT 0, + episodeDescription TEXT, + episodeStatus INTEGER NOT NULL, + fileType TEXT, + keepEpisode INTEGER NOT NULL DEFAULT 0, + playedUpTo REAL NOT NULL DEFAULT 0, + playingStatus INTEGER NOT NULL, + publishedDate REAL, + showNotes TEXT, + sizeInBytes INTEGER NOT NULL DEFAULT 0, + title TEXT, + uuid TEXT NOT NULL, + podcastUuid TEXT NOT NULL, + wasDeleted INTEGER NOT NULL DEFAULT 0, + podcast_id INTEGER NOT NULL + ); + """) + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_uuid ON SJEpisode (uuid);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_podcast_uuid ON SJEpisode (podcastUuid);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_was_deleted ON SJEpisode (wasDeleted);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_pub_date ON SJEpisode (publishedDate);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_podcast_id ON SJEpisode (podcast_id);") + + try db.execute(sql: """ + CREATE TABLE SJFilteredPlaylist ( + id INTEGER PRIMARY KEY, + autoDownloadEpisodes INTEGER NOT NULL DEFAULT 0, + customIcon INTEGER NOT NULL DEFAULT 0, + filterAllPodcasts INTEGER NOT NULL DEFAULT 0, + filterAudioVideoType INTEGER NOT NULL DEFAULT 0, + filterDownloaded INTEGER NOT NULL DEFAULT 0, + filterDownloading INTEGER NOT NULL DEFAULT 0, + filterFinished INTEGER NOT NULL DEFAULT 0, + filterNotDownloaded INTEGER NOT NULL DEFAULT 0, + filterPartiallyPlayed INTEGER NOT NULL DEFAULT 0, + filterStarred INTEGER NOT NULL DEFAULT 0, + filterUnplayed INTEGER NOT NULL DEFAULT 0, + manual INTEGER NOT NULL DEFAULT 0, + playlistName TEXT NOT NULL, + podcastUuids TEXT, + sortPosition INTEGER NOT NULL DEFAULT 0, + sortType INTEGER NOT NULL DEFAULT 0, + uuid TEXT NOT NULL, + syncStatus INTEGER NOT NULL DEFAULT 0, + wasDeleted INTEGER NOT NULL DEFAULT 0 + ); + """) + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS filteredplaylist_uuid ON SJFilteredPlaylist (uuid);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS filteredplaylist_sync_status ON SJFilteredPlaylist (syncStatus);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS filteredplaylist_was_deleted ON SJFilteredPlaylist (wasDeleted);") + + try db.execute(sql: """ + CREATE TABLE SJPlaylistEpisode ( + id INTEGER PRIMARY KEY, + episodePosition INTEGER NOT NULL DEFAULT 0, + episodeUuid TEXT NOT NULL, + playlist_id INTEGER NOT NULL, + upcoming INTEGER NOT NULL DEFAULT 0 + ); + """) + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS playlist_episode_uuid ON SJPlaylistEpisode (episodeUuid);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS playlist_episode_playlist_id ON SJPlaylistEpisode (playlist_id);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS playlist_episode_upcoming ON SJPlaylistEpisode (upcoming);") + + schemaVersion = 1 + } + + if schemaVersion < 2 { + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_episodeStatus ON SJEpisode (episodeStatus);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_playingStatus ON SJEpisode (playingStatus);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_keepEpisode ON SJEpisode (keepEpisode);") + + schemaVersion = 2 + } + + if schemaVersion < 3 { + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN playingStatusModified INTEGER NOT NULL DEFAULT 0;") + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN playedUpToModified INTEGER NOT NULL DEFAULT 0;") + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN durationModified INTEGER NOT NULL DEFAULT 0;") + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN wasDeletedModified INTEGER NOT NULL DEFAULT 0;") + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN keepEpisodeModified INTEGER NOT NULL DEFAULT 0;") + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_playing_status_modified ON SJEpisode (playingStatusModified);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_played_opto_modified ON SJEpisode (playedUpToModified);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_duration_modified ON SJEpisode (durationModified);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_was_deleted_modified ON SJEpisode (wasDeletedModified);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_keep_episode_modified ON SJEpisode (keepEpisodeModified);") + + schemaVersion = 3 + } + + if schemaVersion < 4 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN pushEnabled INTEGER NOT NULL DEFAULT 1;") + schemaVersion = 4 + } + + if schemaVersion < 5 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN episodeSortOrder INTEGER NOT NULL DEFAULT 1;") + schemaVersion = 5 + } + + if schemaVersion < 6 { + try db.execute(sql: "DELETE FROM SJFilteredPlaylist WHERE manual == 1;") + try db.execute(sql: "DELETE FROM SJPlaylistEpisode WHERE upcoming != 1;") + schemaVersion = 6 + } + + if schemaVersion < 7 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN autoAddToUpNext INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 7 + } + + if schemaVersion < 8 { + try db.execute(sql: "ALTER TABLE SJFilteredPlaylist ADD COLUMN filterHours INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 8 + } + + if schemaVersion < 9 { + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN lastDownloadAttemptDate REAL NOT NULL DEFAULT 0;") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS ep_down_date ON SJEpisode (lastDownloadAttemptDate);") + schemaVersion = 9 + } + + if schemaVersion < 10 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN colorVersion INTEGER NOT NULL DEFAULT 1;") + schemaVersion = 10 + } + + if schemaVersion < 11 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN boostVolume INTEGER NOT NULL DEFAULT 0;") + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN trimSilenceAmount INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 11 + } + + if schemaVersion < 12 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN lastColorDownloadDate REAL;") + schemaVersion = 12 + } + + if schemaVersion < 13 { + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN autoDownloadStatus INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 13 + } + + if schemaVersion < 14 { + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN playbackErrorDetails TEXT;") + schemaVersion = 14 + } + + if schemaVersion < 15 { + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN cachedFrameCount INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 15 + } + + if schemaVersion < 16 { + try db.execute(sql: "DELETE FROM SJPlaylistEpisode WHERE upcoming != 1;") + try db.execute(sql: "DROP INDEX IF EXISTS playlist_episode_upcoming;") + + try db.execute(sql: "ALTER TABLE SJPlaylistEpisode ADD COLUMN timeModified INTEGER NOT NULL DEFAULT 0;") + try db.execute(sql: "ALTER TABLE SJPlaylistEpisode ADD COLUMN wasDeleted INTEGER NOT NULL DEFAULT 0;") + try db.execute(sql: "ALTER TABLE SJPlaylistEpisode ADD COLUMN title TEXT;") + try db.execute(sql: "ALTER TABLE SJPlaylistEpisode ADD COLUMN podcastUuid TEXT;") + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS playlist_episode_time_modified ON SJPlaylistEpisode (timeModified);") + schemaVersion = 16 + } + + if schemaVersion < 17 { + try db.execute(sql: "UPDATE SJEpisode set showNotes = NULL;") + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN lastPlaybackInteractionDate REAL;") + schemaVersion = 17 + } + + if schemaVersion < 18 { + try db.execute(sql: """ + CREATE TABLE UpNextChanges ( + id INTEGER PRIMARY KEY, + type INTEGER NOT NULL, + uuid TEXT, + uuids TEXT, + utcTime INTEGER NOT NULL + ); + """) + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS up_next_changes_episode ON UpNextChanges (uuid);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS up_next_changes_time ON UpNextChanges (utcTime);") + try db.execute(sql: "DROP INDEX IF EXISTS playlist_episode_time_modified;") + + schemaVersion = 18 + } + + if schemaVersion < 20 { + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN episodeNumber INTEGER NOT NULL DEFAULT -1;") + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN seasonNumber INTEGER NOT NULL DEFAULT -1;") + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN episodeType TEXT;") + + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN showType TEXT;") + + schemaVersion = 20 + } + + if schemaVersion < 21 { + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN lastPlaybackInteractionSyncStatus INTEGER NOT NULL DEFAULT 1;") + try db.execute(sql: "UPDATE SJEpisode SET lastPlaybackInteractionSyncStatus = 0 WHERE lastPlaybackInteractionDate IS NOT NULL AND lastPlaybackInteractionDate > 0;") + + schemaVersion = 21 + } + + if schemaVersion < 22 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN estimatedNextEpisode REAL;") + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN episodeFrequency TEXT;") + + schemaVersion = 22 + } + + if schemaVersion < 23 { + try db.execute(sql: "DROP INDEX IF EXISTS episode_was_deleted_modified;") + try db.execute(sql: "DROP INDEX IF EXISTS podcast_was_deleted;") + + // remove any really old deleted episodes that could be still around + try db.execute(sql: "DELETE FROM SJEpisode WHERE wasDeleted = 1;") + + // set any podcasts that might have been deleted to be unsubscribed instead + try db.execute(sql: "UPDATE SJPodcast SET subscribed = 0 WHERE wasDeleted = 1;") + + schemaVersion = 23 + } + + if schemaVersion < 24 { + // set any podcasts that might have been deleted to be unsubscribed instead + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN lastUpdatedAt TEXT;") + schemaVersion = 24 + } + + if schemaVersion < 25 { + // remove any really old deleted episodes that could be still around + try db.execute(sql: "DELETE FROM SJEpisode WHERE wasDeleted = 1;") + + // add archive columns to episode table + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;") + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN archivedModified INTEGER NOT NULL DEFAULT 0;") + + // add opt out of auto archive on podcast table + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN excludeFromAutoArchive INTEGER NOT NULL DEFAULT 0;") + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_archived_modified ON SJEpisode (archivedModified);") + + schemaVersion = 25 + } + + if schemaVersion < 26 { + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN lastArchiveInteractionDate REAL NOT NULL DEFAULT 0;") + schemaVersion = 26 + } + + if schemaVersion < 27 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN overrideGlobalEffects INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 27 + } + + if schemaVersion < 28 { + try db.execute(sql: "ALTER TABLE SJFilteredPlaylist ADD COLUMN autoDownloadLimit INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 28 + } + + if schemaVersion < 29 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN overrideGlobalArchive INTEGER NOT NULL DEFAULT 0;") + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN autoArchivePlayedAfter REAL NOT NULL DEFAULT -1;") + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN autoArchiveInactiveAfter REAL NOT NULL DEFAULT -1;") + + // migrate people who had opt out on, to be overriding global. Since the defaults for all the other settings are off we don't have to worry about setting those + try db.execute(sql: "UPDATE SJPodcast SET overrideGlobalArchive = 1 WHERE excludeFromAutoArchive = 1;") + schemaVersion = 29 + } + + if schemaVersion < 30 { + // since we're re-using the old database column that was for keep, clear out any legacy values that might be in there + try db.execute(sql: "UPDATE SJPodcast SET episodeKeepSetting = 0;") + + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN excludeFromEpisodeLimit INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 30 + } + + if schemaVersion < 31 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN episodeGrouping INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 31 + } + + if schemaVersion < 32 { + try db.execute(sql: """ + CREATE TABLE SJUserEpisode ( + id INTEGER PRIMARY KEY, + addedDate REAL NOT NULL, + lastDownloadAttemptDate REAL NOT NULL DEFAULT 0, + downloadErrorDetails TEXT, + downloadTaskId TEXT, + downloadUrl TEXT, + episodeStatus INTEGER NOT NULL, + fileType TEXT, + playedUpTo REAL NOT NULL DEFAULT 0, + duration REAL NOT NULL DEFAULT 0, + playingStatus INTEGER NOT NULL, + autoDownloadStatus INTEGER NOT NULL DEFAULT 0, + publishedDate REAL, + sizeInBytes INTEGER NOT NULL DEFAULT 0, + playingStatusModified INTEGER NOT NULL DEFAULT 0, + playedUpToModified INTEGER NOT NULL DEFAULT 0, + title TEXT, + uuid TEXT NOT NULL, + playbackErrorDetails TEXT, + cachedFrameCount INTEGER NOT NULL DEFAULT 0, + imageUrl TEXT, + uploadStatus INTEGER NOT NULL, + uploadTaskId TEXT, + imageColor INTEGER NOT NULL, + titleModified INTEGER NOT NULL DEFAULT 0, + imageColorModified INTEGER NOT NULL DEFAULT 0, + imageModified INTEGER NOT NULL DEFAULT 0, + durationModified INTEGER NOT NULL DEFAULT 0, + hasCustomImage BOOLEAN DEFAULT FALSE + ); + """) + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS user_episode_uuid ON SJUserEpisode (uuid);") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS user_episode_episodeStatus ON SJUserEpisode (episodeStatus);") + schemaVersion = 32 + } + + if schemaVersion < 33 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN skipLast INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 33 + } + + if schemaVersion < 34 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN isPaid INTEGER NOT NULL DEFAULT 0;") + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN fullSyncLastSyncAt TEXT;") + schemaVersion = 34 + } + + if schemaVersion < 35 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN showArchived INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 35 + } + + if schemaVersion < 36 { + try db.execute(sql: "ALTER TABLE SJFilteredPlaylist ADD COLUMN filterDuration INTEGER NOT NULL DEFAULT 0;") + try db.execute(sql: "ALTER TABLE SJFilteredPlaylist ADD COLUMN longerThan INTEGER NOT NULL DEFAULT 0;") + try db.execute(sql: "ALTER TABLE SJFilteredPlaylist ADD COLUMN shorterThan INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 36 + } + + if schemaVersion < 37 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN licensing INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 37 + } + + if schemaVersion < 38 { + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN starredModified INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 38 + } + + if schemaVersion < 39 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN refreshAvailable INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 39 + } + + if schemaVersion < 40 { + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS Folder ( + uuid TEXT NOT NULL, + name TEXT NOT NULL, + color INTEGER NOT NULL, + addedDate INTEGER NOT NULL, + sortOrder INTEGER NOT NULL, + sortType INTEGER NOT NULL, + wasDeleted INTEGER NOT NULL, + syncModified INTEGER NOT NULL, + PRIMARY KEY(uuid) + ); + """) + + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN folderUuid TEXT;") + + schemaVersion = 40 + } + + if schemaVersion < 41 { + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS AutoAddCandidates ( + id INTEGER PRIMARY KEY, + episode_uuid varchar(40) NOT NULL, + podcast_uuid varchar(40) NOT NULL + ); + """) + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS candidate_episode ON AutoAddCandidates (episode_uuid)") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS candidate_podcast ON AutoAddCandidates (podcast_uuid)") + + schemaVersion = 41 + } + + if schemaVersion < 42 { + try BookmarkDataManager.createTable(in: db) + + schemaVersion = 42 + } + + if schemaVersion < 43 { + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN deselectedChapters TEXT;") + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN settings TEXT NOT NULL DEFAULT '';") + schemaVersion = 43 + } + + if schemaVersion < 44 { + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN deselectedChaptersModified INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 44 + } + + if schemaVersion < 45 { + try db.execute(sql: """ + CREATE TABLE EpisodeMetadata ( + episodeUuid TEXT PRIMARY KEY, + metadata TEXT NOT NULL + ); + """) + schemaVersion = 45 + } + + if schemaVersion < 46 { + try db.execute(sql: "DROP TABLE EpisodeMetadata;") + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN metadata TEXT;") + schemaVersion = 46 + } + + if schemaVersion < 47 { + try db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN contentType TEXT;") + try db.execute(sql: "ALTER TABLE SJUserEpisode ADD COLUMN contentType TEXT;") + schemaVersion = 47 + } + + // Those migrations were some heavy DROP COLUMN that we moved outside of DB startup + if schemaVersion < 48 { + schemaVersion = 48 + } + if schemaVersion < 49 { + schemaVersion = 49 + } + + if schemaVersion < 50 { + // We are doing try? because depending of the cleanup process was done or not these columns could have been dropped and need to recreated + // or they still exist because of the changes of version 47. + try? db.execute(sql: "ALTER TABLE SJEpisode ADD COLUMN contentType TEXT;") + try? db.execute(sql: "ALTER TABLE SJUserEpisode ADD COLUMN contentType TEXT;") + schemaVersion = 50 + } + + if schemaVersion < 51 { + try db.execute(sql: """ + CREATE TABLE PlaylistEpisodeHistory ( + id INTEGER KEY, + episodePosition INTEGER NOT NULL DEFAULT 0, + episodeUuid TEXT NOT NULL, + playlist_id INTEGER NOT NULL, + upcoming INTEGER NOT NULL DEFAULT 0, + timeModified INTEGER NOT NULL DEFAULT 0, + wasDeleted INTEGER NOT NULL DEFAULT 0, + title TEXT, + podcastUuid TEXT, + date REAL NOT NULL + ); + """) + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS episode_history_date ON PlaylistEpisodeHistory (date);") + + schemaVersion = 51 + } + + if schemaVersion < 52 { + try db.execute(sql: """ + CREATE TABLE PodcastFoldersHistory ( + podcastUuid TEXT NOT NULL, + folderUuid TEXT NOT NULL, + date REAL NOT NULL + ); + """) + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS podcast_folders_history_date ON PlaylistEpisodeHistory (date);") + + schemaVersion = 52 + } + + if schemaVersion < 53 { + try db.execute(sql: "ALTER TABLE SJPodcast ADD COLUMN usedCustomEffectsBefore INTEGER NOT NULL DEFAULT 0;") + schemaVersion = 53 + } + + + return .commit + } + } catch { + FileLog.shared.addMessage("Schema update \(schemaVersion+1) failed: \(error.localizedDescription)") + } + } + + // FMDB upgradeIfRequired private class func upgradeIfRequired(schemaVersion: inout Int32, db: FMDatabase) { db.beginTransaction() diff --git a/Modules/DataModel/Sources/PocketCastsDataModel/Public/Bookmarks/BookmarkDataManager.swift b/Modules/DataModel/Sources/PocketCastsDataModel/Public/Bookmarks/BookmarkDataManager.swift index e1a4647c1e..365274ba74 100644 --- a/Modules/DataModel/Sources/PocketCastsDataModel/Public/Bookmarks/BookmarkDataManager.swift +++ b/Modules/DataModel/Sources/PocketCastsDataModel/Public/Bookmarks/BookmarkDataManager.swift @@ -1,4 +1,5 @@ import FMDB +import GRDB import PocketCastsUtils public struct BookmarkDataManager { @@ -323,6 +324,29 @@ extension BookmarkDataManager { try db.executeUpdate("CREATE INDEX IF NOT EXISTS bookmark_podcast ON \(Self.tableName) (\(Column.podcast));", values: nil) try db.executeUpdate("CREATE INDEX IF NOT EXISTS bookmark_deleted ON \(Self.tableName) (\(Column.deleted));", values: nil) } + + static func createTable(in db: Database) throws { + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS \(Self.tableName) ( + \(Column.uuid) varchar(40) NOT NULL, + \(Column.title) varchar(100) NOT NULL, + \(Column.titleModifiedDate) INTEGER, + \(Column.episode) varchar(40) NOT NULL, + \(Column.podcast) varchar(40), + \(Column.time) real NOT NULL, + \(Column.createdDate) INTEGER NOT NULL, + \(Column.deleted) int NOT NULL DEFAULT 0, + \(Column.deletedModifiedDate) INTEGER, + \(Column.syncStatus) int NOT NULL DEFAULT 0, + PRIMARY KEY (\(Column.uuid)) + ); + """) + + try db.execute(sql: "CREATE INDEX IF NOT EXISTS bookmark_uuid ON \(Self.tableName) (\(Column.uuid));") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS bookmark_episode ON \(Self.tableName) (\(Column.episode));") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS bookmark_podcast ON \(Self.tableName) (\(Column.podcast));") + try db.execute(sql: "CREATE INDEX IF NOT EXISTS bookmark_deleted ON \(Self.tableName) (\(Column.deleted));") + } } // MARK: - Bookmark from FMResultSet diff --git a/Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift b/Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift index d0887f49af..9cc043bbab 100644 --- a/Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift +++ b/Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift @@ -1,4 +1,5 @@ import FMDB +import GRDB import PocketCastsUtils import SQLite3 @@ -28,6 +29,10 @@ public class DataManager { private let dbQueue: FMDatabaseQueue + // GRDB + + private let dbPool: DatabasePool! + public static let sharedManager = DataManager() /// Creates a DataManager using a queue that is persisted to a local SQLIte file @@ -45,13 +50,19 @@ public class DataManager { public init(dbQueue: FMDatabaseQueue, shouldCloseQueueAfterSetup: Bool = true) { self.dbQueue = dbQueue - dbQueue.inDatabase { db in - DatabaseHelper.setup(db: db) - } + dbPool = FeatureFlag.grdb.enabled ? try! DatabasePool(path: DataManager.pathToDb()) : nil - if shouldCloseQueueAfterSetup { - // "You don't need to close it during the app lifecycle, unless you modify the schema." Since the above method can modify the schema, we do that here as recommended by the author of FMDB - dbQueue.close() + if FeatureFlag.grdb.enabled { + DatabaseHelper.setup(dbPool: dbPool) + } else { + dbQueue.inDatabase { db in + DatabaseHelper.setup(db: db) + } + + if shouldCloseQueueAfterSetup { + // "You don't need to close it during the app lifecycle, unless you modify the schema." Since the above method can modify the schema, we do that here as recommended by the author of FMDB + dbQueue.close() + } } // closing it above won't affect these calls, since they will re-open it diff --git a/Modules/Utils/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift b/Modules/Utils/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift index 477ca59403..dfa7260fb9 100644 --- a/Modules/Utils/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift +++ b/Modules/Utils/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift @@ -129,7 +129,10 @@ public enum FeatureFlag: String, CaseIterable { /// Uses the episode IDs from the server's response rather than our local database IDs case useSyncResponseEpisodeIDs - ///Use html description for podcast details + /// Uses the new database toolkit + case grdb + + ///Use html description for podcast details case usePodcastHTMLDescription /// Disables logout / keychain clearing when errors occur in the background @@ -218,9 +221,11 @@ public enum FeatureFlag: String, CaseIterable { case .winback: false case .manageDownloadedEpisodes: - true + true case .useSyncResponseEpisodeIDs: true + case .grdb: + true case .usePodcastHTMLDescription: false case .avoidLogoutInBackground: diff --git a/podcasts.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/podcasts.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cd4c693210..35e9a2cddb 100644 --- a/podcasts.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/podcasts.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -127,6 +127,15 @@ "version": "7.12.1" } }, + { + "package": "GRDB", + "repositoryURL": "https://github.com/groue/GRDB.swift.git", + "state": { + "branch": null, + "revision": "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", + "version": "6.29.3" + } + }, { "package": "gRPC", "repositoryURL": "https://github.com/google/grpc-binary.git", @@ -249,8 +258,17 @@ "repositoryURL": "https://github.com/dagronf/SwiftSubtitles", "state": { "branch": null, - "revision": "751866f632ccf438025472eb1c1dccffbc5176a3", - "version": "1.5.2" + "revision": "aaa309326c2b8bfb52b7fdfabb1a43820c2e26b2", + "version": "1.8.2" + } + }, + { + "package": "Swime", + "repositoryURL": "https://github.com/danielebogo/Swime", + "state": { + "branch": "master", + "revision": "6a507c6480de4603bc5b6f178d4b1855b9c05a8c", + "version": null } }, { diff --git a/podcasts.xcworkspace/xcshareddata/swiftpm/Package.resolved b/podcasts.xcworkspace/xcshareddata/swiftpm/Package.resolved index d70595f636..30ed432d37 100644 --- a/podcasts.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/podcasts.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -127,6 +127,15 @@ "version": "7.13.3" } }, + { + "package": "GRDB", + "repositoryURL": "https://github.com/groue/GRDB.swift.git", + "state": { + "branch": null, + "revision": "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", + "version": "6.29.3" + } + }, { "package": "gRPC", "repositoryURL": "https://github.com/google/grpc-binary.git",