• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

optimizely / swift-sdk / 24267258158

10 Apr 2026 10:34PM UTC coverage: 93.828% (+0.06%) from 93.766%
24267258158

Pull #629

github

web-flow
Merge 6b816b1bc into 26759a5df
Pull Request #629: [AI-FSSDK] [FSSDK-12368] Local Holdouts - Cleanup flag base setup and add includedRules and rule-level lookup

3 of 3 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

1125 of 1199 relevant lines covered (93.83%)

8864.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.83
/Sources/Data%20Model/ProjectConfig.swift
1
//
2
// Copyright 2019-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
class ProjectConfig {
20
    var project: Project! {
21
        didSet {
379✔
22
            updateProjectDependentProps()
23
        }
24
    }
25
    
26
    let logger = OPTLoggerFactory.getLogger()
3,221✔
27
    
28
    // local runtime forcedVariations [UserId: [ExperimentId: VariationId]]
29
    // NOTE: experiment.forcedVariations use [ExperimentKey: VariationKey] instead of ids
30
    var whitelistUsers = AtomicProperty(property: [String: [String: String]]())
3,221✔
31
    
32
    var experimentKeyMap = [String: Experiment]()
3,221✔
33
    var experimentIdMap = [String: Experiment]()
3,221✔
34
    var experimentFeatureMap = [String: [String]]()
3,221✔
35
    var eventKeyMap = [String: Event]()
3,221✔
36
    var attributeKeyMap = [String: Attribute]()
3,221✔
37
    var attributeIdMap = [String: Attribute]()
3,221✔
38
    var featureFlagKeyMap = [String: FeatureFlag]()
3,221✔
39
    var featureFlagKeys = [String]()
3,220✔
40
    var rolloutIdMap = [String: Rollout]()
3,221✔
41
    var allExperiments = [Experiment]()
3,221✔
42
    var flagVariationsMap = [String: [Variation]]()
3,221✔
43
    var allSegments = [String]()
3,221✔
44
    var holdoutConfig = HoldoutConfig()
3,221✔
45

46
    // MARK: - Init
47
    
48
    init(datafile: Data) throws {
3,195✔
49
        var project: Project
50
        do {
51
            project = try JSONDecoder().decode(Project.self, from: datafile)
52
        } catch {
53
            throw OptimizelyError.dataFileInvalid
54
        }
55
        
56
        if !isValidVersion(version: project.version) {
57
            throw OptimizelyError.dataFileVersionInvalid(project.version)
58
        }
59

60
        self.project = project
61
        updateProjectDependentProps()  // project:didSet is not fired in init. explicitly called.
62
    }
63
    
64
    convenience init(datafile: String) throws {
×
65
        try self.init(datafile: Data(datafile.utf8))
66
    }
67
    
68
    init() {}
26✔
69
    
70
    func updateProjectDependentProps() {
3,553✔
71
        
72
        self.allExperiments = project.experiments + project.groups.map { $0.experiments }.flatMap { $0 }
314✔
73
        
74
        self.rolloutIdMap = {
3,554✔
75
            var map = [String: Rollout]()
76
            project.rollouts.forEach { map[$0.id] = $0 }
3,103✔
77
            return map
78
        }()
79

80
        // Feature Rollout injection: for each feature flag, inject the "everyone else"
81
        // variation into any experiment with type == .featureRollout
82
        injectFeatureRolloutVariations()
83
        
84
        holdoutConfig.allHoldouts = project.holdouts
85
        
86
        self.experimentKeyMap = {
3,553✔
87
            var map = [String: Experiment]()
88
            allExperiments.forEach { exp in
9,549✔
89
                map[exp.key] = exp
90
            }
91
            return map
92
        }()
93
        
94
        self.experimentIdMap = {
3,554✔
95
            var map = [String: Experiment]()
96
            allExperiments.forEach { map[$0.id] = $0 }
9,549✔
97
            return map
98
        }()
99
        
100
        self.experimentFeatureMap = {
3,552✔
101
            var experimentFeatureMap = [String: [String]]()
102
            project.featureFlags.forEach { (ff) in
4,230✔
103
                ff.experimentIds.forEach {
1,389✔
104
                    if var arr = experimentFeatureMap[$0] {
105
                        arr.append(ff.id)
106
                        experimentFeatureMap[$0] = arr
107
                    } else {
108
                        experimentFeatureMap[$0] = [ff.id]
109
                    }
110
                }
111
            }
112
            return experimentFeatureMap
113
        }()
114
        
115
        self.eventKeyMap = {
3,553✔
116
            var eventKeyMap = [String: Event]()
117
            project.events.forEach { eventKeyMap[$0.key] = $0 }
6,140✔
118
            return eventKeyMap
119
        }()
120
        
121
        self.attributeKeyMap = {
3,554✔
122
            var map = [String: Attribute]()
123
            project.attributes.forEach { map[$0.key] = $0 }
4,124✔
124
            return map
125
        }()
126
        
127
        self.attributeIdMap = {
3,554✔
128
            var map = [String: Attribute]()
129
            project.attributes.forEach { map[$0.id] = $0 }
4,124✔
130
            return map
131
        }()
132
        
133
        self.featureFlagKeyMap = {
3,554✔
134
            var map = [String: FeatureFlag]()
135
            project.featureFlags.forEach { map[$0.key] = $0 }
4,228✔
136
            return map
137
        }()
138
        
139
        self.featureFlagKeys = {
3,554✔
140
            return project.featureFlags.map { $0.key }
4,230✔
141
        }()
142

143
        // all variations for each flag
144
        // - datafile does not contain a separate entity for this.
145
        // - we collect variations used in each rule (experiment rules and delivery rules)
146
        
147
        self.flagVariationsMap = {
3,554✔
148
            var map = [String: [Variation]]()
149
            
150
            project.featureFlags.forEach { flag in
4,228✔
151
                var variations = [Variation]()
152
                
153
                getAllRulesForFlag(flag).forEach { rule in
4,934✔
154
                    rule.variations.forEach { variation in
6,333✔
155
                        if variations.filter({ $0.id == variation.id }).first == nil {
4,490✔
156
                            variations.append(variation)
157
                        }
158
                    }
159
                }
160
                map[flag.key] = variations
161
            }
162
            
163
            return map
164
        }()
165
        
166
        self.allSegments = {
3,554✔
167
            let audiences = project.typedAudiences ?? []
3,082✔
168
            return Array(Set(audiences.flatMap { $0.getSegments() }))
5,240✔
169
        }()
170
        
171
    }
172
    
173
    func getGlobalHoldouts() -> [Holdout] {
4,023✔
174
        return holdoutConfig.getGlobalHoldouts()
175
    }
176

177
    func getHoldoutsForRule(ruleId: String) -> [Holdout] {
3,797✔
178
        return holdoutConfig.getHoldoutsForRule(ruleId: ruleId)
179
    }
180
        
181
    func getAllRulesForFlag(_ flag: FeatureFlag) -> [Experiment] {
4,228✔
182
        var rules = flag.experimentIds.compactMap { experimentIdMap[$0] }
1,389✔
183
        let rollout = self.rolloutIdMap[flag.rolloutId]
184
        rules.append(contentsOf: rollout?.experiments ?? [])
1,158✔
185
        return rules
186
    }
187

188
}
189

