sqinky 0.1.0 → 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 +31 -2
- data/README.md +95 -20
- data/SECURITY.md +44 -0
- data/lib/sqinky/identifier_encoding.rb +129 -46
- data/lib/sqinky/version.rb +1 -1
- metadata +9 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7515cd7b97774f6959bf863732910d884be8478f5c22073667862203f0c729a3
|
|
4
|
+
data.tar.gz: a82902c6faeea5a66956d78d9523c2c06a2bd58c3d046e9e5f2f21c953c220b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f4ddb63f9edbb2448378631d67f7aded214682c3ed39488d7b7f215104d6e8249961d9c560150eb2d72dd5f7e8ff327e7a1884dea6a70d00264761c390630bb
|
|
7
|
+
data.tar.gz: 40c6d737cbea0742dc471d3bab95153d4f231b61efdc168e1945dbba00b90915e2e90e524524887bea96bd94ecac1f26185b3ac64579ddd5b991b72ba2058deb
|
data/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] - 2026-10-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `canonical:` option for `encodes_identifier` and `encodes_identifiers`. Pass `canonical: false` to also accept non-canonical encodings, e.g. those issued before `min_length` was raised or `blocklist` was changed.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Only canonical encodings are accepted by default, so each record has exactly one valid encoding. Non-canonical aliases, including encodings issued before a Sqids option was changed, are rejected unless `canonical: false` is set.
|
|
19
|
+
- `encodes_identifier` and `encodes_identifiers` raise `ArgumentError` if a generated method would replace an existing method, e.g. `as: :id` or `as: :to_param`, or another encoding's method in the same class. Redeclaring an encoding inherited from a parent class is still allowed.
|
|
20
|
+
- The encoding methods read attributes with `public_send` instead of `send`, so encoding a private method raises `NoMethodError`.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- The non-bang encoding method (e.g. `id_encoding`) raises `ArgumentError` for non-Integer attribute values instead of returning a misleading encoding. Previously `1.5` encoded to the same identifier as `1`, and `"abc"` to the identifier of `0`. Blank values still return `nil`.
|
|
25
|
+
- Reject invalid encodings before querying the database. `nil`, empty, non-String, foreign-character, and wrong-arity encodings no longer decode into `nil` conditions that could find, destroy, or delete unrelated records: `find_by_*` returns `nil`, `find_by_*!` raises `ActiveRecord::RecordNotFound`, `destroy_by_*` returns `[]`, `delete_by_*` returns `0`, and the `decodes_as` helper returns `nil`.
|
|
26
|
+
- Reject encodings that decode to a value above `Sqids.max_value`. Long crafted input made `find_by_*`, `find_by_*!`, `destroy_by_*`, `delete_by_*`, and the `decodes_as` helper raise `ArgumentError` instead of treating the encoding as invalid. With `canonical: false` such values now also count as invalid instead of reaching the query.
|
|
27
|
+
- Reject encodings longer than the longest valid encoding before decoding them. Decoding time grows quadratically with the input length, so a crafted 100,000-character encoding took about 1.5 seconds to reject. With `canonical: false`, encodings of up to 255 characters, the largest Sqids `min_length`, are still decoded.
|
|
28
|
+
- Require the Active Support core extensions the library uses (`compact_blank!`, `presence`, `blank?`), so it no longer depends on Rails having loaded them.
|
|
29
|
+
|
|
30
|
+
## [0.1.0] - 2026-03-06
|
|
6
31
|
|
|
7
32
|
- Initial release
|
|
33
|
+
|
|
34
|
+
[unreleased]: https://github.com/david-uhlig/sqinky/compare/v0.2.0...HEAD
|
|
35
|
+
[0.2.0]: https://github.com/david-uhlig/sqinky/compare/v0.1.0...v0.2.0
|
|
36
|
+
[0.1.0]: https://github.com/david-uhlig/sqinky/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
[Sqids]: https://sqids.org/
|
|
2
|
+
[gem]: https://rubygems.org/gems/sqinky
|
|
3
|
+
[license]: https://github.com/david-uhlig/sqinky/blob/main/LICENSE.md
|
|
4
|
+
[tests]: https://github.com/david-uhlig/sqinky/actions/workflows/main.yml
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+

