typeid_rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE.txt +21 -0
- data/README.md +224 -0
- data/Rakefile +13 -0
- data/lib/typeid_rails/active_record.rb +8 -0
- data/lib/typeid_rails/association_support.rb +65 -0
- data/lib/typeid_rails/base32.rb +147 -0
- data/lib/typeid_rails/configuration.rb +61 -0
- data/lib/typeid_rails/derivation.rb +30 -0
- data/lib/typeid_rails/encoder.rb +44 -0
- data/lib/typeid_rails/errors.rb +43 -0
- data/lib/typeid_rails/identifiable.rb +38 -0
- data/lib/typeid_rails/model_options.rb +54 -0
- data/lib/typeid_rails/polymorphic_type.rb +49 -0
- data/lib/typeid_rails/railtie.rb +10 -0
- data/lib/typeid_rails/registry.rb +52 -0
- data/lib/typeid_rails/type.rb +98 -0
- data/lib/typeid_rails/value.rb +110 -0
- data/lib/typeid_rails/version.rb +5 -0
- data/lib/typeid_rails.rb +57 -0
- data/sig/typeid_rails/association_support.rbs +29 -0
- data/sig/typeid_rails/base32.rbs +19 -0
- data/sig/typeid_rails/configuration.rbs +21 -0
- data/sig/typeid_rails/derivation.rbs +5 -0
- data/sig/typeid_rails/encoder.rbs +11 -0
- data/sig/typeid_rails/errors.rbs +29 -0
- data/sig/typeid_rails/identifiable.rbs +23 -0
- data/sig/typeid_rails/model_options.rbs +26 -0
- data/sig/typeid_rails/polymorphic_type.rbs +12 -0
- data/sig/typeid_rails/railtie.rbs +4 -0
- data/sig/typeid_rails/registry.rbs +17 -0
- data/sig/typeid_rails/type.rbs +23 -0
- data/sig/typeid_rails/value.rbs +24 -0
- data/sig/typeid_rails.rbs +9 -0
- metadata +79 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeidRails
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when parsing a string that is not a well-formed resource identifier.
|
|
7
|
+
class InvalidFormatError < Error
|
|
8
|
+
attr_reader :input
|
|
9
|
+
|
|
10
|
+
def initialize(input:, message:)
|
|
11
|
+
@input = input
|
|
12
|
+
super(message)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Raised for a prefix outside TypeID's grammar, whether parsed or declared.
|
|
17
|
+
class InvalidPrefixError < InvalidFormatError; end
|
|
18
|
+
|
|
19
|
+
# Raised when a resource identifier carries a prefix other than the expected one.
|
|
20
|
+
# Only raised by strict consumers; lenient ones substitute the null UUID instead.
|
|
21
|
+
class PrefixMismatchError < Error
|
|
22
|
+
attr_reader :expected, :actual, :input
|
|
23
|
+
|
|
24
|
+
def initialize(expected:, actual:, input:)
|
|
25
|
+
@expected = expected
|
|
26
|
+
@actual = actual
|
|
27
|
+
@input = input
|
|
28
|
+
super("Expected prefix '#{expected}', got '#{actual}' in '#{input}'")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Raised when two different models claim the same prefix.
|
|
33
|
+
class DuplicatePrefixError < Error
|
|
34
|
+
attr_reader :prefix, :existing_model, :conflicting_model
|
|
35
|
+
|
|
36
|
+
def initialize(prefix:, existing_model:, conflicting_model:)
|
|
37
|
+
@prefix = prefix
|
|
38
|
+
@existing_model = existing_model
|
|
39
|
+
@conflicting_model = conflicting_model
|
|
40
|
+
super("Duplicate prefix '#{prefix}' used by #{existing_model} and #{conflicting_model}")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
require_relative "model_options"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
|
|
8
|
+
module TypeidRails
|
|
9
|
+
# Gives a model a public identifier: its UUID primary key is read and written as a
|
|
10
|
+
# prefixed string, and the prefix is registered so the model can be found from one.
|
|
11
|
+
#
|
|
12
|
+
# class User < ApplicationRecord
|
|
13
|
+
# include TypeidRails::Identifiable
|
|
14
|
+
# id_prefix "u"
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# User.first.id # => "u_2n1t201rmv87aae5j4csam8000"
|
|
18
|
+
module Identifiable
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
include ModelOptions
|
|
21
|
+
|
|
22
|
+
class_methods do
|
|
23
|
+
# Declares (with an argument) or reads (without) this model's prefix.
|
|
24
|
+
# @param prefix [String, nil]
|
|
25
|
+
# @param strict [Boolean, nil] see Type#initialize; nil follows typeid_options
|
|
26
|
+
# @param fuzzy [Boolean, nil] see Type#initialize; nil follows typeid_options
|
|
27
|
+
# @return [String, nil]
|
|
28
|
+
def id_prefix(prefix = nil, strict: nil, fuzzy: nil)
|
|
29
|
+
if prefix
|
|
30
|
+
attribute :id, build_typeid_type(prefix, strict:, fuzzy:)
|
|
31
|
+
@id_prefix = prefix
|
|
32
|
+
Registry.register(prefix, self)
|
|
33
|
+
end
|
|
34
|
+
@id_prefix
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/core_ext/class/attribute"
|
|
5
|
+
|
|
6
|
+
require_relative "type"
|
|
7
|
+
|
|
8
|
+
module TypeidRails
|
|
9
|
+
# Per-model defaults for the attribute types this gem installs. Declared on an
|
|
10
|
+
# abstract base class, they are inherited by every model below it:
|
|
11
|
+
#
|
|
12
|
+
# class ApplicationRecord < ActiveRecord::Base
|
|
13
|
+
# primary_abstract_class
|
|
14
|
+
# include TypeidRails::AssociationSupport
|
|
15
|
+
# typeid_options fuzzy: true, strict: true
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Identifiable and AssociationSupport include this, so it rarely needs including directly.
|
|
19
|
+
module ModelOptions
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
|
|
22
|
+
KEYS = [:fuzzy, :strict].freeze
|
|
23
|
+
|
|
24
|
+
included do
|
|
25
|
+
# @type self: singleton(ActiveRecord::Base)
|
|
26
|
+
defaults = {} #: Hash[Symbol, bool]
|
|
27
|
+
class_attribute :typeid_rails_options, instance_accessor: false, default: defaults.freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class_methods do
|
|
31
|
+
# Merges into the options inherited from the superclass, and returns the result.
|
|
32
|
+
# Applies to attributes declared afterwards.
|
|
33
|
+
# @param fuzzy [Boolean] see Type#initialize; unset follows TypeidRails.config
|
|
34
|
+
# @param strict [Boolean] see Type#initialize; unset means false
|
|
35
|
+
def typeid_options(**options)
|
|
36
|
+
unknown = options.keys - KEYS
|
|
37
|
+
raise ArgumentError, "Unknown typeid_options: #{unknown.join(", ")}" unless unknown.empty?
|
|
38
|
+
|
|
39
|
+
self.typeid_rails_options = typeid_rails_options.merge(options).freeze unless options.empty?
|
|
40
|
+
typeid_rails_options
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Builds a Type, filling options the caller left nil from this model's.
|
|
44
|
+
private def build_typeid_type(prefix, strict: nil, fuzzy: nil)
|
|
45
|
+
options = typeid_rails_options
|
|
46
|
+
Type.new(
|
|
47
|
+
prefix:,
|
|
48
|
+
strict: strict.nil? ? options.fetch(:strict, false) : strict,
|
|
49
|
+
fuzzy: fuzzy.nil? ? options[:fuzzy] : fuzzy,
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "type"
|
|
4
|
+
|
|
5
|
+
module TypeidRails
|
|
6
|
+
# An attribute type for a uuid column whose rows point at different models, with a
|
|
7
|
+
# sibling column naming which. Accepts an identifier under any prefix, or a raw
|
|
8
|
+
# UUID, and reads back the bare UUID, since only the owning record knows which
|
|
9
|
+
# prefix applies:
|
|
10
|
+
#
|
|
11
|
+
# class Preference < ApplicationRecord
|
|
12
|
+
# attribute :owner_id, TypeidRails::PolymorphicType.new
|
|
13
|
+
#
|
|
14
|
+
# def owner_id
|
|
15
|
+
# model = OWNER_MODELS[owner_type]
|
|
16
|
+
# model ? model.type_for_attribute("id").cast(super) : super
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
class PolymorphicType < Type
|
|
20
|
+
# @param strict [Boolean] when true, malformed input raises instead of becoming
|
|
21
|
+
# the null UUID
|
|
22
|
+
# @param fuzzy [Boolean, nil] see Type#initialize
|
|
23
|
+
def initialize(strict: false, fuzzy: nil)
|
|
24
|
+
super(prefix: "", strict:, fuzzy:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def deserialize(value)
|
|
28
|
+
value&.downcase
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def cast(value)
|
|
32
|
+
serialize(value)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def serialize(value)
|
|
36
|
+
return if value.nil?
|
|
37
|
+
|
|
38
|
+
value.is_a?(Value) ? value.to_uuid : string_to_uuid(value.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private def parse_or_null(string)
|
|
42
|
+
Value.parse(string, fuzzy: fuzzy?)
|
|
43
|
+
rescue InvalidFormatError
|
|
44
|
+
raise if strict?
|
|
45
|
+
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Reaching this file means Rails is present, so the ActiveRecord integration is
|
|
4
|
+
# wanted and can simply be loaded. The railtie itself carries no behaviour.
|
|
5
|
+
require_relative "active_record"
|
|
6
|
+
|
|
7
|
+
module TypeidRails
|
|
8
|
+
class Railtie < ::Rails::Railtie
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module TypeidRails
|
|
6
|
+
# Process-wide prefix -> model mapping, letting a bare identifier string be routed
|
|
7
|
+
# back to the class that owns it.
|
|
8
|
+
module Registry
|
|
9
|
+
@prefixes = {}
|
|
10
|
+
@models = {}
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# @raise [DuplicatePrefixError] when another model already claims this prefix
|
|
14
|
+
def register(prefix, model)
|
|
15
|
+
existing = @prefixes[prefix]
|
|
16
|
+
# Compared by name, not identity: a code reloader hands us a fresh class object
|
|
17
|
+
# for the same constant, which is a re-registration rather than a conflict.
|
|
18
|
+
if existing && name_of(existing) != name_of(model)
|
|
19
|
+
raise DuplicatePrefixError.new(
|
|
20
|
+
prefix:,
|
|
21
|
+
existing_model: name_of(existing),
|
|
22
|
+
conflicting_model: name_of(model),
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@models.delete(existing) if existing
|
|
27
|
+
@prefixes[prefix] = model
|
|
28
|
+
@models[model] = prefix
|
|
29
|
+
model
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def all_prefixes
|
|
33
|
+
@prefixes.dup
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def model_for(prefix)
|
|
37
|
+
@prefixes[prefix]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def prefix_for(model)
|
|
41
|
+
@models[model]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset!
|
|
45
|
+
@prefixes = {}
|
|
46
|
+
@models = {}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private def name_of(model) = model.name || model.to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
require_relative "encoder"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "value"
|
|
8
|
+
|
|
9
|
+
module TypeidRails
|
|
10
|
+
# An ActiveRecord attribute type that stores a UUID but exposes the prefixed
|
|
11
|
+
# identifier string, so a record's public identifier never has to be built by hand.
|
|
12
|
+
#
|
|
13
|
+
# Reading gives back a plain String rather than a Value, so the attribute stays
|
|
14
|
+
# comparable and serializable in the ways application code already expects.
|
|
15
|
+
class Type < ActiveRecord::Type::Value
|
|
16
|
+
attr_reader :prefix
|
|
17
|
+
|
|
18
|
+
# @param prefix [String] the type tag this attribute is presented under
|
|
19
|
+
# @param strict [Boolean] when true, malformed input and prefix mismatches raise.
|
|
20
|
+
# When false (the default), they collapse to the null UUID, which matches no row.
|
|
21
|
+
# @param fuzzy [Boolean, nil] see Configuration#fuzzy; nil follows the configuration
|
|
22
|
+
# at the time of each cast
|
|
23
|
+
# @raise [InvalidPrefixError]
|
|
24
|
+
def initialize(prefix:, strict: false, fuzzy: nil)
|
|
25
|
+
Value.validate_prefix!(prefix)
|
|
26
|
+
@prefix = prefix
|
|
27
|
+
@strict = strict
|
|
28
|
+
@fuzzy = fuzzy
|
|
29
|
+
super()
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def strict? = @strict
|
|
33
|
+
|
|
34
|
+
def fuzzy?
|
|
35
|
+
fuzzy = @fuzzy
|
|
36
|
+
fuzzy.nil? ? TypeidRails.config.fuzzy : fuzzy
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def type = :uuid
|
|
40
|
+
|
|
41
|
+
def deserialize(value)
|
|
42
|
+
return if value.nil?
|
|
43
|
+
|
|
44
|
+
Value.from_uuid(value, prefix:).to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cast(value)
|
|
48
|
+
return if value.nil?
|
|
49
|
+
|
|
50
|
+
case value
|
|
51
|
+
when Value then value.to_s
|
|
52
|
+
when String then cast_string(value)
|
|
53
|
+
else cast_string(value.to_s)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def serialize(value)
|
|
58
|
+
return if value.nil?
|
|
59
|
+
|
|
60
|
+
case value
|
|
61
|
+
when Value then value.to_uuid
|
|
62
|
+
when String then string_to_uuid(value)
|
|
63
|
+
else value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The exposed value is a frozen String rebuilt on every read, so in-place mutation
|
|
68
|
+
# is not a thing that can happen.
|
|
69
|
+
def changed_in_place?(_raw_old_value, _new_value)
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private def cast_string(string)
|
|
74
|
+
uuid = string_to_uuid(string)
|
|
75
|
+
Value.from_uuid(uuid, prefix:).to_s
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private def string_to_uuid(string)
|
|
79
|
+
# Raw UUIDs are accepted so that seeds, migrations and hand-written queries
|
|
80
|
+
# do not have to encode first.
|
|
81
|
+
return string.downcase if string.match?(Encoder::UUID_REGEX)
|
|
82
|
+
|
|
83
|
+
parsed = parse_or_null(string)
|
|
84
|
+
parsed ? parsed.to_uuid : Encoder::NULL_UUID
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private def parse_or_null(string)
|
|
88
|
+
parsed = Value.parse(string, expected_prefix: prefix, fuzzy: fuzzy?)
|
|
89
|
+
raise PrefixMismatchError.new(expected: prefix, actual: string.rpartition("_").first, input: string) if parsed.nil? && strict?
|
|
90
|
+
|
|
91
|
+
parsed
|
|
92
|
+
rescue InvalidFormatError
|
|
93
|
+
raise if strict?
|
|
94
|
+
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require_relative "configuration"
|
|
6
|
+
require_relative "encoder"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module TypeidRails
|
|
10
|
+
# An immutable TypeID: a 128-bit UUID plus the type prefix it is presented under.
|
|
11
|
+
# Renders as "<prefix>_<base32>", e.g. "u_2n1t201rmv87aae5j4csam8000", or as the
|
|
12
|
+
# bare suffix when the prefix is empty.
|
|
13
|
+
class Value
|
|
14
|
+
# Lowercase ASCII letters and underscores, at most 63 characters, neither starting
|
|
15
|
+
# nor ending with an underscore. Empty is allowed.
|
|
16
|
+
PREFIX_FORMAT = /\A(?:[a-z](?:[a-z_]{0,61}[a-z])?)?\z/
|
|
17
|
+
|
|
18
|
+
attr_reader :uuid, :prefix, :encoded
|
|
19
|
+
|
|
20
|
+
def initialize(uuid:, prefix:, encoded:)
|
|
21
|
+
@uuid = uuid.downcase.freeze
|
|
22
|
+
@prefix = prefix.freeze
|
|
23
|
+
@encoded = encoded.downcase.freeze
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def valid_prefix?(prefix)
|
|
29
|
+
prefix.is_a?(String) && prefix.match?(PREFIX_FORMAT)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @raise [InvalidPrefixError]
|
|
33
|
+
def validate_prefix!(prefix)
|
|
34
|
+
return if valid_prefix?(prefix)
|
|
35
|
+
|
|
36
|
+
raise InvalidPrefixError.new(input: prefix.to_s, message: "Invalid prefix: #{prefix.inspect}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @raise [InvalidPrefixError]
|
|
40
|
+
def from_uuid(uuid, prefix:)
|
|
41
|
+
validate_prefix!(prefix)
|
|
42
|
+
normalized_uuid = uuid.downcase
|
|
43
|
+
new(uuid: normalized_uuid, prefix:, encoded: Encoder.encode(normalized_uuid))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Mints a new identifier from a random UUID.
|
|
47
|
+
# @param version [Integer] 4 or 7; see Configuration#uuid_version
|
|
48
|
+
def generate(prefix:, version: TypeidRails.config.uuid_version)
|
|
49
|
+
Configuration.validate_uuid_version!(version)
|
|
50
|
+
from_uuid(version == 7 ? SecureRandom.uuid_v7 : SecureRandom.uuid, prefix:)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param string [String, nil] a "<prefix>_<base32>" identifier, or a bare suffix
|
|
54
|
+
# @param expected_prefix [String, nil] when given, returns nil on a prefix mismatch
|
|
55
|
+
# @param fuzzy [Boolean] see Configuration#fuzzy; affects the suffix only
|
|
56
|
+
# @raise [InvalidFormatError] when the string is not a well-formed identifier
|
|
57
|
+
# @return [Value, nil]
|
|
58
|
+
def parse(string, expected_prefix: nil, fuzzy: TypeidRails.config.fuzzy)
|
|
59
|
+
return if string.nil?
|
|
60
|
+
|
|
61
|
+
string = string.to_s
|
|
62
|
+
# Prefixes may contain underscores, so the separator is the last one.
|
|
63
|
+
prefix, separator, encoded_part = string.rpartition("_")
|
|
64
|
+
if !separator.empty? && prefix.empty?
|
|
65
|
+
raise InvalidFormatError.new(input: string, message: "Invalid format: separator without a prefix")
|
|
66
|
+
end
|
|
67
|
+
unless valid_prefix?(prefix)
|
|
68
|
+
raise InvalidPrefixError.new(input: string, message: "Invalid format: invalid prefix")
|
|
69
|
+
end
|
|
70
|
+
unless Encoder.valid?(encoded_part, fuzzy:)
|
|
71
|
+
raise InvalidFormatError.new(input: string, message: "Invalid format: invalid suffix")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
return if expected_prefix && prefix != expected_prefix
|
|
75
|
+
|
|
76
|
+
from_uuid(Encoder.decode(encoded_part, fuzzy:), prefix:)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_s
|
|
81
|
+
prefix.empty? ? encoded : "#{prefix}_#{encoded}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_uuid
|
|
85
|
+
uuid
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @see Configuration#equality
|
|
89
|
+
def ==(other)
|
|
90
|
+
uuid_only = TypeidRails.config.equality == :uuid
|
|
91
|
+
case other
|
|
92
|
+
when Value
|
|
93
|
+
uuid == other.uuid && (uuid_only || prefix == other.prefix)
|
|
94
|
+
when String
|
|
95
|
+
to_s == other || (uuid_only && uuid == other.downcase)
|
|
96
|
+
else
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
alias_method :eql?, :==
|
|
101
|
+
|
|
102
|
+
def hash
|
|
103
|
+
TypeidRails.config.equality == :uuid ? uuid.hash : [uuid, prefix].hash
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def inspect
|
|
107
|
+
"#<#{self.class.name} #{self}>"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/typeid_rails.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "typeid_rails/version"
|
|
4
|
+
require_relative "typeid_rails/configuration"
|
|
5
|
+
require_relative "typeid_rails/errors"
|
|
6
|
+
require_relative "typeid_rails/base32"
|
|
7
|
+
require_relative "typeid_rails/encoder"
|
|
8
|
+
require_relative "typeid_rails/value"
|
|
9
|
+
require_relative "typeid_rails/registry"
|
|
10
|
+
|
|
11
|
+
# TypeID identifiers (https://github.com/jetify-com/typeid) for 128-bit UUIDs.
|
|
12
|
+
#
|
|
13
|
+
# TypeidRails.encode("1cb2589e-342d-4829-958f-e63d8ae58365", prefix: "u")
|
|
14
|
+
# # => "u_0wp9c9wd1d90msb3z67p5eb0v5"
|
|
15
|
+
module TypeidRails
|
|
16
|
+
# Loaded on first use so the `sha3` gem stays an optional dependency.
|
|
17
|
+
autoload :Derivation, "typeid_rails/derivation"
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# @return [String] the "<prefix>_<base32>" rendering of a UUID, or the bare
|
|
21
|
+
# suffix when the prefix is empty
|
|
22
|
+
def encode(uuid, prefix:)
|
|
23
|
+
Value.from_uuid(uuid, prefix:).to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String] a newly minted identifier
|
|
27
|
+
# @see Value.generate
|
|
28
|
+
def generate(prefix:, version: config.uuid_version)
|
|
29
|
+
Value.generate(prefix:, version:).to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Value, nil]
|
|
33
|
+
# @raise [InvalidFormatError] when the string is not a well-formed identifier
|
|
34
|
+
def parse(string, expected_prefix: nil, fuzzy: config.fuzzy)
|
|
35
|
+
Value.parse(string, expected_prefix:, fuzzy:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Extracts the UUID from an identifier, or nil when it is malformed or carries
|
|
39
|
+
# an unexpected prefix. Use #parse when a malformed input should raise instead.
|
|
40
|
+
# @return [String, nil]
|
|
41
|
+
def to_uuid(prefixed_id, expected_prefix: nil, fuzzy: config.fuzzy)
|
|
42
|
+
return if prefixed_id.nil?
|
|
43
|
+
|
|
44
|
+
Value.parse(prefixed_id, expected_prefix:, fuzzy:)&.to_uuid
|
|
45
|
+
rescue InvalidFormatError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Derives a stable identifier from its input parts. Requires the `sha3` gem.
|
|
50
|
+
# @see Derivation.uuid
|
|
51
|
+
def derive_uuid(*parts)
|
|
52
|
+
Derivation.uuid(*parts)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
require_relative "typeid_rails/railtie" if defined?(::Rails::Railtie)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module TypeidRails
|
|
2
|
+
# See the note on _IdentifiableClass: this is the surface of the including class
|
|
3
|
+
# that the `class_methods` block body uses.
|
|
4
|
+
interface _AssociationSupportClass
|
|
5
|
+
def attribute: (Symbol | String name, untyped type) -> untyped
|
|
6
|
+
def belongs_to: (untyped name, ?untyped? scope, **untyped options) ?{ () -> void } -> untyped
|
|
7
|
+
def attribute_types: () -> Hash[String, untyped]
|
|
8
|
+
def reflect_on_association: (untyped name) -> untyped
|
|
9
|
+
def reflect_on_all_associations: (?Symbol macro) -> Array[untyped]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module AssociationSupport : Module, _AssociationSupportClass, _ModelOptionsClass
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
include ModelOptions
|
|
15
|
+
|
|
16
|
+
def belongs_to: (untyped name, ?untyped? scope, **untyped options) ?{ () -> void } -> untyped
|
|
17
|
+
def configure_foreign_keys!: () -> void
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def configure_foreign_key_for_association: (untyped association) -> void
|
|
22
|
+
def resolve_association_class: (untyped association) -> untyped
|
|
23
|
+
|
|
24
|
+
# Generated by ActiveSupport::Concern; extended into the including class.
|
|
25
|
+
module ClassMethods
|
|
26
|
+
def configure_foreign_keys!: () -> void
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module TypeidRails
|
|
2
|
+
module Base32
|
|
3
|
+
ALPHABET: String
|
|
4
|
+
ALPHABET_CHARS: Array[String]
|
|
5
|
+
BYTE_SIZE: Integer
|
|
6
|
+
LENGTH: Integer
|
|
7
|
+
STRICT_DECODE_MAP: Hash[String, Integer]
|
|
8
|
+
FUZZY_DECODE_MAP: Hash[String, Integer]
|
|
9
|
+
SHIFT: Integer
|
|
10
|
+
MASK: Integer
|
|
11
|
+
|
|
12
|
+
def self.encode_bytes: (String bytes) -> String
|
|
13
|
+
def self.decode_bytes: (String base32, ?fuzzy: bool) -> String
|
|
14
|
+
def self.valid128?: (untyped string, ?fuzzy: bool) -> bool
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
def self.to_values: (String string, fuzzy: bool) -> Array[Integer]?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module TypeidRails
|
|
2
|
+
class Configuration
|
|
3
|
+
UUID_VERSIONS: Array[Integer]
|
|
4
|
+
EQUALITY_MODES: Array[Symbol]
|
|
5
|
+
|
|
6
|
+
attr_accessor fuzzy: bool
|
|
7
|
+
attr_reader uuid_version: Integer
|
|
8
|
+
attr_reader equality: Symbol
|
|
9
|
+
|
|
10
|
+
def initialize: () -> void
|
|
11
|
+
def uuid_version=: (Integer version) -> Integer
|
|
12
|
+
def equality=: (Symbol mode) -> Symbol
|
|
13
|
+
def self.validate_uuid_version!: (untyped version) -> void
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
self.@config: Configuration?
|
|
17
|
+
|
|
18
|
+
def self.config: () -> Configuration
|
|
19
|
+
def self.configure: () { (Configuration) -> void } -> void
|
|
20
|
+
def self.reset_config!: () -> Configuration
|
|
21
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module TypeidRails
|
|
2
|
+
module Encoder
|
|
3
|
+
NULL_UUID: String
|
|
4
|
+
UUID_REGEX: Regexp
|
|
5
|
+
|
|
6
|
+
def self.encode: (String uuid) -> String
|
|
7
|
+
def self.decode: (String base32, ?fuzzy: bool) -> String
|
|
8
|
+
def self.valid?: (untyped string, ?fuzzy: bool) -> bool
|
|
9
|
+
def self.format_uuid: (String hex) -> String
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module TypeidRails
|
|
2
|
+
class Error < StandardError
|
|
3
|
+
end
|
|
4
|
+
|
|
5
|
+
class InvalidFormatError < Error
|
|
6
|
+
attr_reader input: String
|
|
7
|
+
|
|
8
|
+
def initialize: (input: String, message: String) -> void
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class InvalidPrefixError < InvalidFormatError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class PrefixMismatchError < Error
|
|
15
|
+
attr_reader expected: String
|
|
16
|
+
attr_reader actual: String?
|
|
17
|
+
attr_reader input: String
|
|
18
|
+
|
|
19
|
+
def initialize: (expected: String, actual: String?, input: String) -> void
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class DuplicatePrefixError < Error
|
|
23
|
+
attr_reader prefix: String
|
|
24
|
+
attr_reader existing_model: String
|
|
25
|
+
attr_reader conflicting_model: String
|
|
26
|
+
|
|
27
|
+
def initialize: (prefix: String, existing_model: String, conflicting_model: String) -> void
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module TypeidRails
|
|
2
|
+
# ActiveSupport::Concern's `class_methods` block body is checked as instance methods
|
|
3
|
+
# of the concern itself, with `self` being the including class. This describes the
|
|
4
|
+
# part of that class the block relies on.
|
|
5
|
+
interface _IdentifiableClass
|
|
6
|
+
def attribute: (Symbol | String name, untyped type) -> untyped
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module Identifiable : Module, _IdentifiableClass, _ModelOptionsClass
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
include ModelOptions
|
|
12
|
+
|
|
13
|
+
@id_prefix: String?
|
|
14
|
+
|
|
15
|
+
# Declares this model's prefix when given one, and reads it back either way.
|
|
16
|
+
def id_prefix: (?String? prefix, ?strict: bool?, ?fuzzy: bool?) -> String?
|
|
17
|
+
|
|
18
|
+
# Generated by ActiveSupport::Concern; extended into the including class.
|
|
19
|
+
module ClassMethods
|
|
20
|
+
def id_prefix: (?String? prefix, ?strict: bool?, ?fuzzy: bool?) -> String?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|