diff --git a/app/controllers/quantities_controller.rb b/app/controllers/quantities_controller.rb index 1f1e64a..cb662be 100644 --- a/app/controllers/quantities_controller.rb +++ b/app/controllers/quantities_controller.rb @@ -60,10 +60,11 @@ class QuantitiesController < ApplicationController def quantity_params params.require(:quantity).permit( - :name, - :description, :domain, :parent_id, + :name, + :description, + :formula, :primary ) end diff --git a/app/models/quantity.rb b/app/models/quantity.rb index cc610f0..ff43dcf 100644 --- a/app/models/quantity.rb +++ b/app/models/quantity.rb @@ -1,4 +1,6 @@ class Quantity < ActiveRecord::Base + require 'ripper' + enum domain: { diet: 0, measurement: 1, @@ -13,6 +15,41 @@ class Quantity < ActiveRecord::Base validate if: -> { parent.present? } do errors.add(:parent, :parent_domain_mismatch) unless domain == parent.domain end + validate if: -> { formula.present? } do + # 1st: check if formula is valid Ruby code + tokenized_length = Ripper.tokenize(formula).join.length + unless tokenized_length == formula.length + errors.add(:formula, :invalid_formula, {part: formula[0...tokenized_length]}) + end + + # 2nd: check if formula contains only allowed token types + identifiers = [] + Ripper.lex(formula).each do |location, ttype, token| + case + when [:on_ident, :on_tstring_content, :on_const].include?(ttype) + identifiers << token + when [:on_sp, :on_int, :on_rational, :on_float, :on_tstring_beg, :on_tstring_end, + :on_lparen, :on_rparen].include?(ttype) + when :on_op == ttype && '+-*/'.include?(token) + else + errors.add(:formula, :disallowed_token, + {token: token, ttype: ttype, location: location}) + end + end + + # 3rd: check for disallowed function calls (they are not detected by Ripper.lex) + # FIXME: this is unreliable (?) detection of function calls, should be replaced + # with parsing Ripper.sexp if necessary + function = Ripper.slice(formula, 'ident [sp]* lparen') + errors.add(:formula, :disallowed_function_call, {function: function}) if function + + # 4th: check if identifiers used in formula correspond to existing quantities + identifiers.uniq! + quantities = self.project.quantities.where(name: identifiers).pluck(:name) + if quantities.length != identifiers.length + errors.add(:formula, :unknown_quantity, {quantities: identifiers - quantities}) + end + end after_initialize do if new_record? diff --git a/app/views/quantities/_form.html.erb b/app/views/quantities/_form.html.erb index 1dad598..07d2c9e 100644 --- a/app/views/quantities/_form.html.erb +++ b/app/views/quantities/_form.html.erb @@ -10,6 +10,7 @@ <% end %>

<%= f.text_field :name, size: 25, required: true %>

<%= f.text_field :description, size: 200 %>

+

<%= f.text_field :formula, size: 200, placeholder: t('.formula_placeholder') %>

<%= f.check_box :primary %>

diff --git a/config/locales/en.yml b/config/locales/en.yml index 6dda2c5..fabac86 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -10,6 +10,7 @@ en: field_nutrients: 'Nutrients:' field_domain: 'Domain' field_parent_quantity: 'Parent' + field_formula: 'Formula' field_primary: 'Primary' field_shortname: 'Short name' button_primary: 'Primary' @@ -24,6 +25,12 @@ en: attributes: parent: parent_domain_mismatch: 'parent quantity has to be in the same domain' + formula: + invalid_formula: 'interpretation failed after: %{part}' + disallowed_token: 'includes disallowed token "%{token}" + (of type %{ttype}) at %{location}' + disallowed_function_call: 'includes disallowed function call %{function}' + unknown_quantity: 'contains undefined quantities: %{quantities}' body_trackers: index: heading: 'Summary' @@ -82,6 +89,8 @@ en: measurement: 'measurement' exercise: 'exercise' null_parent: '- none -' + formula_placeholder: 'provide if value of quantity has to be computed in terms of + other quantities' units: index: heading: 'Units'