Skip to content

Conversation

@joshheald
Copy link
Contributor

@joshheald joshheald commented Oct 7, 2025

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:

  1. run xcrun simctl get_app_container booted com.automattic.woocommerce data in the terminal
  2. Cmd-G in Finder and paste the path
  3. Open Documents
  4. You should see woo-local.sqlite

Screenshots

CleanShot 2025-10-07 at 19 11 10@2x
  • I have considered if this change warrants user-facing release notes and have added them to RELEASE-NOTES.txt if necessary.

@joshheald joshheald added this to the 23.5 milestone Oct 7, 2025
@joshheald joshheald added type: task An internally driven task. feature: POS labels Oct 7, 2025
@dangermattic
Copy link
Collaborator

dangermattic commented Oct 7, 2025

1 Warning
⚠️ This PR is larger than 300 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.

Generated by 🚫 Danger

@joshheald joshheald marked this pull request as ready for review October 7, 2025 18:12
@joshheald joshheald changed the base branch from trunk to woomob-1339-schema-global-attribute-support October 7, 2025 18:13
@joshheald joshheald requested review from Copilot and jaclync October 7, 2025 18:14
@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Oct 7, 2025

App Icon📲 You can test the changes from this Pull Request in WooCommerce iOS Prototype by scanning the QR code below to install the corresponding build.

App NameWooCommerce iOS Prototype
Build Numberpr16222-887104e
Version23.4
Bundle IDcom.automattic.alpha.woocommerce
Commit887104e
Installation URL3mfi1v0b2u1a0
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

Copy link
Contributor

Copilot AI left a 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)
}
Copy link

Copilot AI Oct 7, 2025

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.

Suggested change
}
}
// 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"])

Copilot uses AI. Check for mistakes.
references: "image",
columns: ["siteID", "id"],
onDelete: .cascade)
}
Copy link

Copilot AI Oct 7, 2025

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.

Suggested change
}
}
// 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"])

Copilot uses AI. Check for mistakes.
let variationImages = variations.compactMap { variation in
variation.image.map { PersistedImage.make(from: $0, siteID: variation.siteID) }
}
return productImages + variationImages
Copy link

Copilot AI Oct 7, 2025

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated in 262de6e

Comment on lines +82 to +83
imageTable.primaryKey(["siteID", "id"]) // SiteID column created by belongsTo relationship
imageTable.belongsTo("site", onDelete: .cascade)
Copy link

Copilot AI Oct 7, 2025

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines 4 to +5
// periphery:ignore - TODO: remove ignore when populating database
/// Join table linking products to images (many-to-many relationship)
Copy link

Copilot AI Oct 7, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines 4 to +5
// periphery:ignore - TODO: remove ignore when populating database
/// Join table linking product variations to images (many-to-many relationship)
Copy link

Copilot AI Oct 7, 2025

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.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@jaclync jaclync left a 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")
Copy link
Contributor

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.

Copy link
Contributor Author

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)
Copy link
Contributor

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?

Copy link
Contributor Author

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)
Copy link
Contributor

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
Copy link
Contributor

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.

Base automatically changed from woomob-1339-schema-global-attribute-support to trunk October 8, 2025 06:31
@joshheald joshheald enabled auto-merge October 8, 2025 12:29
@joshheald joshheald merged commit 4f592e2 into trunk Oct 8, 2025
13 checks passed
@joshheald joshheald deleted the woomob-1339-schema-many-to-many-images branch October 8, 2025 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature: POS type: task An internally driven task.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants