Heavy duplication when upserting with GORM generics in SQLite

3 weeks ago 27
ARTICLE AD BOX

I have several models in my application, which have associations between each other. I'm trying to recursively upsert them, but haven't been able to find a good solution that doesn't extensively rely on raw SQL or the non-generics API. These are my models:

EDIT 2025-11-26 Added Enclosure.

// Feed represents an RSS channel type Feed struct { gorm.Model Title string `gorm:"not null;"` Description string `gorm:"not null;"` // The link to the HTML representation of the feed. Different from FetchFrom Link string `gorm:"not null;"` // The URL that this feed can be retrieved from. Different from Link FetchFrom string `gorm:"not null;unique"` Language string // The time-to-live of the feed. Time in minutes that the reader should wait between each refresh TTL int `gorm:"default:60"` ETag string LastModified time.Time Items []Item `gorm:"constraint:OnDelete:CASCADE;"` } // Item represents an RSS item/post type Item struct { gorm.Model // The feed this item came from. Feed Feed // ID of the feed. Automatically creates a belongs-to relationship with Feed -> Item. FeedID uint `gorm:"index;not null;"` GUID string `gorm:"not null;unique"` Title string Description string Link *string Author string PubDate time.Time `gorm:"not null;"` Read bool `gorm:"not null;"` Starred bool `gorm:"not null;"` Enclosure *Enclosure `gorm:"constraint:OnDelete:CASCADE;"` } // Enclosure represents an RSS enclosure, usually media associated with an item // See https://www.rssboard.org/rss-specification type Enclosure struct { gorm.Model ItemID uint `gorm:"index"` Item Item URL string `gorm:"not null;"` MimeType string `gorm:"not null;"` FilePath string `gorm:"not null;"` }

And this is my upsert solution:

func upsert[T any](db *gorm.DB, m T, where string, args ...any) error { if where == "" { return fmt.Errorf("expected non-empty where") } ctx := context.Background() w := gorm.G[T](db).Where(where, args...) rows, err := w.Updates(ctx, m) if err != nil { return fmt.Errorf("failed to update object: %w", err) } if rows == 0 { err := gorm.G[T](db).Create(ctx, &m) if err != nil { return fmt.Errorf("failed to create object: %w", err) } return nil } return nil }

When using the upsert on a Feed, it gets stored twice in the feeds table, one having fetch_from as provided and the other having an empty one. The items also appear to be stored twice each. When adding the UNIQUE constraint to Item.GUID, it throws several extremely long errors in this format:

upsert.go:27 constraint failed: UNIQUE constraint failed: items.guid (2067) [50.595ms] [rows:0] sql statement

At the end, there appear to be 2 relatively short errors:

