Delorean is a simple functional scripting language. It is used at PENNYMAC as a scripting language for a financial modeling system.
$ gem install delorean_lang
Or add it to your Gemfile
, etc.
require 'delorean_lang'
engine = Delorean::Engine.new("MyModule")
my_code =<<eom
NodeA:
param =?
attr1 = param * 3
attr2 = attr1 + 3
attr3 = attr1 / attr2
NodeB: NodeA
attr3 = attr1 / NodeA.attr3
eom
engine.parse my_code
engine.evaluate("NodeB", %w{attr1 attr2 attr3}, {"param"=>15})
-
The primary motivation for creation of Delorean was to provide a simple scripting language for use by financial analysts.
-
The scripting language needed to be tightly coupled with Ruby. i.e. be able to query ActiveRecord models. Ruby itself was deemed too complex for our users. Also, sand-boxing Ruby to prevent unauthorized data access did not seem practical.
-
Many of the financial models created at PENNYMAC are simple modifications of earlier models. It was important for the scripting language to provide a simple inheritance model such that major parts of these models could be shared.
Delorean is a functional programming language. As such, it eschews mutable data and state. There's also no concept of I/O in the classic sense.
A Delorean script is comprised of a set of Nodes which include a collection of attribute definitions. The following is a simple node definition:
NodeA:
attr1 = 123
attr2 = attr1*2
In the above example, NodeA
is a new node definition. This node
includes two attributes: attr1
and attr2
. attr1
is defined to be
the integer literal 123
. attr2
is a function which is defined as
attr1
multiplied by 2.
Computation in Delorean happens through evaluation of node attributes.
Therefore, in the above example, NodeA.attr2
evaluates to 246
.
Delorean attribute definitions have the following form:
attr = expression
Where attr
is an attribute name. Attribute names are required to
match the following regular expression: [a-z][a-zA-Z0-9_]*
. An
attribute can only be specified once in a node. Also, any attributes
it refers to in its expression must have been previously defined.
Delorean also provides a mechanism to provide "input" to a computation. This is performed thorough a special kind of attribute called a parameter. The following example shows the usage of a parameter:
NodeB:
param =? "hello"
attr = param + " world"
In this example, param
is defined as a parameter whose default value
is "hello"
, which is a string literal. If we evaluate NodeB.attr
without providing param
, the result will be the string "hello world"
. If the param
is sent in with the value "look out"
, then
NodeB.attr
will evaluate to "look out world"
.
The parameter default value is optional. If no default value if provided for a parameter, then a value must be sent in if that parameter is involved in a computation. Otherwise an error will result.
An important concept in Delorean is that of node inheritance. This mechanism allows nodes to derive functionality from previously defined nodes. The following example shows the usage of inheritance:
USInfo:
age = ?
teen_max = 19
teen_min = 13
is_teenager = age >= teen_min && age <= teen_max
IndiaInfo: USInfo
teen_min = 10
In this example, node USInfo
provides a definition of a
is_teenager
when provided with an age
parameter. Node IndiaInfo
is derived from USInfo
and so it shares all of its attribute
definitions. However, the teen_min
attribute has been overridden.
This specifies that the computation of is_teenager
will use the
newly defined teen_min
. Therefore, IndiaInfo.is_teenager
with
input of age = 10
will evaluate to true
. Whereas,
USInfo.is_teenager
with input of age = 10
will evaluate to false
.
You can use (ERR())
to add a breakpoint:
USInfo:
age = ?
teen_max = 19
teen_min = 13
is_teenager = (ERR()) && age >= teen_min && age <= teen_max
Then you can call attributes by using their mangled name (e.g. attr__D) and passing the context. attr__D(_e). Of course, you can use ls
to list available methods.
teen_max__D(_e) # 19
age__D(_e)
TODO: provide details on the following topics:
- Supported data types
- Data structures (arrays and hashes)
- List comprehension
- Built-in functions
- Defining Delorean-callable class functions
- External modules
This implementation of Delorean "compiles" script code to Ruby.
There are two ways of calling ruby code from delorean. First one is to whitelist methods:
::Delorean::Ruby.whitelist.add__method :any? do |method|
method.called_on Enumerable
end
::Delorean::Ruby.whitelist.add_method :length do |method|
method.called_on String
method.called_on Enumerable
end
::Delorean::Ruby.whitelist.add_method :first do |method|
method.called_on Enumerable, with: [Integer]
end
::Delorean::Ruby.whitelist.add_class_method :last do |method|
method.called_on ActiveRecord::Base, with: [Integer]
end
By default Delorean has some methods whitelisted, such as length
, min
, max
, etc. Those can be found in /lib/delorean/ruby/whitelists/default
. If you don't want to use defaults, you can override whitelist with and empty one.
require 'delorean/ruby/whitelists/empty'
::Delorean::Ruby.whitelist = ::Delorean::Ruby::Whitelists::Empty.new
Another way is to define methods using delorean_fn
with optional private
and cache
flags.
Use extend Delorean::Functions
or include Delorean::Model
in your module or class.
class Dummy < ActiveRecord::Base
include Delorean::Model
delorean_fn(:heres_my_number) do |*a|
a.inject(0, :+)
end
delorean_fn :private_cached_number, cache: true, private: true do |*a|
a.inject(0, :+)
end
end
module DummyModule
extend Delorean::Functions
delorean_fn(:heres_my_number) do |*a|
a.inject(0, :+)
end
end
heres_my_number
method will be accessible from Delorean code.
ExampleScript:
a = Dummy.heres_my_number(867, 5309)'
b = DummyModule.heres_my_number(867, 5309)'
You can use blocks in your Delorean code:
ExampleScript:
a = [1, 2, 3]
b = c.any?
item =?
result = item > 2
c = a.reduce(0)
sum =?
num =?
result = sum + num
Note that do ... end
syntax is not supported
Delorean provides cache
flag for delorean_fn
method that will cache result based on arguments.
delorean_fn :returns_cached_openstruct, cache: true do |timestamp|
User.all
end
If ::Delorean::Cache.adapter.cache_item?(...)
returns false
then caching will not be performed.
By default cache keeps the last 1000 of the results per class. You can override it:
::Delorean::Cache.adapter = ::Delorean::Cache::Adapters::RubyCache.new(size_per_class: 10)
If you want use other caching method, you can use your own adapter:
::Delorean::Cache.adapter = ::My::Custom::Cache::Adapter.new
Delorean expects it to have methods with following signatures:
cache_item(klass:, cache_key:, item:)
fetch_item(klass:, cache_key:, default:)
fetch_item(klass:, cache_key:, default:)
cache_key(klass:, method_name:, args:)
clear!(klass:)
clear_all!
cache_item?(klass:, method_name:, args:)
# See lib/delorean/cache/adapters/base.rb
You can enable caching for a Delorean node with _cache = true
attribute.
ExampleScript:
param1 =?
_cache = true
a = Dummy.heres_my_number(867, 5309)
b = DummyModule.heres_my_number(867, 5309)
result = b
When node is called, Delorean would check if there is a cached result for a combination of node parameters values and the attribute that is to be returned.
ExampleScript(param1=1).result # Will calculate result and cache it for calls with `param1=1`
ExampleScript(param1=1).result # Will fetch the cached result
ExampleScript(param1=2).result # Will calculate result and cache it for calls with `param1=2`
You can override the callback that Delorean calls before performing the caching.
The callback should return a hash with :cache
key.
If cache:
is false, then Delorean wouldn't fetch result from cache or perform caching.
::Delorean::Cache.node_cache_callback = lambda do |klass:, method:, params:|
{
cache: true,
}
end
# See lib/delorean/cache.rb
TODO: provide details
Edit treetop rules in lib/delorean/delorean.treetop
Use make treetop-generate
to regenerate lib/delorean/delorean.rb
based on Treetop logic in lib/delorean/delorean.treetop
Use rspec
to run the tests.
Delorean has been released under the MIT license. Please check the LICENSE file for more details.