Skip to content

Commit

Permalink
Release 1.1.4 (#44)
Browse files Browse the repository at this point in the history
T-12 Add product filtering ability:
- Add Filter* models
- Add fetch available filters error
- Add get available filter route and ability
- Add raw sql for available filter with product count
- Add DTOFactory makeFilters func
- Add product filtering ability
  • Loading branch information
mayer1a authored Sep 7, 2024
2 parents 3f9dec7 + 81cd7b0 commit cc2454b
Show file tree
Hide file tree
Showing 9 changed files with 317 additions and 21 deletions.
16 changes: 14 additions & 2 deletions Sources/App/Controllers/Products/ProductsController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,25 @@ struct ProductsController: RouteCollection {

@Sendable func boot(routes: RoutesBuilder) throws {
let products = routes.grouped("api", "v1", "products")
let filters = routes.grouped("api", "v1", "filters")

products.get("", use: getProducts)
products.get(":product_id", use: getProduct)

filters.get("", use: getFilters)
}

@Sendable func getProducts(_ request: Request) async throws -> PaginationResponse<ProductDTO> {
let page = try request.query.decode(PageRequest.self)
let filters = try? request.query.decode(FilterQueryRequest.self)

if let categoryID: UUID = request.query[categoryQuery] {

return try await productsRepository.getProducts(categoryID: categoryID, with: page)
return try await productsRepository.getProducts(categoryID: categoryID, with: page, filters: filters)

} else if let saleID: UUID = request.query[saleQuery] {

return try await productsRepository.getProducts(saleID: saleID, with: page)
return try await productsRepository.getProducts(saleID: saleID, with: page, filters: filters)
}

throw ErrorFactory.badRequest(.categoryIDRequired)
Expand All @@ -45,4 +49,12 @@ struct ProductsController: RouteCollection {
return try await productsRepository.getDTO(for: productID)
}

@Sendable func getFilters(_ request: Request) async throws -> [FilterDTO] {
guard let categoryID: UUID = request.query[categoryQuery] else {
throw ErrorFactory.badRequest(.categoryIDRequired)
}

return try await productsRepository.getFilters(for: categoryID)
}

}
142 changes: 123 additions & 19 deletions Sources/App/Controllers/Products/ProductsRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@

import Vapor
import Fluent
import FluentSQL

protocol ProductsRepositoryProtocol: Sendable {

func getProducts(categoryID: UUID, with page: PageRequest) async throws -> PaginationResponse<ProductDTO>
func getProducts(saleID: UUID, with page: PageRequest) async throws -> PaginationResponse<ProductDTO>
func getProducts(categoryID: UUID,
with page: PageRequest,
filters: FilterQueryRequest?) async throws -> PaginationResponse<ProductDTO>

func getProducts(saleID: UUID,
with page: PageRequest,
filters: FilterQueryRequest?) async throws -> PaginationResponse<ProductDTO>

func get(for productID: UUID) async throws -> Product
func getDTO(for productID: UUID) async throws -> ProductDTO
func getFilters(for categoryID: UUID) async throws -> [FilterDTO]

}

Expand All @@ -25,15 +33,21 @@ final class ProductsRepository: ProductsRepositoryProtocol {
self.database = database
}

func getProducts(categoryID: UUID, with page: PageRequest) async throws -> PaginationResponse<ProductDTO> {
let paginationPoducts = try await eagerLoadRelations(categoryID: categoryID, page)
func getProducts(categoryID: UUID,
with page: PageRequest,
filters: FilterQueryRequest?) async throws -> PaginationResponse<ProductDTO> {

let paginationPoducts = try await eagerLoadRelations(categoryID: categoryID, page, filters: filters)
let productsDTOs = try DTOFactory.makeProducts(from: paginationPoducts.results) ?? []

return PaginationResponse(results: productsDTOs, paginationInfo: paginationPoducts.paginationInfo)
}

func getProducts(saleID: UUID, with page: PageRequest) async throws -> PaginationResponse<ProductDTO> {
let paginationPoducts = try await eagerLoadRelations(saleID: saleID, page)
func getProducts(saleID: UUID,
with page: PageRequest,
filters: FilterQueryRequest?) async throws -> PaginationResponse<ProductDTO> {

let paginationPoducts = try await eagerLoadRelations(saleID: saleID, page, filters: filters)
let productsDTOs = try DTOFactory.makeProducts(from: paginationPoducts.results) ?? []

return PaginationResponse(results: productsDTOs, paginationInfo: paginationPoducts.paginationInfo)
Expand Down Expand Up @@ -65,49 +79,74 @@ final class ProductsRepository: ProductsRepositoryProtocol {
return try DTOFactory.makeProduct(from: product)
}

func getFilters(for categoryID: UUID) async throws -> [FilterDTO] {
guard
let category = try await Category.find(categoryID, on: database),
let categoriesIDs = try? await getCategoriesIDs(for: category)
else {
throw ErrorFactory.internalError(.fetchFiltersForCategoryError, failures: [.ID(categoryID)])
}

let sqlDatabase = database as! SQLDatabase

let filterCountsQuery = try await sqlDatabase.raw(getRawSQL(for: categoriesIDs))
.all(decoding: FilterDBResponse.self)

return try DTOFactory.makeFilters(from: filterCountsQuery)
}

private func eagerLoadRelations(categoryID: UUID,
_ page: PageRequest) async throws -> PaginationResponse<Product> {
_ page: PageRequest,
filters: FilterQueryRequest?) async throws -> PaginationResponse<Product> {

guard
let category = try await Category.find(categoryID, on: database),
let categoriesIDs = try? await getCategoriesIDs(for: category),
let paginationPoducts = try await eagerLoadProducts(saleID: nil, categoriesIDs: categoriesIDs, with: page)
let products = try await eagerLoadProducts(saleID: nil,
categoriesIDs: categoriesIDs,
with: page,
filters: filters)
else {
throw ErrorFactory.internalError(.fetchProductsForCategoryError, failures: [.ID(categoryID)])
}

return paginationPoducts
return products
}

private func eagerLoadRelations(saleID: UUID, _ page: PageRequest) async throws -> PaginationResponse<Product> {
private func eagerLoadRelations(saleID: UUID,
_ page: PageRequest,
filters: FilterQueryRequest?) async throws -> PaginationResponse<Product> {
guard
let saleID = try await Sale.find(saleID, on: database)?.requireID(),
let paginationPoducts = try await eagerLoadProducts(saleID: saleID, categoriesIDs: nil, with: page)
let products = try await eagerLoadProducts(saleID: saleID,
categoriesIDs: nil,
with: page,
filters: filters)
else {
throw ErrorFactory.internalError(.fetchProductsForSaleError, failures: [.ID(saleID)])
}

return paginationPoducts
return products
}

private func eagerLoadProducts(saleID: UUID?,
categoriesIDs: [UUID]?,
with page: PageRequest) async throws -> PaginationResponse<Product>? {
with page: PageRequest,
filters: FilterQueryRequest?) async throws -> PaginationResponse<Product>? {


var productsQuery = Product.query(on: database)
let productsQuery = Product.query(on: database)

if let saleID {
productsQuery = productsQuery.filter(\.$sale.$id == saleID)
productsQuery.filter(\.$sale.$id == saleID)
} else if let categoriesIDs {
productsQuery = productsQuery
productsQuery
.join(siblings: \.$categories)
.filter(Category.self, \.$id ~~ categoriesIDs)
} else {
return nil
}

productsQuery = productsQuery
addFilters(filters, to: productsQuery)
.with(\.$images)
.with(\.$variants) { variant in
variant
Expand All @@ -119,6 +158,48 @@ final class ProductsRepository: ProductsRepositoryProtocol {
return try await productsQuery.paginate(with: page)
}

private func addFilters(_ filters: FilterQueryRequest?, to query: QueryBuilder<Product>) -> QueryBuilder<Product> {
query
.join(ProductVariant.self, on: \Product.$id == \ProductVariant.$product.$id, method: .left)
.join(ProductVariantsPropertyValues.self,
on: \ProductVariant.$id == \ProductVariantsPropertyValues.$productVariant.$id,
method: .left)
.join(PropertyValue.self,
on: \ProductVariantsPropertyValues.$propertyValue.$id == \PropertyValue.$id,
method: .left)
.join(ProductProperty.self, on: \PropertyValue.$productProperty.$id == \ProductProperty.$id, method: .left)

filters?.filters?.forEach { key, values in
query
.filter(ProductProperty.self, \.$code == key)
.filter(PropertyValue.self, \.$value ~~ values)
}

query.unique()

switch filters?.sort {
case "price": query
.join(Price.self, on: \ProductVariant.$id == \Price.$productVariant.$id)
.sort(Price.self, \.$price)

case "-price": query
.join(Price.self, on: \ProductVariant.$id == \Price.$productVariant.$id)
.sort(Price.self, \.$price, .descending)

case "name":
query.sort(\Product.$name)

case "-name":
query.sort(\Product.$name, .descending)

default:
break

}

return query
}

private func getCategoriesIDs(for category: Category) async throws -> [UUID] {
guard category.hasChildren else { return [try category.requireID()] }

Expand All @@ -135,4 +216,27 @@ final class ProductsRepository: ProductsRepositoryProtocol {
return result
}

private func getRawSQL(for categoriesIDs: [UUID]) -> SQLQueryString {
"""
SELECT
P_PROPERTY.CODE AS PROPERTY_CODE,
P_PROPERTY.NAME AS PROPERTY_NAME,
P_VALUE.VALUE AS PROPERTY_VALUE,
COUNT(DISTINCT P.ID) AS COUNT
FROM
PROPERTY_VALUES P_VALUE
JOIN "product_variants+property_values" PVPV ON P_VALUE.ID = PVPV.PROPERTY_VALUE_ID
JOIN PRODUCT_VARIANTS PV ON PVPV.PRODUCT_VARIANT_ID = PV.ID
JOIN PRODUCTS P ON PV.PRODUCT_ID = P.ID
JOIN "categories+products" CPP ON P.ID = CPP.PRODUCT_ID
JOIN PRODUCT_PROPERTIES P_PROPERTY ON P_VALUE.PRODUCT_PROPERTY_ID = P_PROPERTY.ID
WHERE
CPP.CATEGORY_ID IN (\(unsafeRaw: categoriesIDs.map { "'\($0.uuidString)'" }.joined(separator: ",")))
GROUP BY
P_PROPERTY.CODE,
P_PROPERTY.NAME,
P_VALUE.VALUE
"""
}

}
38 changes: 38 additions & 0 deletions Sources/App/Helpers/DTOFactory/DTOFactory+Products.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,42 @@ extension DTOFactory {
}
}

// MARK: - Filters

static func makeFilters(from rawFilters: [FilterDBResponse]) throws -> [FilterDTO] {

var filtersDict: [String: [FilterDBResponse]] = [:]

rawFilters.forEach { fc in
if filtersDict[fc.propertyCode] == nil {
filtersDict[fc.propertyCode] = []
}
if !filtersDict[fc.propertyCode]!.contains(where: { $0.propertyValue == fc.propertyValue }) {
filtersDict[fc.propertyCode]!.append(fc)
}
}
var filters = filtersDict
.compactMap { rawFilter -> FilterDTO? in
if let propertyName = rawFilter.value.first?.propertyName {
return .init(name: rawFilter.key,
displayName: propertyName,
values: makeFilterValues(from: rawFilter.value))
} else {
return nil
}
}
.sorted(by: { $0.displayName < $1.displayName })

filters.insert(FilterDTO.getSortFilter, at: 0)

return filters
}

private static func makeFilterValues(from filters: [FilterDBResponse]) -> [FilterValueDTO] {
filters.map { filter in
FilterValueDTO(value: filter.propertyValue, displayName: filter.propertyValue, count: filter.count)
}
.sorted(by: { $0.displayName < $1.displayName })
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum InternalServerError: String {
case fetchProductsForCategoryError = "error.fetchProductsForCategoryError"
case fetchProductsForSaleError = "error.fetchProductsForSaleError"
case fetchProductByIdError = "error.fetchProductByIdError"
case fetchFiltersForCategoryError = "error.fetchFiltersForCategoryError"
case bannerCategoriesError = "error.bannerCategoriesError"
case bannerProductsPriceError = "error.bannerProductsPriceError"
case bannerProductsAvailabilityError = "error.bannerProductsAvailabilityError"
Expand Down Expand Up @@ -55,6 +56,9 @@ enum InternalServerError: String {
case .fetchProductByIdError:
return "Error fetching product with ID."

case .fetchFiltersForCategoryError:
return "Error fetching filters for requested category ID."

case .bannerCategoriesError:
return "Categories Banner error."

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// FilterDBResponse.swift
//
//
// Created by Artem Mayer on 07.09.2024.
//

import Vapor

struct FilterDBResponse: Content {

let propertyCode: String
let propertyName: String
let propertyValue: String
var count: Int

enum CodingKeys: String, CodingKey {
case propertyCode = "property_code"
case propertyName = "property_name"
case propertyValue = "property_value"
case count
}

}
46 changes: 46 additions & 0 deletions Sources/App/Models/NetworkModel/Product/Filter/FilterDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// FilterDTO.swift
//
//
// Created by Artem Mayer on 07.09.2024.
//

import Vapor

struct FilterDTO: Content {

let name: String
let displayName: String
let type: FilterType
let values: [FilterValueDTO]
let defaultValue: String?

enum CodingKeys: String, CodingKey {
case name
case displayName = "display_name"
case type, values
case defaultValue = "default_value"
}

init(name: String = "sort",
displayName: String = "Сортировка",
type: FilterType = .multiple,
values: [FilterValueDTO],
defaultValue: String? = nil) {

self.name = name
self.displayName = displayName
self.type = type
self.values = values
self.defaultValue = defaultValue
}

}

extension FilterDTO {

static var getSortFilter: FilterDTO {
FilterDTO(type: .single, values: FilterValueDTO.getSortValues, defaultValue: "index")
}

}
Loading

0 comments on commit cc2454b

Please sign in to comment.