2025/11/25 19:13:01 .../server/database/upsert.go:27 constraint failed: UNIQUE constraint failed: items.guid (2067) [47.266ms] [rows:1] INSERT INTO `feeds` (`created_at`,`updated_at`,`deleted_at`,`title`,`description`,`link`,`fetch_from`,`language`,`ttl`,`e_tag`,`last_modified`) VALUES ("2025-11-25 19:13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advisory Board","RSS Advisory Board announcements and Really Simple Syndication news","https://www.rssboard.org/","","en-us",10,"","0000-00-00 00:00:00"),("2025-11-25 19:13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advisory Board"," RSS Advisory Board announcements and Really Simple Syndication news","https://www.rssboard.org/","","en-us",10,"","0000-00-00 00:00:00"),("2025-11-25 19:13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advisory Board","RSS Advisory Boar d announcements and Really Simple Syndication news","https://www.rssboard.org/","","en-us",10,"","0000-00-00 00:00:00"),("2025-11-25 19:13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advisory Board","RSS Advisory Board announcements a nd Really Simple Syndication news","https://www.rssboard.org/","","en-us",10,"","0000-00-00 00:00:00"),("2025-11-25 19:13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advisory Board","RSS Advisory Board announcements and Really Simple Syndication news","https://www.rssboard.org/","","en-us",10,"","0000-00-00 00:00:00"),("2025-11-25 19:13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advisory Board","RSS Advisory Board announcements and Really Simple Syndication news" ,"https://www.rssboard.org/","","en-us",10,"","0000-00-00 00:00:00"),("2025-11-25 19:13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advisory Board","RSS Advisory Board announcements and Really Simple Syndication news","https://www.rss board.org/","","en-us",10,"","0000-00-00 00:00:00"),("2025-11-25 19:13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advisory Board","RSS Advisory Board announcements and Really Simple Syndication news","https://www.rssboard.org/","","e n-us",10,"","0000-00-00 00:00:00"),("2025-11-25 19:13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advisory Board","RSS Advisory Board announcements and Really Simple Syndication news","https://www.rssboard.org/","","en-us",10,"","0000 -00-00 00:00:00"),("2025-11-25 19:13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advisory Board","RSS Advisory Board announcements and Really Simple Syndication news","https://www.rssboard.org/","","en-us",10,"","0000-00-00 00:00:00") ,("2025-11-25 19:13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advisory Board","RSS Advisory Board announcements and Really Simple Syndication news","https://www.rssboard.org/","","en-us",10,"","0000-00-00 00:00:00"),("2025-11-25 19: 13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advisory Board","RSS Advisory Board announcements and Really Simple Syndication news","https://www.rssboard.org/","","en-us",10,"","0000-00-00 00:00:00"),("2025-11-25 19:13:01.123","2025- 11-25 19:13:01.123",NULL,"RSS Advisory Board","RSS Advisory Board announcements and Really Simple Syndication news","https://www.rssboard.org/","","en-us",10,"","0000-00-00 00:00:00"),("2025-11-25 19:13:01.123","2025-11-25 19:13:01.12 3",NULL,"RSS Advisory Board","RSS Advisory Board announcements and Really Simple Syndication news","https://www.rssboard.org/","","en-us",10,"","0000-00-00 00:00:00"),("2025-11-25 19:13:01.123","2025-11-25 19:13:01.123",NULL,"RSS Advi sory Board","RSS Advisory Board announcements and Really Simple Syndication news","https://www.rssboard.org/","","en-us",10,"","0000-00-00 00:00:00") ON CONFLICT DO NOTHING RETURNING `id` 2025/11/25 19:13:01 .../server/database/upsert.go:27 constraint failed: UNIQUE constraint failed: items.guid (2067) [50.595ms] [rows:0] UPDATE `feeds` SET `updated_at`="2025-11-25 19:13:01.123",`title`="RSS Advisory Board",`description`="RSS Advisory Board announcements and Really Simple Syndication news",`link`="https://www.rssboard.org/",`fetch_f rom`="http://feeds.rssboard.org/rssboard",`language`="en-us",`ttl`=10 WHERE (id = 0 OR fetch_from = "http://feeds.rssboard.org/rssboard") AND `feeds`.`deleted_at` IS NULL > github.com/its-mrarsikk/fedup/server/database.upsert[go.shape.struct { gorm.io/gorm.Model; Title string "gorm:\"not null;\""; Description string "gorm:\"not null;\""; Link string "gorm:\"not null;\""; FetchFrom string "gorm:\"not nu ll;unique\""; Language string; TTL int "gorm:\"default:60\""; ETag string; LastModified time.Time; Items []github.com/its-mrarsikk/fedup/shared/rss.Item "gorm:\"constraint:OnDelete:CASCADE;\"" }]() ./server/database/upsert.go:27 (PC: 0xde9642)

The one(s) above appear to contain all of the items it's about to store, spanning thousands of lines.

This all happens in one upsert call. Any help would be appreciated.

EDIT 2025-11-26 I'm using github.com/glebarez/sqlite for a driver.

Here's a complete reproducible example:

main.go - https://bpa.st/7ZMA

go.mod - https://bpa.st/4Z2A

I tested them and they replicate the issue.

Read Entire Article