Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,24 @@ extension PersistedProduct: FetchableRecord, PersistableRecord {
using: PersistedProductImage.image)

public static let attributes = hasMany(PersistedProductAttribute.self,
key: "attributes",
using: ForeignKey([PersistedProductAttribute.CodingKeys.siteID.stringValue,
PersistedProductAttribute.CodingKeys.productID.stringValue],
to: primaryKey))
}

// MARK: - Point of Sale Requests
public extension PersistedProduct {
/// Returns a request for POS-supported products (simple and variable, non-downloadable) for a given site, ordered by name
static func posProductsRequest(siteID: Int64) -> QueryInterfaceRequest<PersistedProduct> {
return PersistedProduct
.filter(Columns.siteID == siteID)
.filter([ProductType.simple.rawValue, ProductType.variable.rawValue].contains(Columns.productTypeKey))
.filter(Columns.downloadable == false)
.order(Columns.name.collating(.localizedCaseInsensitiveCompare))
}
}

// periphery:ignore - TODO: remove ignore when populating database
private extension PersistedProduct {
enum CodingKeys: String, CodingKey {
Expand All @@ -107,4 +120,9 @@ private extension PersistedProduct {
case stockQuantity
case stockStatusKey
}

enum ProductType: String {
case simple
case variable
}
}
10 changes: 10 additions & 0 deletions Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ extension PersistedProductVariation: FetchableRecord, PersistableRecord {
key: "image")
}

// MARK: - Point of Sale Requests
public extension PersistedProductVariation {
/// Returns a request for non-downloadable variations of a parent product, ordered by ID
static func posVariationsRequest(siteID: Int64, parentProductID: Int64) -> QueryInterfaceRequest<PersistedProductVariation> {
return PersistedProductVariation
.filter(Columns.siteID == siteID && Columns.productID == parentProductID)
.filter(Columns.downloadable == false)
.order(Columns.id)
}
}

