Skip to content

Commit 56aa52a

Browse files
authored
[Local Catalog] Add GRDB data source for item list (#16231)
2 parents 2474539 + 728ac9a commit 56aa52a

File tree

6 files changed

+1210
-0
lines changed

6 files changed

+1210
-0
lines changed

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,24 @@ extension PersistedProduct: FetchableRecord, PersistableRecord {
8484
using: PersistedProductImage.image)
8585

8686
public static let attributes = hasMany(PersistedProductAttribute.self,
87+
key: "attributes",
8788
using: ForeignKey([PersistedProductAttribute.CodingKeys.siteID.stringValue,
8889
PersistedProductAttribute.CodingKeys.productID.stringValue],
8990
to: primaryKey))
9091
}
9192

93+
// MARK: - Point of Sale Requests
94+
public extension PersistedProduct {
95+
/// Returns a request for POS-supported products (simple and variable, non-downloadable) for a given site, ordered by name
96+
static func posProductsRequest(siteID: Int64) -> QueryInterfaceRequest<PersistedProduct> {
97+
return PersistedProduct
98+
.filter(Columns.siteID == siteID)
99+
.filter([ProductType.simple.rawValue, ProductType.variable.rawValue].contains(Columns.productTypeKey))
100+
.filter(Columns.downloadable == false)
101+
.order(Columns.name.collating(.localizedCaseInsensitiveCompare))
102+
}
103+
}
104+
92105
// periphery:ignore - TODO: remove ignore when populating database
93106
private extension PersistedProduct {
94107
enum CodingKeys: String, CodingKey {
@@ -107,4 +120,9 @@ private extension PersistedProduct {
107120
case stockQuantity
108121
case stockStatusKey
109122
}
123+
124+
enum ProductType: String {
125+
case simple
126+
case variable
127+
}
110128
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ extension PersistedProductVariation: FetchableRecord, PersistableRecord {
8080
key: "image")
8181
}
8282

83+
// MARK: - Point of Sale Requests
84+
public extension PersistedProductVariation {
85+
/// Returns a request for non-downloadable variations of a parent product, ordered by ID
86+
static func posVariationsRequest(siteID: Int64, parentProductID: Int64) -> QueryInterfaceRequest<PersistedProductVariation> {
87+
return PersistedProductVariation
88+
.filter(Columns.siteID == siteID && Columns.productID == parentProductID)
89+
.filter(Columns.downloadable == false)
90+
.order(Columns.id)
91+
}
92+
}
8393

8494
// periphery:ignore - TODO: remove ignore when populating database
8595
private extension PersistedProductVariation {
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// periphery:ignore:all
2+
import Foundation
3+
import GRDB
4+
import Combine
5+
import Observation
6+
import Storage
7+
import WooFoundation
8+
9+
/// Observable data source for GRDB-based POS items using ValueObservation
10+
/// Provides automatic SwiftUI updates when database changes occur
11+
@Observable
12+
public final class GRDBObservableDataSource: POSObservableDataSourceProtocol {
13+
// MARK: - Observable Properties
14+
15+
public private(set) var productItems: [POSItem] = []
16+
public private(set) var variationItems: [POSItem] = []
17+
public private(set) var isLoadingProducts: Bool = false
18+
public private(set) var isLoadingVariations: Bool = false
19+
public private(set) var error: Error? = nil
20+
21+
public var hasMoreProducts: Bool {
22+
productItems.count >= (pageSize * currentProductPage) && totalProductCount > productItems.count
23+
}
24+
25+
public var hasMoreVariations: Bool {
26+
variationItems.count >= (pageSize * currentVariationPage) && totalVariationCount > variationItems.count
27+
}
28+
29+
// MARK: - Private Properties
30+
31+
private let siteID: Int64
32+
private let grdbManager: GRDBManagerProtocol
33+
private let itemMapper: PointOfSaleItemMapperProtocol
34+
private let pageSize: Int
35+
36+
private var currentProductPage: Int = 1
37+
private var currentVariationPage: Int = 1
38+
private var currentParentProduct: POSVariableParentProduct?
39+
private var totalProductCount: Int = 0
40+
private var totalVariationCount: Int = 0
41+
42+
// ValueObservation subscriptions
43+
private var productObservationCancellable: AnyCancellable?
44+
private var variationObservationCancellable: AnyCancellable?
45+
private var statisticsObservationCancellable: AnyCancellable?
46+
private var variationStatisticsObservationCancellable: AnyCancellable?
47+
48+
// MARK: - Initialization
49+
50+
public init(siteID: Int64,
51+
grdbManager: GRDBManagerProtocol,
52+
currencySettings: CurrencySettings,
53+
itemMapper: PointOfSaleItemMapperProtocol? = nil,
54+
pageSize: Int = 20) {
55+
self.siteID = siteID
56+
self.grdbManager = grdbManager
57+
self.itemMapper = itemMapper ?? PointOfSaleItemMapper(currencySettings: currencySettings)
58+
self.pageSize = pageSize
59+
60+
setupStatisticsObservation()
61+
}
62+
63+
deinit {
64+
productObservationCancellable?.cancel()
65+
variationObservationCancellable?.cancel()
66+
statisticsObservationCancellable?.cancel()
67+
variationStatisticsObservationCancellable?.cancel()
68+
}
69+
70+
// MARK: - POSObservableDataSourceProtocol
71+
72+
public func loadProducts() {
73+
currentProductPage = 1
74+
isLoadingProducts = true
75+
setupProductObservation()
76+
}
77+
78+
public func loadMoreProducts() {
79+
guard hasMoreProducts && !isLoadingProducts else { return }
80+
81+
isLoadingProducts = true
82+
currentProductPage += 1
83+
setupProductObservation()
84+
}
85+
86+
public func loadVariations(for parentProduct: POSVariableParentProduct) {
87+
guard currentParentProduct?.productID != parentProduct.productID else {
88+
return // Same parent - idempotent
89+
}
90+
91+
currentParentProduct = parentProduct
92+
currentVariationPage = 1
93+
isLoadingVariations = true
94+
variationItems = []
95+
96+
setupVariationObservation(parentProduct: parentProduct)
97+
setupVariationStatisticsObservation(parentProduct: parentProduct)
98+
}
99+
100+
public func loadMoreVariations() {
101+
guard let parentProduct = currentParentProduct,
102+
hasMoreVariations && !isLoadingVariations else { return }
103+
104+
isLoadingVariations = true
105+
currentVariationPage += 1
106+
setupVariationObservation(parentProduct: parentProduct)
107+
}
108+
109+
public func refresh() {
110+
// No-op: database observation automatically updates when data changes during incremental sync
111+
}
112+
113+
// MARK: - ValueObservation Setup
114+
115+
private func setupProductObservation() {
116+
let currentPage = currentProductPage
117+
let observation = ValueObservation
118+
.tracking { [weak self] database -> [POSProduct] in
119+
guard let self else { return [] }
120+
121+
struct ProductWithRelations: Decodable, FetchableRecord {
122+
let product: PersistedProduct
123+
let images: [PersistedImage]?
124+
let attributes: [PersistedProductAttribute]?
125+
}
126+
127+
let productsWithRelations = try PersistedProduct
128+
.posProductsRequest(siteID: siteID)
129+
.limit(pageSize * currentPage)
130+
.including(all: PersistedProduct.images)
131+
.including(all: PersistedProduct.attributes)
132+
.asRequest(of: ProductWithRelations.self)
133+
.fetchAll(database)
134+
135+
return productsWithRelations.map { record in
136+
record.product.toPOSProduct(
137+
images: (record.images ?? []).map { $0.toProductImage() },
138+
attributes: (record.attributes ?? []).map { $0.toProductAttribute(siteID: record.product.siteID) }
139+
)
140+
}
141+
}
142+
143+
productObservationCancellable = observation
144+
.publisher(in: grdbManager.databaseConnection)
145+
.receive(on: DispatchQueue.main)
146+
.sink(
147+
receiveCompletion: { [weak self] completion in
148+
if case .failure(let error) = completion {
149+
self?.error = error
150+
self?.isLoadingProducts = false
151+
}
152+
},
153+
receiveValue: { [weak self] observedProducts in
154+
guard let self else { return }
155+
let posItems = itemMapper.mapProductsToPOSItems(products: observedProducts)
156+
productItems = posItems
157+
error = nil
158+
isLoadingProducts = false
159+
}
160+
)
161+
}
162+
163+
private func setupVariationObservation(parentProduct: POSVariableParentProduct) {
164+
let currentPage = currentVariationPage
165+
let observation = ValueObservation
166+
.tracking { [weak self] database -> [POSProductVariation] in
167+
guard let self else { return [] }
168+
169+
struct VariationWithRelations: Decodable, FetchableRecord {
170+
let persistedProductVariation: PersistedProductVariation
171+
let attributes: [PersistedProductVariationAttribute]?
172+
let image: PersistedImage?
173+
}
174+
175+
let variationsWithRelations = try PersistedProductVariation
176+
.posVariationsRequest(siteID: self.siteID, parentProductID: parentProduct.productID)
177+
.limit(self.pageSize * currentPage)
178+
.including(all: PersistedProductVariation.attributes)
179+
.including(optional: PersistedProductVariation.image)
180+
.asRequest(of: VariationWithRelations.self)
181+
.fetchAll(database)
182+
183+
return variationsWithRelations.map { record in
184+
record.persistedProductVariation.toPOSProductVariation(
185+
attributes: (record.attributes ?? []).map { $0.toProductVariationAttribute() },
186+
image: record.image?.toProductImage()
187+
)
188+
}
189+
}
190+
191+
variationObservationCancellable = observation
192+
.publisher(in: grdbManager.databaseConnection)
193+
.receive(on: DispatchQueue.main)
194+
.sink(
195+
receiveCompletion: { [weak self] completion in
196+
if case .failure(let error) = completion {
197+
self?.error = error
198+
self?.isLoadingVariations = false
199+
}
200+
},
201+
receiveValue: { [weak self] observedVariations in
202+
guard let self else { return }
203+
let posItems = itemMapper.mapVariationsToPOSItems(
204+
variations: observedVariations,
205+
parentProduct: parentProduct
206+
)
207+
variationItems = posItems
208+
error = nil
209+
isLoadingVariations = false
210+
}
211+
)
212+
}
213+
214+
private func setupStatisticsObservation() {
215+
let observation = ValueObservation
216+
.tracking { [weak self] database in
217+
guard let self else { return 0 }
218+
219+
let productCount = try PersistedProduct
220+
.posProductsRequest(siteID: siteID)
221+
.fetchCount(database)
222+
223+
return productCount
224+
}
225+
226+
statisticsObservationCancellable = observation
227+
.publisher(in: grdbManager.databaseConnection)
228+
.receive(on: DispatchQueue.main)
229+
.sink(
230+
receiveCompletion: { completion in
231+
if case .failure = completion {
232+
// Silently ignore - statistics are not critical
233+
}
234+
},
235+
receiveValue: { [weak self] productCount in
236+
self?.totalProductCount = productCount
237+
}
238+
)
239+
}
240+
241+
private func setupVariationStatisticsObservation(parentProduct: POSVariableParentProduct) {
242+
let observation = ValueObservation
243+
.tracking { [weak self] database in
244+
guard let self else { return 0 }
245+
246+
return try PersistedProductVariation
247+
.posVariationsRequest(siteID: siteID, parentProductID: parentProduct.productID)
248+
.fetchCount(database)
249+
}
250+
251+
variationStatisticsObservationCancellable = observation
252+
.publisher(in: grdbManager.databaseConnection)
253+
.receive(on: DispatchQueue.main)
254+
.sink(
255+
receiveCompletion: { completion in
256+
if case .failure = completion {
257+
// Silently ignore - statistics are not critical
258+
}
259+
},
260+
receiveValue: { [weak self] variationCount in
261+
self?.totalVariationCount = variationCount
262+
}
263+
)
264+
}
265+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// periphery:ignore:all
2+
import Foundation
3+
4+
/// Protocol for observable data sources that provide POS items with automatic updates
5+
public protocol POSObservableDataSourceProtocol {
6+
/// Current products mapped to POSItems
7+
var productItems: [POSItem] { get }
8+
9+
/// Current variations for the selected parent product mapped to POSItems
10+
var variationItems: [POSItem] { get }
11+
12+
/// Loading state for products
13+
var isLoadingProducts: Bool { get }
14+
15+
/// Loading state for variations
16+
var isLoadingVariations: Bool { get }
17+
18+
/// Whether more products are available to load
19+
var hasMoreProducts: Bool { get }
20+
21+
/// Whether more variations are available for current parent
22+
var hasMoreVariations: Bool { get }
23+
24+
/// Current error, if any
25+
var error: Error? { get }
26+
27+
/// Loads the first page of products
28+
func loadProducts()
29+
30+
/// Loads the next page of products
31+
func loadMoreProducts()
32+
33+
/// Loads variations for a specific parent product
34+
func loadVariations(for parentProduct: POSVariableParentProduct)
35+
36+
/// Loads more variations for the current parent product
37+
func loadMoreVariations()
38+
39+
/// Refreshes all data
40+
/// Note: For GRDB implementations, this is a no-op as the database observation
41+
/// automatically updates when data changes during incremental sync
42+
func refresh()
43+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Foundation
2+
import Observation
3+
import Yosemite
4+
5+
/// Mock implementation for testing and development
6+
@Observable
7+
final class MockPOSObservableDataSource: POSObservableDataSourceProtocol {
8+
var productItems: [POSItem] = []
9+
var variationItems: [POSItem] = []
10+
var isLoadingProducts: Bool = false
11+
var isLoadingVariations: Bool = false
12+
var hasMoreProducts: Bool = false
13+
var hasMoreVariations: Bool = false
14+
var error: Error? = nil
15+
16+
init() {}
17+
18+
func loadProducts() {
19+
// Tests set properties directly - no async behavior needed
20+
}
21+
22+
func loadMoreProducts() {
23+
// Tests set properties directly - no async behavior needed
24+
}
25+
26+
func loadVariations(for parentProduct: POSVariableParentProduct) {
27+
// Tests set properties directly - no async behavior needed
28+
}
29+
30+
func loadMoreVariations() {
31+
// Tests set properties directly - no async behavior needed
32+
}
33+
34+
func refresh() {
35+
// No-op for mock
36+
}
37+
}

0 commit comments

Comments
 (0)