cel 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12cebd55174d05db4f154e185872921949f3931817503fccb5dec9f516c3128d
4
- data.tar.gz: 8fa9c386f343ecfdda97ce99c37296e15378763ab0308270fdc0e789cb4c9ab0
3
+ metadata.gz: 5e83a98ac5e076442ae06b53d13f43581a3982196f57582bbac02a08eef46ca9
4
+ data.tar.gz: 1fdb06029d13f3a47037688df7d4d4a77f65e237aa73d0e00fb6989a78600000
5
5
  SHA512:
6
- metadata.gz: a46166c14750ef4fc245d1aca362f103d4340ddce4155c51e2f49e03ea64c5d812dccaf61ac073f9f6207995a2f8090a748fe06bf984159542385416a2e9e83a
7
- data.tar.gz: cfa9d65dd070389fca1ed80817a83baee1efe193cab6830bfd0a3278051a8593a673a4de8e19c19db1a450312cad69ccb91ac49e5db2ee02d18d0efb301319c1
6
+ metadata.gz: 59eb650749f3b0fcef9a8ee66b6850831db66f116d851f35894d375191e280cac465e48c1f39103d6fcb21f7ebcf6c695f2cd286c65bcfb6c12ef08ddcb07400
7
+ data.tar.gz: b6dada73b82d27a17a6d9f23a672f36ec7c9af6f89eebef788227aba7b22cdc88720100ffcf5f466db70e923db689ff4df903942abb7ab2f8adeef70de4604df
data/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2025-12-11
4
+
5
+ ### Features
6
+
7
+ #### Custom extensions
8
+
9
+ A new `:extensions` kwarg is added to `Cel::Environment.new` which allows adding custom extensions, in a similar manner as what the standard extensions (like `math` or `string`) are done:
10
+
11
+ ```ruby
12
+ module Ext
13
+ # defines a random function which takes no arguments and returns 42
14
+ end
15
+
16
+ Cel::Environment.new.evaluate("ext.random()") #=> raises error
17
+ Cel::Environment.new(extensions: { ext: Ext }).evaluate("ext.random()") #=> 42
18
+ ```
19
+
20
+ ### Backwards Compatibility
21
+
22
+ The ractor safety introduced in 0.4.1 has been relaxed in order to allow extensions of core classes by custom extensions, And you'll need to explicitly call `Cel.freeze` before using `cel` inside ractors. This is a direct consequence of how extensions patch `cel` core classes.
23
+
24
+ ATTENTION: Changes may be introduced in the way core classes are patched by extensions, towards making `cel` ractor-safe by default. If you rely on custom extensions, do follow the migration instructions in subsequent releases.
25
+
26
+ ### Bugfixes
27
+
28
+ Fixed checker type inference when using nexted expressions (like when using the `bind` extensions to evaluate cel sub-expressions).
29
+
30
+ ## [0.4.1] - 2025-11-25
31
+
32
+ ### Improvements
33
+
34
+ * Literal class can now mark which methods are CEL directives, the remainder being lib private helpers.
35
+ * `cel` is now ractor compatible.
36
+ * Documentation on how to support abstract types has been added.
37
+
38
+ ### Security
39
+
40
+ A remote execution attack vector has been fixed, which allowed executing arbitrary Ruby code within a CEL expression when calling functions on a variable declared as a CEL map. Example:
41
+
42
+ ```ruby
43
+ env = Cel::Environment.new(declarations: { webhook: :map })
44
+ env.evaluate("webhook.payload.send('eval', 'File.write(\"test.txt\", \"Hello, world!\")')", webhook: { payload: {} })
45
+ ```
46
+
3
47
  ## [0.4.0] - 2025-11-18
4
48
 
5
49
  ### Features
@@ -23,7 +67,7 @@ When the `tzinfo-data` gem is loaded, expressions will support human-friendly ti
23
67
 
24
68
  ### Bugfixes
25
69
 
26
- * `has()` macro correctly works for mapsl
70
+ * `has()` macro correctly works for maps
27
71
 
28
72
  ## [0.3.0] - 2025-06-12
29
73
 
