has_public_record 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dadeae14d61a8a853dff01a3fc146bf16903fde443c4863a537987ccb9edaa04
4
+ data.tar.gz: 7d348eb1f8c4f76be37625666c2e99ecbb824355189eb4cdaa8796058c7bcceb
5
+ SHA512:
6
+ metadata.gz: 402e8cd8f72ffc69f45d326cae433f28b3e13aad4fce4de6bd99ca55192976c4346e01e51eb2eab1e37577246e85dd8324e305fd73ace87841f48f0322ebcee9
7
+ data.tar.gz: 292344f738d26c9a99e4eb4bb020dcbbbc51da61626cbb7e5d5fbb15d781d65886f21b1ef96ec31c1c36a71ebf02c91991bb287a63d5b5d3f0076ff95e22a770
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release: prefixed public ids with length growth, lookup helpers, resolve and resolve_many, a standalone
6
+ generator, migration and install generators, backfill / report / rename tasks, and testing helpers.
data/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # has_public_record
2
+
3
+ Short, prefixed, URL-safe public ids for ActiveRecord models: `usr_K3F9A2`. The prefix says what the id
4
+ belongs to, the random part is short, and it grows by itself as a table fills.
5
+
6
+ ~~~ruby
7
+ class User < ApplicationRecord
8
+ has_public_record prefix: "usr"
9
+ end
10
+
11
+ user = User.create!
12
+ user.public_id # => "usr_K3F9A2"
13
+ user.public_id_bare # => "K3F9A2"
14
+ user_path(user) # => "/users/usr_K3F9A2" (to_param)
15
+ User.find_by_public_id!("usr_K3F9A2")
16
+ ~~~
17
+
18
+ ## Why prefixed, and why not the database id
19
+
20
+ Database ids leak counts and invite enumeration. A prefixed id is safe to put in URLs and logs, and the prefix
21
+ lets you resolve an id with no other context: `HasPublicRecord.resolve("usr_K3F9A2")` returns the `User`.
22
+
23
+ ## Options
24
+
25
+ ~~~ruby
26
+ has_public_record prefix: "usr", # required, unique across the app: a-z and 0-9, starts with a letter, max 10
27
+ column: :public_id, # where the id is stored
28
+ length: 8, # starting length of the random part for this model
29
+ max_bumps: 4 # how far the length may grow before ExhaustedError
30
+ ~~~
31
+
32
+ Declare it on the base class of an STI hierarchy. Declaring costs no database access, so it is safe while the
33
+ app boots.
34
+
35
+ ## Methods
36
+
37
+ On the model:
38
+
39
+ | Method | What it does |
40
+ | --- | --- |
41
+ | `find_by_public_id(ref)` | Finds by `usr_K3F9A2` or the bare `K3F9A2`; another model's id or junk gives nil |
42
+ | `find_by_public_id!(ref)` | Same, raises `ActiveRecord::RecordNotFound` |
43
+ | `with_public_id(*refs)` | A relation for several ids; ids of other models are ignored |
44
+ | `public_id?(ref)` | Whether the ref belongs to this model |
45
+ | `public_id_prefix`, `public_id_column` | What the model declared |
46
+ | `missing_public_id` | Rows with no id yet |
47
+ | `generate_public_id` | A fresh unused id |
48
+ | `backfill_public_ids!` | Gives every row without an id one; returns the count |
49
+
50
+ On a record: `public_id`, `public_id_bare`, `public_id_prefix`, `to_param`.
51
+
52
+ On the module:
53
+
54
+ | Method | What it does |
55
+ | --- | --- |
56
+ | `HasPublicRecord.resolve(ref)` / `resolve!` | The record a ref points at, or nil / raises |
57
+ | `HasPublicRecord.model_for(ref)` | The model class for a ref |
58
+ | `HasPublicRecord.parse(ref)` | `Parsed(prefix:, bare:)` or nil |
59
+ | `HasPublicRecord.valid?(ref)` | Whether the ref is shaped like an id |
60
+ | `HasPublicRecord.prefixes` | `{ "usr" => "User", ... }` |
61
+ | `HasPublicRecord.prefix_for(model)` | The prefix a model declared |
62
+ | `HasPublicRecord.generate(...)` | Makes an id for a scope |
63
+
64
+ The id is read-only once created. `insert_all` and `upsert_all` skip callbacks, so rows inserted that way get
65
+ no id; fill them with `backfill_public_ids!`.
66
+
67
+ ## Length growth
68
+
69
+ Ids start at `default_length` (6 by default, about 2 billion codes). When random tries keep hitting existing
70
+ codes, the length grows by one and is remembered per table and column for the life of the process. Each growth
71
+ is announced (see Notifications), so you can see a table getting dense before it matters.
72
+
73
+ ## Configuration
74
+
75
+ ~~~ruby
76
+ # config/initializers/has_public_record.rb (rails g has_public_record:install writes it, all commented out)
77
+ HasPublicRecord.configure do |config|
78
+ config.default_length = 6
79
+ config.attempts_per_length = 10
80
+ config.max_length_bumps = 4
81
+ config.alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 2+ distinct, no "_", no whitespace
82
+ config.lengths = { "users" => 8 } # per table; beats a model's length:
83
+ end
84
+ ~~~
85
+
86
+ Settings are read when an id is made, never copied at load, so an app that keeps its settings elsewhere can
87
+ simply assign them from its own boot code. Prefixes are code, not settings.
88
+
89
+ ## Adding it to an existing table
90
+
91
+ Two steps, so a populated table never needs a NOT NULL column it cannot satisfy:
92
+
93
+ ~~~zsh
94
+ rails g has_public_record:migration users # nullable column + unique index
95
+ rails db:migrate
96
+ rails has_public_record:backfill MODEL=User # gives every row an id
97
+ rails has_public_record:report # all clean?
98
+ rails g has_public_record:migration users --enforce # now NOT NULL
99
+ rails db:migrate
100
+ ~~~
101
+
102
+ Options: `--column=name`, and `--concurrently` (PostgreSQL only: builds the index without locking writes).
103
+ For new tables, `t.string :public_id, null: false, index: { unique: true }` is enough.
104
+
105
+ ## Rake tasks
106
+
107
+ | Task | What it does |
108
+ | --- | --- |
109
+ | `has_public_record:backfill` | Fills missing ids (`MODEL=User` to limit) |
110
+ | `has_public_record:report` | Counts missing ids, wrong prefixes and duplicates, and shows how full each table's code space is; exits 1 if anything is off |
111
+ | `has_public_record:rename MODEL=User FROM=old` | Previews moving stored ids from an old prefix to the declared one; add `APPLY=1` to do it, in one transaction |
112
+
113
+ Renaming a prefix breaks every stored URL and any id saved in another table.
114
+
115
+ ## Notifications
116
+
117
+ Through ActiveSupport::Notifications, never a logger of its own:
118
+
119
+ - `grow.has_public_record` with `key`, `table`, `column` (both nil for a standalone generator), `from`, `to`
120
+ - `rename.has_public_record` with `table`, `column`, `from`, `to`, `count`
121
+
122
+ ## Engines
123
+
124
+ Works inside an engine. Prefixes are global per process, so choose distinctive ones. An engine should not call
125
+ `HasPublicRecord.configure` (it is process-wide); use the per-model `length:` and `max_bumps:` instead.
126
+
127
+ ## Lookup helpers
128
+
129
+ Ids are matched in any letter case (`usr_k3f9a2` finds `usr_K3F9A2`) when the alphabet has one case; an alphabet
130
+ with both cases is left as typed.
131
+
132
+ | Helper | What it does |
133
+ | --- | --- |
134
+ | `User.from_param(params[:id])` / `from_param!` | What a controller has: nil-safe and raising versions |
135
+ | `User.public_id_format` | A regexp for route constraints: `resources :users, constraints: { id: User.public_id_format }` |
136
+ | `HasPublicRecord.resolve_many(*refs)` | Mixed refs (an audit log) in one query per model; returns `{ ref => record }` |
137
+
138
+ ## Ids without a model
139
+
140
+ The generator needs no model. Whether a code is free is a block:
141
+
142
+ ~~~ruby
143
+ HasPublicRecord.generate("inv", length: 8) { |id| !Invoice.exists?(number: id) } # => "inv_7K2QX9AB"
144
+ HasPublicRecord.generate(nil, length: 5, key: "codes") { |id| !Redis.exists?(id) } # bare code: key names its growth
145
+
146
+ invoices = HasPublicRecord.identifier("inv", length: 8) { |id| !Invoice.exists?(number: id) }
147
+ invoices.generate # grows and notifies exactly like models do
148
+ invoices.valid?("inv_7K2QX9AB") # => true
149
+ invoices.bare("inv_7K2QX9AB") # => "7K2QX9AB"
150
+ ~~~
151
+
152
+ Standalone prefixes are not registered for `HasPublicRecord.resolve`; only models are. A prefix a model already
153
+ declared is refused.
154
+
155
+ ## Testing
156
+
157
+ Rails fixtures and `insert_all` skip callbacks, so rows get no id unless you supply one:
158
+
159
+ ~~~ruby
160
+ # fixtures: the same label always gives the same id
161
+ alice:
162
+ public_id: <%= User.fixture_public_id(:alice) %>
163
+
164
+ # bulk inserts
165
+ User.insert_all(User.with_generated_public_ids(rows))
166
+ ~~~
167
+
168
+ ## Alphabets
169
+
170
+ `config.alphabet = :unambiguous` leaves out 0/O and 1/I/L, which makes ids easier to read aloud. The default
171
+ is `0-9A-Z`.
172
+
173
+ ## Associations
174
+
175
+ Plain Rails associations are enough. Keep integer foreign keys inside the system and use the public id only at
176
+ the edge, scoped through the owner so one person cannot reach another's records:
177
+
178
+ ~~~ruby
179
+ current_user.sessions.find_by_public_id!(params[:id])
180
+ ~~~
181
+
182
+ ## Capacity
183
+
184
+ `has_public_record:report` shows how full each table's code space is. Growth starts well before 100%: at half
185
+ full, ten random tries in a row all hit taken codes about once in a thousand.
186
+
187
+ ## License
188
+
189
+ MIT.
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module HasPublicRecord
6
+ module Generators
7
+ # rails g has_public_record:install -> config/initializers/has_public_record.rb
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+ desc "Creates config/initializers/has_public_record.rb with every setting, commented out."
11
+
12
+ def create_initializer
13
+ @defaults = HasPublicRecord::Configuration.new
14
+ template "initializer.rb.tt", "config/initializers/has_public_record.rb"
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :defaults
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # has_public_record settings. Everything is commented out and shows its default: uncomment what you want to change.
4
+ # Prefixes are not settings: each model declares its own (has_public_record prefix: "usr").
5
+
6
+ HasPublicRecord.configure do |config|
7
+ # Starting length of the random part of an id (the prefix is extra). Grows by itself on dense tables.
8
+ # config.default_length = <%= defaults.default_length %>
9
+
10
+ # Random tries at one length before it grows by one.
11
+ # config.attempts_per_length = <%= defaults.attempts_per_length %>
12
+
13
+ # How many times the length may grow before ExhaustedError is raised.
14
+ # config.max_length_bumps = <%= defaults.max_length_bumps %>
15
+
16
+ # Characters used in ids: 2+ distinct, no "_" and no whitespace.
17
+ # config.alphabet = "<%= defaults.alphabet %>" # or a preset: :unambiguous (no 0/O, 1/I/L)
18
+
19
+ # Starting length per table; beats a model's own length: option. Keys are table names.
20
+ # config.lengths = { "users" => 8 }
21
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module HasPublicRecord
7
+ module Generators
8
+ # rails g has_public_record:migration users adds the nullable column and its unique index
9
+ # rails g has_public_record:migration users --enforce later: makes it NOT NULL, once every row has an id
10
+ class MigrationGenerator < ActiveRecord::Generators::Base
11
+ include ActiveRecord::Generators::Migration
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+ desc "Adds a public id column to an existing table (give the table name, e.g. users)."
15
+
16
+ class_option :column, type: :string, default: "public_id", desc: "Column name"
17
+ class_option :enforce, type: :boolean, default: false, desc: "Make the column NOT NULL (run after the backfill)"
18
+ class_option :concurrently, type: :boolean, default: false,
19
+ desc: "PostgreSQL only: build the index without locking writes"
20
+
21
+ def create_migration_file
22
+ migration_template(options[:enforce] ? "enforce.rb.tt" : "add.rb.tt",
23
+ File.join(db_migrate_path, "#{migration_name}.rb"))
24
+ end
25
+
26
+ private
27
+
28
+ def public_column = options[:column]
29
+
30
+ def migration_name
31
+ options[:enforce] ? "enforce_#{public_column}_on_#{table_name}" : "add_#{public_column}_to_#{table_name}"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adds the public id column (nullable) and its unique index. Then fill existing rows:
4
+ # rails has_public_record:backfill MODEL=YourModel
5
+ # and, once every row has an id (rails has_public_record:report), make it required:
6
+ # rails g has_public_record:migration <%= table_name %> --enforce
7
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
8
+ <% if options[:concurrently] -%>
9
+ disable_ddl_transaction!
10
+
11
+ <% end -%>
12
+ def change
13
+ add_column :<%= table_name %>, :<%= public_column %>, :string
14
+ add_index :<%= table_name %>, :<%= public_column %>, unique: true<%= ", algorithm: :concurrently" if options[:concurrently] %>
15
+ end
16
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Run after the backfill: fails if any row still has no id.
4
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
5
+ def change
6
+ change_column_null :<%= table_name %>, :<%= public_column %>, false
7
+ end
8
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HasPublicRecord
4
+ # HasPublicRecord.configure { |c| c.default_length = 8 }
5
+ class Configuration
6
+ DEFAULT_ALPHABET = (("0".."9").to_a + ("A".."Z").to_a).join.freeze
7
+ # No 0/O or 1/I/L: easier to read aloud and to type from a screenshot.
8
+ UNAMBIGUOUS = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
9
+ PRESETS = { default: DEFAULT_ALPHABET, unambiguous: UNAMBIGUOUS }.freeze
10
+
11
+ attr_reader :default_length, :attempts_per_length, :max_length_bumps, :alphabet, :lengths
12
+
13
+ def initialize = reset!
14
+
15
+ def reset!
16
+ @default_length = 6 # starting length of the random part (the prefix is extra)
17
+ @attempts_per_length = 10 # random tries before the length grows by one
18
+ @max_length_bumps = 4 # growth steps before ExhaustedError
19
+ @alphabet = DEFAULT_ALPHABET
20
+ @lengths = {}.freeze # per-table starting length: { "users" => 8 }
21
+ self
22
+ end
23
+
24
+ def default_length=(value)
25
+ @default_length = integer!(value, :default_length, min: 1)
26
+ end
27
+
28
+ def attempts_per_length=(value)
29
+ @attempts_per_length = integer!(value, :attempts_per_length, min: 1)
30
+ end
31
+
32
+ def max_length_bumps=(value)
33
+ @max_length_bumps = integer!(value, :max_length_bumps, min: 0)
34
+ end
35
+
36
+ def alphabet=(value)
37
+ if value.is_a?(Symbol)
38
+ value = PRESETS.fetch(value) { raise ArgumentError, "unknown alphabet preset #{value.inspect} (expected #{PRESETS.keys.join(', ')})" }
39
+ end
40
+ chars = value.to_s.each_char.uniq
41
+ raise ArgumentError, "alphabet needs at least 2 distinct characters" if chars.size < 2
42
+ if chars.any? { |char| char == SEPARATOR || char.match?(/\s/) }
43
+ raise ArgumentError, "alphabet cannot contain #{SEPARATOR.inspect} or whitespace"
44
+ end
45
+
46
+ @alphabet = chars.join.freeze
47
+ end
48
+
49
+ def lengths=(value)
50
+ @lengths = value.to_h.to_h { |table, length| [table.to_s, integer!(length, "lengths[#{table}]", min: 1)] }.freeze
51
+ end
52
+
53
+ private
54
+
55
+ def integer!(value, name, min:)
56
+ number = Integer(value)
57
+ raise ArgumentError, "#{name} must be at least #{min}" if number < min
58
+
59
+ number
60
+ rescue TypeError, ArgumentError => e
61
+ raise ArgumentError, e.message.start_with?(name.to_s) ? e.message : "#{name}: #{e.message}"
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "concurrent/map"
5
+
6
+ module HasPublicRecord
7
+ # Makes codes. Starts at a length (config.lengths[name] > the explicit length > config.default_length) and grows
8
+ # when random tries keep hitting taken codes, remembering the grown length per key (per process). Growth is
9
+ # announced with "grow.has_public_record" (key, table, column, from, to; table and column only for a scope).
10
+ #
11
+ # Whether a code is free is a callable, so nothing here needs a model:
12
+ # Generator.generate(prefix: "inv", length: 8) { |id| !Redis.exists?(id) }
13
+ # Generator.generate(prefix: "usr", scope: User.unscoped, column: :public_id) # what models use
14
+ module Generator
15
+ EVENT = "grow.has_public_record"
16
+ LEARNED = Concurrent::Map.new # key => length reached after growing
17
+
18
+ class << self
19
+ def random(length:, alphabet: HasPublicRecord.config.alphabet)
20
+ chars = alphabet.chars
21
+ Array.new(length) { chars[SecureRandom.random_number(chars.size)] }.join
22
+ end
23
+
24
+ # Returns the full id ("usr_K3F9A2"), or the bare code when prefix is nil (then key: is required: it names
25
+ # the learned length). The block receives a candidate and answers true when it is free.
26
+ def generate(prefix: nil, length: nil, max_bumps: nil, key: nil, scope: nil, column: nil, &free)
27
+ prefix = check_prefix(prefix)
28
+ free, key, table = source_for(prefix, key, scope, column, free)
29
+ config = HasPublicRecord.config
30
+ limit = max_bumps || config.max_length_bumps
31
+ current = [starting_length(table || key, length), LEARNED[key].to_i].max
32
+ bumps = 0
33
+
34
+ loop do
35
+ config.attempts_per_length.times do
36
+ candidate = compose(prefix, random(length: current))
37
+ return candidate if free.call(candidate)
38
+ end
39
+
40
+ if bumps >= limit
41
+ raise ExhaustedError, "no unique code after #{bumps} length bumps (length #{current}) for #{key}"
42
+ end
43
+
44
+ bumps += 1
45
+ from = current
46
+ current += 1
47
+ learn(key, current)
48
+ ActiveSupport::Notifications.instrument(EVENT, key: key, table: table, column: column, from: from, to: current)
49
+ end
50
+ end
51
+
52
+ # The length a name (a table, or a standalone key) starts at.
53
+ def starting_length(name, explicit)
54
+ configured = HasPublicRecord.config.lengths[name.to_s]
55
+ [Integer(configured || explicit || HasPublicRecord.config.default_length), 1].max
56
+ end
57
+
58
+ # nil stays nil; anything else must be a well-formed prefix.
59
+ def check_prefix(prefix)
60
+ return nil if prefix.nil?
61
+
62
+ text = prefix.to_s
63
+ return text if PREFIX_FORMAT.match?(text)
64
+
65
+ raise InvalidPrefixError, "invalid prefix #{text.inspect} (expected #{PREFIX_FORMAT.inspect})"
66
+ end
67
+
68
+ def reset_learned! = LEARNED.clear
69
+
70
+ private
71
+
72
+ def source_for(prefix, key, scope, column, free)
73
+ if scope
74
+ raise ArgumentError, "give a block or scope:, not both" if free
75
+ raise ArgumentError, "scope: needs column:" unless column
76
+
77
+ table = scope.table_name.to_s
78
+ [->(candidate) { !scope.exists?(column => candidate) }, (key || "#{table}.#{column}").to_s, table]
79
+ else
80
+ raise ArgumentError, "give a block that says whether an id is free (or scope: and column:)" unless free
81
+
82
+ key ||= prefix
83
+ raise ArgumentError, "a generator with no prefix needs key: to name its growth" if key.nil?
84
+
85
+ [free, key.to_s, nil]
86
+ end
87
+ end
88
+
89
+ def compose(prefix, bare) = prefix ? "#{prefix}#{SEPARATOR}#{bare}" : bare
90
+
91
+ # Atomic per key: a smaller length never overwrites a larger one.
92
+ def learn(key, length) = LEARNED.compute(key) { |learned| [learned.to_i, length].max }
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HasPublicRecord
4
+ # Ids without a model: order numbers, API keys, Redis keys.
5
+ #
6
+ # invoices = HasPublicRecord.identifier("inv", length: 8) { |id| !Invoice.exists?(number: id) }
7
+ # invoices.generate # => "inv_7K2QX9AB"
8
+ # invoices.valid?("inv_7k2qx9ab") # => true
9
+ # invoices.bare("inv_7K2QX9AB") # => "7K2QX9AB"
10
+ #
11
+ # The prefix is optional (then key: names the generator), but is not registered for HasPublicRecord.resolve:
12
+ # only models are. A prefix a model already declared is refused (best effort: models not loaded yet are unknown).
13
+ class Identifier
14
+ attr_reader :prefix, :key, :length, :max_bumps
15
+
16
+ def initialize(prefix = nil, length: nil, max_bumps: nil, key: nil, &free)
17
+ raise ArgumentError, "an identifier needs a block that says whether an id is free" unless free
18
+
19
+ @prefix = Generator.check_prefix(prefix)
20
+ if @prefix && (owner = HasPublicRecord.registry[@prefix])
21
+ raise DuplicatePrefixError, "prefix #{@prefix.inspect} is already used by #{owner}"
22
+ end
23
+
24
+ @key = (key || @prefix)&.to_s
25
+ raise ArgumentError, "an identifier with no prefix needs key:" if @key.nil?
26
+
27
+ @length = length
28
+ @max_bumps = max_bumps
29
+ @free = free
30
+ end
31
+
32
+ def generate = Generator.generate(prefix: prefix, length: length, max_bumps: max_bumps, key: key, &@free)
33
+
34
+ # The code without the prefix, case folded; nil when the ref belongs elsewhere.
35
+ def bare(ref)
36
+ canonical = HasPublicRecord.canonicalize(ref, prefix)
37
+ canonical && (prefix ? canonical.delete_prefix("#{prefix}#{SEPARATOR}") : canonical)
38
+ end
39
+
40
+ # Shaped like one of this identifier's ids: its prefix, then only alphabet characters.
41
+ def valid?(ref)
42
+ code = bare(ref)
43
+ !code.nil? && !code.empty? && code.each_char.all? { |char| HasPublicRecord.config.alphabet.include?(char) }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HasPublicRecord
4
+ # Extended onto ActiveRecord::Base, so `has_public_record` exists in every model.
5
+ module Macro
6
+ # prefix: required, unique across the app ("usr")
7
+ # column: where the id is stored (default :public_id)
8
+ # length: starting length of the random part for this model (config.lengths[table] beats it)
9
+ # max_bumps: how far the length may grow (default config.max_length_bumps)
10
+ def has_public_record(prefix:, column: :public_id, length: nil, max_bumps: nil)
11
+ include HasPublicRecord::Model
12
+ declare_public_record(prefix: prefix, column: column, length: length, max_bumps: max_bumps)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HasPublicRecord
4
+ # What the rake tasks do, kept here so it can be tested and called from a console or a deploy script.
5
+ module Maintenance
6
+ Report = Data.define(:model, :table, :column, :total, :missing, :wrong_prefix, :wrong_samples, :duplicates, :problem) do
7
+ def ok? = problem.nil? && missing.zero? && wrong_prefix.zero? && duplicates.zero?
8
+ end
9
+
10
+ # How full a table's code space is. Growth starts well before 100%: at half full, one random try in two hits
11
+ # a taken code, so ten tries in a row fail about once in a thousand.
12
+ Capacity = Data.define(:length, :slots, :used) do
13
+ def fill = used.fdiv(slots)
14
+
15
+ def to_s
16
+ "#{length} chars = #{ActiveSupport::NumberHelper.number_to_human(slots)} ids, #{format('%.4f', fill * 100)}% used"
17
+ end
18
+ end
19
+
20
+ class << self
21
+ # Every model that declared has_public_record (one per table and column), optionally only the named one.
22
+ def models(only: nil)
23
+ found = ActiveRecord::Base.descendants.select do |model|
24
+ model.respond_to?(:public_record_options) && model.public_record_options && model.name.present? &&
25
+ !model.abstract_class? && model == model.base_class
26
+ end
27
+ found = found.uniq { |model| [model.table_name, model.public_id_column] }.sort_by(&:to_s)
28
+ only ? found.select { |model| model.name == only.to_s } : found
29
+ end
30
+
31
+ def reports(only: nil) = models(only: only).map { |model| report(model) }
32
+
33
+ # Rows without an id, rows whose id does not start with this model's prefix (legacy bare codes, or another
34
+ # model's prefix), and duplicated ids (only possible when the unique index is missing).
35
+ def report(model)
36
+ table = model.table_name
37
+ column = model.public_id_column
38
+ return broken(model, "table #{table} does not exist") unless model.table_exists?
39
+ unless model.column_names.include?(column.to_s)
40
+ return broken(model, "column #{column} is missing: rails g has_public_record:migration #{table}")
41
+ end
42
+
43
+ scope = model.base_class.unscoped
44
+ present = scope.where.not(column => [nil, ""])
45
+ wrong = present.where.not(with_prefix(model))
46
+ Report.new(model: model, table: table, column: column, total: scope.count,
47
+ missing: scope.where(column => [nil, ""]).count,
48
+ wrong_prefix: wrong.count, wrong_samples: wrong.limit(3).pluck(column),
49
+ duplicates: present.group(column).having("COUNT(*) > 1").count.size, problem: nil)
50
+ end
51
+
52
+ # Moves stored ids from an old prefix to the model's declared one (change prefix: in the model first).
53
+ # Counts only unless apply: true. One transaction: all rows move or none do. Returns the rows concerned.
54
+ def rename_prefix(model, from:, apply: false, batch_size: 1000)
55
+ from = from.to_s
56
+ to = model.public_id_prefix
57
+ raise ArgumentError, "invalid prefix #{from.inspect}" unless PREFIX_FORMAT.match?(from)
58
+ raise ArgumentError, "#{from.inspect} is already the prefix #{model.name} declares" if from == to
59
+
60
+ column = model.public_id_column
61
+ old = "#{from}#{SEPARATOR}"
62
+ scope = model.base_class.unscoped.where(with_prefix(model, old))
63
+ count = scope.count
64
+ return count unless apply && count.positive?
65
+
66
+ model.transaction do
67
+ scope.find_each(batch_size: batch_size) do |record|
68
+ next unless record[column].start_with?(old) # the LIKE may be case-insensitive on some databases
69
+
70
+ renamed = "#{to}#{SEPARATOR}#{record[column].delete_prefix(old)}"
71
+ model.public_record_row_scope(record).update_all(column => renamed)
72
+ end
73
+ end
74
+ ActiveSupport::Notifications.instrument("rename.has_public_record",
75
+ table: model.table_name, column: column, from: from, to: to, count: count)
76
+ count
77
+ end
78
+
79
+ def capacity(model)
80
+ length = Generator.starting_length(model.table_name, model.public_record_options.length)
81
+ used = model.base_class.unscoped.where.not(model.public_id_column => [nil, ""]).count
82
+ Capacity.new(length: length, slots: HasPublicRecord.config.alphabet.size**length, used: used)
83
+ end
84
+
85
+ private
86
+
87
+ def broken(model, problem)
88
+ Report.new(model: model, table: model.table_name, column: model.public_id_column, total: 0, missing: 0,
89
+ wrong_prefix: 0, wrong_samples: [], duplicates: 0, problem: problem)
90
+ end
91
+
92
+ # column LIKE 'usr\_%' with the underscore escaped (it is a wildcard in LIKE).
93
+ def with_prefix(model, text = "#{model.public_id_prefix}#{SEPARATOR}")
94
+ model.base_class.arel_table[model.public_id_column].matches("#{model.sanitize_sql_like(text)}%", "\\")
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module HasPublicRecord
6
+ module Model
7
+ extend ActiveSupport::Concern
8
+
9
+ Options = Data.define(:prefix, :column, :length, :max_bumps)
10
+
11
+ included do
12
+ class_attribute :public_record_options, instance_writer: false, instance_predicate: false
13
+ end
14
+
15
+ class_methods do
16
+ # Called by the has_public_record macro. Touches no database table, so it is safe while the app boots.
17
+ def declare_public_record(prefix:, column:, length:, max_bumps:)
18
+ if public_record_options
19
+ raise HasPublicRecord::Error,
20
+ "#{name || inspect} already has has_public_record (prefix #{public_record_options.prefix.inspect}); " \
21
+ "declare it once, on the base class"
22
+ end
23
+
24
+ prefix = HasPublicRecord.registry.register(prefix, name)
25
+ self.public_record_options = Options.new(prefix: prefix, column: column.to_sym, length: length, max_bumps: max_bumps)
26
+
27
+ attr_readonly column # a public id never changes once created
28
+ before_create :assign_public_record
29
+ validate :validate_public_record_format, on: :create
30
+ end
31
+
32
+ def public_id_prefix = public_record_options.prefix
33
+ def public_id_column = public_record_options.column
34
+
35
+ # The stored form of a ref: "usr_K3F9A2". Accepts the bare code and any letter case (see HasPublicRecord.fold_case).
36
+ # nil for another model's id, for junk and for blank.
37
+ def normalize_public_id(ref) = HasPublicRecord.canonicalize(ref, public_id_prefix)
38
+
39
+ def public_id?(ref) = !normalize_public_id(ref).nil?
40
+
41
+ def find_by_public_id(ref)
42
+ id = normalize_public_id(ref)
43
+ id && find_by(public_id_column => id)
44
+ end
45
+
46
+ def find_by_public_id!(ref)
47
+ find_by_public_id(ref) ||
48
+ raise(ActiveRecord::RecordNotFound.new("Couldn't find #{name} with public id #{ref.inspect}", name, public_id_column, ref))
49
+ end
50
+
51
+ # What a controller passes in: User.from_param!(params[:id]).
52
+ def from_param(param) = find_by_public_id(param)
53
+ def from_param!(param) = find_by_public_id!(param)
54
+
55
+ # A relation for several ids at once; ids of another model are ignored.
56
+ def with_public_id(*refs)
57
+ where(public_id_column => refs.flatten.filter_map { |ref| normalize_public_id(ref) })
58
+ end
59
+
60
+ # A regexp for route constraints (no anchors, as Rails wants): resources :users, constraints: { id: User.public_id_format }
61
+ def public_id_format
62
+ alphabet = HasPublicRecord.config.alphabet
63
+ chars = Regexp.escape((alphabet.chars + alphabet.swapcase.chars).uniq.join)
64
+ /(?i:#{Regexp.escape(public_id_prefix)})#{SEPARATOR}[#{chars}]+/
65
+ end
66
+
67
+ # Rows that have no public id yet (after adding the column to an existing table).
68
+ def missing_public_id = where(public_id_column => nil)
69
+
70
+ # A fresh unused id for this model (what before_create uses).
71
+ def generate_public_id
72
+ HasPublicRecord::Generator.generate(scope: base_class.unscoped, column: public_id_column, prefix: public_id_prefix,
73
+ length: public_record_options.length, max_bumps: public_record_options.max_bumps)
74
+ end
75
+
76
+ # The one row a record is stored in, ignoring default scopes. Handles composite primary keys. A table with no
77
+ # primary key is refused: an empty condition would match every row.
78
+ def public_record_row_scope(record)
79
+ keys = Array(base_class.primary_key)
80
+ raise HasPublicRecord::Error, "#{name} has no primary key, so a single row cannot be addressed" if keys.empty?
81
+
82
+ base_class.unscoped.where(keys.zip(Array(record.id)).to_h)
83
+ end
84
+
85
+ # Gives every row without an id one; returns how many. Plain SQL updates: the column is readonly through the
86
+ # model, and callbacks and updated_at stay untouched.
87
+ def backfill_public_ids!(batch_size: 1000)
88
+ count = 0
89
+ missing_public_id.find_each(batch_size: batch_size) do |record|
90
+ count += public_record_row_scope(record).update_all(public_id_column => generate_public_id)
91
+ end
92
+ count
93
+ end
94
+
95
+ # insert_all / upsert_all skip callbacks, so rows get no id. This fills it in, one distinct id per row, and
96
+ # keeps ids you supplied: User.insert_all(User.with_generated_public_ids(rows))
97
+ def with_generated_public_ids(rows)
98
+ column = public_id_column
99
+ taken = Set.new
100
+ rows.map do |row|
101
+ row = row.to_h.symbolize_keys
102
+ if row[column].present?
103
+ taken << row[column]
104
+ next row
105
+ end
106
+
107
+ id = generate_public_id
108
+ id = generate_public_id while taken.include?(id) # the database probe cannot see the rest of this batch
109
+ taken << id
110
+ row.merge(column => id)
111
+ end
112
+ end
113
+
114
+ # A stable id for a fixture label, for ERB in fixtures (Rails fixtures skip callbacks):
115
+ # public_id: <%= User.fixture_public_id(:alice) %>
116
+ def fixture_public_id(label)
117
+ length = HasPublicRecord::Generator.starting_length(table_name, public_record_options.length)
118
+ HasPublicRecord.fixture_id(public_id_prefix, label, length: length)
119
+ end
120
+ end
121
+
122
+ # A copy is a new record: it must not carry the original's id into the unique index.
123
+ def initialize_dup(other)
124
+ super
125
+ self[self.class.public_id_column] = nil
126
+ end
127
+
128
+ def public_id_prefix = self.class.public_id_prefix
129
+
130
+ # "usr_K3F9A2" -> "K3F9A2"
131
+ def public_id_bare
132
+ value = self[self.class.public_id_column]
133
+ value&.delete_prefix("#{public_id_prefix}#{SEPARATOR}")
134
+ end
135
+
136
+ # Rails' url helpers use this: user_path(user) -> /users/usr_K3F9A2
137
+ def to_param = (self[self.class.public_id_column] if persisted?)
138
+
139
+ private
140
+
141
+ def assign_public_record
142
+ column = self.class.public_id_column
143
+ self[column] = self.class.generate_public_id if self[column].blank?
144
+ end
145
+
146
+ def validate_public_record_format
147
+ column = self.class.public_id_column
148
+ value = self[column]
149
+ return if value.blank? || self.class.normalize_public_id(value) == value
150
+
151
+ errors.add(column, :invalid)
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module HasPublicRecord
6
+ # Loads the rake tasks. Generators need no hook: Rails finds lib/generators of every gem on the load path.
7
+ class Railtie < ::Rails::Railtie
8
+ rake_tasks { load File.expand_path("../tasks/has_public_record.rake", __dir__) }
9
+ end
10
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HasPublicRecord
4
+ # prefix -> class NAME ("usr" => "User"). Names, not classes: a class object goes stale when Rails reloads code,
5
+ # a name is constantized fresh on lookup. Declaring the same prefix again from the same class (a reload) is a
6
+ # no-op; from a different class it raises, which is what makes prefixes unique.
7
+ class Registry
8
+ def initialize
9
+ @map = {}.freeze
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def register(prefix, class_name)
14
+ prefix = prefix.to_s
15
+ unless PREFIX_FORMAT.match?(prefix)
16
+ raise InvalidPrefixError, "invalid prefix #{prefix.inspect} (expected #{PREFIX_FORMAT.inspect}: lower-case letters and digits, starting with a letter, 10 at most)"
17
+ end
18
+ raise InvalidPrefixError, "a model needs a class name to declare a prefix (anonymous class)" if class_name.blank?
19
+
20
+ @mutex.synchronize do
21
+ existing = @map[prefix]
22
+ if existing && existing != class_name
23
+ raise DuplicatePrefixError, "prefix #{prefix.inspect} is already used by #{existing}, so #{class_name} cannot use it"
24
+ end
25
+
26
+ @map = @map.merge(prefix => class_name).freeze
27
+ end
28
+ prefix
29
+ end
30
+
31
+ def [](prefix) = @map[prefix.to_s]
32
+ def prefix_for(class_name) = @map.key(class_name.to_s)
33
+ def to_h = @map
34
+ def clear! = @mutex.synchronize { @map = {}.freeze }
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HasPublicRecord
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/object/blank"
5
+ require "active_support/number_helper"
6
+ require "digest"
7
+ require "zeitwerk"
8
+ require_relative "has_public_record/version"
9
+
10
+ loader = Zeitwerk::Loader.for_gem
11
+ loader.ignore("#{__dir__}/generators") # Rails generators do not follow constant naming
12
+ loader.ignore("#{__dir__}/tasks") # rake files
13
+ loader.ignore("#{__dir__}/has_public_record/version.rb") # required above
14
+ loader.ignore("#{__dir__}/has_public_record/railtie.rb") # needs Rails, so it is required below, only under Rails
15
+ loader.setup
16
+
17
+ # Short, prefixed public ids: has_public_record prefix: "usr" -> usr_K3F9A2
18
+ module HasPublicRecord
19
+ class Error < StandardError; end
20
+ class InvalidPrefixError < Error; end
21
+ class DuplicatePrefixError < Error; end
22
+ class ExhaustedError < Error; end
23
+
24
+ SEPARATOR = "_"
25
+ PREFIX_FORMAT = /\A[a-z][a-z0-9]{0,9}\z/
26
+ REF_FORMAT = /\A([a-z][a-z0-9]{0,9})#{SEPARATOR}(\S+)\z/i # any letter case: people type ids
27
+ Parsed = Data.define(:prefix, :bare)
28
+
29
+ @config = Configuration.new
30
+ @registry = Registry.new
31
+ @models_loaded = false
32
+
33
+ class << self
34
+ attr_reader :config, :registry
35
+
36
+ def configure
37
+ yield(config)
38
+ config
39
+ end
40
+
41
+ # "usr_K3F9A2" -> Parsed(prefix: "usr", bare: "K3F9A2"); nil when it is not shaped like a ref.
42
+ def parse(ref)
43
+ match = REF_FORMAT.match(ref.to_s.strip) or return nil
44
+ Parsed.new(prefix: match[1].downcase, bare: fold_case(match[2]))
45
+ end
46
+
47
+ def valid?(ref) = !parse(ref).nil?
48
+
49
+ # Ids are stored in one case. When the alphabet has only upper-case (or only lower-case) letters, anything typed
50
+ # is folded to it; an alphabet with both cases is left exactly as typed.
51
+ def fold_case(text)
52
+ alphabet = config.alphabet
53
+ if alphabet == alphabet.upcase then text.upcase
54
+ elsif alphabet == alphabet.downcase then text.downcase
55
+ else text
56
+ end
57
+ end
58
+
59
+ # The stored form ("usr_K3F9A2") of a ref that belongs to prefix; the bare code is accepted. nil for a ref of
60
+ # another prefix or a blank one. With no prefix, just the folded text.
61
+ def canonicalize(ref, prefix)
62
+ text = ref.to_s.strip
63
+ return nil if text.empty?
64
+ return fold_case(text) unless prefix
65
+
66
+ head, separator, tail = text.partition(SEPARATOR)
67
+ if separator.empty?
68
+ "#{prefix}#{SEPARATOR}#{fold_case(head)}"
69
+ elsif head.downcase == prefix && !tail.empty?
70
+ "#{prefix}#{SEPARATOR}#{fold_case(tail)}"
71
+ end
72
+ end
73
+
74
+ # { "usr" => "User", ... } for every model declared so far.
75
+ def prefixes = registry.to_h
76
+
77
+ # The prefix a model (class, or class name) declared, or nil.
78
+ def prefix_for(model) = registry.prefix_for(model.is_a?(Module) ? model.name : model)
79
+
80
+ # The model class a ref belongs to, or nil (bad shape, unknown prefix, class gone).
81
+ def model_for(ref)
82
+ parsed = parse(ref) or return nil
83
+ name = registry[parsed.prefix]
84
+ if name.nil?
85
+ load_models!
86
+ name = registry[parsed.prefix]
87
+ end
88
+ name&.safe_constantize
89
+ end
90
+
91
+ # The record a ref points at, or nil. resolve! raises ActiveRecord::RecordNotFound.
92
+ def resolve(ref)
93
+ model = model_for(ref) or return nil
94
+ model.find_by_public_id(ref)
95
+ end
96
+
97
+ def resolve!(ref)
98
+ resolve(ref) || raise(ActiveRecord::RecordNotFound, "Couldn't find a record for public id #{ref.inspect}")
99
+ end
100
+
101
+ # Mixed refs (an audit log, polymorphic references) in one query per model, not one per ref.
102
+ # Returns { ref => record } for the refs that resolved, keyed by the ref as given. Bare codes cannot be
103
+ # resolved without a model, so they are skipped.
104
+ def resolve_many(*refs)
105
+ found = {}
106
+ refs.flatten.compact.uniq.group_by { |ref| model_for(ref) }.each do |model, list|
107
+ next unless model
108
+
109
+ by_id = model.with_public_id(list).index_by { |record| record[model.public_id_column] }
110
+ list.each do |ref|
111
+ record = by_id[model.normalize_public_id(ref)]
112
+ found[ref] = record if record
113
+ end
114
+ end
115
+ found
116
+ end
117
+
118
+ # Makes an id without a model; see Generator and Identifier.
119
+ # HasPublicRecord.generate("inv", length: 8) { |id| !Invoice.exists?(number: id) }
120
+ def generate(prefix = nil, **options, &block) = Generator.generate(**{ prefix: prefix }.merge(options), &block)
121
+
122
+ def identifier(prefix = nil, **options, &free) = Identifier.new(prefix, **options, &free)
123
+
124
+ # A stable id for a label, for fixtures and seeds: the same label always gives the same id.
125
+ def fixture_id(prefix, label, length: config.default_length)
126
+ number = Digest::SHA512.hexdigest("#{prefix}:#{label}").to_i(16)
127
+ chars = config.alphabet.chars
128
+ bare = Array.new(length) do
129
+ number, digit = number.divmod(chars.size)
130
+ chars[digit]
131
+ end.join
132
+ prefix ? "#{prefix}#{SEPARATOR}#{bare}" : bare
133
+ end
134
+
135
+ # For specs: forget every declaration, learned length and setting.
136
+ def reset!
137
+ registry.clear!
138
+ Generator.reset_learned!
139
+ config.reset!
140
+ @models_loaded = false
141
+ end
142
+
143
+ private
144
+
145
+ # Models register when their class loads. With lazy autoloading (development, tests) a ref for a model nobody
146
+ # touched yet would not resolve, so on the first unknown prefix everything is loaded once.
147
+ def load_models!
148
+ return if @models_loaded
149
+
150
+ @models_loaded = true
151
+ Zeitwerk::Loader.eager_load_all
152
+ end
153
+ end
154
+ end
155
+
156
+ ActiveSupport.on_load(:active_record) { extend HasPublicRecord::Macro }
157
+ require_relative "has_public_record/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :has_public_record do
4
+ # Models register their prefix when their class loads, so load them all first.
5
+ task prepare: :environment do
6
+ Rails.application.eager_load!
7
+ end
8
+
9
+ def hpr_models
10
+ only = ENV["MODEL"].presence
11
+ models = HasPublicRecord::Maintenance.models(only: only)
12
+ abort "No model#{" named #{only}" if only} declares has_public_record." if models.empty?
13
+ models
14
+ end
15
+
16
+ desc "Give every row without a public id one (MODEL=User to limit)"
17
+ task backfill: :prepare do
18
+ hpr_models.each do |model|
19
+ problem = HasPublicRecord::Maintenance.report(model).problem
20
+ next puts("#{model.name}: skipped, #{problem}") if problem
21
+
22
+ puts "#{model.name}: #{model.backfill_public_ids!} rows given an id"
23
+ end
24
+ end
25
+
26
+ desc "Missing ids, wrong prefixes, duplicates and capacity (MODEL=User to limit). Exits 1 when anything is off"
27
+ task report: :prepare do
28
+ reports = HasPublicRecord::Maintenance.reports(only: ENV["MODEL"].presence)
29
+ abort "No model declares has_public_record." if reports.empty?
30
+
31
+ reports.each do |r|
32
+ label = "#{r.model.name} (#{r.table}.#{r.column}, prefix #{r.model.public_id_prefix})"
33
+ if r.problem
34
+ puts "#{label}: #{r.problem}"
35
+ elsif r.ok?
36
+ puts "#{label}: #{r.total} rows, ok; #{HasPublicRecord::Maintenance.capacity(r.model)}"
37
+ else
38
+ puts "#{label}: #{r.total} rows, #{r.missing} missing, #{r.wrong_prefix} wrong prefix" \
39
+ "#{" (e.g. #{r.wrong_samples.join(', ')})" if r.wrong_samples.any?}, #{r.duplicates} duplicated; " \
40
+ "#{HasPublicRecord::Maintenance.capacity(r.model)}"
41
+ end
42
+ end
43
+ exit 1 unless reports.all?(&:ok?)
44
+ end
45
+
46
+ desc "Rename stored ids from an old prefix to the model's declared one: MODEL=User FROM=old [APPLY=1]"
47
+ task rename: :prepare do
48
+ from = ENV["FROM"].presence or abort "Give the old prefix: FROM=old"
49
+ ENV["MODEL"].presence or abort "Give the model: MODEL=User"
50
+ model = hpr_models.first
51
+ apply = ENV["APPLY"].present?
52
+
53
+ count = HasPublicRecord::Maintenance.rename_prefix(model, from: from, apply: apply)
54
+ if apply
55
+ puts "#{model.name}: #{count} ids renamed #{from}_ -> #{model.public_id_prefix}_"
56
+ else
57
+ puts "#{model.name}: #{count} ids would be renamed #{from}_ -> #{model.public_id_prefix}_ (dry run; add APPLY=1)"
58
+ end
59
+ end
60
+ end
metadata ADDED
@@ -0,0 +1,120 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: has_public_record
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Baden
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: railties
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: zeitwerk
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '2.6'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '2.6'
68
+ description: has_public_record gives a model a random prefixed public id that grows
69
+ in length as the table fills.
70
+ email:
71
+ - baden.rushworth@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - CHANGELOG.md
77
+ - README.md
78
+ - lib/generators/has_public_record/install/install_generator.rb
79
+ - lib/generators/has_public_record/install/templates/initializer.rb.tt
80
+ - lib/generators/has_public_record/migration/migration_generator.rb
81
+ - lib/generators/has_public_record/migration/templates/add.rb.tt
82
+ - lib/generators/has_public_record/migration/templates/enforce.rb.tt
83
+ - lib/has_public_record.rb
84
+ - lib/has_public_record/configuration.rb
85
+ - lib/has_public_record/generator.rb
86
+ - lib/has_public_record/identifier.rb
87
+ - lib/has_public_record/macro.rb
88
+ - lib/has_public_record/maintenance.rb
89
+ - lib/has_public_record/model.rb
90
+ - lib/has_public_record/railtie.rb
91
+ - lib/has_public_record/registry.rb
92
+ - lib/has_public_record/version.rb
93
+ - lib/tasks/has_public_record.rake
94
+ homepage: https://github.com/BadenRushworth/has_public_record
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ homepage_uri: https://github.com/BadenRushworth/has_public_record
99
+ source_code_uri: https://github.com/BadenRushworth/has_public_record
100
+ changelog_uri: https://github.com/BadenRushworth/has_public_record/blob/main/CHANGELOG.md
101
+ bug_tracker_uri: https://github.com/BadenRushworth/has_public_record/issues
102
+ rubygems_mfa_required: 'true'
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '3.2'
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubygems_version: 4.0.16
118
+ specification_version: 4
119
+ summary: Short, prefixed, URL-safe public ids for ActiveRecord models (usr_K3F9A2).
120
+ test_files: []