From 0f21ed1f81928ece68ec0a881f77e0ecb19afde5 Mon Sep 17 00:00:00 2001 From: Marek Goc Date: Wed, 25 Mar 2026 02:23:12 +0100 Subject: [PATCH] filters --- app/api/openapi.json | 71 +++++++++++++++- .../web/api/restricted/listProducts.go | 84 ++----------------- app/model/product.go | 13 +-- .../listProductsRepo/listProductsRepo.go | 67 ++++++++------- app/utils/query/filters/filters.go | 31 ++++++- .../query_params/where_scope_from_query.go | 4 +- 6 files changed, 151 insertions(+), 119 deletions(-) diff --git a/app/api/openapi.json b/app/api/openapi.json index b01e771..a1207e4 100644 --- a/app/api/openapi.json +++ b/app/api/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "b2b API", - "description": "Authentication, user management, and repository time tracking API", + "description": "Authentication, user management, and repository time tracking API\n\n## Filter Operators\nAll filter parameters support the following operators by adding a suffix to the parameter name:\n\n| Suffix | Operator | Example |\n|--------|----------|---------|\n| `_eq` | equals | `product_id_eq=12` |\n| `_neq` | not equals | `active_neq=0` |\n| `_gt` | greater than | `price_gt=100` |\n| `_gte` | greater than or equal | `price_gte=10` |\n| `_lt` | less than | `price_lt=500` |\n| `_lte` | less than or equal | `price_lte=500` |\n| `_in` | IN (comma-separated) | `product_id_in=1,2,3,4` |\n\n## Special Filters\n\n| Parameter | Example | Description |\n|-----------|---------|-------------|\n| `name=~text` | `name=~gold` | LIKE search (case-insensitive partial match) |\n| `price=[min,max]` | `price=[100,500]` | BETWEEN range (inclusive) |\n| `sort=field,direction` | `sort=price,desc` | ORDER BY clause (direction: asc/desc, default: desc) |\n| `p` | `p=1` | Page number (1-based, default: 1) |\n| `elems` | `elems=30` | Elements per page (max: 100, default: 30) |", "version": "1.0.0", "contact": { "name": "API Support", @@ -1043,7 +1043,7 @@ "get": { "tags": ["Products"], "summary": "Get product listing", - "description": "Returns a paginated list of products with their basic information. Requires authentication.", + "description": "Returns a paginated list of products with their basic information. Supports filtering via query parameters with operators (e.g., product_id_eq=12, name=~gold). Use sort parameter for ordering. Requires authentication.", "operationId": "getProductListing", "security": [ { @@ -1064,12 +1064,77 @@ { "name": "elems", "in": "query", - "description": "Number of items per page", + "description": "Number of items per page (max: 100, default: 30)", "required": false, "schema": { "type": "integer", "default": 30 } + }, + { + "name": "sort", + "in": "query", + "description": "Sort field and direction. Format: field,direction (e.g., 'product_id,desc' or 'name,asc')", + "required": false, + "schema": { + "type": "string", + "example": "product_id,desc" + } + }, + { + "name": "product_id", + "in": "query", + "description": "Filter by product ID. Use suffix _eq, _gt, _gte, _lt, _lte, _neq, _in for operators.", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "name", + "in": "query", + "description": "Filter by product name using LIKE (case-insensitive). Use ~ prefix for partial match (e.g., '~gold')", + "required": false, + "schema": { + "type": "string", + "example": "~gold" + } + }, + { + "name": "reference", + "in": "query", + "description": "Filter by product reference using LIKE (case-insensitive)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id_category", + "in": "query", + "description": "Filter by category ID. Use suffix _eq, _gt, _gte, _lt, _lte, _neq, _in for operators.", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "category_name", + "in": "query", + "description": "Filter by category name using LIKE (case-insensitive)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "quantity", + "in": "query", + "description": "Filter by quantity. Use suffix _eq, _gt, _gte, _lt, _lte for operators.", + "required": false, + "schema": { + "type": "integer" + } } ], "responses": { diff --git a/app/delivery/web/api/restricted/listProducts.go b/app/delivery/web/api/restricted/listProducts.go index 5f7b868..962fd07 100644 --- a/app/delivery/web/api/restricted/listProducts.go +++ b/app/delivery/web/api/restricted/listProducts.go @@ -1,14 +1,11 @@ package restricted import ( - "fmt" - "git.ma-al.com/goc_daniel/b2b/app/config" + "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/listProductsService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" "git.ma-al.com/goc_daniel/b2b/app/utils/query/query_params" "git.ma-al.com/goc_daniel/b2b/app/utils/response" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" @@ -38,66 +35,8 @@ func ListProductsHandlerRoutes(r fiber.Router) fiber.Router { return r } -// var columnMapping map[string]string = map[string]string{ -// "product_id": "id", -// "price": "price_taxed", -// "name": "name", -// "category_id": "category_id", -// "feature_id": "feature_id", -// "feature": "feature_name", -// "value_id": "value_id", -// "value": "value_name", -// "status": "active_sale", -// "stock": "in_stock", -// } - -// type FeatVal = map[uint][]uint - -// func featureValueFilters(feats FeatVal) filters.Filter { -// filt := func(db *gorm.DB) *gorm.DB { -// return db.Where("value_id IN ?", lo.Flatten(lo.Values(feats))).Group("id").Having("COUNT(id) = ?", len(lo.Keys(feats))) -// } -// return filters.NewFilter(filters.FEAT_VAL_PRODUCT_FILTER, filt) -// } - -// func ParseProductFilters(c fiber.Ctx) (find.Paging, *filters.FiltersList, error) { -// var p find.Paging -// fl := filters.NewFiltersList() -// productFilters := new(model.ProductFilters) -// fmt.Printf("fl: %v\n", fl) -// err := c.Bind().Query(productFilters) -// if err != nil { -// return p, &fl, err -// } - -// if productFilters.Name != "" { -// fl.Append(filters.Where("name LIKE ?", fmt.Sprintf("%%%s%%", productFilters.Name))) -// } - -// if productFilters.Sort != "" { -// ord, err := query_params.ParseOrdering[model.Product](c, columnMapping) -// if err != nil { -// return p, &fl, err -// } -// for _, o := range ord { -// fl.Append(filters.Order(o.Column, o.IsDesc)) -// } -// } - -// if len(productFilters.Features) > 0 { -// fl.Append(featureValueFilters(productFilters.Features)) -// } - -// fl.Append(query_params.ParseWhereScopes[model.Product](c, []string{"name"}, columnMapping)...) - -// pageNum, pageElems := query_params.ParsePagination(c) -// p = find.Paging{Page: pageNum, Elements: pageElems} - -// return p, &fl, nil -// } - func (h *ListProductsHandler) GetListing(c fiber.Ctx) error { - paging, filters, err := ParseProductFilters(c) + paging, filters, err := query_params.ParseFilters[model.Product](c, columnMappingGetListing) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) @@ -118,16 +57,11 @@ func (h *ListProductsHandler) GetListing(c fiber.Ctx) error { return c.JSON(response.Make(&listing.Items, int(listing.Count), i18n.T_(c, response.Message_OK))) } -var columnMapping map[string]string = map[string]string{} - -func ParseProductFilters(c fiber.Ctx) (find.Paging, *filters.FiltersList, error) { - var p find.Paging - fl := filters.NewFiltersList() - - fmt.Printf("fl: %v\n", fl.All()) - - pageNum, pageElems := query_params.ParsePagination(c) - p = find.Paging{Page: pageNum, Elements: pageElems} - - return p, &fl, nil +var columnMappingGetListing map[string]string = map[string]string{ + "product_id": "ps.id_product", + "name": "pl.name", + "reference": "ps.reference", + "category_name": "cl.name", + "id_category": "cp.id_category", + "quantity": "sa.quantity", } diff --git a/app/model/product.go b/app/model/product.go index 36b0d70..c0b6331 100644 --- a/app/model/product.go +++ b/app/model/product.go @@ -62,11 +62,14 @@ type Product struct { DeliveryDays uint `gorm:"column:delivery_days" json:"delivery_days" form:"delivery_days"` } type ProductInList struct { - ProductID uint `gorm:"column:product_id;primaryKey" json:"product_id" form:"product_id"` - Name string `gorm:"column:name" json:"name" form:"name"` - ImageID string `gorm:"column:id_image"` - LinkRewrite string `gorm:"column:link_rewrite"` - Active uint `gorm:"column:active" json:"active" form:"active"` + ProductID uint `gorm:"column:product_id" json:"product_id" form:"product_id"` + Name string `gorm:"column:name" json:"name" form:"name"` + LinkRewrite string `gorm:"column:link_rewrite" json:"link_rewrite"` + ImageLink string `gorm:"column:image_link" json:"image_link"` + CategoryName string `gorm:"column:category_name" json:"category_name" form:"category_name"` + Reference string `gorm:"column:reference" json:"reference"` + VariantsNumber uint `gorm:"column:variants_number" json:"variants_number"` + Quantity uint `gorm:"column:quantity" json:"quantity"` } type ProductFilters struct { diff --git a/app/repos/listProductsRepo/listProductsRepo.go b/app/repos/listProductsRepo/listProductsRepo.go index bd9fda3..32739d9 100644 --- a/app/repos/listProductsRepo/listProductsRepo.go +++ b/app/repos/listProductsRepo/listProductsRepo.go @@ -1,10 +1,8 @@ package listProductsRepo import ( - "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/model" - constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" ) @@ -23,43 +21,44 @@ func (repo *ListProductsRepo) GetListing(id_lang uint, p find.Paging, filt *filt var listing []model.ProductInList var total int64 - subQuery := db.DB. - Table("ps_image"). - Select("id_product, MIN(id_image) AS id_image"). - Group("id_product") - - err := db.DB. - Table("ps_product"). + query := db.Get(). + Table("ps_product_shop AS ps"). Select(` - ps_product.id_product AS product_id, - ps_product_lang.name AS name, - ps_product.active AS active, - ps_product_lang.link_rewrite AS link_rewrite, - COALESCE(CONCAT( ?, '/', ps_image_shop.id_image, '-small_default/', ps_product_lang.link_rewrite, '.webp'), CONCAT( ?, '/', any_image.id_image, '-small_default/', ps_product_lang.link_rewrite, '.webp')) AS id_image - `, config.Get().Image.ImagePrefix, config.Get().Image.ImagePrefix). - Joins(` - LEFT JOIN ps_product_lang - ON ps_product_lang.id_product = ps_product.id_product - AND ps_product_lang.id_shop = ? - AND ps_product_lang.id_lang = ? - `, constdata.SHOP_ID, id_lang). - Joins(` - LEFT JOIN ps_image_shop - ON ps_image_shop.id_product = ps_product.id_product - AND ps_image_shop.id_shop = ? - AND ps_image_shop.cover = 1 - `, constdata.SHOP_ID). - Joins("LEFT JOIN (?) AS any_image ON ps_product.id_product = any_image.id_product", subQuery). - Limit(p.Limit()). - Offset(p.Offset()). - Scan(&listing).Error + ps.id_product AS product_id, + pl.name AS name, + pl.link_rewrite AS link_rewrite, + CONCAT('https://www.naluconcept.com', '/', ims.id_image, '-small_default/', pl.link_rewrite, '.webp') AS image_link, + cl.name AS category_name, + p.reference AS reference, + COUNT(DISTINCT pas.id_product_attribute) AS variants_number, + sa.quantity AS quantity + `). + Joins("JOIN ps_product p ON p.id_product = ps.id_product"). + Joins("JOIN ps_category_product cp ON ps.id_product = cp.id_product"). + Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", id_lang). + Joins("JOIN ps_image_shop ims ON ims.id_product = ps.id_product AND ims.cover = 1"). + Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", id_lang). + Joins("LEFT JOIN ps_product_attribute_shop pas ON pas.id_product = cp.id_product"). + Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product"). + Where("ps.active = ?", 1). + Group("cp.id_product") + + // Apply all filters + if filt != nil { + filt.ApplyAll(query) + } + + // run counter first as query is without limit and offset + err := query.Count(&total).Error if err != nil { return find.Found[model.ProductInList]{}, err } - err = db.DB. - Table("ps_product"). - Count(&total).Error + err = query. + Order("ps.id_product DESC"). + Limit(p.Limit()). + Offset(p.Offset()). + Scan(&listing).Error if err != nil { return find.Found[model.ProductInList]{}, err } diff --git a/app/utils/query/filters/filters.go b/app/utils/query/filters/filters.go index 3f7a26d..f818406 100644 --- a/app/utils/query/filters/filters.go +++ b/app/utils/query/filters/filters.go @@ -55,7 +55,9 @@ func WhereFromStrings(column, conditionOperator, value string) Filter { value = strings.ReplaceAll(value, "~", "") filt = func(d *gorm.DB) *gorm.DB { - return d.Where("lower("+column+`) LIKE lower(?)`, "%"+value+"%") + // return d.Where("lower("+column+`) LIKE lower(?)`, "%"+value+"%") + // (jeśli masz collation case-insensitive, np. utf8mb4_general_ci) + return d.Where(column+` LIKE ?`, "%"+value+"%") } @@ -65,6 +67,33 @@ func WhereFromStrings(column, conditionOperator, value string) Filter { } } + // Handle IN operator for comma-separated values (e.g., product_id_in=1,2,3,4) + if conditionOperator == "IN" { + parts := strings.Split(value, ",") + var values []interface{} + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + // Try to parse as int first + if i, err := strconv.ParseInt(p, 10, 64); err == nil { + values = append(values, i) + } else if f, err := strconv.ParseFloat(p, 64); err == nil { + values = append(values, f) + } else { + values = append(values, p) + } + } + filt = func(d *gorm.DB) *gorm.DB { + return d.Where(column+" IN ?", values) + } + return Filter{ + category: WHERE_FILTER, + filter: filt, + } + } + if strings.Contains(value, "]") && strings.Contains(value, "[") { period := strings.ReplaceAll(value, "[", "") period = strings.ReplaceAll(period, "]", "") diff --git a/app/utils/query/query_params/where_scope_from_query.go b/app/utils/query/query_params/where_scope_from_query.go index be4de92..fe8d992 100644 --- a/app/utils/query/query_params/where_scope_from_query.go +++ b/app/utils/query/query_params/where_scope_from_query.go @@ -46,7 +46,7 @@ func ParseWhereScopes[T any](c fiber.Ctx, ignoredKeys []string, formColumnMappin } func extractOperator(key string) (base string, operatorSuffix string) { - suffixes := []string{"_gt", "_gte", "_lt", "_lte", "_eq", "_neq"} + suffixes := []string{"_gt", "_gte", "_lt", "_lte", "_eq", "_neq", "_in"} for _, suf := range suffixes { if strings.HasSuffix(key, suf) { return strings.TrimSuffix(key, suf), suf[1:] @@ -69,6 +69,8 @@ func resolveOperator(suffix string) string { return "!=" case "eq": return "=" + case "in": + return "IN" default: return "LIKE" }