Skip to content

Commit 982147a

Browse files
Implement remaining LiveMap access API properties
Based on [1] at 7d4c215. A few outstanding questions on the PR; have implemented based on my current understanding of what's there. Development approach similar to that described in 4494033. Also, have not implemented the specification points related to RTO2's channel mode checking for same reasons as mentioned there. [1] ably/specification#341
1 parent 2a6b773 commit 982147a

2 files changed

Lines changed: 271 additions & 56 deletions

File tree

Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift

Lines changed: 103 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -120,78 +120,66 @@ internal final class DefaultLiveMap: LiveMap {
120120
return nil
121121
}
122122

123-
// RTLM5d2: If a ObjectsMapEntry exists at the key
124-
125-
// RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null
126-
if entry.tombstone == true {
127-
return nil
128-
}
129-
130-
// Handle primitive values in the order specified by RTLM5d2b through RTLM5d2e
131-
132-
// RTLM5d2b: If ObjectsMapEntry.data.boolean exists, return it
133-
if let boolean = entry.data.boolean {
134-
return .primitive(.bool(boolean))
135-
}
136-
137-
// RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it
138-
if let bytes = entry.data.bytes {
139-
return .primitive(.data(bytes))
140-
}
123+
// RTLM5d2: If a ObjectsMapEntry exists at the key, convert it using the shared logic
124+
return convertEntryToLiveMapValue(entry)
125+
}
141126

