acts_as_calculator_editor 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/MIT-LICENSE +20 -0
- data/README.md +107 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/acts_as_calculator_editor/application.js +26 -0
- data/app/assets/javascripts/acts_as_calculator_editor/dentaku_tokenizer.js +139 -0
- data/app/assets/javascripts/acts_as_calculator_editor/expression_editor_controller.js +380 -0
- data/app/assets/javascripts/acts_as_calculator_editor/list_filter_controller.js +39 -0
- data/app/assets/javascripts/acts_as_calculator_editor/template_editor_controller.js +86 -0
- data/app/assets/stylesheets/acts_as_calculator_editor/application.css +2 -0
- data/app/controllers/acts_as_calculator_editor/application_controller.rb +115 -0
- data/app/controllers/acts_as_calculator_editor/exports_controller.rb +41 -0
- data/app/controllers/acts_as_calculator_editor/formula_versions_controller.rb +128 -0
- data/app/controllers/acts_as_calculator_editor/formulas_controller.rb +94 -0
- data/app/controllers/acts_as_calculator_editor/lookup_tables_controller.rb +112 -0
- data/app/controllers/acts_as_calculator_editor/templates_controller.rb +130 -0
- data/app/controllers/acts_as_calculator_editor/validations_controller.rb +23 -0
- data/app/helpers/acts_as_calculator_editor/application_helper.rb +85 -0
- data/app/jobs/acts_as_calculator_editor/application_job.rb +6 -0
- data/app/mailers/acts_as_calculator_editor/application_mailer.rb +8 -0
- data/app/models/acts_as_calculator_editor/application_record.rb +7 -0
- data/app/views/acts_as_calculator_editor/formula_versions/_expression_field.html.erb +64 -0
- data/app/views/acts_as_calculator_editor/formula_versions/_variable_fields.html.erb +51 -0
- data/app/views/acts_as_calculator_editor/formula_versions/index.html.erb +45 -0
- data/app/views/acts_as_calculator_editor/formula_versions/new.html.erb +80 -0
- data/app/views/acts_as_calculator_editor/formula_versions/show.html.erb +75 -0
- data/app/views/acts_as_calculator_editor/formulas/_form.html.erb +23 -0
- data/app/views/acts_as_calculator_editor/formulas/edit.html.erb +11 -0
- data/app/views/acts_as_calculator_editor/formulas/index.html.erb +71 -0
- data/app/views/acts_as_calculator_editor/formulas/new.html.erb +10 -0
- data/app/views/acts_as_calculator_editor/formulas/show.html.erb +72 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/_entry_fields.html.erb +24 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/_form.html.erb +47 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/edit.html.erb +12 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/index.html.erb +67 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/new.html.erb +11 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/show.html.erb +71 -0
- data/app/views/acts_as_calculator_editor/shared/_error_message.html.erb +12 -0
- data/app/views/acts_as_calculator_editor/shared/_export_panel.html.erb +52 -0
- data/app/views/acts_as_calculator_editor/shared/_flash.html.erb +8 -0
- data/app/views/acts_as_calculator_editor/shared/_page_header.html.erb +16 -0
- data/app/views/acts_as_calculator_editor/shared/error.html.erb +11 -0
- data/app/views/acts_as_calculator_editor/templates/_form.html.erb +124 -0
- data/app/views/acts_as_calculator_editor/templates/_lexxy_toolbar.html.erb +101 -0
- data/app/views/acts_as_calculator_editor/templates/index.html.erb +83 -0
- data/app/views/acts_as_calculator_editor/templates/new.html.erb +13 -0
- data/app/views/acts_as_calculator_editor/templates/preview.html.erb +29 -0
- data/app/views/acts_as_calculator_editor/templates/show.html.erb +111 -0
- data/app/views/layouts/acts_as_calculator_editor/application.html.erb +52 -0
- data/config/importmap.rb +13 -0
- data/config/routes.rb +46 -0
- data/lib/acts_as_calculator_editor/build_export_document.rb +37 -0
- data/lib/acts_as_calculator_editor/build_sample_result.rb +26 -0
- data/lib/acts_as_calculator_editor/cast_entry_rows.rb +52 -0
- data/lib/acts_as_calculator_editor/cast_export_decimal.rb +19 -0
- data/lib/acts_as_calculator_editor/cast_variable_rows.rb +90 -0
- data/lib/acts_as_calculator_editor/delete_lookup_table.rb +54 -0
- data/lib/acts_as_calculator_editor/engine.rb +32 -0
- data/lib/acts_as_calculator_editor/export_formulas.rb +89 -0
- data/lib/acts_as_calculator_editor/export_lookup_tables.rb +49 -0
- data/lib/acts_as_calculator_editor/export_owner_reference.rb +18 -0
- data/lib/acts_as_calculator_editor/export_templates.rb +51 -0
- data/lib/acts_as_calculator_editor/publish_lookup_table.rb +53 -0
- data/lib/acts_as_calculator_editor/render_template_preview.rb +60 -0
- data/lib/acts_as_calculator_editor/validate_expression.rb +127 -0
- data/lib/acts_as_calculator_editor/version.rb +5 -0
- data/lib/acts_as_calculator_editor.rb +76 -0
- data/lib/tasks/acts_as_calculator_editor_tasks.rake +6 -0
- metadata +248 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculatorEditor
|
|
4
|
+
# Form rows in, the `variables:` list `PublishFormulaVersion` takes out.
|
|
5
|
+
#
|
|
6
|
+
# Two things here are not cosmetic. First, an HTML form can post repeated fields either as
|
|
7
|
+
# an array (`version[variables][][name]`) or as an index-keyed hash
|
|
8
|
+
# (`version[variables][0][name]`); the second is what `fields_for` emits and the only one
|
|
9
|
+
# that survives a row whose checkbox is the sole differing field, so both are accepted.
|
|
10
|
+
#
|
|
11
|
+
# Second, `required`. `CastVariableAttributes` treats anything that is not literally
|
|
12
|
+
# `false` as true, and an unchecked Rails checkbox posts the *string* `"0"` — which would
|
|
13
|
+
# sail through as `required: true` and turn an optional variable into one that raises
|
|
14
|
+
# `MissingVariableError` at calculation time. The string has to become a real boolean
|
|
15
|
+
# before it gets anywhere near the core gem.
|
|
16
|
+
class CastVariableRows
|
|
17
|
+
FALSEY = ["0", "false", "off", ""].freeze
|
|
18
|
+
|
|
19
|
+
def self.call(...)
|
|
20
|
+
new(...).call
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(rows)
|
|
24
|
+
@rows = rows
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
normalized_rows.filter_map { |row| variable(row) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :rows
|
|
34
|
+
|
|
35
|
+
def normalized_rows
|
|
36
|
+
given = plain(rows)
|
|
37
|
+
|
|
38
|
+
case given
|
|
39
|
+
when Array then given.map { |row| plain(row) }
|
|
40
|
+
when Hash then given.sort_by { |index, _| index.to_s.rjust(9, "0") }.map { |(_, row)| plain(row) }
|
|
41
|
+
else []
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def variable(row)
|
|
46
|
+
return nil unless row.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
name = row["name"].to_s.strip
|
|
49
|
+
return nil if name.empty?
|
|
50
|
+
|
|
51
|
+
{ "name" => name,
|
|
52
|
+
"source_type" => row["source_type"].presence || "context",
|
|
53
|
+
"source_config" => source_config(row),
|
|
54
|
+
"required" => required?(row) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Absent means the core gem's default (true); present and falsey means the operator
|
|
58
|
+
# unchecked the box, which is the case a bare `!= false` comparison gets wrong.
|
|
59
|
+
def required?(row)
|
|
60
|
+
return true unless row.key?("required")
|
|
61
|
+
|
|
62
|
+
!FALSEY.include?(row["required"].to_s.strip.downcase)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A `source_config` is arbitrary JSON, so the minimal form offers one textarea per row
|
|
66
|
+
# rather than a field per source type. Nested form fields that post a real hash work
|
|
67
|
+
# too — the Frontend Engineer's typed inputs will use those.
|
|
68
|
+
def source_config(row)
|
|
69
|
+
raw = row["source_config"]
|
|
70
|
+
return plain(raw) if raw.is_a?(Hash) || raw.respond_to?(:to_unsafe_h)
|
|
71
|
+
return {} if raw.to_s.strip.empty?
|
|
72
|
+
|
|
73
|
+
parsed = JSON.parse(raw.to_s)
|
|
74
|
+
raise ActsAsCalculator::Error, config_error(row, raw) unless parsed.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
parsed
|
|
77
|
+
rescue JSON::ParserError
|
|
78
|
+
raise ActsAsCalculator::Error, config_error(row, raw)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def config_error(row, raw)
|
|
82
|
+
"variable #{row["name"].to_s.inspect} has an invalid source_config — " \
|
|
83
|
+
"expected a JSON object, got #{raw.to_s.inspect}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def plain(value)
|
|
87
|
+
value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h.to_h : value
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculatorEditor
|
|
4
|
+
# Nothing in the schema stops a lookup table from being deleted out from under a formula
|
|
5
|
+
# that reads it: a variable names its table by key, not by foreign key, so there is no
|
|
6
|
+
# constraint to lean on and `MissingLookupTableError` would only surface at the next
|
|
7
|
+
# calculation.
|
|
8
|
+
#
|
|
9
|
+
# `ImportLookupTable` refuses to *rewrite* brackets an active or retired version resolved
|
|
10
|
+
# to, for the reason that rewriting them retroactively changes what an audited calculation
|
|
11
|
+
# was measured against. Deleting the table is the same act with a worse ending, so it gets
|
|
12
|
+
# the same guard and the same error class — reusing `FindLookupTableReferences` rather
|
|
13
|
+
# than approximating it, since which table a formula resolves to depends on owner
|
|
14
|
+
# shadowing and cannot be answered by matching keys.
|
|
15
|
+
class DeleteLookupTable
|
|
16
|
+
def self.call(...)
|
|
17
|
+
new(...).call
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(lookup_table:)
|
|
21
|
+
@lookup_table = lookup_table
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
raise ActsAsCalculator::LookupTableInUseError, in_use_message unless references.empty?
|
|
26
|
+
|
|
27
|
+
lookup_table.destroy!
|
|
28
|
+
lookup_table
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :lookup_table
|
|
34
|
+
|
|
35
|
+
# Named here rather than in a constant: `FormulaVersion` is an autoloaded model, and
|
|
36
|
+
# this file is required while the engine is still booting.
|
|
37
|
+
def audited_statuses
|
|
38
|
+
[ActsAsCalculator::FormulaVersion::ACTIVE, ActsAsCalculator::FormulaVersion::RETIRED]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def references
|
|
42
|
+
@references ||= ActsAsCalculator::FindLookupTableReferences.(lookup_table:, statuses: audited_statuses)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def in_use_message
|
|
46
|
+
used_by = references.map { |version| "#{version.formula.key}##{version.version_number} (#{version.status})" }
|
|
47
|
+
|
|
48
|
+
"lookup table #{lookup_table.key.inspect} in scope #{lookup_table.scope.inspect} still backs " \
|
|
49
|
+
"#{used_by.join(", ")}; deleting it would leave those versions unable to calculate at all, " \
|
|
50
|
+
"and would remove the brackets their past runs were audited against. Retire the formula " \
|
|
51
|
+
"versions that read it first."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module ActsAsCalculatorEditor
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace ActsAsCalculatorEditor
|
|
8
|
+
|
|
9
|
+
# This engine adds no tables of its own. Every row it reads and writes belongs to
|
|
10
|
+
# `acts_as_calculator`, whose install generator the host runs (docs/core-gem-contract.md
|
|
11
|
+
# §2) — so there is no migration path to append here, deliberately.
|
|
12
|
+
|
|
13
|
+
config.generators do |generate|
|
|
14
|
+
generate.test_framework :rspec
|
|
15
|
+
generate.helper false
|
|
16
|
+
generate.assets false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Merge this engine's pins into the host's importmap. Guarded twice over: a host is free
|
|
20
|
+
# to use esbuild/webpack instead of importmap-rails, in which case `config.importmap`
|
|
21
|
+
# does not exist and our Stimulus controllers are the host's problem to bundle.
|
|
22
|
+
#
|
|
23
|
+
# `before: "importmap"` matters — importmap-rails reads `config.importmap.paths` in its
|
|
24
|
+
# own initializer, so appending afterwards is a no-op that fails silently.
|
|
25
|
+
initializer "acts_as_calculator_editor.importmap", before: "importmap" do |app|
|
|
26
|
+
next unless app.config.respond_to?(:importmap)
|
|
27
|
+
|
|
28
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
29
|
+
app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculatorEditor
|
|
4
|
+
# The `formulas` section of an import document — which is *not* the shape
|
|
5
|
+
# `SerializeFormula` emits (docs/core-gem-contract.md §4.2). Four differences, each of
|
|
6
|
+
# which would silently produce a file that does not round-trip:
|
|
7
|
+
#
|
|
8
|
+
# - one flat entry per version, repeating `key`/`scope`/`owner`, rather than a formula
|
|
9
|
+
# with a nested `versions` array;
|
|
10
|
+
# - `owner` as a `{type, id}` object, omitted entirely when nil;
|
|
11
|
+
# - no `id`, `formula_id`, `version_number`, `created_at` or `updated_at` — none of them
|
|
12
|
+
# are import keys;
|
|
13
|
+
# - `required` written out as a real boolean, because the import default is "anything not
|
|
14
|
+
# literally `false`" and omitting it is therefore not the same as round-tripping it.
|
|
15
|
+
#
|
|
16
|
+
# Version order is load-bearing, not cosmetic. `SupersedeFormulaVersions` applies entries
|
|
17
|
+
# in file order, so a version that takes over the tail of a range has to land after the
|
|
18
|
+
# one giving it up; an out-of-order export re-imports straight into
|
|
19
|
+
# `PartialSupersedeError`.
|
|
20
|
+
class ExportFormulas
|
|
21
|
+
def self.call(...)
|
|
22
|
+
new(...).call
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(key: nil, scope: nil, full_history: false)
|
|
26
|
+
@key = key
|
|
27
|
+
@scope = scope
|
|
28
|
+
@full_history = full_history
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
formulas.flat_map { |formula| versions(formula).map { |version| entry(formula, version) } }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :key, :scope, :full_history
|
|
38
|
+
|
|
39
|
+
def formulas
|
|
40
|
+
relation = ActsAsCalculator::Formula.includes(versions: :variables).order(:scope, :key, :id)
|
|
41
|
+
relation = relation.where(key:) if key.present?
|
|
42
|
+
relation = relation.where(scope:) if scope.present?
|
|
43
|
+
relation
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A formula with no exportable version contributes nothing: the import format has no way
|
|
47
|
+
# to express an identity row with no maths attached, so there is nothing to write.
|
|
48
|
+
def versions(formula)
|
|
49
|
+
selected = formula.versions
|
|
50
|
+
selected = selected.select(&:active?) unless full_history
|
|
51
|
+
|
|
52
|
+
selected.sort_by { |version| [version.effective_from, version.version_number] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def entry(formula, version)
|
|
56
|
+
{ "key" => formula.key, "scope" => formula.scope }
|
|
57
|
+
.merge(owner(formula))
|
|
58
|
+
.merge("expression" => version.expression,
|
|
59
|
+
"effective_from" => version.effective_from.iso8601,
|
|
60
|
+
"effective_to" => version.effective_to&.iso8601,
|
|
61
|
+
"status" => version.status)
|
|
62
|
+
.merge(change_note(version))
|
|
63
|
+
.merge("variables" => variables(version))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def owner(formula)
|
|
67
|
+
reference = ExportOwnerReference.(record: formula)
|
|
68
|
+
reference ? { "owner" => reference } : {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def change_note(version)
|
|
72
|
+
version.change_note.presence ? { "change_note" => version.change_note } : {}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Sorted by name so two exports of the same data diff cleanly; the import compares
|
|
76
|
+
# variable sets order-independently, so the order carries no meaning of its own.
|
|
77
|
+
def variables(version)
|
|
78
|
+
version.variables.sort_by(&:name).map { |variable| declaration(variable) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def declaration(variable)
|
|
82
|
+
config = ActsAsCalculator::CastJsonSafe.(variable.source_config || {})
|
|
83
|
+
declared = { "name" => variable.name, "source_type" => variable.source_type }
|
|
84
|
+
declared["source_config"] = config if config.present?
|
|
85
|
+
|
|
86
|
+
declared.merge("required" => variable.required?)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculatorEditor
|
|
4
|
+
# The `lookup_tables` section of an import document. The core gem has no
|
|
5
|
+
# `SerializeLookupTable` to borrow (docs/core-gem-contract.md §4.4), so the shape is built
|
|
6
|
+
# from the migration's own columns.
|
|
7
|
+
class ExportLookupTables
|
|
8
|
+
def self.call(...)
|
|
9
|
+
new(...).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(key: nil, scope: nil)
|
|
13
|
+
@key = key
|
|
14
|
+
@scope = scope
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
tables.map { |table| entry(table) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :key, :scope
|
|
24
|
+
|
|
25
|
+
def tables
|
|
26
|
+
relation = ActsAsCalculator::LookupTable.includes(:entries).order(:scope, :key, :id)
|
|
27
|
+
relation = relation.where(key:) if key.present?
|
|
28
|
+
relation = relation.where(scope:) if scope.present?
|
|
29
|
+
relation
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def entry(table)
|
|
33
|
+
{ "key" => table.key, "scope" => table.scope }
|
|
34
|
+
.merge(owner(table))
|
|
35
|
+
.merge("entries" => table.entries.sort_by { |row| [row.position, row.id] }.map { |row| bracket(row) })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def owner(table)
|
|
39
|
+
reference = ExportOwnerReference.(record: table)
|
|
40
|
+
reference ? { "owner" => reference } : {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def bracket(row)
|
|
44
|
+
{ "from" => CastExportDecimal.(row.from),
|
|
45
|
+
"to" => CastExportDecimal.(row.to),
|
|
46
|
+
"value" => CastExportDecimal.(row.value) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculatorEditor
|
|
4
|
+
# `{"type": "Company", "id": 7}`, or nothing at all.
|
|
5
|
+
#
|
|
6
|
+
# Nothing at all is load-bearing: `ResolveImportOwner` raises `ImportError` on `{}` and on
|
|
7
|
+
# `{"type": null}`, so an omitted key is the only way an import document can say "the
|
|
8
|
+
# global row" (docs/core-gem-contract.md §2.7). The columns are read directly rather than
|
|
9
|
+
# through the association so an export does not fail on a row whose owner has since been
|
|
10
|
+
# deleted.
|
|
11
|
+
class ExportOwnerReference
|
|
12
|
+
def self.call(record:)
|
|
13
|
+
return nil if record.owner_type.nil?
|
|
14
|
+
|
|
15
|
+
{ "type" => record.owner_type, "id" => record.owner_id }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculatorEditor
|
|
4
|
+
# The `templates` section — current versions only, and that is not a default anyone should
|
|
5
|
+
# switch off (docs/core-gem-contract.md §4.2, rule 2).
|
|
6
|
+
#
|
|
7
|
+
# `ImportTemplate` publishes a new version for every entry it sees, in file order. Export
|
|
8
|
+
# the whole history and a re-import replays it in version order, ending with the highest
|
|
9
|
+
# `version_number` live. After a `PromoteTemplate` rollback the current version is *not*
|
|
10
|
+
# the highest-numbered one, so that replay quietly puts the rolled-back body back in
|
|
11
|
+
# production. Exporting what is live is both correct and what the import format means.
|
|
12
|
+
#
|
|
13
|
+
# This is why `full_history` is a formula-versions switch and not a document-wide one:
|
|
14
|
+
# there is no version history to ask for here that could be re-imported truthfully.
|
|
15
|
+
class ExportTemplates
|
|
16
|
+
def self.call(...)
|
|
17
|
+
new(...).call
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(key: nil, scope: nil)
|
|
21
|
+
@key = key
|
|
22
|
+
@scope = scope
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
templates.map { |template| entry(template) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :key, :scope
|
|
32
|
+
|
|
33
|
+
def templates
|
|
34
|
+
relation = ActsAsCalculator::Template.current.order(:scope, :key, :id)
|
|
35
|
+
relation = relation.where(key:) if key.present?
|
|
36
|
+
relation = relation.where(scope:) if scope.present?
|
|
37
|
+
relation
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def entry(template)
|
|
41
|
+
{ "key" => template.key, "scope" => template.scope }
|
|
42
|
+
.merge(owner(template))
|
|
43
|
+
.merge("format" => template.format, "body" => template.body)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def owner(template)
|
|
47
|
+
reference = ExportOwnerReference.(record: template)
|
|
48
|
+
reference ? { "owner" => reference } : {}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculatorEditor
|
|
4
|
+
# The core gem ships no controller for lookup tables and no Decree for authoring one
|
|
5
|
+
# outside an import document (docs/core-gem-contract.md §4.4). This builds the single
|
|
6
|
+
# `lookup_tables` entry that document would have carried and hands it to
|
|
7
|
+
# `ActsAsCalculator::ImportLookupTable`.
|
|
8
|
+
#
|
|
9
|
+
# Going through that Decree rather than writing `table.entries.create!` here is the whole
|
|
10
|
+
# point: it carries `guard_against_rewriting_audited_brackets`, which refuses to change
|
|
11
|
+
# entries an active or retired formula version resolves to. That guard is what stops a
|
|
12
|
+
# bracket edit from retroactively changing what an audited calculation was measured
|
|
13
|
+
# against, and it exists in exactly one place.
|
|
14
|
+
#
|
|
15
|
+
# The transaction is ours because `ImportLookupTable` deletes the old entries before
|
|
16
|
+
# writing the new ones and leaves the savepoint to its caller — normally
|
|
17
|
+
# `ImportDefinitions`. Without one, an entry the model rejects would leave the table
|
|
18
|
+
# emptied.
|
|
19
|
+
class PublishLookupTable
|
|
20
|
+
def self.call(...)
|
|
21
|
+
new(...).call
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(key:, scope: nil, owner: nil, entries: [])
|
|
25
|
+
@key = key.to_s.strip
|
|
26
|
+
@scope = (scope.presence || ActsAsCalculator::DEFAULT_SCOPE).to_s.strip
|
|
27
|
+
@owner = owner
|
|
28
|
+
@entries = entries
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
ActsAsCalculator::Record.transaction(requires_new: true) do
|
|
33
|
+
ActsAsCalculator::ImportLookupTable.(attributes:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
ActsAsCalculator::LookupTable.owned_by(owner).find_by!(key:, scope:)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :key, :scope, :owner, :entries
|
|
42
|
+
|
|
43
|
+
# `owner` is omitted entirely when nil: `ResolveImportOwner` raises on `{}` and on
|
|
44
|
+
# `{"type" => nil}`, so an absent key is the only way to say "the global row"
|
|
45
|
+
# (contract §2.7).
|
|
46
|
+
def attributes
|
|
47
|
+
document = { "key" => key, "scope" => scope, "entries" => entries }
|
|
48
|
+
document["owner"] = { "type" => owner.class.polymorphic_name, "id" => owner.id } if owner
|
|
49
|
+
|
|
50
|
+
document
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculatorEditor
|
|
4
|
+
# Preview a template body that has never been saved.
|
|
5
|
+
#
|
|
6
|
+
# The core gem's HTTP preview needs a persisted record because its controller does
|
|
7
|
+
# `Template.find(params[:id])` — but `RenderTemplate` itself does not: it reads only
|
|
8
|
+
# `#body` and short-circuits to whatever `template:` it was handed
|
|
9
|
+
# (docs/core-gem-contract.md §4.3). So an unsaved `Template.new` is enough, which beats
|
|
10
|
+
# "save an early version first" — no throwaway version rows, no partial state — while
|
|
11
|
+
# still exercising the real Liquid sandbox with all its resource limits rather than a
|
|
12
|
+
# second copy of it.
|
|
13
|
+
#
|
|
14
|
+
# Failures come back as `ActsAsCalculator::TemplateRenderError` (with a line number, since
|
|
15
|
+
# `RenderLiquid` parses in strict mode) or `UnsafeAssignError` for a context value Liquid
|
|
16
|
+
# will not accept. Both are 422s the preview pane shows; neither is a 500.
|
|
17
|
+
class RenderTemplatePreview
|
|
18
|
+
def self.call(...)
|
|
19
|
+
new(...).call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(body:, format: ActsAsCalculator::Template::HTML, context: {}, result: nil)
|
|
23
|
+
@body = body.to_s
|
|
24
|
+
@format = format.presence || ActsAsCalculator::Template::HTML
|
|
25
|
+
@context = cast_context(context)
|
|
26
|
+
@result = result || BuildSampleResult.()
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
ActsAsCalculator::RenderTemplate.(template:, result:, context:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :body, :format, :context, :result
|
|
36
|
+
|
|
37
|
+
def template
|
|
38
|
+
ActsAsCalculator::Template.new(body:, format:)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The minimal preview form posts context as one JSON textarea. A Frontend Engineer's
|
|
42
|
+
# typed fields will post a real hash instead, so both are accepted.
|
|
43
|
+
def cast_context(given)
|
|
44
|
+
given = given.to_unsafe_h if given.respond_to?(:to_unsafe_h)
|
|
45
|
+
return given.to_h if given.is_a?(Hash)
|
|
46
|
+
return {} if given.to_s.strip.empty?
|
|
47
|
+
|
|
48
|
+
parsed = JSON.parse(given.to_s)
|
|
49
|
+
raise ActsAsCalculator::Error, context_error(given) unless parsed.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
parsed
|
|
52
|
+
rescue JSON::ParserError
|
|
53
|
+
raise ActsAsCalculator::Error, context_error(given)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def context_error(given)
|
|
57
|
+
"preview context must be a JSON object, got #{given.to_s.inspect}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActsAsCalculatorEditor
|
|
4
|
+
# What a live expression field can be told about a formula before it is published, with
|
|
5
|
+
# no evaluation and nothing persisted (docs/core-gem-contract.md §4.1).
|
|
6
|
+
#
|
|
7
|
+
# `BuildCalculator.()` hands back a Dentaku calculator with the gem's function registry
|
|
8
|
+
# already installed, so `#ast` catches unbalanced parentheses, malformed operators *and*
|
|
9
|
+
# undefined function names — including whatever the host registered — while
|
|
10
|
+
# `#identifiers` lists every variable the expression could reach, syntactically, without
|
|
11
|
+
# evaluating a single branch.
|
|
12
|
+
#
|
|
13
|
+
# What this deliberately does not claim: whether the host's model actually responds to an
|
|
14
|
+
# `attribute` or `method` variable. That is only knowable when a real record is in hand,
|
|
15
|
+
# and saying otherwise would promise a guarantee the check does not make.
|
|
16
|
+
#
|
|
17
|
+
# This belongs upstream next to `EvaluateExpression`; it lives here because the core gem
|
|
18
|
+
# has no `ValidateExpression` Decree yet.
|
|
19
|
+
class ValidateExpression
|
|
20
|
+
def self.call(...)
|
|
21
|
+
new(...).call
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(expression:, variables: [], scope: nil, owner: nil)
|
|
25
|
+
@expression = expression.to_s
|
|
26
|
+
@variables = Array(variables).map { |variable| ActsAsCalculator::CastVariableAttributes.(variable) }
|
|
27
|
+
@scope = (scope.presence || ActsAsCalculator::DEFAULT_SCOPE).to_s
|
|
28
|
+
@owner = owner
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
return blank if expression.strip.empty?
|
|
33
|
+
return unparsable if syntax_error
|
|
34
|
+
|
|
35
|
+
{ valid: problems.empty?, errors: problems, warnings:, identifiers: }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :expression, :variables, :scope, :owner
|
|
41
|
+
|
|
42
|
+
# `""` parses to `Dentaku::AST::Nil` rather than raising, so presence is a separate
|
|
43
|
+
# check — the same one `FormulaVersion` makes.
|
|
44
|
+
def blank
|
|
45
|
+
{ valid: false, errors: [error(:blank, "expression can't be blank")], warnings: [], identifiers: [] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def unparsable
|
|
49
|
+
{ valid: false, errors: [error(:syntax, syntax_error)], warnings: [], identifiers: [] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def calculator
|
|
53
|
+
@calculator ||= ActsAsCalculator::BuildCalculator.()
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def syntax_error
|
|
57
|
+
return @syntax_error if defined?(@syntax_error)
|
|
58
|
+
|
|
59
|
+
calculator.ast(expression)
|
|
60
|
+
@syntax_error = nil
|
|
61
|
+
rescue Dentaku::Error => e
|
|
62
|
+
@syntax_error = e.message
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `BuildCalculator` defaults to `case_sensitive: false`, so Dentaku hands these back
|
|
66
|
+
# downcased. Comparing declared names any other way reports false "undeclared variable"
|
|
67
|
+
# errors on `Salary`.
|
|
68
|
+
def identifiers
|
|
69
|
+
@identifiers ||= calculator.identifiers(expression).map(&:to_s)
|
|
70
|
+
rescue Dentaku::Error
|
|
71
|
+
@identifiers = []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def declared
|
|
75
|
+
@declared ||= variables.to_h { |variable| [variable[:name].to_s.downcase, variable] }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def problems
|
|
79
|
+
@problems ||= undeclared + missing_lookup_tables
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def undeclared
|
|
83
|
+
(identifiers.map(&:downcase) - declared.keys).uniq.map do |name|
|
|
84
|
+
error(:undeclared_variable,
|
|
85
|
+
"#{name.inspect} is used in the expression but not declared as a variable — " \
|
|
86
|
+
"it will raise MissingVariableError when the formula runs",
|
|
87
|
+
name:)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def missing_lookup_tables
|
|
92
|
+
lookup_variables.reject { |_, table_key| resolves?(table_key) }.map do |name, table_key|
|
|
93
|
+
error(:missing_lookup_table,
|
|
94
|
+
"variable #{name.inspect} reads lookup table #{table_key.inspect}, " \
|
|
95
|
+
"which does not exist in scope #{scope.inspect}",
|
|
96
|
+
name:)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def lookup_variables
|
|
101
|
+
declared.filter_map do |name, variable|
|
|
102
|
+
next unless variable[:source_type].to_s == "lookup"
|
|
103
|
+
|
|
104
|
+
[name, (variable[:source_config] || {}).transform_keys(&:to_s).fetch("table", name).to_s]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The same owner-first, global-second resolution `BuildLookups` performs at calculation
|
|
109
|
+
# time, so this answers what an actual run would find rather than a near-miss of it.
|
|
110
|
+
def resolves?(table_key)
|
|
111
|
+
!ActsAsCalculator::FindOwnedRecord.(relation: ActsAsCalculator::LookupTable.all,
|
|
112
|
+
key: table_key, scope:, owner:).nil?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def warnings
|
|
116
|
+
(declared.keys - identifiers.map(&:downcase)).map do |name|
|
|
117
|
+
error(:unreferenced_variable,
|
|
118
|
+
"#{name.inspect} is declared but never used in the expression",
|
|
119
|
+
name:)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def error(type, message, name: nil)
|
|
124
|
+
{ type:, message:, name: }.compact
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|