From 2fe20554d46da7d1e1619cd99807a98234566d70 Mon Sep 17 00:00:00 2001 From: Eric Musliner Date: Sun, 23 Nov 2025 12:18:09 -0500 Subject: [PATCH 1/2] Add Valkey Search commands - Add generated Valkey Search commands from json doc files https://github.com/valkey-io/valkey-search/tree/main/src/commands Signed-off-by: Eric Musliner --- Package.swift | 6 + Sources/ValkeySearch/SearchCommands.swift | 796 ++++++++++++++++++ .../Resources/valkey-search-commands.json | 550 ++++++++++++ Sources/_ValkeyCommandsBuilder/app.swift | 2 + 4 files changed, 1354 insertions(+) create mode 100644 Sources/ValkeySearch/SearchCommands.swift create mode 100755 Sources/_ValkeyCommandsBuilder/Resources/valkey-search-commands.json mode change 100644 => 100755 Sources/_ValkeyCommandsBuilder/app.swift diff --git a/Package.swift b/Package.swift index 949591ef..077322c5 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,7 @@ let package = Package( .library(name: "Valkey", targets: ["Valkey"]), .library(name: "ValkeyBloom", targets: ["ValkeyBloom"]), .library(name: "ValkeyJSON", targets: ["ValkeyJSON"]), + .library(name: "ValkeySearch", targets: ["ValkeySearch"]), ], traits: [ .trait(name: "ServiceLifecycleSupport"), @@ -60,6 +61,11 @@ let package = Package( dependencies: ["Valkey"], swiftSettings: defaultSwiftSettings ), + .target( + name: "ValkeySearch", + dependencies: ["Valkey"], + swiftSettings: defaultSwiftSettings + ), .target( name: "_ValkeyConnectionPool", dependencies: [ diff --git a/Sources/ValkeySearch/SearchCommands.swift b/Sources/ValkeySearch/SearchCommands.swift new file mode 100644 index 00000000..8ccefd8d --- /dev/null +++ b/Sources/ValkeySearch/SearchCommands.swift @@ -0,0 +1,796 @@ +// +// This source file is part of the valkey-swift project +// Copyright (c) 2025 the valkey-swift project authors +// +// See LICENSE.txt for license information +// SPDX-License-Identifier: Apache-2.0 +// +// This file is autogenerated by ValkeyCommandsBuilder + +import NIOCore +import Valkey + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +@_documentation(visibility: internal) +public enum FT { + /// Performs a search of the specified index. The keys which match the query expression are subjected to further processing as specified + @_documentation(visibility: internal) + public struct AGGREGATE: ValkeyCommand { + @inlinable public static var name: String { "FT.AGGREGATE" } + + public var index: ValkeyKey + public var query: Query + + @inlinable public init(index: ValkeyKey, query: Query) { + self.index = index + self.query = query + } + + public var keysAffected: CollectionOfOne { .init(index) } + + @inlinable public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + commandEncoder.encodeArray("FT.AGGREGATE", index, RESPBulkString(query)) + } + } + + /// Creates an empty search index and initiates the backfill process + @_documentation(visibility: internal) + public struct CREATE: ValkeyCommand { + public enum On_Type: RESPRenderable, Sendable, Hashable { + case hash + case json + + @inlinable + public var respEntries: Int { 1 } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + switch self { + case .hash: "HASH".encode(into: &commandEncoder) + case .json: "JSON".encode(into: &commandEncoder) + } + } + } + public struct On: RESPRenderable, Sendable, Hashable { + public var type: On_Type + + @inlinable + public init(type: On_Type) { + self.type = type + } + + @inlinable + public var respEntries: Int { + "ON".respEntries + type.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "ON".encode(into: &commandEncoder) + type.encode(into: &commandEncoder) + } + } + public struct Prefix: RESPRenderable, Sendable, Hashable { + public var count: Int + public var prefixes: [String] + + @inlinable + public init(count: Int, prefixes: [String]) { + self.count = count + self.prefixes = prefixes + } + + @inlinable + public var respEntries: Int { + "PREFIX".respEntries + count.respEntries + prefixes.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "PREFIX".encode(into: &commandEncoder) + count.encode(into: &commandEncoder) + prefixes.encode(into: &commandEncoder) + } + } + public struct SchemaFieldsAlias: RESPRenderable, Sendable, Hashable { + public var fieldIdentifier: FieldIdentifier + + @inlinable + public init(fieldIdentifier: FieldIdentifier) { + self.fieldIdentifier = fieldIdentifier + } + + @inlinable + public var respEntries: Int { + "AS".respEntries + RESPBulkString(fieldIdentifier).respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "AS".encode(into: &commandEncoder) + RESPBulkString(fieldIdentifier).encode(into: &commandEncoder) + } + } + public struct SchemaFieldsFieldTypeTagSeparator: RESPRenderable, Sendable, Hashable { + public var sep: String + + @inlinable + public init(sep: String) { + self.sep = sep + } + + @inlinable + public var respEntries: Int { + "SEPARATOR".respEntries + sep.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "SEPARATOR".encode(into: &commandEncoder) + sep.encode(into: &commandEncoder) + } + } + public struct SchemaFieldsFieldTypeTag: RESPRenderable, Sendable, Hashable { + public var separator: SchemaFieldsFieldTypeTagSeparator? + public var casesensitive: Bool + + @inlinable + public init(separator: SchemaFieldsFieldTypeTagSeparator? = nil, casesensitive: Bool = false) { + self.separator = separator + self.casesensitive = casesensitive + } + + @inlinable + public var respEntries: Int { + "TAG".respEntries + separator.respEntries + RESPPureToken("CASESENSITIVE", casesensitive).respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "TAG".encode(into: &commandEncoder) + separator.encode(into: &commandEncoder) + RESPPureToken("CASESENSITIVE", casesensitive).encode(into: &commandEncoder) + } + } + public enum SchemaFieldsFieldTypeVectorAlgorithm: RESPRenderable, Sendable, Hashable { + case hnsw + case flat + + @inlinable + public var respEntries: Int { 1 } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + switch self { + case .hnsw: "HNSW".encode(into: &commandEncoder) + case .flat: "FLAT".encode(into: &commandEncoder) + } + } + } + public struct SchemaFieldsFieldTypeVectorVectorParams_Type: RESPRenderable, Sendable, Hashable { + + @inlinable + public init() { + } + + @inlinable + public var respEntries: Int { + "TYPE".respEntries + "FLOAT32".respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "TYPE".encode(into: &commandEncoder) + "FLOAT32".encode(into: &commandEncoder) + } + } + public struct SchemaFieldsFieldTypeVectorVectorParamsDim: RESPRenderable, Sendable, Hashable { + public var value: Int + + @inlinable + public init(value: Int) { + self.value = value + } + + @inlinable + public var respEntries: Int { + "DIM".respEntries + value.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "DIM".encode(into: &commandEncoder) + value.encode(into: &commandEncoder) + } + } + public enum SchemaFieldsFieldTypeVectorVectorParamsDistanceMetricMetric: RESPRenderable, Sendable, Hashable { + case l2 + case ip + case cosine + + @inlinable + public var respEntries: Int { 1 } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + switch self { + case .l2: "L2".encode(into: &commandEncoder) + case .ip: "IP".encode(into: &commandEncoder) + case .cosine: "COSINE".encode(into: &commandEncoder) + } + } + } + public struct SchemaFieldsFieldTypeVectorVectorParamsDistanceMetric: RESPRenderable, Sendable, Hashable { + public var metric: SchemaFieldsFieldTypeVectorVectorParamsDistanceMetricMetric + + @inlinable + public init(metric: SchemaFieldsFieldTypeVectorVectorParamsDistanceMetricMetric) { + self.metric = metric + } + + @inlinable + public var respEntries: Int { + "DISTANCE_METRIC".respEntries + metric.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "DISTANCE_METRIC".encode(into: &commandEncoder) + metric.encode(into: &commandEncoder) + } + } + public struct SchemaFieldsFieldTypeVectorVectorParamsM: RESPRenderable, Sendable, Hashable { + public var value: Int + + @inlinable + public init(value: Int) { + self.value = value + } + + @inlinable + public var respEntries: Int { + "M".respEntries + value.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "M".encode(into: &commandEncoder) + value.encode(into: &commandEncoder) + } + } + public struct SchemaFieldsFieldTypeVectorVectorParamsEfConstruction: RESPRenderable, Sendable, Hashable { + public var value: Int + + @inlinable + public init(value: Int) { + self.value = value + } + + @inlinable + public var respEntries: Int { + "EF_CONSTRUCTION".respEntries + value.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "EF_CONSTRUCTION".encode(into: &commandEncoder) + value.encode(into: &commandEncoder) + } + } + public struct SchemaFieldsFieldTypeVectorVectorParamsBlockSize: RESPRenderable, Sendable, Hashable { + public var value: Int + + @inlinable + public init(value: Int) { + self.value = value + } + + @inlinable + public var respEntries: Int { + "BLOCK_SIZE".respEntries + value.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "BLOCK_SIZE".encode(into: &commandEncoder) + value.encode(into: &commandEncoder) + } + } + public struct SchemaFieldsFieldTypeVectorVectorParams: RESPRenderable, Sendable, Hashable { + public var type: SchemaFieldsFieldTypeVectorVectorParams_Type + public var dim: SchemaFieldsFieldTypeVectorVectorParamsDim + public var distanceMetric: SchemaFieldsFieldTypeVectorVectorParamsDistanceMetric + public var m: SchemaFieldsFieldTypeVectorVectorParamsM? + public var efConstruction: SchemaFieldsFieldTypeVectorVectorParamsEfConstruction? + public var blockSize: SchemaFieldsFieldTypeVectorVectorParamsBlockSize? + + @inlinable + public init( + type: SchemaFieldsFieldTypeVectorVectorParams_Type, + dim: SchemaFieldsFieldTypeVectorVectorParamsDim, + distanceMetric: SchemaFieldsFieldTypeVectorVectorParamsDistanceMetric, + m: SchemaFieldsFieldTypeVectorVectorParamsM? = nil, + efConstruction: SchemaFieldsFieldTypeVectorVectorParamsEfConstruction? = nil, + blockSize: SchemaFieldsFieldTypeVectorVectorParamsBlockSize? = nil + ) { + self.type = type + self.dim = dim + self.distanceMetric = distanceMetric + self.m = m + self.efConstruction = efConstruction + self.blockSize = blockSize + } + + @inlinable + public var respEntries: Int { + type.respEntries + dim.respEntries + distanceMetric.respEntries + m.respEntries + efConstruction.respEntries + blockSize.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + type.encode(into: &commandEncoder) + dim.encode(into: &commandEncoder) + distanceMetric.encode(into: &commandEncoder) + m.encode(into: &commandEncoder) + efConstruction.encode(into: &commandEncoder) + blockSize.encode(into: &commandEncoder) + } + } + public struct SchemaFieldsFieldTypeVector: RESPRenderable, Sendable, Hashable { + public var algorithm: SchemaFieldsFieldTypeVectorAlgorithm + public var attrCount: Int + public var vectorParams: SchemaFieldsFieldTypeVectorVectorParams + + @inlinable + public init(algorithm: SchemaFieldsFieldTypeVectorAlgorithm, attrCount: Int, vectorParams: SchemaFieldsFieldTypeVectorVectorParams) { + self.algorithm = algorithm + self.attrCount = attrCount + self.vectorParams = vectorParams + } + + @inlinable + public var respEntries: Int { + "VECTOR".respEntries + algorithm.respEntries + attrCount.respEntries + vectorParams.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "VECTOR".encode(into: &commandEncoder) + algorithm.encode(into: &commandEncoder) + attrCount.encode(into: &commandEncoder) + vectorParams.encode(into: &commandEncoder) + } + } + public enum SchemaFieldsFieldType: RESPRenderable, Sendable, Hashable { + case numeric + case text + case tag(SchemaFieldsFieldTypeTag) + case vector(SchemaFieldsFieldTypeVector) + + @inlinable + public var respEntries: Int { + switch self { + case .numeric: "NUMERIC".respEntries + case .text: "TEXT".respEntries + case .tag(let tag): tag.respEntries + case .vector(let vector): vector.respEntries + } + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + switch self { + case .numeric: "NUMERIC".encode(into: &commandEncoder) + case .text: "TEXT".encode(into: &commandEncoder) + case .tag(let tag): tag.encode(into: &commandEncoder) + case .vector(let vector): vector.encode(into: &commandEncoder) + } + } + } + public struct SchemaFields: RESPRenderable, Sendable, Hashable { + public var fieldIdentifier: FieldIdentifier + public var alias: SchemaFieldsAlias? + public var fieldType: SchemaFieldsFieldType + + @inlinable + public init(fieldIdentifier: FieldIdentifier, alias: SchemaFieldsAlias? = nil, fieldType: SchemaFieldsFieldType) { + self.fieldIdentifier = fieldIdentifier + self.alias = alias + self.fieldType = fieldType + } + + @inlinable + public var respEntries: Int { + RESPBulkString(fieldIdentifier).respEntries + alias.respEntries + fieldType.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + RESPBulkString(fieldIdentifier).encode(into: &commandEncoder) + alias.encode(into: &commandEncoder) + fieldType.encode(into: &commandEncoder) + } + } + public struct Schema: RESPRenderable, Sendable, Hashable { + public var fields: [SchemaFields] + + @inlinable + public init(fields: [SchemaFields]) { + self.fields = fields + } + + @inlinable + public var respEntries: Int { + "SCHEMA".respEntries + fields.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "SCHEMA".encode(into: &commandEncoder) + fields.encode(into: &commandEncoder) + } + } + @inlinable public static var name: String { "FT.CREATE" } + + public var indexName: IndexName + public var on: On? + public var prefix: Prefix? + public var schema: Schema + + @inlinable public init(indexName: IndexName, on: On? = nil, prefix: Prefix? = nil, schema: Schema) { + self.indexName = indexName + self.on = on + self.prefix = prefix + self.schema = schema + } + + @inlinable public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + commandEncoder.encodeArray("FT.CREATE", RESPBulkString(indexName), on, prefix, schema) + } + } + + /// Drop the index created by FT.CREATE command. It is an error if the index doesn't exist + @_documentation(visibility: internal) + public struct DROPINDEX: ValkeyCommand { + @inlinable public static var name: String { "FT.DROPINDEX" } + + public var key: ValkeyKey + + @inlinable public init(_ key: ValkeyKey) { + self.key = key + } + + public var keysAffected: CollectionOfOne { .init(key) } + + @inlinable public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + commandEncoder.encodeArray("FT.DROPINDEX", key) + } + } + + /// Detailed information about the specified index is returned + @_documentation(visibility: internal) + public struct INFO: ValkeyCommand { + public enum Scope: RESPRenderable, Sendable, Hashable { + case local + case global + + @inlinable + public var respEntries: Int { 1 } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + switch self { + case .local: "LOCAL".encode(into: &commandEncoder) + case .global: "GLOBAL".encode(into: &commandEncoder) + } + } + } + @inlinable public static var name: String { "FT.INFO" } + + public var key: ValkeyKey + public var scope: Scope? + + @inlinable public init(_ key: ValkeyKey, scope: Scope? = nil) { + self.key = key + self.scope = scope + } + + public var keysAffected: CollectionOfOne { .init(key) } + + @inlinable public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + commandEncoder.encodeArray("FT.INFO", key, scope) + } + } + + /// Performs a search of the specified index. The keys which match the query expression are returned + @_documentation(visibility: internal) + public struct SEARCH: ValkeyCommand { + public struct Timeout: RESPRenderable, Sendable, Hashable { + public var timeoutMs: Int + + @inlinable + public init(timeoutMs: Int) { + self.timeoutMs = timeoutMs + } + + @inlinable + public var respEntries: Int { + "TIMEOUT".respEntries + timeoutMs.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "TIMEOUT".encode(into: &commandEncoder) + timeoutMs.encode(into: &commandEncoder) + } + } + public struct Params: RESPRenderable, Sendable, Hashable { + public var count: Int + public var pairs: [String] + + @inlinable + public init(count: Int, pairs: [String]) { + self.count = count + self.pairs = pairs + } + + @inlinable + public var respEntries: Int { + "PARAMS".respEntries + count.respEntries + pairs.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "PARAMS".encode(into: &commandEncoder) + count.encode(into: &commandEncoder) + pairs.encode(into: &commandEncoder) + } + } + public struct ReturnFields: RESPRenderable, Sendable, Hashable { + public var count: Int + public var fields: [String] + + @inlinable + public init(count: Int, fields: [String]) { + self.count = count + self.fields = fields + } + + @inlinable + public var respEntries: Int { + "RETURN".respEntries + count.respEntries + fields.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "RETURN".encode(into: &commandEncoder) + count.encode(into: &commandEncoder) + fields.encode(into: &commandEncoder) + } + } + public struct Limit: RESPRenderable, Sendable, Hashable { + public var offset: Int + public var count: Int + + @inlinable + public init(offset: Int, count: Int) { + self.offset = offset + self.count = count + } + + @inlinable + public var respEntries: Int { + "LIMIT".respEntries + offset.respEntries + count.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "LIMIT".encode(into: &commandEncoder) + offset.encode(into: &commandEncoder) + count.encode(into: &commandEncoder) + } + } + public struct Dialect: RESPRenderable, Sendable, Hashable { + public var dialect: Int + + @inlinable + public init(dialect: Int) { + self.dialect = dialect + } + + @inlinable + public var respEntries: Int { + "DIALECT".respEntries + dialect.respEntries + } + + @inlinable + public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + "DIALECT".encode(into: &commandEncoder) + dialect.encode(into: &commandEncoder) + } + } + @inlinable public static var name: String { "FT.SEARCH" } + + public var index: ValkeyKey + public var query: Query + public var nocontent: Bool + public var timeout: Timeout? + public var params: Params? + public var returnFields: ReturnFields? + public var limit: Limit? + public var dialect: Dialect? + public var localonly: Bool + + @inlinable public init( + index: ValkeyKey, + query: Query, + nocontent: Bool = false, + timeout: Timeout? = nil, + params: Params? = nil, + returnFields: ReturnFields? = nil, + limit: Limit? = nil, + dialect: Dialect? = nil, + localonly: Bool = false + ) { + self.index = index + self.query = query + self.nocontent = nocontent + self.timeout = timeout + self.params = params + self.returnFields = returnFields + self.limit = limit + self.dialect = dialect + self.localonly = localonly + } + + public var keysAffected: CollectionOfOne { .init(index) } + + @inlinable public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + commandEncoder.encodeArray( + "FT.SEARCH", + index, + RESPBulkString(query), + RESPPureToken("NOCONTENT", nocontent), + timeout, + params, + returnFields, + limit, + dialect, + RESPPureToken("LOCALONLY", localonly) + ) + } + } + + /// Developer access, not for production use + @_documentation(visibility: internal) + public struct DEBUG: ValkeyCommand { + @inlinable public static var name: String { "FT._DEBUG" } + + @inlinable public init() { + } + + @inlinable public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + commandEncoder.encodeArray("FT._DEBUG") + } + } + + /// Lists the currently defined indexes + @_documentation(visibility: internal) + public struct LIST: ValkeyCommand { + @inlinable public static var name: String { "FT._LIST" } + + @inlinable public init() { + } + + @inlinable public func encode(into commandEncoder: inout ValkeyCommandEncoder) { + commandEncoder.encodeArray("FT._LIST") + } + } + +} + +@available(valkeySwift 1.0, *) +extension ValkeyClientProtocol { + /// Performs a search of the specified index. The keys which match the query expression are subjected to further processing as specified + /// + /// - Documentation: [FT.AGGREGATE](https://valkey.io/commands/ft.aggregate) + /// - Complexity: O(log N) + @inlinable + @discardableResult + public func ftAggregate(index: ValkeyKey, query: Query) async throws -> RESPToken { + try await execute(FT.AGGREGATE(index: index, query: query)) + } + + /// Creates an empty search index and initiates the backfill process + /// + /// - Documentation: [FT.CREATE](https://valkey.io/commands/ft.create) + /// - Complexity: Construction time O(N log N), where N is the number of indexed items + @inlinable + @discardableResult + public func ftCreate( + indexName: IndexName, + on: FT.CREATE.On? = nil, + prefix: FT.CREATE.Prefix? = nil, + schema: FT.CREATE.Schema + ) async throws -> RESPToken { + try await execute(FT.CREATE(indexName: indexName, on: on, prefix: prefix, schema: schema)) + } + + /// Drop the index created by FT.CREATE command. It is an error if the index doesn't exist + /// + /// - Documentation: [FT.DROPINDEX](https://valkey.io/commands/ft.dropindex) + /// - Complexity: O(N) + @inlinable + @discardableResult + public func ftDropindex(_ key: ValkeyKey) async throws -> FT.DROPINDEX.Response { + try await execute(FT.DROPINDEX(key)) + } + + /// Detailed information about the specified index is returned + /// + /// - Documentation: [FT.INFO](https://valkey.io/commands/ft.info) + /// - Complexity: O(1) + @inlinable + @discardableResult + public func ftInfo(_ key: ValkeyKey, scope: FT.INFO.Scope? = nil) async throws -> FT.INFO.Response { + try await execute(FT.INFO(key, scope: scope)) + } + + /// Performs a search of the specified index. The keys which match the query expression are returned + /// + /// - Documentation: [FT.SEARCH](https://valkey.io/commands/ft.search) + /// - Complexity: O(log N) + @inlinable + @discardableResult + public func ftSearch( + index: ValkeyKey, + query: Query, + nocontent: Bool = false, + timeout: FT.SEARCH.Timeout? = nil, + params: FT.SEARCH.Params? = nil, + returnFields: FT.SEARCH.ReturnFields? = nil, + limit: FT.SEARCH.Limit? = nil, + dialect: FT.SEARCH.Dialect? = nil, + localonly: Bool = false + ) async throws -> RESPToken { + try await execute( + FT.SEARCH( + index: index, + query: query, + nocontent: nocontent, + timeout: timeout, + params: params, + returnFields: returnFields, + limit: limit, + dialect: dialect, + localonly: localonly + ) + ) + } + + /// Developer access, not for production use + /// + /// - Documentation: [FT._DEBUG](https://valkey.io/commands/ft._debug) + /// - Complexity: O(1) + @inlinable + @discardableResult + public func ftDebug() async throws -> FT.DEBUG.Response { + try await execute(FT.DEBUG()) + } + + /// Lists the currently defined indexes + /// + /// - Documentation: [FT._LIST](https://valkey.io/commands/ft._list) + /// - Complexity: O(1) + @inlinable + @discardableResult + public func ftList() async throws -> FT.LIST.Response { + try await execute(FT.LIST()) + } + +} diff --git a/Sources/_ValkeyCommandsBuilder/Resources/valkey-search-commands.json b/Sources/_ValkeyCommandsBuilder/Resources/valkey-search-commands.json new file mode 100755 index 00000000..8055a57a --- /dev/null +++ b/Sources/_ValkeyCommandsBuilder/Resources/valkey-search-commands.json @@ -0,0 +1,550 @@ +{ + "FT._DEBUG": { + "acl_categories": [ + "ADMIN", + "SLOW", + "READ", + "SEARCH" + ], + "arguments": [], + "complexity": "O(1)", + "group": "search", + "module_since": "1.1.0", + "summary": "Developer access, not for production use" + }, + "FT._LIST": { + "acl_categories": [ + "ADMIN", + "SLOW", + "READ", + "SEARCH" + ], + "arguments": [], + "arity": 1, + "complexity": "O(1)", + "group": "search", + "module_since": "1.0.0", + "summary": "Lists the currently defined indexes" + }, + "FT.AGGREGATE": { + "acl_categories": [ + "READ", + "SLOW", + "SEARCH" + ], + "arguments": [ + { + "key_spec_index": 0, + "name": "index", + "type": "key" + }, + { + "key_spec_index": 1, + "name": "query", + "type": "string" + } + ], + "arity": -3, + "complexity": "O(log N)", + "group": "search", + "module_since": "1.1.0", + "summary": "Performs a search of the specified index. The keys which match the query expression are subjected to further processing as specified" + }, + "FT.CREATE": { + "acl_categories": [ + "FAST", + "WRITE", + "SEARCH" + ], + "arguments": [ + { + "key_spec_index": 0, + "name": "index-name", + "type": "string" + }, + { + "name": "on", + "type": "block", + "optional": true, + "description": "Index source type", + "arguments": [ + { + "name": "on_token", + "type": "pure-token", + "token": "ON" + }, + { + "name": "type", + "type": "oneof", + "arguments": [ + { + "name": "hash", + "type": "pure-token", + "token": "HASH" + }, + { + "name": "json", + "type": "pure-token", + "token": "JSON" + } + ] + } + ] + }, + { + "name": "prefix", + "type": "block", + "optional": true, + "description": "Optional PREFIX clause to restrict indexed keys", + "arguments": [ + { + "name": "PREFIX", + "type": "pure-token", + "token": "PREFIX" + }, + { + "name": "count", + "type": "integer" + }, + { + "name": "prefix", + "type": "string", + "multiple": true + } + ] + }, + { + "name": "schema", + "type": "block", + "description": "Schema definition: one or more field definitions", + "arguments": [ + { + "name": "schema_token", + "type": "pure-token", + "token": "SCHEMA" + }, + { + "name": "fields", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "field-identifier", + "type": "string" + }, + { + "name": "alias", + "optional": true, + "type": "block", + "arguments": [ + { + "name": "AS", + "type": "pure-token", + "token": "AS" + }, + { + "name": "field-identifier", + "type": "string" + } + ] + }, + { + "name": "field-type", + "type": "oneof", + "arguments": [ + { + "name": "NUMERIC", + "type": "pure-token", + "token": "NUMERIC" + }, + { + "name": "TEXT", + "type": "pure-token", + "token": "TEXT" + }, + { + "name": "TAG", + "type": "block", + "arguments": [ + { + "name": "tag_token", + "type": "pure-token", + "token": "TAG" + }, + { + "name": "separator", + "optional": true, + "type": "block", + "arguments": [ + { + "name": "separator_token", + "type": "pure-token", + "token": "SEPARATOR" + }, + { + "name": "sep", + "type": "string" + } + ] + }, + { + "name": "casesensitive", + "optional": true, + "type": "pure-token", + "token": "CASESENSITIVE" + } + ] + }, + { + "name": "vector", + "type": "block", + "arguments": [ + { + "name": "vector", + "type": "pure-token", + "token": "VECTOR" + }, + { + "name": "algorithm", + "type": "oneof", + "arguments": [ + { + "name": "HNSW", + "type": "pure-token", + "token": "HNSW" + }, + { + "name": "FLAT", + "type": "pure-token", + "token": "FLAT" + } + ] + }, + { + "name": "attr_count", + "type": "integer" + }, + { + "name": "vector-params", + "type": "block", + "description": "Vector algorithm parameters (DIM, TYPE, DISTANCE_METRIC, INITIAL_CAP, M, EF_CONSTRUCTION, EF_RUNTIME, BLOCK_SIZE)", + "arguments": [ + { + "name": "type", + "type": "block", + "arguments": [ + { + "name": "type_token", + "type": "pure-token", + "token": "TYPE" + }, + { + "name": "format", + "type": "pure-token", + "token": "FLOAT32" + } + ] + }, + { + "name": "dim", + "type": "block", + "arguments": [ + { + "name": "dim_token", + "type": "pure-token", + "token": "DIM" + }, + { + "name": "value", + "type": "integer" + } + ] + }, + { + "name": "distance_metric", + "type": "block", + "arguments": [ + { + "name": "dm_token", + "type": "pure-token", + "token": "DISTANCE_METRIC" + }, + { + "name": "metric", + "type": "oneof", + "arguments": [ + { + "name": "l2", + "type": "pure-token", + "token": "L2" + }, + { + "name": "ip", + "type": "pure-token", + "token": "IP" + }, + { + "name": "cosine", + "type": "pure-token", + "token": "COSINE" + } + ] + } + ] + }, + { + "name": "m", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "m_token", + "type": "pure-token", + "token": "M" + }, + { + "name": "value", + "type": "integer" + } + ] + }, + { + "name": "ef_construction", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "efc_token", + "type": "pure-token", + "token": "EF_CONSTRUCTION" + }, + { + "name": "value", + "type": "integer" + } + ] + }, + { + "name": "block_size", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "bs_token", + "type": "pure-token", + "token": "BLOCK_SIZE" + }, + { + "name": "value", + "type": "integer" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "complexity": "Construction time O(N log N), where N is the number of indexed items", + "group": "search", + "module_since": "1.0.0", + "summary": "Creates an empty search index and initiates the backfill process" + }, + "FT.DROPINDEX": { + "acl_categories": [ + "FAST", + "WRITE", + "SEARCH" + ], + "arguments": [ + { + "key_spec_index": 0, + "name": "key", + "type": "key" + } + ], + "arity": 2, + "complexity": "O(N)", + "group": "search", + "module_since": "1.0.0", + "summary": "Drop the index created by FT.CREATE command. It is an error if the index doesn't exist" + }, + "FT.INFO": { + "acl_categories": [ + "READ", + "FAST", + "SEARCH" + ], + "arguments": [ + { + "key_spec_index": 0, + "name": "key", + "type": "key" + }, + { + "name": "scope", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "LOCAL", + "type": "pure-token", + "token": "LOCAL" + }, + { + "name": "GLOBAL", + "type": "pure-token", + "token": "GLOBAL" + } + ] + } + ], + "arity": -2, + "complexity": "O(1)", + "group": "search", + "module_since": "1.0.0", + "summary": "Detailed information about the specified index is returned" + }, + "FT.SEARCH": { + "acl_categories": [ + "READ", + "SLOW", + "SEARCH" + ], + "arguments": [ + { + "key_spec_index": 0, + "name": "index", + "type": "key" + }, + { + "key_spec_index": 1, + "name": "query", + "type": "string" + }, + { + "name": "NOCONTENT", + "type": "pure-token", + "optional": true, + "token": "NOCONTENT", + "description": "When present, only matching key names are returned (no fields)." + }, + { + "name": "TIMEOUT", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "TIMEOUT", + "type": "pure-token", + "token": "TIMEOUT" + }, + { + "name": "timeout_ms", + "type": "integer" + } + ] + }, + { + "name": "PARAMS", + "type": "block", + "optional": true, + "description": "Parameter substitution map used by $-placeholders in the query string.", + "arguments": [ + { + "name": "PARAMS", + "type": "pure-token", + "token": "PARAMS" + }, + { + "name": "count", + "type": "integer" + }, + { + "name": "pairs", + "type": "string", + "multiple": true, + "description": "name/value pairs (count must be even)" + } + ] + }, + { + "name": "RETURN_FIELDS", + "type": "block", + "optional": true, + "description": "Specify which fields to return and optional aliases. If count is 0 behaves like NOCONTENT.", + "arguments": [ + { + "name": "RETURN_TOKEN", + "type": "pure-token", + "token": "RETURN" + }, + { + "name": "count", + "type": "integer" + }, + { + "name": "fields", + "type": "string", + "multiple": true + } + ] + }, + { + "name": "LIMIT", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "LIMIT", + "type": "pure-token", + "token": "LIMIT" + }, + { + "name": "offset", + "type": "integer" + }, + { + "name": "count", + "type": "integer" + } + ] + }, + { + "name": "DIALECT", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "DIALECT", + "type": "pure-token", + "token": "DIALECT" + }, + { + "name": "dialect", + "type": "integer" + } + ], + "description": "Specifies the query dialect (supported values validated by server)." + }, + { + "name": "LOCALONLY", + "type": "pure-token", + "optional": true, + "token": "LOCALONLY", + "description": "If present, restricts search to local index data only." + } + ], + "arity": -3, + "complexity": "O(log N)", + "group": "search", + "module_since": "1.0.0", + "summary": "Performs a search of the specified index. The keys which match the query expression are returned" + } +} \ No newline at end of file diff --git a/Sources/_ValkeyCommandsBuilder/app.swift b/Sources/_ValkeyCommandsBuilder/app.swift old mode 100644 new mode 100755 index 03dac675..b680e959 --- a/Sources/_ValkeyCommandsBuilder/app.swift +++ b/Sources/_ValkeyCommandsBuilder/app.swift @@ -25,6 +25,8 @@ struct App { try writeValkeyCommands(toFolder: "Sources/ValkeyBloom/", commands: bloomCommands, module: true) let jsonCommands = try load(fileURL: resourceFolder.appending(path: "valkey-json-commands.json"), as: ValkeyCommands.self) try writeValkeyCommands(toFolder: "Sources/ValkeyJSON/", commands: jsonCommands, module: true) + let searchCommands = try load(fileURL: resourceFolder.appending(path: "valkey-search-commands.json"), as: ValkeyCommands.self) + try writeValkeyCommands(toFolder: "Sources/ValkeySearch/", commands: searchCommands, module: true) } } From 956c1ce18578609ec887772c8842f6aa8e60dd3e Mon Sep 17 00:00:00 2001 From: Eric Musliner Date: Mon, 24 Nov 2025 05:55:02 -0500 Subject: [PATCH 2/2] Add tests for ValkeySearch commands Signed-off-by: Eric Musliner --- Package.swift | 1 + Tests/ValkeyTests/CommandTests.swift | 973 +++++++++++++++++++++++++++ 2 files changed, 974 insertions(+) diff --git a/Package.swift b/Package.swift index 077322c5..47833561 100644 --- a/Package.swift +++ b/Package.swift @@ -99,6 +99,7 @@ let package = Package( name: "ValkeyTests", dependencies: [ "Valkey", + "ValkeySearch", .product(name: "NIOTestUtils", package: "swift-nio"), .product(name: "Logging", package: "swift-log"), .product(name: "NIOEmbedded", package: "swift-nio"), diff --git a/Tests/ValkeyTests/CommandTests.swift b/Tests/ValkeyTests/CommandTests.swift index f6e06e98..999580c8 100644 --- a/Tests/ValkeyTests/CommandTests.swift +++ b/Tests/ValkeyTests/CommandTests.swift @@ -10,6 +10,7 @@ import NIOCore import NIOEmbedded import Testing import Valkey +import ValkeySearch /// Test commands render correctly and process their response correctly. /// @@ -801,6 +802,978 @@ struct CommandTests { } } } + + struct SearchCommands { + @Test + @available(valkeySwift 1.0, *) + func ftSearch() async throws { + try await testCommandEncodesDecodes( + ( + request: .command(["FT.SEARCH", "idx:myIndex", "@title:Hello World"]), + response: .array([ + .number(2), + .bulkString("doc:1"), + .array([ + .bulkString("title"), + .bulkString("Hello World"), + .bulkString("body"), + .bulkString("This is a test document"), + ]), + .bulkString("doc:2"), + .array([ + .bulkString("title"), + .bulkString("Hello Again"), + .bulkString("body"), + .bulkString("Another world example"), + ]), + ]) + ) + ) { connection in + let result = try await connection.ftSearch(index: "idx:myIndex", query: "@title:Hello World") + guard case .array(let array1) = result.value else { + Issue.record("Expected array response") + return + } + let items = Array(array1) + #expect(items.count == 5) + guard case .number(let count) = items[0].value else { + Issue.record("Expected number") + return + } + #expect(count == 2) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftSearch_nocontent() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.SEARCH", + "idx:myIndex", + "@title:Hello", + "NOCONTENT", + ]), + response: .array([ + .number(2), + .bulkString("doc:1"), + .bulkString("doc:2"), + ]) + ) + ) { connection in + let result = try await connection.ftSearch( + index: "idx:myIndex", + query: "@title:Hello", + nocontent: true + ) + + guard case .array(let arr1) = result.value else { + Issue.record("Expected array response") + return + } + + let items = Array(arr1) + #expect(items.count == 3) + + guard case .number(let total) = items[0].value else { + Issue.record("Expected first element to be number") + return + } + #expect(total == 2) + + guard case .bulkString = items[1].value else { + Issue.record("Expected second element to be bulk string (doc id)") + return + } + guard case .bulkString = items[2].value else { + Issue.record("Expected third element to be bulk string (doc id)") + return + } + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftSearch_limit() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.SEARCH", + "idx:myIndex", + "@title:Hello", + "LIMIT", "10", "20", + ]), + response: .array([ + .number(0) + ]) + ) + ) { connection in + let result = try await connection.ftSearch( + index: "idx:myIndex", + query: "@title:Hello", + limit: .init(offset: 10, count: 20) + ) + + guard case .array(let arr1) = result.value else { + Issue.record("Expected array response") + return + } + + let items = Array(arr1) + #expect(items.count == 1) + + guard case .number(let total) = items[0].value else { + Issue.record("Expected first element to be number") + return + } + #expect(total == 0) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftSearch_timeout() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.SEARCH", + "idx:myIndex", + "@title:Hello", + "TIMEOUT", "100", + ]), + response: .array([ + .number(0) + ]) + ) + ) { connection in + let result = try await connection.ftSearch( + index: "idx:myIndex", + query: "@title:Hello", + timeout: .init(timeoutMs: 100) + ) + + guard case .array(let arr1) = result.value else { + Issue.record("Expected array response") + return + } + + let items = Array(arr1) + #expect(items.count == 1) + + guard case .number(let total) = items[0].value else { + Issue.record("Expected first element to be number") + return + } + #expect(total == 0) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftSearch_paramsAndLocalOnly() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.SEARCH", + "idx:myIndex", + "@title:$t @tag:{$tag}", + "PARAMS", "4", + "t", "Hello", + "tag", "world", + "LOCALONLY", + ]), + response: .array([ + .number(0) + ]) + ) + ) { connection in + let result = try await connection.ftSearch( + index: "idx:myIndex", + query: "@title:$t @tag:{$tag}", + params: .init( + count: 4, + pairs: ["t", "Hello", "tag", "world"] + ), + localonly: true + ) + + guard case .array(let arr1) = result.value else { + Issue.record("Expected array response") + return + } + + let items = Array(arr1) + #expect(items.count == 1) + + guard case .number(let total) = items[0].value else { + Issue.record("Expected first element to be number") + return + } + #expect(total == 0) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftSearch_returnFields() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.SEARCH", + "idx:myIndex", + "@title:Hello", + "RETURN", "2", + "title", "body", + ]), + response: .array([ + .number(1), + .bulkString("doc:1"), + .array([ + .bulkString("title"), + .bulkString("Hello"), + .bulkString("body"), + .bulkString("World"), + ]), + ]) + ) + ) { connection in + let result = try await connection.ftSearch( + index: "idx:myIndex", + query: "@title:Hello", + returnFields: .init( + count: 2, + fields: ["title", "body"] + ) + ) + + guard case .array(let arr1) = result.value else { + Issue.record("Expected array response") + return + } + + let items = Array(arr1) + #expect(items.count == 3) + + guard case .number(let total) = items[0].value else { + Issue.record("Expected first element to be number") + return + } + #expect(total == 1) + + guard case .bulkString = items[1].value else { + Issue.record("Expected second element to be bulk string (doc id)") + return + } + + guard case .array(let fields1) = items[2].value else { + Issue.record("Expected third element to be array of fields") + return + } + + let fields = Array(fields1) + #expect(fields.count == 4) + + // We only assert the shape here, not the exact ByteBuffer contents. + guard case .bulkString = fields[0].value, + case .bulkString = fields[1].value, + case .bulkString = fields[2].value, + case .bulkString = fields[3].value + else { + Issue.record("Expected alternating field/value bulk strings") + return + } + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftSearch_nocontentLimitDialect() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.SEARCH", + "idx:myIndex", + "@title:Hello", + "NOCONTENT", + "LIMIT", "0", "5", + "DIALECT", "2", + ]), + response: .array([ + .number(0) + ]) + ) + ) { connection in + let result = try await connection.ftSearch( + index: "idx:myIndex", + query: "@title:Hello", + nocontent: true, + limit: .init(offset: 0, count: 5), + dialect: .init(dialect: 2) + ) + + guard case .array(let arr1) = result.value else { + Issue.record("Expected array") + return + } + + let items = Array(arr1) + #expect(items.count == 1) + + guard case .number(let total) = items[0].value else { + Issue.record("Expected first element to be number") + return + } + #expect(total == 0) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftAggregate() async throws { + try await testCommandEncodesDecodes( + ( + request: .command(["FT.AGGREGATE", "idx:testIndex", "*"]), + response: .array([ + .number(100), + .array([ + .bulkString("field1"), + .bulkString("value1"), + ]), + ]) + ) + ) { connection in + let result = try await connection.ftAggregate( + index: "idx:testIndex", + query: "*" + ) + + guard case .array(let outer) = result.value else { + Issue.record("Expected array response from FT.AGGREGATE") + return + } + + let rows = Array(outer) + #expect(rows.count == 2) + + guard case .number(let total) = rows[0].value else { + Issue.record("Expected first element to be total row count") + return + } + #expect(total == 100) + + guard case .array(let row1) = rows[1].value else { + Issue.record("Expected first row as array") + return + } + + let fields = Array(row1) + #expect(fields.count == 2) + + guard case .bulkString(let fieldName) = fields[0].value, + case .bulkString(let fieldValue) = fields[1].value + else { + Issue.record("Expected field/value pair in first row") + return + } + + #expect(String(buffer: fieldName) == "field1") + #expect(String(buffer: fieldValue) == "value1") + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_onJson_withTextAlias() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "idx:testIndex", + "ON", "JSON", + "PREFIX", "1", "item:", + "SCHEMA", + "$.name", "AS", "name", "TEXT", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "idx:testIndex", + on: .init(type: .json), + prefix: .init(count: 1, prefixes: ["item:"]), + schema: .init(fields: [ + .init(fieldIdentifier: "$.name", alias: .init(fieldIdentifier: "name"), fieldType: .text) + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_noOn_noPrefix_numeric() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "idx:noOn", + "SCHEMA", + "age", "NUMERIC", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "idx:noOn", + on: nil, + prefix: nil, + schema: .init(fields: [ + .init( + fieldIdentifier: "age", + alias: nil, + fieldType: .numeric + ) + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_tag_separatorOnly() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "idx:tagSepOnly", + "SCHEMA", + "category", "TAG", "SEPARATOR", "|", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "idx:tagSepOnly", + on: nil, + prefix: nil, + schema: .init(fields: [ + .init( + fieldIdentifier: "category", + alias: nil, + fieldType: .tag( + .init( + separator: .init(sep: "|"), + casesensitive: false + ) + ) + ) + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_tag_caseSensitiveOnly() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "idx:tagCaseOnly", + "SCHEMA", + "category", "TAG", "CASESENSITIVE", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "idx:tagCaseOnly", + on: nil, + prefix: nil, + schema: .init(fields: [ + .init( + fieldIdentifier: "category", + alias: nil, + fieldType: .tag( + .init( + separator: nil, + casesensitive: true + ) + ) + ) + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_onHASH_simpleText() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "idx:hashIndex", + "ON", "HASH", + "SCHEMA", + "name", "TEXT", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "idx:hashIndex", + on: .init(type: .hash), + prefix: nil, + schema: .init(fields: [ + .init( + fieldIdentifier: "name", + alias: nil, + fieldType: .text + ) + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_onHASH_tagDefaultSeparator() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "idx:tagIndex", + "ON", "HASH", + "SCHEMA", + "category", "TAG", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "idx:tagIndex", + on: .init(type: .hash), + prefix: nil, + schema: .init(fields: [ + .init( + fieldIdentifier: "category", + alias: nil, + fieldType: .tag( + .init( + separator: nil, + casesensitive: false + ) + ) + ) + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_onHASH_tagWithSeparatorAndCaseSensitive() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "idx:tagIndex2", + "ON", "HASH", + "SCHEMA", + "category", "TAG", "SEPARATOR", "|", "CASESENSITIVE", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "idx:tagIndex2", + on: .init(type: .hash), + prefix: nil, + schema: .init(fields: [ + .init( + fieldIdentifier: "category", + alias: nil, + fieldType: .tag( + .init( + separator: .init(sep: "|"), + casesensitive: true + ) + ) + ) + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_onHASH_multiplePrefixes() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "idx:multiPrefix", + "ON", "HASH", + "PREFIX", "2", "item:", "product:", + "SCHEMA", + "name", "TEXT", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "idx:multiPrefix", + on: .init(type: .hash), + prefix: .init(count: 2, prefixes: ["item:", "product:"]), + schema: .init(fields: [ + .init( + fieldIdentifier: "name", + alias: nil, + fieldType: .text + ) + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_onHASH_multipleFields() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "idx:multiField", + "ON", "HASH", + "SCHEMA", + "name", "TEXT", + "age", "NUMERIC", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "idx:multiField", + on: .init(type: .hash), + prefix: nil, + schema: .init(fields: [ + .init( + fieldIdentifier: "name", + alias: nil, + fieldType: .text + ), + .init( + fieldIdentifier: "age", + alias: nil, + fieldType: .numeric + ), + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_mixedFields_allTypes() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "idx:mixed", + "ON", "HASH", + "PREFIX", "2", "item:", "product:", + "SCHEMA", + "name", "TEXT", + "price", "NUMERIC", + "category", "TAG", "SEPARATOR", "|", + "embedding", "VECTOR", "FLAT", "4", + "TYPE", "FLOAT32", + "DIM", "128", + "DISTANCE_METRIC", "L2", + "BLOCK_SIZE", "1024", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "idx:mixed", + on: .init(type: .hash), + prefix: .init(count: 2, prefixes: ["item:", "product:"]), + schema: .init(fields: [ + .init( + fieldIdentifier: "name", + alias: nil, + fieldType: .text + ), + .init( + fieldIdentifier: "price", + alias: nil, + fieldType: .numeric + ), + .init( + fieldIdentifier: "category", + alias: nil, + fieldType: .tag( + .init( + separator: .init(sep: "|"), + casesensitive: false + ) + ) + ), + .init( + fieldIdentifier: "embedding", + alias: nil, + fieldType: .vector( + .init( + algorithm: .flat, + attrCount: 4, + vectorParams: .init( + type: .init(), + dim: .init(value: 128), + distanceMetric: .init(metric: .l2), + m: nil, + efConstruction: nil, + blockSize: .init(value: 1024) + ) + ) + ) + ), + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_vectorHNSW() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "my_index_name", + "SCHEMA", + "my_hash_field_key", + "VECTOR", + "HNSW", + "10", + "TYPE", "FLOAT32", + "DIM", "20", + "DISTANCE_METRIC", "COSINE", + "M", "4", + "EF_CONSTRUCTION", "100", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "my_index_name", + on: nil, + prefix: nil, + schema: .init(fields: [ + .init( + fieldIdentifier: "my_hash_field_key", + alias: nil, + fieldType: .vector( + .init( + algorithm: .hnsw, + attrCount: 10, + vectorParams: .init( + type: .init(), + dim: .init(value: 20), + distanceMetric: .init(metric: .cosine), + m: .init(value: 4), + efConstruction: .init(value: 100) + ) + ) + ) + ) + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_vectorFLAT() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "my_flat_index", + "SCHEMA", + "embedding", + "VECTOR", + "FLAT", + "4", + "TYPE", "FLOAT32", + "DIM", "128", + "DISTANCE_METRIC", "L2", + "BLOCK_SIZE", "1024", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "my_flat_index", + on: nil, + prefix: nil, + schema: .init(fields: [ + .init( + fieldIdentifier: "embedding", + alias: nil, + fieldType: .vector( + .init( + algorithm: .flat, + attrCount: 4, + vectorParams: .init( + type: .init(), + dim: .init(value: 128), + distanceMetric: .init(metric: .l2), + blockSize: .init(value: 1024) + ) + ) + ) + ) + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftCreate_vectorFlat_ipMetric() async throws { + try await testCommandEncodesDecodes( + ( + request: .command([ + "FT.CREATE", + "idx:flatIP", + "SCHEMA", + "embedding", + "VECTOR", + "FLAT", + "8", + "TYPE", "FLOAT32", + "DIM", "64", + "DISTANCE_METRIC", "IP", + ]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftCreate( + indexName: "idx:flatIP", + on: nil, + prefix: nil, + schema: .init(fields: [ + .init( + fieldIdentifier: "embedding", + alias: nil, + fieldType: .vector( + .init( + algorithm: .flat, + attrCount: 8, + vectorParams: .init( + type: .init(), + dim: .init(value: 64), + distanceMetric: .init(metric: .ip), + m: nil, + efConstruction: nil, + blockSize: nil + ) + ) + ) + ) + ]) + ) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftDropindex() async throws { + try await testCommandEncodesDecodes( + ( + request: .command(["FT.DROPINDEX", "idx:myIndex"]), + response: .simpleString("OK") + ) + ) { connection in + try await connection.ftDropindex("idx:myIndex") + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftInfo() async throws { + try await testCommandEncodesDecodes( + ( + request: .command(["FT.INFO", "idx:myIndex"]), + response: .array([ + .bulkString("index_name"), + .bulkString("myIndex"), + .bulkString("num_docs"), + .number(100), + .bulkString("num_terms"), + .number(500), + ]) + ), + ( + request: .command(["FT.INFO", "idx:myIndex", "LOCAL"]), + response: .array([ + .bulkString("index_name"), + .bulkString("myIndex"), + .bulkString("num_docs"), + .number(50), + ]) + ), + ( + request: .command(["FT.INFO", "idx:myIndex", "GLOBAL"]), + response: .array([ + .bulkString("index_name"), + .bulkString("idx:myIndex"), + .bulkString("num_docs"), + .number(200), + ]) + ) + ) { connection in + _ = try await connection.ftInfo("idx:myIndex") + _ = try await connection.ftInfo("idx:myIndex", scope: .local) + _ = try await connection.ftInfo("idx:myIndex", scope: .global) + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftList() async throws { + try await testCommandEncodesDecodes( + ( + request: .command(["FT._LIST"]), + response: .array([ + .bulkString("idx:index1"), + .bulkString("idx:index2"), + .bulkString("idx:myIndex"), + ]) + ) + ) { connection in + _ = try await connection.ftList() + } + } + + @Test + @available(valkeySwift 1.0, *) + func ftDebug() async throws { + try await testCommandEncodesDecodes( + ( + request: .command(["FT._DEBUG"]), + response: .array([ + .bulkString("debug_info"), + .bulkString("some debug data"), + ]) + ) + ) { connection in + _ = try await connection.ftDebug() + } + } + } } @available(valkeySwift 1.0, *)