mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Move modules to Modules folder.
This commit is contained in:
53
Modules/RSDatabase/Sources/RSDatabase/Database.swift
Normal file
53
Modules/RSDatabase/Sources/RSDatabase/Database.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// Database.swift
|
||||
// RSDatabase
|
||||
//
|
||||
// Created by Brent Simmons on 12/15/19.
|
||||
// Copyright © 2019 Brent Simmons. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSDatabaseObjC
|
||||
|
||||
public enum DatabaseError: Error {
|
||||
case isSuspended // On iOS, to support background refreshing, a database may be suspended.
|
||||
}
|
||||
|
||||
/// Result type that provides an FMDatabase or a DatabaseError.
|
||||
public typealias DatabaseResult = Result<FMDatabase, DatabaseError>
|
||||
|
||||
/// Block that executes database code or handles DatabaseQueueError.
|
||||
public typealias DatabaseBlock = (DatabaseResult) -> Void
|
||||
|
||||
/// Completion block that provides an optional DatabaseError.
|
||||
public typealias DatabaseCompletionBlock = (DatabaseError?) -> Void
|
||||
|
||||
/// Result type for fetching an Int or getting a DatabaseError.
|
||||
public typealias DatabaseIntResult = Result<Int, DatabaseError>
|
||||
|
||||
/// Completion block for DatabaseIntResult.
|
||||
public typealias DatabaseIntCompletionBlock = (DatabaseIntResult) -> Void
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
public extension DatabaseResult {
|
||||
/// Convenience for getting the database from a DatabaseResult.
|
||||
var database: FMDatabase? {
|
||||
switch self {
|
||||
case .success(let database):
|
||||
return database
|
||||
case .failure:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience for getting the error from a DatabaseResult.
|
||||
var error: DatabaseError? {
|
||||
switch self {
|
||||
case .success:
|
||||
return nil
|
||||
case .failure(let error):
|
||||
return error
|
||||
}
|
||||
}
|
||||
}
|
||||
61
Modules/RSDatabase/Sources/RSDatabase/DatabaseObject.swift
Normal file
61
Modules/RSDatabase/Sources/RSDatabase/DatabaseObject.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// DatabaseObject.swift
|
||||
// RSDatabase
|
||||
//
|
||||
// Created by Brent Simmons on 8/7/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public typealias DatabaseDictionary = [String: Any]
|
||||
|
||||
public protocol DatabaseObject {
|
||||
|
||||
var databaseID: String { get }
|
||||
|
||||
func databaseDictionary() -> DatabaseDictionary?
|
||||
|
||||
func relatedObjectsWithName(_ name: String) -> [DatabaseObject]?
|
||||
}
|
||||
|
||||
public extension DatabaseObject {
|
||||
|
||||
func relatedObjectsWithName(_ name: String) -> [DatabaseObject]? {
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == DatabaseObject {
|
||||
|
||||
func dictionary() -> [String: DatabaseObject] {
|
||||
|
||||
var d = [String: DatabaseObject]()
|
||||
for object in self {
|
||||
d[object.databaseID] = object
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func databaseIDs() -> Set<String> {
|
||||
|
||||
return Set(self.map { $0.databaseID })
|
||||
}
|
||||
|
||||
func includesObjectWithDatabaseID(_ databaseID: String) -> Bool {
|
||||
|
||||
for object in self {
|
||||
if object.databaseID == databaseID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func databaseDictionaries() -> [DatabaseDictionary]? {
|
||||
|
||||
let dictionaries = self.compactMap{ $0.databaseDictionary() }
|
||||
return dictionaries.isEmpty ? nil : dictionaries
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// DatabaseObjectCache.swift
|
||||
// RSDatabase
|
||||
//
|
||||
// Created by Brent Simmons on 9/12/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class DatabaseObjectCache {
|
||||
|
||||
private var d = [String: DatabaseObject]()
|
||||
|
||||
public init() {
|
||||
//
|
||||
}
|
||||
public func add(_ databaseObjects: [DatabaseObject]) {
|
||||
|
||||
for databaseObject in databaseObjects {
|
||||
self[databaseObject.databaseID] = databaseObject
|
||||
}
|
||||
}
|
||||
|
||||
public subscript(_ databaseID: String) -> DatabaseObject? {
|
||||
get {
|
||||
return d[databaseID]
|
||||
}
|
||||
set {
|
||||
d[databaseID] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
259
Modules/RSDatabase/Sources/RSDatabase/DatabaseQueue.swift
Normal file
259
Modules/RSDatabase/Sources/RSDatabase/DatabaseQueue.swift
Normal file
@@ -0,0 +1,259 @@
|
||||
//
|
||||
// DatabaseQueue.swift
|
||||
// RSDatabase
|
||||
//
|
||||
// Created by Brent Simmons on 11/13/19.
|
||||
// Copyright © 2019 Brent Simmons. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SQLite3
|
||||
import RSDatabaseObjC
|
||||
|
||||
/// Manage a serial queue and a SQLite database.
|
||||
/// It replaces RSDatabaseQueue, which is deprecated.
|
||||
/// Main-thread only.
|
||||
/// Important note: on iOS, the queue can be suspended
|
||||
/// in order to support background refreshing.
|
||||
public final class DatabaseQueue {
|
||||
|
||||
/// Check to see if the queue is suspended. Read-only.
|
||||
/// Calling suspend() and resume() will change the value of this property.
|
||||
/// This will return true only on iOS — on macOS it’s always false.
|
||||
public var isSuspended: Bool {
|
||||
#if os(iOS)
|
||||
precondition(Thread.isMainThread)
|
||||
return _isSuspended
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
private var _isSuspended = true
|
||||
private var isCallingDatabase = false
|
||||
private let database: FMDatabase
|
||||
private let databasePath: String
|
||||
private let serialDispatchQueue: DispatchQueue
|
||||
private let targetDispatchQueue: DispatchQueue
|
||||
#if os(iOS)
|
||||
private let databaseLock = NSLock()
|
||||
#endif
|
||||
|
||||
/// When init returns, the database will not be suspended: it will be ready for database calls.
|
||||
public init(databasePath: String) {
|
||||
precondition(Thread.isMainThread)
|
||||
|
||||
self.serialDispatchQueue = DispatchQueue(label: "DatabaseQueue (Serial) - \(databasePath)", attributes: .initiallyInactive)
|
||||
self.targetDispatchQueue = DispatchQueue(label: "DatabaseQueue (Target) - \(databasePath)")
|
||||
self.serialDispatchQueue.setTarget(queue: self.targetDispatchQueue)
|
||||
self.serialDispatchQueue.activate()
|
||||
|
||||
self.databasePath = databasePath
|
||||
self.database = FMDatabase(path: databasePath)!
|
||||
openDatabase()
|
||||
_isSuspended = false
|
||||
}
|
||||
|
||||
// MARK: - Suspend and Resume
|
||||
|
||||
/// Close the SQLite database and don’t allow database calls until resumed.
|
||||
/// This is for iOS, where we need to close the SQLite database in some conditions.
|
||||
///
|
||||
/// After calling suspend, if you call into the database before calling resume,
|
||||
/// your code will not run, and runInDatabaseSync and runInTransactionSync will
|
||||
/// both throw DatabaseQueueError.isSuspended.
|
||||
///
|
||||
/// On Mac, suspend() and resume() are no-ops, since there isn’t a need for them.
|
||||
public func suspend() {
|
||||
#if os(iOS)
|
||||
precondition(Thread.isMainThread)
|
||||
guard !_isSuspended else {
|
||||
return
|
||||
}
|
||||
|
||||
_isSuspended = true
|
||||
|
||||
serialDispatchQueue.suspend()
|
||||
targetDispatchQueue.async {
|
||||
self.lockDatabase()
|
||||
self.database.close()
|
||||
self.unlockDatabase()
|
||||
DispatchQueue.main.async {
|
||||
self.serialDispatchQueue.resume()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Open the SQLite database. Allow database calls again.
|
||||
/// This is also for iOS only.
|
||||
public func resume() {
|
||||
#if os(iOS)
|
||||
precondition(Thread.isMainThread)
|
||||
guard _isSuspended else {
|
||||
return
|
||||
}
|
||||
|
||||
serialDispatchQueue.suspend()
|
||||
targetDispatchQueue.sync {
|
||||
if _isSuspended {
|
||||
lockDatabase()
|
||||
openDatabase()
|
||||
unlockDatabase()
|
||||
_isSuspended = false
|
||||
}
|
||||
}
|
||||
serialDispatchQueue.resume()
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Make Database Calls
|
||||
|
||||
/// Run a DatabaseBlock synchronously. This call will block the main thread
|
||||
/// potentially for a while, depending on how long it takes to execute
|
||||
/// the DatabaseBlock *and* depending on how many other calls have been
|
||||
/// scheduled on the queue. Use sparingly — prefer async versions.
|
||||
public func runInDatabaseSync(_ databaseBlock: DatabaseBlock) {
|
||||
precondition(Thread.isMainThread)
|
||||
serialDispatchQueue.sync {
|
||||
self._runInDatabase(self.database, databaseBlock, false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a DatabaseBlock asynchronously.
|
||||
public func runInDatabase(_ databaseBlock: @escaping DatabaseBlock) {
|
||||
precondition(Thread.isMainThread)
|
||||
serialDispatchQueue.async {
|
||||
self._runInDatabase(self.database, databaseBlock, false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a DatabaseBlock wrapped in a transaction synchronously.
|
||||
/// Transactions help performance significantly when updating the database.
|
||||
/// Nevertheless, it’s best to avoid this because it will block the main thread —
|
||||
/// prefer the async `runInTransaction` instead.
|
||||
public func runInTransactionSync(_ databaseBlock: @escaping DatabaseBlock) {
|
||||
precondition(Thread.isMainThread)
|
||||
serialDispatchQueue.sync {
|
||||
self._runInDatabase(self.database, databaseBlock, true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a DatabaseBlock wrapped in a transaction asynchronously.
|
||||
/// Transactions help performance significantly when updating the database.
|
||||
public func runInTransaction(_ databaseBlock: @escaping DatabaseBlock) {
|
||||
precondition(Thread.isMainThread)
|
||||
serialDispatchQueue.async {
|
||||
self._runInDatabase(self.database, databaseBlock, true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run all the lines that start with "create".
|
||||
/// Use this to create tables, indexes, etc.
|
||||
public func runCreateStatements(_ statements: String) throws {
|
||||
precondition(Thread.isMainThread)
|
||||
var error: DatabaseError? = nil
|
||||
runInDatabaseSync { result in
|
||||
switch result {
|
||||
case .success(let database):
|
||||
statements.enumerateLines { (line, stop) in
|
||||
if line.lowercased().hasPrefix("create") {
|
||||
database.executeStatements(line)
|
||||
}
|
||||
stop = false
|
||||
}
|
||||
case .failure(let databaseError):
|
||||
error = databaseError
|
||||
}
|
||||
}
|
||||
if let error = error {
|
||||
throw(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact the database. This should be done from time to time —
|
||||
/// weekly-ish? — to keep up the performance level of a database.
|
||||
/// Generally a thing to do at startup, if it’s been a while
|
||||
/// since the last vacuum() call. You almost certainly want to call
|
||||
/// vacuumIfNeeded instead.
|
||||
public func vacuum() {
|
||||
precondition(Thread.isMainThread)
|
||||
runInDatabase { result in
|
||||
result.database?.executeStatements("vacuum;")
|
||||
}
|
||||
}
|
||||
|
||||
/// Vacuum the database if it’s been more than `daysBetweenVacuums` since the last vacuum.
|
||||
/// Normally you would call this right after initing a DatabaseQueue.
|
||||
///
|
||||
/// - Returns: true if database will be vacuumed.
|
||||
@discardableResult
|
||||
public func vacuumIfNeeded(daysBetweenVacuums: Int) -> Bool {
|
||||
precondition(Thread.isMainThread)
|
||||
let defaultsKey = "DatabaseQueue-LastVacuumDate-\(databasePath)"
|
||||
let minimumVacuumInterval = TimeInterval(daysBetweenVacuums * (60 * 60 * 24)) // Doesn’t have to be precise
|
||||
let now = Date()
|
||||
let cutoffDate = now - minimumVacuumInterval
|
||||
if let lastVacuumDate = UserDefaults.standard.object(forKey: defaultsKey) as? Date {
|
||||
if lastVacuumDate < cutoffDate {
|
||||
vacuum()
|
||||
UserDefaults.standard.set(now, forKey: defaultsKey)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Never vacuumed — almost certainly a new database.
|
||||
// Just set the LastVacuumDate pref to now and skip vacuuming.
|
||||
UserDefaults.standard.set(now, forKey: defaultsKey)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private extension DatabaseQueue {
|
||||
|
||||
func lockDatabase() {
|
||||
#if os(iOS)
|
||||
databaseLock.lock()
|
||||
#endif
|
||||
}
|
||||
|
||||
func unlockDatabase() {
|
||||
#if os(iOS)
|
||||
databaseLock.unlock()
|
||||
#endif
|
||||
}
|
||||
|
||||
func _runInDatabase(_ database: FMDatabase, _ databaseBlock: DatabaseBlock, _ useTransaction: Bool) {
|
||||
lockDatabase()
|
||||
defer {
|
||||
unlockDatabase()
|
||||
}
|
||||
|
||||
precondition(!isCallingDatabase)
|
||||
|
||||
isCallingDatabase = true
|
||||
autoreleasepool {
|
||||
if _isSuspended {
|
||||
databaseBlock(.failure(.isSuspended))
|
||||
}
|
||||
else {
|
||||
if useTransaction {
|
||||
database.beginTransaction()
|
||||
}
|
||||
databaseBlock(.success(database))
|
||||
if useTransaction {
|
||||
database.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
isCallingDatabase = false
|
||||
}
|
||||
|
||||
func openDatabase() {
|
||||
database.open()
|
||||
database.executeStatements("PRAGMA synchronous = 1;")
|
||||
database.setShouldCacheStatements(true)
|
||||
}
|
||||
}
|
||||
|
||||
139
Modules/RSDatabase/Sources/RSDatabase/DatabaseTable.swift
Normal file
139
Modules/RSDatabase/Sources/RSDatabase/DatabaseTable.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// DatabaseTable.swift
|
||||
// RSDatabase
|
||||
//
|
||||
// Created by Brent Simmons on 7/16/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSDatabaseObjC
|
||||
|
||||
public protocol DatabaseTable {
|
||||
|
||||
var name: String { get }
|
||||
}
|
||||
|
||||
public extension DatabaseTable {
|
||||
|
||||
// MARK: Fetching
|
||||
|
||||
func selectRowsWhere(key: String, equals value: Any, in database: FMDatabase) -> FMResultSet? {
|
||||
|
||||
return database.rs_selectRowsWhereKey(key, equalsValue: value, tableName: name)
|
||||
}
|
||||
|
||||
func selectSingleRowWhere(key: String, equals value: Any, in database: FMDatabase) -> FMResultSet? {
|
||||
|
||||
return database.rs_selectSingleRowWhereKey(key, equalsValue: value, tableName: name)
|
||||
}
|
||||
|
||||
func selectRowsWhere(key: String, inValues values: [Any], in database: FMDatabase) -> FMResultSet? {
|
||||
|
||||
if values.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return database.rs_selectRowsWhereKey(key, inValues: values, tableName: name)
|
||||
}
|
||||
|
||||
// MARK: Deleting
|
||||
|
||||
func deleteRowsWhere(key: String, equalsAnyValue values: [Any], in database: FMDatabase) {
|
||||
|
||||
if values.isEmpty {
|
||||
return
|
||||
}
|
||||
database.rs_deleteRowsWhereKey(key, inValues: values, tableName: name)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
|
||||
func updateRowsWithValue(_ value: Any, valueKey: String, whereKey: String, matches: [Any], database: FMDatabase) {
|
||||
|
||||
let _ = database.rs_updateRows(withValue: value, valueKey: valueKey, whereKey: whereKey, inValues: matches, tableName: self.name)
|
||||
}
|
||||
|
||||
func updateRowsWithDictionary(_ dictionary: DatabaseDictionary, whereKey: String, matches: Any, database: FMDatabase) {
|
||||
|
||||
let _ = database.rs_updateRows(with: dictionary, whereKey: whereKey, equalsValue: matches, tableName: self.name)
|
||||
}
|
||||
|
||||
// MARK: Saving
|
||||
|
||||
func insertRows(_ dictionaries: [DatabaseDictionary], insertType: RSDatabaseInsertType, in database: FMDatabase) {
|
||||
|
||||
for oneDictionary in dictionaries {
|
||||
let _ = database.rs_insertRow(with: oneDictionary, insertType: insertType, tableName: self.name)
|
||||
}
|
||||
}
|
||||
|
||||
func insertRow(_ rowDictionary: DatabaseDictionary, insertType: RSDatabaseInsertType, in database: FMDatabase) {
|
||||
|
||||
insertRows([rowDictionary], insertType: insertType, in: database)
|
||||
}
|
||||
|
||||
// MARK: Counting
|
||||
|
||||
func numberWithCountResultSet(_ resultSet: FMResultSet) -> Int {
|
||||
|
||||
guard resultSet.next() else {
|
||||
return 0
|
||||
}
|
||||
return Int(resultSet.int(forColumnIndex: 0))
|
||||
}
|
||||
|
||||
func numberWithSQLAndParameters(_ sql: String, _ parameters: [Any], in database: FMDatabase) -> Int {
|
||||
|
||||
if let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) {
|
||||
return numberWithCountResultSet(resultSet)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// MARK: Mapping
|
||||
|
||||
func mapResultSet<T>(_ resultSet: FMResultSet, _ completion: (_ resultSet: FMResultSet) -> T?) -> [T] {
|
||||
|
||||
var objects = [T]()
|
||||
while resultSet.next() {
|
||||
if let obj = completion(resultSet) {
|
||||
objects += [obj]
|
||||
}
|
||||
}
|
||||
return objects
|
||||
}
|
||||
|
||||
// MARK: Columns
|
||||
|
||||
func containsColumn(_ columnName: String, in database: FMDatabase) -> Bool {
|
||||
if let resultSet = database.executeQuery("select * from \(name) limit 1;", withArgumentsIn: nil) {
|
||||
if let columnMap = resultSet.columnNameToIndexMap {
|
||||
if let _ = columnMap[columnName.lowercased()] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public extension FMResultSet {
|
||||
|
||||
func compactMap<T>(_ completion: (_ row: FMResultSet) -> T?) -> [T] {
|
||||
|
||||
var objects = [T]()
|
||||
while next() {
|
||||
if let obj = completion(self) {
|
||||
objects += [obj]
|
||||
}
|
||||
}
|
||||
close()
|
||||
return objects
|
||||
}
|
||||
|
||||
func mapToSet<T>(_ completion: (_ row: FMResultSet) -> T?) -> Set<T> {
|
||||
|
||||
return Set(compactMap(completion))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//
|
||||
// DatabaseLookupTable.swift
|
||||
// RSDatabase
|
||||
//
|
||||
// Created by Brent Simmons on 8/5/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSDatabaseObjC
|
||||
|
||||
// Implement a lookup table for a many-to-many relationship.
|
||||
// Example: CREATE TABLE if not EXISTS authorLookup (authorID TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(authorID, articleID));
|
||||
// articleID is objectID; authorID is relatedObjectID.
|
||||
|
||||
public final class DatabaseLookupTable {
|
||||
|
||||
private let name: String
|
||||
private let objectIDKey: String
|
||||
private let relatedObjectIDKey: String
|
||||
private let relationshipName: String
|
||||
private let relatedTable: DatabaseRelatedObjectsTable
|
||||
private var objectIDsWithNoRelatedObjects = Set<String>()
|
||||
|
||||
public init(name: String, objectIDKey: String, relatedObjectIDKey: String, relatedTable: DatabaseRelatedObjectsTable, relationshipName: String) {
|
||||
|
||||
self.name = name
|
||||
self.objectIDKey = objectIDKey
|
||||
self.relatedObjectIDKey = relatedObjectIDKey
|
||||
self.relatedTable = relatedTable
|
||||
self.relationshipName = relationshipName
|
||||
}
|
||||
|
||||
public func fetchRelatedObjects(for objectIDs: Set<String>, in database: FMDatabase) -> RelatedObjectsMap? {
|
||||
|
||||
let objectIDsThatMayHaveRelatedObjects = objectIDs.subtracting(objectIDsWithNoRelatedObjects)
|
||||
if objectIDsThatMayHaveRelatedObjects.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let relatedObjectIDsMap = fetchRelatedObjectIDsMap(objectIDsThatMayHaveRelatedObjects, database) else {
|
||||
objectIDsWithNoRelatedObjects.formUnion(objectIDsThatMayHaveRelatedObjects)
|
||||
return nil
|
||||
}
|
||||
|
||||
if let relatedObjects = fetchRelatedObjectsWithIDs(relatedObjectIDsMap.relatedObjectIDs(), database) {
|
||||
|
||||
let relatedObjectsMap = RelatedObjectsMap(relatedObjects: relatedObjects, relatedObjectIDsMap: relatedObjectIDsMap)
|
||||
|
||||
let objectIDsWithNoFetchedRelatedObjects = objectIDsThatMayHaveRelatedObjects.subtracting(relatedObjectsMap.objectIDs())
|
||||
objectIDsWithNoRelatedObjects.formUnion(objectIDsWithNoFetchedRelatedObjects)
|
||||
|
||||
return relatedObjectsMap
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public func saveRelatedObjects(for objects: [DatabaseObject], in database: FMDatabase) {
|
||||
|
||||
var objectsWithNoRelationships = [DatabaseObject]()
|
||||
var objectsWithRelationships = [DatabaseObject]()
|
||||
|
||||
for object in objects {
|
||||
if let relatedObjects = object.relatedObjectsWithName(relationshipName), !relatedObjects.isEmpty {
|
||||
objectsWithRelationships += [object]
|
||||
}
|
||||
else {
|
||||
objectsWithNoRelationships += [object]
|
||||
}
|
||||
}
|
||||
|
||||
removeRelationships(for: objectsWithNoRelationships, database)
|
||||
updateRelationships(for: objectsWithRelationships, database)
|
||||
|
||||
objectIDsWithNoRelatedObjects.formUnion(objectsWithNoRelationships.databaseIDs())
|
||||
objectIDsWithNoRelatedObjects.subtract(objectsWithRelationships.databaseIDs())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private extension DatabaseLookupTable {
|
||||
|
||||
// MARK: Removing
|
||||
|
||||
func removeRelationships(for objects: [DatabaseObject], _ database: FMDatabase) {
|
||||
|
||||
let objectIDs = objects.databaseIDs()
|
||||
let objectIDsToRemove = objectIDs.subtracting(objectIDsWithNoRelatedObjects)
|
||||
if objectIDsToRemove.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
database.rs_deleteRowsWhereKey(objectIDKey, inValues: Array(objectIDsToRemove), tableName: name)
|
||||
}
|
||||
|
||||
func deleteLookups(for objectID: String, _ relatedObjectIDs: Set<String>, _ database: FMDatabase) {
|
||||
|
||||
guard !relatedObjectIDs.isEmpty else {
|
||||
assertionFailure("deleteLookups: expected non-empty relatedObjectIDs")
|
||||
return
|
||||
}
|
||||
|
||||
// delete from authorLookup where articleID=? and authorID in (?,?,?)
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(relatedObjectIDs.count))!
|
||||
let sql = "delete from \(name) where \(objectIDKey)=? and \(relatedObjectIDKey) in \(placeholders)"
|
||||
|
||||
let parameters: [Any] = [objectID] + Array(relatedObjectIDs)
|
||||
let _ = database.executeUpdate(sql, withArgumentsIn: parameters)
|
||||
}
|
||||
|
||||
// MARK: Saving/Updating
|
||||
|
||||
func updateRelationships(for objects: [DatabaseObject], _ database: FMDatabase) {
|
||||
|
||||
if objects.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
if let lookupTable = fetchRelatedObjectIDsMap(objects.databaseIDs(), database) {
|
||||
for object in objects {
|
||||
syncRelatedObjectsAndLookupTable(object, lookupTable, database)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the actual related objects.
|
||||
|
||||
let relatedObjectsToSave = uniqueArrayOfRelatedObjects(with: objects)
|
||||
if relatedObjectsToSave.isEmpty {
|
||||
assertionFailure("updateRelationships: expected relatedObjectsToSave would not be empty. This should be unreachable.")
|
||||
return
|
||||
}
|
||||
|
||||
relatedTable.save(relatedObjectsToSave, in: database)
|
||||
}
|
||||
|
||||
func uniqueArrayOfRelatedObjects(with objects: [DatabaseObject]) -> [DatabaseObject] {
|
||||
|
||||
// Can’t create a Set, because we can’t make a Set<DatabaseObject>, because protocol-conforming objects can’t be made Hashable or even Equatable.
|
||||
// We still want the array to include only one copy of each object, but we have to do it the slow way. Instruments will tell us if this is a performance problem.
|
||||
|
||||
var relatedObjectsUniqueArray = [DatabaseObject]()
|
||||
for object in objects {
|
||||
guard let relatedObjects = object.relatedObjectsWithName(relationshipName) else {
|
||||
assertionFailure("uniqueArrayOfRelatedObjects: expected every object to have related objects.")
|
||||
continue
|
||||
}
|
||||
for relatedObject in relatedObjects {
|
||||
if !relatedObjectsUniqueArray.includesObjectWithDatabaseID(relatedObject.databaseID) {
|
||||
relatedObjectsUniqueArray += [relatedObject]
|
||||
}
|
||||
}
|
||||
}
|
||||
return relatedObjectsUniqueArray
|
||||
}
|
||||
|
||||
func syncRelatedObjectsAndLookupTable(_ object: DatabaseObject, _ lookupTable: RelatedObjectIDsMap, _ database: FMDatabase) {
|
||||
|
||||
guard let relatedObjects = object.relatedObjectsWithName(relationshipName) else {
|
||||
assertionFailure("syncRelatedObjectsAndLookupTable should be called only on objects with related objects.")
|
||||
return
|
||||
}
|
||||
|
||||
let relatedObjectIDs = relatedObjects.databaseIDs()
|
||||
let lookupTableRelatedObjectIDs = lookupTable[object.databaseID] ?? Set<String>()
|
||||
|
||||
let relatedObjectIDsToDelete = lookupTableRelatedObjectIDs.subtracting(relatedObjectIDs)
|
||||
if !relatedObjectIDsToDelete.isEmpty {
|
||||
deleteLookups(for: object.databaseID, relatedObjectIDsToDelete, database)
|
||||
}
|
||||
|
||||
let relatedObjectIDsToSave = relatedObjectIDs.subtracting(lookupTableRelatedObjectIDs)
|
||||
if !relatedObjectIDsToSave.isEmpty {
|
||||
saveLookups(for: object.databaseID, relatedObjectIDsToSave, database)
|
||||
}
|
||||
}
|
||||
|
||||
func saveLookups(for objectID: String, _ relatedObjectIDs: Set<String>, _ database: FMDatabase) {
|
||||
|
||||
for relatedObjectID in relatedObjectIDs {
|
||||
let d: [NSObject: Any] = [(objectIDKey as NSString): objectID, (relatedObjectIDKey as NSString): relatedObjectID]
|
||||
let _ = database.rs_insertRow(with: d, insertType: .orIgnore, tableName: name)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Fetching
|
||||
|
||||
func fetchRelatedObjectsWithIDs(_ relatedObjectIDs: Set<String>, _ database: FMDatabase) -> [DatabaseObject]? {
|
||||
|
||||
guard let relatedObjects = relatedTable.fetchObjectsWithIDs(relatedObjectIDs, in: database), !relatedObjects.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return relatedObjects
|
||||
}
|
||||
|
||||
func fetchRelatedObjectIDsMap(_ objectIDs: Set<String>, _ database: FMDatabase) -> RelatedObjectIDsMap? {
|
||||
|
||||
guard let lookupValues = fetchLookupValues(objectIDs, database) else {
|
||||
return nil
|
||||
}
|
||||
return RelatedObjectIDsMap(lookupValues: lookupValues)
|
||||
}
|
||||
|
||||
func fetchLookupValues(_ objectIDs: Set<String>, _ database: FMDatabase) -> Set<LookupValue>? {
|
||||
|
||||
guard !objectIDs.isEmpty, let resultSet = database.rs_selectRowsWhereKey(objectIDKey, inValues: Array(objectIDs), tableName: name) else {
|
||||
return nil
|
||||
}
|
||||
return lookupValuesWithResultSet(resultSet)
|
||||
}
|
||||
|
||||
func lookupValuesWithResultSet(_ resultSet: FMResultSet) -> Set<LookupValue> {
|
||||
|
||||
return resultSet.mapToSet(lookupValueWithRow)
|
||||
}
|
||||
|
||||
func lookupValueWithRow(_ row: FMResultSet) -> LookupValue? {
|
||||
|
||||
guard let objectID = row.string(forColumn: objectIDKey) else {
|
||||
return nil
|
||||
}
|
||||
guard let relatedObjectID = row.string(forColumn: relatedObjectIDKey) else {
|
||||
return nil
|
||||
}
|
||||
return LookupValue(objectID: objectID, relatedObjectID: relatedObjectID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// DatabaseRelatedObjectsTable.swift
|
||||
// RSDatabase
|
||||
//
|
||||
// Created by Brent Simmons on 9/2/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSDatabaseObjC
|
||||
|
||||
// Protocol for a database table for related objects — authors and attachments in NetNewsWire, for instance.
|
||||
|
||||
public protocol DatabaseRelatedObjectsTable: DatabaseTable {
|
||||
|
||||
var databaseIDKey: String { get}
|
||||
var cache: DatabaseObjectCache { get }
|
||||
|
||||
func fetchObjectsWithIDs(_ databaseIDs: Set<String>, in database: FMDatabase) -> [DatabaseObject]?
|
||||
func objectsWithResultSet(_ resultSet: FMResultSet) -> [DatabaseObject]
|
||||
func objectWithRow(_ row: FMResultSet) -> DatabaseObject?
|
||||
|
||||
func save(_ objects: [DatabaseObject], in database: FMDatabase)
|
||||
}
|
||||
|
||||
public extension DatabaseRelatedObjectsTable {
|
||||
|
||||
// MARK: Default implementations
|
||||
|
||||
func fetchObjectsWithIDs(_ databaseIDs: Set<String>, in database: FMDatabase) -> [DatabaseObject]? {
|
||||
|
||||
if databaseIDs.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
var cachedObjects = [DatabaseObject]()
|
||||
var databaseIDsToFetch = Set<String>()
|
||||
|
||||
for databaseID in databaseIDs {
|
||||
if let cachedObject = cache[databaseID] {
|
||||
cachedObjects += [cachedObject]
|
||||
}
|
||||
else {
|
||||
databaseIDsToFetch.insert(databaseID)
|
||||
}
|
||||
}
|
||||
|
||||
if databaseIDsToFetch.isEmpty {
|
||||
return cachedObjects
|
||||
}
|
||||
|
||||
guard let resultSet = selectRowsWhere(key: databaseIDKey, inValues: Array(databaseIDsToFetch), in: database) else {
|
||||
return cachedObjects
|
||||
}
|
||||
|
||||
let fetchedDatabaseObjects = objectsWithResultSet(resultSet)
|
||||
cache.add(fetchedDatabaseObjects)
|
||||
|
||||
return cachedObjects + fetchedDatabaseObjects
|
||||
}
|
||||
|
||||
func objectsWithResultSet(_ resultSet: FMResultSet) -> [DatabaseObject] {
|
||||
|
||||
return resultSet.compactMap(objectWithRow)
|
||||
}
|
||||
|
||||
func save(_ objects: [DatabaseObject], in database: FMDatabase) {
|
||||
|
||||
// Objects in cache must already exist in database. Filter them out.
|
||||
let objectsToSave = objects.filter { (object) -> Bool in
|
||||
if let _ = cache[object.databaseID] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
cache.add(objectsToSave)
|
||||
if let databaseDictionaries = objectsToSave.databaseDictionaries() {
|
||||
insertRows(databaseDictionaries, insertType: .orIgnore, in: database)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// RelatedObjectIDsMap.swift
|
||||
// RSDatabase
|
||||
//
|
||||
// Created by Brent Simmons on 9/10/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Maps objectIDs to Set<String> where the Strings are relatedObjectIDs.
|
||||
|
||||
struct RelatedObjectIDsMap {
|
||||
|
||||
private let dictionary: [String: Set<String>] // objectID: Set<relatedObjectID>
|
||||
|
||||
init(dictionary: [String: Set<String>]) {
|
||||
|
||||
self.dictionary = dictionary
|
||||
}
|
||||
|
||||
init(lookupValues: Set<LookupValue>) {
|
||||
|
||||
var d = [String: Set<String>]()
|
||||
|
||||
for lookupValue in lookupValues {
|
||||
let objectID = lookupValue.objectID
|
||||
let relatedObjectID: String = lookupValue.relatedObjectID
|
||||
if d[objectID] == nil {
|
||||
d[objectID] = Set([relatedObjectID])
|
||||
}
|
||||
else {
|
||||
d[objectID]!.insert(relatedObjectID)
|
||||
}
|
||||
}
|
||||
|
||||
self.init(dictionary: d)
|
||||
}
|
||||
|
||||
func objectIDs() -> Set<String> {
|
||||
|
||||
return Set(dictionary.keys)
|
||||
}
|
||||
|
||||
func relatedObjectIDs() -> Set<String> {
|
||||
|
||||
var ids = Set<String>()
|
||||
for (_, relatedObjectIDs) in dictionary {
|
||||
ids.formUnion(relatedObjectIDs)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
subscript(_ objectID: String) -> Set<String>? {
|
||||
return dictionary[objectID]
|
||||
}
|
||||
}
|
||||
|
||||
struct LookupValue: Hashable {
|
||||
|
||||
let objectID: String
|
||||
let relatedObjectID: String
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// RelatedObjectsMap.swift
|
||||
// RSDatabase
|
||||
//
|
||||
// Created by Brent Simmons on 9/10/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Map objectID to [DatabaseObject] (related objects).
|
||||
// It’s used as the return value for DatabaseLookupTable.fetchRelatedObjects.
|
||||
|
||||
public struct RelatedObjectsMap {
|
||||
|
||||
private let dictionary: [String: [DatabaseObject]] // objectID: relatedObjects
|
||||
|
||||
init(relatedObjects: [DatabaseObject], relatedObjectIDsMap: RelatedObjectIDsMap) {
|
||||
|
||||
var d = [String: [DatabaseObject]]()
|
||||
let relatedObjectsDictionary = relatedObjects.dictionary()
|
||||
|
||||
for objectID in relatedObjectIDsMap.objectIDs() {
|
||||
|
||||
if let relatedObjectIDs = relatedObjectIDsMap[objectID] {
|
||||
let relatedObjects = relatedObjectIDs.compactMap{ relatedObjectsDictionary[$0] }
|
||||
if !relatedObjects.isEmpty {
|
||||
d[objectID] = relatedObjects
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.dictionary = d
|
||||
}
|
||||
|
||||
public func objectIDs() -> Set<String> {
|
||||
|
||||
return Set(dictionary.keys)
|
||||
}
|
||||
|
||||
public subscript(_ objectID: String) -> [DatabaseObject]? {
|
||||
return dictionary[objectID]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user