royal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +70 -0
- data/.gitignore +9 -0
- data/.rubocop.yml +53 -0
- data/.vscode/settings.json +12 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +92 -0
- data/README.md +252 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/rspec +29 -0
- data/bin/rubocop +29 -0
- data/bin/setup +8 -0
- data/lib/generators/royal/install_generator.rb +15 -0
- data/lib/generators/royal/migration_generator.rb +28 -0
- data/lib/generators/royal/templates/create_point_balances.rb.erb +17 -0
- data/lib/generators/royal/templates/royal.rb +19 -0
- data/lib/royal/config.rb +35 -0
- data/lib/royal/error.rb +5 -0
- data/lib/royal/insufficient_points_error.rb +31 -0
- data/lib/royal/locking/advisory.rb +32 -0
- data/lib/royal/locking/optimistic.rb +45 -0
- data/lib/royal/locking/pessimistic.rb +17 -0
- data/lib/royal/locking.rb +20 -0
- data/lib/royal/point_balance.rb +56 -0
- data/lib/royal/points.rb +41 -0
- data/lib/royal/sequence_error.rb +5 -0
- data/lib/royal/transaction.rb +52 -0
- data/lib/royal/version.rb +5 -0
- data/lib/royal.rb +28 -0
- data/royal.gemspec +34 -0
- metadata +96 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 1ae44a417ca2d8441ecd326e7d93fb50df992802a3857bff8b1e71e487b43600
|
4
|
+
data.tar.gz: 352ce9d947cfbe551e93dbce55d6491d75d7948b409d6d4f340866c2f41e4513
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: ae6451b26677cea399826b945967c251b2440a7bf9231050bff5d26869d86b4a604e5cf39c63cc59a23378ae148d018a7c0e56ed0ba07ec3d8110a4437ff4dac
|
7
|
+
data.tar.gz: 5578bcba479a0ab03cb11831dbdc18b28e1253c020224a382e293e6e31adba2c98a8f52b530d51f857c18c6a04d521f9512e642b48f1353754da8358fd591af3
|
@@ -0,0 +1,70 @@
|
|
1
|
+
name: Ruby
|
2
|
+
|
3
|
+
on: [push,pull_request]
|
4
|
+
|
5
|
+
jobs:
|
6
|
+
rubocop:
|
7
|
+
name: Rubocop
|
8
|
+
runs-on: ubuntu-latest
|
9
|
+
steps:
|
10
|
+
- uses: actions/checkout@v2
|
11
|
+
- name: Set up Ruby
|
12
|
+
uses: ruby/setup-ruby@v1
|
13
|
+
with:
|
14
|
+
ruby-version: 3.0
|
15
|
+
bundler-cache: true
|
16
|
+
- name: Run Rubocop
|
17
|
+
run: bin/rubocop
|
18
|
+
|
19
|
+
rspec-sqlite:
|
20
|
+
name: RSpec (SQLite3)
|
21
|
+
runs-on: ubuntu-latest
|
22
|
+
strategy:
|
23
|
+
matrix:
|
24
|
+
ruby-version: [2.6, 2.7, 3.0, 3.1]
|
25
|
+
locking-mode: [optimistic, pessimistic]
|
26
|
+
steps:
|
27
|
+
- uses: actions/checkout@v2
|
28
|
+
- name: Set up Ruby ${{ matrix.ruby-version }}
|
29
|
+
uses: ruby/setup-ruby@v1
|
30
|
+
with:
|
31
|
+
ruby-version: ${{ matrix.ruby-version }}
|
32
|
+
bundler-cache: true
|
33
|
+
- name: Run RSpec (${{ matrix.locking-mode }} locking)
|
34
|
+
env:
|
35
|
+
TEST_LOCKING_MODE: ${{ matrix.locking-mode }}
|
36
|
+
TEST_DATABASE_ADAPTER: sqlite3
|
37
|
+
run: bin/rspec
|
38
|
+
|
39
|
+
rspec-postgresql:
|
40
|
+
name: RSpec (PostgreSQL)
|
41
|
+
runs-on: ubuntu-latest
|
42
|
+
strategy:
|
43
|
+
matrix:
|
44
|
+
ruby-version: [2.6, 2.7, 3.0, 3.1]
|
45
|
+
locking-mode: [optimistic, pessimistic, advisory]
|
46
|
+
services:
|
47
|
+
postgres:
|
48
|
+
image: postgres:13
|
49
|
+
ports:
|
50
|
+
- 5432:5432
|
51
|
+
env:
|
52
|
+
POSTGRES_USER: postgres
|
53
|
+
POSTGRES_PASSWORD: postgres
|
54
|
+
POSTGRES_DB: royal_test
|
55
|
+
options: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
|
56
|
+
steps:
|
57
|
+
- uses: actions/checkout@v2
|
58
|
+
- name: Set up Ruby ${{ matrix.ruby-version }}
|
59
|
+
uses: ruby/setup-ruby@v1
|
60
|
+
with:
|
61
|
+
ruby-version: ${{ matrix.ruby-version }}
|
62
|
+
bundler-cache: true
|
63
|
+
- name: Run RSpec (${{ matrix.locking-mode }} locking)
|
64
|
+
env:
|
65
|
+
PGHOST: localhost
|
66
|
+
PGUSER: postgres
|
67
|
+
PGPASSWORD: postgres
|
68
|
+
TEST_LOCKING_MODE: ${{ matrix.locking-mode }}
|
69
|
+
TEST_DATABASE_ADAPTER: postgresql
|
70
|
+
run: bin/rspec
|
data/.gitignore
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
require:
|
2
|
+
- rubocop-performance
|
3
|
+
- rubocop-rails
|
4
|
+
- rubocop-rspec
|
5
|
+
|
6
|
+
AllCops:
|
7
|
+
NewCops: enable
|
8
|
+
SuggestExtensions: false
|
9
|
+
TargetRubyVersion: 2.6
|
10
|
+
Exclude:
|
11
|
+
- bin/*
|
12
|
+
- vendor/**/*
|
13
|
+
|
14
|
+
Layout/AccessModifierIndentation:
|
15
|
+
EnforcedStyle: outdent
|
16
|
+
|
17
|
+
Layout/LineLength:
|
18
|
+
Max: 120
|
19
|
+
|
20
|
+
Lint/AmbiguousBlockAssociation:
|
21
|
+
Exclude:
|
22
|
+
- spec/**/*.rb
|
23
|
+
|
24
|
+
Metrics/BlockLength:
|
25
|
+
Exclude:
|
26
|
+
- spec/**/*.rb
|
27
|
+
|
28
|
+
Naming/RescuedExceptionsVariableName:
|
29
|
+
PreferredName: error
|
30
|
+
|
31
|
+
Rails/ApplicationRecord:
|
32
|
+
Enabled: false
|
33
|
+
|
34
|
+
RSpec/ExpectChange:
|
35
|
+
EnforcedStyle: block
|
36
|
+
|
37
|
+
RSpec/HookArgument:
|
38
|
+
EnforcedStyle: each
|
39
|
+
|
40
|
+
Style/Documentation:
|
41
|
+
Enabled: false
|
42
|
+
|
43
|
+
Style/RescueModifier:
|
44
|
+
Exclude:
|
45
|
+
- spec/**/*.rb
|
46
|
+
|
47
|
+
Style/StringLiterals:
|
48
|
+
Enabled: true
|
49
|
+
EnforcedStyle: single_quotes
|
50
|
+
|
51
|
+
Style/StringLiteralsInInterpolation:
|
52
|
+
Enabled: true
|
53
|
+
EnforcedStyle: single_quotes
|
data/Gemfile
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
source 'https://rubygems.org'
|
4
|
+
|
5
|
+
# Specify your gem's dependencies in royal.gemspec
|
6
|
+
gemspec
|
7
|
+
|
8
|
+
gem 'rake', '~> 13.0'
|
9
|
+
gem 'rspec', '~> 3.0'
|
10
|
+
gem 'rubocop', '~> 1.25'
|
11
|
+
gem 'rubocop-performance', '~> 1.12'
|
12
|
+
gem 'rubocop-rails', '~> 2.13'
|
13
|
+
gem 'rubocop-rspec', '~> 2.8'
|
14
|
+
|
15
|
+
gem 'pg', '~> 1.3'
|
16
|
+
gem 'sqlite3', '~> 1.4'
|
data/Gemfile.lock
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
royal (0.1.0)
|
5
|
+
activerecord (>= 5, < 8)
|
6
|
+
|
7
|
+
GEM
|
8
|
+
remote: https://rubygems.org/
|
9
|
+
specs:
|
10
|
+
activemodel (6.1.4.1)
|
11
|
+
activesupport (= 6.1.4.1)
|
12
|
+
activerecord (6.1.4.1)
|
13
|
+
activemodel (= 6.1.4.1)
|
14
|
+
activesupport (= 6.1.4.1)
|
15
|
+
activesupport (6.1.4.1)
|
16
|
+
concurrent-ruby (~> 1.0, >= 1.0.2)
|
17
|
+
i18n (>= 1.6, < 2)
|
18
|
+
minitest (>= 5.1)
|
19
|
+
tzinfo (~> 2.0)
|
20
|
+
zeitwerk (~> 2.3)
|
21
|
+
ast (2.4.2)
|
22
|
+
concurrent-ruby (1.1.9)
|
23
|
+
diff-lcs (1.4.4)
|
24
|
+
i18n (1.8.11)
|
25
|
+
concurrent-ruby (~> 1.0)
|
26
|
+
minitest (5.15.0)
|
27
|
+
parallel (1.21.0)
|
28
|
+
parser (3.1.0.0)
|
29
|
+
ast (~> 2.4.1)
|
30
|
+
pg (1.3.1)
|
31
|
+
rack (2.2.3)
|
32
|
+
rainbow (3.1.1)
|
33
|
+
rake (13.0.6)
|
34
|
+
regexp_parser (2.2.0)
|
35
|
+
rexml (3.2.5)
|
36
|
+
rspec (3.10.0)
|
37
|
+
rspec-core (~> 3.10.0)
|
38
|
+
rspec-expectations (~> 3.10.0)
|
39
|
+
rspec-mocks (~> 3.10.0)
|
40
|
+
rspec-core (3.10.1)
|
41
|
+
rspec-support (~> 3.10.0)
|
42
|
+
rspec-expectations (3.10.1)
|
43
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
44
|
+
rspec-support (~> 3.10.0)
|
45
|
+
rspec-mocks (3.10.2)
|
46
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
47
|
+
rspec-support (~> 3.10.0)
|
48
|
+
rspec-support (3.10.3)
|
49
|
+
rubocop (1.25.0)
|
50
|
+
parallel (~> 1.10)
|
51
|
+
parser (>= 3.1.0.0)
|
52
|
+
rainbow (>= 2.2.2, < 4.0)
|
53
|
+
regexp_parser (>= 1.8, < 3.0)
|
54
|
+
rexml
|
55
|
+
rubocop-ast (>= 1.15.1, < 2.0)
|
56
|
+
ruby-progressbar (~> 1.7)
|
57
|
+
unicode-display_width (>= 1.4.0, < 3.0)
|
58
|
+
rubocop-ast (1.15.1)
|
59
|
+
parser (>= 3.0.1.1)
|
60
|
+
rubocop-performance (1.12.0)
|
61
|
+
rubocop (>= 1.7.0, < 2.0)
|
62
|
+
rubocop-ast (>= 0.4.0)
|
63
|
+
rubocop-rails (2.13.2)
|
64
|
+
activesupport (>= 4.2.0)
|
65
|
+
rack (>= 1.1)
|
66
|
+
rubocop (>= 1.7.0, < 2.0)
|
67
|
+
rubocop-rspec (2.8.0)
|
68
|
+
rubocop (~> 1.19)
|
69
|
+
ruby-progressbar (1.11.0)
|
70
|
+
sqlite3 (1.4.2)
|
71
|
+
tzinfo (2.0.4)
|
72
|
+
concurrent-ruby (~> 1.0)
|
73
|
+
unicode-display_width (2.1.0)
|
74
|
+
zeitwerk (2.5.3)
|
75
|
+
|
76
|
+
PLATFORMS
|
77
|
+
x86_64-darwin-19
|
78
|
+
x86_64-linux
|
79
|
+
|
80
|
+
DEPENDENCIES
|
81
|
+
pg (~> 1.3)
|
82
|
+
rake (~> 13.0)
|
83
|
+
royal!
|
84
|
+
rspec (~> 3.0)
|
85
|
+
rubocop (~> 1.25)
|
86
|
+
rubocop-performance (~> 1.12)
|
87
|
+
rubocop-rails (~> 2.13)
|
88
|
+
rubocop-rspec (~> 2.8)
|
89
|
+
sqlite3 (~> 1.4)
|
90
|
+
|
91
|
+
BUNDLED WITH
|
92
|
+
2.2.22
|
data/README.md
ADDED
@@ -0,0 +1,252 @@
|
|
1
|
+
# Royal
|
2
|
+
|
3
|
+
Royal adds loyalty (and other types of) points to your ActiveRecord models.
|
4
|
+
It's backed by an internal ledger for balance tracking, provides a simple programmer interface, and is designed to handle concurrency and locking for you.
|
5
|
+
|
6
|
+
## Installation
|
7
|
+
|
8
|
+
Add this line to your application's Gemfile:
|
9
|
+
|
10
|
+
```ruby
|
11
|
+
gem 'royal'
|
12
|
+
```
|
13
|
+
|
14
|
+
And then execute:
|
15
|
+
|
16
|
+
$ bundle install
|
17
|
+
|
18
|
+
Or install it yourself as:
|
19
|
+
|
20
|
+
$ gem install royal
|
21
|
+
|
22
|
+
### Generating Configuration
|
23
|
+
|
24
|
+
Generate the configuration file with:
|
25
|
+
|
26
|
+
$ bin/rails g royal:install
|
27
|
+
|
28
|
+
This will place a configuration file at `config/initializers/royal.rb` in your Rails application directory.
|
29
|
+
|
30
|
+
### Generating Migrations
|
31
|
+
|
32
|
+
Generate the migration file with:
|
33
|
+
|
34
|
+
$ bin/rails g royal:migration
|
35
|
+
|
36
|
+
And then run the migration:
|
37
|
+
|
38
|
+
$ bin/rails db:migrate
|
39
|
+
|
40
|
+
## Usage
|
41
|
+
|
42
|
+
To add a points balance to any of your ActiveRecord models, include the `Royal::Points` module.
|
43
|
+
For the purposes of the examples below, we'll be using a hypothetical `User` model:
|
44
|
+
|
45
|
+
```ruby
|
46
|
+
class User < ApplicationRecord
|
47
|
+
include Royal::Points
|
48
|
+
|
49
|
+
# ...
|
50
|
+
end
|
51
|
+
```
|
52
|
+
|
53
|
+
Royal uses a separate ledger table to track points for your models, so it's not necessary to add any columns to existing tables.
|
54
|
+
|
55
|
+
### Viewing the Point Balance
|
56
|
+
|
57
|
+
To view the current balance of a record, use the `current_points` method:
|
58
|
+
|
59
|
+
```ruby
|
60
|
+
user = User.first
|
61
|
+
|
62
|
+
user.current_points # => 0
|
63
|
+
```
|
64
|
+
|
65
|
+
**NOTE:** The `current_points` method accesses the database each time it's called.
|
66
|
+
|
67
|
+
### Adding Loyalty Points
|
68
|
+
|
69
|
+
```ruby
|
70
|
+
user.add_points(100) # => 100
|
71
|
+
```
|
72
|
+
|
73
|
+
This method returns the new points balance after the operation.
|
74
|
+
|
75
|
+
### Spending Loyalty Points
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
user.subtract_points(75) # => 25
|
79
|
+
```
|
80
|
+
|
81
|
+
This method returns the new points balance after the operation.
|
82
|
+
|
83
|
+
If the current balance is less than the specified amount of points to subtract, a `Royal::InsufficientPointsError` is raised:
|
84
|
+
|
85
|
+
```ruby
|
86
|
+
user.subtract_points(200) # => raises #<Royal::InsufficientPointsError ...>
|
87
|
+
```
|
88
|
+
|
89
|
+
### Including a Reason or Note
|
90
|
+
|
91
|
+
It's possible to include a String of text when adding or subtracting points, which will be stored with the change:
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
user.add_points(50, reason: 'Birthday points!')
|
95
|
+
```
|
96
|
+
|
97
|
+
**NOTE:** By default, the reason can be at most 1000 characters long.
|
98
|
+
|
99
|
+
### Linking to Another Record
|
100
|
+
|
101
|
+
It's possible to link an additional record when adding or subtracting points.
|
102
|
+
Below is an example using a hypothetical `Reward` model:
|
103
|
+
|
104
|
+
```ruby
|
105
|
+
reward = Reward.find_by(name: 'Gift Card')
|
106
|
+
|
107
|
+
user.subtract_points(50, pointable: reward)
|
108
|
+
```
|
109
|
+
|
110
|
+
### Loyalty Point Balance History
|
111
|
+
|
112
|
+
Since points are stored in a ledger, it's possible to view and display the complete history of a points balance:
|
113
|
+
|
114
|
+
```erb
|
115
|
+
<table>
|
116
|
+
<tr>
|
117
|
+
<th>Operation</th>
|
118
|
+
<th>Balance</th>
|
119
|
+
<th>Reason</th>
|
120
|
+
</tr>
|
121
|
+
<% user.point_balances.order(:sequence).each do |point_balance| %>
|
122
|
+
<tr>
|
123
|
+
<td><%= point_balance.amount.positive? ? 'Added' : 'Spent' %> <%= point_balance.amount %></td>
|
124
|
+
<td><%= point_balance.balance %></td>
|
125
|
+
<td><%= point_balance.reason %></td>
|
126
|
+
</tr>
|
127
|
+
<% end %>
|
128
|
+
</table>
|
129
|
+
```
|
130
|
+
|
131
|
+
**NOTE:** For data integrity purposes, the `owner`, `amount`, `balance`, and `sequence` attributes on `Royal::PointBalance` records are as marked readonly once persisted to the database. Modifying the fields or removing existing balance records is not supported.
|
132
|
+
|
133
|
+
## Concurrency and Locking
|
134
|
+
|
135
|
+
Royal comes with built-in automatic locking around point balance changes.
|
136
|
+
Since each applicable behaves differently, it also includes a few alternative locking strategies.
|
137
|
+
|
138
|
+
Royal supports three possible locking modes:
|
139
|
+
* Optimistic locking (default)
|
140
|
+
* Pessimistic locking
|
141
|
+
* Advisory locking (PosgreSQL only)
|
142
|
+
|
143
|
+
Each mode has its own advantages and disadvantages, which are outlined in detail below.
|
144
|
+
Optimistic locking is used by default and should work well in most use cases and even in high-load applications.
|
145
|
+
In general, you shouldn't need to change this unless you start to experience `SequenceError`s.
|
146
|
+
|
147
|
+
### Optimistic Locking (Default)
|
148
|
+
|
149
|
+
This locking mode relies on a unique index specific to `owner` record such that: `(owner_id, owner_type, sequence)`.
|
150
|
+
A `sequence` column is increased by 1 for every write to the points balance ledger, so any conflicting writes to the table would produce a duplicate value for the `sequence` column and be rejected by the uniqueness constraint.
|
151
|
+
|
152
|
+
Whenever an update is rejected by this constraint, the operation is re-attempted with a new sequence value and updated balance calculation.
|
153
|
+
By default, an update can be re-attempted up to 10 times before a `Royal::SequenceError` is raised.
|
154
|
+
|
155
|
+
For most applications, updates should only be very rarely retried and generally resolve it an most one or two attempts.
|
156
|
+
However, if your application needs to perform frequent, parallel balance updates for a single record, this may become a concern.
|
157
|
+
(For example, an organization that receives points from all of its employees, or some other group that receives points from all of its members.)
|
158
|
+
|
159
|
+
**NOTE:** You shouldn't usually need to change locking strategies unless you see frequent `SequenceError`s or otherwise experience poor performance.
|
160
|
+
Even in cases where your application does perform a large number of parallel balance updates for the same record, you may see better results changing how your application applies these updates. (e.g. Using a queue to asynchronously apply updates in sequence from a separate task instead of in parallel.)
|
161
|
+
|
162
|
+
### Pessimistic Locking
|
163
|
+
|
164
|
+
This locking mode relies on row-level exclusive locks acquired on the `owner` record for the points balance.
|
165
|
+
|
166
|
+
In general, this strategy is the slowest as it will be blocked by any other locks on the record, and will block all other operations on the record for the duration of the update.
|
167
|
+
Unless you are experiencing issues with the optimistic locking strategy and are unable to leverage advisory locks for your applicable, use of the strategy is not advised.
|
168
|
+
|
169
|
+
### Advisory Locking (PostgreSQL Only)
|
170
|
+
|
171
|
+
This locking mode uses an exclusive advisory lock on a key that's unique to each `owner` record via `pg_advisory_xact_lock`.
|
172
|
+
See more here: https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
|
173
|
+
|
174
|
+
Using advisory locking provides similar advantages to pessimistic locking without having to acquire a row-level lock on the owner record.
|
175
|
+
Locking is done using the 2-argument version of `pg_advisory_xact_lock` using values computed based from their `owner` record's ID and polymorphic name.
|
176
|
+
|
177
|
+
e.g. A lock for a `User` record with ID 1 would acquire a lock as so:
|
178
|
+
```sql
|
179
|
+
SELECT pg_advisory_xact_lock(hashtext('User'), 1)
|
180
|
+
```
|
181
|
+
|
182
|
+
When using advisory locks, there are situations where lock-keys could potentially experience collisions.
|
183
|
+
Since advisory lock keys are 64-bits wide, it is unlikely to happen for most applications, but is still worth considering.
|
184
|
+
|
185
|
+
Two possible situations where these keys would collide are:
|
186
|
+
1. Your applicable already heavily relies on advisory locks. Note that the single 64-bit argument advisory lock uses a separate key space and does not overlap with the 2-argument key space.
|
187
|
+
2. Your models have 64-bit (or greater) IDs and the bottom 32-bits are the same. Since the 2 key segments are restricted to 32-bits, this locking system only uses the bottom 32-bits of the `owner` record's ID as part of the lock key.
|
188
|
+
|
189
|
+
In most cases, lock-keys colliding would just result in two or more unrelated records waiting before applying a point balance update.
|
190
|
+
Unless your applicable has a very large number of `owner` records with overlapping keys that are frequently updated together, this shouldn't create any cause for concern.
|
191
|
+
|
192
|
+
However, if your application falls into situation #1 described above and also holds advisory locks for extended periods of time, balance updates may stall for much longer than expected as they'll be blocked until the lock is released.
|
193
|
+
|
194
|
+
### Transactions and Deadlock Prevention
|
195
|
+
|
196
|
+
When adding or subtracting points from a single record, Royal automatically handles locking and usually poses no risk of deadlock (as locks are only acquired for a single record).
|
197
|
+
However, when changing the points balances of multiple records together, care must be taken to avoid conditions that would result in a stall or deadlock.
|
198
|
+
|
199
|
+
To make this easier, Royal providers a `Transaction` object which can be used to define and safely execute a sequence on points balance changes on a number of objects all at once.
|
200
|
+
In the example below, we transfer 100 points from one user to another:
|
201
|
+
|
202
|
+
```ruby
|
203
|
+
transaction = Royal::Transaction.new
|
204
|
+
|
205
|
+
user1 = User.find(1)
|
206
|
+
user2 = User.find(2)
|
207
|
+
|
208
|
+
transaction.add_points(user1, 100)
|
209
|
+
transaction.subtract_points(user2, 100)
|
210
|
+
|
211
|
+
transaction.call
|
212
|
+
```
|
213
|
+
|
214
|
+
Transactions have no limit on how many records can be involved or how many operations can be performed on each record. For example, it's possible to have a single transaction both add and subtract points from the same `User` record.
|
215
|
+
|
216
|
+
Royal will re-order the addition/subtraction operations in such a way that:
|
217
|
+
* Records will be modified in a deterministic order, to avoid deadlocks
|
218
|
+
* If a single record has both addition and subtraction operations, addition will be performed first
|
219
|
+
|
220
|
+
Just like when adding or subtracting points from a single record, the transaction interface allows you to specify a `reason` or associated `pointable` record.
|
221
|
+
|
222
|
+
```ruby
|
223
|
+
trade = Trade.find(...) # Some hypothetical trade record.
|
224
|
+
|
225
|
+
transaction.add_points(user1, 100, reason: 'Trade from User 2', pointable: trade)
|
226
|
+
transaction.subtract_points(user2, 100, reason: 'Trade to User 1', pointable: trade)
|
227
|
+
```
|
228
|
+
|
229
|
+
The transaction `add_points` and `subtract_points` methods also return the transaction object, so it's also possible to chain these method calls together if desired:
|
230
|
+
|
231
|
+
```ruby
|
232
|
+
transaction
|
233
|
+
.add_points(user1, 100)
|
234
|
+
.subtract_points(user2, 100)
|
235
|
+
.call
|
236
|
+
```
|
237
|
+
|
238
|
+
## Future Functionality and Wishlist
|
239
|
+
|
240
|
+
* [ ] Support multiple types of points per model
|
241
|
+
* [ ] Add RSpec matchers to assist in testing
|
242
|
+
* [ ] Improve documentation and examples
|
243
|
+
|
244
|
+
## Development
|
245
|
+
|
246
|
+
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
247
|
+
|
248
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
249
|
+
|
250
|
+
## Contributing
|
251
|
+
|
252
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/mintyfresh/royal.
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'royal'
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require 'irb'
|
15
|
+
IRB.start(__FILE__)
|
data/bin/rspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rspec' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require "pathname"
|
12
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
13
|
+
Pathname.new(__FILE__).realpath)
|
14
|
+
|
15
|
+
bundle_binstub = File.expand_path("../bundle", __FILE__)
|
16
|
+
|
17
|
+
if File.file?(bundle_binstub)
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
19
|
+
load(bundle_binstub)
|
20
|
+
else
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
require "rubygems"
|
27
|
+
require "bundler/setup"
|
28
|
+
|
29
|
+
load Gem.bin_path("rspec-core", "rspec")
|
data/bin/rubocop
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rubocop' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require 'pathname'
|
12
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
|
13
|
+
Pathname.new(__FILE__).realpath)
|
14
|
+
|
15
|
+
bundle_binstub = File.expand_path('../bundle', __FILE__)
|
16
|
+
|
17
|
+
if File.file?(bundle_binstub)
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
19
|
+
load(bundle_binstub)
|
20
|
+
else
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
require 'rubygems'
|
27
|
+
require 'bundler/setup'
|
28
|
+
|
29
|
+
load Gem.bin_path('rubocop', 'rubocop')
|
data/bin/setup
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators'
|
4
|
+
|
5
|
+
module Royal
|
6
|
+
module Generators
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
8
|
+
source_root File.expand_path('templates', __dir__)
|
9
|
+
|
10
|
+
def create_initializer
|
11
|
+
template 'royal.rb', 'config/initializers/royal.rb'
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators'
|
4
|
+
require 'rails/generators/active_record'
|
5
|
+
|
6
|
+
module Royal
|
7
|
+
module Generators
|
8
|
+
class MigrationGenerator < Rails::Generators::Base
|
9
|
+
include Rails::Generators::Migration
|
10
|
+
|
11
|
+
source_root File.expand_path('templates', __dir__)
|
12
|
+
|
13
|
+
def self.next_migration_number(dir)
|
14
|
+
::ActiveRecord::Generators::Base.next_migration_number(dir)
|
15
|
+
end
|
16
|
+
|
17
|
+
def create_point_balances_migration
|
18
|
+
migration_template 'create_point_balances.rb.erb', 'db/migrate/create_point_balances.rb'
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def migration_version
|
24
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" if Rails.version >= '5.0.0'
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreatePointBalances < ActiveRecord::Migration<%= migration_version %>
|
4
|
+
def change
|
5
|
+
create_table :point_balances do |t|
|
6
|
+
t.belongs_to :owner, polymorphic: true, null: false
|
7
|
+
t.belongs_to :pointable, polymorphic: true, null: true
|
8
|
+
t.string :reason, null: true
|
9
|
+
t.integer :amount, null: false
|
10
|
+
t.integer :balance, null: false
|
11
|
+
t.integer :sequence, null: false
|
12
|
+
t.timestamps
|
13
|
+
|
14
|
+
t.index %i[owner_id owner_type sequence], unique: true
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
Royal.configure do |config|
|
4
|
+
# Sets the locking for the points balance ledger.
|
5
|
+
#
|
6
|
+
# Available modes are:
|
7
|
+
# - :optimistic (default)
|
8
|
+
# - :pessimistic
|
9
|
+
# - :advisory (PostgreSQL only)
|
10
|
+
#
|
11
|
+
# Most applications should use the default locking mode.
|
12
|
+
# For further information about each of the supported modes, see the README file:
|
13
|
+
# https://github.com/mintyfresh/royal#concurrency-and-locking
|
14
|
+
#
|
15
|
+
# config.locking = :optimistic
|
16
|
+
|
17
|
+
# Sets the maximum number of retries when using the optimistic locking mode. (default 10)
|
18
|
+
# config.max_retries = 10
|
19
|
+
end
|
data/lib/royal/config.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Royal
|
4
|
+
class Config
|
5
|
+
DEFAULT_LOCKING = :optimistic
|
6
|
+
DEFAULT_MAX_RETRIES = 10
|
7
|
+
|
8
|
+
# The configured locking mechanism for the points balance ledger.
|
9
|
+
#
|
10
|
+
# @return [#call]
|
11
|
+
attr_reader :locking
|
12
|
+
# @return [Integer]
|
13
|
+
attr_reader :max_retries
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
self.locking = DEFAULT_LOCKING
|
17
|
+
self.max_retries = DEFAULT_MAX_RETRIES
|
18
|
+
end
|
19
|
+
|
20
|
+
# @param locator [Symbol]
|
21
|
+
# @return [void]
|
22
|
+
def locking=(locator)
|
23
|
+
@locking = Royal::Locking.resolve(locator)
|
24
|
+
end
|
25
|
+
|
26
|
+
# @param max_retries [Integer]
|
27
|
+
# @return [void]
|
28
|
+
def max_retries=(max_retries)
|
29
|
+
raise ArgumentError, 'Max retries must be an Integer' unless max_retries.is_a?(Integer)
|
30
|
+
raise ArgumentError, 'Max retries must be positive' unless max_retries.positive?
|
31
|
+
|
32
|
+
@max_retries = max_retries
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/royal/error.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Royal
|
4
|
+
class InsufficientPointsError < Error
|
5
|
+
# @return [ActiveRecord::Base]
|
6
|
+
attr_reader :owner
|
7
|
+
# @return [Integer]
|
8
|
+
attr_reader :amount
|
9
|
+
# @return [Integer]
|
10
|
+
attr_reader :balance
|
11
|
+
# @return [String, nil]
|
12
|
+
attr_reader :reason
|
13
|
+
# @return [ActiveRecord::Base, nil]
|
14
|
+
attr_reader :pointable
|
15
|
+
|
16
|
+
# @param owner [ActiveRecord::Base]
|
17
|
+
# @param amount [Integer]
|
18
|
+
# @param balance [Integer]
|
19
|
+
# @param reason [String, nil]
|
20
|
+
# @param pointable [ActiveRecord::Base, nil]
|
21
|
+
def initialize(owner, amount, balance, reason, pointable)
|
22
|
+
@owner = owner
|
23
|
+
@amount = amount
|
24
|
+
@balance = balance
|
25
|
+
@reason = reason
|
26
|
+
@pointable = pointable
|
27
|
+
|
28
|
+
super("Insufficient points: #{amount} (balance: #{balance})")
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Royal
|
4
|
+
module Locking
|
5
|
+
class Advisory
|
6
|
+
# Used to extract the lower 32-bits of owner IDs.
|
7
|
+
# The 2-argument version of `pg_advisory_xact_lock` accepts 32 bit integers,
|
8
|
+
# and this ensures we do not overflow that constraint.
|
9
|
+
ID_LOBITS_MASK = 0xFFFFFFFF
|
10
|
+
|
11
|
+
# @param owner [ActiveRecord::Base]
|
12
|
+
def call(owner)
|
13
|
+
PointBalance.transaction(requires_new: true) do
|
14
|
+
acquire_advisory_lock_on_owner(owner)
|
15
|
+
yield
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
# @param owner [ActiveRecord::Base]
|
22
|
+
# @return [void]
|
23
|
+
def acquire_advisory_lock_on_owner(owner)
|
24
|
+
sql = PointBalance.sanitize_sql_array([<<-SQL.squish, owner.class.polymorphic_name, owner.id & ID_LOBITS_MASK])
|
25
|
+
SELECT pg_advisory_xact_lock(hashtext(?), ?)
|
26
|
+
SQL
|
27
|
+
|
28
|
+
PointBalance.connection.query(sql)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Royal
|
4
|
+
module Locking
|
5
|
+
class Optimistic
|
6
|
+
# @param owner [Royal::PointBalance]
|
7
|
+
def call(_owner, &block)
|
8
|
+
result = nil
|
9
|
+
|
10
|
+
up_to_max_retries do
|
11
|
+
success, result = try_create_record(&block)
|
12
|
+
return result if success
|
13
|
+
end
|
14
|
+
|
15
|
+
# NOTE: Failed to insert record after maximum number of attempts.
|
16
|
+
# This could be caused by too much write contention for the same owner.
|
17
|
+
# One possible solution is to acquire a row-level lock on the owner record and retry.
|
18
|
+
# Other solutions like advisory locks or sequential processing queues may work better in some situations.
|
19
|
+
raise Royal::SequenceError, "Failed to update points: #{result.message}"
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def up_to_max_retries(&block)
|
25
|
+
Royal.config.max_retries.times(&block)
|
26
|
+
end
|
27
|
+
|
28
|
+
# @return [[Boolean, Object]]
|
29
|
+
def try_create_record
|
30
|
+
success = true
|
31
|
+
result = nil
|
32
|
+
|
33
|
+
PointBalance.transaction(requires_new: true) do
|
34
|
+
result = yield
|
35
|
+
rescue ActiveRecord::RecordNotUnique => error
|
36
|
+
success = false
|
37
|
+
result = error
|
38
|
+
raise ActiveRecord::Rollback
|
39
|
+
end
|
40
|
+
|
41
|
+
[success, result]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Royal
|
4
|
+
module Locking
|
5
|
+
class Pessimistic
|
6
|
+
# @param owner [ActiveRecord::Base]
|
7
|
+
def call(owner)
|
8
|
+
owner.transaction(requires_new: true) do
|
9
|
+
# NOTE: Avoid using `lock!` to prevent reloading the record from DB.
|
10
|
+
# We don't need any updated state, just exclusive use of the record.
|
11
|
+
owner.class.where(id: owner).lock(true).take!
|
12
|
+
yield
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Royal
|
4
|
+
module Locking
|
5
|
+
autoload :Advisory, 'royal/locking/advisory'
|
6
|
+
autoload :Optimistic, 'royal/locking/optimistic'
|
7
|
+
autoload :Pessimistic, 'royal/locking/pessimistic'
|
8
|
+
|
9
|
+
# @param locator [Symbol]
|
10
|
+
# @return [#call]
|
11
|
+
def self.resolve(locator)
|
12
|
+
case locator
|
13
|
+
when :advisory then Advisory.new
|
14
|
+
when :optimistic then Optimistic.new
|
15
|
+
when :pessimistic then Pessimistic.new
|
16
|
+
else raise ArgumentError, "Unsupported locking type: #{locator.inspect}"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record'
|
4
|
+
|
5
|
+
module Royal
|
6
|
+
class PointBalance < ActiveRecord::Base
|
7
|
+
attr_readonly :owner_id, :owner_type, :amount, :balance, :sequence
|
8
|
+
|
9
|
+
belongs_to :owner, polymorphic: true, optional: false
|
10
|
+
belongs_to :pointable, polymorphic: true, optional: true
|
11
|
+
|
12
|
+
validates :amount, numericality: { other_than: 0 }
|
13
|
+
validates :reason, length: { maximum: 1000 }
|
14
|
+
|
15
|
+
before_create do
|
16
|
+
previous_balance = self.class.latest_for_owner(owner)
|
17
|
+
|
18
|
+
self.sequence = (previous_balance&.sequence || 0) + 1
|
19
|
+
self.balance = (previous_balance&.balance || 0) + amount
|
20
|
+
end
|
21
|
+
|
22
|
+
after_create if: -> { balance.negative? } do
|
23
|
+
# Rollback the transaction _after_ the operation to ensure amount
|
24
|
+
# was applied against the most recent points balance.
|
25
|
+
raise InsufficientPointsError.new(owner, amount, original_balance, reason, pointable)
|
26
|
+
end
|
27
|
+
|
28
|
+
# @param owner [ActiveRecord::Base]
|
29
|
+
# @return [PointBalance, nil]
|
30
|
+
def self.latest_for_owner(owner)
|
31
|
+
PointBalance.where(owner: owner).order(:sequence).last
|
32
|
+
end
|
33
|
+
|
34
|
+
# @param owner [ActiveRecord::Base]
|
35
|
+
def self.with_lock_on_owner(owner, &block)
|
36
|
+
Royal.config.locking.call(owner, &block)
|
37
|
+
end
|
38
|
+
|
39
|
+
# @param owner [ActiveRecord::Base]
|
40
|
+
# @param amount [Integer]
|
41
|
+
# @param attributes [Hash] Additional attributes to set on the new record.
|
42
|
+
# @return [Integer] Returns the new points balance.
|
43
|
+
def self.apply_change_to_points(owner, amount, **attributes)
|
44
|
+
with_lock_on_owner(owner) do
|
45
|
+
create!(owner: owner, amount: amount, **attributes).balance
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Returns the balance before this operation.
|
50
|
+
#
|
51
|
+
# @return [Integer]
|
52
|
+
def original_balance
|
53
|
+
balance - amount
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
data/lib/royal/points.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/concern'
|
4
|
+
|
5
|
+
module Royal
|
6
|
+
module Points
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do
|
10
|
+
has_many :point_balances, as: :owner, inverse_of: :owner,
|
11
|
+
class_name: 'Royal::PointBalance', dependent: :delete_all
|
12
|
+
end
|
13
|
+
|
14
|
+
# Returns the current number of points in the record's balance.
|
15
|
+
#
|
16
|
+
# @return [Integer]
|
17
|
+
def current_points
|
18
|
+
point_balances.order(sequence: :desc).limit(1).pluck(:balance).first || 0
|
19
|
+
end
|
20
|
+
|
21
|
+
# Adds a number of points to the record's current points balance.
|
22
|
+
#
|
23
|
+
# @param amount [Integer] The number of points to add to the blance.
|
24
|
+
# @param reason [String, nil] An optional reason to store with the balance change.
|
25
|
+
# @param pointable [ActiveRecord::Base, nil] An optional record to associate to the balance change.
|
26
|
+
# @return [Integer] Returns the new points balance.
|
27
|
+
def add_points(amount, reason: nil, pointable: nil)
|
28
|
+
point_balances.apply_change_to_points(self, amount, reason: reason, pointable: pointable)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Subtracts a number of points to the record's current points balance.
|
32
|
+
#
|
33
|
+
# @param amount [Integer] The number of points to subtract from the balance.
|
34
|
+
# @param reason [String, nil] An optional reason to store with the balance change.
|
35
|
+
# @param pointable [ActiveRecord::Base, nil] An optional record to associate to the balance change.
|
36
|
+
# @return [Integer] Returns the new points balance.
|
37
|
+
def subtract_points(amount, reason: nil, pointable: nil)
|
38
|
+
point_balances.apply_change_to_points(self, -amount, reason: reason, pointable: pointable)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Royal
|
4
|
+
class Transaction
|
5
|
+
Operation = Struct.new(:owner, :amount, :reason, :pointable) do
|
6
|
+
# @return [void]
|
7
|
+
def perform
|
8
|
+
owner.add_points(amount, reason: reason, pointable: pointable)
|
9
|
+
end
|
10
|
+
|
11
|
+
# Used to ensure a deterministic order of operations in a transaction to avoid deadlocks.
|
12
|
+
#
|
13
|
+
# @return [Array]
|
14
|
+
def sorting_key
|
15
|
+
[owner.class.polymorphic_name, owner.id, -amount]
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize
|
20
|
+
@operations = []
|
21
|
+
end
|
22
|
+
|
23
|
+
# @param owner [ActiveRecord::Base]
|
24
|
+
# @param amount [Integer]
|
25
|
+
# @param reason [String, nil]
|
26
|
+
# @param pointable [ActiveRecord::Base, nil]
|
27
|
+
# @return [self]
|
28
|
+
def add_points(owner, amount, reason: nil, pointable: nil)
|
29
|
+
@operations << Operation.new(owner, amount, reason, pointable).freeze
|
30
|
+
|
31
|
+
self
|
32
|
+
end
|
33
|
+
|
34
|
+
# @param owner [ActiveRecord::Base]
|
35
|
+
# @param amount [Integer]
|
36
|
+
# @param reason [String, nil]
|
37
|
+
# @param pointable [ActiveRecord::Base, nil]
|
38
|
+
# @return [self]
|
39
|
+
def subtract_points(owner, amount, reason: nil, pointable: nil)
|
40
|
+
add_points(owner, -amount, reason: reason, pointable: pointable)
|
41
|
+
end
|
42
|
+
|
43
|
+
# @return [self]
|
44
|
+
def call
|
45
|
+
PointBalance.transaction(requires_new: true) do
|
46
|
+
@operations.sort_by(&:sorting_key).each(&:perform)
|
47
|
+
end
|
48
|
+
|
49
|
+
self
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
data/lib/royal.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'royal/version'
|
4
|
+
|
5
|
+
require_relative 'royal/error'
|
6
|
+
require_relative 'royal/insufficient_points_error'
|
7
|
+
require_relative 'royal/sequence_error'
|
8
|
+
|
9
|
+
require_relative 'royal/config'
|
10
|
+
require_relative 'royal/points'
|
11
|
+
require_relative 'royal/point_balance'
|
12
|
+
require_relative 'royal/locking'
|
13
|
+
require_relative 'royal/transaction'
|
14
|
+
|
15
|
+
module Royal
|
16
|
+
# @return [Royal::Config]
|
17
|
+
def self.config
|
18
|
+
@config ||= Royal::Config.new.freeze
|
19
|
+
end
|
20
|
+
|
21
|
+
# @return [void]
|
22
|
+
def self.configure
|
23
|
+
config = Royal::Config.new
|
24
|
+
yield(config)
|
25
|
+
|
26
|
+
@config = config.freeze
|
27
|
+
end
|
28
|
+
end
|
data/royal.gemspec
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'lib/royal/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'royal'
|
7
|
+
spec.version = Royal::VERSION
|
8
|
+
spec.authors = ['Minty Fresh']
|
9
|
+
spec.email = ['7896757+mintyfresh@users.noreply.github.com']
|
10
|
+
|
11
|
+
spec.summary = 'Loyalty Points for ActiveRecord'
|
12
|
+
spec.description = 'Ledger backed loyalty points for your ActiveRecord models'
|
13
|
+
spec.homepage = 'https://github.com/mintyfresh/royal'
|
14
|
+
|
15
|
+
spec.required_ruby_version = Gem::Requirement.new('>= 2.6.0')
|
16
|
+
|
17
|
+
spec.metadata['allowed_push_host'] = 'https://rubygems.org/'
|
18
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
19
|
+
|
20
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
21
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
22
|
+
|
23
|
+
# Specify which files should be added to the gem when it is released.
|
24
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
25
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
26
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
27
|
+
end
|
28
|
+
|
29
|
+
spec.bindir = 'exe'
|
30
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
31
|
+
spec.require_paths = ['lib']
|
32
|
+
|
33
|
+
spec.add_dependency 'activerecord', '>= 5', '< 8'
|
34
|
+
end
|
metadata
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: royal
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Minty Fresh
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-02-02 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '5'
|
20
|
+
- - "<"
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '8'
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '5'
|
30
|
+
- - "<"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '8'
|
33
|
+
description: Ledger backed loyalty points for your ActiveRecord models
|
34
|
+
email:
|
35
|
+
- 7896757+mintyfresh@users.noreply.github.com
|
36
|
+
executables: []
|
37
|
+
extensions: []
|
38
|
+
extra_rdoc_files: []
|
39
|
+
files:
|
40
|
+
- ".github/workflows/main.yml"
|
41
|
+
- ".gitignore"
|
42
|
+
- ".rubocop.yml"
|
43
|
+
- ".vscode/settings.json"
|
44
|
+
- Gemfile
|
45
|
+
- Gemfile.lock
|
46
|
+
- README.md
|
47
|
+
- Rakefile
|
48
|
+
- bin/console
|
49
|
+
- bin/rspec
|
50
|
+
- bin/rubocop
|
51
|
+
- bin/setup
|
52
|
+
- lib/generators/royal/install_generator.rb
|
53
|
+
- lib/generators/royal/migration_generator.rb
|
54
|
+
- lib/generators/royal/templates/create_point_balances.rb.erb
|
55
|
+
- lib/generators/royal/templates/royal.rb
|
56
|
+
- lib/royal.rb
|
57
|
+
- lib/royal/config.rb
|
58
|
+
- lib/royal/error.rb
|
59
|
+
- lib/royal/insufficient_points_error.rb
|
60
|
+
- lib/royal/locking.rb
|
61
|
+
- lib/royal/locking/advisory.rb
|
62
|
+
- lib/royal/locking/optimistic.rb
|
63
|
+
- lib/royal/locking/pessimistic.rb
|
64
|
+
- lib/royal/point_balance.rb
|
65
|
+
- lib/royal/points.rb
|
66
|
+
- lib/royal/sequence_error.rb
|
67
|
+
- lib/royal/transaction.rb
|
68
|
+
- lib/royal/version.rb
|
69
|
+
- royal.gemspec
|
70
|
+
homepage: https://github.com/mintyfresh/royal
|
71
|
+
licenses: []
|
72
|
+
metadata:
|
73
|
+
allowed_push_host: https://rubygems.org/
|
74
|
+
rubygems_mfa_required: 'true'
|
75
|
+
homepage_uri: https://github.com/mintyfresh/royal
|
76
|
+
source_code_uri: https://github.com/mintyfresh/royal
|
77
|
+
post_install_message:
|
78
|
+
rdoc_options: []
|
79
|
+
require_paths:
|
80
|
+
- lib
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
82
|
+
requirements:
|
83
|
+
- - ">="
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: 2.6.0
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
87
|
+
requirements:
|
88
|
+
- - ">="
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '0'
|
91
|
+
requirements: []
|
92
|
+
rubygems_version: 3.2.15
|
93
|
+
signing_key:
|
94
|
+
specification_version: 4
|
95
|
+
summary: Loyalty Points for ActiveRecord
|
96
|
+
test_files: []
|