Common Expression Language (CEL) is an expression language that’s fast, portable, and safe to execute in performance-critical applications. CEL is designed to be embedded in an application, with application-specific extensions, and is ideal for extending declarative configurations that your applications might already use.
This codelab is aimed at developers who would like to learn CEL to use services that already support CEL. This Codelab covers common use cases. This codelab doesn't cover how to integrate CEL into your own project. For a more in-depth look at the language, semantics, and features see the CEL Language Definition on GitHub.
Some key areas covered are:
- Hello, World: Using CEL to evaluate a String
- Creating variables
- Commutative logical AND/OR
- Adding custom functions
This codelab builds upon a basic understanding of Protocol Buffers and C++.
If you're not familiar with Protocol Buffers, the first exercise will give you a sense of how CEL works, but because the more advanced examples use Protocol Buffers as the input into CEL, they may be harder to understand. Consider working through one of these tutorials, first. See the devsite for Protocol Buffers.
If you're not familiar with Bazel, Bazel is an open-source build and test tool
that supports multi-language projects and manages dependencies. This codelab and
cel-cpp are built using Bazel. To learn more about building C++ projects with
Bazel, see the official Bazel C++ Tutorial
and the Bazel Documentation.
Notes on portability: Protocol Buffers are not required to use CEL generally, but the C++ implementation has a hard dependency on the library and some APIs reference protobuf types directly. Automated builds test against gcc10 and clang14 on linux, a recent visual code version on Windows, and a recent xcode version on MacOS. We accept requests for portability fixes for other OSes and compilers, but don't actively maintain support at this time. A simple Docker file is provided as a reference for a known good environment configuration for running the codelab solutions.
What you'll need:
- Git
- Bazel
- C/C++ Compiler (GCC, Clang, Visual Studio).
- Optional: bazelisk is a wrapper around bazel that simplifies version
management. If using, substitute all bazel commands below with
bazelisk.
GitHub Repo:
The code for this codelab lives in the codelab folder of the cel-cpp repo. The
solutions are available in the codelab/solution folder of the same repo.
Clone and cd into the repo:
git clone git@github.com:google/cel-cpp.git
cd cel-cpp
Make sure everything is working by building the codelab:
bazel build //codelab:all
If you want to integrate cel-cpp into your own standalone C++ project using
Bazel, you can manage it as an external dependency
with Bzlmod.
In your project's root MODULE.bazel file, declare a dependency on cel-cpp
from the
Bazel Central Registry (BCR):
bazel_dep(name = "cel-cpp", version = "<version>")Alternatively, if you want to depend directly on a specific commit from the
GitHub repository, you can use git_override:
bazel_dep(name = "cel-cpp", version = "0.0.0")
git_override(
module_name = "cel-cpp",
remote = "https://github.com/google/cel-cpp.git",
commit = "<commit_hash>",
)In your project's BUILD (or BUILD.bazel) file, reference cel-cpp targets
using @cel-cpp//.... For example, to compile a C++ binary that compiles and
evaluates CEL expressions using the modern Compiler and Runtime APIs shown
in this codelab:
cc_binary(
name = "my_cel_app",
srcs = ["main.cc"],
deps = [
"@cel-cpp//checker:validation_result",
"@cel-cpp//common:ast",
"@cel-cpp//common:minimal_descriptor_pool",
"@cel-cpp//common:value",
"@cel-cpp//compiler",
"@cel-cpp//compiler:compiler_factory",
"@cel-cpp//compiler:standard_library",
"@cel-cpp//runtime",
"@cel-cpp//runtime:activation",
"@cel-cpp//runtime:runtime_builder",
"@cel-cpp//runtime:runtime_options",
"@cel-cpp//runtime:standard_runtime_builder_factory",
],
)In the tried and true tradition of all programming languages, let's start with "Hello, World!".
Update exercise1.cc with the following:
Using declarations:
using ::cel::Activation;
using ::cel::Compiler;
using ::cel::CompilerBuilder;
using ::cel::Program;
using ::cel::Runtime;
using ::cel::RuntimeBuilder;
using ::cel::RuntimeOptions;
using ::cel::ValidationResult;
using ::cel::Value;Implementation:
absl::StatusOr<std::string> ParseAndEvaluate(absl::string_view cel_expr) {
// === Start Codelab ===
// Setup a default compiler for compiling expressions.
CEL_ASSIGN_OR_RETURN(
std::unique_ptr<CompilerBuilder> compiler_builder,
cel::NewCompilerBuilder(cel::GetMinimalDescriptorPool()));
CEL_RETURN_IF_ERROR(
compiler_builder->AddLibrary(cel::StandardCompilerLibrary()));
CEL_ASSIGN_OR_RETURN(std::unique_ptr<Compiler> compiler,
std::move(compiler_builder)->Build());
// Compile the expression.
CEL_ASSIGN_OR_RETURN(ValidationResult validation_result,
compiler->Compile(cel_expr));
if (!validation_result.IsValid()) {
return absl::InvalidArgumentError(validation_result.FormatError());
}
// Setup a standard runtime for evaluating expressions.
RuntimeOptions options;
CEL_ASSIGN_OR_RETURN(
RuntimeBuilder runtime_builder,
cel::CreateStandardRuntimeBuilder(cel::GetMinimalDescriptorPool(),
options));
CEL_ASSIGN_OR_RETURN(std::unique_ptr<Runtime> runtime,
std::move(runtime_builder).Build());
// Build the executable program from the compiled AST.
CEL_ASSIGN_OR_RETURN(std::unique_ptr<cel::Ast> ast,
validation_result.ReleaseAst());
CEL_ASSIGN_OR_RETURN(std::unique_ptr<Program> program,
runtime->CreateProgram(std::move(ast)));
// The evaluator uses a proto Arena for allocations during evaluation.
proto2::Arena arena;
// The activation provides variables and functions bound into the
// expression environment. In this example, there's no context expected, so
// we provide an empty activation.
Activation activation;
// Run the program.
CEL_ASSIGN_OR_RETURN(Value result,
program->Evaluate(&arena, activation));
// Convert the result to a C++ string.
return ConvertResult(result);
// === End Codelab ===
}Run the following to check your work:
bazel test //codelab:exercise1_test
You can add additional test cases or experiment with different return types.
Hello, World! Now, let's break down what's happening.
CEL applications compile and evaluate an expression against an environment.
The standard CEL environment supports all of the types, operators, functions, and macros defined within the language spec. The compiler and runtime objects can be customized with other variables, types, and functions.
A CompilerBuilder configures the compilation environment, while a
RuntimeBuilder configures the execution environment:
#include "compiler/compiler_factory.h"
#include "compiler/standard_library.h"
#include "runtime/runtime_builder.h"
#include "runtime/runtime_options.h"
#include "runtime/standard_runtime_builder_factory.h"
...
// Create a compiler configured with the standard CEL library.
CEL_ASSIGN_OR_RETURN(
std::unique_ptr<cel::CompilerBuilder> compiler_builder,
cel::NewCompilerBuilder(cel::GetMinimalDescriptorPool()));
CEL_RETURN_IF_ERROR(
compiler_builder->AddLibrary(cel::StandardCompilerLibrary()));
CEL_ASSIGN_OR_RETURN(std::unique_ptr<cel::Compiler> compiler,
std::move(compiler_builder)->Build());
// Create a standard runtime.
cel::RuntimeOptions options;
CEL_ASSIGN_OR_RETURN(
cel::RuntimeBuilder runtime_builder,
cel::CreateStandardRuntimeBuilder(cel::GetMinimalDescriptorPool(),
options));
CEL_ASSIGN_OR_RETURN(std::unique_ptr<cel::Runtime> runtime,
std::move(runtime_builder).Build());After the compiler is configured, you can compile source expressions into a
checked cel::Ast:
#include "checker/validation_result.h"
// ...
CEL_ASSIGN_OR_RETURN(cel::ValidationResult validation_result,
compiler->Compile(cel_expr));
if (!validation_result.IsValid()) {
return absl::InvalidArgumentError(validation_result.FormatError());
}compiler->Compile(...) parses and type-checks the expression in one step,
returning a cel::ValidationResult. If the expression is valid
(validation_result.IsValid()), the resulting cel::Ast can be extracted using
validation_result.ReleaseAst().
Note: If you need to interface with external systems that store or transmit serialized protobuf ASTs (
google::api::expr::CheckedExpr), conversion examples are available inchecked_expr_conversion_example.h(cel::AstToCheckedExprandcel::CreateAstFromCheckedExpr).
Once a cel::Ast is compiled, runtime->CreateProgram(...) creates an
executable cel::Program. The program is then evaluated against a
cel::Activation, which provides per-evaluation bindings for variables and
functions in the environment:
#include "common/ast.h"
#include "runtime/activation.h"
#include "third_party/protobuf/arena.h"
...
// Build the executable program from the compiled AST.
CEL_ASSIGN_OR_RETURN(std::unique_ptr<cel::Ast> ast,
validation_result.ReleaseAst());
CEL_ASSIGN_OR_RETURN(std::unique_ptr<cel::Program> program,
runtime->CreateProgram(std::move(ast)));
// Run the program against an activation and proto Arena.
cel::Activation activation;
proto2::Arena arena;
CEL_ASSIGN_OR_RETURN(cel::Value result,
program->Evaluate(&arena, activation));
return ConvertResult(result);Most CEL applications declare variables that can be referenced within expressions. Variable declarations specify a name and a type. A variable's type may be a CEL built-in type, a protocol buffer well-known type, or any protobuf message type so long as its descriptor is registered.
At runtime, the hosting program binds variable values into cel::Activation.
For the C++ evaluator at runtime, values are represented by cel::Value.
Update exercise2.cc:
absl::StatusOr<bool> CompileAndEvaluateWithBoolVar(absl::string_view cel_expr,
bool bool_var) {
CEL_ASSIGN_OR_RETURN(std::unique_ptr<cel::Compiler> compiler,
MakeCelCompiler());
CEL_ASSIGN_OR_RETURN(cel::ValidationResult validation_result,
compiler->Compile(cel_expr));
if (!validation_result.IsValid()) {
return absl::InvalidArgumentError(validation_result.FormatError());
}
cel::Activation activation;
proto2::Arena arena;
// === Start Codelab ===
activation.InsertOrAssignValue("bool_var", cel::BoolValue(bool_var));
CEL_ASSIGN_OR_RETURN(std::unique_ptr<cel::Ast> ast,
validation_result.ReleaseAst());
return EvalAst(std::move(ast), activation, &arena);
}Run the following to check your work. You should have fixed the first two test cases in exercise2_test.cc.
bazel test //codelab:exercise2_test
The second overload uses a protocol buffer message (AttributeContext) to
represent the environment variables. For this use case,
cel::BindProtoToActivation automatically binds fields from a top-level message
into the activation (#include "runtime/bind_proto_to_activation.h"):
#include "runtime/bind_proto_to_activation.h"
// ...
absl::StatusOr<bool> CompileAndEvaluateWithContext(
absl::string_view cel_expr, const AttributeContext& context) {
// ... compile cel_expr ...
cel::Activation activation;
proto2::Arena arena;
// === Start Codelab ===
CEL_RETURN_IF_ERROR(cel::BindProtoToActivation(
context, cel::BindProtoUnsetFieldBehavior::kBindDefaultValue,
proto2::DescriptorPool::generated_pool(),
proto2::MessageFactory::generated_factory(), &arena, &activation));
// === End Codelab ===
CEL_ASSIGN_OR_RETURN(std::unique_ptr<cel::Ast> ast,
validation_result.ReleaseAst());
return EvalAst(std::move(ast), activation, &arena);
}Note: You can experiment with unset values using
cel::BindProtoUnsetFieldBehavior::kBindDefaultValue or kSkip. With kSkip,
unset protobuf fields will not be bound at all, and accesses in expressions will
result in errors.
One of CEL's more distinctive features is its use of commutative logical operators. Either side of a conditional branch can short-circuit the evaluation, even in the face of errors or partial input. Note: If you are skipping ahead, copy the solution for exercise2 -- we'll be using it to test the behavior of some simple expressions.
exercise3_test.cc lists truth tables for simple expressions using the 'or', 'and', and 'ternary' operators.
Running the following should result in some failing expectations.
bazel test //codelab:exercise3_test
Open exercise3_test.cc in your editor:
TEST(Exercise3Var, LogicalOr) {
// Some of these expectations are incorrect.
// If a logical operation can short-circuit a branch that results in an error,
// CEL evaluation will return the logical result instead of propagating the
// error. For logical or, this means if one branch is true, the result will
// always be true, regardless of the other branch.
// Wrong
EXPECT_THAT(TruthTableTest("true || (1 / 0 > 2)"),
StatusIs(absl::StatusCode::kInvalidArgument, "divide by zero"));
EXPECT_THAT(TruthTableTest("false || (1 / 0 > 2)"),
StatusIs(absl::StatusCode::kInvalidArgument, "divide by zero"));
// Wrong
EXPECT_THAT(TruthTableTest("(1 / 0 > 2) || true"),
StatusIs(absl::StatusCode::kInvalidArgument, "divide by zero"));
EXPECT_THAT(TruthTableTest("(1 / 0 > 2) || false"),
StatusIs(absl::StatusCode::kInvalidArgument, "divide by zero"));
EXPECT_THAT(TruthTableTest("(1 / 0 > 2) || (1 / 0 > 2)"),
StatusIs(absl::StatusCode::kInvalidArgument, "divide by zero"));
EXPECT_THAT(TruthTableTest("true || true"), IsOkAndHolds(true));
EXPECT_THAT(TruthTableTest("true || false"), IsOkAndHolds(true));
EXPECT_THAT(TruthTableTest("false || true"), IsOkAndHolds(true));
EXPECT_THAT(TruthTableTest("false || false"), IsOkAndHolds(false));
}Updating the two failing cases "true || (1 / 0 > 2)" and "(1 / 0 > 2) || true" should fix this test:
// ...
// Correct
EXPECT_THAT(TruthTableTest("true || (1 / 0 > 2)"),
IsOkAndHolds(true));
EXPECT_THAT(TruthTableTest("false || (1 / 0 > 2)"),
StatusIs(absl::StatusCode::kInvalidArgument, "divide by zero"));
// Correct
EXPECT_THAT(TruthTableTest("(1 / 0 > 2) || true"),
IsOkAndHolds(true));You can examine the other tests for other cases for corresponding behavior for the 'and' and ternary operators.
CEL finds an evaluation order which gives results whenever possible, ignoring errors or even missing data that might occur in other evaluation orders. Applications like IAM conditions rely on this property to minimize the cost of evaluation, deferring the gathering of expensive inputs when a result can be reached without them.
See exercise4.h and exercise4.cc. Documentation to be added later.