Module: Philiprehberger::CompactId

Defined in:
lib/philiprehberger/compact_id.rb,
lib/philiprehberger/compact_id/version.rb

Defined Under Namespace

Classes: Error

Constant Summary collapse

BASE58_ALPHABET =
'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
BASE62_ALPHABET =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
UUID_PATTERN =
/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
PREFIX_PATTERN =
/\A[a-zA-Z0-9]+\z/
VERSION =
'0.5.0'

Class Method Summary collapse

Class Method Details

.base58_to_base62(str) ⇒ String

Convert a Base58 string directly to Base62

Parameters:

  • str (String)

    Base58-encoded string

Returns:

  • (String)

    Base62-encoded string

Raises:

  • (Error)

    if the string contains invalid characters



127
128
129
130
# File 'lib/philiprehberger/compact_id.rb', line 127

def self.base58_to_base62(str)
  num = decode_str(str, BASE58_ALPHABET)
  encode(num, BASE62_ALPHABET, 22)
end

.base62_to_base58(str) ⇒ String

Convert a Base62 string directly to Base58

Parameters:

  • str (String)

    Base62-encoded string

Returns:

  • (String)

    Base58-encoded string

Raises:

  • (Error)

    if the string contains invalid characters



137
138
139
140
# File 'lib/philiprehberger/compact_id.rb', line 137

def self.base62_to_base58(str)
  num = decode_str(str, BASE62_ALPHABET)
  encode(num, BASE58_ALPHABET, 22)
end

.batch_generate(count, format: :base58) ⇒ Array<String>

Generate multiple compact IDs at once

Parameters:

  • count (Integer)

    number of IDs to generate

  • format (Symbol) (defaults to: :base58)

    :base58 or :base62

Returns:

  • (Array<String>)

    array of encoded UUIDs

Raises:

  • (Error)

    if format is invalid or count is not a positive integer



73
74
75
76
77
# File 'lib/philiprehberger/compact_id.rb', line 73

def self.batch_generate(count, format: :base58)
  raise Error, 'Count must be a positive integer' unless count.is_a?(Integer) && count.positive?

  Array.new(count) { generate(format) }
end

.batch_to_base58(uuids) ⇒ Array<String>

Bulk encode an array of UUIDs to Base58

Parameters:

  • uuids (Array<String>)

    array of UUIDs with dashes

Returns:

  • (Array<String>)

    array of Base58-encoded strings

Raises:

  • (Error)

    if any UUID format is invalid



105
106
107
108
109
# File 'lib/philiprehberger/compact_id.rb', line 105

def self.batch_to_base58(uuids)
  raise Error, 'Expected an Array of UUIDs' unless uuids.is_a?(Array)

  uuids.map { |uuid| to_base58(uuid) }
end

.batch_to_base62(uuids) ⇒ Array<String>

Bulk encode an array of UUIDs to Base62

Parameters:

  • uuids (Array<String>)

    array of UUIDs with dashes

Returns:

  • (Array<String>)

    array of Base62-encoded strings

Raises:

  • (Error)

    if any UUID format is invalid



116
117
118
119
120
# File 'lib/philiprehberger/compact_id.rb', line 116

def self.batch_to_base62(uuids)
  raise Error, 'Expected an Array of UUIDs' unless uuids.is_a?(Array)

  uuids.map { |uuid| to_base62(uuid) }
end

.decode(str) ⇒ String

Auto-detect format and decode to UUID

Parameters:

  • str (String)

    Base58 or Base62 encoded string

Returns:

  • (String)

    UUID with dashes

Raises:

  • (Error)

    if the format cannot be detected or string is invalid



165
166
167
168
169
170
171
172
# File 'lib/philiprehberger/compact_id.rb', line 165

def self.decode(str)
  detected = format?(str)
  case detected
  when :base58 then from_base58(str)
  when :base62 then from_base62(str)
  else raise Error, "Unable to detect format of: #{str}"
  end
end

.decode_safe(str, expected_format:) ⇒ String

Safer variant of ‘.decode` that only succeeds when the detected encoding matches the expected format. Use when consuming IDs that should be a specific encoding to prevent silent confusion between Base58 and Base62.

Parameters:

  • str (String)
  • expected_format (Symbol)

    :base58 or :base62

Returns:

  • (String)

    decoded UUID

Raises:

  • (Error)

    if the detected format does not match

  • (ArgumentError)

    if expected_format is not a known format



183
184
185
186
187
188
189
190
191
192
# File 'lib/philiprehberger/compact_id.rb', line 183

def self.decode_safe(str, expected_format:)
  unless %i[base58 base62].include?(expected_format)
    raise ArgumentError, "unknown expected_format: #{expected_format.inspect}"
  end

  detected = format?(str)
  raise Error, "expected #{expected_format} but got #{detected}" unless detected == expected_format

  expected_format == :base58 ? from_base58(str) : from_base62(str)
end

.format?(str) ⇒ Symbol

Detect the format of an encoded string

Parameters:

  • str (String)

    encoded string to check

Returns:

  • (Symbol)

    :base58, :base62, or :unknown



146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/philiprehberger/compact_id.rb', line 146

def self.format?(str)
  return :unknown unless str.is_a?(String) && !str.empty?

  # Base58 is a strict subset of Base62, so check Base58 first.
  # A string is only :base62 if it contains characters outside the Base58 alphabet.
  if valid_base58?(str)
    :base58
  elsif valid_base62?(str)
    :base62
  else
    :unknown
  end
end

.from_base58(str) ⇒ String

Decode a Base58 string back to a UUID

Parameters:

  • str (String)

    Base58-encoded string

Returns:

  • (String)

    UUID with dashes

Raises:

  • (Error)

    if the string contains invalid characters



39
40
41
# File 'lib/philiprehberger/compact_id.rb', line 39

def self.from_base58(str)
  int_to_uuid(decode_str(str, BASE58_ALPHABET))
end

.from_base62(str) ⇒ String

Decode a Base62 string back to a UUID

Parameters:

  • str (String)

    Base62-encoded string

Returns:

  • (String)

    UUID with dashes

Raises:

  • (Error)

    if the string contains invalid characters



48
49
50
# File 'lib/philiprehberger/compact_id.rb', line 48

def self.from_base62(str)
  int_to_uuid(decode_str(str, BASE62_ALPHABET))
end

.generate(format = :base58) ⇒ String

Generate a new UUID and encode it

Parameters:

  • format (Symbol) (defaults to: :base58)

    :base58 or :base62

Returns:

  • (String)

    encoded UUID

Raises:

  • (Error)

    if format is invalid



57
58
59
60
61
62
63
64
65
# File 'lib/philiprehberger/compact_id.rb', line 57

def self.generate(format = :base58)
  uuid = SecureRandom.uuid

  case format
  when :base58 then to_base58(uuid)
  when :base62 then to_base62(uuid)
  else raise Error, "Unknown format: #{format}. Use :base58 or :base62"
  end
end

.generate_prefixed(prefix, format: :base58, separator: '_') ⇒ String

Generate a compact ID with a type prefix

Parameters:

  • prefix (String)

    type prefix (e.g. ‘usr’, ‘ord’, ‘txn’)

  • format (Symbol) (defaults to: :base58)

    :base58 or :base62

  • separator (String) (defaults to: '_')

    character between prefix and ID (default ‘_’)

Returns:

  • (String)

    prefixed compact ID (e.g. ‘usr_6fpBHktS7sqEUqhp4E2nE4’)

Raises:

  • (Error)

    if prefix or separator is invalid



201
202
203
204
205
# File 'lib/philiprehberger/compact_id.rb', line 201

def self.generate_prefixed(prefix, format: :base58, separator: '_')
  validate_prefix!(prefix)
  validate_separator!(separator)
  "#{prefix}#{separator}#{generate(format)}"
end

.parse_prefixed(str, separator: '_') ⇒ Hash

Parse a prefixed compact ID into its components

Parameters:

  • str (String)

    prefixed compact ID (e.g. ‘usr_6fpBHktS7sqEUqhp4E2nE4’)

  • separator (String) (defaults to: '_')

    character between prefix and ID (default ‘_’)

Returns:

  • (Hash)

    { prefix:, id:, uuid: }

Raises:

  • (Error)

    if the string has no separator or the ID cannot be decoded



213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/philiprehberger/compact_id.rb', line 213

def self.parse_prefixed(str, separator: '_')
  raise Error, 'Expected a non-empty String' unless str.is_a?(String) && !str.empty?

  parts = str.split(separator, 2)
  raise Error, "No separator '#{separator}' found in: #{str}" if parts.length < 2 || parts[1].empty?

  prefix = parts[0]
  id = parts[1]
  uuid = decode(id)

  { prefix: prefix, id: id, uuid: uuid }
end

.sortable_id(format: :base62) ⇒ String

Generate a time-sortable ID by combining a millisecond timestamp with random bytes

IDs generated later will always sort lexicographically after earlier ones. The ID is composed of the current time in milliseconds (high bits) and 64 bits of randomness (low bits), encoded in the specified format.

Parameters:

  • format (Symbol) (defaults to: :base62)

    :base58 or :base62

Returns:

  • (String)

    time-sortable encoded ID

Raises:

  • (Error)

    if format is unsupported



88
89
90
91
92
93
94
95
96
97
98
# File 'lib/philiprehberger/compact_id.rb', line 88

def self.sortable_id(format: :base62)
  ms = (Time.now.to_f * 1000).to_i
  random = SecureRandom.random_number(2**64)
  combined = (ms << 64) | random

  case format
  when :base58 then encode(combined, BASE58_ALPHABET, 0)
  when :base62 then encode(combined, BASE62_ALPHABET, 0)
  else raise Error, "unsupported format: #{format}"
  end
end

.to_base58(uuid) ⇒ String

Encode a UUID as a Base58 string

Parameters:

  • uuid (String)

    UUID with dashes (e.g. “550e8400-e29b-41d4-a716-446655440000”)

Returns:

  • (String)

    Base58-encoded string

Raises:

  • (Error)

    if the UUID format is invalid



19
20
21
22
# File 'lib/philiprehberger/compact_id.rb', line 19

def self.to_base58(uuid)
  validate_uuid!(uuid)
  encode(uuid_to_int(uuid), BASE58_ALPHABET, 22)
end

.to_base62(uuid) ⇒ String

Encode a UUID as a Base62 string

Parameters:

  • uuid (String)

    UUID with dashes

Returns:

  • (String)

    Base62-encoded string

Raises:

  • (Error)

    if the UUID format is invalid



29
30
31
32
# File 'lib/philiprehberger/compact_id.rb', line 29

def self.to_base62(uuid)
  validate_uuid!(uuid)
  encode(uuid_to_int(uuid), BASE62_ALPHABET, 22)
end

.valid_base58?(str) ⇒ Boolean

Check if a string is valid Base58

Parameters:

  • str (String)

    string to validate

Returns:

  • (Boolean)


230
231
232
233
234
# File 'lib/philiprehberger/compact_id.rb', line 230

def self.valid_base58?(str)
  return false unless str.is_a?(String) && !str.empty?

  str.each_char.all? { |c| BASE58_ALPHABET.include?(c) }
end

.valid_base62?(str) ⇒ Boolean

Check if a string is valid Base62

Parameters:

  • str (String)

    string to validate

Returns:

  • (Boolean)


240
241
242
243
244
# File 'lib/philiprehberger/compact_id.rb', line 240

def self.valid_base62?(str)
  return false unless str.is_a?(String) && !str.empty?

  str.each_char.all? { |c| BASE62_ALPHABET.include?(c) }
end