OCL Engine User Guide
Fennec OCL is a lightweight, spec-compliant OCL v2.5 engine (backward compatible with v2.4) that works as a standalone Java library — no Eclipse platform required.
Table of Contents
- Overview
- Quick Start
- Engine Setup
- Evaluating Expressions
- OclContext — Evaluation Context
- Evaluation Options
- Caching
- Error Handling
- EMF Delegate Integration
- Complete OCL Documents
- Custom Operations
- Thread Safety
- Value Type Mapping
- Language Semantics
- Security Hardening
1. Overview
The Fennec OCL Engine provides:
- OCL v2.5 expression parsing and evaluation (backward compatible with v2.4)
- Standalone operation — works as a plain Java library without OSGi
- OSGi-optional — full Declarative Services support when running in OSGi
- ANTLR4-based parser — fast, reliable parsing with precise error locations
- EMF delegate integration — derived features, operation bodies, and validation constraints
- LRU expression cache — thread-safe caching with hit/miss statistics
- Configurable evaluation — strict/lenient null handling, timeouts, depth limits
Performance
Fennec OCL is significantly faster than Eclipse OCL Classic:
| Operation | Speedup |
|---|---|
| Parse (no cache) | 100–315x faster |
| Parse (cached) | ~100,000x faster |
| Parse + Eval | 176–454x faster |
| Pure Evaluation | 20–59% faster |
See benchmark-results.md for detailed numbers.
Maven Coordinates
GroupId: org.eclipse.fennec.m2x| Bundle | Description |
|---|---|
org.eclipse.fennec.m2x.ocl.api | Public API interfaces |
org.eclipse.fennec.m2x.ocl.parser | ANTLR4 parser |
org.eclipse.fennec.m2x.ocl.engine | Evaluator implementation |
org.eclipse.fennec.m2x.ocl.model | OCL EMF metamodel |
2. Quick Start
Minimal example — parse and evaluate an OCL expression against an EMF object:
import org.eclipse.fennec.m2x.ocl.api.OclContext;
import org.eclipse.fennec.m2x.ocl.api.OclEngine;
import org.eclipse.fennec.m2x.ocl.engine.OclEngines;
import org.eclipse.fennec.m2x.ocl.parser.OclParserSupport;
// Create engine
OclEngine engine = OclEngines.create(new OclParserSupport());
// Evaluate an expression
Object result = engine.evaluate("self.name.size() > 0", OclContext.of(myEObject));
System.out.println(result); // trueThat's it. Three lines to set up, one line to evaluate.
For a worked example over real files — a metamodel whose OCL sits in delegate annotations, a Complete OCL document beside it, and the same document as a compiled unit — see the org.eclipse.fennec.m2x.ocl.example project. Its main() classes are the walkthrough.
3. Engine Setup
3.1 Minimal (No Cache)
import org.eclipse.fennec.m2x.ocl.api.OclEngine;
import org.eclipse.fennec.m2x.ocl.engine.OclEngines;
import org.eclipse.fennec.m2x.ocl.parser.OclParserSupport;
OclEngine engine = OclEngines.create(new OclParserSupport());3.2 With LRU Expression Cache
import org.eclipse.fennec.m2x.ocl.api.OclEngine;
import org.eclipse.fennec.m2x.ocl.engine.OclEngines;
import org.eclipse.fennec.m2x.ocl.engine.OclLruExpressionCache;
import org.eclipse.fennec.m2x.ocl.parser.OclParserSupport;
OclEngine engine = OclEngines.create(
new OclParserSupport(),
OclLruExpressionCache.ofSize(2048)
);3.3 With OclConfiguration Builder (Recommended)
import org.eclipse.fennec.m2x.ocl.api.OclConfiguration;
import org.eclipse.fennec.m2x.ocl.api.OclEngine;
import org.eclipse.fennec.m2x.ocl.engine.OclEngines;
import org.eclipse.fennec.m2x.ocl.engine.OclLruExpressionCache;
import org.eclipse.fennec.m2x.ocl.parser.OclParserSupport;
OclConfiguration config = OclConfiguration.builder(new OclParserSupport())
.expressionCache(OclLruExpressionCache.ofSize(2048))
.addOperationProvider(myCustomOps)
.build();
OclEngine engine = OclEngines.create(config);3.4 OSGi (Declarative Services)
In OSGi, the engine and parser are registered as PROTOTYPE-scoped DS components. Every @Reference injection creates a fresh, isolated instance:
import org.eclipse.fennec.m2x.ocl.api.OclEngine;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
@Component
public class MyComponent {
@Reference
private OclEngine engine; // fresh instance, own PropertyAccessorCache
public boolean validateName(EObject obj) {
return (Boolean) engine.evaluate("self.name.size() > 0", OclContext.of(obj));
}
}Component scopes:
| Component | Scope | What's shared? |
|---|---|---|
OclParserSupport | PROTOTYPE | Nothing — each engine gets its own parser, bound prototype_required to its own ResourceSet from emf.osgi, whose package registry resolves qualified names |
OclEngineComponent (DefaultOclEngine) | PROTOTYPE | Nothing — each consumer gets its own engine with isolated PropertyAccessorCache. Published as OclEngine and, for the EMF delegates in the same bundle, as the internal OclDelegateSupport |
DefaultOclExpressionCacheComponent | SINGLETON | Shared by default — all engines share the same LRU parse cache (1024 entries, plus up to 256 remembered parse failures — properties oclExpressionSize, oclExpressionFailureSize) |
The expression cache is shared because parsed OclExpression ASTs are immutable and thread-safe. This means a parse result from one engine benefits all others.
That sharing has a limit worth knowing. The default cache keys by nsURI, which names a model but not a version of it. Where two versions of one nsURI are live at once, they share an entry, and whichever was compiled first decides what the other gets. Navigation survives that by accident — feature access falls back to resolving by name on the runtime class — but an expression naming a type does not, and reports a perfectly valid object as invalid with nothing said about why.
org.eclipse.fennec.m2x.ocl.fingerprint keys by the model fingerprint instead, so two versions are two entries and two loads of the same content are one. Select it where two versions can coexist:
{
"DefaultOclEngine": { "expressionCache.target": "(cache.name=fingerprint)" }
}Outside OSGi:
OclConfiguration.builder(parser)
.expressionCache(new FingerprintExpressionCache(OclLruExpressionCache.ofSize(1024),
FingerprintHelper.getDefaultFingerprintService()))
.build();It is not the default because the shipped cache is right whenever one version of an nsURI is live at a time, which is the ordinary case. Note also that changing a model after expressions have been compiled against it is out of contract — a fingerprint is computed per package instance, and neither cache tries to notice a mutation.
To use individual caches (e.g. for different metamodels), create factory configurations with expressionCache.target — see §3.5 and the Architecture Doc §9.5.
3.5 OSGi Configuration via ConfigAdmin
The OclEngineComponent supports engine-wide defaults through OSGi ConfigurationAdmin. All properties use the ocl. prefix.
OSGi Configurator JSON:
{
":configurator:resource-version": 1,
"DefaultOclEngine": {
"ocl.maxDepth": 500,
"ocl.maxCollectionSize": 100000,
"ocl.maxClosureIterations": 10000,
"ocl.maxRegexLength": 200,
"ocl.maxStringLength": 100000,
"ocl.timeout": 5000,
"ocl.nullHandling": "STRICT",
"ocl.errorRecovery": "FAIL_FAST",
"ocl.customOperationsEnabled": false
}
}Available properties:
| Property | Type | Default | Description |
|---|---|---|---|
ocl.maxDepth | int | 1,000 | Maximum expression nesting depth |
ocl.maxCollectionSize | int | 1,000,000 | Maximum collection elements |
ocl.maxClosureIterations | int | 100,000 | Maximum closure() iterations |
ocl.maxRegexLength | int | 1,000 | Maximum regex pattern length |
ocl.maxStringLength | int | 10,000,000 | Maximum length of a string an operation produces |
ocl.timeout | long | 0 | Evaluation timeout in ms (0 = no timeout) |
ocl.nullHandling | String | STRICT | STRICT or LENIENT |
ocl.errorRecovery | String | FAIL_FAST | FAIL_FAST or COLLECT_ERRORS |
ocl.customOperationsEnabled | boolean | false | Enable config-registered custom operations |
ocl.specStrict | boolean | false | Follow the OCL v2.4 text where it and the Eclipse OCL default disagree (see §6.5) |
These defaults are used by evaluate(expression, context) (without explicit options). Calls with explicit OclEvaluationOptions override the engine-wide defaults.
Multiple engine configurations:
Use factory configurations to create multiple engines with different settings:
{
":configurator:resource-version": 1,
"DefaultOclEngine~strict": {
"ocl.maxDepth": 100,
"ocl.nullHandling": "STRICT",
"engine.tag": "strict"
},
"DefaultOclEngine~lenient": {
"ocl.maxDepth": 1000,
"ocl.nullHandling": "LENIENT",
"engine.tag": "lenient"
}
}Inject a specific engine via filter:
@Reference(target = "(engine.tag=strict)")
private OclEngine strictEngine;
@Reference(target = "(engine.tag=lenient)")
private OclEngine lenientEngine;3.6 Standalone Configuration with Engine-Wide Defaults
The OclConfiguration builder also supports engine-wide defaults for standalone (non-OSGi) use:
OclConfiguration config = OclConfiguration.builder(new OclParserSupport())
.expressionCache(OclLruExpressionCache.ofSize(2048))
.nullHandling(NullHandling.LENIENT)
.errorRecovery(ErrorRecovery.COLLECT_ERRORS)
.maxDepth(500)
.timeoutMs(5000)
.maxCollectionSize(100_000)
.maxClosureIterations(10_000)
.maxRegexLength(200)
.maxStringLength(100_000)
.specStrict(false) // the default; see §6.5
.build();
OclEngine engine = OclEngines.create(config);
// evaluate() without options uses the engine-wide defaults
Object result = engine.evaluate("self.name", OclContext.of(obj));
// LENIENT covers navigation, not calls:
engine.evaluate("self.employer.name", OclContext.of(obj)); // employer unset → null
engine.evaluate("self.employer.name.size()", OclContext.of(obj)); // → OclInvalid
engine.evaluate("'at ' + self.employer.name", OclContext.of(obj)); // → OclInvalid
// Neither invalid is quiet: evaluateWithDiagnostics() carries an error for each —
// "Unknown operation: size on null" and "Null argument for operation '+'"
// evaluate() with explicit options overrides the defaults
Object sandboxed = engine.evaluate(expr, ctx,
OclEvaluationOptions.strict().withMaxDepth(50));3.7 Type Resolution — Which Packages the Parser Sees
Classifier names are resolved when an expression is parsed — self.oclIsTypeOf(Novel) needs to find Novel somewhere. The parser looks in the context type's own package first, then in its EPackage.Registry.
// plain Java: the global registry, which is the correct answer there
OclEngine engine = OclEngines.create(new OclParserSupport());
// your own packages — hand over the ResourceSet you already have
OclEngine engine = OclEngines.create(new OclParserSupport(resourceSet));
// or the registry itself, if that is what you hold
OclEngine engine = OclEngines.create(new OclParserSupport(registry));Under OSGi you do not construct the parser. OclParserSupport is a DS component that binds the ResourceSet emf.osgi publishes as a prototype-scoped service — as a mandatory reference, deliberately not an optional one with a fallback to the global registry. A metamodel registered for the resource set only (emf.model.scope=resourceset) lives in that resource set's package registry and never in the global one, so a parser built on the fallback would report a valid qualified name as unknown; and an optional reference cannot say when the service arrives. Mandatory means the parser exists once the resource set does. Where several resource sets are published, pick one:
{
"org.eclipse.fennec.m2x.ocl.parser.OclParserSupport": { "resourceSet.target": "(emf.name=myapp)" }
}Building an engine by hand under OSGi works the same way as in plain Java — the configured, isolated stack arrives as a ResourceSetFactory, never as a bare registry:
@Reference(target = "(rsf.name=myapp)")
ResourceSetFactory factory;
OclEngine engine = OclEngines.create(new OclParserSupport(factory.createResourceSet()));Only the resource set's package registry is used; nothing is loaded through it.
The registry is the parser's, not the engine's: hand it over when constructing OclParserSupport, and every expression and Complete OCL document parsed by that engine resolves against it. Nothing inside the engine reaches for the global registry on its own (D42), so what you pass in is what the parser sees.
Which packages a caller supplies is a model-identity decision, and identity is the model fingerprint rather than the nsURI — see the emf.osgi fingerprint guide. Supply the packages you resolved and verified yourself; the engine forms no opinion about which version an nsURI names.
A qualified name that resolves in neither the context package nor the registry is an error: parsing fails with OclParseException, and getErrors() carries one diagnostic per unresolved name — all of them, not just the first.
parser.parse("self.oclIsTypeOf(nosuch::Type)", bookClass);
// OclParseException: Unknown type (nosuch::Type)
parser.parse("Status::UNKNOWN", personClass);
// OclParseException: Unknown enumeration literal (Status::UNKNOWN)An unqualified name is not rejected in a value position. A bare name that matches no property and no classifier becomes an external variable reference, which OclContext can bind at evaluation time — rejecting it would break context variables.
In a type position it is rejected, because there is no second reading available there. The argument of oclIsKindOf, oclIsTypeOf and oclAsType is a type by definition (OCL v2.4 §13.2: a TypeExp refers to an existing type), as is every : Type annotation:
parser.parse("self.oclIsKindOf(NoSuchType)", bookClass);
// OclParseException: Unknown type (NoSuchType)Before this was reported at parse time, such a name became an external variable: the evaluator then said Unresolved variable: NoSuchType — the wrong noun, only visible to a caller who asked for diagnostics — and evaluate answered false, which for oclIsKindOf is an answer nobody questions.
A name that is bound stays accepted in a type position: whether a bound value may serve as a type is a question about the value, and belongs to evaluation.
parser.parse("Sequence{1}->exists(x | self.oclIsKindOf(x))", bookClass); // parses4. Evaluating Expressions
4.1 Convenience: Parse + Eval in One Step
The simplest approach — pass a String expression and context:
Object result = engine.evaluate("self.name", OclContext.of(myEObject));This parses and evaluates in one call. If a cache is configured, the parsed expression is cached automatically.
4.2 Pre-Parsed: Parse Once, Evaluate Many Times
For repeated evaluation of the same expression against different objects:
import org.eclipse.fennec.m2x.model.ocl.OclExpression;
// Parse once
OclExpression expr = engine.parse("self.name.size() > 3", myEClass);
// Evaluate many times
for (EObject obj : objects) {
Object result = engine.evaluate(expr, OclContext.of(obj));
}4.3 With Diagnostics: Fault-Tolerant Evaluation
Use evaluateWithDiagnostics() to collect warnings and errors without throwing:
import org.eclipse.fennec.m2x.ocl.api.OclEvaluationOptions;
import org.eclipse.fennec.m2x.ocl.api.OclResult;
OclResult result = engine.evaluateWithDiagnostics(
expr,
OclContext.of(myEObject),
OclEvaluationOptions.lenient()
);
if (result.isSuccess()) {
String name = result.getValueAs(String.class);
} else {
result.diagnostics().forEach(d -> System.err.println(d.getMessage()));
}4.4 With Custom Evaluation Options
import org.eclipse.fennec.m2x.ocl.api.OclEvaluationOptions;
Object result = engine.evaluate(
expr,
OclContext.of(myEObject),
OclEvaluationOptions.strict().withTimeout(Duration.ofSeconds(5))
);4.5 Declaring Variables at Parse Time
An expression that uses variables of its own — the parameters of an operation, a value the caller supplies — should declare them when it is parsed:
OclExpression expr = engine.parse(
"self.books->select(b | b.author = author)",
libraryClass,
Map.of("author", personClass)); // name → type; a null type means unknown
engine.evaluate(expr, OclContext.of(library, Map.of("author", alice)));The declaration matters because a named iterator variable is an implicit source (§14.3): without it, author above is read as b.author, and the expression compares every book's author with itself. A bare name that is neither declared nor a property of an implicit source or self still becomes an external variable bound from OclContext (§3.7). Expressions parsed with declared variables are cached like any other, with the names and types of the variables as part of the key, since the declarations are part of what the text means (§7.1). The same method exists on OclExpressionParser.
The EMF invocation delegate (§9.3) and Complete OCL documents (§10.6) declare the operation's parameters, and result in a postcondition, on their own.
5. OclContext — Evaluation Context
OclContext is a Java record that defines the evaluation environment.
5.1 Factory Methods
// Just a context object (self)
OclContext ctx = OclContext.of(myEObject);
// With external variables
OclContext ctx = OclContext.of(myEObject, Map.of("threshold", 42));
// With model extent (enables allInstances())
OclContext ctx = OclContext.of(myEObject, myExtent);
// With ResourceSet (for package resolution)
OclContext ctx = OclContext.of(myEObject, myResourceSet);
// With extent and ResourceSet
OclContext ctx = OclContext.of(myEObject, myExtent, myResourceSet);
// Variables only (no self)
OclContext ctx = OclContext.of(Map.of("x", 1, "y", 2));5.2 Full Constructor
OclContext ctx = new OclContext(
myEObject, // self — context object (nullable)
myExtent, // OclModelExtent — scope for allInstances() (nullable)
Map.of("x", 42), // variables — external variable bindings
myResourceSet, // ResourceSet — for EPackage resolution (nullable)
myInterceptor // BiFunction<EObject, String, Object> — property interceptor (nullable)
);5.3 OclModelExtent — allInstances() Scope
To use allInstances() in OCL expressions, provide an OclModelExtent:
import org.eclipse.fennec.m2x.ocl.api.OclModelExtent;
OclModelExtent extent = eClass -> {
// Return all instances of the given EClass in your model
return myResource.getAllContents()
.filter(eClass::isInstance)
.toList();
};5.4 Property Interceptor
Intercept property access for custom resolution (e.g., aspect-oriented properties):
BiFunction<EObject, String, Object> interceptor = (obj, propertyName) -> {
if ("customProp".equals(propertyName)) {
return computeCustomValue(obj);
}
return OclContext.PROPERTY_NOT_HANDLED; // fall back to default
};
OclContext ctx = new OclContext(myEObject, null, Map.of(), null, interceptor);6. Evaluation Options
OclEvaluationOptions controls how the engine handles nulls, errors, and resource limits.
There are three levels of configuration, from broadest to narrowest:
- Engine-wide defaults — set via
OclConfigurationbuilder (standalone) or ConfigAdmin (OSGi). Used byevaluate(expression, context). - Delegate defaults — set via
setDelegateOptions(). Used by EMF delegate evaluations. - Per-evaluation options — passed explicitly to
evaluate(expression, context, options). Override engine-wide defaults.
6.1 Presets
// Strict: null access → OclInvalid, stop on first error
OclEvaluationOptions strict = OclEvaluationOptions.strict();
// Lenient: null access → null, collect all errors
OclEvaluationOptions lenient = OclEvaluationOptions.lenient();6.2 Custom Options
All with* methods return new immutable instances:
OclEvaluationOptions options = OclEvaluationOptions.strict()
.withTimeout(Duration.ofSeconds(10))
.withMaxDepth(500)
.withMaxCollectionSize(100_000)
.withMaxClosureIterations(50_000)
.withMaxRegexLength(500)
.withUseEMFTypes(true);6.3 Option Reference
| Option | Default | Description |
|---|---|---|
NullHandling.STRICT | yes | Null property access returns OclInvalid.INSTANCE |
NullHandling.LENIENT | Null property access returns null | |
ErrorRecovery.FAIL_FAST | yes | Stop on first error |
ErrorRecovery.COLLECT_ERRORS | Collect all errors, continue evaluation | |
maxDepth | 1,000 | Maximum expression nesting depth |
timeout | none | Maximum evaluation time |
maxCollectionSize | 1,000,000 | Maximum elements in a collection |
maxClosureIterations | 100,000 | Maximum closure() iterations |
maxRegexLength | 1,000 | Maximum regex pattern length |
maxStringLength | 10,000,000 | Maximum length of a string an operation produces (concat, replaceAll, format, …) |
useEMFTypes | false | Wrap top-level Collection as EList, top-level Map as EMap |
specStrict | false | Follow the specification text where it and the Eclipse OCL default disagree (§6.5) |
6.4 Return Types — Java vs. EMF
By default, OclEngine.evaluate(...) returns OCL-spec-native Java types:
| OCL type | Java class (default) |
|---|---|
Sequence(T) | java.util.ArrayList |
OrderedSet(T) | OclOrderedSet (extends ArrayList, OCL equality) |
Bag(T) | OclBag (extends ArrayList) |
Set(T) | OclSet (extends AbstractSet, OCL equality) |
Map(K,V) | java.util.LinkedHashMap |
Integer | Integer when it fits 32 bits, else Long, BigInteger beyond 64 bits |
Real | Double (exact BigDecimal inside the evaluation, see §13) |
When a caller expects EMF-shaped results — e.g. the result is being assigned to an EList<T>-typed variable or fed back into EMF APIs — set useEMFTypes = true. The engine then wraps top-level only collection/map results:
Collection(any OCL kind) → unmodifiableEListMap→ unmodifiableEMap
Nested collections inside the result are left untouched. Non-collection values (numbers, strings, EObjects, …) pass through unchanged.
Example
OclEvaluationOptions options = OclEvaluationOptions.strict()
.withUseEMFTypes(true);
// "self.employees->select(e | e.name.startsWith(prefix))->asSequence()"
EList<Person> matches = (EList<Person>) engine.evaluate(parsed, ctx, options);The same flag can be set once on the engine configuration so every call uses it:
OclConfiguration config = OclConfiguration.builder(parser)
.useEMFTypes(true)
.build();
OclEngine engine = OclEngines.create(config);Delegate note: The EMF invocation delegate always returns an
EListfor multi-valuedEOperations regardless of this flag — that is required by the EMF contract (see issue #3). TheuseEMFTypesflag (see issue #4) only affects the shape ofOclEngine.evaluate(...)return values.
6.5 specStrict — Specification Text vs. Eclipse Default
In a few places the OCL v2.4 text and the default of Eclipse OCL disagree. Fennec follows the Eclipse default unless specStrict is set (#316, #326):
| Case | Default (specStrict = false) | specStrict = true |
|---|---|---|
any without a matching element | null (Eclipse, OMG issue 18504) | invalid (§11.9.1) |
one, isUnique | stop at the first decisive element | evaluate every body; invalid if any body is invalid (§11.9.1) |
any after a match | returns the match | evaluates the remaining bodies first, so a later invalid body makes it invalid |
Decreasing range Sequence{5..3} | empty, with a WARNING | invalid, with an ERROR (§8.3.7 CollectionRange [2]) |
Untyped let x = 1 or iterate(i; acc = 0 | …) | accepted, type inferred | ERROR (§7.4.3) |
OclEvaluationOptions literal = OclEvaluationOptions.strict().withSpecStrict(true);
// engine-wide
OclConfiguration config = OclConfiguration.builder(parser).specStrict(true).build();The untyped-variable check is reported at evaluation, not at parse time: specStrict is an evaluation option, and a parsed expression is cached by its text and evaluated under whatever options a caller passes.
specStrict is independent of NullHandling.STRICT (§6.1): the null handling decides what a navigation from null yields, specStrict only the cases in the table.
7. Caching
7.1 Expression Cache
The OclLruExpressionCache caches parsed OclExpression ASTs keyed by (expression, contextType):
import org.eclipse.fennec.m2x.ocl.engine.OclLruExpressionCache;
OclLruExpressionCache cache = OclLruExpressionCache.ofSize(2048);
OclEngine engine = OclEngines.create(new OclParserSupport(), cache);
// Cache statistics
long hits = cache.hitCount();
long misses = cache.missCount();
long size = cache.size();
double hitRate = (double) hits / (hits + misses);- Thread-safe — internally synchronized
- LRU eviction — least-recently-used entries evicted when full
- Cache key —
nsURI#contextTypeName#expression; with declared variablesnsURI#contextTypeName(name:nsURI#Type,…)#expression, the variables sorted by name
Parse failures are cached too (negative cache). When a text does not parse, the engine keeps the failure under the same key, and the next parse with that key throws a fresh OclParseException with the same message and diagnostics, without parsing again. A caller that evaluates the same broken expression for every event therefore pays the parse once and needs no cache of its own. Failures live in a second LRU with a limit of its own (default 256), so a flood of broken expressions only evicts other failures, never a parse that works; 0 keeps none:
// 2048 parsed expressions, at most 512 remembered failures
OclLruExpressionCache cache = OclLruExpressionCache.ofSize(2048, 512);
long failures = cache.failureCount(); // size() counts parses and failuresinvalidate(expression, contextType) and invalidateAll() drop failures as well. With the fingerprint cache (§3.4) the failure is keyed by the model fingerprint, so a changed model is parsed again instead of being handed the old verdict. Only OclParseException is remembered: syntax and type errors are a property of the text and the model.
Parses with variables are cached. parse(expression, contextType, variables) uses the cache like the two-argument form. The names and types of the variables are part of the key, because the same text means something else under other declarations; the order in which they are declared does not matter.
Own cache implementations. OclExpressionCache gained lookup(OclCacheKey), store(OclCacheKey, OclParseResult) and invalidate(OclCacheKey) as default methods. An implementation that only implements get/put keeps working unchanged, and caches what it did before: successful parses without variables. Override the three methods to remember failures and parses with variables; OclParseResult is either Parsed(expression) or Failed(message, errors).
7.2 WarmUp
Pre-populate the PropertyAccessorCache and parse common expressions:
OclEngine engine = OclEngines.create(config);
engine.warmUp(MyPackage.eINSTANCE);This caches property accessors for all classes in the package, reducing first-evaluation latency.
7.3 Performance Impact
From benchmarks with cache enabled:
| Scenario | Without Cache | With Cache | Speedup |
|---|---|---|---|
| Simple expression (repeated) | 6.66 ms | ~0.06 ms | ~100x |
| Medium expression (repeated) | 34.52 ms | ~0.35 ms | ~100x |
8. Error Handling
8.1 Parse Errors
OclParseException is a checked exception carrying one diagnostic per problem:
try {
engine.parse("self.name +++ 42", myEClass);
} catch (OclParseException e) {
System.err.println(e.getMessage());
for (Resource.Diagnostic error : e.getErrors()) {
System.err.printf(" Line %d, Col %d: %s%n",
error.getLine(), error.getColumn(), error.getMessage());
}
}Positions are real for both kinds now (#110). A syntax error carries what ANTLR reported; an unresolved name or unknown type carries the place the offending name starts — several problems on one line are told apart by their column. 0:0 still means "position unknown" and is what you get for a diagnostic reported without a parse context at all, which is rare.
8.2 OclInvalid vs. null
OCL distinguishes three value states:
| State | Meaning | Check |
|---|---|---|
| Regular value | Normal result | result != null && !(result instanceof OclInvalid) |
null | OCL void/undefined | result == null |
OclInvalid.INSTANCE | OCL invalid (error) | result instanceof OclInvalid |
8.3 OclResult — Three States
When using evaluateWithDiagnostics():
OclResult result = engine.evaluateWithDiagnostics(expr, ctx, options);
if (result.hasValue()) {
// Normal value — no errors, value is present and not invalid
process(result.getValueAs(String.class));
} else if (result.isInvalid()) {
// OclInvalid — the expression evaluated to invalid
handleInvalid(result.diagnostics());
} else if (result.isNull()) {
// null — the expression evaluated to void/undefined
handleNull();
}
// Or simply check for success (no ERROR-level diagnostics)
if (result.isSuccess()) {
// safe to use result.value()
}9. EMF Delegate Integration
Register the OCL engine as an EMF delegate for derived features, operation bodies, and validation constraints.
9.1 Standalone Registration
OclEngine engine = OclEngines.create(config);
engine.installDelegates();
// Now EMF will use Fennec OCL for:
// - EOperation invocation delegates (body annotations)
// - EStructuralFeature setting delegates (derivation/initial annotations)
// - EValidator validation delegates (constraint annotations)
// Cleanup when done
engine.uninstallDelegates();9.2 Delegate URI
The native Fennec OCL delegate URI is:
http://www.eclipse.org/fennec/m2x/ocl/1.0The engine also serves the legacy Eclipse OCL Pivot delegate URI for interop (see 9.6):
http://www.eclipse.org/emf/2002/Ecore/OCL/PivotBoth are exposed as OclDelegateUtil.DELEGATE_URI and OclDelegateUtil.LEGACY_PIVOT_URI; the full set served by the engine is OclDelegateUtil.SERVED_URIS.
9.3 Ecore Annotations
In your .ecore model, add annotations with this delegate URI:
Derived Feature:
<eStructuralFeatures xsi:type="ecore:EAttribute" name="fullName" eType="ecore:EDataType ...">
<eAnnotations source="http://www.eclipse.org/fennec/m2x/ocl/1.0">
<details key="derivation" value="self.firstName + ' ' + self.lastName"/>
</eAnnotations>
</eStructuralFeatures>Operation Body:
<eOperations name="isAdult" eType="ecore:EDataType ...">
<eAnnotations source="http://www.eclipse.org/fennec/m2x/ocl/1.0">
<details key="body" value="self.age >= 18"/>
</eAnnotations>
</eOperations>Validation Constraint:
<eAnnotations source="http://www.eclipse.org/fennec/m2x/ocl/1.0">
<details key="nameNotEmpty" value="self.name.size() > 0"/>
</eAnnotations>What each key means at run time:
derivationis evaluated on everyeGet.initialis evaluated exactly like a derivation, on every access — the setting delegate is stateless, there is no "value at creation time" (§12.8) that it stores. Eclipse OCL behaves the same; the key is read only when the feature has noderivation.bodyanswers the operation. Its parameters are variables of the expression, declared at parse time (§4.5), so a parameter name never collides with a property of an iterator.preandpostare checked around the body; a violated condition fails the invocation (InvocationTargetException). Inpost,resultis the body's value and@prereads the state before the call (§10.6). Preconditions and postconditions of loaded Complete OCL documents are checked here as well (§10.2).- A constraint (validation) holds only if it evaluates to
true. A non-Boolean result is a violation (§12.6.1 [1]), as arenullandinvalid.
A number leaves OCL as the feature's or operation's instance class: an EInt derivation returns an int, an EFloat one a float, an EBigDecimal one a BigDecimal (OclValues.toEcore, §13).
9.4 Operations of Dynamic Models
An .ecore loaded at runtime has no generated XxxImpl and therefore no eInvoke switch. That does not mean its operations cannot run — it means EMF asks a delegate instead, and the OCL engine is one. Three things have to come together:
// 1. the package declares which delegate URI it uses
EAnnotation packageAnnotation = EcoreFactory.eINSTANCE.createEAnnotation();
packageAnnotation.setSource("http://www.eclipse.org/emf/2002/Ecore");
packageAnnotation.getDetails().put("invocationDelegates",
"http://www.eclipse.org/fennec/m2x/ocl/1.0");
bookPackage.getEAnnotations().add(packageAnnotation);
// 2. the operation carries its body
EAnnotation body = EcoreFactory.eINSTANCE.createEAnnotation();
body.setSource("http://www.eclipse.org/fennec/m2x/ocl/1.0");
body.getDetails().put("body", "self.title.toUpper()");
shout.getEAnnotations().add(body);
// 3. the factory is registered
engine.installDelegates();
book.eInvoke(shout, ECollections.emptyEList()); // → "MOBY DICK"In an .ecore file the same three parts look like this — the package annotation is the one that is easy to forget:
<ecore:EPackage ...>
<eAnnotations source="http://www.eclipse.org/emf/2002/Ecore">
<details key="invocationDelegates" value="http://www.eclipse.org/fennec/m2x/ocl/1.0"/>
</eAnnotations>
<eClassifiers xsi:type="ecore:EClass" name="Book">
<eOperations name="shout" eType="ecore:EDataType http://www.eclipse.org/emf/2002/Ecore#//EString">
<eAnnotations source="http://www.eclipse.org/fennec/m2x/ocl/1.0">
<details key="body" value="self.title.toUpper()"/>
</eAnnotations>
</eOperations>
</eClassifiers>
</ecore:EPackage>Without a body annotation there is nothing to invoke. An operation declared in a dynamic .ecore and meant to be implemented in Java needs generated model code — with a dynamic package, evaluating such a call reports:
Operation 'displayLabel' has no implementation. Either the model is generated and
implements it, or the operation carries an OCL body annotation and the package
declares the delegate (see installDelegates())So the rule of thumb: generated model code → implement in Java; dynamic model → express the body in OCL. Mixing the two — a dynamic model with an operation that expects a Java implementation — is the one combination that cannot work.
9.5 OSGi
In OSGi, the delegate factories (OclInvocationDelegateFactory, OclSettingDelegateFactory, OclValidationDelegateFactory) are registered automatically as DS components with emf.osgi whiteboard properties. The emf.osgi delegate registry components discover them and populate the global EMF registries. No manual installDelegates() call needed.
Each delegate factory injects OclEngine via @Reference to access both the public API methods (parse, evaluate) and internal delegate methods (getDelegateOptions, evaluatePostcondition).
9.6 Legacy Eclipse OCL Pivot Namespace
The Fennec engine also serves the legacy Eclipse OCL Pivot delegate URI:
http://www.eclipse.org/emf/2002/Ecore/OCL/PivotThis lets models authored against Eclipse OCL — whose derived features, operation bodies, and constraints are annotated under the Pivot URI rather than the Fennec URI — evaluate with the Fennec engine without re-annotating the model. The Fennec OCL dialect is a superset of Eclipse OCL's, so no expression translation is involved; only the annotation source differs.
Both URIs are registered on every path:
- Standalone —
installDelegates()registers each factory under all served URIs;uninstallDelegates()removes them all. - OSGi — the emf.osgi delegate registry reads
emf.configuratorNameas a single value, so each delegate type ships a thin companion component for the legacy URI (OclLegacyPivotSettingDelegateFactory,OclLegacyPivotInvocationDelegateFactory,OclLegacyPivotValidationDelegateFactory). These subclass the Fennec factories and only differ in their whiteboard properties (emf.configuratorName= the Pivot URI,emf.name=fennec-ocl-pivot).
The factories resolve the OCL expression by consulting the served URIs in order (Fennec first, Pivot as fallback), so a feature annotated under either URI is picked up transparently. An example of a model that relies on this is org.eclipse.daanse.cwm.model.cwmx.eorm, whose derived features (e.g. Column.name = cwmColumn.name) are annotated under the legacy Pivot URI.
To serve additional legacy or third-party delegate URIs, add them to OclDelegateUtil.SERVED_URIS — standalone registration, expression lookup, and warm-up all iterate that list. For OSGi, also add a companion component for the new URI (one per delegate type), mirroring the OclLegacyPivot* factories.
Note: This changes only the set of annotation sources the engine answers to. The OCL expressions themselves are handled exactly as before.
10. Complete OCL Documents
Load standalone .ocl documents that define additional operations, properties, and constraints for existing Ecore models.
10.1 Parse a Document
import org.eclipse.fennec.m2x.model.ocl.Constraint;
List<Constraint> constraints = engine.parseDocument(
"""
context Person
inv nameNotEmpty: self.name.size() > 0
inv agePositive: self.age >= 0
context Person
def: fullName : String = self.firstName + ' ' + self.lastName
"""
);10.2 Load and Register
loadDocument() parses the document and puts its constraints into effect (#204):
engine.loadDocument(oclDocumentText);
// def: — new properties and operations for evaluation
Object fullName = engine.evaluate("self.fullName", OclContext.of(personObject));
// derive:/body: — visible to evaluation too: a navigation prefers the derivation over the
// stored value, an operation call is answered by the document's body
engine.evaluate("self.name", context); // the derive: applies
engine.evaluate("self.greet()", context); // the body: answers
// inv: — feeds the document validator, EMF-integrable via Diagnostician or directly
OclDocumentValidator validator = new OclDocumentValidator(engine);
validator.validate(personObject, diagnostics, null);
// pre:/post: — checked by the invocation delegate around the operation (see below)The boundary, on purpose: what a document registers reaches OCL evaluation and the document validator — not EMF's own eGet/EObjectValidator machinery. Those run off the delegate annotations in the .ecore (§9), and only off them: person.eGet(name) keeps answering the stored value however many derivations a document states, and init: — which only ever meant something at object creation, a setting-delegate affair — has no document-side effect at all. Registering a document mutates no shared EPackage; unregistering takes everything back out.
The one place a document reaches eInvoke is pre: and post: (#323): the invocation delegate checks a loaded document's conditions for an operation next to the annotation's own. That needs the operation to run through the delegate, which EMF does only for an operation with an OCL delegate — an annotation with body or post (§9.3). A Java-implemented operation of a generated model never passes the delegate, so a document's pre:/post: for it is not checked.
10.3 With ResourceSet
If the document references types from specific packages:
List<Constraint> constraints = engine.parseDocument(oclDocument, myResourceSet);This resolves against the resource set's own package registry, for that one call. To make every parse of an engine use a specific registry, give it to the parser instead (see §3.7).
10.3.1 Import declarations
A Complete OCL document may open with import, include or library. These are not part of OCL v2.4 — its Complete OCL concrete syntax is packageDeclarationCS ::= "package" pathNameCS contextDeclarationCS* "endpackage" (§12.12.1), and the specification does not use the word "import" at all. Fennec accepts them for compatibility with Eclipse OCL, so what each one does is a decision:
| Form | Effect |
|---|---|
import company::Company | Nothing, and nothing is missing. Type names resolve against the whole package registry the engine was given, so an import cannot add anything that is not already visible. |
import c : company | The alias is remembered — c::Person resolves for the rest of the document. |
library company::Utilities | Reported as an error. Additional operations reach the engine as OclOperationProviders, not by being named in a document, so there is nothing to resolve this to. |
The library form is reported rather than skipped on purpose: skipping it means the operations it was supposed to bring are missing, and the document then fails somewhere else with "unknown operation" — at a place that says nothing about the cause. See §11 for how to supply operations.
10.4 Locating Documents
A Complete OCL document is passed as text, so where it comes from is the caller's business. When that means a file or a bundle resource, UriHelper in the shared org.eclipse.fennec.m2x bundle produces the EMF URI the resource set expects:
import org.eclipse.fennec.m2x.utils.UriHelper;
URI uri = UriHelper.fromPath(Path.of("/srv/constraints/company.ocl"));
String document = new String(resourceSet.getURIConverter()
.createInputStream(uri).readAllBytes(), StandardCharsets.UTF_8);
engine.parseDocument(document, resourceSet);UriHelper.fromJavaUri and fromFile cover the other forms; toJavaUri converts back. Do not build a URI with URI.createURI(path.toString()) — a path is not a URI, and on Windows the drive letter becomes the scheme.
UriHelperships in the bundleorg.eclipse.fennec.m2x(packageorg.eclipse.fennec.m2x.utils) — the shared commons of this workspace, separate from the engine bundles:xml<dependency> <groupId>org.eclipse.fennec.m2x</groupId> <artifactId>org.eclipse.fennec.m2x</artifactId> <version>0.1.1-SNAPSHOT</version> </dependency>
10.5 OSGi: CompleteOclContribution
In OSGi, deploy Complete OCL documents as whiteboard services:
import org.eclipse.fennec.m2x.ocl.api.CompleteOclContribution;
@Component(service = CompleteOclContribution.class)
public class MyOclConstraints implements CompleteOclContribution {
@Override
public URI getDocumentUri() {
return URI.createURI("platform:/plugin/my.bundle/model/constraints.ocl");
}
@Override
public String getDocumentText() {
return """
context Person
inv nameNotEmpty: self.name.size() > 0
""";
}
}10.6 Contexts, Variables and Pre-State
Context declarations are checked. A context naming an operation or property the classifier does not have (context Person::noSuchOp() body: 1, or an operation of another arity) is a parse error; it used to be dropped on load (#324). Parameter types of an operation context are not yet compared with the EOperation's, only name and arity. Package paths resolve segment by segment through subpackages: context root::sub::Item and package root::sub … endpackage work (#325).
Named context variable (form [B]). In context p : Person inv: p.age > 0, p is self under a second name and evaluates like it.
Parameters and result are variables (§12.2.1). The parameters of an operation context are in scope in its body:, pre: and post:, result in post:, and the parameters of an operation def: in its body:
context Library::booksBy(author : Person) : Set(Book)
body: self.books->select(b | b.author = author)
post: result->forAll(b | b.author = author)def: conflicts are errors (§7.4.4). A def that repeats a property or an operation of the same arity of its class, or an earlier def, is a parse error; an attribute def no longer hides a real feature silently. Operation defs keep their arity, so overloads by arity dispatch correctly; their parameter types are not checked at the call.
@pre and oclIsNew(). Both read the state before an operation call and are therefore allowed in post: only (§7.5.14, §12.5.1, A.3.2.2). In a document, either one in inv:, def:, pre:, body:, init: or derive: is a parse error. An ad-hoc expression (engine.parse) cannot know whether it will be evaluated as a postcondition, so there:
| Expression without a pre-state | Result |
|---|---|
self.age@pre | the current value, with a WARNING |
self.oclIsNew() | invalid, with an ERROR |
null.oclIsNew() | false (§11.3.2) |
Inside a postcondition, a property @pre is captured per object and feature before the body runs and read on the object its source denotes after the call (A.3.2.2 (vii)), so self.employees.name@pre gives each employee's previous name. An object the operation created has no pre-state: obj.feature@pre on it is invalid with a WARNING. Many-valued pre-values are copies. Both stockprice@pre() (the spec's position, §7.5.14) and stockprice()@pre are accepted for operations; an operation @pre is evaluated once before the body, also when it sits inside an iterator body.
Not yet supported: the message of inv name('message'):. The parenthesised expression is parsed and then dropped; the validator reports the constraint name only. Evaluating it as the violation message, as Eclipse does, needs a metamodel change (#324).
11. Custom Operations
Extend the OCL standard library with custom operations.
11.1 Define an Operation
Use OclOperation.of() for simple no-arg operations or the full constructor for operations with parameters:
import org.eclipse.fennec.m2x.ocl.api.OclOperation;
// Simple no-arg operation using factory method
OclOperation toUpperCase = OclOperation.of(
"toUpperCase", // operation name
stringOclType, // owner type (OclType instance for String)
stringOclType, // return type
(self, args) -> ((String) self).toUpperCase()
);11.2 Register via Provider
public class MyOperations implements OclOperationProvider {
@Override
public List<OclOperation> getOperations() {
return List.of(
new OclOperation(
"trimToNull",
stringType,
List.of(), // no parameters
stringType,
(self, args) -> {
String s = ((String) self).trim();
return s.isEmpty() ? null : s;
}
)
);
}
}
// Standalone registration via configuration (D29: no runtime registration)
OclConfiguration config = OclConfiguration.builder(new OclParserSupport())
.operationProviders(List.of(new MyOperations()))
.customOperationsEnabled(true) // D29: required opt-in
.build();
OclEngine engine = OclEngines.create(config);11.3 OSGi Registration
In OSGi, OclEngineComponent binds every registered OclOperationProvider. Several can coexist — the MOFM2T standard library, a set of domain operations, the operations a generator brings — and they have no reason to exclude each other. The OCL specification has no notion of one source of additional operations either: §11.8.1 makes the standard library extensible, and a document may carry any number of def: declarations.
Register a provider as a DS component and it is bound:
@Component(service = OclOperationProvider.class,
property = "provider.name=myProvider")
public class MyOperations implements OclOperationProvider {
@Override
public List<OclOperation> getOperations() {
return List.of(/* ... */);
}
}Binding a provider does not make its operations usable. The gate does that, and it is shut by default (D29, see §14 Security):
{
"DefaultOclEngine": {
"ocl.customOperationsEnabled": true
}
}That is the whole security decision: with the gate shut, no config-registered operation resolves, however many providers a runtime happens to hold. With it open, all of them do.
A deployment that wants exactly one provider says so with a target filter — the reference is named operationProvider:
{
"DefaultOclEngine": {
"ocl.customOperationsEnabled": true,
"operationProvider.target": "(provider.name=myProvider)"
}
}11.3.1 Which operation answers
Among the registered operations, the one that answers a call is chosen by, in order:
- How many arguments the call passes. An operation declaring that many parameters is preferred, so a no-argument and a one-argument operation of the same name do not hide each other. If none matches that way, the count is ignored — several operations declare no parameter types at all and are still called with arguments.
- How specific the receiver type is. An exact primitive match beats a widening one (Integer → Real), which beats a catch-all (
OclAny/AnyType). - Registration order. Among equally applicable candidates the first registered one answers — whichever provider it came from. Two providers offering the same operation is a configuration mistake, not a crash, and this is what makes the outcome predictable rather than dependent on the order services happened to bind.
The parameter types are not checked, only their number.
11.4 Using OclConfiguration
OclConfiguration config = OclConfiguration.builder(parser)
.addOperationProvider(new MyOperations())
.addOperationProvider(new MoreOperations())
.build();11.5 Tutorial: compiled OCL that follows the model version
A model states its own constraints. Until something asks for one, each is a string in an annotation, so the first validation of a model pays for parsing and the second does not — and an LRU inside the engine cannot help with the part that actually matters: it knows how many entries it may keep, not what a model version is. An expression compiled against a model long retired survives because it was used recently; one belonging to a live model is dropped because it was not.
This wires up the other arrangement: a model's OCL is compiled when the model registers, and filed under that model version. Four configurations, no code.
Everything below is what OclMetadataCacheOSGiTest and OclMetadataBridgeOSGiTest run, so the tutorial and the tests cannot drift apart.
What the pieces do
| Piece | Role |
|---|---|
EObjectRegistry | holds the compiled expressions, each anchored with emf.nsURI and emf.fingerprint |
OclMetadataExpressionCache | the OclExpressionCache service over that registry, with an in-memory LRU for what has no model version to anchor to, for parses with declared variables and for parse failures — those never go into the registry |
OclConstraintPrecompiler | binds EPackage services: when a model arrives, compiles the OCL it declares; when it goes, drops it |
EObjectRegistryMetadataBridge | optional — puts registry content onto the metadata tree of the model version it names |
OclExpressionAnchorResolver | tells that bridge which class a compiled expression belongs to: its context type |
The configuration
{
":configurator:resource-version": 1,
"EObjectRegistry~ocl": {
"name": "ocl-compiled",
"initialProvider.target": "(emf.eobject.provider.name=ocl-empty-provider)"
},
"OclMetadataExpressionCache": {
"writer.target": "(emf.eobject.registry.name=ocl-compiled)",
"ocl.metadata.lruSize": 256,
"ocl.metadata.lruFailureSize": 256
},
"OclConstraintPrecompiler": {
"cache.target": "(cache.name=metadata)",
"versioned.target": "(cache.name=metadata)"
},
"EObjectRegistryMetadataBridge~ocl": {
"emf.eobject.registry.name": "ocl-compiled",
"aspect.type.id": "ocl.compiled",
"anchorResolver.target": "(anchor.resolver.name=ocl)"
},
"DefaultOclEngine": {
"expressionCache.target": "(cache.name=metadata)"
}
}The last one is what makes an engine use it. Without it everything above still runs and simply holds nothing anyone reads — nothing here displaces the default cache by existing.
The initial provider
A registry publishes only once its initial provider has finished loading, which is what keeps a consumer from ever seeing a half-filled one. Compiled OCL does not come from a content source — it is written as expressions are compiled — so the provider here loads nothing, and says so:
@Component(service = EObjectProvider.class,
property = "emf.eobject.provider.name=ocl-empty-provider")
public class EmptyEObjectProvider implements EObjectProvider {
@Override
public CompletableFuture<Void> load(EObjectRegistryWriter writer) {
return CompletableFuture.completedFuture(null);
}
}If the registry should also carry authored expression libraries from files, use FileEObjectProvider instead and the compiled ones join them.
The bridge is what makes compiled OCL findable from a class: getClassAspect(book, "ocl.compiled"). It needs the anchor resolver in its target, because by default a bridge anchors an entry at the eClass() of its content — for a compiled expression that is a class of the OCL metamodel, where nobody looks. The resolver anchors it at the context type instead.
The trigger is the model
The precompiler reacts to the arrival of an EPackage, not to a metadata handler callback. That is not a detail: a version's metadata tree is published only after every handler has run, and handlers run in registration order — so content derived inside a handler and pushed from there lands on the tree being built only if the bridge happened to be wired first. Reacting to the model itself has no such dependency.
What you get
- A model's invariants, derivations and operation bodies are compiled once, when it arrives.
- Each is keyed by the derived fingerprint of the version it was compiled against, so two versions of one nsURI never answer for each other — the failure that is otherwise silent, since feature access falls back to resolving by name while a type reference does not.
- Each carries the plain model fingerprint as
emf.fingerprint, which is what the bridge compares against a tree before placing anything on it. So an expression compiled against v1 appears on v1's tree and on no other, even while v2 of the same nsURI is live. - A version that goes takes its compiled OCL with it.
- A model that registers later than its compiled OCL still gets it, through the handler replay.
- Only executable ASTs go into the registry, because others read it. A text that does not parse, and a parse with declared variables (whose AST has free variables), stay in memory under the derived fingerprint; failures are bounded by
ocl.metadata.lruFailureSize.releasedoes not reach them — they are bounded and age out of the LRU on their own.
Limits worth knowing
- Changing a model after registration is out of contract. A fingerprint describes the package as it was; nothing here re-checks a model that changed underneath.
- The registry is the read face, not the aspect. A class carries at most one aspect per type id, and an OCL context type has as many expressions as someone writes — so with several expressions on one class, the aspect shows the last one placed. Ask the registry for all of them.
- The bridge needs the entry's fingerprint to be honoured, which requires
org.eclipse.fennec.emf.osgi.eobject.registry.metadata1.1.0 or newer (emf.osgi#81). Against an older version, a compiled expression is copied onto every live version's tree, where its references still point into the package it was built from. - A constraint that does not parse is logged and skipped. Refusing the model would turn a validation error into a model that cannot be loaded at all; the delegate still reports it when someone asks.
11.9 OCL and Compiled Units
Since #209 a Complete OCL document is a unit of its own: compiled once, stored, prepared — and then registered, because a document is installed into an engine, never executed. The runtime that registers needs no parser for the document.
// build side
CompiledUnit unit = engine.compileDocument("company.rules", """
package company
context Person
def : isAdult : Boolean = self.age >= 18
endpackage
""");
UnitKey key = store.put(unit); // language "ocl", manifest nature = LIBRARY
// runtime side — no document text, no document parse
PreparedContext prepared = new UnitPreparer(store, List.of(runtime.unitBinder()))
.registerPackage(company).prepare(key);
runtime.registerCompleteOclDocument(prepared, "company.rules");
runtime.evaluate("self.isAdult", OclContext.of(person)); // trueregisterCompleteOclDocument(CompleteOclDocument) is the AST-taking sibling of the text-taking contribution API, with unregisterCompleteOclDocument taking it back out. A document has no unit imports, so there is no OclStoreUnitResolver and nothing to bind — the OclUnitBinder only holds the root to its type. The manifest's nature = LIBRARY says the rest: a store, a registry entry or an atlas answers "installable, not startable" without loading the AST.
Ad-hoc expressions stay what they were — an expression is not something you import by name. And expressions keep living inside the units of the other three languages:
- The
m2x1unit fingerprint walks the OCL expressions of a transformation or template, so what it says about a QVT-O mapping includes the OCL it contains. Standard-library types enter as URIs, and a metamodel reference asnsURI#//Classifier/feature— where a metamodel was loaded from does not change the value. - A navigation whose owner type is unknown at parse time leaves a marked placeholder, which the evaluator resolves by name at runtime. A caller that hands over a whole document can ask for the typo instead:
new OclParserSupport(registry).strictPropertyResolution(true).parse("self.nam", personClass);
// OclParseException: Unknown property (Person::nam)See the Compiled Units Guide §11.
12. Thread Safety
| Component | Thread-Safe? | Notes |
|---|---|---|
OclEngine | Yes | Stateless evaluation; shared parser and cache are synchronized |
OclLruExpressionCache | Yes | Internally synchronized with atomic counters |
OclContext | Yes | Immutable record |
OclEvaluationOptions | Yes | Immutable record |
OclResult | Yes | Immutable record |
OclExpression (parsed AST) | Yes | Immutable after parsing; safe to share across threads |
OclParserSupport | Yes | Thread-safe parser implementation |
installDelegates() | No | Modifies global EMF registries — call once at startup |
Recommended pattern for multi-threaded use:
// Create once, share across threads
OclEngine engine = OclEngines.create(config);
// Each thread creates its own context
ExecutorService pool = Executors.newFixedThreadPool(8);
for (EObject obj : objects) {
pool.submit(() -> {
OclContext ctx = OclContext.of(obj);
return engine.evaluate("self.name", ctx);
});
}13. Value Type Mapping
See also Section 6.4 for the exact runtime classes used for OCL collections and the
useEMFTypesoption that switches top-levelCollection/Mapreturns toEList/EMap.
Inside an evaluation, OCL's numbers are the mathematical concepts of OCL v2.4 §11.4.1/§11.4.2, not 64-bit machine numbers (#322). Where a value leaves OCL it takes the target's Java type.
| OCL Type | Inside the evaluation (evaluateWithDiagnostics) | Returned by evaluate() |
|---|---|---|
Boolean | java.lang.Boolean | same |
Integer | Long; BigInteger where a result leaves the 64-bit range | Integer when it fits 32 bits, else Long / BigInteger |
Real | java.math.BigDecimal | Double |
String | java.lang.String | same |
UnlimitedNatural | a finite value is an Integer; * is an engine singleton whose toString() is * | same |
OclVoid | null | same |
OclInvalid | OclInvalid.INSTANCE | same |
Set(T) | OclSet (OCL equality, insertion order) | same |
OrderedSet(T) | OclOrderedSet | same |
Bag(T) | OclBag | same |
Sequence(T) | java.util.List (ArrayList) | same |
Tuple | java.util.Map<String, Object> (LinkedHashMap) | same |
| Any EClass | org.eclipse.emf.ecore.EObject | same |
| Any EEnum | the value the model holds: EEnumLiteral (dynamic) or the generated enum constant | same |
| Any other EDataType | its Java instance class | same |
evaluate() narrows recursively, collection elements included; evaluateWithDiagnostics() keeps the exact values. useEMFTypes (§6.4) wraps the top-level collection on top of that.
Integer arithmetic continues in BigInteger where a long would overflow and comes back to Long when the result fits: 9223372036854775807 + 1 is 9223372036854775808, not a negative number. toInteger() reads values beyond 64 bits. An Integer literal is limited to 64 bits; a longer one is the parse error Integer literal out of range (64 bit).
Real is a BigDecimal: 0.1 + 0.2 = 0.3 is true. Division keeps at least 32 fraction digits (HALF_EVEN, as Eclipse OCL), so (1/3).toString() is 0.33333333333333333333333333333333. A Double or Float read from the model is taken by its shortest decimal (the 0.1 a model stores is 0.1). NaN and the infinities are no Real values: an operation that meets one is invalid, and toReal() accepts decimal notation only ('NaN', '1f', '0x1p3' are invalid). A Real literal is read through a double, so it is exact up to 15 significant digits. toString() writes plain decimal notation with at least one fraction digit: 3.0, 100000000000000000000.0.
Equality, hashing and ordering compare the mathematical value: 1 = 1.0, -0.0 = 0.0, and 9007199254740993 <> 9007199254740992 (#317). Collections use the same equality, so Set{1, 1.0} has one element and Set{1}->intersection(Bag{1.0}) is Set{1}. Objects are equal only if they are the same object (§11.3.1, #318); two objects with equal attribute values are two values.
Where values leave OCL — a derived feature or an operation answered by the EMF delegates (§9.3), a QVT-O assignment, a QVT-R enforcement — they are converted to the target feature's instance class with OclValues.toEcore(value, type) (ocl.api): an EInt gets an int, an EFloat a float, an EBigDecimal a BigDecimal. Code that writes OCL results into a model itself can use the same method; OclValues.toText(value) gives the text OCL's toString() writes (M2T uses it for generated text).
Navigations of a many-valued feature yield the collection kind of the feature (§7.5.3, §7.5.11, #319): unique and ordered → OrderedSet, unique → Set, ordered → Sequence, neither → Bag. The collection is a copy in the model's order, so an expression neither writes into the model through it nor sees the model change underneath it. Inside an evaluation it implements OclFeatureValue (ocl.api), which names the owner() and feature() it came from; the guard that refuses mutating operations on model values uses it, and so does QVT-O's feature.unlink(item), which changes the live feature. evaluate() returns plain copies.
Strings compare (<, <=, >, >=, sortedBy, max, min) by the Unicode Collation Algorithm in the locale oclLocale names (§11.1, §11.5.3, #329), using java.text.Collator; strings equal by collation fall back to code-point order so the order stays total. 'B' < 'a' is false. The default locale is en_us; another one is set with let oclLocale : String = 'de_DE' in …. equalsIgnoreCase is self.toUpperCase() = s.toUpperCase() in that locale, so 'ß'.equalsIgnoreCase('SS') is true. size, at, substring, indexOf, lastIndexOf and characters count Unicode code points, not UTF-16 units: '😀'.size() is 1.
14. Language Semantics
This section collects the places where OCL's rules are easy to get wrong, and the few where Fennec deliberately chose one reading. The decisions behind them are listed in the architecture document.
14.1 Boolean Logic with null and invalid
and, or, xor, implies and not follow OCL v2.4 §11.5.4: the determinate cases first, then invalid if an operand is invalid, then null if an operand is null (#313).
| Expression | Result |
|---|---|
false and null, null and false | false |
true or null, null implies true, false implies null | true |
false and invalid, true or invalid | false, true |
true and null, null or false, null xor true, not null | null |
null and invalid | invalid |
1 and true | invalid, with an ERROR |
An if whose condition is null or not a Boolean is invalid with an ERROR. So are select, reject, forAll, exists, any and one with a body that is not Boolean (#315).
14.2 Iterators and invalid
A collection never holds invalid (§11.7.1 [1], #316): Set{1, invalid, 3} is invalid, and so are collect, collectNested and closure when a body is invalid. A null body stays an element. collect flattens recursively: Sequence{1}->collect(x | Sequence{Sequence{x, 2}}) is Sequence{1, 2}.
anyisinvalidfor aninvalidornullbody. Without a match it isnullby default andinvalidunderspecStrict(§6.5).one,isUniquestop at the first decisive element by default; underspecStrictthey evaluate every body.isUniquecompares with OCL equality, soSequence{1, 1.0}->isUnique(x | x)isfalse.sortedBywith anullkey isinvalid.
14.3 Implicit Sources, Dot and Arrow
A dot on a collection is an implicit collect (§7.6.2, #320) — for properties and for operations, OclAny operations included:
Set{'ab', 'c'}.size() -- Bag{2, 1}: size() of each element
Set{'ab', 'c'}->size() -- 2: size() of the set
self.employees.name.toUpperCase() -- the names in upper case
Set{1}.oclType() -- Bag{Integer}
Set{1}->oclType() -- the type of the set
Set{1}->oclAsType(Set(Real)) -- the cast of the setOperations on the collection itself — including oclAsType, oclIsKindOf, oclType — need ->. This matches Eclipse Pivot. The implicit collect is built where the parser knows the source is a collection; since every expression is typed (§14.6), that is the case after a navigation, a collection literal and every operation or iterator answering a collection — invalid.oclAsSet().oclIsInvalid() collects, invalid.oclAsSet()->oclIsInvalid() asks the set. A source whose type is not known (a variable bound only at evaluation) is not rewritten; write ->collect(…) there.
Implicit sources. A name without a source is looked up in the implicit sources, innermost first, then on self (§7.8, §9.4.1 [10]). Implicit sources are the implicit iterators of iterator calls and named iterator variables (§9.3.25 [A], §9.3.26):
self.employees->collect(p | name) -- each person's name, not the company's
self.employees->forAll(Set{1}->forAll(age > 0)) -- age of the outer implicit iterator
Set{'ab', 'c'}->collect(size()) -- Bag{2, 1}
self.items->forAll(oclIsKindOf(sub::Item)) -- tests each item, not selfNamed iterator variables as implicit sources deliberately differ from Eclipse, which reads name above as self.name. Where you mean self, write self. Where a variable of the caller is meant, declare it at parse time (§4.5).
An unqualified operation call goes to the innermost implicit source whose type has an operation of that name; OclAny operations go to the innermost one; otherwise to self. iterate may omit its iterator variable, which is then implicit: Sequence{1,2,3}->iterate(acc : Integer = 0 | acc + 1).
14.4 Variables and Declarations
- Shadowing is an error (§9.3.41 [1]): a
let, iterator or accumulator variable may not have the name of a variable in scope.let x = 1 in let x = 2 in xis the parse errorVariable name already used: 'x'. Eclipse allows it; Fennec follows the spec (#326). - An initial value must conform to the declared type:
let x : Integer = 'abc' in xandlet x : Integer = 1 / 0 in x(/is aReal) are parse errors (§14.6). Collection{1, 2}is a parse error —Collectionis abstract;Collection(Integer)as a type stays valid. Duplicate tuple part names (Tuple{a = 1, a = 2}) are a parse error.- An untyped
letor accumulator is accepted, with the type inferred; underspecStrictit is an ERROR at evaluation (§6.5).
14.5 Operation Calls
- Arguments are checked, not coerced (§7.4.5, #314):
1 + 'motorcycle','a' + 1,23 * falseand1.mod(2.5)are parse errors (§14.6). Where an argument's type is only known at evaluation, the same call isinvalidwith an ERROR. A model operation called with an arity none of its overloads has, or with an argument that does not conform, is a parse error. - An unknown property is
invalidwith the ERRORUnknown property 'x' on Person; it used to throw out ofevaluate()(#327). 5.oclAsType(Real)is5.0.5.oclIsKindOf(UnlimitedNatural)isfalse(UnlimitedNatural ⊂ Integer, not the reverse);oclAsType(UnlimitedNatural)accepts values>= 0.oclIsKindOf/oclIsTypeOfwith a collection type check kind and element type:Set{1}->oclIsKindOf(Bag(Integer))isfalse(#321).invalid.toString()isinvalid.oclIsInStatereports that Ecore models have no state machines.allInstances()of an enumeration,BooleanandOclVoidneeds no model extent;OclInvalid.allInstances()isinvalid(#328).Sequence{'a', 'b'}->sum()is'ab'(Eclipse);max/minover Booleans areinvalid; with*in a collection,maxis*andminthe smallest finite value.- Complete OCL keywords (
body,context,init,pre, …) are ordinary names in expressions:self.bodyparses. Restricted words are names after::(sub::Set). Escaped names take the escapes of a string literal (_'it\'s'); the doubled quote_'it''s'stays accepted. - Package paths resolve through subpackages:
root::sub::Item. The qualifier ofself.Type::featuremust name a classifier the source conforms to, andType::op()without a source is a call onselfwhenTypeis a supertype of the context — Ecore has no static operations (#325).
14.6 Static Type Checking (#378)
Every expression has a static type, and one whose types are known and do not fit is a parse error (OCL v2.4 §8.3.7) — reported with its line and column, like an unknown name:
1 + 'a' -- No operation Integer::+(String); expected [Integer]
'abc'.substring() -- Operation String::substring does not take 0 argument(s)
if 1 then 'a' else 'b' endif -- The condition of an if is a Integer, not a Boolean
self.employees->select(e | e.age) -- The body of select is a Integer, not a Boolean
let m : Sequence(Person) = self.employees->select(e | e.isMarried) in m
-- … declared as Sequence(Person) but initialized with a OrderedSet(Person)
self.transfer('x', 1.0) -- Argument 1 of transfer is a String, but the parameter 'target' is a PersonWhat conforms passes: Integer to Real, UnlimitedNatural to Integer, a subclass to its class, null to every type, any type to OclAny, Set(T) to Collection(T); an Ecore data type is the OCL type it stands for (EInt is Integer).
Unknown types are never an error. A variable bound only at evaluation (§4.5), a value of a dynamic model, a blackbox result: the parser does not know their types and checks nothing about them. The evaluator keeps its own checks for exactly those values — 1 + v with v = 'a' is invalid with an ERROR, as before.
A def: of a Complete OCL document declares its parameter and result types for the whole document: a call of a defined operation has the declared type and its arguments are checked, also where the call stands above the def.
OclEngine.validate(expression, contextType) applies the same rules to an expression that did not come from the parser — built by hand, loaded from a stored unit — and returns one ERROR diagnostic per problem. An expression parse returned is valid.
15. Security Hardening
When evaluating OCL expressions from untrusted sources (user input, external Complete OCL documents, model annotations from unknown models), configure conservative resource limits to prevent denial-of-service attacks.
Configurable Limits
| Field | Type | Default | Protects against |
|---|---|---|---|
maxDepth | int | 1,000 | Stack overflow via deeply nested expressions |
maxCollectionSize | int | 1,000,000 | Range explosion, product explosion, allInstances, collection growth (union, including, collect, …) |
maxClosureIterations | int | 100,000 | Unbounded closure traversal |
maxRegexLength | int | 1,000 | ReDoS via crafted regex patterns |
maxStringLength | int | 10,000,000 | String growth (concat, replaceAll, joinfields, format, …) |
timeout | Duration | none | Runaway evaluation (deadline-based enforcement) |
All limits produce OclInvalid with a diagnostic error when exceeded.
Sandboxed Evaluation
import java.time.Duration;
// Configure conservative limits for untrusted input
OclEvaluationOptions sandboxed = OclEvaluationOptions.strict()
.withMaxDepth(100)
.withMaxCollectionSize(10_000)
.withMaxClosureIterations(1_000)
.withMaxRegexLength(200)
.withTimeout(Duration.ofSeconds(5));
// Evaluate with sandboxed options
OclResult result = engine.evaluateWithDiagnostics(expr, context, sandboxed);
// Check for limit violations
if (result.value() == OclInvalid.INSTANCE) {
result.diagnostics().forEach(d ->
logger.warn("OCL limit violation: {}", d.getMessage()));
}How Limits Are Enforced
- Recursion depth — checked in
eval()before every expression evaluation - Collection size — checked before creating ranges (
Sequence{1..n}), products, and allInstances results - Closure iterations — checked in the worklist loop of
closure()iterator - Regex length — checked before
Pattern.compile()inmatches(),replaceAll(),replaceFirst() - Timeout — deadline-based
System.nanoTime()check at everyeval()call and every closure iteration (~15ns overhead)
Trust Boundaries
| Source | Trust Level | Recommendation |
|---|---|---|
| Own model annotations | Trusted | Default strict() options |
| External Complete OCL documents | Semi-trusted | strict() with tightened limits |
| User input (console, LSP) | Untrusted | Tightened limits + timeout |
| Custom operation providers | Controlled (D29) | Disabled by default; requires customOperationsEnabled on both Config and Options |
| EMF delegates | Explicit opt-in | Configure delegateOptions before installDelegates() |
OSGi: Engine-Wide Security Defaults via ConfigAdmin
In an OSGi environment, configure conservative defaults centrally via ConfigAdmin instead of relying on each caller to pass sandboxed options:
{
":configurator:resource-version": 1,
"DefaultOclEngine": {
"ocl.maxDepth": 100,
"ocl.maxCollectionSize": 10000,
"ocl.maxClosureIterations": 1000,
"ocl.maxRegexLength": 200,
"ocl.maxStringLength": 100000,
"ocl.timeout": 5000,
"ocl.nullHandling": "STRICT",
"ocl.errorRecovery": "FAIL_FAST"
}
}These limits apply to all evaluate(expression, context) calls (without explicit options), including EMF delegate evaluations. Callers can still pass explicit OclEvaluationOptions to override for individual evaluations.
EMF Delegate Security
// Configure delegate options BEFORE installing delegates
engine.setDelegateOptions(sandboxed);
engine.installDelegates();After installDelegates(), any model loaded in the EPackage.Registry can trigger OCL evaluation via EAnnotations. Only install delegates if you trust the models being loaded.
Custom Operation Security (D29)
Custom operations are disabled by default. To enable them, set customOperationsEnabled on both the engine configuration and the per-evaluation options (AND-linked):
// Engine configuration: enable custom ops engine-wide
OclConfiguration config = OclConfiguration.builder(parserSupport)
.addOperationProvider(myProvider)
.customOperationsEnabled(true)
.build();
// Per-evaluation: enable for this specific evaluation
OclEvaluationOptions opts = OclEvaluationOptions.strict()
.withCustomOperationsEnabled(true);If either flag is false, custom operations are not dispatched. This allows sandboxing individual evaluations (e.g. user input) even when the engine has providers registered.
