Most Java projects reach for an expression engine sooner or later — a rule check here, a dynamic config value there, a filter that a user types at runtime. The usual suspects are heavyweight: they pull in scripting runtimes, allow arbitrary code, and become a security review headache.
Solon takes a more restrained path with SnEL (Solon Expression Language). It is a pure-Java, zero-dependency engine that compiles to a little over 40KB, and it works standalone — you can drop it into Spring Boot, Vert.x, jFinal, or a plain main method. But the part I find genuinely clever is how Solon AI reuses the parsed expression tree as a portable DSL and rewrites it into the native filter syntax of Redis, Milvus, Qdrant, pgvector, and friends.
This post walks through SnEL from "hello world" to that vector-database trick.
All APIs below are checked against the
solon-expressionandsolon-aisource. SnEL ships in Solon 3.1.1+.
Adding the dependency
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-expression</artifactId>
</dependency>
SnEL is a shortcut interface over SnelEvaluator.getInstance(). You can use the static helpers directly, or instantiate an evaluator when you need isolation.
The philosophy: an evaluator, not a scripting language
SnEL is deliberately constrained, and the constraints are the feature:
- It always returns a single value — it is an evaluation expression, not a statement block.
- Variables come only from the context you pass in; there is no
new Xxx(). - One expression, no
;. Noif/for/loops — it is not a scripting engine. - Field, property, and method access can nest deeply, but only
publicmembers are reachable.
That last set of rules is exactly what makes it safe to expose to config files or, carefully, to end-user input: there is no way to instantiate arbitrary classes or run control flow.
Evaluating expressions
The context is just a Map (or any Function<String, Object>). Values flow in by name.
import org.noear.solon.expression.snel.SnEL;
// Constants and arithmetic — no context needed
SnEL.eval("1 + 1"); // 2
SnEL.eval("1 * (1 + 2)"); // 3
SnEL.eval("'hello ' + 'world!'"); // "hello world!"
SnEL.eval("[1, 2, 3, -4]"); // a list
// Variables from a context map
Map<String, Object> ctx = new HashMap<>();
ctx.put("name", "solon");
ctx.put("list", Arrays.asList(1, 2, 3));
SnEL.eval("name.length()", ctx); // 5 (method call)
SnEL.eval("name.length() > 2 OR true", ctx);// true
SnEL.eval("list[0] == 1", ctx); // true
The syntax at a glance
| Capability | Example |
|---|---|
| Constants |
1, 'name', true, [1,2,3]
|
| Variables | name |
| Map / list access |
map['name'], list[0]
|
| Property / method |
user.name, user['name'], order.getUser()
|
| Arithmetic |
+ - * / %
|
| Comparison |
< <= > >= == !=
|
like / in
|
name LIKE 'so', vip IN ['l3','l4']
|
| Ternary | age > 18 ? 'adult' : 'minor' |
| Logical |
AND OR NOT (aliases && ` |
| Safe navigation | {% raw %}user?.name
|
| Default (Elvis) | user.name ?: 'noear' |
| Property reference |
${user.name}, ${user.name:noear}
|
| Static type call | T(java.lang.Integer).valueOf(45) |
A couple of rules worth remembering:
- Keywords are uppercase:
LIKE,NOT LIKE,IN,NOT IN,AND,OR,NOT. - Numeric literals follow Java:
1.1F,1.1D,1L,1.1(double),1(int).
Here is a heftier condition, exactly the kind of rule you would otherwise hand-code:
Map<String, Object> ctx = new HashMap<>();
ctx.put("age", 25);
ctx.put("salary", 4000);
ctx.put("isMarried", false);
ctx.put("label", "aa");
ctx.put("title", "ee");
ctx.put("vip", "l3");
String expr = "(((age > 18 AND salary < 5000) OR (NOT isMarried)) "
+ "AND label IN ['aa','bb'] AND title NOT IN ['cc','dd']) "
+ "OR vip == 'l3'";
boolean pass = (Boolean) SnEL.eval(expr, ctx); // true
Beans as context, and virtual variables
A plain Function<String, Object> cannot expose a POJO's properties. EnhanceContext bridges that gap and adds the root / this virtual variables:
import org.noear.solon.expression.context.EnhanceContext;
User user = new User(); // has a public getUserId()
SnEL.eval("userId > 12 ? 'A' : 'B'", new EnhanceContext(user));
SnEL.eval("root.userId > 12 ? 'A' : 'B'", new EnhanceContext(user));
// When the whole target is the value itself:
SnEL.eval("root ? 'A' : 'B'", new EnhanceContext(true)); // "A"
EnhanceContext also lets you bind application properties, so ${...} property references resolve against config:
SnEL.eval("${user.name:solon}", Solon.cfg());
SnEL.eval("'Hello ' + ${user.name:solon}", Solon.cfg());
Template expressions
For string templating, SnEL uses two placeholders:
-
#{...}— an evaluation placeholder (a full sub-expression) -
${...}/${...:default}— a property placeholder
SnEL.evalTmpl("a val is #{a}", model);
SnEL.evalTmpl("sum val is #{a + b}", model);
SnEL.evalTmpl("sum is #{a + b}, c prop is ${demo.c:c}", context);
This is not a toy. Solon AI uses evalTmpl in real code paths: system/user message templates (SystemMessageTemplate, UserMessageTemplate), tool descriptions (@ToolMapping descriptions run through SnEL.evalTmpl), and even DDL loaders that build SQL for RAG ingestion.
The interesting part: one tree, many query languages
SnEL.parse(expr) does not just give you an answer — it returns an Expression tree. Because that tree is a neutral structure, it can be walked and rewritten. Solon defines a Transformer<Boolean, String> interface for exactly this, and Solon AI ships a FilterTransformer for each vector store.
In practice, you write your metadata filter once as a SnEL string, and each repository turns it into its own dialect. Here is where it enters the RAG path — QueryCondition parses the string into a tree:
// solon-ai: QueryCondition
public QueryCondition filterExpression(String filterExpression) {
this.filterExpression = SnEL.parse(filterExpression);
return this;
}
So your application code stays portable:
QueryCondition cond = new QueryCondition("What is Solon?")
.filterExpression("category == 'framework' AND year >= 2020")
.limit(4);
List<Document> docs = repository.search(cond);
Now the same tree — LogicalNode(AND) → ComparisonNode(eq), ComparisonNode(gte) — gets rewritten per backend. The Redis transformer, for example, walks the node types and emits Redis Search syntax:
// solon-ai-repo-redis: FilterTransformer (abridged)
if (filterExpression instanceof ComparisonNode) {
ComparisonNode node = (ComparisonNode) filterExpression;
switch (node.getOperator()) {
case eq: // @field:{value}
parse(node.getLeft(), buf); buf.append(":"); parse(node.getRight(), buf);
break;
case gte: // @field:[value +inf]
parse(node.getLeft(), buf); buf.append(":[");
parse(node.getRight(), buf); buf.append(" +inf]");
break;
// ...
}
} else if (filterExpression instanceof LogicalNode) {
// AND -> space, OR -> " | ", NOT -> "-"
}
category == 'framework' AND year >= 2020 becomes something like (@category:{framework} @year:[2020 +inf]) for Redis, while the Milvus, Qdrant, pgvector, Elasticsearch, and Chroma transformers each produce their own native filter for the very same input. Swap your vector store and the filter code doesn't move.
Building the tree by hand
If you would rather not go through string parsing, ConditionBuilder assembles the same tree programmatically — handy when the condition is generated from a UI or another rule system:
import org.noear.solon.expression.snel.ConditionBuilder;
import org.noear.solon.expression.Expression;
ConditionBuilder cb = new ConditionBuilder();
// (age > 18 AND salary < 5000) OR (isMarried == false)
Expression<Boolean> condition = cb.or(
cb.and(cb.gt("age", 18), cb.lt("salary", 5000)),
cb.eq("isMarried", "false")
);
boolean result = condition.eval(context::get);
The output of ConditionBuilder is the same Expression<Boolean> type that SnEL.parse yields, so it flows into QueryCondition.filterExpression(...) and every Transformer exactly the same way.
When to use it
SnEL fits nicely when you want:
- Dynamic config / conditions without embedding a scripting engine.
- A safe evaluator for semi-trusted input — no object construction, no control flow.
- A portable filter DSL that you can retarget across data stores (its reason for existing inside Solon AI).
It is intentionally not a general-purpose scripting language. If you need loops, assignments, or object instantiation, reach for something else. But for the "evaluate this condition against this context" job — which is 90% of what people actually want — its small surface area and predictable behavior are the whole point.
Wrapping up
SnEL is a good example of Solon's design taste: keep the core tiny, keep it safe by omission, and make the output composable. The fact that a 40KB evaluator doubles as the intermediate representation for cross-database vector filtering is the kind of leverage you get from picking the right abstraction.
- solon-expression: https://github.com/opensolon/solon-expression
- Docs (SnEL series): https://solon.noear.org/article/learn-solon-snel
- Solon: https://github.com/opensolon/solon
If you have used SnEL — or a similar "expression tree as DSL" pattern — I'd love to hear how it held up in your project.
Top comments (0)