Allow Quantity formula to be nil but not blank Add computed energy quantity Reverse default quantities deletion order
146 lines
4.2 KiB
Ruby
146 lines
4.2 KiB
Ruby
module BodyTracking
|
|
module Formula
|
|
require 'ripper'
|
|
QUANTITY_TTYPES = [:on_ident, :on_tstring_content, :on_const]
|
|
FUNCTIONS = ['abs', 'nil?']
|
|
|
|
class InvalidFormula < RuntimeError; end
|
|
class Formula
|
|
def initialize(project, formula)
|
|
@project_quantities = Quantity.where(project: project)
|
|
@formula = formula
|
|
@paramed_formula = nil
|
|
@quantities = nil
|
|
end
|
|
|
|
def validate
|
|
# TODO: add tests
|
|
# failing test vectors:
|
|
# - fcall disallowed: "abs(Fats)+Energy < 10"
|
|
# working test vectors:
|
|
# ((Energy-Calculated)/Energy).abs > 0.2
|
|
# Fats.nil? || Fats/Proteins > 2
|
|
errors = []
|
|
|
|
# 1st: check if formula is syntactically valid Ruby code
|
|
begin
|
|
eval("-> { #{@formula} }")
|
|
rescue ScriptError => e
|
|
errors << [:invalid_formula, {msg: e.message}]
|
|
end
|
|
|
|
# 2nd: check if formula contains only allowed token types
|
|
# 3rd: check for disallowed function calls
|
|
identifiers = []
|
|
disallowed = Hash.new { |h,k| h[k] = Set.new }
|
|
|
|
stree = [Ripper.sexp(@formula)]
|
|
errors << [:unparsable_formula, {}] unless stree.first
|
|
|
|
while stree.first
|
|
ttype, token, *rest = stree.shift
|
|
case ttype
|
|
when :program, :args_add_block, :paren
|
|
stree.unshift(*token)
|
|
when :binary
|
|
operator, token2 = rest
|
|
stree.unshift(token, token2)
|
|
when :method_add_arg
|
|
stree.unshift(token, *rest)
|
|
when :call
|
|
stree.unshift(token)
|
|
dot, method = rest
|
|
ftype, fname, floc = method
|
|
disallowed[:function] << fname unless FUNCTIONS.include?(fname)
|
|
when :fcall
|
|
ftype, fname, floc = token
|
|
disallowed[:function] << fname
|
|
when :vcall
|
|
ftype, fname, floc = token
|
|
identifiers << fname
|
|
when :arg_paren
|
|
stree.unshift(token)
|
|
when :var_ref
|
|
vtype, vname, vloc = token
|
|
case vtype
|
|
when :@const
|
|
identifiers << vname
|
|
when :@kw
|
|
disallowed[:keyword] << token if vname != 'nil'
|
|
end
|
|
when :@int, :@float
|
|
else
|
|
errors << [:disallowed_token, {token: token, ttype: ttype}]
|
|
end
|
|
end
|
|
|
|
disallowed[:function].each { |f| errors << [:disallowed_function, {function: f}] }
|
|
disallowed[:keyword].each { |k| errors << [:disallowed_keyword, {keyword: k}] }
|
|
|
|
# 4th: check if identifiers used in formula correspond to existing quantities
|
|
identifiers.uniq!
|
|
quantities = @project_quantities.where(name: identifiers)
|
|
quantities_names = quantities.pluck(:name)
|
|
(identifiers - quantities_names).each do |q|
|
|
errors << [:unknown_quantity, {quantity: q}]
|
|
end
|
|
|
|
if errors.empty?
|
|
@quantities = quantities
|
|
@paramed_formula = Ripper.lex(@formula).map do |*, ttype, token|
|
|
if QUANTITY_TTYPES.include?(ttype) && quantities_names.include?(token)
|
|
"params['#{token}']"
|
|
else
|
|
token
|
|
end
|
|
end.join
|
|
end
|
|
|
|
errors
|
|
end
|
|
|
|
def valid?
|
|
@quantities || self.validate.empty?
|
|
end
|
|
|
|
def get_quantities
|
|
raise RuntimeError, 'Invalid formula' unless self.valid?
|
|
|
|
@quantities.to_a
|
|
end
|
|
|
|
def calculate(inputs)
|
|
raise RuntimeError, 'Invalid formula' unless self.valid?
|
|
|
|
inputs.map do |i, values|
|
|
puts values.inspect
|
|
begin
|
|
[i, [get_binding(values).eval(@paramed_formula), nil]]
|
|
rescue Exception => e
|
|
puts e.message
|
|
[i, [nil, nil]]
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def get_binding(params)
|
|
binding
|
|
end
|
|
end
|
|
|
|
class FormulaValidator < ActiveModel::EachValidator
|
|
def initialize(options)
|
|
super(options)
|
|
end
|
|
|
|
def validate_each(record, attribute, value)
|
|
Formula.new(record.project, value).validate.each do |message, params|
|
|
record.errors.add(attribute, message, params)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|