diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index d27695e..f4660c2 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Ruby 3.3 uses: ruby/setup-ruby@v1 with: - ruby-version: '3.3' + ruby-version: '3.3.4' - name: install dependencies run: bundle install - name: build diff --git a/.tool-versions b/.tool-versions index 1dd1998..05668b7 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 3.3.5 +ruby 3.3.4 diff --git a/Cargo.lock b/Cargo.lock index 3d93f24..d7bbd5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,6 +77,8 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", + "serde", + "serde_json", ] [[package]] diff --git a/expression-core/Cargo.toml b/expression-core/Cargo.toml index c4b497b..b36f005 100644 --- a/expression-core/Cargo.toml +++ b/expression-core/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -bigdecimal = "0.4.5" +bigdecimal = { version = "0.4.6", features = ["serde-json"] } lazy_static = "1.5.0" pest = "2.7.13" pest_derive = "2.7.13" diff --git a/expression-core/src/evaluate.rs b/expression-core/src/evaluate.rs index 79eaef5..801bd4e 100644 --- a/expression-core/src/evaluate.rs +++ b/expression-core/src/evaluate.rs @@ -1,10 +1,12 @@ -use std::{collections::HashMap, fmt::Display}; +use std::fmt::Display; use bigdecimal::{BigDecimal, RoundingMode, ToPrimitive}; -use serde::Deserialize; use thiserror::Error; -use crate::parser::{EventAttribute, Expression, Function, Operation}; +use crate::{ + parser::{EventAttribute, Expression, Function, Operation}, + Event, PropertyValue, +}; #[derive(Debug, PartialEq)] pub enum ExpressionValue { @@ -12,13 +14,6 @@ pub enum ExpressionValue { String(String), } -#[derive(Debug, Default, PartialEq, Deserialize)] -pub struct Event { - pub code: String, - pub timestamp: u64, - pub properties: HashMap, -} - impl Expression { pub fn evaluate(&self, event: &Event) -> EvaluationResult { let evaluated_expr = match self { @@ -141,10 +136,15 @@ impl EventAttribute { .get(name) .ok_or(ExpressionError::MissingVariable(name.clone()))?; - if let Ok(decimal_value) = value.parse() { - ExpressionValue::Number(decimal_value) - } else { - ExpressionValue::String(value.clone()) + match value { + PropertyValue::String(s) => { + if let Ok(decimal_value) = s.parse() { + ExpressionValue::Number(decimal_value) + } else { + ExpressionValue::String(s.clone()) + } + } + PropertyValue::Number(d) => ExpressionValue::Number(d.to_owned()), } } }; diff --git a/expression-core/src/event.rs b/expression-core/src/event.rs new file mode 100644 index 0000000..9460492 --- /dev/null +++ b/expression-core/src/event.rs @@ -0,0 +1,109 @@ +use std::collections::HashMap; + +use bigdecimal::BigDecimal; +use serde::Deserialize; + +#[derive(Debug, Default, PartialEq, Deserialize)] +pub struct Event { + pub code: String, + pub timestamp: u64, + pub properties: HashMap, +} + +#[derive(Debug, PartialEq, Deserialize)] +#[serde(untagged)] +pub enum PropertyValue { + String(String), + Number(BigDecimal), +} + +impl From<&str> for PropertyValue { + fn from(value: &str) -> Self { + PropertyValue::String(value.into()) + } +} +impl From for PropertyValue { + fn from(value: String) -> Self { + PropertyValue::String(value) + } +} + +impl From for PropertyValue { + fn from(value: u64) -> Self { + PropertyValue::Number(value.into()) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn test_deserialize_with_numbers() { + let json = json!({ + "code": "testing", + "timestamp": 123124123123i64, + "properties": { + "text": "text_value", + "number": 300 + } + }); + + let event = serde_json::from_value::(json).expect("expected json to parse"); + + let code = "testing".to_owned(); + let timestamp = 123124123123; + let mut properties = HashMap::default(); + properties.insert("number".to_owned(), PropertyValue::Number(300.into())); + properties.insert( + "text".to_owned(), + PropertyValue::String("text_value".into()), + ); + + assert_eq!( + event, + Event { + code, + timestamp, + properties + } + ) + } + + #[test] + fn test_deserialize_with_floating_point() { + let json = r#"{ + "code": "testing", + "timestamp": 123124123123, + "properties": { + "text": "text_value", + "number": 3.2 + } + }"#; + + let event = serde_json::from_str::(json).expect("expected json to parse"); + + let code = "testing".to_owned(); + let timestamp = 123124123123; + let mut properties = HashMap::default(); + properties.insert( + "number".to_owned(), + PropertyValue::Number("3.2".parse().unwrap()), + ); + properties.insert( + "text".to_owned(), + PropertyValue::String("text_value".into()), + ); + + assert_eq!( + event, + Event { + code, + timestamp, + properties + } + ) + } +} diff --git a/expression-core/src/lib.rs b/expression-core/src/lib.rs index 0ca8410..0d75451 100644 --- a/expression-core/src/lib.rs +++ b/expression-core/src/lib.rs @@ -1,6 +1,8 @@ -pub use evaluate::{EvaluationResult, Event, ExpressionValue}; +pub use evaluate::{EvaluationResult, ExpressionValue}; +pub use event::{Event, PropertyValue}; pub use parser::{Expression, ExpressionParser, ParseError}; pub use pest::Parser; mod evaluate; +mod event; mod parser; diff --git a/expression-go/bindings.h b/expression-go/bindings.h index a07e8cf..100780b 100644 --- a/expression-go/bindings.h +++ b/expression-go/bindings.h @@ -3,16 +3,14 @@ #include #include -typedef struct Expression Expression; - /** * # Safety - * Pass in a valid string + * Pass in a valid strings */ -Expression *parse(const char *input); +char *evaluate(const char *input, const char *event); /** * # Safety - * Pass in a valid string + * Only pass in pointers to strings that have been obtained through `evaluate` */ -const char *evaluate(Expression *expr, const char *event); +void free_evaluate(char *ptr); diff --git a/expression-go/expressiongo b/expression-go/expressiongo deleted file mode 100755 index b7e7232..0000000 Binary files a/expression-go/expressiongo and /dev/null differ diff --git a/expression-go/main.go b/expression-go/main.go index 45e8ec3..688e38c 100644 --- a/expression-go/main.go +++ b/expression-go/main.go @@ -1,17 +1,26 @@ package main -// #cgo LDFLAGS: -L../target/debug -lexpression_go +// #cgo LDFLAGS: -L../target/release -lexpression_go // #include // #include // #include "bindings.h" import "C" +import "unsafe" func main() { - cs := C.CString("event.timestamp+event.properties.a") - event := C.CString("{\"code\":\"123\",\"timestamp\":2,\"properties\":{\"a\": \"123\"}}") - expr := C.parse(cs) + cs := C.CString("concat(event.properties.a, 'test')") + event := C.CString("{\"code\":\"13\",\"timestamp\":2,\"properties\":{\"a\": 123.12}}") - result := C.GoString(C.evaluate(expr, event)) - println(result) + ptr := C.evaluate(cs, event) + if ptr != nil { + + result := C.GoString(ptr) + println(result) + + C.free_evaluate(ptr) + } + + C.free(unsafe.Pointer(cs)) + C.free(unsafe.Pointer(event)) } diff --git a/expression-go/src/lib.rs b/expression-go/src/lib.rs index 757ecda..f10e279 100644 --- a/expression-go/src/lib.rs +++ b/expression-go/src/lib.rs @@ -1,32 +1,43 @@ -use std::ffi::{c_char, CStr, CString}; +use std::{ + ffi::{c_char, CStr, CString}, + ptr::null_mut, +}; -use expression_core::{Event, Expression, ExpressionParser}; +use expression_core::ExpressionParser; #[no_mangle] /// # Safety -/// Pass in a valid string -pub unsafe extern "C" fn parse(input: *const c_char) -> *mut Expression { +/// Pass in a valid strings +pub unsafe extern "C" fn evaluate(input: *const c_char, event: *const c_char) -> *mut c_char { let input = unsafe { CStr::from_ptr(input).to_str().unwrap().to_owned() }; - let expression = ExpressionParser::parse_expression(&input).unwrap(); - Box::into_raw(Box::new(expression)) + + // Cannot parse expression -> return null + let Ok(expr) = ExpressionParser::parse_expression(&input) else { + return null_mut(); + }; + + let json = unsafe { CStr::from_ptr(event).to_str().unwrap() }; + + // TODO: solve the fact that json will contain numbers, deserialize will fail as it's expecting only + // strings. in the Event::properties which is a HashMap + let Ok(event) = serde_json::from_str(json) else { + return null_mut(); + }; + + // evaluate expression, errors are not returned, but we do catch them and return null + let Ok(res) = expr.evaluate(&event) else { + return null_mut(); + }; + + let Ok(temp) = CString::new(res.to_string()) else { + return null_mut(); + }; + temp.into_raw() } #[no_mangle] /// # Safety -/// Pass in a valid string -pub unsafe extern "C" fn evaluate(expr: *mut Expression, event: *const c_char) -> *const c_char { - let json = unsafe { CStr::from_ptr(event).to_str().unwrap() }; - let event: Event = serde_json::from_str(json).unwrap(); - - let expr = unsafe { expr.as_ref().unwrap() }; - match expr.evaluate(&event).unwrap() { - expression_core::ExpressionValue::Number(d) => { - let temp = CString::new(d.to_string()).unwrap(); - temp.into_raw() - } - expression_core::ExpressionValue::String(d) => { - let temp = CString::new(d).unwrap(); - temp.into_raw() - } - } +/// Only pass in pointers to strings that have been obtained through `evaluate` +pub unsafe extern "C" fn free_evaluate(ptr: *mut c_char) { + unsafe { drop(CString::from_raw(ptr)) } } diff --git a/expression-js/index.js b/expression-js/index.js index 7e2ae44..a62eead 100644 --- a/expression-js/index.js +++ b/expression-js/index.js @@ -10,8 +10,8 @@ elem.oninput = function () { error.innerHTML = ""; try { expression = parseExpression(elem.value); - output.innerHTML = evaluateExpression(expression, "code", 1231254123, { - started_at: 123124123, + output.innerHTML = evaluateExpression(expression, "code", BigInt(1231254123123125), { + started_at: 1231254123123125, ended_at: 1241231241, replicas: 8, }); diff --git a/expression-js/src/lib.rs b/expression-js/src/lib.rs index 5d4c049..04c2960 100644 --- a/expression-js/src/lib.rs +++ b/expression-js/src/lib.rs @@ -3,10 +3,9 @@ use std::collections::HashMap; use js_sys::Reflect; use wasm_bindgen::prelude::*; -use bigdecimal::ToPrimitive; +use bigdecimal::{BigDecimal, FromPrimitive, ToPrimitive}; -use expression_core::{ExpressionParser, ExpressionValue}; -use web_sys::console; +use expression_core::{ExpressionParser, ExpressionValue, PropertyValue}; extern crate console_error_panic_hook; #[wasm_bindgen(start)] @@ -29,7 +28,7 @@ pub fn parse_expression(expression: String) -> Result { pub fn evaluate_expression( expression: Expression, code: String, - timestamp: u32, + timestamp: u64, js_properties: &JsValue, ) -> Result { let mut properties = HashMap::new(); @@ -38,21 +37,25 @@ pub fn evaluate_expression( for key in keys { let value = Reflect::get(js_properties, &key)?; - console::log_1(&value); - let insert = if value.is_string() { - String::try_from(value)? + let property_value = if value.is_string() { + String::try_from(value)?.into() + } else if value.is_bigint() { + let n = u64::try_from(value)?; + PropertyValue::Number(n.into()) } else { let n = f64::try_from(value)?; - format!("{n}") + PropertyValue::Number( + BigDecimal::from_f64(n).ok_or("failed to convert property value")?, + ) }; - properties.insert(key.as_string().ok_or("expected string")?, insert); + properties.insert(key.as_string().ok_or("expected string")?, property_value); } let event = expression_core::Event { code, - timestamp: timestamp.into(), + timestamp, properties, }; diff --git a/expression-ruby/Gemfile b/expression-ruby/Gemfile index 7a8fc09..4eded90 100644 --- a/expression-ruby/Gemfile +++ b/expression-ruby/Gemfile @@ -1,5 +1,5 @@ source "https://rubygems.org" -ruby "3.3.5" +ruby "3.3.4" gemspec diff --git a/expression-ruby/Gemfile.lock b/expression-ruby/Gemfile.lock index fcfb192..d207300 100644 --- a/expression-ruby/Gemfile.lock +++ b/expression-ruby/Gemfile.lock @@ -42,7 +42,7 @@ DEPENDENCIES rspec (~> 3) RUBY VERSION - ruby 3.3.5p100 + ruby 3.3.4p94 BUNDLED WITH 2.5.11 diff --git a/expression-ruby/ext/lago_expression/src/lib.rs b/expression-ruby/ext/lago_expression/src/lib.rs index e388c0f..96e9f89 100644 --- a/expression-ruby/ext/lago_expression/src/lib.rs +++ b/expression-ruby/ext/lago_expression/src/lib.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; -use expression_core::{Event, Expression, ExpressionParser, ExpressionValue}; -use magnus::{error, function, method, value::ReprValue, Error, IntoValue, Module, Object, Ruby}; +use expression_core::{Event, Expression, ExpressionParser, ExpressionValue, PropertyValue}; +use magnus::{ + error, function, method, r_hash::ForEach, value::ReprValue, Error, IntoValue, Module, Object, + RHash, Ruby, TryConvert, Value, +}; #[magnus::wrap(class = "Lago::Expression", free_immediately, size)] struct ExpressionWrapper(Expression); @@ -10,12 +13,35 @@ struct ExpressionWrapper(Expression); struct EventWrapper(Event); impl EventWrapper { - fn new(code: String, timestamp: u64, map: HashMap) -> EventWrapper { - Self(Event { + fn new(ruby: &Ruby, code: String, timestamp: u64, map: RHash) -> error::Result { + let mut properties = HashMap::default(); + + map.foreach(|key: String, value: Value| { + let property_value = if value.is_kind_of(ruby.class_numeric()) { + // Convert ruby numbers to a formatted string, that can be parsed into a BigDecimal + let ruby_string = value.to_r_string()?; + let big_d = ruby_string + .to_string()? + .parse() + .expect("Failed to parse a number as bigdecimal"); + PropertyValue::Number(big_d) + } else if value.is_kind_of(ruby.class_string()) { + PropertyValue::String(String::try_convert(value)?) + } else { + return Err(magnus::Error::new( + ruby.exception_runtime_error(), + "Expected string or number".to_owned(), + )); + }; + properties.insert(key, property_value); + Ok(ForEach::Continue) + })?; + + Ok(Self(Event { code, timestamp, - properties: map, - }) + properties, + })) } } diff --git a/expression-ruby/spec/lago-expression/expression_spec.rb b/expression-ruby/spec/lago-expression/expression_spec.rb index 20022f8..74efc70 100644 --- a/expression-ruby/spec/lago-expression/expression_spec.rb +++ b/expression-ruby/spec/lago-expression/expression_spec.rb @@ -2,7 +2,7 @@ RSpec.describe Lago::Expression do - let(:event) { Lago::Event.new("code", 1234, {"property_1" => "1.23", "property_2" => "test", "property_3" => "12.34"}) } + let(:event) { Lago::Event.new("code", 1234, {"property_1" => 1.23, "property_2" => "test", "property_3" => "12.34"}) } describe '#evaluate' do context "with a simple math expression" do @@ -39,6 +39,14 @@ end end + context "with a string expression with a decimal value from the event" do + let(:expression) { Lago::ExpressionParser.parse("CONCAT(event.properties.property_1, 'test')") } + + it "returns the calculated value" do + expect(expression.evaluate(event)).to eq("1.23test") + end + end + context "with a concat function" do let(:expression) { Lago::ExpressionParser.parse("concat(event.properties.property_2, '-', 'suffix')") }