From 1e50c01d7bcc233af8129ccedabfc864caaad8c1 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 16:35:05 +0800 Subject: [PATCH 01/16] Add sort field to categories --- database/attrs.go | 1 + .../000004_categories_sort.down.sql | 3 ++ .../migrations/000004_categories_sort.up.sql | 4 ++ database/model.go | 1 + database/repository/categories.go | 8 +++- .../repository/categories_postgres_test.go | 42 +++++++++++++++++++ database/seeder/seeds/categories.go | 3 +- handler/categories.go | 1 + handler/categories_test.go | 3 +- handler/payload/categories.go | 2 + handler/payload/categories_test.go | 3 +- handler/payload/posts_response_test.go | 3 +- 12 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 database/infra/migrations/000004_categories_sort.down.sql create mode 100644 database/infra/migrations/000004_categories_sort.up.sql diff --git a/database/attrs.go b/database/attrs.go index 2b9358f8..20f85e30 100644 --- a/database/attrs.go +++ b/database/attrs.go @@ -21,6 +21,7 @@ type CategoriesAttrs struct { Slug string Name string Description string + Sort int } type TagAttrs struct { diff --git a/database/infra/migrations/000004_categories_sort.down.sql b/database/infra/migrations/000004_categories_sort.down.sql new file mode 100644 index 00000000..cae1051e --- /dev/null +++ b/database/infra/migrations/000004_categories_sort.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_categories_sort; +ALTER TABLE categories + DROP COLUMN IF EXISTS sort; diff --git a/database/infra/migrations/000004_categories_sort.up.sql b/database/infra/migrations/000004_categories_sort.up.sql new file mode 100644 index 00000000..77bfe6a4 --- /dev/null +++ b/database/infra/migrations/000004_categories_sort.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE categories + ADD COLUMN IF NOT EXISTS sort INT NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_categories_sort ON categories (sort, name); diff --git a/database/model.go b/database/model.go index 8945e16b..48b8c4ff 100644 --- a/database/model.go +++ b/database/model.go @@ -109,6 +109,7 @@ type Category struct { Name string `gorm:"type:varchar(255);unique;not null"` Slug string `gorm:"type:varchar(255);unique;not null"` Description string `gorm:"type:text"` + Sort int `gorm:"type:int;default:0;index:idx_categories_sort"` CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` DeletedAt gorm.DeletedAt diff --git a/database/repository/categories.go b/database/repository/categories.go index b841a15d..a2de0b73 100644 --- a/database/repository/categories.go +++ b/database/repository/categories.go @@ -20,6 +20,7 @@ func (c Categories) Get() ([]database.Category, error) { err := c.DB.Sql(). Model(&database.Category{}). Where("categories.deleted_at is null"). + Order("categories.sort asc, categories.name asc"). Find(&categories).Error if err != nil { @@ -53,7 +54,7 @@ func (c Categories) GetAll(paginate pagination.Paginate) (*pagination.Pagination Preload("Posts", "posts.deleted_at IS NULL AND posts.published_at IS NOT NULL"). Offset(offset). Limit(paginate.Limit). - Order("categories.name asc"). + Order("categories.sort asc, categories.name asc"). Group(group). Find(&categories).Error @@ -104,6 +105,7 @@ func (c Categories) CreateOrUpdate(post database.Post, attrs database.PostsAttrs Name: seed.Name, Slug: seed.Slug, Description: seed.Description, + Sort: seed.Sort, } if result := c.DB.Sql().Create(&category); model.HasDbIssues(result.Error) { @@ -140,6 +142,10 @@ func (c Categories) ExistOrUpdate(seed database.CategoriesAttrs) (bool, error) { category.Description = seed.Description } + if seed.Sort != 0 { + category.Sort = seed.Sort + } + if result := c.DB.Sql().Save(&category); model.HasDbIssues(result.Error) { return false, fmt.Errorf("error on exist or update category [%s]: %s", category.Name, result.Error) } diff --git a/database/repository/categories_postgres_test.go b/database/repository/categories_postgres_test.go index fa4e70af..c90289e9 100644 --- a/database/repository/categories_postgres_test.go +++ b/database/repository/categories_postgres_test.go @@ -3,6 +3,7 @@ package repository_test import ( "testing" + "github.com/google/uuid" "github.com/oullin/database" "github.com/oullin/database/repository" ) @@ -22,3 +23,44 @@ func TestCategoriesFindByPostgres(t *testing.T) { t.Fatalf("expected missing category lookup to return nil") } } + +func TestCategoriesGetOrdersBySort(t *testing.T) { + conn := newPostgresConnection(t, &database.Category{}) + + repo := repository.Categories{DB: conn} + + low := database.Category{ + UUID: uuid.NewString(), + Name: "Low", + Slug: "low", + Sort: 10, + } + + high := database.Category{ + UUID: uuid.NewString(), + Name: "High", + Slug: "high", + Sort: 20, + } + + if err := conn.Sql().Create(&high).Error; err != nil { + t.Fatalf("create high sort: %v", err) + } + + if err := conn.Sql().Create(&low).Error; err != nil { + t.Fatalf("create low sort: %v", err) + } + + items, err := repo.Get() + if err != nil { + t.Fatalf("get: %v", err) + } + + if len(items) != 2 { + t.Fatalf("expected 2 categories, got %d", len(items)) + } + + if items[0].Slug != "low" || items[1].Slug != "high" { + t.Fatalf("expected sort ordering, got %+v", items) + } +} diff --git a/database/seeder/seeds/categories.go b/database/seeder/seeds/categories.go index cf4dc63d..2eedb391 100644 --- a/database/seeder/seeds/categories.go +++ b/database/seeder/seeds/categories.go @@ -26,12 +26,13 @@ func (s CategoriesSeed) Create(attrs database.CategoriesAttrs) ([]database.Categ "Cloud", "Data", "DevOps", "ML", "Startups", "Engineering", } - for _, seed := range seeds { + for index, seed := range seeds { categories = append(categories, database.Category{ UUID: uuid.NewString(), Name: seed, Slug: strings.ToLower(seed), Description: attrs.Description, + Sort: index + 1, }) } diff --git a/handler/categories.go b/handler/categories.go index 8e636405..81737c64 100644 --- a/handler/categories.go +++ b/handler/categories.go @@ -41,6 +41,7 @@ func (h *CategoriesHandler) Index(w http.ResponseWriter, r *http.Request) *endpo Name: s.Name, Slug: s.Slug, Description: s.Description, + Sort: s.Sort, } }, ) diff --git a/handler/categories_test.go b/handler/categories_test.go index 6e5e898f..23f729e6 100644 --- a/handler/categories_test.go +++ b/handler/categories_test.go @@ -39,6 +39,7 @@ func TestCategoriesHandlerIndex_Success(t *testing.T) { Name: "Cat", Slug: "cat", Description: "desc", + Sort: 10, } if err := conn.Sql().Create(&cat).Error; err != nil { @@ -75,7 +76,7 @@ func TestCategoriesHandlerIndex_Success(t *testing.T) { t.Fatalf("decode: %v", err) } - if len(resp.Data) != 1 || resp.Data[0].Slug != "cat" { + if len(resp.Data) != 1 || resp.Data[0].Slug != "cat" || resp.Data[0].Sort != 10 { t.Fatalf("unexpected data: %+v", resp.Data) } } diff --git a/handler/payload/categories.go b/handler/payload/categories.go index fedb7380..c9e57c4f 100644 --- a/handler/payload/categories.go +++ b/handler/payload/categories.go @@ -7,6 +7,7 @@ type CategoryResponse struct { Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` + Sort int `json:"sort"` } func GetCategoriesResponse(categories []database.Category) []CategoryResponse { @@ -18,6 +19,7 @@ func GetCategoriesResponse(categories []database.Category) []CategoryResponse { Name: category.Name, Slug: category.Slug, Description: category.Description, + Sort: category.Sort, }) } diff --git a/handler/payload/categories_test.go b/handler/payload/categories_test.go index 25fc4e6c..964c8872 100644 --- a/handler/payload/categories_test.go +++ b/handler/payload/categories_test.go @@ -13,12 +13,13 @@ func TestGetCategoriesResponse(t *testing.T) { Name: "n", Slug: "s", Description: "d", + Sort: 2, }, } r := GetCategoriesResponse(cats) - if len(r) != 1 || r[0].Slug != "s" { + if len(r) != 1 || r[0].Slug != "s" || r[0].Sort != 2 { t.Fatalf("unexpected %#v", r) } } diff --git a/handler/payload/posts_response_test.go b/handler/payload/posts_response_test.go index 1ca1e4f8..94f196cd 100644 --- a/handler/payload/posts_response_test.go +++ b/handler/payload/posts_response_test.go @@ -25,6 +25,7 @@ func TestGetPostsResponse(t *testing.T) { Name: "cn", Slug: "cs", Description: "cd", + Sort: 3, }, }, Tags: []database.Tag{ @@ -50,7 +51,7 @@ func TestGetPostsResponse(t *testing.T) { r := GetPostsResponse(p) - if r.UUID != "1" || r.Author.UUID != "u1" || len(r.Categories) != 1 || len(r.Tags) != 1 { + if r.UUID != "1" || r.Author.UUID != "u1" || len(r.Categories) != 1 || len(r.Tags) != 1 || r.Categories[0].Sort != 3 { t.Fatalf("unexpected response: %+v", r) } } From 34d6d1dab2a35cd3826be1178f6b601095d6baa9 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 16:53:53 +0800 Subject: [PATCH 02/16] Allow nullable category sort order --- database/attrs.go | 2 +- database/infra/migrations/000004_categories_sort.up.sql | 2 +- database/model.go | 2 +- database/repository/categories.go | 2 +- database/repository/categories_postgres_test.go | 6 ++++-- database/seeder/seeds/categories.go | 3 ++- handler/categories_test.go | 5 +++-- handler/payload/categories.go | 2 +- handler/payload/categories_test.go | 5 +++-- handler/payload/posts_response_test.go | 5 +++-- 10 files changed, 20 insertions(+), 14 deletions(-) diff --git a/database/attrs.go b/database/attrs.go index 20f85e30..b335ee9a 100644 --- a/database/attrs.go +++ b/database/attrs.go @@ -21,7 +21,7 @@ type CategoriesAttrs struct { Slug string Name string Description string - Sort int + Sort *int } type TagAttrs struct { diff --git a/database/infra/migrations/000004_categories_sort.up.sql b/database/infra/migrations/000004_categories_sort.up.sql index 77bfe6a4..bd09fc16 100644 --- a/database/infra/migrations/000004_categories_sort.up.sql +++ b/database/infra/migrations/000004_categories_sort.up.sql @@ -1,4 +1,4 @@ ALTER TABLE categories - ADD COLUMN IF NOT EXISTS sort INT NOT NULL DEFAULT 0; + ADD COLUMN IF NOT EXISTS sort INT; CREATE INDEX IF NOT EXISTS idx_categories_sort ON categories (sort, name); diff --git a/database/model.go b/database/model.go index 48b8c4ff..5d999ce3 100644 --- a/database/model.go +++ b/database/model.go @@ -109,7 +109,7 @@ type Category struct { Name string `gorm:"type:varchar(255);unique;not null"` Slug string `gorm:"type:varchar(255);unique;not null"` Description string `gorm:"type:text"` - Sort int `gorm:"type:int;default:0;index:idx_categories_sort"` + Sort *int `gorm:"type:int;index:idx_categories_sort"` CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` DeletedAt gorm.DeletedAt diff --git a/database/repository/categories.go b/database/repository/categories.go index a2de0b73..8803130d 100644 --- a/database/repository/categories.go +++ b/database/repository/categories.go @@ -142,7 +142,7 @@ func (c Categories) ExistOrUpdate(seed database.CategoriesAttrs) (bool, error) { category.Description = seed.Description } - if seed.Sort != 0 { + if seed.Sort != nil { category.Sort = seed.Sort } diff --git a/database/repository/categories_postgres_test.go b/database/repository/categories_postgres_test.go index c90289e9..6f6a7360 100644 --- a/database/repository/categories_postgres_test.go +++ b/database/repository/categories_postgres_test.go @@ -29,18 +29,20 @@ func TestCategoriesGetOrdersBySort(t *testing.T) { repo := repository.Categories{DB: conn} + lowSort := 10 low := database.Category{ UUID: uuid.NewString(), Name: "Low", Slug: "low", - Sort: 10, + Sort: &lowSort, } + highSort := 20 high := database.Category{ UUID: uuid.NewString(), Name: "High", Slug: "high", - Sort: 20, + Sort: &highSort, } if err := conn.Sql().Create(&high).Error; err != nil { diff --git a/database/seeder/seeds/categories.go b/database/seeder/seeds/categories.go index 2eedb391..d9ae82ec 100644 --- a/database/seeder/seeds/categories.go +++ b/database/seeder/seeds/categories.go @@ -27,12 +27,13 @@ func (s CategoriesSeed) Create(attrs database.CategoriesAttrs) ([]database.Categ } for index, seed := range seeds { + sort := index + 1 categories = append(categories, database.Category{ UUID: uuid.NewString(), Name: seed, Slug: strings.ToLower(seed), Description: attrs.Description, - Sort: index + 1, + Sort: &sort, }) } diff --git a/handler/categories_test.go b/handler/categories_test.go index 23f729e6..303ea57d 100644 --- a/handler/categories_test.go +++ b/handler/categories_test.go @@ -34,12 +34,13 @@ func TestCategoriesHandlerIndex_Success(t *testing.T) { t.Fatalf("create post: %v", err) } + sort := 10 cat := database.Category{ UUID: uuid.NewString(), Name: "Cat", Slug: "cat", Description: "desc", - Sort: 10, + Sort: &sort, } if err := conn.Sql().Create(&cat).Error; err != nil { @@ -76,7 +77,7 @@ func TestCategoriesHandlerIndex_Success(t *testing.T) { t.Fatalf("decode: %v", err) } - if len(resp.Data) != 1 || resp.Data[0].Slug != "cat" || resp.Data[0].Sort != 10 { + if len(resp.Data) != 1 || resp.Data[0].Slug != "cat" || resp.Data[0].Sort == nil || *resp.Data[0].Sort != 10 { t.Fatalf("unexpected data: %+v", resp.Data) } } diff --git a/handler/payload/categories.go b/handler/payload/categories.go index c9e57c4f..953566a2 100644 --- a/handler/payload/categories.go +++ b/handler/payload/categories.go @@ -7,7 +7,7 @@ type CategoryResponse struct { Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` - Sort int `json:"sort"` + Sort *int `json:"sort"` } func GetCategoriesResponse(categories []database.Category) []CategoryResponse { diff --git a/handler/payload/categories_test.go b/handler/payload/categories_test.go index 964c8872..706cda07 100644 --- a/handler/payload/categories_test.go +++ b/handler/payload/categories_test.go @@ -7,19 +7,20 @@ import ( ) func TestGetCategoriesResponse(t *testing.T) { + sort := 2 cats := []database.Category{ { UUID: "1", Name: "n", Slug: "s", Description: "d", - Sort: 2, + Sort: &sort, }, } r := GetCategoriesResponse(cats) - if len(r) != 1 || r[0].Slug != "s" || r[0].Sort != 2 { + if len(r) != 1 || r[0].Slug != "s" || r[0].Sort == nil || *r[0].Sort != 2 { t.Fatalf("unexpected %#v", r) } } diff --git a/handler/payload/posts_response_test.go b/handler/payload/posts_response_test.go index 94f196cd..16d695aa 100644 --- a/handler/payload/posts_response_test.go +++ b/handler/payload/posts_response_test.go @@ -9,6 +9,7 @@ import ( func TestGetPostsResponse(t *testing.T) { now := time.Now() + sort := 3 p := database.Post{ UUID: "1", Slug: "slug", @@ -25,7 +26,7 @@ func TestGetPostsResponse(t *testing.T) { Name: "cn", Slug: "cs", Description: "cd", - Sort: 3, + Sort: &sort, }, }, Tags: []database.Tag{ @@ -51,7 +52,7 @@ func TestGetPostsResponse(t *testing.T) { r := GetPostsResponse(p) - if r.UUID != "1" || r.Author.UUID != "u1" || len(r.Categories) != 1 || len(r.Tags) != 1 || r.Categories[0].Sort != 3 { + if r.UUID != "1" || r.Author.UUID != "u1" || len(r.Categories) != 1 || len(r.Tags) != 1 || r.Categories[0].Sort == nil || *r.Categories[0].Sort != 3 { t.Fatalf("unexpected response: %+v", r) } } From ba74c81b42b39152e7ebbeb1b524dd7907ea8680 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 17:13:49 +0800 Subject: [PATCH 03/16] Restore non-null category sort --- database/infra/migrations/000004_categories_sort.up.sql | 5 ++++- database/model.go | 2 +- database/repository/categories.go | 9 +++++++-- database/repository/categories_postgres_test.go | 6 ++---- database/seeder/seeds/categories.go | 2 +- handler/categories_test.go | 5 ++--- handler/payload/categories.go | 2 +- handler/payload/categories_test.go | 5 ++--- handler/payload/posts_response_test.go | 5 ++--- 9 files changed, 22 insertions(+), 19 deletions(-) diff --git a/database/infra/migrations/000004_categories_sort.up.sql b/database/infra/migrations/000004_categories_sort.up.sql index bd09fc16..85a2d639 100644 --- a/database/infra/migrations/000004_categories_sort.up.sql +++ b/database/infra/migrations/000004_categories_sort.up.sql @@ -1,4 +1,7 @@ ALTER TABLE categories - ADD COLUMN IF NOT EXISTS sort INT; + ADD COLUMN IF NOT EXISTS sort INT NOT NULL DEFAULT 0; + +ALTER TABLE categories + ALTER COLUMN sort DROP DEFAULT; CREATE INDEX IF NOT EXISTS idx_categories_sort ON categories (sort, name); diff --git a/database/model.go b/database/model.go index 5d999ce3..144e144c 100644 --- a/database/model.go +++ b/database/model.go @@ -109,7 +109,7 @@ type Category struct { Name string `gorm:"type:varchar(255);unique;not null"` Slug string `gorm:"type:varchar(255);unique;not null"` Description string `gorm:"type:text"` - Sort *int `gorm:"type:int;index:idx_categories_sort"` + Sort int `gorm:"type:int;not null;index:idx_categories_sort"` CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` DeletedAt gorm.DeletedAt diff --git a/database/repository/categories.go b/database/repository/categories.go index 8803130d..7f0f14ef 100644 --- a/database/repository/categories.go +++ b/database/repository/categories.go @@ -100,12 +100,17 @@ func (c Categories) CreateOrUpdate(post database.Post, attrs database.PostsAttrs return nil, fmt.Errorf("error creating/updating category [%s]: %s", seed.Name, err) } + sort := 0 + if seed.Sort != nil { + sort = *seed.Sort + } + category := database.Category{ UUID: uuid.NewString(), Name: seed.Name, Slug: seed.Slug, Description: seed.Description, - Sort: seed.Sort, + Sort: sort, } if result := c.DB.Sql().Create(&category); model.HasDbIssues(result.Error) { @@ -143,7 +148,7 @@ func (c Categories) ExistOrUpdate(seed database.CategoriesAttrs) (bool, error) { } if seed.Sort != nil { - category.Sort = seed.Sort + category.Sort = *seed.Sort } if result := c.DB.Sql().Save(&category); model.HasDbIssues(result.Error) { diff --git a/database/repository/categories_postgres_test.go b/database/repository/categories_postgres_test.go index 6f6a7360..c90289e9 100644 --- a/database/repository/categories_postgres_test.go +++ b/database/repository/categories_postgres_test.go @@ -29,20 +29,18 @@ func TestCategoriesGetOrdersBySort(t *testing.T) { repo := repository.Categories{DB: conn} - lowSort := 10 low := database.Category{ UUID: uuid.NewString(), Name: "Low", Slug: "low", - Sort: &lowSort, + Sort: 10, } - highSort := 20 high := database.Category{ UUID: uuid.NewString(), Name: "High", Slug: "high", - Sort: &highSort, + Sort: 20, } if err := conn.Sql().Create(&high).Error; err != nil { diff --git a/database/seeder/seeds/categories.go b/database/seeder/seeds/categories.go index d9ae82ec..1ce3615e 100644 --- a/database/seeder/seeds/categories.go +++ b/database/seeder/seeds/categories.go @@ -33,7 +33,7 @@ func (s CategoriesSeed) Create(attrs database.CategoriesAttrs) ([]database.Categ Name: seed, Slug: strings.ToLower(seed), Description: attrs.Description, - Sort: &sort, + Sort: sort, }) } diff --git a/handler/categories_test.go b/handler/categories_test.go index 303ea57d..23f729e6 100644 --- a/handler/categories_test.go +++ b/handler/categories_test.go @@ -34,13 +34,12 @@ func TestCategoriesHandlerIndex_Success(t *testing.T) { t.Fatalf("create post: %v", err) } - sort := 10 cat := database.Category{ UUID: uuid.NewString(), Name: "Cat", Slug: "cat", Description: "desc", - Sort: &sort, + Sort: 10, } if err := conn.Sql().Create(&cat).Error; err != nil { @@ -77,7 +76,7 @@ func TestCategoriesHandlerIndex_Success(t *testing.T) { t.Fatalf("decode: %v", err) } - if len(resp.Data) != 1 || resp.Data[0].Slug != "cat" || resp.Data[0].Sort == nil || *resp.Data[0].Sort != 10 { + if len(resp.Data) != 1 || resp.Data[0].Slug != "cat" || resp.Data[0].Sort != 10 { t.Fatalf("unexpected data: %+v", resp.Data) } } diff --git a/handler/payload/categories.go b/handler/payload/categories.go index 953566a2..c9e57c4f 100644 --- a/handler/payload/categories.go +++ b/handler/payload/categories.go @@ -7,7 +7,7 @@ type CategoryResponse struct { Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` - Sort *int `json:"sort"` + Sort int `json:"sort"` } func GetCategoriesResponse(categories []database.Category) []CategoryResponse { diff --git a/handler/payload/categories_test.go b/handler/payload/categories_test.go index 706cda07..964c8872 100644 --- a/handler/payload/categories_test.go +++ b/handler/payload/categories_test.go @@ -7,20 +7,19 @@ import ( ) func TestGetCategoriesResponse(t *testing.T) { - sort := 2 cats := []database.Category{ { UUID: "1", Name: "n", Slug: "s", Description: "d", - Sort: &sort, + Sort: 2, }, } r := GetCategoriesResponse(cats) - if len(r) != 1 || r[0].Slug != "s" || r[0].Sort == nil || *r[0].Sort != 2 { + if len(r) != 1 || r[0].Slug != "s" || r[0].Sort != 2 { t.Fatalf("unexpected %#v", r) } } diff --git a/handler/payload/posts_response_test.go b/handler/payload/posts_response_test.go index 16d695aa..94f196cd 100644 --- a/handler/payload/posts_response_test.go +++ b/handler/payload/posts_response_test.go @@ -9,7 +9,6 @@ import ( func TestGetPostsResponse(t *testing.T) { now := time.Now() - sort := 3 p := database.Post{ UUID: "1", Slug: "slug", @@ -26,7 +25,7 @@ func TestGetPostsResponse(t *testing.T) { Name: "cn", Slug: "cs", Description: "cd", - Sort: &sort, + Sort: 3, }, }, Tags: []database.Tag{ @@ -52,7 +51,7 @@ func TestGetPostsResponse(t *testing.T) { r := GetPostsResponse(p) - if r.UUID != "1" || r.Author.UUID != "u1" || len(r.Categories) != 1 || len(r.Tags) != 1 || r.Categories[0].Sort == nil || *r.Categories[0].Sort != 3 { + if r.UUID != "1" || r.Author.UUID != "u1" || len(r.Categories) != 1 || len(r.Tags) != 1 || r.Categories[0].Sort != 3 { t.Fatalf("unexpected response: %+v", r) } } From 9504830f5416c1b91ef19c79865e3ac46a144ca9 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 17 Oct 2025 17:21:10 +0800 Subject: [PATCH 04/16] Make category attrs sort required --- database/attrs.go | 2 +- database/repository/categories.go | 11 ++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/database/attrs.go b/database/attrs.go index b335ee9a..20f85e30 100644 --- a/database/attrs.go +++ b/database/attrs.go @@ -21,7 +21,7 @@ type CategoriesAttrs struct { Slug string Name string Description string - Sort *int + Sort int } type TagAttrs struct { diff --git a/database/repository/categories.go b/database/repository/categories.go index 7f0f14ef..df5fa1d5 100644 --- a/database/repository/categories.go +++ b/database/repository/categories.go @@ -100,17 +100,12 @@ func (c Categories) CreateOrUpdate(post database.Post, attrs database.PostsAttrs return nil, fmt.Errorf("error creating/updating category [%s]: %s", seed.Name, err) } - sort := 0 - if seed.Sort != nil { - sort = *seed.Sort - } - category := database.Category{ UUID: uuid.NewString(), Name: seed.Name, Slug: seed.Slug, Description: seed.Description, - Sort: sort, + Sort: seed.Sort, } if result := c.DB.Sql().Create(&category); model.HasDbIssues(result.Error) { @@ -147,9 +142,7 @@ func (c Categories) ExistOrUpdate(seed database.CategoriesAttrs) (bool, error) { category.Description = seed.Description } - if seed.Sort != nil { - category.Sort = *seed.Sort - } + category.Sort = seed.Sort if result := c.DB.Sql().Save(&category); model.HasDbIssues(result.Error) { return false, fmt.Errorf("error on exist or update category [%s]: %s", category.Name, result.Error) From 3488474090c7967615bf858e10ad329582ec245b Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 21 Oct 2025 09:19:49 +0800 Subject: [PATCH 05/16] Add name tiebreaker category ordering test --- .../repository/categories_postgres_test.go | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/database/repository/categories_postgres_test.go b/database/repository/categories_postgres_test.go index c90289e9..fcb93807 100644 --- a/database/repository/categories_postgres_test.go +++ b/database/repository/categories_postgres_test.go @@ -64,3 +64,44 @@ func TestCategoriesGetOrdersBySort(t *testing.T) { t.Fatalf("expected sort ordering, got %+v", items) } } + +func TestCategoriesGetOrdersBySortAndName(t *testing.T) { + conn := newPostgresConnection(t, &database.Category{}) + + repo := repository.Categories{DB: conn} + + alpha := database.Category{ + UUID: uuid.NewString(), + Name: "Alpha", + Slug: "alpha", + Sort: 10, + } + + bravo := database.Category{ + UUID: uuid.NewString(), + Name: "Bravo", + Slug: "bravo", + Sort: 10, + } + + if err := conn.Sql().Create(&bravo).Error; err != nil { + t.Fatalf("create bravo: %v", err) + } + + if err := conn.Sql().Create(&alpha).Error; err != nil { + t.Fatalf("create alpha: %v", err) + } + + items, err := repo.Get() + if err != nil { + t.Fatalf("get: %v", err) + } + + if len(items) != 2 { + t.Fatalf("expected 2 categories, got %d", len(items)) + } + + if items[0].Slug != "alpha" || items[1].Slug != "bravo" { + t.Fatalf("expected secondary name ordering, got %+v", items) + } +} From 98661581f8f31536d1c74ca9981bf7a381a9619b Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 21 Oct 2025 09:33:42 +0800 Subject: [PATCH 06/16] Fix importer seed test for category sort --- database/seeder/importer/runner_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/database/seeder/importer/runner_test.go b/database/seeder/importer/runner_test.go index e0d28cec..c91286d0 100644 --- a/database/seeder/importer/runner_test.go +++ b/database/seeder/importer/runner_test.go @@ -500,8 +500,8 @@ func TestSeedFromFileRunsMigrations(t *testing.T) { t.Cleanup(cleanup) contents := strings.Join([]string{ - "INSERT INTO categories (uuid, name, slug)", - "VALUES ('00000000-0000-0000-0000-00000000c001', 'Tech', 'tech');", + "INSERT INTO categories (uuid, name, slug, sort)", + "VALUES ('00000000-0000-0000-0000-00000000c001', 'Tech', 'tech', 1);", }, "\n") fileName := writeStorageFile(t, withSuffix(t, ".sql"), contents) From 0f3508145514c9bd46cba0d8443eedfea27e9e1e Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 21 Oct 2025 09:54:47 +0800 Subject: [PATCH 07/16] Remove GORM sort index tag --- database/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/model.go b/database/model.go index 144e144c..f5413852 100644 --- a/database/model.go +++ b/database/model.go @@ -109,7 +109,7 @@ type Category struct { Name string `gorm:"type:varchar(255);unique;not null"` Slug string `gorm:"type:varchar(255);unique;not null"` Description string `gorm:"type:text"` - Sort int `gorm:"type:int;not null;index:idx_categories_sort"` + Sort int `gorm:"type:int;not null"` CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` DeletedAt gorm.DeletedAt From 505f8db8e7928fb614f33dfa010592a2ee7e0636 Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 21 Oct 2025 10:06:53 +0800 Subject: [PATCH 08/16] Set category sort in test fixtures --- database/repository/repository_test.go | 2 ++ database/repository/testhelpers_test.go | 1 + metal/cli/posts/handler_test.go | 1 + metal/cli/seo/testhelpers_test.go | 1 + 4 files changed, 5 insertions(+) diff --git a/database/repository/repository_test.go b/database/repository/repository_test.go index 9c4ace4b..0429fafb 100644 --- a/database/repository/repository_test.go +++ b/database/repository/repository_test.go @@ -149,6 +149,7 @@ func TestCategoriesFindBy(t *testing.T) { UUID: uuid.NewString(), Name: "News", Slug: "news", + Sort: 1, } if err := conn.Sql().Create(&c).Error; err != nil { @@ -192,6 +193,7 @@ func TestPostsCreateAndFind(t *testing.T) { UUID: uuid.NewString(), Name: "Tech", Slug: "tech", + Sort: 1, } if err := conn.Sql().Create(&cat).Error; err != nil { diff --git a/database/repository/testhelpers_test.go b/database/repository/testhelpers_test.go index 361d4c2b..4b1a1f3e 100644 --- a/database/repository/testhelpers_test.go +++ b/database/repository/testhelpers_test.go @@ -113,6 +113,7 @@ func seedCategory(t *testing.T, conn *database.Connection, slug, name string) da UUID: uuid.NewString(), Slug: slug, Name: name, + Sort: 1, } if err := conn.Sql().Create(&category).Error; err != nil { diff --git a/metal/cli/posts/handler_test.go b/metal/cli/posts/handler_test.go index 00991d73..bd1961ef 100644 --- a/metal/cli/posts/handler_test.go +++ b/metal/cli/posts/handler_test.go @@ -46,6 +46,7 @@ func setupPostsHandler(t *testing.T) (*Handler, *database.Connection) { UUID: uuid.NewString(), Name: "Tech", Slug: "tech", + Sort: 1, }) conn.Sql().Create(&database.Tag{ UUID: uuid.NewString(), diff --git a/metal/cli/seo/testhelpers_test.go b/metal/cli/seo/testhelpers_test.go index 79a421d3..dd054a10 100644 --- a/metal/cli/seo/testhelpers_test.go +++ b/metal/cli/seo/testhelpers_test.go @@ -131,6 +131,7 @@ func seedCategory(t *testing.T, conn *database.Connection, slug, name string) da UUID: uuid.NewString(), Slug: slug, Name: name, + Sort: 1, } if err := conn.Sql().Create(&category).Error; err != nil { From 6b9e65e8777378424ac5300e542d594e417d902a Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 21 Oct 2025 10:16:30 +0800 Subject: [PATCH 09/16] Assert category handler orders by name --- handler/categories_test.go | 51 ++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/handler/categories_test.go b/handler/categories_test.go index 23f729e6..5aedbb36 100644 --- a/handler/categories_test.go +++ b/handler/categories_test.go @@ -34,25 +34,46 @@ func TestCategoriesHandlerIndex_Success(t *testing.T) { t.Fatalf("create post: %v", err) } - cat := database.Category{ + catB := database.Category{ UUID: uuid.NewString(), - Name: "Cat", - Slug: "cat", + Name: "Beta", + Slug: "beta", Description: "desc", Sort: 10, } - if err := conn.Sql().Create(&cat).Error; err != nil { - t.Fatalf("create category: %v", err) + if err := conn.Sql().Create(&catB).Error; err != nil { + t.Fatalf("create category beta: %v", err) } - link := database.PostCategory{ + catA := database.Category{ + UUID: uuid.NewString(), + Name: "Alpha", + Slug: "alpha", + Description: "desc", + Sort: 10, + } + + if err := conn.Sql().Create(&catA).Error; err != nil { + t.Fatalf("create category alpha: %v", err) + } + + linkBeta := database.PostCategory{ + PostID: post.ID, + CategoryID: catB.ID, + } + + if err := conn.Sql().Create(&linkBeta).Error; err != nil { + t.Fatalf("create beta link: %v", err) + } + + linkAlpha := database.PostCategory{ PostID: post.ID, - CategoryID: cat.ID, + CategoryID: catA.ID, } - if err := conn.Sql().Create(&link).Error; err != nil { - t.Fatalf("create link: %v", err) + if err := conn.Sql().Create(&linkAlpha).Error; err != nil { + t.Fatalf("create alpha link: %v", err) } h := NewCategoriesHandler(&repository.Categories{ @@ -76,7 +97,15 @@ func TestCategoriesHandlerIndex_Success(t *testing.T) { t.Fatalf("decode: %v", err) } - if len(resp.Data) != 1 || resp.Data[0].Slug != "cat" || resp.Data[0].Sort != 10 { - t.Fatalf("unexpected data: %+v", resp.Data) + if len(resp.Data) != 2 { + t.Fatalf("unexpected data length: %+v", resp.Data) + } + + if resp.Data[0].Name != "Alpha" || resp.Data[0].Slug != "alpha" || resp.Data[0].Sort != 10 { + t.Fatalf("unexpected first category: %+v", resp.Data[0]) + } + + if resp.Data[1].Name != "Beta" || resp.Data[1].Slug != "beta" || resp.Data[1].Sort != 10 { + t.Fatalf("unexpected second category: %+v", resp.Data[1]) } } From 9baa7a759e773a5dd42692838924bc442def9924 Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 21 Oct 2025 10:26:59 +0800 Subject: [PATCH 10/16] Reorder categories sort column --- database/infra/migrations/000001_schema.up.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/database/infra/migrations/000001_schema.up.sql b/database/infra/migrations/000001_schema.up.sql index fead4b94..d3beb8a0 100644 --- a/database/infra/migrations/000001_schema.up.sql +++ b/database/infra/migrations/000001_schema.up.sql @@ -56,6 +56,7 @@ CREATE TABLE IF NOT EXISTS categories ( name VARCHAR(255) UNIQUE NOT NULL, slug VARCHAR(255) UNIQUE NOT NULL, description TEXT, + sort INT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP DEFAULT NULL From d723f4b6e1b1d6bfb93faddfa120ea80489c63b0 Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 21 Oct 2025 10:34:44 +0800 Subject: [PATCH 11/16] Avoid clobbering category sort updates --- database/repository/categories.go | 4 +- .../repository/categories_postgres_test.go | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/database/repository/categories.go b/database/repository/categories.go index df5fa1d5..a2de0b73 100644 --- a/database/repository/categories.go +++ b/database/repository/categories.go @@ -142,7 +142,9 @@ func (c Categories) ExistOrUpdate(seed database.CategoriesAttrs) (bool, error) { category.Description = seed.Description } - category.Sort = seed.Sort + if seed.Sort != 0 { + category.Sort = seed.Sort + } if result := c.DB.Sql().Save(&category); model.HasDbIssues(result.Error) { return false, fmt.Errorf("error on exist or update category [%s]: %s", category.Name, result.Error) diff --git a/database/repository/categories_postgres_test.go b/database/repository/categories_postgres_test.go index fcb93807..30c44b35 100644 --- a/database/repository/categories_postgres_test.go +++ b/database/repository/categories_postgres_test.go @@ -105,3 +105,46 @@ func TestCategoriesGetOrdersBySortAndName(t *testing.T) { t.Fatalf("expected secondary name ordering, got %+v", items) } } + +func TestCategoriesExistOrUpdatePreservesSortWhenZero(t *testing.T) { + conn := newPostgresConnection(t, &database.Category{}) + + repo := repository.Categories{DB: conn} + + original := database.Category{ + UUID: uuid.NewString(), + Name: "Original", + Slug: "original", + Sort: 25, + } + + if err := conn.Sql().Create(&original).Error; err != nil { + t.Fatalf("create original: %v", err) + } + + existed, err := repo.ExistOrUpdate(database.CategoriesAttrs{ + Slug: "original", + Name: "Renamed", + Sort: 0, + }) + if err != nil { + t.Fatalf("exist or update: %v", err) + } + + if !existed { + t.Fatalf("expected category to exist") + } + + var updated database.Category + if err := conn.Sql().Where("id = ?", original.ID).First(&updated).Error; err != nil { + t.Fatalf("reload category: %v", err) + } + + if updated.Sort != original.Sort { + t.Fatalf("expected sort to remain %d, got %d", original.Sort, updated.Sort) + } + + if updated.Name != "Renamed" { + t.Fatalf("expected name to be updated, got %s", updated.Name) + } +} From dba1a9041e19af8ae3a0a69a1de18b41485d5bcf Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 21 Oct 2025 10:46:05 +0800 Subject: [PATCH 12/16] Adjust category test helpers and coverage --- .../repository/categories_postgres_test.go | 2 +- database/repository/posts_postgres_test.go | 12 +-- database/repository/testhelpers_test.go | 4 +- handler/categories_test.go | 81 +++++++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/database/repository/categories_postgres_test.go b/database/repository/categories_postgres_test.go index 30c44b35..05f94fb9 100644 --- a/database/repository/categories_postgres_test.go +++ b/database/repository/categories_postgres_test.go @@ -11,7 +11,7 @@ import ( func TestCategoriesFindByPostgres(t *testing.T) { conn := newPostgresConnection(t, &database.Category{}) - category := seedCategory(t, conn, "news", "News") + category := seedCategory(t, conn, "news", "News", 1) repo := repository.Categories{DB: conn} diff --git a/database/repository/posts_postgres_test.go b/database/repository/posts_postgres_test.go index 5a688025..60ddda07 100644 --- a/database/repository/posts_postgres_test.go +++ b/database/repository/posts_postgres_test.go @@ -21,7 +21,7 @@ func TestPostsCreateLinksAssociationsPostgres(t *testing.T) { ) user := seedUser(t, conn, "Alice", "Smith", "alice") - category := seedCategory(t, conn, "tech", "Tech") + category := seedCategory(t, conn, "tech", "Tech", 1) tag := seedTag(t, conn, "go", "Go") postsRepo := repository.Posts{ @@ -89,7 +89,7 @@ func TestPostsFindByLoadsAssociationsPostgres(t *testing.T) { ) user := seedUser(t, conn, "Bob", "Jones", "bobj") - category := seedCategory(t, conn, "career", "Career") + category := seedCategory(t, conn, "career", "Career", 1) tag := seedTag(t, conn, "work", "Work") post := seedPost(t, conn, user, category, tag, "career-path", "Career Path", true) @@ -138,7 +138,7 @@ func TestPostsGetAllFiltersPublishedRecordsPostgres(t *testing.T) { authorOne := seedUser(t, conn, "Carol", "One", "carol") authorTwo := seedUser(t, conn, "Dave", "Two", "dave") - category := seedCategory(t, conn, "engineering", "Engineering") + category := seedCategory(t, conn, "engineering", "Engineering", 1) tag := seedTag(t, conn, "backend", "Backend") otherTag := seedTag(t, conn, "frontend", "Frontend") @@ -196,8 +196,8 @@ func TestPostsGetAllDeduplicatesResultsPostgres(t *testing.T) { author := seedUser(t, conn, "Eve", "Duplicates", "eve") - primaryCategory := seedCategory(t, conn, "engineering", "Engineering") - secondaryCategory := seedCategory(t, conn, "engagement", "Engagement") + primaryCategory := seedCategory(t, conn, "engineering", "Engineering", 1) + secondaryCategory := seedCategory(t, conn, "engagement", "Engagement", 2) primaryTag := seedTag(t, conn, "eng-backend", "Eng Backend") secondaryTag := seedTag(t, conn, "eng-frontend", "Eng Frontend") @@ -240,7 +240,7 @@ func TestPostsGetAllDeduplicatesResultsPostgres(t *testing.T) { func TestPostsFindCategoryByDelegatesPostgres(t *testing.T) { conn := newPostgresConnection(t, &database.Category{}) - category := seedCategory(t, conn, "lifestyle", "Lifestyle") + category := seedCategory(t, conn, "lifestyle", "Lifestyle", 1) postsRepo := repository.Posts{ DB: conn, diff --git a/database/repository/testhelpers_test.go b/database/repository/testhelpers_test.go index 4b1a1f3e..24f6b5fe 100644 --- a/database/repository/testhelpers_test.go +++ b/database/repository/testhelpers_test.go @@ -106,14 +106,14 @@ func seedUser(t *testing.T, conn *database.Connection, first, last, username str return user } -func seedCategory(t *testing.T, conn *database.Connection, slug, name string) database.Category { +func seedCategory(t *testing.T, conn *database.Connection, slug, name string, sort int) database.Category { t.Helper() category := database.Category{ UUID: uuid.NewString(), Slug: slug, Name: name, - Sort: 1, + Sort: sort, } if err := conn.Sql().Create(&category).Error; err != nil { diff --git a/handler/categories_test.go b/handler/categories_test.go index 5aedbb36..3f168306 100644 --- a/handler/categories_test.go +++ b/handler/categories_test.go @@ -109,3 +109,84 @@ func TestCategoriesHandlerIndex_Success(t *testing.T) { t.Fatalf("unexpected second category: %+v", resp.Data[1]) } } + +func TestCategoriesHandlerIndex_SortOrdering(t *testing.T) { + conn, author := handlertests.NewTestDB(t) + + published := time.Now() + + post := database.Post{ + UUID: uuid.NewString(), + AuthorID: author.ID, + Slug: "hello", + Title: "Hello", + Excerpt: "Ex", + Content: "Body", + PublishedAt: &published, + } + + if err := conn.Sql().Create(&post).Error; err != nil { + t.Fatalf("create post: %v", err) + } + + catLow := database.Category{ + UUID: uuid.NewString(), + Name: "Zebra", + Slug: "zebra", + Description: "desc", + Sort: 5, + } + + if err := conn.Sql().Create(&catLow).Error; err != nil { + t.Fatalf("create low sort category: %v", err) + } + + catHigh := database.Category{ + UUID: uuid.NewString(), + Name: "Apple", + Slug: "apple", + Description: "desc", + Sort: 20, + } + + if err := conn.Sql().Create(&catHigh).Error; err != nil { + t.Fatalf("create high sort category: %v", err) + } + + for _, catID := range []uint64{catLow.ID, catHigh.ID} { + link := database.PostCategory{PostID: post.ID, CategoryID: catID} + if err := conn.Sql().Create(&link).Error; err != nil { + t.Fatalf("create link: %v", err) + } + } + + h := NewCategoriesHandler(&repository.Categories{DB: conn}) + req := httptest.NewRequest("GET", "/categories", nil) + rec := httptest.NewRecorder() + + if err := h.Index(rec, req); err != nil { + t.Fatalf("index err: %v", err) + } + + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + + var resp pagination.Pagination[payload.CategoryResponse] + + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + + if len(resp.Data) != 2 { + t.Fatalf("unexpected data length: %+v", resp.Data) + } + + if resp.Data[0].Name != "Zebra" || resp.Data[0].Sort != 5 { + t.Fatalf("unexpected first category: %+v", resp.Data[0]) + } + + if resp.Data[1].Name != "Apple" || resp.Data[1].Sort != 20 { + t.Fatalf("unexpected second category: %+v", resp.Data[1]) + } +} From 2b1363101bab071b5f48980c68658569743eed08 Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 21 Oct 2025 11:09:43 +0800 Subject: [PATCH 13/16] Adjust category migrations for sort column order --- .../infra/migrations/000001_schema.up.sql | 1 - .../migrations/000004_categories_sort.up.sql | 29 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/database/infra/migrations/000001_schema.up.sql b/database/infra/migrations/000001_schema.up.sql index d3beb8a0..fead4b94 100644 --- a/database/infra/migrations/000001_schema.up.sql +++ b/database/infra/migrations/000001_schema.up.sql @@ -56,7 +56,6 @@ CREATE TABLE IF NOT EXISTS categories ( name VARCHAR(255) UNIQUE NOT NULL, slug VARCHAR(255) UNIQUE NOT NULL, description TEXT, - sort INT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP DEFAULT NULL diff --git a/database/infra/migrations/000004_categories_sort.up.sql b/database/infra/migrations/000004_categories_sort.up.sql index 85a2d639..257d7c00 100644 --- a/database/infra/migrations/000004_categories_sort.up.sql +++ b/database/infra/migrations/000004_categories_sort.up.sql @@ -4,4 +4,33 @@ ALTER TABLE categories ALTER TABLE categories ALTER COLUMN sort DROP DEFAULT; +ALTER TABLE post_categories + DROP CONSTRAINT IF EXISTS post_categories_category_id_fkey; + +ALTER TABLE categories + RENAME TO categories_old; + +CREATE TABLE categories ( + id BIGSERIAL PRIMARY KEY, + uuid UUID UNIQUE NOT NULL, + name VARCHAR(255) UNIQUE NOT NULL, + slug VARCHAR(255) UNIQUE NOT NULL, + description TEXT, + sort INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT NULL +); + +INSERT INTO categories (id, uuid, name, slug, description, sort, created_at, updated_at, deleted_at) +SELECT id, uuid, name, slug, description, sort, created_at, updated_at, deleted_at +FROM categories_old; + +DROP TABLE categories_old; + CREATE INDEX IF NOT EXISTS idx_categories_sort ON categories (sort, name); + +ALTER TABLE post_categories + ADD CONSTRAINT post_categories_category_id_fkey FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE; + +SELECT setval(pg_get_serial_sequence('categories', 'id'), COALESCE((SELECT MAX(id) FROM categories), 0)); From c2f2d6dd07281a747a0520322e71e55a7102a22b Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 21 Oct 2025 11:09:50 +0800 Subject: [PATCH 14/16] Simplify categories sort migration --- .../migrations/000004_categories_sort.up.sql | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/database/infra/migrations/000004_categories_sort.up.sql b/database/infra/migrations/000004_categories_sort.up.sql index 257d7c00..85a2d639 100644 --- a/database/infra/migrations/000004_categories_sort.up.sql +++ b/database/infra/migrations/000004_categories_sort.up.sql @@ -4,33 +4,4 @@ ALTER TABLE categories ALTER TABLE categories ALTER COLUMN sort DROP DEFAULT; -ALTER TABLE post_categories - DROP CONSTRAINT IF EXISTS post_categories_category_id_fkey; - -ALTER TABLE categories - RENAME TO categories_old; - -CREATE TABLE categories ( - id BIGSERIAL PRIMARY KEY, - uuid UUID UNIQUE NOT NULL, - name VARCHAR(255) UNIQUE NOT NULL, - slug VARCHAR(255) UNIQUE NOT NULL, - description TEXT, - sort INT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP DEFAULT NULL -); - -INSERT INTO categories (id, uuid, name, slug, description, sort, created_at, updated_at, deleted_at) -SELECT id, uuid, name, slug, description, sort, created_at, updated_at, deleted_at -FROM categories_old; - -DROP TABLE categories_old; - CREATE INDEX IF NOT EXISTS idx_categories_sort ON categories (sort, name); - -ALTER TABLE post_categories - ADD CONSTRAINT post_categories_category_id_fkey FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE; - -SELECT setval(pg_get_serial_sequence('categories', 'id'), COALESCE((SELECT MAX(id) FROM categories), 0)); From 6b95042d0a0e6d1f134cbbf2df4e22a33dbf9218 Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 21 Oct 2025 11:15:11 +0800 Subject: [PATCH 15/16] Avoid updating category sort in ExistOrUpdate --- database/repository/categories.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/database/repository/categories.go b/database/repository/categories.go index a2de0b73..75f0f97c 100644 --- a/database/repository/categories.go +++ b/database/repository/categories.go @@ -142,10 +142,6 @@ func (c Categories) ExistOrUpdate(seed database.CategoriesAttrs) (bool, error) { category.Description = seed.Description } - if seed.Sort != 0 { - category.Sort = seed.Sort - } - if result := c.DB.Sql().Save(&category); model.HasDbIssues(result.Error) { return false, fmt.Errorf("error on exist or update category [%s]: %s", category.Name, result.Error) } From 2287e3ad1a5c2ee51b1d4add909703c7d9cc847c Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 21 Oct 2025 11:32:39 +0800 Subject: [PATCH 16/16] Add test for category sort updates --- .../repository/categories_postgres_test.go | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/database/repository/categories_postgres_test.go b/database/repository/categories_postgres_test.go index 05f94fb9..a2185d8b 100644 --- a/database/repository/categories_postgres_test.go +++ b/database/repository/categories_postgres_test.go @@ -148,3 +148,46 @@ func TestCategoriesExistOrUpdatePreservesSortWhenZero(t *testing.T) { t.Fatalf("expected name to be updated, got %s", updated.Name) } } + +func TestCategoriesExistOrUpdateUpdatesSortWhenNonZero(t *testing.T) { + conn := newPostgresConnection(t, &database.Category{}) + + repo := repository.Categories{DB: conn} + + original := database.Category{ + UUID: uuid.NewString(), + Name: "Original", + Slug: "original", + Sort: 25, + } + + if err := conn.Sql().Create(&original).Error; err != nil { + t.Fatalf("create original: %v", err) + } + + existed, err := repo.ExistOrUpdate(database.CategoriesAttrs{ + Slug: "original", + Name: "Renamed", + Sort: 30, + }) + if err != nil { + t.Fatalf("exist or update: %v", err) + } + + if !existed { + t.Fatalf("expected category to exist") + } + + var updated database.Category + if err := conn.Sql().Where("id = ?", original.ID).First(&updated).Error; err != nil { + t.Fatalf("reload category: %v", err) + } + + if updated.Sort != 30 { + t.Fatalf("expected sort to be updated to 30, got %d", updated.Sort) + } + + if updated.Name != "Renamed" { + t.Fatalf("expected name to be updated, got %s", updated.Name) + } +}