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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +107 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/javascripts/acts_as_calculator_editor/application.js +26 -0
  6. data/app/assets/javascripts/acts_as_calculator_editor/dentaku_tokenizer.js +139 -0
  7. data/app/assets/javascripts/acts_as_calculator_editor/expression_editor_controller.js +380 -0
  8. data/app/assets/javascripts/acts_as_calculator_editor/list_filter_controller.js +39 -0
  9. data/app/assets/javascripts/acts_as_calculator_editor/template_editor_controller.js +86 -0
  10. data/app/assets/stylesheets/acts_as_calculator_editor/application.css +2 -0
  11. data/app/controllers/acts_as_calculator_editor/application_controller.rb +115 -0
  12. data/app/controllers/acts_as_calculator_editor/exports_controller.rb +41 -0
  13. data/app/controllers/acts_as_calculator_editor/formula_versions_controller.rb +128 -0
  14. data/app/controllers/acts_as_calculator_editor/formulas_controller.rb +94 -0
  15. data/app/controllers/acts_as_calculator_editor/lookup_tables_controller.rb +112 -0
  16. data/app/controllers/acts_as_calculator_editor/templates_controller.rb +130 -0
  17. data/app/controllers/acts_as_calculator_editor/validations_controller.rb +23 -0
  18. data/app/helpers/acts_as_calculator_editor/application_helper.rb +85 -0
  19. data/app/jobs/acts_as_calculator_editor/application_job.rb +6 -0
  20. data/app/mailers/acts_as_calculator_editor/application_mailer.rb +8 -0
  21. data/app/models/acts_as_calculator_editor/application_record.rb +7 -0
  22. data/app/views/acts_as_calculator_editor/formula_versions/_expression_field.html.erb +64 -0
  23. data/app/views/acts_as_calculator_editor/formula_versions/_variable_fields.html.erb +51 -0
  24. data/app/views/acts_as_calculator_editor/formula_versions/index.html.erb +45 -0
  25. data/app/views/acts_as_calculator_editor/formula_versions/new.html.erb +80 -0
  26. data/app/views/acts_as_calculator_editor/formula_versions/show.html.erb +75 -0
  27. data/app/views/acts_as_calculator_editor/formulas/_form.html.erb +23 -0
  28. data/app/views/acts_as_calculator_editor/formulas/edit.html.erb +11 -0
  29. data/app/views/acts_as_calculator_editor/formulas/index.html.erb +71 -0
  30. data/app/views/acts_as_calculator_editor/formulas/new.html.erb +10 -0
  31. data/app/views/acts_as_calculator_editor/formulas/show.html.erb +72 -0
  32. data/app/views/acts_as_calculator_editor/lookup_tables/_entry_fields.html.erb +24 -0
  33. data/app/views/acts_as_calculator_editor/lookup_tables/_form.html.erb +47 -0
  34. data/app/views/acts_as_calculator_editor/lookup_tables/edit.html.erb +12 -0
  35. data/app/views/acts_as_calculator_editor/lookup_tables/index.html.erb +67 -0
  36. data/app/views/acts_as_calculator_editor/lookup_tables/new.html.erb +11 -0
  37. data/app/views/acts_as_calculator_editor/lookup_tables/show.html.erb +71 -0
  38. data/app/views/acts_as_calculator_editor/shared/_error_message.html.erb +12 -0
  39. data/app/views/acts_as_calculator_editor/shared/_export_panel.html.erb +52 -0
  40. data/app/views/acts_as_calculator_editor/shared/_flash.html.erb +8 -0
  41. data/app/views/acts_as_calculator_editor/shared/_page_header.html.erb +16 -0
  42. data/app/views/acts_as_calculator_editor/shared/error.html.erb +11 -0
  43. data/app/views/acts_as_calculator_editor/templates/_form.html.erb +124 -0
  44. data/app/views/acts_as_calculator_editor/templates/_lexxy_toolbar.html.erb +101 -0
  45. data/app/views/acts_as_calculator_editor/templates/index.html.erb +83 -0
  46. data/app/views/acts_as_calculator_editor/templates/new.html.erb +13 -0
  47. data/app/views/acts_as_calculator_editor/templates/preview.html.erb +29 -0
  48. data/app/views/acts_as_calculator_editor/templates/show.html.erb +111 -0
  49. data/app/views/layouts/acts_as_calculator_editor/application.html.erb +52 -0
  50. data/config/importmap.rb +13 -0
  51. data/config/routes.rb +46 -0
  52. data/lib/acts_as_calculator_editor/build_export_document.rb +37 -0
  53. data/lib/acts_as_calculator_editor/build_sample_result.rb +26 -0
  54. data/lib/acts_as_calculator_editor/cast_entry_rows.rb +52 -0
  55. data/lib/acts_as_calculator_editor/cast_export_decimal.rb +19 -0
  56. data/lib/acts_as_calculator_editor/cast_variable_rows.rb +90 -0
  57. data/lib/acts_as_calculator_editor/delete_lookup_table.rb +54 -0
  58. data/lib/acts_as_calculator_editor/engine.rb +32 -0
  59. data/lib/acts_as_calculator_editor/export_formulas.rb +89 -0
  60. data/lib/acts_as_calculator_editor/export_lookup_tables.rb +49 -0
  61. data/lib/acts_as_calculator_editor/export_owner_reference.rb +18 -0
  62. data/lib/acts_as_calculator_editor/export_templates.rb +51 -0
  63. data/lib/acts_as_calculator_editor/publish_lookup_table.rb +53 -0
  64. data/lib/acts_as_calculator_editor/render_template_preview.rb +60 -0
  65. data/lib/acts_as_calculator_editor/validate_expression.rb +127 -0
  66. data/lib/acts_as_calculator_editor/version.rb +5 -0
  67. data/lib/acts_as_calculator_editor.rb +76 -0
  68. data/lib/tasks/acts_as_calculator_editor_tasks.rake +6 -0
  69. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculatorEditor
4
+ VERSION = "0.1.0"
5
+ end