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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40416492e70faec554b8a67ff4c00c8d0ee9e4a0b2b76a885adffa0dbce64294
4
- data.tar.gz: 1d538c90645378e38d3007b0d3473f07b264433cf95eabb826c392cc4099ef05
3
+ metadata.gz: 7515cd7b97774f6959bf863732910d884be8478f5c22073667862203f0c729a3
4
+ data.tar.gz: a82902c6faeea5a66956d78d9523c2c06a2bd58c3d046e9e5f2f21c953c220b2
5
5
  SHA512:
6
- metadata.gz: c55b2e64caf756a43f6336fe99135d5eeb4e7a10e664c87bbfb17143ab91ab1e75b8b2a1350061588172678997167da6219ae5c0e87ae1b5faf87679735f30c7
7
- data.tar.gz: 7f83412fe303c907f842b1c510675b2d9efc00e2721fa3a2d85b9ac679d6d0488c30a95c52b42b1fb4f0a9882e7f170ae2320ae217596e39b64d9884e5ec6c94
6
+ metadata.gz: 3f4ddb63f9edbb2448378631d67f7aded214682c3ed39488d7b7f215104d6e8249961d9c560150eb2d72dd5f7e8ff327e7a1884dea6a70d00264761c390630bb
7
+ data.tar.gz: 40c6d737cbea0742dc471d3bab95153d4f231b61efdc168e1945dbba00b90915e2e90e524524887bea96bd94ecac1f26185b3ac64579ddd5b991b72ba2058deb
data/CHANGELOG.md CHANGED
@@ -1,7 +1,36 @@
1
1
  # Changelog
2
2
 
3
- ### Unreleased
3
+ All notable changes to this project will be documented in this file.
4
4
 
5
- ### 0.1.0
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
- # 🫟 Sqinky
6
+ ![Sqinky – Sqids for your Active Record models](.github/assets/banner-dark.svg)
4
7
 
5
- ## 🦑 [Sqids] for your Active Record models.
8
+ # Sqinky - 🦑 [Sqids] for your Active Record models.
6
9
 
7
- [![License](https://img.shields.io/github/license/david-uhlig/sqinky?label=License&labelColor=343B42&color=blue)](https://github.com/david-uhlig/sqinky/blob/main/LICENSE.md) [![Gem Version](https://badge.fury.io/rb/sqinky.svg)](https://badge.fury.io/rb/sqinky) [![Tests](https://github.com/david-uhlig/sqinky/actions/workflows/main.yml/badge.svg)](https://github.com/david-uhlig/sqinky/actions/workflows/main.yml)
10
+ [![Gem Version](http://img.shields.io/gem/v/sqinky.svg)][gem]
11
+ [![License: MIT](https://img.shields.io/github/license/david-uhlig/sqinky?label=License&labelColor=343B42&color=blue)][license]
12
+ [![Tests](https://github.com/david-uhlig/sqinky/actions/workflows/main.yml/badge.svg)][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("86Rf07") # => { id: 1 }
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
- | `**sqids_options` | `{}` | Sqids options passed through to `Sqids.new`, e.g. `min_length`, `alphabet`, and `blocklist` |
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 `nil`. |
192
- | `instance.<as>!` | Same as above, but raises `ArgumentError` if any attribute is not an `Integer`. |
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>(encoding)!` | Decodes `encoding` and passes the decoded hash to `find_by(...)!` |
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
- # * +#<decodes_as>(encoding)+ - Decodes a Sqids encoding back to the attribute-value hash. (Optional)
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 [Void]
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
- # * +#<decodes_as>(encoding)+ - Decodes a Sqids encoding back to the attributes-values hash. (Optional)
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 # => e.g. "86Rf07"
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
- # encode_identifier
159
+ # encodes_identifier
154
160
  # # Encodes +id+ (again) into +code+ with the +abc+ alphabet.
155
- # encode_identifier as: :code, alphabet: "abc"
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 = %w[find_by find_by! destroy_by delete_by].map do |base_method|
198
- # ["find_by!", "find_by_id_encoding!"]
199
- [base_method, base_method.gsub(/(\w+?)(!?)\b/, "\\1_#{encoding_method_name}\\2")]
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
- # Will return an irreversible encoding if any attribute is noninteger. Use the bang method to ensure a
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 { send(_1) }
212
- values.any?(&:blank?) ? nil : coder.encode(values)
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
- # Ensures a reversible encoding.
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
- values = attributes.map { send(_1) }
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
- values = coder.decode(encoding)
249
- args = attributes.zip(values).to_h
250
- send(base_method, args)
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
- values = coder.decode(encoding)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sqinky
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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-03-06 00:00:00.000000000 Z
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.0
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.0
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.2.0
203
+ version: 3.3.0
202
204
  required_rubygems_version: !ruby/object:Gem::Requirement
203
205
  requirements:
204
206
  - - ">="