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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dadeae14d61a8a853dff01a3fc146bf16903fde443c4863a537987ccb9edaa04
4
- data.tar.gz: 7d348eb1f8c4f76be37625666c2e99ecbb824355189eb4cdaa8796058c7bcceb
3
+ metadata.gz: bccfeb56d4de4b2cc334b9e090d990be30970367278a6ad96175365692ac3e19
4
+ data.tar.gz: 3bcd2494228239500b849cac40abe4d16ca9ac5b3680862b8a9bac8c2e11fca0
5
5
  SHA512:
6
- metadata.gz: 402e8cd8f72ffc69f45d326cae433f28b3e13aad4fce4de6bd99ca55192976c4346e01e51eb2eab1e37577246e85dd8324e305fd73ace87841f48f0322ebcee9
7
- data.tar.gz: 292344f738d26c9a99e4eb4bb020dcbbbc51da61626cbb7e5d5fbb15d781d65886f21b1ef96ec31c1c36a71ebf02c91991bb287a63d5b5d3f0076ff95e22a770
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: 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.
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: "PostgreSQL only: build the index without locking writes"
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
- add_index :<%= table_name %>, :<%= public_column %>, unique: true<%= ", algorithm: :concurrently" if options[:concurrently] %>
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). Growth is
9
- # announced with "grow.has_public_record" (key, table, column, from, to; table and column only for a scope).
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
- 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
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
- if bumps >= limit
41
- raise ExhaustedError, "no unique code after #{bumps} length bumps (length #{current}) for #{key}"
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
- 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)
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 (one per table and column), optionally only the named one.
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
- 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
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. One transaction: all rows move or none do. Returns the rows concerned.
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
- count = scope.count
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
- next unless record[column].start_with?(old) # the LIKE may be case-insensitive on some databases
86
+ current = record[column]
87
+ next unless current.start_with?(old) # the LIKE may be case-insensitive on some databases
69
88
 
70
- renamed = "#{to}#{SEPARATOR}#{record[column].delete_prefix(old)}"
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: 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)
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
- self.public_record_options = Options.new(prefix: prefix, column: column.to_sym, length: length, max_bumps: max_bumps)
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
- attr_readonly column # a public id never changes once created
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 (after adding the column to an existing table).
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. Plain SQL updates: the column is readonly through the
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.find_each(batch_size: batch_size) do |record|
90
- count += public_record_row_scope(record).update_all(public_id_column => generate_public_id)
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, one distinct id per row, and
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
- 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
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
- 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
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.normalize_public_id(value) == value
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. Generators need no hook: Rails finds lib/generators of every gem on the load path.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HasPublicRecord
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
@@ -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 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
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
- hpr_models.each do |model|
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). Exits 1 when anything is off"
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.reports(only: ENV["MODEL"].presence)
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
- exit 1 unless reports.all?(&:ok?)
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 = hpr_models.first
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.0
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