Skip to content

Commit 35a642f

Browse files
Merge branch 'master' into muzahid/prep-release-5.1.1
2 parents ae8f6b5 + 4dffe6d commit 35a642f

32 files changed

+3455
-177
lines changed

.github/workflows/integration_tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,4 @@ jobs:
4545
EVENT_MESSAGE: ${{ github.event.message }}
4646
HOME: 'home/runner'
4747
run: |
48-
home/runner/ci-helper-tools/trigger-script-with-status-update.sh
48+
home/runner/ci-helper-tools/trigger-script-with-status-update.sh

.github/workflows/unit_tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ jobs:
7676
NAME: ${{ matrix.device }}
7777
run: |
7878
gem install coveralls-lcov
79-
gem install cocoapods -v '1.11.3'
79+
gem install cocoapods -v '1.15.2'
8080
pod repo update
8181
pod install
8282
HOMEBREW_NO_INSTALL_CLEANUP=true brew update && brew install jq

OptimizelySwiftSDK.xcodeproj/project.pbxproj

Lines changed: 171 additions & 1 deletion
Large diffs are not rendered by default.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,4 @@ Used to enforce Swift style and conventions.
129129

130130
- React - https://github.com/optimizely/react-sdk
131131

132-
- Ruby - https://github.com/optimizely/ruby-sdk
132+
- Ruby - https://github.com/optimizely/ruby-sdk

Sources/Data Model/Experiment.swift

Lines changed: 1 addition & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import Foundation
1818

