From 65235d86d052de31f85bd4146cc7df8a1a18ba0c Mon Sep 17 00:00:00 2001 From: Artem Mayer Date: Tue, 10 Sep 2024 15:11:57 +0700 Subject: [PATCH 1/2] T-16 Add FilteredProductsBuilder for build raw sql Add FiltersSQLRawFactory for build raw sql Add Sort enum model --- .../Products/FilteredProductsBuilder.swift | 73 +++++++++++++ .../Products/FiltersSQLRawFactory.swift | 102 ++++++++++++++++++ .../NetworkModel/Product/Filter/Sort.swift | 49 +++++++++ 3 files changed, 224 insertions(+) create mode 100644 Sources/App/Controllers/Products/FilteredProductsBuilder.swift create mode 100644 Sources/App/Controllers/Products/FiltersSQLRawFactory.swift create mode 100644 Sources/App/Models/NetworkModel/Product/Filter/Sort.swift diff --git a/Sources/App/Controllers/Products/FilteredProductsBuilder.swift b/Sources/App/Controllers/Products/FilteredProductsBuilder.swift new file mode 100644 index 0000000..4485607 --- /dev/null +++ b/Sources/App/Controllers/Products/FilteredProductsBuilder.swift @@ -0,0 +1,73 @@ +// +// FilteredProductsBuilder.swift +// +// +// Created by Artem Mayer on 10.09.2024. +// + +import Foundation +import FluentSQL + +final class FilteredProductsBuilder { + + private var categoriesWhereCondition: String = "" + private var filtersWhereCondition: String = "" + private var filtersHavingCondition: SQLQueryString = "" + + @discardableResult + func setCategoriesWhereCondition(for categoriesIDs: [UUID]?) -> FilteredProductsBuilder { + guard let categoriesIDs else { return self } + let categoriesList = categoriesIDs.map({ "'\($0)'" }).joined(separator: ",") + categoriesWhereCondition = "CP.CATEGORY_ID IN (\(categoriesList))" + return self + } + + @discardableResult + func setFiltersWhereCondition(for filters: [String: [String]]) -> FilteredProductsBuilder { + let whereCondition = filters.reduce("") { partialResult, pair in + guard !pair.value.isEmpty else { return partialResult } + + let values = pair.value.map({ "'\($0)'" }).joined(separator: ",") + if partialResult.isEmpty { + return "(P_PROPERTY.CODE = '\(pair.key)' AND P_VALUE.VALUE IN (\(values))" + } else { + return partialResult + "\n\tOR P_PROPERTY.CODE = '\(pair.key)' AND P_VALUE.VALUE IN (\(values))" + } + } + + if !whereCondition.isEmpty { + filtersWhereCondition = "\(whereCondition)\n)" + filtersHavingCondition = "HAVING COUNT(DISTINCT P_VALUE.VALUE) = \(bind: filters.count)" + } + + return self + } + + func build() -> SQLQueryString? { + let whereCondition: SQLQueryString + + if !categoriesWhereCondition.isEmpty, !filtersWhereCondition.isEmpty { + whereCondition = "\(unsafeRaw: categoriesWhereCondition)\n\tAND \(unsafeRaw: filtersWhereCondition)" + } else if !filtersWhereCondition.isEmpty { + whereCondition = "\(unsafeRaw: filtersWhereCondition)" + } else { + return nil + } + + return """ + SELECT DISTINCT P.* + FROM + PRODUCTS P + JOIN PRODUCT_VARIANTS PV ON P.ID = PV.PRODUCT_ID + JOIN "product_variants+property_values" PVPV ON PV.ID = PVPV.PRODUCT_VARIANT_ID + JOIN PROPERTY_VALUES P_VALUE ON PVPV.PROPERTY_VALUE_ID = P_VALUE.ID + JOIN PRODUCT_PROPERTIES P_PROPERTY ON P_VALUE.PRODUCT_PROPERTY_ID = P_PROPERTY.ID + JOIN "categories+products" CP ON CP.PRODUCT_ID = P.ID + WHERE + \(whereCondition) + GROUP BY P.ID + \(filtersHavingCondition) + """ + } + +} diff --git a/Sources/App/Controllers/Products/FiltersSQLRawFactory.swift b/Sources/App/Controllers/Products/FiltersSQLRawFactory.swift new file mode 100644 index 0000000..2473cc4 --- /dev/null +++ b/Sources/App/Controllers/Products/FiltersSQLRawFactory.swift @@ -0,0 +1,102 @@ +// +// FiltersSQLRawFactory.swift +// +// +// Created by Artem Mayer on 10.09.2024. +// + +import Foundation +import FluentSQL + +final class FiltersSQLRawFactory { + + private init() {} + + static func makeAvailableFiltersSQL(for categoriesIDs: [UUID], filters: [String: [String]]) -> SQLQueryString { + """ + \(getFilteredProducts(for: categoriesIDs, filters: filters)) + \(getAvailableFilters(for: Array(filters.keys))) + """ + } + + private static func getFilteredProducts(for categoriesIDs: [UUID], filters: [String: [String]]) -> SQLQueryString { + let codeValueFiltersSQL: SQLQueryString = filters.map { + let values = $0.value.map({ "'\($0)'" }).joined(separator: ",") + return "(P_PROPERTY.CODE='\(unsafeRaw: $0.key)' AND P_VALUE.VALUE IN (\(unsafeRaw: values)))" + }.joined(separator: "\n\t\t\tOR ") + + return """ + WITH FILTERED_PRODUCTS AS ( + SELECT DISTINCT P.ID + FROM + PRODUCTS P + JOIN PRODUCT_VARIANTS PV ON P.ID = PV.PRODUCT_ID + JOIN "product_variants+property_values" PVPV ON PV.ID = PVPV.PRODUCT_VARIANT_ID + JOIN PROPERTY_VALUES P_VALUE ON PVPV.PROPERTY_VALUE_ID = P_VALUE.ID + JOIN PRODUCT_PROPERTIES P_PROPERTY ON P_VALUE.PRODUCT_PROPERTY_ID = P_PROPERTY.ID + JOIN "categories+products" CP ON CP.PRODUCT_ID = P.ID + WHERE + \(getWhereCategoriesCondition(for: categoriesIDs)) + AND ( + \(codeValueFiltersSQL) + ) + GROUP BY P.ID + HAVING COUNT(DISTINCT P_VALUE.VALUE) >= \(bind: filters.count - 1) + ) + """ + } + + private static func getAvailableFilters(for filtersCodes: [String]) -> SQLQueryString { + var selectedFiltersCodes: [String] = [] + + var resultSQL = filtersCodes.map { key -> String in + selectedFiltersCodes.append(key) + return getSelectFilterTypeSQL(for: key, excludeKeys: nil) + }.joined(separator: "\nUNION ALL\n") + + resultSQL += "\nUNION ALL\n\(getSelectFilterTypeSQL(for: nil, excludeKeys: selectedFiltersCodes))" + + return "\(unsafeRaw: resultSQL)" + } + + /// - WARNING: Either `key` or `excludeKeys` must be not nil + private static func getSelectFilterTypeSQL(for key: String?, excludeKeys: [String]?) -> String { + let whereCondition: String + + if let key { + whereCondition = "WHERE P_PROPERTY.CODE = '\(key)'" + } else if let excludeKeys { + whereCondition = excludeKeys.reduce("") { initialResult, selectedKey -> String in + if initialResult.isEmpty { + return "WHERE P_PROPERTY.CODE != '\(selectedKey)'" + } else { + return initialResult + " AND P_PROPERTY.CODE != '\(selectedKey)'" + } + } + } else { + fatalError("keys or excludeKeys must be not nil!") + } + + return """ + 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 + PRODUCTS P + JOIN PRODUCT_VARIANTS PV ON P.ID = PV.PRODUCT_ID + JOIN "product_variants+property_values" PVPV ON PV.ID = PVPV.PRODUCT_VARIANT_ID + JOIN PROPERTY_VALUES P_VALUE ON PVPV.PROPERTY_VALUE_ID = P_VALUE.ID + JOIN PRODUCT_PROPERTIES P_PROPERTY ON P_VALUE.PRODUCT_PROPERTY_ID = P_PROPERTY.ID + JOIN FILTERED_PRODUCTS FP ON P.ID = FP.ID + \(whereCondition) + GROUP BY P_PROPERTY.CODE, P_PROPERTY.NAME, P_VALUE.VALUE + """ + } + + private static func getWhereCategoriesCondition(for categoriesIDs: [UUID]) -> SQLQueryString { + "CP.CATEGORY_ID IN (\(unsafeRaw: categoriesIDs.map { "'\($0.uuidString)'" }.joined(separator: ",")))" + } + +} diff --git a/Sources/App/Models/NetworkModel/Product/Filter/Sort.swift b/Sources/App/Models/NetworkModel/Product/Filter/Sort.swift new file mode 100644 index 0000000..bff2016 --- /dev/null +++ b/Sources/App/Models/NetworkModel/Product/Filter/Sort.swift @@ -0,0 +1,49 @@ +// +// Sort.swift +// +// +// Created by Artem Mayer on 10.09.2024. +// + +import Vapor +import FluentSQL + +extension FilterQueryRequest { + + enum Sort: String, Content { + + case priceAsc = "price" + case priceDesc = "-price" + case nameAsc = "name" + case nameDesc = "-name" + case index + + var direction: DatabaseQuery.Sort.Direction { + switch self { + case .priceAsc: return .ascending + case .priceDesc: return .descending + case .nameAsc: return .ascending + case .nameDesc: return .descending + case .index: return .ascending + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try? container.decode(String.self) + self = Sort(rawValue: value) + } + + init(rawValue: String?) { + switch rawValue { + case Sort.priceAsc.rawValue: self = .priceAsc + case Sort.priceDesc.rawValue: self = .priceDesc + case Sort.nameAsc.rawValue: self = .nameAsc + case Sort.nameDesc.rawValue: self = .nameDesc + default: self = .index + } + } + + } + +} From 3b5d586c389dc9c2cb6493d3c82744d0627d8c9a Mon Sep 17 00:00:00 2001 From: Artem Mayer Date: Tue, 10 Sep 2024 15:13:02 +0700 Subject: [PATCH 2/2] T-16 Fix filtered products behavior Fix available filters behavior Add Sort typealias --- .../Products/ProductsRepository.swift | 193 ++++++++---------- Sources/App/Helpers/typealiases.swift | 2 + .../Product/Filter/FilterQueryResponse.swift | 9 +- 3 files changed, 91 insertions(+), 113 deletions(-) diff --git a/Sources/App/Controllers/Products/ProductsRepository.swift b/Sources/App/Controllers/Products/ProductsRepository.swift index 7086b46..d2241cf 100644 --- a/Sources/App/Controllers/Products/ProductsRepository.swift +++ b/Sources/App/Controllers/Products/ProductsRepository.swift @@ -47,7 +47,7 @@ final class ProductsRepository: ProductsRepositoryProtocol { with page: PageRequest, filters: FilterQueryRequest?) async throws -> PaginationResponse { - let paginationPoducts = try await eagerLoadRelations(saleID: saleID, page, filters: filters) + let paginationPoducts = try await eagerLoadRelations(saleID: saleID, page) let productsDTOs = try DTOFactory.makeProducts(from: paginationPoducts.results) ?? [] return PaginationResponse(results: productsDTOs, paginationInfo: paginationPoducts.paginationInfo) @@ -87,33 +87,24 @@ final class ProductsRepository: ProductsRepositoryProtocol { throw ErrorFactory.internalError(.fetchFiltersForCategoryError, failures: [.ID(categoryID)]) } - let sqlDatabase = database as! SQLDatabase - - let productsIDs: [UUID]? - if let filters { - let productsQuery = getFilteredLoadedProducts(categoriesIDs: categoriesIDs, filters: filters, full: false) - productsIDs = try await productsQuery?.all().map({ try $0.requireID() }) - } else { - productsIDs = nil + guard let filters = filters?.filters, let sqlDatabase = database as? SQLDatabase else { + return [] } - let filterCountsQuery = try await sqlDatabase.raw(getRawSQL(for: categoriesIDs, productsIDs: productsIDs)) + let availableFiltersRawSQL = FiltersSQLRawFactory.makeAvailableFiltersSQL(for: categoriesIDs, filters: filters) + let filterCountsQuery = try await sqlDatabase.raw(availableFiltersRawSQL) .all(decoding: FilterDBResponse.self) return try DTOFactory.makeFilters(from: filterCountsQuery) } - private func eagerLoadRelations(categoryID: UUID, + private func eagerLoadRelations(categoryID: UUID, _ page: PageRequest, filters: FilterQueryRequest?) async throws -> PaginationResponse { - - guard + guard let category = try await Category.find(categoryID, on: database), let categoriesIDs = try? await getCategoriesIDs(for: category), - let products = try await eagerLoadProducts(saleID: nil, - categoriesIDs: categoriesIDs, - with: page, - filters: filters) + let products = try await eagerLoadProducts(categoriesIDs: categoriesIDs, filters: filters, with: page) else { throw ErrorFactory.internalError(.fetchProductsForCategoryError, failures: [.ID(categoryID)]) } @@ -121,15 +112,10 @@ final class ProductsRepository: ProductsRepositoryProtocol { return products } - private func eagerLoadRelations(saleID: UUID, - _ page: PageRequest, - filters: FilterQueryRequest?) async throws -> PaginationResponse { + private func eagerLoadRelations(saleID: UUID, _ page: PageRequest) async throws -> PaginationResponse { guard let saleID = try await Sale.find(saleID, on: database)?.requireID(), - let products = try await eagerLoadProducts(saleID: saleID, - categoriesIDs: nil, - with: page, - filters: filters) + let products = try await eagerLoadProducts(saleID: saleID, with: page) else { throw ErrorFactory.internalError(.fetchProductsForSaleError, failures: [.ID(saleID)]) } @@ -137,24 +123,58 @@ final class ProductsRepository: ProductsRepositoryProtocol { return products } - private func eagerLoadProducts(saleID: UUID?, - categoriesIDs: [UUID]?, - with page: PageRequest, - filters: FilterQueryRequest?) async throws -> PaginationResponse? { + private func eagerLoadProducts(saleID: UUID? = nil, + categoriesIDs: [UUID]? = nil, + filters: FilterQueryRequest? = nil, + with page: PageRequest) async throws -> PaginationResponse? { + + var query: QueryBuilder? + if let filters { + let productsIDs = try await getFilteredProducts(for: categoriesIDs, filters: filters) + query = getFilteredProductsQuery(saleID: saleID, categoriesIDs: categoriesIDs, productsIDs: productsIDs) + + if let sort = filters.sort { + query = addSort(sort, to: query) + } + } else { + query = getFilteredProductsQuery(saleID: saleID, categoriesIDs: categoriesIDs, productsIDs: nil) + } + + return try await query?.paginate(with: page) + } + + private func addSort(_ sort: FiltersSort, to query: QueryBuilder?) -> QueryBuilder? { + switch sort { + case .priceAsc, .priceDesc: + query? + .join(ProductVariant.self, + on: \Product.$id == \ProductVariant.$product.$id + && \ProductVariant.$article == \Product.$defaultVariantArticle) + .join(Price.self, on: \ProductVariant.$id == \Price.$productVariant.$id) + .sort(Price.self, \.$price, sort.direction) + + case .nameAsc, .nameDesc: + query?.sort(\Product.$name, sort.direction) + + case .index: + break + + } - let productsQuery = getFilteredLoadedProducts(saleID: saleID, categoriesIDs: categoriesIDs, filters: filters) - return try await productsQuery?.paginate(with: page) + return query } - private func getFilteredLoadedProducts(saleID: UUID? = nil, - categoriesIDs: [UUID]?, - filters: FilterQueryRequest?, - full: Bool = true) -> QueryBuilder? { + private func getFilteredProductsQuery(saleID: UUID? = nil, + categoriesIDs: [UUID]?, + productsIDs: [UUID]?) -> QueryBuilder? { let productsQuery = Product.query(on: database) if let saleID { productsQuery.filter(\.$sale.$id == saleID) + } else if let productsIDs { + productsQuery + .filter(\.$id ~~ productsIDs) } else if let categoriesIDs { productsQuery .join(siblings: \.$categories) @@ -163,58 +183,14 @@ final class ProductsRepository: ProductsRepositoryProtocol { return nil } - if full { - return addFilters(filters, to: productsQuery) - .with(\.$images) - .with(\.$variants) { variant in - variant - .with(\.$price) - .with(\.$availabilityInfo) - .with(\.$badges) - } - } else { - return addFilters(filters, to: productsQuery) - } - } - - private func addFilters(_ filters: FilterQueryRequest?, to query: QueryBuilder) -> QueryBuilder { - query - .join(ProductVariant.self, - on: \Product.$id == \ProductVariant.$product.$id - && \ProductVariant.$article == \Product.$defaultVariantArticle) - .join(ProductVariantsPropertyValues.self, - on: \ProductVariant.$id == \ProductVariantsPropertyValues.$productVariant.$id) - .join(PropertyValue.self, - on: \ProductVariantsPropertyValues.$propertyValue.$id == \PropertyValue.$id) - .join(ProductProperty.self, on: \PropertyValue.$productProperty.$id == \ProductProperty.$id) - - filters?.filters?.forEach { key, values in - query - .filter(ProductProperty.self, \.$code == key) - .filter(PropertyValue.self, \.$value ~~ values) - } - - switch filters?.sort { - case "price": query - .join(Price.self, on: \ProductVariant.$id == \Price.$productVariant.$id) - .sort(Price.self, \.$price, .ascending) - - 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 + return productsQuery + .with(\.$images) + .with(\.$variants) { variant in + variant + .with(\.$price) + .with(\.$availabilityInfo) + .with(\.$badges) + } } private func getCategoriesIDs(for category: Category) async throws -> [UUID] { @@ -233,31 +209,26 @@ final class ProductsRepository: ProductsRepositoryProtocol { return result } - private func getRawSQL(for categoriesIDs: [UUID], productsIDs: [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 - \(unsafeRaw: productsIDs == nil || productsIDs?.isEmpty == true - ? "" - : "P.ID IN (\(productsIDs!.map { "'\($0.uuidString)'" }.joined(separator: ","))) AND") - CPP.CATEGORY_ID IN (\(unsafeRaw: categoriesIDs.map { "'\($0.uuidString)'" }.joined(separator: ","))) - GROUP BY - P_PROPERTY.CODE, - P_PROPERTY.NAME, - P_VALUE.VALUE - """ + private func getFilteredProducts(for categoriesIDs: [UUID]?, filters: FilterQueryRequest?) async throws -> [UUID]? { + guard filters?.filters != nil || filters?.sort != nil else { return nil } + + let rawBuilder = FilteredProductsBuilder() + + if let categoriesIDs { + rawBuilder.setCategoriesWhereCondition(for: categoriesIDs) + } + + if let filters = filters?.filters { + rawBuilder.setFiltersWhereCondition(for: filters) + } + + guard let rawSQL = rawBuilder.build(), let sqlDatabase = database as? SQLDatabase else { + return nil + } + + return try? await sqlDatabase.raw(rawSQL) + .all(decodingFluent: Product.self) + .map({ try $0.requireID() }) } } - diff --git a/Sources/App/Helpers/typealiases.swift b/Sources/App/Helpers/typealiases.swift index cc799c4..0fa9be3 100644 --- a/Sources/App/Helpers/typealiases.swift +++ b/Sources/App/Helpers/typealiases.swift @@ -76,3 +76,5 @@ typealias SuggestionsType = DaDataResponse.SuggestionsType typealias CartRequestItem = CartRequest.CartDTO.Item typealias Authentication = User.Authentication + +typealias FiltersSort = FilterQueryRequest.Sort diff --git a/Sources/App/Models/NetworkModel/Product/Filter/FilterQueryResponse.swift b/Sources/App/Models/NetworkModel/Product/Filter/FilterQueryResponse.swift index 061d371..57b7db1 100644 --- a/Sources/App/Models/NetworkModel/Product/Filter/FilterQueryResponse.swift +++ b/Sources/App/Models/NetworkModel/Product/Filter/FilterQueryResponse.swift @@ -10,11 +10,11 @@ import Vapor struct FilterQueryRequest: Content { let filters: [String: [String]]? - let sort: String? + let sort: Sort? init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.sort = try container.decodeIfPresent(String.self, forKey: .sort) + self.sort = try container.decodeIfPresent(Sort.self, forKey: .sort) var filters: [String: [String]] = [:] if let filtersKey = container.allKeys.first(where: { $0.stringValue == "filters" }) { @@ -27,4 +27,9 @@ struct FilterQueryRequest: Content { self.filters = filters } + init(filters: [String: [String]]? = nil, sort: Sort? = nil) { + self.filters = filters + self.sort = sort + } + }