Cel::Ruby

Gem Version pipeline status coverage report

Pure Ruby implementation of Google Common Expression Language, https://opensource.google/projects/cel.

The Common Expression Language (CEL) implements common semantics for expression evaluation, enabling different applications to more easily interoperate.

Installation

Add this line to your application's Gemfile:

gem 'cel'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install cel

Usage

The usage pattern follows the pattern defined by cel-go, i.e. define an environment, which then can be used to parse, compile and evaluate a CEL program.

require "cel"

# set the environment
env = Cel::Environment.new(declarations: { name: :string, group: :string })

# 1.1 parse
begin
  ast = env.compile('name.startsWith("/groups/" + group)') #=> Cel::Types[:bool], which is == :bool
rescue Cel::Error => e
  STDERR.puts("type-check error: #{e.message}")
  raise e
end
# 1.2 check
prg = env.program(ast)
# 1.3 evaluate
return_value = prg.evaluate(name: Cel::String.new("/groups/acme.co/documents/secret-stuff"),
    group: Cel::String.new("acme.co"))

# 2.1 parse and check
prg = env.program('name.startsWith("/groups/" + group)')
# 2.2 then evaluate
return_value = prg.evaluate(name: Cel::String.new("/groups/acme.co/documents/secret-stuff"),
    group: Cel::String.new("acme.co"))

# 3. or parse, check and evaluate
begin
  return_value = env.evaluate(ast,
    name: Cel::String.new("/groups/acme.co/documents/secret-stuff"),
    group: Cel::String.new("acme.co")
  )
rescue Cel::Error => e
  STDERR.puts("evaluation error: #{e.message}")
  raise e
end

puts return_value #=> true

Environment

Cel::Environment is the entrypoint for parsing, checking and evaluating CEL expressions, as well as customizing/constraining any of these functions. For that matter, it can be initialized with the following keyword arguments:

  • :declarations: a hash of the expected variables names-to-cel-types, which are used to validate the expression and data bindings. When variables aren't declared, they assume the any type.
  • :container: used to declare the package of protobuf messages only declared by name used in the expression (i.e. cel.expr.conformance.proto3).
  • :disable_check: (defaults to false) enables/disables expression check phase (except when env.check(expr) is explicitly called).
  • max_recursion_depth: (defaults to 32) max number of parsable recursive/repeating rules, as per what the spec states.
  • max_nesting_depth: (defaults to 12) max number of parsable nested rules, as per what the spec states.

declarations

cel-ruby supports declaring the types of variables in the environment, which enhances expression type checking:

# without declarations
env = Cel::Environment.new
env.check("[first_name] + middle_names + [last_name]") #=> any

# with declarations
env = Cel::Environment.new(
  declarations: {
    first_name: :string, # shortcut for Cel::Types[:string]
    middle_names: Cel::TYPES[:list, :string], # list of strings
    last_name: :string
  }
)
env.check("[first_name] + middle_names + [last_name]") #=> list(string)

# you can use Cel::TYPES to access any type of primitive type, i.e. Cel::TYPES[:bytes]

:container

This can be used to simplify writing CEL expressions with long protobuf package declarations:

env = Cel::Environment.new
env.evaluate("my.company.private.protobufs.Account{id: 2}.id)")
# or
env = Cel::Environment.new(container: "my.company.private.protobufs.")
env.evaluate("Account{id: 2}.id)")

Note: the google.protobuf packaged is already supported OOTB.

protobuf

If google/protobuf is available in the environment, cel-ruby will also be able to integrate with protobuf declarations in CEL expressions.

require "google/protobuf"
require "cel"

env = Cel::Environment.new

env.evaluate("google.protobuf.Duration{seconds: 123}.seconds == 123") #=> true

Custom functions

cel-ruby allows you to define custom functions to be used insde CEL expressions. While we strongly recommend usage of Cel::Function for defining them (due to the ability of them being used for checking), the only requirement is that the function object responds to .call:

env = Cel::Environment.new(declarations: {foo: Cel::Function(:int, :int, return_type: :int) { |a, b|  a + b }})
env.evaluate("foo(2, 2)") #=> 4

# this is also possible, just not as type-safe
env2 = Cel::Environment.new(declarations: {foo: ->(a, b) { a + b }})
env2.evaluate("foo(2, 2)") #=> 4

Abstract types

cel-ruby supports defining abstract types, via the Cel::AbstractType class.

class Tuple
  attr_reader :value
  def initialize(ary)
    @value = ary
  end
end

tuple_type = Class.new(Cel::AbstractType) do
  def convert(value)
    Tuple.new(value)
  end
end.new(:tuple, Cel::TYPES[:int], Cel::TYPES[:int])
env = Cel::Environment.new(declarations: { tuple: Cel::Function(:int, :int, return_type: tuple_type) {|a, b| [a, b] } })
env.check('tuple(1, 2)') #=> tuple type
res = env.evaluate('tuple(1, 2)') #=> Tuple instance
res.value #=> [1, 2]

Custom Extensions

cel already supports the conformance spec extensions packages. However, if you need to add your own, you can do so:

module Ext
  def __check(funcall, checker:)
    func = funcall.func
    args = funcall.args

    case func
    when :random
      checker.check_arity(func, args, 0)
      return TYPES[:int]
    else
      checker.unsupported_operation(funcall)
    end
  end

  # extensions will always receive the program instance as a kwarg
  def random(program:)
    42
  end
end

env = Cel::Environment.new(extensions: { ext: Ext})
env.evaluate("ext.random()") #=> 42

Spec Coverage

cel is tested against the conformance suite from the cel-spec repository, and supports all features from the language except:

  • math extensions
  • string extensions
  • bindings extensions
  • block extensions
  • encoders extensions
  • comprehensions V2 API
  • optionals

If this is something you're interested in (helping out), add a mention in the corresponding issue (or create one when non is available already).

Supported Rubies

All Rubies greater or equal to 2.7, and always latest JRuby and Truffleruby.

cel can be used inside ractors, but you need to freeze it first:

# can't be used in ractors
Cel.freeze
# can be used in ractors

Development

Clone the repo in your local machine, where you have ruby installed. Then you can:

# install dev dependencies
> bundle install
# create protobuf stubs for tests
> git clone --depth 1 https://github.com/google/cel-spec.git
> git clone --depth 1 https://github.com/googleapis/googleapis.git
> bundle exec rake build_test_protos
# run tests
> bundle exec rake test
# build protobuf stubs for conformance tests
> bundle exec rake build_conformance_protos
# run conformance tests
> bundle exec rake conformance

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

CEL parser

The parser is based on the grammar defined in cel-spec, and developed using racc, a LALR(1) parser generator, which is part of ruby's standard library.

Changes in the parser are therefore accomplished by modifying the parser.ry file and running:

> bundle exec racc -F -o lib/cel/parser.rb lib/cel/parser.ry

Contributing

Bug reports and pull requests are welcome on Gitlab at https://gitlab.com/os85/cel-ruby.