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
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
|