@@ -129,6 +129,12 @@ final case class HMap[M <: Tuple](private val m: TreeMap[Array[Byte], Any] =
129129 // Lower bound: [length][prefix] - all keys with this prefix start here
130130 val lowerBound = Array (prefixLength.toByte) ++ prefixBytes
131131
132+ val upperBound = computePrefixUpperBound(lowerBound)
133+
134+ (lowerBound, upperBound)
135+
136+ // Helper: computes the lexicographic upper bound for a prefix
137+ private [raft] def computePrefixUpperBound (prefixBytes : Array [Byte ]): Array [Byte ] =
132138 // Upper bound: Increment prefix bytes with carry propagation
133139 // Start from rightmost byte, find first byte that isn't 0xFF, increment it, zero rest
134140 val upperPrefixBytes = prefixBytes.clone()
@@ -137,24 +143,27 @@ final case class HMap[M <: Tuple](private val m: TreeMap[Array[Byte], Any] =
137143
138144 while carry && i >= 0 do
139145 if upperPrefixBytes(i) != 0xff .toByte then
140- // Found a byte that is already 0xFF, will zero it and propagate carry (continue)
141- if upperPrefixBytes(i) == 0xff .toByte then
142- upperPrefixBytes(i) = 0 .toByte
143- else
144- // Found a byte that is not 0xFF, increment it and stop carry
145- upperPrefixBytes(i) = (upperPrefixBytes(i) + 1 ).toByte
146- carry = false
146+ // Found a byte that is not 0xFF, increment it and stop carry
147+ upperPrefixBytes(i) = (upperPrefixBytes(i) + 1 ).toByte
148+ carry = false
149+ else
150+ // Found a byte that is 0xFF, will zero it and propagate carry (continue)
151+ upperPrefixBytes(i) = 0 .toByte
147152 i -= 1
148153
149- val upperBound =
150- if carry then
151- // All bytes were 0xFF - use next length value with empty prefix
152- // This is lexicographically after all keys with current prefix length
153- Array ((prefixLength + 1 ).toByte)
154- else
155- Array (prefixLength.toByte) ++ upperPrefixBytes
154+ if carry then
155+ // All bytes were 0xFF, append zero to the string
156+ Array .fill(prefixBytes.length)(0xff .toByte) ++ Array (0 .toByte)
157+ else
158+ // Truncate any trailing zeros from the upperPrefixBytes for minimal upper bound representation
159+ var end = upperPrefixBytes.length
160+ while end > 0 && upperPrefixBytes(end - 1 ) == 0 .toByte do
161+ end -= 1
156162
157- (lowerBound, upperBound)
163+ if end != upperPrefixBytes.length then
164+ upperPrefixBytes.slice(0 , end)
165+ else
166+ upperPrefixBytes
158167
159168 /** Retrieve a value for the given prefix and key.
160169 *
@@ -342,6 +351,100 @@ final case class HMap[M <: Tuple](private val m: TreeMap[Array[Byte], Any] =
342351 (logicalKey, v.asInstanceOf [ValueAt [M , P ]])
343352 }
344353
354+ /** Returns an iterator over (key, value) pairs for all entries whose compound key starts with the specified prefix
355+ * and partial key, but only within the scope of the first component of the compound key.
356+ *
357+ * This is intended for use with compound keys, where you want to fetch all entries grouped by the first part of the
358+ * compound key. The user specifies the first part of the key, and a "zero" value (usually empty string or zero-like
359+ * value) for the second component. The method returns all keys beginning with that compound key prefix, but only
360+ * within the same first key.
361+ *
362+ * For example, for compound keys like (namespace, userId), you can fetch all keys for a given namespace:
363+ * hmap.rangeByCompoundKeyPrefix["users"]((namespace, "")) This will return all user records within the `namespace`,
364+ * regardless of the second component value.
365+ *
366+ * IMPORTANT: For this method to work correctly, the `KeyLike` implementation for the compound key type must encode
367+ * only the leading component(s) in the byte array when the trailing ("zero") component is empty. That is, when
368+ * encoding a partial/compound key like (namespace, ""), the encoder must omit any length prefix or bytes for the
369+ * "zero"/empty part — the resulting byte array must end after the first, non-empty component. Do NOT emit an
370+ * explicit "length = 0" for the empty tail component.
371+ *
372+ * For decoding, the `KeyLike` instance should interpret a missing (truncated) trailing component in the byte array
373+ * as the "zero" value (such as empty string, 0, or Nil), i.e., treat the absence of those bytes as an empty value.
374+ *
375+ * This is required because the range calculation increments the bytes length for the entire prefix (including the
376+ * first key component). If the zero-part is ever encoded explicitly, it will instead increment that rather than just
377+ * the first component, breaking correct grouping/iteration.
378+ *
379+ * Example: KeyLike instance for (String, String) that omits the second component if empty:
380+ * {{{
381+ * given KeyLike[(String, String)] with
382+ * def asBytes(key: (String, String)): Array[Byte] =
383+ * val (first, second) = key
384+ * val firstBytes = first.getBytes(StandardCharsets.UTF_8)
385+ * if second.isEmpty then
386+ * // Only encode the first component, omit the second part entirely
387+ * Array(firstBytes.length.toByte) ++ firstBytes
388+ * else
389+ * val secondBytes = second.getBytes(StandardCharsets.UTF_8)
390+ * Array(firstBytes.length.toByte) ++ firstBytes ++ Array(secondBytes.length.toByte) ++ secondBytes
391+ *
392+ * def fromBytes(bytes: Array[Byte]): (String, String) =
393+ * // Decode first component
394+ * val len1 = bytes(0) & 0xff
395+ * val first = new String(bytes.slice(1, 1 + len1), StandardCharsets.UTF_8)
396+ * // If there are no more bytes, treat second as ""
397+ * if bytes.length == 1 + len1 then (first, "")
398+ * else
399+ * val len2Pos = 1 + len1
400+ * val len2 = bytes(len2Pos) & 0xff
401+ * val second = new String(bytes.slice(len2Pos + 1, len2Pos + 1 + len2), StandardCharsets.UTF_8)
402+ * (first, second)
403+ * }}}
404+ *
405+ * @tparam P
406+ * The prefix (must be present in the schema)
407+ * @param partial
408+ * The partial (compound) key, where the first component is provided and the trailing component is "zero" (empty
409+ * string, 0, Nil, etc. depending on how KeyLike[KeyAt[M, P]] is implemented, but crucially, should be OMITTED in
410+ * byte encoding)
411+ * @return
412+ * Iterator of (KeyType, ValueType) pairs matching the compound prefix
413+ *
414+ * @example
415+ * {{{
416+ * type Schema = ("users", (String, String), UserData) *: EmptyTuple
417+ * val hmap = HMap.empty[Schema]
418+ * .updated["users"](("region_1", "userA"), UserData(...))
419+ * .updated["users"](("region_1", "userB"), UserData(...))
420+ * .updated["users"](("region_2", "userC"), UserData(...))
421+ *
422+ * // To select all users in "region_1", you must implement KeyLike so that
423+ * // ("region_1", "") is encoded as just the bytes of "region_1" (no length/marker for the second field).
424+ * hmap.rangeByCompoundKeyPrefix["users"](("region_1", "")) // Returns both userA and userB
425+ * }}}
426+ *
427+ * NOTE: The name `rangeByCompoundKeyPrefix` is suggested as it clarifies the intent and scope. Alternative names:
428+ * `rangeByPrefixKey`, `rangeByPrimaryKey`.
429+ */
430+ def rangeByCompoundKeyPrefix [P <: String & Singleton : ValueOf ](partial : KeyAt [M , P ])(using
431+ c : Contains [M , P ],
432+ kl : KeyLike [KeyAt [M , P ]]
433+ ): Iterator [(KeyAt [M , P ], ValueAt [M , P ])] =
434+ val prefixBytes = valueOf[P ].getBytes(StandardCharsets .UTF_8 )
435+ val prefixLength = Array (prefixBytes.length.toByte)
436+ val prefixWithLength = prefixLength ++ prefixBytes
437+
438+ val fromKey = prefixWithLength ++ kl.asBytes(partial)
439+
440+ // Compute the lexicographic upper bound for the given partial key (only increment the prefix + first component of the compound key)
441+ val untilKey = computePrefixUpperBound(fromKey)
442+
443+ m.range(fromKey, untilKey).iterator.map { case (k, v) =>
444+ val logicalKey = extractKey(k)
445+ (logicalKey, v.asInstanceOf [ValueAt [M , P ]])
446+ }
447+
345448 /** Check if any entry in the specified prefix satisfies the predicate.
346449 *
347450 * Uses the underlying TreeMap's range.exists for efficient short-circuit evaluation. Stops as soon as it finds a
@@ -403,7 +506,7 @@ object HMap:
403506 *
404507 * Private since it's only used internally by HMap and is explicitly referenced where needed.
405508 */
406- private given byteArrayOrdering : Ordering [Array [Byte ]] =
509+ private [raft] given byteArrayOrdering : Ordering [Array [Byte ]] =
407510 Ordering .comparatorToOrdering(java.util.Arrays .compareUnsigned(_, _))
408511
409512 /** Create an empty HMap with the given schema.
0 commit comments