Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ struct V001InitialSchema {
private static func createProductVariationImageTable(_ db: Database) throws {
try db.create(table: "productVariationImage") { productVariationImageTable in
productVariationImageTable.primaryKey("id", .integer).notNull()
productVariationImageTable.belongsTo("productVariation").notNull()
productVariationImageTable.belongsTo("productVariation", onDelete: .cascade).notNull()

productVariationImageTable.column("dateCreated", .datetime).notNull()
productVariationImageTable.column("dateModified", .datetime)
Expand Down
17 changes: 14 additions & 3 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import protocol Networking.POSCatalogSyncRemoteProtocol
import class Networking.AlamofireNetwork
import class Networking.POSCatalogSyncRemote
import CocoaLumberjackSwift
import Storage
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: maybe can import just POSCatalogPersistenceServiceProtocol and POSCatalogPersistenceService instead of the whole Storage?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's actually for GRDBManagerProtocol, the persistence service is in Yosemite anyway so doesn't need to be imported.

I'm not sure that granular imports help us much here. AIUI, we do it from the main app to make it easier to split POS to a new app, if we ever do that. Storage is much simpler, and we implicitly depend on most of it via the storage model typealiases.

Perhaps a different question: Should GRDB be in a new module of its own? Maybe it would be best to do that now, in a new PR but before we add more to it. WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure that granular imports help us much here. AIUI, we do it from the main app to make it easier to split POS to a new app, if we ever do that. Storage is much simpler, and we implicitly depend on most of it via the storage model typealiases.

My understanding for granular imports is so that it's clear which (minimum) dependencies are required for the file. It's more useful when the imported module is bigger though, like Yosemite. For Storage I think it's fine, just a nit preference.

Perhaps a different question: Should GRDB be in a new module of its own? Maybe it would be best to do that now, in a new PR but before we add more to it. WDYT?

Were you thinking of keeping GRDB code in a separate module, and Storage imports it for its usage with potential abstraction in the interface with Yosemite? Was it because the GRDB code might get complex, or any other reasons? Right now, I think it's fine for GRDB to be within Storage as an implementation of the protocols. Would like to hear any strong reasons for moving it to a separate module.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Were you thinking of keeping GRDB code in a separate module, and Storage imports it for its usage with potential abstraction in the interface with Yosemite? Was it because the GRDB code might get complex, or any other reasons? Right now, I think it's fine for GRDB to be within Storage as an implementation of the protocols. Would like to hear any strong reasons for moving it to a separate module.

No, I was thinking a new sibling module for Storage, e.g. GRDBStorage (bad name, but for discussion).

Our new persisted models don't rely on anything pre-existing in Storage. Now's the easiest time to separate them so that we don't end up with anything cutting across both types of storage.

By putting them in a separate module, we could call them Product, ProductVariation etc, at least within GRDBStorage. If we split POS into a separate app, we might be able to leave Storage behind (unlikely, but not impossible.)

I don't have super-strong arguments beyond that. Keeping it in Storage is fine... but other than the name, and saving a little effort adding a module, there's no particular reason it has to be in there.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, so the current Storage is more like CoreDataStorage. Makes sense to me. We could put it up for discussion and fit this after the main tasks are done.

From development so far, it's not a strong need, but it might be great to have more abstraction on the interface between the (GRDB)Storage and Yosemite layers.


// TODO - remove the periphery ignore comment when the catalog is integrated with POS.
// periphery:ignore
Expand All @@ -26,20 +27,23 @@ public struct POSCatalog {
public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol {
private let syncRemote: POSCatalogSyncRemoteProtocol
private let batchSize: Int
private let persistenceService: POSCatalogPersistenceServiceProtocol

public convenience init?(credentials: Credentials?, batchSize: Int = 2) {
public convenience init?(credentials: Credentials?, batchSize: Int = 2, grdbManager: GRDBManagerProtocol) {
guard let credentials else {
DDLogError("⛔️ Could not create POSCatalogFullSyncService due missing credentials")
return nil
}
let network = AlamofireNetwork(credentials: credentials, ensuresSessionManagerIsInitialized: true)
let syncRemote = POSCatalogSyncRemote(network: network)
self.init(syncRemote: syncRemote, batchSize: batchSize)
let persistenceService = POSCatalogPersistenceService(grdbManager: grdbManager)
self.init(syncRemote: syncRemote, batchSize: batchSize, persistenceService: persistenceService)
}

init(syncRemote: POSCatalogSyncRemoteProtocol, batchSize: Int) {
init(syncRemote: POSCatalogSyncRemoteProtocol, batchSize: Int, persistenceService: POSCatalogPersistenceServiceProtocol) {
self.syncRemote = syncRemote
self.batchSize = batchSize
self.persistenceService = persistenceService
}

// MARK: - Protocol Conformance
Expand All @@ -48,10 +52,17 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol
DDLogInfo("🔄 Starting full catalog sync for site ID: \(siteID)")

do {
// Sync from network
let catalog = try await loadCatalog(for: siteID, syncRemote: syncRemote)
DDLogInfo("✅ Loaded \(catalog.products.count) products and \(catalog.variations.count) variations for siteID \(siteID)")

// Persist to database
try await persistenceService.replaceAllCatalogData(catalog, siteID: siteID)
DDLogInfo("✅ Persisted \(catalog.products.count) products and \(catalog.variations.count) variations to database for siteID \(siteID)")

return catalog
} catch {
DDLogError("❌ Failed to sync and persist catalog: \(error)")
throw error
}
}
Expand Down
105 changes: 105 additions & 0 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// periphery:ignore:all
import Foundation
import Storage

protocol POSCatalogPersistenceServiceProtocol {
/// Clears existing data and persists new catalog data
/// - Parameters:
/// - catalog: The catalog to persist
/// - siteID: The site ID to associate the catalog with
func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws
}

final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol {
private let grdbManager: GRDBManagerProtocol

init(grdbManager: GRDBManagerProtocol) {
self.grdbManager = grdbManager
}

func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {
DDLogInfo("💾 Persisting catalog with \(catalog.products.count) products and \(catalog.variations.count) variations")

try await grdbManager.databaseConnection.write { db in
DDLogInfo("🗑️ Clearing catalog data for site \(siteID)")
// currently, we can't save for more than one site as entity IDs are not namespaced.
try PersistedSite.deleteAll(db)

let site = PersistedSite(id: siteID)
try site.insert(db)

for product in catalog.productsToPersist {
try product.insert(db, onConflict: .ignore)
}

for image in catalog.productImagesToPersist {
try image.insert(db, onConflict: .ignore)
}

for var attribute in catalog.productAttributesToPersist {
try attribute.insert(db)
}

for variation in catalog.variationsToPersist {
try variation.insert(db, onConflict: .ignore)
}

for image in catalog.variationImagesToPersist {
try image.insert(db, onConflict: .ignore)
}

for var attribute in catalog.variationAttributesToPersist {
try attribute.insert(db)
}
}

DDLogInfo("✅ Catalog persistence complete")

try await grdbManager.databaseConnection.read { db in
let productCount = try PersistedProduct.fetchCount(db)
let productImageCount = try PersistedProductImage.fetchCount(db)
let productAttributeCount = try PersistedProductAttribute.fetchCount(db)
let variationCount = try PersistedProductVariation.fetchCount(db)
let variationImageCount = try PersistedProductVariationImage.fetchCount(db)
let variationAttributeCount = try PersistedProductVariationAttribute.fetchCount(db)

DDLogInfo("Persisted \(productCount) products, \(productImageCount) product images, " +
"\(productAttributeCount) product attributes, \(variationCount) variations, " +
"\(variationImageCount) variation images, \(variationAttributeCount) variation attributes")
}
}
}

private extension POSCatalog {
var productsToPersist: [PersistedProduct] {
products.map { PersistedProduct(from: $0) }
}

var productImagesToPersist: [PersistedProductImage] {
products.flatMap { product in
product.images.map { PersistedProductImage(from: $0, productID: product.productID) }
}
}

var productAttributesToPersist: [PersistedProductAttribute] {
products.flatMap { product in
product.attributes.map { PersistedProductAttribute(from: $0, productID: product.productID) }
}
}

var variationsToPersist: [PersistedProductVariation] {
variations.map { PersistedProductVariation(from: $0) }
}

var variationImagesToPersist: [PersistedProductVariationImage] {
variations.compactMap { variation in
variation.image.map { PersistedProductVariationImage(from: $0, productVariationID: variation.productVariationID) }
}
}

var variationAttributesToPersist: [PersistedProductVariationAttribute] {
variations.flatMap { variation in
variation.attributes.map { PersistedProductVariationAttribute(from: $0, productVariationID: variation.productVariationID) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ import Foundation
import Testing
@testable import Networking
@testable import Yosemite
@testable import Storage

struct POSCatalogFullSyncServiceTests {
private let sut: POSCatalogFullSyncService
private let mockSyncRemote: MockPOSCatalogSyncRemote
private let mockPersistenceService: MockPOSCatalogPersistenceService
private let sampleSiteID: Int64 = 134

init() {
self.mockSyncRemote = MockPOSCatalogSyncRemote()
self.sut = POSCatalogFullSyncService(syncRemote: mockSyncRemote, batchSize: 2)
self.mockPersistenceService = MockPOSCatalogPersistenceService()
self.sut = POSCatalogFullSyncService(syncRemote: mockSyncRemote, batchSize: 2, persistenceService: mockPersistenceService)
}

// MARK: - Full Sync Tests
Expand Down Expand Up @@ -128,20 +131,24 @@ struct POSCatalogFullSyncServiceTests {

// MARK: - Initialization Tests

@Test func init_with_valid_credentials_creates_service() {
@Test func init_with_valid_credentials_creates_service() throws {
// Given
let credentials = Credentials.wpcom(username: "test", authToken: "token", siteAddress: "site.com")
let grdbManager = try GRDBManager()

// When
let service = POSCatalogFullSyncService(credentials: credentials)
let service = POSCatalogFullSyncService(credentials: credentials, grdbManager: grdbManager)

// Then
#expect(service != nil)
}

@Test func init_with_nil_credentials_returns_nil() {
// Given/When
let service = POSCatalogFullSyncService(credentials: nil)
@Test func init_with_nil_credentials_returns_nil() throws {
// Given
let grdbManager = try GRDBManager()

// When
let service = POSCatalogFullSyncService(credentials: nil, grdbManager: grdbManager)

// Then
#expect(service == nil)
Expand All @@ -152,7 +159,9 @@ struct POSCatalogFullSyncServiceTests {
let customBatchSize = 5

// When
let service = POSCatalogFullSyncService(syncRemote: mockSyncRemote, batchSize: customBatchSize)
let service = POSCatalogFullSyncService(syncRemote: mockSyncRemote,
batchSize: customBatchSize,
persistenceService: mockPersistenceService)
_ = try await service.startFullSync(for: sampleSiteID)

// Then
Expand Down Expand Up @@ -231,3 +240,17 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol {
return fallbackVariationResult
}
}

// MARK: - Mock POSCatalogPersistenceService

final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol {
private(set) var replaceAllCatalogDataCallCount = 0
private(set) var lastPersistedCatalog: POSCatalog?
private(set) var lastPersistedSiteID: Int64?
Comment on lines +247 to +249
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing test cases that assert on these mock persistence service variables will be added in a future PR?


func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {
replaceAllCatalogDataCallCount += 1
lastPersistedSiteID = siteID
lastPersistedCatalog = catalog
}
}
Loading