Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FSSDK-11372] assign holdouts to feature flags #578

Draft
wants to merge 12 commits into
base: master
Choose a base branch
from
Draft
4 changes: 3 additions & 1 deletion Sources/Data Model/ExperimentCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@

import Foundation

protocol ExperimentCore: OptimizelyExperiment {
protocol ExperimentCore {
var id: String { get }
var key: String { get }
var audiences: String { get set }
var layerId: String { get }
var variations: [Variation] { get }
Expand Down
1 change: 1 addition & 0 deletions Sources/Data Model/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ struct FeatureFlag: Codable, Equatable, OptimizelyFeature {
var variablesMap: [String: OptimizelyVariable] = [:]
var experimentRules: [OptimizelyExperiment] = []
var deliveryRules: [OptimizelyExperiment] = []
var holdoutIds: [String] = []
}

// MARK: - Utils
Expand Down
38 changes: 36 additions & 2 deletions Sources/Data Model/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ struct Project: Codable, Equatable {
var sendFlagDecisions: Bool?
var sdkKey: String?
var environmentKey: String?

// Holdouts
var holdouts: [Holdout]
let logger = OPTLoggerFactory.getLogger()

// Required since logger is not decodable
Expand All @@ -57,11 +58,44 @@ struct Project: Codable, Equatable {
case anonymizeIP
// V4
case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey
// holdouts
case holdouts
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

// V2
version = try container.decode(String.self, forKey: .version)
projectId = try container.decode(String.self, forKey: .projectId)
experiments = try container.decode([Experiment].self, forKey: .experiments)
audiences = try container.decode([Audience].self, forKey: .audiences)
groups = try container.decode([Group].self, forKey: .groups)
attributes = try container.decode([Attribute].self, forKey: .attributes)
accountId = try container.decode(String.self, forKey: .accountId)
events = try container.decode([Event].self, forKey: .events)
revision = try container.decode(String.self, forKey: .revision)

// V3
anonymizeIP = try container.decode(Bool.self, forKey: .anonymizeIP)

// V4
rollouts = try container.decode([Rollout].self, forKey: .rollouts)
integrations = try container.decodeIfPresent([Integration].self, forKey: .integrations)
typedAudiences = try container.decodeIfPresent([Audience].self, forKey: .typedAudiences)
featureFlags = try container.decode([FeatureFlag].self, forKey: .featureFlags)
botFiltering = try container.decodeIfPresent(Bool.self, forKey: .botFiltering)
sendFlagDecisions = try container.decodeIfPresent(Bool.self, forKey: .sendFlagDecisions)
sdkKey = try container.decodeIfPresent(String.self, forKey: .sdkKey)
environmentKey = try container.decodeIfPresent(String.self, forKey: .environmentKey)

// Holdouts - defaults to empty array if key is not present
holdouts = try container.decodeIfPresent([Holdout].self, forKey: .holdouts) ?? []
}

// Required since logger is not equatable
static func == (lhs: Project, rhs: Project) -> Bool {
return lhs.version == rhs.version && lhs.projectId == rhs.projectId && lhs.experiments == rhs.experiments &&
return lhs.version == rhs.version && lhs.projectId == rhs.projectId && lhs.experiments == rhs.experiments && lhs.holdouts == rhs.holdouts &&
lhs.audiences == rhs.audiences && lhs.groups == rhs.groups && lhs.attributes == rhs.attributes &&
lhs.accountId == rhs.accountId && lhs.events == rhs.events && lhs.revision == rhs.revision &&
lhs.anonymizeIP == rhs.anonymizeIP && lhs.rollouts == rhs.rollouts &&
Expand Down
53 changes: 52 additions & 1 deletion Sources/Data Model/ProjectConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@
import Foundation

class ProjectConfig {
private var isUpdating = false // Flag to prevent recursion

var project: Project! {
didSet {
updateProjectDependentProps()
if !isUpdating {
updateProjectDependentProps()
}
}
}

let logger = OPTLoggerFactory.getLogger()

// local runtime forcedVariations [UserId: [ExperimentId: VariationId]]
Expand All @@ -40,6 +44,7 @@ class ProjectConfig {
var allExperiments = [Experiment]()
var flagVariationsMap = [String: [Variation]]()
var allSegments = [String]()
var holdoutIdMap = [String: Holdout]()

// MARK: - Init

Expand All @@ -66,8 +71,19 @@ class ProjectConfig {
init() {}

func updateProjectDependentProps() {
isUpdating = true
defer { isUpdating = false }

self.allExperiments = project.experiments + project.groups.map { $0.experiments }.flatMap { $0 }

holdoutIdMap = {
var map = [String : Holdout]()
project.holdouts.forEach { map[$0.id] = $0 }
return map
}()

assignHoldoutIdsToFeatureFlags()

self.experimentKeyMap = {
var map = [String: Experiment]()
allExperiments.forEach { exp in
Expand Down Expand Up @@ -155,6 +171,34 @@ class ProjectConfig {

}

private func assignHoldoutIdsToFeatureFlags() {
let flagsWithHoldoutIds = project.featureFlags.map { flag -> FeatureFlag in
var updatedFlag = flag
var holdoutIds = [String]()
for holdout in project.holdouts ?? [] {
if !holdout.includedFlags.isEmpty {
if holdout.includedFlags.contains(flag.id) {
holdoutIds.append(holdout.id)
}
} else if !holdout.excludedFlags.isEmpty {
if !holdout.excludedFlags.contains(flag.id) {
holdoutIds.append(holdout.id)
}
} else {
// Global holdout
holdoutIds.append(holdout.id)
}
}

/// Update holdoutIds for the flag
updatedFlag.holdoutIds = holdoutIds
return updatedFlag
}

// Update project featureFlags after mapping with holdoutIds
project.featureFlags = flagsWithHoldoutIds
}

func getAllRulesForFlag(_ flag: FeatureFlag) -> [Experiment] {
var rules = flag.experimentIds.compactMap { experimentIdMap[$0] }
let rollout = self.rolloutIdMap[flag.rolloutId]
Expand Down Expand Up @@ -270,6 +314,13 @@ extension ProjectConfig {
return rolloutIdMap[id]
}

/**
* Get a Holdout object for an Id.
*/
func getHoldout(id: String) -> Holdout? {
return holdoutIdMap[id]
}

/**
* Gets an event for a corresponding event key
*/
Expand Down
3 changes: 3 additions & 0 deletions Sources/Optimizely/OptimizelyConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public protocol OptimizelyExperiment {
var variationsMap: [String: OptimizelyVariation] { get }
}

// Experiment compliances OptimizelyExperiment
extension Experiment: OptimizelyExperiment { }

public protocol OptimizelyFeature {
var id: String { get }
var key: String { get }
Expand Down
91 changes: 91 additions & 0 deletions Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,97 @@ class ProjectConfigTests: XCTestCase {
XCTAssertEqual(featureMap["1004"], ["2002"])
}

func testHoldoutIdMapIsBuiltFromProject() {
var exp0 = ExperimentTests.sampleData
var exp1 = ExperimentTests.sampleData
var exp2 = ExperimentTests.sampleData
var exp3 = ExperimentTests.sampleData
var exp4 = ExperimentTests.sampleData
exp0["id"] = "1000"
exp1["id"] = "1001"
exp2["id"] = "1002"
exp3["id"] = "1003"
exp4["id"] = "1004"


var holdout0 = HoldoutTests.sampleData
var holdout1 = HoldoutTests.sampleData
var holdout2 = HoldoutTests.sampleData
var holdout3 = HoldoutTests.sampleData
var holdout4 = HoldoutTests.sampleData
holdout0["id"] = "3000" // Global holdout (no included or excluded flags)
holdout1["id"] = "3001" // Global holdout (no included or excluded flags)
holdout2["id"] = "3002" // Global holdout (no included or excluded flags)
holdout3["id"] = "3003" // Included flagids ["2000", "2002"]
holdout4["id"] = "3004" // Excluded flagids ["2001"]

holdout3["includedFlags"] = ["2000", "2002"]
holdout4["excludedFlags"] = ["2001"]

var feature0 = FeatureFlagTests.sampleData
var feature1 = FeatureFlagTests.sampleData
var feature2 = FeatureFlagTests.sampleData
var feature3 = FeatureFlagTests.sampleData

feature0["id"] = "2000"
feature0["key"] = "key_2000"

feature1["id"] = "2001"
feature1["key"] = "key_2001"

feature2["id"] = "2002"
feature2["key"] = "key_2002"

feature3["id"] = "2003"
feature3["key"] = "key_2003"

feature0["experimentIds"] = ["1000"]
feature1["experimentIds"] = ["1000", "1001", "1002"]
feature2["experimentIds"] = ["1000", "1003", "1004"]
feature3["experimentIds"] = ["1000", "1003", "1004"]

var projectData = ProjectTests.sampleData
projectData["experiments"] = [exp0, exp1, exp2, exp3, exp4]
projectData["featureFlags"] = [feature0, feature1, feature2, feature3]
projectData["holdouts"] = [holdout0, holdout1, holdout2, holdout3, holdout4]

// check experimentFeatureMap extracted properly

let model: Project = try! OTUtils.model(from: projectData)
let projectConfig = ProjectConfig()
projectConfig.project = model

let holdoutIdMap = projectConfig.holdoutIdMap

XCTAssertEqual(holdoutIdMap["3000"]?.includedFlags, [])
XCTAssertEqual(holdoutIdMap["3000"]?.excludedFlags, [])

XCTAssertEqual(holdoutIdMap["3001"]?.includedFlags, [])
XCTAssertEqual(holdoutIdMap["3001"]?.excludedFlags, [])

XCTAssertEqual(holdoutIdMap["3002"]?.includedFlags, [])
XCTAssertEqual(holdoutIdMap["3002"]?.excludedFlags, [])

XCTAssertEqual(holdoutIdMap["3003"]?.includedFlags, ["2000", "2002"])
XCTAssertEqual(holdoutIdMap["3003"]?.excludedFlags, [])


XCTAssertEqual(holdoutIdMap["3004"]?.includedFlags, [])
XCTAssertEqual(holdoutIdMap["3004"]?.excludedFlags, ["2001"])

let featureFlagKeyMap = projectConfig.featureFlagKeyMap

/// Test Global holdout + included
XCTAssertEqual(featureFlagKeyMap["key_2000"]?.holdoutIds, ["3000", "3001", "3002", "3003", "3004"])
XCTAssertEqual(featureFlagKeyMap["key_2002"]?.holdoutIds, ["3000", "3001", "3002", "3003", "3004"])

/// Test Global holdout - excluded
XCTAssertEqual(featureFlagKeyMap["key_2001"]?.holdoutIds, ["3000", "3001", "3002"])

/// Test Global holdout
XCTAssertEqual(featureFlagKeyMap["key_2003"]?.holdoutIds, ["3000", "3001", "3002", "3004"])
}

func testFlagVariations() {
let datafile = OTUtils.loadJSONDatafile("decide_datafile")!
let optimizely = OptimizelyClient(sdkKey: "12345",
Expand Down
12 changes: 12 additions & 0 deletions Tests/OptimizelyTests-DataModel/ProjectTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class ProjectTests: XCTestCase {
static var sampleData: [String: Any] = ["version": "4",
"projectId": "11111",
"experiments": [ExperimentTests.sampleData],
"holdouts": [HoldoutTests.sampleData],
"audiences": [AudienceTests.sampleData],
"groups": [GroupTests.sampleData],
"attributes": [AttributeTests.sampleData],
Expand Down Expand Up @@ -49,6 +50,7 @@ extension ProjectTests {
XCTAssert(model.version == "4")
XCTAssert(model.projectId == "11111")
XCTAssert(model.experiments == [try! OTUtils.model(from: ExperimentTests.sampleData)])
XCTAssert(model.holdouts == [try! OTUtils.model(from: HoldoutTests.sampleData)])
XCTAssert(model.audiences == [try! OTUtils.model(from: AudienceTests.sampleData)])
XCTAssert(model.groups == [try! OTUtils.model(from: GroupTests.sampleData)])
XCTAssert(model.attributes == [try! OTUtils.model(from: AttributeTests.sampleData)])
Expand Down Expand Up @@ -210,6 +212,16 @@ extension ProjectTests {
XCTAssertNil(model.sendFlagDecisions)
}

func testDecodeSuccessWithMissingHoldouts() {
var data: [String: Any] = ProjectTests.sampleData
data["holdouts"] = nil

let model: Project = try! OTUtils.model(from: data)
XCTAssertNotNil(model)
XCTAssertNil(model.holdouts)

}

}

// MARK: - Encode
Expand Down
Loading