diff --git a/.circleci/config.yml b/.circleci/config.yml index 6f98693..0008293 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,13 +1,28 @@ -# Use the latest 2.1 version of CircleCI pipeline process engine. See: https://circleci.com/docs/2.0/configuration-reference version: 2.1 -# Use a package of configuration called an orb. -orbs: - # Declare a dependency on the welcome-orb - welcome: circleci/welcome-orb@0.4.1 -# Orchestrate or schedule a set of jobs + +jobs: + test: + macos: + xcode: 13.2.1 # Using 13.2.1 to support backwards compatibility of Modern Concurrency + steps: + - checkout + - run: + name: Install Typesense + command: | + curl --output ts.tar.gz https://dl.typesense.org/releases/0.22.2/typesense-server-0.22.2-darwin-amd64.tar.gz + tar -xzf ts.tar.gz + - run: + name: Run Typesense + background: true + command: | + ./typesense-server --api-key=xyz --data-dir=/tmp + - run: + # Build and Test the Typesense Package + name: Run Tests + command: swift test + workflows: - # Name the workflow "welcome" - welcome: - # Run the welcome/run job in its own container + version: 2 + test_build: jobs: - - welcome/run + - test diff --git a/.gitignore b/.gitignore index bb460e7..e284f64 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ /*.xcodeproj xcuserdata/ DerivedData/ -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.swiftpm diff --git a/Package.swift b/Package.swift index fdf39bd..18ba827 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "Typesense", platforms: [ - .iOS(.v13), .macOS(.v12) + .iOS(.v13), .macOS(.v10_15) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/README.md b/README.md index 67641ea..e9aa887 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,5 @@ The generated Models (inside the Models directory) are to be used inside the Mod ## TODO: Features - Curation API -- Multisearch - Dealing with Dirty Data - Scoped Search Key diff --git a/Sources/Typesense/Client.swift b/Sources/Typesense/Client.swift index c09f056..f19e345 100644 --- a/Sources/Typesense/Client.swift +++ b/Sources/Typesense/Client.swift @@ -27,4 +27,8 @@ public struct Client { public func operations() -> Operations { return Operations(config: self.configuration) } + + public func multiSearch() -> MultiSearch { + return MultiSearch(config: self.configuration) + } } diff --git a/Sources/Typesense/MultiSearch.swift b/Sources/Typesense/MultiSearch.swift new file mode 100644 index 0000000..18558f2 --- /dev/null +++ b/Sources/Typesense/MultiSearch.swift @@ -0,0 +1,177 @@ +import Foundation + +public struct MultiSearch { + var apiCall: ApiCall + let RESOURCEPATH = "multi_search" + + public init(config: Configuration) { + apiCall = ApiCall(config: config) + } + + public func perform(searchRequests: [MultiSearchCollectionParameters], commonParameters: MultiSearchParameters, for: T.Type) async throws -> (MultiSearchResult?, URLResponse?) { + var searchQueryParams: [URLQueryItem] = [] + + if let query = commonParameters.q { + searchQueryParams.append(URLQueryItem(name: "q", value: query)) + } + + if let queryBy = commonParameters.queryBy { + searchQueryParams.append(URLQueryItem(name: "query_by", value: queryBy)) + } + + if let queryByWeights = commonParameters.queryByWeights { + searchQueryParams.append(URLQueryItem(name: "query_by_weights", value: queryByWeights)) + } + + if let maxHits = commonParameters.maxHits { + searchQueryParams.append(URLQueryItem(name: "max_hits", value: maxHits)) + } + + if let _prefix = commonParameters._prefix { + var fullString = "" + for item in _prefix { + fullString.append(String(item)) + fullString.append(",") + } + + searchQueryParams.append(URLQueryItem(name: "prefix", value: String(fullString.dropLast()))) + } + + if let filterBy = commonParameters.filterBy { + searchQueryParams.append(URLQueryItem(name: "filter_by", value: filterBy)) + } + + if let sortBy = commonParameters.sortBy { + searchQueryParams.append(URLQueryItem(name: "sort_by", value: sortBy)) + } + + if let facetBy = commonParameters.facetBy { + searchQueryParams.append(URLQueryItem(name: "facet_by", value: facetBy)) + } + + if let maxFacetValues = commonParameters.maxFacetValues { + searchQueryParams.append(URLQueryItem(name: "max_facet_values", value: String(maxFacetValues))) + } + + if let facetQuery = commonParameters.facetQuery { + searchQueryParams.append(URLQueryItem(name: "facet_query", value: facetQuery)) + } + + if let numTypos = commonParameters.numTypos { + searchQueryParams.append(URLQueryItem(name: "num_typos", value: String(numTypos))) + } + + if let page = commonParameters.page { + searchQueryParams.append(URLQueryItem(name: "page", value: String(page))) + } + + if let perPage = commonParameters.perPage { + searchQueryParams.append(URLQueryItem(name: "per_page", value: String(perPage))) + } + + if let groupBy = commonParameters.groupBy { + searchQueryParams.append(URLQueryItem(name: "group_by", value: groupBy)) + } + + if let groupLimit = commonParameters.groupLimit { + searchQueryParams.append(URLQueryItem(name: "group_limit", value: String(groupLimit))) + } + + if let includeFields = commonParameters.includeFields { + searchQueryParams.append(URLQueryItem(name: "include_fields", value: includeFields)) + } + + if let excludeFields = commonParameters.excludeFields { + searchQueryParams.append(URLQueryItem(name: "exclude_fields", value: excludeFields)) + } + + if let highlightFullFields = commonParameters.highlightFullFields { + searchQueryParams.append(URLQueryItem(name: "highlight_full_fields", value: highlightFullFields)) + } + + if let highlightAffixNumTokens = commonParameters.highlightAffixNumTokens { + searchQueryParams.append(URLQueryItem(name: "highlight_affix_num_tokens", value: String(highlightAffixNumTokens))) + } + + if let highlightStartTag = commonParameters.highlightStartTag { + searchQueryParams.append(URLQueryItem(name: "highlight_start_tag", value: highlightStartTag)) + } + + if let highlightEndTag = commonParameters.highlightEndTag { + searchQueryParams.append(URLQueryItem(name: "highlight_end_tag", value: highlightEndTag)) + } + + if let snippetThreshold = commonParameters.snippetThreshold { + searchQueryParams.append(URLQueryItem(name: "snippet_threshold", value: String(snippetThreshold))) + } + + if let dropTokensThreshold = commonParameters.dropTokensThreshold { + searchQueryParams.append(URLQueryItem(name: "drop_tokens_threshold", value: String(dropTokensThreshold))) + } + + if let typoTokensThreshold = commonParameters.typoTokensThreshold { + searchQueryParams.append(URLQueryItem(name: "typo_tokens_threshold", value: String(typoTokensThreshold))) + } + + if let pinnedHits = commonParameters.pinnedHits { + searchQueryParams.append(URLQueryItem(name: "pinned_hits", value: pinnedHits)) + } + + if let hiddenHits = commonParameters.hiddenHits { + searchQueryParams.append(URLQueryItem(name: "hidden_hits", value: hiddenHits)) + } + + if let highlightFields = commonParameters.highlightFields { + searchQueryParams.append(URLQueryItem(name: "highlight_fields", value: highlightFields)) + } + + if let preSegmentedQuery = commonParameters.preSegmentedQuery { + searchQueryParams.append(URLQueryItem(name: "pre_segmented_query", value: String(preSegmentedQuery))) + } + + if let enableOverrides = commonParameters.enableOverrides { + searchQueryParams.append(URLQueryItem(name: "enable_overrides", value: String(enableOverrides))) + } + + if let prioritizeExactMatch = commonParameters.prioritizeExactMatch { + searchQueryParams.append(URLQueryItem(name: "prioritize_exact_match", value: String(prioritizeExactMatch))) + } + + if let exhaustiveSearch = commonParameters.exhaustiveSearch { + searchQueryParams.append(URLQueryItem(name: "exhaustive_search", value: String(exhaustiveSearch))) + } + + if let searchCutoffMs = commonParameters.searchCutoffMs { + searchQueryParams.append(URLQueryItem(name: "search_cutoff_ms", value: String(searchCutoffMs))) + } + + if let useCache = commonParameters.useCache { + searchQueryParams.append(URLQueryItem(name: "use_cache", value: String(useCache))) + } + + if let cacheTtl = commonParameters.cacheTtl { + searchQueryParams.append(URLQueryItem(name: "cache_ttl", value: String(cacheTtl))) + } + + if let minLen1typo = commonParameters.minLen1typo { + searchQueryParams.append(URLQueryItem(name: "min_len1type", value: String(minLen1typo))) + } + + if let minLen2typo = commonParameters.minLen2typo { + searchQueryParams.append(URLQueryItem(name: "min_len2type", value: String(minLen2typo))) + } + + let searches = MultiSearchSearchesParameter(searches: searchRequests) + + let searchesData = try encoder.encode(searches) + + let (data, response) = try await apiCall.post(endPoint: "\(RESOURCEPATH)", body: searchesData, queryParameters: searchQueryParams) + + if let validData = data { + let searchRes = try decoder.decode(MultiSearchResult.self, from: validData) + return (searchRes, response) + } + + return (nil, response) + } +} diff --git a/Tests/TypesenseTests/MultiSearchTests.swift b/Tests/TypesenseTests/MultiSearchTests.swift new file mode 100644 index 0000000..e946970 --- /dev/null +++ b/Tests/TypesenseTests/MultiSearchTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import Typesense + +final class MultiSearchTests: XCTestCase { + + struct Product: Codable, Equatable { + var name: String? + var price: Int? + var brand: String? + var desc: String? + + static func == (lhs: Product, rhs: Product) -> Bool { + return + lhs.name == rhs.name && + lhs.price == rhs.price && + lhs.brand == rhs.brand && + lhs.desc == rhs.desc + } + } + + struct Brand: Codable { + var name: String + } + + + func testMultiSearch() async { + let config = Configuration(nodes: [Node(host: "localhost", port: "8108", nodeProtocol: "http")], apiKey: "xyz", logger: Logger(debugMode: true)) + + let client = Client(config: config) + + let productSchema = CollectionSchema(name: "products", fields: [ + Field(name: "name", type: "string"), + Field(name: "price", type: "int32"), + Field(name: "brand", type: "string"), + Field(name: "desc", type: "string"), + ]) + + let brandSchema = CollectionSchema(name: "brands", fields: [ + Field(name: "name", type: "string"), + ]) + + let searchRequests = [ + MultiSearchCollectionParameters(q: "shoe", filterBy: "price:=[50..120]", collection: "products"), + MultiSearchCollectionParameters(q: "Nike", collection: "brands"), + ] + + let brand1 = Brand(name: "Nike") + let product1 = Product(name: "Jordan", price: 70, brand: "Nike", desc: "High quality shoe") + + let commonParams = MultiSearchParameters(queryBy: "name") + + do { + let (_, _) = try await client.collections.create(schema: productSchema) //Creating test collection - Products + let (_,_) = try await client.collections.create(schema: brandSchema) + + let (_,_) = try await client.collection(name: "products").documents().create(document: encoder.encode(product1)) + + let (_,_) = try await client.collection(name: "brands").documents().create(document: encoder.encode(brand1)) + + let (data, _) = try await client.multiSearch().perform(searchRequests: searchRequests, commonParameters: commonParams, for: Product.self) + + let (_,_) = try await client.collection(name: "products").delete() //Deleting test collection + let (_,_) = try await client.collection(name: "brands").delete() //Deleting test collection + + XCTAssertNotNil(data) + guard let validResp = data else { + throw DataError.dataNotFound + } + + XCTAssertNotNil(validResp.results) + XCTAssertNotEqual(validResp.results.count, 0) + XCTAssertNotNil(validResp.results[0].hits) + XCTAssertNotNil(validResp.results[1].hits) + XCTAssertEqual(validResp.results[1].hits?.count, 1) + + print(validResp.results[1].hits as Any) + } catch HTTPError.serverError(let code, let desc) { + print(desc) + print("The response status code is \(code)") + XCTAssertTrue(false) + } catch (let error) { + print(error.localizedDescription) + XCTAssertTrue(false) + } + + } + + + +}