142-
// RTLM5d2d: If ObjectsMapEntry.data.number exists, return it
143-
if let number = entry.data.number {
144-
return .primitive(.number(number.doubleValue))
145-
}
127+
internal var size: Int {
128+
get throws(ARTErrorInfo) {
129+
// RTLM10c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001
130+
let currentChannelState = coreSDK.channelState
131+
if currentChannelState == .detached || currentChannelState == .failed {
132+
throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "LiveMap.size operation failed (invalid channel state: \(currentChannelState))")
133+
}
146134

147-
// RTLM5d2e: If ObjectsMapEntry.data.string exists, return it
148-
if let string = entry.data.string {
149-
switch string {
150-
case let .string(string):
151-
return .primitive(.string(string))
152-
case .json:
153-
// TODO: Understand how to handle JSON values (https://github.com/ably/specification/pull/333/files#r2164561055)
154-
notYetImplemented()
135+
return mutex.withLock {
136+
// RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map
137+
mutableState.data.values.count { entry in
138+
// RTLM14a: The method returns true if ObjectsMapEntry.tombstone is true
139+
// RTLM14b: Otherwise, it returns false
140+
entry.tombstone != true
141+
}
155142
}
156143
}
144+
}
157145

158-
// RTLM5d2f: If ObjectsMapEntry.data.objectId exists, get the object stored at that objectId from the internal ObjectsPool
159-
if let objectId = entry.data.objectId {
160-
// RTLM5d2f1: If an object with id objectId does not exist, return undefined/null
161-
guard let poolEntry = delegate.referenced?.getObjectFromPool(id: objectId) else {
162-
return nil
146+
internal var entries: [(key: String, value: LiveMapValue)] {
147+
get throws(ARTErrorInfo) {
148+
// RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001
149+
let currentChannelState = coreSDK.channelState
150+
if currentChannelState == .detached || currentChannelState == .failed {
151+
throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "LiveMap.entries operation failed (invalid channel state: \(currentChannelState))")
163152
}
164153

165-
// RTLM5d2f2: If an object with id objectId exists, return it
166-
switch poolEntry {
167-
case let .map(map):
168-
return .liveMap(map)
169-
case let .counter(counter):
170-
return .liveCounter(counter)
171-
}
172-
}
154+
return mutex.withLock {
155+
// RTLM11d: Returns key-value pairs from the internal data map
156+
// RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned
157+
var result: [(key: String, value: LiveMapValue)] = []
173158

174-
// RTLM5d2g: Otherwise, return undefined/null
175-
return nil
176-
}
159+
for (key, entry) in mutableState.data {
160+
// Convert entry to LiveMapValue using the same logic as get(key:)
161+
if let value = convertEntryToLiveMapValue(entry) {
162+
result.append((key: key, value: value))
163+
}
164+
}
177165

178-
internal var size: Int {
179-
mutex.withLock {
180-
// TODO: this is not yet specified, but it seems like the obvious right thing and it unlocks some integration tests; add spec point once specified
181-
mutableState.data.count
166+
return result
167+
}
182168
}
183169
}
184170

185-
internal var entries: [(key: String, value: LiveMapValue)] {
186-
notYetImplemented()
187-
}
188-
189171
internal var keys: [String] {
190-
notYetImplemented()
172+
get throws(ARTErrorInfo) {
173+
// RTLM12b: Identical to LiveMap#entries, except that it returns only the keys from the internal data map
174+
try entries.map(\.key)
175+
}
191176
}
192177

193178
internal var values: [LiveMapValue] {
194-
notYetImplemented()
179+
get throws(ARTErrorInfo) {
180+
// RTLM13b: Identical to LiveMap#entries, except that it returns only the values from the internal data map
181+
try entries.map(\.value)
182+
}
195183
}
196184

197185
internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) {
@@ -441,4 +429,63 @@ internal final class DefaultLiveMap: LiveMap {
441429
}
442430
}
443431
}
432+
433+
// MARK: - Helper Methods
434+
435+
/// Converts an ObjectsMapEntry to LiveMapValue using the same logic as get(key:)
436+
/// This is used by entries to ensure consistent value conversion
437+
private func convertEntryToLiveMapValue(_ entry: ObjectsMapEntry) -> LiveMapValue? {
438+
// RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null
439+
// This is also equivalent to the RTLM14 check
440+
if entry.tombstone == true {
441+
return nil
442+
}
443+
444+
// Handle primitive values in the order specified by RTLM5d2b through RTLM5d2e
445+
446+
// RTLM5d2b: If ObjectsMapEntry.data.boolean exists, return it
447+
if let boolean = entry.data.boolean {
448+
return .primitive(.bool(boolean))
449+
}
450+
451+
// RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it
452+
if let bytes = entry.data.bytes {
453+
return .primitive(.data(bytes))
454+
}
455+
456+
// RTLM5d2d: If ObjectsMapEntry.data.number exists, return it
457+
if let number = entry.data.number {
458+
return .primitive(.number(number.doubleValue))
459+
}
460+
461+
// RTLM5d2e: If ObjectsMapEntry.data.string exists, return it
462+
if let string = entry.data.string {
463+
switch string {
464+
case let .string(string):
465+
return .primitive(.string(string))
466+
case .json:
467+
// TODO: Understand how to handle JSON values (https://github.com/ably/specification/pull/333/files#r2164561055)
468+
notYetImplemented()
469+
}
470+
}
471+
472+
// RTLM5d2f: If ObjectsMapEntry.data.objectId exists, get the object stored at that objectId from the internal ObjectsPool
473+
if let objectId = entry.data.objectId {
474+
// RTLM5d2f1: If an object with id objectId does not exist, return undefined/null
475+
guard let poolEntry = delegate.referenced?.getObjectFromPool(id: objectId) else {
476+
return nil
477+
}
478+
479+
// RTLM5d2f2: If an object with id objectId exists, return it
480+
switch poolEntry {
481+
case let .map(map):
482+
return .liveMap(map)
483+
case let .counter(counter):
484+
return .liveCounter(counter)
485+
}
486+
}
487+
488+
// RTLM5d2g: Otherwise, return undefined/null
489+
return nil
490+
}
444491
}

Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,174 @@ struct DefaultLiveMapTests {
263263
}
264264
}
265265

