diff --git a/Sources/SwiftUIQuery/Core/QueryKey.swift b/Sources/SwiftUIQuery/Core/QueryKey.swift index 14c78ad..f6e7e75 100644 --- a/Sources/SwiftUIQuery/Core/QueryKey.swift +++ b/Sources/SwiftUIQuery/Core/QueryKey.swift @@ -4,61 +4,85 @@ import Foundation /// A protocol that represents a unique identifier for queries /// Equivalent to TanStack Query's QueryKey (ReadonlyArray) -public protocol QueryKey: Sendable, Hashable, Codable { +public protocol QueryKey: Sendable, Equatable { /// Convert the query key to a string hash for identification var queryHash: String { get } } -/// Default QueryKey implementation using arrays of strings -public struct ArrayQueryKey: QueryKey { - public let components: [String] - - public init(_ components: String...) { - self.components = components +extension QueryKey where Self: Hashable & Codable { + public var queryHash: String { + let jsonEncoder = JSONEncoder() + jsonEncoder.outputFormatting = .sortedKeys // stable output with key sorted + guard let jsonData = try? jsonEncoder.encode(self) else { + return "\(hashValue)" + } + return String(decoding: jsonData, as: UTF8.self) } +} - public init(_ components: [String]) { - self.components = components - } +// MARK: - QueryKey Extensions for Common Types +extension String: QueryKey { public var queryHash: String { - // Create a deterministic hash similar to TanStack Query's approach - guard let jsonData = try? JSONEncoder().encode(components.sorted()), - let jsonString = String(data: jsonData, encoding: .utf8) else { - return components.sorted().joined(separator: "|") - } - return jsonString + self } } -/// Generic QueryKey implementation for any Codable type -public struct GenericQueryKey: QueryKey { - public let value: T +extension Array: QueryKey where Element: Hashable & Codable {} +extension Dictionary: QueryKey where Key: Hashable & Codable, Value: Hashable & Codable {} - public init(_ value: T) { - self.value = value +public typealias QueryKeyCodable = Codable & Hashable & Sendable +public struct KeyTuple2: QueryKey, QueryKeyCodable { + public let key1: K1 + public let key2: K2 + + public init(_ key1: K1, _ key2: K2) { + self.key1 = key1 + self.key2 = key2 } - public var queryHash: String { - guard let jsonData = try? JSONEncoder().encode(value), - let jsonString = String(data: jsonData, encoding: .utf8) else { - return String(describing: value) - } - return jsonString + public init(_ key1: (some Any).Type, _ key2: K2) where K1 == String { + self.key1 = String(describing: key1) + self.key2 = key2 } } -// MARK: - QueryKey Extensions for Common Types +public struct KeyTuple3: QueryKey, QueryKeyCodable { + public let key1: K1 + public let key2: K2 + public let key3: K3 -extension String: QueryKey { - public var queryHash: String { - self + public init(_ key1: K1, _ key2: K2, _ key3: K3) { + self.key1 = key1 + self.key2 = key2 + self.key3 = key3 + } + + public init(_ key1: (some Any).Type, _ key2: K2, _ key3: K3) where K1 == String { + self.key1 = String(describing: key1) + self.key2 = key2 + self.key3 = key3 } } -extension [String]: QueryKey { - public var queryHash: String { - // Create a deterministic hash by joining sorted components - sorted().joined(separator: "|") +public struct KeyTuple4: QueryKey, + QueryKeyCodable { + public let key1: K1 + public let key2: K2 + public let key3: K3 + public let key4: K4 + + public init(_ key1: K1, _ key2: K2, _ key3: K3, _ key4: K4) { + self.key1 = key1 + self.key2 = key2 + self.key3 = key3 + self.key4 = key4 + } + + public init(_ key1: (some Any).Type, _ key2: K2, _ key3: K3, _ key4: K4) where K1 == String { + self.key1 = String(describing: key1) + self.key2 = key2 + self.key3 = key3 + self.key4 = key4 } } diff --git a/Sources/SwiftUIQuery/UseInfiniteQuery.swift b/Sources/SwiftUIQuery/UseInfiniteQuery.swift index bb9e531..f638e18 100644 --- a/Sources/SwiftUIQuery/UseInfiniteQuery.swift +++ b/Sources/SwiftUIQuery/UseInfiniteQuery.swift @@ -304,9 +304,9 @@ public struct UseInfiniteQuery< /// Additional convenience methods for SwiftUI integration extension UseInfiniteQuery { - /// Create a UseInfiniteQuery with string-based query key + /// Create UseInfiniteQuery with KeyTuple2-based query key /// - Parameters: - /// - queryKey: String-based query key + /// - queryKey: KeyTuple2 identifier for the query /// - queryFn: Function that fetches page data /// - getNextPageParam: Function to get next page parameter from pages /// - getPreviousPageParam: Function to determine the previous page parameter @@ -323,9 +323,135 @@ extension UseInfiniteQuery { /// - enabled: Whether the query should execute automatically (default: true) /// - queryClient: Optional query client (uses shared instance if nil) /// - content: View builder that receives the query result - public init( - queryKey: String, - queryFn: @escaping @Sendable (String, TPageParam?) async throws -> TData, + public init( + queryKey: KeyTuple2, + queryFn: @escaping @Sendable (KeyTuple2, TPageParam?) async throws -> TData, + getNextPageParam: @escaping GetNextPageParamFunction, + getPreviousPageParam: GetPreviousPageParamFunction? = nil, + initialPageParam: TPageParam? = nil, + maxPages: Int? = nil, + retryConfig: RetryConfig = RetryConfig(), + networkMode: NetworkMode = .online, + staleTime: TimeInterval = 0, + gcTime: TimeInterval = defaultGcTime, + refetchTriggers: RefetchTriggers = .default, + refetchOnAppear: RefetchOnAppear = .ifStale, + structuralSharing: Bool = true, + meta: QueryMeta? = nil, + enabled: Bool = true, + queryClient: QueryClient? = nil, + @ViewBuilder content: @escaping (UseInfiniteQueryResult) -> Content + ) where TKey == KeyTuple2 { + let options = InfiniteQueryOptions, TPageParam>( + queryKey: queryKey, + queryFn: queryFn, + getNextPageParam: getNextPageParam, + getPreviousPageParam: getPreviousPageParam, + initialPageParam: initialPageParam, + maxPages: maxPages, + retryConfig: retryConfig, + networkMode: networkMode, + staleTime: staleTime, + gcTime: gcTime, + refetchTriggers: refetchTriggers, + refetchOnAppear: refetchOnAppear, + structuralSharing: structuralSharing, + meta: meta, + enabled: enabled + ) + + self.init( + options: options, + queryClient: queryClient, + content: content + ) + } + + /// Create UseInfiniteQuery with KeyTuple3-based query key + /// - Parameters: + /// - queryKey: KeyTuple3 identifier for the query + /// - queryFn: Function that fetches page data + /// - getNextPageParam: Function to get next page parameter from pages + /// - getPreviousPageParam: Function to determine the previous page parameter + /// - initialPageParam: Initial page parameter for the first page + /// - maxPages: Maximum number of pages to retain + /// - retryConfig: Configuration for retry behavior (default: RetryConfig()) + /// - networkMode: Network behavior configuration (default: .online) + /// - staleTime: Time before data is considered stale (default: 0) + /// - gcTime: Time before unused data is garbage collected (default: 5 minutes) + /// - refetchTriggers: Configuration for automatic refetching triggers (default: .default) + /// - refetchOnAppear: When to refetch data on view appear (default: .ifStale) + /// - structuralSharing: Whether to use structural sharing for performance (default: true) + /// - meta: Arbitrary metadata for this query + /// - enabled: Whether the query should execute automatically (default: true) + /// - queryClient: Optional query client (uses shared instance if nil) + /// - content: View builder that receives the query result + public init( + queryKey: KeyTuple3, + queryFn: @escaping @Sendable (KeyTuple3, TPageParam?) async throws -> TData, + getNextPageParam: @escaping GetNextPageParamFunction, + getPreviousPageParam: GetPreviousPageParamFunction? = nil, + initialPageParam: TPageParam? = nil, + maxPages: Int? = nil, + retryConfig: RetryConfig = RetryConfig(), + networkMode: NetworkMode = .online, + staleTime: TimeInterval = 0, + gcTime: TimeInterval = defaultGcTime, + refetchTriggers: RefetchTriggers = .default, + refetchOnAppear: RefetchOnAppear = .ifStale, + structuralSharing: Bool = true, + meta: QueryMeta? = nil, + enabled: Bool = true, + queryClient: QueryClient? = nil, + @ViewBuilder content: @escaping (UseInfiniteQueryResult) -> Content + ) where TKey == KeyTuple3 { + let options = InfiniteQueryOptions, TPageParam>( + queryKey: queryKey, + queryFn: queryFn, + getNextPageParam: getNextPageParam, + getPreviousPageParam: getPreviousPageParam, + initialPageParam: initialPageParam, + maxPages: maxPages, + retryConfig: retryConfig, + networkMode: networkMode, + staleTime: staleTime, + gcTime: gcTime, + refetchTriggers: refetchTriggers, + refetchOnAppear: refetchOnAppear, + structuralSharing: structuralSharing, + meta: meta, + enabled: enabled + ) + + self.init( + options: options, + queryClient: queryClient, + content: content + ) + } + + /// Create UseInfiniteQuery with KeyTuple4-based query key + /// - Parameters: + /// - queryKey: KeyTuple4 identifier for the query + /// - queryFn: Function that fetches page data + /// - getNextPageParam: Function to get next page parameter from pages + /// - getPreviousPageParam: Function to determine the previous page parameter + /// - initialPageParam: Initial page parameter for the first page + /// - maxPages: Maximum number of pages to retain + /// - retryConfig: Configuration for retry behavior (default: RetryConfig()) + /// - networkMode: Network behavior configuration (default: .online) + /// - staleTime: Time before data is considered stale (default: 0) + /// - gcTime: Time before unused data is garbage collected (default: 5 minutes) + /// - refetchTriggers: Configuration for automatic refetching triggers (default: .default) + /// - refetchOnAppear: When to refetch data on view appear (default: .ifStale) + /// - structuralSharing: Whether to use structural sharing for performance (default: true) + /// - meta: Arbitrary metadata for this query + /// - enabled: Whether the query should execute automatically (default: true) + /// - queryClient: Optional query client (uses shared instance if nil) + /// - content: View builder that receives the query result + public init( + queryKey: KeyTuple4, + queryFn: @escaping @Sendable (KeyTuple4, TPageParam?) async throws -> TData, getNextPageParam: @escaping GetNextPageParamFunction, getPreviousPageParam: GetPreviousPageParamFunction? = nil, initialPageParam: TPageParam? = nil, @@ -341,8 +467,8 @@ extension UseInfiniteQuery { enabled: Bool = true, queryClient: QueryClient? = nil, @ViewBuilder content: @escaping (UseInfiniteQueryResult) -> Content - ) where TKey == String { - let options = InfiniteQueryOptions( + ) where TKey == KeyTuple4 { + let options = InfiniteQueryOptions, TPageParam>( queryKey: queryKey, queryFn: queryFn, getNextPageParam: getNextPageParam, diff --git a/Sources/SwiftUIQuery/UseQuery.swift b/Sources/SwiftUIQuery/UseQuery.swift index 6f7ac9f..7149106 100644 --- a/Sources/SwiftUIQuery/UseQuery.swift +++ b/Sources/SwiftUIQuery/UseQuery.swift @@ -191,9 +191,9 @@ public struct UseQuery: View { // MARK: - Convenience Extensions extension UseQuery { - /// Create UseQuery with string-based query key + /// Create UseQuery with KeyTuple2-based query key /// - Parameters: - /// - queryKey: String identifier for the query + /// - queryKey: KeyTuple2 identifier for the query /// - queryFn: Function that fetches the data /// - retryConfig: Configuration for retry behavior (default: RetryConfig()) /// - networkMode: Network behavior configuration (default: .online) @@ -208,9 +208,9 @@ extension UseQuery { /// - enabled: Whether the query should execute automatically (default: true) /// - queryClient: Optional query client (uses shared instance if nil) /// - content: View builder that receives the query result - public init( - queryKey: String, - queryFn: @escaping @Sendable (String) async throws -> TData, + public init( + queryKey: KeyTuple2, + queryFn: @escaping @Sendable (KeyTuple2) async throws -> TData, retryConfig: RetryConfig = RetryConfig(), networkMode: NetworkMode = .online, staleTime: TimeInterval = 0, @@ -224,8 +224,8 @@ extension UseQuery { enabled: Bool = true, queryClient: QueryClient? = nil, @ViewBuilder content: @escaping (UseQueryResult) -> Content - ) where TKey == String { - let options = QueryOptions( + ) where TKey == KeyTuple2 { + let options = QueryOptions>( queryKey: queryKey, queryFn: queryFn, retryConfig: retryConfig, @@ -249,9 +249,9 @@ extension UseQuery { self.content = content } - /// Create UseQuery with array-based query key + /// Create UseQuery with KeyTuple3-based query key /// - Parameters: - /// - queryKey: Array identifier for the query + /// - queryKey: KeyTuple3 identifier for the query /// - queryFn: Function that fetches the data /// - retryConfig: Configuration for retry behavior (default: RetryConfig()) /// - networkMode: Network behavior configuration (default: .online) @@ -266,9 +266,67 @@ extension UseQuery { /// - enabled: Whether the query should execute automatically (default: true) /// - queryClient: Optional query client (uses shared instance if nil) /// - content: View builder that receives the query result - public init( - queryKey: [String], - queryFn: @escaping @Sendable ([String]) async throws -> TData, + public init( + queryKey: KeyTuple3, + queryFn: @escaping @Sendable (KeyTuple3) async throws -> TData, + retryConfig: RetryConfig = RetryConfig(), + networkMode: NetworkMode = .online, + staleTime: TimeInterval = 0, + gcTime: TimeInterval = defaultGcTime, + refetchTriggers: RefetchTriggers = .default, + refetchOnAppear: RefetchOnAppear = .ifStale, + initialData: TData? = nil, + initialDataFunction: InitialDataFunction? = nil, + structuralSharing: Bool = true, + meta: QueryMeta? = nil, + enabled: Bool = true, + queryClient: QueryClient? = nil, + @ViewBuilder content: @escaping (UseQueryResult) -> Content + ) where TKey == KeyTuple3 { + let options = QueryOptions>( + queryKey: queryKey, + queryFn: queryFn, + retryConfig: retryConfig, + networkMode: networkMode, + staleTime: staleTime, + gcTime: gcTime, + refetchTriggers: refetchTriggers, + refetchOnAppear: refetchOnAppear, + initialData: initialData, + initialDataFunction: initialDataFunction, + structuralSharing: structuralSharing, + meta: meta, + enabled: enabled + ) + + // Store options and client for later use + self.options = options + self.queryClient = queryClient + let client = queryClient ?? QueryClientProvider.shared.queryClient + self._observer = StateObject(wrappedValue: QueryObserver(client: client, options: options)) + self.content = content + } + + /// Create UseQuery with KeyTuple4-based query key + /// - Parameters: + /// - queryKey: KeyTuple4 identifier for the query + /// - queryFn: Function that fetches the data + /// - retryConfig: Configuration for retry behavior (default: RetryConfig()) + /// - networkMode: Network behavior configuration (default: .online) + /// - staleTime: Time before data is considered stale (default: 0) + /// - gcTime: Time before unused data is garbage collected (default: 5 minutes) + /// - refetchTriggers: Configuration for automatic refetching triggers (default: .default) + /// - refetchOnAppear: When to refetch data on view appear (default: .ifStale) + /// - initialData: Initial data to show while the query loads + /// - initialDataFunction: Function to provide initial data + /// - structuralSharing: Whether to use structural sharing for performance (default: true) + /// - meta: Arbitrary metadata for this query + /// - enabled: Whether the query should execute automatically (default: true) + /// - queryClient: Optional query client (uses shared instance if nil) + /// - content: View builder that receives the query result + public init( + queryKey: KeyTuple4, + queryFn: @escaping @Sendable (KeyTuple4) async throws -> TData, retryConfig: RetryConfig = RetryConfig(), networkMode: NetworkMode = .online, staleTime: TimeInterval = 0, @@ -282,8 +340,8 @@ extension UseQuery { enabled: Bool = true, queryClient: QueryClient? = nil, @ViewBuilder content: @escaping (UseQueryResult) -> Content - ) where TKey == [String] { - let options = QueryOptions( + ) where TKey == KeyTuple4 { + let options = QueryOptions>( queryKey: queryKey, queryFn: queryFn, retryConfig: retryConfig, diff --git a/Tests/SwiftUIQueryTests/FoundationalTypesTests.swift b/Tests/SwiftUIQueryTests/FoundationalTypesTests.swift index 2444655..5003006 100644 --- a/Tests/SwiftUIQueryTests/FoundationalTypesTests.swift +++ b/Tests/SwiftUIQueryTests/FoundationalTypesTests.swift @@ -5,34 +5,6 @@ import Foundation @Suite("Foundational Types Tests") @MainActor struct FoundationalTypesTests { - // MARK: - QueryKey Tests - - @Test("ArrayQueryKey creates consistent hashes") - func arrayQueryKeyHashing() { - let key1 = ArrayQueryKey("users", "123") - let key2 = ArrayQueryKey("users", "123") - let key3 = ArrayQueryKey("users", "456") - - #expect(key1.queryHash == key2.queryHash) - #expect(key1.queryHash != key3.queryHash) - #expect(key1 == key2) - #expect(key1 != key3) - } - - @Test("GenericQueryKey works with different types") - func genericQueryKeyTypes() { - let stringKey = GenericQueryKey("test") - let intKey = GenericQueryKey(42) - let arrayKey = GenericQueryKey(["a", "b", "c"]) - - #expect(stringKey.value == "test") - #expect(intKey.value == 42) - #expect(arrayKey.value == ["a", "b", "c"]) - - // Different types should have different hashes - #expect(stringKey.queryHash != intKey.queryHash) - } - // MARK: - Status Enum Tests @Test("QueryStatus enum values") diff --git a/Tests/SwiftUIQueryTests/KeyTupleTests.swift b/Tests/SwiftUIQueryTests/KeyTupleTests.swift new file mode 100644 index 0000000..261a6d7 --- /dev/null +++ b/Tests/SwiftUIQueryTests/KeyTupleTests.swift @@ -0,0 +1,199 @@ +import Testing +import SwiftUI +@testable import SwiftUIQuery + +@Suite("KeyTuple Tests") +struct KeyTupleTests { + @Test("KeyTuple2 with QueryOptions") + func keyTuple2QueryOptions() async { + let key = KeyTuple2("users", 123) + + let options = QueryOptions>( + queryKey: key, + queryFn: { (key: KeyTuple2) async throws -> String in + return "User \(key.key2)" + } + ) + + #expect(options.queryKey == key) + #expect(options.queryKey.key1 == "users") + #expect(options.queryKey.key2 == 123) + } + + @Test("KeyTuple3 with QueryOptions") + func keyTuple3QueryOptions() async { + let key = KeyTuple3("users", 123, true) + + let options = QueryOptions>( + queryKey: key, + queryFn: { (key: KeyTuple3) async throws -> String in + return "User \(key.key2) active: \(key.key3)" + } + ) + + #expect(options.queryKey == key) + #expect(options.queryKey.key1 == "users") + #expect(options.queryKey.key2 == 123) + #expect(options.queryKey.key3 == true) + } + + @Test("KeyTuple4 with QueryOptions") + func keyTuple4QueryOptions() async { + let key = KeyTuple4("users", 123, true, "admin") + + let options = QueryOptions>( + queryKey: key, + queryFn: { (key: KeyTuple4) async throws -> String in + return "User \(key.key2) active: \(key.key3) role: \(key.key4)" + } + ) + + #expect(options.queryKey == key) + #expect(options.queryKey.key1 == "users") + #expect(options.queryKey.key2 == 123) + #expect(options.queryKey.key3 == true) + #expect(options.queryKey.key4 == "admin") + } + + @Test("KeyTuple2 with Type as first key") + func keyTuple2WithType() async { + struct User: Codable, Sendable { + let id: Int + let name: String + } + + let key = KeyTuple2(User.self, 123) + + #expect(key.key1 == "User") + #expect(key.key2 == 123) + + let options = QueryOptions>( + queryKey: key, + queryFn: { (key: KeyTuple2) async throws -> User in + return User(id: key.key2, name: "Test User") + } + ) + + #expect(options.queryKey == key) + } + + @Test("KeyTuple3 with Type as first key") + func keyTuple3WithType() async { + struct Post: Codable, Sendable { + let id: Int + let title: String + } + + let key = KeyTuple3(Post.self, 456, "published") + + #expect(key.key1 == "Post") + #expect(key.key2 == 456) + #expect(key.key3 == "published") + + let options = QueryOptions>( + queryKey: key, + queryFn: { (key: KeyTuple3) async throws -> Post in + return Post(id: key.key2, title: "Post \(key.key3)") + } + ) + + #expect(options.queryKey == key) + } + + @Test("KeyTuple4 with Type as first key") + func keyTuple4WithType() async { + struct Comment: Codable, Sendable { + let id: Int + let text: String + } + + let key = KeyTuple4(Comment.self, 789, "user123", true) + + #expect(key.key1 == "Comment") + #expect(key.key2 == 789) + #expect(key.key3 == "user123") + #expect(key.key4 == true) + + let options = QueryOptions>( + queryKey: key, + queryFn: { (key: KeyTuple4) async throws -> Comment in + return Comment(id: key.key2, text: "Comment from \(key.key3)") + } + ) + + #expect(options.queryKey == key) + } + + @Test("KeyTuple queryHash uniqueness") + func keyTupleQueryHash() { + let key1 = KeyTuple2("users", 123) + let key2 = KeyTuple2("users", 456) + let key3 = KeyTuple2("posts", 123) + + #expect(key1.queryHash != key2.queryHash) + #expect(key1.queryHash != key3.queryHash) + #expect(key2.queryHash != key3.queryHash) + + let key4 = KeyTuple3("users", 123, true) + let key5 = KeyTuple3("users", 123, false) + + #expect(key4.queryHash != key5.queryHash) + + let key6 = KeyTuple4("comments", 1, "user", true) + let key7 = KeyTuple4("comments", 1, "admin", true) + + #expect(key6.queryHash != key7.queryHash) + } + + @Test("KeyTuple equality") + func keyTupleEquality() { + let key1 = KeyTuple2("users", 123) + let key2 = KeyTuple2("users", 123) + let key3 = KeyTuple2("users", 456) + + #expect(key1 == key2) + #expect(key1 != key3) + + let key4 = KeyTuple3("posts", 1, true) + let key5 = KeyTuple3("posts", 1, true) + let key6 = KeyTuple3("posts", 1, false) + + #expect(key4 == key5) + #expect(key4 != key6) + + let key7 = KeyTuple4("comments", 1, "a", true) + let key8 = KeyTuple4("comments", 1, "a", true) + let key9 = KeyTuple4("comments", 1, "b", true) + + #expect(key7 == key8) + #expect(key7 != key9) + } + + // MARK: - InfiniteQueryOptions KeyTuple Tests + + @Test("InfiniteQueryOptions with KeyTuple2") + func infiniteQueryOptionsKeyTuple2() async { + struct Post: Sendable, Codable { + let id: Int + let title: String + } + + let key = KeyTuple2("posts", 10) + + let options = InfiniteQueryOptions<[Post], QueryError, KeyTuple2, Int>( + queryKey: key, + queryFn: { (_: KeyTuple2, pageParam: Int?) async throws -> [Post] in + let page = pageParam ?? 0 + return [Post(id: page, title: "Post \(page)")] + }, + getNextPageParam: { pages in + return pages.count < 3 ? pages.count : nil + }, + initialPageParam: 0 + ) + + #expect(options.queryKey == key) + #expect(options.queryKey.key1 == "posts") + #expect(options.queryKey.key2 == 10) + } +} diff --git a/Tests/SwiftUIQueryTests/QueryLoggerTests.swift b/Tests/SwiftUIQueryTests/QueryLoggerTests.swift index e8f9d3d..383f670 100644 --- a/Tests/SwiftUIQueryTests/QueryLoggerTests.swift +++ b/Tests/SwiftUIQueryTests/QueryLoggerTests.swift @@ -87,7 +87,7 @@ struct QueryLoggerTests { let client = QueryClient() // Test cache operations with logging enabled - let queryKey = ArrayQueryKey("test", "user") + let queryKey = ["test", "user"] let testData = TestUser(id: "123", name: "Test User", email: "test@example.com") // This should trigger cache miss -> cache hit logging diff --git a/Tests/SwiftUIQueryTests/UseInfiniteQueryTests.swift b/Tests/SwiftUIQueryTests/UseInfiniteQueryTests.swift index 27a260a..aa27df1 100644 --- a/Tests/SwiftUIQueryTests/UseInfiniteQueryTests.swift +++ b/Tests/SwiftUIQueryTests/UseInfiniteQueryTests.swift @@ -697,7 +697,7 @@ struct InfiniteQueryTests { // After refetch, new data should be present #expect(refetchedData.pages.count == 1) #expect(infiniteQuery.state.data?.pages.count == 1) - #expect(await fetchCounter.count == 2) // Verify fetch was called twice + #expect(fetchCounter.count == 2) // Verify fetch was called twice } } diff --git a/Tests/SwiftUIQueryTests/UseQueryTests.swift b/Tests/SwiftUIQueryTests/UseQueryTests.swift index 50d89fd..23b794c 100644 --- a/Tests/SwiftUIQueryTests/UseQueryTests.swift +++ b/Tests/SwiftUIQueryTests/UseQueryTests.swift @@ -66,6 +66,25 @@ struct UseQueryTests { #expect(useQuery.testObserver.options.enabled == true) } + @Test("UseQuery initializes with Dictionary initializer") + func initializeWithDictionaryKey() { + let queryKey: [String: String] = ["type": "user", "id": "123", "status": "active"] + + let useQuery = UseQuery( + queryKey: queryKey, + queryFn: { (key: [String: String]) in + TestUser(id: key["id"] ?? "", name: "User \(key["id"] ?? "")") + } + ) { result in + Text("User: \(result.data?.name ?? "Loading")") + } + + #expect(useQuery.testObserver.options.queryKey == queryKey) + #expect(useQuery.testObserver.options.staleTime == 0) + #expect(useQuery.testObserver.options.gcTime == defaultGcTime) + #expect(useQuery.testObserver.options.enabled == true) + } + @Test("UseQuery initializes with custom parameters") func initializeWithCustomParameters() { let useQuery = UseQuery( @@ -165,14 +184,42 @@ struct UseQueryTests { @Test("Array QueryKey extension works") func arrayQueryKeyExtension() { let key = ["posts", "user-123"] - #expect(key.queryHash == "posts|user-123") + // Arrays use JSON encoding for queryHash + #expect(key.queryHash == "[\"posts\",\"user-123\"]") } - @Test("Array QueryKey extension sorts consistently") - func arrayQueryKeySorting() { + @Test("Array QueryKey extension generates unique hashes") + func arrayQueryKeyUniqueness() { let key1 = ["user-123", "posts"] let key2 = ["posts", "user-123"] + // Different order means different hash + #expect(key1.queryHash != key2.queryHash) + + let key3 = ["posts", "user-456"] + let key4 = ["posts", "user-123"] + // Different values mean different hash + #expect(key3.queryHash != key4.queryHash) + } + + @Test("Dictionary QueryKey extension works") + func dictionaryQueryKeyExtension() { + let key: [String: String] = ["type": "user", "id": "123"] + // Dictionaries use JSON encoding with sorted keys for queryHash + let expectedHash = "{\"id\":\"123\",\"type\":\"user\"}" + #expect(key.queryHash == expectedHash) + } + + @Test("Dictionary QueryKey generates consistent hashes") + func dictionaryQueryKeyConsistency() { + let key1: [String: String] = ["type": "post", "id": "456", "status": "published"] + let key2: [String: String] = ["status": "published", "type": "post", "id": "456"] + // Same keys and values in different order should produce same hash (sorted keys) #expect(key1.queryHash == key2.queryHash) + + let key3: [String: String] = ["type": "post", "id": "789"] + let key4: [String: String] = ["type": "post", "id": "456"] + // Different values should produce different hashes + #expect(key3.queryHash != key4.queryHash) } // MARK: - Environment Support Tests