1
0
This repository has been archived on 2023-12-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
body_tracking/lib/body_tracking/formula.rb
cryptogopher 3ce5d5c940 Fix migration of default quantities
Allow Quantity formula to be nil but not blank
Add computed energy quantity
Reverse default quantities deletion order
2019-11-15 00:04:34 +01:00

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