Skip to content

Commit eb5b7ff

Browse files
authored
feat(DataStore): database recreation based on new schema (#551)
* Added feature: database recreation when there is a schema change; Added two integration tests * Added a version helper and refactored code a bit * Added MockFileManager, four unit tests * delete AmplifyModels_v2.swift * Changed the recreation logic a bit * Modified unit tets * Fixed PR comments * 2nd: Fixed PR comments * 3rd: Fixed PR comments * 4th: Fixed PR comments
1 parent d3ba16a commit eb5b7ff

File tree

10 files changed

+369
-21
lines changed

10 files changed

+369
-21
lines changed

AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/AWSDataStorePlugin.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ final public class AWSDataStorePlugin: DataStoreCategoryPlugin {
5252
self.isSyncEnabled = false
5353
self.validAPIPluginKey = "awsAPIPlugin"
5454
self.validAuthPluginKey = "awsCognitoAuthPlugin"
55+
5556
if #available(iOS 13.0, *) {
5657
self.dataStorePublisher = DataStorePublisher()
5758
} else {
@@ -81,6 +82,7 @@ final public class AWSDataStorePlugin: DataStoreCategoryPlugin {
8182
public func configure(using amplifyConfiguration: Any?) throws {
8283
modelRegistration.registerModels(registry: ModelRegistry.self)
8384
resolveSyncEnabled()
85+
8486
try resolveStorageEngine(dataStoreConfiguration: dataStoreConfiguration)
8587

8688
try storageEngine.setUp(models: ModelRegistry.models)
@@ -119,7 +121,8 @@ final public class AWSDataStorePlugin: DataStoreCategoryPlugin {
119121
storageEngine = try StorageEngine(isSyncEnabled: isSyncEnabled,
120122
dataStoreConfiguration: dataStoreConfiguration,
121123
validAPIPluginKey: validAPIPluginKey,
122-
validAuthPluginKey: validAuthPluginKey)
124+
validAuthPluginKey: validAuthPluginKey,
125+
modelRegistryVersion: modelRegistration.version)
123126
if #available(iOS 13.0, *) {
124127
setupStorageSink()
125128
}

AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Storage/SQLite/StorageEngineAdapter+SQLite.swift

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@ final class SQLiteStorageEngineAdapter: StorageEngineAdapter {
1616

1717
internal var connection: Connection!
1818
private var dbFilePath: URL?
19+
static let dbVersionKey = "com.amazonaws.DataStore.dbVersion"
20+
21+
convenience init(version: String,
22+
databaseName: String = "database",
23+
userDefaults: UserDefaults = UserDefaults.standard) throws {
24+
var dbFilePath = SQLiteStorageEngineAdapter.getDbFilePath(databaseName: databaseName)
25+
26+
try SQLiteStorageEngineAdapter.clearIfNewVersion(version: version,
27+
dbFilePath: dbFilePath)
1928

20-
convenience init(databaseName: String = "database") throws {
21-
guard let documentsPath = getDocumentPath() else {
22-
preconditionFailure("Could not create the database. The `.documentDirectory` is invalid")
23-
}
24-
var dbFilePath = documentsPath.appendingPathComponent("\(databaseName).db")
2529
let path = dbFilePath.absoluteString
2630

2731
let connection: Connection
@@ -35,14 +39,22 @@ final class SQLiteStorageEngineAdapter: StorageEngineAdapter {
3539
throw DataStoreError.invalidDatabase(path: path, error)
3640
}
3741

38-
try self.init(connection: connection, dbFilePath: dbFilePath)
42+
try self.init(connection: connection,
43+
dbFilePath: dbFilePath,
44+
userDefaults: userDefaults,
45+
version: version)
46+
3947
}
4048

41-
internal init(connection: Connection, dbFilePath: URL? = nil) throws {
49+
internal init(connection: Connection,
50+
dbFilePath: URL? = nil,
51+
userDefaults: UserDefaults = UserDefaults.standard,
52+
version: String = "version") throws {
4253
self.connection = connection
4354
self.dbFilePath = dbFilePath
4455
try SQLiteStorageEngineAdapter.initializeDatabase(connection: connection)
4556
log.verbose("Initialized \(connection)")
57+
userDefaults.set(version, forKey: SQLiteStorageEngineAdapter.dbVersionKey)
4658
}
4759

4860
static func initializeDatabase(connection: Connection) throws {
@@ -62,6 +74,13 @@ final class SQLiteStorageEngineAdapter: StorageEngineAdapter {
6274
try connection.execute(databaseInitializationStatement)
6375
}
6476

77+
static func getDbFilePath(databaseName: String) -> URL {
78+
guard let documentsPath = getDocumentPath() else {
79+
preconditionFailure("Could not create the database. The `.documentDirectory` is invalid")
80+
}
81+
return documentsPath.appendingPathComponent("\(databaseName).db")
82+
}
83+
6584
func setUp(models: [Model.Type]) throws {
6685
log.debug("Setting up \(models.count) models")
6786

@@ -295,6 +314,32 @@ final class SQLiteStorageEngineAdapter: StorageEngineAdapter {
295314
}
296315
completion(.successfulVoid)
297316
}
317+
318+
static func clearIfNewVersion(version: String,
319+
dbFilePath: URL,
320+
userDefaults: UserDefaults = UserDefaults.standard,
321+
fileManager: FileManager = FileManager.default) throws {
322+
323+
guard let previousVersion = userDefaults.string(forKey: dbVersionKey) else {
324+
return
325+
}
326+
327+
if previousVersion == version {
328+
return
329+
}
330+
331+
guard fileManager.fileExists(atPath: dbFilePath.path) else {
332+
return
333+
}
334+
335+
log.verbose("\(#function) Warning: Schema change detected, removing your previous database")
336+
do {
337+
try fileManager.removeItem(at: dbFilePath)
338+
} catch {
339+
log.error("\(#function) Failed to delete database file located at: \(dbFilePath), error: \(error)")
340+
throw DataStoreError.invalidDatabase(path: dbFilePath.path, error)
341+
}
342+
}
298343
}
299344

300345
// MARK: - Private Helpers

AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Storage/StorageEngine.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Combine
1010
import Foundation
1111
import AWSPluginsCore
1212

13+
// swiftlint:disable type_body_length
1314
final class StorageEngine: StorageEngineBehavior {
1415
// TODO: Make this private once we get a mutation flow that passes the type of mutation as needed
1516
let storageAdapter: StorageEngineAdapter
@@ -78,10 +79,14 @@ final class StorageEngine: StorageEngineBehavior {
7879
convenience init(isSyncEnabled: Bool,
7980
dataStoreConfiguration: DataStoreConfiguration,
8081
validAPIPluginKey: String = "awsAPIPlugin",
81-
validAuthPluginKey: String = "awsCognitoAuthPlugin") throws {
82+
validAuthPluginKey: String = "awsCognitoAuthPlugin",
83+
modelRegistryVersion: String,
84+
userDefault: UserDefaults = UserDefaults.standard) throws {
85+
8286
let key = kCFBundleNameKey as String
83-
let databaseName = Bundle.main.object(forInfoDictionaryKey: key) as? String
84-
let storageAdapter = try SQLiteStorageEngineAdapter(databaseName: databaseName ?? "app")
87+
let databaseName = Bundle.main.object(forInfoDictionaryKey: key) as? String ?? "app"
88+
89+
let storageAdapter = try SQLiteStorageEngineAdapter(version: modelRegistryVersion, databaseName: databaseName)
8590

8691
try storageAdapter.setUp(models: StorageEngine.systemModels)
8792
if #available(iOS 13.0, *) {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
//
2+
// Copyright 2018-2020 Amazon.com,
3+
// Inc. or its affiliates. All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import XCTest
9+
10+
import AmplifyPlugins
11+
import AWSMobileClient
12+
import AWSPluginsCore
13+
14+
@testable import Amplify
15+
@testable import AmplifyTestCommon
16+
@testable import AWSDataStoreCategoryPlugin
17+
18+
class DataStoreConfigurationTests: XCTestCase {
19+
20+
override func setUp() {
21+
Amplify.reset()
22+
}
23+
24+
override func tearDown() {
25+
Amplify.DataStore.clear(completion: { _ in })
26+
}
27+
28+
func testConfigureWithSameSchemaDoesNotDeleteDatabase() throws {
29+
30+
let previousVersion = "previousVersion"
31+
let saveSuccess = expectation(description: "Save was successful")
32+
33+
do {
34+
let dataStorePlugin = AWSDataStorePlugin(modelRegistration: AmplifyModels(version: previousVersion))
35+
try Amplify.add(plugin: dataStorePlugin)
36+
try Amplify.configure(AmplifyConfiguration(dataStore: nil))
37+
} catch {
38+
XCTFail("Failed to initialize Amplify with \(error)")
39+
}
40+
41+
let post = Post(title: "title", content: "content", createdAt: .now())
42+
43+
Amplify.DataStore.save(post, completion: { result in
44+
switch result {
45+
case .success:
46+
saveSuccess.fulfill()
47+
case .failure(let error):
48+
XCTFail("Error saving post \(error)")
49+
}
50+
})
51+
52+
wait(for: [saveSuccess], timeout: TestCommonConstants.networkTimeout)
53+
54+
Amplify.reset()
55+
56+
let querySuccess = expectation(description: "query was successful")
57+
58+
do {
59+
let dataStorePlugin = AWSDataStorePlugin(modelRegistration: AmplifyModels(version: previousVersion))
60+
try Amplify.add(plugin: dataStorePlugin)
61+
try Amplify.configure(AmplifyConfiguration(dataStore: nil))
62+
} catch {
63+
XCTFail("Failed to initialize Amplify with \(error)")
64+
}
65+
66+
// Query for the previously saved post. Data should be retrieved successfully which indicates that
67+
// the database file was not deleted after re-configuring Amplify when using the same model registry version
68+
Amplify.DataStore.query(Post.self, byId: post.id) { result in
69+
switch result {
70+
case .success(let postResult):
71+
guard let queriedPost = postResult else {
72+
XCTFail("could not retrieve post across Amplify re-configure")
73+
return
74+
}
75+
XCTAssertEqual(queriedPost.title, "title")
76+
XCTAssertEqual(queriedPost.content, "content")
77+
XCTAssertEqual(queriedPost.createdAt, post.createdAt)
78+
querySuccess.fulfill()
79+
case .failure(let error):
80+
XCTFail("Failed to query post, error: \(error)")
81+
}
82+
}
83+
84+
wait(for: [querySuccess], timeout: TestCommonConstants.networkTimeout)
85+
}
86+
87+
func testConfigureWithDifferentSchemaClearsDatabase() throws {
88+
89+
let prevoisVersion = "previousVersion"
90+
let saveSuccess = expectation(description: "Save was successful")
91+
92+
do {
93+
let dataStorePlugin = AWSDataStorePlugin(modelRegistration: AmplifyModels(version: prevoisVersion))
94+
try Amplify.add(plugin: dataStorePlugin)
95+
try Amplify.configure(AmplifyConfiguration(dataStore: nil))
96+
} catch {
97+
XCTFail("Failed to initialize Amplify with \(error)")
98+
}
99+
100+
let post = Post(title: "title", content: "content", createdAt: .now())
101+
102+
Amplify.DataStore.save(post, completion: { result in
103+
switch result {
104+
case .success:
105+
saveSuccess.fulfill()
106+
case .failure(let error):
107+
XCTFail("Error saving post \(error)")
108+
}
109+
})
110+
111+
wait(for: [saveSuccess], timeout: TestCommonConstants.networkTimeout)
112+
113+
Amplify.reset()
114+
115+
let querySuccess = expectation(description: "query was successful")
116+
117+
do {
118+
let dataStorePlugin = AWSDataStorePlugin(modelRegistration: AmplifyModels(version: "1234"))
119+
try Amplify.add(plugin: dataStorePlugin)
120+
try Amplify.configure(AmplifyConfiguration(dataStore: nil))
121+
} catch {
122+
XCTFail("Failed to initialize Amplify with \(error)")
123+
}
124+
125+
// Query for the previously saved post. Data should not be retrieved successfully which indicates that
126+
// the database file was deleted after re-configuring Amplify when using a different model registry version
127+
Amplify.DataStore.query(Post.self) { result in
128+
switch result {
129+
case .success(let postResult):
130+
XCTAssertTrue(postResult.isEmpty)
131+
querySuccess.fulfill()
132+
case .failure(let error):
133+
XCTFail(error.errorDescription)
134+
}
135+
}
136+
137+
wait(for: [querySuccess], timeout: TestCommonConstants.networkTimeout)
138+
}
139+
}

AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Core/SQLiteStorageEngineAdapterTests.swift

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,4 +375,111 @@ class SQLiteStorageEngineAdapterTests: BaseDataStoreTests {
375375
XCTFail(String(describing: error))
376376
}
377377
}
378+
379+
func testClearIfNewVersionWithEmptyUserDefaults() {
380+
guard let userDefaults = UserDefaults.init(suiteName: "testClearIfNewVersionWithEmptyUserDefaults") else {
381+
XCTFail("Could not create a UserDafult with this suite name")
382+
return
383+
}
384+
userDefaults.removeObject(forKey: SQLiteStorageEngineAdapter.dbVersionKey)
385+
386+
let newVersion = "newVersion"
387+
let mockFileManager = MockFileManager()
388+
mockFileManager.removeItem = { res in
389+
XCTFail("Should not have called removeItem")
390+
}
391+
392+
do {
393+
try SQLiteStorageEngineAdapter.clearIfNewVersion(version: newVersion,
394+
dbFilePath: URL(string: "dbFilePath")!,
395+
userDefaults: userDefaults,
396+
fileManager: mockFileManager)
397+
} catch {
398+
XCTFail("Test failed due to \(error)")
399+
}
400+
401+
_ = UserDefaults.removeObject(userDefaults)
402+
}
403+
404+
func testClearIfNewVersionWithVersionSameAsPrevious() {
405+
guard let userDefaults = UserDefaults.init(suiteName: "testClearIfNewVersionWithVersionSameAsPrevious") else {
406+
XCTFail("Could not create a UserDafult with this suite name")
407+
return
408+
}
409+
let previousVersion = "previousVersion"
410+
userDefaults.set(previousVersion, forKey: SQLiteStorageEngineAdapter.dbVersionKey)
411+
412+
let newVersion = "previousVersion"
413+
let mockFileManager = MockFileManager()
414+
mockFileManager.fileExists = true
415+
mockFileManager.removeItem = { res in
416+
XCTFail("Should not have called removeItem")
417+
}
418+
419+
do {
420+
try SQLiteStorageEngineAdapter.clearIfNewVersion(version: newVersion,
421+
dbFilePath: URL(string: "dbFilePath")!,
422+
userDefaults: userDefaults,
423+
fileManager: mockFileManager)
424+
} catch {
425+
XCTFail("Test failed due to \(error)")
426+
}
427+
428+
_ = UserDefaults.removeObject(userDefaults)
429+
}
430+
431+
func testClearIfNewVersionWithMissingFile() {
432+
guard let userDefaults = UserDefaults.init(suiteName: "testClearIfNewVersionWithMissingFile") else {
433+
XCTFail("Could not create a UserDafult with this suite name")
434+
return
435+
}
436+
437+
userDefaults.set("previousVersion", forKey: SQLiteStorageEngineAdapter.dbVersionKey)
438+
439+
let newVersion = "previousVersion"
440+
let mockFileManager = MockFileManager()
441+
mockFileManager.fileExists = true
442+
mockFileManager.removeItem = { res in
443+
XCTFail("Should not have called removeItem")
444+
}
445+
446+
do {
447+
try SQLiteStorageEngineAdapter.clearIfNewVersion(version: newVersion,
448+
dbFilePath: URL(string: "dbFilePath")!,
449+
userDefaults: userDefaults,
450+
fileManager: mockFileManager)
451+
} catch {
452+
XCTFail("Test failed due to \(error)")
453+
}
454+
455+
_ = UserDefaults.removeObject(userDefaults)
456+
}
457+
458+
func testClearIfNewVersionFailure() {
459+
guard let userDefaults = UserDefaults.init(suiteName: "testClearIfNewVersionFailure") else {
460+
XCTFail("Could not create a UserDafult with this suite name")
461+
return
462+
}
463+
464+
userDefaults.set("previousVersion", forKey: SQLiteStorageEngineAdapter.dbVersionKey)
465+
466+
let newVersion = "newVersion"
467+
let mockFileManager = MockFileManager()
468+
mockFileManager.hasError = true
469+
mockFileManager.fileExists = true
470+
471+
do {
472+
try SQLiteStorageEngineAdapter.clearIfNewVersion(version: newVersion,
473+
dbFilePath: URL(string: "dbFilePath")!,
474+
userDefaults: userDefaults,
475+
fileManager: mockFileManager)
476+
} catch {
477+
guard let dataStoreError = error as? DataStoreError, case .invalidDatabase = dataStoreError else {
478+
XCTFail("Expected DataStoreErrorF")
479+
return
480+
}
481+
}
482+
483+
_ = UserDefaults.removeObject(userDefaults)
484+
}
378485
}

0 commit comments

Comments
 (0)