filters
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.0.3",
|
"openapi": "3.0.3",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "b2b API",
|
"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",
|
"version": "1.0.0",
|
||||||
"contact": {
|
"contact": {
|
||||||
"name": "API Support",
|
"name": "API Support",
|
||||||
@@ -1043,7 +1043,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"tags": ["Products"],
|
"tags": ["Products"],
|
||||||
"summary": "Get product listing",
|
"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",
|
"operationId": "getProductListing",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
@@ -1064,12 +1064,77 @@
|
|||||||
{
|
{
|
||||||
"name": "elems",
|
"name": "elems",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "Number of items per page",
|
"description": "Number of items per page (max: 100, default: 30)",
|
||||||
"required": false,
|
"required": false,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"default": 30
|
"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": {
|
"responses": {
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package restricted
|
package restricted
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.ma-al.com/goc_daniel/b2b/app/config"
|
"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/service/listProductsService"
|
||||||
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n"
|
"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/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/query/query_params"
|
||||||
"git.ma-al.com/goc_daniel/b2b/app/utils/response"
|
"git.ma-al.com/goc_daniel/b2b/app/utils/response"
|
||||||
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors"
|
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors"
|
||||||
@@ -38,66 +35,8 @@ func ListProductsHandlerRoutes(r fiber.Router) fiber.Router {
|
|||||||
return r
|
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 {
|
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 {
|
if err != nil {
|
||||||
return c.Status(responseErrors.GetErrorStatus(err)).
|
return c.Status(responseErrors.GetErrorStatus(err)).
|
||||||
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, 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)))
|
return c.JSON(response.Make(&listing.Items, int(listing.Count), i18n.T_(c, response.Message_OK)))
|
||||||
}
|
}
|
||||||
|
|
||||||
var columnMapping map[string]string = map[string]string{}
|
var columnMappingGetListing map[string]string = map[string]string{
|
||||||
|
"product_id": "ps.id_product",
|
||||||
func ParseProductFilters(c fiber.Ctx) (find.Paging, *filters.FiltersList, error) {
|
"name": "pl.name",
|
||||||
var p find.Paging
|
"reference": "ps.reference",
|
||||||
fl := filters.NewFiltersList()
|
"category_name": "cl.name",
|
||||||
|
"id_category": "cp.id_category",
|
||||||
fmt.Printf("fl: %v\n", fl.All())
|
"quantity": "sa.quantity",
|
||||||
|
|
||||||
pageNum, pageElems := query_params.ParsePagination(c)
|
|
||||||
p = find.Paging{Page: pageNum, Elements: pageElems}
|
|
||||||
|
|
||||||
return p, &fl, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,11 +62,14 @@ type Product struct {
|
|||||||
DeliveryDays uint `gorm:"column:delivery_days" json:"delivery_days" form:"delivery_days"`
|
DeliveryDays uint `gorm:"column:delivery_days" json:"delivery_days" form:"delivery_days"`
|
||||||
}
|
}
|
||||||
type ProductInList struct {
|
type ProductInList struct {
|
||||||
ProductID uint `gorm:"column:product_id;primaryKey" json:"product_id" form:"product_id"`
|
ProductID uint `gorm:"column:product_id" json:"product_id" form:"product_id"`
|
||||||
Name string `gorm:"column:name" json:"name" form:"name"`
|
Name string `gorm:"column:name" json:"name" form:"name"`
|
||||||
ImageID string `gorm:"column:id_image"`
|
LinkRewrite string `gorm:"column:link_rewrite" json:"link_rewrite"`
|
||||||
LinkRewrite string `gorm:"column:link_rewrite"`
|
ImageLink string `gorm:"column:image_link" json:"image_link"`
|
||||||
Active uint `gorm:"column:active" json:"active" form:"active"`
|
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 {
|
type ProductFilters struct {
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package listProductsRepo
|
package listProductsRepo
|
||||||
|
|
||||||
import (
|
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/db"
|
||||||
"git.ma-al.com/goc_daniel/b2b/app/model"
|
"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/filters"
|
||||||
"git.ma-al.com/goc_daniel/b2b/app/utils/query/find"
|
"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 listing []model.ProductInList
|
||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
subQuery := db.DB.
|
query := db.Get().
|
||||||
Table("ps_image").
|
Table("ps_product_shop AS ps").
|
||||||
Select("id_product, MIN(id_image) AS id_image").
|
|
||||||
Group("id_product")
|
|
||||||
|
|
||||||
err := db.DB.
|
|
||||||
Table("ps_product").
|
|
||||||
Select(`
|
Select(`
|
||||||
ps_product.id_product AS product_id,
|
ps.id_product AS product_id,
|
||||||
ps_product_lang.name AS name,
|
pl.name AS name,
|
||||||
ps_product.active AS active,
|
pl.link_rewrite AS link_rewrite,
|
||||||
ps_product_lang.link_rewrite AS link_rewrite,
|
CONCAT('https://www.naluconcept.com', '/', ims.id_image, '-small_default/', pl.link_rewrite, '.webp') AS image_link,
|
||||||
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
|
cl.name AS category_name,
|
||||||
`, config.Get().Image.ImagePrefix, config.Get().Image.ImagePrefix).
|
p.reference AS reference,
|
||||||
Joins(`
|
COUNT(DISTINCT pas.id_product_attribute) AS variants_number,
|
||||||
LEFT JOIN ps_product_lang
|
sa.quantity AS quantity
|
||||||
ON ps_product_lang.id_product = ps_product.id_product
|
`).
|
||||||
AND ps_product_lang.id_shop = ?
|
Joins("JOIN ps_product p ON p.id_product = ps.id_product").
|
||||||
AND ps_product_lang.id_lang = ?
|
Joins("JOIN ps_category_product cp ON ps.id_product = cp.id_product").
|
||||||
`, constdata.SHOP_ID, id_lang).
|
Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", id_lang).
|
||||||
Joins(`
|
Joins("JOIN ps_image_shop ims ON ims.id_product = ps.id_product AND ims.cover = 1").
|
||||||
LEFT JOIN ps_image_shop
|
Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", id_lang).
|
||||||
ON ps_image_shop.id_product = ps_product.id_product
|
Joins("LEFT JOIN ps_product_attribute_shop pas ON pas.id_product = cp.id_product").
|
||||||
AND ps_image_shop.id_shop = ?
|
Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product").
|
||||||
AND ps_image_shop.cover = 1
|
Where("ps.active = ?", 1).
|
||||||
`, constdata.SHOP_ID).
|
Group("cp.id_product")
|
||||||
Joins("LEFT JOIN (?) AS any_image ON ps_product.id_product = any_image.id_product", subQuery).
|
|
||||||
Limit(p.Limit()).
|
// Apply all filters
|
||||||
Offset(p.Offset()).
|
if filt != nil {
|
||||||
Scan(&listing).Error
|
filt.ApplyAll(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// run counter first as query is without limit and offset
|
||||||
|
err := query.Count(&total).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return find.Found[model.ProductInList]{}, err
|
return find.Found[model.ProductInList]{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = db.DB.
|
err = query.
|
||||||
Table("ps_product").
|
Order("ps.id_product DESC").
|
||||||
Count(&total).Error
|
Limit(p.Limit()).
|
||||||
|
Offset(p.Offset()).
|
||||||
|
Scan(&listing).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return find.Found[model.ProductInList]{}, err
|
return find.Found[model.ProductInList]{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ func WhereFromStrings(column, conditionOperator, value string) Filter {
|
|||||||
value = strings.ReplaceAll(value, "~", "")
|
value = strings.ReplaceAll(value, "~", "")
|
||||||
|
|
||||||
filt = func(d *gorm.DB) *gorm.DB {
|
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, "[") {
|
if strings.Contains(value, "]") && strings.Contains(value, "[") {
|
||||||
period := strings.ReplaceAll(value, "[", "")
|
period := strings.ReplaceAll(value, "[", "")
|
||||||
period = strings.ReplaceAll(period, "]", "")
|
period = strings.ReplaceAll(period, "]", "")
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func ParseWhereScopes[T any](c fiber.Ctx, ignoredKeys []string, formColumnMappin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractOperator(key string) (base string, operatorSuffix string) {
|
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 {
|
for _, suf := range suffixes {
|
||||||
if strings.HasSuffix(key, suf) {
|
if strings.HasSuffix(key, suf) {
|
||||||
return strings.TrimSuffix(key, suf), suf[1:]
|
return strings.TrimSuffix(key, suf), suf[1:]
|
||||||
@@ -69,6 +69,8 @@ func resolveOperator(suffix string) string {
|
|||||||
return "!="
|
return "!="
|
||||||
case "eq":
|
case "eq":
|
||||||
return "="
|
return "="
|
||||||
|
case "in":
|
||||||
|
return "IN"
|
||||||
default:
|
default:
|
||||||
return "LIKE"
|
return "LIKE"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user