cel-rs-rb 0.1.0 → 0.3.1

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: 4a2986048cff8c1e335c72ffc410487bae67a02251ff9ef9815c34a430199b39
4
- data.tar.gz: 14df42d51ffda3ecdbfd0a9455a9e11d9990b0f30f11c9f261d5367768d35498
3
+ metadata.gz: 4649bfaf71cdf145a7cce4befa9b00e6e74d9a5d4463253a0bfee3ad13e516fa
4
+ data.tar.gz: '0940f3b1af9507a22f77c7471c3d96a045afe2c86bd977617d45eae902c8f570'
5
5
  SHA512:
6
- metadata.gz: 1af27f75d923ded581e5dfab5d046cd97ccb3837822f2cbae96f751c6dc7e41e2db539e86e61a70520efdf98c314b5a20ba557e242ffe2fee9788b5a9f29b843
7
- data.tar.gz: 16f711a65c9ef6b529410d8b3d823bb6317d80626460fa5cd43d01bd365b84efb4a6d800897bf8e927b56e8ce216aa2960e811e9f6000c959c0ec9b24c7c8207
6
+ metadata.gz: 94a4d0a7ad34e64a5b4afbfc6895b5432df83b0397af2d985a1d589fff8790efe23a6abd6fadebe2c9955d039553cdd4f48ad52e9785f6513d8b025d64a53dcf
7
+ data.tar.gz: f17cc32784ddf733bd690b3f688a04a8586f1ed9a23e281be88c4432d5052fa0691318cf9d389b0678f9e8e6201390075afeee8ae061dbc2aee749d4ef622fd0
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.3.1
4
+
5
+ ### Fixed
6
+
7
+ - Keep registered Ruby callback Procs and their captured values alive while their
8
+ `CEL::Context` is reachable. Prevents use-after-free after the caller drops its
9
+ own reference and Ruby runs garbage collection. Callbacks are also pinned during
10
+ compaction, including when collection runs inside a callback.
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # cel-rs-rb
2
2
 
