diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/GeneratedCode.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/GeneratedCode.swift new file mode 100644 index 0000000..15f4c53 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/GeneratedCode.swift @@ -0,0 +1,87 @@ +import Foundation +import Ably + +// MARK: - Code that would be generated (for now we're just writing it out) + +// These would come from some sort of macro like @LiveMapShape applied to MyChannelObject + +extension MyChannelObject: LiveMapShape { + enum LiveMapKeys { + private struct Key: LiveMapKey { + typealias Shape = MyChannelObject + + /// The underlying key to use for fetching this key from a map's entries + var rawKey: String + } + + static let topLevelCounter: some LiveMapKey = Key(rawKey: "topLevelCounter") + static let topLevelMap: some LiveMapKey> = Key(rawKey: "topLevelCounter") + } + + enum InitialEntry: LiveMapInitialEntry { + case topLevelCounter(LiveCounter) + case topLevelMap(ShapedLiveMap) + + // TODO: this might be a bit tricky for codegen as-is, because ideally we wouldn't have to understand the meaning of the shape's properties; we just want to copy and paste their types. Might be better to have an init(containerCreationValue:) on Value, overloaded for all of the supported types. Although according to ChatGPT you can perform full type resolution inside a macro expansion now: https://chatgpt.com/c/693c6ec0-32d0-8333-8776-1145397c263f + + var toKeyValuePair: (String, Value) { + switch self { + case .topLevelCounter(let liveCounter): + ("topLevelCounter", .liveCounter(liveCounter)) + case .topLevelMap(let shapedLiveMap): + ("topLevelMap", .liveMap(shapedLiveMap.toLiveMap)) + } + } + } + + enum PathObjectKnownEntry: LiveMapPathObjectKnownEntry { + case topLevelCounter(LiveCounterPathObject) + case topLevelMap(any ShapedLiveMapPathObject) + + // TODO: I think that this is going to be another one that's tricky for codegen, again might require us to actually interpret the type because we need to turn a ShapedLiveMap property into a ShapedLiveMapPathObject. Perhaps what we actually want to do here is to let the caller be in charge of creating the object itself, i.e. return some sort of enum result from here instead, but I'm still not sure that fully helps us. + // (note that the `get` variants don't have to handle this problem because they perform the conversion via the compiler picking the correct overload; maybe we need to see what we can do along those lines, maybe we can lean on the Key type more again) + init?(key: String, pathObject: any PathObject) { + fatalError("TODO: Not implemented") + } + } +} + +extension MyChannelObject.TopLevelMap: LiveMapShape { + enum LiveMapKeys { + private struct Key: LiveMapKey { + typealias Shape = MyChannelObject.TopLevelMap + + /// The underlying key to use for fetching this key from a map's entries + var rawKey: String + } + + static let nestedEntry: some LiveMapKey = Key(rawKey: "nestedEntry") + } + + enum InitialEntry: LiveMapInitialEntry { + case nestedEntry(String) + + var toKeyValuePair: (String, Value) { + switch self { + case .nestedEntry(let string): + ("nestedEntry", .primitive(.string(string))) + } + } + } + + enum PathObjectKnownEntry: LiveMapPathObjectKnownEntry { + case nestedEntry(any TypedPrimitivePathObject) + + init?(key: String, pathObject: any PathObject) { + fatalError("TODO: Not implemented") + } + } +} + +// Note that each `LiveMapKeys` declares their own `Key` type — this is so that we don't have to pollute the library's public types with something that's only used for generated code; i.e. else we'd have to have something like the following: + +/* +struct DefaultLiveMapKey: LiveMapKey { + var rawKey: String +} +*/ diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/Shape.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/Shape.swift new file mode 100644 index 0000000..8a94d6c --- /dev/null +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/Example/Shape.swift @@ -0,0 +1,77 @@ +import Foundation +import Ably + +// MARK: - Example + +struct MyChannelObject { + var topLevelCounter: LiveCounter + var topLevelMap: ShapedLiveMap + + struct TopLevelMap { + var nestedEntry: String + } +} + +func exampleWithChannel(_ channel: ARTRealtimeChannel) async throws { + // Note that we can't say `.get()` like in TypeScript; gives us "Cannot explicitly specialize instance method 'get()'" + let myChannelPathObject = try await channel.object.get(withShape: MyChannelObject.self) + + // Note that fetching the keys is verbose; see the next example with key paths + let topLevelCounter = myChannelPathObject.get(key: MyChannelObject.LiveMapKeys.topLevelCounter) + let topLevelMap = myChannelPathObject.get(key: MyChannelObject.LiveMapKeys.topLevelMap) + + let nestedEntry = topLevelMap.get(key: MyChannelObject.TopLevelMap.LiveMapKeys.nestedEntry) +} + +// Example that uses the key paths convenience methods for get(), set(), remove() +func keyPathsExampleWithChannel(_ channel: ARTRealtimeChannel) async throws { + let myChannelPathObject = try await channel.object.get(withShape: MyChannelObject.self) + + let topLevelCounter = myChannelPathObject.get(keyAt: \.topLevelCounter) + let topLevelMap = myChannelPathObject.get(keyAt: \.topLevelMap) + + let nestedEntry = topLevelMap.get(keyAt: \.nestedEntry) + + try await topLevelMap.set(keyAt: \.nestedEntry, value: "Hello") + try await topLevelMap.remove(keyAt: \.nestedEntry) + + try await myChannelPathObject.set(keyAt: \.topLevelCounter, value: LiveCounter.create(initialCount: 3)) + try await topLevelCounter.increment(amount: 4) + + try await myChannelPathObject.set( + keyAt: \.topLevelMap, + value: .create( + // TODO not decided if this is the API I want yet (that is, `Entry` being an enum); see the other places where I need entries and figure it out + initialEntries: [ + .nestedEntry("Goodbye") + ] + ) + ) + + for entry in myChannelPathObject.entries { + switch entry { + case .known(let known): + switch known { + case .topLevelCounter(let liveCounterPathObject): + break + case .topLevelMap(let shapedLiveMapPathObject): + break + } + case .unknown(let key, let value): + break + } + } + + for entry in topLevelMap.entries { + switch entry { + case .known(let known): + switch known { + case .nestedEntry(let typedPrimitivePathObject): + break + } + case .unknown(let key, let value): + break + } + + } +} diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/KeyPathConvenience.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/KeyPathConvenience.swift new file mode 100644 index 0000000..eeb58f7 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/KeyPathConvenience.swift @@ -0,0 +1,84 @@ +import Foundation +import Ably + +// Convenience extensions for specifying a key by using a key path into a static member of Shape.LiveMapKeys. TODO improve naming: it's a bit confusing because it's a key path _into a set of keys_ (i.e. not into the shape itself). The reason we use key paths instead of implicit member access is because it doesn't require that the "member" actually have that type +extension ShapedLiveMapPathObject { + // `set()` + + func set(keyAt keyPath: KeyPath, value: String) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == String { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: Double) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Double { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: Bool) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Bool { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: Data) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Data { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: [JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == JSONValue { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: [String: JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == [String: JSONValue] { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: LiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveMap { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: ShapedLiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == ShapedLiveMap { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + func set(keyAt keyPath: KeyPath, value: LiveCounter) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveCounter { + try await set(key: Shape.LiveMapKeys.self[keyPath: keyPath], value: value) + } + + // `remove()` + + func remove(keyAt keyPath: KeyPath) async throws(ARTErrorInfo) where Key.Shape == Shape { + try await remove(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + // `get()` + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == String { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Double { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Bool { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Data { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> any TypedPrimitivePathObject<[JSONValue]> where Key.Shape == Shape, Key.Value == [JSONValue] { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> LiveMapPathObject where Key.Shape == Shape, Key.Value == LiveMap { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + + } + + func get(keyAt keyPath: KeyPath) -> any ShapedLiveMapPathObject where Key.Shape == Shape, Key.Value == ShapedLiveMap { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } + + func get(keyAt keyPath: KeyPath) -> LiveCounterPathObject where Key.Shape == Shape, Key.Value == LiveCounter { + get(key: Shape.LiveMapKeys.self[keyPath: keyPath]) + } +} diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/PublicShapedTypes.swift b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/PublicShapedTypes.swift new file mode 100644 index 0000000..15f74b6 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/PublicShapedTypes.swift @@ -0,0 +1,137 @@ +import Foundation +import Ably + +// MARK: - Public-facing types for shaped LiveMaps + +// TODO assess how much LiveMapShape needs to be able to do, and if it's just a convenience, then remove some constraints + +// TODO not sure this actually needs to be a protocol +protocol LiveMapShape { + // I'm unsure about this but I think that we want something like it so that we can do implicit member access: `.get(key: .topLevelCounter)`. but again it's not clear what this would inherit from. Also we might need this in order to see whether a key is a known key or not. But we may have to have one of these per Value type? e.g. LiveMapStringKey, LiveMapLiveCounterKey etc (no, that falls apart when you start having parameterisable types e.g. nested maps) — Hmm. I think that `entries` might just not be possible because there's no obvious type to define. In that case we _would_ have to do codegen and list all of the possible types. we can still have a LiveMapEntry type here I guess + + // TODO: currently this is _only_ used for the convenience extension that allows key path lookups to make things neater + associatedtype LiveMapKeys + + /// An entry that can be passed to `ShapedLiveMap.create()`. + associatedtype InitialEntry: LiveMapInitialEntry + + /// An entry that can be returned from `ShapedLiveMapPathObject.entries()`. + associatedtype PathObjectKnownEntry: LiveMapPathObjectKnownEntry +} + +// TODO this name isn't great, it's not really a key, it's a key description (but I guess a KeyPath is not just a "key path") +protocol LiveMapKey: Sendable { + associatedtype Shape: LiveMapShape + associatedtype Value +} + +protocol LiveMapInitialEntry { + /// A key-value pair to use when creating the LiveMap. + var toKeyValuePair: (String, Value) { get } +} + +protocol LiveMapPathObjectKnownEntry { + /// Should return `nil` if the key does not correspond to a known entry. + init?(key: String, pathObject: PathObject) +} + +struct ShapedLiveMap: Sendable { + private let liveMap: LiveMap + + public static func create(initialEntries: [Shape.InitialEntry] = []) -> Self { + // TODO: There's a mismatch here between this using an array and LiveMap using a dictionary + let liveMap = LiveMap.create(initialEntries: .init(uniqueKeysWithValues: initialEntries.map(\.toKeyValuePair))) + return .init(liveMap: liveMap) + } + + // TODO: we don't _really_ want this to have to be public + + /// A type-erased representation of this ShapedLiveMap. + public var toLiveMap: LiveMap { + return liveMap + } +} + +// TODO: naming TBD +// TODO: we don't have any constraints on Value which makes things trickier +// TODO: I didn't actually do PrimitivePathObject in the non-typed API; we should have that +protocol TypedPrimitivePathObject { + associatedtype Value + + var value: Value? { get } +} + +// TODO: How is Instance going to work? is it actually going to check types? if so will it do it all the way down through nested maps etc? + +/// An element of `ShapedLiveMapPathObject.entries`. +enum ShapedLiveMapPathObjectEntry { + /// A known key-value pair. + case known(Known) + + /// An unknown key-value pair; the best we can do is return a String key and an untyped PathObject. + case unknown(key: String, value: PathObject) +} + +protocol ShapedLiveMapPathObject { + associatedtype Shape: LiveMapShape + + // This is my proposal for `entries`; I think its return value should be consistent with `keys` and `values`; that is, it should be able to represent things that were found at runtime even when they aren't in the known set of keys. + var entries: [ShapedLiveMapPathObjectEntry] { get } + + // I think that we'll just keep `keys` and `values` as String and PathObject (same as LiveMapPathObject), given that shapes only matter when considering the relationship between a key and a value + var keys: [String] { get } + var values: [PathObject] { get } + + // TODO: you should still be able to interact with this without shape too — I think the best thing would be to make _this_ type only work with Key but have a way to turn it into a normal LiveMapPathObject + + // Variants of `set()` + + // All the set() operations that this needs to be able to support. (I don't think we can do better than this because this type isn't expected to be able to handle arbitrary values, even if a user can form a Key that has one; that is, we can't just have a single one that takes Key.Value); unless we end up being able to impose constraints on Key.Value somehow but I don't really want to start adding extensions to String etc + + // For entries of each of the primitive types + func set(key: Key, value: String) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == String + func set(key: Key, value: Double) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Double + func set(key: Key, value: Bool) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Bool + func set(key: Key, value: Data) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == Data + func set(key: Key, value: [JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == JSONValue + func set(key: Key, value: [String: JSONValue]) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == [String: JSONValue] + + // For LiveMap entries + func set(key: Key, value: LiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveMap + func set(key: Key, value: ShapedLiveMap) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == ShapedLiveMap + + // For LiveCounter entries + func set(key: Key, value: LiveCounter) async throws(ARTErrorInfo) where Key.Shape == Shape, Key.Value == LiveCounter + + // `remove()` + + func remove(key: Key) async throws(ARTErrorInfo) + + // Variants of `get()` + + // I don't _think_ there is a less verbose way of figuring out the shape of the PathObject + + // For entries of each of the primitive types + func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == String + func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Double + func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Bool + func get(key: Key) -> any TypedPrimitivePathObject where Key.Shape == Shape, Key.Value == Data + func get(key: Key) -> any TypedPrimitivePathObject<[JSONValue]> where Key.Shape == Shape, Key.Value == [JSONValue] + func get(key: Key) -> any TypedPrimitivePathObject<[String: JSONValue]> where Key.Shape == Shape, Key.Value == [String: JSONValue] + + // For LiveMap entries + func get(key: Key) -> LiveMapPathObject where Key.Shape == Shape, Key.Value == LiveMap + func get(key: Key) -> any ShapedLiveMapPathObject where Key.Shape == Shape, Key.Value == ShapedLiveMap + + // For LiveCounter entries + func get(key: Key) -> LiveCounterPathObject where Key.Shape == Shape, Key.Value == LiveCounter +} + +// MARK: - RealtimeObject `get` implementation for shaped LiveMaps + +extension RealtimeObject { + func get(withShape shape: Shape.Type = Shape.self) async throws(ARTErrorInfo) -> any ShapedLiveMapPathObject { + // TODO + fatalError("Not implemented") + } +} diff --git a/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/README.md b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/README.md new file mode 100644 index 0000000..2710594 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/UserDefinedTypeSupport/README.md @@ -0,0 +1,60 @@ +# UserDefinedTypeSupport + +An experiment to convince ourselves that, if we later wanted to, we could +layer support for user-specified object shapes (the Swift analogue of +ably-js's `channel.object.get()`) on top of the unshaped +path-based API, without breaking it. + +If you only want to read one file, read +[`Example/Shape.swift`](./Example/Shape.swift) — it shows what the API would +ideally look like to a user. Everything else here is the supporting +machinery that makes those call sites compile. + +Nothing here is intended to ship as part of the initial GA — the plan is to +ship the unshaped API first. This directory only exists to prove the +additive-layering claim. See the discussion in +[`PATH-BASED-API.md`](../../../../PATH-BASED-API.md) for the design notes +that informed it. + +It's also not yet a complete proof. Specifically: + +- The hand-written stand-in in `Example/GeneratedCode.swift` still has + unfilled pieces — the `PathObjectKnownEntry.init?(key:pathObject:)` + implementations are `fatalError("TODO")`, and there are flagged TODOs in + the surrounding code about how the conversions would actually need to + work. +- No real codegen has been demonstrated. There is no `@LiveMapShape` macro + yet; the "generated" code is written by hand to prove the call-site + shape compiles. The hardest parts of the codegen story (interpreting the + user's property types so that, say, a `ShapedLiveMap` + property turns into a `case` carrying `any + ShapedLiveMapPathObject`) are noted in TODOs but not + actually exercised. + +So we haven't yet convinced ourselves that the additive-layering claim +holds. This directory shows that the *shape* of the public API is +expressible in Swift and that example call sites compile, but the +codegen-and-runtime-glue layer underneath remains unproven. + +## Files + +- **[`PublicShapedTypes.swift`](./PublicShapedTypes.swift)** — the public + protocol family and types: `LiveMapShape`, `LiveMapKey`, + `LiveMapInitialEntry`, `LiveMapPathObjectKnownEntry`, `ShapedLiveMap`, + `TypedPrimitivePathObject`, `ShapedLiveMapPathObjectEntry`, + `ShapedLiveMapPathObject`, plus the `RealtimeObject.get(withShape:)` + entry point. +- **[`KeyPathConvenience.swift`](./KeyPathConvenience.swift)** — the + `extension ShapedLiveMapPathObject` adding `keyAt:` variants of `get`, + `set` and `remove`, so call sites can write `\.topLevelCounter` instead of + `MyChannelObject.LiveMapKeys.topLevelCounter`. +- **[`Example/Shape.swift`](./Example/Shape.swift)** — the user-written side + of the example: a `MyChannelObject` struct that would be annotated with a + hypothetical `@LiveMapShape` macro, plus two example functions + demonstrating use of the API (one using explicit `LiveMapKeys` + references, the other using the key-path convenience). +- **[`Example/GeneratedCode.swift`](./Example/GeneratedCode.swift)** — the + hand-written stand-in for what a `@LiveMapShape` macro would generate from + `MyChannelObject`: the `LiveMapShape` conformance and the three + associated-type enums (`LiveMapKeys`, `InitialEntry`, + `PathObjectKnownEntry`).