Skip to content

Commit 64d7580

Browse files
feat: [WIP] Deep check of Dictionary schema
1 parent b6dc16b commit 64d7580

File tree

5 files changed

+562
-41
lines changed

5 files changed

+562
-41
lines changed

Sources/Confidence/ConfidenceValue.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ public class ConfidenceValue: Equatable, Codable, CustomStringConvertible {
218218
return nil
219219
}
220220

221-
private func asNative() -> Any {
221+
func asNative() -> Any {
222222
switch value {
223223
case .boolean(let bool):
224224
return bool

Sources/Confidence/FlagEvaluation.swift

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ extension FlagResolution {
7373
}
7474

7575
let parsedValue = try getValueForPath(path: parsedKey.path, value: value)
76-
let typedValue: T? = try getTyped(value: parsedValue)
76+
let typedValue: T? = try getTyped(value: parsedValue, defaultValue: defaultValue)
7777

7878
if resolvedFlag.resolveReason == .match {
7979
var resolveReason: ResolveReason = .match
@@ -178,7 +178,7 @@ extension FlagResolution {
178178
}
179179

180180
// swiftlint:disable:next cyclomatic_complexity
181-
private func getTyped<T>(value: ConfidenceValue) throws -> T? {
181+
private func getTyped<T>(value: ConfidenceValue, defaultValue: T) throws -> T? {
182182
if let value = self as? T {
183183
return value
184184
}
@@ -207,15 +207,99 @@ extension FlagResolution {
207207
case .list:
208208
result = value.asList()
209209
case .structure:
210-
result = value.asStructureNative()
210+
guard let defaultDict = defaultValue as? [String: Any] else {
211+
throw ConfidenceError
212+
.typeMismatch(
213+
message: "Expected a Dictionary as default value, but got a different type"
214+
)
215+
}
216+
guard let structure = value.asStructure() else {
217+
throw ConfidenceError
218+
.typeMismatch(
219+
message: "Unexpected error with internal ConfidenceStruct conversion"
220+
)
221+
}
222+
try validateDictionaryStructureCompatibility(
223+
structure: structure,
224+
defaultDict: defaultDict
225+
)
226+
// Filter only the entries in the original default value
227+
var filteredNative: [String: Any] = [:]
228+
for requiredKey in defaultDict.keys {
229+
if let confidenceValue = structure[requiredKey] {
230+
filteredNative[requiredKey] = confidenceValue.asNative()
231+
}
232+
}
233+
result = filteredNative
211234
case .null:
212235
return nil
213236
}
214237

215238
if let typedResult = result as? T {
216239
return typedResult
240+
} else {
241+
throw ConfidenceError.typeMismatch(message: "Value \(value) cannot be cast to \(T.self)")
242+
}
243+
}
244+
245+
private func validateDictionaryStructureCompatibility(
246+
structure: ConfidenceStruct,
247+
defaultDict: [String: Any]
248+
) throws {
249+
for defaultValueKey in defaultDict.keys {
250+
guard let confidenceValue = structure[defaultValueKey] else {
251+
throw ConfidenceError.typeMismatch(
252+
message: "Default value key '\(defaultValueKey)' not found in flag"
253+
)
254+
}
255+
256+
let defaultValueValue: Any? = defaultDict[defaultValueKey]
257+
if !isValueCompatibleWithDefaultValue(
258+
confidenceType: confidenceValue.type(),
259+
defaultValue: defaultValueValue
260+
) {
261+
let message = "Default value key '\(defaultValueKey)' has incompatible type. " +
262+
"Expected from flag is '\(printIntrinsicType(of: defaultValueValue))', " +
263+
"got '\(confidenceValue.type())'"
264+
throw ConfidenceError.typeMismatch(message: message)
265+
}
266+
}
267+
}
268+
269+
private func printIntrinsicType(of value: Any?) -> String {
270+
if let unwrapped = value {
271+
return "\(type(of: unwrapped))"
272+
} else {
273+
return "nil"
274+
}
275+
}
276+
277+
private func isValueCompatibleWithDefaultValue(
278+
confidenceType: ConfidenceValueType,
279+
defaultValue: Any?
280+
) -> Bool {
281+
switch defaultValue {
282+
case is String:
283+
return confidenceType == .string
284+
case is Int, is Int32, is Int64:
285+
return confidenceType == .integer
286+
case is Double, is Float:
287+
return confidenceType == .double
288+
case is Bool:
289+
return confidenceType == .boolean
290+
case is Date:
291+
return confidenceType == .timestamp
292+
case is DateComponents:
293+
return confidenceType == .date
294+
case is Array<Any>:
295+
return confidenceType == .list
296+
case is [String: Any]:
297+
return confidenceType == .structure
298+
case .none: // TODO This requires extra care
299+
return confidenceType == .null
300+
default:
301+
return false
217302
}
218-
throw ConfidenceError.typeMismatch(message: "Value \(value) cannot be cast to \(T.self)")
219303
}
220304

221305
private func getValueForPath(path: [String], value: ConfidenceValue) throws -> ConfidenceValue {

Tests/ConfidenceProviderTests/ConfidenceProviderTest.swift

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// swiftlint:disable file_length
12
import Foundation
23
import ConfidenceProvider
34
import Combine
@@ -6,6 +7,7 @@ import XCTest
67

78
@testable import Confidence
89

10+
// swiftlint:disable:next type_body_length
911
class ConfidenceProviderTest: XCTestCase {
1012
func testErrorFetchOnInit() async throws {
1113
let readyExpectation = XCTestExpectation(description: "Ready")
@@ -430,7 +432,7 @@ class ConfidenceProviderTest: XCTestCase {
430432
}
431433

432434
XCTAssertEqual(resultMap["width"], .integer(200))
433-
XCTAssertEqual(resultMap["height"], .integer(400))
435+
XCTAssertNil(resultMap["height"])
434436
XCTAssertEqual(evaluation.variant, "control")
435437
XCTAssertEqual(evaluation.reason, "targetingMatch")
436438
}
@@ -538,7 +540,7 @@ class ConfidenceProviderTest: XCTestCase {
538540

539541
XCTAssertEqual(resultMap["width"], .integer(200))
540542
XCTAssertEqual(resultMap["color"], .string("yellow"))
541-
XCTAssertEqual(resultMap["error"], .string("Unknown"))
543+
XCTAssertNil(resultMap["error"])
542544
XCTAssertEqual(evaluation.variant, "control")
543545
XCTAssertEqual(evaluation.reason, "targetingMatch")
544546
}
@@ -580,22 +582,26 @@ class ConfidenceProviderTest: XCTestCase {
580582
await fulfillment(of: [readyExpectation], timeout: 1.0)
581583
cancellable.cancel()
582584

583-
let evaluation = try provider.getObjectEvaluation(
584-
key: "flag",
585-
defaultValue: Value.structure(["width": .integer(100), "color": .string("black"), "error": .string("Unknown")]),
586-
context: context)
587-
588-
guard case let .structure(resultMap) = evaluation.value else {
589-
XCTFail("Expected structure value")
590-
return
585+
let defaultStructure = Value.structure([
586+
"width": .integer(100),
587+
"color": .string("black"),
588+
"error": .string("Unknown")
589+
])
590+
591+
// With the new validation behavior, this should throw an error because
592+
// the "error" key is missing from the flag structure
593+
do {
594+
_ = try provider.getObjectEvaluation(
595+
key: "flag",
596+
defaultValue: defaultStructure,
597+
context: context)
598+
XCTFail("Expected an error to be thrown because 'error' key is missing from flag structure")
599+
} catch {
600+
// This is expected - the validation should catch that the required "error" key is missing
601+
XCTAssertTrue(error.localizedDescription.contains("Type mismatch") ||
602+
error.localizedDescription.contains("missing required keys") ||
603+
error.localizedDescription.contains("error"))
591604
}
592-
593-
// The returned structure should only contain the keys from the flag response
594-
XCTAssertEqual(resultMap["width"], .integer(200))
595-
XCTAssertEqual(resultMap["color"], .string("yellow"))
596-
XCTAssertNil(resultMap["error"]) // Extra key from default should not appear
597-
XCTAssertEqual(evaluation.variant, "control")
598-
XCTAssertEqual(evaluation.reason, "targetingMatch")
599605
}
600606
}
601607

Tests/ConfidenceTests/ConfidenceTest.swift

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,8 @@ class ConfidenceTest: XCTestCase {
366366
)
367367

368368
XCTAssertEqual(client.resolveStats, 1)
369-
XCTAssertEqual(evaluation.value, ["width": 200, "height": 400])
369+
// New behavior: extra keys from flag are filtered out, only required keys from defaultValue are returned
370+
XCTAssertEqual(evaluation.value, ["width": 200])
370371
XCTAssertNil(evaluation.errorCode)
371372
XCTAssertNil(evaluation.errorMessage)
372373
XCTAssertEqual(evaluation.reason, .match)
@@ -460,7 +461,8 @@ class ConfidenceTest: XCTestCase {
460461
)
461462

462463
XCTAssertEqual(client.resolveStats, 1)
463-
let expected: [String: AnyHashable] = ["width": 200, "color": "yellow", "error": "Unknown"]
464+
// New behavior: extra keys from flag are filtered out, only required keys from defaultValue are returned
465+
let expected: [String: AnyHashable] = ["width": 200, "color": "yellow"]
464466
XCTAssertEqual(evaluation.value as? [String: AnyHashable], expected)
465467
XCTAssertNil(evaluation.errorCode)
466468
XCTAssertNil(evaluation.errorMessage)
@@ -507,15 +509,15 @@ class ConfidenceTest: XCTestCase {
507509
)
508510

509511
XCTAssertEqual(client.resolveStats, 1)
510-
let expected: [String: AnyHashable] = ["width": 200, "color": "yellow"]
511-
XCTAssertEqual(evaluation.value as? [String: AnyHashable], expected)
512-
XCTAssertNil(evaluation.errorCode)
513-
XCTAssertNil(evaluation.errorMessage)
514-
XCTAssertEqual(evaluation.reason, .match)
515-
XCTAssertEqual(evaluation.variant, "control")
512+
// New behavior: should fail because the "error" key is missing from the flag structure
513+
XCTAssertEqual(evaluation.value as? [String: AnyHashable], ["width": 100, "color": "black", "error": "Unknown"])
514+
XCTAssertEqual(evaluation.errorCode, .typeMismatch(
515+
message: "Default value key \'error\' not found in flag"))
516+
XCTAssertEqual(evaluation.errorMessage, "Default value key \'error\' not found in flag")
517+
XCTAssertEqual(evaluation.reason, .error)
518+
XCTAssertNil(evaluation.variant)
516519
XCTAssertEqual(client.resolveStats, 1)
517-
await fulfillment(of: [flagApplier.applyExpectation], timeout: 5)
518-
XCTAssertEqual(flagApplier.applyCallCount, 1)
520+
XCTAssertEqual(flagApplier.applyCallCount, 0)
519521
}
520522

521523
func testResolveStructHeterogenousMismatch() async throws {
@@ -533,7 +535,7 @@ class ConfidenceTest: XCTestCase {
533535
ResolvedValue(
534536
variant: "control",
535537
value: .init(structure: [
536-
"width": .init(integer: 200),
538+
"width": .init(string: "200"),
537539
"color": .init(string: "yellow")
538540
]),
539541
flag: "flag",
@@ -555,17 +557,18 @@ class ConfidenceTest: XCTestCase {
555557

556558
XCTAssertEqual(client.resolveStats, 1)
557559
XCTAssertEqual(evaluation.value, ["width": 100])
560+
// New behavior: type mismatch should be detected at the individual key level
558561
if case let .typeMismatch(message) = evaluation.errorCode {
559-
XCTAssertTrue(message.hasSuffix("cannot be cast to Dictionary<String, Int>"))
560-
XCTAssertTrue(message.contains("\"width\": \"200\""))
561-
XCTAssertTrue(message.contains("\"color\": \"yellow\""))
562+
XCTAssertEqual(
563+
message,
564+
"Default value key \'width\' has incompatible type. Expected from flag is \'Int\', got \'string\'"
565+
)
562566
} else {
563567
XCTFail("Expected .typeMismatch but got \(String(describing: evaluation.errorCode))")
564568
}
565569
let errorMessage = evaluation.errorMessage ?? ""
566-
XCTAssertTrue(errorMessage.hasSuffix("cannot be cast to Dictionary<String, Int>"))
567-
XCTAssertTrue(errorMessage.contains("\"width\": \"200\""))
568-
XCTAssertTrue(errorMessage.contains("\"color\": \"yellow\""))
570+
XCTAssertTrue(errorMessage.contains(
571+
"Default value key \'width\' has incompatible type. Expected from flag is \'Int\', got \'string\'"))
569572
XCTAssertEqual(evaluation.reason, .error)
570573
XCTAssertNil(evaluation.variant)
571574
XCTAssertEqual(client.resolveStats, 1)
@@ -606,8 +609,8 @@ class ConfidenceTest: XCTestCase {
606609
XCTAssertEqual(client.resolveStats, 1)
607610
XCTAssertEqual(evaluation.value, ConfidenceValue.init(structure: ["size": .init(integer: 4)]))
608611
XCTAssertEqual(evaluation.errorCode, .typeMismatch(
609-
message: "Value [\"size\": \"3\"] cannot be cast to ConfidenceValue"))
610-
XCTAssertEqual(evaluation.errorMessage, "Value [\"size\": \"3\"] cannot be cast to ConfidenceValue")
612+
message: "Expected a Dictionary as default value, but got a different type"))
613+
XCTAssertEqual(evaluation.errorMessage, "Expected a Dictionary as default value, but got a different type")
611614
XCTAssertEqual(evaluation.reason, .error)
612615
XCTAssertNil(evaluation.variant)
613616
XCTAssertEqual(client.resolveStats, 1)

0 commit comments

Comments
 (0)