has_public_record 0.1.0 → 0.1.1
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 +4 -4
- data/CHANGELOG.md +21 -2
- data/LICENSE.txt +21 -0
- data/lib/generators/has_public_record/migration/migration_generator.rb +4 -1
- data/lib/generators/has_public_record/migration/templates/add.rb.tt +9 -2
- data/lib/generators/has_public_record/migration/templates/enforce.rb.tt +5 -0
- data/lib/has_public_record/generator.rb +64 -14
- data/lib/has_public_record/maintenance.rb +35 -18
- data/lib/has_public_record/model.rb +66 -24
- data/lib/has_public_record/public_id_type.rb +37 -0
- data/lib/has_public_record/railtie.rb +9 -1
- data/lib/has_public_record/version.rb +1 -1
- data/lib/has_public_record.rb +18 -0
- data/lib/tasks/has_public_record.rake +10 -11
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bccfeb56d4de4b2cc334b9e090d990be30970367278a6ad96175365692ac3e19
|
|
4
|
+
data.tar.gz: 3bcd2494228239500b849cac40abe4d16ca9ac5b3680862b8a9bac8c2e11fca0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fd13f5114d34aed5fa93cc8a249bd6ffaefbd66f1807e844c098cf097b38ace21d125eae44d7ce8667f57d538cb74e775eb9574bb917d2fc3b227f2beb3a41e7
|
|
7
|
+
data.tar.gz: 6a1d43e2c924db64231390da8d129d0c0153ca30368f14f4a73985b00a394954684cf32fea41ad563936e69871fcef72da406085992000415a536a6f8cb9fb4a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
Fixes
|
|
6
|
+
- Ids are validated against the alphabet when created; lower-case and bare ids assigned to a record are normalized
|
|
7
|
+
(a custom attribute type also normalizes `where` / `find_by` values). Reading a row returns exactly what is stored.
|
|
8
|
+
- `missing_public_id` counts empty strings, like the report does, so backfill and report agree.
|
|
9
|
+
- `has_public_record` on an abstract class raises at declaration instead of breaking `resolve` later.
|
|
10
|
+
- Learned length can no longer ratchet past `starting length + max_bumps` during an outage.
|
|
11
|
+
- `rename_prefix` reports the rows it actually changed.
|
|
12
|
+
- Rake helpers no longer define methods on `Object`; `MODEL=` accepts `user`, `users` or `User`.
|
|
13
|
+
- Maintenance no longer drops models that share a table name across databases.
|
|
14
|
+
- `--concurrently` only asks for a concurrent index on PostgreSQL.
|
|
15
|
+
- The gem now ships its LICENSE and declares `concurrent-ruby`.
|
|
16
|
+
|
|
17
|
+
Added
|
|
18
|
+
- `regenerate_public_id!`, `generate_public_ids(count)`, `HasPublicRecord.well_formed?`, `HasPublicRecord.reset_registry!`.
|
|
19
|
+
- `with_generated_public_ids` and `backfill_public_ids!` use one existence query per batch instead of one per row.
|
|
20
|
+
- The registry resets when Rails reloads code in development.
|
|
21
|
+
- Generator specs; README covers every public method.
|
|
22
|
+
|
|
3
23
|
## 0.1.0
|
|
4
24
|
|
|
5
|
-
First release
|
|
6
|
-
generator, migration and install generators, backfill / report / rename tasks, and testing helpers.
|
|
25
|
+
First release.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Baden
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -16,7 +16,7 @@ module HasPublicRecord
|
|
|
16
16
|
class_option :column, type: :string, default: "public_id", desc: "Column name"
|
|
17
17
|
class_option :enforce, type: :boolean, default: false, desc: "Make the column NOT NULL (run after the backfill)"
|
|
18
18
|
class_option :concurrently, type: :boolean, default: false,
|
|
19
|
-
desc: "
|
|
19
|
+
desc: "Build the index without locking writes (PostgreSQL; elsewhere it is built normally)"
|
|
20
20
|
|
|
21
21
|
def create_migration_file
|
|
22
22
|
migration_template(options[:enforce] ? "enforce.rb.tt" : "add.rb.tt",
|
|
@@ -25,6 +25,9 @@ module HasPublicRecord
|
|
|
25
25
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
|
+
# The name as given: Rails would pluralize it ("status" -> "statuses").
|
|
29
|
+
def table_name = name.to_s
|
|
30
|
+
|
|
28
31
|
def public_column = options[:column]
|
|
29
32
|
|
|
30
33
|
def migration_name
|
|
@@ -6,11 +6,18 @@
|
|
|
6
6
|
# rails g has_public_record:migration <%= table_name %> --enforce
|
|
7
7
|
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
8
8
|
<% if options[:concurrently] -%>
|
|
9
|
-
disable_ddl_transaction!
|
|
9
|
+
disable_ddl_transaction! # needed to build an index concurrently; harmless on other databases
|
|
10
10
|
|
|
11
11
|
<% end -%>
|
|
12
12
|
def change
|
|
13
13
|
add_column :<%= table_name %>, :<%= public_column %>, :string
|
|
14
|
-
|
|
14
|
+
<% if options[:concurrently] -%>
|
|
15
|
+
# Only PostgreSQL can build an index without locking writes; other databases build it normally.
|
|
16
|
+
index_options = { unique: true }
|
|
17
|
+
index_options[:algorithm] = :concurrently if connection.adapter_name.match?(/postgres/i)
|
|
18
|
+
add_index :<%= table_name %>, :<%= public_column %>, **index_options
|
|
19
|
+
<% else -%>
|
|
20
|
+
add_index :<%= table_name %>, :<%= public_column %>, unique: true
|
|
21
|
+
<% end -%>
|
|
15
22
|
end
|
|
16
23
|
end
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Run after the backfill: fails if any row still has no id.
|
|
4
|
+
#
|
|
5
|
+
# Big PostgreSQL table? change_column_null scans it under a lock. The lock-light way (PostgreSQL 12+) is a CHECK
|
|
6
|
+
# constraint added unvalidated and validated separately; with a validated constraint in place, NOT NULL needs no scan:
|
|
7
|
+
# add_check_constraint :<%= table_name %>, "<%= public_column %> IS NOT NULL", name: "<%= table_name %>_<%= public_column %>_not_null", validate: false
|
|
8
|
+
# validate_check_constraint :<%= table_name %>, name: "<%= table_name %>_<%= public_column %>_not_null"
|
|
4
9
|
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
5
10
|
def change
|
|
6
11
|
change_column_null :<%= table_name %>, :<%= public_column %>, false
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require "set"
|
|
4
5
|
require "concurrent/map"
|
|
5
6
|
|
|
6
7
|
module HasPublicRecord
|
|
7
8
|
# 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).
|
|
9
|
-
#
|
|
9
|
+
# when random tries keep hitting taken codes, remembering the grown length per key (per process). The length never
|
|
10
|
+
# goes past start + max_bumps, so a long outage cannot ratchet it up for good. Growth is announced with
|
|
11
|
+
# "grow.has_public_record" (key, table, column, from, to; table and column only for a scope).
|
|
10
12
|
#
|
|
11
13
|
# Whether a code is free is a callable, so nothing here needs a model:
|
|
12
14
|
# Generator.generate(prefix: "inv", length: 8) { |id| !Redis.exists?(id) }
|
|
@@ -14,6 +16,7 @@ module HasPublicRecord
|
|
|
14
16
|
module Generator
|
|
15
17
|
EVENT = "grow.has_public_record"
|
|
16
18
|
LEARNED = Concurrent::Map.new # key => length reached after growing
|
|
19
|
+
EXISTS_CHUNK = 500 # candidates per existence query (keeps IN lists inside every database's limits)
|
|
17
20
|
|
|
18
21
|
class << self
|
|
19
22
|
def random(length:, alphabet: HasPublicRecord.config.alphabet)
|
|
@@ -26,27 +29,50 @@ module HasPublicRecord
|
|
|
26
29
|
def generate(prefix: nil, length: nil, max_bumps: nil, key: nil, scope: nil, column: nil, &free)
|
|
27
30
|
prefix = check_prefix(prefix)
|
|
28
31
|
free, key, table = source_for(prefix, key, scope, column, free)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
current = [starting_length(table || key, length), LEARNED[key].to_i].max
|
|
32
|
-
bumps = 0
|
|
32
|
+
start, ceiling = bounds(table || key, length, max_bumps)
|
|
33
|
+
current = start_at(key, start, ceiling)
|
|
33
34
|
|
|
34
35
|
loop do
|
|
35
|
-
config.attempts_per_length.times do
|
|
36
|
+
HasPublicRecord.config.attempts_per_length.times do
|
|
36
37
|
candidate = compose(prefix, random(length: current))
|
|
37
38
|
return candidate if free.call(candidate)
|
|
38
39
|
end
|
|
40
|
+
current = grow(key, table, column, current, start, ceiling)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
# count distinct ids for a scope with ONE existence query per round (per 500 candidates), not one per id.
|
|
45
|
+
# reserved: ids that are not in the database yet but must not be handed out (a batch's supplied ids).
|
|
46
|
+
def generate_many(count, scope:, column:, prefix: nil, length: nil, max_bumps: nil, key: nil, reserved: [])
|
|
47
|
+
prefix = check_prefix(prefix)
|
|
48
|
+
count = Integer(count)
|
|
49
|
+
return [] if count <= 0
|
|
50
|
+
|
|
51
|
+
table = scope.table_name.to_s
|
|
52
|
+
key = (key || "#{table}.#{column}").to_s
|
|
53
|
+
start, ceiling = bounds(table, length, max_bumps)
|
|
54
|
+
current = start_at(key, start, ceiling)
|
|
55
|
+
seen = Set.new(reserved.map(&:to_s))
|
|
56
|
+
made = []
|
|
57
|
+
rounds = 0
|
|
58
|
+
|
|
59
|
+
while made.size < count
|
|
60
|
+
if rounds >= HasPublicRecord.config.attempts_per_length
|
|
61
|
+
current = grow(key, table, column, current, start, ceiling)
|
|
62
|
+
rounds = 0
|
|
42
63
|
end
|
|
64
|
+
rounds += 1
|
|
65
|
+
|
|
66
|
+
candidates = Array.new(count - made.size) { compose(prefix, random(length: current)) }.uniq.reject { |id| seen.include?(id) }
|
|
67
|
+
taken = taken_among(scope, column, candidates)
|
|
68
|
+
candidates.each do |id|
|
|
69
|
+
next if taken.include?(id)
|
|
43
70
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
learn(key, current)
|
|
48
|
-
ActiveSupport::Notifications.instrument(EVENT, key: key, table: table, column: column, from: from, to: current)
|
|
71
|
+
seen << id
|
|
72
|
+
made << id
|
|
73
|
+
end
|
|
49
74
|
end
|
|
75
|
+
made
|
|
50
76
|
end
|
|
51
77
|
|
|
52
78
|
# The length a name (a table, or a standalone key) starts at.
|
|
@@ -86,6 +112,30 @@ module HasPublicRecord
|
|
|
86
112
|
end
|
|
87
113
|
end
|
|
88
114
|
|
|
115
|
+
# [where this name starts, the longest it may ever get]
|
|
116
|
+
def bounds(name, length, max_bumps)
|
|
117
|
+
start = starting_length(name, length)
|
|
118
|
+
[start, start + (max_bumps || HasPublicRecord.config.max_length_bumps)]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def start_at(key, start, ceiling) = [[LEARNED[key].to_i, ceiling].min, start].max
|
|
122
|
+
|
|
123
|
+
# One step longer, remembered and announced; ExhaustedError once the ceiling is reached.
|
|
124
|
+
def grow(key, table, column, current, start, ceiling)
|
|
125
|
+
if current >= ceiling
|
|
126
|
+
raise ExhaustedError, "no unique code after #{current - start} length bumps (length #{current}) for #{key}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
longer = current + 1
|
|
130
|
+
learn(key, longer)
|
|
131
|
+
ActiveSupport::Notifications.instrument(EVENT, key: key, table: table, column: column, from: current, to: longer)
|
|
132
|
+
longer
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def taken_among(scope, column, candidates)
|
|
136
|
+
candidates.each_slice(EXISTS_CHUNK).flat_map { |chunk| scope.where(column => chunk).pluck(column) }.to_set
|
|
137
|
+
end
|
|
138
|
+
|
|
89
139
|
def compose(prefix, bare) = prefix ? "#{prefix}#{SEPARATOR}#{bare}" : bare
|
|
90
140
|
|
|
91
141
|
# Atomic per key: a smaller length never overwrites a larger one.
|
|
@@ -18,14 +18,25 @@ module HasPublicRecord
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
class << self
|
|
21
|
-
# Every model that declared has_public_record (
|
|
21
|
+
# Every model that declared has_public_record (base classes only, so STI subclasses are not listed twice).
|
|
22
|
+
# only: accepts "User", "user" or "users" (also "billing/invoice").
|
|
22
23
|
def models(only: nil)
|
|
23
24
|
found = ActiveRecord::Base.descendants.select do |model|
|
|
24
25
|
model.respond_to?(:public_record_options) && model.public_record_options && model.name.present? &&
|
|
25
26
|
!model.abstract_class? && model == model.base_class
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
end.sort_by(&:to_s)
|
|
28
|
+
return found unless only
|
|
29
|
+
|
|
30
|
+
names = name_variants(only)
|
|
31
|
+
found.select { |model| names.include?(model.name) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Like models, but raises HasPublicRecord::Error when nothing matches (the rake tasks turn that into an abort).
|
|
35
|
+
def models!(only: nil)
|
|
36
|
+
found = models(only: only)
|
|
37
|
+
return found unless found.empty?
|
|
38
|
+
|
|
39
|
+
raise Error, only ? "No model named #{only} declares has_public_record." : "No model declares has_public_record."
|
|
29
40
|
end
|
|
30
41
|
|
|
31
42
|
def reports(only: nil) = models(only: only).map { |model| report(model) }
|
|
@@ -49,8 +60,15 @@ module HasPublicRecord
|
|
|
49
60
|
duplicates: present.group(column).having("COUNT(*) > 1").count.size, problem: nil)
|
|
50
61
|
end
|
|
51
62
|
|
|
63
|
+
def capacity(model)
|
|
64
|
+
length = Generator.starting_length(model.table_name, model.public_record_options.length)
|
|
65
|
+
used = model.base_class.unscoped.where.not(model.public_id_column => [nil, ""]).count
|
|
66
|
+
Capacity.new(length: length, slots: HasPublicRecord.config.alphabet.size**length, used: used)
|
|
67
|
+
end
|
|
68
|
+
|
|
52
69
|
# 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
|
|
70
|
+
# Counts only unless apply: true (the count is then an estimate from the database). One transaction: all rows
|
|
71
|
+
# move or none do. Returns the rows actually changed.
|
|
54
72
|
def rename_prefix(model, from:, apply: false, batch_size: 1000)
|
|
55
73
|
from = from.to_s
|
|
56
74
|
to = model.public_id_prefix
|
|
@@ -60,30 +78,29 @@ module HasPublicRecord
|
|
|
60
78
|
column = model.public_id_column
|
|
61
79
|
old = "#{from}#{SEPARATOR}"
|
|
62
80
|
scope = model.base_class.unscoped.where(with_prefix(model, old))
|
|
63
|
-
|
|
64
|
-
return count unless apply && count.positive?
|
|
81
|
+
return scope.count unless apply
|
|
65
82
|
|
|
83
|
+
renamed = 0
|
|
66
84
|
model.transaction do
|
|
67
85
|
scope.find_each(batch_size: batch_size) do |record|
|
|
68
|
-
|
|
86
|
+
current = record[column]
|
|
87
|
+
next unless current.start_with?(old) # the LIKE may be case-insensitive on some databases
|
|
69
88
|
|
|
70
|
-
renamed
|
|
71
|
-
model.public_record_row_scope(record).update_all(column => renamed)
|
|
89
|
+
renamed += model.public_record_row_scope(record).update_all(column => "#{to}#{SEPARATOR}#{current.delete_prefix(old)}")
|
|
72
90
|
end
|
|
73
91
|
end
|
|
74
92
|
ActiveSupport::Notifications.instrument("rename.has_public_record",
|
|
75
|
-
table: model.table_name, column: column, from: from, to: to, count:
|
|
76
|
-
|
|
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)
|
|
93
|
+
table: model.table_name, column: column, from: from, to: to, count: renamed)
|
|
94
|
+
renamed
|
|
83
95
|
end
|
|
84
96
|
|
|
85
97
|
private
|
|
86
98
|
|
|
99
|
+
def name_variants(only)
|
|
100
|
+
text = only.to_s.strip
|
|
101
|
+
[text, text.camelize, text.singularize.camelize].uniq
|
|
102
|
+
end
|
|
103
|
+
|
|
87
104
|
def broken(model, problem)
|
|
88
105
|
Report.new(model: model, table: model.table_name, column: model.public_id_column, total: 0, missing: 0,
|
|
89
106
|
wrong_prefix: 0, wrong_samples: [], duplicates: 0, problem: problem)
|
|
@@ -15,6 +15,11 @@ module HasPublicRecord
|
|
|
15
15
|
class_methods do
|
|
16
16
|
# Called by the has_public_record macro. Touches no database table, so it is safe while the app boots.
|
|
17
17
|
def declare_public_record(prefix:, column:, length:, max_bumps:)
|
|
18
|
+
if abstract_class?
|
|
19
|
+
raise HasPublicRecord::Error,
|
|
20
|
+
"#{name || inspect}: has_public_record cannot be declared on an abstract class (it has no table); " \
|
|
21
|
+
"declare it on each concrete model"
|
|
22
|
+
end
|
|
18
23
|
if public_record_options
|
|
19
24
|
raise HasPublicRecord::Error,
|
|
20
25
|
"#{name || inspect} already has has_public_record (prefix #{public_record_options.prefix.inspect}); " \
|
|
@@ -22,9 +27,11 @@ module HasPublicRecord
|
|
|
22
27
|
end
|
|
23
28
|
|
|
24
29
|
prefix = HasPublicRecord.registry.register(prefix, name)
|
|
25
|
-
|
|
30
|
+
column = column.to_sym
|
|
31
|
+
self.public_record_options = Options.new(prefix: prefix, column: column, length: length, max_bumps: max_bumps)
|
|
26
32
|
|
|
27
|
-
|
|
33
|
+
attribute column, HasPublicRecord::PublicIdType.new(prefix: prefix)
|
|
34
|
+
attr_readonly column # a public id never changes once created (regenerate_public_id! is the one way)
|
|
28
35
|
before_create :assign_public_record
|
|
29
36
|
validate :validate_public_record_format, on: :create
|
|
30
37
|
end
|
|
@@ -32,8 +39,16 @@ module HasPublicRecord
|
|
|
32
39
|
def public_id_prefix = public_record_options.prefix
|
|
33
40
|
def public_id_column = public_record_options.column
|
|
34
41
|
|
|
42
|
+
# Rails would call the column "Public" (it strips _id as if it were a foreign key).
|
|
43
|
+
def human_attribute_name(attribute, options = {})
|
|
44
|
+
return super unless public_record_options && attribute.to_s == public_id_column.to_s
|
|
45
|
+
|
|
46
|
+
super(attribute, options.merge(default: "Public ID"))
|
|
47
|
+
end
|
|
48
|
+
|
|
35
49
|
# 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.
|
|
50
|
+
# nil for another model's id, for junk and for blank. The alphabet is not checked: ids made under an older
|
|
51
|
+
# alphabet must stay findable.
|
|
37
52
|
def normalize_public_id(ref) = HasPublicRecord.canonicalize(ref, public_id_prefix)
|
|
38
53
|
|
|
39
54
|
def public_id?(ref) = !normalize_public_id(ref).nil?
|
|
@@ -58,14 +73,15 @@ module HasPublicRecord
|
|
|
58
73
|
end
|
|
59
74
|
|
|
60
75
|
# A regexp for route constraints (no anchors, as Rails wants): resources :users, constraints: { id: User.public_id_format }
|
|
76
|
+
# It follows the CURRENT alphabet: when changing the alphabet, widen it rather than replace it.
|
|
61
77
|
def public_id_format
|
|
62
78
|
alphabet = HasPublicRecord.config.alphabet
|
|
63
79
|
chars = Regexp.escape((alphabet.chars + alphabet.swapcase.chars).uniq.join)
|
|
64
80
|
/(?i:#{Regexp.escape(public_id_prefix)})#{SEPARATOR}[#{chars}]+/
|
|
65
81
|
end
|
|
66
82
|
|
|
67
|
-
# Rows that have no public id yet
|
|
68
|
-
def missing_public_id = where(public_id_column => nil)
|
|
83
|
+
# Rows that have no public id yet: NULL or an empty string (the report counts both).
|
|
84
|
+
def missing_public_id = where(public_id_column => [nil, ""])
|
|
69
85
|
|
|
70
86
|
# A fresh unused id for this model (what before_create uses).
|
|
71
87
|
def generate_public_id
|
|
@@ -73,6 +89,14 @@ module HasPublicRecord
|
|
|
73
89
|
length: public_record_options.length, max_bumps: public_record_options.max_bumps)
|
|
74
90
|
end
|
|
75
91
|
|
|
92
|
+
# count distinct fresh ids with one existence query instead of one per id. reserved: ids to avoid that are not
|
|
93
|
+
# in the table yet.
|
|
94
|
+
def generate_public_ids(count, reserved: [])
|
|
95
|
+
HasPublicRecord::Generator.generate_many(count, scope: base_class.unscoped, column: public_id_column,
|
|
96
|
+
prefix: public_id_prefix, length: public_record_options.length,
|
|
97
|
+
max_bumps: public_record_options.max_bumps, reserved: reserved)
|
|
98
|
+
end
|
|
99
|
+
|
|
76
100
|
# The one row a record is stored in, ignoring default scopes. Handles composite primary keys. A table with no
|
|
77
101
|
# primary key is refused: an empty condition would match every row.
|
|
78
102
|
def public_record_row_scope(record)
|
|
@@ -82,33 +106,32 @@ module HasPublicRecord
|
|
|
82
106
|
base_class.unscoped.where(keys.zip(Array(record.id)).to_h)
|
|
83
107
|
end
|
|
84
108
|
|
|
85
|
-
# Gives every row without an id one; returns how many.
|
|
86
|
-
# model, and callbacks and updated_at stay untouched.
|
|
109
|
+
# Gives every row without an id one; returns how many. One existence query and one transaction per batch.
|
|
110
|
+
# Plain SQL updates: the column is readonly through the model, and callbacks and updated_at stay untouched.
|
|
87
111
|
def backfill_public_ids!(batch_size: 1000)
|
|
88
112
|
count = 0
|
|
89
|
-
missing_public_id.
|
|
90
|
-
|
|
113
|
+
missing_public_id.find_in_batches(batch_size: batch_size) do |records|
|
|
114
|
+
ids = generate_public_ids(records.size)
|
|
115
|
+
transaction do
|
|
116
|
+
records.zip(ids) do |record, id|
|
|
117
|
+
count += public_record_row_scope(record).update_all(public_id_column => id)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
91
120
|
end
|
|
92
121
|
count
|
|
93
122
|
end
|
|
94
123
|
|
|
95
|
-
# insert_all / upsert_all skip callbacks, so rows get no id. This fills it in
|
|
96
|
-
# keeps ids you supplied: User.insert_all(User.with_generated_public_ids(rows))
|
|
124
|
+
# insert_all / upsert_all skip callbacks, so rows get no id. This fills it in (one existence query for the
|
|
125
|
+
# whole batch) and keeps ids you supplied: User.insert_all(User.with_generated_public_ids(rows))
|
|
97
126
|
def with_generated_public_ids(rows)
|
|
98
127
|
column = public_id_column
|
|
99
|
-
|
|
100
|
-
rows.
|
|
101
|
-
|
|
102
|
-
if row[column].present?
|
|
103
|
-
taken << row[column]
|
|
104
|
-
next row
|
|
105
|
-
end
|
|
128
|
+
rows = rows.map { |row| row.to_h.symbolize_keys }
|
|
129
|
+
needed = rows.count { |row| row[column].blank? }
|
|
130
|
+
return rows if needed.zero?
|
|
106
131
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
row.merge(column => id)
|
|
111
|
-
end
|
|
132
|
+
reserved = rows.filter_map { |row| row[column].presence && (normalize_public_id(row[column]) || row[column]) }
|
|
133
|
+
fresh = generate_public_ids(needed, reserved: reserved).each
|
|
134
|
+
rows.map { |row| row[column].present? ? row : row.merge(column => fresh.next) }
|
|
112
135
|
end
|
|
113
136
|
|
|
114
137
|
# A stable id for a fixture label, for ERB in fixtures (Rails fixtures skip callbacks):
|
|
@@ -136,6 +159,24 @@ module HasPublicRecord
|
|
|
136
159
|
# Rails' url helpers use this: user_path(user) -> /users/usr_K3F9A2
|
|
137
160
|
def to_param = (self[self.class.public_id_column] if persisted?)
|
|
138
161
|
|
|
162
|
+
# Replaces the id (a leaked link, a rotated invite) and returns the new one. Every old URL stops working.
|
|
163
|
+
# The column stays readonly for everything else.
|
|
164
|
+
def regenerate_public_id!
|
|
165
|
+
raise HasPublicRecord::Error, "#{self.class.name} must be saved before its public id can be regenerated" unless persisted?
|
|
166
|
+
|
|
167
|
+
column = self.class.public_id_column
|
|
168
|
+
previous = self[column]
|
|
169
|
+
fresh = self.class.generate_public_id
|
|
170
|
+
if self.class.public_record_row_scope(self).update_all(column => fresh).zero?
|
|
171
|
+
raise ActiveRecord::RecordNotFound, "#{self.class.name} #{id.inspect} no longer exists"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
@attributes.write_from_database(column.to_s, fresh) # not a dirty change, and not an assignment to a readonly column
|
|
175
|
+
ActiveSupport::Notifications.instrument("regenerate.has_public_record",
|
|
176
|
+
table: self.class.table_name, column: column, from: previous, to: fresh)
|
|
177
|
+
fresh
|
|
178
|
+
end
|
|
179
|
+
|
|
139
180
|
private
|
|
140
181
|
|
|
141
182
|
def assign_public_record
|
|
@@ -143,10 +184,11 @@ module HasPublicRecord
|
|
|
143
184
|
self[column] = self.class.generate_public_id if self[column].blank?
|
|
144
185
|
end
|
|
145
186
|
|
|
187
|
+
# Only ids being created are held to the current alphabet.
|
|
146
188
|
def validate_public_record_format
|
|
147
189
|
column = self.class.public_id_column
|
|
148
190
|
value = self[column]
|
|
149
|
-
return if value.blank? || self.class.
|
|
191
|
+
return if value.blank? || HasPublicRecord.well_formed?(value, self.class.public_id_prefix)
|
|
150
192
|
|
|
151
193
|
errors.add(column, :invalid)
|
|
152
194
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HasPublicRecord
|
|
4
|
+
# The attribute type of a public id column. Assigning "usr_k3f9a2" or "K3F9A2" stores "usr_K3F9A2", and values used
|
|
5
|
+
# in where / find_by are normalized the same way, so lookups work in any letter case.
|
|
6
|
+
#
|
|
7
|
+
# Reading is deliberately NOT normalized: a row is returned exactly as stored. A legacy bare code stays visible as
|
|
8
|
+
# bare (the report and the prefix tools need to see it) instead of looking fine while the database says otherwise.
|
|
9
|
+
class PublicIdType < ActiveRecord::Type::String
|
|
10
|
+
attr_reader :prefix
|
|
11
|
+
|
|
12
|
+
def initialize(prefix:, **options)
|
|
13
|
+
super(**options)
|
|
14
|
+
@prefix = prefix
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cast(value) = canonical(super)
|
|
18
|
+
|
|
19
|
+
def serialize(value) = value.is_a?(::String) ? super(canonical(value)) : super
|
|
20
|
+
|
|
21
|
+
def deserialize(value) = value.nil? ? nil : value.to_s
|
|
22
|
+
|
|
23
|
+
# Two types with different prefixes are different types (the base class would call them equal).
|
|
24
|
+
def ==(other) = super && prefix == other.prefix
|
|
25
|
+
def eql?(other) = self == other
|
|
26
|
+
def hash = [super, prefix].hash
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Junk is left as typed: the model's validation reports it, nothing is silently rewritten.
|
|
31
|
+
def canonical(text)
|
|
32
|
+
return text if text.blank?
|
|
33
|
+
|
|
34
|
+
HasPublicRecord.canonicalize(text, prefix) || text
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
require "rails/railtie"
|
|
4
4
|
|
|
5
5
|
module HasPublicRecord
|
|
6
|
-
# Loads the rake tasks
|
|
6
|
+
# Loads the rake tasks, and forgets declared prefixes when Rails unloads code in development. Generators need no
|
|
7
|
+
# hook: Rails finds lib/generators of every gem on the load path.
|
|
8
|
+
#
|
|
9
|
+
# before_class_unload, not to_prepare: to_prepare also runs at boot, and a model loaded before it would be wiped
|
|
10
|
+
# from the registry without ever being reloaded to declare itself again.
|
|
7
11
|
class Railtie < ::Rails::Railtie
|
|
8
12
|
rake_tasks { load File.expand_path("../tasks/has_public_record.rake", __dir__) }
|
|
13
|
+
|
|
14
|
+
initializer "has_public_record.reloader" do
|
|
15
|
+
ActiveSupport::Reloader.before_class_unload { HasPublicRecord.reset_registry! }
|
|
16
|
+
end
|
|
9
17
|
end
|
|
10
18
|
end
|
data/lib/has_public_record.rb
CHANGED
|
@@ -71,6 +71,17 @@ module HasPublicRecord
|
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# Whether ref is an id this prefix could have issued: the prefix, then only characters of the current alphabet.
|
|
75
|
+
# For vetting ids that are GIVEN. Lookups use canonicalize, which does not check the alphabet, so ids made under an
|
|
76
|
+
# older alphabet stay findable.
|
|
77
|
+
def well_formed?(ref, prefix)
|
|
78
|
+
head, separator, tail = ref.to_s.partition(SEPARATOR)
|
|
79
|
+
return false if separator.empty? || head != prefix.to_s || tail.empty?
|
|
80
|
+
|
|
81
|
+
alphabet = config.alphabet
|
|
82
|
+
tail.each_char.all? { |char| alphabet.include?(char) }
|
|
83
|
+
end
|
|
84
|
+
|
|
74
85
|
# { "usr" => "User", ... } for every model declared so far.
|
|
75
86
|
def prefixes = registry.to_h
|
|
76
87
|
|
|
@@ -132,6 +143,13 @@ module HasPublicRecord
|
|
|
132
143
|
prefix ? "#{prefix}#{SEPARATOR}#{bare}" : bare
|
|
133
144
|
end
|
|
134
145
|
|
|
146
|
+
# Forgets which class declared which prefix. Rails calls this when it unloads code in development, so a renamed
|
|
147
|
+
# class or prefix does not raise DuplicatePrefixError; the reloaded classes declare themselves again.
|
|
148
|
+
def reset_registry!
|
|
149
|
+
registry.clear!
|
|
150
|
+
@models_loaded = false
|
|
151
|
+
end
|
|
152
|
+
|
|
135
153
|
# For specs: forget every declaration, learned length and setting.
|
|
136
154
|
def reset!
|
|
137
155
|
registry.clear!
|
|
@@ -6,16 +6,16 @@ namespace :has_public_record do
|
|
|
6
6
|
Rails.application.eager_load!
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
-
def
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
# A lambda, not a def: a def inside a rake file defines a method on Object for the whole host app.
|
|
10
|
+
pick_models = lambda do
|
|
11
|
+
HasPublicRecord::Maintenance.models!(only: ENV["MODEL"].presence)
|
|
12
|
+
rescue HasPublicRecord::Error => e
|
|
13
|
+
abort e.message
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
desc "Give every row without a public id one (MODEL=User to limit)"
|
|
17
17
|
task backfill: :prepare do
|
|
18
|
-
|
|
18
|
+
pick_models.call.each do |model|
|
|
19
19
|
problem = HasPublicRecord::Maintenance.report(model).problem
|
|
20
20
|
next puts("#{model.name}: skipped, #{problem}") if problem
|
|
21
21
|
|
|
@@ -23,10 +23,9 @@ namespace :has_public_record do
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
desc "Missing ids, wrong prefixes, duplicates and capacity (MODEL=User to limit).
|
|
26
|
+
desc "Missing ids, wrong prefixes, duplicates and capacity (MODEL=User to limit). Fails when anything is off"
|
|
27
27
|
task report: :prepare do
|
|
28
|
-
reports = HasPublicRecord::Maintenance.
|
|
29
|
-
abort "No model declares has_public_record." if reports.empty?
|
|
28
|
+
reports = pick_models.call.map { |model| HasPublicRecord::Maintenance.report(model) }
|
|
30
29
|
|
|
31
30
|
reports.each do |r|
|
|
32
31
|
label = "#{r.model.name} (#{r.table}.#{r.column}, prefix #{r.model.public_id_prefix})"
|
|
@@ -40,14 +39,14 @@ namespace :has_public_record do
|
|
|
40
39
|
"#{HasPublicRecord::Maintenance.capacity(r.model)}"
|
|
41
40
|
end
|
|
42
41
|
end
|
|
43
|
-
|
|
42
|
+
abort "has_public_record:report found problems (see above)" unless reports.all?(&:ok?)
|
|
44
43
|
end
|
|
45
44
|
|
|
46
45
|
desc "Rename stored ids from an old prefix to the model's declared one: MODEL=User FROM=old [APPLY=1]"
|
|
47
46
|
task rename: :prepare do
|
|
48
47
|
from = ENV["FROM"].presence or abort "Give the old prefix: FROM=old"
|
|
49
48
|
ENV["MODEL"].presence or abort "Give the model: MODEL=User"
|
|
50
|
-
model =
|
|
49
|
+
model = pick_models.call.first
|
|
51
50
|
apply = ENV["APPLY"].present?
|
|
52
51
|
|
|
53
52
|
count = HasPublicRecord::Maintenance.rename_prefix(model, from: from, apply: apply)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: has_public_record
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Baden
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '7.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: concurrent-ruby
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.1'
|
|
40
54
|
- !ruby/object:Gem::Dependency
|
|
41
55
|
name: railties
|
|
42
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -74,6 +88,7 @@ extensions: []
|
|
|
74
88
|
extra_rdoc_files: []
|
|
75
89
|
files:
|
|
76
90
|
- CHANGELOG.md
|
|
91
|
+
- LICENSE.txt
|
|
77
92
|
- README.md
|
|
78
93
|
- lib/generators/has_public_record/install/install_generator.rb
|
|
79
94
|
- lib/generators/has_public_record/install/templates/initializer.rb.tt
|
|
@@ -87,6 +102,7 @@ files:
|
|
|
87
102
|
- lib/has_public_record/macro.rb
|
|
88
103
|
- lib/has_public_record/maintenance.rb
|
|
89
104
|
- lib/has_public_record/model.rb
|
|
105
|
+
- lib/has_public_record/public_id_type.rb
|
|
90
106
|
- lib/has_public_record/railtie.rb
|
|
91
107
|
- lib/has_public_record/registry.rb
|
|
92
108
|
- lib/has_public_record/version.rb
|
|
@@ -95,7 +111,6 @@ homepage: https://github.com/BadenRushworth/has_public_record
|
|
|
95
111
|
licenses:
|
|
96
112
|
- MIT
|
|
97
113
|
metadata:
|
|
98
|
-
homepage_uri: https://github.com/BadenRushworth/has_public_record
|
|
99
114
|
source_code_uri: https://github.com/BadenRushworth/has_public_record
|
|
100
115
|
changelog_uri: https://github.com/BadenRushworth/has_public_record/blob/main/CHANGELOG.md
|
|
101
116
|
bug_tracker_uri: https://github.com/BadenRushworth/has_public_record/issues
|