How Stalactite ORM Implements Its Fluent DSL
Target the goal of getting expressive code: leveraging Java Proxy technology to implement an intuitive and fluent DSL to enhance the user-developer experience.
Join the DZone community and get the full member experience.
Join For Free“DX”, aka Developer Experience
One of the goals of Stalactite is to make developers aware of the impact of the mapping of their entities onto the database, and, as a consequence, onto performances. To fulfill this goal, the developer's experience, as a user of the Mapping API, is key to helping him express his intention.
The idea is to guide the user-developer in the choices he can make while he describes its persistence. As you may already know, Stalactite doesn’t use annotation or XML files for that. It proposes a fluent API that constrains user choices according to the context. To clarify: available methods after a call to mapOneToOne(..)
are not the same as the ones after mapOneToMany(..)
. This capacity can be done in different ways. Stalactite chose to leverage Java proxies for it and combines it with the multiple-inheritance capability of interfaces.
Contextualized Options
Let’s start with a simple goal: we want to help a developer express the option of aliasing a column in a request, and also the option of casting it. Usually, we would find something like:
select()
.add("id", new ColumnOptions().as("countryId").cast("int"))
.from(..);
It would be smarter to have this:
select()
.add("id").as("countryId").cast("int")
.add("name").as("countryName")
.add("population").cast("long")
.from(..);
As the former is kind of trivial to implement and many examples can be found on the Internet, in particular Spring with its Security DSL or its MockMVC DSL, the latter is trickier because we have to locally mix the main API (select().from(..).where(..)
) with some local one (as(..).cast(..)
) on the return type of the add(..)
method. This means that if the main API is brought by the FluentSelect
interface, and the column options by the FluentColumnOptions
interface, the method add(String)
must return a third one that inherits from both: the FluentSelectColumnOptions
interface.
/** The main API */
interface FluentSelect {
FluentSelect add(String columnName);
FluentFrom from(String tableName);
}
/** The column options API */
interface FluentColumnOptions {
FluentColumnOptions as(String alias);
FluentColumnOptions cast(String alias);
}
/** The main API with column options as an argument to make it more fluent */
interface EnhancedFluentSelect extends FluentSelect {
/** The returned type of this method is overwritten to return and enhanced version */
FluentSelectColumnOptions add(String columnName);
FluentFrom from(String tableName);
}
/** The mashup between main API and column options API */
interface FluentSelectColumnOptions extends
EnhancedFluentSelect, // we inherit from it to be capable of coming back to from(..) or chain with another add(..)
FluentColumnOptions
{
/** we overwrite return types to make it capable of chaining with itself */
FluentSelectColumnOptions as(String alias);
FluentSelectColumnOptions cast(String alias);
}
This can be done with standard Java code but brings some boilerplate code which is cumbersome to maintain. An elegant way to address it is to create a “method dispatcher” that will redirect main methods to the object that supports the main API, and redirect the options ones to the object that supports the options.
Creating a Method Dispatcher: Java Proxy
Luckily, Java Proxy API helps in being aware of method invocations on an object. As a reminder, a Proxy can be created as such:
Proxy.newProxyInstance(classLoader, interfaces, methodHandler)
- It returns an instance (a "magic" one, thanks to the JVM) that can be typed in one of the interfaces passed as a parameter at any time (the Proxy implements all given interfaces).
- All methods of all interfaces are intercepted by
InvocationHandler.invoke(..)
(evenequals
/hashCode
/toString !
).
So, our goal can be fulfilled if we’re capable of returning a Proxy that implements our interfaces (or the mashup one) and creates an InvocationHandler
that propagates calls of the main API to the “main object” and calls the options to the “options object." Since InvocationHandler.invoke(..)
gets the invoked Method
as an argument, we can easily check if it belongs to one or another of the aforementioned interfaces. This gives the following naïve implementation for our example :
public static void main(String[] args) {
String mySelect = MyFluentQueryAPI.newQuery()
.select("a").as("A").cast("char")
.add("b").as("B").cast("int")
.toString();
System.out.println(mySelect); // will print "[a as A cast char, b as B cast int]", see createFluentSelect() on case "toString"
}
interface Select {
Select add(String s);
Select select(String s);
}
interface ColumnOptions {
ColumnOptions as(String s);
ColumnOptions cast(String s);
}
interface FluentSelect extends Select, ColumnOptions {
FluentSelect as(String s);
FluentSelect cast(String s);
FluentSelect add(String s);
FluentSelect select(String s);
}
public static class MyFluentQueryAPI {
public static FluentSelect newQuery() {
return new MyFluentQueryAPI().createFluentSelect();
}
private final SelectSupport selectSupport = new SelectSupport();
public FluentSelect createFluentSelect() {
return (FluentSelect) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[] { FluentSelect.class }, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
switch (method.getName()) {
case "as":
case "cast":
// we look for "as" or "cast" method on ColumnOptions class
Method optionMethod = ColumnOptions.class.getMethod(method.getName(), method.getParameterTypes());
// we apply the "as" or "cast" call on element being created
optionMethod.invoke(selectSupport.getCurrentElement(), args);
break;
case "add":
case "select":
// we look for "add" or "select" method on Select class
Method selectMethod = Select.class.getMethod(method.getName(), method.getParameterTypes());
// we apply the "add" or "select" call on the final result (select instance)
selectMethod.invoke(selectSupport, args);
break;
case "toString":
return selectSupport.getElements().toString();
}
return proxy;
}
});
}
}
/** Basic implementation of Select */
static class SelectSupport implements Select {
private final List<SelectedElement> elements = new ArrayList<>();
private SelectedElement currentElement;
@Override
public Select add(String s) {
this.currentElement = new SelectedElement(s);
this.elements.add(currentElement);
return this;
}
@Override
public Select select(String s) {
return add(s);
}
public SelectedElement getCurrentElement() {
return currentElement;
}
public List<SelectedElement> getElements() {
return elements;
}
}
/** Basic representation of an element of the select clause, implements ColumnOptions */
static class SelectedElement implements ColumnOptions {
private String clause;
public SelectedElement(String clause) {
this.clause = clause;
}
@Override
public ColumnOptions as(String s) {
clause += " as " + s;
return this;
}
@Override
public ColumnOptions cast(String s) {
clause += " cast " + s;
return this;
}
@Override
public String toString() {
return clause;
}
}
This proof of concept needs to take inheritance into account, as well as argument type compatibility, and even more to make it a robust solution. Stalactite invested that time and created the MethodDispatcher class in the standalone library named Reflection, the final DX for an SQL Query definition is available here, and its usage is here. Stalactite DSL for persistence mapping definition is even more complex; that’s the caveat of all this: creating all the composite interfaces and redirecting correctly all the method calls is a bit complex. That’s why, for the last stage of the rocket, the MethodReferenceDispatcher has been created: it lets one redirect a method reference to some lambda expression to avoid extra code for small interfaces. Its usage can be seen here.
Conclusion
Implementing a naïve DSL can be straightforward but doesn’t really guide the user-developer. On the other hand, implementing a robust DSL can be cumbersome, Stalactite helped itself by creating an engine for it. While it's not easy to master, it really helps to meet the user-developer experience. Since the engine is the library Reflection, which is out of Stalactite, it can be used in other projects.
Opinions expressed by DZone contributors are their own.
Comments