custom_id 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.
@@ -0,0 +1,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CustomId
4
+ # Optional database-side ID generation using PostgreSQL, MySQL or SQLite triggers.
5
+ #
6
+ # This is an *alternative* to the Ruby-side {CustomId::Concern} approach.
7
+ # Both achieve the same goal – prefixed, collision-resistant string IDs –
8
+ # but this module offloads the work to the database engine.
9
+ #
10
+ # ## Trade-offs vs. the Ruby concern
11
+ #
12
+ # | Aspect | Ruby concern | DbExtension (DB trigger) |
13
+ # |--------------------|----------------------|----------------------------|
14
+ # | Portability | Any AR adapter | PG, MySQL, SQLite only |
15
+ # | Bulk inserts | Per-record callbacks | Handled by the DB |
16
+ # | Raw SQL inserts | IDs not generated | IDs always generated |
17
+ # | Testability | Easy (SQLite ok) | Needs a real DB connection |
18
+ # | Migration needed | No | Yes (install/uninstall) |
19
+ #
20
+ # ## Requirements
21
+ #
22
+ # * **PostgreSQL**: 9.6+ (uses +gen_random_bytes+ from the +pgcrypto+ extension).
23
+ # * **MySQL**: 5.7+ (uses +RANDOM_BYTES+).
24
+ # * **SQLite**: 3.0+ (uses +randomblob+). Non-PK columns use an AFTER INSERT
25
+ # trigger; NOT NULL primary key columns use a BEFORE INSERT trigger with
26
+ # RAISE(IGNORE) so the row is inserted with a generated ID before SQLite
27
+ # evaluates the NOT NULL constraint on the outer statement.
28
+ #
29
+ # ## Usage in migrations (PostgreSQL example)
30
+ #
31
+ # class CreateUsers < ActiveRecord::Migration[7.0]
32
+ # def up
33
+ # enable_extension "pgcrypto" # once per database
34
+ #
35
+ # create_table :users, id: :string do |t|
36
+ # t.string :name, null: false
37
+ # t.timestamps
38
+ # end
39
+ #
40
+ # CustomId::DbExtension.install_trigger!(connection, :users, prefix: "usr")
41
+ # end
42
+ #
43
+ # def down
44
+ # CustomId::DbExtension.uninstall_trigger!(connection, :users)
45
+ # drop_table :users
46
+ # end
47
+ # end
48
+ #
49
+ # @note The shared-characters feature available in {CustomId::Concern} is
50
+ # intentionally omitted here because cross-table lookups inside a trigger
51
+ # introduce concurrency risks and make schema evolution painful.
52
+ module DbExtension
53
+ # rubocop:disable Style/MutableConstant
54
+ # Adapters considered PostgreSQL-compatible.
55
+ PG_ADAPTERS = %w[postgresql postgis].freeze
56
+ MYSQL_ADAPTERS = %w[mysql mysql2 trilogy].freeze
57
+ SQLITE_ADAPTERS = %w[sqlite sqlite3].freeze
58
+
59
+ SUPPORTED_ADAPTERS = (PG_ADAPTERS + MYSQL_ADAPTERS + SQLITE_ADAPTERS).freeze
60
+
61
+ # Base58 characters
62
+ ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
63
+
64
+ # --- PostgreSQL SQL ------------------------------------------------------
65
+
66
+ # SQL that creates (or replaces) the reusable Base58 generator function.
67
+ # Depends on +pgcrypto+'s +gen_random_bytes+.
68
+ PG_GENERATE_FUNCTION_SQL = <<~SQL
69
+ CREATE OR REPLACE FUNCTION custom_id_base58(p_size INT DEFAULT 16)
70
+ RETURNS TEXT AS $$
71
+ DECLARE
72
+ chars TEXT := '#{ALPHABET}';
73
+ result TEXT := '';
74
+ i INT;
75
+ rand_bytes BYTEA;
76
+ BEGIN
77
+ rand_bytes := gen_random_bytes(p_size);
78
+ FOR i IN 0..p_size - 1 LOOP
79
+ result := result || substr(chars, (get_byte(rand_bytes, i) % 58) + 1, 1);
80
+ END LOOP;
81
+ RETURN result;
82
+ END;
83
+ $$ LANGUAGE plpgsql;
84
+ SQL
85
+
86
+ # SQL that removes the shared Base58 generator function.
87
+ PG_DROP_GENERATE_FUNCTION_SQL = "DROP FUNCTION IF EXISTS custom_id_base58(INT);"
88
+
89
+ # --- MySQL SQL -----------------------------------------------------------
90
+
91
+ # MySQL implementation of Base58 generator.
92
+ # Note: RANDOM_BYTES(N) returns binary data.
93
+ MYSQL_GENERATE_FUNCTION_SQL = <<~SQL
94
+ CREATE FUNCTION IF NOT EXISTS custom_id_base58(p_size INT)
95
+ RETURNS TEXT DETERMINISTIC
96
+ BEGIN
97
+ DECLARE chars TEXT DEFAULT '#{ALPHABET}';
98
+ DECLARE result TEXT DEFAULT '';
99
+ DECLARE i INT DEFAULT 0;
100
+ WHILE i < p_size DO
101
+ SET result = CONCAT(result, SUBSTR(chars, (ORD(RANDOM_BYTES(1)) % 58) + 1, 1));
102
+ SET i = i + 1;
103
+ END WHILE;
104
+ RETURN result;
105
+ END;
106
+ SQL
107
+
108
+ # rubocop:enable Style/MutableConstant
109
+
110
+ MYSQL_DROP_GENERATE_FUNCTION_SQL = "DROP FUNCTION IF EXISTS custom_id_base58;"
111
+
112
+ # Returns +true+ when +connection+ targets a supported adapter.
113
+ #
114
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
115
+ # @return [Boolean]
116
+ def self.supported?(connection)
117
+ SUPPORTED_ADAPTERS.include?(connection.adapter_name.downcase)
118
+ end
119
+
120
+ # Installs the shared Base58 generator function into the database.
121
+ # Safe to call multiple times (uses +CREATE OR REPLACE+ or +IF NOT EXISTS+).
122
+ #
123
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
124
+ # @raise [NotImplementedError] when the adapter is not supported or (PG only)
125
+ # when the pgcrypto extension is not enabled.
126
+ def self.install_generate_function!(connection)
127
+ assert_supported!(connection)
128
+
129
+ case connection.adapter_name.downcase
130
+ when *PG_ADAPTERS
131
+ pg_assert_pgcrypto!(connection)
132
+ connection.execute(PG_GENERATE_FUNCTION_SQL)
133
+ when *MYSQL_ADAPTERS
134
+ connection.execute(MYSQL_GENERATE_FUNCTION_SQL)
135
+ when *SQLITE_ADAPTERS
136
+ # SQLite doesn't support stored functions in the same way.
137
+ # We'll embed the logic directly in the trigger.
138
+ end
139
+ end
140
+
141
+ # Removes the shared Base58 generator function from the database.
142
+ #
143
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
144
+ # @raise [NotImplementedError] when the adapter is not supported.
145
+ def self.uninstall_generate_function!(connection)
146
+ assert_supported!(connection)
147
+
148
+ case connection.adapter_name.downcase
149
+ when *PG_ADAPTERS
150
+ connection.execute(PG_DROP_GENERATE_FUNCTION_SQL)
151
+ when *MYSQL_ADAPTERS
152
+ connection.execute(MYSQL_DROP_GENERATE_FUNCTION_SQL)
153
+ end
154
+ end
155
+
156
+ # Installs both the per-table trigger function and the BEFORE INSERT trigger
157
+ # on +table_name+, auto-generating a prefixed custom ID when the column is NULL.
158
+ #
159
+ # Idempotent: uses +CREATE OR REPLACE FUNCTION+ and +DROP TRIGGER IF EXISTS+
160
+ # before re-creating the trigger.
161
+ #
162
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
163
+ # @param table_name [String, Symbol] Target table.
164
+ # @param prefix [String] ID prefix (e.g. "usr").
165
+ # @param column [Symbol, String] Column to populate (default: :id).
166
+ # @param size [Integer] Length of the random portion (default: 16).
167
+ # @raise [NotImplementedError] when the adapter is not supported.
168
+ #
169
+ # @note **MySQL + ActiveRecord string PKs:** MySQL's protocol does not return a
170
+ # trigger-generated string PK to the caller (unlike PostgreSQL's +RETURNING+).
171
+ # After an AR +create+, Rails reads +LAST_INSERT_ID()+ which returns +0+ for
172
+ # non-AUTO_INCREMENT columns, leaving the in-memory record with +id = "0"+
173
+ # while the database row is correct. Fix: also declare +cid+ on the model
174
+ # so that AR generates the ID in Ruby before the INSERT. The trigger then
175
+ # acts only as a safety net for raw-SQL inserts that bypass ActiveRecord.
176
+ def self.install_trigger!(connection, table_name, prefix:, column: :id, size: 16)
177
+ assert_supported!(connection)
178
+
179
+ adapter = connection.adapter_name.downcase
180
+ case adapter
181
+ when *PG_ADAPTERS
182
+ pg_assert_pgcrypto!(connection)
183
+ connection.execute(PG_GENERATE_FUNCTION_SQL)
184
+ connection.execute(pg_trigger_function_sql(table_name, prefix: prefix, column: column, size: size))
185
+ connection.execute(pg_create_trigger_sql(table_name, column: column))
186
+ when *MYSQL_ADAPTERS
187
+ connection.execute(MYSQL_GENERATE_FUNCTION_SQL)
188
+ # mysql2/trilogy execute only one statement per call, so DROP and CREATE
189
+ # must be sent separately (unlike PG which accepts multi-statement strings).
190
+ connection.execute(mysql_drop_trigger_sql(table_name, column: column))
191
+ connection.execute(mysql_create_trigger_sql(table_name, prefix: prefix, column: column, size: size))
192
+ when *SQLITE_ADAPTERS
193
+ sqlite_install_trigger!(connection, table_name, prefix: prefix, column: column, size: size)
194
+ end
195
+ end
196
+
197
+ # Drops the per-table trigger and its companion trigger function from +table_name+.
198
+ #
199
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
200
+ # @param table_name [String, Symbol] Target table.
201
+ # @param column [Symbol, String] Column the trigger was installed on (default: :id).
202
+ # @raise [NotImplementedError] when the adapter is not supported.
203
+ def self.uninstall_trigger!(connection, table_name, column: :id)
204
+ assert_supported!(connection)
205
+
206
+ adapter = connection.adapter_name.downcase
207
+ case adapter
208
+ when *PG_ADAPTERS
209
+ connection.execute(pg_drop_trigger_sql(table_name, column: column))
210
+ when *MYSQL_ADAPTERS
211
+ connection.execute(mysql_drop_trigger_sql(table_name, column: column))
212
+ when *SQLITE_ADAPTERS
213
+ connection.execute(sqlite_drop_trigger_sql(table_name, column: column))
214
+ end
215
+ end
216
+
217
+ # --- Private helpers -----------------------------------------------------
218
+
219
+ # Raises +NotImplementedError+ when the pgcrypto extension is absent.
220
+ # Called before installing any PostgreSQL function or trigger so the error
221
+ # surface is at install time rather than at the first INSERT.
222
+ private_class_method def self.pg_assert_pgcrypto!(connection)
223
+ enabled = connection.select_value(
224
+ "SELECT COUNT(*) FROM pg_extension WHERE extname = 'pgcrypto'"
225
+ ).to_i.positive?
226
+ return if enabled
227
+
228
+ raise NotImplementedError,
229
+ "The pgcrypto PostgreSQL extension is required but not enabled. " \
230
+ "Run: rails custom_id:db:enable_pgcrypto " \
231
+ "or add enable_extension \"pgcrypto\" to a migration."
232
+ end
233
+
234
+ private_class_method def self.assert_supported!(connection)
235
+ return if supported?(connection)
236
+
237
+ raise NotImplementedError,
238
+ "CustomId::DbExtension does not support #{connection.adapter_name}. " \
239
+ "Supported: #{SUPPORTED_ADAPTERS.join(", ")}. " \
240
+ "Use CustomId::Concern for other databases."
241
+ end
242
+
243
+ private_class_method def self.trigger_function_name(table_name, column)
244
+ "#{table_name}_#{column}_custom_id"
245
+ end
246
+
247
+ private_class_method def self.trigger_name(table_name, column)
248
+ "#{table_name}_#{column}_before_insert_custom_id"
249
+ end
250
+
251
+ # --- PostgreSQL specific helpers ---
252
+
253
+ private_class_method def self.pg_trigger_function_sql(table_name, prefix:, column:, size:)
254
+ func = trigger_function_name(table_name, column)
255
+ "CREATE OR REPLACE FUNCTION #{func}()\nRETURNS TRIGGER AS $$\nBEGIN\n IF NEW.#{column} IS NULL THEN\n NEW.#{column} := '#{prefix}_' || custom_id_base58(#{size});\n END IF;\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n" # rubocop:disable Layout/LineLength
256
+ end
257
+
258
+ private_class_method def self.pg_create_trigger_sql(table_name, column:)
259
+ trig = trigger_name(table_name, column)
260
+ fn = trigger_function_name(table_name, column)
261
+ <<~SQL
262
+ DROP TRIGGER IF EXISTS #{trig} ON #{table_name};
263
+ CREATE TRIGGER #{trig}
264
+ BEFORE INSERT ON #{table_name}
265
+ FOR EACH ROW EXECUTE FUNCTION #{fn}();
266
+ SQL
267
+ end
268
+
269
+ private_class_method def self.pg_drop_trigger_sql(table_name, column:)
270
+ trig = trigger_name(table_name, column)
271
+ fn = trigger_function_name(table_name, column)
272
+ <<~SQL
273
+ DROP TRIGGER IF EXISTS #{trig} ON #{table_name};
274
+ DROP FUNCTION IF EXISTS #{fn}();
275
+ SQL
276
+ end
277
+
278
+ # --- MySQL specific helpers ---
279
+
280
+ # Returns only the CREATE TRIGGER statement – the caller is responsible for
281
+ # dropping any existing trigger first (mysql2/trilogy reject multi-statement strings).
282
+ private_class_method def self.mysql_create_trigger_sql(table_name, prefix:, column:, size:)
283
+ trig = trigger_name(table_name, column)
284
+ <<~SQL
285
+ CREATE TRIGGER #{trig}
286
+ BEFORE INSERT ON #{table_name}
287
+ FOR EACH ROW
288
+ BEGIN
289
+ IF NEW.#{column} IS NULL THEN
290
+ SET NEW.#{column} = CONCAT('#{prefix}_', custom_id_base58(#{size}));
291
+ END IF;
292
+ END;
293
+ SQL
294
+ end
295
+
296
+ private_class_method def self.mysql_drop_trigger_sql(table_name, column:)
297
+ trig = trigger_name(table_name, column)
298
+ "DROP TRIGGER IF EXISTS #{trig};"
299
+ end
300
+
301
+ # --- SQLite specific helpers ---
302
+
303
+ private_class_method def self.sqlite_create_trigger_sql(table_name, prefix:, column:, size:)
304
+ trig = trigger_name(table_name, column)
305
+ # We generate the Base58 string by concatenating random characters from the ALPHABET.
306
+ generator = Array.new(size) { "substr('#{ALPHABET}', (abs(random()) % 58) + 1, 1)" }.join(" || ")
307
+
308
+ # In SQLite, we use an AFTER INSERT trigger to update the row.
309
+ # We use rowid to identify the row, as it is always present even if not explicitly defined.
310
+ <<~SQL
311
+ CREATE TRIGGER IF NOT EXISTS #{trig} AFTER INSERT ON #{table_name}
312
+ FOR EACH ROW
313
+ WHEN NEW.#{column} IS NULL
314
+ BEGIN
315
+ UPDATE #{table_name} SET #{column} = '#{prefix}_' || (#{generator}) WHERE rowid = NEW.rowid;
316
+ END;
317
+ SQL
318
+ end
319
+
320
+ private_class_method def self.sqlite_drop_trigger_sql(table_name, column:)
321
+ trig = trigger_name(table_name, column)
322
+ "DROP TRIGGER IF EXISTS #{trig};"
323
+ end
324
+
325
+ # Dispatches to the correct SQLite trigger strategy:
326
+ # * Non-PK / nullable column → AFTER INSERT trigger (updates the row in-place).
327
+ # * NOT NULL primary key → BEFORE INSERT trigger with RAISE(IGNORE) so the
328
+ # row is inserted with a generated id before SQLite checks the NOT NULL
329
+ # constraint on the original (id-less) outer statement.
330
+ #
331
+ # NOTE: When the BEFORE INSERT path is used, SQLite's RETURNING clause on
332
+ # the outer INSERT sees the original NULL value (the outer INSERT was
333
+ # abandoned via RAISE(IGNORE)). After +create+, call +reload+ on the record
334
+ # to obtain the correct id from the database.
335
+ private_class_method def self.sqlite_install_trigger!(connection, table_name, prefix:, column:, size:)
336
+ if sqlite_pk_not_null?(connection, table_name, column)
337
+ sql = sqlite_create_pk_trigger_sql(connection, table_name, prefix: prefix, column: column, size: size)
338
+ connection.execute(sql)
339
+ else
340
+ connection.execute(sqlite_create_trigger_sql(table_name, prefix: prefix, column: column, size: size))
341
+ end
342
+ end
343
+
344
+ # Builds the BEFORE INSERT + RAISE(IGNORE) trigger used when the target
345
+ # column is a NOT NULL primary key. The trigger body:
346
+ # 1. Inserts the row with a generated id (the WHEN guard prevents recursion).
347
+ # 2. Calls RAISE(IGNORE) to silently abandon the outer NULL-id statement.
348
+ private_class_method def self.sqlite_create_pk_trigger_sql(connection, table_name, prefix:, column:, size:)
349
+ trig = trigger_name(table_name, column)
350
+ generator = Array.new(size) { "substr('#{ALPHABET}', (abs(random()) % 58) + 1, 1)" }.join(" || ")
351
+ col_list, val_list = sqlite_pk_col_and_val_lists(connection, table_name, column, prefix, generator)
352
+ <<~SQL
353
+ CREATE TRIGGER IF NOT EXISTS #{trig} BEFORE INSERT ON #{table_name}
354
+ FOR EACH ROW WHEN NEW.#{column} IS NULL
355
+ BEGIN
356
+ INSERT INTO #{table_name} (#{col_list}) VALUES (#{val_list});
357
+ SELECT RAISE(IGNORE);
358
+ END;
359
+ SQL
360
+ end
361
+
362
+ # Returns [col_list, val_list] strings for the inner INSERT inside the
363
+ # BEFORE INSERT trigger. The target +column+ gets the generated id
364
+ # expression; every other column gets the corresponding NEW.column value.
365
+ private_class_method def self.sqlite_pk_col_and_val_lists(connection, table_name, column, prefix, generator)
366
+ cols = connection.columns(table_name.to_s).map(&:name)
367
+ val_list = cols.map do |c|
368
+ c == column.to_s ? "'#{prefix}_' || (#{generator})" : "NEW.#{c}"
369
+ end.join(", ")
370
+ [cols.join(", "), val_list]
371
+ end
372
+
373
+ # Returns true when +column+ is the NOT NULL primary key of +table_name+.
374
+ # This is the case where an AFTER INSERT trigger cannot fire (the NOT NULL
375
+ # constraint blocks the INSERT first) and the BEFORE INSERT path is needed.
376
+ private_class_method def self.sqlite_pk_not_null?(connection, table_name, column)
377
+ pk = connection.primary_key(table_name.to_s)
378
+ return false unless pk == column.to_s
379
+
380
+ col = connection.columns(table_name.to_s).find { |c| c.name == column.to_s }
381
+ col && !col.null
382
+ end
383
+ end
384
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module CustomId
6
+ # Manages the Rails initializer file that auto-includes {CustomId::Concern}
7
+ # into every ActiveRecord model via +ActiveSupport.on_load(:active_record)+.
8
+ #
9
+ # This class is intentionally decoupled from Rails so it can be exercised in
10
+ # unit tests without booting a full Rails application.
11
+ class Installer
12
+ # Path of the generated initializer, relative to the Rails root.
13
+ INITIALIZER_PATH = Pathname.new("config/initializers/custom_id.rb")
14
+
15
+ # Content written to the initializer file.
16
+ INITIALIZER_CONTENT = <<~RUBY
17
+ # frozen_string_literal: true
18
+
19
+ # Auto-include CustomId::Concern into every ActiveRecord model so that
20
+ # the +cid+ class macro is available application-wide.
21
+ #
22
+ # Generated by: rails custom_id:install
23
+ ActiveSupport.on_load(:active_record) do
24
+ include CustomId::Concern
25
+ end
26
+ RUBY
27
+
28
+ # Creates the initializer file under +rails_root+.
29
+ #
30
+ # @param rails_root [Pathname, String] Root of the Rails application.
31
+ # @return [:created] when the file was written.
32
+ # @return [:skipped] when the file already exists.
33
+ def self.install!(rails_root)
34
+ target = Pathname(rails_root).join(INITIALIZER_PATH)
35
+ return :skipped if target.exist?
36
+
37
+ target.dirname.mkpath
38
+ target.write(INITIALIZER_CONTENT)
39
+ :created
40
+ end
41
+
42
+ # Removes the initializer file from +rails_root+.
43
+ #
44
+ # @param rails_root [Pathname, String] Root of the Rails application.
45
+ # @return [:removed] when the file was deleted.
46
+ # @return [:skipped] when the file did not exist.
47
+ def self.uninstall!(rails_root)
48
+ target = Pathname(rails_root).join(INITIALIZER_PATH)
49
+ return :skipped unless target.exist?
50
+
51
+ target.delete
52
+ :removed
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CustomId
4
+ # Hooks CustomId into a Rails application.
5
+ #
6
+ # When Rails loads, this Railtie exposes the +custom_id:install+ and
7
+ # +custom_id:uninstall+ rake tasks so developers can set up the initializer
8
+ # that auto-includes {CustomId::Concern} into every ActiveRecord model.
9
+ class Railtie < Rails::Railtie
10
+ railtie_name :custom_id
11
+
12
+ rake_tasks do
13
+ load File.expand_path("../tasks/custom_id.rake", __dir__)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CustomId
4
+ VERSION = "0.1.0"
5
+ end
data/lib/custom_id.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "custom_id/version"
4
+ require_relative "custom_id/concern"
5
+ require_relative "custom_id/installer"
6
+ require_relative "custom_id/db_extension"
7
+
8
+ module CustomId
9
+ class Error < StandardError; end
10
+ end
11
+
12
+ require "custom_id/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ CUSTOM_ID_INITIALIZER_PATH = CustomId::Installer::INITIALIZER_PATH.to_s
4
+
5
+ namespace :custom_id do
6
+ desc "Install the CustomId initializer that auto-includes CustomId::Concern into ActiveRecord"
7
+ task :install do
8
+ result = CustomId::Installer.install!(Rails.root)
9
+ case result
10
+ when :created then puts " #{"create".ljust(10)} #{CUSTOM_ID_INITIALIZER_PATH}"
11
+ when :skipped then puts " #{"skip".ljust(10)} #{CUSTOM_ID_INITIALIZER_PATH} already exists"
12
+ end
13
+ end
14
+
15
+ desc "Remove the CustomId initializer"
16
+ task :uninstall do
17
+ result = CustomId::Installer.uninstall!(Rails.root)
18
+ case result
19
+ when :removed then puts " #{"remove".ljust(10)} #{CUSTOM_ID_INITIALIZER_PATH}"
20
+ when :skipped then puts " #{"skip".ljust(10)} #{CUSTOM_ID_INITIALIZER_PATH} not found"
21
+ end
22
+ end
23
+
24
+ namespace :db do
25
+ # Resolves an ActiveRecord connection for an optional +database_key+.
26
+ #
27
+ # * When +database_key+ is nil or blank the default AR connection is returned.
28
+ # * When a key is given the matching config for the current Rails environment is
29
+ # looked up via +configurations.find_db_config+. A named abstract AR subclass
30
+ # (CustomId::RakeDbProxy) is used so the global default connection is never replaced.
31
+ # * Rails 7.2+ requires a named class for +establish_connection+; assigning to a
32
+ # constant gives the class a non-nil +name+ so the check passes.
33
+ # * Aborts with a list of valid database names when the key is unknown.
34
+ resolve_connection = lambda do |database_key|
35
+ return ActiveRecord::Base.connection if database_key.nil? || database_key.strip.empty?
36
+
37
+ db_config = ActiveRecord::Base.configurations.find_db_config(database_key)
38
+ unless db_config
39
+ all_configs = ActiveRecord::Base.configurations.configurations
40
+ available = all_configs.select { |c| c.env_name == Rails.env }.reject(&:replica?).map(&:name).join(", ")
41
+ abort " error: Unknown database \"#{database_key}\". " \
42
+ "Available for \"#{Rails.env}\": #{available}"
43
+ end
44
+
45
+ unless CustomId.const_defined?(:RakeDbProxy, false)
46
+ proxy = Class.new(ActiveRecord::Base) { self.abstract_class = true }
47
+ CustomId.const_set(:RakeDbProxy, proxy)
48
+ end
49
+ CustomId::RakeDbProxy.establish_connection(db_config)
50
+ CustomId::RakeDbProxy.connection
51
+ end
52
+
53
+ desc "Enable the pgcrypto PostgreSQL extension required by the DB functions [DATABASE]"
54
+ task :enable_pgcrypto, [:database] => :environment do |_task, args|
55
+ conn = resolve_connection.call(args[:database])
56
+ unless CustomId::DbExtension::PG_ADAPTERS.include?(conn.adapter_name.downcase)
57
+ abort " error: pgcrypto is a PostgreSQL extension; adapter is #{conn.adapter_name}"
58
+ end
59
+ conn.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
60
+ db_tag = args[:database].presence ? " (db=#{args[:database]})" : ""
61
+ puts " #{"create".ljust(10)} pgcrypto extension#{db_tag}"
62
+ rescue ActiveRecord::StatementInvalid => e
63
+ abort " error: #{e.message}"
64
+ end
65
+
66
+ desc "Install the shared custom_id_base58() DB function (PG/MySQL only) [DATABASE]"
67
+ task :install_function, [:database] => :environment do |_task, args|
68
+ conn = resolve_connection.call(args[:database])
69
+ CustomId::DbExtension.install_generate_function!(conn)
70
+ puts " #{"create".ljust(10)} custom_id_base58() function"
71
+ rescue NotImplementedError => e
72
+ abort " error: #{e.message}"
73
+ end
74
+
75
+ desc "Remove the shared custom_id_base58() DB function (PG/MySQL only) [DATABASE]"
76
+ task :uninstall_function, [:database] => :environment do |_task, args|
77
+ conn = resolve_connection.call(args[:database])
78
+ CustomId::DbExtension.uninstall_generate_function!(conn)
79
+ puts " #{"remove".ljust(10)} custom_id_base58() function"
80
+ rescue NotImplementedError => e
81
+ abort " error: #{e.message}"
82
+ end
83
+
84
+ desc "Add a database-level trigger on TABLE with PREFIX [COLUMN=id] [SIZE=16] [DATABASE]"
85
+ task :add_trigger, %i[table prefix column size database] => :environment do |_task, args|
86
+ table = args[:table]
87
+ prefix = args[:prefix]
88
+ abort 'Usage: rails "custom_id:db:add_trigger[table,prefix,column,size,database]"' if table.nil? || prefix.nil?
89
+ column = (args[:column].presence || "id").to_sym
90
+ size = (args[:size].presence || "16").to_i
91
+ conn = resolve_connection.call(args[:database])
92
+ CustomId::DbExtension.install_trigger!(conn, table, prefix: prefix, column: column, size: size)
93
+ db_tag = args[:database].presence ? " (db=#{args[:database]})" : ""
94
+ puts " #{"create".ljust(10)} trigger on #{table}.#{column}#{db_tag} (prefix=#{prefix}, size=#{size})"
95
+ if CustomId::DbExtension::MYSQL_ADAPTERS.include?(conn.adapter_name.downcase)
96
+ warn " warn MySQL: pair this trigger with `cid \"#{prefix}\"` on the model."
97
+ warn " Without cid, ActiveRecord reads LAST_INSERT_ID() = 0 for string PKs"
98
+ warn " and nil for other trigger-managed columns after INSERT."
99
+ warn " The trigger still fires for raw SQL inserts that bypass ActiveRecord."
100
+ end
101
+ rescue NotImplementedError => e
102
+ abort " error: #{e.message}"
103
+ end
104
+
105
+ desc "Remove the database-level trigger from TABLE [COLUMN=id] [DATABASE]"
106
+ task :remove_trigger, %i[table column database] => :environment do |_task, args|
107
+ table = args[:table]
108
+ abort 'Usage: rails "custom_id:db:remove_trigger[table,column,database]"' if table.nil?
109
+ column = (args[:column].presence || "id").to_sym
110
+ conn = resolve_connection.call(args[:database])
111
+ CustomId::DbExtension.uninstall_trigger!(conn, table, column: column)
112
+ db_tag = args[:database].presence ? " (db=#{args[:database]})" : ""
113
+ puts " #{"remove".ljust(10)} trigger on #{table}.#{column}#{db_tag}"
114
+ rescue NotImplementedError => e
115
+ abort " error: #{e.message}"
116
+ end
117
+ end
118
+ end