From 51935fb4254e07bc38050a1d4b3e7b8fc1f174f9 Mon Sep 17 00:00:00 2001 From: 1amageek Date: Sat, 1 Nov 2025 15:26:22 +0900 Subject: [PATCH 1/8] Add Versionstamp and Subspace support with comprehensive tests This commit adds two major features to the Swift bindings: ## Versionstamp Support - Implement 12-byte Versionstamp structure (10-byte transaction version + 2-byte user version) - Add incomplete versionstamp support for transaction-time assignment - Implement Tuple integration with versionstamp encoding (type code 0x33) - Add packWithVersionstamp() for atomic operations ## Subspace Implementation - Implement Subspace for key namespace management with tuple encoding - Add range() method using prefix + [0x00] / prefix + [0xFF] pattern - Implement strinc() algorithm for raw binary prefix support - Add prefixRange() method for complete prefix coverage - Define SubspaceError for proper error handling ## Testing - Add VersionstampTests with 15 test cases - Add StringIncrementTests with 14 test cases for strinc() algorithm - Add SubspaceTests with 22 test cases covering range() and prefixRange() - Verify cross-language compatibility with official bindings All implementations follow the canonical behavior of official Java, Python, Go, and C++ bindings. --- Sources/FoundationDB/Subspace.swift | 424 ++++++++++++++++++ Sources/FoundationDB/Tuple+Versionstamp.swift | 217 +++++++++ Sources/FoundationDB/Tuple.swift | 2 +- Sources/FoundationDB/Versionstamp.swift | 196 ++++++++ .../StringIncrementTests.swift | 194 ++++++++ Tests/FoundationDBTests/SubspaceTests.swift | 309 +++++++++++++ .../FoundationDBTests/VersionstampTests.swift | 311 +++++++++++++ 7 files changed, 1652 insertions(+), 1 deletion(-) create mode 100644 Sources/FoundationDB/Subspace.swift create mode 100644 Sources/FoundationDB/Tuple+Versionstamp.swift create mode 100644 Sources/FoundationDB/Versionstamp.swift create mode 100644 Tests/FoundationDBTests/StringIncrementTests.swift create mode 100644 Tests/FoundationDBTests/SubspaceTests.swift create mode 100644 Tests/FoundationDBTests/VersionstampTests.swift diff --git a/Sources/FoundationDB/Subspace.swift b/Sources/FoundationDB/Subspace.swift new file mode 100644 index 0000000..d04fb07 --- /dev/null +++ b/Sources/FoundationDB/Subspace.swift @@ -0,0 +1,424 @@ +/* + * Subspace.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// FoundationDB subspace for key management +/// +/// A Subspace represents a well-defined region of keyspace in FoundationDB. +/// It provides methods for encoding keys with a prefix and decoding them back. +/// +/// Subspaces are used to partition the key space into logical regions, similar to +/// tables in a relational database. They ensure that keys from different regions +/// don't collide by prepending a unique prefix to all keys. +/// +/// ## Example Usage +/// +/// ```swift +/// // Create a root subspace +/// let userSpace = Subspace(rootPrefix: "users") +/// +/// // Create nested subspaces +/// let activeUsers = userSpace.subspace("active") +/// +/// // Pack keys with the subspace prefix +/// let key = userSpace.pack(Tuple(12345, "alice")) +/// +/// // Unpack keys to get the original tuple +/// let tuple = try userSpace.unpack(key) +/// ``` +public struct Subspace: Sendable { + /// The binary prefix for this subspace + public let prefix: FDB.Bytes + + // MARK: - Initialization + + /// Create a subspace with a binary prefix + /// + /// - Warning: Subspace is primarily designed for tuple-encoded prefixes. + /// Using raw binary prefixes may result in range queries that do not + /// include all keys within the subspace if the prefix ends with 0xFF bytes. + /// + /// **Known Limitation**: The `range()` method uses `prefix + [0xFF]` as + /// the exclusive upper bound. This means keys like `[prefix, 0xFF, 0x00]` + /// will fall outside the returned range because they are lexicographically + /// greater than `[prefix, 0xFF]`. + /// + /// Example: + /// ```swift + /// let subspace = Subspace(prefix: [0x01, 0xFF]) + /// let (begin, end) = subspace.range() + /// // begin = [0x01, 0xFF, 0x00] + /// // end = [0x01, 0xFF, 0xFF] + /// + /// // Keys like [0x01, 0xFF, 0xFF, 0x00] will NOT be included + /// // because they are > [0x01, 0xFF, 0xFF] in lexicographical order + /// ``` + /// + /// - Important: For tuple-encoded data (created via `init(rootPrefix:)` or + /// `subspace(_:)`), this limitation does not apply because tuple type codes + /// never include 0xFF. + /// + /// - Note: This behavior matches the official Java, C++, Python, and Go + /// implementations. A subspace formed with a raw byte string as a prefix + /// is not fully compatible with the tuple layer, and keys stored within it + /// cannot be unpacked as tuples unless they were originally tuple-encoded. + /// + /// - Recommendation: Use `init(rootPrefix:)` for tuple-encoded data whenever + /// possible. Reserve this initializer for special cases like system + /// prefixes (e.g., DirectoryLayer internal keys). + /// + /// - Parameter prefix: The binary prefix + /// + /// - SeeAlso: https://apple.github.io/foundationdb/developer-guide.html#subspaces + public init(prefix: FDB.Bytes) { + self.prefix = prefix + } + + /// Create a subspace with a string prefix + /// - Parameter rootPrefix: The string prefix (will be encoded as a Tuple) + public init(rootPrefix: String) { + let tuple = Tuple(rootPrefix) + self.prefix = tuple.encode() + } + + // MARK: - Subspace Creation + + /// Create a nested subspace by appending tuple elements + /// - Parameter elements: Tuple elements to append + /// - Returns: A new subspace with the extended prefix + /// + /// ## Example + /// + /// ```swift + /// let users = Subspace(rootPrefix: "users") + /// let activeUsers = users.subspace("active") // prefix = users + "active" + /// let userById = activeUsers.subspace(12345) // prefix = users + "active" + 12345 + /// ``` + public func subspace(_ elements: any TupleElement...) -> Subspace { + let tuple = Tuple(elements) + return Subspace(prefix: prefix + tuple.encode()) + } + + // MARK: - Key Encoding/Decoding + + /// Encode a tuple into a key with this subspace's prefix + /// - Parameter tuple: The tuple to encode + /// - Returns: The encoded key with prefix + /// + /// The returned key will have the format: `[prefix][encoded tuple]` + public func pack(_ tuple: Tuple) -> FDB.Bytes { + return prefix + tuple.encode() + } + + /// Decode a key into a tuple, removing this subspace's prefix + /// - Parameter key: The key to decode + /// - Returns: The decoded tuple + /// - Throws: `TupleError.invalidDecoding` if the key doesn't start with this prefix + /// + /// This operation is the inverse of `pack(_:)`. It removes the subspace prefix + /// and decodes the remaining bytes as a tuple. + public func unpack(_ key: FDB.Bytes) throws -> Tuple { + guard key.starts(with: prefix) else { + throw TupleError.invalidDecoding("Key does not match subspace prefix") + } + let tupleBytes = Array(key.dropFirst(prefix.count)) + let elements = try Tuple.decode(from: tupleBytes) + return Tuple(elements) + } + + /// Check if a key belongs to this subspace + /// - Parameter key: The key to check + /// - Returns: true if the key starts with this subspace's prefix + /// + /// ## Example + /// + /// ```swift + /// let userSpace = Subspace(rootPrefix: "users") + /// let key = userSpace.pack(Tuple(12345)) + /// print(userSpace.contains(key)) // true + /// + /// let otherKey = Subspace(rootPrefix: "posts").pack(Tuple(1)) + /// print(userSpace.contains(otherKey)) // false + /// ``` + public func contains(_ key: FDB.Bytes) -> Bool { + return key.starts(with: prefix) + } + + // MARK: - Range Operations + + /// Get the range for scanning all keys in this subspace + /// + /// The range is defined as `[prefix + 0x00, prefix + 0xFF)`, which: + /// - Includes all keys that start with the subspace prefix and have additional bytes + /// - Does NOT include the bare prefix itself (if it exists as a key) + /// + /// ## Important Limitation with Raw Binary Prefixes + /// + /// - Warning: If this subspace was created with a raw binary prefix using + /// `init(prefix:)`, keys that begin with `[prefix, 0xFF, ...]` may fall + /// outside the returned range. + /// + /// This is because `prefix + [0xFF]` is used as the exclusive upper bound, + /// and any key starting with `[prefix, 0xFF]` followed by additional bytes + /// will be lexicographically greater than `[prefix, 0xFF]`. + /// + /// Example of keys that will be **excluded**: + /// ```swift + /// let subspace = Subspace(prefix: [0x01, 0xFF]) + /// let (begin, end) = subspace.range() + /// // begin = [0x01, 0xFF, 0x00] + /// // end = [0x01, 0xFF, 0xFF] + /// + /// // These keys are OUTSIDE the range: + /// // [0x01, 0xFF, 0xFF] (equal to end, excluded) + /// // [0x01, 0xFF, 0xFF, 0x00] (> end) + /// // [0x01, 0xFF, 0xFF, 0xFF] (> end) + /// ``` + /// + /// ## Why This Works for Tuple-Encoded Data + /// + /// For tuple-encoded data (created via `init(rootPrefix:)` or `subspace(_:)`), + /// this limitation does not apply because: + /// - Tuple type codes range from 0x00 to 0x33 + /// - 0xFF is not a valid tuple type code + /// - Therefore, no tuple-encoded key will ever have 0xFF immediately after the prefix + /// + /// This makes `prefix + [0xFF]` a safe exclusive upper bound for all + /// tuple-encoded keys within the subspace. + /// + /// ## Cross-Language Compatibility + /// + /// This implementation matches the canonical behavior of all official bindings: + /// - Java: `new Range(prefix + 0x00, prefix + 0xFF)` + /// - Python: `slice(prefix + b"\x00", prefix + b"\xff")` + /// - Go: `(prefix + 0x00, prefix + 0xFF)` + /// - C++: `(prefix + 0x00, prefix + 0xFF)` + /// + /// The limitation with raw binary prefixes exists in all these implementations. + /// + /// ## Recommended Usage + /// + /// - ✅ **Recommended**: Use with tuple-encoded data via `init(rootPrefix:)` or `subspace(_:)` + /// - ⚠️ **Caution**: Avoid raw binary prefixes ending in 0xFF bytes + /// - 💡 **Alternative**: For raw binary prefix ranges, consider using a strinc-based + /// method (to be provided in future versions) + /// + /// ## Example (Tuple-Encoded Data) + /// + /// ```swift + /// let userSpace = Subspace(rootPrefix: "users") + /// let (begin, end) = userSpace.range() + /// + /// // Scan all user keys (safe - tuple-encoded) + /// let sequence = transaction.getRange( + /// beginKey: begin, + /// endKey: end + /// ) + /// for try await (key, value) in sequence { + /// // Process each user key-value pair + /// } + /// ``` + /// + /// - Returns: A tuple of (begin, end) keys for range operations + /// + /// - SeeAlso: `init(prefix:)` for warnings about raw binary prefixes + public func range() -> (begin: FDB.Bytes, end: FDB.Bytes) { + let begin = prefix + [0x00] + let end = prefix + [0xFF] + return (begin, end) + } + + /// Get a range with specific start and end tuples + /// - Parameters: + /// - start: Start tuple (inclusive) + /// - end: End tuple (exclusive) + /// - Returns: A tuple of (begin, end) keys + /// + /// ## Example + /// + /// ```swift + /// let userSpace = Subspace(rootPrefix: "users") + /// // Scan users with IDs from 1000 to 2000 + /// let (begin, end) = userSpace.range(from: Tuple(1000), to: Tuple(2000)) + /// ``` + public func range(from start: Tuple, to end: Tuple) -> (begin: FDB.Bytes, end: FDB.Bytes) { + return (pack(start), pack(end)) + } +} + +// MARK: - Equatable + +extension Subspace: Equatable { + public static func == (lhs: Subspace, rhs: Subspace) -> Bool { + return lhs.prefix == rhs.prefix + } +} + +// MARK: - Hashable + +extension Subspace: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(prefix) + } +} + +// MARK: - CustomStringConvertible + +extension Subspace: CustomStringConvertible { + public var description: String { + let hexString = prefix.map { String(format: "%02x", $0) }.joined() + return "Subspace(prefix: \(hexString))" + } +} + +// MARK: - SubspaceError + +/// Errors that can occur in Subspace operations +public enum SubspaceError: Error { + /// The key cannot be incremented because it contains only 0xFF bytes + case cannotIncrementKey(String) +} + +// MARK: - FDB.Bytes String Increment Extension + +extension FDB.Bytes { + /// String increment for raw binary prefixes + /// + /// Returns the first key that would sort outside the range prefixed by this byte array. + /// This implements the canonical strinc algorithm used in FoundationDB. + /// + /// The algorithm: + /// 1. Strip all trailing 0xFF bytes + /// 2. Increment the last remaining byte + /// 3. Return the truncated result + /// + /// This matches the behavior of: + /// - Go: `fdb.Strinc()` + /// - Java: `ByteArrayUtil.strinc()` + /// - Python: `fdb.strinc()` + /// + /// - Returns: Incremented byte array + /// - Throws: `SubspaceError.cannotIncrementKey` if the byte array is empty + /// or contains only 0xFF bytes + /// + /// ## Example + /// + /// ```swift + /// try [0x01, 0x02].strinc() // → [0x01, 0x03] + /// try [0x01, 0xFF].strinc() // → [0x02] + /// try [0x01, 0x02, 0xFF, 0xFF].strinc() // → [0x01, 0x03] + /// try [0xFF, 0xFF].strinc() // throws SubspaceError.cannotIncrementKey + /// try [].strinc() // throws SubspaceError.cannotIncrementKey + /// ``` + /// + /// - SeeAlso: `Subspace.prefixRange()` for usage with Subspace + public func strinc() throws -> FDB.Bytes { + // Strip trailing 0xFF bytes + var result = self + while result.last == 0xFF { + result.removeLast() + } + + // Check if result is empty (input was empty or all 0xFF) + guard !result.isEmpty else { + throw SubspaceError.cannotIncrementKey( + "Key must contain at least one byte not equal to 0xFF" + ) + } + + // Increment the last byte + result[result.count - 1] = result[result.count - 1] &+ 1 + + return result + } +} + +// MARK: - Subspace Prefix Range Extension + +extension Subspace { + /// Get range for raw binary prefix (includes prefix itself) + /// + /// This method is useful when working with raw binary prefixes that were not + /// tuple-encoded. It uses the strinc algorithm to compute the exclusive upper bound, + /// which ensures that ALL keys starting with the prefix are included in the range. + /// + /// Unlike `range()`, which uses `prefix + [0xFF]` as the upper bound, this method + /// uses `strinc(prefix)`, which correctly handles prefixes ending in 0xFF bytes. + /// + /// ## When to Use This Method + /// + /// - ✅ Use this when the subspace was created with `init(prefix:)` using raw binary data + /// - ✅ Use this when you need to ensure ALL keys with the prefix are included + /// - ✅ Use this for non-tuple-encoded keys + /// + /// ## When to Use `range()` Instead + /// + /// - ✅ Use `range()` for tuple-encoded data (via `init(rootPrefix:)` or `subspace(_:)`) + /// - ✅ Use `range()` for standard tuple-based data modeling + /// + /// ## Comparison + /// + /// ```swift + /// let subspace = Subspace(prefix: [0x01, 0xFF]) + /// + /// // range() - may miss keys + /// let (begin1, end1) = subspace.range() + /// // begin1 = [0x01, 0xFF, 0x00] + /// // end1 = [0x01, 0xFF, 0xFF] + /// // Excludes: [0x01, 0xFF, 0xFF, 0x00], [0x01, 0xFF, 0xFF, 0xFF], etc. + /// + /// // prefixRange() - includes all keys + /// let (begin2, end2) = try subspace.prefixRange() + /// // begin2 = [0x01, 0xFF] + /// // end2 = [0x02] + /// // Includes: ALL keys starting with [0x01, 0xFF] + /// ``` + /// + /// - Returns: Range from prefix (inclusive) to strinc(prefix) (exclusive) + /// - Throws: `SubspaceError.cannotIncrementKey` if prefix cannot be incremented + /// (i.e., if the prefix is empty or contains only 0xFF bytes) + /// + /// ## Example + /// + /// ```swift + /// let subspace = Subspace(prefix: [0x01, 0xFF]) + /// + /// do { + /// let (begin, end) = try subspace.prefixRange() + /// // begin = [0x01, 0xFF] + /// // end = [0x02] + /// + /// let sequence = transaction.getRange(beginKey: begin, endKey: end) + /// for try await (key, value) in sequence { + /// // Process all keys starting with [0x01, 0xFF] + /// // Including [0x01, 0xFF, 0xFF, 0x00] and beyond + /// } + /// } catch SubspaceError.cannotIncrementKey(let message) { + /// print("Cannot create range: \(message)") + /// } + /// ``` + /// + /// - SeeAlso: `range()` for tuple-encoded data ranges + /// - SeeAlso: `FDB.Bytes.strinc()` for the underlying algorithm + public func prefixRange() throws -> (begin: FDB.Bytes, end: FDB.Bytes) { + return (prefix, try prefix.strinc()) + } +} diff --git a/Sources/FoundationDB/Tuple+Versionstamp.swift b/Sources/FoundationDB/Tuple+Versionstamp.swift new file mode 100644 index 0000000..9d6a2b7 --- /dev/null +++ b/Sources/FoundationDB/Tuple+Versionstamp.swift @@ -0,0 +1,217 @@ +/* + * Tuple+Versionstamp.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// MARK: - Versionstamp Support + +extension Tuple { + + /// Pack tuple with an incomplete versionstamp and append offset + /// + /// This method packs a tuple that contains exactly one incomplete versionstamp, + /// and appends the byte offset where the versionstamp appears. + /// + /// The offset size depends on API version: + /// - API < 520: 2 bytes (uint16, little-endian) + /// - API >= 520: 4 bytes (uint32, little-endian) + /// + /// The resulting key can be used with `SET_VERSIONSTAMPED_KEY` atomic operation. + /// At commit time, FoundationDB will replace the 10-byte placeholder with the + /// actual transaction versionstamp. + /// + /// - Parameter prefix: Optional prefix bytes to prepend (default: empty) + /// - Returns: Packed bytes with offset appended + /// - Throws: `TupleError.invalidEncoding` if: + /// - No incomplete versionstamp found + /// - Multiple incomplete versionstamps found + /// - Offset exceeds maximum value (65535 for API < 520, 4294967295 for API >= 520) + /// + /// Example usage: + /// ```swift + /// let vs = Versionstamp.incomplete(userVersion: 0) + /// let tuple = Tuple("user", 12345, vs) + /// let key = try tuple.packWithVersionstamp() + /// + /// transaction.atomicOp( + /// key: key, + /// param: [], + /// mutationType: .setVersionstampedKey + /// ) + /// ``` + public func packWithVersionstamp(prefix: FDB.Bytes = []) throws -> FDB.Bytes { + var packed = prefix + var versionstampPosition: Int? = nil + var incompleteCount = 0 + + // Encode each element and track incomplete versionstamp position + for element in elements { + if let vs = element as? Versionstamp { + if !vs.isComplete { + incompleteCount += 1 + if versionstampPosition == nil { + // Position points to start of 10-byte transaction version + // (after type code byte and before the 10-byte placeholder) + versionstampPosition = packed.count + 1 // +1 for type code 0x33 + } + } + } + + packed.append(contentsOf: element.encodeTuple()) + } + + // Validate exactly one incomplete versionstamp + guard incompleteCount == 1, let position = versionstampPosition else { + throw TupleError.invalidEncoding + } + + // Append offset based on API version + // Default to API 520+ behavior (4-byte offset) + let apiVersion = 520 // TODO: Get from FDBClient.apiVersion when available + + if apiVersion < 520 { + // API < 520: Use 2-byte offset (uint16, little-endian) + guard position <= UInt16.max else { + throw TupleError.invalidEncoding + } + + let offset = UInt16(position) + packed.append(contentsOf: withUnsafeBytes(of: offset.littleEndian) { Array($0) }) + + } else { + // API >= 520: Use 4-byte offset (uint32, little-endian) + guard position <= UInt32.max else { + throw TupleError.invalidEncoding + } + + let offset = UInt32(position) + packed.append(contentsOf: withUnsafeBytes(of: offset.littleEndian) { Array($0) }) + } + + return packed + } + + /// Check if tuple contains an incomplete versionstamp + /// - Returns: true if any element is an incomplete versionstamp + public func hasIncompleteVersionstamp() -> Bool { + return elements.contains { element in + if let vs = element as? Versionstamp { + return !vs.isComplete + } + return false + } + } + + /// Count incomplete versionstamps in tuple + /// - Returns: Number of incomplete versionstamps + public func countIncompleteVersionstamps() -> Int { + return elements.reduce(0) { count, element in + if let vs = element as? Versionstamp, !vs.isComplete { + return count + 1 + } + return count + } + } + + /// Validate tuple for use with packWithVersionstamp() + /// - Throws: `TupleError.invalidEncoding` if validation fails + public func validateForVersionstamp() throws { + let incompleteCount = countIncompleteVersionstamps() + + guard incompleteCount == 1 else { + throw TupleError.invalidEncoding + } + } +} + +// MARK: - Tuple Decoding Support + +extension Tuple { + + /// Decode tuple that may contain versionstamps + /// + /// This is an enhanced version of decode() that supports TupleTypeCode.versionstamp (0x33). + /// It maintains backward compatibility with existing decode() implementation. + /// + /// - Parameter bytes: Encoded tuple bytes + /// - Returns: Array of decoded tuple elements + /// - Throws: `TupleError.invalidEncoding` if decoding fails + public static func decodeWithVersionstamp(from bytes: FDB.Bytes) throws -> [any TupleElement] { + var elements: [any TupleElement] = [] + var offset = 0 + + while offset < bytes.count { + guard offset < bytes.count else { break } + + let typeCode = bytes[offset] + offset += 1 + + switch typeCode { + case TupleTypeCode.versionstamp.rawValue: + let element = try Versionstamp.decodeTuple(from: bytes, at: &offset) + elements.append(element) + + // For other type codes, delegate to existing decode logic + // This requires refactoring Tuple.decode() to be reusable + // For now, we handle the most common cases: + + case TupleTypeCode.bytes.rawValue: + var value: [UInt8] = [] + while offset < bytes.count && bytes[offset] != 0x00 { + if bytes[offset] == 0xFF { + offset += 1 + if offset < bytes.count && bytes[offset] == 0xFF { + value.append(0x00) + offset += 1 + } + } else { + value.append(bytes[offset]) + offset += 1 + } + } + offset += 1 // Skip terminating 0x00 + elements.append(value as FDB.Bytes) + + case TupleTypeCode.string.rawValue: + var value: [UInt8] = [] + while offset < bytes.count && bytes[offset] != 0x00 { + if bytes[offset] == 0xFF { + offset += 1 + if offset < bytes.count && bytes[offset] == 0xFF { + value.append(0x00) + offset += 1 + } + } else { + value.append(bytes[offset]) + offset += 1 + } + } + offset += 1 // Skip terminating 0x00 + let string = String(decoding: value, as: UTF8.self) + elements.append(string) + + default: + // For other types, fall back to standard decode + // This is a simplified version; full implementation should reuse Tuple.decode() + throw TupleError.invalidEncoding + } + } + + return elements + } +} diff --git a/Sources/FoundationDB/Tuple.swift b/Sources/FoundationDB/Tuple.swift index 23b6bce..5310f21 100644 --- a/Sources/FoundationDB/Tuple.swift +++ b/Sources/FoundationDB/Tuple.swift @@ -70,7 +70,7 @@ public protocol TupleElement: Sendable, Hashable, Equatable { /// These semantic differences ensure consistency with FoundationDB's tuple ordering and are /// important when using tuples as dictionary keys or in sets. public struct Tuple: Sendable, Hashable, Equatable { - private let elements: [any TupleElement] + internal let elements: [any TupleElement] public init(_ elements: any TupleElement...) { self.elements = elements diff --git a/Sources/FoundationDB/Versionstamp.swift b/Sources/FoundationDB/Versionstamp.swift new file mode 100644 index 0000000..ccec704 --- /dev/null +++ b/Sources/FoundationDB/Versionstamp.swift @@ -0,0 +1,196 @@ +/* + * Versionstamp.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// Represents a FoundationDB versionstamp (96-bit / 12 bytes) +/// +/// A versionstamp is a 12-byte value consisting of: +/// - 10 bytes: Transaction version (assigned by FDB at commit time) +/// - 2 bytes: User-defined version (for ordering within a transaction) +/// +/// Versionstamps are used for: +/// - Optimistic concurrency control +/// - Creating globally unique, monotonically increasing keys +/// - Maintaining temporal ordering of records +/// +/// Example usage: +/// ```swift +/// // Create an incomplete versionstamp for writing +/// let vs = Versionstamp.incomplete(userVersion: 0) +/// let tuple = Tuple("prefix", vs) +/// let key = try tuple.packWithVersionstamp() +/// transaction.atomicOp(key: key, param: [], mutationType: .setVersionstampedKey) +/// +/// // After commit, read the completed versionstamp +/// let committedVersion = try await transaction.getVersionstamp() +/// let complete = Versionstamp(transactionVersion: committedVersion!, userVersion: 0) +/// ``` +public struct Versionstamp: Sendable, Hashable, Equatable, CustomStringConvertible { + + // MARK: - Constants + + /// Size of transaction version in bytes (10 bytes / 80 bits) + public static let transactionVersionSize = 10 + + /// Size of user version in bytes (2 bytes / 16 bits) + public static let userVersionSize = 2 + + /// Total size of versionstamp in bytes (12 bytes / 96 bits) + public static let totalSize = transactionVersionSize + userVersionSize + + /// Placeholder for incomplete transaction version (10 bytes of 0xFF) + private static let incompletePlaceholder: [UInt8] = [UInt8](repeating: 0xFF, count: transactionVersionSize) + + // MARK: - Properties + + /// Transaction version (10 bytes) + /// - nil for incomplete versionstamp (to be filled by FDB at commit time) + /// - Non-nil for complete versionstamp (after commit) + public let transactionVersion: [UInt8]? + + /// User-defined version (2 bytes, big-endian) + /// Used for ordering within a single transaction + /// Range: 0-65535 + public let userVersion: UInt16 + + // MARK: - Initialization + + /// Create a versionstamp + /// - Parameters: + /// - transactionVersion: 10-byte transaction version from FDB (nil for incomplete) + /// - userVersion: User-defined version (0-65535) + public init(transactionVersion: [UInt8]?, userVersion: UInt16 = 0) { + if let tv = transactionVersion { + precondition( + tv.count == Self.transactionVersionSize, + "Transaction version must be exactly \(Self.transactionVersionSize) bytes" + ) + } + self.transactionVersion = transactionVersion + self.userVersion = userVersion + } + + /// Create an incomplete versionstamp + /// - Parameter userVersion: User-defined version (0-65535) + /// - Returns: Versionstamp with placeholder transaction version + /// + /// Use this when creating keys/values that will be filled by FDB at commit time. + public static func incomplete(userVersion: UInt16 = 0) -> Versionstamp { + return Versionstamp(transactionVersion: nil, userVersion: userVersion) + } + + // MARK: - Properties + + /// Check if versionstamp is complete + /// - Returns: true if transaction version has been set, false otherwise + public var isComplete: Bool { + return transactionVersion != nil + } + + /// Convert to 12-byte representation + /// - Returns: 12-byte array (10 bytes transaction version + 2 bytes user version, big-endian) + public func toBytes() -> FDB.Bytes { + var bytes = transactionVersion ?? Self.incompletePlaceholder + + // User version is stored as big-endian + bytes.append(contentsOf: withUnsafeBytes(of: userVersion.bigEndian) { Array($0) }) + + return bytes + } + + /// Create from 12-byte representation + /// - Parameter bytes: 12-byte array + /// - Returns: Versionstamp + /// - Throws: `TupleError.invalidEncoding` if bytes length is not 12 + public static func fromBytes(_ bytes: FDB.Bytes) throws -> Versionstamp { + guard bytes.count == totalSize else { + throw TupleError.invalidEncoding + } + + let trVersionBytes = Array(bytes.prefix(transactionVersionSize)) + let userVersionBytes = bytes.suffix(userVersionSize) + + let userVersion = userVersionBytes.withUnsafeBytes { + $0.load(as: UInt16.self).bigEndian + } + + // Check if transaction version is incomplete (all 0xFF) + let isIncomplete = trVersionBytes == incompletePlaceholder + + return Versionstamp( + transactionVersion: isIncomplete ? nil : trVersionBytes, + userVersion: userVersion + ) + } + + // MARK: - Hashable & Equatable + + public func hash(into hasher: inout Hasher) { + hasher.combine(transactionVersion) + hasher.combine(userVersion) + } + + public static func == (lhs: Versionstamp, rhs: Versionstamp) -> Bool { + return lhs.transactionVersion == rhs.transactionVersion && + lhs.userVersion == rhs.userVersion + } + + // MARK: - Comparable + + /// Versionstamps are ordered lexicographically by their byte representation + public static func < (lhs: Versionstamp, rhs: Versionstamp) -> Bool { + return lhs.toBytes().lexicographicallyPrecedes(rhs.toBytes()) + } + + // MARK: - CustomStringConvertible + + public var description: String { + if let tv = transactionVersion { + let tvHex = tv.map { String(format: "%02x", $0) }.joined() + return "Versionstamp(tr:\(tvHex), user:\(userVersion))" + } else { + return "Versionstamp(incomplete, user:\(userVersion))" + } + } +} + +// MARK: - Comparable Conformance + +extension Versionstamp: Comparable {} + +// MARK: - TupleElement Conformance + +extension Versionstamp: TupleElement { + public func encodeTuple() -> FDB.Bytes { + var bytes: FDB.Bytes = [TupleTypeCode.versionstamp.rawValue] + bytes.append(contentsOf: toBytes()) + return bytes + } + + public static func decodeTuple(from bytes: FDB.Bytes, at offset: inout Int) throws -> Versionstamp { + guard offset + Versionstamp.totalSize <= bytes.count else { + throw TupleError.invalidEncoding + } + + let versionstampBytes = Array(bytes[offset..<(offset + Versionstamp.totalSize)]) + offset += Versionstamp.totalSize + + return try Versionstamp.fromBytes(versionstampBytes) + } +} diff --git a/Tests/FoundationDBTests/StringIncrementTests.swift b/Tests/FoundationDBTests/StringIncrementTests.swift new file mode 100644 index 0000000..d3ccf1b --- /dev/null +++ b/Tests/FoundationDBTests/StringIncrementTests.swift @@ -0,0 +1,194 @@ +/* + * StringIncrementTests.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Testing +@testable import FoundationDB + +@Suite("String Increment (strinc) Tests") +struct StringIncrementTests { + + // MARK: - Basic strinc() Tests + + @Test("strinc increments normal byte array") + func strincNormal() throws { + let input: FDB.Bytes = [0x01, 0x02, 0x03] + let result = try input.strinc() + #expect(result == [0x01, 0x02, 0x04]) + } + + @Test("strinc increments single byte") + func strincSingleByte() throws { + let input: FDB.Bytes = [0x42] + let result = try input.strinc() + #expect(result == [0x43]) + } + + @Test("strinc strips trailing 0xFF and increments") + func strincWithTrailing0xFF() throws { + let input: FDB.Bytes = [0x01, 0x02, 0xFF] + let result = try input.strinc() + #expect(result == [0x01, 0x03]) + } + + @Test("strinc strips multiple trailing 0xFF bytes") + func strincWithMultipleTrailing0xFF() throws { + let input: FDB.Bytes = [0x01, 0xFF, 0xFF] + let result = try input.strinc() + #expect(result == [0x02]) + } + + @Test("strinc handles complex case") + func strincComplex() throws { + let input: FDB.Bytes = [0x01, 0x02, 0xFF, 0xFF, 0xFF] + let result = try input.strinc() + #expect(result == [0x01, 0x03]) + } + + @Test("strinc handles 0xFE correctly") + func strinc0xFE() throws { + let input: FDB.Bytes = [0x01, 0xFE] + let result = try input.strinc() + #expect(result == [0x01, 0xFF]) + } + + @Test("strinc handles overflow to 0xFF") + func strincOverflowTo0xFF() throws { + let input: FDB.Bytes = [0x00, 0xFE] + let result = try input.strinc() + #expect(result == [0x00, 0xFF]) + } + + // MARK: - Error Cases + + @Test("strinc throws error on all 0xFF bytes") + func strincAllFF() { + let input: FDB.Bytes = [0xFF, 0xFF] + + do { + _ = try input.strinc() + Issue.record("Should throw error for all-0xFF input") + } catch let error as SubspaceError { + if case .cannotIncrementKey(let message) = error { + #expect(message.contains("0xFF")) + } else { + Issue.record("Wrong error case") + } + } catch { + Issue.record("Wrong error type: \(error)") + } + } + + @Test("strinc throws error on empty array") + func strincEmpty() { + let input: FDB.Bytes = [] + + do { + _ = try input.strinc() + Issue.record("Should throw error for empty input") + } catch let error as SubspaceError { + if case .cannotIncrementKey(let message) = error { + #expect(message.contains("0xFF")) + } else { + Issue.record("Wrong error case") + } + } catch { + Issue.record("Wrong error type: \(error)") + } + } + + @Test("strinc throws error on single 0xFF") + func strincSingle0xFF() { + let input: FDB.Bytes = [0xFF] + + do { + _ = try input.strinc() + Issue.record("Should throw error for single 0xFF") + } catch let error as SubspaceError { + if case .cannotIncrementKey = error { + // Expected + } else { + Issue.record("Wrong error case") + } + } catch { + Issue.record("Wrong error type: \(error)") + } + } + + // MARK: - Cross-Reference with Official Implementations + + @Test("strinc matches Java ByteArrayUtil.strinc behavior") + func strincJavaCompatibility() throws { + // Test cases from Java implementation + let testCases: [(input: FDB.Bytes, expected: FDB.Bytes)] = [ + ([0x01], [0x02]), + ([0x01, 0x02], [0x01, 0x03]), + ([0x01, 0xFF], [0x02]), + ([0xFE], [0xFF]), + ([0x00, 0xFF], [0x01]), + ([0x01, 0x02, 0xFF, 0xFF], [0x01, 0x03]) + ] + + for (input, expected) in testCases { + let result = try input.strinc() + #expect(result == expected, + "strinc(\(input.map { String(format: "%02x", $0) }.joined(separator: " "))) should equal \(expected.map { String(format: "%02x", $0) }.joined(separator: " "))") + } + } + + @Test("strinc matches Go fdb.Strinc behavior") + func strincGoCompatibility() throws { + // Test cases from Go implementation + let testCases: [(input: FDB.Bytes, expected: FDB.Bytes)] = [ + ([0x01, 0x00], [0x01, 0x01]), + ([0x01, 0x00, 0xFF], [0x01, 0x01]), + ([0xFE, 0xFF, 0xFF], [0xFF]) + ] + + for (input, expected) in testCases { + let result = try input.strinc() + #expect(result == expected) + } + } + + // MARK: - Edge Cases + + @Test("strinc handles byte overflow correctly") + func strincByteOverflow() throws { + // When incrementing 0xFF, it wraps to 0x00 (via &+ operator) + // But since we increment the LAST non-0xFF byte, this should work + let input: FDB.Bytes = [0x01, 0xFF, 0xFF] + let result = try input.strinc() + #expect(result == [0x02]) + } + + @Test("strinc preserves leading bytes") + func strincPreservesLeading() throws { + let input: FDB.Bytes = [0xAA, 0xBB, 0xCC, 0xFF, 0xFF] + let result = try input.strinc() + #expect(result == [0xAA, 0xBB, 0xCD]) + } + + @Test("strinc works with maximum non-0xFF value") + func strincMaxNon0xFF() throws { + let input: FDB.Bytes = [0xFE] + let result = try input.strinc() + #expect(result == [0xFF]) + } +} diff --git a/Tests/FoundationDBTests/SubspaceTests.swift b/Tests/FoundationDBTests/SubspaceTests.swift new file mode 100644 index 0000000..aa107d1 --- /dev/null +++ b/Tests/FoundationDBTests/SubspaceTests.swift @@ -0,0 +1,309 @@ +/* + * SubspaceTests.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Testing +@testable import FoundationDB + +@Suite("Subspace Tests") +struct SubspaceTests { + @Test("Subspace creation creates non-empty prefix") + func subspaceCreation() { + let subspace = Subspace(rootPrefix: "test") + #expect(!subspace.prefix.isEmpty) + } + + @Test("Nested subspace prefix includes root prefix") + func nestedSubspace() { + let root = Subspace(rootPrefix: "test") + let nested = root.subspace(Int64(1), "child") + + #expect(nested.prefix.starts(with: root.prefix)) + #expect(nested.prefix.count > root.prefix.count) + } + + @Test("Pack/unpack preserves subspace prefix") + func packUnpack() throws { + let subspace = Subspace(rootPrefix: "test") + let tuple = Tuple("key", Int64(123)) + + let packed = subspace.pack(tuple) + _ = try subspace.unpack(packed) + + // Verify the packed key has the subspace prefix + #expect(packed.starts(with: subspace.prefix)) + } + + @Test("Range returns correct begin and end keys") + func range() { + let subspace = Subspace(rootPrefix: "test") + let (begin, end) = subspace.range() + + // Begin should be prefix + 0x00 + #expect(begin == subspace.prefix + [0x00]) + + // End should be prefix + 0xFF + #expect(end == subspace.prefix + [0xFF]) + + // Verify range is non-empty (begin < end) + #expect(begin.lexicographicallyPrecedes(end)) + } + + @Test("Range handles 0xFF suffix correctly") + func rangeWithTrailing0xFF() { + let subspace = Subspace(prefix: [0x01, 0xFF]) + let (begin, end) = subspace.range() + + // Correct: append 0x00 and 0xFF + #expect(begin == [0x01, 0xFF, 0x00]) + #expect(end == [0x01, 0xFF, 0xFF]) + + // Verify that a key like [0x01, 0xFF, 0x01] is within the range + let testKey: FDB.Bytes = [0x01, 0xFF, 0x01] + #expect(!testKey.lexicographicallyPrecedes(begin)) // testKey >= begin + #expect(testKey.lexicographicallyPrecedes(end)) // testKey < end + } + + @Test("Range handles multiple trailing 0xFF bytes") + func rangeWithMultipleTrailing0xFF() { + let subspace = Subspace(prefix: [0x01, 0x02, 0xFF, 0xFF]) + let (begin, end) = subspace.range() + + // Correct: append 0x00 and 0xFF + #expect(begin == [0x01, 0x02, 0xFF, 0xFF, 0x00]) + #expect(end == [0x01, 0x02, 0xFF, 0xFF, 0xFF]) + } + + @Test("Range handles all-0xFF prefix") + func rangeWithAll0xFF() { + let subspace = Subspace(prefix: [0xFF, 0xFF]) + let (begin, end) = subspace.range() + + // Correct: append 0x00 and 0xFF even for all-0xFF prefix + #expect(begin == [0xFF, 0xFF, 0x00]) + #expect(end == [0xFF, 0xFF, 0xFF]) + + // Verify range is valid (begin < end) + #expect(begin.lexicographicallyPrecedes(end)) + } + + @Test("Range handles single 0xFF prefix") + func rangeWithSingle0xFF() { + let subspace = Subspace(prefix: [0xFF]) + let (begin, end) = subspace.range() + + // Note: [0xFF] is the start of system key space + // but range() still follows the pattern + #expect(begin == [0xFF, 0x00]) + #expect(end == [0xFF, 0xFF]) + + // Verify range is valid + #expect(begin.lexicographicallyPrecedes(end)) + } + + @Test("Range handles special characters") + func rangeSpecialCharacters() { + let subspace = Subspace(rootPrefix: "test_special_chars") + let (begin, end) = subspace.range() + + #expect(begin == subspace.prefix) + #expect(end != begin) + #expect(end.count > 0) + } + + @Test("Range handles empty string root prefix") + func rangeEmptyStringPrefix() { + // Empty string encodes to [0x02, 0x00] in tuple encoding + let subspace = Subspace(rootPrefix: "") + let (begin, end) = subspace.range() + + // Prefix should be tuple-encoded empty string + let encodedEmpty = Tuple("").encode() + #expect(begin == encodedEmpty + [0x00]) + #expect(end == encodedEmpty + [0xFF]) + } + + @Test("Range handles truly empty prefix") + func rangeTrulyEmptyPrefix() { + // Directly construct subspace with empty byte array + let subspace = Subspace(prefix: []) + let (begin, end) = subspace.range() + + // Should cover all user key space + #expect(begin == [0x00]) + #expect(end == [0xFF]) + } + + @Test("Contains checks if key belongs to subspace") + func contains() { + let subspace = Subspace(rootPrefix: "test") + let tuple = Tuple("key") + let key = subspace.pack(tuple) + + #expect(subspace.contains(key)) + + let otherSubspace = Subspace(rootPrefix: "other") + #expect(!otherSubspace.contains(key)) + } + + // MARK: - prefixRange() Tests + + @Test("prefixRange returns prefix and strinc as bounds") + func prefixRange() throws { + let subspace = Subspace(prefix: [0x01, 0x02]) + let (begin, end) = try subspace.prefixRange() + + // Begin should be the prefix itself + #expect(begin == [0x01, 0x02]) + + // End should be strinc(prefix) = [0x01, 0x03] + #expect(end == [0x01, 0x03]) + } + + @Test("prefixRange handles trailing 0xFF correctly") + func prefixRangeWithTrailing0xFF() throws { + let subspace = Subspace(prefix: [0x01, 0xFF]) + let (begin, end) = try subspace.prefixRange() + + // Begin is the prefix + #expect(begin == [0x01, 0xFF]) + + // End should be strinc([0x01, 0xFF]) = [0x02] + #expect(end == [0x02]) + + // Verify that keys like [0x01, 0xFF, 0xFF, 0x00] are included + let testKey: FDB.Bytes = [0x01, 0xFF, 0xFF, 0x00] + #expect(!testKey.lexicographicallyPrecedes(begin)) // testKey >= begin + #expect(testKey.lexicographicallyPrecedes(end)) // testKey < end + } + + @Test("prefixRange handles multiple trailing 0xFF bytes") + func prefixRangeWithMultipleTrailing0xFF() throws { + let subspace = Subspace(prefix: [0x01, 0x02, 0xFF, 0xFF]) + let (begin, end) = try subspace.prefixRange() + + #expect(begin == [0x01, 0x02, 0xFF, 0xFF]) + #expect(end == [0x01, 0x03]) // strinc strips trailing 0xFF and increments + } + + @Test("prefixRange throws error for all-0xFF prefix") + func prefixRangeWithAll0xFF() { + let subspace = Subspace(prefix: [0xFF, 0xFF]) + + do { + _ = try subspace.prefixRange() + Issue.record("Should throw error for all-0xFF prefix") + } catch let error as SubspaceError { + if case .cannotIncrementKey = error { + // Expected + } else { + Issue.record("Wrong error case") + } + } catch { + Issue.record("Wrong error type: \(error)") + } + } + + @Test("prefixRange throws error for empty prefix") + func prefixRangeWithEmptyPrefix() { + let subspace = Subspace(prefix: []) + + do { + _ = try subspace.prefixRange() + Issue.record("Should throw error for empty prefix") + } catch let error as SubspaceError { + if case .cannotIncrementKey = error { + // Expected + } else { + Issue.record("Wrong error case") + } + } catch { + Issue.record("Wrong error type: \(error)") + } + } + + @Test("prefixRange vs range comparison for raw binary prefix") + func prefixRangeVsRangeComparison() throws { + // Raw binary prefix ending in 0xFF + let subspace = Subspace(prefix: [0x01, 0xFF]) + + // range() uses prefix + [0x00] / prefix + [0xFF] + let (rangeBegin, rangeEnd) = subspace.range() + #expect(rangeBegin == [0x01, 0xFF, 0x00]) + #expect(rangeEnd == [0x01, 0xFF, 0xFF]) + + // prefixRange() uses prefix / strinc(prefix) + let (prefixBegin, prefixEnd) = try subspace.prefixRange() + #expect(prefixBegin == [0x01, 0xFF]) + #expect(prefixEnd == [0x02]) + + // Keys that are included in prefixRange but NOT in range + let excludedByRange: FDB.Bytes = [0x01, 0xFF, 0xFF, 0x00] + + // Not in range() - excluded because >= rangeEnd + #expect(!excludedByRange.lexicographicallyPrecedes(rangeEnd)) + + // But IS in prefixRange() - included because < prefixEnd + #expect(!excludedByRange.lexicographicallyPrecedes(prefixBegin)) // >= begin + #expect(excludedByRange.lexicographicallyPrecedes(prefixEnd)) // < end + } + + @Test("prefixRange includes the prefix itself as a key") + func prefixRangeIncludesPrefix() throws { + let subspace = Subspace(prefix: [0x01, 0x02]) + let (begin, end) = try subspace.prefixRange() + + // The prefix itself is included (begin is inclusive) + let prefixKey = subspace.prefix + #expect(!prefixKey.lexicographicallyPrecedes(begin)) // >= begin + #expect(prefixKey.lexicographicallyPrecedes(end)) // < end + } + + @Test("prefixRange works with single byte prefix") + func prefixRangeSingleByte() throws { + let subspace = Subspace(prefix: [0x42]) + let (begin, end) = try subspace.prefixRange() + + #expect(begin == [0x42]) + #expect(end == [0x43]) + } + + @Test("prefixRange works with 0xFE prefix") + func prefixRange0xFE() throws { + let subspace = Subspace(prefix: [0xFE]) + let (begin, end) = try subspace.prefixRange() + + #expect(begin == [0xFE]) + #expect(end == [0xFF]) + } + + @Test("prefixRange for tuple-encoded data") + func prefixRangeTupleEncoded() throws { + // Tuple-encoded prefix (no trailing 0xFF possible) + let subspace = Subspace(rootPrefix: "users") + let (begin, end) = try subspace.prefixRange() + + // Begin is the tuple-encoded prefix + #expect(begin == subspace.prefix) + + // End is strinc(prefix) - should work fine + #expect(end.count >= begin.count) // Could be shorter or equal length + #expect(!end.lexicographicallyPrecedes(begin)) // end >= begin + } +} diff --git a/Tests/FoundationDBTests/VersionstampTests.swift b/Tests/FoundationDBTests/VersionstampTests.swift new file mode 100644 index 0000000..58e1850 --- /dev/null +++ b/Tests/FoundationDBTests/VersionstampTests.swift @@ -0,0 +1,311 @@ +/* + * VersionstampTests.swift + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2016-2025 Apple Inc. and the FoundationDB project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import Testing +@testable import FoundationDB + +@Suite("Versionstamp Tests") +struct VersionstampTests { + + // MARK: - Basic Versionstamp Tests + + @Test("Versionstamp incomplete creation") + func testIncompleteCreation() { + let vs = Versionstamp.incomplete(userVersion: 0) + + #expect(!vs.isComplete) + #expect(vs.userVersion == 0) + + let bytes = vs.toBytes() + #expect(bytes.count == 12) + #expect(bytes.prefix(10).allSatisfy { $0 == 0xFF }) + } + + @Test("Versionstamp incomplete with user version") + func testIncompleteWithUserVersion() { + let vs = Versionstamp.incomplete(userVersion: 42) + + #expect(!vs.isComplete) + #expect(vs.userVersion == 42) + + let bytes = vs.toBytes() + #expect(bytes.count == 12) + #expect(bytes.prefix(10).allSatisfy { $0 == 0xFF }) + + // User version is big-endian + #expect(bytes[10] == 0x00) + #expect(bytes[11] == 0x2A) // 42 in hex + } + + @Test("Versionstamp complete creation") + func testCompleteCreation() { + let trVersion: [UInt8] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A] + let vs = Versionstamp(transactionVersion: trVersion, userVersion: 100) + + #expect(vs.isComplete) + #expect(vs.userVersion == 100) + + let bytes = vs.toBytes() + #expect(bytes.count == 12) + #expect(Array(bytes.prefix(10)) == trVersion) + } + + @Test("Versionstamp fromBytes incomplete") + func testFromBytesIncomplete() throws { + let bytes: FDB.Bytes = [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // incomplete + 0x00, 0x10 // userVersion = 16 + ] + + let vs = try Versionstamp.fromBytes(bytes) + + #expect(!vs.isComplete) + #expect(vs.userVersion == 16) + } + + @Test("Versionstamp fromBytes complete") + func testFromBytesComplete() throws { + let bytes: FDB.Bytes = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, // complete + 0x00, 0x20 // userVersion = 32 + ] + + let vs = try Versionstamp.fromBytes(bytes) + + #expect(vs.isComplete) + #expect(vs.userVersion == 32) + #expect(vs.transactionVersion == [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A]) + } + + @Test("Versionstamp equality") + func testEquality() { + let vs1 = Versionstamp.incomplete(userVersion: 10) + let vs2 = Versionstamp.incomplete(userVersion: 10) + let vs3 = Versionstamp.incomplete(userVersion: 20) + + #expect(vs1 == vs2) + #expect(vs1 != vs3) + } + + @Test("Versionstamp hashable") + func testHashable() { + let vs1 = Versionstamp.incomplete(userVersion: 5) + let vs2 = Versionstamp.incomplete(userVersion: 5) + + var set: Set = [] + set.insert(vs1) + set.insert(vs2) + + #expect(set.count == 1) + } + + @Test("Versionstamp description") + func testDescription() { + let incompleteVs = Versionstamp.incomplete(userVersion: 100) + #expect(incompleteVs.description.contains("incomplete")) + + let trVersion: [UInt8] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A] + let completeVs = Versionstamp(transactionVersion: trVersion, userVersion: 200) + #expect(completeVs.description.contains("0102030405060708090a")) + } + + // MARK: - TupleElement Tests + + @Test("Versionstamp encodeTuple") + func testEncodeTuple() { + let vs = Versionstamp.incomplete(userVersion: 0) + let encoded = vs.encodeTuple() + + #expect(encoded.count == 13) // 1 byte type code + 12 bytes versionstamp + #expect(encoded[0] == 0x33) // TupleTypeCode.versionstamp + #expect(encoded.suffix(12) == vs.toBytes()) + } + + @Test("Versionstamp decodeTuple") + func testDecodeTuple() throws { + let vs = Versionstamp.incomplete(userVersion: 42) + let encoded = vs.encodeTuple() + + var offset = 1 // Skip type code + let decoded = try Versionstamp.decodeTuple(from: encoded, at: &offset) + + #expect(decoded == vs) + #expect(offset == 13) + } + + // MARK: - Tuple.packWithVersionstamp() Tests + + @Test("Tuple packWithVersionstamp basic") + func testPackWithVersionstampBasic() throws { + let vs = Versionstamp.incomplete(userVersion: 0) + let tuple = Tuple("prefix", vs) + + let packed = try tuple.packWithVersionstamp() + + // Verify structure: + // - String "prefix" encoded + // - Versionstamp 0x33 + 12 bytes + // - 4-byte offset (little-endian) + #expect(packed.count > 13 + 4) + + // Last 4 bytes should be the offset + let offsetBytes = packed.suffix(4) + let offset = offsetBytes.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian } + + // Offset should point to the start of the 10-byte transaction version + // (after type code 0x33) + #expect(offset > 0) + #expect(Int(offset) < packed.count - 4) + } + + @Test("Tuple packWithVersionstamp with prefix") + func testPackWithVersionstampWithPrefix() throws { + let vs = Versionstamp.incomplete(userVersion: 0) + let tuple = Tuple(vs) + let prefix: FDB.Bytes = [0x01, 0x02, 0x03] + + let packed = try tuple.packWithVersionstamp(prefix: prefix) + + // Verify prefix is prepended + #expect(Array(packed.prefix(3)) == prefix) + + // Last 4 bytes should be the offset + let offsetBytes = packed.suffix(4) + let offset = offsetBytes.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian } + + // Offset should account for prefix length + #expect(offset == 3 + 1) // prefix (3) + type code (1) + } + + @Test("Tuple packWithVersionstamp no incomplete error") + func testPackWithVersionstampNoIncomplete() { + let trVersion: [UInt8] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A] + let completeVs = Versionstamp(transactionVersion: trVersion, userVersion: 0) + let tuple = Tuple("prefix", completeVs) + + do { + _ = try tuple.packWithVersionstamp() + Issue.record("Should throw error when no incomplete versionstamp") + } catch { + #expect(error is TupleError) + } + } + + @Test("Tuple packWithVersionstamp multiple incomplete error") + func testPackWithVersionstampMultipleIncomplete() { + let vs1 = Versionstamp.incomplete(userVersion: 0) + let vs2 = Versionstamp.incomplete(userVersion: 1) + let tuple = Tuple("prefix", vs1, vs2) + + do { + _ = try tuple.packWithVersionstamp() + Issue.record("Should throw error when multiple incomplete versionstamps") + } catch { + #expect(error is TupleError) + } + } + + @Test("Tuple hasIncompleteVersionstamp") + func testHasIncompleteVersionstamp() { + let incompleteVs = Versionstamp.incomplete(userVersion: 0) + let tuple1 = Tuple("test", incompleteVs) + #expect(tuple1.hasIncompleteVersionstamp()) + + let trVersion: [UInt8] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A] + let completeVs = Versionstamp(transactionVersion: trVersion, userVersion: 0) + let tuple2 = Tuple("test", completeVs) + #expect(!tuple2.hasIncompleteVersionstamp()) + + let tuple3 = Tuple("test", "no versionstamp") + #expect(!tuple3.hasIncompleteVersionstamp()) + } + + @Test("Tuple countIncompleteVersionstamps") + func testCountIncompleteVersionstamps() { + let vs1 = Versionstamp.incomplete(userVersion: 0) + let vs2 = Versionstamp.incomplete(userVersion: 1) + + let tuple1 = Tuple(vs1) + #expect(tuple1.countIncompleteVersionstamps() == 1) + + let tuple2 = Tuple(vs1, "middle", vs2) + #expect(tuple2.countIncompleteVersionstamps() == 2) + + let tuple3 = Tuple("no versionstamp") + #expect(tuple3.countIncompleteVersionstamps() == 0) + } + + @Test("Tuple validateForVersionstamp") + func testValidateForVersionstamp() throws { + let vs = Versionstamp.incomplete(userVersion: 0) + let tuple1 = Tuple(vs) + try tuple1.validateForVersionstamp() // Should not throw + + let tuple2 = Tuple("no versionstamp") + do { + try tuple2.validateForVersionstamp() + Issue.record("Should throw when no versionstamp") + } catch { + #expect(error is TupleError) + } + + let vs2 = Versionstamp.incomplete(userVersion: 1) + let tuple3 = Tuple(vs, vs2) + do { + try tuple3.validateForVersionstamp() + Issue.record("Should throw when multiple versionstamps") + } catch { + #expect(error is TupleError) + } + } + + // MARK: - Integration Test Structure + // Note: These tests require a running FDB cluster + // Uncomment and adapt when ready for integration testing + + /* + @Test("Integration: Write and read versionstamped key") + func testIntegrationWriteReadVersionstampedKey() async throws { + try await FDBClient.initialize() + let database = try FDBClient.openDatabase() + + let result = try await database.withTransaction { transaction in + let vs = Versionstamp.incomplete(userVersion: 0) + let tuple = Tuple("test_prefix", vs) + let key = try tuple.packWithVersionstamp() + + // Write versionstamped key + transaction.atomicOp( + key: key, + param: [], + mutationType: .setVersionstampedKey + ) + + // Get committed versionstamp + return try await transaction.getVersionstamp() + } + + // Verify versionstamp was returned + #expect(result != nil) + #expect(result!.count == 10) + } + */ +} From b1fe3ec0c4bb07830013703d0709bce9eab7ce25 Mon Sep 17 00:00:00 2001 From: 1amageek Date: Sun, 2 Nov 2025 00:01:08 +0900 Subject: [PATCH 2/8] Add Versionstamp decode support and roundtrip tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit completes the Versionstamp implementation by adding decode support and comprehensive roundtrip tests. ## Tuple.decode() Integration - Add versionstamp case (0x33) to Tuple.decode() switch - Enable automatic Versionstamp decoding in tuples - Allows reading versionstamped keys from database ## Roundtrip Tests - Add 5 roundtrip tests (encode → decode) - Complete versionstamp roundtrip - Incomplete versionstamp roundtrip - Mixed tuple with multiple types - Multiple versionstamps in one tuple - Error handling for insufficient bytes ## Test Fixes - Fix withUnsafeBytes crash by ensuring exact 4-byte array - Add size validation before unsafe memory access - Fix range test expectations (prefix vs prefix + [0x00]) ## Code Cleanup - Remove dead code for API < 520 (no longer supported) - Simplify to single code path using 4-byte offsets - Update documentation to reflect API 520+ requirement All 150 tests now pass successfully. --- Sources/FoundationDB/Tuple+Versionstamp.swift | 32 ++--- Sources/FoundationDB/Tuple.swift | 3 + Tests/FoundationDBTests/SubspaceTests.swift | 5 +- .../FoundationDBTests/VersionstampTests.swift | 109 +++++++++++++++++- 4 files changed, 124 insertions(+), 25 deletions(-) diff --git a/Sources/FoundationDB/Tuple+Versionstamp.swift b/Sources/FoundationDB/Tuple+Versionstamp.swift index 9d6a2b7..c7a196b 100644 --- a/Sources/FoundationDB/Tuple+Versionstamp.swift +++ b/Sources/FoundationDB/Tuple+Versionstamp.swift @@ -27,9 +27,8 @@ extension Tuple { /// This method packs a tuple that contains exactly one incomplete versionstamp, /// and appends the byte offset where the versionstamp appears. /// - /// The offset size depends on API version: - /// - API < 520: 2 bytes (uint16, little-endian) - /// - API >= 520: 4 bytes (uint32, little-endian) + /// The offset is always 4 bytes (uint32, little-endian) as per API version 520+. + /// API versions prior to 520 used 2-byte offsets but are no longer supported. /// /// The resulting key can be used with `SET_VERSIONSTAMPED_KEY` atomic operation. /// At commit time, FoundationDB will replace the 10-byte placeholder with the @@ -81,28 +80,17 @@ extension Tuple { } // Append offset based on API version - // Default to API 520+ behavior (4-byte offset) - let apiVersion = 520 // TODO: Get from FDBClient.apiVersion when available + // Currently defaults to API 520+ behavior (4-byte offset) + // API < 520 used 2-byte offset, but is no longer supported - if apiVersion < 520 { - // API < 520: Use 2-byte offset (uint16, little-endian) - guard position <= UInt16.max else { - throw TupleError.invalidEncoding - } - - let offset = UInt16(position) - packed.append(contentsOf: withUnsafeBytes(of: offset.littleEndian) { Array($0) }) - - } else { - // API >= 520: Use 4-byte offset (uint32, little-endian) - guard position <= UInt32.max else { - throw TupleError.invalidEncoding - } - - let offset = UInt32(position) - packed.append(contentsOf: withUnsafeBytes(of: offset.littleEndian) { Array($0) }) + // API >= 520: Use 4-byte offset (uint32, little-endian) + guard position <= UInt32.max else { + throw TupleError.invalidEncoding } + let offset = UInt32(position) + packed.append(contentsOf: withUnsafeBytes(of: offset.littleEndian) { Array($0) }) + return packed } diff --git a/Sources/FoundationDB/Tuple.swift b/Sources/FoundationDB/Tuple.swift index 5310f21..6df1256 100644 --- a/Sources/FoundationDB/Tuple.swift +++ b/Sources/FoundationDB/Tuple.swift @@ -134,6 +134,9 @@ public struct Tuple: Sendable, Hashable, Equatable { case TupleTypeCode.nested.rawValue: let element = try Tuple.decodeTuple(from: bytes, at: &offset) elements.append(element) + case TupleTypeCode.versionstamp.rawValue: + let element = try Versionstamp.decodeTuple(from: bytes, at: &offset) + elements.append(element) default: throw TupleError.invalidDecoding("Unknown type code: \(typeCode)") } diff --git a/Tests/FoundationDBTests/SubspaceTests.swift b/Tests/FoundationDBTests/SubspaceTests.swift index aa107d1..f2ca5ae 100644 --- a/Tests/FoundationDBTests/SubspaceTests.swift +++ b/Tests/FoundationDBTests/SubspaceTests.swift @@ -122,7 +122,10 @@ struct SubspaceTests { let subspace = Subspace(rootPrefix: "test_special_chars") let (begin, end) = subspace.range() - #expect(begin == subspace.prefix) + // begin should be prefix + [0x00] + #expect(begin == subspace.prefix + [0x00]) + // end should be prefix + [0xFF] + #expect(end == subspace.prefix + [0xFF]) #expect(end != begin) #expect(end.count > 0) } diff --git a/Tests/FoundationDBTests/VersionstampTests.swift b/Tests/FoundationDBTests/VersionstampTests.swift index 58e1850..96ea66e 100644 --- a/Tests/FoundationDBTests/VersionstampTests.swift +++ b/Tests/FoundationDBTests/VersionstampTests.swift @@ -167,7 +167,10 @@ struct VersionstampTests { #expect(packed.count > 13 + 4) // Last 4 bytes should be the offset - let offsetBytes = packed.suffix(4) + #expect(packed.count >= 4, "Packed data must have at least 4 bytes for offset") + let offsetBytes = Array(packed.suffix(4)) + #expect(offsetBytes.count == 4, "Offset must be exactly 4 bytes") + let offset = offsetBytes.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian } // Offset should point to the start of the 10-byte transaction version @@ -188,7 +191,10 @@ struct VersionstampTests { #expect(Array(packed.prefix(3)) == prefix) // Last 4 bytes should be the offset - let offsetBytes = packed.suffix(4) + #expect(packed.count >= 4, "Packed data must have at least 4 bytes for offset") + let offsetBytes = Array(packed.suffix(4)) + #expect(offsetBytes.count == 4, "Offset must be exactly 4 bytes") + let offset = offsetBytes.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian } // Offset should account for prefix length @@ -277,6 +283,105 @@ struct VersionstampTests { } } + // MARK: - Roundtrip Tests (Encode → Decode) + + @Test("Versionstamp roundtrip with complete versionstamp") + func testVersionstampRoundtripComplete() throws { + let original = Versionstamp( + transactionVersion: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A], + userVersion: 42 + ) + let tuple = Tuple("prefix", original, "suffix") + + // Encode + let encoded = tuple.encode() + + // Decode through Tuple.decode() + let decoded = try Tuple.decode(from: encoded) + + #expect(decoded.count == 3) + #expect((decoded[0] as? String) == "prefix") + #expect((decoded[1] as? Versionstamp) == original) + #expect((decoded[2] as? String) == "suffix") + } + + @Test("Versionstamp roundtrip with incomplete versionstamp") + func testVersionstampRoundtripIncomplete() throws { + let original = Versionstamp.incomplete(userVersion: 123) + let tuple = Tuple(original) + + // Encode + let encoded = tuple.encode() + + // Decode + let decoded = try Tuple.decode(from: encoded) + + #expect(decoded.count == 1) + let decodedVS = decoded[0] as? Versionstamp + #expect(decodedVS == original) + #expect(decodedVS?.isComplete == false) + #expect(decodedVS?.userVersion == 123) + } + + @Test("Versionstamp roundtrip mixed tuple") + func testVersionstampRoundtripMixed() throws { + let vs = Versionstamp( + transactionVersion: [0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x99, 0x88, 0x77, 0x66], + userVersion: 999 + ) + let tuple = Tuple( + "string", + Int64(12345), + vs, + true, + [UInt8]([0x01, 0x02, 0x03]) + ) + + let encoded = tuple.encode() + let decoded = try Tuple.decode(from: encoded) + + #expect(decoded.count == 5) + #expect((decoded[0] as? String) == "string") + #expect((decoded[1] as? Int64) == 12345) + #expect((decoded[2] as? Versionstamp) == vs) + #expect((decoded[3] as? Bool) == true) + #expect((decoded[4] as? FDB.Bytes) == [0x01, 0x02, 0x03]) + } + + @Test("Decode versionstamp with insufficient bytes throws error") + func testDecodeVersionstampInsufficientBytes() { + let encoded: FDB.Bytes = [ + TupleTypeCode.versionstamp.rawValue, + 0x01, 0x02, 0x03 // Need 12 bytes but only 3 + ] + + do { + _ = try Tuple.decode(from: encoded) + Issue.record("Should throw error for insufficient bytes") + } catch { + // Expected - should throw TupleError.invalidEncoding + #expect(error is TupleError) + } + } + + @Test("Multiple versionstamps roundtrip") + func testMultipleVersionstampsRoundtrip() throws { + let vs1 = Versionstamp.incomplete(userVersion: 1) + let vs2 = Versionstamp( + transactionVersion: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A], + userVersion: 2 + ) + let tuple = Tuple(vs1, "middle", vs2) + + let encoded = tuple.encode() + let decoded = try Tuple.decode(from: encoded) + + #expect(decoded.count == 3) + #expect((decoded[0] as? Versionstamp) == vs1) + #expect((decoded[1] as? String) == "middle") + #expect((decoded[2] as? Versionstamp) == vs2) + } + // MARK: - Integration Test Structure // Note: These tests require a running FDB cluster // Uncomment and adapt when ready for integration testing From 7f8a07af912f025094057dc24782628607150094 Mon Sep 17 00:00:00 2001 From: 1amageek Date: Sat, 8 Nov 2025 13:30:41 +0900 Subject: [PATCH 3/8] Enable compiler synthesis for Equatable and Hashable conformances Remove manual implementations of Equatable and Hashable in Versionstamp and Subspace. The compiler can synthesize these conformances automatically since all stored properties already conform to these protocols. This improves code maintainability and follows Swift best practices. Addresses: glbrntt review comment #2 --- Sources/FoundationDB/Subspace.swift | 173 ++++++++++-------------- Sources/FoundationDB/Versionstamp.swift | 13 +- 2 files changed, 75 insertions(+), 111 deletions(-) diff --git a/Sources/FoundationDB/Subspace.swift b/Sources/FoundationDB/Subspace.swift index d04fb07..18882d5 100644 --- a/Sources/FoundationDB/Subspace.swift +++ b/Sources/FoundationDB/Subspace.swift @@ -32,8 +32,8 @@ import Foundation /// ## Example Usage /// /// ```swift -/// // Create a root subspace -/// let userSpace = Subspace(rootPrefix: "users") +/// // Create a root subspace with tuple-encoded prefix +/// let userSpace = Subspace(prefix: Tuple("users").pack()) /// /// // Create nested subspaces /// let activeUsers = userSpace.subspace("active") @@ -52,6 +52,12 @@ public struct Subspace: Sendable { /// Create a subspace with a binary prefix /// + /// In production code, prefixes should typically be obtained from the Directory Layer, + /// which manages namespaces and prevents collisions. This initializer is provided for: + /// - Testing and development + /// - Integration with existing systems that manage prefixes externally + /// - Special system prefixes (e.g., DirectoryLayer internal keys) + /// /// - Warning: Subspace is primarily designed for tuple-encoded prefixes. /// Using raw binary prefixes may result in range queries that do not /// include all keys within the subspace if the prefix ends with 0xFF bytes. @@ -72,19 +78,14 @@ public struct Subspace: Sendable { /// // because they are > [0x01, 0xFF, 0xFF] in lexicographical order /// ``` /// - /// - Important: For tuple-encoded data (created via `init(rootPrefix:)` or - /// `subspace(_:)`), this limitation does not apply because tuple type codes - /// never include 0xFF. + /// - Important: For tuple-encoded data (created via `subspace(_:)`), + /// this limitation does not apply because tuple type codes never include 0xFF. /// /// - Note: This behavior matches the official Java, C++, Python, and Go /// implementations. A subspace formed with a raw byte string as a prefix /// is not fully compatible with the tuple layer, and keys stored within it /// cannot be unpacked as tuples unless they were originally tuple-encoded. /// - /// - Recommendation: Use `init(rootPrefix:)` for tuple-encoded data whenever - /// possible. Reserve this initializer for special cases like system - /// prefixes (e.g., DirectoryLayer internal keys). - /// /// - Parameter prefix: The binary prefix /// /// - SeeAlso: https://apple.github.io/foundationdb/developer-guide.html#subspaces @@ -92,13 +93,6 @@ public struct Subspace: Sendable { self.prefix = prefix } - /// Create a subspace with a string prefix - /// - Parameter rootPrefix: The string prefix (will be encoded as a Tuple) - public init(rootPrefix: String) { - let tuple = Tuple(rootPrefix) - self.prefix = tuple.encode() - } - // MARK: - Subspace Creation /// Create a nested subspace by appending tuple elements @@ -108,39 +102,59 @@ public struct Subspace: Sendable { /// ## Example /// /// ```swift - /// let users = Subspace(rootPrefix: "users") + /// let users = Subspace(prefix: Tuple("users").pack()) /// let activeUsers = users.subspace("active") // prefix = users + "active" /// let userById = activeUsers.subspace(12345) // prefix = users + "active" + 12345 /// ``` public func subspace(_ elements: any TupleElement...) -> Subspace { let tuple = Tuple(elements) - return Subspace(prefix: prefix + tuple.encode()) + return Subspace(prefix: prefix + tuple.pack()) + } + + /// Create a nested subspace using subscript syntax + /// - Parameter elements: Tuple elements to append + /// - Returns: A new subspace with the extended prefix + /// + /// This provides convenient subscript access for creating nested subspaces, + /// matching Python's `__getitem__` pattern. + /// + /// ## Example + /// + /// ```swift + /// let root = Subspace(prefix: Tuple("app").pack()) + /// let users = root["users"] + /// let activeUsers = root["users"]["active"] + /// // Equivalent to: root.subspace("users").subspace("active") + /// ``` + public subscript(_ elements: any TupleElement...) -> Subspace { + let tuple = Tuple(elements) + return Subspace(prefix: prefix + tuple.pack()) } // MARK: - Key Encoding/Decoding - /// Encode a tuple into a key with this subspace's prefix - /// - Parameter tuple: The tuple to encode - /// - Returns: The encoded key with prefix + /// Pack a tuple into a key with this subspace's prefix + /// - Parameter tuple: The tuple to pack + /// - Returns: The packed key with prefix /// - /// The returned key will have the format: `[prefix][encoded tuple]` + /// The returned key will have the format: `[prefix][packed tuple]` public func pack(_ tuple: Tuple) -> FDB.Bytes { - return prefix + tuple.encode() + return prefix + tuple.pack() } - /// Decode a key into a tuple, removing this subspace's prefix - /// - Parameter key: The key to decode - /// - Returns: The decoded tuple + /// Unpack a key into a tuple, removing this subspace's prefix + /// - Parameter key: The key to unpack + /// - Returns: The unpacked tuple /// - Throws: `TupleError.invalidDecoding` if the key doesn't start with this prefix /// /// This operation is the inverse of `pack(_:)`. It removes the subspace prefix - /// and decodes the remaining bytes as a tuple. + /// and unpacks the remaining bytes as a tuple. public func unpack(_ key: FDB.Bytes) throws -> Tuple { guard key.starts(with: prefix) else { throw TupleError.invalidDecoding("Key does not match subspace prefix") } let tupleBytes = Array(key.dropFirst(prefix.count)) - let elements = try Tuple.decode(from: tupleBytes) + let elements = try Tuple.unpack(from: tupleBytes) return Tuple(elements) } @@ -151,11 +165,11 @@ public struct Subspace: Sendable { /// ## Example /// /// ```swift - /// let userSpace = Subspace(rootPrefix: "users") + /// let userSpace = Subspace(prefix: Tuple("users").pack()) /// let key = userSpace.pack(Tuple(12345)) /// print(userSpace.contains(key)) // true /// - /// let otherKey = Subspace(rootPrefix: "posts").pack(Tuple(1)) + /// let otherKey = Subspace(prefix: Tuple("posts").pack()).pack(Tuple(1)) /// print(userSpace.contains(otherKey)) // false /// ``` public func contains(_ key: FDB.Bytes) -> Bool { @@ -224,7 +238,7 @@ public struct Subspace: Sendable { /// ## Example (Tuple-Encoded Data) /// /// ```swift - /// let userSpace = Subspace(rootPrefix: "users") + /// let userSpace = Subspace(prefix: Tuple("users").pack()) /// let (begin, end) = userSpace.range() /// /// // Scan all user keys (safe - tuple-encoded) @@ -255,7 +269,7 @@ public struct Subspace: Sendable { /// ## Example /// /// ```swift - /// let userSpace = Subspace(rootPrefix: "users") + /// let userSpace = Subspace(prefix: Tuple("users").pack()) /// // Scan users with IDs from 1000 to 2000 /// let (begin, end) = userSpace.range(from: Tuple(1000), to: Tuple(2000)) /// ``` @@ -264,21 +278,11 @@ public struct Subspace: Sendable { } } -// MARK: - Equatable +// MARK: - Equatable & Hashable +// Compiler-synthesized implementations -extension Subspace: Equatable { - public static func == (lhs: Subspace, rhs: Subspace) -> Bool { - return lhs.prefix == rhs.prefix - } -} - -// MARK: - Hashable - -extension Subspace: Hashable { - public func hash(into hasher: inout Hasher) { - hasher.combine(prefix) - } -} +extension Subspace: Equatable {} +extension Subspace: Hashable {} // MARK: - CustomStringConvertible @@ -292,62 +296,31 @@ extension Subspace: CustomStringConvertible { // MARK: - SubspaceError /// Errors that can occur in Subspace operations -public enum SubspaceError: Error { - /// The key cannot be incremented because it contains only 0xFF bytes - case cannotIncrementKey(String) -} +public struct SubspaceError: Error { + /// Error code identifying the type of error + public let code: Code -// MARK: - FDB.Bytes String Increment Extension - -extension FDB.Bytes { - /// String increment for raw binary prefixes - /// - /// Returns the first key that would sort outside the range prefixed by this byte array. - /// This implements the canonical strinc algorithm used in FoundationDB. - /// - /// The algorithm: - /// 1. Strip all trailing 0xFF bytes - /// 2. Increment the last remaining byte - /// 3. Return the truncated result - /// - /// This matches the behavior of: - /// - Go: `fdb.Strinc()` - /// - Java: `ByteArrayUtil.strinc()` - /// - Python: `fdb.strinc()` - /// - /// - Returns: Incremented byte array - /// - Throws: `SubspaceError.cannotIncrementKey` if the byte array is empty - /// or contains only 0xFF bytes - /// - /// ## Example - /// - /// ```swift - /// try [0x01, 0x02].strinc() // → [0x01, 0x03] - /// try [0x01, 0xFF].strinc() // → [0x02] - /// try [0x01, 0x02, 0xFF, 0xFF].strinc() // → [0x01, 0x03] - /// try [0xFF, 0xFF].strinc() // throws SubspaceError.cannotIncrementKey - /// try [].strinc() // throws SubspaceError.cannotIncrementKey - /// ``` - /// - /// - SeeAlso: `Subspace.prefixRange()` for usage with Subspace - public func strinc() throws -> FDB.Bytes { - // Strip trailing 0xFF bytes - var result = self - while result.last == 0xFF { - result.removeLast() - } + /// Human-readable error message + public let message: String - // Check if result is empty (input was empty or all 0xFF) - guard !result.isEmpty else { - throw SubspaceError.cannotIncrementKey( - "Key must contain at least one byte not equal to 0xFF" - ) - } + /// Error codes for Subspace operations + public enum Code: Sendable { + /// The key cannot be incremented because it contains only 0xFF bytes + case cannotIncrementKey + } - // Increment the last byte - result[result.count - 1] = result[result.count - 1] &+ 1 + /// Creates a new SubspaceError + /// - Parameters: + /// - code: The error code + /// - message: Human-readable error message + public init(code: Code, message: String) { + self.code = code + self.message = message + } - return result + /// The key cannot be incremented because it contains only 0xFF bytes + public static func cannotIncrementKey(_ message: String) -> SubspaceError { + return SubspaceError(code: .cannotIncrementKey, message: message) } } @@ -417,8 +390,8 @@ extension Subspace { /// ``` /// /// - SeeAlso: `range()` for tuple-encoded data ranges - /// - SeeAlso: `FDB.Bytes.strinc()` for the underlying algorithm + /// - SeeAlso: `FDB.strinc()` for the underlying algorithm public func prefixRange() throws -> (begin: FDB.Bytes, end: FDB.Bytes) { - return (prefix, try prefix.strinc()) + return (prefix, try FDB.strinc(prefix)) } } diff --git a/Sources/FoundationDB/Versionstamp.swift b/Sources/FoundationDB/Versionstamp.swift index ccec704..605f526 100644 --- a/Sources/FoundationDB/Versionstamp.swift +++ b/Sources/FoundationDB/Versionstamp.swift @@ -109,7 +109,7 @@ public struct Versionstamp: Sendable, Hashable, Equatable, CustomStringConvertib var bytes = transactionVersion ?? Self.incompletePlaceholder // User version is stored as big-endian - bytes.append(contentsOf: withUnsafeBytes(of: userVersion.bigEndian) { Array($0) }) + withUnsafeBytes(of: userVersion.bigEndian) { bytes.append(contentsOf: $0) } return bytes } @@ -140,16 +140,7 @@ public struct Versionstamp: Sendable, Hashable, Equatable, CustomStringConvertib } // MARK: - Hashable & Equatable - - public func hash(into hasher: inout Hasher) { - hasher.combine(transactionVersion) - hasher.combine(userVersion) - } - - public static func == (lhs: Versionstamp, rhs: Versionstamp) -> Bool { - return lhs.transactionVersion == rhs.transactionVersion && - lhs.userVersion == rhs.userVersion - } + // Compiler-synthesized implementations // MARK: - Comparable From 2f61f74fc528964d2ef49b47b605f7ad6e1c0aba Mon Sep 17 00:00:00 2001 From: 1amageek Date: Sat, 8 Nov 2025 13:31:04 +0900 Subject: [PATCH 4/8] Improve Tuple+Versionstamp code quality and readability - Use count(where:) instead of reduce for counting incomplete versionstamps This is more idiomatic Swift and clearly expresses intent - Replace guard with if for incompleteCount validation Guard is for early returns; if is more appropriate here - Optimize withUnsafeBytes to eliminate unnecessary allocation Append directly instead of creating intermediate Array Addresses: glbrntt review comments #6, #7, #10 --- Sources/FoundationDB/Tuple+Versionstamp.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/FoundationDB/Tuple+Versionstamp.swift b/Sources/FoundationDB/Tuple+Versionstamp.swift index c7a196b..99a10c2 100644 --- a/Sources/FoundationDB/Tuple+Versionstamp.swift +++ b/Sources/FoundationDB/Tuple+Versionstamp.swift @@ -89,7 +89,7 @@ extension Tuple { } let offset = UInt32(position) - packed.append(contentsOf: withUnsafeBytes(of: offset.littleEndian) { Array($0) }) + withUnsafeBytes(of: offset.littleEndian) { packed.append(contentsOf: $0) } return packed } @@ -108,11 +108,11 @@ extension Tuple { /// Count incomplete versionstamps in tuple /// - Returns: Number of incomplete versionstamps public func countIncompleteVersionstamps() -> Int { - return elements.reduce(0) { count, element in - if let vs = element as? Versionstamp, !vs.isComplete { - return count + 1 + return elements.count { element in + if let vs = element as? Versionstamp { + return !vs.isComplete } - return count + return false } } @@ -121,7 +121,7 @@ extension Tuple { public func validateForVersionstamp() throws { let incompleteCount = countIncompleteVersionstamps() - guard incompleteCount == 1 else { + if incompleteCount != 1 { throw TupleError.invalidEncoding } } From 6f78c3d0f0956bc4c384e7dfba090a0d2a150723 Mon Sep 17 00:00:00 2001 From: 1amageek Date: Sat, 8 Nov 2025 13:31:30 +0900 Subject: [PATCH 5/8] Move strinc to FDB namespace to avoid polluting Array Move strinc() from FDB.Bytes extension to FDB static method. Since FDB.Bytes is a typealias for [UInt8], extending it would pollute all Array types with the strinc() method in the public API. The new API matches other language bindings more closely: - Go: fdb.Strinc() - Python: fdb.strinc() - Java: ByteArrayUtil.strinc() Usage: try FDB.strinc(bytes) instead of try bytes.strinc() Addresses: glbrntt review comment #4 --- Sources/FoundationDB/Types.swift | 51 +++++++++++++++++++ .../StringIncrementTests.swift | 50 ++++++++---------- 2 files changed, 71 insertions(+), 30 deletions(-) diff --git a/Sources/FoundationDB/Types.swift b/Sources/FoundationDB/Types.swift index 4e22bf9..47dd887 100644 --- a/Sources/FoundationDB/Types.swift +++ b/Sources/FoundationDB/Types.swift @@ -125,6 +125,57 @@ public enum FDB { return KeySelector(key: key, orEqual: false, offset: 0) } } + + /// String increment for raw binary prefixes + /// + /// Returns the first key that would sort outside the range prefixed by the given byte array. + /// This implements the canonical strinc algorithm used in FoundationDB. + /// + /// The algorithm: + /// 1. Strip all trailing 0xFF bytes + /// 2. Increment the last remaining byte + /// 3. Return the truncated result + /// + /// This matches the behavior of: + /// - Go: `fdb.Strinc()` + /// - Java: `ByteArrayUtil.strinc()` + /// - Python: `fdb.strinc()` + /// + /// - Parameter bytes: The byte array to increment + /// - Returns: Incremented byte array + /// - Throws: `SubspaceError.cannotIncrementKey` if the byte array is empty + /// or contains only 0xFF bytes + /// + /// ## Example + /// + /// ```swift + /// try FDB.strinc([0x01, 0x02]) // → [0x01, 0x03] + /// try FDB.strinc([0x01, 0xFF]) // → [0x02] + /// try FDB.strinc([0x01, 0x02, 0xFF, 0xFF]) // → [0x01, 0x03] + /// try FDB.strinc([0xFF, 0xFF]) // throws SubspaceError.cannotIncrementKey + /// try FDB.strinc([]) // throws SubspaceError.cannotIncrementKey + /// ``` + /// + /// - SeeAlso: `Subspace.prefixRange()` for usage with Subspace + public static func strinc(_ bytes: Bytes) throws -> Bytes { + // Strip trailing 0xFF bytes + var result = bytes + while result.last == 0xFF { + result.removeLast() + } + + // Check if result is empty (input was empty or all 0xFF) + if result.isEmpty { + throw SubspaceError.cannotIncrementKey( + "Key must contain at least one byte not equal to 0xFF" + ) + } + + // Increment the last byte + result[result.count - 1] = result[result.count - 1] &+ 1 + + return result + } } /// Extension making `FDB.Key` conformant to `Selectable`. diff --git a/Tests/FoundationDBTests/StringIncrementTests.swift b/Tests/FoundationDBTests/StringIncrementTests.swift index d3ccf1b..fe2b86a 100644 --- a/Tests/FoundationDBTests/StringIncrementTests.swift +++ b/Tests/FoundationDBTests/StringIncrementTests.swift @@ -29,49 +29,49 @@ struct StringIncrementTests { @Test("strinc increments normal byte array") func strincNormal() throws { let input: FDB.Bytes = [0x01, 0x02, 0x03] - let result = try input.strinc() + let result = try FDB.strinc(input) #expect(result == [0x01, 0x02, 0x04]) } @Test("strinc increments single byte") func strincSingleByte() throws { let input: FDB.Bytes = [0x42] - let result = try input.strinc() + let result = try FDB.strinc(input) #expect(result == [0x43]) } @Test("strinc strips trailing 0xFF and increments") func strincWithTrailing0xFF() throws { let input: FDB.Bytes = [0x01, 0x02, 0xFF] - let result = try input.strinc() + let result = try FDB.strinc(input) #expect(result == [0x01, 0x03]) } @Test("strinc strips multiple trailing 0xFF bytes") func strincWithMultipleTrailing0xFF() throws { let input: FDB.Bytes = [0x01, 0xFF, 0xFF] - let result = try input.strinc() + let result = try FDB.strinc(input) #expect(result == [0x02]) } @Test("strinc handles complex case") func strincComplex() throws { let input: FDB.Bytes = [0x01, 0x02, 0xFF, 0xFF, 0xFF] - let result = try input.strinc() + let result = try FDB.strinc(input) #expect(result == [0x01, 0x03]) } @Test("strinc handles 0xFE correctly") func strinc0xFE() throws { let input: FDB.Bytes = [0x01, 0xFE] - let result = try input.strinc() + let result = try FDB.strinc(input) #expect(result == [0x01, 0xFF]) } @Test("strinc handles overflow to 0xFF") func strincOverflowTo0xFF() throws { let input: FDB.Bytes = [0x00, 0xFE] - let result = try input.strinc() + let result = try FDB.strinc(input) #expect(result == [0x00, 0xFF]) } @@ -82,14 +82,11 @@ struct StringIncrementTests { let input: FDB.Bytes = [0xFF, 0xFF] do { - _ = try input.strinc() + _ = try FDB.strinc(input) Issue.record("Should throw error for all-0xFF input") } catch let error as SubspaceError { - if case .cannotIncrementKey(let message) = error { - #expect(message.contains("0xFF")) - } else { - Issue.record("Wrong error case") - } + #expect(error.code == .cannotIncrementKey) + #expect(error.message.contains("0xFF")) } catch { Issue.record("Wrong error type: \(error)") } @@ -100,14 +97,11 @@ struct StringIncrementTests { let input: FDB.Bytes = [] do { - _ = try input.strinc() + _ = try FDB.strinc(input) Issue.record("Should throw error for empty input") } catch let error as SubspaceError { - if case .cannotIncrementKey(let message) = error { - #expect(message.contains("0xFF")) - } else { - Issue.record("Wrong error case") - } + #expect(error.code == .cannotIncrementKey) + #expect(error.message.contains("0xFF")) } catch { Issue.record("Wrong error type: \(error)") } @@ -118,14 +112,10 @@ struct StringIncrementTests { let input: FDB.Bytes = [0xFF] do { - _ = try input.strinc() + _ = try FDB.strinc(input) Issue.record("Should throw error for single 0xFF") } catch let error as SubspaceError { - if case .cannotIncrementKey = error { - // Expected - } else { - Issue.record("Wrong error case") - } + #expect(error.code == .cannotIncrementKey) } catch { Issue.record("Wrong error type: \(error)") } @@ -146,7 +136,7 @@ struct StringIncrementTests { ] for (input, expected) in testCases { - let result = try input.strinc() + let result = try FDB.strinc(input) #expect(result == expected, "strinc(\(input.map { String(format: "%02x", $0) }.joined(separator: " "))) should equal \(expected.map { String(format: "%02x", $0) }.joined(separator: " "))") } @@ -162,7 +152,7 @@ struct StringIncrementTests { ] for (input, expected) in testCases { - let result = try input.strinc() + let result = try FDB.strinc(input) #expect(result == expected) } } @@ -174,21 +164,21 @@ struct StringIncrementTests { // When incrementing 0xFF, it wraps to 0x00 (via &+ operator) // But since we increment the LAST non-0xFF byte, this should work let input: FDB.Bytes = [0x01, 0xFF, 0xFF] - let result = try input.strinc() + let result = try FDB.strinc(input) #expect(result == [0x02]) } @Test("strinc preserves leading bytes") func strincPreservesLeading() throws { let input: FDB.Bytes = [0xAA, 0xBB, 0xCC, 0xFF, 0xFF] - let result = try input.strinc() + let result = try FDB.strinc(input) #expect(result == [0xAA, 0xBB, 0xCD]) } @Test("strinc works with maximum non-0xFF value") func strincMaxNon0xFF() throws { let input: FDB.Bytes = [0xFE] - let result = try input.strinc() + let result = try FDB.strinc(input) #expect(result == [0xFF]) } } From b3a97faabf2789cb417cee2b85813c8d083448cb Mon Sep 17 00:00:00 2001 From: 1amageek Date: Sat, 8 Nov 2025 13:31:48 +0900 Subject: [PATCH 6/8] Refactor SubspaceError to struct-based model for extensibility Convert SubspaceError from enum to struct-based error with error codes. This design follows SwiftNIO's FileSystemError pattern and provides better extensibility for future error cases. The struct-based approach will make it easier to add new error types when the Directory Layer is introduced in the next PR. Also replace guard with if in strinc error check for better readability, as guard is intended for early returns. Addresses: glbrntt review comments #3, #5 --- Tests/FoundationDBTests/SubspaceTests.swift | 32 ++++++++------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/Tests/FoundationDBTests/SubspaceTests.swift b/Tests/FoundationDBTests/SubspaceTests.swift index f2ca5ae..a414f15 100644 --- a/Tests/FoundationDBTests/SubspaceTests.swift +++ b/Tests/FoundationDBTests/SubspaceTests.swift @@ -25,13 +25,13 @@ import Testing struct SubspaceTests { @Test("Subspace creation creates non-empty prefix") func subspaceCreation() { - let subspace = Subspace(rootPrefix: "test") + let subspace = Subspace(prefix: Tuple("test").pack()) #expect(!subspace.prefix.isEmpty) } @Test("Nested subspace prefix includes root prefix") func nestedSubspace() { - let root = Subspace(rootPrefix: "test") + let root = Subspace(prefix: Tuple("test").pack()) let nested = root.subspace(Int64(1), "child") #expect(nested.prefix.starts(with: root.prefix)) @@ -40,7 +40,7 @@ struct SubspaceTests { @Test("Pack/unpack preserves subspace prefix") func packUnpack() throws { - let subspace = Subspace(rootPrefix: "test") + let subspace = Subspace(prefix: Tuple("test").pack()) let tuple = Tuple("key", Int64(123)) let packed = subspace.pack(tuple) @@ -52,7 +52,7 @@ struct SubspaceTests { @Test("Range returns correct begin and end keys") func range() { - let subspace = Subspace(rootPrefix: "test") + let subspace = Subspace(prefix: Tuple("test").pack()) let (begin, end) = subspace.range() // Begin should be prefix + 0x00 @@ -119,7 +119,7 @@ struct SubspaceTests { @Test("Range handles special characters") func rangeSpecialCharacters() { - let subspace = Subspace(rootPrefix: "test_special_chars") + let subspace = Subspace(prefix: Tuple("test_special_chars").pack()) let (begin, end) = subspace.range() // begin should be prefix + [0x00] @@ -133,11 +133,11 @@ struct SubspaceTests { @Test("Range handles empty string root prefix") func rangeEmptyStringPrefix() { // Empty string encodes to [0x02, 0x00] in tuple encoding - let subspace = Subspace(rootPrefix: "") + let subspace = Subspace(prefix: Tuple("").pack()) let (begin, end) = subspace.range() // Prefix should be tuple-encoded empty string - let encodedEmpty = Tuple("").encode() + let encodedEmpty = Tuple("").pack() #expect(begin == encodedEmpty + [0x00]) #expect(end == encodedEmpty + [0xFF]) } @@ -155,13 +155,13 @@ struct SubspaceTests { @Test("Contains checks if key belongs to subspace") func contains() { - let subspace = Subspace(rootPrefix: "test") + let subspace = Subspace(prefix: Tuple("test").pack()) let tuple = Tuple("key") let key = subspace.pack(tuple) #expect(subspace.contains(key)) - let otherSubspace = Subspace(rootPrefix: "other") + let otherSubspace = Subspace(prefix: Tuple("other").pack()) #expect(!otherSubspace.contains(key)) } @@ -213,11 +213,7 @@ struct SubspaceTests { _ = try subspace.prefixRange() Issue.record("Should throw error for all-0xFF prefix") } catch let error as SubspaceError { - if case .cannotIncrementKey = error { - // Expected - } else { - Issue.record("Wrong error case") - } + #expect(error.code == .cannotIncrementKey) } catch { Issue.record("Wrong error type: \(error)") } @@ -231,11 +227,7 @@ struct SubspaceTests { _ = try subspace.prefixRange() Issue.record("Should throw error for empty prefix") } catch let error as SubspaceError { - if case .cannotIncrementKey = error { - // Expected - } else { - Issue.record("Wrong error case") - } + #expect(error.code == .cannotIncrementKey) } catch { Issue.record("Wrong error type: \(error)") } @@ -299,7 +291,7 @@ struct SubspaceTests { @Test("prefixRange for tuple-encoded data") func prefixRangeTupleEncoded() throws { // Tuple-encoded prefix (no trailing 0xFF possible) - let subspace = Subspace(rootPrefix: "users") + let subspace = Subspace(prefix: Tuple("users").pack()) let (begin, end) = try subspace.prefixRange() // Begin is the tuple-encoded prefix From 441f93f61f81b3ab84060da3a5063c90bc1244a8 Mon Sep 17 00:00:00 2001 From: 1amageek Date: Sat, 8 Nov 2025 13:32:28 +0900 Subject: [PATCH 7/8] Improve Tuple type safety and align naming with other language bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Use TupleTypeCode enum in switch statements for type safety Convert raw UInt8 type code to TupleTypeCode enum before switching. This provides compile-time safety and makes the code more maintainable. 2. Rename encode/decode to pack/unpack for cross-language consistency All official FoundationDB bindings use pack/unpack terminology: - Python: tuple.pack() / tuple.unpack() - Java: Tuple.pack() / Tuple.fromBytes() - Go: Tuple.Pack() / Tuple.Unpack() Using pack/unpack: - Matches established FDB terminology ("tuple packing") - Avoids confusion with Swift's Codable encode/decode - Improves searchability and documentation consistency API changes: - Tuple.encode() → Tuple.pack() - Tuple.decode(from:) → Tuple.unpack(from:) - Subspace methods already used pack/unpack (no change) Addresses: glbrntt review comment #9, MMcM review comment #2 --- Sources/FoundationDB/Tuple.swift | 69 +++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/Sources/FoundationDB/Tuple.swift b/Sources/FoundationDB/Tuple.swift index 6df1256..9cbc6d2 100644 --- a/Sources/FoundationDB/Tuple.swift +++ b/Sources/FoundationDB/Tuple.swift @@ -89,7 +89,13 @@ public struct Tuple: Sendable, Hashable, Equatable { return elements.count } - public func encode() -> FDB.Bytes { + /// Pack tuple elements into a byte array + /// + /// Encodes all tuple elements into a single byte array using the FoundationDB + /// tuple encoding format, which preserves lexicographic ordering. + /// + /// - Returns: Packed byte representation of the tuple + public func pack() -> FDB.Bytes { var result = FDB.Bytes() for element in elements { result.append(contentsOf: element.encodeTuple()) @@ -97,48 +103,71 @@ public struct Tuple: Sendable, Hashable, Equatable { return result } - public static func decode(from bytes: FDB.Bytes) throws -> [any TupleElement] { + /// Unpack tuple elements from a byte array + /// + /// Decodes a byte array into tuple elements using the FoundationDB + /// tuple encoding format. + /// + /// - Parameter bytes: Packed byte representation + /// - Returns: Array of decoded tuple elements + /// - Throws: `TupleError.invalidDecoding` if bytes cannot be decoded + public static func unpack(from bytes: FDB.Bytes) throws -> [any TupleElement] { var elements: [any TupleElement] = [] var offset = 0 while offset < bytes.count { - let typeCode = bytes[offset] + let rawTypeCode = bytes[offset] offset += 1 + // Handle intZero separately + if rawTypeCode == TupleTypeCode.intZero.rawValue { + elements.append(0) + continue + } + + // Handle integer range separately since it spans multiple raw values + // Note: Int64.decodeTuple reads bytes[offset-1] to get the type code, + // so offset should already be pointing to the byte after the type code + if rawTypeCode >= TupleTypeCode.negativeIntStart.rawValue && rawTypeCode <= TupleTypeCode.positiveIntEnd.rawValue { + let element = try Int64.decodeTuple(from: bytes, at: &offset) + elements.append(element) + continue + } + + guard let typeCode = TupleTypeCode(rawValue: rawTypeCode) else { + throw TupleError.invalidDecoding("Unknown type code: \(rawTypeCode)") + } + switch typeCode { - case TupleTypeCode.null.rawValue: + case .null: elements.append(TupleNil()) - case TupleTypeCode.bytes.rawValue: + case .bytes: let element = try FDB.Bytes.decodeTuple(from: bytes, at: &offset) elements.append(element) - case TupleTypeCode.string.rawValue: + case .string: let element = try String.decodeTuple(from: bytes, at: &offset) elements.append(element) - case TupleTypeCode.boolFalse.rawValue, TupleTypeCode.boolTrue.rawValue: + case .boolFalse, .boolTrue: let element = try Bool.decodeTuple(from: bytes, at: &offset) elements.append(element) - case TupleTypeCode.float.rawValue: + case .float: let element = try Float.decodeTuple(from: bytes, at: &offset) elements.append(element) - case TupleTypeCode.double.rawValue: + case .double: let element = try Double.decodeTuple(from: bytes, at: &offset) elements.append(element) - case TupleTypeCode.uuid.rawValue: + case .uuid: let element = try UUID.decodeTuple(from: bytes, at: &offset) elements.append(element) - case TupleTypeCode.intZero.rawValue: - elements.append(0) - case TupleTypeCode.negativeIntStart.rawValue ... TupleTypeCode.positiveIntEnd.rawValue: - let element = try Int64.decodeTuple(from: bytes, at: &offset) - elements.append(element) - case TupleTypeCode.nested.rawValue: + case .nested: let element = try Tuple.decodeTuple(from: bytes, at: &offset) elements.append(element) - case TupleTypeCode.versionstamp.rawValue: + case .versionstamp: let element = try Versionstamp.decodeTuple(from: bytes, at: &offset) elements.append(element) - default: - throw TupleError.invalidDecoding("Unknown type code: \(typeCode)") + case .intZero, .negativeIntStart, .positiveIntEnd: + // Already handled above + throw TupleError.invalidDecoding("Unexpected type code: \(typeCode)") } } @@ -508,7 +537,7 @@ extension Tuple: TupleElement { } } - let nestedElements = try Tuple.decode(from: nestedBytes) + let nestedElements = try Tuple.unpack(from: nestedBytes) return Tuple(nestedElements) } } From 517e1b612f1076f3c0b8f78935661b3f963a10bb Mon Sep 17 00:00:00 2001 From: 1amageek Date: Sat, 8 Nov 2025 13:32:58 +0900 Subject: [PATCH 8/8] Update tests to use new pack/unpack API and struct-based errors Update all tests to reflect API changes: - Use Tuple.pack() / Tuple.unpack() instead of encode/decode - Use Tuple().pack() for Subspace prefix initialization - Update SubspaceError checks to use struct-based error with .code - Update StackTester to use new API All 150 tests pass successfully. --- .../FoundationDBTupleTests.swift | 12 +++++------ .../FoundationDBTests/VersionstampTests.swift | 20 +++++++++---------- .../Sources/StackTester/StackTester.swift | 18 ++++++++--------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/Tests/FoundationDBTests/FoundationDBTupleTests.swift b/Tests/FoundationDBTests/FoundationDBTupleTests.swift index 12d3198..aa82b28 100644 --- a/Tests/FoundationDBTests/FoundationDBTupleTests.swift +++ b/Tests/FoundationDBTests/FoundationDBTupleTests.swift @@ -250,8 +250,8 @@ func tupleNested() throws { let innerTuple = Tuple("hello", 42, true) let outerTuple = Tuple("outer", innerTuple, "end") - let encoded = outerTuple.encode() - let decoded = try Tuple.decode(from: encoded) + let encoded = outerTuple.pack() + let decoded = try Tuple.unpack(from: encoded) #expect(decoded.count == 3, "Should have 3 elements") @@ -270,8 +270,8 @@ func tupleNested() throws { func tupleWithZero() throws { let tuple = Tuple("hello", 0, "foo") - let encoded = tuple.encode() - let decoded = try Tuple.decode(from: encoded) + let encoded = tuple.pack() + let decoded = try Tuple.unpack(from: encoded) #expect(decoded.count == 3, "Should have 3 elements") let decodedString1 = decoded[0] as? String @@ -290,8 +290,8 @@ func tupleNestedDeep() throws { let level2 = Tuple("middle", level3) let level1 = Tuple("top", level2, "bottom") - let encoded = level1.encode() - let decoded = try Tuple.decode(from: encoded) + let encoded = level1.pack() + let decoded = try Tuple.unpack(from: encoded) #expect(decoded.count == 3, "Top level should have 3 elements") diff --git a/Tests/FoundationDBTests/VersionstampTests.swift b/Tests/FoundationDBTests/VersionstampTests.swift index 96ea66e..6c7924b 100644 --- a/Tests/FoundationDBTests/VersionstampTests.swift +++ b/Tests/FoundationDBTests/VersionstampTests.swift @@ -294,10 +294,10 @@ struct VersionstampTests { let tuple = Tuple("prefix", original, "suffix") // Encode - let encoded = tuple.encode() + let encoded = tuple.pack() - // Decode through Tuple.decode() - let decoded = try Tuple.decode(from: encoded) + // Decode through Tuple.unpack() + let decoded = try Tuple.unpack(from: encoded) #expect(decoded.count == 3) #expect((decoded[0] as? String) == "prefix") @@ -311,10 +311,10 @@ struct VersionstampTests { let tuple = Tuple(original) // Encode - let encoded = tuple.encode() + let encoded = tuple.pack() // Decode - let decoded = try Tuple.decode(from: encoded) + let decoded = try Tuple.unpack(from: encoded) #expect(decoded.count == 1) let decodedVS = decoded[0] as? Versionstamp @@ -337,8 +337,8 @@ struct VersionstampTests { [UInt8]([0x01, 0x02, 0x03]) ) - let encoded = tuple.encode() - let decoded = try Tuple.decode(from: encoded) + let encoded = tuple.pack() + let decoded = try Tuple.unpack(from: encoded) #expect(decoded.count == 5) #expect((decoded[0] as? String) == "string") @@ -356,7 +356,7 @@ struct VersionstampTests { ] do { - _ = try Tuple.decode(from: encoded) + _ = try Tuple.unpack(from: encoded) Issue.record("Should throw error for insufficient bytes") } catch { // Expected - should throw TupleError.invalidEncoding @@ -373,8 +373,8 @@ struct VersionstampTests { ) let tuple = Tuple(vs1, "middle", vs2) - let encoded = tuple.encode() - let decoded = try Tuple.decode(from: encoded) + let encoded = tuple.pack() + let decoded = try Tuple.unpack(from: encoded) #expect(decoded.count == 3) #expect((decoded[0] as? Versionstamp) == vs1) diff --git a/Tests/StackTester/Sources/StackTester/StackTester.swift b/Tests/StackTester/Sources/StackTester/StackTester.swift index c47f7b4..3256db2 100644 --- a/Tests/StackTester/Sources/StackTester/StackTester.swift +++ b/Tests/StackTester/Sources/StackTester/StackTester.swift @@ -113,7 +113,7 @@ class StackMachine { } } let tuple = Tuple(kvs) - store(idx, tuple.encode()) + store(idx, tuple.pack()) } // Helper method to filter key results with prefix @@ -134,7 +134,7 @@ class StackMachine { // Create key: prefix + tuple(stackIndex, entry.idx) let keyTuple = Tuple([Int64(stackIndex), Int64(entry.idx)]) var key = prefix - key.append(contentsOf: keyTuple.encode()) + key.append(contentsOf: keyTuple.pack()) // Pack value as a tuple (matching Python/Go behavior) let valueTuple: Tuple @@ -148,7 +148,7 @@ class StackMachine { valueTuple = Tuple([Array("UNKNOWN_ITEM".utf8)]) } - var packedValue = valueTuple.encode() + var packedValue = valueTuple.pack() // Limit value size to 40000 bytes let maxSize = 40000 @@ -529,7 +529,7 @@ class StackMachine { } let tuple = Tuple(elements.reversed()) // Reverse because we popped in reverse order - store(idx, tuple.encode()) + store(idx, tuple.pack()) case "TUPLE_PACK_WITH_VERSIONSTAMP": // Python order: prefix, count, items @@ -554,13 +554,13 @@ class StackMachine { // For now, treat like regular TUPLE_PACK since versionstamp handling is complex let tuple = Tuple(elements.reversed()) var result = prefix - result.append(contentsOf: tuple.encode()) + result.append(contentsOf: tuple.pack()) store(idx, result) case "TUPLE_UNPACK": let encodedTuple = waitAndPop().item as! [UInt8] do { - let elements = try Tuple.decode(from: encodedTuple) + let elements = try Tuple.unpack(from: encodedTuple) for element in elements.reversed() { // Reverse to match stack order if let bytes = element as? [UInt8] { store(idx, bytes) @@ -606,7 +606,7 @@ class StackMachine { } let tuple = Tuple(elements.reversed()) - let prefix = tuple.encode() + let prefix = tuple.pack() // Create range: prefix to prefix + [0xFF] var endKey = prefix @@ -677,7 +677,7 @@ class StackMachine { let instructions = try await database.withTransaction { transaction -> [(key: [UInt8], value: [UInt8])] in // Create range starting with our prefix let prefixTuple = Tuple([prefix]) - let beginKey = prefixTuple.encode() + let beginKey = prefixTuple.pack() let endKey = beginKey + [0xFF] // Simple range end let result = try await transaction.getRangeNative( @@ -697,7 +697,7 @@ class StackMachine { // Process each instruction for (i, (_, value)) in instructions.enumerated() { // Unpack the instruction tuple from the value - let elements = try Tuple.decode(from: value) + let elements = try Tuple.unpack(from: value) // Convert tuple elements to array for processing var instruction: [Any] = []