acts_as_calculator 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/.rspec +3 -0
- data/.rubocop.yml +38 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +12 -0
- data/app/controllers/acts_as_calculator/application_controller.rb +76 -0
- data/app/controllers/acts_as_calculator/formula_versions_controller.rb +50 -0
- data/app/controllers/acts_as_calculator/formulas_controller.rb +58 -0
- data/app/controllers/acts_as_calculator/imports_controller.rb +33 -0
- data/app/controllers/acts_as_calculator/templates_controller.rb +66 -0
- data/app/models/acts_as_calculator/formula.rb +21 -0
- data/app/models/acts_as_calculator/formula_version.rb +88 -0
- data/app/models/acts_as_calculator/lookup_table.rb +25 -0
- data/app/models/acts_as_calculator/lookup_table_entry.rb +26 -0
- data/app/models/acts_as_calculator/record.rb +12 -0
- data/app/models/acts_as_calculator/run.rb +24 -0
- data/app/models/acts_as_calculator/template.rb +56 -0
- data/app/models/acts_as_calculator/variable.rb +20 -0
- data/config/routes.rb +36 -0
- data/lib/acts_as_calculator/aggregate_results.rb +57 -0
- data/lib/acts_as_calculator/aggregation.rb +9 -0
- data/lib/acts_as_calculator/apportion_amount.rb +62 -0
- data/lib/acts_as_calculator/apportionment.rb +57 -0
- data/lib/acts_as_calculator/build_calculator.rb +24 -0
- data/lib/acts_as_calculator/build_lookups.rb +37 -0
- data/lib/acts_as_calculator/calculable.rb +84 -0
- data/lib/acts_as_calculator/calculator_cache.rb +36 -0
- data/lib/acts_as_calculator/cast_date.rb +16 -0
- data/lib/acts_as_calculator/cast_decimal.rb +15 -0
- data/lib/acts_as_calculator/cast_json_safe.rb +37 -0
- data/lib/acts_as_calculator/cast_liquid_value.rb +58 -0
- data/lib/acts_as_calculator/cast_variable_attributes.rb +17 -0
- data/lib/acts_as_calculator/configuration.rb +31 -0
- data/lib/acts_as_calculator/distribute_remainder.rb +49 -0
- data/lib/acts_as_calculator/divide_proportionally.rb +27 -0
- data/lib/acts_as_calculator/engine.rb +19 -0
- data/lib/acts_as_calculator/errors.rb +23 -0
- data/lib/acts_as_calculator/evaluate_expression.rb +49 -0
- data/lib/acts_as_calculator/evaluate_formula.rb +53 -0
- data/lib/acts_as_calculator/find_lookup_table_references.rb +68 -0
- data/lib/acts_as_calculator/find_owned_record.rb +38 -0
- data/lib/acts_as_calculator/find_tier.rb +25 -0
- data/lib/acts_as_calculator/formula_version_drop.rb +55 -0
- data/lib/acts_as_calculator/function_registry.rb +83 -0
- data/lib/acts_as_calculator/import_definitions.rb +70 -0
- data/lib/acts_as_calculator/import_formula.rb +102 -0
- data/lib/acts_as_calculator/import_lookup_table.rb +94 -0
- data/lib/acts_as_calculator/import_outcome.rb +20 -0
- data/lib/acts_as_calculator/import_summary.rb +29 -0
- data/lib/acts_as_calculator/import_template.rb +57 -0
- data/lib/acts_as_calculator/liquid_filters.rb +70 -0
- data/lib/acts_as_calculator/persist_run.rb +31 -0
- data/lib/acts_as_calculator/promote_template.rb +21 -0
- data/lib/acts_as_calculator/publish_formula_version.rb +54 -0
- data/lib/acts_as_calculator/publish_template.rb +18 -0
- data/lib/acts_as_calculator/read_import_file.rb +23 -0
- data/lib/acts_as_calculator/render_liquid.rb +113 -0
- data/lib/acts_as_calculator/render_template.rb +43 -0
- data/lib/acts_as_calculator/resolve_formula_version.rb +57 -0
- data/lib/acts_as_calculator/resolve_import_owner.rb +38 -0
- data/lib/acts_as_calculator/resolve_template.rb +41 -0
- data/lib/acts_as_calculator/resolve_variables.rb +105 -0
- data/lib/acts_as_calculator/result.rb +32 -0
- data/lib/acts_as_calculator/result_drop.rb +46 -0
- data/lib/acts_as_calculator/serialize_formula.rb +18 -0
- data/lib/acts_as_calculator/serialize_formula_version.rb +26 -0
- data/lib/acts_as_calculator/serialize_import_summary.rb +21 -0
- data/lib/acts_as_calculator/serialize_template.rb +13 -0
- data/lib/acts_as_calculator/supersede_formula_versions.rb +83 -0
- data/lib/acts_as_calculator/tier.rb +31 -0
- data/lib/acts_as_calculator/variable_spec.rb +33 -0
- data/lib/acts_as_calculator/version.rb +5 -0
- data/lib/acts_as_calculator.rb +78 -0
- data/lib/generators/acts_as_calculator/import/import_generator.rb +26 -0
- data/lib/generators/acts_as_calculator/install/install_generator.rb +31 -0
- data/lib/generators/acts_as_calculator/install/templates/create_acts_as_calculator_tables.rb.tt +133 -0
- data/lib/tasks/acts_as_calculator.rake +21 -0
- data/sig/acts_as_calculator.rbs +4 -0
- metadata +251 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "liquid"
|
|
4
|
+
|
|
5
|
+
module ActsAsCalculator
|
|
6
|
+
# The security boundary. Template bodies are authored by non-developers (eventually
|
|
7
|
+
# through Phase 5's API), so this deliberately renders inside a private
|
|
8
|
+
# Liquid::Environment rather than Liquid's process-global default, which a host app is
|
|
9
|
+
# free to have repointed at a real file system or loaded its own tags into.
|
|
10
|
+
class RenderLiquid
|
|
11
|
+
# `include`/`render` are the only tags that read from outside the assigns. A
|
|
12
|
+
# BlankFileSystem already refuses them; dropping the tags means the template fails at
|
|
13
|
+
# parse time with a syntax error instead of at render time with a file system error.
|
|
14
|
+
FILE_SYSTEM_TAGS = %w[include render].freeze
|
|
15
|
+
|
|
16
|
+
# `tablerow` iterates a collection the same way `for` does, but through its own
|
|
17
|
+
# render_to_output_buffer rather than a subclassable collection_segment. It is a
|
|
18
|
+
# storefront grid-layout tag with no use in rendering a calculation, so it is dropped
|
|
19
|
+
# rather than given a second, version-fragile bounded subclass.
|
|
20
|
+
ITERATING_TAGS = %w[tablerow].freeze
|
|
21
|
+
|
|
22
|
+
# Bounds output size and assign churn. These do NOT bound iteration on their own —
|
|
23
|
+
# render_score counts rendered nodes, so an empty loop body scores zero per pass. That
|
|
24
|
+
# gap is closed by BoundedFor/MAX_ITERATIONS below, not here.
|
|
25
|
+
RESOURCE_LIMITS = {
|
|
26
|
+
render_length_limit: 2_000_000,
|
|
27
|
+
render_score_limit: 200_000,
|
|
28
|
+
assign_score_limit: 2_000_000
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Cumulative across every loop in one render, so nested loops multiply into the same
|
|
32
|
+
# budget instead of each staying under a per-loop cap. Generous for presenting a
|
|
33
|
+
# calculation; a template needing more than this is not a template.
|
|
34
|
+
MAX_ITERATIONS = 10_000
|
|
35
|
+
|
|
36
|
+
class IterationBudget
|
|
37
|
+
def initialize(limit)
|
|
38
|
+
@limit = limit
|
|
39
|
+
@remaining = limit
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Checked against the collection as declared, *before* Liquid reaches Range#to_a on
|
|
43
|
+
# it — `{% for i in (1..2000000000) %}` is a heap bomb no post-hoc count can undo.
|
|
44
|
+
def reserve(collection)
|
|
45
|
+
size = countable_size(collection)
|
|
46
|
+
|
|
47
|
+
exhausted! if !size.nil? && size > remaining
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def spend(count)
|
|
51
|
+
@remaining -= count
|
|
52
|
+
|
|
53
|
+
exhausted! if remaining.negative?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
attr_reader :limit, :remaining
|
|
59
|
+
|
|
60
|
+
def countable_size(collection)
|
|
61
|
+
case collection
|
|
62
|
+
when ::Range, ::Array, ::Hash then collection.size
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Liquid::MemoryError is the one exception BlockBody.rescue_render_node re-raises
|
|
67
|
+
# instead of routing through the error mode, so a resource bound reported this way
|
|
68
|
+
# cannot be swallowed into the rendered output.
|
|
69
|
+
def exhausted!
|
|
70
|
+
raise Liquid::MemoryError, "template exceeded #{limit} loop iterations"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class BoundedFor < Liquid::For
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# The collection expression is evaluated once more than stock Liquid does, to size
|
|
78
|
+
# it before the superclass materialises it. Everything reachable from a template is
|
|
79
|
+
# a Drop, Hash or Array by construction, so re-evaluating is cheap and side-effect
|
|
80
|
+
# free.
|
|
81
|
+
def collection_segment(context)
|
|
82
|
+
budget = context.registers[:iteration_budget]
|
|
83
|
+
budget&.reserve(context.evaluate(@collection_name))
|
|
84
|
+
|
|
85
|
+
super.tap { |segment| budget&.spend(segment.length) }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
SANDBOX_TAGS = Liquid::Tags::STANDARD_TAGS
|
|
90
|
+
.reject { |name, _| (FILE_SYSTEM_TAGS + ITERATING_TAGS).include?(name) }
|
|
91
|
+
.merge("for" => BoundedFor)
|
|
92
|
+
.freeze
|
|
93
|
+
|
|
94
|
+
SANDBOX = Liquid::Environment.build(
|
|
95
|
+
error_mode: :strict,
|
|
96
|
+
file_system: Liquid::BlankFileSystem.new,
|
|
97
|
+
tags: SANDBOX_TAGS
|
|
98
|
+
) do |environment|
|
|
99
|
+
environment.default_resource_limits = RESOURCE_LIMITS
|
|
100
|
+
environment.register_filter(LiquidFilters)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.call(source:, assigns: {})
|
|
104
|
+
Liquid::Template
|
|
105
|
+
.parse(source.to_s, environment: SANDBOX, line_numbers: true)
|
|
106
|
+
.render!(assigns,
|
|
107
|
+
registers: { iteration_budget: IterationBudget.new(MAX_ITERATIONS) },
|
|
108
|
+
strict_filters: true)
|
|
109
|
+
rescue Liquid::Error => e
|
|
110
|
+
raise TemplateRenderError, e.to_s
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class RenderTemplate
|
|
5
|
+
def self.call(...)
|
|
6
|
+
new(...).call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(key: nil, scope: nil, owner: nil, version_number: nil,
|
|
10
|
+
template: nil, result: nil, results: {}, context: {})
|
|
11
|
+
@key = key
|
|
12
|
+
@scope = scope
|
|
13
|
+
@owner = owner
|
|
14
|
+
@version_number = version_number
|
|
15
|
+
@template = template
|
|
16
|
+
@result = result
|
|
17
|
+
@results = results
|
|
18
|
+
@context = context
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
RenderLiquid.(source: template.body, assigns:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :key, :scope, :owner, :version_number, :result, :results, :context
|
|
28
|
+
|
|
29
|
+
def template
|
|
30
|
+
@template ||= ResolveTemplate.(key:, scope:, owner:, version_number:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Context is merged under the result assigns rather than over them: a host that happens
|
|
34
|
+
# to pass a `result` key in context must not be able to shadow the calculation the
|
|
35
|
+
# template exists to display.
|
|
36
|
+
def assigns
|
|
37
|
+
CastLiquidValue.(context).merge(
|
|
38
|
+
"result" => CastLiquidValue.(result),
|
|
39
|
+
"results" => CastLiquidValue.(results)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class ResolveFormulaVersion
|
|
5
|
+
def self.call(...)
|
|
6
|
+
new(...).call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(key:, scope: nil, owner: nil, as_of: nil)
|
|
10
|
+
@key = key.to_s
|
|
11
|
+
@scope = (scope || DEFAULT_SCOPE).to_s
|
|
12
|
+
@owner = owner
|
|
13
|
+
@as_of = CastDate.(as_of || Date.current)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
covering_version || raise_no_effective_version
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :key, :scope, :owner, :as_of
|
|
23
|
+
|
|
24
|
+
def formula
|
|
25
|
+
@formula ||= FindOwnedRecord.(relation: Formula.all, key:, scope:, owner:) || raise_formula_not_found
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def covering_version
|
|
29
|
+
formula.versions.active.covering(as_of).order(effective_from: :desc, id: :desc).first
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def raise_formula_not_found
|
|
33
|
+
raise FormulaNotFoundError, "no formula #{key.inspect} in scope #{scope.inspect} for #{describe_owner}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Deliberately not "fall back to the most recent active version" — that would apply
|
|
37
|
+
# today's rules to a date they were never in force for, which is the exact failure
|
|
38
|
+
# effective-dating exists to prevent (docs/PLAN.md, "Data model").
|
|
39
|
+
def raise_no_effective_version
|
|
40
|
+
raise NoEffectiveVersionError,
|
|
41
|
+
"formula #{key.inspect} in scope #{scope.inspect} has no active version " \
|
|
42
|
+
"covering #{as_of.iso8601} (versions: #{described_versions})"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def describe_owner
|
|
46
|
+
owner.nil? ? "no owner" : "#{owner.class}##{owner.id} (or globally)"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def described_versions
|
|
50
|
+
ranges = formula.versions.active.order(:effective_from).map do |version|
|
|
51
|
+
"#{version.effective_from.iso8601}..#{version.effective_to&.iso8601 || "open"}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ranges.empty? ? "none active" : ranges.join(", ")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
# `{"owner": {"type": "Company", "id": 7}}` — omitted means the global (nil-owner) row,
|
|
5
|
+
# which is what a single-tenant host wants and what `FindOwnedRecord` falls back to.
|
|
6
|
+
class ResolveImportOwner
|
|
7
|
+
def self.call(reference)
|
|
8
|
+
return nil if reference.nil?
|
|
9
|
+
|
|
10
|
+
attributes = symbolize(reference)
|
|
11
|
+
model = model_for(attributes[:type])
|
|
12
|
+
id = attributes[:id]
|
|
13
|
+
raise ImportError, "owner reference #{reference.inspect} needs both a type and an id" if id.nil?
|
|
14
|
+
|
|
15
|
+
model.find_by(model.primary_key => id) ||
|
|
16
|
+
raise(ImportError, "no #{model.name} with id #{id.inspect} to own the imported record")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.symbolize(reference)
|
|
20
|
+
raise ImportError, "owner must be an object like {\"type\": \"Company\", \"id\": 1}" unless reference.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
reference.transform_keys(&:to_sym)
|
|
23
|
+
end
|
|
24
|
+
private_class_method :symbolize
|
|
25
|
+
|
|
26
|
+
# An import file is operator-authored, but it is still data — resolving an arbitrary
|
|
27
|
+
# string to a constant is only safe if the result is checked, not just loaded.
|
|
28
|
+
def self.model_for(type)
|
|
29
|
+
model = type.to_s.safe_constantize
|
|
30
|
+
unless model.is_a?(Class) && model < ::ActiveRecord::Base
|
|
31
|
+
raise ImportError, "owner type #{type.inspect} is not an ActiveRecord model"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
model
|
|
35
|
+
end
|
|
36
|
+
private_class_method :model_for
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class ResolveTemplate
|
|
5
|
+
def self.call(...)
|
|
6
|
+
new(...).call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(key:, scope: nil, owner: nil, version_number: nil)
|
|
10
|
+
@key = key.to_s
|
|
11
|
+
@scope = (scope || DEFAULT_SCOPE).to_s
|
|
12
|
+
@owner = owner
|
|
13
|
+
@version_number = version_number
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
FindOwnedRecord.(relation:, key:, scope:, owner:) || raise_template_not_found
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :key, :scope, :owner, :version_number
|
|
23
|
+
|
|
24
|
+
# Presentation, not an audited rule, so there is no effective-dating to resolve: the
|
|
25
|
+
# current version is whichever row carries the flag, and asking for a version_number
|
|
26
|
+
# pins an exact row for previewing or diffing one that isn't live.
|
|
27
|
+
def relation
|
|
28
|
+
version_number.nil? ? Template.current : Template.where(version_number:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def raise_template_not_found
|
|
32
|
+
raise TemplateNotFoundError,
|
|
33
|
+
"no #{version_number.nil? ? "current" : "version #{version_number}"} template " \
|
|
34
|
+
"#{key.inspect} in scope #{scope.inspect} for #{describe_owner}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def describe_owner
|
|
38
|
+
owner.nil? ? "no owner" : "#{owner.class}##{owner.id} (or globally)"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class ResolveVariables
|
|
5
|
+
SOURCE_TYPES = %i[attribute method lookup context].freeze
|
|
6
|
+
|
|
7
|
+
def self.call(...)
|
|
8
|
+
new(...).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(specs:, calculable: nil, context: {}, lookups: {})
|
|
12
|
+
@specs = specs
|
|
13
|
+
@calculable = calculable
|
|
14
|
+
@context = context
|
|
15
|
+
@lookups = lookups
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
variables.each_with_object({}) do |spec, inputs|
|
|
20
|
+
inputs[spec.name] = resolve(spec)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :specs, :calculable, :context, :lookups
|
|
27
|
+
|
|
28
|
+
def variables
|
|
29
|
+
specs.map { |spec| VariableSpec.build(spec) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def resolve(spec)
|
|
33
|
+
value = fetch(spec)
|
|
34
|
+
return value unless value.nil?
|
|
35
|
+
raise MissingVariableError, "required variable #{spec.name.inspect} resolved to nil" if spec.required
|
|
36
|
+
|
|
37
|
+
spec.default
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def fetch(spec)
|
|
41
|
+
case spec.source_type
|
|
42
|
+
when :attribute then attribute_value(spec)
|
|
43
|
+
when :method then method_value(spec)
|
|
44
|
+
when :lookup then lookup_value(spec)
|
|
45
|
+
when :context then context_value(spec.source_config[:key] || spec.name)
|
|
46
|
+
else raise_unknown_source_type(spec)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def attribute_value(spec)
|
|
51
|
+
send_to_calculable(spec.source_config[:attribute] || spec.name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def method_value(spec)
|
|
55
|
+
send_to_calculable(spec.source_config[:method] || spec.name, *Array(spec.source_config[:args]))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def send_to_calculable(name, *args)
|
|
59
|
+
return calculable.public_send(name, *args) if calculable.respond_to?(name)
|
|
60
|
+
return calculable[name] if args.empty? && calculable.respond_to?(:[])
|
|
61
|
+
|
|
62
|
+
raise_unreadable(name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def raise_unreadable(name)
|
|
66
|
+
raise VariableResolutionError, "no calculable given to read #{name.inspect} from" if calculable.nil?
|
|
67
|
+
|
|
68
|
+
raise VariableResolutionError, "#{calculable.class} does not respond to #{name.inspect}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def raise_unknown_source_type(spec)
|
|
72
|
+
raise UnknownSourceTypeError,
|
|
73
|
+
"unknown source_type #{spec.source_type.inspect} for #{spec.name.inspect} " \
|
|
74
|
+
"(known: #{SOURCE_TYPES.join(", ")})"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def lookup_value(spec)
|
|
78
|
+
tiers = tiers_for(spec.source_config[:table] || spec.name)
|
|
79
|
+
using = spec.source_config[:using]
|
|
80
|
+
return tiers if using.nil?
|
|
81
|
+
|
|
82
|
+
FindTier.(tiers:, amount: lookup_amount(using)).value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def lookup_amount(source)
|
|
86
|
+
return context_value(source) if context.key?(source.to_s) || context.key?(source.to_sym)
|
|
87
|
+
|
|
88
|
+
send_to_calculable(source)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def tiers_for(table)
|
|
92
|
+
lookups.fetch(table.to_s) do
|
|
93
|
+
lookups.fetch(table.to_sym) do
|
|
94
|
+
raise MissingLookupTableError, "no lookup table registered for #{table.inspect}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def context_value(key)
|
|
100
|
+
return context[key.to_s] if context.key?(key.to_s)
|
|
101
|
+
|
|
102
|
+
context[key.to_sym]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
Result = Data.define(:value, :breakdown, :formula_version, :as_of) do
|
|
5
|
+
def initialize(value:, breakdown: {}, formula_version: nil, as_of: nil)
|
|
6
|
+
super(value:, breakdown: deep_copy(breakdown), formula_version:, as_of:)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def inputs
|
|
10
|
+
breakdown.fetch(:inputs, {})
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def expression
|
|
14
|
+
breakdown[:expression]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Copies the containers rather than freezing them in place: the breakdown is built
|
|
20
|
+
# from the caller's own inputs hash, and freezing that would reach back out of the
|
|
21
|
+
# Result. Leaves are left alone for the same reason — a formula_version or a record
|
|
22
|
+
# passed through the breakdown stays the caller's to mutate.
|
|
23
|
+
def deep_copy(value)
|
|
24
|
+
case value
|
|
25
|
+
when Hash then value.to_h { |key, nested| [deep_copy(key), deep_copy(nested)] }.freeze
|
|
26
|
+
when Array then value.map { |nested| deep_copy(nested) }.freeze
|
|
27
|
+
when String then -value
|
|
28
|
+
else value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "liquid"
|
|
4
|
+
|
|
5
|
+
module ActsAsCalculator
|
|
6
|
+
class ResultDrop < Liquid::Drop
|
|
7
|
+
def initialize(result)
|
|
8
|
+
super()
|
|
9
|
+
@result = result
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def value
|
|
13
|
+
CastLiquidValue.(result.value)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def as_of
|
|
17
|
+
result.as_of
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def expression
|
|
21
|
+
result.expression
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def inputs
|
|
25
|
+
CastLiquidValue.(result.inputs)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def breakdown
|
|
29
|
+
CastLiquidValue.(result.breakdown)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def formula_version
|
|
33
|
+
version = result.formula_version
|
|
34
|
+
|
|
35
|
+
FormulaVersionDrop.new(version) unless version.nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_s
|
|
39
|
+
value.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
attr_reader :result
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
# Identity only by default — content lives on the versions, and a formulas index that
|
|
5
|
+
# inlined every version of every formula would be the wrong default for the one endpoint
|
|
6
|
+
# a host hits most.
|
|
7
|
+
class SerializeFormula
|
|
8
|
+
def self.call(formula:, versions: false)
|
|
9
|
+
payload = { id: formula.id, key: formula.key, scope: formula.scope,
|
|
10
|
+
owner_type: formula.owner_type, owner_id: formula.owner_id,
|
|
11
|
+
created_at: formula.created_at&.iso8601, updated_at: formula.updated_at&.iso8601 }
|
|
12
|
+
return payload unless versions
|
|
13
|
+
|
|
14
|
+
payload.merge(versions: formula.versions.order(:version_number)
|
|
15
|
+
.map { |version| SerializeFormulaVersion.(version:) })
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class SerializeFormulaVersion
|
|
5
|
+
def self.call(version:, variables: false)
|
|
6
|
+
payload = attributes(version)
|
|
7
|
+
return payload unless variables
|
|
8
|
+
|
|
9
|
+
payload.merge(variables: version.variables.order(:name).map { |variable| serialize_variable(variable) })
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.attributes(version)
|
|
13
|
+
{ id: version.id, formula_id: version.formula_id, version_number: version.version_number,
|
|
14
|
+
expression: version.expression, status: version.status, change_note: version.change_note,
|
|
15
|
+
effective_from: version.effective_from&.iso8601, effective_to: version.effective_to&.iso8601,
|
|
16
|
+
created_at: version.created_at&.iso8601, updated_at: version.updated_at&.iso8601 }
|
|
17
|
+
end
|
|
18
|
+
private_class_method :attributes
|
|
19
|
+
|
|
20
|
+
def self.serialize_variable(variable)
|
|
21
|
+
{ id: variable.id, name: variable.name, source_type: variable.source_type,
|
|
22
|
+
source_config: variable.source_config, required: variable.required }
|
|
23
|
+
end
|
|
24
|
+
private_class_method :serialize_variable
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
# The same per-entry report the rake task prints, as JSON: failures are per entry, so a
|
|
5
|
+
# caller has to be told which entries landed and which did not, not just that "the import
|
|
6
|
+
# failed".
|
|
7
|
+
class SerializeImportSummary
|
|
8
|
+
def self.call(summary:)
|
|
9
|
+
{ source: summary.source,
|
|
10
|
+
success: summary.success?,
|
|
11
|
+
counts: summary.counts,
|
|
12
|
+
outcomes: summary.outcomes.map { |outcome| serialize_outcome(outcome) } }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.serialize_outcome(outcome)
|
|
16
|
+
{ kind: outcome.kind, status: outcome.status, key: outcome.key,
|
|
17
|
+
scope: outcome.scope, detail: outcome.detail }
|
|
18
|
+
end
|
|
19
|
+
private_class_method :serialize_outcome
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
class SerializeTemplate
|
|
5
|
+
def self.call(template:)
|
|
6
|
+
{ id: template.id, key: template.key, scope: template.scope,
|
|
7
|
+
owner_type: template.owner_type, owner_id: template.owner_id,
|
|
8
|
+
body: template.body, format: template.format,
|
|
9
|
+
version_number: template.version_number, current: template.current,
|
|
10
|
+
created_at: template.created_at&.iso8601, updated_at: template.updated_at&.iso8601 }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
# Makes room for a new active version. `FormulaVersion` refuses two active versions whose
|
|
5
|
+
# effective ranges overlap, so publishing a correction to a rule that is currently in
|
|
6
|
+
# force has to close the incumbent out first — by moving its `effective_to` or retiring
|
|
7
|
+
# it, never by touching its `expression`, which is what stays immutable.
|
|
8
|
+
#
|
|
9
|
+
# An incumbent is only ever superseded into a range the newcomer takes over. Where the
|
|
10
|
+
# newcomer would leave part of the incumbent's range with no rule at all, this raises:
|
|
11
|
+
# `effective_from` is immutable on an active version, so there is no way to keep just the
|
|
12
|
+
# tail, and quietly retiring the whole row would delete coverage a past calculation had.
|
|
13
|
+
# Same principle as the resolver's "no covering version raises, never fall back" — a gap
|
|
14
|
+
# the operator did not ask for is not this Decree's to create (docs/PLAN.md, "Data model").
|
|
15
|
+
class SupersedeFormulaVersions
|
|
16
|
+
def self.call(...)
|
|
17
|
+
new(...).call
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(formula:, effective_from:, effective_to: nil)
|
|
21
|
+
@formula = formula
|
|
22
|
+
@effective_from = CastDate.(effective_from)
|
|
23
|
+
@effective_to = effective_to && CastDate.(effective_to)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Every incumbent is checked before any is written, so a refusal never leaves half the
|
|
27
|
+
# supersede applied.
|
|
28
|
+
def call
|
|
29
|
+
incumbents = overlapping
|
|
30
|
+
orphaned = incumbents.find { |version| !covers_tail_of?(version) }
|
|
31
|
+
raise PartialSupersedeError, uncovered_tail_message(orphaned) if orphaned
|
|
32
|
+
|
|
33
|
+
incumbents.each { |version| supersede(version) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :formula, :effective_from, :effective_to
|
|
39
|
+
|
|
40
|
+
def overlapping
|
|
41
|
+
versions = formula.versions.active
|
|
42
|
+
versions = versions.where(effective_from: ..effective_to) if effective_to
|
|
43
|
+
|
|
44
|
+
versions.open_ended_or_ending_on_or_after(effective_from).to_a
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# An open-ended newcomer runs to the end of time, so nothing can outlast it.
|
|
48
|
+
def covers_tail_of?(version)
|
|
49
|
+
return true if effective_to.nil?
|
|
50
|
+
|
|
51
|
+
!version.effective_to.nil? && version.effective_to <= effective_to
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Starting on or after the newcomer leaves no range to shorten the incumbent to, so
|
|
55
|
+
# retiring is the only move — and by here the newcomer is known to cover all of it.
|
|
56
|
+
def supersede(version)
|
|
57
|
+
if version.effective_from >= effective_from
|
|
58
|
+
version.update!(status: FormulaVersion::RETIRED)
|
|
59
|
+
else
|
|
60
|
+
version.update!(effective_to: effective_from - 1)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def uncovered_tail_message(version)
|
|
65
|
+
tail = describe(effective_to + 1, version.effective_to)
|
|
66
|
+
|
|
67
|
+
"version #{version.version_number} of formula #{formula.key.inspect} is effective " \
|
|
68
|
+
"#{describe(version.effective_from, version.effective_to)}, which outlasts the new version's " \
|
|
69
|
+
"#{describe(effective_from, effective_to)}. Superseding it would leave #{tail} with no active " \
|
|
70
|
+
"version at all. Either extend the new version's effective_to to " \
|
|
71
|
+
"#{describe_bound(version.effective_to)}, or declare a version covering #{tail} first — entries " \
|
|
72
|
+
"are applied in order, so the one taking over the tail has to land before the one giving it up."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def describe(from, to)
|
|
76
|
+
"#{from.iso8601}..#{describe_bound(to)}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def describe_bound(date)
|
|
80
|
+
date.nil? ? "open" : date.iso8601
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculator
|
|
4
|
+
Tier = Data.define(:from, :to, :value) do
|
|
5
|
+
def self.build(source)
|
|
6
|
+
return source if source.is_a?(Tier)
|
|
7
|
+
|
|
8
|
+
attributes = source.respond_to?(:to_h) ? source.to_h : read_from(source)
|
|
9
|
+
attributes = attributes.transform_keys(&:to_sym)
|
|
10
|
+
|
|
11
|
+
new(from: attributes[:from], to: attributes[:to], value: attributes[:value])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.read_from(source)
|
|
15
|
+
{ from: source.from, to: source.to, value: source.value }
|
|
16
|
+
end
|
|
17
|
+
private_class_method :read_from
|
|
18
|
+
|
|
19
|
+
def covers?(amount)
|
|
20
|
+
amount >= lower_bound && amount < upper_bound
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def lower_bound
|
|
24
|
+
from || -Float::INFINITY
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def upper_bound
|
|
28
|
+
to || Float::INFINITY
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|