19-
struct Experiment: Codable, OptimizelyExperiment {
19+
struct Experiment: Codable, ExperimentCore {
2020
enum Status: String, Codable {
2121
case running = "Running"
2222
case launched = "Launched"
@@ -64,74 +64,9 @@ extension Experiment: Equatable {
6464
// MARK: - Utils
6565

6666
extension Experiment {
67-
func getVariation(id: String) -> Variation? {
68-
return variations.filter { $0.id == id }.first
69-
}
70-
71-
func getVariation(key: String) -> Variation? {
72-
return variations.filter { $0.key == key }.first
73-
}
7467

7568
var isActivated: Bool {
7669
return status == .running
7770
}
7871

79-
mutating func serializeAudiences(with audiencesMap: [String: String]) {
80-
guard let conditions = audienceConditions else { return }
81-
82-
let serialized = conditions.serialized
83-
audiences = replaceAudienceIdsWithNames(string: serialized, audiencesMap: audiencesMap)
84-
}
85-
86-
/// Replace audience ids with audience names
87-
///
88-
/// example:
89-
/// - string: "(AUDIENCE(1) OR AUDIENCE(2)) AND AUDIENCE(3)"
90-
/// - replaced: "(\"us\" OR \"female\") AND \"adult\""
91-
///
92-
/// - Parameter string: before replacement
93-
/// - Returns: string after replacement
94-
func replaceAudienceIdsWithNames(string: String, audiencesMap: [String: String]) -> String {
95-
let beginWord = "AUDIENCE("
96-
let endWord = ")"
97-
var keyIdx = 0
98-
var audienceId = ""
99-
var collect = false
100-
101-
var replaced = ""
102-
for ch in string {
103-
// extract audience id in parenthesis (example: AUDIENCE("35") => "35")
104-
if collect {
105-
if String(ch) == endWord {
106-
// output the extracted audienceId
107-
replaced += "\"\(audiencesMap[audienceId] ?? audienceId)\""
108-
collect = false
109-
audienceId = ""
110-
} else {
111-
audienceId += String(ch)
112-
}
113-
continue
114-
}
115-
116-
// walk-through until finding a matching keyword "AUDIENCE("
117-
if ch == Array(beginWord)[keyIdx] {
118-
keyIdx += 1
119-
if keyIdx == beginWord.count {
120-
keyIdx = 0
121-
collect = true
122-
}
123-
continue
124-
} else {
125-
if keyIdx > 0 {
126-
replaced += Array(beginWord)[..<keyIdx]
127-
}
128-
keyIdx = 0
129-
}
130-
131-
// pass through other characters
132-
replaced += String(ch)
133-
}
134-
135-
return replaced
136-
}
13772
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//
2+
// Copyright 2022, Optimizely, Inc. and contributors
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//
16+
17+
import Foundation
18+
19+
protocol ExperimentCore: OptimizelyExperiment {
20+
var audiences: String { get set }
21+
var layerId: String { get }
22+
var variations: [Variation] { get }
23+
var trafficAllocation: [TrafficAllocation] { get }
24+
var audienceIds: [String] { get }
25+
var audienceConditions: ConditionHolder? { get }
26+
}
27+
28+
// Shared utilities in an extension
29+
extension ExperimentCore {
30+
func getVariation(id: String) -> Variation? {
31+
return variations.filter { $0.id == id }.first
32+
}
33+
34+
func getVariation(key: String) -> Variation? {
35+
return variations.filter { $0.key == key }.first
36+
}
37+
38+
func replaceAudienceIdsWithNames(string: String, audiencesMap: [String: String]) -> String {
39+
let beginWord = "AUDIENCE("
40+
let endWord = ")"
41+
var keyIdx = 0
42+
var audienceId = ""
43+
var collect = false
44+
45+
var replaced = ""
46+
for ch in string {
47+
if collect {
48+
if String(ch) == endWord {
49+
replaced += "\"\(audiencesMap[audienceId] ?? audienceId)\""
50+
collect = false
51+
audienceId = ""
52+
} else {
53+
audienceId += String(ch)
54+
}
55+
continue
56+
}
57+
58+
if ch == Array(beginWord)[keyIdx] {
59+
keyIdx += 1
60+
if keyIdx == beginWord.count {
61+
keyIdx = 0
62+
collect = true
63+
}
64+
continue
65+
} else {
66+
if keyIdx > 0 {
67+
replaced += Array(beginWord)[..<keyIdx]
68+
}
69+
keyIdx = 0
70+
}
71+
72+
replaced += String(ch)
73+
}
74+
75+
return replaced
76+
}
77+
78+
mutating func serializeAudiences(with audiencesMap: [String: String]) {
79+
guard let conditions = audienceConditions else { return }
80+
81+
let serialized = conditions.serialized
82+
audiences = replaceAudienceIdsWithNames(string: serialized, audiencesMap: audiencesMap)
83+
}
84+
}

Sources/Data Model/Holdout.swift

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//
2+
// Copyright 2022, Optimizely, Inc. and contributors
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//
16+
17+
import Foundation
18+
19+
struct Holdout: Codable, ExperimentCore {
20+
enum Status: String, Codable {
21+
case draft = "Draft"
22+
case running = "Running"
23+
case concluded = "Concluded"
24+
case archived = "Archived"
25+
}
26+
27+
var id: String
28+
var key: String
29+
var status: Status
30+
var layerId: String
31+
var variations: [Variation]
32+
var trafficAllocation: [TrafficAllocation]
33+
var audienceIds: [String]
34+
var audienceConditions: ConditionHolder?
35+
var includedFlags: [String]
36+
var excludedFlags: [String]
37+
38+
enum CodingKeys: String, CodingKey {
39+
case id, key, status, layerId, variations, trafficAllocation, audienceIds, audienceConditions, includedFlags, excludedFlags
40+
}
41+
42+
var variationsMap: [String: OptimizelyVariation] = [:]
43+
// replace with serialized string representation with audience names when ProjectConfig is ready
44+
var audiences: String = ""
45+
46+
init(from decoder: Decoder) throws {
47+
let container = try decoder.container(keyedBy: CodingKeys.self)
48+
49+
id = try container.decode(String.self, forKey: .id)
50+
key = try container.decode(String.self, forKey: .key)
51+
status = try container.decode(Status.self, forKey: .status)
52+
layerId = try container.decode(String.self, forKey: .layerId)
53+
variations = try container.decode([Variation].self, forKey: .variations)
54+
trafficAllocation = try container.decode([TrafficAllocation].self, forKey: .trafficAllocation)
55+
audienceIds = try container.decode([String].self, forKey: .audienceIds)
56+
audienceConditions = try container.decodeIfPresent(ConditionHolder.self, forKey: .audienceConditions)
57+
58+
includedFlags = try container.decodeIfPresent([String].self, forKey: .includedFlags) ?? []
59+
excludedFlags = try container.decodeIfPresent([String].self, forKey: .excludedFlags) ?? []
60+
}
61+
}
62+
63+
extension Holdout: Equatable {
64+
static func == (lhs: Holdout, rhs: Holdout) -> Bool {
65+
return lhs.id == rhs.id &&
66+
lhs.key == rhs.key &&
67+
lhs.status == rhs.status &&
68+
lhs.layerId == rhs.layerId &&
69+
lhs.variations == rhs.variations &&
70+
lhs.trafficAllocation == rhs.trafficAllocation &&
71+
lhs.audienceIds == rhs.audienceIds &&
72+
lhs.audienceConditions == rhs.audienceConditions &&
73+
lhs.includedFlags == rhs.includedFlags &&
74+
lhs.excludedFlags == rhs.excludedFlags
75+
}
76+
}
77+
78+
extension Holdout {
79+
var isActivated: Bool {
80+
return status == .running
81+
}
82+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
//
2+
// Copyright 2025, Optimizely, Inc. and contributors
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//
16+
17+
import Foundation
18+
19+
struct HoldoutConfig {
20+
var allHoldouts: [Holdout] {
21+
didSet {
22+
updateHoldoutMapping()
23+
}
24+
}
25+
private(set) var global: [Holdout] = []
26+
private(set) var holdoutIdMap: [String: Holdout] = [:]
27+
private(set) var flagHoldoutsMap: [String: [Holdout]] = [:]
28+
private(set) var includedHoldouts: [String: [Holdout]] = [:]
29+
private(set) var excludedHoldouts: [String: [Holdout]] = [:]
30+
31+
init(allholdouts: [Holdout] = []) {
32+
self.allHoldouts = allholdouts
33+
updateHoldoutMapping()
34+
}
35+
36+
/// Updates internal mappings of holdouts including the id map, global list, and per-flag inclusion/exclusion maps.
37+
mutating func updateHoldoutMapping() {
38+
holdoutIdMap = {
39+
var map = [String: Holdout]()
40+
allHoldouts.forEach { map[$0.id] = $0 }
41+
return map
42+
}()
43+
44+
flagHoldoutsMap = [:]
45+
global = []
46+
includedHoldouts = [:]
47+
excludedHoldouts = [:]
48+
49+
for holdout in allHoldouts {
50+
switch (holdout.includedFlags.isEmpty, holdout.excludedFlags.isEmpty) {
51+
case (true, true):
52+
global.append(holdout)
53+
54+
case (false, _):
55+
holdout.includedFlags.forEach { flagId in
56+
if var existing = includedHoldouts[flagId] {
57+
existing.append(holdout)
58+
includedHoldouts[flagId] = existing
59+
} else {
60+
includedHoldouts[flagId] = [holdout]
61+
}
62+
}
63+
64+
case (true, false):
65+
global.append(holdout)
66+
67+
holdout.excludedFlags.forEach { flagId in
68+
if var existing = excludedHoldouts[flagId] {
69+
existing.append(holdout)
70+
excludedHoldouts[flagId] = existing
71+
} else {
72+
excludedHoldouts[flagId] = [holdout]
73+
}
74+
}
75+
}
76+
}
77+
}
78+
79+
/// Returns the applicable holdouts for the given flag ID by combining global holdouts (excluding any specified) and included holdouts, in that order.
80+
/// Caches the result for future calls.
81+
/// - Parameter id: The flag identifier.
82+
/// - Returns: An array of `Holdout` objects relevant to the given flag.
83+
mutating func getHoldoutForFlag(id: String) -> [Holdout] {
84+
guard !allHoldouts.isEmpty else { return [] }
85+
86+
// Check cache and return persistent holdouts
87+
if let holdouts = flagHoldoutsMap[id] {
88+
return holdouts
89+
}
90+
91+
// Prioritize global holdouts first
92+
var activeHoldouts: [Holdout] = []
93+
94+
let excluded = excludedHoldouts[id] ?? []
95+
96+
if !excluded.isEmpty {
97+
activeHoldouts = global.filter { holdout in
98+
return !excluded.contains(holdout)
99+
}
100+
} else {
101+
activeHoldouts = global
102+
}
103+
104+
let includedHoldouts = includedHoldouts[id] ?? []
105+
106+
activeHoldouts += includedHoldouts
107+
108+
flagHoldoutsMap[id] = activeHoldouts
109+
110+
return flagHoldoutsMap[id] ?? []
111+
}
112+
113+
/// Get a Holdout object for an Id.
114+
func getHoldout(id: String) -> Holdout? {
115+
return holdoutIdMap[id]
116+
}
117+
}

0 commit comments

Comments
 (0)