diff --git a/Sources/FoundationDB/Subspace.swift b/Sources/FoundationDB/Subspace.swift new file mode 100644 index 0000000..18882d5 --- /dev/null +++ b/Sources/FoundationDB/Subspace.swift @@ -0,0 +1,397 @@ +/* + * 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 with tuple-encoded prefix +/// let userSpace = Subspace(prefix: Tuple("users").pack()) +/// +/// // 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 + /// + /// 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. + /// + /// **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 `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. + /// + /// - Parameter prefix: The binary prefix + /// + /// - SeeAlso: https://apple.github.io/foundationdb/developer-guide.html#subspaces + public init(prefix: FDB.Bytes) { + self.prefix = prefix + } + + // 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(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.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 + + /// 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][packed tuple]` + public func pack(_ tuple: Tuple) -> FDB.Bytes { + return prefix + tuple.pack() + } + + /// 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 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.unpack(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(prefix: Tuple("users").pack()) + /// let key = userSpace.pack(Tuple(12345)) + /// print(userSpace.contains(key)) // true + /// + /// let otherKey = Subspace(prefix: Tuple("posts").pack()).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(prefix: Tuple("users").pack()) + /// 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(prefix: Tuple("users").pack()) + /// // 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 & Hashable +// Compiler-synthesized implementations + +extension Subspace: Equatable {} +extension Subspace: Hashable {} + +// 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 struct SubspaceError: Error { + /// Error code identifying the type of error + public let code: Code + + /// Human-readable error message + public let message: String + + /// Error codes for Subspace operations + public enum Code: Sendable { + /// The key cannot be incremented because it contains only 0xFF bytes + case cannotIncrementKey + } + + /// 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 + } + + /// The key cannot be incremented because it contains only 0xFF bytes + public static func cannotIncrementKey(_ message: String) -> SubspaceError { + return SubspaceError(code: .cannotIncrementKey, message: message) + } +} + +// 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.strinc()` for the underlying algorithm + public func prefixRange() throws -> (begin: FDB.Bytes, end: FDB.Bytes) { + return (prefix, try FDB.strinc(prefix)) + } +} diff --git a/Sources/FoundationDB/Tuple+Versionstamp.swift b/Sources/FoundationDB/Tuple+Versionstamp.swift new file mode 100644 index 0000000..99a10c2 --- /dev/null +++ b/Sources/FoundationDB/Tuple+Versionstamp.swift @@ -0,0 +1,205 @@ +/* + * 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 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 + /// 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 + // Currently defaults to API 520+ behavior (4-byte offset) + // API < 520 used 2-byte offset, but is no longer supported + + // API >= 520: Use 4-byte offset (uint32, little-endian) + guard position <= UInt32.max else { + throw TupleError.invalidEncoding + } + + let offset = UInt32(position) + withUnsafeBytes(of: offset.littleEndian) { packed.append(contentsOf: $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.count { element in + if let vs = element as? Versionstamp { + return !vs.isComplete + } + return false + } + } + + /// Validate tuple for use with packWithVersionstamp() + /// - Throws: `TupleError.invalidEncoding` if validation fails + public func validateForVersionstamp() throws { + let incompleteCount = countIncompleteVersionstamps() + + if incompleteCount != 1 { + 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..9cbc6d2 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 @@ -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,45 +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) - default: - throw TupleError.invalidDecoding("Unknown type code: \(typeCode)") + case .versionstamp: + let element = try Versionstamp.decodeTuple(from: bytes, at: &offset) + elements.append(element) + case .intZero, .negativeIntStart, .positiveIntEnd: + // Already handled above + throw TupleError.invalidDecoding("Unexpected type code: \(typeCode)") } } @@ -505,7 +537,7 @@ extension Tuple: TupleElement { } } - let nestedElements = try Tuple.decode(from: nestedBytes) + let nestedElements = try Tuple.unpack(from: nestedBytes) return Tuple(nestedElements) } } 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/Sources/FoundationDB/Versionstamp.swift b/Sources/FoundationDB/Versionstamp.swift new file mode 100644 index 0000000..605f526 --- /dev/null +++ b/Sources/FoundationDB/Versionstamp.swift @@ -0,0 +1,187 @@ +/* + * 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 + withUnsafeBytes(of: userVersion.bigEndian) { bytes.append(contentsOf: $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 + // Compiler-synthesized implementations + + // 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/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/StringIncrementTests.swift b/Tests/FoundationDBTests/StringIncrementTests.swift new file mode 100644 index 0000000..fe2b86a --- /dev/null +++ b/Tests/FoundationDBTests/StringIncrementTests.swift @@ -0,0 +1,184 @@ +/* + * 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 FDB.strinc(input) + #expect(result == [0x01, 0x02, 0x04]) + } + + @Test("strinc increments single byte") + func strincSingleByte() throws { + let input: FDB.Bytes = [0x42] + 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 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 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 FDB.strinc(input) + #expect(result == [0x01, 0x03]) + } + + @Test("strinc handles 0xFE correctly") + func strinc0xFE() throws { + let input: FDB.Bytes = [0x01, 0xFE] + 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 FDB.strinc(input) + #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 FDB.strinc(input) + Issue.record("Should throw error for all-0xFF input") + } catch let error as SubspaceError { + #expect(error.code == .cannotIncrementKey) + #expect(error.message.contains("0xFF")) + } catch { + Issue.record("Wrong error type: \(error)") + } + } + + @Test("strinc throws error on empty array") + func strincEmpty() { + let input: FDB.Bytes = [] + + do { + _ = try FDB.strinc(input) + Issue.record("Should throw error for empty input") + } catch let error as SubspaceError { + #expect(error.code == .cannotIncrementKey) + #expect(error.message.contains("0xFF")) + } catch { + Issue.record("Wrong error type: \(error)") + } + } + + @Test("strinc throws error on single 0xFF") + func strincSingle0xFF() { + let input: FDB.Bytes = [0xFF] + + do { + _ = try FDB.strinc(input) + Issue.record("Should throw error for single 0xFF") + } catch let error as SubspaceError { + #expect(error.code == .cannotIncrementKey) + } 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 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: " "))") + } + } + + @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 FDB.strinc(input) + #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 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 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 FDB.strinc(input) + #expect(result == [0xFF]) + } +} diff --git a/Tests/FoundationDBTests/SubspaceTests.swift b/Tests/FoundationDBTests/SubspaceTests.swift new file mode 100644 index 0000000..a414f15 --- /dev/null +++ b/Tests/FoundationDBTests/SubspaceTests.swift @@ -0,0 +1,304 @@ +/* + * 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(prefix: Tuple("test").pack()) + #expect(!subspace.prefix.isEmpty) + } + + @Test("Nested subspace prefix includes root prefix") + func nestedSubspace() { + let root = Subspace(prefix: Tuple("test").pack()) + 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(prefix: Tuple("test").pack()) + 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(prefix: Tuple("test").pack()) + 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(prefix: Tuple("test_special_chars").pack()) + 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]) + #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(prefix: Tuple("").pack()) + let (begin, end) = subspace.range() + + // Prefix should be tuple-encoded empty string + let encodedEmpty = Tuple("").pack() + #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(prefix: Tuple("test").pack()) + let tuple = Tuple("key") + let key = subspace.pack(tuple) + + #expect(subspace.contains(key)) + + let otherSubspace = Subspace(prefix: Tuple("other").pack()) + #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 { + #expect(error.code == .cannotIncrementKey) + } 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 { + #expect(error.code == .cannotIncrementKey) + } 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(prefix: Tuple("users").pack()) + 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..6c7924b --- /dev/null +++ b/Tests/FoundationDBTests/VersionstampTests.swift @@ -0,0 +1,416 @@ +/* + * 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 + #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 + // (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 + #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 + #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: - 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.pack() + + // Decode through Tuple.unpack() + let decoded = try Tuple.unpack(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.pack() + + // Decode + let decoded = try Tuple.unpack(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.pack() + let decoded = try Tuple.unpack(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.unpack(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.pack() + let decoded = try Tuple.unpack(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 + + /* + @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) + } + */ +} 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] = []