diff --git a/.gitignore b/.gitignore index 74c49ed9..5d51014f 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,4 @@ iOSInjectionProject/ .DS_Store Package.resolved *.xcuserdatad +/.swiftpm/xcode/xcshareddata diff --git a/Examples/apps/DestinationsExample/DestinationsExample.xcodeproj/project.pbxproj b/Examples/apps/DestinationsExample/DestinationsExample.xcodeproj/project.pbxproj index 84688edc..dcbe1fe9 100644 --- a/Examples/apps/DestinationsExample/DestinationsExample.xcodeproj/project.pbxproj +++ b/Examples/apps/DestinationsExample/DestinationsExample.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 46871694270E16080028B595 /* NotificationTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4687168E270E16080028B595 /* NotificationTracking.swift */; }; + 46871695270E16080028B595 /* UIKitScreenTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4687168F270E16080028B595 /* UIKitScreenTracking.swift */; }; + 46871696270E16080028B595 /* ConsentTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46871690270E16080028B595 /* ConsentTracking.swift */; }; + 46871697270E16080028B595 /* IDFACollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46871691270E16080028B595 /* IDFACollection.swift */; }; + 46871698270E16080028B595 /* ConsoleLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46871692270E16080028B595 /* ConsoleLogger.swift */; }; 469EC8D0266066130068F9E3 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 469EC8CF266066130068F9E3 /* SystemConfiguration.framework */; }; 469EC8E0266828860068F9E3 /* FlurryAnalyticsSPM in Frameworks */ = {isa = PBXBuildFile; productRef = 469EC8DF266828860068F9E3 /* FlurryAnalyticsSPM */; }; 469F7B08266011690038E773 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469F7B07266011690038E773 /* AppDelegate.swift */; }; @@ -36,6 +41,11 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 4687168E270E16080028B595 /* NotificationTracking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationTracking.swift; sourceTree = ""; }; + 4687168F270E16080028B595 /* UIKitScreenTracking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIKitScreenTracking.swift; sourceTree = ""; }; + 46871690270E16080028B595 /* ConsentTracking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConsentTracking.swift; sourceTree = ""; }; + 46871691270E16080028B595 /* IDFACollection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IDFACollection.swift; sourceTree = ""; }; + 46871692270E16080028B595 /* ConsoleLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConsoleLogger.swift; sourceTree = ""; }; 469EC8CF266066130068F9E3 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; 469EC8E1266828AF0068F9E3 /* DestinationsExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "DestinationsExample-Bridging-Header.h"; sourceTree = ""; }; 469F7B04266011690038E773 /* DestinationsExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DestinationsExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -80,6 +90,19 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 4687168C270E16080028B595 /* other_plugins */ = { + isa = PBXGroup; + children = ( + 4687168E270E16080028B595 /* NotificationTracking.swift */, + 4687168F270E16080028B595 /* UIKitScreenTracking.swift */, + 46871690270E16080028B595 /* ConsentTracking.swift */, + 46871691270E16080028B595 /* IDFACollection.swift */, + 46871692270E16080028B595 /* ConsoleLogger.swift */, + ); + name = other_plugins; + path = ../../../other_plugins; + sourceTree = ""; + }; 469F7AFB266011690038E773 = { isa = PBXGroup; children = ( @@ -101,6 +124,7 @@ isa = PBXGroup; children = ( BA384C9D2686609000AFEA1B /* DestinationsExample.entitlements */, + 4687168C270E16080028B595 /* other_plugins */, 469F7B1E266012CB0038E773 /* destination_plugins */, 469F7B07266011690038E773 /* AppDelegate.swift */, 469F7B09266011690038E773 /* SceneDelegate.swift */, @@ -241,6 +265,7 @@ buildActionMask = 2147483647; files = ( BA384C9A2682973300AFEA1B /* AppsFlyerDestination.swift in Sources */, + 46871698270E16080028B595 /* ConsoleLogger.swift in Sources */, 469F7B20266012CB0038E773 /* FlurryDestination.swift in Sources */, 469F7B0C266011690038E773 /* ViewController.swift in Sources */, 96469A9B270279A600AC5772 /* IntercomDestination.swift in Sources */, @@ -249,9 +274,13 @@ 96DBF37D26FA943300724B0B /* ComscoreDestination.swift in Sources */, 965DC0FB2668077400DDF9C7 /* AmplitudeSession.swift in Sources */, 469F7B08266011690038E773 /* AppDelegate.swift in Sources */, + 46871694270E16080028B595 /* NotificationTracking.swift in Sources */, 469F7B25266013320038E773 /* AdjustDestination.swift in Sources */, 469F7B0A266011690038E773 /* SceneDelegate.swift in Sources */, 965DC1232669947F00DDF9C7 /* FirebaseDestination.swift in Sources */, + 46871695270E16080028B595 /* UIKitScreenTracking.swift in Sources */, + 46871696270E16080028B595 /* ConsentTracking.swift in Sources */, + 46871697270E16080028B595 /* IDFACollection.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Package.resolved b/Package.resolved index e50f64ce..41e52434 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/segmentio/Sovran-Swift.git", "state": { "branch": null, - "revision": "8eab95bb85b4816b25b32e7d2af2c88b358b04df", - "version": "1.0.1" + "revision": "be79aad4393d36959f422c2b40566b84ee1170f7", + "version": "1.0.2" } } ] diff --git a/Sources/Segment/Plugins/Logger/SegmentLog.swift b/Sources/Segment/Plugins/Logger/SegmentLog.swift index ce079faf..b9f3e77e 100644 --- a/Sources/Segment/Plugins/Logger/SegmentLog.swift +++ b/Sources/Segment/Plugins/Logger/SegmentLog.swift @@ -20,15 +20,32 @@ internal class SegmentLog: UtilityPlugin { // For internal use only. Note: This will contain the last created instance // of analytics when used in a multi-analytics environment. - internal static var sharedAnalytics: Analytics? + internal static var sharedAnalytics: Analytics? = nil + + #if DEBUG + internal static var globalLogger: SegmentLog { + get { + let logger = SegmentLog() + logger.addTargets() + return logger + } + } + #endif required init() { } func configure(analytics: Analytics) { self.analytics = analytics SegmentLog.sharedAnalytics = analytics + addTargets() + } + + internal func addTargets() { #if !os(Linux) try? add(target: SystemTarget(), for: LoggingType.log) + #if DEBUG + try? add(target: ConsoleTarget(), for: LoggingType.log) + #endif #else try? add(target: ConsoleTarget(), for: LoggingType.log) #endif @@ -146,16 +163,23 @@ internal extension Analytics { /// - function: The name of the function the log came from. This will be captured automatically. /// - line: The line number in the function the log came from. This will be captured automatically. static func segmentLog(message: String, kind: LogFilterKind? = nil, function: String = #function, line: Int = #line) { - SegmentLog.sharedAnalytics?.apply { plugin in - if let loggerPlugin = plugin as? SegmentLog { - var filterKind = loggerPlugin.filterKind - if let logKind = kind { - filterKind = logKind + if let shared = SegmentLog.sharedAnalytics { + shared.apply { plugin in + if let loggerPlugin = plugin as? SegmentLog { + var filterKind = loggerPlugin.filterKind + if let logKind = kind { + filterKind = logKind + } + + let log = LogFactory.buildLog(destination: .log, title: "", message: message, kind: filterKind, function: function, line: line) + loggerPlugin.log(log, destination: .log) } - - let log = LogFactory.buildLog(destination: .log, title: "", message: message, kind: filterKind, function: function, line: line) - loggerPlugin.log(log, destination: .log) } + } else { + #if DEBUG + let log = LogFactory.buildLog(destination: .log, title: "", message: message, kind: .debug, function: function, line: line) + SegmentLog.globalLogger.log(log, destination: .log) + #endif } } diff --git a/Sources/Segment/Settings.swift b/Sources/Segment/Settings.swift index 39e631fc..72eca586 100644 --- a/Sources/Segment/Settings.swift +++ b/Sources/Segment/Settings.swift @@ -86,6 +86,14 @@ extension Settings: Equatable { } extension Analytics { + /// Manually enable a destination plugin. This is useful when a given DestinationPlugin doesn't have any Segment tie-ins at all. + /// This will allow the destination to be processed in the same way within this library. + /// - Parameters: + /// - plugin: The destination plugin to enable. + public func manuallyEnableDestination(plugin: DestinationPlugin) { + self.store.dispatch(action: System.AddIntegrationAction(key: plugin.key)) + } + internal func update(settings: Settings, type: UpdateType) { apply { (plugin) in // tell all top level plugins to update. diff --git a/Sources/Segment/Types.swift b/Sources/Segment/Types.swift index 98520815..e9fb50ac 100644 --- a/Sources/Segment/Types.swift +++ b/Sources/Segment/Types.swift @@ -9,16 +9,10 @@ import Foundation import Sovran -// MARK: - Event Parameter Types - -typealias Integrations = Codable -typealias Properties = Codable -typealias Traits = Codable - - // MARK: - Event Types public protocol RawEvent: Codable { + var type: String? { get set } var anonymousId: String? { get set } var messageId: String? { get set } @@ -149,11 +143,192 @@ public struct AliasEvent: RawEvent { } } +// MARK: - RawEvent conveniences + +internal struct IntegrationConstants { + static let allIntegrationsKey = "All" +} + +extension RawEvent { + /** + Disable all cloud-mode integrations for this event, except for any specific keys given. + This will preserve any per-integration specific settings if the integration is to remain enabled. + - Parameters: + - exceptKeys: A list of integration keys to exclude from disabling. + */ + public mutating func disableCloudIntegrations(exceptKeys: [String]? = nil) { + guard let existing = integrations?.dictionaryValue else { + // this shouldn't happen, might oughta log it. + Analytics.segmentLog(message: "Unable to get what should be a valid list of integrations from event.", kind: .error) + return + } + var new = [String: Any]() + new[IntegrationConstants.allIntegrationsKey] = false + if let exceptKeys = exceptKeys { + for key in exceptKeys { + if let value = existing[key], value is [String: Any] { + new[key] = value + } else { + new[key] = true + } + } + } + + do { + integrations = try JSON(new) + } catch { + // this shouldn't happen, log it. + Analytics.segmentLog(message: "Unable to convert list of integrations to JSON. \(error)", kind: .error) + } + } + + /** + Enable all cloud-mode integrations for this event, except for any specific keys given. + - Parameters: + - exceptKeys: A list of integration keys to exclude from enabling. + */ + public mutating func enableCloudIntegrations(exceptKeys: [String]? = nil) { + var new = [String: Any]() + new[IntegrationConstants.allIntegrationsKey] = true + if let exceptKeys = exceptKeys { + for key in exceptKeys { + new[key] = false + } + } + + do { + integrations = try JSON(new) + } catch { + // this shouldn't happen, log it. + Analytics.segmentLog(message: "Unable to convert list of integrations to JSON. \(error)", kind: .error) + } + } + + /** + Disable a specific cloud-mode integration using it's key name. + - Parameters: + - key: The key name of the integration to disable. + */ + public mutating func disableIntegration(key: String) { + guard let existing = integrations?.dictionaryValue else { + // this shouldn't happen, might oughta log it. + Analytics.segmentLog(message: "Unable to get what should be a valid list of integrations from event.", kind: .error) + return + } + // we don't really care what the value of this key was before, as + // a disabled one can only be false. + var new = existing + new[key] = false + + do { + integrations = try JSON(new) + } catch { + // this shouldn't happen, log it. + Analytics.segmentLog(message: "Unable to convert list of integrations to JSON. \(error)", kind: .error) + } + } + + /** + Enable a specific cloud-mode integration using it's key name. + - Parameters: + - key: The key name of the integration to enable. + */ + public mutating func enableIntegration(key: String) { + guard let existing = integrations?.dictionaryValue else { + // this shouldn't happen, might oughta log it. + Analytics.segmentLog(message: "Unable to get what should be a valid list of integrations from event.", kind: .error) + return + } + + var new = existing + // if it's a dictionary already, it's considered enabled, so don't + // overwrite whatever they may have put there. If that's not the case + // just set it to true since that's the only other value it could have + // to be considered `enabled`. + if (existing[key] as? [String: Any]) == nil { + new[key] = true + } + + do { + integrations = try JSON(new) + } catch { + // this shouldn't happen, log it. + Analytics.segmentLog(message: "Unable to convert list of integrations to JSON. \(error)", kind: .error) + } + } + + /** + Set values to be received for this event in cloud-mode, specific to an integration key path. + Note that when specifying nil as the value, the key will be removed for the given key path. Additionally, + any keys that don't already exist in the path will be created as necessary. + + Example: + ``` + trackEvent.setIntegrationValue(42, forKeyPath: "Amplitude.threshold") + ``` + + - Parameters: + - value: The value to set for the given keyPath, or nil. + - forKeyPath: The key path for the value. + */ + public mutating func setIntegrationValue(_ value: Any?, forKeyPath keyPath: String) { + guard let existing = integrations?.dictionaryValue else { + // this shouldn't happen, might oughta log it. + Analytics.segmentLog(message: "Unable to get what should be a valid list of integrations from event.", kind: .error) + return + } + + var new = existing + new[keyPath: KeyPath(keyPath)] = value + do { + integrations = try JSON(new) + } catch { + // this shouldn't happen, log it. + Analytics.segmentLog(message: "Unable to convert list of integrations to JSON. \(error)", kind: .error) + } + } + + /** + Set context values for this event. + Note that when specifying nil as the value, the key will be removed for the given key path. Additionally, + any keys that don't already exist in the path will be created as necessary. + + Example: + ``` + // the metadata key will be created as a dictionary, and the key nickname will be set. + trackEvent.setContextValue("Brandon's device", forKeyPath: "device.metadata.nickname") + + // the metadata key will be removed entirely. + trackEvent.setContextValue(nil, forKeyPath: "device.metadata") + ``` + + - Parameters: + - value: The value to set for the given keyPath, or nil. + - forKeyPath: The key path for the value. + */ + public mutating func setContextValue(_ value: Any?, forKeyPath keyPath: String) { + guard let existing = context?.dictionaryValue else { + // this shouldn't happen, might oughta log it. + Analytics.segmentLog(message: "Unable to get what should be a valid context from event.", kind: .error) + return + } + + var new = existing + new[keyPath: KeyPath(keyPath)] = value + do { + context = try JSON(new) + } catch { + // this shouldn't happen, log it. + Analytics.segmentLog(message: "Unable to convert context to JSON. \(error)", kind: .error) + } + } +} + // MARK: - RawEvent data helpers extension RawEvent { - public mutating func applyRawEventData(event: RawEvent?) { + internal mutating func applyRawEventData(event: RawEvent?) { if let e = event { anonymousId = e.anonymousId messageId = e.messageId diff --git a/Sources/Segment/Utilities/JSON.swift b/Sources/Segment/Utilities/JSON.swift index a924e6df..006b4a9c 100644 --- a/Sources/Segment/Utilities/JSON.swift +++ b/Sources/Segment/Utilities/JSON.swift @@ -62,7 +62,8 @@ public enum JSON: Equatable { self = .array(try array.map(JSON.init)) case let object as [String: Any]: self = .object(try object.mapValues(JSON.init)) - + case let json as JSON: + self = json // we don't work with whatever is being supplied default: throw JSONError.nonJSONType(type: "\(value.self)") @@ -267,19 +268,188 @@ extension JSON { } } -// MARK: - Mapping +// MARK: - Mutation extension JSON { /// Maps keys supplied, in the format of ["Old": "New"]. Gives an optional value transformer that can be used to transform values based on the final key name. /// - Parameters: /// - keys: A dictionary containing key mappings, in the format of ["Old": "New"]. /// - valueTransform: An optional value transform closure. Key represents the new key name. + /// + /// - Returns: A new JSON object with the specified changes. + /// - Throws: This method will throw if transformation or JSON cannot be properly completed. public func mapTransform(_ keys: [String: String], valueTransform: ((_ key: String, _ value: Any) -> Any)? = nil) throws -> JSON { guard let dict = self.dictionaryValue else { return self } let mapped = try dict.mapTransform(keys, valueTransform: valueTransform) let result = try JSON(mapped) return result } + + /// Adds a new value to an array and returns a new JSON object. Function will throw if value cannot be serialized. + /// - Parameters: + /// - value: Value to add to the JSON array. + /// + /// - Returns: A new JSON array with the supplied value added. + /// - Throws: This method throws when a value is added and unable to be serialized. + public func add(value: Any) throws -> JSON? { + var result: JSON? = nil + switch self { + case .array: + var newArray = [Any]() + if let existing = arrayValue { + newArray.append(contentsOf: existing) + } + newArray.append(value) + result = try JSON(newArray) + default: + throw "This JSON object is not an array type." + } + return result + } + + /// Adds a new key, value pair to and returns a new JSON object. Function will throw if value cannot be serialized. + /// - Parameters: + /// - value: Value to add to the JSON array. + /// - forKey: The key name of the given value. + /// + /// - Returns: A new JSON object with the supplied Key/Value added. + /// - Throws: This method throws when a value is added and unable to be serialized. + public func add(value: Any, forKey key: String) throws -> JSON? { + var result: JSON? = nil + switch self { + case .object: + var newObject = [String: Any]() + if let existing = dictionaryValue { + newObject = existing + } + newObject[key] = value + result = try JSON(newObject) + default: + throw "This JSON object is not an array type." + } + return result + } + + /// Removes the key and associated value pair from this JSON object. + /// - Parameters: + /// - key: The key of the value to be removed. + /// + /// - Returns: A new JSON object with the specified key and it's associated value removed. + /// - Throws: This method throws when after modification, it is unable to be serialized. + public func remove(key: String) throws -> JSON? { + var result: JSON? = nil + switch self { + case .object: + var newObject = [String: Any]() + if let existing = dictionaryValue { + newObject = existing + } + newObject.removeValue(forKey: key) + result = try JSON(newObject) + default: + throw "This JSON object is not an array type." + } + return result + + } + + /// Directly access a specific index in the JSON array. + public subscript(index: Int) -> JSON? { + get { + switch self { + case .array(let value): + if index < value.count { + let v = value[index] + return v + } + default: + break + } + return nil + } + } + + /// Directly access a key within the JSON object. + public subscript(key: String) -> JSON? { + get { + switch self { + case .object(let value): + return value[key] + default: + break + } + return nil + } + } + + /// Directly access or set a value within the JSON object using a key path. + public subscript(keyPath keyPath: KeyPath) -> T? { + get { + var result: T? = nil + switch self { + case .object: + var value: Any? = nil + if let dict = dictionaryValue { + value = dict[keyPath: keyPath] + if let v = value as? [String: Any] { + if let jsonData = try? JSONSerialization.data(withJSONObject: v) { + do { + result = try JSONDecoder().decode(T.self, from: jsonData) + } catch { + Analytics.segmentLog(message: "Unable to decode object to a Codable: \(error)", kind: .error) + } + } + if result == nil { + result = v as? T + } + } else { + result = value as? T + } + } + default: + break + } + return result + } + + set(newValue) { + switch self { + case .object: + if var dict: [String: Any] = dictionaryValue { + var json: JSON? = try? JSON(newValue as Any) + if json == nil { + json = try? JSON(with: newValue) + } + + if let json = json { + dict[keyPath: keyPath] = json + if let newSelf = try? JSON(dict) { + self = newSelf + } + } + } + default: + break + } + } + } + + /// Directly access a value within the JSON object using a key path. + /// - Parameters: + /// - forKeyPath: The keypath within the object to retrieve. eg: `context.device.ip` + /// + /// - Returns: The value as typed, or nil. + public func value(forKeyPath keyPath: KeyPath) -> T? { + return self[keyPath: keyPath] + } + + /// Directly access a value within the JSON object using a key path. + /// - Parameters: + /// - forKeyPath: The keypath within the object to set. eg: `context.device.ip` + public mutating func setValue(_ value: T?, forKeyPath keyPath: KeyPath) { + self[keyPath: keyPath] = value + } + } // MARK: - Helpers diff --git a/Sources/Segment/Utilities/KeyPath.swift b/Sources/Segment/Utilities/KeyPath.swift index 42d35971..deecf0ef 100644 --- a/Sources/Segment/Utilities/KeyPath.swift +++ b/Sources/Segment/Utilities/KeyPath.swift @@ -78,11 +78,15 @@ extension Dictionary where Key: StringProtocol, Value: Any { return result } - internal mutating func setValue(_ value: Any, keyPath: KeyPath) { + internal mutating func setValue(_ value: Any?, keyPath: KeyPath) { guard let key = keyPath.current as? Key else { return } if keyPath.remaining.isEmpty { - self[key] = (value as! Value) + if value.flattened() != nil { + self[key] = (value as! Value) + } else { + self.removeValue(forKey: key) + } } else { if var nestedDict = self[key] as? [String: Any] { nestedDict[keyPath: KeyPath(keyPath.remainingPath)] = value diff --git a/Sources/Segment/Utilities/Utils.swift b/Sources/Segment/Utilities/Utils.swift index 8367eaad..c4c254fa 100644 --- a/Sources/Segment/Utilities/Utils.swift +++ b/Sources/Segment/Utilities/Utils.swift @@ -43,3 +43,17 @@ internal func exceptionFailure(_ message: String) { assertionFailure(message) #endif } + +internal protocol Flattenable { + func flattened() -> Any? +} + +extension Optional: Flattenable { + internal func flattened() -> Any? { + switch self { + case .some(let x as Flattenable): return x.flattened() + case .some(let x): return x + case .none: return nil + } + } +} diff --git a/Tests/Segment-Tests/JSON_Tests.swift b/Tests/Segment-Tests/JSON_Tests.swift index e6b2152b..14a6aeff 100644 --- a/Tests/Segment-Tests/JSON_Tests.swift +++ b/Tests/Segment-Tests/JSON_Tests.swift @@ -14,6 +14,18 @@ struct Personal: Codable { let type: String? } +struct TestStruct: Codable { + let str: String + let bool: Bool + let float: Float + let int: Int + let uint: UInt + let double: Double + let decimal: Decimal + let array: JSON? + let dict: JSON? +} + class JSONTests: XCTestCase { override func setUpWithError() throws { @@ -72,19 +84,52 @@ class JSONTests: XCTestCase { } } - func testTypesFromJSON() throws { - struct TestStruct: Codable { - let str: String - let bool: Bool - let float: Float - let int: Int - let uint: UInt - let double: Double - let decimal: Decimal - let array: JSON? - let dict: JSON? - } + func testJSONMutation() throws { + let test = TestStruct( + str: "hello", + bool: true, + float: 3.14, + int: -42, + uint: 42, + double: 1.234, + decimal: 333.9999, + array: try JSON(["1", "2"]), + dict: try JSON(["1": 1, "2": 2]) + ) + + let wrapper: [String: Any] = [ + "name": "Brandon", + "someValue": 42, + "test": try JSON(with: test) + ] + + var jsonObject = try JSON(wrapper) + XCTAssertNotNil(jsonObject) + + // wrapping a JSON object with another results in a no-op. + let jsonObject2 = try JSON(jsonObject) + XCTAssertNotNil(jsonObject2) + + let structValue: TestStruct? = jsonObject[keyPath: "test"] + XCTAssertEqual(structValue!.str, "hello") + + let intValue: Int? = jsonObject[keyPath: "test.dict.1"] + XCTAssertEqual(intValue, 1) + + jsonObject[keyPath: "structSet.object"] = test + jsonObject[keyPath: "codys.brain.melted"] = true + + XCTAssertTrue(jsonObject[keyPath: "structSet.object.str"] == "hello") + XCTAssertTrue(jsonObject[keyPath: "codys.brain.melted"] == true) + + let dubz: Double? = jsonObject.value(forKeyPath: "structSet.object.double") + XCTAssertEqual(dubz, 1.234) + jsonObject.setValue(47, forKeyPath: "test.uint") + XCTAssertEqual(jsonObject[keyPath: "test.uint"], 47) + } + + func testTypesFromJSON() throws { let test = TestStruct( str: "hello", bool: true, @@ -191,4 +236,64 @@ class JSONTests: XCTestCase { XCTAssertTrue(subArray[1] as! Int == 2) XCTAssertTrue(subArrayDict["AKey1"] as! Int == 11) } + + func testAddRemoveValues() { + struct NotCodable { + let x = 1 + } + + let array = [1, 2, 3, 4] + let dict = ["hello": true, "goodbye": false] + let notCodable = NotCodable() + + var json: JSON? = nil + + // does a simple add to array work? + json = try? JSON(array) + XCTAssertNotNil(json) + do { + json = try json?.add(value: 5) + let v = json?[4] + XCTAssertNotNil(v) + XCTAssertTrue(v?.intValue == 5) + XCTAssertThrowsError(try json?.add(value:notCodable)) + } catch { + XCTFail() + } + + // does a simple add key/value work? + json = try? JSON(dict) + XCTAssertNotNil(json) + do { + json = try json?.add(value: true, forKey: "howdy") + let v = json?["howdy"] + XCTAssertNotNil(v) + XCTAssertTrue(v?.boolValue == true) + XCTAssertThrowsError(try json?.add(value:notCodable, forKey:"issaFail")) + } catch { + XCTFail() + } + + // try to remove a key + json = try? JSON(dict) + XCTAssertNotNil(json) + do { + json = try json?.remove(key: "goodbye") + XCTAssertNotNil(json) + XCTAssertNil(json?["goodbye"]) + } catch { + XCTFail() + } + + // Merchant: if we add/remove, do we not bleed?! + json = try? JSON(true) + XCTAssertNotNil(json) + // it's not a JSON array, throw + XCTAssertThrowsError(try json?.add(value: 1)) + // it's not a JSON object, throw + XCTAssertThrowsError(try json?.add(value: 1, forKey: "shakespeare")) + // it's not a JSON object, throw + XCTAssertThrowsError(try json?.remove(key: "merchant")) + } + } diff --git a/Tests/Segment-Tests/KeyPath_Tests.swift b/Tests/Segment-Tests/KeyPath_Tests.swift index 4cf986cd..6c942450 100644 --- a/Tests/Segment-Tests/KeyPath_Tests.swift +++ b/Tests/Segment-Tests/KeyPath_Tests.swift @@ -6,7 +6,7 @@ // import XCTest -import Segment +@testable import Segment class KeyPath_Tests: XCTestCase { let baseDictionary: [String: Any] = [ @@ -102,9 +102,22 @@ class KeyPath_Tests: XCTestCase { dict[keyPath: "booya.skibbidy.shazam"] = "bad-movie" let shazam = dict[keyPath: "booya.skibbidy.shazam"] as? String XCTAssertTrue(shazam == "bad-movie") + } + + func testNilHandling() throws { + var dict = baseDictionary + // test that nil removes a deep object + dict[keyPath: "data.characters.Gyro"] = nil + let shouldBeNil = dict[keyPath: "data.characters.Gyro"] + XCTAssertNil(shouldBeNil) + + // test that nil removes a higher level object + dict[keyPath: "data.characters"] = nil + let shouldAlsoBeNil = dict[keyPath: "booya.characters"] + XCTAssertNil(shouldAlsoBeNil) } - + func testIfExistsThenElseHandler() { var dict = [String: Any]() let keys = mapping.keys @@ -189,8 +202,24 @@ class KeyPath_Tests: XCTestCase { XCTAssertTrue(dict["user_id"] as? String == "brandon") } + // useful for once-in-awhile checking, but doesn't need to be run as part of + // the overall test suite. + /*func testDictDeconstructionSpeed() { + let dict = baseDictionary + // test regular dictionary crap + measure { + for _ in 0..<100 { + let data = dict["data"] as? [String: Any] + let characters = data?["characters"] as? [String: Any] + let gyro = characters?["Gyro"] as? String + XCTAssertTrue(gyro == "Leather") + } + } + } + func testKeyPathSpeed() { let dict = baseDictionary + // test keypath stuff measure { for _ in 0..<100 { let gyro = dict[keyPath: "data.characters.Gyro"] as? String @@ -198,5 +227,17 @@ class KeyPath_Tests: XCTestCase { } } } - + + func testJSONKeyPathSpeed() throws { + let dict = baseDictionary + let json = try JSON(dict) + // test json keypath stuff + measure { + for _ in 0..<100 { + let gyro: String? = json[keyPath: "data.characters.Gyro"] + XCTAssertTrue(gyro == "Leather") + } + } + }*/ + }