DEV Community

Solon Framework
Solon Framework

Posted on

One Expression, Many Databases: A Tour of Solon's SnEL Engine (Java)

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-expression and solon-ai source. SnEL ships in Solon 3.1.1+.

Adding the dependency

<dependency>
    <groupId>org.noear</groupId>
    <artifactId>solon-expression</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

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 ;. No if/for/loops — it is not a scripting engine.
  • Field, property, and method access can nest deeply, but only public members 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 -> "-"
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

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)