190
// MARK: - Feature Rollout Injection
191

192
extension ProjectConfig {
193
    /// Injects the "everyone else" variation from a flag's rollout into any
194
    /// experiment with type == .featureRollout. After injection the existing
195
    /// decision logic evaluates feature rollouts without modification.
196
    func injectFeatureRolloutVariations() {
3,553✔
197
        for flag in project.featureFlags {
198
            guard let everyoneElseVariation = getEveryoneElseVariation(for: flag) else {
199
                continue
200
            }
201

202
            for experimentId in flag.experimentIds {
203
                guard let index = allExperiments.firstIndex(where: { $0.id == experimentId }) else {
744✔
204
                    continue
205
                }
206

207
                guard allExperiments[index].isFeatureRollout else {
208
                    continue
209
                }
210

211
                allExperiments[index].variations.append(everyoneElseVariation)
212
                allExperiments[index].trafficAllocation.append(
213
                    TrafficAllocation(entityId: everyoneElseVariation.id, endOfRange: 10000)
214
                )
215
            }
216
        }
217
    }
218

219
    /// Returns the first variation of the last experiment (the "everyone else"
220
    /// rule) in the rollout associated with the given feature flag. Returns nil
221
    /// if the rollout cannot be resolved or has no variations.
222
    func getEveryoneElseVariation(for flag: FeatureFlag) -> Variation? {
4,230✔
223
        guard !flag.rolloutId.isEmpty,
224
              let rollout = rolloutIdMap[flag.rolloutId],
225
              let everyoneElseRule = rollout.experiments.last,
226
              let variation = everyoneElseRule.variations.first else {
227
            return nil
228
        }
229
        return variation
230
    }
231
}
232