// periphery:ignore - TODO: remove ignore when populating database
private extension PersistedProductVariation {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
// periphery:ignore:all
import Foundation
import GRDB
import Combine
import Observation
import Storage
import WooFoundation

/// Observable data source for GRDB-based POS items using ValueObservation
/// Provides automatic SwiftUI updates when database changes occur
@Observable
public final class GRDBObservableDataSource: POSObservableDataSourceProtocol {
// MARK: - Observable Properties

public private(set) var productItems: [POSItem] = []
public private(set) var variationItems: [POSItem] = []
public private(set) var isLoadingProducts: Bool = false
public private(set) var isLoadingVariations: Bool = false
public private(set) var error: Error? = nil

public var hasMoreProducts: Bool {
productItems.count >= (pageSize * currentProductPage) && totalProductCount > productItems.count
}

public var hasMoreVariations: Bool {
variationItems.count >= (pageSize * currentVariationPage) && totalVariationCount > variationItems.count
}

// MARK: - Private Properties

private let siteID: Int64
private let grdbManager: GRDBManagerProtocol
private let itemMapper: PointOfSaleItemMapperProtocol
private let pageSize: Int

private var currentProductPage: Int = 1
private var currentVariationPage: Int = 1
private var currentParentProduct: POSVariableParentProduct?
private var totalProductCount: Int = 0
private var totalVariationCount: Int = 0

// ValueObservation subscriptions
private var productObservationCancellable: AnyCancellable?
private var variationObservationCancellable: AnyCancellable?
private var statisticsObservationCancellable: AnyCancellable?
private var variationStatisticsObservationCancellable: AnyCancellable?

// MARK: - Initialization

public init(siteID: Int64,
grdbManager: GRDBManagerProtocol,
currencySettings: CurrencySettings,
itemMapper: PointOfSaleItemMapperProtocol? = nil,
pageSize: Int = 20) {
self.siteID = siteID
self.grdbManager = grdbManager
self.itemMapper = itemMapper ?? PointOfSaleItemMapper(currencySettings: currencySettings)
self.pageSize = pageSize

setupStatisticsObservation()
}

deinit {
productObservationCancellable?.cancel()
variationObservationCancellable?.cancel()
statisticsObservationCancellable?.cancel()
variationStatisticsObservationCancellable?.cancel()
}

// MARK: - POSObservableDataSourceProtocol

public func loadProducts() {
currentProductPage = 1
isLoadingProducts = true
setupProductObservation()
}

public func loadMoreProducts() {
guard hasMoreProducts && !isLoadingProducts else { return }

isLoadingProducts = true
currentProductPage += 1
setupProductObservation()
}

public func loadVariations(for parentProduct: POSVariableParentProduct) {
guard currentParentProduct?.productID != parentProduct.productID else {
return // Same parent - idempotent
}

currentParentProduct = parentProduct
currentVariationPage = 1
isLoadingVariations = true
variationItems = []

setupVariationObservation(parentProduct: parentProduct)
setupVariationStatisticsObservation(parentProduct: parentProduct)
}

public func loadMoreVariations() {
guard let parentProduct = currentParentProduct,
hasMoreVariations && !isLoadingVariations else { return }

isLoadingVariations = true
currentVariationPage += 1
setupVariationObservation(parentProduct: parentProduct)
}

public func refresh() {
// No-op: database observation automatically updates when data changes during incremental sync
}

// MARK: - ValueObservation Setup

private func setupProductObservation() {
let currentPage = currentProductPage
let observation = ValueObservation
.tracking { [weak self] database -> [POSProduct] in
guard let self else { return [] }

struct ProductWithRelations: Decodable, FetchableRecord {
let product: PersistedProduct
let images: [PersistedImage]?
let attributes: [PersistedProductAttribute]?
}

let productsWithRelations = try PersistedProduct
.posProductsRequest(siteID: siteID)
.limit(pageSize * currentPage)
.including(all: PersistedProduct.images)
.including(all: PersistedProduct.attributes)
.asRequest(of: ProductWithRelations.self)
.fetchAll(database)

return productsWithRelations.map { record in
record.product.toPOSProduct(
images: (record.images ?? []).map { $0.toProductImage() },
attributes: (record.attributes ?? []).map { $0.toProductAttribute(siteID: record.product.siteID) }
)
}
}

productObservationCancellable = observation
.publisher(in: grdbManager.databaseConnection)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.error = error
self?.isLoadingProducts = false
}
},
receiveValue: { [weak self] observedProducts in
guard let self else { return }
let posItems = itemMapper.mapProductsToPOSItems(products: observedProducts)
Copy link
Contributor

Choose a reason for hiding this comment

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

You mentioned the performance with a lot of items which may be connected to mapping, could we do the mapping outside the main thread and only receive on main thread?

    .publisher(in: grdbManager.databaseConnection)
    .map { [itemMapper] observedProducts in
        // Mapping happens on background Combine scheduler
        itemMapper.mapProductsToPOSItems(products: observedProducts)
    }
    .receive(on: DispatchQueue.main)

We could also leave such performance considerations for the future. I'm sure there are even more things that we could do if we reach use cases with extremely large catalogs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I thought about this approach after I finished up last week, and it should work. But I previously had the mapping happen off the main thread (with different syntax) and it didn't make a noticable performance difference, so I think it's fine to leave for now.

productItems = posItems
error = nil
isLoadingProducts = false
}
)
}