data/README.md CHANGED
@@ -139,6 +139,58 @@ env2 = Cel::Environment.new(declarations: {foo: ->(a, b) { a + b }})
139
139
  env2.evaluate("foo(2, 2)") #=> 4
140
140
  ```
141
141
 
142
+ ### Abstract types
143
+
144
+ `cel-ruby` supports defining abstract types, via the `Cel::AbstractType` class.
145
+
146
+ ```ruby
147
+ class Tuple
148
+ attr_reader :value
149
+ def initialize(ary)
150
+ @value = ary
151
+ end
152
+ end
153
+
154
+ tuple_type = Class.new(Cel::AbstractType) do
155
+ def convert(value)
156
+ Tuple.new(value)
157
+ end
158
+ end.new(:tuple, Cel::TYPES[:int], Cel::TYPES[:int])
159
+ env = Cel::Environment.new(declarations: { tuple: Cel::Function(:int, :int, return_type: tuple_type) {|a, b| [a, b] } })
160
+ env.check('tuple(1, 2)') #=> tuple type
161
+ res = env.evaluate('tuple(1, 2)') #=> Tuple instance
162
+ res.value #=> [1, 2]
163
+ ```
164
+
165
+ ### Custom Extensions
166
+
167
+ `cel` already supports the [conformance spec extensions packages](https://pkg.go.dev/github.com/google/cel-go/ext#section-readme). However, if you need to add your own, you can do so:
168
+
169
+ ```ruby
170
+ module Ext
171
+ def __check(funcall, checker:)
172
+ func = funcall.func
173
+ args = funcall.args
174
+
175
+ case func
176
+ when :random
177
+ checker.check_arity(func, args, 0)
178
+ return TYPES[:int]
179
+ else
180
+ checker.unsupported_operation(funcall)
181
+ end
182
+ end
183
+
184
+ # extensions will always receive the program instance as a kwarg
185
+ def random(program:)
186
+ 42
187
+ end
188
+ end
189
+
190
+ env = Cel::Environment.new(extensions: { ext: Ext})
191
+ env.evaluate("ext.random()") #=> 42
192
+ ```
193
+
142
194
  ## Spec Coverage
143
195
 
144
196
  `cel` is tested against the conformance suite from the [cel-spec repository](https://github.com/google/cel-spec/tree/master/conformance), and supports all features from the language except:
@@ -157,6 +209,14 @@ If this is something you're interested in (helping out), add a mention in the co
157
209
 
158
210
  All Rubies greater or equal to 2.7, and always latest JRuby and Truffleruby.
159
211
 
212
+ `cel` can be used inside ractors, but you need to freeze it first:
213
+
214
+ ```ruby
215
+ # can't be used in ractors
216
+ Cel.freeze
217
+ # can be used in ractors
218
+ ```
219
+
160
220
  ## Development
161
221
 
162
222
  Clone the repo in your local machine, where you have `ruby` installed. Then you can:
@@ -185,7 +245,7 @@ The parser is based on the grammar defined in [cel-spec](https://github.com/goog
185
245
  Changes in the parser are therefore accomplished by modifying the `parser.ry` file and running:
186
246
 
187
247
  ```bash
