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.
- checksums.yaml +7 -0
- data/AGENTS.md +143 -0
- data/CHANGELOG.md +20 -0
- data/CLAUDE.md +133 -0
- data/LICENSE.txt +21 -0
- data/README.md +240 -0
- data/lib/custom_id/concern.rb +103 -0
- data/lib/custom_id/db_extension.rb +384 -0
- data/lib/custom_id/installer.rb +55 -0
- data/lib/custom_id/railtie.rb +16 -0
- data/lib/custom_id/version.rb +5 -0
- data/lib/custom_id.rb +12 -0
- data/lib/tasks/custom_id.rake +118 -0
- data/llms/overview.md +274 -0
- data/llms/usage.md +530 -0
- metadata +122 -0
|
@@ -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
|
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
|