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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 545fbb367919209222c4334d6ae3ae560a6f338d1d76cd59bf964b74009d0df4
|
|
4
|
+
data.tar.gz: 8d5edbc4b5096f8c418047e71b6400c5895c2f431b0cf1358315b136f91478f9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8eee45396ef77293c114761afbe6a21f3655af65bc157934eee501b64b59e4a22e9dacc70181f7b45a8369dfcbcf7279b3ebe01cf6250ba26db6bd854a1b436e
|
|
7
|
+
data.tar.gz: 820f7eeb45365192d2f66d4651f2e93abf17b1c5dff9c6486a5bad989fbbe49271dc67e2517194c22d3ee6193cd84041412790bd95906e1920d25739be92fc8c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-10-19
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- `TypeidRails.encode`, `parse`, `to_uuid` and `generate` convert between UUIDs and
|
|
8
|
+
[TypeID](https://github.com/jetify-com/typeid) strings, conforming to spec version 0.3.0.
|
|
9
|
+
`TypeidRails::Value` is the parsed, immutable form.
|
|
10
|
+
- `TypeidRails.configure` sets process-wide defaults: `fuzzy` accepts Crockford's lenient
|
|
11
|
+
spellings when decoding, `equality` picks prefix-sensitive (`:typeid`) or UUID-only (`:uuid`)
|
|
12
|
+
comparison, and `uuid_version` picks UUIDv4 or UUIDv7 for `generate`.
|
|
13
|
+
- `TypeidRails::Identifiable` exposes a model's UUID primary key as a TypeID, and registers its
|
|
14
|
+
prefix in `TypeidRails::Registry`. `TypeidRails::AssociationSupport` does the same for foreign
|
|
15
|
+
keys pointing at such models.
|
|
16
|
+
- `typeid_options` sets `fuzzy` and `strict` on a model class, inherited by its subclasses.
|
|
17
|
+
- `TypeidRails::PolymorphicType` types a uuid column whose rows point at different models.
|
|
18
|
+
- `TypeidRails.derive_uuid` derives a stable UUID from input parts with SHAKE128, when the
|
|
19
|
+
`sha3` gem is available.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sorah Fukumori
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# typeid_rails: TypeID identifiers for UUID primary keys in Rails
|
|
2
|
+
|
|
3
|
+
typeid_rails renders a 128-bit UUID as a [TypeID](https://github.com/jetify-com/typeid), a short string that carries its own type tag, and parses it back. Its ActiveRecord attribute type keeps a plain `uuid` column in the database and exposes the TypeID to application code.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
user_0wp9c9wd1d90msb3z67p5eb0v5
|
|
7
|
+
│ └─ the UUID, 26 Base32 characters
|
|
8
|
+
└────── a prefix naming the kind of resource
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
A UUID is a fine primary key and a poor thing to put in a URL. It is long, it looks like every other UUID, and pasting a user's ID where an order's ID belongs silently finds nothing. A TypeID is shorter, says what it identifies, and is obviously wrong in the wrong place.
|
|
12
|
+
|
|
13
|
+
The implementation passes the TypeID specification's test vectors (spec version 0.3.0). Optional settings relax decoding and equality for applications that already hand out Crockford Base32 identifiers decoded leniently.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle add typeid_rails
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Ruby 3.3 or later. The optional ActiveRecord integration is tested against ActiveRecord 7.1 and later.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "typeid_rails"
|
|
27
|
+
|
|
28
|
+
TypeidRails.encode("1cb2589e-342d-4829-958f-e63d8ae58365", prefix: "user")
|
|
29
|
+
# => "user_0wp9c9wd1d90msb3z67p5eb0v5"
|
|
30
|
+
|
|
31
|
+
TypeidRails.to_uuid("user_0wp9c9wd1d90msb3z67p5eb0v5")
|
|
32
|
+
# => "1cb2589e-342d-4829-958f-e63d8ae58365"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`to_uuid` returns `nil` for anything malformed. When a bad identifier should be an error instead, parse it:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
id = TypeidRails.parse("user_0wp9c9wd1d90msb3z67p5eb0v5")
|
|
39
|
+
id.uuid # => "1cb2589e-342d-4829-958f-e63d8ae58365"
|
|
40
|
+
id.prefix # => "user"
|
|
41
|
+
id.to_s # => "user_0wp9c9wd1d90msb3z67p5eb0v5"
|
|
42
|
+
|
|
43
|
+
TypeidRails.parse("user_0WP9C9WD1D90MSB3Z67P5EB0V5") # raises TypeidRails::InvalidFormatError
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Pass `expected_prefix:` to reject identifiers that belong to another kind of resource. The result is `nil` rather than an exception, since a wrong prefix is usually a lookup that should miss:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
TypeidRails.parse("user_0wp9c9wd1d90msb3z67p5eb0v5", expected_prefix: "org") # => nil
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Prefixes follow the TypeID grammar: lowercase ASCII letters and underscores, at most 63 characters, with no underscore at either end. An empty prefix is allowed and renders as the bare suffix. `TypeidRails::Value` is immutable. Decoding is strict by default. See [Configuration](#configuration) to relax it.
|
|
53
|
+
|
|
54
|
+
To mint a new identifier without a database round-trip:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
TypeidRails.generate(prefix: "user") # => "user_..." from a UUIDv4
|
|
58
|
+
TypeidRails.generate(prefix: "user", version: 7) # => "user_01m5a5kxrqeea9n21at57rtheq"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### ActiveRecord
|
|
62
|
+
|
|
63
|
+
Under Rails the integration loads itself. Elsewhere, `require "typeid_rails/active_record"`.
|
|
64
|
+
|
|
65
|
+
Declare a prefix on any model whose primary key is a UUID:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class User < ApplicationRecord
|
|
69
|
+
include TypeidRails::Identifiable
|
|
70
|
+
id_prefix "user"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
User.first.id # => "user_0wp9c9wd1d90msb3z67p5eb0v5"
|
|
74
|
+
User.find_by(id: "user_0wp9c9wd1d90msb3z67p5eb0v5")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The column still holds a plain UUID. Only the attribute is encoded. Raw UUIDs are accepted on assignment too, so seeds, migrations and hand-written queries do not have to encode first.
|
|
78
|
+
|
|
79
|
+
This gem never generates primary keys. That stays with the database (`gen_random_uuid()` or similar) or with your own code.
|
|
80
|
+
|
|
81
|
+
Foreign keys are handled by a second concern, included once in your base class. It is separate because a model with no public identifier of its own, such as a join table or an audit row, can still point at models that have one:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
85
|
+
primary_abstract_class
|
|
86
|
+
include TypeidRails::AssociationSupport
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class Membership < ApplicationRecord
|
|
90
|
+
belongs_to :user # user_id now reads and writes "user_..." identifiers
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
By default an identifier with the wrong prefix, or one that is malformed, collapses to the null UUID. It matches no row, and the lookup simply finds nothing. Pass `strict: true` to raise `TypeidRails::PrefixMismatchError` or `TypeidRails::InvalidFormatError` instead:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
id_prefix "user", strict: true
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A uuid column whose rows point at different models, with a sibling column naming which, has no single prefix. Type it with `TypeidRails::PolymorphicType`. It accepts an identifier under any prefix, or a raw UUID, and reads back the bare UUID, so the owning record supplies the prefix:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
class Preference < ApplicationRecord
|
|
104
|
+
OWNER_MODELS = {"user" => User, "org" => Organization}.freeze
|
|
105
|
+
|
|
106
|
+
attribute :owner_id, TypeidRails::PolymorphicType.new
|
|
107
|
+
|
|
108
|
+
def owner_id
|
|
109
|
+
model = OWNER_MODELS[owner_type]
|
|
110
|
+
model ? model.type_for_attribute("id").cast(super) : super
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Prefix registry
|
|
116
|
+
|
|
117
|
+
Declaring a prefix registers the model under it, so an identifier can be routed back to whatever owns it. This is useful when a token or an API path carries an identifier whose kind is not known ahead of time.
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
TypeidRails::Registry.model_for("user") # => User
|
|
121
|
+
TypeidRails::Registry.prefix_for(User) # => "user"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Deterministic identifiers
|
|
125
|
+
|
|
126
|
+
`derive_uuid` turns any set of input parts into a stable UUID, so two systems that agree on the inputs arrive at the same identifier without talking to each other. Useful when records are mirrored across a boundary and both sides need to name them identically.
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
TypeidRails.derive_uuid("production", "users", "42")
|
|
130
|
+
# => "82b28cf0-404a-d9b3-1a45-70f9e81ea106"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Parts are joined with a NUL byte before hashing, so `("ab", "c")` and `("a", "bc")` derive different identifiers.
|
|
134
|
+
|
|
135
|
+
This requires the [`sha3`](https://rubygems.org/gems/sha3) gem, which is not a dependency of this gem. Add it yourself if you use this feature:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
gem "sha3"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The result is 128 raw bits of SHAKE128 output rendered in UUID shape. It is deliberately not an RFC 9562 UUID: no version or variant bits are set. This keeps derived identifiers stable forever, at the cost of them not being valid v4 or v7 UUIDs.
|
|
142
|
+
|
|
143
|
+
## Configuration
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
# config/initializers/typeid_rails.rb
|
|
147
|
+
TypeidRails.configure do |config|
|
|
148
|
+
config.fuzzy = false # accept Crockford's lenient spellings when decoding
|
|
149
|
+
config.equality = :typeid # :typeid or :uuid, see below
|
|
150
|
+
config.uuid_version = 4 # 4 or 7, used by generate
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Each setting can be overridden where it applies: `parse(..., fuzzy:)`, `generate(..., version:)`, and `id_prefix "user", fuzzy:` or `Type.new(prefix:, fuzzy:)` for an attribute.
|
|
155
|
+
|
|
156
|
+
- `fuzzy`: TypeID accepts only the lowercase alphabet. With `fuzzy = true`, the suffix also decodes in uppercase, and `i`/`l` read as `1`, `o` as `0` and `u` as `v`, as in [Crockford's Base32](https://www.crockford.com/base32.html). The prefix is never relaxed, and the result always renders canonically.
|
|
157
|
+
- `equality`: with `:typeid`, two values are equal when both the prefix and the UUID match, and a value equals a String only when it is the exact rendering. With `:uuid`, the prefix is ignored and a value also equals its raw UUID text.
|
|
158
|
+
- `uuid_version`: TypeID recommends v7 so identifiers sort by creation time. A v7 UUID also reveals that time to anyone who holds the identifier, so the default is v4.
|
|
159
|
+
|
|
160
|
+
### Per model
|
|
161
|
+
|
|
162
|
+
The attribute types can take `fuzzy` and `strict` from a model class instead, so they can be set on an abstract class rather than for the whole process:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
166
|
+
primary_abstract_class
|
|
167
|
+
include TypeidRails::AssociationSupport
|
|
168
|
+
typeid_options fuzzy: true, strict: true
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
class LegacyRecord < ApplicationRecord
|
|
172
|
+
self.abstract_class = true
|
|
173
|
+
typeid_options strict: false # merged over the inherited options
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
An attribute takes the first setting found: the `id_prefix` argument, then the model's `typeid_options`, then the global configuration (`fuzzy`) or `false` (`strict`). A foreign key follows the options of the model that declares the `belongs_to`. `typeid_options` applies to attributes declared after it, so call it before `id_prefix` and `belongs_to` in the same class. `equality` and `uuid_version` act on values outside any model, and stay global.
|
|
178
|
+
|
|
179
|
+
To keep accepting identifiers an application already issued under the lenient Crockford rules, while migrating it towards TypeID:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
TypeidRails.configure do |config|
|
|
183
|
+
config.fuzzy = true
|
|
184
|
+
config.equality = :uuid
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Difference with the typeid gem
|
|
189
|
+
|
|
190
|
+
The [typeid](https://github.com/broothie/typeid-ruby) gem is a general Ruby implementation of TypeID that mints UUIDv7 identifiers. Use it when you need TypeIDs outside ActiveRecord and want no integration with models.
|
|
191
|
+
|
|
192
|
+
typeid_rails is built around existing UUID primary keys. It adds an attribute type so columns stay `uuid` while models read and write TypeIDs, a prefix registry, a type for polymorphic columns, and compatibility settings for identifiers already issued under lenient Crockford rules. The namespaces are different (`TypeidRails` and `TypeID`), so both gems can be loaded together.
|
|
193
|
+
|
|
194
|
+
## Caveats
|
|
195
|
+
|
|
196
|
+
- With `fuzzy = true`, the gem accepts strings that the TypeID spec requires to be rejected. Encoding is unaffected, and every identifier it renders is a valid TypeID.
|
|
197
|
+
- `generate` mints UUIDv4 by default, while the spec recommends UUIDv7. Set `uuid_version = 7` to follow it.
|
|
198
|
+
- `Value#==` and `#hash` read the `equality` setting on every call. Changing it while values are held as hash keys leaves those hashes inconsistent.
|
|
199
|
+
|
|
200
|
+
## Development
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
bin/setup # install dependencies
|
|
204
|
+
bundle exec rake # run the specs, including the TypeID test vectors
|
|
205
|
+
bundle exec rbs collection install
|
|
206
|
+
bundle exec steep check # typecheck lib/ against sig/
|
|
207
|
+
hk check --all # run every linter, as CI does
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Third-party code
|
|
211
|
+
|
|
212
|
+
The bit-buffer technique in `TypeidRails::Base32` is adapted from the [rotp](https://github.com/mdp/rotp) gem, Copyright (c) 2011 Mark Percival, used under the MIT License. The notice is reproduced in `lib/typeid_rails/base32.rb`.
|
|
213
|
+
|
|
214
|
+
The test vectors in `spec/fixtures/typeid/` are taken from the [TypeID specification](https://github.com/jetify-com/typeid) and used under the Apache License 2.0. They are not part of the published gem.
|
|
215
|
+
|
|
216
|
+
## Contributing
|
|
217
|
+
|
|
218
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/sorah/typeid_rails.
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
Copyright (c) 2026 Sorah Fukumori
|
|
223
|
+
|
|
224
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
7
|
+
|
|
8
|
+
desc "Typecheck lib/ against sig/ with steep"
|
|
9
|
+
task :steep do
|
|
10
|
+
sh "bundle exec steep check"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
task default: :spec
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
require_relative "model_options"
|
|
6
|
+
require_relative "type"
|
|
7
|
+
|
|
8
|
+
module TypeidRails
|
|
9
|
+
# Applies TypeidRails::Type to foreign keys pointing at Identifiable models, so an
|
|
10
|
+
# association can be assigned a prefixed identifier directly. The type takes this
|
|
11
|
+
# model's typeid_options, not the referenced model's.
|
|
12
|
+
#
|
|
13
|
+
# This lives apart from Identifiable because a model that has no public identifier
|
|
14
|
+
# of its own — a join table, an audit row — can still hold foreign keys to models
|
|
15
|
+
# that do. Include it once, in the application's base record class:
|
|
16
|
+
#
|
|
17
|
+
# class ApplicationRecord < ActiveRecord::Base
|
|
18
|
+
# primary_abstract_class
|
|
19
|
+
# include TypeidRails::AssociationSupport
|
|
20
|
+
# end
|
|
21
|
+
module AssociationSupport
|
|
22
|
+
extend ActiveSupport::Concern
|
|
23
|
+
include ModelOptions
|
|
24
|
+
|
|
25
|
+
class_methods do
|
|
26
|
+
def belongs_to(name, scope = nil, **options, &block)
|
|
27
|
+
result = super
|
|
28
|
+
association = reflect_on_association(name)
|
|
29
|
+
configure_foreign_key_for_association(association) if association
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Configures every already-declared belongs_to. Needed only when an association
|
|
34
|
+
# was defined before this module was included.
|
|
35
|
+
def configure_foreign_keys!
|
|
36
|
+
reflect_on_all_associations(:belongs_to).each do |association|
|
|
37
|
+
configure_foreign_key_for_association(association)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private def configure_foreign_key_for_association(association)
|
|
42
|
+
return if association.polymorphic?
|
|
43
|
+
|
|
44
|
+
fk_name = association.foreign_key.to_s
|
|
45
|
+
return if attribute_types[fk_name].is_a?(Type)
|
|
46
|
+
|
|
47
|
+
referenced_model = resolve_association_class(association)
|
|
48
|
+
return unless referenced_model.respond_to?(:id_prefix) && referenced_model.id_prefix
|
|
49
|
+
|
|
50
|
+
attribute fk_name, build_typeid_type(referenced_model.id_prefix)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# An association may name a class that does not exist yet (or at all); that is
|
|
54
|
+
# not our problem to report. Any other NameError comes from loading the class
|
|
55
|
+
# itself and must not be swallowed.
|
|
56
|
+
private def resolve_association_class(association)
|
|
57
|
+
association.klass
|
|
58
|
+
rescue NameError => e
|
|
59
|
+
raise unless e.name.to_s == association.class_name.to_s.split("::").last
|
|
60
|
+
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "configuration"
|
|
4
|
+
|
|
5
|
+
module TypeidRails
|
|
6
|
+
# TypeID's Base32 encoding/decoding for raw bytes: Crockford's alphabet over a
|
|
7
|
+
# 130-bit big-endian value whose top 2 bits are zero.
|
|
8
|
+
# Uses a streaming bit-buffer approach to avoid bigint arithmetic.
|
|
9
|
+
#
|
|
10
|
+
# Reference: https://github.com/jetify-com/typeid/tree/main/spec#base32-encoding
|
|
11
|
+
#
|
|
12
|
+
# Bit-buffer technique adapted from rotp gem (https://github.com/mdp/rotp).
|
|
13
|
+
# Copyright (C) 2011 by Mark Percival <m@mdp.im>
|
|
14
|
+
#
|
|
15
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
16
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
17
|
+
# in the Software without restriction, including without limitation the rights
|
|
18
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
19
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
20
|
+
# furnished to do so, subject to the following conditions:
|
|
21
|
+
#
|
|
22
|
+
# The above copyright notice and this permission notice shall be included in
|
|
23
|
+
# all copies or substantial portions of the Software.
|
|
24
|
+
#
|
|
25
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
27
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
28
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
29
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
30
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
31
|
+
# THE SOFTWARE.
|
|
32
|
+
module Base32
|
|
33
|
+
# Crockford's Base32 alphabet (lowercase, excludes I, L, O, U to avoid ambiguity)
|
|
34
|
+
ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
|
|
35
|
+
|
|
36
|
+
# Indexable form of ALPHABET, so encoding does not allocate a String per lookup.
|
|
37
|
+
ALPHABET_CHARS = ALPHABET.chars.freeze
|
|
38
|
+
|
|
39
|
+
# Number of bytes a single encoded value carries.
|
|
40
|
+
BYTE_SIZE = 16
|
|
41
|
+
|
|
42
|
+
# Number of characters produced for BYTE_SIZE bytes.
|
|
43
|
+
LENGTH = 26
|
|
44
|
+
|
|
45
|
+
STRICT_DECODE_MAP = ALPHABET.each_char.with_index.to_h.freeze
|
|
46
|
+
|
|
47
|
+
# Looked up after downcasing.
|
|
48
|
+
FUZZY_DECODE_MAP = STRICT_DECODE_MAP.merge(
|
|
49
|
+
"i" => 1,
|
|
50
|
+
"l" => 1,
|
|
51
|
+
"o" => 0,
|
|
52
|
+
"u" => 27, # Crockford reserves U as a check symbol; read it as V.
|
|
53
|
+
).freeze
|
|
54
|
+
|
|
55
|
+
SHIFT = 5
|
|
56
|
+
MASK = 0x1f
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# Encodes a 16-byte string to 26 Base32 characters.
|
|
60
|
+
# 16 bytes = 128 bits; with 2 leading zero bits = 130 bits = 26 × 5.
|
|
61
|
+
# @param bytes [String] 16-byte binary string
|
|
62
|
+
# @return [String] 26-character base32 string
|
|
63
|
+
def encode_bytes(bytes)
|
|
64
|
+
raise ArgumentError, "Expected #{BYTE_SIZE} bytes, got #{bytes.bytesize}" unless bytes.bytesize == BYTE_SIZE
|
|
65
|
+
|
|
66
|
+
data = bytes.unpack("C*") #: Array[Integer]
|
|
67
|
+
out = String.new
|
|
68
|
+
buffer = 0
|
|
69
|
+
bits_left = 2 # 2 leading zero bits: 2 + 128 = 130 = 26 * 5
|
|
70
|
+
idx = 0
|
|
71
|
+
|
|
72
|
+
while bits_left > 0 || idx < data.length
|
|
73
|
+
if bits_left < SHIFT
|
|
74
|
+
buffer = (buffer << 8) | data.fetch(idx)
|
|
75
|
+
bits_left += 8
|
|
76
|
+
idx += 1
|
|
77
|
+
end
|
|
78
|
+
bits_left -= SHIFT
|
|
79
|
+
out << ALPHABET_CHARS.fetch((buffer >> bits_left) & MASK)
|
|
80
|
+
buffer &= (1 << bits_left) - 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
out
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Decodes a 26-character Base32 string to a 16-byte string.
|
|
87
|
+
# 26 chars × 5 bits = 130 bits. The first char carries 2 padding zeros + 3 data bits.
|
|
88
|
+
# Remaining 25 chars carry 125 bits. Total data: 3 + 125 = 128 bits = 16 bytes.
|
|
89
|
+
# @param base32 [String] 26-character base32 string
|
|
90
|
+
# @param fuzzy [Boolean] see Configuration#fuzzy
|
|
91
|
+
# @return [String] 16-byte binary string
|
|
92
|
+
def decode_bytes(base32, fuzzy: TypeidRails.config.fuzzy)
|
|
93
|
+
values = to_values(base32, fuzzy:) if valid128?(base32, fuzzy:)
|
|
94
|
+
raise ArgumentError, "Invalid base32 format" unless values
|
|
95
|
+
|
|
96
|
+
# First character: discard top 2 padding bits, keep bottom 3 data bits
|
|
97
|
+
buffer = values.fetch(0) & 0x07
|
|
98
|
+
bits_left = 3
|
|
99
|
+
result = [] #: Array[Integer]
|
|
100
|
+
|
|
101
|
+
values.drop(1).each do |value|
|
|
102
|
+
buffer = (buffer << SHIFT) | value
|
|
103
|
+
bits_left += SHIFT
|
|
104
|
+
|
|
105
|
+
if bits_left >= 8
|
|
106
|
+
bits_left -= 8
|
|
107
|
+
result << ((buffer >> bits_left) & 0xff)
|
|
108
|
+
buffer &= (1 << bits_left) - 1
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
result.pack("C*")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Checks if a string is a valid 26-character base32 encoding of 128 bits.
|
|
116
|
+
# @param string [String] string to validate
|
|
117
|
+
# @param fuzzy [Boolean] see Configuration#fuzzy
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def valid128?(string, fuzzy: TypeidRails.config.fuzzy)
|
|
120
|
+
return false unless string.is_a?(String)
|
|
121
|
+
return false unless string.length == LENGTH
|
|
122
|
+
|
|
123
|
+
values = to_values(string, fuzzy:)
|
|
124
|
+
return false unless values
|
|
125
|
+
|
|
126
|
+
# A 128-bit encoding carries 2 leading zero bits, so the first character
|
|
127
|
+
# must decode to a value in 0-7. Without this check, decode_bytes silently
|
|
128
|
+
# masks the top 2 bits and multiple base32 strings collapse to the same UUID.
|
|
129
|
+
values.fetch(0) <= 7
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Array<Integer>, nil] nil when a character is outside the alphabet
|
|
133
|
+
private def to_values(string, fuzzy:)
|
|
134
|
+
map = fuzzy ? FUZZY_DECODE_MAP : STRICT_DECODE_MAP
|
|
135
|
+
normalized = fuzzy ? string.downcase : string
|
|
136
|
+
values = [] #: Array[Integer]
|
|
137
|
+
normalized.each_char do |char|
|
|
138
|
+
value = map[char]
|
|
139
|
+
return nil unless value
|
|
140
|
+
|
|
141
|
+
values << value
|
|
142
|
+
end
|
|
143
|
+
values
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypeidRails
|
|
4
|
+
# Process-wide defaults. Each setting can also be overridden where it is consumed.
|
|
5
|
+
class Configuration
|
|
6
|
+
UUID_VERSIONS = [4, 7].freeze
|
|
7
|
+
EQUALITY_MODES = [:typeid, :uuid].freeze
|
|
8
|
+
|
|
9
|
+
# When true, suffixes are decoded with Crockford's leniency: uppercase is accepted,
|
|
10
|
+
# and I/L read as 1, O as 0 and U as V. TypeID itself rejects all of these.
|
|
11
|
+
attr_accessor :fuzzy
|
|
12
|
+
|
|
13
|
+
# UUID version minted by Value.generate. 7 sorts by creation time, and also
|
|
14
|
+
# reveals it.
|
|
15
|
+
attr_reader :uuid_version
|
|
16
|
+
|
|
17
|
+
# How Value#== compares. :typeid compares the prefix and the UUID, and matches a
|
|
18
|
+
# String only when it is the exact rendering. :uuid ignores the prefix and also
|
|
19
|
+
# matches the raw UUID text.
|
|
20
|
+
attr_reader :equality
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@fuzzy = false
|
|
24
|
+
@uuid_version = 4
|
|
25
|
+
@equality = :typeid
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def equality=(mode)
|
|
29
|
+
unless EQUALITY_MODES.include?(mode)
|
|
30
|
+
raise ArgumentError, "Unsupported equality mode #{mode.inspect}; expected one of #{EQUALITY_MODES.join(", ")}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@equality = mode
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def uuid_version=(version)
|
|
37
|
+
self.class.validate_uuid_version!(version)
|
|
38
|
+
@uuid_version = version
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.validate_uuid_version!(version)
|
|
42
|
+
return if UUID_VERSIONS.include?(version)
|
|
43
|
+
|
|
44
|
+
raise ArgumentError, "Unsupported UUID version #{version.inspect}; expected one of #{UUID_VERSIONS.join(", ")}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
def config
|
|
50
|
+
@config ||= Configuration.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def configure
|
|
54
|
+
yield config
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reset_config!
|
|
58
|
+
@config = Configuration.new
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "sha3"
|
|
5
|
+
rescue LoadError => e
|
|
6
|
+
raise LoadError, "TypeidRails.derive_uuid requires the `sha3` gem; add `gem \"sha3\"` to your Gemfile", cause: e
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "encoder"
|
|
10
|
+
|
|
11
|
+
module TypeidRails
|
|
12
|
+
# Deterministic identifier derivation.
|
|
13
|
+
#
|
|
14
|
+
# Two systems that agree on the same input parts derive the same identifier without
|
|
15
|
+
# coordinating, which is what makes it useful for mirroring records across a boundary.
|
|
16
|
+
#
|
|
17
|
+
# The result is 128 raw bits of SHAKE128 output rendered in UUID shape. It is
|
|
18
|
+
# deliberately *not* an RFC 4122 UUID: no version or variant bits are set, so it
|
|
19
|
+
# cannot be confused with a randomly generated one and stays stable forever.
|
|
20
|
+
module Derivation
|
|
21
|
+
class << self
|
|
22
|
+
# @param parts [Array<String>] input components, joined with a NUL separator
|
|
23
|
+
# @return [String] canonical UUID text
|
|
24
|
+
def uuid(*parts)
|
|
25
|
+
digest = SHA3::Digest::SHAKE_128.new.tap { |shake| shake.update(parts.join("\0")) }.hex_squeeze(16)
|
|
26
|
+
Encoder.format_uuid(digest)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base32"
|
|
4
|
+
|
|
5
|
+
module TypeidRails
|
|
6
|
+
# Converts between canonical UUID text and its Base32 rendering.
|
|
7
|
+
# This is the unprefixed half of the format; see Value for the prefixed form.
|
|
8
|
+
module Encoder
|
|
9
|
+
# Sentinel UUID substituted for invalid or mismatched input by lenient consumers.
|
|
10
|
+
NULL_UUID = "00000000-0000-0000-0000-000000000000"
|
|
11
|
+
|
|
12
|
+
UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# @param uuid [String] canonical UUID text
|
|
16
|
+
# @return [String] 26-character Base32 string
|
|
17
|
+
def encode(uuid)
|
|
18
|
+
raise ArgumentError, "Invalid UUID format" unless uuid.is_a?(String) && uuid.match?(UUID_REGEX)
|
|
19
|
+
|
|
20
|
+
bytes = [uuid.delete("-")].pack("H*")
|
|
21
|
+
Base32.encode_bytes(bytes)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param base32 [String] 26-character Base32 string
|
|
25
|
+
# @param fuzzy [Boolean] see Configuration#fuzzy
|
|
26
|
+
# @return [String] canonical lowercase UUID text
|
|
27
|
+
def decode(base32, fuzzy: TypeidRails.config.fuzzy)
|
|
28
|
+
hex = Base32.decode_bytes(base32, fuzzy:).unpack1("H*") #: String
|
|
29
|
+
format_uuid(hex)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def valid?(string, fuzzy: TypeidRails.config.fuzzy)
|
|
33
|
+
Base32.valid128?(string, fuzzy:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Formats 32 hex characters as canonical UUID text.
|
|
37
|
+
# @param hex [String] 32-character hex string
|
|
38
|
+
# @return [String]
|
|
39
|
+
def format_uuid(hex)
|
|
40
|
+
"#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|