188
- > bundle exec racc -o lib/cel/parser.rb lib/cel/parser.ry
248
+ > bundle exec racc -F -o lib/cel/parser.rb lib/cel/parser.ry
189
249
  ```
190
250
 
191
251
  ## Contributing
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ module CelMethods
5
+ def self.included(klass)
6
+ super
7
+ klass.class_eval do
8
+ @cel_methods = [] # rubocop:disable ThreadSafety/ClassInstanceVariable
9
+
10
+ extend ClassMethods
11
+ include InstanceMethods
12
+ end
13
+ end
14
+
15
+ module ClassMethods
16
+ attr_reader :cel_methods
17
+
18
+ def define_cel_method(meth, &blk)
19
+ Ractor.make_shareable(blk) if defined?(Ractor)
20
+ define_method(meth, &blk)
21
+
22
+ @cel_methods << meth # rubocop:disable ThreadSafety/ClassInstanceVariable
23
+ end
24
+
25
+ def inherited(klass)
26
+ super
27
+
28
+ klass.instance_variable_set(:@cel_methods, [])
29
+ end
30
+
31
+ def freeze(*a)
32
+ super
33
+ @cel_methods.freeze(*a) # rubocop:disable ThreadSafety/ClassInstanceVariable
34
+ end
35
+ end
36
+
37
+ module InstanceMethods
38
+ def cel_send(meth, *args)
39
+ meth = meth.to_sym
40
+ raise NoCelMethodError unless cel_method_defined?(meth)
41
+
42
+ public_send(meth, *args)
43
+ end
44
+
45
+ def cel_method_defined?(meth)
46
+ klass = self.class
47
+
48
+ while klass != Literal
49
+ return true if klass.cel_methods.include?(meth)
50
+
51
+ klass = klass.superclass
52
+ end
53
+
54
+ klass.cel_methods.include?(meth)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Bool < Literal
5
+ def initialize(value)
6
+ super(:bool, value)
7
+ end
8
+
9
+ (LOGICAL_OPERATORS - %w[== != in]).each do |op|
10
+ class_eval(<<-OUT, __FILE__, __LINE__ + 1)
11
+ define_cel_method(:#{op}) do |other|
12
+ return super(other) unless other.is_a?(Bool)
13
+
14
+ lhs = @value ? 1 : 0
15
+ rhs = other.value ? 1 : 0
16
+ lhs.__send__(__method__, rhs)
17
+ end
18
+ OUT
19
+ end
20
+
21
+ def !
22
+ Bool.cast(super)
23
+ end
24
+
25
+ TRUE_VALUE = new(true)
26
+ FALSE_VALUE = new(false)
27
+
28
+ def self.cast(val)
29
+ return val if val.is_a?(Bool)
30
+
31
+ val ? TRUE_VALUE : FALSE_VALUE
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Bytes < Literal
5
+ attr_reader :string
6
+
7
+ def initialize(value)
8
+ super(:bytes, value)
9
+ @string = value.pack("C*")
10
+ end
11
+
12
+ def to_ary
13
+ [self]
14
+ end
15
+
16
+ alias_method :to_ruby_type, :string
17
+
18
+ (LOGICAL_OPERATORS - %w[==]).each do |op|
19
+ class_eval(<<-OUT, __FILE__, __LINE__ + 1)
20
+ define_cel_method(:#{op}) do |other|
21
+ other.is_a?(Cel::Bytes) ? Bool.cast(@string.__send__(__method__, other.string)) : super(other)
22
+ end
23
+ OUT
24
+ end
25
+
26
+ %i[+ -].each do |op|
27
+ class_eval(<<-OUT, __FILE__, __LINE__ + 1)
28
+ define_cel_method(:#{op}) do |other|
29
+ Bytes.new(@value + other.value)
30
+ end
31
+ OUT
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Condition
5
+ attr_reader :if, :then, :else, :depth
6
+
7
+ def initialize(if_, then_, else_, depth: 1)
8
+ @if = if_
9
+ @then = then_
10
+ @else = else_
11
+ @depth = depth
12
+ end
13
+
14
+ def type
15
+ TYPES[:any]
16
+ end
17
+
18
+ def ==(other)
19
+ other.is_a?(Condition) && @if == other.if && @then == other.then && @else == other.else
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Duration < Literal
5
+ def initialize(value)
6
+ value =
7
+ case value
8
+ when ::String
9
+ init_from_string(value)
10
+ when Hash
11
+ seconds, nanos = value.values_at(:seconds, :nanos)
12
+ seconds ||= 0
13
+ nanos ||= 0
14
+ seconds + (nanos / 1_000_000_000.0)
15
+ else
16
+ value
17
+ end
18
+
19
+ raise EvaluateError, "out of range" unless (value * 1_000_000_000).between?(-MAX_INT, MAX_INT)
20
+
21
+ super(:duration, value)
22
+ end
23
+
24
+ def to_s
25
+ seconds = getSeconds
26
+ millis = getMilliseconds
27
+ millis.positive? ? "#{seconds}s#{millis}m" : "#{seconds}s"
28
+ end
29
+
30
+ define_cel_method(:+) do |other|
31
+ case other
32
+ when Cel::Duration
33
+ Cel::Duration.new(@value + other.value)
34
+ when Cel::Timestamp
35
+ Cel::Timestamp.new(other.value + @value)
36
+ end
37
+ end
38
+
39
+ define_cel_method(:-) do |other|
40
+ case other
41
+ when Cel::Duration
42
+ Cel::Duration.new(@value - other.value)
43
+ else
44
+ raise Error, "invalid operand #{other} for `-`"
45
+ end
46
+ end
47
+
48
+ LOGICAL_OPERATORS.each do |op|
49
+ class_eval(<<-OUT, __FILE__, __LINE__ + 1)
50
+ define_cel_method(:#{op}) do |other|
51
+ case other
52
+ when Cel::Literal
53
+ Bool.cast(super(other))
54
+ when Numeric
55
+ @value == other
56
+
57
+ else
58
+ super(other)
59
+ end
60
+ end
61
+ OUT
62
+ end
63
+
64
+ # Cel Functions
65
+
66
+ define_cel_method(:getHours) do
67
+ Cel::Number.new(:int, (getMinutes / 60).to_i)
68
+ end
69
+
70
+ define_cel_method(:getMinutes) do
71
+ Cel::Number.new(:int, (getSeconds / 60).to_i)
72
+ end
73
+
74
+ define_cel_method(:getSeconds) do
75
+ Cel::Number.new(:int, @value.divmod(1).first)
76
+ end
77
+
78
+ define_cel_method(:getMilliseconds) do
79
+ Cel::Number.new(:int, (@value.divmod(1).last * 1000).round)
80
+ end
81
+
82
+ def to_ruby_type
83
+ Protobuf.duration_class.new(seconds: getSeconds.value, nanos: getMilliseconds.value * 1_000_000_000.0)
84
+ end
85
+
86
+ private
87
+
88
+ def init_from_string(value)
89
+ seconds = 0
90
+ nanos = 0
91
+ value.scan(/([0-9]*(?:\.[0-9]*)?)([a-z]+)/) do |duration, units|
92
+ case units
93
+ when "h"
94
+ seconds += Cel.to_numeric(duration) * 60 * 60
95
+ when "m"
96
+ seconds += Cel.to_numeric(duration) * 60
97
+ when "s"
98
+ seconds += Cel.to_numeric(duration)
99
+ when "ms"
100
+ nanos += Cel.to_numeric(duration) * 1000 * 1000
101
+ when "us"
102
+ nanos += Cel.to_numeric(duration) * 1000
103
+ when "ns"
104
+ nanos += Cel.to_numeric(duration)
105
+ else
106
+ raise EvaluateError, "#{units} is unsupported"
107
+ end
108
+ end
109
+ duration = seconds + (nanos / 1_000_000_000.0)
110
+ duration = -duration if value.start_with?("-")
111
+ duration
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Function
5
+ attr_reader :label, :types, :type
6
+
7
+ def initialize(*types, label: nil, return_type: nil, &func)
8
+ unless func.nil?
9
+ types = Array.new(func.arity) { TYPES[:any] } if types.empty?
10
+ raise(Error, "number of arg types does not match number of yielded args") unless types.size == func.arity
11
+ end
12
+ @types = types.map { |typ| typ.is_a?(Type) ? typ : TYPES[typ] }
13
+ @type = if return_type.nil?
14
+ TYPES[:any]
15
+ else
16
+ return_type.is_a?(Type) ? return_type : TYPES[return_type]
17
+ end
18
+ @func = func
19
+ @label = label
20
+ end
21
+
22
+ def call(*args)
23
+ result = @func.call(*args)
24
+
25
+ @type.convert(result)
26
+ end
27
+ end
28
+
29
+ mod = self
30
+ mod.define_singleton_method(:Function) do |*args, **kwargs, &blk|
31
+ mod::Function.new(*args, **kwargs, &blk)
32
+ end
33
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Group
5
+ attr_reader :value
6
+
7
+ def initialize(value)
8
+ @value = value
9
+ end
10
+
11
+ def type
12
+ TYPES[:any]
13
+ end
14
+
15
+ def ==(other)
16
+ other.is_a?(Group) && @value == other.value
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Cel
6
+ class Identifier < SimpleDelegator
7
+ attr_reader :id
8
+
9
+ attr_accessor :type
10
+
11
+ def initialize(identifier, package = nil)
12
+ @id = identifier
13
+ @type = TYPES[:any]
14
+ @package = package
15
+ super(@id)
16
+ end
17
+
18
+ def ==(other)
19
+ super || other.to_s == @id.to_s
20
+ end
21
+
22
+ def to_s
23
+ @id.to_s
24
+ end
25
+
26
+ alias_method :to_ruby_type, :to_s
27
+
28
+ def try_convert_to_proto_type
29
+ Protobuf.convert_to_proto_type(@id.to_s, @package)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class Invoke
5
+ attr_accessor :var
6
+
7
+ attr_reader :func, :args, :depth
8
+
9
+ def self.new(func:, var: nil, args: nil, **rest)
10
+ Protobuf.try_invoke_from(var, func, args) || super
11
+ end
12
+
13
+ def type
14
+ TYPES[:any]
15
+ end
16
+
17
+ def initialize(func:, var: nil, args: nil, depth: 1, package: nil)
18
+ @var = var
19
+ @func = func.to_sym
20
+ @args = args
21
+ @package = package
22
+ @depth = depth
23
+ end
24
+
25
+ def ==(other)
26
+ case other
27
+ when Invoke
28
+ @var == other.var && @func == other.func && @args == other.args
29
+ when Array
30
+ [@var, @func, @args].compact == other
31
+ else
32
+ super
33
+ end
34
+ end
35
+
36
+ def to_s
37
+ if var
38
+ if indexing?
39
+ "#{var}[#{args}]"
40
+ else
41
+ "#{var}.#{func}#{"(#{args.map(&:to_s).join(", ")})" if args}"
42
+ end
43
+ else
44
+ "#{func}#{"(#{args.map(&:to_s).join(", ")})" if args}"
45
+ end
46
+ end
47
+
48
+ def try_convert_to_proto_type
49
+ return unless @var
50
+
51
+ proto_type = Protobuf.convert_to_proto_type(@var.to_s, @package)
52
+
53
+ return unless proto_type
54
+
55
+ return proto_type unless proto_type.const_defined?(@func)
56
+
57
+ proto_type.const_get(@func)
58
+ end
59
+
60
+ def indexing?
61
+ @func == :[]
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cel
4
+ class List < Literal
5
+ attr_reader :depth
6
+
7
+ def initialize(value, depth: 1)
8
+ value = value.map do |v|
9
+ Literal.to_cel_type(v)
10
+ end
11
+ super(TYPES[:list], value)
12
+ @depth = depth
13
+ end
14
+
15
+ define_cel_method(:[]) do |key|
16
+ case key
17
+ when Number
18
+ if key.type == :double
19
+ val = key.value
20
+
21
+ raise InvalidArgumentError, key unless (val % 1).zero?
22
+ end
23
+ end
24
+
25
+ super(key)
26
+ end
27
+
28
+ def to_ary
29
+ [self]
30
+ end
31
+
32
+ def to_ruby_type
33
+ value.map(&:to_ruby_type)
34
+ end
35
+
36
+ %i[+ -].each do |op|
37
+ class_eval(<<-OUT, __FILE__, __LINE__ + 1)
38
+ define_cel_method(:#{op}) do |other|
39
+ List.new(@value.send(__method__, other.value))
40
+ end
41
+ OUT
42
+ end
43
+
44
+ def by_max_depth
45
+ max = @value.max { |a1, a2| calc_depth(a1, 0) <=> calc_depth(a2, 0) }
46
+
47
+ # return the last value if all options have the same depth
48
+ return @value.last if (max == @value.first) && (calc_depth(max, 0) == calc_depth(@value.last, 0))
49
+
50
+ max
51
+ end
52
+
53
+ def to_s
54
+ "[#{@value.map(&:to_s).join(", ")}]"
55
+ end
56
+
57
+ private
58
+
59
+ def calc_depth(element, acc)
60
+ case element
61
+ when List
62
+ element.value.map { |el| calc_depth(el, acc + 1) }.max || (acc + 1)
63
+ when Map
64
+ element.value.map { |(_, el)| calc_depth(el, acc + 1) }.max || (acc + 1)
65
+ else
66
+ acc
67
+ end
68
+ end
69
+ end
70
+ end