badged_ids 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4d4e44d20b4fe1ff726d0311f0d9d528d4622c8fa1e0bdd4397bdaf217b43a94
4
+ data.tar.gz: b53e26dad7bb2cd5e822c7df83ea837e6cc2ce28368fefb66160ac395d3bf291
5
+ SHA512:
6
+ metadata.gz: e2c1b8beca316c503319691e34cdbb60f1751ed2eec83eefe552b26c9a317cfe9c4ad60cb94a8d45d5d966df34ef4966e9316236b6f149d9e5ad780db93d83cb
7
+ data.tar.gz: 5b56ff1d8efb74cb2cdc9e132a0eb36be059553fa3d5410bfd0a80893d4c4f1f63e7def719ea4382a0657db6fdee6f26b860cfc32c98f1b11384bf7c62104443
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Fabian Schwarz
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,219 @@
1
+ # Badged IDs
2
+
3
+ ### Descriptive and Secure IDs for your Rails Models
4
+ [![Build Status](https://github.com/fabian12943/badged_ids/workflows/CI/badge.svg)](https://github.com/fabian12943/badged_ids/actions)
5
+
6
+ Badged IDs makes it simple to create descriptive and secure identifiers for your Rails
7
+ models.
8
+
9
+ By combining a badge with a randomly generated string, it helps you to prevent
10
+ [vulnerabilities associated with sequential IDs]() while keeping your IDs relatively short (e.g. compared to
11
+ [UUIDs](https://developer.mozilla.org/en-US/docs/Glossary/UUID)). Additionally,
12
+ it allows you to clearly identify the type of record, even when the ID is seen out of context,
13
+ enhancing both readability and traceability.
14
+
15
+ #### Example:
16
+
17
+ ```ruby
18
+ Order.create(/* ... */).id
19
+ # => "ord_24t6pjz7wpecix5"
20
+
21
+ Order.find("ord_24t6pjz7wpecix5")
22
+ # => #<Order id: "ord_24t6pjz7wpecix5", ...>
23
+
24
+ BadgedIds.find("ord_24t6pjz7wpecix5")
25
+ # => #<Order id: "ord_24t6pjz7wpecix5", ...>
26
+ ```
27
+
28
+ The ID generation is highly configurable, enabling you to customize the badge, delimiter, length,
29
+ character set, and much more to suit your application’s needs.
30
+
31
+ Inspired by [Stripe's prefixed IDs](https://stripe.com/docs/api) and [Chris Oliver's prefixed_ids
32
+ gem](https://github.com/excid3/prefixed_ids).
33
+
34
+ ## Installation
35
+
36
+ 1. Add the following line to your app's `Gemfile`:
37
+
38
+ ```ruby
39
+ gem 'badged_ids'
40
+ ```
41
+ 2. Run the following command to install it:
42
+
43
+ ```bash
44
+ $ bundle install
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ### Prerequisites
50
+
51
+ Ensure that the primary key of your model (typically `id`), or the field where you intend to store
52
+ the badged ID, is of type string.
53
+
54
+ To set the primary key for a new table to a string, you can use the following migration:
55
+
56
+ ```ruby
57
+ create_table :orders, id: :string do |t|
58
+ # Add your columns here
59
+ end
60
+ ```
61
+
62
+ If you like the generator to use string primary keys by default, add this to your `config/application.rb`:
63
+
64
+ ```ruby
65
+ # config/application.rb
66
+ config.generators do |g|
67
+ g.orm :active_record, primary_key_type: :string
68
+ end
69
+ ```
70
+
71
+ ### Basic Usage
72
+
73
+ To enable Badged IDs for your model, simplfy include the `has_badged_id` method and specify a badge
74
+ for your model. The badge is a short prefix that helps you to quickly identify the type of a record.
75
+
76
+ That's it! Now, whenever you create a new record, a unique Badged ID is automatically generated and
77
+ assigned to the primary key column of the record.
78
+
79
+ ```ruby
80
+ class Order < ApplicationRecord
81
+ has_badged_id :ord
82
+ end
83
+
84
+ Order.create(/* ... */).id
85
+ # => "ord_24t6pjz7wpecix5"
86
+ ```
87
+
88
+ > [!NOTE]
89
+ > This basic setup uses the default configuration settings, but you can customize them
90
+ > *globally* or *per-model* to better suit your application's needs. For more information on configuring
91
+ > Badged IDs, refer to the [Configuration](#configuration) section.
92
+
93
+ ### Find any record by Badged ID
94
+
95
+ You can easily find any record by its Badged ID using `BadgedIds.find`. Just provide the full badged ID (including the badge) to retrieve the corresponding record.
96
+
97
+ ```ruby
98
+ BadgedIds.find("ord_24t6pjz7wpecix5")
99
+ # => #<Order id: "ord_24t6pjz7wpecix5", ...>
100
+
101
+ BadgedIds.find("usr_y0m4ud4iy94sq2y")
102
+ # => #<User id: "usr_y0m4ud4iy94sq2y", ...>
103
+ ```
104
+
105
+ ### Manually generate Badged IDs for a model
106
+
107
+ If you need to generate Badged IDs manually—such as when performing batch inserts or processing
108
+ records in bulk—you can use the `generate_badged_id` method.
109
+
110
+ ```ruby
111
+ Order.generate_badged_id
112
+ # => "ord_24t6pjz7wpecix5"
113
+ ```
114
+
115
+ ## Configuration
116
+
117
+ ### Global Configuration
118
+
119
+ Various configuration options are available to adjust the behavior to your application's needs.
120
+ You can define global settings by creating or modifying the `config/initializers/badged_ids.rb` file
121
+ in your Rails application.
122
+
123
+ #### Example
124
+
125
+ ```ruby
126
+ # config/initializers/badged_ids.rb
127
+ BadgedIds.configure do |config|
128
+ config.alphabet = "abc123"
129
+ config.delimiter = "-"
130
+ config.minimum_length = 20
131
+ config.max_generation_attempts = 3
132
+ config.skip_uniqueness_check = false
133
+ config.implicit_order_column = :created_at
134
+ end
135
+ ```
136
+
137
+ #### Available Configuration Options
138
+
139
+ The table below details all available configuration options and their default values:
140
+
141
+ | Config | Description | Default |
142
+ | ------------------------ | ----------------------------------------------------------------------------- | ---------------------------------------- |
143
+ | alphabet | Characters used to generate the random part of the ID. | `"abcdefghijklmnopqrstuvwxyz0123456789"` |
144
+ | delimiter | String separating the badge from the random string in the ID. | `"_"` |
145
+ | minimum_length | Length of the random part of the ID, excluding the badge and delimiter. | `15` |
146
+ | max_generation_attemptsÂą | Maximum attempts to generate a unique ID before raising an error. | `1` |
147
+ | skip_uniqueness_check² | Skip uniqueness check and retry mechanism when generating a new ID. | `false` |
148
+ | implicit_order_column | Default column used for ordering records of model, if not explicitly defined. | `nil` |
149
+
150
+ Âą It is recommended to leave this value as-is. If you encounter collisions, it's likely that the
151
+ `minimum_length` is too short and/or the `alphabet` contains too few unique characters.
152
+ When configured properly, the chances of a collision are virtually zero.
153
+ If you're uncertain, it's best to stick with the default values.
154
+
155
+ ² You can skip the uniqueness check if you're confident that the combination of `minimum_length`
156
+ and `alphabet` ensures that collisions are virtually impossible. This improves performance when creating thousands
157
+ of records at once (see [Benchmarks](#benchmarks)).
158
+
159
+ ### Model Overrides
160
+
161
+ When defining a model with `has_badged_id`, you can override specific settings for that model. These
162
+ options allow you to fine-tune the ID generation without affecting the global configuration.
163
+
164
+ #### Example
165
+
166
+ ```ruby
167
+ class Order < ApplicationRecord
168
+ has_badged_id :ord, id_field: :public_id, alphabet: "ABCDEF0123456789", minimum_length: 20
169
+ end
170
+
171
+ Order.create(/* ... */).public_id
172
+ # => "ord_E19D5E5ADB476416C1F6"
173
+ ```
174
+
175
+ #### Available Model Overrides
176
+
177
+ The table below details all available options that can be overridden on a per-model basis and their default values:
178
+
179
+ | Option | Description | Default |
180
+ | ----------------------- | ----------------------------------------------------------------------------- | ---------------------------------- |
181
+ | id_field | Database field used for storing the ID. Must be of type `string`. | `model.primary_key` (usually `id`) |
182
+ | alphabet | Characters used to generate the random part of the ID. | `config.alphabet` |
183
+ | minimum_length | Length of the random part of the ID, excluding the badge and delimiter. | `config.minimum_length` |
184
+ | max_generation_attempts | Maximum attempts to generate a unique ID before raising an error. | `config.max_generation_attempts` |
185
+ | skip_uniqueness_check | Skip uniqueness check and retry mechanism when generating a new ID. | `config.skip_uniqueness_check` |
186
+ | implicit_order_column | Default column used for ordering records of model, if not explicitly defined. | `config.implicit_order_column` |
187
+
188
+ ### Configuration Validation
189
+
190
+ Both global and model-specific configurations are validated at two critical points: during
191
+ application startup and immediately before generating a new ID. If any configuration is invalid,
192
+ an error is raised with a detailed message on how to resolve the issue.
193
+
194
+ ## Benchmarks
195
+
196
+ The following benchmarks compare the performance of Badged IDs with other common ID generation
197
+ methods when creating 20,000 simple records on my local machine.
198
+
199
+ | | user | system | total | real |
200
+ | --------------------------------------------- | --------- | -------- | --------- | --------- |
201
+ | Sequential integer id | 15.266384 | 1.211182 | 16.477566 | 21.128873 |
202
+ | UUIDv4 | 15.324471 | 1.315743 | 16.640214 | 21.262059 |
203
+ | Badged ID<br>(`skip_uniqueness_check: true`) | 15.898291 | 1.245899 | 17.144190 | 21.599303 |
204
+ | Badged ID<br>(`skip_uniqueness_check: false`) | 22.520556 | 1.696816 | 24.217372 | 30.400110 |
205
+
206
+ ### Conclusion
207
+
208
+ When using Badged IDs with `skip_uniqueness_check: true`, the performance is slightly slower than
209
+ that of sequential integer IDs or UUIDs, but the difference is minimal. This configuration is ideal
210
+ when the `alphabet` and `minimum_length` ensure that collisions are virtually impossible.
211
+
212
+ When using Badged IDs with `skip_uniqueness_check: false`, the performance is notably slower
213
+ than that of sequential integer IDs or UUIDs. This configuration is only recommended when the risk
214
+ of collision is a realistic concern due to a short `alphabet` and/or `minimum_length` that, for
215
+ whatever reason, cannot be adjusted. In such cases, verifying the uniqueness of the generated ID
216
+ before saving and retrying in the event of a collision becomes necessary.
217
+
218
+ For most use cases, especially when not creating thousands of records at once,
219
+ the performance impact is negligible, regardless of the `skip_uniqueness_check` config.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+ require "rspec/core/rake_task"
8
+
9
+ RSpec::Core::RakeTask.new(:spec)
10
+
11
+ task default: %i[spec]
@@ -0,0 +1,44 @@
1
+ module BadgedIds
2
+ class BadgedId
3
+ attr_reader :badge, :id_field, :config
4
+
5
+ def initialize(model, badge, options = {})
6
+ @badge = badge.to_s
7
+ @id_field = options.fetch(:id_field, model.primary_key).to_s
8
+ @config = build_config(options)
9
+ end
10
+
11
+ def generate_id
12
+ validate!
13
+ "#{badge}#{config.delimiter}#{generate_random_part}"
14
+ end
15
+
16
+ def validate!
17
+ config.validate!
18
+ validate_badge_delimiter_combination
19
+ true
20
+ end
21
+
22
+ private
23
+
24
+ def build_config(options)
25
+ BadgedIds.config.dup.tap do |config|
26
+ options.slice(*Configuration::OVERRIDABLE_CONFIGS_PER_MODEL).each do |key, value|
27
+ config.public_send("#{key}=", value)
28
+ end
29
+ end
30
+ end
31
+
32
+ def generate_random_part
33
+ SecureRandom.alphanumeric(config.minimum_length, chars: config.alphabet.chars)
34
+ end
35
+
36
+ def validate_badge_delimiter_combination
37
+ overlapping_chars = badge.chars & config.delimiter.chars
38
+ return if overlapping_chars.empty?
39
+
40
+ formatted_chars = overlapping_chars.map { |char| "`#{char}`" }.join(", ")
41
+ raise ConfigError, "Badge and delimiter cannot share characters: #{formatted_chars}."
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,66 @@
1
+ module BadgedIds
2
+ class Configuration
3
+ CONFIG_DEFAULTS = {
4
+ delimiter: "_",
5
+ alphabet: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
6
+ minimum_length: 24,
7
+ implicit_order_column: nil,
8
+ max_generation_attempts: 1,
9
+ skip_uniqueness_check: false
10
+ }.freeze
11
+
12
+ OVERRIDABLE_CONFIGS_PER_MODEL = CONFIG_DEFAULTS.keys - %i[delimiter]
13
+
14
+ attr_accessor(*CONFIG_DEFAULTS.keys)
15
+
16
+ def initialize
17
+ CONFIG_DEFAULTS.each { |key, value| instance_variable_set("@#{key}", value) }
18
+ end
19
+
20
+ def validate!
21
+ %i[
22
+ validate_delimiter
23
+ validate_alphabet
24
+ validate_alphabet_delimiter_combination
25
+ validate_minimum_length
26
+ validate_max_generation_attempts
27
+ ].each { |method| send(method) }
28
+ true
29
+ end
30
+
31
+ def to_h
32
+ CONFIG_DEFAULTS.keys.each_with_object({}) { |key, hash| hash[key] = public_send(key) }
33
+ end
34
+
35
+ def reset_to_defaults!
36
+ CONFIG_DEFAULTS.each { |key, value| public_send("#{key}=", value) }
37
+ end
38
+
39
+ private
40
+
41
+ def validate_delimiter
42
+ raise ConfigError, "Delimiter cannot be blank." if delimiter.to_s.strip.empty?
43
+ end
44
+
45
+ def validate_alphabet
46
+ raise ConfigError, "Alphabet cannot be blank." if alphabet.to_s.strip.empty?
47
+ raise ConfigError, "Alphabet must contain at least two unique characters." if alphabet.chars.uniq.size < 2
48
+ end
49
+
50
+ def validate_alphabet_delimiter_combination
51
+ overlapping_chars = delimiter.chars & alphabet.chars
52
+ return if overlapping_chars.empty?
53
+
54
+ formatted_chars = overlapping_chars.map { |char| "`#{char}`" }.join(", ")
55
+ raise ConfigError, "Alphabet and delimiter cannot share characters: #{formatted_chars}."
56
+ end
57
+
58
+ def validate_minimum_length
59
+ raise ConfigError, "Minimum length must be greater than 0." if minimum_length < 1
60
+ end
61
+
62
+ def validate_max_generation_attempts
63
+ raise ConfigError, "Max generation attempts must be greater than 0." if max_generation_attempts < 1
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,5 @@
1
+ module BadgedIds
2
+ class Error < StandardError; end
3
+ class RegistryError < Error; end
4
+ class ConfigError < Error; end
5
+ end
@@ -0,0 +1,47 @@
1
+ require "badged_ids/badged_id"
2
+
3
+ module BadgedIds
4
+ module Rails
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :_badged_id
9
+ end
10
+
11
+ class_methods do
12
+ def has_badged_id(badge, **options)
13
+ self._badged_id = BadgedId.new(self, badge, **options)
14
+ Registry.register(badge, self)
15
+
16
+ before_create { self[_badged_id.id_field] ||= self.class.generate_badged_id }
17
+
18
+ if _badged_id.config.implicit_order_column.present? && implicit_order_column.nil?
19
+ self.implicit_order_column = _badged_id.config.implicit_order_column
20
+ end
21
+
22
+ define_singleton_method(:generate_badged_id) do
23
+ return _badged_id.generate_id if _badged_id.config.skip_uniqueness_check
24
+
25
+ generated_ids = Set.new
26
+ attempts = 0
27
+
28
+ loop do
29
+ generated_id = _badged_id.generate_id
30
+
31
+ unless generated_ids.include?(generated_id)
32
+ generated_ids.add(generated_id)
33
+ return generated_id unless exists?(id: generated_id)
34
+ end
35
+
36
+ if (attempts += 1) >= _badged_id.config.max_generation_attempts
37
+ raise Error, <<~MESSAGE.squish
38
+ Failed to generate a unique badged ID within #{_badged_id.config.max_generation_attempts} attempts.
39
+ Consider increasing the minimum length or the unique characters in the alphabet.
40
+ MESSAGE
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,9 @@
1
+ module BadgedIds
2
+ class Railtie < ::Rails::Railtie
3
+ initializer "badged_ids.extend_active_record" do
4
+ ActiveSupport.on_load(:active_record) do
5
+ include BadgedIds::Rails
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ module BadgedIds
2
+ class Registry
3
+ mattr_accessor :models, default: {}
4
+
5
+ class << self
6
+ def register(badge, model)
7
+ badge = badge.to_s
8
+ if models.key?(badge) && models[badge] != model
9
+ raise RegistryError, "Badge `#{badge}` is already assigned to `#{models[badge]}`."
10
+ end
11
+
12
+ models[badge] = model
13
+ end
14
+
15
+ def find_model(badge)
16
+ models.fetch(badge.to_s) do
17
+ raise RegistryError, <<~MESSAGE.squish
18
+ No model with the badge `#{badge}` registered.
19
+ Available badges are: #{registered_badges.join(", ")}.
20
+ MESSAGE
21
+ end
22
+ end
23
+
24
+ def registered_badges
25
+ models.keys
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,3 @@
1
+ module BadgedIds
2
+ VERSION = "1.0.0"
3
+ end
data/lib/badged_ids.rb ADDED
@@ -0,0 +1,33 @@
1
+ require "badged_ids/version"
2
+ require "badged_ids/railtie"
3
+ require "badged_ids/errors"
4
+ require "badged_ids/registry"
5
+ require "badged_ids/rails"
6
+ require "badged_ids/configuration"
7
+
8
+ module BadgedIds
9
+ @config = Configuration.new
10
+
11
+ class << self
12
+ def config
13
+ if block_given?
14
+ yield @config
15
+ @config.validate!
16
+ else
17
+ @config
18
+ end
19
+ end
20
+
21
+ def find(badged_id)
22
+ badge, _id = split_id(badged_id)
23
+ Registry.find_model(badge).find(badged_id)
24
+ end
25
+
26
+ private
27
+
28
+ def split_id(badged_id, delimiter = config.delimiter)
29
+ badge, _, id = badged_id.to_s.rpartition(delimiter)
30
+ [ badge, id ]
31
+ end
32
+ end
33
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: badged_ids
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Fabian Schwarz
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-01-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 6.0.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 6.0.0
27
+ description: Badged-IDs generates and persists unguessable IDs with friendly prefixes
28
+ for your models
29
+ email:
30
+ - fabian12943@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - MIT-LICENSE
36
+ - README.md
37
+ - Rakefile
38
+ - lib/badged_ids.rb
39
+ - lib/badged_ids/badged_id.rb
40
+ - lib/badged_ids/configuration.rb
41
+ - lib/badged_ids/errors.rb
42
+ - lib/badged_ids/rails.rb
43
+ - lib/badged_ids/railtie.rb
44
+ - lib/badged_ids/registry.rb
45
+ - lib/badged_ids/version.rb
46
+ homepage: https://github.com/fabian12943/badged_ids
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://github.com/fabian12943/badged_ids
51
+ source_code_uri: https://github.com/fabian12943/badged_ids
52
+ changelog_uri: https://github.com/fabian12943/badged_ids/blob/main/CHANGELOG.md
53
+ post_install_message:
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 2.7.0
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.5.3
69
+ signing_key:
70
+ specification_version: 4
71
+ summary: Badged-IDs generates and persists unguessable IDs with friendly prefixes
72
+ for your models
73
+ test_files: []