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) + } +}