Skip to content

Commit c48f166

Browse files
authored
[Runtime] Support recursive types (#58)
[Runtime] Support recursive types ### Motivation Runtime changes for apple/swift-openapi-generator#70. ### Modifications Introduce a new wrapper type `CopyOnWriteBox` as a reference type with copy-on-write semantics, to be used by the generated code to hide a ref type inside a struct that needs boxing. (Details on the logic will be in the generator PR description.) ### Result A new type available for the generated code to use for breaking reference cycles. ### Test Plan Added unit tests for the type. Reviewed by: dnadoba Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - Build finished. ✔︎ pull request validation (api breakage) - Build finished. ✔︎ pull request validation (docc test) - Build finished. ✔︎ pull request validation (integration test) - Build finished. ✔︎ pull request validation (nightly) - Build finished. ✔︎ pull request validation (soundness) - Build finished. #58
1 parent b931341 commit c48f166

File tree

3 files changed

+310
-2
lines changed

3 files changed

+310
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
/// A type that wraps a value and enforces copy-on-write semantics.
16+
///
17+
/// It also enables recursive types by introducing a "box" into the cycle, which
18+
/// allows the owning type to have a finite size.
19+
@_spi(Generated)
20+
public struct CopyOnWriteBox<Wrapped> {
21+
22+
/// The reference type storage for the box.
23+
@usableFromInline
24+
internal final class Storage {
25+
26+
/// The stored value.
27+
@usableFromInline
28+
var value: Wrapped
29+
30+
/// Creates a new storage with the provided initial value.
31+
/// - Parameter value: The initial value to store in the box.
32+
@inlinable
33+
init(value: Wrapped) {
34+
self.value = value
35+
}
36+
}
37+
38+
/// The internal storage of the box.
39+
@usableFromInline
40+
internal var storage: Storage
41+
42+
/// Creates a new box.
43+
/// - Parameter value: The value to store in the box.
44+
@inlinable
45+
public init(value: Wrapped) {
46+
self.storage = .init(value: value)
47+
}
48+
49+
/// The stored value whose accessors enforce copy-on-write semantics.
50+
@inlinable
51+
public var value: Wrapped {
52+
get {
53+
storage.value
54+
}
55+
_modify {
56+
if !isKnownUniquelyReferenced(&storage) {
57+
storage = Storage(value: storage.value)
58+
}
59+
yield &storage.value
60+
}
61+
}
62+
}
63+
64+
extension CopyOnWriteBox: Encodable where Wrapped: Encodable {
65+
66+
/// Encodes this value into the given encoder.
67+
///
68+
/// If the value fails to encode anything, `encoder` will encode an empty
69+
/// keyed container in its place.
70+
///
71+
/// This function throws an error if any values are invalid for the given
72+
/// encoder's format.
73+
///
74+
/// - Parameter encoder: The encoder to write data to.
75+
/// - Throws: On an encoding error.
76+
@inlinable
77+
public func encode(to encoder: any Encoder) throws {
78+
try value.encode(to: encoder)
79+
}
80+
}
81+
82+
extension CopyOnWriteBox: Decodable where Wrapped: Decodable {
83+
84+
/// Creates a new instance by decoding from the given decoder.
85+
///
86+
/// This initializer throws an error if reading from the decoder fails, or
87+
/// if the data read is corrupted or otherwise invalid.
88+
///
89+
/// - Parameter decoder: The decoder to read data from.
90+
/// - Throws: On a decoding error.
91+
@inlinable
92+
public init(from decoder: any Decoder) throws {
93+
let value = try Wrapped(from: decoder)
94+
self.init(value: value)
95+
}
96+
}
97+
98+
extension CopyOnWriteBox: Equatable where Wrapped: Equatable {
99+
100+
/// Returns a Boolean value indicating whether two values are equal.
101+
///
102+
/// Equality is the inverse of inequality. For any values `a` and `b`,
103+
/// `a == b` implies that `a != b` is `false`.
104+
///
105+
/// - Parameters:
106+
/// - lhs: A value to compare.
107+
/// - rhs: Another value to compare.
108+
/// - Returns: A Boolean value indicating whether the values are equal.
109+
@inlinable
110+
public static func == (
111+
lhs: CopyOnWriteBox<Wrapped>,
112+
rhs: CopyOnWriteBox<Wrapped>
113+
) -> Bool {
114+
lhs.value == rhs.value
115+
}
116+
}
117+
118+
extension CopyOnWriteBox: Hashable where Wrapped: Hashable {
119+
120+
/// Hashes the essential components of this value by feeding them into the
121+
/// given hasher.
122+
///
123+
/// Implement this method to conform to the `Hashable` protocol. The
124+
/// components used for hashing must be the same as the components compared
125+
/// in your type's `==` operator implementation. Call `hasher.combine(_:)`
126+
/// with each of these components.
127+
///
128+
/// - Important: In your implementation of `hash(into:)`,
129+
/// don't call `finalize()` on the `hasher` instance provided,
130+
/// or replace it with a different instance.
131+
/// Doing so may become a compile-time error in the future.
132+
///
133+
/// - Parameter hasher: The hasher to use when combining the components
134+
/// of this instance.
135+
@inlinable
136+
public func hash(into hasher: inout Hasher) {
137+
hasher.combine(value)
138+
}
139+
}
140+
141+
extension CopyOnWriteBox: CustomStringConvertible where Wrapped: CustomStringConvertible {
142+
143+
/// A textual representation of this instance.
144+
///
145+
/// Calling this property directly is discouraged. Instead, convert an
146+
/// instance of any type to a string by using the `String(describing:)`
147+
/// initializer. This initializer works with any type, and uses the custom
148+
/// `description` property for types that conform to
149+
/// `CustomStringConvertible`:
150+
///
151+
/// struct Point: CustomStringConvertible {
152+
/// let x: Int, y: Int
153+
///
154+
/// var description: String {
155+
/// return "(\(x), \(y))"
156+
/// }
157+
/// }
158+
///
159+
/// let p = Point(x: 21, y: 30)
160+
/// let s = String(describing: p)
161+
/// print(s)
162+
/// // Prints "(21, 30)"
163+
///
164+
/// The conversion of `p` to a string in the assignment to `s` uses the
165+
/// `Point` type's `description` property.
166+
@inlinable
167+
public var description: String {
168+
value.description
169+
}
170+
}
171+
172+
extension CopyOnWriteBox: CustomDebugStringConvertible where Wrapped: CustomDebugStringConvertible {
173+
174+
/// A textual representation of this instance, suitable for debugging.
175+
///
176+
/// Calling this property directly is discouraged. Instead, convert an
177+
/// instance of any type to a string by using the `String(reflecting:)`
178+
/// initializer. This initializer works with any type, and uses the custom
179+
/// `debugDescription` property for types that conform to
180+
/// `CustomDebugStringConvertible`:
181+
///
182+
/// struct Point: CustomDebugStringConvertible {
183+
/// let x: Int, y: Int
184+
///
185+
/// var debugDescription: String {
186+
/// return "(\(x), \(y))"
187+
/// }
188+
/// }
189+
///
190+
/// let p = Point(x: 21, y: 30)
191+
/// let s = String(reflecting: p)
192+
/// print(s)
193+
/// // Prints "(21, 30)"
194+
///
195+
/// The conversion of `p` to a string in the assignment to `s` uses the
196+
/// `Point` type's `debugDescription` property.
197+
@inlinable
198+
public var debugDescription: String {
199+
value.debugDescription
200+
}
201+
}
202+
203+
extension CopyOnWriteBox: @unchecked Sendable where Wrapped: Sendable {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import XCTest
15+
@_spi(Generated) import OpenAPIRuntime
16+
17+
final class Test_CopyOnWriteBox: Test_Runtime {
18+
19+
struct Node: Codable, Equatable {
20+
var id: Int
21+
var parent: CopyOnWriteBox<Node>?
22+
}
23+
24+
func testModification() throws {
25+
var value = Node(
26+
id: 3,
27+
parent: .init(
28+
value: .init(
29+
id: 2
30+
)
31+
)
32+
)
33+
XCTAssertEqual(
34+
value,
35+
Node(
36+
id: 3,
37+
parent: .init(
38+
value: .init(
39+
id: 2
40+
)
41+
)
42+
)
43+
)
44+
value.parent!.value.parent = .init(value: .init(id: 1))
45+
XCTAssertEqual(
46+
value,
47+
Node(
48+
id: 3,
49+
parent: .init(
50+
value: .init(
51+
id: 2,
52+
parent: .init(
53+
value: .init(id: 1)
54+
)
55+
)
56+
)
57+
)
58+
)
59+
}
60+
61+
func testSerialization() throws {
62+
let value = CopyOnWriteBox(value: "Hello")
63+
try testRoundtrip(
64+
value,
65+
expectedJSON: #""Hello""#
66+
)
67+
}
68+
69+
func testIntegration() throws {
70+
let value = Node(
71+
id: 3,
72+
parent: .init(
73+
value: .init(
74+
id: 2,
75+
parent: .init(
76+
value: .init(id: 1)
77+
)
78+
)
79+
)
80+
)
81+
try testRoundtrip(
82+
value,
83+
expectedJSON: #"""
84+
{
85+
"id" : 3,
86+
"parent" : {
87+
"id" : 2,
88+
"parent" : {
89+
"id" : 1
90+
}
91+
}
92+
}
93+
"""#
94+
)
95+
}
96+
}

Tests/OpenAPIRuntimeTests/Test_Runtime.swift

+11-2
Original file line numberDiff line numberDiff line change
@@ -131,18 +131,27 @@ class Test_Runtime: XCTestCase {
131131
Data(testStructURLFormString.utf8)
132132
}
133133

134-
func _testPrettyEncoded<Value: Encodable>(_ value: Value, expectedJSON: String) throws {
134+
@discardableResult
135+
func _testPrettyEncoded<Value: Encodable>(_ value: Value, expectedJSON: String) throws -> String {
135136
let encoder = JSONEncoder()
136137
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
137138
let data = try encoder.encode(value)
138-
XCTAssertEqual(String(data: data, encoding: .utf8)!, expectedJSON)
139+
let encodedString = String(decoding: data, as: UTF8.self)
140+
XCTAssertEqual(encodedString, expectedJSON)
141+
return encodedString
139142
}
140143

141144
func _getDecoded<Value: Decodable>(json: String) throws -> Value {
142145
let inputData = json.data(using: .utf8)!
143146
let decoder = JSONDecoder()
144147
return try decoder.decode(Value.self, from: inputData)
145148
}
149+
150+
func testRoundtrip<Value: Codable & Equatable>(_ value: Value, expectedJSON: String) throws {
151+
let encodedString = try _testPrettyEncoded(value, expectedJSON: expectedJSON)
152+
let decoded: Value = try _getDecoded(json: encodedString)
153+
XCTAssertEqual(decoded, value)
154+
}
146155
}
147156

148157
/// Asserts that a given URL's absolute string representation is equal to an expected string.

0 commit comments

Comments
 (0)