dentaku icon indicating copy to clipboard operation
dentaku copied to clipboard

Support for custom object evaluation

Open bbugh opened this issue 3 years ago • 4 comments

Hi! 👋 This gem looks awesome, thanks for making it. It's a perfect fit for some needs we have to do simple user template evaluation to generate documents. Our use case includes object evaluation, though, and I couldn't see a way for it to work with dentaku. For example, "user.age >= 18, where user is a simple class like this:

class User
  attr_reader :age
end

I tried doing something like

calculator.store(user: ->{ User.last })
calculator.evaluate("user.age >= 15")
=> nil

We managed to make it work by adding a string representing the object chain...

calculator.store('user.age', ->{ User.last.age })
calculator.evaluate("user.age >= 15")
=> true

...but we have hundreds of variations and nestings we'd have to support (like user.profile.default_value) and it wouldn't be practical to generate them all.

Is there a mechanism that would allow for the dot chain evaluation that I missed, maybe some kind of custom evaluator? If not, do you think it would be feasible to add?

(We can also pre-evaluate the values into a string, and then use Dentaku to calculate the final results, but that would unfortunately require a large refactor of our parser and generator, so this seems like the simplest route to start).

Thanks!

bbugh avatar Dec 01 '20 17:12 bbugh

I would approach this by figuring out a way to transform your objects into hashes, and then pass those in as the context data for your formulas. If these are something like ActiveRecord or ActiveModel objects, then you can probably use attributes or as_json to help. If these are just simple Ruby classes, then a quick attribute serializer module might do the trick:

module Hashify
  def hashify
    instance_variables.each_with_object({}) do |var, h|
      key = var.to_s[1..] # transform :@var into "var"
      value = instance_variable_get(var)
      h[key] = value.respond_to?(:hashify) ? value.hashify : value
    end
  end
end

class User
  include Hashify
  attr_reader :age, :profile

  def initialize(age, profile)
    @age = age
    @profile = profile
  end
end

class Profile
  include Hashify
  attr_reader :favorite_color

  def initialize(favorite_color)
    @favorite_color = favorite_color
  end
end

user = User.new(21, Profile.new("red"))
Dentaku("user.age >= 18 AND user.profile.favorite_color = 'red'", user: user.hashify)
# => true

rubysolo avatar Dec 02 '20 04:12 rubysolo

Thanks for the reply! We looked at hashing, but unfortunately all of the fields need to be lazy-loaded. We have hundreds of calculated value methods used in these templates, and in aggregate would be way too slow. Generating a full hash for just one object could take 10+ seconds, and in many cases dozens of objects are used in one template.

If supporting dot notation is not a good fit, another solution that could work is making it possible to override/subclass variable lookup. Something like:

class CustomVariableLookup < DentakuLookup
  def initialize(context)
    @context = context 
  end

  def value(name)
    # look up name and method availability (split on first dots?)
    if found
      result
    else
      super
    end
  end
end

calculator = Dentaku::Calculator.new(lookup: CustomVariableLookup.new(our_context))

bbugh avatar Dec 02 '20 08:12 bbugh

Ah, sorry -- I guess that's what you meant by "it wouldn't be practical to generate them all". 😆

Currently Dentaku eagerly flattens hash values into dot-separated keys when storing things in memory by default, but you can disable this by setting nested_data_support to false on the Calculator instance. However, that would probably not be enough because variable lookup assumes a flattened key. I can look into how to best support this.

As a temporary workaround, would it be feasible to make the "hashify" code a little smarter so that you could feed in just the values you need flattened? If so, you could parse a formula, get the unbound variables needed to evaluate it and then feed that to hashify to get just the data you need.

rubysolo avatar Dec 02 '20 16:12 rubysolo

I pushed up a lazy-resolver branch, can you take a look and see if this would work for your use case?

(updated to fix branch reference) 😊

rubysolo avatar Dec 02 '20 23:12 rubysolo