Skip to content

Commit 3914fc5

Browse files
authored
[Local Catalog] Incremental sync: persist incrementally changed products/variations to storage (#16106)
2 parents 2777090 + 7162e20 commit 3914fc5

8 files changed

+417
-13
lines changed

Modules/Sources/Storage/GRDB/Model/PersistedProductAttribute.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ extension PersistedProductAttribute: FetchableRecord, MutablePersistableRecord {
3434

3535
public enum Columns {
3636
static let id = Column(CodingKeys.id)
37-
static let productID = Column(CodingKeys.productID)
37+
public static let productID = Column(CodingKeys.productID)
3838
static let name = Column(CodingKeys.name)
3939
static let position = Column(CodingKeys.position)
4040
static let visible = Column(CodingKeys.visible)

Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ extension PersistedProductImage: FetchableRecord, PersistableRecord {
3434

3535
public enum Columns {
3636
static let id = Column(CodingKeys.id)
37-
static let productID = Column(CodingKeys.productID)
37+
public static let productID = Column(CodingKeys.productID)
3838
static let dateCreated = Column(CodingKeys.dateCreated)
3939
static let dateModified = Column(CodingKeys.dateModified)
4040
static let src = Column(CodingKeys.src)

Modules/Sources/Storage/GRDB/Model/PersistedProductVariationAttribute.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ extension PersistedProductVariationAttribute: FetchableRecord, MutablePersistabl
2626

2727
public enum Columns {
2828
static let id = Column(CodingKeys.id)
29-
static let productVariationID = Column(CodingKeys.productVariationID)
29+
public static let productVariationID = Column(CodingKeys.productVariationID)
3030
static let name = Column(CodingKeys.name)
3131
static let option = Column(CodingKeys.option)
3232
}

Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ extension PersistedProductVariationImage: FetchableRecord, PersistableRecord {
3434

3535
public enum Columns {
3636
static let id = Column(CodingKeys.id)
37-
static let productVariationID = Column(CodingKeys.productVariationID)
37+
public static let productVariationID = Column(CodingKeys.productVariationID)
3838
static let dateCreated = Column(CodingKeys.dateCreated)
3939
static let dateModified = Column(CodingKeys.dateModified)
4040
static let src = Column(CodingKeys.src)

Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,25 @@ public protocol POSCatalogIncrementalSyncServiceProtocol {
2020
public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncServiceProtocol {
2121
private let syncRemote: POSCatalogSyncRemoteProtocol
2222
private let batchSize: Int
23+
private let persistenceService: POSCatalogPersistenceServiceProtocol
2324
private var lastIncrementalSyncDates: [Int64: Date] = [:]
2425
private let batchedLoader: BatchedRequestLoader
2526

26-
public convenience init?(credentials: Credentials?, batchSize: Int = 1) {
27+
public convenience init?(credentials: Credentials?, batchSize: Int = 1, grdbManager: GRDBManagerProtocol) {
2728
guard let credentials else {
2829
DDLogError("⛔️ Could not create POSCatalogIncrementalSyncService due missing credentials")
2930
return nil
3031
}
3132
let network = AlamofireNetwork(credentials: credentials, ensuresSessionManagerIsInitialized: true)
3233
let syncRemote = POSCatalogSyncRemote(network: network)
33-
self.init(syncRemote: syncRemote, batchSize: batchSize)
34+
let persistenceService = POSCatalogPersistenceService(grdbManager: grdbManager)
35+
self.init(syncRemote: syncRemote, batchSize: batchSize, persistenceService: persistenceService)
3436
}
3537

36-
init(syncRemote: POSCatalogSyncRemoteProtocol, batchSize: Int) {
38+
init(syncRemote: POSCatalogSyncRemoteProtocol, batchSize: Int, persistenceService: POSCatalogPersistenceServiceProtocol) {
3739
self.syncRemote = syncRemote
3840
self.batchSize = batchSize
41+
self.persistenceService = persistenceService
3942
self.batchedLoader = BatchedRequestLoader(batchSize: batchSize)
4043
}
4144

@@ -51,7 +54,8 @@ public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncSe
5154
let catalog = try await loadCatalog(for: siteID, modifiedAfter: modifiedAfter, syncRemote: syncRemote)
5255
DDLogInfo("✅ Loaded \(catalog.products.count) products and \(catalog.variations.count) variations for siteID \(siteID)")
5356

54-
// TODO: WOOMOB-1298 - persist to database
57+
try await persistenceService.persistIncrementalCatalogData(catalog, siteID: siteID)
58+
DDLogInfo("✅ Persisted \(catalog.products.count) products and \(catalog.variations.count) variations to database for siteID \(siteID)")
5559

5660
// TODO: WOOMOB-1289 - replace with store settings persistence
5761
lastIncrementalSyncDates[siteID] = syncStartDate

Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
// periphery:ignore:all
22
import Foundation
33
import Storage
4+
import GRDB
45

56
protocol POSCatalogPersistenceServiceProtocol {
67
/// Clears existing data and persists new catalog data
78
/// - Parameters:
89
/// - catalog: The catalog to persist
910
/// - siteID: The site ID to associate the catalog with
1011
func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws
12+
13+
/// Persists incremental catalog data (insert/update)
14+
/// - Parameters:
15+
/// - catalog: The catalog difference to persist
16+
/// - siteID: The site ID to associate the catalog with
17+
func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws
1118
}
1219

1320
final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol {
@@ -29,23 +36,23 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol {
2936
try site.insert(db)
3037

3138
for product in catalog.productsToPersist {
32-
try product.insert(db, onConflict: .ignore)
39+
try product.insert(db, onConflict: .replace)
3340
}
3441

3542
for image in catalog.productImagesToPersist {
36-
try image.insert(db, onConflict: .ignore)
43+
try image.insert(db, onConflict: .replace)
3744
}
3845

3946
for var attribute in catalog.productAttributesToPersist {
4047
try attribute.insert(db)
4148
}
4249

4350
for variation in catalog.variationsToPersist {
44-
try variation.insert(db, onConflict: .ignore)
51+
try variation.insert(db, onConflict: .replace)
4552
}
4653

4754
for image in catalog.variationImagesToPersist {
48-
try image.insert(db, onConflict: .ignore)
55+
try image.insert(db, onConflict: .replace)
4956
}
5057

5158
for var attribute in catalog.variationAttributesToPersist {
@@ -68,6 +75,67 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol {
6875
"\(variationImageCount) variation images, \(variationAttributeCount) variation attributes")
6976
}
7077
}
78+
79+
func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {
80+
DDLogInfo("💾 Persisting incremental catalog with \(catalog.products.count) products and \(catalog.variations.count) variations")
81+
82+
try await grdbManager.databaseConnection.write { db in
83+
for product in catalog.productsToPersist {
84+
try product.insert(db, onConflict: .replace)
85+
86+
try PersistedProductImage
87+
.filter { $0.productID == product.id }
88+
.deleteAll(db)
89+
90+
try PersistedProductAttribute
91+
.filter { $0.productID == product.id }
92+
.deleteAll(db)
93+
}
94+
95+
for image in catalog.productImagesToPersist {
96+
try image.insert(db, onConflict: .replace)
97+
}
98+
99+
for var attribute in catalog.productAttributesToPersist {
100+
try attribute.insert(db, onConflict: .replace)
101+
}
102+
103+
for variation in catalog.variationsToPersist {
104+
try variation.insert(db, onConflict: .replace)
105+
106+
try PersistedProductVariationImage
107+
.filter { $0.productVariationID == variation.id }
108+
.deleteAll(db)
109+
110+
try PersistedProductVariationAttribute
111+
.filter { $0.productVariationID == variation.id }
112+
.deleteAll(db)
113+
}
114+
115+
for image in catalog.variationImagesToPersist {
116+
try image.insert(db, onConflict: .replace)
117+
}
118+
119+
for var attribute in catalog.variationAttributesToPersist {
120+
try attribute.insert(db, onConflict: .replace)
121+
}
122+
}
123+
124+
DDLogInfo("✅ Incremental catalog persistence complete")
125+
126+
try await grdbManager.databaseConnection.read { db in
127+
let productCount = try PersistedProduct.fetchCount(db)
128+
let productImageCount = try PersistedProductImage.fetchCount(db)
129+
let productAttributeCount = try PersistedProductAttribute.fetchCount(db)
130+
let variationCount = try PersistedProductVariation.fetchCount(db)
131+
let variationImageCount = try PersistedProductVariationImage.fetchCount(db)
132+
let variationAttributeCount = try PersistedProductVariationAttribute.fetchCount(db)
133+
134+
DDLogInfo("Total after incremental update: \(productCount) products, \(productImageCount) product images, " +
135+
"\(productAttributeCount) product attributes, \(variationCount) variations, " +
136+
"\(variationImageCount) variation images, \(variationAttributeCount) variation attributes")
137+
}
138+
}
71139
}
72140

73141
private extension POSCatalog {

Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@ import Foundation
22
import Testing
33
@testable import Networking
44
@testable import Yosemite
5+
@testable import Storage
56

67
struct POSCatalogIncrementalSyncServiceTests {
78
private let sut: POSCatalogIncrementalSyncService
89
private let mockSyncRemote: MockPOSCatalogSyncRemote
10+
private let mockPersistenceService: MockPOSCatalogPersistenceService
911
private let sampleSiteID: Int64 = 134
1012

1113
init() {
1214
self.mockSyncRemote = MockPOSCatalogSyncRemote()
13-
self.sut = POSCatalogIncrementalSyncService(syncRemote: mockSyncRemote, batchSize: 2)
15+
self.mockPersistenceService = MockPOSCatalogPersistenceService()
16+
self.sut = POSCatalogIncrementalSyncService(syncRemote: mockSyncRemote, batchSize: 2, persistenceService: mockPersistenceService)
1417
}
1518

1619
// MARK: - Basic Incremental Sync Tests
@@ -32,6 +35,7 @@ struct POSCatalogIncrementalSyncServiceTests {
3235
#expect(mockSyncRemote.loadIncrementalProductVariationsCallCount == 2)
3336
#expect(mockSyncRemote.lastIncrementalProductsModifiedAfter == lastFullSyncDate)
3437
#expect(mockSyncRemote.lastIncrementalVariationsModifiedAfter == lastFullSyncDate)
38+
#expect(mockPersistenceService.persistIncrementalCatalogDataCallCount == 1)
3539
}
3640

3741
@Test func startIncrementalSync_uses_last_incremental_sync_date_as_modifiedAfter_date_when_available() async throws {
@@ -73,6 +77,8 @@ struct POSCatalogIncrementalSyncServiceTests {
7377

7478
// Then
7579
#expect(mockSyncRemote.loadIncrementalProductsCallCount == 4)
80+
let persistedCatalog = try #require(mockPersistenceService.persistIncrementalCatalogDataLastPersistedCatalog)
81+
#expect(persistedCatalog.products.count == 3)
7682
}
7783

7884
@Test func startIncrementalSync_handles_paginated_variations_correctly() async throws {
@@ -92,6 +98,8 @@ struct POSCatalogIncrementalSyncServiceTests {
9298

9399
// Then
94100
#expect(mockSyncRemote.loadIncrementalProductVariationsCallCount == 2)
101+
let persistedCatalog = try #require(mockPersistenceService.persistIncrementalCatalogDataLastPersistedCatalog)
102+
#expect(persistedCatalog.variations.count == 2)
95103
}
96104

97105
// MARK: - Error Handling Tests
@@ -108,13 +116,39 @@ struct POSCatalogIncrementalSyncServiceTests {
108116
await #expect(throws: expectedError) {
109117
try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate)
110118
}
119+
#expect(mockPersistenceService.persistIncrementalCatalogDataCallCount == 0)
111120

112121
// When attempting a second sync
113122
mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)))
114123
try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate)
115124

116125
// Then it uses lastFullSyncDate since no incremental date was stored due to previous failure
117126
#expect(mockSyncRemote.lastIncrementalProductsModifiedAfter == lastFullSyncDate)
127+
#expect(mockPersistenceService.persistIncrementalCatalogDataCallCount == 1)
128+
}
129+
130+
@Test func startIncrementalSync_throws_error_when_persistence_fails() async throws {
131+
// Given
132+
let lastFullSyncDate = Date(timeIntervalSince1970: 1000)
133+
let expectedError = NSError(domain: "persistence", code: 500, userInfo: nil)
134+
135+
mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)))
136+
mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)))
137+
mockPersistenceService.persistIncrementalCatalogDataError = expectedError
138+
139+
// When/Then
140+
await #expect(throws: Error.self) {
141+
try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate)
142+
}
143+
#expect(mockPersistenceService.persistIncrementalCatalogDataCallCount == 1)
144+
145+
// When attempting a second sync
146+
mockPersistenceService.persistIncrementalCatalogDataError = nil // Clear the error
147+
try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate)
148+
149+
// Then it uses lastFullSyncDate since no incremental date was stored due to previous persistence failure
150+
#expect(mockSyncRemote.lastIncrementalProductsModifiedAfter == lastFullSyncDate)
151+
#expect(mockPersistenceService.persistIncrementalCatalogDataCallCount == 2)
118152
}
119153

120154
// MARK: - Per-Site Behavior Tests
@@ -140,3 +174,23 @@ struct POSCatalogIncrementalSyncServiceTests {
140174
#expect(site2ModifiedAfter == lastFullSyncDate)
141175
}
142176
}
177+
178+
// MARK: - Mock Classes
179+
180+
private final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol {
181+
private(set) var persistIncrementalCatalogDataCallCount = 0
182+
private(set) var persistIncrementalCatalogDataLastPersistedCatalog: POSCatalog?
183+
private(set) var persistIncrementalCatalogDataLastPersistedSiteID: Int64?
184+
var persistIncrementalCatalogDataError: Error?
185+
186+
func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {}
187+
188+
func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {
189+
persistIncrementalCatalogDataCallCount += 1
190+
persistIncrementalCatalogDataLastPersistedSiteID = siteID
191+
persistIncrementalCatalogDataLastPersistedCatalog = catalog
192+
if let error = persistIncrementalCatalogDataError {
193+
throw error
194+
}
195+
}
196+
}

0 commit comments

Comments
 (0)