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
49 changes: 33 additions & 16 deletions Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct V001InitialSchema {
try createSiteTable(db)
try createProductTable(db)
try createProductAttributeTable(db)
try createImageTable(db)
try createProductImageTable(db)
try createProductVariationTable(db)
try createProductVariationAttributeTable(db)
Expand Down Expand Up @@ -74,23 +75,39 @@ struct V001InitialSchema {
}
}

private static func createImageTable(_ db: Database) throws {
// Single image table shared by products and variations
try db.create(table: "image") { imageTable in
imageTable.column("id", .integer).notNull()
imageTable.primaryKey(["siteID", "id"]) // SiteID column created by belongsTo relationship
imageTable.belongsTo("site", onDelete: .cascade)
Comment on lines +82 to +83
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.

imageTable.column("dateCreated", .datetime).notNull()
imageTable.column("dateModified", .datetime)

imageTable.column("src", .text).notNull()
imageTable.column("name", .text)
imageTable.column("alt", .text)
}
}

private static func createProductImageTable(_ db: Database) throws {
// Join table for many-to-many relationship between products and images
try db.create(table: "productImage") { productImageTable in
productImageTable.column("siteID", .integer).notNull()
productImageTable.column("id", .integer).notNull()
productImageTable.primaryKey(["siteID", "id"])
productImageTable.column("productID", .integer).notNull()
productImageTable.column("imageID", .integer).notNull()
productImageTable.primaryKey(["siteID", "productID", "imageID"])

productImageTable.foreignKey(["siteID", "productID"],
references: "product",
columns: ["siteID", "id"],
onDelete: .cascade)

productImageTable.column("dateCreated", .datetime).notNull()
productImageTable.column("dateModified", .datetime)

productImageTable.column("src", .text).notNull()
productImageTable.column("name", .text)
productImageTable.column("alt", .text)
productImageTable.foreignKey(["siteID", "imageID"],
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.
}

Expand Down Expand Up @@ -138,22 +155,22 @@ struct V001InitialSchema {
}

private static func createProductVariationImageTable(_ db: Database) throws {
// Join table for many-to-many relationship between product variations and images
try db.create(table: "productVariationImage") { productVariationImageTable in
productVariationImageTable.column("siteID", .integer).notNull()
productVariationImageTable.column("id", .integer).notNull()
productVariationImageTable.primaryKey(["siteID", "id"])
productVariationImageTable.column("productVariationID", .integer).notNull()
productVariationImageTable.column("imageID", .integer).notNull()
productVariationImageTable.primaryKey(["siteID", "productVariationID", "imageID"])

productVariationImageTable.foreignKey(["siteID", "productVariationID"],
references: "productVariation",
columns: ["siteID", "id"],
onDelete: .cascade)

productVariationImageTable.column("dateCreated", .datetime).notNull()
productVariationImageTable.column("dateModified", .datetime)

productVariationImageTable.column("src", .text).notNull()
productVariationImageTable.column("name", .text)
productVariationImageTable.column("alt", .text)
productVariationImageTable.foreignKey(["siteID", "imageID"],
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.
}
}
60 changes: 60 additions & 0 deletions Modules/Sources/Storage/GRDB/Model/PersistedImage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation
import GRDB

// periphery:ignore
public struct PersistedImage: Codable {
public let siteID: Int64
public let id: Int64
public let dateCreated: Date
public let dateModified: Date?
public let src: String
public let name: String?
public let alt: String?

public init(siteID: Int64,
id: Int64,
dateCreated: Date,
dateModified: Date?,
src: String,
name: String?,
alt: String?) {
self.siteID = siteID
self.id = id
self.dateCreated = dateCreated
self.dateModified = dateModified
self.src = src
self.name = name
self.alt = alt
}
}

// periphery:ignore - TODO: remove ignore when populating database
extension PersistedImage: FetchableRecord, PersistableRecord {
public static var databaseTableName: String { "image" }

public static var primaryKey: [String] { [CodingKeys.siteID.stringValue, CodingKeys.id.stringValue] }

public enum Columns {
public static let siteID = Column(CodingKeys.siteID)
public static let id = Column(CodingKeys.id)
public static let dateCreated = Column(CodingKeys.dateCreated)
public static let dateModified = Column(CodingKeys.dateModified)
public static let src = Column(CodingKeys.src)
public static let name = Column(CodingKeys.name)
public static let alt = Column(CodingKeys.alt)
}
}


// periphery:ignore - TODO: remove ignore when populating database
extension PersistedImage {
enum CodingKeys: String, CodingKey {
case siteID
case id
case dateCreated
case dateModified
case src
case name
case alt
}
}
15 changes: 11 additions & 4 deletions Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,17 @@ extension PersistedProduct: FetchableRecord, PersistableRecord {
public static let stockStatusKey = Column(CodingKeys.stockStatusKey)
}

public static let images = hasMany(PersistedProductImage.self,
using: ForeignKey([PersistedProductImage.CodingKeys.siteID.stringValue,
PersistedProductImage.CodingKeys.productID.stringValue],
to: primaryKey))
// Join table association (internal - used by 'images' through association)
private static let productImages = hasMany(PersistedProductImage.self,
using: ForeignKey([PersistedProductImage.CodingKeys.siteID.stringValue,
PersistedProductImage.CodingKeys.productID.stringValue],
to: primaryKey))

// Through association to access actual images via join table (use this to fetch images)
public static let images = hasMany(PersistedImage.self,
through: productImages,
using: PersistedProductImage.image)

public static let attributes = hasMany(PersistedProductAttribute.self,
using: ForeignKey([PersistedProductAttribute.CodingKeys.siteID.stringValue,
PersistedProductAttribute.CodingKeys.productID.stringValue],
Expand Down
46 changes: 15 additions & 31 deletions Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,64 +2,48 @@ import Foundation
import GRDB

// periphery:ignore - TODO: remove ignore when populating database
/// Join table linking products to images (many-to-many relationship)
Comment on lines 4 to +5
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.
public struct PersistedProductImage: Codable {
public let siteID: Int64
public let id: Int64
public let productID: Int64
public let dateCreated: Date
public let dateModified: Date?
public let src: String
public let name: String?
public let alt: String?
public let imageID: Int64

public init(siteID: Int64,
id: Int64,
productID: Int64,
dateCreated: Date,
dateModified: Date?,
src: String,
name: String?,
alt: String?) {
imageID: Int64) {
self.siteID = siteID
self.id = id
self.productID = productID
self.dateCreated = dateCreated
self.dateModified = dateModified
self.src = src
self.name = name
self.alt = alt
self.imageID = imageID
}
}

// periphery:ignore - TODO: remove ignore when populating database
extension PersistedProductImage: FetchableRecord, PersistableRecord {
public static var databaseTableName: String { "productImage" }

public static var primaryKey: [String] { [CodingKeys.siteID.stringValue, CodingKeys.id.stringValue] }
public static var primaryKey: [String] {
[CodingKeys.siteID.stringValue, CodingKeys.productID.stringValue, CodingKeys.imageID.stringValue]
}

public enum Columns {
public static let siteID = Column(CodingKeys.siteID)
public static let id = Column(CodingKeys.id)
public static let productID = Column(CodingKeys.productID)
public static let dateCreated = Column(CodingKeys.dateCreated)
public static let dateModified = Column(CodingKeys.dateModified)
public static let src = Column(CodingKeys.src)
public static let name = Column(CodingKeys.name)
public static let alt = Column(CodingKeys.alt)
public static let imageID = Column(CodingKeys.imageID)
}

// Association to the actual image
public static let image = belongsTo(PersistedImage.self,
using: ForeignKey([CodingKeys.siteID.stringValue,
CodingKeys.imageID.stringValue],
to: PersistedImage.primaryKey))
}


// periphery:ignore - TODO: remove ignore when populating database
extension PersistedProductImage {
enum CodingKeys: String, CodingKey {
case siteID
case id
case productID
case dateCreated
case dateModified
case src
case name
case alt
case imageID
}
}
18 changes: 13 additions & 5 deletions Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,19 @@ extension PersistedProductVariation: FetchableRecord, PersistableRecord {
using: ForeignKey([PersistedProductVariationAttribute.CodingKeys.siteID.stringValue,
PersistedProductVariationAttribute.CodingKeys.productVariationID.stringValue],
to: primaryKey))
public static let image = hasOne(PersistedProductVariationImage.self,
key: "image",
using: ForeignKey([PersistedProductVariationImage.CodingKeys.siteID.stringValue,
PersistedProductVariationImage.CodingKeys.productVariationID.stringValue],
to: primaryKey))

// Join table association (internal - used by 'image' through association)
private static let productVariationImage = hasOne(PersistedProductVariationImage.self,
key: "productVariationImage",
using: ForeignKey([PersistedProductVariationImage.CodingKeys.siteID.stringValue,
PersistedProductVariationImage.CodingKeys.productVariationID.stringValue],
to: primaryKey))

// Through association to access actual image via join table (use this to fetch image)
public static let image = hasOne(PersistedImage.self,
through: productVariationImage,
using: PersistedProductVariationImage.image,
key: "image")
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,64 +2,48 @@ import Foundation
import GRDB

// periphery:ignore - TODO: remove ignore when populating database
/// Join table linking product variations to images (many-to-many relationship)
Comment on lines 4 to +5
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.
public struct PersistedProductVariationImage: Codable {
public let siteID: Int64
public let id: Int64
public let productVariationID: Int64
public let dateCreated: Date
public let dateModified: Date?
public let src: String
public let name: String?
public let alt: String?
public let imageID: Int64

public init(siteID: Int64,
id: Int64,
productVariationID: Int64,
dateCreated: Date,
dateModified: Date?,
src: String,
name: String?,
alt: String?) {
imageID: Int64) {
self.siteID = siteID
self.id = id
self.productVariationID = productVariationID
self.dateCreated = dateCreated
self.dateModified = dateModified
self.src = src
self.name = name
self.alt = alt
self.imageID = imageID
}
}

// periphery:ignore - TODO: remove ignore when populating database
extension PersistedProductVariationImage: FetchableRecord, PersistableRecord {
public static var databaseTableName: String { "productVariationImage" }

public static var primaryKey: [String] { [CodingKeys.siteID.stringValue, CodingKeys.id.stringValue] }
public static var primaryKey: [String] {
[CodingKeys.siteID.stringValue, CodingKeys.productVariationID.stringValue, CodingKeys.imageID.stringValue]
}

public enum Columns {
public static let siteID = Column(CodingKeys.siteID)
public static let id = Column(CodingKeys.id)
public static let productVariationID = Column(CodingKeys.productVariationID)
public static let dateCreated = Column(CodingKeys.dateCreated)
public static let dateModified = Column(CodingKeys.dateModified)
public static let src = Column(CodingKeys.src)
public static let name = Column(CodingKeys.name)
public static let alt = Column(CodingKeys.alt)
public static let imageID = Column(CodingKeys.imageID)
}

// Association to the actual image
public static let image = belongsTo(PersistedImage.self,
using: ForeignKey([CodingKeys.siteID.stringValue,
CodingKeys.imageID.stringValue],
to: PersistedImage.primaryKey))
}


// periphery:ignore - TODO: remove ignore when populating database
extension PersistedProductVariationImage {
enum CodingKeys: String, CodingKey {
case siteID
case id
case productVariationID
case dateCreated
case dateModified
case src
case name
case alt
case imageID
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation
import Storage

// MARK: - PersistedImage Conversions
public extension PersistedImage {
/// Create a PersistedImage from a ProductImage
static func make(from productImage: ProductImage, siteID: Int64) -> PersistedImage {
return PersistedImage(
siteID: siteID,
id: productImage.imageID,
dateCreated: productImage.dateCreated,
dateModified: productImage.dateModified,
src: productImage.src,
name: productImage.name,
alt: productImage.alt
)
}

func toProductImage() -> ProductImage {
return ProductImage(
imageID: id,
dateCreated: dateCreated,
dateModified: dateModified,
src: src,
name: name,
alt: alt
)
}
}
Loading