active_record_pretty_key 0.1.1 → 0.2.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 +4 -4
- data/CHANGELOG.md +46 -0
- data/LICENSE +21 -0
- data/README.md +224 -0
- data/VERSION +1 -1
- data/lib/active_record_pretty_key/concern.rb +7 -8
- data/lib/active_record_pretty_key/pretty_key_ticket.rb +126 -32
- data/lib/active_record_pretty_key.rb +9 -3
- data/lib/generators/active_record_pretty_key/install/install_generator.rb +8 -6
- data/lib/generators/active_record_pretty_key/install/templates/{create_tickets.erb → create_pretty_key_tickets.rb.erb} +4 -2
- metadata +25 -22
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: defcd22218e95c247f2e0a177c016feb952f473cc236e5c0d3ec637323b909c6
|
|
4
|
+
data.tar.gz: ffaf93a43f824d0612806c5b3924174ee0e4bfc4aa4f35aa9d4a04d506413956
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a81a35ffd83be05b4fd2918731a3fbdec9d2dd532500af0c8a6fe75eb433c13f7b771c26091f58ef2a1c8986d5db5e9d78edd4e95b27d807e6f6a77bef385b95
|
|
7
|
+
data.tar.gz: dcdbdfcd838af938bff255dbb7a3304fac2a8e6929a6356128e7874ddf3e408d851c55c4d6e343d694d5c9128f00362aa3cd348630bbd612398268ce08aa4a86
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
## [0.2.0] - 2026-11-07
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Custom Sqids alphabet from Rails credentials is now read lazily on first use; previously it was read when the gem loaded (before `Rails.application` exists) and silently ignored
|
|
12
|
+
- PostgreSQL ticket generation uses `nextval()` and no longer holds a row lock on the tickets table for the duration of the caller's transaction
|
|
13
|
+
- MySQL via the Trilogy adapter is now supported; previously it fell through to a fallback that could hand out duplicate IDs under concurrency
|
|
14
|
+
- SQLite ticket generation uses the connection-scoped `last_insert_rowid()` instead of reading the last row
|
|
15
|
+
- `generate_pretty_key` is now public, as documented, and returns the record's key
|
|
16
|
+
- MySQL ticket generation no longer deadlocks under concurrency: concurrent `REPLACE INTO` statements deadlock on InnoDB, so it now uses `UPDATE ... SET id = LAST_INSERT_ID(id + 1)`
|
|
17
|
+
- MySQL tickets are allocated on a dedicated connection pool and commit independently of the caller's transaction, so creates no longer wait for each other's transactions to commit
|
|
18
|
+
- Ticket generation no longer leaves the thread's database connection checked out on Rails 7.2+
|
|
19
|
+
- Requiring the gem no longer loads `ActiveRecord::Base` early (before the app's ActiveRecord configuration is applied)
|
|
20
|
+
- `ActiveRecordPrettyKey::VERSION` is now defined when the gem is required
|
|
21
|
+
- Install generator uses Rails' migration numbering and refuses to create a duplicate migration; the generated migration can be rolled back
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- Unsupported database adapters now raise `ActiveRecordPrettyKey::Error` instead of using an unsafe fallback
|
|
25
|
+
- README: corrected security claims and documented that the alphabet must not change once records exist
|
|
26
|
+
- Requires Ruby 3.0+ (as the `sqids` dependency always has), ActiveRecord 6.1 – 8.x and, for MySQL, 8.0+
|
|
27
|
+
- On MySQL, each process may open up to `pool` additional connections for ticket generation
|
|
28
|
+
- Gem releases require RubyGems MFA; README, LICENSE and CHANGELOG are now packaged
|
|
29
|
+
- Development: specs run against SQLite, PostgreSQL, MySQL (mysql2 and Trilogy) on Ruby 3.3, 3.4 and 4.0 in CI; added `bin/console`; fixed `bin/setup` and `bin/build`
|
|
30
|
+
|
|
31
|
+
### Removed
|
|
32
|
+
- Broken `Thorfile` and `bin/active_record_pretty_key` executable (neither could load the generator), and the `thor` dependency
|
|
33
|
+
|
|
34
|
+
## [0.1.1] - 2025-08-25
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- Fixed Gemspec homepage
|
|
38
|
+
|
|
39
|
+
## [0.1.0] - 2025-08-25
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
- Initial gem structure
|
|
43
|
+
- Basic concern `ActiveRecordPrettyKey::Concern` module for ActiveRecord models
|
|
44
|
+
- `generate_pretty_key` instance method
|
|
45
|
+
- RSpec test suite
|
|
46
|
+
- Development tools and scripts
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Noreaster Group, LLC
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# ActiveRecordPrettyKey
|
|
2
|
+
|
|
3
|
+
A Ruby gem for generating and managing pretty, human-readable keys in ActiveRecord models.
|
|
4
|
+
|
|
5
|
+
The gem automatically generates pretty keys by creating unique integer "tickets" and encoding them with the [Sqids](https://github.com/sqids/sqids-ruby) library to produce short, URL-safe string identifiers. Keys are generated before saving records, ensuring both uniqueness and consistency.
|
|
6
|
+
|
|
7
|
+
**Examples:** `BnJe`, `xK4m`, `Qp7R`, `vN2f8` — short, memorable IDs perfect for URLs like `/posts/xK4m`
|
|
8
|
+
|
|
9
|
+
Inspired by:
|
|
10
|
+
- [Flickr Ticket Servers: Distributed Unique Primary Keys on the Cheap](https://code.flickr.net/2010/02/08/ticket-servers-distributed-unique-primary-keys-on-the-cheap/)
|
|
11
|
+
- [Hashids](https://github.com/peterhellberg/hashids.rb)
|
|
12
|
+
|
|
13
|
+
## Why Pretty Keys?
|
|
14
|
+
|
|
15
|
+
| Feature | Regular Primary Keys | UUID7 | Pretty Keys |
|
|
16
|
+
|---------|---------------------|-------|-------------|
|
|
17
|
+
| **Human Readable** | ✅ Sequential numbers (1, 2, 3...) | ❌ Long, random strings | ✅ Short, memorable strings |
|
|
18
|
+
| **In Order** | ✅ Yes | ✅ Yes | ❌ No |
|
|
19
|
+
| **URL Friendly** | ✅ Simple and clean | ❌ Long and unwieldy | ✅ Short and clean |
|
|
20
|
+
| **Guessability** | ❌ Sequential, easily enumerated | ✅ Hard to guess | ⚠️ Obfuscated, not secret (see [Security](#security)) |
|
|
21
|
+
| **Length** | ✅ Very short | ❌ Very long (36 chars) | ✅ Short (4+ chars, grows slowly) |
|
|
22
|
+
| **Collision Risk** | ✅ None (auto-increment) | ✅ Extremely low | ✅ None (ticket database table) |
|
|
23
|
+
|
|
24
|
+
Pretty keys offer a middle ground between auto-incrementing integers and UUIDs — short, human-readable identifiers that don't expose your raw row IDs.
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- Ruby 3.0+
|
|
29
|
+
- Rails / ActiveRecord 6.1 – 8.x
|
|
30
|
+
- PostgreSQL (incl. PostGIS), MySQL 8.0+ (`mysql2` or `trilogy` adapter) or SQLite. Other databases raise `ActiveRecordPrettyKey::Error`.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
Add this line to your application's Gemfile:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
gem 'active_record_pretty_key'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
And then execute:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
$ bundle install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or install it yourself as:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
$ gem install active_record_pretty_key
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### Setup
|
|
55
|
+
|
|
56
|
+
First, run the generator to create the required tickets table:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
rails generate active_record_pretty_key:install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This creates a `create_pretty_key_tickets` migration for the single-row `pretty_key_tickets` table that hands out unique ticket numbers. Run it with `rails db:migrate`.
|
|
63
|
+
|
|
64
|
+
### Customizing or Randomizing the Sqids Alphabet
|
|
65
|
+
|
|
66
|
+
A custom Sqids alphabet can be configured in Rails credentials:
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
sqids:
|
|
70
|
+
alphabet: "ENwUpVBslGq25afc6i0hyD4tnjxRY31Wouz7HFTeMKgSbmPZvJ9ALXCQdrkI8O"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This can be used to configure a randomized alphabet to obfuscate object IDs. The alphabet is read from credentials the first time a key is generated.
|
|
74
|
+
|
|
75
|
+
> **Warning:** Choose the alphabet before you create any records and never change it afterwards. Existing keys are stored as strings, so a new alphabet can encode a new ticket number to a key that already exists, causing primary key collisions.
|
|
76
|
+
|
|
77
|
+
To generate a shuffled alphabet:
|
|
78
|
+
```ruby
|
|
79
|
+
require 'sqids'
|
|
80
|
+
puts Sqids::DEFAULT_ALPHABET.split('').shuffle.join
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Creating Models
|
|
84
|
+
|
|
85
|
+
Pretty keys require that your model use a string primary key:
|
|
86
|
+
```ruby
|
|
87
|
+
class CreatePosts < ActiveRecord::Migration[8.0]
|
|
88
|
+
def change
|
|
89
|
+
create_table :posts, id: :string do |t|
|
|
90
|
+
t.string :title
|
|
91
|
+
|
|
92
|
+
t.timestamps
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
You may choose to configure ActiveRecord to always generate migrations with a string primary key:
|
|
99
|
+
```ruby
|
|
100
|
+
# application.rb
|
|
101
|
+
|
|
102
|
+
# Default string primary key in migrations for use with Sqids
|
|
103
|
+
config.generators do |generate|
|
|
104
|
+
generate.orm :active_record, primary_key_type: :string
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Including the Concern
|
|
109
|
+
|
|
110
|
+
Include the concern in your ApplicationRecord to use everywhere:
|
|
111
|
+
```ruby
|
|
112
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
113
|
+
primary_abstract_class
|
|
114
|
+
include ActiveRecordPrettyKey::Concern
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
OR
|
|
119
|
+
|
|
120
|
+
Include the concern on a model by model basis:
|
|
121
|
+
```ruby
|
|
122
|
+
class Post < ApplicationRecord
|
|
123
|
+
include ActiveRecordPrettyKey::Concern
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Generating Keys
|
|
128
|
+
|
|
129
|
+
Keys are generated automatically before a record is created:
|
|
130
|
+
```ruby
|
|
131
|
+
post = Post.create(title: "My Awesome Post")
|
|
132
|
+
post.id
|
|
133
|
+
# => "BnJe"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Or the generation helper can be called manually. It returns the record's key, and does not replace a key that is already set:
|
|
137
|
+
```ruby
|
|
138
|
+
post = Post.new(title: "My Awesome Post")
|
|
139
|
+
post.generate_pretty_key
|
|
140
|
+
# => "9LW9"
|
|
141
|
+
|
|
142
|
+
post.save!
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Because keys are generated in a `before_create` callback, which runs *after* validation, don't add `validates :id, presence: true`. If you need the key during validation, generate it first:
|
|
146
|
+
```ruby
|
|
147
|
+
class Post < ApplicationRecord
|
|
148
|
+
include ActiveRecordPrettyKey::Concern
|
|
149
|
+
before_validation :generate_pretty_key, on: :create
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Associations
|
|
154
|
+
|
|
155
|
+
Every column that refers to a pretty-keyed record must also be a string, including foreign keys and polymorphic `*_id` columns:
|
|
156
|
+
```ruby
|
|
157
|
+
create_table :comments, id: :string do |t|
|
|
158
|
+
t.references :post, type: :string, foreign_key: true
|
|
159
|
+
t.references :commentable, polymorphic: true, type: :string
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Active Storage
|
|
164
|
+
|
|
165
|
+
For the same reason, Active Storage's polymorphic `record_id` column must be a string:
|
|
166
|
+
```ruby
|
|
167
|
+
class FixAttachmentsFks < ActiveRecord::Migration[8.0]
|
|
168
|
+
def change
|
|
169
|
+
change_column :active_storage_attachments, :record_id, :string, null: false
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### How tickets are generated
|
|
175
|
+
|
|
176
|
+
Keys are generated inside the record's save transaction. A ticket number is never handed out twice, even if that transaction rolls back, and generating one doesn't hold locks until it commits:
|
|
177
|
+
|
|
178
|
+
- **PostgreSQL** uses a sequence (`nextval`), which takes no locks and is not rolled back.
|
|
179
|
+
- **MySQL** uses a dedicated connection pool for the tickets table, so each ticket commits immediately on its own, separately from your transaction. The pool is created on first use with the same settings as your application's pool, so each process may open up to `pool` additional database connections; account for this in your database's `max_connections`.
|
|
180
|
+
- **SQLite** allows only one writer at a time, so tickets are generated in your transaction and **rolled back with it**. A key that was read inside a transaction that then rolled back (for example, passed to a job or API before the rollback) can be given to a later record. Use keys outside a transaction only once it has committed.
|
|
181
|
+
|
|
182
|
+
If the ticket row is missing, for example in a database created from `schema.rb` with `db:schema:load` or `db:prepare`, it is recreated automatically.
|
|
183
|
+
|
|
184
|
+
## Security
|
|
185
|
+
|
|
186
|
+
Pretty keys are **obfuscated, not encrypted**. Each key is an encoded, globally sequential ticket number:
|
|
187
|
+
|
|
188
|
+
- With the default alphabet, anyone can decode a key back to its ticket number (e.g. `Sqids.new(min_length: 4).decode("gbHJ") # => [2]`) and generate the keys before and after it.
|
|
189
|
+
- A shuffled alphabet makes this harder, but it is not a cryptographic secret and should not be treated as one.
|
|
190
|
+
- Because the ticket counter is shared across all models, decoded keys reveal roughly how many records your application has created and how quickly.
|
|
191
|
+
|
|
192
|
+
Never rely on a key being unguessable for authorization. Always check that the current user is allowed to access the record.
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
After checking out the repo, run `bin/setup` to install dependencies and run the specs. Run `bundle exec rspec` to run the tests, and `bin/console` for an interactive prompt with an in-memory database and a `Post` model.
|
|
197
|
+
|
|
198
|
+
The specs use in-memory SQLite by default. To run them against another database, install its driver group and set `DB` (and optionally `DATABASE_URL`):
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
docker run -d --rm -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=pretty_key_test postgres:17
|
|
202
|
+
BUNDLE_WITH=postgresql bundle install
|
|
203
|
+
DB=postgresql bundle exec rspec
|
|
204
|
+
|
|
205
|
+
docker run -d --rm -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=pretty_key_test mysql:8.4
|
|
206
|
+
BUNDLE_WITH=mysql bundle install
|
|
207
|
+
DB=mysql2 bundle exec rspec # or DB=trilogy
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
CI runs the suite against all four adapters.
|
|
211
|
+
|
|
212
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in the `VERSION` file, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). Releases require RubyGems multi-factor authentication.
|
|
213
|
+
|
|
214
|
+
## Contributing
|
|
215
|
+
|
|
216
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/noreastergroup/active_record_pretty_key. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/noreastergroup/active_record_pretty_key/blob/main/CODE_OF_CONDUCT.md).
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
221
|
+
|
|
222
|
+
## Code of Conduct
|
|
223
|
+
|
|
224
|
+
Everyone interacting in the ActiveRecordPrettyKey project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/noreastergroup/active_record_pretty_key/blob/main/CODE_OF_CONDUCT.md).
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.2.0
|
|
@@ -8,16 +8,15 @@ module ActiveRecordPrettyKey
|
|
|
8
8
|
before_create :generate_pretty_key
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
# Assigns a pretty key to the primary key if it is a string column and
|
|
12
|
+
# not already set. Returns the primary key value, or nil when the model's
|
|
13
|
+
# primary key is not a single string column.
|
|
13
14
|
def generate_pretty_key
|
|
14
|
-
|
|
15
|
-
return
|
|
16
|
-
|
|
17
|
-
# Ensure PrettyKeyTicket is available
|
|
18
|
-
return unless defined?(ActiveRecordPrettyKey::PrettyKeyTicket)
|
|
15
|
+
pk = self.class.primary_key
|
|
16
|
+
return unless pk.is_a?(String) && self.class.type_for_attribute(pk).type == :string
|
|
19
17
|
|
|
20
|
-
self
|
|
18
|
+
self[pk] = ActiveRecordPrettyKey::PrettyKeyTicket.next_sqid if self[pk].blank?
|
|
19
|
+
self[pk]
|
|
21
20
|
end
|
|
22
21
|
end
|
|
23
22
|
end
|
|
@@ -1,43 +1,137 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "concurrent/map"
|
|
4
|
+
require "concurrent/atomic/atomic_fixnum"
|
|
5
|
+
|
|
3
6
|
module ActiveRecordPrettyKey
|
|
4
7
|
class PrettyKeyTicket < ActiveRecord::Base
|
|
5
8
|
self.table_name = "pretty_key_tickets"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
9
|
+
|
|
10
|
+
MIN_LENGTH = 4
|
|
11
|
+
MYSQL_DEADLOCK_ATTEMPTS = 5
|
|
12
|
+
|
|
13
|
+
@ticket_connection_pools = Concurrent::Map.new
|
|
14
|
+
@ticket_connection_count = Concurrent::AtomicFixnum.new
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Generate a new unique integer ID.
|
|
18
|
+
#
|
|
19
|
+
# This is usually called inside the caller's transaction (a model's
|
|
20
|
+
# save). An issued ID must never be handed out again, even if that
|
|
21
|
+
# transaction rolls back, and allocation must not hold locks until the
|
|
22
|
+
# caller commits:
|
|
23
|
+
# - PostgreSQL: nextval() is non-transactional and takes no row locks.
|
|
24
|
+
# - MySQL: UPDATE ... SET id = LAST_INSERT_ID(id + 1), the sequence
|
|
25
|
+
# emulation pattern from the MySQL manual. (Flickr's REPLACE INTO relies
|
|
26
|
+
# on MyISAM; on InnoDB concurrent REPLACEs deadlock.) An UPDATE is
|
|
27
|
+
# transactional, so it runs on a dedicated autocommit connection: it
|
|
28
|
+
# commits immediately and its row lock is released straight away.
|
|
29
|
+
# - SQLite: REPLACE + last_insert_rowid(). SQLite allows a single writer,
|
|
30
|
+
# so a second connection would deadlock with the caller's transaction;
|
|
31
|
+
# the ticket is rolled back along with the caller's transaction.
|
|
32
|
+
#
|
|
33
|
+
# Every query bypasses the Rails query cache: a cached
|
|
34
|
+
# `SELECT nextval(...)` would hand out the same ID twice.
|
|
35
|
+
#
|
|
36
|
+
# Connections are borrowed with connection_pool.with_connection, which
|
|
37
|
+
# reuses the thread's current connection (and so its transaction).
|
|
38
|
+
# Calling `connection` instead would, on Rails 7.2+, keep the connection
|
|
39
|
+
# checked out by the thread after the caller is done with it.
|
|
40
|
+
def next_id
|
|
41
|
+
id = connection_pool.with_connection do |conn|
|
|
42
|
+
case conn.adapter_name.downcase
|
|
43
|
+
when "postgresql", "postgis"
|
|
44
|
+
conn.uncached { conn.select_value("SELECT nextval(#{conn.quote(sequence_name)})") }
|
|
45
|
+
when "mysql", "mysql2", "trilogy"
|
|
46
|
+
ticket_connection_pool.with_connection { |ticket_conn| ticket_conn.uncached { mysql_next_id(ticket_conn) } }
|
|
47
|
+
when "sqlite", "sqlite3"
|
|
48
|
+
conn.uncached do
|
|
49
|
+
conn.execute("REPLACE INTO #{quoted_table_name} (stub) VALUES ('a')")
|
|
50
|
+
conn.select_value("SELECT last_insert_rowid()")
|
|
51
|
+
end
|
|
52
|
+
else
|
|
53
|
+
raise ActiveRecordPrettyKey::Error,
|
|
54
|
+
"Unsupported database adapter #{conn.adapter_name.inspect}; " \
|
|
55
|
+
"supported adapters are PostgreSQL, PostGIS, MySQL (mysql2/trilogy) and SQLite"
|
|
56
|
+
end
|
|
35
57
|
end
|
|
58
|
+
|
|
59
|
+
Integer(id)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Connection pool used only for MySQL ticket allocation, separate from
|
|
63
|
+
# the application's pool so allocations commit independently of the
|
|
64
|
+
# caller's transaction. One pool per database config (so multiple
|
|
65
|
+
# databases and shards each get their own), created on first use with
|
|
66
|
+
# the same settings, including pool size, as the application's pool.
|
|
67
|
+
# Registered through establish_connection so Rails manages it (e.g.
|
|
68
|
+
# discarding it after fork); the pool object is used directly so it
|
|
69
|
+
# works regardless of the caller's connected_to role or shard.
|
|
70
|
+
def ticket_connection_pool
|
|
71
|
+
db_config = connection_db_config
|
|
72
|
+
@ticket_connection_pools.compute_if_absent(db_config.configuration_hash) do
|
|
73
|
+
# establish_connection needs a named class to own the pool
|
|
74
|
+
owner = Class.new(ActiveRecord::Base) { self.abstract_class = true }
|
|
75
|
+
ActiveRecordPrettyKey.const_set("TicketConnection#{@ticket_connection_count.increment}", owner)
|
|
76
|
+
owner.establish_connection(db_config)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def next_sqid
|
|
81
|
+
sqids.encode([ next_id ])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Built lazily so Rails credentials are available. The gem is loaded by
|
|
85
|
+
# Bundler.require before Rails.application exists, so reading credentials
|
|
86
|
+
# at class-load time would silently fall back to the default alphabet.
|
|
87
|
+
def sqids
|
|
88
|
+
@sqids ||= Sqids.new(min_length: MIN_LENGTH, alphabet: alphabet)
|
|
36
89
|
end
|
|
37
|
-
end
|
|
38
90
|
|
|
39
|
-
|
|
40
|
-
|
|
91
|
+
def alphabet
|
|
92
|
+
configured =
|
|
93
|
+
if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
94
|
+
::Rails.application.credentials.dig(:sqids, :alphabet)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
configured.presence || Sqids::DEFAULT_ALPHABET
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def mysql_next_id(conn)
|
|
103
|
+
attempts = 0
|
|
104
|
+
begin
|
|
105
|
+
attempts += 1
|
|
106
|
+
mysql_allocate(conn)
|
|
107
|
+
rescue ActiveRecord::Deadlocked
|
|
108
|
+
# Each statement runs in its own autocommit transaction, so the
|
|
109
|
+
# deadlock victim issued nothing and can safely be retried. (Not if
|
|
110
|
+
# something, e.g. a test harness, opened a transaction around it.)
|
|
111
|
+
retry if attempts < MYSQL_DEADLOCK_ATTEMPTS && !conn.transaction_open?
|
|
112
|
+
raise
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def mysql_allocate(conn)
|
|
117
|
+
update = "UPDATE #{quoted_table_name} SET id = LAST_INSERT_ID(id + 1) WHERE stub = 'a'"
|
|
118
|
+
|
|
119
|
+
if conn.update(update).zero?
|
|
120
|
+
# The row seeded by the install migration is missing, e.g. in a
|
|
121
|
+
# database built from schema.rb (db:schema:load, db:prepare, test
|
|
122
|
+
# databases). Create it; IGNORE covers a concurrent insert. MySQL 8
|
|
123
|
+
# moves the AUTO_INCREMENT counter past values set by UPDATE, so the
|
|
124
|
+
# new row can't start below IDs that were already issued. Concurrent
|
|
125
|
+
# INSERT IGNORE and UPDATE can deadlock here; the caller retries.
|
|
126
|
+
conn.execute("INSERT IGNORE INTO #{quoted_table_name} (stub) VALUES ('a')")
|
|
127
|
+
|
|
128
|
+
if conn.update(update).zero?
|
|
129
|
+
raise ActiveRecordPrettyKey::Error, "Could not create the ticket row in #{table_name}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
conn.select_value("SELECT LAST_INSERT_ID()")
|
|
134
|
+
end
|
|
41
135
|
end
|
|
42
136
|
end
|
|
43
137
|
end
|
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_record"
|
|
4
4
|
require "active_support/concern"
|
|
5
|
+
require "active_support/lazy_load_hooks"
|
|
5
6
|
require "sqids"
|
|
6
|
-
require_relative "active_record_pretty_key/
|
|
7
|
+
require_relative "active_record_pretty_key/version"
|
|
7
8
|
require_relative "active_record_pretty_key/concern"
|
|
8
|
-
# require_relative "active_record_pretty_key/version"
|
|
9
|
-
|
|
10
9
|
|
|
11
10
|
module ActiveRecordPrettyKey
|
|
12
11
|
class Error < StandardError; end
|
|
13
12
|
end
|
|
13
|
+
|
|
14
|
+
# PrettyKeyTicket subclasses ActiveRecord::Base. Defining it when the gem is
|
|
15
|
+
# required would load ActiveRecord::Base before the application's
|
|
16
|
+
# config.active_record settings are applied, so wait for ActiveRecord to load.
|
|
17
|
+
ActiveSupport.on_load(:active_record) do
|
|
18
|
+
require_relative "active_record_pretty_key/pretty_key_ticket"
|
|
19
|
+
end
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
4
5
|
|
|
5
6
|
module ActiveRecordPrettyKey
|
|
6
7
|
module Generators
|
|
7
8
|
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
8
11
|
source_root File.expand_path("templates", __dir__)
|
|
9
|
-
desc "Creates the
|
|
12
|
+
desc "Creates the pretty_key_tickets table migration for active_record_pretty_key"
|
|
10
13
|
|
|
11
|
-
def
|
|
12
|
-
|
|
14
|
+
def create_migration_file
|
|
15
|
+
migration_template "create_pretty_key_tickets.rb.erb",
|
|
16
|
+
File.join(db_migrate_path, "create_pretty_key_tickets.rb")
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
private
|
|
16
20
|
|
|
17
21
|
def migration_version
|
|
18
|
-
|
|
19
|
-
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
20
|
-
end
|
|
22
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
21
23
|
end
|
|
22
24
|
end
|
|
23
25
|
end
|
|
@@ -5,7 +5,9 @@ class CreatePrettyKeyTickets < ActiveRecord::Migration<%= migration_version %>
|
|
|
5
5
|
end
|
|
6
6
|
add_index :pretty_key_tickets, :stub, unique: true
|
|
7
7
|
|
|
8
|
-
# Seed
|
|
9
|
-
|
|
8
|
+
# Seed the single ticket row (dropped along with the table on rollback)
|
|
9
|
+
up_only do
|
|
10
|
+
execute "INSERT INTO pretty_key_tickets (stub) VALUES ('a')"
|
|
11
|
+
end
|
|
10
12
|
end
|
|
11
13
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_record_pretty_key
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stuart Yamartino
|
|
@@ -16,28 +16,40 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 6.
|
|
19
|
+
version: '6.1'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '9'
|
|
20
23
|
type: :runtime
|
|
21
24
|
prerelease: false
|
|
22
25
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
26
|
requirements:
|
|
24
27
|
- - ">="
|
|
25
28
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 6.
|
|
29
|
+
version: '6.1'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '9'
|
|
27
33
|
- !ruby/object:Gem::Dependency
|
|
28
34
|
name: activesupport
|
|
29
35
|
requirement: !ruby/object:Gem::Requirement
|
|
30
36
|
requirements:
|
|
31
37
|
- - ">="
|
|
32
38
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: 6.
|
|
39
|
+
version: '6.1'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '9'
|
|
34
43
|
type: :runtime
|
|
35
44
|
prerelease: false
|
|
36
45
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
46
|
requirements:
|
|
38
47
|
- - ">="
|
|
39
48
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: 6.
|
|
49
|
+
version: '6.1'
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '9'
|
|
41
53
|
- !ruby/object:Gem::Dependency
|
|
42
54
|
name: sqids
|
|
43
55
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -52,20 +64,6 @@ dependencies:
|
|
|
52
64
|
- - "~>"
|
|
53
65
|
- !ruby/object:Gem::Version
|
|
54
66
|
version: '0.1'
|
|
55
|
-
- !ruby/object:Gem::Dependency
|
|
56
|
-
name: thor
|
|
57
|
-
requirement: !ruby/object:Gem::Requirement
|
|
58
|
-
requirements:
|
|
59
|
-
- - "~>"
|
|
60
|
-
- !ruby/object:Gem::Version
|
|
61
|
-
version: '1.0'
|
|
62
|
-
type: :runtime
|
|
63
|
-
prerelease: false
|
|
64
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
-
requirements:
|
|
66
|
-
- - "~>"
|
|
67
|
-
- !ruby/object:Gem::Version
|
|
68
|
-
version: '1.0'
|
|
69
67
|
description: ActiveRecordPrettyKey provides functionality to generate and manage pretty,
|
|
70
68
|
human-readable keys for ActiveRecord models.
|
|
71
69
|
email:
|
|
@@ -75,13 +73,16 @@ executables: []
|
|
|
75
73
|
extensions: []
|
|
76
74
|
extra_rdoc_files: []
|
|
77
75
|
files:
|
|
76
|
+
- CHANGELOG.md
|
|
77
|
+
- LICENSE
|
|
78
|
+
- README.md
|
|
78
79
|
- VERSION
|
|
79
80
|
- lib/active_record_pretty_key.rb
|
|
80
81
|
- lib/active_record_pretty_key/concern.rb
|
|
81
82
|
- lib/active_record_pretty_key/pretty_key_ticket.rb
|
|
82
83
|
- lib/active_record_pretty_key/version.rb
|
|
83
84
|
- lib/generators/active_record_pretty_key/install/install_generator.rb
|
|
84
|
-
- lib/generators/active_record_pretty_key/install/templates/
|
|
85
|
+
- lib/generators/active_record_pretty_key/install/templates/create_pretty_key_tickets.rb.erb
|
|
85
86
|
homepage: https://github.com/noreastergroup/active_record_pretty_key
|
|
86
87
|
licenses:
|
|
87
88
|
- MIT
|
|
@@ -89,6 +90,8 @@ metadata:
|
|
|
89
90
|
homepage_uri: https://github.com/noreastergroup/active_record_pretty_key
|
|
90
91
|
source_code_uri: https://github.com/noreastergroup/active_record_pretty_key
|
|
91
92
|
changelog_uri: https://github.com/noreastergroup/active_record_pretty_key/blob/main/CHANGELOG.md
|
|
93
|
+
bug_tracker_uri: https://github.com/noreastergroup/active_record_pretty_key/issues
|
|
94
|
+
rubygems_mfa_required: 'true'
|
|
92
95
|
rdoc_options: []
|
|
93
96
|
require_paths:
|
|
94
97
|
- lib
|
|
@@ -96,14 +99,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
96
99
|
requirements:
|
|
97
100
|
- - ">="
|
|
98
101
|
- !ruby/object:Gem::Version
|
|
99
|
-
version:
|
|
102
|
+
version: 3.0.0
|
|
100
103
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
101
104
|
requirements:
|
|
102
105
|
- - ">="
|
|
103
106
|
- !ruby/object:Gem::Version
|
|
104
107
|
version: '0'
|
|
105
108
|
requirements: []
|
|
106
|
-
rubygems_version: 3.6.
|
|
109
|
+
rubygems_version: 3.6.9
|
|
107
110
|
specification_version: 4
|
|
108
111
|
summary: A gem for generating pretty keys in ActiveRecord models
|
|
109
112
|
test_files: []
|