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
data/llms/overview.md
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# CustomId – Architecture Overview
|
|
2
|
+
|
|
3
|
+
> Intended audience: LLMs and AI agents that need a precise mental model of
|
|
4
|
+
> the gem internals before generating code.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Purpose
|
|
9
|
+
|
|
10
|
+
`custom_id` gives ActiveRecord models human-readable, prefixed string IDs in
|
|
11
|
+
the style popularised by Stripe:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
usr_7xKmN2pQaBcDeFgH ← "usr" prefix + 16 Base58 chars
|
|
15
|
+
doc_ABCDEF7xKmN2pQaBcD ← "doc" prefix + 6 shared chars from parent + 18 random
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
IDs are generated in Ruby inside a `before_create` callback. An optional
|
|
19
|
+
database-trigger alternative (`CustomId::DbExtension`) produces identical IDs
|
|
20
|
+
at the database level and supports PostgreSQL, MySQL, and SQLite.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Module structure
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
CustomId (top-level namespace, lib/custom_id.rb)
|
|
28
|
+
├── Concern (lib/custom_id/concern.rb)
|
|
29
|
+
│ └── class_methods { cid } → registers before_create on the calling model
|
|
30
|
+
├── Installer (lib/custom_id/installer.rb)
|
|
31
|
+
│ ├── .install!(root) → writes config/initializers/custom_id.rb
|
|
32
|
+
│ └── .uninstall!(root) → removes the initializer file
|
|
33
|
+
├── Railtie < Rails::Railtie (lib/custom_id/railtie.rb)
|
|
34
|
+
│ └── rake_tasks { load rake } → exposes all custom_id:* rake tasks
|
|
35
|
+
├── DbExtension (lib/custom_id/db_extension.rb)
|
|
36
|
+
│ ├── .supported?(connection) → true for pg/mysql/sqlite adapters
|
|
37
|
+
│ ├── .install_generate_function! → creates custom_id_base58() PG/MySQL function
|
|
38
|
+
│ ├── .uninstall_generate_function! → drops the PG/MySQL function
|
|
39
|
+
│ ├── .install_trigger! → writes trigger (PG/MySQL/SQLite)
|
|
40
|
+
│ └── .uninstall_trigger! → drops trigger
|
|
41
|
+
└── Error < StandardError
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## ID generation algorithm (Ruby path)
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
before_create callback fires
|
|
50
|
+
│
|
|
51
|
+
├─ send(name).nil? → false → skip (ID already set by caller)
|
|
52
|
+
│
|
|
53
|
+
└─ true → generate
|
|
54
|
+
│
|
|
55
|
+
├─ resolve shared_chars
|
|
56
|
+
│ └─ related.present?
|
|
57
|
+
│ ├─ no → shared = ""
|
|
58
|
+
│ └─ yes → reflection = self.class.reflections[assoc_name]
|
|
59
|
+
│ foreign_key = reflection.foreign_key
|
|
60
|
+
│ ref_id = read_attribute(foreign_key)
|
|
61
|
+
│ shared = ref_id.split("_", 2).last.first(borrow_count)
|
|
62
|
+
│ or "" if ref_id is nil
|
|
63
|
+
│
|
|
64
|
+
└─ collision-resistant loop
|
|
65
|
+
generate = "#{prefix}_#{shared}#{SecureRandom.base58(size - shared.length)}"
|
|
66
|
+
send(:"#{name}=", generate)
|
|
67
|
+
break unless Model.exists?(name => generate)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Complexity notes
|
|
71
|
+
|
|
72
|
+
- For the primary-key column (`name: :id`) the `exists?` check hits the PK
|
|
73
|
+
index – O(log n), fast even for large tables.
|
|
74
|
+
- For non-PK columns (`name: :slug`) an index on that column is recommended.
|
|
75
|
+
- Base58 alphabet: `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz`
|
|
76
|
+
(58 chars, no `0`, `O`, `I`, `l` to avoid visual ambiguity).
|
|
77
|
+
- With `size: 16` the collision probability at 1 million rows is ≈ 5 × 10⁻⁹.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## ID generation algorithm (database trigger paths)
|
|
82
|
+
|
|
83
|
+
### PostgreSQL
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
BEFORE INSERT trigger fires on each row
|
|
87
|
+
│
|
|
88
|
+
└─ IF NEW.column IS NULL
|
|
89
|
+
NEW.column := prefix || '_' || custom_id_base58(size)
|
|
90
|
+
|
|
91
|
+
custom_id_base58(n):
|
|
92
|
+
rand_bytes := gen_random_bytes(n) -- pgcrypto extension (required)
|
|
93
|
+
FOR i IN 0..n-1:
|
|
94
|
+
result += chars[ get_byte(rand_bytes, i) % 58 ]
|
|
95
|
+
RETURN result
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
PostgreSQL uses two objects per table:
|
|
99
|
+
- a **trigger function** `#{table}_#{column}_custom_id()` (PL/pgSQL)
|
|
100
|
+
- a **BEFORE INSERT trigger** `#{table}_#{column}_before_insert_custom_id`
|
|
101
|
+
|
|
102
|
+
The shared `custom_id_base58()` function is created once per database.
|
|
103
|
+
Requires the `pgcrypto` extension (`enable_extension "pgcrypto"` or
|
|
104
|
+
`rails custom_id:db:enable_pgcrypto`).
|
|
105
|
+
|
|
106
|
+
### MySQL
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
BEFORE INSERT trigger fires on each row
|
|
110
|
+
│
|
|
111
|
+
└─ IF NEW.column IS NULL
|
|
112
|
+
SET NEW.column = CONCAT(prefix, '_', custom_id_base58(size))
|
|
113
|
+
|
|
114
|
+
custom_id_base58(n):
|
|
115
|
+
WHILE i < n:
|
|
116
|
+
result += SUBSTR(chars, (ORD(RANDOM_BYTES(1)) % 58) + 1, 1)
|
|
117
|
+
RETURN result
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
MySQL uses a single `BEFORE INSERT` trigger per table and a shared
|
|
121
|
+
`custom_id_base58()` stored function. Both DROP and CREATE are sent as
|
|
122
|
+
separate `connection.execute` calls because `mysql2`/`trilogy` reject
|
|
123
|
+
multi-statement strings.
|
|
124
|
+
|
|
125
|
+
**⚠ MySQL + ActiveRecord string PKs:** MySQL's `LAST_INSERT_ID()` returns `0`
|
|
126
|
+
for non-`AUTO_INCREMENT` columns. After an AR `create` without an explicit
|
|
127
|
+
`id`, the in-memory record has `id = "0"` even though the DB row is correct.
|
|
128
|
+
**Fix:** also declare `cid` on the model so AR generates the ID before INSERT.
|
|
129
|
+
|
|
130
|
+
### SQLite
|
|
131
|
+
|
|
132
|
+
SQLite has two strategies depending on the column type:
|
|
133
|
+
|
|
134
|
+
**Non-PK or nullable column → AFTER INSERT trigger**
|
|
135
|
+
```
|
|
136
|
+
AFTER INSERT trigger updates the row in-place using rowid.
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**NOT NULL primary key → BEFORE INSERT + RAISE(IGNORE)**
|
|
140
|
+
```
|
|
141
|
+
BEFORE INSERT trigger fires:
|
|
142
|
+
1. Generates ID with substr/abs(random())/% 58 inline expressions
|
|
143
|
+
2. Inner INSERT with generated ID (WHEN NEW.id IS NULL guard prevents recursion)
|
|
144
|
+
3. SELECT RAISE(IGNORE) abandons the outer NULL-id statement before
|
|
145
|
+
SQLite evaluates the NOT NULL constraint
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Note: when RAISE(IGNORE) abandons the outer INSERT, `RETURNING "id"` returns
|
|
149
|
+
nothing. Call `record.reload` after `create` to read the correct ID from the DB.
|
|
150
|
+
|
|
151
|
+
Shared-character support is **not** available in any trigger path because
|
|
152
|
+
cross-table reads inside a trigger are a concurrency anti-pattern.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## ActiveSupport integration
|
|
157
|
+
|
|
158
|
+
### Auto-include mechanism
|
|
159
|
+
|
|
160
|
+
The gem ships a Railtie. When a developer runs `rails custom_id:install`, the
|
|
161
|
+
Installer writes:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
# config/initializers/custom_id.rb
|
|
165
|
+
ActiveSupport.on_load(:active_record) do
|
|
166
|
+
include CustomId::Concern
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`ActiveSupport.on_load(:active_record)` defers the `include` until
|
|
171
|
+
`ActiveRecord::Base` is fully loaded, preventing load-order issues. After this
|
|
172
|
+
runs, every class that inherits from `ActiveRecord::Base` automatically has the
|
|
173
|
+
`cid` class macro available.
|
|
174
|
+
|
|
175
|
+
### Without the initializer
|
|
176
|
+
|
|
177
|
+
Include manually in individual models or a base class:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
181
|
+
include CustomId::Concern
|
|
182
|
+
primary_abstract_class
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Callback inheritance and STI
|
|
189
|
+
|
|
190
|
+
`cid "usr"` registers a `before_create` callback on the **class that calls
|
|
191
|
+
`cid`**, not on its subclasses. Because ActiveRecord inherits callbacks,
|
|
192
|
+
subclasses in an STI hierarchy will fire the parent's callback – which is
|
|
193
|
+
usually correct.
|
|
194
|
+
|
|
195
|
+
**Pitfall**: if a subclass calls `cid` again with different options, both
|
|
196
|
+
callbacks run. The first one sees `nil` and generates an ID; the second sees a
|
|
197
|
+
non-nil value and skips. Result: only the parent's options take effect. To
|
|
198
|
+
override, either use a fresh non-inheriting class in tests, or avoid double
|
|
199
|
+
`cid` calls in the same hierarchy.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Multi-database support (rake tasks)
|
|
204
|
+
|
|
205
|
+
All `custom_id:db:*` rake tasks accept an optional `DATABASE` positional
|
|
206
|
+
argument (last position). It is matched against named database configs in
|
|
207
|
+
`database.yml` via `ActiveRecord::Base.configurations.find_db_config`.
|
|
208
|
+
|
|
209
|
+
A named abstract AR subclass (`CustomId::RakeDbProxy`) is created via
|
|
210
|
+
`const_set` and used for the alternate connection. This avoids replacing the
|
|
211
|
+
global default connection and satisfies Rails 7.2+'s requirement that
|
|
212
|
+
`establish_connection` only be called on named (non-anonymous) classes.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## File inclusion in gem package
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
spec.files = Dir[
|
|
220
|
+
"lib/**/*.rb",
|
|
221
|
+
"lib/tasks/*.rake",
|
|
222
|
+
"llms/**/*.md", # ← these files ship inside the gem
|
|
223
|
+
"AGENTS.md",
|
|
224
|
+
"CLAUDE.md",
|
|
225
|
+
"README.md",
|
|
226
|
+
"LICENSE.txt",
|
|
227
|
+
"CHANGELOG.md"
|
|
228
|
+
]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
LLM context files ship **inside the released gem** so that tools that install
|
|
232
|
+
the gem can serve them as context to AI assistants.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Rake task reference
|
|
237
|
+
|
|
238
|
+
All `db:*` tasks accept an optional `DATABASE` arg (matches a name from
|
|
239
|
+
`database.yml`). Omit it to use the default connection.
|
|
240
|
+
|
|
241
|
+
| Task | Depends on | Description |
|
|
242
|
+
|------|-----------|-------------|
|
|
243
|
+
| `custom_id:install` | — | Delegates to `CustomId::Installer.install!(Rails.root)` |
|
|
244
|
+
| `custom_id:uninstall` | — | Delegates to `CustomId::Installer.uninstall!(Rails.root)` |
|
|
245
|
+
| `custom_id:db:enable_pgcrypto[DATABASE]` | `:environment` | `CREATE EXTENSION IF NOT EXISTS pgcrypto` on PG connection |
|
|
246
|
+
| `custom_id:db:install_function[DATABASE]` | `:environment` | `DbExtension.install_generate_function!(conn)` |
|
|
247
|
+
| `custom_id:db:uninstall_function[DATABASE]` | `:environment` | `DbExtension.uninstall_generate_function!(conn)` |
|
|
248
|
+
| `custom_id:db:add_trigger[table,prefix,column,size,DATABASE]` | `:environment` | `DbExtension.install_trigger!(conn, ...)` + MySQL warning |
|
|
249
|
+
| `custom_id:db:remove_trigger[table,column,DATABASE]` | `:environment` | `DbExtension.uninstall_trigger!(conn, ...)` |
|
|
250
|
+
|
|
251
|
+
All `db:*` tasks rescue `NotImplementedError` and call `abort` with a
|
|
252
|
+
human-readable message when the adapter is not supported.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Testing architecture
|
|
257
|
+
|
|
258
|
+
| Component | Tool | Database |
|
|
259
|
+
|-----------|------|----------|
|
|
260
|
+
| Ruby concern | Minitest | SQLite in-memory |
|
|
261
|
+
| Installer | Minitest | `Dir.mktmpdir` (filesystem) |
|
|
262
|
+
| DB extension – nullable column | Minitest | SQLite in-memory |
|
|
263
|
+
| DB extension – NOT NULL PK | Minitest | SQLite in-memory |
|
|
264
|
+
|
|
265
|
+
`test_helper.rb` boots ActiveRecord against SQLite and calls
|
|
266
|
+
`ActiveSupport.on_load(:active_record) { include CustomId::Concern }` to
|
|
267
|
+
replicate what the Rails initializer does.
|
|
268
|
+
|
|
269
|
+
Each test creates its own tables in `setup` and drops them in `teardown`,
|
|
270
|
+
providing full isolation without transactions.
|
|
271
|
+
|
|
272
|
+
PostgreSQL and MySQL paths are not covered by the automated test suite
|
|
273
|
+
(they require a live server). The SQLite tests exercise the same
|
|
274
|
+
`install_trigger!` / `uninstall_trigger!` public interface.
|