-
Notifications
You must be signed in to change notification settings - Fork 121
[Local Catalog] Update schema with many-to-many images #16222
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Generated by 🚫 Danger |
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR updates the local catalog schema and persistence to support many-to-many relationships between products/variations and images, allowing image records to be shared.
- Introduces a centralized PersistedImage table and shifts product/variation image references to join tables.
- Updates persistence flows to insert/update shared images and manage join entries.
- Adjusts tests and conversion utilities accordingly.
Reviewed Changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift | Adds image table and converts product/variation image tables to join tables. |
| Modules/Sources/Storage/GRDB/Model/PersistedImage.swift | Adds PersistedImage model for shared image data. |
| Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift | Refactors to a product-image join model; adds association to PersistedImage. |
| Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift | Refactors to a variation-image join model; adds association to PersistedImage. |
| Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift | Updates associations to fetch images through join table. |
| Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift | Adds through-association to PersistedImage via join table. |
| Modules/Sources/Yosemite/Model/Storage/PersistedImage+Conversions.swift | Adds conversions between ProductImage and PersistedImage. |
| Modules/Sources/Yosemite/Model/Storage/PersistedProduct+Conversions.swift | Saves shared images and creates join entries for products. |
| Modules/Sources/Yosemite/Model/Storage/PersistedProductVariation+Conversions.swift | Saves shared images and creates join entries for variations. |
| Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift | Persists shared images first, then join rows; deletes stale join rows in subset replace. |
| Modules/Tests/YosemiteTests/... | Updates tests to the new schema and assertions around images and join rows. |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
| references: "image", | ||
| columns: ["siteID", "id"], | ||
| onDelete: .cascade) | ||
| } |
Copilot
AI
Oct 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider adding indexes on the foreign key columns to improve join/filter performance, e.g., composite indexes on (siteID, productID) and (siteID, imageID). You can add them after table creation with db.create(index:, on:), which will significantly help common queries resolving product→images and image→products.
| } | |
| } | |
| // Add composite indexes to improve join/filter performance | |
| try db.create(index: "productImage_siteID_productID", on: "productImage", columns: ["siteID", "productID"]) | |
| try db.create(index: "productImage_siteID_imageID", on: "productImage", columns: ["siteID", "imageID"]) |
| references: "image", | ||
| columns: ["siteID", "id"], | ||
| onDelete: .cascade) | ||
| } |
Copilot
AI
Oct 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to products, add indexes on (siteID, productVariationID) and (siteID, imageID) to optimize queries traversing variation↔image relationships.
| } | |
| } | |
| // Add indexes to optimize queries traversing variation↔image relationships | |
| try db.create(indexOn: "productVariationImage", columns: ["siteID", "productVariationID"]) | |
| try db.create(indexOn: "productVariationImage", columns: ["siteID", "imageID"]) |
| let variationImages = variations.compactMap { variation in | ||
| variation.image.map { PersistedImage.make(from: $0, siteID: variation.siteID) } | ||
| } | ||
| return productImages + variationImages |
Copilot
AI
Oct 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This builds a non-unique list and will call save multiple times per duplicate image. Deduplicate by (siteID, id) before persisting to reduce unnecessary upserts, e.g., by using a Dictionary keyed by (siteID, id) or a Set of IDs to return unique images.
| return productImages + variationImages | |
| let allImages = productImages + variationImages | |
| // Deduplicate by (siteID, imageID) | |
| var uniqueImages: [PersistedImage] = [] | |
| var seenKeys = Set<String>() | |
| for image in allImages { | |
| let key = "\(image.siteID)-\(image.imageID)" | |
| if !seenKeys.contains(key) { | |
| uniqueImages.append(image) | |
| seenKeys.insert(key) | |
| } | |
| } | |
| return uniqueImages |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 to keep the images unique.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated in 262de6e
| imageTable.primaryKey(["siteID", "id"]) // SiteID column created by belongsTo relationship | ||
| imageTable.belongsTo("site", onDelete: .cascade) |
Copilot
AI
Oct 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] For clarity and to avoid potential migration-order pitfalls, consider declaring belongsTo("site") (which defines siteID) before setting the composite primary key that references siteID. Reordering these lines makes the dependency explicit and easier to reason about.
| imageTable.primaryKey(["siteID", "id"]) // SiteID column created by belongsTo relationship | |
| imageTable.belongsTo("site", onDelete: .cascade) | |
| imageTable.belongsTo("site", onDelete: .cascade) | |
| imageTable.primaryKey(["siteID", "id"]) // SiteID column created by belongsTo relationship |
| // periphery:ignore - TODO: remove ignore when populating database | ||
| /// Join table linking products to images (many-to-many relationship) |
Copilot
AI
Oct 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The periphery:ignore looks obsolete now that this type is actively used; consider removing it so the static analysis tool can properly flag real unused symbols.
| // periphery:ignore - TODO: remove ignore when populating database | ||
| /// Join table linking product variations to images (many-to-many relationship) |
Copilot
AI
Oct 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here: please drop periphery:ignore as this join model is now part of normal persistence and associations.
jaclync
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good, just some non-blocking questions 🚢
The other CI test failure seems like a flaky test from concurrently updating the mock count variables and unrelated to this PR:
init_with_custom_batch_size_uses_specified_size() in POSCatalogFullSyncServiceTests
POSCatalogFullSyncServiceTests.swift:179: Expectation failed: (mockSyncRemote.loadProductsCallCount → 4) == 5 - // Then
I will look into this separately.
| // Check actual images | ||
| let images = try PersistedImage.fetchAll(db).sorted(by: { $0.id < $1.id }) | ||
| #expect(images[0].src == "https://example.com/image1-1.jpg") | ||
| #expect(images[1].src == "https://example.com/image3.jpg") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CI is failing on this line:
persistIncrementalCatalogData_replaces_images_for_updated_product() in POSCatalogPersistenceServiceTests
POSCatalogPersistenceServiceTests.swift:337: Expectation failed: (images[1].src → "https://example.com/image2.jpg") == "https://example.com/image3.jpg"
My understanding of the failure:
Since this test case performs an incremental sync, it doesn't delete images that are no longer in the products/variations in the sync response. Before the sync of 1 product, there are image id 1 and 2, and this product has image id 1 and 3 after the sync. The incremental sync just inserts image id 3 without deleting image id 2. Thus, the database now has image id 1, 2, and 3. images here has 3 values, and when sorted by id, images[1] is the image with id 2.
We probably want to assert on PersistedImage with id 1 and 3 here. We can also add an assert that images.count is 3 for this incremental sync behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, thanks, you're spot on. Fixed in 2566193
| try attribute.insert(db) | ||
| // Insert actual image data first (shared by products and variations) | ||
| for image in catalog.imagesToPersist { | ||
| try image.save(db) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ What are the reasons the image is inserted with save while other entities are inserted with insert?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No good reasons for this, updated to use insert(_ onConflict: .replace)
|
|
||
| // Insert/update actual image data (shared by products and variations) | ||
| for image in catalog.imagesToPersist { | ||
| try image.save(db) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar question to the full sync version, what are the reasons of using save instead of insert with replace?
| let variationImages = variations.compactMap { variation in | ||
| variation.image.map { PersistedImage.make(from: $0, siteID: variation.siteID) } | ||
| } | ||
| return productImages + variationImages |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 to keep the images unique.

Merge after: #16219
Description
This PR updates our attribute schemas to support using the same image for multiple products.
Steps to reproduce
This can't be properly tested in the app yet, as the database isn't used in the POS UI.
You can put breakpoints in the
POSCatalogPersistenceService.replaceAllCatalogData(_:siteID:)function and check that they are created, or find the SQLite database file from your simulator and open it with a tool like SQLPro... but mostly, just checking that it logs the expected number of products/variations being synced should be enough. We'll see any issues soon enough when it's in the UI, anyway.You can get the database file for a running simulator by:
xcrun simctl get_app_container booted com.automattic.woocommerce datain the terminalDocumentswoo-local.sqliteScreenshots
RELEASE-NOTES.txtif necessary.