Unified Query DSL: One Language for MongoDB, Elasticsearch, and Druid
Build a unified query language that translates to MongoDB, Elasticsearch, and Druid native queries. Master the interpreter pattern for cross-database query abstraction in Java.
Table of Contents
Introduction
Modern applications often interact with multiple database systems - MongoDB for document storage, Elasticsearch for full-text search, and Apache Druid for real-time analytics. Each database has its own query syntax, forcing developers to learn and maintain different query languages.
What if you could write queries in a single, unified syntax and have them automatically translated to the native query language of each database? This project implements a Query DSL (Domain Specific Language) that does exactly that - providing a consistent query interface across heterogeneous data stores.
Key Insight: By abstracting query construction behind a unified DSL, you reduce cognitive load, improve code maintainability, and enable seamless database switching without application changes.
The Problem: Query Language Fragmentation
Medallion Data Architecture
Comparing Query Syntaxes
| Operation | MongoDB | Elasticsearch | Druid SQL |
|---|---|---|---|
| Equality | {"field": "value"} | {"term": {"field": "value"}} | WHERE field = 'value' |
| Greater Than | {"field": {"$gt": 10}} | {"range": {"field": {"gt": 10}}} | WHERE field > 10 |
| AND | {"$and": [...]} | {"bool": {"must": [...]}} | WHERE ... AND ... |
| OR | {"$or": [...]} | {"bool": {"should": [...]}} | WHERE ... OR ... |
| IN | {"field": {"$in": [...]}} | {"terms": {"field": [...]}} | WHERE field IN (...) |
The Unified DSL Solution
Input: "firstName==gaurav"
↓ DSL Parser
MongoDB: {"firstName": "gaurav"}
Elastic: {"term": {"firstName": "gaurav"}}
Druid: SELECT * FROM table WHERE firstName = 'gaurav'
Architecture Overview
Medallion Data Architecture
Core Components
┌─────────────────────────────────────────────────────────────────────┐
│ UNIFIED QUERY DSL ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ DSL Input String │ │
│ │ "age=gt=25;status==active,role==admin" │ │
│ └────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ DSL Parser │ │
│ │ (Tokenizer) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ AST Builder │ │
│ │ (Abstract Syntax│ │
│ │ Tree) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌───────────────────┼───────────────────┐ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌───────▼───────┐ ┌──────▼──────┐ │
│ │ MongoDB │ │ Elasticsearch │ │ Druid │ │
│ │ Translator │ │ Translator │ │ Translator │ │
│ └──────┬──────┘ └───────┬───────┘ └──────┬──────┘ │
│ │ │ │ │
│ ┌──────▼──────┐ ┌───────▼───────┐ ┌──────▼──────┐ │
│ │ BSON │ │ QueryBuilder │ │ SQL │ │
│ │ Query │ │ Object │ │ String │ │
│ └─────────────┘ └───────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
DSL Operators
| Operator | Symbol | Example | Description |
|---|---|---|---|
| Equal | == | name==John | Exact match |
| Not Equal | != | status!=deleted | Exclusion |
| Greater Than | =gt= | age=gt=18 | Numeric comparison |
| Greater Equal | =ge= | score=ge=100 | Inclusive comparison |
| Less Than | =lt= | price=lt=50 | Numeric comparison |
| Less Equal | =le= | count=le=10 | Inclusive comparison |
| In | =in= | status=in=(active,pending) | Set membership |
| Out | =out= | role=out=(guest,banned) | Set exclusion |
| AND | ; | age=gt=18;status==active | Logical conjunction |
| OR | , | role==admin,role==moderator | Logical disjunction |
Implementation Deep Dive
DSL Parser
package com.gonnect.querydsl.parser;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class DslParser {
// Regex patterns for operators
private static final Pattern COMPARISON_PATTERN = Pattern.compile(
"(\\w+)(==|!=|=gt=|=ge=|=lt=|=le=|=in=|=out=)([^;,]+)"
);
public QueryNode parse(String dslQuery) {
// Handle AND (;) and OR (,) operators
if (dslQuery.contains(";")) {
return parseAndExpression(dslQuery);
} else if (dslQuery.contains(",")) {
return parseOrExpression(dslQuery);
} else {
return parseComparison(dslQuery);
}
}
private QueryNode parseAndExpression(String query) {
String[] parts = query.split(";");
List<QueryNode> children = new ArrayList<>();
for (String part : parts) {
children.add(parse(part.trim()));
}
return new AndNode(children);
}
private QueryNode parseOrExpression(String query) {
String[] parts = query.split(",");
List<QueryNode> children = new ArrayList<>();
for (String part : parts) {
children.add(parse(part.trim()));
}
return new OrNode(children);
}
private QueryNode parseComparison(String query) {
Matcher matcher = COMPARISON_PATTERN.matcher(query);
if (matcher.matches()) {
String field = matcher.group(1);
String operator = matcher.group(2);
String value = matcher.group(3);
return new ComparisonNode(field, operator, parseValue(value));
}
throw new DslParseException("Invalid query: " + query);
}
private Object parseValue(String value) {
// Handle IN/OUT lists: (val1,val2,val3)
if (value.startsWith("(") && value.endsWith(")")) {
String inner = value.substring(1, value.length() - 1);
return List.of(inner.split(","));
}
// Try numeric parsing
try {
if (value.contains(".")) {
return Double.parseDouble(value);
}
return Long.parseLong(value);
} catch (NumberFormatException e) {
// Return as string (remove quotes if present)
return value.replace("'", "").replace("\"", "");
}
}
}
Abstract Syntax Tree Nodes
package com.gonnect.querydsl.ast;
// Base interface for all query nodes
public interface QueryNode {
<T> T accept(QueryVisitor<T> visitor);
}
// Comparison node (leaf node)
public record ComparisonNode(
String field,
String operator,
Object value
) implements QueryNode {
@Override
public <T> T accept(QueryVisitor<T> visitor) {
return visitor.visitComparison(this);
}
}
// AND node (composite)
public record AndNode(List<QueryNode> children) implements QueryNode {
@Override
public <T> T accept(QueryVisitor<T> visitor) {
return visitor.visitAnd(this);
}
}
// OR node (composite)
public record OrNode(List<QueryNode> children) implements QueryNode {
@Override
public <T> T accept(QueryVisitor<T> visitor) {
return visitor.visitOr(this);
}
}
// Visitor interface for database-specific translations
public interface QueryVisitor<T> {
T visitComparison(ComparisonNode node);
T visitAnd(AndNode node);
T visitOr(OrNode node);
}
MongoDB Translator
package com.gonnect.querydsl.mongodb;
import org.bson.Document;
import java.util.List;
import java.util.stream.Collectors;
public class MongoDbTranslator implements QueryVisitor<Document> {
@Override
public Document visitComparison(ComparisonNode node) {
String field = node.field();
Object value = node.value();
return switch (node.operator()) {
case "==" -> new Document(field, value);
case "!=" -> new Document(field, new Document("$ne", value));
case "=gt=" -> new Document(field, new Document("$gt", value));
case "=ge=" -> new Document(field, new Document("$gte", value));
case "=lt=" -> new Document(field, new Document("$lt", value));
case "=le=" -> new Document(field, new Document("$lte", value));
case "=in=" -> new Document(field, new Document("$in", value));
case "=out=" -> new Document(field, new Document("$nin", value));
default -> throw new IllegalArgumentException(
"Unknown operator: " + node.operator()
);
};
}
@Override
public Document visitAnd(AndNode node) {
List<Document> conditions = node.children().stream()
.map(child -> child.accept(this))
.collect(Collectors.toList());
return new Document("$and", conditions);
}
@Override
public Document visitOr(OrNode node) {
List<Document> conditions = node.children().stream()
.map(child -> child.accept(this))
.collect(Collectors.toList());
return new Document("$or", conditions);
}
}
// Usage example
public class MongoDbExample {
public static void main(String[] args) {
DslParser parser = new DslParser();
MongoDbTranslator translator = new MongoDbTranslator();
String dsl = "age=gt=32";
QueryNode ast = parser.parse(dsl);
Document mongoQuery = ast.accept(translator);
System.out.println(mongoQuery.toJson());
// Output: {"age": {"$gt": 32}}
}
}
Elasticsearch Translator
package com.gonnect.querydsl.elasticsearch;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import java.util.Collection;
public class ElasticsearchTranslator implements QueryVisitor<QueryBuilder> {
@Override
public QueryBuilder visitComparison(ComparisonNode node) {
String field = node.field();
Object value = node.value();
return switch (node.operator()) {
case "==" -> QueryBuilders.termQuery(field, value);
case "!=" -> QueryBuilders.boolQuery()
.mustNot(QueryBuilders.termQuery(field, value));
case "=gt=" -> QueryBuilders.rangeQuery(field).gt(value);
case "=ge=" -> QueryBuilders.rangeQuery(field).gte(value);
case "=lt=" -> QueryBuilders.rangeQuery(field).lt(value);
case "=le=" -> QueryBuilders.rangeQuery(field).lte(value);
case "=in=" -> QueryBuilders.termsQuery(field,
(Collection<?>) value);
case "=out=" -> QueryBuilders.boolQuery()
.mustNot(QueryBuilders.termsQuery(field, (Collection<?>) value));
default -> throw new IllegalArgumentException(
"Unknown operator: " + node.operator()
);
};
}
@Override
public QueryBuilder visitAnd(AndNode node) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
for (QueryNode child : node.children()) {
boolQuery.must(child.accept(this));
}
return boolQuery;
}
@Override
public QueryBuilder visitOr(OrNode node) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
for (QueryNode child : node.children()) {
boolQuery.should(child.accept(this));
}
// Ensure at least one should clause matches
boolQuery.minimumShouldMatch(1);
return boolQuery;
}
}
// Usage example
public class ElasticsearchExample {
public static void main(String[] args) {
DslParser parser = new DslParser();
ElasticsearchTranslator translator = new ElasticsearchTranslator();
String dsl = "status==active;age=ge=18";
QueryNode ast = parser.parse(dsl);
QueryBuilder esQuery = ast.accept(translator);
System.out.println(esQuery.toString());
// Output: {"bool":{"must":[{"term":{"status":"active"}},
// {"range":{"age":{"gte":18}}}]}}
}
}
Druid SQL Translator
package com.gonnect.querydsl.druid;
import java.util.List;
import java.util.stream.Collectors;
public class DruidSqlTranslator implements QueryVisitor<String> {
private final String tableName;
public DruidSqlTranslator(String tableName) {
this.tableName = tableName;
}
public String buildFullQuery(QueryNode node) {
String whereClause = node.accept(this);
return String.format("SELECT * FROM \"%s\" WHERE %s", tableName, whereClause);
}
@Override
public String visitComparison(ComparisonNode node) {
String field = node.field();
Object value = node.value();
return switch (node.operator()) {
case "==" -> formatEquality(field, value);
case "!=" -> formatInequality(field, value);
case "=gt=" -> String.format("(%s > %s)", field, formatValue(value));
case "=ge=" -> String.format("(%s >= %s)", field, formatValue(value));
case "=lt=" -> String.format("(%s < %s)", field, formatValue(value));
case "=le=" -> String.format("(%s <= %s)", field, formatValue(value));
case "=in=" -> formatIn(field, value);
case "=out=" -> formatNotIn(field, value);
default -> throw new IllegalArgumentException(
"Unknown operator: " + node.operator()
);
};
}
@Override
public String visitAnd(AndNode node) {
String conditions = node.children().stream()
.map(child -> child.accept(this))
.collect(Collectors.joining(" and "));
return "(" + conditions + ")";
}
@Override
public String visitOr(OrNode node) {
String conditions = node.children().stream()
.map(child -> child.accept(this))
.collect(Collectors.joining(" or "));
return "(" + conditions + ")";
}
private String formatEquality(String field, Object value) {
return String.format("(%s = %s)", field, formatValue(value));
}
private String formatInequality(String field, Object value) {
return String.format("(%s <> %s)", field, formatValue(value));
}
private String formatIn(String field, Object value) {
if (value instanceof List<?> list) {
String values = list.stream()
.map(this::formatValue)
.collect(Collectors.joining(", "));
return String.format("(%s IN (%s))", field, values);
}
return formatEquality(field, value);
}
private String formatNotIn(String field, Object value) {
if (value instanceof List<?> list) {
String values = list.stream()
.map(this::formatValue)
.collect(Collectors.joining(", "));
return String.format("(%s NOT IN (%s))", field, values);
}
return formatInequality(field, value);
}
private String formatValue(Object value) {
if (value instanceof String) {
return "'" + value + "'";
}
return String.valueOf(value);
}
}
// Usage example
public class DruidExample {
public static void main(String[] args) {
DslParser parser = new DslParser();
DruidSqlTranslator translator = new DruidSqlTranslator("orders");
String dsl = "name=='Kill Bill';createdBy=='Tiger'";
QueryNode ast = parser.parse(dsl);
String sql = translator.buildFullQuery(ast);
System.out.println(sql);
// Output: SELECT * FROM "orders" WHERE ((name = 'Kill Bill') and (createdBy = 'Tiger'))
}
}
Spring Boot Integration
Unified Query Service
package com.gonnect.querydsl.service;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
@Service
public class UnifiedQueryService {
private final DslParser parser;
private final MongoTemplate mongoTemplate;
private final ElasticsearchRestTemplate esTemplate;
private final DruidClient druidClient;
@Autowired
public UnifiedQueryService(
MongoTemplate mongoTemplate,
ElasticsearchRestTemplate esTemplate,
DruidClient druidClient) {
this.parser = new DslParser();
this.mongoTemplate = mongoTemplate;
this.esTemplate = esTemplate;
this.druidClient = druidClient;
}
public <T> List<T> queryMongoDB(String dsl, String collection, Class<T> type) {
QueryNode ast = parser.parse(dsl);
Document mongoQuery = ast.accept(new MongoDbTranslator());
Query query = new BasicQuery(mongoQuery);
return mongoTemplate.find(query, type, collection);
}
public <T> List<T> queryElasticsearch(String dsl, String index, Class<T> type) {
QueryNode ast = parser.parse(dsl);
QueryBuilder esQuery = ast.accept(new ElasticsearchTranslator());
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(esQuery)
.build();
SearchHits<T> hits = esTemplate.search(searchQuery, type,
IndexCoordinates.of(index));
return hits.stream()
.map(SearchHit::getContent)
.collect(Collectors.toList());
}
public List<Map<String, Object>> queryDruid(String dsl, String dataSource) {
DruidSqlTranslator translator = new DruidSqlTranslator(dataSource);
QueryNode ast = parser.parse(dsl);
String sql = translator.buildFullQuery(ast);
return druidClient.executeSql(sql);
}
}
REST API Controller
package com.gonnect.querydsl.controller;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/query")
public class QueryController {
private final UnifiedQueryService queryService;
public QueryController(UnifiedQueryService queryService) {
this.queryService = queryService;
}
@PostMapping("/mongodb/{collection}")
public List<Map<String, Object>> queryMongoDB(
@PathVariable String collection,
@RequestBody QueryRequest request) {
return queryService.queryMongoDB(
request.dsl(),
collection,
Map.class
);
}
@PostMapping("/elasticsearch/{index}")
public List<Map<String, Object>> queryElasticsearch(
@PathVariable String index,
@RequestBody QueryRequest request) {
return queryService.queryElasticsearch(
request.dsl(),
index,
Map.class
);
}
@PostMapping("/druid/{dataSource}")
public List<Map<String, Object>> queryDruid(
@PathVariable String dataSource,
@RequestBody QueryRequest request) {
return queryService.queryDruid(
request.dsl(),
dataSource
);
}
}
record QueryRequest(String dsl) {}
Testing Examples
Unit Tests for DSL Parser
@Test
void testSimpleEquality() {
DslParser parser = new DslParser();
MongoDbTranslator translator = new MongoDbTranslator();
String dsl = "firstName==gaurav";
QueryNode ast = parser.parse(dsl);
Document result = ast.accept(translator);
assertEquals("{\"firstName\": \"gaurav\"}", result.toJson());
}
@Test
void testGreaterThan() {
DslParser parser = new DslParser();
MongoDbTranslator translator = new MongoDbTranslator();
String dsl = "age=gt=32";
QueryNode ast = parser.parse(dsl);
Document result = ast.accept(translator);
assertEquals("{\"age\": {\"$gt\": 32}}", result.toJson());
}
@Test
void testComplexAndOr() {
DslParser parser = new DslParser();
DruidSqlTranslator translator = new DruidSqlTranslator("users");
String dsl = "status==active;role==admin,role==moderator";
QueryNode ast = parser.parse(dsl);
String sql = translator.buildFullQuery(ast);
assertTrue(sql.contains("status = 'active'"));
assertTrue(sql.contains("role = 'admin'"));
assertTrue(sql.contains("role = 'moderator'"));
}
@Test
void testInOperator() {
DslParser parser = new DslParser();
MongoDbTranslator translator = new MongoDbTranslator();
String dsl = "status=in=(pending,processing,completed)";
QueryNode ast = parser.parse(dsl);
Document result = ast.accept(translator);
assertTrue(result.toJson().contains("$in"));
}
Query Translation Reference
DSL to Database Query Mapping
| DSL Query | MongoDB | Elasticsearch | Druid SQL |
|---|---|---|---|
name==John | {"name": "John"} | {"term": {"name": "John"}} | name = 'John' |
age=gt=25 | {"age": {"$gt": 25}} | {"range": {"age": {"gt": 25}}} | age > 25 |
a==1;b==2 | {"$and": [{"a": 1}, {"b": 2}]} | {"bool": {"must": [...]}} | a = 1 and b = 2 |
a==1,b==2 | {"$or": [{"a": 1}, {"b": 2}]} | {"bool": {"should": [...]}} | a = 1 or b = 2 |
Best Practices
| Practice | Description |
|---|---|
| Type Safety | Use generics for type-safe query results |
| Validation | Validate DSL syntax before translation |
| Error Handling | Provide clear error messages for invalid queries |
| Extensibility | Design for easy addition of new operators |
| Testing | Maintain comprehensive test suites for each translator |
Conclusion
A Unified Query DSL provides significant benefits for applications working with multiple databases:
- Consistency: One query language across all data stores
- Maintainability: Centralized query logic and easier debugging
- Flexibility: Swap databases without changing application code
- Developer Experience: Reduced learning curve for team members
The interpreter pattern used in this implementation enables clean separation between query parsing and database-specific translation, making it easy to add support for additional databases like Cassandra, ClickHouse, or PostgreSQL.
Explore the complete implementation at query-dsl-mongodb-elasticsearch-druid on GitHub.