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.

GT
Gonnect Team
January 14, 202413 min readView on GitHub
JavaMongoDBElasticsearchApache DruidSpring Boot

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

Loading diagram...

Comparing Query Syntaxes

OperationMongoDBElasticsearchDruid 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

Loading diagram...

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

OperatorSymbolExampleDescription
Equal==name==JohnExact match
Not Equal!=status!=deletedExclusion
Greater Than=gt=age=gt=18Numeric comparison
Greater Equal=ge=score=ge=100Inclusive comparison
Less Than=lt=price=lt=50Numeric comparison
Less Equal=le=count=le=10Inclusive comparison
In=in=status=in=(active,pending)Set membership
Out=out=role=out=(guest,banned)Set exclusion
AND;age=gt=18;status==activeLogical conjunction
OR,role==admin,role==moderatorLogical 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 QueryMongoDBElasticsearchDruid 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

PracticeDescription
Type SafetyUse generics for type-safe query results
ValidationValidate DSL syntax before translation
Error HandlingProvide clear error messages for invalid queries
ExtensibilityDesign for easy addition of new operators
TestingMaintain 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.