acts_as_calculator 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +0 -5
- data/README.md +147 -93
- data/app/controllers/acts_as_calculator/application_controller.rb +0 -16
- data/app/controllers/acts_as_calculator/formula_versions_controller.rb +0 -4
- data/app/controllers/acts_as_calculator/formulas_controller.rb +0 -4
- data/app/controllers/acts_as_calculator/imports_controller.rb +0 -9
- data/app/controllers/acts_as_calculator/templates_controller.rb +0 -6
- data/app/models/acts_as_calculator/formula_version.rb +0 -2
- data/app/models/acts_as_calculator/lookup_table_entry.rb +0 -3
- data/app/models/acts_as_calculator/record.rb +0 -3
- data/app/models/acts_as_calculator/run.rb +0 -2
- data/app/models/acts_as_calculator/template.rb +0 -2
- data/config/routes.rb +0 -18
- data/lib/acts_as_calculator/calculable.rb +0 -11
- data/lib/acts_as_calculator/calculator_cache.rb +0 -4
- data/lib/acts_as_calculator/cast_json_safe.rb +0 -2
- data/lib/acts_as_calculator/cast_liquid_value.rb +0 -10
- data/lib/acts_as_calculator/cast_variable_attributes.rb +0 -3
- data/lib/acts_as_calculator/configuration.rb +0 -8
- data/lib/acts_as_calculator/detect_formula_call_cycles.rb +54 -0
- data/lib/acts_as_calculator/engine.rb +0 -3
- data/lib/acts_as_calculator/errors.rb +2 -0
- data/lib/acts_as_calculator/evaluate_expression.rb +0 -3
- data/lib/acts_as_calculator/evaluate_formula.rb +3 -19
- data/lib/acts_as_calculator/evaluate_formula_call.rb +124 -0
- data/lib/acts_as_calculator/find_lookup_table_references.rb +0 -16
- data/lib/acts_as_calculator/formula_call.rb +49 -0
- data/lib/acts_as_calculator/formula_call_cache.rb +56 -0
- data/lib/acts_as_calculator/formula_version_drop.rb +0 -3
- data/lib/acts_as_calculator/function_registry.rb +0 -3
- data/lib/acts_as_calculator/import_definitions.rb +0 -8
- data/lib/acts_as_calculator/import_formula.rb +12 -10
- data/lib/acts_as_calculator/import_lookup_table.rb +0 -2
- data/lib/acts_as_calculator/import_outcome.rb +0 -1
- data/lib/acts_as_calculator/import_template.rb +0 -4
- data/lib/acts_as_calculator/liquid_filters.rb +0 -5
- data/lib/acts_as_calculator/parse_formula_expression.rb +53 -0
- data/lib/acts_as_calculator/promote_template.rb +0 -6
- data/lib/acts_as_calculator/publish_formula_version.rb +17 -14
- data/lib/acts_as_calculator/publish_template.rb +0 -5
- data/lib/acts_as_calculator/render_liquid.rb +0 -26
- data/lib/acts_as_calculator/render_template.rb +0 -3
- data/lib/acts_as_calculator/resolve_formula_call.rb +57 -0
- data/lib/acts_as_calculator/resolve_formula_version.rb +0 -3
- data/lib/acts_as_calculator/resolve_import_owner.rb +0 -4
- data/lib/acts_as_calculator/resolve_template.rb +0 -3
- data/lib/acts_as_calculator/result.rb +0 -4
- data/lib/acts_as_calculator/serialize_formula.rb +0 -3
- data/lib/acts_as_calculator/serialize_formula_version.rb +6 -0
- data/lib/acts_as_calculator/serialize_import_summary.rb +0 -3
- data/lib/acts_as_calculator/supersede_formula_versions.rb +0 -16
- data/lib/acts_as_calculator/version.rb +1 -1
- data/lib/acts_as_calculator.rb +6 -9
- data/lib/generators/acts_as_calculator/import/import_generator.rb +0 -2
- data/lib/generators/acts_as_calculator/install/templates/create_acts_as_calculator_tables.rb.tt +1 -11
- data/lib/tasks/acts_as_calculator.rake +0 -2
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ad149828fc403a8faf199be49400d0f5742df5d131fee9c45d61dd4aa961477
|
|
4
|
+
data.tar.gz: 7a24e2b53e7b408e597d1e08c3eda46b03d61b3c26ceb4b7f9c27f9ca7f6814a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ccf113444d6697e1be4ffa9e05642cbff4c0846720d1c884360a13ec480d97842d43bd96ffb99199c7e99ce58d9f099628ee678bb34831058da3ebcbaf5b3cfc
|
|
7
|
+
data.tar.gz: 76fec437da064bbf0d9ebc5f45d51992e4c82721e635a82a79851b2a9301db3f2b2873b902a4ad511af0cbecbde420d3375173220c854db50414e9c098a85584
|
data/.rubocop.yml
CHANGED
|
@@ -3,14 +3,9 @@ AllCops:
|
|
|
3
3
|
NewCops: disable
|
|
4
4
|
SuggestExtensions: false
|
|
5
5
|
|
|
6
|
-
# Decrees are invoked as `DoSomething.(...)` at call sites but delegate with an explicit
|
|
7
|
-
# `new(...).call` — see the decree-service-objects skill. Neither style is wrong here.
|
|
8
6
|
Style/LambdaCall:
|
|
9
7
|
Enabled: false
|
|
10
8
|
|
|
11
|
-
# Decrees take keyword arguments by design (docs/PLAN.md, "Code guidelines"), and a
|
|
12
|
-
# keyword argument costs a reader nothing at the call site — counting them would turn the
|
|
13
|
-
# gem's mandated style into an offence. Positional parameters are still capped.
|
|
14
9
|
Metrics/ParameterLists:
|
|
15
10
|
CountKeywordArgs: false
|
|
16
11
|
|
data/README.md
CHANGED
|
@@ -1,43 +1,152 @@
|
|
|
1
1
|
# ActsAsCalculator
|
|
2
2
|
|
|
3
|
-
A pricing and
|
|
3
|
+
A calculation engine for pricing, payroll, tax, and insurance domains. Built on [Dentaku](https://github.com/rubysolo/dentaku).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Add formula-based calculations to any model with `Calculable`. Formulas are versioned, effective-dated, can call other formulas, and render to Liquid templates.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Install
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
```ruby
|
|
10
|
+
bundle add acts_as_calculator
|
|
11
|
+
rails generate acts_as_calculator:install
|
|
12
|
+
rails db:migrate
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
Mix `Calculable` into your model:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
class Employee < ApplicationRecord
|
|
21
|
+
include ActsAsCalculator::Calculable
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Create a formula:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
ActsAsCalculator::Formula.create!(key: "net_pay", scope: "payroll")
|
|
29
|
+
formula = ActsAsCalculator::Formula.find_by(key: "net_pay")
|
|
30
|
+
|
|
31
|
+
formula.versions.create!(
|
|
32
|
+
expression: "gross - tax - deductions",
|
|
33
|
+
effective_from: Date.new(2026, 1, 1),
|
|
34
|
+
effective_to: nil,
|
|
35
|
+
status: "active",
|
|
36
|
+
variables: [
|
|
37
|
+
{ name: "gross", source_type: "attribute" },
|
|
38
|
+
{ name: "tax", source_type: "context" },
|
|
39
|
+
{ name: "deductions", source_type: "context" }
|
|
40
|
+
]
|
|
41
|
+
)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Calculate:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
employee = Employee.find(1)
|
|
48
|
+
|
|
49
|
+
result = employee.calculate(
|
|
50
|
+
:net_pay,
|
|
51
|
+
as_of: Date.new(2026, 3, 15),
|
|
52
|
+
tax: BigDecimal("500"),
|
|
53
|
+
deductions: BigDecimal("200")
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
result.value # => #<BigDecimal "2300.00">
|
|
57
|
+
result.breakdown # => { expression: "...", inputs: {...}, calls: [...] }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Formulas Calling Formulas
|
|
61
|
+
|
|
62
|
+
Use `@formula_key` syntax to call other formulas:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
formula = ActsAsCalculator::Formula.create!(key: "total_deductions", scope: "payroll")
|
|
66
|
+
|
|
67
|
+
formula.versions.create!(
|
|
68
|
+
expression: "@tax + @insurance + @retirement",
|
|
69
|
+
effective_from: Date.new(2026, 1, 1),
|
|
70
|
+
status: "active",
|
|
71
|
+
variables: [
|
|
72
|
+
{ name: "salary", source_type: "attribute" }
|
|
73
|
+
],
|
|
74
|
+
formula_calls: [
|
|
75
|
+
{ key: "tax" },
|
|
76
|
+
{ key: "insurance" },
|
|
77
|
+
{ key: "retirement" }
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Each called formula resolves independently at the calculation date. Pin a specific version:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
formula_calls: [
|
|
86
|
+
{ key: "tax", version_id: 2 } # Always use version 2, ignore as_of
|
|
87
|
+
]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Lookup Tables
|
|
91
|
+
|
|
92
|
+
Use lookup tables for tiered calculations:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
table = ActsAsCalculator::LookupTable.create!(key: "tax_brackets", scope: "payroll")
|
|
96
|
+
|
|
97
|
+
table.entries.create!([
|
|
98
|
+
{ from: 0, to: 20000, value: BigDecimal("0.10") },
|
|
99
|
+
{ from: 20000, to: 50000, value: BigDecimal("0.22") },
|
|
100
|
+
{ from: 50000, to: nil, value: BigDecimal("0.32") }
|
|
101
|
+
])
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Reference in formulas:
|
|
10
105
|
|
|
11
|
-
|
|
106
|
+
```ruby
|
|
107
|
+
formula.versions.create!(
|
|
108
|
+
expression: "salary * bracket",
|
|
109
|
+
variables: [
|
|
110
|
+
{ name: "salary", source_type: "attribute" },
|
|
111
|
+
{ name: "bracket", source_type: "lookup",
|
|
112
|
+
source_config: { table: "tax_brackets", using: "salary" } }
|
|
113
|
+
]
|
|
114
|
+
)
|
|
115
|
+
```
|
|
12
116
|
|
|
13
|
-
##
|
|
117
|
+
## Templates
|
|
118
|
+
|
|
119
|
+
Render results to Liquid templates:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
ActsAsCalculator::Template.create!(
|
|
123
|
+
key: "payslip",
|
|
124
|
+
scope: "payroll",
|
|
125
|
+
format: "text",
|
|
126
|
+
body: "Net: {{ result.value | currency }}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
rendered = employee.render(:payslip, as_of: Date.today, net_pay: result.value)
|
|
130
|
+
```
|
|
14
131
|
|
|
15
|
-
|
|
16
|
-
through a generator or repeatedly from a deploy script:
|
|
132
|
+
Available filters: `currency`, `percentage`, `date`.
|
|
17
133
|
|
|
18
|
-
|
|
19
|
-
$ rake acts_as_calculator:import[config/calculator/payroll.json]
|
|
134
|
+
## JSON Import
|
|
20
135
|
|
|
21
|
-
|
|
22
|
-
task exits non-zero if any entry failed. One file may declare any of the three sections.
|
|
136
|
+
Define formulas, lookup tables, and templates in JSON:
|
|
23
137
|
|
|
24
138
|
```json
|
|
25
139
|
{
|
|
26
140
|
"lookup_tables": [
|
|
27
|
-
{ "key": "
|
|
28
|
-
"entries": [{ "from": 0, "to": 20000, "value": 0.1 }
|
|
29
|
-
{ "from": 20000, "to": null, "value": 0.25 }] }
|
|
141
|
+
{ "key": "tax_brackets", "scope": "payroll",
|
|
142
|
+
"entries": [{ "from": 0, "to": 20000, "value": 0.1 }] }
|
|
30
143
|
],
|
|
31
144
|
"formulas": [
|
|
32
145
|
{ "key": "net_pay", "scope": "payroll",
|
|
33
|
-
"expression": "salary -
|
|
34
|
-
"effective_from": "2026-01-01",
|
|
35
|
-
"status": "active",
|
|
36
|
-
"variables": [
|
|
37
|
-
{ "name": "salary", "source_type": "attribute" },
|
|
38
|
-
{ "name": "federal_2026", "source_type": "lookup",
|
|
39
|
-
"source_config": { "table": "federal_2026", "using": "salary" } }
|
|
40
|
-
] }
|
|
146
|
+
"expression": "salary - tax",
|
|
147
|
+
"effective_from": "2026-01-01",
|
|
148
|
+
"status": "active",
|
|
149
|
+
"variables": [{ "name": "salary", "source_type": "attribute" }] }
|
|
41
150
|
],
|
|
42
151
|
"templates": [
|
|
43
152
|
{ "key": "payslip", "scope": "payroll", "format": "text",
|
|
@@ -46,32 +155,18 @@ task exits non-zero if any entry failed. One file may declare any of the three s
|
|
|
46
155
|
}
|
|
47
156
|
```
|
|
48
157
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
version's `effective_to`, or declare the version taking over the tail earlier in the file.
|
|
62
|
-
- **Lookup tables** — unchanged entries are a no-op, and changed entries are replaced in
|
|
63
|
-
place *only* if no active or retired formula version resolves to that table. If one does,
|
|
64
|
-
the import fails that entry rather than retroactively changing what an audited
|
|
65
|
-
calculation was measured against; author a new table key and a new formula version
|
|
66
|
-
pointing at it.
|
|
67
|
-
- **Templates** — unchanged body and format are a no-op; a change publishes a new version
|
|
68
|
-
and demotes the previous one, which stays in the history for rollback.
|
|
69
|
-
|
|
70
|
-
## The API
|
|
71
|
-
|
|
72
|
-
The gem ships a mountable REST/JSON API that is **off by default** — not guarded, off. The
|
|
73
|
-
routes are behind a routing constraint, so with `enable_api` false they do not match at
|
|
74
|
-
all: requests 404 because there is no such path, not because a controller declined.
|
|
158
|
+
Import once or repeatedly:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
rails generate acts_as_calculator:import config/payroll.json
|
|
162
|
+
rake acts_as_calculator:import[config/payroll.json]
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Both run the same logic and report created/updated/skipped counts. Importing is idempotent — unchanged content is skipped, changed content creates a new version.
|
|
166
|
+
|
|
167
|
+
## API
|
|
168
|
+
|
|
169
|
+
Enable the REST/JSON API (disabled by default):
|
|
75
170
|
|
|
76
171
|
```ruby
|
|
77
172
|
# config/initializers/acts_as_calculator.rb
|
|
@@ -81,53 +176,12 @@ ActsAsCalculator.configure { |c| c.enable_api = true }
|
|
|
81
176
|
mount ActsAsCalculator::Engine => "/calculator"
|
|
82
177
|
```
|
|
83
178
|
|
|
84
|
-
|
|
85
|
-
point in whatever your app already uses.
|
|
86
|
-
|
|
87
|
-
| Method | Path | What it does |
|
|
88
|
-
|---|---|---|
|
|
89
|
-
| `GET` | `/formulas` | Identity only (`key`, `scope`, `owner`); filter with `?key=&scope=` |
|
|
90
|
-
| `POST` | `/formulas` | Creates a formula identity |
|
|
91
|
-
| `GET` | `/formulas/:id` | The formula plus its version history |
|
|
92
|
-
| `PATCH` | `/formulas/:id` | Updates only the identity attributes that were sent — including `owner`, unguarded; re-owning a formula silently re-targets every future `#calculate` resolution for that key, it isn't a rename-only endpoint |
|
|
93
|
-
| `DELETE` | `/formulas/:id` | 409s instead of deleting a formula whose versions have runs |
|
|
94
|
-
| `GET` | `/formulas/:id/versions` | Versions in version order |
|
|
95
|
-
| `GET` | `/formulas/:id/versions/:id` | One version with its declared variables |
|
|
96
|
-
| `POST` | `/formulas/:id/versions` | Publishes a version and its variables |
|
|
97
|
-
| `GET` | `/templates` | Every version; `?current=true` narrows to the published one |
|
|
98
|
-
| `POST` | `/templates` | Publishes a new version and demotes the outgoing one |
|
|
99
|
-
| `GET`/`DELETE` | `/templates/:id` | |
|
|
100
|
-
| `POST` | `/templates/:id/preview` | Renders against a posted `context` → `{ body:, format: }` |
|
|
101
|
-
| `POST` | `/templates/:id/promote` | Rollback — points `current` at this version |
|
|
102
|
-
| `POST` | `/import` | The JSON import document above, over HTTP |
|
|
103
|
-
|
|
104
|
-
Writes go through the same Decrees the other authoring paths use, so the API is not a way
|
|
105
|
-
around the rules: publishing a version supersedes the one in force rather than overlapping
|
|
106
|
-
it, and a supersede that would leave part of the incumbent's range with no active version
|
|
107
|
-
comes back as `409 Conflict` rather than silently creating a gap. `POST /import` is
|
|
108
|
-
`ImportDefinitions` handed the parsed body — same idempotency, same lookup-table guard, and
|
|
109
|
-
the same per-entry created/updated/skipped/failed summary the rake task prints, as JSON.
|
|
110
|
-
|
|
111
|
-
Errors are `{ "error": { "type": ..., "message": ..., "details": {...} } }`: 422 on
|
|
112
|
-
validation failure (with the model's own messages), 404 on a record or template that isn't
|
|
113
|
-
there, 409 on a conflict with existing state, 400 on a malformed request body.
|
|
114
|
-
|
|
115
|
-
Lookup tables have no CRUD endpoints of their own. `docs/PLAN.md` names formulas, versions
|
|
116
|
-
and templates as the API's surface, and lookup tables are already authorable over HTTP
|
|
117
|
-
through `POST /import` — which is also the only path that enforces the guard against
|
|
118
|
-
editing a table an audited formula version resolves to.
|
|
119
|
-
|
|
120
|
-
## Development
|
|
121
|
-
|
|
122
|
-
$ bin/setup
|
|
123
|
-
$ rake spec
|
|
124
|
-
|
|
125
|
-
`bin/console` gives an interactive prompt for experimenting.
|
|
179
|
+
Endpoints: `GET/POST /formulas`, `GET/PATCH/DELETE /formulas/:id`, `GET/POST /formulas/:id/versions`, `GET/POST /templates`, `GET/DELETE /templates/:id`, `POST /templates/:id/preview`, `POST /import`.
|
|
126
180
|
|
|
127
181
|
## Contributing
|
|
128
182
|
|
|
129
|
-
|
|
183
|
+
Issues and PRs welcome at https://github.com/lautarograc/acts_as_calculator.
|
|
130
184
|
|
|
131
185
|
## License
|
|
132
186
|
|
|
133
|
-
|
|
187
|
+
MIT
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ActsAsCalculator
|
|
4
|
-
# There is deliberately no authentication or authorization here, and no `before_action`
|
|
5
|
-
# stub inviting one (docs/PLAN.md, "Constraints & non-goals"). The gem's only gate is the
|
|
6
|
-
# routing constraint in config/routes.rb; a host that opts the API on wraps the mounted
|
|
7
|
-
# engine in its own auth, at its own mount point.
|
|
8
4
|
class ApplicationController < ActionController::API
|
|
9
|
-
# rescue_from matches the most recently registered handler first, so the catch-all for
|
|
10
|
-
# the gem's own errors goes first and the narrower ones override it below.
|
|
11
5
|
rescue_from Error, with: :render_unprocessable
|
|
12
6
|
rescue_from FormulaNotFoundError, TemplateNotFoundError, NoEffectiveVersionError, with: :render_not_found
|
|
13
7
|
rescue_from PartialSupersedeError, with: :render_conflict
|
|
@@ -37,8 +31,6 @@ module ActsAsCalculator
|
|
|
37
31
|
render_error(error, :bad_request)
|
|
38
32
|
end
|
|
39
33
|
|
|
40
|
-
# 409, not 422: the request is well-formed and the state is valid — the two are just
|
|
41
|
-
# irreconcilable without the operator saying which range they meant to keep.
|
|
42
34
|
def render_conflict(error)
|
|
43
35
|
render_error(error, :conflict)
|
|
44
36
|
end
|
|
@@ -47,28 +39,20 @@ module ActsAsCalculator
|
|
|
47
39
|
render_error(error, :unprocessable_entity, details: error.record.errors.messages)
|
|
48
40
|
end
|
|
49
41
|
|
|
50
|
-
# Reached when a version still has runs: the audit trail is never deleted to make room
|
|
51
|
-
# for a delete of something else (docs/PLAN.md, "Data model").
|
|
52
42
|
def render_record_not_destroyed(error)
|
|
53
43
|
render_error(error, :conflict, details: error.record.errors.messages)
|
|
54
44
|
end
|
|
55
45
|
|
|
56
|
-
# Nested params arrive as ActionController::Parameters, which is not a Hash — anything
|
|
57
|
-
# handed to a Decree has to be a plain one first.
|
|
58
46
|
def plain_hash(value)
|
|
59
47
|
value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h.to_h : (value || {}).to_h
|
|
60
48
|
end
|
|
61
49
|
|
|
62
|
-
# `key` alone never identifies a row in this gem (docs/PLAN.md, "Data model"), so an
|
|
63
|
-
# index filters on the pair.
|
|
64
50
|
def filter_by_key_and_scope(relation)
|
|
65
51
|
relation = relation.where(key: params[:key]) if params[:key].present?
|
|
66
52
|
relation = relation.where(scope: params[:scope]) if params[:scope].present?
|
|
67
53
|
relation
|
|
68
54
|
end
|
|
69
55
|
|
|
70
|
-
# `{"type": "Company", "id": 7}` — the same owner reference the JSON import takes, run
|
|
71
|
-
# through the same guard that refuses a type which isn't an ActiveRecord model.
|
|
72
56
|
def resolve_owner(reference)
|
|
73
57
|
ResolveImportOwner.(plain_hash(reference).presence)
|
|
74
58
|
end
|
|
@@ -12,10 +12,6 @@ module ActsAsCalculator
|
|
|
12
12
|
render json: { version: SerializeFormulaVersion.(version:, variables: true) }
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Straight through `PublishFormulaVersion`, so the API takes exactly the same route
|
|
16
|
-
# through the effective-dating rules as the JSON import does: the incumbent is
|
|
17
|
-
# superseded, never rewritten, and a supersede that would leave part of its range with
|
|
18
|
-
# no active version raises `PartialSupersedeError` — surfaced as a 409, not swallowed.
|
|
19
15
|
def create
|
|
20
16
|
published = PublishFormulaVersion.(formula:, **version_attributes)
|
|
21
17
|
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ActsAsCalculator
|
|
4
|
-
# Identity only — `key`, `scope`, `owner`. Everything that can change about a formula is
|
|
5
|
-
# a version, which is why there is a separate controller for those.
|
|
6
4
|
class FormulasController < ApplicationController
|
|
7
5
|
def index
|
|
8
6
|
render json: { formulas: scoped_formulas.map { |formula| SerializeFormula.(formula:) } }
|
|
@@ -44,8 +42,6 @@ module ActsAsCalculator
|
|
|
44
42
|
params.require(:formula).permit(:key, :scope, owner: %i[type id])
|
|
45
43
|
end
|
|
46
44
|
|
|
47
|
-
# Only what was sent, so a PATCH that renames a formula doesn't blank its scope. `scope`
|
|
48
|
-
# is left to the column default on create rather than defaulted here.
|
|
49
45
|
def formula_attributes
|
|
50
46
|
permitted = formula_params
|
|
51
47
|
attributes = {}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ActsAsCalculator
|
|
4
|
-
# docs/PLAN.md lists JSON import and the API as two authoring paths, not two import
|
|
5
|
-
# mechanisms: this is `ImportDefinitions` handed the parsed request body, so an HTTP
|
|
6
|
-
# import means exactly what the generator and the rake task mean, idempotency and
|
|
7
|
-
# lookup-table guard included.
|
|
8
4
|
class ImportsController < ApplicationController
|
|
9
5
|
def create
|
|
10
6
|
summary = ImportDefinitions.(data: definitions)
|
|
@@ -15,11 +11,6 @@ module ActsAsCalculator
|
|
|
15
11
|
|
|
16
12
|
private
|
|
17
13
|
|
|
18
|
-
# The request body itself, not `params` — which carries `controller` and `action`, and
|
|
19
|
-
# `ImportDefinitions` rejects a section it doesn't recognise rather than ignoring it.
|
|
20
|
-
#
|
|
21
|
-
# An empty document is refused for the same reason a misspelled section is: reporting
|
|
22
|
-
# "0 created, 0 failed" to a caller whose body never parsed looks exactly like success.
|
|
23
14
|
def definitions
|
|
24
15
|
body = request.request_parameters
|
|
25
16
|
if body.empty?
|
|
@@ -22,16 +22,12 @@ module ActsAsCalculator
|
|
|
22
22
|
head :no_content
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
# `{ body:, format: }`, never `html_safe` — whether API-authored markup is safe to
|
|
26
|
-
# inject into a page is the host's call, not this gem's (Phase 3).
|
|
27
25
|
def preview
|
|
28
26
|
body = RenderTemplate.(template:, context: plain_hash(params[:context]))
|
|
29
27
|
|
|
30
28
|
render json: { preview: { body:, format: template.format } }
|
|
31
29
|
end
|
|
32
30
|
|
|
33
|
-
# Rollback: point `current` at an older version instead of deleting the one that went
|
|
34
|
-
# wrong, which is the whole reason the version history exists.
|
|
35
31
|
def promote
|
|
36
32
|
render json: { template: SerializeTemplate.(template: PromoteTemplate.(template:)) }
|
|
37
33
|
end
|
|
@@ -47,8 +43,6 @@ module ActsAsCalculator
|
|
|
47
43
|
current_only? ? templates.current : templates
|
|
48
44
|
end
|
|
49
45
|
|
|
50
|
-
# Spelled out rather than reaching for ActiveModel::Type::Boolean, which the gemspec
|
|
51
|
-
# doesn't declare and which is more machinery than one query parameter is worth.
|
|
52
46
|
def current_only?
|
|
53
47
|
%w[true 1].include?(params[:current].to_s)
|
|
54
48
|
end
|
|
@@ -9,8 +9,6 @@ module ActsAsCalculator
|
|
|
9
9
|
RETIRED = "retired"
|
|
10
10
|
STATUSES = [DRAFT, ACTIVE, RETIRED].freeze
|
|
11
11
|
|
|
12
|
-
# `effective_to` and `status` stay writable so an active version can be closed out or
|
|
13
|
-
# retired; rewriting what was in force on a date already calculated is what's banned.
|
|
14
12
|
IMMUTABLE_ONCE_ACTIVE = %w[formula_id version_number expression effective_from].freeze
|
|
15
13
|
|
|
16
14
|
belongs_to :formula, inverse_of: :versions
|
|
@@ -10,9 +10,6 @@ module ActsAsCalculator
|
|
|
10
10
|
validates :position, presence: true, numericality: { only_integer: true }
|
|
11
11
|
validate :bounds_ordered
|
|
12
12
|
|
|
13
|
-
# `from`/`to` are half-open and nullable on both ends, so neither is orderable in SQL
|
|
14
|
-
# in a way that puts an unbounded floor first across every adapter — position is the
|
|
15
|
-
# explicit, adapter-independent answer.
|
|
16
13
|
scope :ordered, -> { order(:position, :id) }
|
|
17
14
|
|
|
18
15
|
private
|
|
@@ -4,9 +4,6 @@ module ActsAsCalculator
|
|
|
4
4
|
class Record < ::ActiveRecord::Base
|
|
5
5
|
self.abstract_class = true
|
|
6
6
|
|
|
7
|
-
# Set here rather than inherited from the host: `belongs_to_required_by_default` only
|
|
8
|
-
# becomes true via `config.load_defaults`, and whether a run may be saved without the
|
|
9
|
-
# formula version it applied is not the host's decision to make.
|
|
10
7
|
self.belongs_to_required_by_default = true
|
|
11
8
|
end
|
|
12
9
|
end
|
|
@@ -15,8 +15,6 @@ module ActsAsCalculator
|
|
|
15
15
|
joins(formula_version: :formula).where(calculator_formulas: { key: key.to_s })
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
# The audit trail is append-only: a correction is a new run, never an edit of the row
|
|
19
|
-
# that recorded what was actually applied.
|
|
20
18
|
def readonly?
|
|
21
19
|
persisted?
|
|
22
20
|
end
|
|
@@ -16,8 +16,6 @@ module ActsAsCalculator
|
|
|
16
16
|
validates :format, inclusion: { in: FORMATS }
|
|
17
17
|
validates :version_number, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
|
18
18
|
|
|
19
|
-
# Only on update: a create demotes its siblings in before_create, which runs *after*
|
|
20
|
-
# validation, so running this on create would flag the version it is about to replace.
|
|
21
19
|
validate :single_current_version, on: :update
|
|
22
20
|
|
|
23
21
|
before_create :demote_other_versions, if: :current?
|
data/config/routes.rb
CHANGED
|
@@ -1,27 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# The API ships off (docs/PLAN.md, "Authoring paths"): a host opts in with
|
|
4
|
-
# `ActsAsCalculator.configure { |c| c.enable_api = true }`.
|
|
5
|
-
#
|
|
6
|
-
# The gate is a routing constraint rather than a `before_action` on purpose. A controller
|
|
7
|
-
# guard leaves the route registered — the router matches it, instantiates the controller,
|
|
8
|
-
# and the controller declines — which means the endpoints exist, answer HEAD/OPTIONS, show
|
|
9
|
-
# up in `rails routes`, and are one forgotten `skip_before_action` away from being live.
|
|
10
|
-
# A constraint that returns false makes the route not match at all: the engine's router
|
|
11
|
-
# passes, the host's router finds nothing, and the request 404s because there is no such
|
|
12
|
-
# path. Off means absent, not refused.
|
|
13
|
-
#
|
|
14
|
-
# The lambda is evaluated per request, so the flag is read at match time, not captured when
|
|
15
|
-
# the routes were drawn.
|
|
16
3
|
ActsAsCalculator::Engine.routes.draw do
|
|
17
4
|
constraints(->(_request) { ActsAsCalculator.configuration.enable_api }) do
|
|
18
5
|
resources :formulas, only: %i[index show create update destroy] do
|
|
19
6
|
resources :versions, only: %i[index show create], controller: "formula_versions"
|
|
20
7
|
end
|
|
21
8
|
|
|
22
|
-
# No `update`: a template body is versioned for rollback, so a change publishes a new
|
|
23
|
-
# version (POST) and going back promotes an old one — editing a row in place would
|
|
24
|
-
# rewrite the history that makes rollback possible.
|
|
25
9
|
resources :templates, only: %i[index show create destroy] do
|
|
26
10
|
member do
|
|
27
11
|
post :preview
|
|
@@ -29,8 +13,6 @@ ActsAsCalculator::Engine.routes.draw do
|
|
|
29
13
|
end
|
|
30
14
|
end
|
|
31
15
|
|
|
32
|
-
# docs/PLAN.md's JSON import, reachable over HTTP — the same ImportDefinitions the
|
|
33
|
-
# generator and the rake task call, handed a parsed body instead of a file.
|
|
34
16
|
post "import", to: "imports#create"
|
|
35
17
|
end
|
|
36
18
|
end
|
|
@@ -8,8 +8,6 @@ module ActsAsCalculator
|
|
|
8
8
|
extend ::ActiveSupport::Concern
|
|
9
9
|
|
|
10
10
|
included do
|
|
11
|
-
# No `dependent:` on purpose. Whether an audit trail outlives the record it audited
|
|
12
|
-
# is the host app's retention policy, and Run#readonly? makes `:destroy` raise.
|
|
13
11
|
has_many :calculator_runs,
|
|
14
12
|
class_name: "ActsAsCalculator::Run",
|
|
15
13
|
as: :calculable,
|
|
@@ -24,9 +22,6 @@ module ActsAsCalculator
|
|
|
24
22
|
end
|
|
25
23
|
end
|
|
26
24
|
|
|
27
|
-
# Leftover keywords splat into the calculation context, which makes `as_of`, `scope`,
|
|
28
|
-
# `owner` and `dry_run` unusable as Dentaku variable names; `context:` is the escape
|
|
29
|
-
# hatch for a formula that genuinely declares one, and wins over the splat.
|
|
30
25
|
def calculate(key, as_of: nil, scope: nil, owner: nil, dry_run: false, context: {}, **extra)
|
|
31
26
|
EvaluateFormula.(
|
|
32
27
|
calculable: self,
|
|
@@ -53,10 +48,6 @@ module ActsAsCalculator
|
|
|
53
48
|
nil
|
|
54
49
|
end
|
|
55
50
|
|
|
56
|
-
# Chains a calculation and a render in one call — `calculate:` names one formula key
|
|
57
|
-
# (assigned as `result`) or several (assigned as `results`) — or renders a Result the
|
|
58
|
-
# caller already has. Leftover keywords splat into the template's assigns *and* into
|
|
59
|
-
# the calculation's context, so one call can feed both.
|
|
60
51
|
def render(key, calculate: nil, result: nil, results: {}, as_of: nil, scope: nil,
|
|
61
52
|
owner: nil, version_number: nil, dry_run: false, context: {}, **extra)
|
|
62
53
|
assigns = extra.merge(context)
|
|
@@ -73,8 +64,6 @@ module ActsAsCalculator
|
|
|
73
64
|
|
|
74
65
|
private
|
|
75
66
|
|
|
76
|
-
# Forwards the assigns as `context:` rather than splatting them, so a key named
|
|
77
|
-
# `scope` reaches the formula instead of redirecting its lookup.
|
|
78
67
|
def calculator_render_results(keys, as_of:, scope:, owner:, dry_run:, context:)
|
|
79
68
|
Array(keys).to_h do |formula_key|
|
|
80
69
|
[formula_key.to_s, calculate(formula_key, as_of:, scope:, owner:, dry_run:, context:)]
|
|
@@ -25,10 +25,6 @@ module ActsAsCalculator
|
|
|
25
25
|
|
|
26
26
|
attr_reader :functions, :key
|
|
27
27
|
|
|
28
|
-
# Keyed by formula_version_id because version content is immutable, so an entry can
|
|
29
|
-
# never go stale. Held per thread because Dentaku::Calculator mutates its own memory
|
|
30
|
-
# while evaluating — one shared instance across a Puma thread pool would interleave
|
|
31
|
-
# two requests' variables.
|
|
32
28
|
def store
|
|
33
29
|
Thread.current[key] ||= {}
|
|
34
30
|
end
|
|
@@ -9,8 +9,6 @@ module ActsAsCalculator
|
|
|
9
9
|
def self.call(value)
|
|
10
10
|
case value
|
|
11
11
|
when Hash, Array then container(value)
|
|
12
|
-
# BigDecimal#to_json emits "0.1234e4"; a plain decimal string round-trips into the
|
|
13
|
-
# jsonb column as something a human (and a Phase 3 template) can read.
|
|
14
12
|
when BigDecimal then value.to_s("F")
|
|
15
13
|
when Symbol then value.to_s
|
|
16
14
|
when Rational then value.to_f
|
|
@@ -5,10 +5,6 @@ require "date"
|
|
|
5
5
|
require "liquid"
|
|
6
6
|
|
|
7
7
|
module ActsAsCalculator
|
|
8
|
-
# The one gate into a template's render context. Liquid only dispatches methods on
|
|
9
|
-
# `Liquid::Drop`s and hash-likes, so the sandbox holds exactly as long as nothing else
|
|
10
|
-
# is assigned — an allowlist here is what makes that a property of the code rather than
|
|
11
|
-
# a habit every caller has to remember.
|
|
12
8
|
class CastLiquidValue
|
|
13
9
|
PASS_THROUGH = [
|
|
14
10
|
::NilClass, ::TrueClass, ::FalseClass, ::String, ::Integer, ::Float,
|
|
@@ -27,18 +23,12 @@ module ActsAsCalculator
|
|
|
27
23
|
def self.scalar(value)
|
|
28
24
|
return value if PASS_THROUGH.any? { |type| value.is_a?(type) }
|
|
29
25
|
return value.to_s if value.is_a?(::Symbol)
|
|
30
|
-
# BigDecimal#to_s emits engineering notation ("0.1e4"), which is not what belongs in
|
|
31
|
-
# rendered output; the plain-decimal form round-trips exactly back through CastDecimal.
|
|
32
26
|
return value.to_s("F") if value.is_a?(::BigDecimal)
|
|
33
27
|
|
|
34
28
|
time_like(value) || refuse(value)
|
|
35
29
|
end
|
|
36
30
|
private_class_method :scalar
|
|
37
31
|
|
|
38
|
-
# The one class a Rails host will hit that is neither a Date nor a Time, since
|
|
39
|
-
# `record.created_at` is the obvious thing to pass. Matched by name because the core
|
|
40
|
-
# layer must stay loadable without ActiveSupport, and named rather than duck-typed so
|
|
41
|
-
# this stays an allowlist — `respond_to?(:strftime)` would admit anything.
|
|
42
32
|
TIME_WITH_ZONE = "ActiveSupport::TimeWithZone"
|
|
43
33
|
|
|
44
34
|
# rubocop:disable Style/ClassEqualityComparison -- instance_of? would need the constant
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ActsAsCalculator
|
|
4
|
-
# Both authoring paths hand over the same loose variable declaration — an import file's
|
|
5
|
-
# `variables` list and the API's — and both have to land on the same row, with the same
|
|
6
|
-
# defaults. Keeping the defaulting here is what stops the two from drifting.
|
|
7
4
|
class CastVariableAttributes
|
|
8
5
|
def self.call(variable)
|
|
9
6
|
variable = variable.to_h.transform_keys(&:to_s)
|
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ActsAsCalculator
|
|
4
|
-
# Host-facing settings, set from an initializer:
|
|
5
|
-
#
|
|
6
|
-
# ActsAsCalculator.configure { |c| c.enable_api = true }
|
|
7
|
-
#
|
|
8
|
-
# `enable_api` is read per request by the routing constraint in `config/routes.rb`, not
|
|
9
|
-
# captured when the routes are drawn — so flipping it takes effect without a reload, and
|
|
10
|
-
# a host can gate the API on something it computes at boot.
|
|
11
4
|
class Configuration
|
|
12
5
|
attr_accessor :enable_api
|
|
13
6
|
|
|
@@ -24,7 +17,6 @@ module ActsAsCalculator
|
|
|
24
17
|
yield(configuration)
|
|
25
18
|
end
|
|
26
19
|
|
|
27
|
-
# Test-suite affordance: a host's own specs need to flip `enable_api` and put it back.
|
|
28
20
|
def self.reset_configuration!
|
|
29
21
|
@configuration = Configuration.new
|
|
30
22
|
end
|