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.
data/llms/usage.md ADDED
@@ -0,0 +1,530 @@
1
+ # CustomId – Usage Patterns for LLMs
2
+
3
+ > Concrete, copy-pasteable examples covering every supported scenario.
4
+ > Optimised for LLM code generation – each section is self-contained.
5
+
6
+ ---
7
+
8
+ ## 1. Installation
9
+
10
+ ```bash
11
+ # Gemfile
12
+ gem "custom_id"
13
+
14
+ # Terminal
15
+ bundle install
16
+ rails custom_id:install # creates config/initializers/custom_id.rb
17
+ ```
18
+
19
+ The initializer auto-includes `CustomId::Concern` into every `ActiveRecord::Base`
20
+ subclass. No `include` is needed in individual models.
21
+
22
+ ---
23
+
24
+ ## 2. Migration – string primary key
25
+
26
+ Always declare the id column as `:string` for tables where `cid` manages the
27
+ primary key.
28
+
29
+ ```ruby
30
+ class CreateUsers < ActiveRecord::Migration[7.1]
31
+ def change
32
+ create_table :users, id: :string do |t|
33
+ t.string :name, null: false
34
+ t.string :email, null: false, index: { unique: true }
35
+ t.timestamps
36
+ end
37
+ end
38
+ end
39
+ ```
40
+
41
+ **Do not** set a `default:` on the id column – the gem handles that.
42
+
43
+ ---
44
+
45
+ ## 3. Basic model – default primary key
46
+
47
+ ```ruby
48
+ class User < ApplicationRecord
49
+ cid "usr"
50
+ end
51
+
52
+ # Result:
53
+ user = User.create!(name: "Alice", email: "alice@example.com")
54
+ user.id # => "usr_7xKmN2pQaBcDeFgH" (16 random Base58 chars after "usr_")
55
+ ```
56
+
57
+ The ID format is always `"#{prefix}_#{random}"` where `random` is `size`
58
+ Base58 characters long.
59
+
60
+ ---
61
+
62
+ ## 4. Custom size
63
+
64
+ ```ruby
65
+ class ApiKey < ApplicationRecord
66
+ cid "key", size: 32
67
+ end
68
+
69
+ ApiKey.create!.id # => "key_<32 Base58 chars>"
70
+ ```
71
+
72
+ `size` controls the length of the random portion only, not the total ID length.
73
+ Total length = `prefix.length + 1 + size`.
74
+
75
+ ---
76
+
77
+ ## 5. Non-primary-key column (`name:`)
78
+
79
+ Use when you want a generated reference code in a non-PK column. The primary
80
+ key can remain an integer or UUID.
81
+
82
+ **Migration:**
83
+
84
+ ```ruby
85
+ class AddSlugToArticles < ActiveRecord::Migration[7.1]
86
+ def change
87
+ add_column :articles, :slug, :string
88
+ add_index :articles, :slug, unique: true
89
+ end
90
+ end
91
+ ```
92
+
93
+ **Model:**
94
+
95
+ ```ruby
96
+ class Article < ApplicationRecord
97
+ # id is a regular integer PK managed by the DB
98
+ cid "art", name: :slug, size: 12
99
+ end
100
+
101
+ article = Article.create!(title: "Hello World")
102
+ article.id # => 1 (integer)
103
+ article.slug # => "art_aBcDeFgHiJkL"
104
+ ```
105
+
106
+ **Skipping generation** – pre-set the attribute before save:
107
+
108
+ ```ruby
109
+ Article.create!(title: "Custom slug", slug: "art_my_special_code")
110
+ # slug will NOT be overwritten because it is not nil
111
+ ```
112
+
113
+ ---
114
+
115
+ ## 6. Embedding parent ID characters (`related:`)
116
+
117
+ Visually embeds the first N characters of a parent model's random portion into
118
+ the child's ID. Useful for traceability – you can tell which workspace a
119
+ document belongs to just by looking at its ID.
120
+
121
+ **Models:**
122
+
123
+ ```ruby
124
+ class Workspace < ApplicationRecord
125
+ cid "wsp"
126
+ end
127
+
128
+ class Document < ApplicationRecord
129
+ belongs_to :workspace
130
+ # Borrow first 6 chars of workspace's random portion; total random = 22
131
+ cid "doc", size: 22, related: { workspace: 6 }
132
+ end
133
+ ```
134
+
135
+ **Migration for Document:**
136
+
137
+ ```ruby
138
+ create_table :documents, id: :string do |t|
139
+ t.string :title, null: false
140
+ t.string :workspace_id, null: false
141
+ t.timestamps
142
+ end
143
+ ```
144
+
145
+ **Usage:**
146
+
147
+ ```ruby
148
+ workspace = Workspace.create!(name: "Acme")
149
+ workspace.id # => "wsp_ABCDEF..."
150
+
151
+ doc = Document.create!(title: "Spec", workspace: workspace)
152
+ doc.id # => "doc_ABCDEF<16 random chars>"
153
+ # ^^^^^^ first 6 chars of workspace's random portion
154
+ ```
155
+
156
+ **Constraint:** `size` must be strictly greater than the borrowed char count.
157
+ `cid "doc", size: 6, related: { workspace: 6 }` raises `ArgumentError`.
158
+
159
+ **Nil parent:** if the foreign key is `nil` at create time, the shared portion
160
+ falls back to `""` and the full `size` is random.
161
+
162
+ ---
163
+
164
+ ## 7. `related:` key must match the `belongs_to` name
165
+
166
+ ```ruby
167
+ belongs_to :user → related: { user: 8 } ✓
168
+ belongs_to :created_by_user → related: { created_by_user: 8 } ✓
169
+ belongs_to :user, foreign_key: :author_id → related: { user: 8 } ✓
170
+ # gem resolves FK via reflection
171
+ ```
172
+
173
+ ---
174
+
175
+ ## 8. Multiple `cid` calls on one model
176
+
177
+ Each call registers an independent `before_create` callback. Use this to
178
+ manage multiple string columns:
179
+
180
+ ```ruby
181
+ class Contract < ApplicationRecord
182
+ cid "ctr" # manages :id
183
+ cid "ref", name: :ref_number, size: 8 # manages :ref_number column
184
+ end
185
+ ```
186
+
187
+ **Warning:** calling `cid` twice for the **same column** on the same class
188
+ stacks two callbacks; the second one will always skip because the first already
189
+ set the value. Don't do this.
190
+
191
+ ---
192
+
193
+ ## 9. Manual include (without the Rails initializer)
194
+
195
+ For non-Rails setups or for a single model:
196
+
197
+ ```ruby
198
+ require "custom_id"
199
+
200
+ class MyModel < ActiveRecord::Base
201
+ include CustomId::Concern
202
+ cid "my"
203
+ end
204
+ ```
205
+
206
+ ---
207
+
208
+ ## 10. Database trigger alternative (`DbExtension`)
209
+
210
+ Use when IDs must be generated even for raw SQL inserts (bulk imports, ETL).
211
+ Supports **PostgreSQL 9.6+**, **MySQL 5.7+**, and **SQLite 3.0+**.
212
+
213
+ ### 10a. PostgreSQL
214
+
215
+ **Requires the `pgcrypto` extension.**
216
+
217
+ ```bash
218
+ # Enable pgcrypto via rake task (once per database)
219
+ rails custom_id:db:enable_pgcrypto
220
+ # or for a specific database in a multi-database app:
221
+ rails "custom_id:db:enable_pgcrypto[postgres]"
222
+ ```
223
+
224
+ Or in a migration:
225
+
226
+ ```ruby
227
+ class EnablePgcrypto < ActiveRecord::Migration[8.0]
228
+ def up = enable_extension "pgcrypto"
229
+ def down = disable_extension "pgcrypto"
230
+ end
231
+ ```
232
+
233
+ **Install trigger in the same migration as table creation:**
234
+
235
+ ```ruby
236
+ class CreateTeams < ActiveRecord::Migration[7.1]
237
+ def up
238
+ create_table :teams, id: :string do |t|
239
+ t.string :name, null: false
240
+ t.timestamps
241
+ end
242
+ CustomId::DbExtension.install_trigger!(connection, :teams, prefix: "tea")
243
+ end
244
+
245
+ def down
246
+ CustomId::DbExtension.uninstall_trigger!(connection, :teams)
247
+ drop_table :teams
248
+ end
249
+ end
250
+ ```
251
+
252
+ PostgreSQL returns the generated PK via `RETURNING "id"`, so the ActiveRecord
253
+ object has the correct `id` after `create` without any extra work.
254
+
255
+ ### 10b. MySQL – always pair with `cid` on the model
256
+
257
+ **⚠ Important:** MySQL's `LAST_INSERT_ID()` returns `0` for
258
+ non-`AUTO_INCREMENT` columns. After an AR `create` that leaves `id` blank,
259
+ Rails reads back `0` even though the DB row has the correct trigger-generated
260
+ value.
261
+
262
+ **Required pattern:** declare `cid` on the model alongside the trigger.
263
+
264
+ ```ruby
265
+ # model – cid generates id in Ruby before INSERT
266
+ class Order < ApplicationRecord
267
+ cid "ord"
268
+ end
269
+ ```
270
+
271
+ ```ruby
272
+ # migration
273
+ class CreateOrders < ActiveRecord::Migration[8.0]
274
+ def up
275
+ create_table :orders, id: :string do |t|
276
+ t.string :status, null: false
277
+ t.timestamps
278
+ end
279
+ CustomId::DbExtension.install_trigger!(connection, :orders, prefix: "ord")
280
+ end
281
+
282
+ def down
283
+ CustomId::DbExtension.uninstall_trigger!(connection, :orders)
284
+ drop_table :orders
285
+ end
286
+ end
287
+ ```
288
+
289
+ With `cid` on the model, the INSERT includes `id` in the column list, the
290
+ trigger's `IF NEW.id IS NULL` guard is false (no-op), and AR has the correct
291
+ ID. The trigger still fires for raw SQL inserts that bypass ActiveRecord.
292
+
293
+ The rake task `custom_id:db:add_trigger` prints a reminder when targeting MySQL:
294
+
295
+ ```
296
+ warn MySQL: pair this trigger with `cid "ord"` on the model.
297
+ ```
298
+
299
+ ### 10c. SQLite
300
+
301
+ SQLite uses two strategies automatically selected by `install_trigger!`:
302
+
303
+ **Nullable / non-PK column** → AFTER INSERT trigger updates the row in place.
304
+
305
+ **NOT NULL primary key** → BEFORE INSERT + RAISE(IGNORE) pattern:
306
+
307
+ ```ruby
308
+ # migration
309
+ class CreateItems < ActiveRecord::Migration[8.0]
310
+ def up
311
+ create_table :items, id: :string do |t|
312
+ t.string :name
313
+ end
314
+ CustomId::DbExtension.install_trigger!(connection, :items, prefix: "itm")
315
+ end
316
+
317
+ def down
318
+ CustomId::DbExtension.uninstall_trigger!(connection, :items)
319
+ drop_table :items
320
+ end
321
+ end
322
+ ```
323
+
324
+ ⚠ When the BEFORE INSERT + RAISE(IGNORE) path is used, `RETURNING "id"` on
325
+ the outer INSERT returns nothing. The in-memory AR record may have a stale `id`
326
+ until reloaded:
327
+
328
+ ```ruby
329
+ item = Item.create!(name: "Widget")
330
+ item.reload # fetch the trigger-generated id from the DB
331
+ item.id # => "itm_Ab3xY7…"
332
+ ```
333
+
334
+ ### 10d. Custom column and size (all adapters)
335
+
336
+ ```ruby
337
+ CustomId::DbExtension.install_trigger!(
338
+ connection, :reports,
339
+ prefix: "rpt",
340
+ column: :report_key, # default: :id
341
+ size: 24 # default: 16
342
+ )
343
+ ```
344
+
345
+ ### 10e. Remove trigger (all adapters)
346
+
347
+ ```ruby
348
+ CustomId::DbExtension.uninstall_trigger!(connection, :teams)
349
+ CustomId::DbExtension.uninstall_trigger!(connection, :reports, column: :report_key)
350
+ ```
351
+
352
+ ### 10f. Check adapter support
353
+
354
+ ```ruby
355
+ CustomId::DbExtension.supported?(ActiveRecord::Base.connection) # => true/false
356
+ ```
357
+
358
+ ---
359
+
360
+ ## 11. Rails installer rake tasks
361
+
362
+ ```bash
363
+ rails custom_id:install # create config/initializers/custom_id.rb
364
+ rails custom_id:uninstall # remove config/initializers/custom_id.rb
365
+ ```
366
+
367
+ The installed file contains:
368
+
369
+ ```ruby
370
+ # frozen_string_literal: true
371
+ ActiveSupport.on_load(:active_record) do
372
+ include CustomId::Concern
373
+ end
374
+ ```
375
+
376
+ ---
377
+
378
+ ## 12. Complete working example
379
+
380
+ ```ruby
381
+ # db/migrate/..._create_workspace_documents.rb
382
+ class CreateWorkspaceDocuments < ActiveRecord::Migration[7.1]
383
+ def change
384
+ create_table :workspaces, id: :string do |t|
385
+ t.string :name, null: false
386
+ t.timestamps
387
+ end
388
+
389
+ create_table :documents, id: :string do |t|
390
+ t.string :title, null: false
391
+ t.string :workspace_id, null: false, index: true
392
+ t.string :slug, index: { unique: true }
393
+ t.timestamps
394
+ end
395
+ end
396
+ end
397
+
398
+ # app/models/workspace.rb
399
+ class Workspace < ApplicationRecord
400
+ has_many :documents
401
+ cid "wsp"
402
+ end
403
+
404
+ # app/models/document.rb
405
+ class Document < ApplicationRecord
406
+ belongs_to :workspace
407
+ cid "doc", size: 24, related: { workspace: 6 }
408
+ cid "dsl", name: :slug, size: 10
409
+ end
410
+
411
+ # Usage in console / specs
412
+ workspace = Workspace.create!(name: "Acme Corp")
413
+ # workspace.id => "wsp_AbCdEf1234567890"
414
+
415
+ doc = Document.create!(title: "Roadmap", workspace:)
416
+ # doc.id => "doc_AbCdEf<18 random chars>" (shares "AbCdEf" with workspace)
417
+ # doc.slug => "dsl_<10 random chars>"
418
+ ```
419
+
420
+ ---
421
+
422
+ ## 13. Error reference
423
+
424
+ | Error | Message | Cause | Fix |
425
+ |-------|---------|-------|-----|
426
+ | `ArgumentError` | `size (N) must be greater than the number of shared characters (M)` | `size <= chars_to_borrow` in `related:` | Increase `size` |
427
+ | `NotImplementedError` | `CustomId::DbExtension does not support …` | `DbExtension` called on an unsupported adapter | Supported: PG, MySQL, SQLite |
428
+ | `NotImplementedError` | `The pgcrypto PostgreSQL extension is required but not enabled. Run: rails custom_id:db:enable_pgcrypto` | pgcrypto missing | Run the rake task or add migration |
429
+ | `ActiveRecord::NotNullViolation` | `NOT NULL constraint failed: table.id` | String PK table, `cid` callback not firing | Verify include / table schema |
430
+ | `id = "0"` after `Model.create` (MySQL) | *(no exception)* | `LAST_INSERT_ID()` returns 0 for string PKs | Add `cid "prefix"` to the model |
431
+
432
+ ---
433
+
434
+ ## 14. DB-extension rake tasks
435
+
436
+ These tasks let you install and remove `CustomId::DbExtension` objects without
437
+ writing migration code. They wrap the `DbExtension` class methods and require
438
+ a live database connection (`:environment` task).
439
+
440
+ All tasks accept an optional `DATABASE` positional argument that targets a
441
+ named database from `database.yml`. Omit it to use the default connection.
442
+
443
+ **Enable pgcrypto (PostgreSQL only – once per database)**
444
+
445
+ ```bash
446
+ rails custom_id:db:enable_pgcrypto
447
+ rails "custom_id:db:enable_pgcrypto[postgres]" # multi-database
448
+ # create pgcrypto extension
449
+ ```
450
+
451
+ **Install the shared Base58 function (PG and MySQL)**
452
+
453
+ ```bash
454
+ rails custom_id:db:install_function
455
+ rails "custom_id:db:install_function[postgres]" # multi-database
456
+ # create custom_id_base58() function
457
+ ```
458
+
459
+ Safe to call multiple times – uses `CREATE OR REPLACE` / `IF NOT EXISTS`.
460
+
461
+ **Remove the shared Base58 function**
462
+
463
+ ```bash
464
+ rails custom_id:db:uninstall_function
465
+ rails "custom_id:db:uninstall_function[postgres]" # multi-database
466
+ # remove custom_id_base58() function
467
+ ```
468
+
469
+ **Add a BEFORE INSERT trigger to a table**
470
+
471
+ ```bash
472
+ # Minimal – column defaults to :id, size defaults to 16
473
+ rails "custom_id:db:add_trigger[users,usr]"
474
+ # create trigger on users.id (prefix=usr, size=16)
475
+
476
+ # Custom column and size
477
+ rails "custom_id:db:add_trigger[reports,rpt,report_key,24]"
478
+ # create trigger on reports.report_key (prefix=rpt, size=24)
479
+
480
+ # Multi-database (skip optional args with empty positions)
481
+ rails "custom_id:db:add_trigger[users,usr,,,postgres]"
482
+ rails "custom_id:db:add_trigger[reports,rpt,report_key,24,postgres]"
483
+ ```
484
+
485
+ Arguments (positional, comma-separated inside brackets):
486
+
487
+ | Position | Name | Default | Notes |
488
+ |----------|------|---------|-------|
489
+ | 1 | `table` | required | Table name |
490
+ | 2 | `prefix` | required | ID prefix string |
491
+ | 3 | `column` | `id` | Column to populate |
492
+ | 4 | `size` | `16` | Random portion length |
493
+ | 5 | `database` | *(default)* | Named DB from `database.yml` |
494
+
495
+ **MySQL warning:** when targeting a MySQL connection the task also prints:
496
+
497
+ ```
498
+ warn MySQL: pair this trigger with `cid "prefix"` on the model.
499
+ Without cid, ActiveRecord reads LAST_INSERT_ID() = 0 for string PKs
500
+ and nil for other trigger-managed columns after INSERT.
501
+ The trigger still fires for raw SQL inserts that bypass ActiveRecord.
502
+ ```
503
+
504
+ **Remove a BEFORE INSERT trigger from a table**
505
+
506
+ ```bash
507
+ rails "custom_id:db:remove_trigger[users]"
508
+ # remove trigger on users.id
509
+
510
+ rails "custom_id:db:remove_trigger[reports,report_key]"
511
+ # remove trigger on reports.report_key
512
+
513
+ rails "custom_id:db:remove_trigger[users,,postgres]" # multi-database
514
+ ```
515
+
516
+ **Error behaviour**
517
+
518
+ If pgcrypto is missing on PostgreSQL:
519
+
520
+ ```
521
+ error: The pgcrypto PostgreSQL extension is required but not enabled.
522
+ Run: rails custom_id:db:enable_pgcrypto
523
+ or add enable_extension "pgcrypto" to a migration.
524
+ ```
525
+
526
+ If an unknown database name is passed:
527
+
528
+ ```
529
+ error: Unknown database "bad_name". Available for "development": primary, postgres, mysql
530
+ ```
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: custom_id
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Pawel Niemczyk
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activesupport
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9'
52
+ - !ruby/object:Gem::Dependency
53
+ name: railties
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '7.0'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '9'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '7.0'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '9'
72
+ description: |
73
+ CustomId generates unique, human-readable, prefixed string IDs (e.g. "usr_7xKmN2pQ…")
74
+ for ActiveRecord models. Inspired by Stripe-style identifiers. Supports embedding
75
+ shared characters from related model IDs, custom target columns, configurable
76
+ random-part length, and an optional PostgreSQL trigger-based alternative.
77
+ email:
78
+ - pawel@way2do.it
79
+ executables: []
80
+ extensions: []
81
+ extra_rdoc_files: []
82
+ files:
83
+ - AGENTS.md
84
+ - CHANGELOG.md
85
+ - CLAUDE.md
86
+ - LICENSE.txt
87
+ - README.md
88
+ - lib/custom_id.rb
89
+ - lib/custom_id/concern.rb
90
+ - lib/custom_id/db_extension.rb
91
+ - lib/custom_id/installer.rb
92
+ - lib/custom_id/railtie.rb
93
+ - lib/custom_id/version.rb
94
+ - lib/tasks/custom_id.rake
95
+ - llms/overview.md
96
+ - llms/usage.md
97
+ homepage: https://github.com/pniemczyk/custom_id
98
+ licenses:
99
+ - MIT
100
+ metadata:
101
+ homepage_uri: https://github.com/pniemczyk/custom_id
102
+ source_code_uri: https://github.com/pniemczyk/custom_id
103
+ changelog_uri: https://github.com/pniemczyk/custom_id/blob/main/CHANGELOG.md
104
+ rubygems_mfa_required: 'true'
105
+ rdoc_options: []
106
+ require_paths:
107
+ - lib
108
+ required_ruby_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: 3.2.0
113
+ required_rubygems_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ requirements: []
119
+ rubygems_version: 3.6.9
120
+ specification_version: 4
121
+ summary: Prefixed, Stripe-style custom IDs for ActiveRecord models
122
+ test_files: []