first import

dev v0.1.0
LeMarsu 2019-09-29 23:48:14 +02:00
commit 7db26b8c44
9 changed files with 347 additions and 0 deletions

9
.editorconfig Normal file
View File

@ -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

9
.gitignore vendored Normal file
View File

@ -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

6
.travis.yml Normal file
View File

@ -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

21
LICENSE Normal file
View File

@ -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.

71
README.md Normal file
View File

@ -0,0 +1,71 @@
# ulid
Universally Unique Lexicographically Sortable Identifier for Crystal
![](https://raw.githubusercontent.com/diegogub/ulid/master/README/logo.png)
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

18
shard.yml Normal file
View File

@ -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

18
spec/spec_helper.cr Normal file
View File

@ -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

129
spec/ulid_spec.cr Normal file
View File

@ -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

66
src/ulid.cr Normal file
View File

@ -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