266+
/// Tests for the `size`, `entries`, `keys`, and `values` properties, covering RTLM10, RTLM11, RTLM12, and RTLM13 specification points
267+
struct AccessPropertiesTests {
268+
// MARK: - Error Throwing Tests (RTLM10c, RTLM11c, RTLM12b, RTLM13b)
269+
270+
// @spec RTLM10c
271+
// @spec RTLM11c
272+
// @spec RTLM12b
273+
// @spec RTLM13b
274+
@Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState])
275+
func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws {
276+
let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState))
277+
278+
// Define actions to test
279+
let actions: [(String, () throws -> Any)] = [
280+
("size", { try map.size }),
281+
("entries", { try map.entries }),
282+
("keys", { try map.keys }),
283+
("values", { try map.values }),
284+
]
285+
286+
// Test each property throws the expected error
287+
for (propertyName, action) in actions {
288+
#expect("\(propertyName) should throw") {
289+
_ = try action()
290+
} throws: { error in
291+
guard let errorInfo = error as? ARTErrorInfo else {
292+
return false
293+
}
294+
return errorInfo.code == 90001
295+
}
296+
}
297+
}
298+
299+
// MARK: - Tombstone Filtering Tests (RTLM10d, RTLM11d1, RTLM12b, RTLM13b)
300+
301+
// @specOneOf(1/2) RTLM10d - Tests the "non-tombstoned" part of spec point
302+
// @spec RTLM11d1
303+
// @specOneOf(1/2) RTLM12b - Tests the "non-tombstoned" part of RTLM10d
304+
// @specOneOf(1/2) RTLM13b - Tests the "non-tombstoned" part of RTLM10d
305+
// @spec RTLM14
306+
@Test
307+
func allPropertiesFilterOutTombstonedEntries() throws {
308+
let coreSDK = MockCoreSDK(channelState: .attaching)
309+
let map = DefaultLiveMap(
310+
testsOnly_data: [
311+
// tombstone is nil, so not considered tombstoned
312+
"active1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))),
313+
// tombstone is false, so not considered tombstoned[
314+
"active2": TestFactories.mapEntry(tombstone: false, data: ObjectData(string: .string("value2"))),
315+
"tombstoned": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned"))),
316+
"tombstoned2": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned2"))),
317+
],
318+
delegate: nil,
319+
coreSDK: coreSDK,
320+
)
321+
322+
// Test size - should only count non-tombstoned entries
323+
let size = try map.size
324+
#expect(size == 2)
325+
326+
// Test entries - should only return non-tombstoned entries
327+
let entries = try map.entries
328+
#expect(entries.count == 2)
329+
#expect(Set(entries.map(\.key)) == ["active1", "active2"])
330+
#expect(entries.first { $0.key == "active1" }?.value.stringValue == "value1")
331+
#expect(entries.first { $0.key == "active2" }?.value.stringValue == "value2")
332+
333+
// Test keys - should only return keys from non-tombstoned entries
334+
let keys = try map.keys
335+
#expect(keys.count == 2)
336+
#expect(Set(keys) == ["active1", "active2"])
337+
338+
// Test values - should only return values from non-tombstoned entries
339+
let values = try map.values
340+
#expect(values.count == 2)
341+
#expect(Set(values.compactMap(\.stringValue)) == Set(["value1", "value2"]))
342+
}
343+
344+
// MARK: - Consistency Tests
345+
346+
// @specOneOf(2/2) RTLM10d
347+
// @specOneOf(2/2) RTLM12b
348+
// @specOneOf(2/2) RTLM13b
349+
@Test
350+
func allAccessPropertiesReturnExpectedValuesAndAreConsistentWithEachOther() throws {
351+
let coreSDK = MockCoreSDK(channelState: .attaching)
352+
let map = DefaultLiveMap(
353+
testsOnly_data: [
354+
"key1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))),
355+
"key2": TestFactories.mapEntry(data: ObjectData(string: .string("value2"))),
356+
"key3": TestFactories.mapEntry(data: ObjectData(string: .string("value3"))),
357+
],
358+
delegate: nil,
359+
coreSDK: coreSDK,
360+
)
361+
362+
let size = try map.size
363+
let entries = try map.entries
364+
let keys = try map.keys
365+
let values = try map.values
366+
367+
// All properties should return the same count
368+
#expect(size == 3)
369+
#expect(entries.count == 3)
370+
#expect(keys.count == 3)
371+
#expect(values.count == 3)
372+
373+
// Keys should match the keys from entries
374+
#expect(Set(keys) == Set(entries.map(\.key)))
375+
376+
// Values should match the values from entries
377+
#expect(Set(values.compactMap(\.stringValue)) == Set(entries.compactMap(\.value.stringValue)))
378+
}
379+
380+
// MARK: - `entries` handling of different value types, per RTLM5d2
381+
382+
// @spec RTLM11d
383+
@Test
384+
func entriesHandlesAllValueTypes() throws {
385+
let delegate = MockLiveMapObjectPoolDelegate()
386+
let coreSDK = MockCoreSDK(channelState: .attaching)
387+
388+
// Create referenced objects for testing
389+
let referencedMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK)
390+
let referencedCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK)
391+
delegate.objects["map:ref@123"] = .map(referencedMap)
392+
delegate.objects["counter:ref@456"] = .counter(referencedCounter)
393+
394+
let map = DefaultLiveMap(
395+
testsOnly_data: [
396+
"boolean": TestFactories.mapEntry(data: ObjectData(boolean: true)), // RTLM5d2b
397+
"bytes": TestFactories.mapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c
398+
"number": TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 42))), // RTLM5d2d
399+
"string": TestFactories.mapEntry(data: ObjectData(string: .string("hello"))), // RTLM5d2e
400+
"mapRef": TestFactories.mapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2
401+
"counterRef": TestFactories.mapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2
402+
],
403+
delegate: delegate,
404+
coreSDK: coreSDK,
405+
)
406+
407+
let size = try map.size
408+
let entries = try map.entries
409+
let keys = try map.keys
410+
let values = try map.values
411+
412+
#expect(size == 6)
413+
#expect(entries.count == 6)
414+
#expect(keys.count == 6)
415+
#expect(values.count == 6)
416+
417+
// Verify the correct values are returned by `entries`
418+
let booleanEntry = entries.first { $0.key == "boolean" } // RTLM5d2b
419+
let bytesEntry = entries.first { $0.key == "bytes" } // RTLM5d2c
420+
let numberEntry = entries.first { $0.key == "number" } // RTLM5d2d
421+
let stringEntry = entries.first { $0.key == "string" } // RTLM5d2e
422+
let mapRefEntry = entries.first { $0.key == "mapRef" } // RTLM5d2f2
423+
let counterRefEntry = entries.first { $0.key == "counterRef" } // RTLM5d2f2
424+
425+
#expect(booleanEntry?.value.boolValue == true) // RTLM5d2b
426+
#expect(bytesEntry?.value.dataValue == Data([0x01, 0x02, 0x03])) // RTLM5d2c
427+
#expect(numberEntry?.value.numberValue == 42) // RTLM5d2d
428+
#expect(stringEntry?.value.stringValue == "hello") // RTLM5d2e
429+
#expect(mapRefEntry?.value.liveMapValue as AnyObject === referencedMap as AnyObject) // RTLM5d2f2
430+
#expect(counterRefEntry?.value.liveCounterValue as AnyObject === referencedCounter as AnyObject) // RTLM5d2f2
431+
}
432+
}
433+
266434
/// Tests for `MAP_SET` operations, covering RTLM7 specification points
267435
struct MapSetOperationTests {
268436
// MARK: - RTLM7a Tests (Existing Entry)

0 commit comments

Comments
 (0)