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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dd4f65f79f9628ea3ca56ab97b1dd5916e0efc2dd3a13d441ba3e263ebaedd7e
4
+ data.tar.gz: f1097107e8511f05d2a8b20f4f162a0e06c6c605a387a4d7c4213b8fefa9ec0a
5
+ SHA512:
6
+ metadata.gz: 87183837c3630a4f66a9630e38683d0a91de7ff28c9a0b804a95c5314131274ab063a4715f844371011334cfa0842669a262ba93038d6427dff57644ed3fe7b9
7
+ data.tar.gz: cc1274ae3575c48ea41e103efed632da85bd7ca090968f1b7c81fab9bbbd532c8feb008f4e72585ca930ce61d64d128de3f0293d3245d85087bbb47d960f50a6
data/AGENTS.md ADDED
@@ -0,0 +1,143 @@
1
+ # CustomId – Agent Guide
2
+
3
+ > Concise reference for AI coding agents working **on** the `custom_id` gem or
4
+ > **with** it inside a host Rails application.
5
+ >
6
+ > For end-user documentation see `README.md`.
7
+ > For LLM-optimised usage patterns see `llms/usage.md`.
8
+ > For architecture details see `llms/overview.md`.
9
+
10
+ ---
11
+
12
+ ## What this gem does
13
+
14
+ `custom_id` generates unique, prefixed, Base58 string IDs for ActiveRecord
15
+ models – e.g. `"usr_7xKmN2pQ…"` – via a single class-macro `cid`.
16
+
17
+ ---
18
+
19
+ ## Quick integration checklist (host app)
20
+
21
+ ```bash
22
+ bundle add custom_id
23
+ rails custom_id:install # creates config/initializers/custom_id.rb
24
+ ```
25
+
26
+ After that every model inherits the `cid` macro automatically. No `include`
27
+ needed.
28
+
29
+ ---
30
+
31
+ ## `cid` macro signature
32
+
33
+ ```ruby
34
+ cid(prefix, size: 16, related: {}, name: :id)
35
+ ```
36
+
37
+ | Parameter | Type | Default | Notes |
38
+ |-----------|------|---------|-------|
39
+ | `prefix` | `String \| Symbol` | required | Prepended before `_`. Stored verbatim in the ID. |
40
+ | `size` | `Integer` | `16` | Length of the random Base58 portion **after** the `_` separator. |
41
+ | `related` | `Hash{Symbol => Integer}` | `{}` | Single-entry: `{ association_name => chars_to_borrow }`. Borrows the first N characters from the parent's ID random portion. |
42
+ | `name` | `Symbol` | `:id` | Column to populate. Use any string column, not just the primary key. |
43
+
44
+ Generated ID format: `"#{prefix}_#{shared_chars}#{random_chars}"`
45
+ where `shared_chars.length + random_chars.length == size`.
46
+
47
+ ---
48
+
49
+ ## Rules agents must follow
50
+
51
+ ### Always
52
+ - Call `cid` **after** the `belongs_to` declaration when using `related:`.
53
+ - Use `id: :string` in migrations for tables where `cid` manages the primary key.
54
+ - Match the `related:` key to the exact `belongs_to` association name (`:user`,
55
+ not `:user_id`).
56
+ - Ensure `size > chars_to_borrow` (otherwise an `ArgumentError` is raised at
57
+ record creation time).
58
+ - **MySQL + DB trigger on string PK:** always declare `cid` on the model as
59
+ well. MySQL's `LAST_INSERT_ID()` returns `0` for non-`AUTO_INCREMENT` columns,
60
+ so ActiveRecord cannot read back the trigger-generated string PK. `cid`
61
+ generates the ID in Ruby before INSERT so Rails includes it in the column list.
62
+ The trigger then acts only as a safety net for raw SQL inserts.
63
+
64
+ ### Never
65
+ - Set `default:` on the id column in migrations – the gem handles generation.
66
+ - Call `cid` more than once per column on the same class (multiple `cid` calls
67
+ on a class are cumulative callbacks; only the first one that finds a nil value
68
+ will fire, but it is confusing).
69
+ - On **PostgreSQL or SQLite**, mix `CustomId::Concern` with a DB trigger on the
70
+ same table/column – pick one approach. On **MySQL**, combining both is required
71
+ for correct ActiveRecord behaviour (see above).
72
+
73
+ ---
74
+
75
+ ## Gem internals (working on the gem itself)
76
+
77
+ ### Module map
78
+
79
+ | File | Responsibility |
80
+ |------|----------------|
81
+ | `lib/custom_id/concern.rb` | `cid` class macro + private `before_create` helpers |
82
+ | `lib/custom_id/installer.rb` | Creates/removes `config/initializers/custom_id.rb` |
83
+ | `lib/custom_id/railtie.rb` | Registers all `custom_id:*` rake tasks |
84
+ | `lib/custom_id/db_extension.rb` | PostgreSQL, MySQL, and SQLite trigger-based alternative |
85
+ | `lib/tasks/custom_id.rake` | Rake task implementations (install/uninstall + db sub-namespace) |
86
+
87
+ ### Rake task reference
88
+
89
+ All `custom_id:db:*` tasks accept an optional `DATABASE` positional argument
90
+ that selects a named database from `database.yml` (multi-database Rails apps).
91
+ Omit it to use the default connection.
92
+
93
+ | Task | Args | Description |
94
+ |------|------|-------------|
95
+ | `custom_id:install` | — | Create `config/initializers/custom_id.rb` |
96
+ | `custom_id:uninstall` | — | Remove `config/initializers/custom_id.rb` |
97
+ | `custom_id:db:enable_pgcrypto` | `[DATABASE]` | Enable `pgcrypto` PG extension (required before PG triggers) |
98
+ | `custom_id:db:install_function` | `[DATABASE]` | Install shared `custom_id_base58()` function (PG/MySQL) |
99
+ | `custom_id:db:uninstall_function` | `[DATABASE]` | Remove the shared function (PG/MySQL) |
100
+ | `custom_id:db:add_trigger` | `[table,prefix,column,size,DATABASE]` | Install BEFORE INSERT trigger; column defaults to `id`, size to `16` |
101
+ | `custom_id:db:remove_trigger` | `[table,column,DATABASE]` | Remove BEFORE INSERT trigger from a table |
102
+
103
+ The `custom_id:db:*` tasks require the `:environment` task (Rails app booted).
104
+
105
+ ### Test suite
106
+
107
+ ```bash
108
+ bundle exec rake test # Minitest with SQLite in-memory
109
+ bundle exec rubocop # RuboCop linting
110
+ bundle exec rake # Both (default task)
111
+ ```
112
+
113
+ Tests live in `test/custom_id/`. Each test class uses `setup`/`teardown` to
114
+ create and drop SQLite tables so tests are fully isolated.
115
+
116
+ ### Adding a new test
117
+
118
+ 1. Extend `Minitest::Test` inside the `CustomId` module namespace.
119
+ 2. Name the file `test/custom_id/<feature>_test.rb` (picked up by Rakefile glob).
120
+ 3. Create tables in `setup`, drop them in `teardown`.
121
+
122
+ ### Dependency notes
123
+
124
+ - `SecureRandom.base58` is provided by `active_support/core_ext/securerandom` –
125
+ already required by `concern.rb`.
126
+ - `related:` resolution uses `ActiveRecord::Reflection` – works only on proper
127
+ AR models with `belongs_to` defined.
128
+
129
+ ---
130
+
131
+ ## Common pitfalls
132
+
133
+ | Symptom | Cause | Fix |
134
+ |---------|-------|-----|
135
+ | `NOT NULL constraint failed: table.id` | String PK table, `cid` not firing | Verify `before_create` callback is registered; check `name:` param |
136
+ | ID generated without shared prefix | `belongs_to` missing or `account_id` is nil at create time | Ensure parent is persisted and passed before child is created |
137
+ | `ArgumentError: size must be greater than shared chars` | `size <= chars_to_borrow` | Increase `size` or reduce borrowed chars |
138
+ | `NoMethodError: undefined method 'base58'` | `active_support/core_ext/securerandom` not loaded | Ensure `require "custom_id"` – the gem requires it automatically |
139
+ | `NotImplementedError: CustomId::DbExtension does not support …` | `custom_id:db:*` task run against an unsupported adapter | Supported adapters: PostgreSQL, MySQL, SQLite |
140
+ | `PG::UndefinedFunction: function gen_random_bytes` | `pgcrypto` extension not enabled | Run `rails custom_id:db:enable_pgcrypto` or add `enable_extension "pgcrypto"` to a migration |
141
+ | `id = "0"` after `Model.create` on MySQL | Trigger sets string PK but `LAST_INSERT_ID()` returns `0` | Add `cid "prefix"` to the model – generates ID in Ruby before INSERT |
142
+ | Column stays `nil` after `Model.create` on MySQL (non-PK trigger) | Same root cause – AR never learns the trigger-set value | Add `cid "prefix", name: :col` to the model |
143
+ | `ArgumentError: Anonymous class is not allowed` (Rails 7.2+) | Tried to call `establish_connection` on an unnamed class | Gem internally uses `CustomId::RakeDbProxy` – no action needed; update to latest gem version if seen |
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-02-27
11
+
12
+ ### Added
13
+
14
+ - `CustomId::Concern` with the `cid` class macro for generating prefixed Base58 string IDs
15
+ - Support for embedding shared characters from a related model's ID (`related:` option)
16
+ - Support for targeting a non-primary-key column (`name:` option)
17
+ - Configurable random-portion length (`size:` option)
18
+ - `CustomId::Installer` for creating/removing the Rails initializer
19
+ - `CustomId::Railtie` with `custom_id:install` and `custom_id:uninstall` rake tasks
20
+ - `CustomId::DbExtension` – optional PostgreSQL trigger-based ID generation
data/CLAUDE.md ADDED
@@ -0,0 +1,133 @@
1
+ # CustomId – Claude Project Context
2
+
3
+ > Project-level instructions and context for Claude Code when working inside
4
+ > the `custom_id` gem repository.
5
+
6
+ ## Project overview
7
+
8
+ `custom_id` is a Ruby gem that adds a `cid` class macro to ActiveRecord models
9
+ for generating prefixed, Base58, Stripe-style string IDs (e.g. `"usr_7xKmN2pQ…"`).
10
+
11
+ ## Repository layout
12
+
13
+ ```
14
+ lib/
15
+ custom_id.rb # Entry point – require this in host apps
16
+ custom_id/
17
+ concern.rb # Core: cid macro via ActiveSupport::Concern
18
+ installer.rb # Manages config/initializers/custom_id.rb
19
+ railtie.rb # Rails integration, exposes rake tasks
20
+ db_extension.rb # Optional: PostgreSQL, MySQL, SQLite trigger-based alternative
21
+ tasks/
22
+ custom_id.rake # rails custom_id:install / uninstall + custom_id:db:* tasks
23
+ test/
24
+ test_helper.rb # SQLite in-memory, ActiveSupport.on_load hook
25
+ custom_id/
26
+ concern_test.rb # Tests for cid macro behaviour
27
+ installer_test.rb # Tests for Installer class
28
+ sqlite_db_extension_test.rb # Tests for DbExtension on SQLite
29
+ llms/
30
+ overview.md # Architecture overview for LLMs
31
+ usage.md # Usage patterns for LLMs
32
+ AGENTS.md # Concise guide for AI coding agents
33
+ CLAUDE.md # This file
34
+ ```
35
+
36
+ ## Development workflow
37
+
38
+ ```bash
39
+ bundle install
40
+ bundle exec rake test # run Minitest suite (SQLite in-memory)
41
+ bundle exec rubocop # lint
42
+ bundle exec rake # tests + rubocop (default)
43
+ bin/console # IRB with gem loaded
44
+ ```
45
+
46
+ ## Rake tasks
47
+
48
+ All `custom_id:db:*` tasks accept an optional `DATABASE` positional argument
49
+ that selects a named database from `database.yml` (multi-database apps).
50
+
51
+ | Task | Description |
52
+ |------|-------------|
53
+ | `rails custom_id:install` | Create `config/initializers/custom_id.rb` |
54
+ | `rails custom_id:uninstall` | Remove `config/initializers/custom_id.rb` |
55
+ | `rails custom_id:db:enable_pgcrypto [DATABASE]` | Enable the `pgcrypto` PG extension (required before PG triggers) |
56
+ | `rails custom_id:db:install_function [DATABASE]` | Install `custom_id_base58()` PG/MySQL function |
57
+ | `rails custom_id:db:uninstall_function [DATABASE]` | Remove the function |
58
+ | `rails "custom_id:db:add_trigger[table,prefix,column,size,DATABASE]"` | Install BEFORE INSERT trigger on a table |
59
+ | `rails "custom_id:db:remove_trigger[table,column,DATABASE]"` | Remove BEFORE INSERT trigger from a table |
60
+
61
+ The `db:*` tasks require `:environment` (full Rails boot).
62
+
63
+ ## Code style
64
+
65
+ - **Double quotes** throughout (enforced by RuboCop).
66
+ - Frozen string literals on every file.
67
+ - Nested module/class syntax (`module CustomId; class Concern`) preferred over
68
+ compact (`CustomId::Concern`).
69
+ - Test files use `module CustomId; class XxxTest < Minitest::Test` nesting.
70
+ - Metrics limits: `MethodLength: Max: 15`; test files are excluded from metrics.
71
+
72
+ ## Key design decisions
73
+
74
+ 1. **`cid` registers a `before_create` callback** on the calling class, not on
75
+ `ActiveRecord::Base`. Calling `cid` twice on the same class stacks two
76
+ callbacks – avoid this.
77
+
78
+ 2. **Collision loop**: generates a Base58 ID, checks `Model.exists?(col => id)`,
79
+ retries on collision. For `name: :id` this hits the PK index so it's fast;
80
+ for other columns an index is recommended.
81
+
82
+ 3. **`related:` option** resolves the association via `self.class.reflections`
83
+ at callback runtime (not at `cid` declaration time) – safe for STI.
84
+
85
+ 4. **`DbExtension`** is a pure class-method module; no AR callbacks involved.
86
+ It writes SQL directly via `connection.execute`. Supports **PostgreSQL**
87
+ (requires `pgcrypto`), **MySQL** 5.7+, and **SQLite** 3.0+.
88
+ The `custom_id:db:*` rake tasks are thin wrappers that delegate to its class
89
+ methods – they do no SQL themselves.
90
+
91
+ **MySQL + ActiveRecord string PK limitation:** MySQL's `LAST_INSERT_ID()`
92
+ returns `0` for non-`AUTO_INCREMENT` columns, so ActiveRecord cannot read
93
+ back a trigger-generated string PK. Always pair a MySQL trigger with `cid`
94
+ on the model. `cid` generates the ID in Ruby before INSERT (trigger fires
95
+ only for raw SQL). This limitation does not affect PostgreSQL or SQLite.
96
+
97
+ **Rails 7.2+ anonymous class restriction:** `establish_connection` rejects
98
+ classes with no name. The rake tasks use `CustomId::RakeDbProxy` (a named
99
+ abstract subclass created via `const_set`) to avoid this.
100
+
101
+ 5. **Installer is decoupled from Rails**: `CustomId::Installer` takes a
102
+ `Pathname` root and never references `Rails.root`, making it unit-testable
103
+ with a `Dir.mktmpdir`.
104
+
105
+ 6. **Multi-database support**: all `custom_id:db:*` rake tasks accept an
106
+ optional `DATABASE` positional argument. The `resolve_connection` lambda
107
+ uses `ActiveRecord::Base.configurations.find_db_config` to look up the
108
+ named config and establishes a connection via `CustomId::RakeDbProxy`
109
+ without replacing the global default connection.
110
+
111
+ ## When adding features
112
+
113
+ - Add the `before_create` logic in `concern.rb` only.
114
+ - New public class-level DSL methods belong inside `class_methods do`.
115
+ - Private instance helpers used inside the callback belong in the `private`
116
+ section of `Concern` (they are mixed into the model instance).
117
+ - Update `sig/custom_id.rbs` when adding or changing public method signatures.
118
+ - Write Minitest tests in `test/custom_id/` before implementing (TDD).
119
+ - Run `bundle exec rake` before committing – must be fully green.
120
+
121
+ ## Dependency notes
122
+
123
+ - `SecureRandom.base58` comes from `active_support/core_ext/securerandom`.
124
+ - `Array#second` and `String#first(n)` come from `activesupport`.
125
+ - `Hash#present?` and `blank?` come from `activesupport`.
126
+ - All three are already pulled in by the gem's declared dependencies.
127
+
128
+ ## Out of scope (do not add unless explicitly requested)
129
+
130
+ - UUID or ULID generation – use Rails' built-in `uuid` column type for that.
131
+ - Validation that the stored ID matches the expected pattern.
132
+ - Automatic index creation – migrations are the developer's responsibility.
133
+ - Support for non-ActiveRecord ORMs (Sequel, ROM, etc.).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Pawel Niemczyk
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,240 @@
1
+ # CustomId
2
+
3
+ Generate unique, human-readable, prefixed string IDs for ActiveRecord models – inspired by Stripe-style identifiers like `usr_7xKmN2pQ…`.
4
+
5
+ ## Features
6
+
7
+ * One-line `cid` macro – declare a prefix and the gem handles the rest
8
+ * Collision-resistant loop with database uniqueness check
9
+ * Embed shared characters from a parent model's ID for visual traceability
10
+ * Target any string column, not just `id`. Ensure you set the column as a string primary key if using `id`.
11
+ * Configurable random-portion length
12
+ * Rails installer (`rails custom_id:install`) that auto-includes the concern
13
+ * Optional **database trigger-based** alternative for DB-level enforcement (PostgreSQL, MySQL, SQLite)
14
+
15
+ ## Installation
16
+
17
+ Add to your application's `Gemfile`:
18
+
19
+ ```ruby
20
+ gem "custom_id"
21
+ ```
22
+
23
+ Then run:
24
+
25
+ ```bash
26
+ bundle install
27
+ rails custom_id:install
28
+ ```
29
+
30
+ The installer creates `config/initializers/custom_id.rb` which auto-includes `CustomId::Concern` into every ActiveRecord model via `ActiveSupport.on_load(:active_record)`.
31
+
32
+ ## Usage
33
+
34
+ ### Basic usage
35
+
36
+ ```ruby
37
+ class User < ApplicationRecord
38
+ cid "usr"
39
+ end
40
+
41
+ User.create!(name: "Alice").id # => "usr_7xKmN2pQaBcDeFgH"
42
+ ```
43
+
44
+ The ID format is `<prefix>_<random>` where the random part is 16 Base58 characters by default.
45
+ .cid by default use the `id` column as the target for generated IDs, so make sure to set it as a string primary key in your migration. Like this: `bin/rails g model Account id:string:primary_key`
46
+
47
+ ### Custom size
48
+
49
+ ```ruby
50
+ class ApiKey < ApplicationRecord
51
+ cid "key", size: 32
52
+ end
53
+
54
+ ApiKey.create!.id # => "key_Ab3xY7mN…" (32 random chars)
55
+ ```
56
+
57
+ ### Embed shared characters from a related model
58
+
59
+ Pass `related: { association_name => chars_to_borrow }` to prefix the random portion with characters borrowed from the related model's ID. This creates visual traceability between parent and child IDs.
60
+
61
+ ```ruby
62
+ class Document < ApplicationRecord
63
+ belongs_to :workspace
64
+ cid "doc", size: 24, related: { workspace: 6 }
65
+ end
66
+
67
+ # workspace.id => "wsp_ABCDEF…"
68
+ # document.id => "doc_ABCDEF<18 random chars>"
69
+ ```
70
+
71
+ ### Custom column
72
+
73
+ Use `name:` to generate the ID into a non-primary-key column:
74
+
75
+ ```ruby
76
+ class Article < ApplicationRecord
77
+ cid "art", name: :slug, size: 12
78
+ end
79
+
80
+ Article.create!(title: "Hello").slug # => "art_aBcDeFgHiJkL"
81
+ ```
82
+
83
+ The primary key is left untouched; set it as usual.
84
+
85
+ ### Manual include (without the Rails initializer)
86
+
87
+ ```ruby
88
+ class MyModel
89
+ include CustomId::Concern
90
+ cid "my"
91
+ end
92
+ ```
93
+
94
+ ## Database-side alternative (PostgreSQL, MySQL, SQLite)
95
+
96
+ For applications that need IDs generated even when records are inserted via raw SQL (e.g., bulk imports, database-level ETL), `CustomId::DbExtension` installs database triggers that produce the same prefixed Base58 IDs.
97
+
98
+ ### Requirements
99
+
100
+ * **PostgreSQL**: 9.6+ (requires `pgcrypto` extension)
101
+ * **MySQL**: 5.7+ (uses `RANDOM_BYTES`)
102
+ * **SQLite**: 3.0+ (uses `AFTER INSERT` trigger)
103
+
104
+ ### MySQL + ActiveRecord: always pair with `cid`
105
+
106
+ MySQL's protocol does not expose a generated string PK back to the caller after INSERT (unlike PostgreSQL's `RETURNING`). When ActiveRecord inserts a row without an `id` value it reads `LAST_INSERT_ID()`, which returns `0` for non-`AUTO_INCREMENT` columns — so the in-memory record gets `id = "0"` even though the row in the database has the correct trigger-generated value.
107
+
108
+ **Solution:** declare `cid` on the model alongside the trigger. `cid` generates the ID in Ruby *before* the INSERT, so Rails includes it in the column list and `LAST_INSERT_ID()` is never consulted. The trigger's `WHEN NEW.id IS NULL` guard makes it a no-op for AR inserts and a safety net for raw-SQL inserts.
109
+
110
+ ```ruby
111
+ # model
112
+ class Order < ApplicationRecord
113
+ cid "ord" # generates id in Ruby; trigger fires only for raw SQL inserts
114
+ end
115
+ ```
116
+
117
+ ```ruby
118
+ # migration
119
+ class CreateOrders < ActiveRecord::Migration[8.0]
120
+ def up
121
+ create_table :orders, id: :string do |t|
122
+ t.string :status, null: false
123
+ t.timestamps
124
+ end
125
+ CustomId::DbExtension.install_trigger!(connection, :orders, prefix: "ord")
126
+ end
127
+
128
+ def down
129
+ CustomId::DbExtension.uninstall_trigger!(connection, :orders)
130
+ drop_table :orders
131
+ end
132
+ end
133
+ ```
134
+
135
+ This limitation does **not** affect PostgreSQL or SQLite.
136
+
137
+ ### Migration example (PostgreSQL)
138
+
139
+ ```ruby
140
+ class CreateUsers < ActiveRecord::Migration[7.0]
141
+ def up
142
+ enable_extension "pgcrypto" # only needed once per database
143
+
144
+ create_table :users, id: :string do |t|
145
+ t.string :name, null: false
146
+ t.timestamps
147
+ end
148
+
149
+ CustomId::DbExtension.install_trigger!(connection, :users, prefix: "usr")
150
+ end
151
+
152
+ def down
153
+ CustomId::DbExtension.uninstall_trigger!(connection, :users)
154
+ drop_table :users
155
+ end
156
+ end
157
+ ```
158
+
159
+ ### Trade-offs
160
+
161
+ | Aspect | Ruby concern (`cid`) | `DbExtension` trigger |
162
+ |----------------------------|-----------------------|--------------------------------|
163
+ | Portability | Any AR adapter | PG, MySQL, SQLite only |
164
+ | Bulk / raw inserts | IDs **not** generated | IDs **always** generated |
165
+ | Testability | SQLite in-memory ok | Needs a real DB connection |
166
+ | Related-model IDs | Supported | Not supported |
167
+ | Migration needed | No | Yes |
168
+ | MySQL + AR id read-back | ✅ Works | ⚠️ Needs `cid` on model too |
169
+
170
+ ## Rails installer tasks
171
+
172
+ ```bash
173
+ rails custom_id:install # create config/initializers/custom_id.rb
174
+ rails custom_id:uninstall # remove config/initializers/custom_id.rb
175
+ ```
176
+
177
+ ## Database-side rake tasks
178
+
179
+ The `custom_id:db:*` tasks manage `CustomId::DbExtension` objects from the command line without writing migration code. All tasks depend on the Rails `:environment` task.
180
+
181
+ An optional `DATABASE` argument targets a specific database in a multi-database Rails app (matches the name from `database.yml`). Omit it to use the default connection.
182
+
183
+ ```bash
184
+ # Enable the pgcrypto extension (PostgreSQL only – required before install_function / add_trigger)
185
+ rails custom_id:db:enable_pgcrypto
186
+ rails "custom_id:db:enable_pgcrypto[postgres]" # multi-database
187
+
188
+ # optional add migration
189
+
190
+ ```
191
+ class EnablePgcrypto < ActiveRecord::Migration[8.1]
192
+ def up = enable_extension "pgcrypto"
193
+ def down = disable_extension "pgcrypto"
194
+ end
195
+ ```
196
+
197
+ # Install the shared Base58 generator function (once per database)
198
+ rails custom_id:db:install_function
199
+ rails "custom_id:db:install_function[postgres]" # multi-database
200
+
201
+ # Remove the shared function
202
+ rails custom_id:db:uninstall_function
203
+ rails "custom_id:db:uninstall_function[postgres]" # multi-database
204
+
205
+ # Add a BEFORE INSERT trigger to a table (column defaults to id, size defaults to 16)
206
+ rails "custom_id:db:add_trigger[users,usr]"
207
+ rails "custom_id:db:add_trigger[reports,rpt,report_key,24]"
208
+ rails "custom_id:db:add_trigger[users,usr,,,postgres]" # multi-database, default column/size
209
+ rails "custom_id:db:add_trigger[reports,rpt,report_key,24,postgres]" # all options
210
+
211
+ # Remove a BEFORE INSERT trigger from a table
212
+ rails "custom_id:db:remove_trigger[users]"
213
+ rails "custom_id:db:remove_trigger[reports,report_key]"
214
+ rails "custom_id:db:remove_trigger[users,,postgres]" # multi-database
215
+ ```
216
+
217
+ `install_trigger!` is idempotent – it is safe to call again if the trigger already exists.
218
+
219
+ > **PostgreSQL setup order:** `enable_pgcrypto` → `install_function` is handled automatically by `add_trigger`, but if you run them separately keep that order. If pgcrypto is missing you will see:
220
+ > ```
221
+ > error: The pgcrypto PostgreSQL extension is required but not enabled.
222
+ > Run: rails custom_id:db:enable_pgcrypto
223
+ > or add enable_extension "pgcrypto" to a migration.
224
+ > ```
225
+
226
+ ## Development
227
+
228
+ ```bash
229
+ bin/setup # install dependencies
230
+ bundle exec rake # run tests + RuboCop
231
+ bin/console # interactive prompt
232
+ ```
233
+
234
+ ## Contributing
235
+
236
+ Bug reports and pull requests are welcome on GitHub at https://github.com/pniemczyk/custom_id.
237
+
238
+ ## License
239
+
240
+ MIT – see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/securerandom"
5
+
6
+ module CustomId
7
+ # ActiveSupport::Concern that provides the +cid+ class macro for generating
8
+ # prefixed, collision-resistant custom string IDs for ActiveRecord models.
9
+ #
10
+ # @example Minimal usage – generate a custom primary key
11
+ # class User < ApplicationRecord
12
+ # include CustomId::Concern # not needed when using the Rails initializer
13
+ # cid "usr"
14
+ # end
15
+ #
16
+ # User.create!(name: "Alice").id # => "usr_7xKmN2pQ..."
17
+ #
18
+ # @example Embedding shared characters from a related model's ID
19
+ # class Document < ApplicationRecord
20
+ # belongs_to :workspace
21
+ # cid "doc", size: 24, related: { workspace: 6 }
22
+ # end
23
+ #
24
+ # # If workspace.id == "wsp_ABCDEF...", document.id starts with "doc_ABCDEF..."
25
+ #
26
+ # @example Using a non-primary-key column
27
+ # class Article < ApplicationRecord
28
+ # cid "art", name: :slug, size: 12
29
+ # end
30
+ #
31
+ # Article.create!(title: "Hello").slug # => "art_aBcDeFgHiJkL"
32
+ module Concern
33
+ extend ActiveSupport::Concern
34
+
35
+ class_methods do
36
+ # Registers a +before_create+ callback that generates a prefixed Base58 ID.
37
+ #
38
+ # The generated value has the form:
39
+ # "#{prefix}_#{shared_chars}#{random_chars}"
40
+ #
41
+ # where +shared_chars+ (optional) are copied from a related model's ID and
42
+ # +random_chars+ fills the remaining +size+ characters with Base58 noise.
43
+ #
44
+ # @param prefix [String, Symbol] Prefix to prepend (e.g. "usr", :doc).
45
+ # @param size [Integer] Length of the generated portion after the
46
+ # underscore separator (default: 16).
47
+ # @param related [Hash{Symbol => Integer}] A single-entry hash of
48
+ # { association_name => chars_to_borrow }.
49
+ # The gem borrows the first +chars+
50
+ # characters from the related model's ID.
51
+ # @param name [Symbol] Attribute to assign the ID to (default: :id).
52
+ def cid(prefix, size: 16, related: {}, name: :id)
53
+ id_prefix = prefix.to_s
54
+
55
+ before_create do
56
+ next unless send(name).nil?
57
+
58
+ shared = resolve_shared_chars(related)
59
+ loop do
60
+ generated = build_id(id_prefix, shared, size)
61
+ send(:"#{name}=", generated)
62
+ break unless self.class.exists?(name => generated)
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Instance helpers (called from the before_create block)
70
+ # ---------------------------------------------------------------------------
71
+
72
+ private
73
+
74
+ # Returns the shared-character prefix borrowed from the related model's ID,
75
+ # or an empty string when +related+ is blank or the association is unset.
76
+ def resolve_shared_chars(related)
77
+ return "" unless related.present?
78
+
79
+ association_name, borrow_count = related.first
80
+ ref_id = related_model_id(association_name)
81
+ ref_id ? ref_id.split("_", 2).last.first(borrow_count) : ""
82
+ end
83
+
84
+ # Reads the foreign-key value for +association_name+.
85
+ def related_model_id(association_name)
86
+ reflection = self.class.reflections[association_name.to_s]
87
+ foreign_key = reflection&.foreign_key.to_s
88
+ read_attribute(foreign_key) if foreign_key.present?
89
+ end
90
+
91
+ # Generates a single candidate ID from prefix, shared chars, and random noise.
92
+ def build_id(id_prefix, shared, size)
93
+ rand_size = size - shared.length
94
+ if rand_size < 1
95
+ raise ArgumentError,
96
+ "size (#{size}) must be greater than the number of " \
97
+ "shared characters (#{shared.length})"
98
+ end
99
+
100
+ "#{id_prefix}_#{shared}#{SecureRandom.base58(rand_size)}"
101
+ end
102
+ end
103
+ end