rotulus 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b01279029660a73c745ba2c67c703454f54f44a160b5964523c0fd521ca973c0
4
+ data.tar.gz: b1479c0f2a2b8971e4d8977f4b5e4a052829d2d3f472fe1199497266d8d3cafe
5
+ SHA512:
6
+ metadata.gz: 12120dce0516e9823e891dea5bfe76af1148234e18a556f1d889d1b5ec68031746afbe6e0599955ced295de214d6eedfa3b560ffdce03e0c49e94561a3c5399a
7
+ data.tar.gz: 6b10d0bab75dffd5f1c988f09161a56cdc0688e50af403864a9b15c6d6b0b2a1191eddff5606be86e516ba3ba785a96cbbaa39dbb963593a00108fb4aee8bc23
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## 0.1.0
2
+
3
+ Initial release.
data/Gemfile ADDED
@@ -0,0 +1,46 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ rails_version = ENV['RAILS_VERSION'] || '7-0-stable'
5
+
6
+ gem 'rake'
7
+ gem 'rspec'
8
+
9
+ if ENV.fetch('COVERAGE', nil) == 'true'
10
+ gem 'simplecov'
11
+ gem 'simplecov-cobertura'
12
+ end
13
+
14
+ case rails_version
15
+ when '4-2-stable'
16
+ # Ruby 2.2 or newer.
17
+ gem 'pg', '~> 0.15'
18
+ gem 'sqlite3', '~> 1.3.6'
19
+ gem 'mysql2', '~> 0.4.4'
20
+ when '5-0-stable', '5-1-stable', '5-2-stable'
21
+ # Ruby 2.2.2 or newer.
22
+ gem 'pg', '~> 0.18'
23
+ gem 'sqlite3', '~> 1.3.6'
24
+ gem 'mysql2', '~> 0.4.4'
25
+ when '6-0-stable'
26
+ # Ruby 2.5.0 or newer.
27
+ gem 'pg', '~> 0.18'
28
+ gem 'sqlite3', '~> 1.4'
29
+ gem 'mysql2', '>= 0.4.4'
30
+ when '6-1-stable'
31
+ # Ruby 2.5.0 or newer.
32
+ gem 'pg', '~> 1.1'
33
+ gem 'sqlite3', '~> 1.4'
34
+ gem 'mysql2', '~> 0.5'
35
+ when '7-0-stable'
36
+ # Ruby 2.7.0 or newer.
37
+ gem 'pg', '~> 1.1'
38
+ gem 'sqlite3', '~> 1.4'
39
+ gem 'mysql2', '~> 0.5'
40
+ else
41
+ gem 'pg'
42
+ gem 'sqlite3'
43
+ gem 'mysql2'
44
+ end
45
+
46
+ gem 'rails', git: 'https://github.com/rails/rails', branch: rails_version
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Jayson Uy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,475 @@
1
+ # Rotulus
2
+
3
+ [![CI](https://github.com/jsonb-uy/rotulus/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jsonb-uy/rotulus/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/jsonb-uy/rotulus/branch/main/graph/badge.svg?token=OKGOWP4SH9)](https://codecov.io/gh/jsonb-uy/rotulus)
4
+
5
+ ### Cursor-based pagination for apps built on Rails/ActiveRecord
6
+
7
+ Cursor-based pagination is an alternative to OFFSET-based pagination that provides a more stable and predictable pagination behavior as records are being added, updated, and removed in the database through the use of an encoded cursor token.
8
+
9
+ Some advantages of this approach are:
10
+
11
+ * Reduces inaccuracies such as duplicate/skipped records due to records being actively manipulated in the DB.
12
+ * Can significantly improve performance(with proper DB indexing on ordered columns) especially as you move forward on large datasets.
13
+
14
+
15
+ ## Features
16
+
17
+ * Sort records by multiple/any number of columns
18
+ * Sort records using columns from joined tables
19
+ * `NULLS FIRST`/`NULLS LAST` handling
20
+ * Allows custom cursor format
21
+ * Built-in cursor token expiration
22
+ * Built-in cursor integrity check
23
+ * Supports **MySQL**, **PostgreSQL**, and **SQLite**
24
+ * Supports **Rails 4.2** and above
25
+
26
+
27
+ ## Installation
28
+
29
+ Add this line to your application's Gemfile:
30
+
31
+ ```ruby
32
+ gem 'rotulus'
33
+ ```
34
+
35
+ And then execute:
36
+
37
+ ```sh
38
+ bundle install
39
+ ```
40
+
41
+ Or install it yourself as:
42
+
43
+ ```sh
44
+ gem install rotulus
45
+ ```
46
+
47
+ ## Configuration
48
+ At the very least you only need to set the environment variable `ROTULUS_SECRET` to a random string(e.g. generate via `rails secret`).
49
+ For more configuration options:
50
+
51
+ #### Create an initializer `config/initializers/rotulus.rb`:
52
+
53
+ ```ruby
54
+ Rotulus.configure do |config|
55
+ config.page_default_limit = 5
56
+ config.page_max_limit = 50
57
+ config.secret = ENV["MY_ENV_VAR"]
58
+ config.token_expires_in = 10800
59
+ config.cursor_class = MyCursor
60
+ end
61
+ ```
62
+
63
+ | Configuration | Description |
64
+ | ----------- | ----------- |
65
+ | `page_default_limit` | **Default: 5** <br/> Default record limit per page in case the `:limit` is not given when initializing a page `Rotulus::Page.new(...)` |
66
+ | `page_max_limit` | **Default: 50** <br/> Maximum `:limit` value allowed when initializing a page.|
67
+ | `secret` | **Default: ENV['ROTULUS_SECRET']** <br/> Key needed to generate the cursor state. |
68
+ | `token_expires_in` | **Default: 259200**(3 days) <br/> Validity period of a cursor token (in seconds). Set to `nil` to disable token expiration. |
69
+ | `cursor_class` | **Default: Rotulus::Cursor** <br/> Cursor class responsible for encoding/decoding cursor data. Default uses Base64 encoding. see [Custom Token Format](#custom-token-format). |
70
+ <br/>
71
+
72
+
73
+ ## Usage
74
+
75
+ ### Basic Usage
76
+
77
+ #### Initialize a page
78
+
79
+ ```ruby
80
+ users = User.where('age > ?', 16)
81
+
82
+ page = Rotulus::Page.new(users, order: { first_name: :asc, last_name: :desc }, limit: 3)
83
+ ```
84
+ Example above will automatically add the table's PK(`users.id`) in the generated SQL query as tie-breaker if the PK isn't included in the `:order` column config yet.
85
+
86
+
87
+ ###### Example with `ORDER BY users.id asc` only:
88
+
89
+ ```ruby
90
+ page = Rotulus::Page.new(users, order: { id: :asc } limit: 3)
91
+
92
+ # OR
93
+
94
+ page = Rotulus::Page.new(users, limit: 3)
95
+ ```
96
+
97
+ #### Access the page records
98
+
99
+ ```ruby
100
+ page.records
101
+ => [#<User id: 11, first_name: 'John'...]
102
+ ```
103
+
104
+ #### Check if a next page exists
105
+
106
+ ```ruby
107
+ page.next?
108
+ => true
109
+ ```
110
+ #### Check if a previous page exists
111
+
112
+ ```ruby
113
+ page.prev?
114
+ => false
115
+ ```
116
+
117
+ #### Get the cursor to access the next page
118
+ ```ruby
119
+ page.next_token
120
+ => "eyI6ZiI6eyJebyI6..."
121
+ ```
122
+ In case there is no next page, `nil` is returned
123
+
124
+ #### Get the cursor to access the previous page
125
+ ```ruby
126
+ page.prev_token
127
+ => "eyI6ZiI6eyJebyI6..."
128
+ ```
129
+ In case there is no previous page(i.e. currently in first page), `nil` is returned
130
+
131
+
132
+ #### Navigate to the page given a cursor
133
+ ##### Return a new page instance pointed at the given cursor
134
+ ```ruby
135
+ another_page = page.at('eyI6ZiI6eyJebyI6...')
136
+ => #<Rotulus::Page ..>
137
+ ```
138
+
139
+ Or to immediately get the records:
140
+
141
+ ```ruby
142
+ page.at(next_page_token).records
143
+ ```
144
+
145
+ ##### Return the same page instance pointed at the given cursor
146
+ ```ruby
147
+ page.at!('eyI6ZiI6eyJebyI6...')
148
+ => #<Rotulus::Page ..>
149
+ ```
150
+
151
+ #### Get the next page
152
+ ```ruby
153
+ next_page = page.next
154
+ ```
155
+ This is the same as `page.at(page.next_token)`. Returns `nil` if there is no next page.
156
+
157
+ #### Get the previous page
158
+ ```ruby
159
+ previous_page = page.prev
160
+ ```
161
+ This is the same as `page.at(page.prev_token)`. Returns `nil` if there is no previous page.
162
+
163
+
164
+ ### Extras
165
+ #### Reload page
166
+ ```ruby
167
+ page.reload
168
+
169
+ # reload then return records
170
+ page.reload.records
171
+ ```
172
+
173
+ #### Print page in table format for debugging
174
+ Currently, only the columns included in `ORDER BY` are shown:
175
+
176
+ ```ruby
177
+ puts page.as_table
178
+
179
+ +------------------------------------------------------------+
180
+ | users.first_name | users.last_name | users.id |
181
+ +------------------------------------------------------------+
182
+ | George | <NULL> | 1 |
183
+ | Jane | Smith | 3 |
184
+ | Jane | Doe | 2 |
185
+ +------------------------------------------------------------+
186
+ ```
187
+
188
+ <br/>
189
+
190
+ ### Advanced Usage
191
+ #### Expanded order definition
192
+ Instead of just specifying the column sorting such as ```{ first_name: :asc }``` in the :order param, one can use the expanded order config in `Hash` format for more sorting options:
193
+
194
+ | Column Configuration | Description |
195
+ | ----------- | ----------- |
196
+ | `direction` | **Default: :asc**. `:asc` or `:desc` |
197
+ | `nullable` | **Default: true** if column is defined as nullable in its table, _false_ otherwise. <br/><br />Whether a null value is expected for this column in the result set. <br /><br/>**Note:** <br/>- Not setting this to _true_ when there are possible rows with NULL values for the specific column in the DB won't return those records. <br/> - In queries with table (outer)`JOIN`s, a column in the result could have a NULL value even if the column doesn't allow nulls in its table. So set `nullable` to _true_ for such cases.
198
+ | `nulls` | **Default:**<br/>- MySQL and SQLite: `:first` if `direction` is `:asc`, otherwise `:last`<br/>- PostgreSQL: `:last` if `direction` is `:asc`, otherwise `:first`<br/><br/>Tells whether rows with NULL column values comes before/after the records with non-null values. Applicable only if column is `nullable`. |
199
+ | `distinct` | **Default: true** if the column is the primary key of its table, _false_ otherwise.<br/><br /> Tells whether rows in the result are expected to have unique values for this column. <br/><br />**Note:**<br/>- In queries with table `JOIN`s, multiple rows could have the same column value even if the column has a unique index in its table. So set `distinct` to false for such cases. |
200
+ | `model` | **Default:**<br/> - the model of the base AR relation passed to `Rotulus::Page.new(<ar_relation>)` if column name has no prefix(e.g. `first_name`) and the AR relation model has a column matching the column name.<br/>- the model of the base AR relation passed to `Rotulus::Page.new(<ar_relation>)` if column name has a prefix(e.g. `users.first_name`) and thre prefix matches the AR relation's table name and the table has a column matching the column name. <br/><br/>Model where this column belongs. This allows the gem to infer the nullability and uniqueness from the column definition in its table instead of manually setting the `nullable` or `distinct` options and to also automatically prefix the column name with the table name. <br/>|
201
+
202
+
203
+ ##### Example:
204
+
205
+ ```ruby
206
+ order = {
207
+ first_name: :asc,
208
+ last_name: {
209
+ direction: :desc,
210
+ nullable: true,
211
+ nulls: :last
212
+ },
213
+ email: {
214
+ distinct: true
215
+ }
216
+ }
217
+ page = Rotulus::Page.new(users, order: order, limit: 3)
218
+
219
+ ```
220
+ <br/>
221
+
222
+ #### Queries with `JOIN`ed tables
223
+ ##### Example:
224
+
225
+ Suppose the requirement is to:<br/>
226
+ 1. Get all `Item` records.<br/>
227
+ 2. If an `Item` record has associated `OrderItem` records, get the order ids.<br/>
228
+ 3. `Item` records with `OrderItem`s should come first.
229
+ 4. `Item` records with `OrderItem`s should be sorted by `item_count` in descending order. <br/>
230
+ 5. If multiple rows have the same `item_count` value, sort them by item name in ascending order. <br/>
231
+ 6. If multiple rows have the same `item_count` value and the same `name`, sort them by `OrderItem` id. <br/>
232
+ 7. Sort `Item` records with no `OrderItem`, based on the item name in ascending order (tie-breaker). <br/>
233
+ 8. Sort `Item` records with no `OrderItem` and having the same name by the item id (also tie-breaker).
234
+
235
+ ##### Our solution would be:
236
+
237
+ ```ruby
238
+ items = Item.all # Requirement 1
239
+ .joins("LEFT JOIN order_items oi ON oi.item_id = items.id") # Requirement 2
240
+ .select('oi.order_id', 'items.*') # Requirement 2
241
+
242
+ order_by = {
243
+ 'oi.item_count' => {
244
+ direction: :desc, # Requirement 4
245
+ nulls: :last, # Requirement 3
246
+ nullable: true, # Requirement 1
247
+ model: OrderItem
248
+ },
249
+ name: :asc, # Requirement 5, 7
250
+ 'oi.id' => {
251
+ direction: :asc, # Requirement 6
252
+ distinct: true, # Requirement 6
253
+ nullable: true, # Requirement 1
254
+ model: OrderItem
255
+ },
256
+ id: :asc # Requirement 8
257
+ }
258
+ page = Rotulus::Page.new(items, order: order_by, limit: 2)
259
+
260
+ ```
261
+
262
+ Some notes for the example above: <br/>
263
+ 1. `oi.id` is needed to uniquely identify and serve as the tie-breaker for `Item`s that have `OrderItem`s having the same item_count and name. The combination of `oi.item_count`, `items.name`, and `oi.id` makes those record unique in the dataset. <br/>
264
+ 2. `id` is translated to `items.id` and is needed to uniquely identify and serve as the tie-breaker for `Item`s that have NO `OrderItem`s. The combination of `oi.item_count`(NULL), `items.name`, `oi.id`(NULL), and `items.id` makes those record unique in the dataset. Although, this can be removed in the configuration above as the `Item` table's primary key will be automatically added as the last `ORDER BY` column if it isn't included yet.<br/>
265
+ 3. Explicitly setting the `model: OrderItem` in joined table columns is required for now.
266
+
267
+ An alternate solution that would also avoid N+1 if the `OrderItem` instances are to be accessed:
268
+
269
+ ```ruby
270
+ items = Item.all # Requirement 1
271
+ .eager_load(:order_items) # Requirement 2
272
+
273
+ order_by = {
274
+ item_count: {
275
+ direction: :desc, # Requirement 4
276
+ nulls: :last, # Requirement 3
277
+ nullable: true, # Requirement 1
278
+ model: OrderItem
279
+ },
280
+ name: :asc, # Requirement 5, 7
281
+ 'order_items.id' => {
282
+ direction: :asc, # Requirement 6
283
+ distinct: true, # Requirement 6
284
+ nullable: true, # Requirement 1
285
+ model: OrderItem
286
+ }
287
+ }
288
+ page = Rotulus::Page.new(items, order: order_by, limit: 2)
289
+
290
+ ```
291
+
292
+ <br/>
293
+
294
+ ### Errors
295
+
296
+ | Class | Description |
297
+ | ----------- | ----------- |
298
+ | `Rotulus::InvalidCursor` | Cursor token received is invalid e.g., unrecognized token, token data has been tampered/updated, base ActiveRecord relation filter/sorting/limit is no longer consistent to the token/ |
299
+ | `Rotulus::Expired` | Cursor token received has expired based on the configured `token_expires_in` |
300
+ | `Rotulus::InvalidLimit` | Limit set to Rotulus::Page is not valid. e.g., exceeds the configured limit. see `config.page_max_limit` |
301
+ | `Rotulus::CursorError` | Generic error for cursor related validations |
302
+ | `Rotulus:: InvalidColumnError` | Column provided in the :order param is can't be recognized. |
303
+ | `Rotulus::ConfigurationError` | Generic error for missing/invalid configurations. |
304
+
305
+ ## How it works
306
+ Cursor-based pagination uses a reference point/record to fetch the previous or next set of records. This gem takes care of the SQL query and cursor generation needed for the pagination. To ensure that the pagination results are stable, it requires that:
307
+
308
+ * Records are sorted (`ORDER BY`).
309
+ * In case multiple records with the same column value(s) exists in the result, a unique column is needed as tie-breaker. Usually, the table PK suffices for this but for complex queries(e.g. with table joins and with nullable columns, etc.), combining and using multiple columns that would uniquely identify the row in the result is needed.
310
+ * Columns used in `ORDER BY` would need to be indexed as they will be used in filtering.
311
+
312
+
313
+ #### Sample SQL generated snippets
314
+
315
+ ##### Example 1: With order by `id` only
316
+ ###### Ruby
317
+ ```ruby
318
+ page = Rotulus::Page.new(User.all, limit: 3)
319
+ ```
320
+
321
+ ###### SQL:
322
+ ```sql
323
+ WHERE
324
+ users.id > ?
325
+ ORDER BY
326
+ users.id asc LIMIT 3
327
+ ```
328
+
329
+ ##### Example 2: With non-distinct and not nullable column `first_name`
330
+ ###### Ruby
331
+ ```ruby
332
+ page = Rotulus::Page.new(User.all, order: { first_name: :asc }, limit: 3)
333
+ ```
334
+
335
+ ###### SQL:
336
+ ```sql
337
+ WHERE
338
+ users.first_name >= ? AND
339
+ (users.first_name > ? OR
340
+ (users.first_name = ? AND
341
+ users.id > ?))
342
+ ORDER BY
343
+ users.first_name asc,
344
+ users.id asc LIMIT 3
345
+ ```
346
+
347
+ ##### Example 3: With non-distinct and nullable(nulls last) column `last_name`
348
+ ###### Ruby
349
+ ```ruby
350
+ page = Rotulus::Page.new(User.all, order: { first_name: { direction: :asc, nulls: :last }}, limit: 3)
351
+ ```
352
+
353
+ ###### SQL:
354
+ ```sql
355
+ -- if first_name value of the last record on current page is not null:
356
+ WHERE ((users.last_name >= ? OR users.last_name IS NULL) AND
357
+ ((users.last_name > ? OR users.last_name IS NULL)
358
+ OR (users.last_name = ? AND users.id > ?)))
359
+ ORDER BY users.last_name asc nulls last, users.id asc LIMIT 3
360
+
361
+ -- if first_name value of the last record on current page is null:
362
+ WHERE users.last_name IS NULL AND users.id > ?
363
+ ORDER BY users.last_name asc nulls last, users.id asc LIMIT 3
364
+ ```
365
+
366
+
367
+ ### Cursor
368
+ To navigate between pages, a cursor is used. The cursor token is a Base64 encoded string containing the data on how to filter the next/previous page's records. A decoded cursor to access the next page would look like:
369
+
370
+ #### Decoded Cursor
371
+
372
+ ```json
373
+ {
374
+ "f": {"users.first_name": "Jane", "users.id": 2},
375
+ "d": "next",
376
+ "s": "251177d65873aa37057dba548ecba82f",
377
+ "c": 1672502400
378
+ }
379
+ ```
380
+ 1. `f` - contains the record values from the last record of the current page. Only the columns included in the `ORDER BY` are included. Note also that the unique column `users.id` is included as a tie-breaker.
381
+ 2. `d` - the pagination direction. `next` or `prev` set of records from the reference values in "f".
382
+ 3. `s` - the cursor state needed for integrity check so we can detect whether the base ActiveRecord relation filter/sorting is no longer consistent(e.g. API request params changed) with the cursor token. Additionally, to restrict clients/third-parties from generating their own (unsafe)tokens or to tamper the data of an existing token. The gem requires a secret key configured for this through the `ROTULUS_SECRET` environment variable or the `config.secret` configuration. see [Configuration](#configuration) section.
383
+ 4. `c` - the time when this cursor was generated.
384
+
385
+ A condition generated from the cursor above would look like:
386
+
387
+ ```sql
388
+ WHERE users.first_name >= 'Jane' AND (
389
+ users.first_name > 'Jane' OR (
390
+ users.first_name = 'Jane' AND (users.id > 2)
391
+ )
392
+ ) LIMIT N
393
+ ```
394
+
395
+ #### Custom Token Format
396
+ By default, the cursor is encoded as a Base64 token. To customize how the cursor is encoded and decoded, you may just create a subclass of `Rotulus::Cursor` with `.decode` and `.encode` methods implemented.
397
+
398
+ ##### Example:
399
+ The implementation below would generate tokens in UUID format where the actual cursor data is stored in memory:
400
+
401
+ ```ruby
402
+ class MyCustomCursor < Rotulus::Cursor
403
+ def self.decode(token)
404
+ data = storage[token]
405
+ return data if data.present?
406
+
407
+ raise Rotulus::InvalidCursor
408
+ end
409
+
410
+ def self.encode(data)
411
+ storage_key = SecureRandom.uuid
412
+
413
+ storage[storage_key] = data
414
+ storage_key
415
+ end
416
+
417
+ def self.storage
418
+ @storage ||= {}
419
+ end
420
+ end
421
+ ```
422
+
423
+ ###### config/initializers/rotulus.rb
424
+ ```ruby
425
+ Rotulus.configure do |config|
426
+ ...
427
+ config.cursor_class = MyCustomCursor
428
+ end
429
+ ```
430
+ <br/>
431
+
432
+ ### Limitations
433
+ 1. Custom SQL in `ORDER BY` expression other than sorting by table column values aren't supported to leverage the index usage.
434
+ 2. `ORDER BY` column names with characters other than alphanumeric and underscores are not supported.
435
+
436
+ ### Considerations
437
+ 1. Although adding indexes improves DB read performance, it can impact write performance. Only expose/whitelist the columns that are really needed in sorting.
438
+ 2. Depending on your use case, a disadvantage is that cursor-based pagination does not allow jumping to a specific page (no page numbers).
439
+
440
+
441
+ ## Development
442
+
443
+ 1. If testing/developing for MySQL or PG, create the database first:<br/>
444
+
445
+ ###### MySQL
446
+ ```sh
447
+ mysql> CREATE DATABASE rotulus;
448
+ ```
449
+
450
+ ###### PostgreSQL
451
+ ```sh
452
+ $ createdb rotulus
453
+ ```
454
+
455
+ 2. After checking out the repo, run `bin/setup` to install dependencies.
456
+ 3. Run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. Use the environment variables below to target the database<br/><br/>
457
+
458
+ By default, SQLite and the latest stable Rails version are used in tests and console. Refer to the environment variables below to change this:
459
+
460
+ | Environment Variable | Values | Example |
461
+ | ----------- | ----------- |----------- |
462
+ | `DB_ADAPTER` | **Default: :sqlite**. `sqlite`,`mysql2`, or `postgresql` | ```DB_ADAPTER=postgresql bundle exec rspec```<br/><br/> ```DB_ADAPTER=postgresql ./bin/console``` |
463
+ | `RAILS_VERSION` | **Default: 7-0-stable** <br/><br/> `4-2-stable`,`5-0-stable`,`5-1-stable`,<br/>`5-2-stable`,`6-0-stable`,`6-1-stable`,<br/>`7-0-stable` |```RAILS_VERSION=5-2-stable ./bin/setup```<br/><br/>```RAILS_VERSION=5-2-stable bundle exec rspec```<br/><br/> ```RAILS_VERSION=5-2-stable ./bin/console```|
464
+
465
+
466
+ <br/><br/>
467
+ To install this gem onto your local machine, run `bundle exec rake install`.
468
+
469
+ ## Contributing
470
+
471
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jsonb-uy/rotulus.
472
+
473
+ ## License
474
+
475
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).