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 +7 -0
- data/CHANGELOG.md +6 -0
- data/README.md +189 -0
- data/lib/generators/has_public_record/install/install_generator.rb +22 -0
- data/lib/generators/has_public_record/install/templates/initializer.rb.tt +21 -0
- data/lib/generators/has_public_record/migration/migration_generator.rb +35 -0
- data/lib/generators/has_public_record/migration/templates/add.rb.tt +16 -0
- data/lib/generators/has_public_record/migration/templates/enforce.rb.tt +8 -0
- data/lib/has_public_record/configuration.rb +64 -0
- data/lib/has_public_record/generator.rb +95 -0
- data/lib/has_public_record/identifier.rb +46 -0
- data/lib/has_public_record/macro.rb +15 -0
- data/lib/has_public_record/maintenance.rb +98 -0
- data/lib/has_public_record/model.rb +154 -0
- data/lib/has_public_record/railtie.rb +10 -0
- data/lib/has_public_record/registry.rb +36 -0
- data/lib/has_public_record/version.rb +5 -0
- data/lib/has_public_record.rb +157 -0
- data/lib/tasks/has_public_record.rake +60 -0
- metadata +120 -0
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
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,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: []
|