3
- Ruby bindings for the Rust [`cel`](https://crates.io/crates/cel) crate, implemented with [Magnus](https://github.com/matsadler/magnus).
3
+ [![Gem Version](https://img.shields.io/gem/v/cel-rs-rb.svg)](https://rubygems.org/gems/cel-rs-rb)
4
+
5
+ Ruby bindings for the Rust [`cel`](https://crates.io/crates/cel) 0.15 crate, implemented with [Magnus](https://github.com/matsadler/magnus).
4
6
 
5
7
  ## Goals
6
8
 
@@ -30,10 +32,69 @@ program.execute(context)
30
32
  # => 60
31
33
  ```
32
34
 
35
+ ### Optional values
36
+
37
+ Use optional access to handle missing fields, and `orValue` to supply a default:
38
+
39
+ ```ruby
40
+ context = CEL::Context.build(user: {})
41
+ CEL.compile("user.?name.orValue('unknown')").execute(context)
42
+ # => "unknown"
43
+
44
+ context = CEL::Context.build(user: {"name" => "Ada"})
45
+ CEL.compile("user.?name.optMap(n, n.size()).orValue(0)").execute(context)
46
+ # => 3
47
+ ```
48
+
49
+ Unwrap optional values inside CEL with `orValue` or `value()` before returning
50
+ them to Ruby. Returning an optional directly raises `CEL::TypeError`; it is not
51
+ automatically converted to its contained value or `nil`.
52
+
53
+ ### `max` and `min`
54
+
55
+ Default contexts include `max` and `min`. They take a list or positional arguments and
56
+ return `nil` when given no values:
57
+
58
+ ```ruby
59
+ CEL.compile("max(2, 9, -3)").execute # => 9
60
+ CEL.compile("min([2, 9, -3])").execute # => -3
61
+ CEL.compile("max([])").execute # => nil
62
+ ```
63
+
64
+ ### Extension libraries
65
+
66
+ Opt into upstream CEL extension libraries when compiling a program:
67
+
68
+ ```ruby
69
+ program = CEL.compile(
70
+ "[label.lowerAscii(), scores.sort(), math.greatest(scores), base64.encode(b'hi')]",
71
+ extensions: [:strings, :lists, :math, :encoders]
72
+ )
73
+ program.execute(CEL::Context.build(label: "TacoCat", scores: [2, 9, -3]))
74
+ # => ["tacocat", [-3, 2, 9], 9, "aGk="]
75
+ ```
76
+
77
+ | Library | Examples |
78
+ | --- | --- |
79
+ | [`strings`](https://docs.rs/cel/0.15.0/cel/extensions/fn.strings.html) | `'TacoCat'.lowerAscii()`, `'a,b'.split(',')` |
80
+ | [`lists`](https://docs.rs/cel/0.15.0/cel/extensions/fn.lists.html) | `lists.range(3)`, `[3, 1, 2].sort()`, `items.sortBy(x, x.score)` |
81
+ | [`math`](https://docs.rs/cel/0.15.0/cel/extensions/fn.math.html) | `math.greatest(1, 5, 3)`, `math.least([1, 5, 3])` |
82
+ | [`encoders`](https://docs.rs/cel/0.15.0/cel/extensions/fn.encoders.html) | `base64.encode(b'hi')`, `base64.decode('aGk=')` |
83
+
84
+ `CEL::Program.compile` accepts the same `extensions:` option. Names can be
85
+ symbols or strings; unknown or repeated names raise `ArgumentError`.
86
+
87
+ Extensions are disabled by default and registered per program, for both macro
88
+ expansion and execution. They work with bare `execute` or a default context,
89
+ including its variables and Ruby callbacks. They do not change other programs
90
+ or add functions to explicitly empty contexts. Ruby callbacks that conflict
91
+ with enabled extension functions raise `CEL::ExecutionError` during execution,
92
+ just like conflicts with standard CEL builtins.
93
+
33
94
  ### API mapping
34
95
 
35
- - `CEL.compile(source)` -> `CEL::Program`
36
- - `CEL::Program.compile(source)` -> `CEL::Program`
96
+ - `CEL.compile(source, extensions: [])` -> `CEL::Program`
97
+ - `CEL::Program.compile(source, extensions: [])` -> `CEL::Program`
37
98
  - `CEL::Program#execute(context = nil)` -> Ruby value
38
99
  - `CEL::Program#references` -> `{ "variables" => [...], "functions" => [...] }`
39
100
  - `CEL::Context.new(empty = false)` -> context with builtins (`false`) or empty (`true`)
@@ -75,9 +136,14 @@ program.execute(context)
75
136
 
76
137
  ## Thread safety and concurrency
77
138
 
78
- - Context data is mutex-protected and immutable during execution snapshots.
79
- - CEL execution is run with the Ruby GVL released so other Ruby threads can run.
80
- - Ruby callbacks from CEL functions temporarily re-acquire the GVL.
139
+ - Compiled `CEL::Program` instances can be shared and executed concurrently from
140
+ multiple Ruby threads with separate execution contexts.
141
+ - Each thread may pass its own `CEL::Context`; context data is mutex-protected
142
+ and copied into an immutable execution snapshot for each run.
143
+ - CEL evaluation runs with the Ruby GVL released, so other Ruby threads can keep
144
+ progressing while a program executes.
145
+ - Ruby callbacks from CEL functions temporarily re-acquire the GVL before
146
+ invoking Ruby code.
81
147
 
82
148
  ## Development
83
149
 
@@ -105,7 +171,52 @@ This runs:
105
171
 
106
172
  CI also runs the test suite on Ruby 3.3, Ruby 3.4, and Ruby 4.0 through Buildkite.
107
173
 
174
+ ## Releasing
175
+
176
+ See [CHANGELOG.md](CHANGELOG.md) for release notes. Update it alongside each version bump.
177
+
178
+ Releases publish a generic `ruby` platform gem and **precompiled native gems**
179
+ for six platforms (`x86_64-linux`, `aarch64-linux`, `x86_64-linux-musl`,
180
+ `aarch64-linux-musl`, `arm64-darwin`, `x86_64-darwin`) to
181
+ [rubygems.org](https://rubygems.org/gems/cel-rs-rb). The generic gem keeps
182
+ RubyGems' latest-version metadata aligned; supported platforms should still
183
+ resolve to their matching precompiled native gem.
184
+
185
+ ### Cutting a release
186
+
187
+ The gem version comes from `lib/cel/version.rb`, not from the tag, so bump the
188
+ file and tag the same version:
189
+
190
+ ```bash
191
+ # 1. Bump CEL::VERSION in lib/cel/version.rb (e.g. "0.2.0")
192
+ # 2. Refresh Gemfile.lock's local gem version
193
+ bundle install
194
+
195
+ # 3. Commit the bump
196
+ git add lib/cel/version.rb Gemfile.lock
197
+ git commit -m "Release v0.2.0"
198
+
199
+ # 4. Tag that commit (the v-prefix triggers the release pipeline)
200
+ git tag v0.2.0
201
+ git push origin main
202
+ git push origin v0.2.0
203
+ ```
204
+
205
+ `Gemfile.lock` includes a local `cel-rs-rb` path entry, so commit its version
206
+ change alongside `lib/cel/version.rb`.
207
+
208
+ The `release:verify-tag` step fails the build if the tag and
209
+ `CEL::VERSION` disagree, so the tag (`v0.2.0`) must match `version.rb`
210
+ (`0.2.0`).
211
+
108
212
  ## Compatibility notes
109
213
 
214
+ - Compilation uses the standard CEL environment, including optional access syntax (`user.?name`) and the `optMap` / `optFlatMap` macros.
215
+ - The upstream strings, lists, math, and encoders extension libraries are opt-in through the `extensions:` compile option.
216
+ - cel 0.15 no longer includes `max` and `min` by default, so default contexts register them for compatibility with cel 0.13. See [`max` and `min`](#max-and-min).
217
+ - When upgrading from cel 0.13, replace list/map `.contains(value)` expressions with `value in collection`; `.contains()` now only accepts strings. Map literals with duplicate keys raise `CEL::ExecutionError`, and string `size()` counts Unicode code points rather than bytes. See the [cel 0.15 release notes](https://github.com/cel-rust/cel-rust/releases/tag/v0.15.0) for further CEL semantic changes.
218
+ - `string(bytes)` now rejects invalid UTF-8. Numeric conversions reject out-of-range results, and parsed timestamps and durations must fit CEL's allowed ranges.
219
+ - `all()` and `exists()` can now absorb an earlier evaluation error when a later element determines the result. For example, `[2, 1].exists(e, 6 / (2 - e) == 6)` returns `true` despite the first element's division by zero.
220
+ - Ruby callbacks cannot shadow standard CEL builtins (such as `size`) in a default context; the compatibility functions `max` and `min` are exceptions. Conflicts raise `CEL::ExecutionError` when `Program#execute(context)` builds the execution snapshot, even if the expression does not call the conflicting function. Use `CEL::Context.new(true)` or `CEL::Context.build(empty: true)` to define functions without builtins. Registering the same custom callback name again replaces the previous callback.
110
221
  - Some CEL value variants (e.g. opaque custom Rust types) cannot be fully marshaled to Ruby and raise `CEL::TypeError`.
111
222
  - This project targets broad CEL compatibility, but parity for every Rust-only extension point is still evolving.
data/ext/cel/Cargo.toml CHANGED
@@ -10,5 +10,5 @@ crate-type = ["cdylib"]
10
10
  [dependencies]
11
11
  magnus = { version = "0.8.2", features = ["chrono", "rb-sys"] }
12
12
  rb-sys = { version = "0.9.128", features = ["stable-api-compiled-fallback"] }
13
- cel = { version = "0.13.0", features = ["chrono", "regex", "json"] }
13
+ cel = { version = "0.15.0", features = ["chrono", "regex", "json"] }
14
14
  chrono = "0.4.45"
data/ext/cel/src/lib.rs CHANGED
@@ -155,17 +155,27 @@ struct FunctionRegistration {
155
155
  callback: Arc<CallbackFunction>,
156
156
  }
157
157
 
158
- #[derive(Default)]
159
- #[magnus::wrap(class = "CEL::Context", free_immediately, size)]
158
+ #[derive(Default, magnus::TypedData)]
159
+ #[magnus(class = "CEL::Context", free_immediately, size, mark)]
160
160
  struct ContextWrap {
161
161
  use_empty: bool,
162
162
  variables: Mutex<HashMap<String, CelValue>>,
163
163
  functions: Mutex<Vec<FunctionRegistration>>,
164
164
  }
165
165
 
166
+ impl magnus::DataTypeFunctions for ContextWrap {
167
+ fn mark(&self, marker: &magnus::gc::Marker) {
168
+ for registration in self.functions.lock().unwrap().iter() {
169
+ // Non-movable marking also pins the Proc during Ruby compaction.
170
+ marker.mark(registration.callback.proc);
171
+ }
172
+ }
173
+ }
174
+
166
175
  #[magnus::wrap(class = "CEL::Program", free_immediately, size)]
167
176
  struct ProgramWrap {
168
177
  inner: CelProgram,
178
+ env: Arc<cel::Env>,
169
179
  }
170
180
 
171
181
  #[derive(Clone)]
@@ -355,11 +365,16 @@ impl ContextWrap {
355
365
  });
356
366
  }
357
367
 
358
- fn build_context(&self) -> CelContext<'static> {
368
+ fn build_context(&self, env: Arc<cel::Env>) -> Result<CelContext<'static, 'static>, Error> {
359
369
  let mut ctx = if self.use_empty {
360
370
  CelContext::empty()
361
371
  } else {
362
- CelContext::default()
372
+ let mut ctx = CelContext::with_env(env);
373
+ ctx.add_function("max", cel::functions::max)
374
+ .map_err(|e| errors::execution(e.to_string()))?;
375
+ ctx.add_function("min", cel::functions::min)
376
+ .map_err(|e| errors::execution(e.to_string()))?;
377
+ ctx
363
378
  };
364
379
 
365
380
  for (name, value) in self.variables.lock().unwrap().iter() {
@@ -375,7 +390,11 @@ impl ContextWrap {
375
390
  cel::extractors::Arguments(args): cel::extractors::Arguments|
376
391
  -> ResolveResult {
377
392
  let callback = callback.clone();
378
- let this = ftx.this.clone();
393
+ let this = ftx
394
+ .this
395
+ .as_ref()
396
+ .map(|value| CelValue::try_from(value.as_ref()))
397
+ .transpose()?;
379
398
  let args = args.clone();
380
399
 
381
400
  with_gvl(|| {
@@ -406,29 +425,49 @@ impl ContextWrap {
406
425
  .map_err(|e| CelExecutionError::function_error(ftx.name, e.to_string()))
407
426
  })
408
427
  },
409
- );
428
+ )
429
+ .map_err(|e| errors::execution(e.to_string()))?;
410
430
  }
411
431
 
412
- ctx
432
+ Ok(ctx)
413
433
  }
414
434
  }
415
435
 
416
436
  impl ProgramWrap {
417
- fn compile(source: String) -> Result<Self, Error> {
418
- CelProgram::compile(&source)
419
- .map(|inner| Self { inner })
437
+ fn compile(source: String, extensions: Vec<String>) -> Result<Self, Error> {
438
+ let ruby = Ruby::get().expect("ruby runtime");
439
+ let mut env = cel::Env::stdlib();
440
+ for name in extensions {
441
+ let extension = match name.as_str() {
442
+ "strings" => cel::extensions::strings,
443
+ "lists" => cel::extensions::lists,
444
+ "math" => cel::extensions::math,
445
+ "encoders" => cel::extensions::encoders,
446
+ _ => {
447
+ return Err(Error::new(
448
+ ruby.exception_arg_error(),
449
+ format!("Unknown CEL extension: {name}"),
450
+ ))
451
+ }
452
+ };
453
+ env.add_extension(extension)
454
+ .map_err(|e| Error::new(ruby.exception_arg_error(), e.to_string()))?;
455
+ }
456
+ let env = Arc::new(env);
457
+ env.compile(&source)
458
+ .map(|inner| Self { inner, env })
420
459
  .map_err(|e: ParseErrors| errors::parse(e.to_string()))
421
460
  }
422
461
 
423
462
  fn execute(&self) -> Result<Value, Error> {
424
- self.execute_with_context_internal(&CelContext::default())
463
+ self.execute_with_context(&ContextWrap::new(false))
425
464
  }
426
465
 
427
466
  fn execute_with_context(&self, context: &ContextWrap) -> Result<Value, Error> {
428
- self.execute_with_context_internal(&context.build_context())
467
+ self.execute_with_context_internal(&context.build_context(self.env.clone())?)
429
468
  }
430
469
 
431
- fn execute_with_context_internal(&self, ctx: &CelContext<'_>) -> Result<Value, Error> {
470
+ fn execute_with_context_internal(&self, ctx: &CelContext<'_, '_>) -> Result<Value, Error> {
432
471
  let run = || self.inner.execute(ctx);
433
472
  let result = panic::catch_unwind(AssertUnwindSafe(|| without_gvl(run)))
434
473
  .map_err(|_| errors::execution("CEL execution panicked"))?;
@@ -484,7 +523,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
484
523
  duration_class.define_method("==", method!(DurationWrap::eq, 1))?;
485
524
 
486
525
  let program_class = module.define_class("Program", ruby.class_object())?;
487
- program_class.define_singleton_method("compile", function!(ProgramWrap::compile, 1))?;
526
+ program_class.define_singleton_method("compile", function!(ProgramWrap::compile, 2))?;
488
527
  program_class.define_method("execute", method!(ProgramWrap::execute, 0))?;
489
528
  program_class.define_method(
490
529
  "execute_with_context",
@@ -493,7 +532,5 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
493
532
  program_class.define_method("references", method!(ProgramWrap::references, 0))?;
494
533
  program_class.define_method("expression", method!(ProgramWrap::expression, 0))?;
495
534
 
496
- module.define_singleton_method("compile", function!(ProgramWrap::compile, 1))?;
497
-
498
535
  Ok(())
499
536
  }
data/lib/cel/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CEL
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/cel.rb CHANGED
@@ -10,6 +10,10 @@ rescue LoadError
10
10
  end
11
11
 
12
12
  module CEL
13
+ def self.compile(source, extensions: [])
14
+ Program.compile(source, extensions: extensions)
15
+ end
16
+
13
17
  class Context
14
18
  class << self
15
19
  alias_method :__native_new, :new
@@ -34,6 +38,14 @@ module CEL
34
38
  end
35
39
 
36
40
  class Program
41
+ class << self
42
+ alias_method :__native_compile, :compile
43
+
44
+ def compile(source, extensions: [])
45
+ __native_compile(source, extensions.map(&:to_s))
46
+ end
47
+ end
48
+
37
49
  alias_method :__native_execute, :execute
38
50
 
39
51
  def execute(context = nil)
data/spec/cel_spec.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "spec_helper"
4
4
  require "time"
5
+ require "open3"
5
6
 
6
7
  RSpec.describe CEL do
7
8
  describe ".compile" do
@@ -13,6 +14,49 @@ RSpec.describe CEL do
13
14
  it "raises parse errors for invalid programs" do
14
15
  expect { CEL.compile("1 +") }.to raise_error(CEL::ParseError)
15
16
  end
17
+
18
+ it "enables each upstream extension for compilation and execution" do
19
+ tests = {
20
+ strings: ["'TacoCat'.lowerAscii()", "tacocat"],
21
+ lists: ["[{'n': 8}, {'n': 2}, {'n': 5}].sortBy(x, x.n).map(x, x.n)", [2, 5, 8]],
22
+ math: ["math.greatest(2, 9, -3)", 9],
23
+ encoders: ["base64.encode(b'hi')", "aGk="]
24
+ }
25
+
26
+ tests.each do |extension, (expression, expected)|
27
+ program = CEL.compile(expression, extensions: [extension])
28
+ expect(program.execute).to eq(expected)
29
+ expect(program.execute(CEL::Context.new)).to eq(expected)
30
+ end
31
+ end
32
+
33
+ it "combines extensions with context variables and Ruby callbacks" do
34
+ context = CEL::Context.build(label: "TacoCat", scores: [2, 9, -3]) do |ctx|
35
+ ctx.define_function("decorate") { |value| "[#{value}]" }
36
+ end
37
+ program = CEL::Program.compile(
38
+ "[decorate(label.lowerAscii()), scores.sort(), math.greatest(scores), base64.encode(b'hi')]",
39
+ extensions: ["strings", "lists", "math", "encoders"]
40
+ )
41
+
42
+ expect(program.execute(context)).to eq(["[tacocat]", [-3, 2, 9], 9, "aGk="])
43
+ end
44
+
45
+ it "keeps extensions local to a program and excludes them from empty contexts" do
46
+ context = CEL::Context.new
47
+ enabled = CEL.compile("'ABC'.lowerAscii()", extensions: [:strings])
48
+ expect(enabled.execute(context)).to eq("abc")
49
+ expect { CEL.compile("'ABC'.lowerAscii()").execute(context) }.to raise_error(CEL::ExecutionError)
50
+ expect { enabled.execute(CEL::Context.new(true)) }.to raise_error(CEL::ExecutionError)
51
+
52
+ context.define_function("lowerAscii") { |value| value }
53
+ expect { enabled.execute(context) }.to raise_error(CEL::ExecutionError, /lowerAscii/)
54
+ end
55
+
56
+ it "rejects unknown and repeated extension registrations" do
57
+ expect { CEL.compile("1", extensions: [:unknown]) }.to raise_error(ArgumentError, /unknown/)
58
+ expect { CEL.compile("1", extensions: [:strings, "strings"]) }.to raise_error(ArgumentError)
59
+ end
16
60
  end
17
61
 
18
62
  describe CEL::Context do
@@ -56,6 +100,40 @@ RSpec.describe CEL do
56
100
  expect(CEL.compile("sum(1, 2, 3)").execute(context)).to eq(6)
57
101
  end
58
102
 
103
+ it "keeps registered callbacks alive through garbage collection and compaction" do
104
+ # Isolate native GC failures and discard the registering thread's stack roots.
105
+ output, status = Open3.capture2e(
106
+ RbConfig.ruby, "-I", File.expand_path("../lib", __dir__), "-rcel", "-rweakref", "-e", <<~RUBY
107
+ context, callbacks = Thread.new do
108
+ ctx = CEL::Context.new
109
+ prefix = +"retained:token:"
110
+ decorate = proc { |value| prefix + value }
111
+ collect = proc do
112
+ GC.start
113
+ GC.verify_compaction_references(expand_heap: true, toward: :empty)
114
+ 17
115
+ end
116
+ ctx.define_function("decorate", &decorate)
117
+ ctx.define_function("collect", &collect)
118
+ [ctx, [WeakRef.new(decorate), WeakRef.new(collect)]]
119
+ end.value
120
+
121
+ program = CEL.compile("[decorate('ok'), collect(), decorate('again')]")
122
+ 3.times do
123
+ GC.start
124
+ GC.verify_compaction_references(expand_heap: true, toward: :empty)
125
+ raise "registered callback was collected" unless callbacks.all?(&:weakref_alive?)
126
+ result = program.execute(context)
127
+ raise "incorrect callback result: \#{result.inspect}" unless result == [
128
+ "retained:token:ok", 17, "retained:token:again"
129
+ ]
130
+ end
131
+ RUBY
132
+ )
133
+
134
+ expect(status.success?).to eq(true), output
135
+ end
136
+
59
137
  it "passes method receiver as first block arg for method calls" do
60
138
  context = CEL::Context.new(true)
61
139
  context.define_function("startsWith") { |target, prefix| target.start_with?(prefix) }
@@ -63,6 +141,31 @@ RSpec.describe CEL do
63
141
  expect(CEL.compile("'hello'.startsWith('he')").execute(context)).to eq(true)
64
142
  end
65
143
 
144
+ it "marshals callback receivers, arguments, and results" do
145
+ context = CEL::Context.build(prefix: {"label" => "result"})
146
+ context.define_function("combine") do |target, values, suffix|
147
+ {target["label"] => values + [suffix]}
148
+ end
149
+
150
+ expect(CEL.compile("prefix.combine([2, 5], 9)").execute(context)).to eq("result" => [2, 5, 9])
151
+ end
152
+
153
+ it "reports builtin callback conflicts when building the execution context" do
154
+ context = CEL::Context.new
155
+ context.define_function("size") { |_| 99 }
156
+
157
+ expect { CEL.compile("size([1, 2])").execute(context) }
158
+ .to raise_error(CEL::ExecutionError, /size/)
159
+ end
160
+
161
+ it "replaces callbacks with the same name" do
162
+ context = CEL::Context.new
163
+ context.define_function("adjust") { |value| value + 1 }
164
+ context.define_function("adjust") { |value| value + 7 }
165
+
166
+ expect(CEL.compile("adjust(3)").execute(context)).to eq(10)
167
+ end
168
+
66
169
  it "raises clear type error for unsupported variable types" do
67
170
  context = CEL::Context.new
68
171
  expect { context.add_variable("bad", Object.new) }.to raise_error(CEL::TypeError)
@@ -77,6 +180,36 @@ RSpec.describe CEL do
77
180
  expect(refs["functions"]).to include("size")
78
181
  end
79
182
 
183
+ it "registers max and min by default" do
184
+ tests = {
185
+ "max(2, 9, -3)" => 9,
186
+ "min(2, 9, -3)" => -3,
187
+ "max([2, 9, -3])" => 9,
188
+ "min([2, 9, -3])" => -3,
189
+ "max()" => nil,
190
+ "min([])" => nil
191
+ }
192
+
193
+ [nil, CEL::Context.new].each do |context|
194
+ tests.each do |expression, expected|
195
+ expect(CEL.compile(expression).execute(context)).to eq(expected)
196
+ end
197
+ end
198
+ end
199
+
200
+ it "leaves max and min out of empty contexts and allows custom callbacks" do
201
+ ["max", "min"].each do |name|
202
+ program = CEL.compile("#{name}(2, 9)")
203
+ empty = CEL::Context.new(true)
204
+ expect { program.execute(empty) }.to raise_error(CEL::ExecutionError)
205
+
206
+ [empty, CEL::Context.new].each do |context|
207
+ context.define_function(name) { |a, b| a + b }
208
+ expect(program.execute(context)).to eq(11)
209
+ end
210
+ end
211
+ end
212
+
80
213
  it "ports core CEL suite behavior examples" do
81
214
  tests = {
82
215
  "size([1,2,3]) == 3" => true,
@@ -84,8 +217,13 @@ RSpec.describe CEL do
84
217
  "[1,2,3].filter(x, x > 1)" => [2, 3],
85
218
  "[1,2,3].exists(x, x == 2)" => true,
86
219
  "[1,2,3].all(x, x > 0)" => true,
87
- "{'a': 1}.contains('a')" => true,
88
- "optional.of(1).hasValue()" => true
220
+ "'a' in {'a': 1}" => true,
221
+ "optional.of(1).hasValue()" => true,
222
+ "size('café😊')" => 5,
223
+ "{'name': 'Ada'}.?name.optMap(n, n.size()).orValue(0)" => 3,
224
+ "{}.?name.optMap(n, n.size()).orValue(0)" => 0,
225
+ "{'user': {'name': 'Ada'}}.?user.optFlatMap(u, u.?name).orValue('missing')" => "Ada",
226
+ "[2, 1].exists(e, 6 / (2 - e) == 6)" => true
89
227
  }
90
228
 
91
229
  tests.each do |expr, expected|
@@ -113,6 +251,10 @@ RSpec.describe CEL do
113
251
  expect { program.execute }.to raise_error(CEL::ExecutionError)
114
252
  end
115
253
 
254
+ it "rejects duplicate map literal keys" do
255
+ expect { CEL.compile("{'a': 1, 'a': 2}").execute }.to raise_error(CEL::ExecutionError)
256
+ end
257
+
116
258
  it "releases GVL so other ruby threads can progress" do
117
259
  context = CEL::Context.new
118
260
  context.add_variable("items", Array.new(15_000, 1))
@@ -135,15 +277,38 @@ RSpec.describe CEL do
135
277
  worker.join
136
278
  end
137
279
 
138
- it "is thread-safe when executing concurrently" do
139
- context = CEL::Context.new
140
- context.add_variable("n", 10)
141
- program = CEL.compile("n * 2")
280
+ it "executes the same program concurrently with independent contexts" do
281
+ program = CEL.compile("items.map(x, x + offset).filter(x, x > cutoff).size() + id")
282
+ ready = Queue.new
283
+ start = Queue.new
142
284
 
143
- threads = Array.new(8) { Thread.new { 50.times.map { program.execute(context) } } }
144
- values = threads.flat_map(&:value)
285
+ threads = Array.new(8) do |id|
286
+ Thread.new do
287
+ ready << true
288
+ start.pop
289
+
290
+ offset = id % 3
291
+ context = CEL::Context.build(
292
+ id: id,
293
+ items: Array.new(200, id),
294
+ offset: offset,
295
+ cutoff: id
296
+ )
297
+
298
+ 25.times.map { program.execute(context) }
299
+ end
300
+ end
301
+ 8.times { ready.pop }
302
+ 8.times { start << true }
303
+ results = threads.map(&:value)
304
+
305
+ expected = Array.new(8) do |id|
306
+ offset = id % 3
307
+ value = (offset.zero? ? 0 : 200) + id
308
+ Array.new(25, value)
309
+ end
145
310
 
146
- expect(values.uniq).to eq([20])
311
+ expect(results).to eq(expected)
147
312
  end
148
313
  end
149
314
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cel-rs-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - CEL Ruby Contributors
@@ -100,6 +100,7 @@ extensions:
100
100
  - ext/cel/extconf.rb
101
101
  extra_rdoc_files: []
102
102
  files:
103
+ - CHANGELOG.md
103
104
  - Cargo.toml
104
105
  - LICENSE
105
106
  - README.md
@@ -110,12 +111,12 @@ files:
110
111
  - lib/cel/version.rb
111
112
  - spec/cel_spec.rb
112
113
  - spec/spec_helper.rb
113
- homepage: https://github.com/catkins/cel-rs-rb
114
+ homepage: https://github.com/buildkite/cel-rs-rb
114
115
  licenses:
115
116
  - MIT
116
117
  metadata:
117
- homepage_uri: https://github.com/catkins/cel-rs-rb
118
- source_code_uri: https://github.com/catkins/cel-rs-rb
118
+ homepage_uri: https://github.com/buildkite/cel-rs-rb
119
+ source_code_uri: https://github.com/buildkite/cel-rs-rb
119
120
  rubygems_mfa_required: 'true'
120
121
  rdoc_options: []
121
122
  require_paths:
@@ -131,7 +132,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
131
132
  - !ruby/object:Gem::Version
132
133
  version: '0'
133
134
  requirements: []
134
- rubygems_version: 4.0.10
135
+ rubygems_version: 4.0.20
135
136
  specification_version: 4
136
137
  summary: Ruby bindings for the Rust CEL crate
137
138
  test_files: []