|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
# Sqinky - 🦑 [Sqids] for your Active Record models.
|
|
6
9
|
|
|
7
|
-
[][gem]
|
|
11
|
+
[][license]
|
|
12
|
+
[][tests]
|
|
8
13
|
|
|
9
|
-
> **What is Sqids?**
|
|
14
|
+
> **What is [Sqids]?**
|
|
10
15
|
>
|
|
11
16
|
> Sqids (pronounced "squids") is an open-source library that lets you generate short unique identifiers from numbers. These IDs are URL-safe, can encode several numbers, and do not contain common profanity words.
|
|
12
17
|
>
|
|
@@ -32,6 +37,12 @@ Run the following command to add Sqinky to your Gemfile:
|
|
|
32
37
|
bundle add sqinky
|
|
33
38
|
```
|
|
34
39
|
|
|
40
|
+
### Supported versions
|
|
41
|
+
|
|
42
|
+
Sqinky officially supports only the Ruby and Rails versions that still receive maintenance from their maintainers, see the [Ruby](https://www.ruby-lang.org/en/downloads/branches/) and [Rails](https://rubyonrails.org/maintenance) maintenance policies. The [test workflow](.github/workflows/main.yml) lists the versions currently tested.
|
|
43
|
+
|
|
44
|
+
The gemspec allows older versions (`ruby >= 3.3`, `activerecord >= 7.0`) so that you are not blocked from installing Sqinky. They may work, but they aren't tested, and bugs that only occur on end-of-life versions may not get fixed.
|
|
45
|
+
|
|
35
46
|
## Usage
|
|
36
47
|
|
|
37
48
|
To use it include `Sqinky::IdentifierEncoding` in your Active Record model and
|
|
@@ -108,7 +119,7 @@ class Order < ApplicationRecord
|
|
|
108
119
|
encodes_identifier :id, as: :public_id, decodes_as: :decode_public_id
|
|
109
120
|
end
|
|
110
121
|
|
|
111
|
-
Order.decode_public_id("
|
|
122
|
+
Order.decode_public_id("Uk") # => { id: 1 }
|
|
112
123
|
```
|
|
113
124
|
|
|
114
125
|
### Custom Sqids Options
|
|
@@ -123,6 +134,9 @@ class Comment < ApplicationRecord
|
|
|
123
134
|
end
|
|
124
135
|
```
|
|
125
136
|
|
|
137
|
+
> [!IMPORTANT]
|
|
138
|
+
> Sqids encodings are not encrypted. With the default alphabet, `"Uk"` means `1` in every app that uses Sqids, and anyone can decode an encoding with the public Sqids library. Use encodings to shorten URLs and hide sequential IDs from casual view, not for access control: always authorize access to the record you find. To make encodings app-specific, pass a shuffled `alphabet:` before you issue any encodings, since changing it later breaks existing ones (see [Changing Sqids options](#changing-sqids-options)).
|
|
139
|
+
|
|
126
140
|
### Multiple Encodings
|
|
127
141
|
|
|
128
142
|
A model can have multiple encodings, even for the same attribute, as long as they have distinct `as:` method names.
|
|
@@ -139,7 +153,7 @@ post = Post.create!(title: "How Sqinky became so inkie.")
|
|
|
139
153
|
post.id # => 212
|
|
140
154
|
post.tenant_id # => 42
|
|
141
155
|
post.id_encoding # => "37E"
|
|
142
|
-
post.id_and_tenant_id_encoding # "jGTwn"
|
|
156
|
+
post.id_and_tenant_id_encoding # => "jGTwn"
|
|
143
157
|
|
|
144
158
|
Post.find_by_id_encoding("37E") # => #<Post id: 212, ...>
|
|
145
159
|
Post.find_by_id_and_tenant_id_encoding("jGTwn") # => #<Post id: 212, ...>
|
|
@@ -164,6 +178,51 @@ label.id = 1
|
|
|
164
178
|
label.id_encoding # => "Uk"
|
|
165
179
|
```
|
|
166
180
|
|
|
181
|
+
### Method Name Collisions
|
|
182
|
+
|
|
183
|
+
Sqinky raises an `ArgumentError` instead of replacing a method that already exists. This includes attributes like `id`, methods inherited from Active Record like `to_param`, your own methods, and the methods of another encoding in the same class. Redeclaring an encoding inherited from a parent class is allowed.
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
class Comment < ApplicationRecord
|
|
187
|
+
include Sqinky::IdentifierEncoding
|
|
188
|
+
|
|
189
|
+
encodes_identifier as: :id # => ArgumentError: Comment already defines #id. Choose a different name with `as:` or `decodes_as:`.
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
To use an encoding in URLs, give it its own name and delegate to it:
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
class Comment < ApplicationRecord
|
|
197
|
+
include Sqinky::IdentifierEncoding
|
|
198
|
+
|
|
199
|
+
encodes_identifier as: :token
|
|
200
|
+
|
|
201
|
+
def to_param = token
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
> [!WARNING]
|
|
206
|
+
> Database columns can't be detected, because Active Record defines their reader methods lazily. Don't name an encoding after one of the model's columns: the encoding would hide the column's reader.
|
|
207
|
+
|
|
208
|
+
### Changing Sqids options
|
|
209
|
+
|
|
210
|
+
By default only canonical encodings are accepted, so every record has exactly one valid encoding. As a consequence, changing a Sqids option invalidates previously issued encodings. This includes raising `min_length`: `"Uk"` still decodes to `[1]`, but the canonical encoding becomes `"UkLWZg9DAJ"`, so `"Uk"` is rejected.
|
|
211
|
+
|
|
212
|
+
If old encodings must keep working after raising `min_length` or changing `blocklist`, pass `canonical: false`. Encodings with the wrong number of values are still rejected, but several encodings may then resolve to the same record.
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
class Comment < ApplicationRecord
|
|
216
|
+
include Sqinky::IdentifierEncoding
|
|
217
|
+
|
|
218
|
+
# Issues "UkLWZg9DAJ", but still accepts the previously issued "Uk".
|
|
219
|
+
encodes_identifier :id, min_length: 10, canonical: false
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
> [!WARNING]
|
|
224
|
+
> Never change `alphabet` once encodings are issued: old encodings then decode to different values and resolve to the wrong records, regardless of `canonical`.
|
|
225
|
+
|
|
167
226
|
### Parameter Overview
|
|
168
227
|
|
|
169
228
|
#### `encodes_identifier`
|
|
@@ -173,28 +232,36 @@ label.id_encoding # => "Uk"
|
|
|
173
232
|
| `attribute` | `:id` | The attribute to encode. Should only have `Integer` `>= 0` values. |
|
|
174
233
|
| `as:` | `nil` | If `nil` inferred as `<attribute>_encoding`. |
|
|
175
234
|
| `decodes_as:` | `nil` | Name of the decoding class method. If `nil` no such method is generated. |
|
|
235
|
+
| `canonical:` | `true` | If `false`, also accept non-canonical encodings. See [Changing Sqids options](#changing-sqids-options). |
|
|
176
236
|
| `**sqids_options` | `{}` | Sqids options passed through to `Sqids.new`, e.g. `min_length`, `alphabet`, and `blocklist` |
|
|
177
237
|
|
|
178
238
|
#### `encodes_identifiers`
|
|
179
239
|
|
|
180
|
-
| Parameter | Default | Description
|
|
181
|
-
|
|
182
|
-
| `*attributes` | | The attribute(s) to encode. Should only have `Integer` `>= 0` values.
|
|
183
|
-
| `as:` | `nil` | If `nil` inferred as `<attribute[_and_<attribute>]>_encoding`.
|
|
184
|
-
| `decodes_as:` | `nil` | Name of the decoding class method. If `nil` no such method is generated.
|
|
185
|
-
|
|
|
240
|
+
| Parameter | Default | Description |
|
|
241
|
+
|-------------------|---------|----------------------------------------------------------------------------------------------|
|
|
242
|
+
| `*attributes` | | The attribute(s) to encode. Should only have `Integer` `>= 0` values. |
|
|
243
|
+
| `as:` | `nil` | If `nil` inferred as `<attribute[_and_<attribute>]>_encoding`. |
|
|
244
|
+
| `decodes_as:` | `nil` | Name of the decoding class method. If `nil` no such method is generated. |
|
|
245
|
+
| `canonical:` | `true` | If `false`, also accept non-canonical encodings. See [Changing Sqids options](#changing-sqids-options). |
|
|
246
|
+
| `**sqids_options` | `{}` | Sqids options passed through to `Sqids.new`, e.g. `min_length`, `alphabet`, and `blocklist`. |
|
|
186
247
|
|
|
187
248
|
### Generated Methods Overview
|
|
188
249
|
|
|
250
|
+
Sqinky generates these methods when invoking `encodes_identifier(s)`:
|
|
251
|
+
|
|
189
252
|
| Method | Description |
|
|
190
253
|
|-----------------------------------|----------------------------------------------------------------------------------------------------|
|
|
191
|
-
| `instance.<as>` | Returns the Sqids encoding for the configured attributes. Returns `nil` if any attribute is `
|
|
192
|
-
| `instance.<as>!` | Same as above, but raises `ArgumentError`
|
|
193
|
-
| `Class.<decodes_as>(encoding)` | Returns the decoded hash, e.g. `{ id: 42 }
|
|
194
|
-
| `Class.find_by_<as>(encoding)` | Decodes `encoding` and passes the decoded hash to `find_by(...)
|
|
195
|
-
| `Class.find_by_<as
|
|
196
|
-
| `Class.destroy_by_<as>(encoding)` | Decodes `encoding` and passes the decoded hash to `destroy_by(...)
|
|
197
|
-
| `Class.delete_by_<as>(encoding)` | Decodes `encoding` and passes the decoded hash to `delete_by(...)
|
|
254
|
+
| `instance.<as>` | Returns the Sqids encoding for the configured attributes. Returns `nil` if any attribute is blank, raises `ArgumentError` if any attribute is not an `Integer`. |
|
|
255
|
+
| `instance.<as>!` | Same as above, but also raises `ArgumentError` instead of returning `nil` for blank attributes. |
|
|
256
|
+
| `Class.<decodes_as>(encoding)` | Returns the decoded hash, e.g. `{ id: 42 }`, or `nil` if the encoding is invalid. |
|
|
257
|
+
| `Class.find_by_<as>(encoding)` | Decodes `encoding` and passes the decoded hash to `find_by(...)`. |
|
|
258
|
+
| `Class.find_by_<as>!(encoding)` | Decodes `encoding` and passes the decoded hash to `find_by!(...)`. |
|
|
259
|
+
| `Class.destroy_by_<as>(encoding)` | Decodes `encoding` and passes the decoded hash to `destroy_by(...)`. |
|
|
260
|
+
| `Class.delete_by_<as>(encoding)` | Decodes `encoding` and passes the decoded hash to `delete_by(...)`. |
|
|
261
|
+
|
|
262
|
+
> [!NOTE]
|
|
263
|
+
> Invalid encodings never reach the database. An encoding is valid if it is a non-empty `String`, no longer than the longest encoding the encoder can issue (or 255 characters with `canonical: false`), that decodes to exactly one value per configured attribute, none of them above `Sqids.max_value`, and, unless `canonical: false` is set, is the canonical Sqids encoding of those values. For an invalid encoding `find_by_<as>` returns `nil`, `find_by_<as>!` raises `ActiveRecord::RecordNotFound`, `destroy_by_<as>` returns `[]`, and `delete_by_<as>` returns `0`.
|
|
264
|
+
|
|
198
265
|
|
|
199
266
|
## Development
|
|
200
267
|
|
|
@@ -213,14 +280,22 @@ This project uses [mise](https://mise.jdx.dev/) for managing Ruby versions and t
|
|
|
213
280
|
- `bin/console`: Open an interactive prompt to experiment with the code.
|
|
214
281
|
- `rake spec`: Run the test suite.
|
|
215
282
|
- `rake standard`: Run the StandardRB linter.
|
|
216
|
-
- `bundle exec appraisal install`:
|
|
283
|
+
- `bundle exec appraisal install`: Install the dependencies for all supported Rails versions.
|
|
217
284
|
- `bundle exec appraisal rake spec`: Run tests against all supported Rails versions.
|
|
218
285
|
- `mise run ci`: Run the local CI pipeline (linting and multi-Rails tests).
|
|
286
|
+
- `bundle exec ruby benchmarks/id_encoding.rb`: Benchmark encoding and decoding against raw Sqids.
|
|
287
|
+
- `bundle exec ruby benchmarks/db_retrieval.rb`: Benchmark finding records by encoding against SQLite and, if reachable via `DATABASE_URL`, PostgreSQL. Set `NUM_RECORDS`, `WARMUP`, and `TIME` for quicker runs.
|
|
288
|
+
|
|
289
|
+
## Versioning
|
|
290
|
+
|
|
291
|
+
This library aims to adhere to [Semantic Versioning 2.0.0](http://semver.org/). Violations of this scheme should be reported as bugs.
|
|
219
292
|
|
|
220
293
|
## Contributing
|
|
221
294
|
|
|
222
295
|
Bug reports and pull requests are welcome on GitHub at https://github.com/david-uhlig/sqinky. 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/david-uhlig/sqinky/blob/main/CODE_OF_CONDUCT.md).
|
|
223
296
|
|
|
297
|
+
Please report security vulnerabilities privately as described in the [security policy](SECURITY.md).
|
|
298
|
+
|
|
224
299
|
## License
|
|
225
300
|
|
|
226
301
|
The gem is available as open source under the terms of the [MIT License](LICENSE.md).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
Sqinky is pre-1.0. Security fixes are released only for the latest published version. Please upgrade to the latest release before reporting an issue.
|
|
6
|
+
|
|
7
|
+
Fixes are tested only against the Ruby and Rails versions that still receive maintenance from their maintainers, see [Supported versions](README.md#supported-versions).
|
|
8
|
+
|
|
9
|
+
## Reporting a Vulnerability
|
|
10
|
+
|
|
11
|
+
Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
|
|
12
|
+
|
|
13
|
+
Report them privately through [GitHub private vulnerability reporting](https://github.com/david-uhlig/sqinky/security/advisories/new) instead. If you can't use GitHub, email david.uhlig@gmail.com with `[sqinky security]` in the subject.
|
|
14
|
+
|
|
15
|
+
Please include:
|
|
16
|
+
|
|
17
|
+
- the affected Sqinky version, and your Ruby, Rails, and Sqids versions,
|
|
18
|
+
- a description of the issue and its impact,
|
|
19
|
+
- steps or a minimal model definition to reproduce it, and
|
|
20
|
+
- a suggested fix, if you have one.
|
|
21
|
+
|
|
22
|
+
## What to Expect
|
|
23
|
+
|
|
24
|
+
Sqinky is maintained by one person in their spare time, so these timelines are a best effort:
|
|
25
|
+
|
|
26
|
+
- You will get an acknowledgement within 7 days.
|
|
27
|
+
- You will get an initial assessment within 14 days, including whether the report is accepted.
|
|
28
|
+
- If accepted, a fix is developed in a private fork and released as a new gem version. A GitHub security advisory is published at the same time, and a CVE is requested where appropriate.
|
|
29
|
+
|
|
30
|
+
You will be credited in the advisory unless you prefer to stay anonymous. Please keep the report confidential until the advisory is published.
|
|
31
|
+
|
|
32
|
+
## Scope
|
|
33
|
+
|
|
34
|
+
Sqids encodings are not encrypted, and anyone can decode them with the public Sqids library. The following is expected behavior and not a vulnerability:
|
|
35
|
+
|
|
36
|
+
- decoding an encoding back to its numbers,
|
|
37
|
+
- guessing or enumerating valid encodings, and
|
|
38
|
+
- accessing a record found through an encoding when the application doesn't authorize that access.
|
|
39
|
+
|
|
40
|
+
Use encodings to shorten URLs and hide sequential IDs from casual view, not for access control, see the note in [Usage](README.md#usage).
|
|
41
|
+
|
|
42
|
+
In scope are, for example, a crafted encoding that makes a `find_by_*`, `destroy_by_*`, or `delete_by_*` helper act on a record other than the one it decodes to, or that triggers excessive resource use.
|
|
43
|
+
|
|
44
|
+
Vulnerabilities in Sqids itself should be reported to the [Sqids Ruby project](https://github.com/sqids/sqids-ruby).
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/concern"
|
|
4
|
+
require "active_support/core_ext/enumerable"
|
|
5
|
+
require "active_support/core_ext/object/blank"
|
|
4
6
|
require "sqids"
|
|
5
7
|
|
|
6
8
|
module Sqinky
|
|
@@ -19,6 +21,9 @@ module Sqinky
|
|
|
19
21
|
module IdentifierEncoding
|
|
20
22
|
extend ActiveSupport::Concern
|
|
21
23
|
|
|
24
|
+
# The largest +min_length+ that +Sqids.new+ accepts.
|
|
25
|
+
SQIDS_MAX_MIN_LENGTH = 255
|
|
26
|
+
|
|
22
27
|
class_methods do
|
|
23
28
|
# Generates methods for creating and consuming a single identifier attribute encoding, typically for the primary
|
|
24
29
|
# key +id+.
|
|
@@ -28,9 +33,9 @@ module Sqinky
|
|
|
28
33
|
# * The referenced +attribute+ must be present when the generated +#{as}+ method is called.
|
|
29
34
|
#
|
|
30
35
|
# #### Generates
|
|
31
|
-
# * +#<as>+ - Generates the Sqids encoding from the attribute value.
|
|
32
|
-
# * +#<as>!+ - Generates the Sqids encoding from the attribute value. Raises +ArgumentError+ the attribute value is noninteger.
|
|
33
|
-
# *
|
|
36
|
+
# * +#<as>+ - Generates the Sqids encoding from the attribute value. Returns nil if the value is blank, raises +ArgumentError+ if it is noninteger.
|
|
37
|
+
# * +#<as>!+ - Generates the Sqids encoding from the attribute value. Raises +ArgumentError+ if the attribute value is noninteger, including nil.
|
|
38
|
+
# * +.<decodes_as>(encoding)+ - Decodes a Sqids encoding back to the attribute-value hash. (Optional)
|
|
34
39
|
# * +.find_by_<as>(encoding)+ - Finds record by +encoding+ or returns nil.
|
|
35
40
|
# * +.find_by_<as>!(encoding)+ - Finds record by +encoding+ or raises +ActiveRecord::RecordNotFound+ error.
|
|
36
41
|
# * +.destroy_by_<as>(encoding)+ - Destroys record by +encoding+.
|
|
@@ -39,13 +44,14 @@ module Sqinky
|
|
|
39
44
|
# @param attribute [Symbol] Attribute to encode. Must take positive +Integer+ values.
|
|
40
45
|
# @param as [Symbol, nil] Optional name of the instance method that returns the encoding. Also part of the database methods, e.g. +find_by_<as>+. If missing, it is generated from the attribute name, e.g. +id_encoding+.
|
|
41
46
|
# @param decodes_as [Symbol, nil] Optional class method name that, when given an encoding, returns a hash of decoded attribute values. If missing, no such method is generated.
|
|
47
|
+
# @param canonical [Boolean] If +true+ (default), only the canonical encoding of the decoded values is accepted. Set to +false+ to also accept non-canonical encodings, e.g. those issued before +min_length+ was raised or +blocklist+ was changed. Encodings with the wrong number of values are rejected either way.
|
|
42
48
|
# @param sqids_options [Hash] Options forwarded to +Sqids.new+, e.g. +alphabet+, +min_length+, and +blocklist+.
|
|
43
49
|
#
|
|
44
|
-
# @return [
|
|
50
|
+
# @return [void]
|
|
45
51
|
#
|
|
46
52
|
# @see .encodes_identifiers
|
|
47
|
-
def encodes_identifier(attribute = :id, as: nil, decodes_as: nil, **sqids_options)
|
|
48
|
-
encodes_identifiers(attribute, as: as, decodes_as: decodes_as, **sqids_options)
|
|
53
|
+
def encodes_identifier(attribute = :id, as: nil, decodes_as: nil, canonical: true, **sqids_options)
|
|
54
|
+
encodes_identifiers(attribute, as: as, decodes_as: decodes_as, canonical: canonical, **sqids_options)
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
# Generates methods for creating and consuming a single or multiple identifier attribute encoding.
|
|
@@ -55,9 +61,9 @@ module Sqinky
|
|
|
55
61
|
# * The referenced +attribute+ must be present when the generated +#{as}+ method is called.
|
|
56
62
|
#
|
|
57
63
|
# #### Generates
|
|
58
|
-
# * +#<as>+ - Generates the Sqids encoding from the attribute values.
|
|
59
|
-
# * +#<as>!+ - Generates the Sqids encoding from the attribute values. Raises +ArgumentError+ if any attribute value is noninteger.
|
|
60
|
-
# *
|
|
64
|
+
# * +#<as>+ - Generates the Sqids encoding from the attribute values. Returns nil if any value is blank, raises +ArgumentError+ if any value is noninteger.
|
|
65
|
+
# * +#<as>!+ - Generates the Sqids encoding from the attribute values. Raises +ArgumentError+ if any attribute value is noninteger, including nil.
|
|
66
|
+
# * +.<decodes_as>(encoding)+ - Decodes a Sqids encoding back to the attributes-values hash. (Optional)
|
|
61
67
|
# * +.find_by_<as>(encoding)+ - Finds record by +encoding+ or returns nil.
|
|
62
68
|
# * +.find_by_<as>!(encoding)+ - Finds record by +encoding+ or raises +ActiveRecord::RecordNotFound+ error.
|
|
63
69
|
# * +.destroy_by_<as>(encoding)+ - Destroys record by +encoding+.
|
|
@@ -128,7 +134,7 @@ module Sqinky
|
|
|
128
134
|
# end
|
|
129
135
|
#
|
|
130
136
|
# order = Order.create!(shop_id: 10)
|
|
131
|
-
# encoded = order.public_id # =>
|
|
137
|
+
# encoded = order.public_id # => "U6Lg"
|
|
132
138
|
# Order.decode_public_id(encoded)
|
|
133
139
|
# # => { shop_id: 10, id: 1 }
|
|
134
140
|
#
|
|
@@ -150,9 +156,9 @@ module Sqinky
|
|
|
150
156
|
# include Sqinky::IdentifierEncoding
|
|
151
157
|
#
|
|
152
158
|
# # Encodes +id+ into +id_encoding+.
|
|
153
|
-
#
|
|
159
|
+
# encodes_identifier
|
|
154
160
|
# # Encodes +id+ (again) into +code+ with the +abc+ alphabet.
|
|
155
|
-
#
|
|
161
|
+
# encodes_identifier as: :code, alphabet: "abc"
|
|
156
162
|
# # Encodes both +user_id+ and +group_id+ into a single token. Make sure to
|
|
157
163
|
# # use a different +as+ (and +decodes_as+) value for each encoder, otherwise they will overwrite
|
|
158
164
|
# # each other.
|
|
@@ -173,62 +179,99 @@ module Sqinky
|
|
|
173
179
|
# membership_token = membership.membership_token
|
|
174
180
|
# # => "7edZ"
|
|
175
181
|
# Membership.find_by_membership_token(membership_token)
|
|
176
|
-
# # => Internally calls `find_by(user_id: 44, group_id: 12)
|
|
177
|
-
#
|
|
178
|
-
# @return [Void]
|
|
182
|
+
# # => Internally calls `find_by(user_id: 44, group_id: 12)`
|
|
179
183
|
#
|
|
180
184
|
# @param attributes [Array<Symbol>] List of attributes to encode. At least one attribute must be provided.
|
|
181
185
|
# @param as [Symbol, nil] Name of the instance method that returns the encoding. Also part of the database methods, e.g. +find_by_<as>+. If missing, it is generated from the attribute names, e.g. +id_encoding+ or +id_and_tenant_id_encoding+.
|
|
182
186
|
# @param decodes_as [Symbol, nil] Optional class method name that, when given an encoding, returns a hash of decoded attribute values. If missing, no such method is generated.
|
|
187
|
+
# @param canonical [Boolean] If +true+ (default), only the canonical encoding of the decoded values is accepted. Set to +false+ to also accept non-canonical encodings, e.g. those issued before +min_length+ was raised or +blocklist+ was changed. Encodings with the wrong number of values are rejected either way.
|
|
183
188
|
# @param sqids_options [Hash] Options forwarded to +Sqids.new+, e.g. +alphabet+, +min_length+, and +blocklist+.
|
|
184
189
|
#
|
|
185
|
-
# @raise [ArgumentError] if no attributes are given.
|
|
190
|
+
# @raise [ArgumentError] if no attributes are given, or if a generated method would replace an existing method.
|
|
186
191
|
#
|
|
187
192
|
# @return [void]
|
|
188
|
-
def encodes_identifiers(*attributes, as: nil, decodes_as: nil, **sqids_options)
|
|
193
|
+
def encodes_identifiers(*attributes, as: nil, decodes_as: nil, canonical: true, **sqids_options)
|
|
189
194
|
if attributes.compact_blank!.empty?
|
|
190
195
|
raise ArgumentError, <<~MSG
|
|
191
|
-
Must specify at least one attribute. Hint: Use `encodes_identifier` instead to encode the primary key
|
|
196
|
+
Must specify at least one attribute. Hint: Use `encodes_identifier` instead to encode the primary key
|
|
192
197
|
without having to specify the `:id` attribute.
|
|
193
198
|
MSG
|
|
194
199
|
end
|
|
195
200
|
coder = Sqids.new(**sqids_options)
|
|
196
201
|
encoding_method_name = as.presence || attributes.join("_and_").concat("_encoding")
|
|
197
|
-
database_methods =
|
|
198
|
-
|
|
199
|
-
|
|
202
|
+
database_methods = {
|
|
203
|
+
"find_by" => "find_by_#{encoding_method_name}",
|
|
204
|
+
"find_by!" => "find_by_#{encoding_method_name}!",
|
|
205
|
+
"destroy_by" => "destroy_by_#{encoding_method_name}",
|
|
206
|
+
"delete_by" => "delete_by_#{encoding_method_name}"
|
|
207
|
+
}
|
|
208
|
+
sqinky_ensure_method_names_available!(
|
|
209
|
+
instance_methods: [encoding_method_name, "#{encoding_method_name}!"],
|
|
210
|
+
class_methods: database_methods.values + [decodes_as.presence].compact
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Decoding time grows quadratically with the encoding length, so longer encodings are rejected before decoding.
|
|
214
|
+
# The longest canonical encoding encodes the maximum value for every attribute, padded to +min_length+.
|
|
215
|
+
# Non-canonical encodings may have been issued with a larger +min_length+, up to the Sqids limit.
|
|
216
|
+
max_encoding_length = coder.encode([Sqids.max_value] * attributes.size).length
|
|
217
|
+
max_encoding_length = [max_encoding_length, SQIDS_MAX_MIN_LENGTH].max unless canonical
|
|
218
|
+
|
|
219
|
+
# Returns the attribute-value hash for a valid encoding, or nil. An encoding is valid if it is a non-empty
|
|
220
|
+
# String no longer than +max_encoding_length+ that decodes to exactly one value per attribute, no value exceeds
|
|
221
|
+
# +Sqids.max_value+ and, unless +canonical+ is false, is the canonical encoding of those values. This rejects
|
|
222
|
+
# foreign characters, encodings of a different arity, oversized values, and non-canonical aliases of the same
|
|
223
|
+
# values.
|
|
224
|
+
decode = lambda do |encoding|
|
|
225
|
+
values = (encoding.is_a?(String) && encoding.length <= max_encoding_length) ? coder.decode(encoding) : []
|
|
226
|
+
|
|
227
|
+
# Covers nil, non-String, empty, too long, and foreign-character input, which all decode to no values.
|
|
228
|
+
if values.size != attributes.size
|
|
229
|
+
nil
|
|
230
|
+
# Sqids decodes long input into values it can't encode, so re-encoding them would raise.
|
|
231
|
+
elsif values.any? { _1 > Sqids.max_value }
|
|
232
|
+
nil
|
|
233
|
+
elsif canonical && coder.encode(values) != encoding
|
|
234
|
+
nil
|
|
235
|
+
else
|
|
236
|
+
attributes.zip(values).to_h
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Returns the Sqids encoding of the given values. Raises +ArgumentError+ unless every value is an +Integer+, so
|
|
241
|
+
# that, e.g., 1.5 cannot be encoded as the same identifier as 1.
|
|
242
|
+
encode = lambda do |values|
|
|
243
|
+
unless values.all? { _1.is_a?(Integer) }
|
|
244
|
+
raise ArgumentError, <<~MSG
|
|
245
|
+
Encoding supports integers between 0 and #{Sqids.max_value}.
|
|
246
|
+
|
|
247
|
+
Received: #{attributes.zip(values).to_h}
|
|
248
|
+
MSG
|
|
249
|
+
end
|
|
250
|
+
coder.encode(values)
|
|
200
251
|
end
|
|
201
252
|
|
|
202
253
|
# @!method <encoding_method_name>
|
|
203
254
|
# Returns the Sqids-encoded identifier for the configured attributes.
|
|
204
255
|
#
|
|
205
|
-
#
|
|
206
|
-
# reversible encoding.
|
|
207
|
-
#
|
|
208
|
-
# @raises [ArgumentError] If any of the attributes is a number below 0 or above +Sqids.max_value+.
|
|
256
|
+
# @raise [ArgumentError] If any of the attributes is present but not an integer between 0 and +Sqids.max_value+.
|
|
209
257
|
# @return [String, nil] Encoded identifier or nil if any of the attributes is +blank?+.
|
|
210
258
|
define_method(encoding_method_name) do
|
|
211
|
-
values = attributes.map {
|
|
212
|
-
|
|
259
|
+
values = attributes.map { public_send(_1) }
|
|
260
|
+
|
|
261
|
+
if values.any?(&:blank?)
|
|
262
|
+
nil
|
|
263
|
+
else
|
|
264
|
+
encode.call(values)
|
|
265
|
+
end
|
|
213
266
|
end
|
|
214
267
|
|
|
215
|
-
# @!method <encoding_method_name
|
|
268
|
+
# @!method <encoding_method_name>!
|
|
216
269
|
# Returns the Sqids-encoded identifier for the configured attributes.
|
|
217
270
|
#
|
|
218
|
-
#
|
|
219
|
-
#
|
|
220
|
-
# @raises [ArgumentError] If any of the attributes is not a positive integer between 0 and +Sqids.max_value+
|
|
271
|
+
# @raise [ArgumentError] If any of the attributes is not an integer between 0 and +Sqids.max_value+, including nil.
|
|
221
272
|
# @return [String] Encoded identifier.
|
|
222
273
|
define_method("#{encoding_method_name}!") do
|
|
223
|
-
|
|
224
|
-
unless values.all? { _1.is_a?(Integer) }
|
|
225
|
-
raise ArgumentError, <<~MSG
|
|
226
|
-
Encoding supports integers between 0 and #{Sqids.max_value}.
|
|
227
|
-
|
|
228
|
-
Received: #{attributes.zip(values).to_h}
|
|
229
|
-
MSG
|
|
230
|
-
end
|
|
231
|
-
coder.encode(values)
|
|
274
|
+
encode.call(attributes.map { public_send(_1) })
|
|
232
275
|
end
|
|
233
276
|
|
|
234
277
|
database_methods.each do |base_method, dynamic_method|
|
|
@@ -237,6 +280,9 @@ module Sqinky
|
|
|
237
280
|
# attributes, then delegating to the corresponding Active Record
|
|
238
281
|
# query method (e.g. `find_by`, `find_by!`, `destroy_by`, `delete_by`).
|
|
239
282
|
#
|
|
283
|
+
# An invalid encoding never reaches the database: +find_by+ returns nil, +find_by!+ raises
|
|
284
|
+
# +ActiveRecord::RecordNotFound+, +destroy_by+ returns [], and +delete_by+ returns 0.
|
|
285
|
+
#
|
|
240
286
|
# @param encoding [String] Sqids-encoded identifier
|
|
241
287
|
# @return [Object, nil] model instance or result of the delegated
|
|
242
288
|
# query method
|
|
@@ -245,9 +291,18 @@ module Sqinky
|
|
|
245
291
|
# `find_by_id_encoding`, `find_by_id_encoding!`,
|
|
246
292
|
# `destroy_by_id_encoding`, `delete_by_id_encoding`.
|
|
247
293
|
define_singleton_method(dynamic_method) do |encoding|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
294
|
+
args = decode.call(encoding)
|
|
295
|
+
|
|
296
|
+
if args
|
|
297
|
+
send(base_method, args)
|
|
298
|
+
else
|
|
299
|
+
case base_method
|
|
300
|
+
when "find_by" then nil
|
|
301
|
+
when "find_by!" then raise ActiveRecord::RecordNotFound.new("Couldn't find #{name} with an invalid encoding", name)
|
|
302
|
+
when "destroy_by" then []
|
|
303
|
+
when "delete_by" then 0
|
|
304
|
+
end
|
|
305
|
+
end
|
|
251
306
|
end
|
|
252
307
|
end
|
|
253
308
|
|
|
@@ -258,13 +313,41 @@ module Sqinky
|
|
|
258
313
|
# attribute to its decoded numeric value.
|
|
259
314
|
#
|
|
260
315
|
# @param encoding [String] Sqids-encoded identifier
|
|
261
|
-
# @return [Hash{Symbol=>Integer}] decoded attribute values
|
|
316
|
+
# @return [Hash{Symbol=>Integer}, nil] decoded attribute values, or nil if the encoding is invalid
|
|
262
317
|
define_singleton_method(decoding_method_name) do |encoding|
|
|
263
|
-
|
|
264
|
-
attributes.zip(values).to_h
|
|
318
|
+
decode.call(encoding)
|
|
265
319
|
end
|
|
266
320
|
end
|
|
267
321
|
end
|
|
322
|
+
|
|
323
|
+
private
|
|
324
|
+
|
|
325
|
+
# Raises +ArgumentError+ if any of the generated methods would replace an existing method, e.g. +as: :id+.
|
|
326
|
+
# Replacing a method that Sqinky generated in a superclass is allowed, so child classes can redeclare an
|
|
327
|
+
# inherited encoding.
|
|
328
|
+
def sqinky_ensure_method_names_available!(instance_methods:, class_methods:)
|
|
329
|
+
conflicts = instance_methods.filter { sqinky_method_name_taken?(self, _1) }.map { "##{_1}" } +
|
|
330
|
+
class_methods.filter { sqinky_method_name_taken?(singleton_class, _1) }.map { ".#{_1}" }
|
|
331
|
+
|
|
332
|
+
unless conflicts.empty?
|
|
333
|
+
raise ArgumentError, <<~MSG
|
|
334
|
+
#{name || inspect} already defines #{conflicts.join(", ")}. Choose a different name with `as:` or `decodes_as:`.
|
|
335
|
+
MSG
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# A name is taken if +mod+ already has a method by that name, unless Sqinky generated it in a superclass.
|
|
340
|
+
def sqinky_method_name_taken?(mod, method_name)
|
|
341
|
+
if mod.method_defined?(method_name) || mod.private_method_defined?(method_name)
|
|
342
|
+
method = mod.instance_method(method_name)
|
|
343
|
+
generated_by_sqinky = method.source_location&.first == __FILE__
|
|
344
|
+
inherited = method.owner != mod
|
|
345
|
+
|
|
346
|
+
!(generated_by_sqinky && inherited)
|
|
347
|
+
else
|
|
348
|
+
false
|
|
349
|
+
end
|
|
350
|
+
end
|
|
268
351
|
end
|
|
269
352
|
end
|
|
270
353
|
end
|
data/lib/sqinky/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sqinky
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Uhlig
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -42,16 +42,16 @@ dependencies:
|
|
|
42
42
|
name: sqids
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
44
44
|
requirements:
|
|
45
|
-
- - "
|
|
45
|
+
- - "~>"
|
|
46
46
|
- !ruby/object:Gem::Version
|
|
47
|
-
version: 0.2
|
|
47
|
+
version: '0.2'
|
|
48
48
|
type: :runtime
|
|
49
49
|
prerelease: false
|
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
|
-
- - "
|
|
52
|
+
- - "~>"
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
|
-
version: 0.2
|
|
54
|
+
version: '0.2'
|
|
55
55
|
- !ruby/object:Gem::Dependency
|
|
56
56
|
name: appraisal
|
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -180,6 +180,7 @@ files:
|
|
|
180
180
|
- LICENSE.md
|
|
181
181
|
- README.md
|
|
182
182
|
- Rakefile
|
|
183
|
+
- SECURITY.md
|
|
183
184
|
- lib/sqinky.rb
|
|
184
185
|
- lib/sqinky/identifier_encoding.rb
|
|
185
186
|
- lib/sqinky/version.rb
|
|
@@ -190,6 +191,7 @@ metadata:
|
|
|
190
191
|
homepage_uri: https://github.com/david-uhlig/sqinky
|
|
191
192
|
source_code_uri: https://github.com/david-uhlig/sqinky
|
|
192
193
|
changelog_uri: https://github.com/david-uhlig/sqinky/blob/main/CHANGELOG.md
|
|
194
|
+
rubygems_mfa_required: 'true'
|
|
193
195
|
post_install_message:
|
|
194
196
|
rdoc_options: []
|
|
195
197
|
require_paths:
|
|
@@ -198,7 +200,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
198
200
|
requirements:
|
|
199
201
|
- - ">="
|
|
200
202
|
- !ruby/object:Gem::Version
|
|
201
|
-
version: 3.
|
|
203
|
+
version: 3.3.0
|
|
202
204
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
203
205
|
requirements:
|
|
204
206
|
- - ">="
|