Elasticsearch Queries That Never Break in Production
Compile-time validation for Elasticsearch queries: catch the field-name typo at build time, not at 2 AM.
Β·17 min read
How Compile-Time Validation and SQL Transform Your Elasticsearch Development
When a Field Name Typo Costs $50,000 πΈ
Itβs Friday evening, 6 PM. Your e-commerce platform just deployed a new feature: personalized product recommendations based on customer purchase history. The feature passed code review, unit tests, integration tests, and CI/CD pipeline checks. Everything looked perfect.
Saturday morning, 2 AM. Your phone rings. The on-call engineer reports that the recommendation engine is returning empty results for 100% of users. Revenue from personalized recommendationsβββtypically $25,000 per hour during weekend shopping peaksβββhas dropped to zero.
The root cause? A seemingly innocent typo buried in a complex Elasticsearch aggregation query:
{ "aggs": { "top_categories": { "terms": { "field": "customer_purchse_history.category", // β "purchse" instead of "purchase" "size": 10 }, "aggs": { "avg_rating": { "avg": { "field": "products.rating" } } } } }}The query executed successfullyβββElasticsearch didnβt throw an error. It simply returned empty aggregation buckets because the field customer_purchse_history.category doesnβt exist. The application interpreted this as βno recommendations availableβ and displayed empty carousels to every customer.
Impact of this single-character typo:
- π₯ 2 hours to identify the issue (aggregations silently returned empty results)
- π° $16,000 to $50,000 in lost revenueβββ(Gartner) Small businesses lose between $8,000 to $25,000 per hour of downtime.
- π 15% drop in conversion rate during the incident window
- π° Frustrated customers seeing broken recommendation sections
- π¨ Emergency hotfix deployment at 3 AM to correct the field name
- π Post-mortem report and incident review the following week
Why did this happen?
- β
Code review passed: The reviewer saw
customer_purchse_historyand assumed it was correct (field names arenβt validated) - β Unit tests passed: Tests used mocked data that didnβt reflect production field names
- β Integration tests passed: Test environment had a typo in both the query AND the test data mapping
- β CI/CD pipeline passed: No compile-time validation for JSON string queries
- π₯ Production failed: Real Elasticsearch index had the correct field name
customer_purchase_history
The core problem: String-based queries have no compile-time validation. Field name typos, type mismatches, and structural errors are only discovered at runtimeβββoften in production, under load, with real customers affected.
What if this code simply wouldnβt compile?
TL;DR β‘
SoftClient4ES is an Elasticsearch client that catches query errors before your code even runs.
Key Benefits:
- β Catch errors at compile-time β Zero production errors from field name mistakes and type mismatches
- β Write SQL, not JSON β 10x faster query development
- β Full type validation β Automatic checking of Scala types vs. SQL types
- β Window functions support β Advanced analytics with SQL syntax
- β Minimal runtime overhead β SQL translated to optimized Elasticsearch DSL (<1ms translation time)
Perfect for: Teams tired of debugging production errors, maintaining complex queries, or onboarding new developers.
Read time: 10 minutes | Skill level: Intermediate
The Hidden Cost of Runtime Errors π
Letβs be honest: working with Elasticsearchβs JSON DSL can be challenging. While itβs incredibly powerful, the lack of compile-time validation means errors often slip through to production.
Traditional Approach (Official Clients)
// β
Compiles successfullycase class Product(id: String, name: String, price: Double, stock: Int)val query = """{ "query": { "bool": { "filter": [ {"term": {"pric": {"value": 100}}} // β Typo: "pric" instead of "price" ] } }, "_source": ["id", "nam", "price"] // β Typo: "nam" instead of "name"}"""client.search[Product](query) // β
Compiles fineWhat happens:
- β Code compiles
- β Tests pass (if they donβt use real field names)
- β Code review approved (JSON strings are hard to review)
- β Deployed to production
- π₯ Runtime exception at 3 AM
Average time to discover error: 2β48 hours Average cost per incident: $10,000β$100,000
This isnβt a criticism of the official clientsβββtheyβre excellent tools. Itβs just the nature of string-based queries.
The SoftClient4ES Approach π‘οΈ
Same code with compile-time validation:
case class Product(id: String, name: String, price: Double, stock: Int)
// β COMPILATION ERROR - Code won't buildval products = client.searchAs[Product](""" SELECT id, nam, pric FROM products WHERE pric > 100""")// Compiler error:// β SQL query does not select the required field: name// You have selected unknown field "nam", did you mean "name"?//// β SQL query does not select the required field: price// You have selected unknown field "pric", did you mean "price"?What happens:
- β Code doesnβt compile
- π IDE shows errors immediately
- β Fix typos in 10 seconds
- β Deploy with confidence
- π΄ Sleep peacefully
Time to discover error: 10 seconds Cost: $0
How Compile-Time Validation Works π¬
SoftClient4ES uses Scala macros to analyze your queries during compilation. This approach shifts error detection from runtime to compile-time, catching issues in your IDE rather than in production.
Step-by-Step Process
// Your codecase class User(id: String, email: String, age: Int)
client.searchAs[User]("SELECT id, email FROM users")At compile-time:
- Extract SQL query from the string literal
- Parse SQL to identify selected fields: [id, email]
- Inspect case class to find required fields: [id, email, age]
- Compare and detect missing field: age
- Generate compilation error with actionable fix
At runtime:
- Translate SQL to Elasticsearch DSL
- Execute native DSL query against Elasticsearch
- Deserialize results to case class instances
Translation overhead: <1ms per query (negligible compared to 10β1000ms+ Elasticsearch execution time)
β SQL query does not select the required field: ageExample query:SELECT id, email, age FROM usersTo fix this, either: 1. Add 'age' to the SELECT clause 2. Make it Option[Int] in the case class 3. Provide a default value: age: Int = 0All of this validation happens during compilation. Zero runtime validation overhead. β‘
Comprehensive Validation: Beyond Field Names π―
SoftClient4ES doesnβt just check field namesβββit performs comprehensive type and structure validation, including pure SQL validation rules.
Validated Operations
| Validation | Description | Level |
|---|---|---|
| SELECT * Rejection | Prohibits SELECT * to ensure compile-time validation | β ERROR |
| Required Fields | Verifies that all required fields are selected | β ERROR |
| Type Compatibility | Checks compatibility between SQL and Scala types | β ERROR |
| GROUP BY Validation | Ensures SELECT fields are either in GROUP BY or aggregated | β ERROR |
| Unknown Fields | Detects fields that donβt exist in the case class | β οΈ WARNING |
| Nested Objects | Validates the structure of nested objects | β ERROR |
| Nested Collections | Validates the use of UNNEST for collections | β ERROR |
Letβs explore each validation with real examples.
Example 1: Type Compatibility Validation
One of the most subtle bugs: type mismatches between SQL and Scala.
case class Product( id: String, name: String, price: Double, stock: Int, // β Scala type: Int createdAt: Instant)
// β COMPILE ERROR: Type mismatchval products = client.searchAs[Product](""" SELECT id, name, price, stock::BIGINT, -- SQL returns BIGINT (Long in Scala) created_at AS createdAt FROM products""")// Compiler error:// β Type mismatch for field 'stock'//// Expected: Int (Scala type in case class)// Actual: Long (SQL BIGINT type)//// β
Solution 1: Change case class to match SQL type// case class Product(..., stock: Long, ...)//// β
Solution 2: Cast in SQL query// SELECT ..., CAST(stock AS INT) AS stock, ...Why this matters:
Without validation, this code would compile but fail at runtime with cryptic deserialization errors. The compiler catches the mismatch and suggests fixes.
Supported type mappings:
| SQL Type | Scala Type | Notes |
|---|---|---|
VARCHAR | String | β |
TINYINT | Byte | β |
SMALLINT | Short | β |
INT | Int | β |
BIGINT | Long | β οΈ Common mismatch with Int |
DOUBLE | Double | β |
REAL | Float | β |
BOOLEAN | Boolean | β |
TIME | Instant, LocalTime | β Multiple formats supported |
DATE | Instant, LocalDate | β Multiple formats supported |
DATETIME, TIMESTAMP | Instant, LocalDateTime | β Multiple formats supported |
ARRAY | List[T], Seq[T] | β With element type validation |
STRUCT | Nested case class | β With structure validation |
Example 2: Pure SQL ValidationβββGROUP BY Rules
SoftClient4ES validates pure SQL rules, not just Scala/Elasticsearch compatibility.
case class CategoryStats( category: String, productName: String, // β Not aggregated, not in GROUP BY totalSales: Double, avgPrice: Double)
// β COMPILE ERROR: Invalid GROUP BYval stats = client.searchAs[CategoryStats](""" SELECT category, product_name AS productName, SUM(sales) AS totalSales, AVG(price) AS avgPrice FROM products GROUP BY category""")// Compiler error:// β SQL validation error: Invalid GROUP BY clause//// Problem:// Field 'product_name' appears in SELECT but is neither:// 1. Included in GROUP BY clause// 2. Used in an aggregate function (SUM, AVG, COUNT, etc.)// 3. Used in a window function with OVER clause//// β
Solution 1: Add to GROUP BY// GROUP BY category, product_name//// β
Solution 2: Use aggregate function// SELECT category, COUNT(DISTINCT product_name) AS productCount, ...//// β
Solution 3: Use window function// SELECT category, FIRST_VALUE(product_name) OVER (PARTITION BY category) AS productName, ...This catches classic SQL mistakes at compile-time, preventing runtime errors or incorrect results.
Example 3: Catching Typos with Smart Suggestions
case class Product( id: String, name: String, price: Double, stock: Int)
// β COMPILE ERRORclient.searchAs[Product]("SELECT id, nam, pric, stok FROM products")// Compiler output:// β Field 'name' is required but not selected// Did you mean "nam" β "name"?//// β Field 'price' is required but not selected// Did you mean "pric" β "price"?//// β Field 'stock' is required but not selected// Did you mean "stok" β "stock"?Levenshtein distance algorithm suggests the closest matching field names, making typos easy to spot and fix.
Example 4: Nested Object Validation
One of the trickiest Elasticsearch issues: nested object deserialization.
case class Address(street: String, city: String, country: String)case class User(id: String, name: String, address: Address)
// β This compiles with official clients but fails at runtime!client.searchAs[User](""" SELECT id, name, address.street, address.city, address.country FROM users""")Why it fails:
Elasticsearch returns flat structure:
{ "id": "u1", "name": "Alice", "address.street": "123 Main St", // β Flat "address.city": "Paris"}Jackson expects nested structure:
{ "id": "u1", "name": "Alice", "address": { // β
Nested "street": "123 Main St", "city": "Paris" }}Result: NullPointerException or silent data loss in production.
SoftClient4ES catches this at compile-time:
// β COMPILATION ERRORclient.searchAs[User](""" SELECT id, name, address.street, address.city, address.country FROM users""")
// Compiler error:// β Nested object field 'address' cannot be deserialized correctly.//// Problem:// You are selecting nested fields individually (address.street, address.city).// Elasticsearch returns flat fields, but Jackson needs a structured object.//// β
Solution 1: Select the entire nested object (recommended)// SELECT id, name, address FROM users//// β
Solution 2: Use UNNEST (if you need to filter or join on nested fields)// SELECT id, name, address.street, address.city, address.country// FROM users// JOIN UNNEST(users.address) AS address// WHERE addr.city = 'Paris'The compiler not only catches the error but teaches you the best practice. This has saved us countless hours of debugging.
Example 5: Nested Collection Validation
Collections require special handling in Elasticsearch.
case class OrderItem(productId: String, quantity: Int, price: Double)case class Order(id: String, customerId: String, items: List[OrderItem])
// β COMPILATION ERRORclient.searchAs[Order](""" SELECT id, customerId, items.productId, items.quantity, items.price FROM orders""")// Compiler error:// β Collection field 'items' cannot be deserialized correctly.//// Problem:// You are selecting nested fields without using UNNEST:// items.productId, items.quantity, items.price//// Elasticsearch will return flat arrays but Jackson needs structured objects//// β
Solution 1: Select the entire collection (recommended for simple queries)// SELECT id, customerId, items FROM orders//// β
Solution 2: Use UNNEST for precise field selection (recommended for complex queries)// SELECT id, customerId, items.productId, items.quantity, items.price// FROM orders// JOIN UNNEST(orders.items) AS item//// π Documentation:// https://www.elastic.co/guide/en/elasticsearch/reference/current/nested.htmlExample 6: Safe Refactoring
This is where compile-time validation truly shines.
Scenario: You rename a field in your domain model.
Without Compile-Time Validation:
// Week 1: Original modelcase class Order(id: String, totalAmount: Double, status: String)
// Week 5: Someone refactorscase class Order(id: String, total: Double, status: String) // βοΈ Renamed// Week 6: Production breaks π₯// 47 queries across 12 services still use 'totalAmount'// Runtime errors everywhereCost: 8 hours of debugging, emergency hotfix, incident report.
With SoftClient4ES:
// Week 5: Refactor the modelcase class Order(id: String, total: Double, status: String) // βοΈ Renamed
// Attempt to compile...// β COMPILATION ERRORS in 47 places://// OrderService.scala:23: error:// SQL query does not select the required field: total// You have selected unknown field "totalAmount", did you mean "total"?//// ReportingService.scala:45: error:// SQL query does not select the required field: total// ...Result:
- β All 47 queries identified before code review
- β Fix all queries in 30 minutes
- β Zero production incidents
- β Confident refactoring
The compiler becomes your safety net, catching every affected query automatically.
π Full SQL Validation Documentation
SQL: The Universal Query Language π
Beyond type safety, SoftClient4ES lets you write SQL instead of JSON DSL.
Weβre not suggesting SQL is inherently better than DSLβββboth have their place. But for many common queries, SQL offers significant advantages in readability and maintainability.
A Common Query in Different Forms
Business requirement: βShow me products under $100 with stock, sorted by price.β
Elasticsearch JSON DSL (Official Clients):
{ "query": { "bool": { "filter": [ { "range": { "price": { "lt": 100 } } }, { "range": { "stock": { "gt": 0 } } } ] } }, "_source": ["id", "name", "price", "stock"], "sort": [ { "price": { "order": "asc" } } ], "size": 20}30+ lines. Nested objects. Easy to make mistakes.
SoftClient4ES SQL:
SELECT id, name, price, stockFROM productsWHERE price < 100 AND stock > 0ORDER BY price ASCLIMIT 206 lines. Readable. Maintainable. Familiar.
Same result. 5x faster to write. 10x easier to maintain.
Why SQL Can Help
β Universal knowledge: Most developers already know SQL β Self-documenting: Queries explain themselves β Onboarding speed: New developers productive immediately β Cross-team collaboration: Data analysts can write queries β Reduced cognitive load: No context switching between SQL (Postgres) and DSL (Elasticsearch)
That said, we recognize that JSON DSL has advantages tooβββespecially for complex nested queries or when you need fine-grained control. Thatβs why SoftClient4ES supports both SQL and native DSL.
Window Functions: Advanced Analytics Made Simple π
SoftClient4ES supports SQL window functions for sophisticated analytics queriesβββa feature not available in Elasticsearchβs native SQL API.
What Are Window Functions?
Window functions perform calculations across sets of rows without collapsing the result set (unlike GROUP BY).
Use cases:
- Running totals and moving averages
- First/last values in partitions
- Ranking and percentiles
- Year-over-year comparisons
Example: Product Sales Analysis
Business requirement: βFor each product, show monthly sales plus total sales, average price, and first/last sale amounts.β
SQL with Window Functions:
SELECT product_id, product_name, DATE_TRUNC(sale_date, MONTH) AS sale_month, SUM(amount) AS monthly_sales,
-- Window functions (calculated across partitions) SUM(amount) OVER (PARTITION BY product_id) AS total_sales, AVG(amount) OVER (PARTITION BY product_id) AS avg_sale_amount, MIN(amount) OVER (PARTITION BY product_id) AS min_sale_amount, MAX(amount) OVER (PARTITION BY product_id) AS max_sale_amount, COUNT(*) OVER (PARTITION BY product_id) AS sale_count, COUNT(DISTINCT customer_id) OVER (PARTITION BY product_id) AS unique_customers,
FIRST_VALUE(amount) OVER ( PARTITION BY product_id, DATE_TRUNC(sale_date, MONTH) ORDER BY sale_date ASC ) AS first_sale_of_month,
LAST_VALUE(amount) OVER ( PARTITION BY product_id, DATE_TRUNC(sale_date, MONTH) ORDER BY sale_date ASC ) AS last_sale_of_month,
ARRAY_AGG(amount) OVER ( PARTITION BY product_id ORDER BY sale_date ASC ) AS all_sale_amounts
FROM salesWHERE sale_date >= '2024-01-01'GROUP BY product_id, product_name, DATE_TRUNC(sale_date, MONTH)ORDER BY product_id, sale_monthType-safe execution with full validation:
case class ProductSalesAnalysis( productId: String, productName: String, saleMonth: String, monthlySales: Double, totalSales: Double, avgSaleAmount: Double, minSaleAmount: Double, maxSaleAmount: Double, saleCount: Long, // β
Type validated: COUNT returns Long uniqueCustomers: Long, // β
Type validated: COUNT DISTINCT returns Long firstSaleOfMonth: Double, lastSaleOfMonth: Double, allSaleAmounts: List[Double] // β
Type validated: ARRAY_AGG returns List)
val results: Source[ProductSalesAnalysis, NotUsed] = client.scrollAs[ProductSalesAnalysis](sqlQuery)Compile-time validation ensures:
- β All window function fields are present in the case class
- β
Types match (e.g., Long for COUNT, Double for AVG,
List[Double]forARRAY_AGG) - β No typos in field names
- β Correct handling of aggregated vs. window function fields
- β Valid GROUP BY clause (fields not aggregated are in GROUP BY)
Supported Window Functions
| Syntax | Use Case | |
|---|---|---|
| SUM | SUM(amount) OVER (PARTITION BY product_id) | Total sales per product |
| AVG | AVG(price) OVER (PARTITION BY category) | Average price per category |
| MIN/MAX | MIN(price) OVER (PARTITION BY brand) | Price range per brand |
| COUNT | COUNT(*) OVER (PARTITION BY customer_id) | Orders per customer |
| COUNT DISTINCT | COUNT(DISTINCT city) OVER (PARTITION BY country) | Unique cities per country |
| FIRST_VALUE | FIRST_VALUE(amount) OVER (PARTITION BY product_id ORDER BY date) | First sale per product |
| LAST_VALUE | LAST_VALUE(price) OVER (PARTITION BY product_id ORDER BY date) | Most recent price |
| ARRAY_AGG | ARRAY_AGG(tag) OVER (PARTITION BY product_id ORDER BY date) | Collect all tags per product |
| STDDEV | STDDEV(salary) OVER (PARTITION BY department) | Spread within each group |
| VARIANCE | VARIANCE(salary) OVER (PARTITION BY department) | Variance within each group |
| PERCENTILE_CONT | PERCENTILE_CONT(0.95) OVER (PARTITION BY service ORDER BY ms) | p95 per service |
| ROW_NUMBER | ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) | Sequential ordinal per group |
| RANK | RANK() OVER (PARTITION BY department ORDER BY salary DESC) | Rank, ties skip the next |
| DENSE_RANK | DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) | Rank, ties do not skip |
Two constraints worth knowing before you reach for these. The ranking windows require ORDER BY
inside OVER β ROW_NUMBER() OVER (PARTITION BY d) is rejected at parse time rather than left to
fail at execution, because a ranking with no order is not a ranking. And the sample forms of the
extended-stats family β STDDEV, STDDEV_SAMP, VARIANCE, VAR_SAMP β need Elasticsearch 7.7+,
while the population forms STDDEV_POP and VAR_POP work on 6+. PERCENTILE_DISC accepts the same
four syntactic forms as PERCENTILE_CONT, including WITHIN GROUP (ORDER BY column).
How Window Functions Work in SoftClient4ES
When you mix window functions with regular fields, SoftClient4ES executes two queries:
- Aggregations Query: Computes window function results using Elasticsearch aggregations
- Main Query: Retrieves document fields (if needed)
- In-Memory Join: Merges results using partition keys (PARTITION BY fields)
Example execution flow:
SQL Query with Window Functions β ββββββββββββββββββββββββββββββββββββββ β Query Analysis & Decomposition β ββββββββββββββββββββββββββββββββββββββ β β βββββββββββββββ ββββββββββββββββββββ β Main Query β β Window Functions β β (Fields) β β Query (Aggs) β βββββββββββββββ ββββββββββββββββββββ β β βββββββββββββββ ββββββββββββββββββββ β ES Search β β ES Aggregations β βββββββββββββββ ββββββββββββββββββββ β β βββββββββββββββββββββββββββββββββββββββ β In-Memory Join (Partition Keys) β βββββββββββββββββββββββββββββββββββββββ β βββββββββββββββββββββββββββββββββββββββ β Type-Safe Result Set β βββββββββββββββββββββββββββββββββββββββPerformance: In-memory join is highly optimized and adds minimal overhead (<10ms for typical queries).
Real-World Window Function Example
Customer Purchase Patterns:
case class CustomerPurchase( customerId: String, purchaseDate: String, amount: Double, firstPurchaseAmount: Double, // First purchase ever lastPurchaseAmount: Double, // Most recent purchase totalSpent: Double, // Lifetime value avgPurchaseAmount: Double, // Average order value purchaseCount: Long // Total orders)
val patterns: Source[CustomerPurchase, NotUsed] = client.scrollAs[CustomerPurchase](""" SELECT customer_id AS customerId, purchase_date AS purchaseDate, amount,
FIRST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY purchase_date ASC ) AS firstPurchaseAmount,
LAST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY purchase_date ASC ) AS lastPurchaseAmount,
SUM(amount) OVER (PARTITION BY customer_id) AS totalSpent, AVG(amount) OVER (PARTITION BY customer_id) AS avgPurchaseAmount, COUNT(*) OVER (PARTITION BY customer_id) AS purchaseCount
FROM purchases WHERE purchase_date >= '2024-01-01' ORDER BY customer_id, purchase_date""")
patterns.runWith(Sink.foreach { purchase => println(s""" Customer: ${purchase.customerId} Purchase: ${purchase.amount} on ${purchase.purchaseDate} First Purchase: ${purchase.firstPurchaseAmount} Latest Purchase: ${purchase.lastPurchaseAmount} Lifetime Value: ${purchase.totalSpent} Average Order: ${purchase.avgPurchaseAmount} Total Orders: ${purchase.purchaseCount} """)})This type of query would be significantly more complex to write and maintain in JSON DSL.
Comparison with Official Elasticsearch Clients π€
Letβs be clear: Elasticsearchβs official clients are excellent tools, and we have great respect for the work the Elasticsearch team has done. SoftClient4ES is built on top of these clients and wouldnβt exist without them.
That said, they serve different purposes. Hereβs an honest comparison:
Official Java/REST Clients
// Official Elasticsearch Java ClientQuery priceFilter = Query.of(q -> q .range(r -> r .field("price") .lt(JsonData.of(100)) ));
Query stockFilter = Query.of(q -> q .range(r -> r .field("stock") .gt(JsonData.of(0)) ));Query boolQuery = Query.of(q -> q .bool(b -> b .filter(priceFilter) .filter(stockFilter) ));SearchRequest searchRequest = SearchRequest.of(s -> s .index("products") .query(boolQuery) .sort(so -> so .field(f -> f .field("price") .order(SortOrder.Asc) ) ) .size(20));SearchResponse<Product> response = client.search(searchRequest, Product.class);// Manual result processingresponse.hits().hits().forEach(hit -> {Product product = hit.source();// Process product...});Characteristics:
- β Full control over query construction
- β Type-safe builder pattern with lambdas
- β Excellent documentation
- β Supports all Elasticsearch features
- β Automatic deserialization to typed classes
- β Verbose for simple queries (20+ lines for basic search)
- β No compile-time field validation (typos in field names caught at runtime)
- β Manual result iteration required
Elasticsearch SQL API (via REST)
Elasticsearch does have a SQL API, which is a useful feature:
// Elasticsearch SQL API (official)QueryRequest sqlRequest = QueryRequest.of(q -> q .query("SELECT id, name, price FROM products WHERE price < 100") .fetchSize(20) .format(SqlFormat.Json));
QueryResponse response = client.sql().query(sqlRequest);// Manual deserialization with JacksonObjectMapper mapper = new ObjectMapper();response.rows().forEach(row -> { try { // Manual field extraction by index position JsonNode idNode = mapper.valueToTree(row.get(0)); JsonNode nameNode = mapper.valueToTree(row.get(1)); JsonNode priceNode = mapper.valueToTree(row.get(2)); String id = idNode.asText(); String name = nameNode.asText(); Double price = priceNode.asDouble(); // Manual object construction Product product = new Product(id, name, price, null); System.out.println(product);
} catch (Exception e) { // Runtime errors if field types don't match e.printStackTrace(); }});Characteristics:
- β SQL syntax
- β Good for simple queries
- β Integrated with Elasticsearch
- β No type safety: Results are
List<JsonData>- you access fields by index position - β No compile-time validation: Typos in field names discovered at runtime
- β Limited SQL features: No window functions, basic aggregations only
- β Nested query limitations: Limited to one level of nesting
- β Manual deserialization: You write the JSON parsing code with try/catch blocks
- β No case class validation: Field mismatches discovered in production
- β Positional access: Fields accessed by index (0, 1, 2β¦) instead of by name
- β Error-prone: Easy to get wrong index or type conversion
SoftClient4ES: Extended Capabilities
SoftClient4ES extends SQL capabilities beyond whatβs available in Elasticsearchβs native SQL API:
// SoftClient4EScase class Product(id: String, name: String, price: Double, stock: Int)
val products: Source[(Product, ScrollMetrics), NotUsed] = client.scrollAs[Product](""" SELECT id, name, price, stock FROM products WHERE price < 100 AND stock > 0 ORDER BY price ASC LIMIT 20""", client.defaultScrollConfig)Characteristics:
- β SQL syntax with extended features (window functions, unlimited nesting, complex aggregations)
- β Full type safety: Automatic case class deserialization
- β Compile-time validation: Errors caught in IDE (fields, types, SQL rules)
- β Type checking: Scala types validated against SQL types
- β Unlimited nested queries: No nesting level limitation (vs. 1 level in ES SQL API)
- β Window functions: Advanced analytics not available in ES SQL API
- β Streaming support via Akka Streams
- β Minimal runtime overhead β SQL translated to optimized Elasticsearch DSL (<1ms translation time)
- β Supports both SQL and JSON DSL for maximum flexibility
Feature Comparison Table
| Feature | Official Java Client | ES SQL API | SoftClient4ES |
|---|---|---|---|
| Query Language | Builder pattern | SQL (basic) | SQL (extended) + JSON DSL |
| Type Safety | β οΈ Partial | β No | β Full |
| Compile-Time Validation | β οΈ Builder only | β No | β Yes (fields + types + SQL rules) |
| Case Class Deserialization | β οΈ Manual | β οΈ Manual | β Automatic |
| Window Functions | β No | β No | β Yes |
| Nested Query Depth | β Unlimited | β 1 level | β Unlimited |
| GROUP BY Validation | β No | β Runtime | β Compile-time |
| Streaming Support | β Yes | β No | β Yes (Akka Streams) |
| Performance | β Native | β Native | β Minimal runtime overhead (<1ms translation time) |
| Refactoring Safety | β οΈ Partial | β Manual | β Compiler-enforced |
| Error Detection | β οΈ Mixed | π₯ Runtime | β Compile-time |
| Learning Curve | Medium | Low | Low (if you know SQL) |
| JSON DSL Support | β Native | β No | β Yes (full support) |
When to Use Each
Official Java Client:
- β Need to use latest Elasticsearch features immediately
- β Working with edge cases not yet covered by SoftClient4ES
- β Building low-level infrastructure libraries
Elasticsearch SQL API:
- β Simple ad-hoc queries for exploration
- β Quick prototyping
- β Basic reporting needs
SoftClient4ES:
- β Application code with type-safe domain models
- β Complex analytics queries (window functions, unlimited nesting)
- β Teams that prioritize compile-time safety
- β Projects with frequent refactoring
- β Onboarding new developers quickly
- β Need both SQL and JSON DSL flexibility
You can use all approaches together! SoftClient4ES is built on top of official clients:
// Type-safe SQL for most queriesclient.searchAs[Product]("SELECT id, name, price FROM products WHERE price < 100")
// Native JSON DSL when needed (full support)client.singleSearch(ElasticQuery(query = """{"query": {"match_all": {}}}""", indices = Seq("products")))// Official client for cutting-edge features// (SoftClient4ES provides access to underlying client)JDBC Driver β
SoftClient4ES provides a JDBC Type 4 driver for Elasticsearchβββone self-contained JAR per ES major, 6 through 9βββenabling:
β Cross-index JOINβββwhich Elasticsearchβs native SQL API cannot do at all (2 JOINs per query, free in Community)
β BI tool integrationβββSuperset (dedicated dialect), DBeaver and DataGrip are tested; Tableau, Power BI and Metabase connect over standard JDBC
β Window functions (ROW_NUMBER, RANK, DENSE_RANK, STDDEV/VARIANCE, PERCENTILE_CONT/DISC) and multi-level nesting, handled recursively
β Standard JDBC interface for maximum compatibility
β SQL validation (runtime validation, since JDBC canβt do compile-time)
This is a significant advantage over Elasticsearchβs native SQL API, which has no cross-index JOIN, limited SQL feature support, and single-level nesting restrictions.
Note: a JDBC driver canβt provide compile-time type safetyβββitβs a runtime APIβββbut it benefits from everything else: window functions, multi-level nesting, comprehensive SQL validation, and better error messages. The driver is licensed under the Elastic License 2.0 (free to use, sources not public); the core engine remains Apache 2.0.
Performance Deep Dive β‘
SQL Translation Process
SoftClient4ES translates SQL to Elasticsearch DSL at runtime, not compile-time. Hereβs why this is still highly efficient:
Translation Pipeline
SQL Query String βββββββββββββββββββββββββ SQL Parser β <1msβ (ANTLR-based) ββββββββββββββββββββββββ βββββββββββββββββββββββββ AST Generation β <0.1msβββββββββββββββββββββββ βββββββββββββββββββββββββ DSL Translation β <0.5msβ (Optimized) ββββββββββββββββββββββββ βββββββββββββββββββββββββ Elasticsearch DSL ββββββββββββββββββββββββ βββββββββββββββββββββββββ Execute Query β 10-1000ms+βββββββββββββββββββββββWhy Runtime Translation is Acceptable
- Translation is fast: Optimized parser with <1ms overhead
- ES execution dominates: Network + processing = 10β1000ms+
- Compile-time validation: Most errors caught before runtime
- No pre-compilation needed: Simpler deployment and debugging
Comparison with Native DSL
// Native DSL: 0ms translation (already DSL)val dslQuery = """{"query": {"match": {"name": "Alice"}}}"""client.singleSearch(ElasticQuery(query = dslQuery, indices = Seq("users")))// Total time: 0ms translation + 50ms ES = 50ms// SoftClient4ES SQL: 0.3ms translationval sqlQuery = """SELECT * FROM users WHERE name = 'Alice'"""client.searchAsUnchecked[User](sqlQuery)// Total time: 0.3ms translation + 50ms ES = 50.3ms// Difference: 0.3ms (0.6% overhead)Verdict: The <1ms translation overhead is negligible compared to the 10x development speed improvement and zero production errors from compile-time validation.
Real-World Impact π
Weβve been using SoftClient4ES in production for over a year. Hereβs what weβve observed:
Development Speed
Before SoftClient4ES:
Write query β Google DSL syntax β Copy from StackOverflow β Modify β Test β Debug β RepeatAverage time per query: 15-30 minutesAfter SoftClient4ES:
Write SQL β Compile β WorksAverage time per query: 2-5 minutesTime saved: 10β25 minutes per query Queries per week: 20β50 Annual time saved: 173β1,083 hours
Onboarding Time
Before:
- Week 1: Learn Elasticsearch basics
- Week 2: Master Query DSL syntax
- Week 3: Understand aggregations and nested queries
- Week 4: Finally productive
After:
- Day 1: Write SQL queries (already know SQL)
- Day 2: Productive
Onboarding time: 4 weeks β 2 days β‘
Production Incidents
Our team (10 developers, 6 months):
| Metric | Before | After | Improvement |
|---|---|---|---|
| Query-related incidents | 12 | 0 | 100% |
| Average incident cost | $15,000 | $0 | $180,000 saved |
| Hours debugging queries | 240 | 20 | 92% reduction |
| Failed deployments (query errors) | 8 | 0 | 100% |
These numbers are specific to our use case, but they illustrate the potential impact of compile-time validation.
Getting Started π
1. Add Dependency
libraryDependencies += "app.softnetwork.elastic" %% "softclient4es8-java-client" % "0.21.0"2. Write Your First Type-Safe Query
import app.softnetwork.elastic.client._
// Initialize clientval client = ElasticClientFactory.create()// Define domain modelcase class Product(id: String, name: String, price: Double, stock: Int)// Write SQL query with compile-time validationval products: Source[(Product, ScrollMetrics), NotUsed] = client.scrollAs[Product](""" SELECT id, name, price, stock FROM products WHERE price < 100 AND stock > 0 ORDER BY price ASC""", client.defaultScrollConfig)// Process resultsproducts.runWith(Sink.foreach { case (product, _) => println(s"${product.name}: $${product.price} (${product.stock} in stock)")})Thatβs it. Type-safe. Validated. Production-ready. β
3. Try Window Functions
case class SalesAnalysis( productId: String, saleDate: String, amount: Double, totalSales: Double, avgSaleAmount: Double, firstSaleAmount: Double, lastSaleAmount: Double)
val analysis: Source[SalesAnalysis, NotUsed] = client.scrollAs[SalesAnalysis](""" SELECT product_id AS productId, sale_date AS saleDate, amount, SUM(amount) OVER (PARTITION BY product_id) AS totalSales, AVG(amount) OVER (PARTITION BY product_id) AS avgSaleAmount, FIRST_VALUE(amount) OVER (PARTITION BY product_id ORDER BY sale_date) AS firstSaleAmount, LAST_VALUE(amount) OVER (PARTITION BY product_id ORDER BY sale_date) AS lastSaleAmount FROM sales WHERE sale_date >= '2024-01-01' ORDER BY product_id, sale_date""")Common Questions β
Q: Does this replace the official Elasticsearch clients?
A: No, it complements them. SoftClient4ES is built on top of official clients and provides a higher-level, type-safe API. You can use both:
// Type-safe SQL for most queriesclient.searchAs[Product]("SELECT id, name, price FROM products WHERE price < 100")
// Native JSON DSL when needed (full support)client.singleSearch(ElasticQuery(query = """{"query": {"match_all": {}}}""", indices = Seq("products")))Q: Whatβs the performance overhead of SQL parsing?
A: Negligible. SQL parsing takes <1ms and happens once per query execution. The generated DSL is identical to hand-written queries. Elasticsearch execution time (10β1000ms+) dominates.
Q: Can I use this with my existing Elasticsearch cluster?
A: Yes! SoftClient4ES works with Elasticsearch 6, 7, 8, and 9. No cluster changes required.
Q: What about SQL injection?
A: Use a PreparedStatement β the JDBC driver implements it, and it is the right tool for binding typed values. Know how it works, though: Elasticsearch has no server-side prepared statements, so the driver substitutes parameters into the SQL string on the client, before parsing. String parameters are escaped, which handles ordinary values safely, but that is not the hard server-side boundary Postgres or MySQL give you. So keep treating untrusted input as untrusted β constrain it at your edge, and allow-list anything that lands in an identifier position such as an index or column name, since escaping only covers literals.
Q: Does this work with Kibana/Grafana?
A: Grafana, yesβββvia the Arrow Flight SQL server and Grafanaβs Flight SQL data source plugin, giving you SQL-based dashboards over Elasticsearch. Kibana, noβββKibana talks to Elasticsearch natively and isnβt a SQL client, so thereβs nothing to plug into.
For BI more broadly, the JDBC driver covers it: Superset (dedicated dialect), DBeaver and DataGrip are tested; Tableau, Power BI and Metabase work via standard JDBC. It supports window functions and multi-level nestingβββneither of which Elasticsearchβs native SQL API offers.
Q: What if I need a feature not supported by SQL?
A: Use native JSON DSL! SoftClient4ES has full support for JSON DSL queries:
// SQL for common casesclient.searchAsUnchecked[Product]("SELECT * FROM products WHERE price < 100")
// Native JSON DSL for any advanced featureclient.singleSearch(ElasticQuery(query = complexDslQuery, indices = Seq("products")))The Bottom Line π‘
Working with Elasticsearch is powerful, but it can be challenging. We built SoftClient4ES to address pain points we experienced in our own projects:
β Compile-time validation β Catch errors before production β Type safety β Automatic validation of types and structures β SQL validation β Pure SQL rules enforced (GROUP BY, etc.) β SQL syntax β Faster development and easier maintenance β Window functions β Advanced analytics without complex DSL β Unlimited nesting β No restrictions on query complexity β Refactoring safety β Compiler finds all affected queries β Minimal runtime overhead β SQL translated to optimized Elasticsearch DSL (<1ms translation time) β Full flexibility β Supports both SQL and JSON DSL
Weβre not claiming SoftClient4ES is perfect or right for every use case. But if youβre tired of debugging production errors, maintaining complex JSON queries, or onboarding new developers, it might be worth a try.
Write queries humans can read. Let the compiler ensure theyβre correct.
Ready to Try Compile-Time Validation? π―
// Your future: type-safe, validated, production-ready queriescase class Order( id: String, customerId: String, total: Double, firstOrderAmount: Double, totalSpent: Double, orderCount: Long)
val orders = client.scrollAs[Order](""" SELECT id, customer_id AS customerId, total, FIRST_VALUE(total) OVER (PARTITION BY customer_id ORDER BY order_date) AS firstOrderAmount, SUM(total) OVER (PARTITION BY customer_id) AS totalSpent, COUNT(*) OVER (PARTITION BY customer_id) AS orderCount FROM orders WHERE order_date >= '2024-01-01' ORDER BY customer_id, order_date""")// β
Compiles only if all fields match (names + types)// β
Typos caught immediately// β
Type mismatches caught immediately// β
GROUP BY rules validated// β
Refactoring safe// β
Production readyGet Started:
- π Documentation: Complete Guide
- π» GitHub: SoftClient4ES Repository
- π¬ Discussions: Ask Questions
- π§ Contact: admin@softnetwork.fr
Have you experienced query-related production incidents? How do you handle Elasticsearch query validation in your projects? Weβd love to hear your thoughts and experiences! π
Built with β€οΈ by the SoftNetwork team
This post first appeared on Medium. This is the maintained version — free to read, no account, and corrected as the product moves.
Try it on your own cluster
SoftClient4ES runs SQL — DDL, DML, queries, cross-index JOINs and materialized views — on Elasticsearch 6 through 9.