From 63fa1e8e0deb2cf407a13fb260e29fa188f34ffd Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Sun, 13 Apr 2025 09:49:48 -0700 Subject: [PATCH] Convert to storing AnyValue as JSON objects SDK used to store AnyValue objects as json Data objects. This gets encoded and decoded as base64, which is incompatible with the way server stores AnyValue, which is as a JSON blob. --- Sources/CodecHelper.swift | 36 +++++++ Sources/Scalars/AnyValue.swift | 129 +++++++++++++++++++++++-- Tests/Integration/AnyScalarTests.swift | 37 +++++++ Tests/Unit/AnyValueCodableTests.swift | 75 ++++++++++++++ 4 files changed, 267 insertions(+), 10 deletions(-) create mode 100644 Tests/Unit/AnyValueCodableTests.swift diff --git a/Sources/CodecHelper.swift b/Sources/CodecHelper.swift index ff93125..6c2bc4a 100644 --- a/Sources/CodecHelper.swift +++ b/Sources/CodecHelper.swift @@ -61,5 +61,41 @@ public class CodecHelper { return try container.decode(type, forKey: forKey) } + // This is public because the class is used by generated SDK code public init() {} } + +class SingleValueCodecHelper { + func encodeSingle(_ value: Encodable, container: inout SingleValueEncodingContainer) throws { + switch value { + case let int64Value as Int64: + let int64Converter = Int64CodableConverter() + let int64Value = try int64Converter.encode(input: int64Value) + try container.encode(int64Value) + case let uuidValue as UUID: + let uuidConverter = UUIDCodableConverter() + let uuidValue = try uuidConverter.encode(input: uuidValue) + try container.encode(uuidValue) + default: + try container.encode(value) + } + } + + func decodeSingle(_ type: T.Type, + container: inout SingleValueDecodingContainer) throws -> T { + if type == Int64.self || type == Int64?.self { + let int64String = try? container.decode(String.self) + let int64Converter = Int64CodableConverter() + let int64Value = try int64Converter.decode(input: int64String) + return int64Value as! T + } else if type == UUID.self || type == UUID?.self { + let uuidString = try container.decode(String.self) + let uuidConverter = UUIDCodableConverter() + let uuidDecoded = try uuidConverter.decode(input: uuidString) + + return uuidDecoded as! T + } else { + return try container.decode(type) + } + } +} diff --git a/Sources/Scalars/AnyValue.swift b/Sources/Scalars/AnyValue.swift index 6425f18..44734df 100644 --- a/Sources/Scalars/AnyValue.swift +++ b/Sources/Scalars/AnyValue.swift @@ -18,34 +18,71 @@ import Foundation /// Double, String, Bool,...) or a JSON object @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct AnyValue { - public private(set) var value: Data + public var value: Data { + do { + let jsonEncoder = JSONEncoder() + let data = try jsonEncoder.encode(anyCodableValue) + return data + } catch { + DataConnectLogger.logger.warning("Error encoding anyCodableValue \(error)") + return Data() + } + } + + private var anyCodableValue: AnyCodableValue public init(codableValue: Codable) throws { do { - let jsonEncoder = JSONEncoder() - value = try jsonEncoder.encode(codableValue) + if let int64Val = codableValue as? Int64 { + anyCodableValue = .int64(int64Val) + } else { + // to recontruct JSON dictionary, one has to decode it from json data + let jsonEncoder = JSONEncoder() + let jsonData = try jsonEncoder.encode(codableValue) + let jsonDecoder = JSONDecoder() + anyCodableValue = try jsonDecoder.decode(AnyCodableValue.self, from: jsonData) + } } } public func decodeValue(_ type: T.Type) throws -> T? { do { - let jsonDecoder = JSONDecoder() - let decodedResult = try jsonDecoder.decode(type, from: value) - return decodedResult + switch anyCodableValue { + case let .int64(int64): + if type == Int64.self { + return int64 as? T + } else { + throw DataConnectCodecError.decodingFailed() + } + default: + let jsonDecoder = JSONDecoder() + let decodedResult = try jsonDecoder.decode(type, from: value) + return decodedResult + } } } } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) extension AnyValue: Codable { - public init(from decoder: any Decoder) throws { - let singleValueContainer = try decoder.singleValueContainer() - value = try singleValueContainer.decode(Data.self) + public init(from decoder: any Swift.Decoder) throws { + var container = try decoder.singleValueContainer() + do { + if let b64Data = try? container.decode(Data.self) { + // backwards compatibility + let jsonDecoder = JSONDecoder() + anyCodableValue = try jsonDecoder.decode(AnyCodableValue.self, from: b64Data) + } else { + let codecHelper = SingleValueCodecHelper() + anyCodableValue = try codecHelper.decodeSingle(AnyCodableValue.self, container: &container) + } + } } public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() - try container.encode(value) + let codecHelper = SingleValueCodecHelper() + try codecHelper.encodeSingle(anyCodableValue, container: &container) } } @@ -65,3 +102,75 @@ extension AnyValue: Hashable { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) extension AnyValue: Sendable {} + +// MARK: - + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum AnyCodableValue: Codable, Equatable { + case string(String) + case int64(Int64) + case number(Double) + case bool(Bool) + case dictionary([String: AnyCodableValue]) + case array([AnyCodableValue]) + case null + + static func == (lhs: AnyCodableValue, rhs: AnyCodableValue) -> Bool { + switch (lhs, rhs) { + case let (.string(l), .string(r)): return l == r + case let (.int64(l), .int64(r)): return l == r + case let (.number(l), .number(r)): return l == r + case let (.bool(l), .bool(r)): return l == r + case let (.dictionary(l), .dictionary(r)): return l == r + case let (.array(l), .array(r)): return l == r + case (.null, .null): return true + default: return false + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let stringVal = try? container.decode(String.self) { + if let int64Val = try? Int64CodableConverter().decode(input: stringVal) { + self = .int64(int64Val) + } else { + self = .string(stringVal) + } + } else if let doubleVal = try? container.decode(Double.self) { + self = .number(doubleVal) + } else if let boolVal = try? container.decode(Bool.self) { + self = .bool(boolVal) + } else if let dictVal = try? container.decode([String: AnyCodableValue].self) { + self = .dictionary(dictVal) + } else if let arrayVal = try? container.decode([AnyCodableValue].self) { + self = .array(arrayVal) + } else if container.decodeNil() { + self = .null + } else { + throw DataConnectCodecError + .decodingFailed(message: "Error decode AnyCodableValue from \(container)") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .int64(value): + let encodedVal = try? Int64CodableConverter().encode(input: value) + try container.encode(encodedVal) + case let .string(value): + try container.encode(value) + case let .number(value): + try container.encode(value) + case let .bool(value): + try container.encode(value) + case let .dictionary(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} diff --git a/Tests/Integration/AnyScalarTests.swift b/Tests/Integration/AnyScalarTests.swift index 32f9935..0759091 100644 --- a/Tests/Integration/AnyScalarTests.swift +++ b/Tests/Integration/AnyScalarTests.swift @@ -153,6 +153,21 @@ final class AnyScalarTests: IntegrationTestBase { XCTAssertEqual(testDouble, decodedResult) } + func testAnyValueUUID() async throws { + let anyValueId = UUID() + _ = try await DataConnect.kitchenSinkConnector.createAnyValueTypeMutation.ref( + id: anyValueId, + props: AnyValue(codableValue: anyValueId) + ).execute() + + let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.ref(id: anyValueId) + .execute() + let anyValueResult = result.data.anyValueType?.props + let decodedResult = try anyValueResult?.decodeValue(UUID.self) + + XCTAssertEqual(anyValueId, decodedResult) + } + func testAnyValueDoubleMin() async throws { let testDouble = Double.leastNormalMagnitude let anyTestData = try AnyValue(codableValue: testDouble) @@ -223,4 +238,26 @@ final class AnyScalarTests: IntegrationTestBase { XCTAssertEqual(structVal, decodedResult) } + + func testAnyValueArray() async throws { + let intArray = { + var ia = [Double]() + for _ in 1 ... 10 { + ia.append(Double.random(in: Double.leastNormalMagnitude ... Double.greatestFiniteMagnitude)) + } + return ia + }() + + let anyValueId = UUID() + _ = try await DataConnect.kitchenSinkConnector.createAnyValueTypeMutation + .execute(id: anyValueId, props: AnyValue(codableValue: intArray)) + + let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.execute( + id: anyValueId + ) + let anyValueResult = result.data.anyValueType?.props + let decodedResult = try anyValueResult?.decodeValue([Double].self) + + XCTAssertEqual(intArray, decodedResult) + } } diff --git a/Tests/Unit/AnyValueCodableTests.swift b/Tests/Unit/AnyValueCodableTests.swift new file mode 100644 index 0000000..ddddd9c --- /dev/null +++ b/Tests/Unit/AnyValueCodableTests.swift @@ -0,0 +1,75 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import XCTest + +@testable import FirebaseDataConnect + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class AnyValueCodableTests: XCTestCase { + override func setUpWithError() throws {} + + override func tearDownWithError() throws {} + + func testAnyValueStringCodable() throws { + let stringVal = "Hello World \(Int.random(in: 1 ... 1000))" + let anyValue = try AnyValue(codableValue: stringVal) + let stringDecoded = try anyValue.decodeValue(String.self) + XCTAssert(stringVal == stringDecoded) + } + + func testAnyValueDoubleRandomCodable() throws { + let doubleVal = Double + .random(in: Double.leastNormalMagnitude ... Double.greatestFiniteMagnitude) + let anyValue = try AnyValue(codableValue: doubleVal) + let doubleDecoded = try anyValue.decodeValue(Double.self) + XCTAssertEqual(doubleVal, doubleDecoded) + } + + func testAnyValueDoubleMaxCodable() throws { + let doubleValMax = Double.greatestFiniteMagnitude + let anyValue = try AnyValue(codableValue: doubleValMax) + let doubleDecoded = try anyValue.decodeValue(Double.self) + XCTAssertEqual(doubleValMax, doubleDecoded) + } + + func testAnyValueDoubleMinCodable() throws { + let doubleValMin = Double.leastNormalMagnitude + let anyValue = try AnyValue(codableValue: doubleValMin) + let doubleDecoded = try anyValue.decodeValue(Double.self) + XCTAssertEqual(doubleValMin, doubleDecoded) + } + + func testAnyValueInt64RandomCodable() throws { + let int64 = Int64.random(in: Int64.min ... Int64.max) + let anyValue = try AnyValue(codableValue: int64) + let int64Decoded = try anyValue.decodeValue(Int64.self) + XCTAssertEqual(int64, int64Decoded) + } + + func testAnyValueInt64MaxCodable() throws { + let int64 = Int64.max + let anyValue = try AnyValue(codableValue: int64) + let int64Decoded = try anyValue.decodeValue(Int64.self) + XCTAssertEqual(int64, int64Decoded) + } + + func testAnyValueInt64MinCodable() throws { + let int64 = Int64.min + let anyValue = try AnyValue(codableValue: int64) + let int64Decoded = try anyValue.decodeValue(Int64.self) + XCTAssertEqual(int64, int64Decoded) + } +}