Skip to content

Commit 8d881e2

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 cb427d8. 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 d82d90d commit 8d881e2

File tree

2 files changed

+279
-56
lines changed

2 files changed

+279
-56
lines changed

Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift

Lines changed: 111 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -124,78 +124,74 @@ internal final class DefaultLiveMap: LiveMap {
124124
return nil
125125
}
126126

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

146-
// RTLM5d2d: If ObjectsMapEntry.data.number exists, return it
147-
if let number = entry.data.number {
148-
return .primitive(.number(number.doubleValue))
149-
}
131+
internal var size: Int {
132+
get throws(ARTErrorInfo) {
133+
// RTLM10c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001
134+
let currentChannelState = coreSDK.channelState
135+
if currentChannelState == .detached || currentChannelState == .failed {
136+
throw LiveObjectsError.objectsOperationFailedInvalidChannelState(
137+
operationDescription: "LiveMap.size",
138+
channelState: currentChannelState,
139+
)
140+
.toARTErrorInfo()
141+
}
150142

151-
// RTLM5d2e: If ObjectsMapEntry.data.string exists, return it
152-
if let string = entry.data.string {
153-
switch string {
154-
case let .string(string):
155-
return .primitive(.string(string))
156-
case .json:
157-
// TODO: Understand how to handle JSON values (https://github.com/ably/specification/pull/333/files#r2164561055)
158-
notYetImplemented()
143+
return mutex.withLock {
144+
// RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map
145+
mutableState.data.values.count { entry in
146+
// RTLM14a: The method returns true if ObjectsMapEntry.tombstone is true
147+
// RTLM14b: Otherwise, it returns false
148+
entry.tombstone != true
149+
}
159150
}
160151
}
152+
}
161153

162-
// RTLM5d2f: If ObjectsMapEntry.data.objectId exists, get the object stored at that objectId from the internal ObjectsPool
163-
if let objectId = entry.data.objectId {
164-
// RTLM5d2f1: If an object with id objectId does not exist, return undefined/null
165-
guard let poolEntry = delegate.referenced?.getObjectFromPool(id: objectId) else {
166-
return nil
154+
internal var entries: [(key: String, value: LiveMapValue)] {
155+
get throws(ARTErrorInfo) {
156+
// RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001
157+
let currentChannelState = coreSDK.channelState
158+
if currentChannelState == .detached || currentChannelState == .failed {
159+
throw LiveObjectsError.objectsOperationFailedInvalidChannelState(
160+
operationDescription: "LiveMap.entries",
161+
channelState: currentChannelState,
162+
)
163+
.toARTErrorInfo()
167164
}
168165

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

178-
// RTLM5d2g: Otherwise, return undefined/null
179-
return nil
180-
}
171+
for (key, entry) in mutableState.data {
172+
// Convert entry to LiveMapValue using the same logic as get(key:)
173+
if let value = convertEntryToLiveMapValue(entry) {
174+
result.append((key: key, value: value))
175+
}
176+
}
181177

182-
internal var size: Int {
183-
mutex.withLock {
184-
// 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
185-
mutableState.data.count
178+
return result
179+
}
186180
}
187181
}
188182

189-
internal var entries: [(key: String, value: LiveMapValue)] {
190-
notYetImplemented()
191-
}
192-
193183
internal var keys: [String] {
194-
notYetImplemented()
184+
get throws(ARTErrorInfo) {
185+
// RTLM12b: Identical to LiveMap#entries, except that it returns only the keys from the internal data map
186+
try entries.map(\.key)
187+
}
195188
}
196189

197190
internal var values: [LiveMapValue] {
198-
notYetImplemented()
191+
get throws(ARTErrorInfo) {
192+
// RTLM13b: Identical to LiveMap#entries, except that it returns only the values from the internal data map
193+
try entries.map(\.value)
194+
}
199195
}
200196

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

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 && errorInfo.statusCode == 400
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)