From e76beee988d5151d4e5fa82f49f3f1a0ad3e3d34 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sat, 19 Aug 2017 22:07:31 -0700 Subject: [PATCH] Progress on relationships. --- .../RSDatabase/DatabaseLookupTable.swift | 297 ++++++++++-------- ToDo.ooutline | Bin 2404 -> 2581 bytes 2 files changed, 162 insertions(+), 135 deletions(-) diff --git a/Frameworks/RSDatabase/RSDatabase/DatabaseLookupTable.swift b/Frameworks/RSDatabase/RSDatabase/DatabaseLookupTable.swift index 8af4cad11..b125142f1 100644 --- a/Frameworks/RSDatabase/RSDatabase/DatabaseLookupTable.swift +++ b/Frameworks/RSDatabase/RSDatabase/DatabaseLookupTable.swift @@ -1,5 +1,5 @@ // -// LookupTable.swift +// DatabaseLookupTable.swift // RSDatabase // // Created by Brent Simmons on 8/5/17. @@ -10,36 +10,51 @@ import Foundation // 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)); -// authorID is primaryKey; articleID is foreignKey. +// articleID is objectID; authorID is relatedObjectID. public final class DatabaseLookupTable { private let name: String - private let primaryKey: String - private let foreignKey: String + private let objectIDKey: String + private let relatedObjectIDKey: String private let relationshipName: String private weak var relatedTable: DatabaseTable? private let cache: DatabaseLookupTableCache - public init(name: String, primaryKey: String, foreignKey: String, relatedTable: DatabaseTable, relationshipName: String) { + public init(name: String, objectIDKey: String, relatedObjectIDKey: String, relatedTable: DatabaseTable, relationshipName: String) { self.name = name - self.primaryKey = primaryKey - self.foreignKey = foreignKey + self.objectIDKey = objectIDKey + self.relatedObjectIDKey = relatedObjectIDKey self.relatedTable = relatedTable self.relationshipName = relationshipName self.cache = DatabaseLookupTableCache(relationshipName) } - public func attachRelationships(to objects: [DatabaseObject], database: FMDatabase) { + public func attachRelationships(to objects: [DatabaseObject], in database: FMDatabase) { - guard let lookupTable = fetchLookupTable(objects.databaseIDs(), database) else { - return; + let objectsThatMayHaveRelatedObjects = cache.objectsThatMayHaveRelatedObjects(objects) + if objectsThatMayHaveRelatedObjects.isEmpty { + return } - attachRelationshipsUsingLookupTable(to: objects, lookupTable: lookupTable, database: database) + + attachRelatedObjectsUsingCache(objectsThatMayHaveRelatedObjects, database) + + let objectsNeedingFetching = objectsThatMayHaveRelatedObjects.filter { (object) -> Bool in + return object.relatedObjectsWithName(self.relationshipName) == nil + } + if objectsNeedingFetching.isEmpty { + return + } + + if let lookupTable = fetchLookupTable(objectsNeedingFetching.databaseIDs(), database) { + attachRelatedObjectsUsingLookupTable(objectsNeedingFetching, lookupTable, database) + } + + cache.update(with: objectsNeedingFetching) } - public func saveRelationships(for objects: [DatabaseObject], database: FMDatabase) { + public func saveRelationships(for objects: [DatabaseObject], in database: FMDatabase) { var objectsWithNoRelationships = [DatabaseObject]() var objectsWithRelationships = [DatabaseObject]() @@ -53,23 +68,35 @@ public final class DatabaseLookupTable { } } - removeRelationships(for: objectsWithNoRelationships, database: database) - updateRelationships(for: objectsWithRelationships, database: database) + removeRelationships(for: objectsWithNoRelationships, database) + updateRelationships(for: objectsWithRelationships, database) + + cache.update(with: objects) } } private extension DatabaseLookupTable { - func removeRelationships(for objects: [DatabaseObject], database: FMDatabase) { + // MARK: Removing + + func removeRelationships(for objects: [DatabaseObject], _ database: FMDatabase) { - removeLookupsForForeignIDs(objects.databaseIDs(), database) - } - - func updateRelationships(for objects: [DatabaseObject], database: FMDatabase) { - - let objectsNeedingUpdate = objects.filter { (object) -> Bool in - return !relationshipsMatchCache(object) + let objectIDs = objects.databaseIDs() + let objectIDsToRemove = objectIDs.subtracting(cache.objectIDsWithNoRelationship) + if objectIDsToRemove.isEmpty { + return } + + database.rs_deleteRowsWhereKey(objectIDKey, inValues: Array(objectIDsToRemove), tableName: name) + } + + // MARK: Saving/Updating + + func updateRelationships(for objects: [DatabaseObject], _ database: FMDatabase) { + +// let objectsNeedingUpdate = objects.filter { (object) -> Bool in +// return !relationshipsMatchCache(object) +// } } func relationshipsMatchCache(_ object: DatabaseObject) -> Bool { @@ -91,217 +118,217 @@ private extension DatabaseLookupTable { } } - func attachRelationshipsUsingLookupTable(to objects: [DatabaseObject], lookupTable: LookupTable, database: FMDatabase) { + // MARK: Attaching + + func attachRelatedObjectsUsingCache(_ objects: [DatabaseObject], _ database: FMDatabase) { - let primaryIDs = lookupTable.primaryIDs() - if (primaryIDs.isEmpty) { + let lookupTable = cache.lookupTableForObjectIDs(objects.databaseIDs()) + attachRelatedObjectsUsingLookupTable(objects, lookupTable, database) + } + + func attachRelatedObjectsUsingLookupTable(_ objects: [DatabaseObject], _ lookupTable: LookupTable, _ database: FMDatabase) { + + let relatedObjectIDs = lookupTable.relatedObjectIDs() + if (relatedObjectIDs.isEmpty) { return } - guard let relatedObjects: [DatabaseObject] = relatedTable?.fetchObjectsWithIDs(primaryIDs, database), !relatedObjects.isEmpty else { + guard let relatedObjects = fetchRelatedObjectsWithIDs(relatedObjectIDs, database) else { return } - let relatedObjectsDictionary = relatedObjects.dictionary() for object in objects { - let identifier = object.databaseID - if let lookupValues = lookupTable[identifier], !lookupValues.isEmpty { - let primaryIDs = lookupValues.primaryIDs() - let oneObjectRelatedObjects = primaryIDs.flatMap{ (primaryID) -> DatabaseObject? in - return relatedObjectsDictionary[primaryID] - } - object.setRelatedObjects(oneObjectRelatedObjects, name: relationshipName) - } + attachRelatedObjectsToObjectUsingLookupTable(object, relatedObjectsDictionary, lookupTable) } } - func fetchLookupTable(_ foreignIDs: Set, _ database: FMDatabase) -> LookupTable? { + func attachRelatedObjectsToObjectUsingLookupTable(_ object: DatabaseObject, _ relatedObjectsDictionary: [String: DatabaseObject], _ lookupTable: LookupTable) { - let foreignIDsToLookup = foreignIDs.subtracting(foreignIDsWithNoRelationship) - guard let lookupValues = fetchLookupValues(foreignIDsToLookup, database) else { - return nil - } - updateCache(lookupValues, foreignIDsToLookup) - - return LookupTable(lookupValues) - } - - func cacheForeignIDsWithNoRelationships(_ foreignIDs: Set) { - - foreignIDsWithNoRelationship.formUnion(foreignIDs) - for foreignID in foreignIDs { - cache[foreignID] = nil - } - } - - func updateCache(_ lookupValues: Set, _ foreignIDs: Set) { - - // Maintain foreignIDsWithNoRelationship. - // If a relationship exist, remove the foreignID from foreignIDsWithNoRelationship. - // If a relationship does not exist, add the foreignID to foreignIDsWithNoRelationship. - - let foreignIDsWithRelationship = lookupValues.foreignIDs() - - let foreignIDs - for foreignID in foreignIDs { - if !foreignIDsWithRelationship.contains(foreignID) { - foreignIDsWithNoRelationship.insert(foreignID) - } - } - } - - func removeLookupsForForeignIDs(_ foreignIDs: Set, _ database: FMDatabase) { - - let foreignIDsToRemove = foreignIDs.subtracting(foreignIDsWithNoRelationship) - if foreignIDsToRemove.isEmpty { + let identifier = object.databaseID + guard let relatedObjectIDs = lookupTable[identifier], !relatedObjectIDs.isEmpty else { return } - - foreignIDsWithNoRelationship.formUnion(foreignIDsToRemove) - - database.rs_deleteRowsWhereKey(foreignKey, inValues: Array(foreignIDsToRemove), tableName: name) + let relatedObjects = relatedObjectIDs.flatMap { relatedObjectsDictionary[$0] } + if !relatedObjects.isEmpty { + object.setRelatedObjects(relatedObjects, name: relationshipName) + } } - func fetchLookupValues(_ foreignIDs: Set, _ database: FMDatabase) -> Set? { + // MARK: Fetching + + func fetchRelatedObjectsWithIDs(_ relatedObjectIDs: Set, _ database: FMDatabase) -> [DatabaseObject]? { - guard !foreignIDs.isEmpty, let resultSet = database.rs_selectRowsWhereKey(foreignKey, inValues: Array(foreignIDs), tableName: name) else { + guard let relatedObjects = relatedTable?.fetchObjectsWithIDs(relatedObjectIDs, database), !relatedObjects.isEmpty else { + return nil + } + return relatedObjects + } + + func fetchLookupTable(_ objectIDs: Set, _ database: FMDatabase) -> LookupTable? { + + guard let lookupValues = fetchLookupValues(objectIDs, database) else { + return nil + } + return LookupTable(lookupValues: lookupValues) + } + + func fetchLookupValues(_ objectIDs: Set, _ database: FMDatabase) -> Set? { + + 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 { - + return resultSet.mapToSet(lookupValueWithRow) } - + func lookupValueWithRow(_ row: FMResultSet) -> LookupValue? { - - guard let primaryID = row.string(forColumn: primaryKey) else { + + guard let objectID = row.string(forColumn: objectIDKey) else { return nil } - guard let foreignID = row.string(forColumn: foreignKey) else { + guard let relatedObjectID = row.string(forColumn: relatedObjectIDKey) else { return nil } - return LookupValue(primaryID: primaryID, foreignID: foreignID) + return LookupValue(objectID: objectID, relatedObjectID: relatedObjectID) } - } struct LookupTable { - private let dictionary: [String: Set] + private let dictionary: [String: Set] // objectID: Set - init(_ lookupValues: Set) { + init(dictionary: [String: Set]) { - var d = [String: Set]() + self.dictionary = dictionary + } + + init(lookupValues: Set) { + + var d = [String: Set]() for lookupValue in lookupValues { - let foreignID = lookupValue.foreignID - if d[foreignID] == nil { - d[foreignID] = Set([lookupValue]) + let objectID = lookupValue.objectID + let relatedObjectID: String = lookupValue.relatedObjectID + if d[objectID] == nil { + d[objectID] = Set([relatedObjectID]) } else { - d[foreignID]!.insert(lookupValue) + d[objectID]!.insert(relatedObjectID) } } - self.dictionary = d + self.init(dictionary: d) } - func primaryIDs() -> Set { + func relatedObjectIDs() -> Set { var ids = Set() - for (_, lookupValues) in dictionary { - ids.formUnion(lookupValues.primaryIDs()) + for (_, relatedObjectIDs) in dictionary { + ids.formUnion(relatedObjectIDs) } return ids } - subscript(_ foreignID: String) -> Set? { + subscript(_ objectID: String) -> Set? { get { - return dictionary[foreignID] + return dictionary[objectID] } } } struct LookupValue: Hashable { - let primaryID: String - let foreignID: String + let objectID: String + let relatedObjectID: String let hashValue: Int - init(primaryID: String, foreignID: String) { + init(objectID: String, relatedObjectID: String) { - self.primaryID = primaryID - self.foreignID = foreignID - self.hashValue = (primaryID + foreignID).hashValue + self.objectID = objectID + self.relatedObjectID = relatedObjectID + self.hashValue = (objectID + relatedObjectID).hashValue } static public func ==(lhs: LookupValue, rhs: LookupValue) -> Bool { - return lhs.primaryID == rhs.primaryID && lhs.foreignID == rhs.foreignID + return lhs.objectID == rhs.objectID && lhs.relatedObjectID == rhs.relatedObjectID } } private final class DatabaseLookupTableCache { + var objectIDsWithNoRelationship = Set() private let relationshipName: String - private var foreignIDsWithNoRelationship = Set() - private var cachedLookups = [String: Set]() // foreignID: Set + private var cachedLookups = [String: Set]() // objectID: Set init(_ relationshipName: String) { self.relationshipName = relationshipName } - func updateCacheWithIDsWithNoRelationship(_ foreignIDs: Set) { - - foreignIDsWithNoRelationship.formUnion(foreignIDs) - for foreignID in foreignIDs { - cachedLookups[foreignID] = nil - } - } - - func updateCacheWithObjects(_ object: [DatabaseObject]) { - - var foreignIDsWithRelationship = Set() + func update(with objects: [DatabaseObject]) { + var idsWithRelationships = Set() + var idsWithNoRelationships = Set() + for object in objects { - - if let relatedObjects = object.relatedObjectsWithName, !relatedObjects.isEmpty { - foreignIDsWithRelationship.insert(object.databaseID) + let objectID = object.databaseID + if let relatedObjects = object.relatedObjectsWithName(relationshipName), !relatedObjects.isEmpty { + idsWithRelationships.insert(objectID) + self[objectID] = relatedObjects.databaseIDs() } else { - updateCacheWithIDsWithNoRelationship(objects.databaseIDs()) + idsWithNoRelationships.insert(objectID) + self[objectID] = nil } } - foreignIDsWithNoRelationship.subtract(foreignIDsWithRelationships) + objectIDsWithNoRelationship.subtract(idsWithRelationships) + objectIDsWithNoRelationship.formUnion(idsWithNoRelationships) } - func foreignIDHasNoRelationship(_ foreignID: String) -> Bool { - - return foreignIDsWithNoRelationship.contains(foreignID) + subscript(_ objectID: String) -> Set? { + get { + return cachedLookups[objectID] + } + set { + cachedLookups[objectID] = newValue + } } - - func relationshipIDsForForeignID(_ foreignID: String) -> Set? { - - return cachedLookups[foreignID] + + func objectsThatMayHaveRelatedObjects(_ objects: [DatabaseObject]) -> [DatabaseObject] { + + // Filter out objects that are known to have no related objects + return objects.filter{ !objectIDsWithNoRelationship.contains($0.databaseID) } + } + + func lookupTableForObjectIDs(_ objectIDs: Set) -> LookupTable { + + var d = [String: Set]() + for objectID in objectIDs { + if let relatedObjectIDs = self[objectID] { + d[objectID] = relatedObjectIDs + } + } + return LookupTable(dictionary: d) } } private extension Set where Element == LookupValue { - func primaryIDs() -> Set { + func objectIDs() -> Set { - return Set(self.map { $0.primaryID }) + return Set(self.map { $0.objectID }) } - func foreignIDs() -> Set { + func relatedObjectIDs() -> Set { - return Set(self.map { $0.foreignID }) + return Set(self.map { $0.relatedObjectID }) } } diff --git a/ToDo.ooutline b/ToDo.ooutline index 798b71e85834a97ae03f2c1d4836dffe88874ef1..ee8e338d0e03ee439244c308370baa658e81eb14 100644 GIT binary patch delta 2542 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 E0QsfPM*si- literal 2404 zcmZ`*X*3&%8jX-tDq|^CLP{y6wj$DqQoCA4f?8sWT8k3Ovo@-_pw&z*MNxZc?Rzmu zQ57_rXqgH{iAJrBSf`d}-k&#b&UxSW5p3<`Dq^0K9+!7hy~BX-xfX zFaRJT1pq*gST90wxKD6+m}*qejZr5Ty9ty?+*<9A9sg|uwn}J^b=q*a`0*DmpGDg< zvPhmvB-n0(^0`uSoZR@jmcF5d-^_idk<>2Ea}4VWjNB=Z++v963q{bY^Irujtb0nC zbsLkr&|I?nD;rcNDEGBx*x!DXPHb(S#ETKZ!-0TGnf@XK0!ip`}V zF`x)2d+u}6aByUwqF;D2@>Utj(}Um03fV3%G#}J1ug{(~$ujd8bepl`h%J1yGj>P{ zeeljSFu7c`1;*!P*rGP1gHV%KSIHVCuYcbKuG_irVNyJ)#3BUh3LDdO(qhUcKB*z( z0ZLv{T?BiDdG^fk{_(H-#*xRk`$x+&iDG$LJ>3aGpwjl!_q>~>aYQ0I3Mu&>%j*O! z&K|O?*bt~0&q-Ly*>5zqnrj9^{n*F-d)zGx)sSrdnO{Luh+l~TdJ=I!MW*ICJ^+Nj zSkUBTr4K&231$m0mb@m1W`o{h-^`>)Q_aG%g}@F^yeB*Gq-7E)Nm{+pAw_F6hllzs z?T6}vOpQ`oaI6z!aAfXr-lnb<+~-eOOYwX!=9$n&?^P)??$Lrz%KC=&ys4BUZz9{yN(|yX)hs=)<$0SPXw4xnzCPvm0l|#Y%Xnl z^YV1BsnX}tF~MF{ejNMugFEDogrFV2d%Pmopm=};Cb(`(yL?g2Ep0xdG6}5dCTzF0 zd?&D7m1RzZMlU{pD)o3Jh~je^=WyDsjI_H!nYB{?m$MO{#mVnlU8g;0Yj?GY+d-Z6 z1O}7=2Uk1x6l$R}GUr*%bD#M*{2hdkv*Fk4@q8+)t33e%Uvj%C?zi}S16i!;I;sb` zw~*s3b}5Q1m&+O5Wod#hA6);au|ONSts^^CP{cl zVohS!j{^l=w?sLmTd^mi8x}=Tk>}i73JfD=DCa5S{6CE*r~?DP!SjmEcE%@GXq#P! zULR^FsXl+K1lz;9swRBbDDY?dB|WjLzY`J4rRpK1$?hK}SK@KEB6V61V@Okx<_lwm zbjx&_RL?eNBQ)xXA!WGy=JI-=zv}S zdmIo#@5{Udgo>-B`)1GR(_V^Q>gIt2kVT1pdIS-S@$4P{8@zLjgX*?H@vB$Q`@Sxm$%h%%6F5y3|`eJ{+tXbK18+YbjGdSzRVxhW-jiH zbKd-d!s`WhCGbVQ7-9`H^ph?0w57(T(;`#d$%~n^NS$xUZGFAAjgtDCs@kjfUgz~~ zud{)PINmYC)DA23I=qDLJxD%p^2gON8|Ru9(-T3w^p}>OCs_oZ2+n+RF*^3uCq<7EV8E-p^YfseIkgO`zet$a-as_y#3s zT;tP3pIi#wF*Kpm;r!5->wdwq;}f?xb!sJMc?(JB(qip7#)+oan(`JjHt8r|lVLkZ zT`Ig2B(G;86rWK}>6k5VGk_$Y3usr5*p-y<+((N%BVn?3w%#9Hw2H1fe#2fx?F0ky zh>2l_VeBWkEs?UHQZN_M8p!@91~y>ySAS5sXikhri;k(NQo$5sc`K|cup?f^W&z*x zbw!dzZiNoUpopBy*#8Nt^(8q|2oM0!RjsW!Q3sebsO8$=IhgEQ{=J*LV;9R;qGSI3m*5?*ZfcZW-45 zmof7aVc7PujCkwEPtcYN)XkH*G!BlyNU87P#h!irL$j!Dd;X_F#Zo)rxQb?AMMFW3 z%t!~oab2VsG