private func setupVariationObservation(parentProduct: POSVariableParentProduct) {
let currentPage = currentVariationPage
let observation = ValueObservation
.tracking { [weak self] database -> [POSProductVariation] in
guard let self else { return [] }

struct VariationWithRelations: Decodable, FetchableRecord {
let persistedProductVariation: PersistedProductVariation
let attributes: [PersistedProductVariationAttribute]?
let image: PersistedImage?
}

let variationsWithRelations = try PersistedProductVariation
.posVariationsRequest(siteID: self.siteID, parentProductID: parentProduct.productID)
.limit(self.pageSize * currentPage)
.including(all: PersistedProductVariation.attributes)
.including(optional: PersistedProductVariation.image)
.asRequest(of: VariationWithRelations.self)
.fetchAll(database)

return variationsWithRelations.map { record in
record.persistedProductVariation.toPOSProductVariation(
attributes: (record.attributes ?? []).map { $0.toProductVariationAttribute() },
image: record.image?.toProductImage()
)
}
}

variationObservationCancellable = observation
.publisher(in: grdbManager.databaseConnection)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.error = error
self?.isLoadingVariations = false
}
},
receiveValue: { [weak self] observedVariations in
guard let self else { return }
let posItems = itemMapper.mapVariationsToPOSItems(
variations: observedVariations,
parentProduct: parentProduct
)
variationItems = posItems
error = nil
isLoadingVariations = false
}
)
}

private func setupStatisticsObservation() {
let observation = ValueObservation
.tracking { [weak self] database in
guard let self else { return 0 }

let productCount = try PersistedProduct
.posProductsRequest(siteID: siteID)
.fetchCount(database)

return productCount
}

statisticsObservationCancellable = observation
.publisher(in: grdbManager.databaseConnection)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
if case .failure = completion {
// Silently ignore - statistics are not critical
}
},
receiveValue: { [weak self] productCount in
self?.totalProductCount = productCount
}
)
}

private func setupVariationStatisticsObservation(parentProduct: POSVariableParentProduct) {
let observation = ValueObservation
.tracking { [weak self] database in
guard let self else { return 0 }

return try PersistedProductVariation
.posVariationsRequest(siteID: siteID, parentProductID: parentProduct.productID)
.fetchCount(database)
}

variationStatisticsObservationCancellable = observation
.publisher(in: grdbManager.databaseConnection)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
if case .failure = completion {
// Silently ignore - statistics are not critical
}
},
receiveValue: { [weak self] variationCount in
self?.totalVariationCount = variationCount
}
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// periphery:ignore:all
import Foundation

/// Protocol for observable data sources that provide POS items with automatic updates
public protocol POSObservableDataSourceProtocol {
/// Current products mapped to POSItems
var productItems: [POSItem] { get }

/// Current variations for the selected parent product mapped to POSItems
var variationItems: [POSItem] { get }

/// Loading state for products
var isLoadingProducts: Bool { get }

/// Loading state for variations
var isLoadingVariations: Bool { get }

/// Whether more products are available to load
var hasMoreProducts: Bool { get }

/// Whether more variations are available for current parent
var hasMoreVariations: Bool { get }

/// Current error, if any
var error: Error? { get }

/// Loads the first page of products
func loadProducts()

/// Loads the next page of products
func loadMoreProducts()

/// Loads variations for a specific parent product
func loadVariations(for parentProduct: POSVariableParentProduct)

/// Loads more variations for the current parent product
func loadMoreVariations()

/// Refreshes all data
/// Note: For GRDB implementations, this is a no-op as the database observation
/// automatically updates when data changes during incremental sync
func refresh()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation
import Observation
import Yosemite

/// Mock implementation for testing and development
@Observable
final class MockPOSObservableDataSource: POSObservableDataSourceProtocol {
var productItems: [POSItem] = []
var variationItems: [POSItem] = []
var isLoadingProducts: Bool = false
var isLoadingVariations: Bool = false
var hasMoreProducts: Bool = false
var hasMoreVariations: Bool = false
var error: Error? = nil

init() {}

func loadProducts() {
// Tests set properties directly - no async behavior needed
}

func loadMoreProducts() {
// Tests set properties directly - no async behavior needed
}

func loadVariations(for parentProduct: POSVariableParentProduct) {
// Tests set properties directly - no async behavior needed
}

func loadMoreVariations() {
// Tests set properties directly - no async behavior needed
}

func refresh() {
// No-op for mock
}
}
Loading