From 7db26b8c44bda72426355936ac9a89a79adebaec Mon Sep 17 00:00:00 2001 From: LeMarsu Date: Sun, 29 Sep 2019 23:48:14 +0200 Subject: [PATCH] first import --- .editorconfig | 9 ++++ .gitignore | 9 ++++ .travis.yml | 6 +++ LICENSE | 21 ++++++++ README.md | 71 ++++++++++++++++++++++++ shard.yml | 18 +++++++ spec/spec_helper.cr | 18 +++++++ spec/ulid_spec.cr | 129 ++++++++++++++++++++++++++++++++++++++++++++ src/ulid.cr | 66 +++++++++++++++++++++++ 9 files changed, 347 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 shard.yml create mode 100644 spec/spec_helper.cr create mode 100644 spec/ulid_spec.cr create mode 100644 src/ulid.cr diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bbd4a9 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..765f0e9 --- /dev/null +++ b/.travis.yml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d6c51cb --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8ded81 --- /dev/null +++ b/README.md @@ -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 () +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 diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..932941c --- /dev/null +++ b/shard.yml @@ -0,0 +1,18 @@ +name: ulid +version: 0.1.0 + +authors: + - LeMarsu + +crystal: 0.31.0 + +dependencies: + base32: + github: lemarsu/base32 + version: 0.1.1 + +development_dependencies: + timecop: + github: crystal-community/timecop.cr + +license: MIT diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..dafb82f --- /dev/null +++ b/spec/spec_helper.cr @@ -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 diff --git a/spec/ulid_spec.cr b/spec/ulid_spec.cr new file mode 100644 index 0000000..9a9a74a --- /dev/null +++ b/spec/ulid_spec.cr @@ -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 diff --git a/src/ulid.cr b/src/ulid.cr new file mode 100644 index 0000000..d75cc01 --- /dev/null +++ b/src/ulid.cr @@ -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