233
// MARK: - Persistent Data
234

235
extension ProjectConfig {
236
    func whitelistUser(userId: String, experimentId: String, variationId: String) {
1,011✔
237
        whitelistUsers.performAtomic { whitelist in
1,011✔
238
            var dict = whitelist[userId] ?? [String: String]()
110✔
239
            dict[experimentId] = variationId
240
            whitelist[userId] = dict
241
        }
242
    }
243
    
244
    func removeFromWhitelist(userId: String, experimentId: String) {
1,001✔
245
        whitelistUsers.performAtomic { whitelist in
1,001✔
246
            whitelist[userId]?.removeValue(forKey: experimentId)
247
        }
248
    }
249
    
250
    func getWhitelistedVariationId(userId: String, experimentId: String) -> String? {
5,650✔
251
        if let dict = whitelistUsers.property?[userId] {
252
            return dict[experimentId]
253
        }
254
        
255
        logger.d(.userHasNoForcedVariation(userId))
256
        return nil
257
    }
258
    
259
    func isValidVersion(version: String) -> Bool {
3,178✔
260
        // old versions (< 4) of datafiles not supported
261
        return ["4"].contains(version)
262
    }
263
}
264

265
// MARK: - Project Access
266

267
extension ProjectConfig {
268
    
269
    /**
270
     * Get the region value. Defaults to US if not specified in the project.
271
     */
272
    public var region: Region {
1,500✔
273
        return project.region ?? .US
1,500✔
274
    }
275
    
276
    /**
277
     * Get sendFlagDecisions value.
278
     */
279
    var sendFlagDecisions: Bool {
2,958✔
280
        return project.sendFlagDecisions ?? false
2,513✔
281
    }
282
    
283
    /**
284
     * ODP API server publicKey.
285
     */
286
    var publicKeyForODP: String? {
3,131✔
287
        return project.integrations?.filter { $0.key == "odp" }.first?.publicKey
37✔
288
    }
289
    
290
    /**
291
     * ODP API server host.
292
     */
293
    var hostForODP: String? {
3,133✔
294
        return project.integrations?.filter { $0.key == "odp" }.first?.host
37✔
295
    }
296
    
297
    /**
298
     * Get an Experiment object for a key.
299
     */
300
    func getExperiment(key: String) -> Experiment? {
8,800✔
301
        return experimentKeyMap[key]
302
    }
303
    
304
    /**
305
     * Get an Experiment object for an Id.
306
     */
307
    func getExperiment(id: String) -> Experiment? {
1,195✔
308
        return experimentIdMap[id]
309
    }
310
    
311
    /**
312
     * Get an experiment Id for the human readable experiment key
313
     **/
314
    func getExperimentId(key: String) -> String? {
5✔
315
        return getExperiment(key: key)?.id
316
    }
317
    
318
    /**
319
     * Get a Group object for an Id.
320
     */
321
    func getGroup(id: String) -> Group? {
6✔
322
        return project.groups.filter { $0.id == id }.first
10✔
323
    }
324
    
325
    /**
326
     * Get a Feature Flag object for a key.
327
     */
328
    func getFeatureFlag(key: String) -> FeatureFlag? {
5,622✔
329
        return featureFlagKeyMap[key]
330
    }
331
    
332
    /**
333
     * Get all Feature Flag objects.
334
     */
335
    func getFeatureFlags() -> [FeatureFlag] {
4✔
336
        return project.featureFlags
337
    }
338
    
339
    /**
340
     * Get a Rollout object for an Id.
341
     */
342
    func getRollout(id: String) -> Rollout? {
2,590✔
343
        return rolloutIdMap[id]
344
    }
345
    
346
    /**
347
     * Get a Holdout object for an Id.
348
     */
UNCOV
349
    func getHoldout(id: String) -> Holdout? {
×
350
        return holdoutConfig.getHoldout(id: id)
351
    }
352
    
353
    /**
354
     * Gets an event for a corresponding event key
355
     */
356
    func getEvent(key: String) -> Event? {
68✔
357
        return eventKeyMap[key]
358
    }
359
    
360
    /**
361
     * Gets an event id for a corresponding event key
362
     */
363
    func getEventId(key: String) -> String? {
1✔
364
        return getEvent(key: key)?.id
365
    }
366
    
367
    /**
368
     * Get an attribute for a given key.
369
     */
370
    func getAttribute(key: String) -> Attribute? {
210✔
371
        return attributeKeyMap[key]
372
    }
373
    
374
    /**
375
     * Get an attribute for a given id.
376
     */
377
    func getAttribute(id: String) -> Attribute? {
45✔
378
        return attributeIdMap[id]
379
    }
380
    
381
    /**
382
     * Get an attribute Id for a given key.
383
     **/
384
    func getAttributeId(key: String) -> String? {
209✔
385
        return getAttribute(key: key)?.id
386
    }
387
    
388
    /**
389
     * Get an audience for a given audience id.
390
     */
391
    func getAudience(id: String) -> Audience? {
1✔
392
        return project.getAudience(id: id)
393
    }
394
    
395
    /**
396
     *  Returns true if experiment belongs to any feature, false otherwise.
397
     */
398
    func isFeatureExperiment(id: String) -> Bool {
2,556✔
399
        return !(experimentFeatureMap[id]?.isEmpty ?? true)
2,533✔
400
    }
401
        
402
    /**
403
     * Get forced variation for a given experiment key and user id.
404
     */
405
    func getForcedVariation(experimentKey: String, userId: String) -> DecisionResponse<Variation> {
3,651✔
406
        let reasons = DecisionReasons()
407
        
408
        guard let experiment = getExperiment(key: experimentKey) else {
409
            return DecisionResponse(result: nil, reasons: reasons)
410
        }
411
        
412
        if let id = getWhitelistedVariationId(userId: userId, experimentId: experiment.id) {
413
            if let variation = experiment.getVariation(id: id) {
414
                let info = LogMessage.userHasForcedVariation(userId, experiment.key, variation.key)
415
                logger.d(info)
416
                reasons.addInfo(info)
417

418
                return DecisionResponse(result: variation, reasons: reasons)
419
            }
420
            
421
            let info = LogMessage.userHasForcedVariationButInvalid(userId, experiment.key)
422
            logger.d(info)
423
            reasons.addInfo(info)
424

425
            return DecisionResponse(result: nil, reasons: reasons)
426
        }
427
        
428
        logger.d(.userHasNoForcedVariationForExperiment(userId, experiment.key))
429
        return DecisionResponse(result: nil, reasons: reasons)
430
    }
431
    
432
    /**
433
     * Set forced variation for a given experiment key and user id according to a given variation key.
434
     */
435
    func setForcedVariation(experimentKey: String, userId: String, variationKey: String?) -> Bool {
15✔
436
        guard let experiment = getExperiment(key: experimentKey) else {
437
            return false
438
        }
439
        
440
        guard var variationKey = variationKey else {
441
            logger.d(.variationRemovedForUser(userId, experimentKey))
442
            self.removeFromWhitelist(userId: userId, experimentId: experiment.id)
443
            return true
444
        }
445
        
446
        // TODO: common function to trim all keys
447
        variationKey = variationKey.trimmingCharacters(in: NSCharacterSet.whitespaces)
448
        
449
        guard !variationKey.isEmpty else {
450
            logger.e(.variationKeyInvalid(experimentKey, variationKey))
451
            return false
452
        }
453
        
454
        guard let variation = experiment.getVariation(key: variationKey) else {
455
            logger.e(.variationKeyInvalid(experimentKey, variationKey))
456
            return false
457
        }
458
        
459
        self.whitelistUser(userId: userId, experimentId: experiment.id, variationId: variation.id)
460
        
461
        logger.d(.userMappedToForcedVariation(userId, experiment.id, variation.id))
462
        return true
463
    }
464
    
465
    func getFlagVariationByKey(flagKey: String, variationKey: String) -> Variation? {
21✔
466
        if let variations = flagVariationsMap[flagKey] {
467
            return variations.filter { $0.key == variationKey }.first
93✔
468
        }
469
        
470
        return nil
471
    }
472
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc