commit
7db26b8c44
|
|
@ -0,0 +1,9 @@
|
|||
root = true
|
||||
|
||||
[*.cr]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/docs/
|
||||
/lib/
|
||||
/bin/
|
||||
/.shards/
|
||||
*.dwarf
|
||||
|
||||
# Libraries don't need dependency lock
|
||||
# Dependencies will be locked in applications that use them
|
||||
/shard.lock
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
language: crystal
|
||||
|
||||
# Uncomment the following if you'd like Travis to run specs and check code formatting
|
||||
# script:
|
||||
# - crystal spec
|
||||
# - crystal tool format --check
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2019 LeMarsu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
# ulid
|
||||
|
||||
Universally Unique Lexicographically Sortable Identifier for Crystal
|
||||
|
||||

|
||||
|
||||
UUID can be suboptimal for many use-cases because:
|
||||
|
||||
- It isn't the most character efficient way of encoding 128 bits of randomness
|
||||
- UUID v1/v2 is impractical in many environments, as it requires access to a unique, stable MAC address
|
||||
- UUID v3/v5 requires a unique seed and produces randomly distributed IDs, which can cause fragmentation in many data structures
|
||||
- UUID v4 provides no other information than randomness which can cause fragmentation in many data structures
|
||||
|
||||
Instead, herein is proposed ULID:
|
||||
|
||||
```crystal
|
||||
require "ulid"
|
||||
|
||||
ulid = Ulid::ULID.new
|
||||
ulid.to_s # => "05PQXW3M5XRY8ERYNCGZWD2MCM"
|
||||
```
|
||||
|
||||
- 128-bit compatibility with UUID
|
||||
- 1.21e+24 unique ULIDs per millisecond
|
||||
- Lexicographically sortable!
|
||||
- Canonically encoded as a 26 character string, as opposed to the 36 character UUID
|
||||
- Uses Crockford's base32 for better efficiency and readability (5 bits per character)
|
||||
- Case insensitive
|
||||
- No special characters (URL safe)
|
||||
- Monotonic sort order (correctly detects and handles the same millisecond)
|
||||
|
||||
For more information, see [ulid specs][ulid-specs]
|
||||
|
||||
## Installation
|
||||
|
||||
1. Add the dependency to your `shard.yml`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
ulid:
|
||||
github: lemarsu/ulid
|
||||
version: 0.1.0
|
||||
```
|
||||
|
||||
2. Run `shards install`
|
||||
|
||||
## Usage
|
||||
|
||||
```crystal
|
||||
require "ulid"
|
||||
|
||||
ulid = Ulid::ULID.new
|
||||
ulid.to_s # => "05PQXW3M5XRY8ERYNCGZWD2MCM"
|
||||
|
||||
ulid2 = Ulid::ULID.new "05PQXW3M5XRY8ERYNCGZWD2MCM"
|
||||
ulid2 == ulid # => true
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork it (<https://github.com/lemarsu/ulid/fork>)
|
||||
2. Create your feature branch (`git checkout -b my-new-feature`)
|
||||
3. Commit your changes (`git commit -am 'Add some feature'`)
|
||||
4. Push to the branch (`git push origin my-new-feature`)
|
||||
5. Create a new Pull Request
|
||||
|
||||
## Contributors
|
||||
|
||||
- [LeMarsu](https://github.com/lemarsu) - creator and maintainer
|
||||
|
||||
[ulid-specs]: https://github.com/ulid/spec
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
name: ulid
|
||||
version: 0.1.0
|
||||
|
||||
authors:
|
||||
- LeMarsu <ch.ruelle at lemarsu.com>
|
||||
|
||||
crystal: 0.31.0
|
||||
|
||||
dependencies:
|
||||
base32:
|
||||
github: lemarsu/base32
|
||||
version: 0.1.1
|
||||
|
||||
development_dependencies:
|
||||
timecop:
|
||||
github: crystal-community/timecop.cr
|
||||
|
||||
license: MIT
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
require "spec"
|
||||
require "timecop"
|
||||
require "../src/ulid"
|
||||
|
||||
def array_slice(array : Array(UInt8)) : Bytes
|
||||
Bytes.new 16 { |i| array[i] }
|
||||
end
|
||||
|
||||
class NotRandom
|
||||
include Random
|
||||
|
||||
def initialize(@seed : UInt8)
|
||||
end
|
||||
|
||||
def next_u
|
||||
@seed += 1
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
require "./spec_helper"
|
||||
|
||||
describe Ulid::ULID do
|
||||
# TODO: Write tests
|
||||
|
||||
it "should initialize" do
|
||||
bytes = Bytes.new(16, 0u8)
|
||||
ulid = Ulid::ULID.new(bytes)
|
||||
ulid.bytes.should eq bytes
|
||||
end
|
||||
|
||||
it "should refuse slice of wrong size" do
|
||||
not_enough_bytes = Bytes.new(10, 0u8)
|
||||
too_many_bytes = Bytes.new(20, 0u8)
|
||||
expect_raises Ulid::ULID::Error, "Not enough bytes" do
|
||||
Ulid::ULID.new not_enough_bytes
|
||||
end
|
||||
expect_raises Ulid::ULID::Error, "Too many bytes" do
|
||||
Ulid::ULID.new too_many_bytes
|
||||
end
|
||||
end
|
||||
|
||||
it "should serialize a date" do
|
||||
time = Time.utc(2019, 9, 29, 10, 55, 22, nanosecond: 123_456_789)
|
||||
random = Bytes.new(10) { |i| (201 + i).to_u8 }
|
||||
ulid = Ulid::ULID.new(time, random)
|
||||
ulid.bytes.to_a.should eq [
|
||||
1, 109, 124, 169, 34, 11,
|
||||
201, 202, 203, 204, 205, 206, 207, 208, 209, 210,
|
||||
] of UInt8
|
||||
end
|
||||
|
||||
it "should generate random" do
|
||||
time = Time.utc(2019, 9, 29, 10, 55, 22, nanosecond: 123_456_789)
|
||||
generator = NotRandom.new(200)
|
||||
ulid = Ulid::ULID.new(time, generator: generator)
|
||||
ulid.bytes.to_a.should eq [
|
||||
1, 109, 124, 169, 34, 11,
|
||||
201, 202, 203, 204, 205, 206, 207, 208, 209, 210,
|
||||
]
|
||||
end
|
||||
|
||||
it "should generate random and use current date" do
|
||||
time = Time.utc(2019, 9, 29, 10, 55, 22, nanosecond: 123_456_789)
|
||||
generator = NotRandom.new(200)
|
||||
ulid = nil
|
||||
Timecop.freeze(time) do
|
||||
ulid = Ulid::ULID.new(generator: generator)
|
||||
end
|
||||
ulid.not_nil!.bytes.to_a.should eq [
|
||||
1, 109, 124, 169, 34, 11,
|
||||
201, 202, 203, 204, 205, 206, 207, 208, 209, 210,
|
||||
]
|
||||
end
|
||||
|
||||
it "#to_s should return Crockford's Base32 of bytes" do
|
||||
bytes = array_slice [
|
||||
101, 102, 103, 104, 105, 106,
|
||||
107, 108, 109, 110, 111, 112, 113, 114, 115, 116,
|
||||
] of UInt8
|
||||
ulid = Ulid::ULID.new(bytes)
|
||||
ulid.to_s.should eq "CNK6ET39D9NPRVBEDXR72WKKEG"
|
||||
end
|
||||
|
||||
it "should instantiate from a string" do
|
||||
ulid = Ulid::ULID.new "CNK6ET39D9NPRVBEDXR72WKKEG"
|
||||
ulid.to_s.should eq "CNK6ET39D9NPRVBEDXR72WKKEG"
|
||||
end
|
||||
|
||||
it "should return back time" do
|
||||
time = Time.utc(2019, 9, 29, 10, 55, 22, nanosecond: 123_456_789)
|
||||
random = Bytes.new(10) { |i| (201 + i).to_u8 }
|
||||
ulid = Ulid::ULID.new(time, random)
|
||||
ulid.time.should eq Time.utc(2019, 9, 29, 10, 55, 22, nanosecond: 123_000_000)
|
||||
end
|
||||
|
||||
it "should be comparable" do
|
||||
ulid1 = Ulid::ULID.new(array_slice [
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
9, 10, 11, 12, 13, 14, 15, 16,
|
||||
] of UInt8)
|
||||
ulid2 = Ulid::ULID.new(array_slice [
|
||||
2, 2, 3, 4, 5, 6, 7, 8,
|
||||
9, 10, 11, 12, 13, 14, 15, 16,
|
||||
] of UInt8)
|
||||
ulid3 = Ulid::ULID.new(array_slice [
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
9, 10, 11, 12, 13, 14, 15, 17,
|
||||
] of UInt8)
|
||||
ulid4 = Ulid::ULID.new(array_slice [
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
9, 10, 11, 12, 13, 14, 15, 16,
|
||||
] of UInt8)
|
||||
|
||||
(ulid2 > ulid1).should be_true
|
||||
(ulid3 > ulid1).should be_true
|
||||
(ulid2 > ulid3).should be_true
|
||||
end
|
||||
|
||||
it "should only compare bytes" do
|
||||
bytes = array_slice [
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
9, 10, 11, 12, 13, 14, 15, 16,
|
||||
] of UInt8
|
||||
ulid1 = Ulid::ULID.new(bytes)
|
||||
ulid2 = Ulid::ULID.new(bytes)
|
||||
|
||||
# Generate time instance cache
|
||||
ulid2.time
|
||||
|
||||
(ulid1 == ulid2).should be_true
|
||||
end
|
||||
|
||||
it "should return a readonly slice" do
|
||||
ulid = Ulid::ULID.new
|
||||
ulid.bytes.read_only?.should be_true
|
||||
|
||||
ulid = Ulid::ULID.new(array_slice [
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
9, 10, 11, 12, 13, 14, 15, 16,
|
||||
] of UInt8)
|
||||
ulid.bytes.read_only?.should be_true
|
||||
|
||||
time = Time.utc(2019, 9, 29, 10, 55, 22, nanosecond: 123_456_789)
|
||||
random = Bytes.new(10) { |i| (201 + i).to_u8 }
|
||||
ulid = Ulid::ULID.new(time, random)
|
||||
ulid.bytes.read_only?.should be_true
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
require "base32"
|
||||
|
||||
module Ulid
|
||||
VERSION = "0.1.0"
|
||||
|
||||
class ULID
|
||||
class Error < Exception; end
|
||||
|
||||
include Comparable(ULID)
|
||||
|
||||
BYTE_COUNT = 16
|
||||
|
||||
getter bytes : Bytes
|
||||
|
||||
def initialize(bytes : Bytes)
|
||||
initialize(bytes, true)
|
||||
end
|
||||
|
||||
private def initialize(bytes : Bytes, copy : Bool)
|
||||
raise Error.new "Not enough bytes, #{BYTE_COUNT} required" if bytes.size < BYTE_COUNT
|
||||
raise Error.new "Too many bytes, #{BYTE_COUNT} required" if bytes.size > BYTE_COUNT
|
||||
@bytes = copy ? Bytes.new(BYTE_COUNT, read_only: true) { |i| bytes[i] } : bytes
|
||||
end
|
||||
|
||||
def initialize(time : Time, random : Bytes)
|
||||
ms = time.to_unix_ms
|
||||
bytes = Bytes.new(BYTE_COUNT, read_only: true) do |i|
|
||||
i < 6 ? (ms >> (8 * (5 - i)) & 0xFF).to_u8 : random[i - 6]
|
||||
end
|
||||
initialize(bytes, false)
|
||||
end
|
||||
|
||||
def initialize(time : Time, *, generator : Random = Random::PCG32)
|
||||
random = generate_random_bytes(generator)
|
||||
initialize(time, random)
|
||||
end
|
||||
|
||||
def initialize(*, generator : Random = Random::PCG32.new)
|
||||
random = generate_random_bytes(generator)
|
||||
initialize(Time.utc, random)
|
||||
end
|
||||
|
||||
def initialize(str : String)
|
||||
initialize(Base32.decode(str, Base32::Crockford))
|
||||
end
|
||||
|
||||
def to_s : String
|
||||
Base32.encode(@bytes, Base32::Crockford)
|
||||
end
|
||||
|
||||
def time : Time
|
||||
return time if time = @time
|
||||
ms = 0u64
|
||||
6.times { |i| ms |= @bytes[5 - i].to_u64 << (i * 8) }
|
||||
@time = Time.unix_ms(ms)
|
||||
end
|
||||
|
||||
def <=>(other : ULID)
|
||||
@bytes.<=>(other.@bytes)
|
||||
end
|
||||
|
||||
private def generate_random_bytes(generator : Random)
|
||||
Bytes.new(10) { |i| generator.rand(256).to_u8 }
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue