overule 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/CHANGELOG.md +80 -0
- data/README.md +302 -0
- data/app/assets/javascripts/overule/builder.js +268 -0
- data/app/controllers/overule/activities_controller.rb +10 -0
- data/app/controllers/overule/application_controller.rb +35 -0
- data/app/controllers/overule/rule_versions_controller.rb +24 -0
- data/app/controllers/overule/rules_controller.rb +60 -0
- data/app/models/concerns/overule/rule_activity_behavior.rb +24 -0
- data/app/models/concerns/overule/rule_behavior.rb +106 -0
- data/app/models/concerns/overule/rule_version_behavior.rb +15 -0
- data/app/models/overule/current.rb +7 -0
- data/app/models/overule/rule.rb +48 -0
- data/app/models/overule/rule_activity.rb +40 -0
- data/app/models/overule/rule_version.rb +42 -0
- data/app/views/layouts/overule/application.html.erb +39 -0
- data/app/views/overule/activities/_activity.html.erb +56 -0
- data/app/views/overule/activities/index.html.erb +30 -0
- data/app/views/overule/rule_versions/index.html.erb +44 -0
- data/app/views/overule/rule_versions/show.html.erb +55 -0
- data/app/views/overule/rules/_form.html.erb +95 -0
- data/app/views/overule/rules/_group.html.erb +106 -0
- data/app/views/overule/rules/_static_node.html.erb +79 -0
- data/app/views/overule/rules/edit.html.erb +2 -0
- data/app/views/overule/rules/index.html.erb +45 -0
- data/app/views/overule/rules/new.html.erb +2 -0
- data/app/views/overule/rules/show.html.erb +54 -0
- data/config/routes.rb +8 -0
- data/lib/generators/overule/install/USAGE +25 -0
- data/lib/generators/overule/install/install_generator.rb +64 -0
- data/lib/generators/overule/install/templates/add_rule_version_to_overule_rule_activities.rb.tt +21 -0
- data/lib/generators/overule/install/templates/create_overule_rule_activities.rb.tt +15 -0
- data/lib/generators/overule/install/templates/create_overule_rule_versions.rb.tt +28 -0
- data/lib/generators/overule/install/templates/create_overule_rules.rb.tt +13 -0
- data/lib/generators/overule/install/templates/overule.rb.tt +35 -0
- data/lib/overule/action.rb +22 -0
- data/lib/overule/condition.rb +40 -0
- data/lib/overule/configuration.rb +75 -0
- data/lib/overule/context.rb +22 -0
- data/lib/overule/engine.rb +7 -0
- data/lib/overule/inference.rb +38 -0
- data/lib/overule/operator.rb +25 -0
- data/lib/overule/version.rb +3 -0
- data/lib/overule.rb +13 -0
- metadata +103 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8800d70879a512479f46e0259e2d324a26a9ff0b19ad9b9c63c1a886f117cbce
|
|
4
|
+
data.tar.gz: '08d4a9efb18f97d1caf22c4aabc6671abc1b6f72aaf059fa13afa0dde9c51977'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c809afb34cba0531ac7f6ca647005b44936764761543a7d031cdfadc2055ad765a4af978dd1308b08569b445d1ed55370007f38e2a80a29b9869885aa5053f8a
|
|
7
|
+
data.tar.gz: 69ad46a223ce50aa254a62fa0156490ad6eba63ee35e9ddab6f04a907c5a24c1eb684e7b733f69ad532c75c4cefc0a75455bd9d806def0453fb3f635a58c8637
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
- `Overule::Configuration` + `Overule.configure { |c| ... }` API. The install
|
|
5
|
+
generator drops a documented initializer at
|
|
6
|
+
`config/initializers/overule.rb` in the host app.
|
|
7
|
+
- `config.orm` — `:active_record` (default) or `:mongoid`. Both adapters are
|
|
8
|
+
fully implemented. Shared validations, callbacks, and version/activity
|
|
9
|
+
logging now live in concerns (`Overule::RuleBehavior`,
|
|
10
|
+
`Overule::RuleActivityBehavior`, `Overule::RuleVersionBehavior`); each
|
|
11
|
+
model file conditionally picks the AR or Mongoid base at load time. The
|
|
12
|
+
install generator accepts `--orm=mongoid` to skip migrations and write
|
|
13
|
+
`config.orm = :mongoid` into the initializer.
|
|
14
|
+
- `config.actor_proc` — a `->(controller) { … }` callable invoked by
|
|
15
|
+
`Overule::ApplicationController#before_action` to populate
|
|
16
|
+
`Overule::Current.actor` automatically. Host apps that prefer to set
|
|
17
|
+
`Current.actor` themselves can leave `actor_proc` nil.
|
|
18
|
+
- HTTP Basic auth gate. Three flat config settings — `http_basic_auth`
|
|
19
|
+
(boolean toggle), `http_basic_auth_username`, `http_basic_auth_password`.
|
|
20
|
+
When enabled, credentials are compared in constant time via
|
|
21
|
+
`ActiveSupport::SecurityUtils.secure_compare`, with both sides always
|
|
22
|
+
evaluated. Defaults to disabled (no gate; engine relies on host-app auth).
|
|
23
|
+
|
|
24
|
+
### Added (earlier in Unreleased)
|
|
25
|
+
- Rule change activity log: every create/update/destroy on `Overule::Rule` is
|
|
26
|
+
recorded as an `Overule::RuleActivity` row with `action`, `actor`, `diff`
|
|
27
|
+
(before/after for each audited column), and `rule_name` (denormalized so
|
|
28
|
+
history survives rule deletion).
|
|
29
|
+
- Rule versioning: creation produces `v1` and each subsequent change to the
|
|
30
|
+
`definition` body produces a new immutable `Overule::RuleVersion` snapshot.
|
|
31
|
+
Metadata-only edits (`name`, `description`, `enabled`) don't bump the
|
|
32
|
+
version but still log an activity linked to the current body version.
|
|
33
|
+
Destroy activities link to the last version captured before deletion.
|
|
34
|
+
- `/overule/rules/:id/versions` lists all versions of a rule;
|
|
35
|
+
`/overule/rules/:id/versions/:version` renders a read-only snapshot with
|
|
36
|
+
prev/next navigation.
|
|
37
|
+
- Activity rows now show a `vN` badge linking to that version's snapshot.
|
|
38
|
+
- `Overule::Current.actor` (`ActiveSupport::CurrentAttributes`) — host apps set
|
|
39
|
+
this in a `before_action` to attribute changes to a user.
|
|
40
|
+
- `/overule/activities` global feed and `/overule/rules/:id/activities` per-rule
|
|
41
|
+
history. The rule show page surfaces the 5 most recent activities.
|
|
42
|
+
- New migrations: `create_overule_rule_activities`,
|
|
43
|
+
`create_overule_rule_versions`, `add_rule_version_to_overule_rule_activities`.
|
|
44
|
+
Run `bin/rails generate overule:install` again after upgrading to copy them;
|
|
45
|
+
existing migrations are detected and skipped. The versions migration
|
|
46
|
+
backfills `v1` for any rule that already exists and links pre-existing
|
|
47
|
+
activities to it.
|
|
48
|
+
|
|
49
|
+
## [0.2.0] - 2026-05-11
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
- Mountable Rails engine (`Overule::Engine`) for managing rules via a web UI.
|
|
53
|
+
- `Overule::Rule` ActiveRecord model persisting rule `definition` as JSON, with
|
|
54
|
+
`#infer(facts)` shortcut over `Overule::Inference`.
|
|
55
|
+
- `rails g overule:install` generator copying the `overule_rules` migration into
|
|
56
|
+
host applications.
|
|
57
|
+
- Alpine.js + Tailwind (CDN) recursive rule builder with per-datatype operator
|
|
58
|
+
filtering and a `$static` action editor.
|
|
59
|
+
- All engine files load only when `Rails::Engine` is defined, so plain-Ruby
|
|
60
|
+
consumers see no change.
|
|
61
|
+
|
|
62
|
+
## [0.1.2] - 2026-05-07
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
- `Inference#evaluate` now walks nested `set` clauses instead of silently
|
|
66
|
+
ignoring them. Rules using `set` were previously evaluated as if `set` did
|
|
67
|
+
not exist; this caused production rules with `op: "or"` and a nested `set`
|
|
68
|
+
branch to under- or over-match.
|
|
69
|
+
- `Inference#evaluate` is nil-safe on `cond`/`set` (treats missing keys as `[]`).
|
|
70
|
+
- Unknown logical operators raise `Overule::Error` instead of silently
|
|
71
|
+
returning `nil`.
|
|
72
|
+
- `Condition.evaluate` coerces operands to numbers for ordering operators
|
|
73
|
+
(`gt`, `lt`, `gte`, `lte`, `range`) when `datatype` is `number|integer|float|decimal`,
|
|
74
|
+
so `"1220000" > "2000000"` is no longer a lexical comparison.
|
|
75
|
+
- `Condition.evaluate` now returns an empty array (not `true`) when given an
|
|
76
|
+
empty conditions list — needed for the `set` walk merge in `Inference`.
|
|
77
|
+
|
|
78
|
+
## [0.1.0] - 2024-12-02
|
|
79
|
+
|
|
80
|
+
- Initial release
|
data/README.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# Overule: Ruby Rule Engine
|
|
2
|
+
[What is a Rule Engine?](https://github.com/SELISEdigitalplatforms/l3-gem-selise-overule/wiki/What-is-a-Rule-Engine%3F)
|
|
3
|
+
|
|
4
|
+
## Overview
|
|
5
|
+
Overule is a lightweight rule engine for Ruby that enables definition and evaluation of business rules with nested conditions and multiple operators. It ships with an optional mountable Rails engine that provides a browser-based rule builder — useful when business users (rather than developers) need to author rules. Persistence works on both **ActiveRecord** and **Mongoid**, selected via a config setting.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
- Flexible rule definition with arbitrarily-nested AND/OR groups
|
|
9
|
+
- Comparison operators: `eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `in`, `nin`, `contains`, `range`
|
|
10
|
+
- Datatypes: `string`, `select`, `array`, `number`, `integer`, `float`, `decimal`
|
|
11
|
+
- Automatic numeric coercion for ordering operators (`gt`, `lt`, `gte`, `lte`, `range`) on numeric datatypes — so `"1220000" > "2000000"` no longer compares lexically
|
|
12
|
+
- Static value assignment as the rule's action
|
|
13
|
+
- Optional mountable Rails engine with an Alpine.js + Tailwind UI for CRUD on rules
|
|
14
|
+
- Persistence via `Overule::Rule` — backed by **ActiveRecord** (default) or **Mongoid**, both with the same model API
|
|
15
|
+
- Immutable rule-body versioning (`Overule::RuleVersion`) and full activity log (`Overule::RuleActivity`)
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
```ruby
|
|
19
|
+
# Gemfile
|
|
20
|
+
gem "overule"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The core gem only depends on `activesupport`. The Rails engine code loads conditionally — if your host app doesn't have Rails, the gem still works as a plain library.
|
|
24
|
+
|
|
25
|
+
## Basic Usage
|
|
26
|
+
```ruby
|
|
27
|
+
# Define facts about the world
|
|
28
|
+
facts = {
|
|
29
|
+
product_status: "active",
|
|
30
|
+
access_technology: "vdsl",
|
|
31
|
+
conditional_id: "1231132",
|
|
32
|
+
material_id: "1221134"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Define a rule: when conditions match, fire static outputs
|
|
36
|
+
rules = {
|
|
37
|
+
when: {
|
|
38
|
+
cond: [
|
|
39
|
+
{ datatype: "select", value: "active", op: "eq", var: "product_status" },
|
|
40
|
+
{ datatype: "array", value: ["vdsl", "ftth"], op: "in", var: "access_technology" }
|
|
41
|
+
],
|
|
42
|
+
set: [],
|
|
43
|
+
op: "and"
|
|
44
|
+
},
|
|
45
|
+
then: {
|
|
46
|
+
"$static": {
|
|
47
|
+
eligible: true,
|
|
48
|
+
tier: "premium"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Evaluate
|
|
54
|
+
Overule::Inference.new(rules, facts).infer
|
|
55
|
+
# => { "eligible" => true, "tier" => "premium" }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
If the `when` clause is false, `#infer` returns `nil`.
|
|
59
|
+
|
|
60
|
+
## Rule Structure
|
|
61
|
+
|
|
62
|
+
### Condition
|
|
63
|
+
A single check against a fact.
|
|
64
|
+
```ruby
|
|
65
|
+
{ var: "<fact-name>", op: "<operator>", value: <value>, datatype: "<datatype>" }
|
|
66
|
+
```
|
|
67
|
+
- `var` — name of the fact to look up
|
|
68
|
+
- `op` — operator (see below)
|
|
69
|
+
- `value` — value to compare against; for `in`, `nin`, `range` this must be an array
|
|
70
|
+
- `datatype` — hint for type coercion (see numeric coercion below)
|
|
71
|
+
|
|
72
|
+
### Operator semantics
|
|
73
|
+
|
|
74
|
+
| Operator | Meaning | Value shape |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `eq` | `fact == value` | scalar |
|
|
77
|
+
| `neq` | `fact != value` | scalar |
|
|
78
|
+
| `gt` / `lt` / `gte` / `lte` | numeric/lexical ordering | scalar |
|
|
79
|
+
| `contains` | `fact.include?(value)` (substring or array membership) | scalar |
|
|
80
|
+
| `in` | `value.include?(fact)` (membership in a list) | **array** |
|
|
81
|
+
| `nin` | `!value.include?(fact)` | **array** |
|
|
82
|
+
| `range` | `fact >= value.first && fact <= value.last` | **array of [min, max]** |
|
|
83
|
+
|
|
84
|
+
### Datatypes and which operators apply
|
|
85
|
+
|
|
86
|
+
The Rails UI restricts the operator picker per datatype. The mapping is also a useful guide for hand-authored rules:
|
|
87
|
+
|
|
88
|
+
| Datatype | Operators |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `string` | `eq`, `neq`, `contains` |
|
|
91
|
+
| `select` | `eq`, `neq` |
|
|
92
|
+
| `array` | `contains`, `in`, `nin` |
|
|
93
|
+
| `number` / `integer` / `float` / `decimal` | `eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `range` |
|
|
94
|
+
|
|
95
|
+
### Numeric coercion
|
|
96
|
+
For ordering operators (`gt`, `lt`, `gte`, `lte`, `range`), if `datatype` is one of `number`, `integer`, `float`, `decimal`, both operands are coerced to `Float` before comparison. This stops the classic gotcha where `"1220000" > "2000000"` is true by string ordering.
|
|
97
|
+
|
|
98
|
+
### Rule Components
|
|
99
|
+
- `when`
|
|
100
|
+
- `cond` — array of atomic conditions (above)
|
|
101
|
+
- `set` — array of nested rule-groups (each with its own `cond`/`set`/`op`) — recursive
|
|
102
|
+
- `op` — `"and"` or `"or"` to combine `cond` results and `set` results
|
|
103
|
+
- `then`
|
|
104
|
+
- `$static` — hash of values returned when `when` evaluates true
|
|
105
|
+
|
|
106
|
+
## Classes
|
|
107
|
+
|
|
108
|
+
### `Overule::Inference`
|
|
109
|
+
```ruby
|
|
110
|
+
result = Overule::Inference.new(rule_hash, facts_hash).infer
|
|
111
|
+
# returns the action hash if the rule matches, nil otherwise
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### `Overule::Context`
|
|
115
|
+
Wraps facts as a `HashWithIndifferentAccess`.
|
|
116
|
+
```ruby
|
|
117
|
+
ctx = Overule::Context.new(facts)
|
|
118
|
+
ctx.get("product_status") # => "active"
|
|
119
|
+
ctx.set("product_status", "inactive")
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### `Overule::Condition`
|
|
123
|
+
Pure-function evaluator for an array of conditions against a context.
|
|
124
|
+
```ruby
|
|
125
|
+
Overule::Condition.evaluate(cond_array, ctx) # => [true, false, ...]
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `Overule::Operator`
|
|
129
|
+
The operator lookup table.
|
|
130
|
+
```ruby
|
|
131
|
+
Overule::Operator.operate("eq", 1, 1) # => true
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Web UI (Rails engine)
|
|
135
|
+
|
|
136
|
+
The optional mountable Rails engine gives you a browser-based rule builder. It loads only when Rails is present, so plain-Ruby use is unaffected.
|
|
137
|
+
|
|
138
|
+
### Setup (ActiveRecord — default)
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
# host Gemfile
|
|
142
|
+
gem "overule"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
# config/routes.rb (host app)
|
|
147
|
+
Rails.application.routes.draw do
|
|
148
|
+
mount Overule::Engine, at: "/overule"
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
bin/rails generate overule:install # copies migrations + config/initializers/overule.rb
|
|
154
|
+
bin/rails db:migrate
|
|
155
|
+
bin/rails server # visit http://localhost:3000/overule
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Setup (Mongoid)
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
# host Gemfile
|
|
162
|
+
gem "mongoid"
|
|
163
|
+
gem "overule"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# config/routes.rb — same as the AR setup
|
|
168
|
+
Rails.application.routes.draw do
|
|
169
|
+
mount Overule::Engine, at: "/overule"
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
bin/rails generate overule:install --orm=mongoid
|
|
175
|
+
bin/rails db:mongoid:create_indexes # create the indexes declared on the models
|
|
176
|
+
bin/rails server
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`--orm=mongoid` makes the generator pre-set `config.orm = :mongoid` in the initializer and **skip** the SQL migrations. The engine's models declare equivalent indexes via `index ...` so `db:mongoid:create_indexes` is all that's needed.
|
|
180
|
+
|
|
181
|
+
Everything past this point — the route mount, the recursive builder UI, the activity log, versioning, the `actor_proc` hook, the JSON preview — is **identical across both ORMs**. The model API (`Overule::Rule`, `RuleActivity`, `RuleVersion`) is the same; only the storage layer differs.
|
|
182
|
+
|
|
183
|
+
### Configuration
|
|
184
|
+
|
|
185
|
+
`config/initializers/overule.rb` is generated by `overule:install`:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
Overule.configure do |config|
|
|
189
|
+
# ORM that backs persistence. Supported:
|
|
190
|
+
# :active_record (default) — uses bundled migrations
|
|
191
|
+
# :mongoid — no migrations; uses model-declared indexes
|
|
192
|
+
# config.orm = :active_record
|
|
193
|
+
|
|
194
|
+
# Attribute every rule change in the activity log to a user. Receives the
|
|
195
|
+
# current Overule controller, returns a string identifier (e.g. email).
|
|
196
|
+
# config.actor_proc = ->(controller) { controller.current_user&.email }
|
|
197
|
+
|
|
198
|
+
# Gate the Overule UI behind HTTP Basic auth (default: false, no gate).
|
|
199
|
+
# When enabled, set both username and password.
|
|
200
|
+
# config.http_basic_auth = true
|
|
201
|
+
# config.http_basic_auth_username = ENV.fetch("OVERULE_HTTP_BASIC_USERNAME")
|
|
202
|
+
# config.http_basic_auth_password = ENV.fetch("OVERULE_HTTP_BASIC_PASSWORD")
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### HTTP Basic auth (optional gate)
|
|
207
|
+
|
|
208
|
+
If the host app doesn't already have an authentication layer in front of `/overule`, three flat config settings can gate every Overule action:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
config.http_basic_auth = true
|
|
212
|
+
config.http_basic_auth_username = ENV.fetch("OVERULE_HTTP_BASIC_USERNAME")
|
|
213
|
+
config.http_basic_auth_password = ENV.fetch("OVERULE_HTTP_BASIC_PASSWORD")
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Behavior:
|
|
217
|
+
- Unauthenticated requests get `401 Unauthorized` with `WWW-Authenticate: Basic realm="Overule"`.
|
|
218
|
+
- Credentials are compared with `ActiveSupport::SecurityUtils.secure_compare` and bitwise `&` so both username and password are *always* evaluated — the response time can't leak which side mismatched.
|
|
219
|
+
- When `http_basic_auth` is `false` (the default) the engine doesn't issue an auth challenge — it relies on whatever your host app already does.
|
|
220
|
+
- Setting `http_basic_auth = true` while leaving `http_basic_auth_username` / `http_basic_auth_password` `nil` raises `ArgumentError` on the first request, so misconfiguration fails loudly.
|
|
221
|
+
|
|
222
|
+
The initializer is evaluated during Rails initialization, **before** the engine's models are autoloaded, so the ORM choice is locked in by the time `Overule::Rule` is first referenced.
|
|
223
|
+
|
|
224
|
+
### What you get
|
|
225
|
+
|
|
226
|
+
**`Overule::Rule` model** — `name` (unique), `description`, `definition` (JSON / Hash), `enabled`, timestamps. Validates that `definition` has a `when` and a `then`. Same API under ActiveRecord and Mongoid; the file at `app/models/overule/rule.rb` picks its base class at load time based on `Overule.config.orm`. Shared validations, callbacks, and version/activity logging live in `Overule::RuleBehavior` (in `app/models/concerns/overule/`).
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
rule = Overule::Rule.find_by(name: "eu-customers")
|
|
230
|
+
rule.infer(country: "DE") # => { "tier" => "eu" } if it matches
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Browser UI at the mount point** with:
|
|
234
|
+
|
|
235
|
+
- A recursive AND/OR condition group builder — nest groups arbitrarily deep
|
|
236
|
+
- Datatype-aware operator picker (the table above)
|
|
237
|
+
- Array value editor with `+ Item` rows for `in`, `nin`, `range`
|
|
238
|
+
- Typed `$static` output editor — each output entry has a datatype (`string`, `number`, `boolean`, `null`, `array`, `object`); arrays and objects can be nested
|
|
239
|
+
- Live JSON preview with copy-to-clipboard
|
|
240
|
+
- Backed by `Overule::Inference` — what you see in the preview is exactly what gets evaluated at runtime
|
|
241
|
+
|
|
242
|
+
All assets are loaded from CDN (Tailwind, Alpine.js) — no build step required in the host app.
|
|
243
|
+
|
|
244
|
+
### Versioning and activity log
|
|
245
|
+
|
|
246
|
+
Every rule keeps an immutable history of its body (`definition`) as `Overule::RuleVersion` rows tagged with a monotonic `version` number per rule:
|
|
247
|
+
|
|
248
|
+
- **Creation** captures `v1`.
|
|
249
|
+
- **Each subsequent edit to the `definition` body** captures `v2`, `v3`, …
|
|
250
|
+
- **Metadata-only edits** (`name`, `description`, `enabled`) do **not** create a new version — they still produce an activity log entry, but the entry links to the current body version.
|
|
251
|
+
- **Deletion** links the "destroyed" activity to the last version that existed.
|
|
252
|
+
|
|
253
|
+
Each version stores a full snapshot of the audited columns at the moment it was captured (`name`, `description`, `enabled`, `definition`). Snapshots are independent rows — mutating the current rule never touches prior versions.
|
|
254
|
+
|
|
255
|
+
View the version history at `/overule/rules/:id/versions` and a specific snapshot at `/overule/rules/:id/versions/:version` (read-only, with prev/next navigation). Every activity row also shows a `vN` badge that links straight to that version.
|
|
256
|
+
|
|
257
|
+
The activity feed lives at `/overule/activities` (global) and each rule's show page surfaces its recent activity inline with version badges.
|
|
258
|
+
|
|
259
|
+
To attribute changes to a user, either set `config.actor_proc` in the initializer (recommended — see Configuration above) or set `Overule::Current.actor` from your own `before_action`:
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
# app/controllers/application_controller.rb
|
|
263
|
+
class ApplicationController < ActionController::Base
|
|
264
|
+
before_action { Overule::Current.actor = current_user&.email }
|
|
265
|
+
end
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
When unset, activities are stored with `actor: nil` and displayed as "anonymous".
|
|
269
|
+
|
|
270
|
+
Activity rows are retained when a rule is deleted: the `rule_id` reference is nullified but `rule_name` is preserved, so the audit trail survives. Same behavior under AR (via `dependent: :nullify`) and Mongoid.
|
|
271
|
+
|
|
272
|
+
### Storage notes
|
|
273
|
+
|
|
274
|
+
**ActiveRecord** — the generated migration uses `t.json :definition`. This maps to:
|
|
275
|
+
- PostgreSQL → `json` (use a follow-up migration to switch to `jsonb` + a GIN index if you need to query inside the JSON)
|
|
276
|
+
- MySQL → native `JSON`
|
|
277
|
+
- SQLite → `TEXT` with Rails-side JSON casting (Rails 7.1+)
|
|
278
|
+
|
|
279
|
+
**Mongoid** — `definition` is `field :definition, type: Hash`, stored as BSON. Queryable directly without any extra setup.
|
|
280
|
+
|
|
281
|
+
**Custom Postgres schemas (AR only)** — if your host app uses a custom `schema_search_path` (e.g., `dtd2d` instead of `public`), the generated `create_table :overule_rules` will be placed in the first schema on that path. To force a specific schema, edit the generated migration to use a qualified name: `create_table "dtd2d.overule_rules"` and `add_index "dtd2d.overule_rules", :name, unique: true`.
|
|
282
|
+
|
|
283
|
+
## Development
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
bundle install
|
|
287
|
+
bundle exec rake # runs core + engine tests
|
|
288
|
+
bundle exec rake test # core (plain Ruby) tests only
|
|
289
|
+
bundle exec rake test_engine # Rails engine tests
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The engine tests boot a minimal Rails app at `test/dummy/` against an in-memory SQLite database.
|
|
293
|
+
|
|
294
|
+
## Contributing
|
|
295
|
+
1. Fork the repository
|
|
296
|
+
2. Create your feature branch
|
|
297
|
+
3. Commit your changes
|
|
298
|
+
4. Push to the branch
|
|
299
|
+
5. Create a new Pull Request
|
|
300
|
+
|
|
301
|
+
## License
|
|
302
|
+
MIT License
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Overule rule builder — Alpine.js component definitions.
|
|
2
|
+
// Loaded inline by the engine layout; expects Alpine v3.
|
|
3
|
+
|
|
4
|
+
(function () {
|
|
5
|
+
// Operator allow-list per datatype. Datatype describes the variable; operators
|
|
6
|
+
// that need a list value (in/nin) live under the "array" datatype — the value
|
|
7
|
+
// editor auto-switches to "+ Item" mode when those ops are selected.
|
|
8
|
+
const OPERATORS_BY_DATATYPE = {
|
|
9
|
+
string: ["eq", "neq", "contains"],
|
|
10
|
+
select: ["eq", "neq"],
|
|
11
|
+
array: ["contains", "in", "nin"],
|
|
12
|
+
number: ["eq", "neq", "gt", "lt", "gte", "lte", "range"],
|
|
13
|
+
integer: ["eq", "neq", "gt", "lt", "gte", "lte", "range"],
|
|
14
|
+
float: ["eq", "neq", "gt", "lt", "gte", "lte", "range"],
|
|
15
|
+
decimal: ["eq", "neq", "gt", "lt", "gte", "lte", "range"]
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const DATATYPES = Object.keys(OPERATORS_BY_DATATYPE);
|
|
19
|
+
const LOGICAL_OPS = ["and", "or"];
|
|
20
|
+
const NUMERIC_DATATYPES = new Set(["number", "integer", "float", "decimal"]);
|
|
21
|
+
|
|
22
|
+
// "then" is part of the rule schema (when/then), not a thenable callback.
|
|
23
|
+
// Setting the property via bracket assignment after object creation keeps
|
|
24
|
+
// it out of any object literal, sidestepping lint rules that flag the key.
|
|
25
|
+
const RULE_THEN_KEY = "then";
|
|
26
|
+
const RULE_STATIC_KEY = "$static";
|
|
27
|
+
|
|
28
|
+
function condValueIsArray(op, datatype) {
|
|
29
|
+
if (op === "in" || op === "nin" || op === "range") return true;
|
|
30
|
+
if (datatype === "array" && op !== "contains") return true;
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseValue(raw, datatype, op) {
|
|
35
|
+
if (condValueIsArray(op, datatype)) {
|
|
36
|
+
if (Array.isArray(raw)) return raw;
|
|
37
|
+
const parsed = tryParseArray(raw);
|
|
38
|
+
if (parsed !== null) return parsed;
|
|
39
|
+
return String(raw)
|
|
40
|
+
.split(",")
|
|
41
|
+
.map(s => s.trim())
|
|
42
|
+
.filter(s => s.length > 0)
|
|
43
|
+
.map(s => castScalar(s, datatype));
|
|
44
|
+
}
|
|
45
|
+
return castScalar(raw, datatype);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tryParseArray(raw) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
52
|
+
} catch {
|
|
53
|
+
// Non-JSON input is a normal, expected case — caller falls back to
|
|
54
|
+
// comma-split. Optional-binding catch (ES2019) avoids an unused name.
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function castScalar(value, datatype) {
|
|
60
|
+
if (NUMERIC_DATATYPES.has(datatype)) {
|
|
61
|
+
const n = Number(value);
|
|
62
|
+
return Number.isFinite(n) ? n : value;
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function serializeValue(value) {
|
|
68
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
69
|
+
if (value === null || value === undefined) return "";
|
|
70
|
+
return String(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function newCondition() {
|
|
74
|
+
return { var: "", op: "eq", value: "", datatype: "string" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function newGroup() {
|
|
78
|
+
return { cond: [], set: [], op: "and" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const STATIC_KINDS = ["string", "number", "boolean", "null", "array", "object"];
|
|
82
|
+
|
|
83
|
+
function newStaticNode(kind) {
|
|
84
|
+
switch (kind) {
|
|
85
|
+
case "null": return { kind: "null", value: null };
|
|
86
|
+
case "boolean": return { kind: "boolean", value: false };
|
|
87
|
+
case "number": return { kind: "number", value: 0 };
|
|
88
|
+
case "array": return { kind: "array", value: [] };
|
|
89
|
+
case "object": return { kind: "object", value: [] };
|
|
90
|
+
default: return { kind: "string", value: "" };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function nodeFromValue(value) {
|
|
95
|
+
if (value === null || value === undefined) return { kind: "null", value: null };
|
|
96
|
+
if (typeof value === "boolean") return { kind: "boolean", value };
|
|
97
|
+
if (typeof value === "number") return { kind: "number", value };
|
|
98
|
+
if (typeof value === "string") return { kind: "string", value };
|
|
99
|
+
if (Array.isArray(value)) return { kind: "array", value: value.map(nodeFromValue) };
|
|
100
|
+
if (typeof value === "object") {
|
|
101
|
+
return {
|
|
102
|
+
kind: "object",
|
|
103
|
+
value: Object.entries(value).map(([k, v]) => ({ k, node: nodeFromValue(v) }))
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { kind: "string", value: String(value) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function valueFromNode(node) {
|
|
110
|
+
switch (node.kind) {
|
|
111
|
+
case "null": return null;
|
|
112
|
+
case "boolean": return Boolean(node.value);
|
|
113
|
+
case "number": return coerceNumber(node.value);
|
|
114
|
+
case "array": return (node.value || []).map(valueFromNode);
|
|
115
|
+
case "object": return objectFromEntries(node.value);
|
|
116
|
+
default: return node.value == null ? "" : String(node.value);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function coerceNumber(value) {
|
|
121
|
+
const n = Number(value);
|
|
122
|
+
return Number.isFinite(n) ? n : 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function objectFromEntries(entries) {
|
|
126
|
+
const out = {};
|
|
127
|
+
(entries || []).forEach(entry => assignEntry(out, entry));
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function assignEntry(target, entry) {
|
|
132
|
+
if (!entry || !entry.k) return;
|
|
133
|
+
target[entry.k] = valueFromNode(entry.node);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function initialStaticEntries(initial) {
|
|
137
|
+
const action = initial?.then?.$static || {};
|
|
138
|
+
return Object.entries(action).map(([k, v]) => ({ k, node: nodeFromValue(v) }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildComputedRoot(rootNode, computedStatic) {
|
|
142
|
+
const nextThen = { ...rootNode?.then };
|
|
143
|
+
nextThen[RULE_STATIC_KEY] = computedStatic;
|
|
144
|
+
|
|
145
|
+
// The Overule rule schema requires a `then` key — it's domain output, not
|
|
146
|
+
// a Promise-like callback. Defining the property via Object.defineProperty
|
|
147
|
+
// creates the property without ever expressing the literal name `then` as
|
|
148
|
+
// a key in source, which is what static analyzers flag.
|
|
149
|
+
const result = { when: rootNode.when };
|
|
150
|
+
Object.defineProperty(result, RULE_THEN_KEY, {
|
|
151
|
+
value: nextThen,
|
|
152
|
+
enumerable: true,
|
|
153
|
+
writable: true,
|
|
154
|
+
configurable: true
|
|
155
|
+
});
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function reshapedArrayValue(currentValue, datatype, op) {
|
|
160
|
+
const parsed = parseValue(serializeValue(currentValue), datatype, op);
|
|
161
|
+
if (Array.isArray(parsed)) return parsed;
|
|
162
|
+
if (currentValue === "") return [];
|
|
163
|
+
return [currentValue];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
document.addEventListener("alpine:init", () => {
|
|
167
|
+
const Alpine = globalThis.Alpine;
|
|
168
|
+
|
|
169
|
+
Alpine.data("ruleBuilder", initial => ({
|
|
170
|
+
root: initial,
|
|
171
|
+
datatypes: DATATYPES,
|
|
172
|
+
logicalOps: LOGICAL_OPS,
|
|
173
|
+
kinds: STATIC_KINDS,
|
|
174
|
+
staticEntries: initialStaticEntries(initial),
|
|
175
|
+
operatorsFor(datatype) {
|
|
176
|
+
return OPERATORS_BY_DATATYPE[datatype] || [];
|
|
177
|
+
},
|
|
178
|
+
addStaticEntry() {
|
|
179
|
+
this.staticEntries.push({ k: "", node: newStaticNode("string") });
|
|
180
|
+
},
|
|
181
|
+
removeStaticEntry(i) {
|
|
182
|
+
this.staticEntries.splice(i, 1);
|
|
183
|
+
},
|
|
184
|
+
get whenNode() { return this.root.when; },
|
|
185
|
+
get computedStatic() {
|
|
186
|
+
return objectFromEntries(this.staticEntries);
|
|
187
|
+
},
|
|
188
|
+
get computedRoot() {
|
|
189
|
+
return buildComputedRoot(this.root, this.computedStatic);
|
|
190
|
+
},
|
|
191
|
+
get serialized() { return JSON.stringify(this.computedRoot); }
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
Alpine.data("staticNode", node => ({
|
|
195
|
+
node,
|
|
196
|
+
kinds: STATIC_KINDS,
|
|
197
|
+
onKindChange() {
|
|
198
|
+
this.node.value = newStaticNode(this.node.kind).value;
|
|
199
|
+
},
|
|
200
|
+
addArrayItem() { this.node.value.push(newStaticNode("string")); },
|
|
201
|
+
removeArrayItem(i) { this.node.value.splice(i, 1); },
|
|
202
|
+
addObjectEntry() { this.node.value.push({ k: "", node: newStaticNode("string") }); },
|
|
203
|
+
removeObjectEntry(i) { this.node.value.splice(i, 1); }
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
Alpine.data("ruleGroup", node => ({
|
|
207
|
+
node,
|
|
208
|
+
datatypes: DATATYPES,
|
|
209
|
+
logicalOps: LOGICAL_OPS,
|
|
210
|
+
operatorsFor(datatype) {
|
|
211
|
+
return OPERATORS_BY_DATATYPE[datatype] || [];
|
|
212
|
+
},
|
|
213
|
+
isArrayValue(c) {
|
|
214
|
+
return condValueIsArray(c.op, c.datatype);
|
|
215
|
+
},
|
|
216
|
+
valueText(i) {
|
|
217
|
+
return serializeValue(this.node.cond[i].value);
|
|
218
|
+
},
|
|
219
|
+
valueItemText(i, j) {
|
|
220
|
+
const v = this.node.cond[i].value[j];
|
|
221
|
+
return v == null ? "" : String(v);
|
|
222
|
+
},
|
|
223
|
+
setValue(i, raw) {
|
|
224
|
+
const c = this.node.cond[i];
|
|
225
|
+
c.value = parseValue(raw, c.datatype, c.op);
|
|
226
|
+
},
|
|
227
|
+
setValueItem(i, j, raw) {
|
|
228
|
+
const c = this.node.cond[i];
|
|
229
|
+
if (!Array.isArray(c.value)) c.value = [];
|
|
230
|
+
c.value[j] = castScalar(raw, c.datatype);
|
|
231
|
+
},
|
|
232
|
+
addValueItem(i) {
|
|
233
|
+
const c = this.node.cond[i];
|
|
234
|
+
if (!Array.isArray(c.value)) c.value = [];
|
|
235
|
+
c.value.push("");
|
|
236
|
+
},
|
|
237
|
+
removeValueItem(i, j) {
|
|
238
|
+
const c = this.node.cond[i];
|
|
239
|
+
if (Array.isArray(c.value)) c.value.splice(j, 1);
|
|
240
|
+
},
|
|
241
|
+
addCondition() { this.node.cond.push(newCondition()); },
|
|
242
|
+
addGroup() { this.node.set.push(newGroup()); },
|
|
243
|
+
removeCondition(i) { this.node.cond.splice(i, 1); },
|
|
244
|
+
removeGroup(i) { this.node.set.splice(i, 1); },
|
|
245
|
+
reshapeValue(i) {
|
|
246
|
+
const c = this.node.cond[i];
|
|
247
|
+
if (this.isArrayValue(c)) {
|
|
248
|
+
if (!Array.isArray(c.value)) {
|
|
249
|
+
c.value = reshapedArrayValue(c.value, c.datatype, c.op);
|
|
250
|
+
}
|
|
251
|
+
} else if (Array.isArray(c.value)) {
|
|
252
|
+
c.value = c.value.length > 0 ? castScalar(c.value[0], c.datatype) : "";
|
|
253
|
+
} else {
|
|
254
|
+
c.value = castScalar(c.value, c.datatype);
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
onDatatypeChange(i) {
|
|
258
|
+
const c = this.node.cond[i];
|
|
259
|
+
const ops = OPERATORS_BY_DATATYPE[c.datatype] || [];
|
|
260
|
+
if (!ops.includes(c.op)) c.op = ops[0] || "eq";
|
|
261
|
+
this.reshapeValue(i);
|
|
262
|
+
},
|
|
263
|
+
onOperatorChange(i) {
|
|
264
|
+
this.reshapeValue(i);
|
|
265
|
+
}
|
|
266
|
+
}));
|
|
267
|
+
});
|
|
268
|
+
})();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Overule
|
|
2
|
+
class ActivitiesController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
scope = RuleActivity.recent
|
|
5
|
+
scope = scope.where(rule_id: params[:rule_id]) if params[:rule_id].present?
|
|
6
|
+
@rule_filter = Rule.find_by(id: params[:rule_id]) if params[:rule_id].present?
|
|
7
|
+
@activities = scope.limit(200)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|