Skip to content

Query DSL Guide ​

Build type-safe Elasticsearch queries using generated metamodels. The Metalastic Query DSL provides a fluent API inspired by QueryDSL for SQL databases, with compile-time safety and IDE auto-completion.

Getting Started ​

Overview ​

The Query DSL module is an optional add-on that works with generated metamodels to provide:

  • Type-safe query construction - Compile-time validation of field names and types
  • Fluent API - Intuitive method chaining for query building
  • IDE support - Full auto-completion for fields and query methods
  • Automatic value conversion - Handles dates, enums, collections, and custom types

Installation ​

Add the DSL module to your dependencies:

kotlin
dependencies {
    // Core modules (required)
    implementation("com.ekino.oss:metalastic-core:1.2.10")
    ksp("com.ekino.oss:metalastic-processor:1.2.10")

    // Query DSL module (optional) - choose based on your Spring Data ES version
    implementation("com.ekino.oss:metalastic-elasticsearch-dsl:1.2.10")  // 6.0.x (rolling)
    // OR
    implementation("com.ekino.oss:metalastic-elasticsearch-dsl-5.5:1.2.10")  // 5.4-5.5 (frozen)
    // OR
    implementation("com.ekino.oss:metalastic-elasticsearch-dsl-5.3:1.2.10")  // 5.0-5.3 (frozen)
}

Version Compatibility ​

The DSL module uses a rolling release strategy with frozen artifacts for stability:

ArtifactStrategySupported Spring Data ESBrings Transitively
metalastic-elasticsearch-dslRolling6.0.x (currently)Spring Data ES 6.0.1
metalastic-elasticsearch-dsl-5.5Frozen5.4.x - 5.5.xSpring Data ES 5.5.6
metalastic-elasticsearch-dsl-5.3Frozen5.0.x - 5.3.xSpring Data ES 5.3.13

Rolling Release: The base artifact (elasticsearch-dsl) tracks the latest Spring Data ES versions. When breaking changes occur (like the 6.0 release), we freeze the previous version and update the rolling artifact.

Understanding the DSL Syntax ​

The Metalastic Query DSL offers two equivalent syntaxes for building queries. Both provide identical functionality and type safety - choose based on your preference!

Two Syntaxes Comparison ​

FeatureOperator Syntax (must + { })Classical Syntax (mustDsl { })
StyleModern, operator-overloadedTraditional method calls
ReadabilityConcise, mirrors ES JSONExplicit, self-documenting
SearchabilityUses + operatorEasy to grep/search
Learning CurveKotlin-idiomaticFamiliar to all developers
Type Safety✅ Full compile-time✅ Full compile-time
IDE Support✅ Complete autocomplete✅ Complete autocomplete

Operator Syntax (must + { }) ​

Clean, operator-overloaded syntax using the + operator:

kotlin
import com.ekino.oss.metalastic.elasticsearch.dsl.*
import com.example.MetaProduct.Companion.product

val query = BoolQuery.of {
    it.boolQueryDsl {
        must + {
            product.title match "laptop"
            product.status term ProductStatus.ACTIVE
        }

        filter + {
            product.price greaterThan 100.0
            product.inStock term true
        }

        should + {
            product.brand term "Apple"
            product.brand term "Dell"
        }

        mustNot + {
            product.tags containsTerm "discontinued"
        }

        minimumShouldMatch(1)
    }
}

Benefits:

  • ✨ Natural syntax - Reads like Elasticsearch JSON structure
  • 🎯 Concise - Less boilerplate, focus on query logic
  • 📖 Visual clarity - + operator clearly shows clause additions
  • 🔗 Composable - Easy to see query structure at a glance

Classical Syntax (mustDsl { }) ​

Traditional method-based syntax with explicit names:

kotlin
import com.ekino.oss.metalastic.elasticsearch.dsl.*
import com.example.MetaProduct.Companion.product

val query = BoolQuery.of {
    it.boolQueryDsl {
        mustDsl {
            product.title match "laptop"
            product.status term ProductStatus.ACTIVE
        }

        filterDsl {
            product.price greaterThan 100.0
            product.inStock term true
        }

        shouldDsl {
            product.brand term "Apple"
            product.brand term "Dell"
        }

        mustNotDsl {
            product.tags containsTerm "discontinued"
        }

        minimumShouldMatch(1)
    }
}

Benefits:

  • 📝 Explicit naming - Clear intent with mustDsl, shouldDsl method names
  • 🔍 Searchable - Easy to find in codebase with standard method search
  • 🎓 Familiar - Traditional method call syntax, no operators
  • 🔄 Consistent - Matches common DSL patterns in other libraries

Mixing Both Syntaxes ​

Both syntaxes are fully interchangeable and can be mixed in the same query:

kotlin
val query = BoolQuery.of {
    it.boolQueryDsl {
        // Use operator syntax for simple clauses
        must + {
            product.title match "laptop"
        }

        // Use classical syntax for complex clauses
        filterDsl {
            product.price.range(Range.closed(100.0, 2000.0))
            product.category term "electronics"
        }

        // Mix freely based on readability
        should + { product.featured term true }
    }
}

Which Syntax Should You Use? ​

Our Recommendation: Choose based on team preference - there's no "wrong" choice!

  • Prefer operator syntax if: Your team likes concise Kotlin idioms and operator overloading
  • Prefer classical syntax if: Your team values explicit method names and searchability
  • Mix both if: You want to optimize readability case-by-case

Throughout this guide, we'll show both syntaxes in examples to help you learn both approaches.

Basic Usage ​

Import the metamodel from its companion object:

kotlin
import com.ekino.oss.metalastic.elasticsearch.dsl.*
import com.example.MetaProduct.Companion.product

// Build a simple query (operator syntax)
val query = BoolQuery.of {
    it.boolQueryDsl {
        must + {
            product.title match "laptop"
        }
    }
}

// Same query (classical syntax)
val query = BoolQuery.of {
    it.boolQueryDsl {
        mustDsl {
            product.title match "laptop"
        }
    }
}

Adding a Raw Query ​

Every QueryVariantDsl scope exposes a unary + operator on any QueryVariant, so you can drop in a query the DSL doesn't (yet) wrap without leaving the DSL block:

kotlin
import co.elastic.clients.elasticsearch._types.query_dsl.WrapperQuery

val query = BoolQuery.of {
    it.boolQueryDsl {
        filter + {
            // The DSL has no helper for wrapper queries — add the raw QueryVariant directly.
            +WrapperQuery.of { it.query(base64EncodedQuery) }
        }
    }
}

Use when:

  • A query type has no dedicated DSL function yet
  • You already built a QueryVariant (or a Query.Builder lambda result) some other way

Null and Empty Handling ​

Every DSL function accepts nullable arguments and is a no-op when the argument is "empty" in the relevant sense:

InputResult
null valueNo query added
Blank String ("", " ")No query added
Empty or null collectionNo query added
Collection whose values all convert to null (e.g. all-blank strings)No query added
null RangeNo query added
Range.all() (no bounds)match_none query added — see Range Queries

This also applies at the composition level:

  • bool { } and disMax { } omit themselves entirely (no clause is added to the parent) when the block didn't add any clause.
  • minimumShouldMatch(null) is a no-op — call it unconditionally.

Because of this contract, a redundant null-guard around a DSL call is unnecessary:

kotlin
// ❌ Redundant — the guard duplicates what the DSL already does
if (searchTerm != null) {
    must + {
        product.title match searchTerm
    }
}

// ✅ Just pass the (possibly null) value directly
must + {
    product.title match searchTerm
}

Empty collections are "no constraint," not "match nothing"

Elasticsearch itself treats terms: [] (and ids: []) as matching no document. This DSL instead treats an empty collection as "no constraint" — the function is simply skipped, and the surrounding query behaves as if the clause weren't there.

This matters for allow-lists computed at runtime: if allowedTenants is unexpectedly empty, doc.tenantId terms allowedTenants does not restrict the query — it silently exposes every tenant instead of none.

Use the explicit escape hatch when an empty collection should mean "match nothing":

kotlin
must + {
    if (allowedTenants.isEmpty()) matchNone() else doc.tenantId terms allowedTenants
}

Full-text Queries ​

Full-text queries analyze the query string and search analyzed fields. These queries are best for natural language search.

Match Query ​

Search for terms in a text field:

kotlin
// Simple match
product.title match "laptop computer"

// With options
product.title.match("laptop") {
    fuzziness("AUTO")
    operator(Operator.And)
    minimumShouldMatch("75%")
}

Use when:

  • Searching user input in text fields
  • Need fuzzy matching or typo tolerance
  • Want analyzed search (stemming, synonyms, etc.)

Multi-Match Query ​

Search across multiple fields:

kotlin
// Simple multi-match — call on a Collection<Metamodel<*>>
listOf(product.title, product.description, product.brand) multiMatch "laptop"

// With type and options
listOf(product.title, product.description).multiMatch("gaming laptop") {
    type(TextQueryType.BestFields)
    tieBreaker(0.3)
}

Use when:

  • Searching across multiple text fields
  • Want to boost certain fields over others
  • Need cross-field matching strategies

Match Phrase Query ​

Search for exact phrase in order:

kotlin
// Exact phrase
product.description matchPhrase "high performance gaming"

// With slop (word distance tolerance)
product.description.matchPhrase("performance gaming") {
    slop(2)  // allows up to 2 words between terms
}

Use when:

  • Need exact phrase matching
  • Searching for multi-word terms
  • Order of words matters

Match Phrase Prefix Query ​

Autocomplete-style prefix matching:

kotlin
// Prefix match
product.title matchPhrasePrefix "lap"  // matches "laptop", "laptop computer", etc.

// With max expansions
product.title.matchPhrasePrefix("gam") {
    maxExpansions(50)
}

Use when:

  • Building autocomplete features
  • Need prefix matching on phrases
  • Want fuzzy completion

Contains Match (Collection Field) ​

For fields whose value is itself a collection (e.g. a KeywordField<Collection<String>>), containsMatch runs a match query against the field, the same way match does for scalar fields:

kotlin
product.tags containsMatch "kotlin"

// With options
product.tags.containsMatch("kotlin") {
    fuzziness("AUTO")
}

Use when:

  • The field is a multi-value array in your mapping and you want analyzed text matching against it (as opposed to the exact-value semantics of containsTerm)

Combined Fields Query ​

Search across multiple text fields as if their contents were indexed into one combined field — see the combined fields query documentation. Call it on a Collection<Metamodel<*>>:

kotlin
listOf(product.title, product.description).combinedFields("gaming laptop") {
    operator(CombinedFieldsOperator.And)
}

Use when:

  • The fields share the same analyzer and you want relevance scoring based on the combined term frequency across them (unlike multiMatch, which scores each field independently)

Common Terms Query ​

commonTerms builds a Common terms query. This query type was deprecated by Elasticsearch in favor of the standard match query with its modern term-frequency-aware scoring, and has been removed from recent Elasticsearch server versions — treat this DSL function as legacy support for older clusters rather than a first choice for new code:

kotlin
product.title.commonTerms("the quick brown fox") {
    cutoffFrequency(0.001)
}

Use when:

  • You must integrate with an older Elasticsearch cluster that still supports common queries; prefer match otherwise

Term-level Queries ​

Term-level queries match exact values without analysis. Use these for structured data like IDs, statuses, numbers, and dates.

Term Query ​

Match an exact value:

kotlin
// Exact match
product.status term Status.ACTIVE
product.category term "electronics"
product.id term "PROD-12345"

// With enums (automatic conversion)
product.status term ProductStatus.IN_STOCK

Use when:

  • Searching keyword fields
  • Exact value matching (IDs, statuses, codes)
  • Filtering by enums or boolean values

Terms Query ​

Match any value from a list. The typed vararg form is the recommended default:

kotlin
// Strings, numbers, booleans
product.category.terms("electronics", "computers", "gaming")
product.price.terms(100.0, 200.0, 500.0)

// Enums — both vararg and Collection forms are supported
product.status.terms(Status.ACTIVE, Status.PENDING)
product.status terms listOf(Status.ACTIVE, Status.PENDING)

// Dates
product.publishedAt.terms(Instant.now(), Instant.now().minusSeconds(3600))

Use when:

  • Filtering by multiple values (OR logic)
  • Building faceted search
  • "Any of" filtering

Typed vararg vs FieldValue collection escape hatch ​

terms ships two complementary flavors:

  1. Typed vararg — one overload per supported element type (String, Int, Long, Float, Double, Boolean, every DateField<*> time type, and <T : Enum<T>>). The compiler enforces that the values match the field. Prefer this form.

  2. Typed Collection<T> — supported directly for String and <T : Enum<T>> (the two well-defined-conversion types where there's no footgun risk):

    kotlin
    val ids: List<String> = userInput.parseIds()
    product.id terms ids
  3. Collection<FieldValue> escape hatch — for runtime collections of any other type, materialize each value into FieldValue yourself:

    kotlin
    import co.elastic.clients.elasticsearch._types.FieldValue
    
    val timestamps: List<Long> = userInput.parseTimestamps()
    product.createdAt terms timestamps.map { FieldValue.of(it) }

    The name signals intent: by going through FieldValue, the caller takes responsibility for the conversion. The DSL deliberately does not accept arbitrary Collection<Any> (which would silently toString() whatever it gets — a footgun).

Contains Term (Collection Field) ​

For fields whose value is itself a collection (e.g. KeywordField<Collection<String>>), containsTerm queries whether the field's collection contains a single given value — the singular analog of containsTerms below:

kotlin
product.tags containsTerm "discontinued"

// With enums
product.statuses containsTerm Status.ACTIVE

Use when:

  • The field is a multi-value array in your Elasticsearch mapping and you're filtering on a single exact value

Contains Terms (Collection Field) ​

For fields whose value is itself a collection (e.g. KeywordField<Collection<String>>), containsTerms queries whether the field's collection intersects any of the given values. Same two-flavor design as terms — see the escape hatch note above.

kotlin
// Strings, numbers, booleans — vararg form
product.tags.containsTerms("kotlin", "elasticsearch", "spring")
product.scores.containsTerms(10, 20, 30)

// Strings and enums also support the Collection form directly
product.tags containsTerms listOf("kotlin", "elasticsearch")
product.statuses.containsTerms(Status.ACTIVE, Status.PENDING)
product.statuses containsTerms listOf(Status.ACTIVE, Status.PENDING)

// For other types, use the FieldValue escape hatch
import co.elastic.clients.elasticsearch._types.FieldValue

val scoreSet: Set<Int> = userInput.scores
product.scores containsTerms scoreSet.map { FieldValue.of(it) }

The receiver constraint (Metamodel<out Collection<T>>) means the compiler only lets you call containsTerms on actual collection fields — using terms on a collection field, or containsTerms on a scalar field, is a compile error.

Use when:

  • The field is a multi-value array in your Elasticsearch mapping (e.g. tags, categories)
  • You want "the field's array intersects the given values" semantics
  • "Any of" filtering against a multi-value field

Terms Set Query ​

Match a minimum number of terms:

kotlin
// Match at least N terms
product.tags.termsSet(listOf("new", "sale", "featured")) {
    minimumShouldMatchField("required_matches")
    // OR
    minimumShouldMatchScript("Math.min(params.num_terms, 2)")
}

Use when:

  • Need flexible term matching
  • Dynamic minimum should match requirements
  • Advanced filtering logic

Wildcard Query ​

Pattern matching with wildcards:

kotlin
// * matches zero or more characters
product.sku wildCard "PROD-*-2024"

// ? matches single character
product.code wildCard "AB?-123"

// Case insensitive
product.email.wildCard("*@example.com") {
    caseInsensitive(true)
}

Use when:

  • Pattern-based search
  • Partial matching needed
  • Building search filters

Prefix Query ​

Match terms starting with a prefix:

kotlin
// Simple prefix
product.code prefix "ABC"
product.sku prefix "PROD-2024"

// With case insensitivity
product.email.prefix("user") {
    caseInsensitive(true)
}

Use when:

  • Autocomplete on keyword fields
  • Filtering by prefix
  • Building type-ahead search

Regexp Query ​

Regular expression matching:

kotlin
// Basic regexp
product.email regexp "[a-z]+@[a-z]+\\.[a-z]+"
product.sku regexp "PROD-[0-9]{4}-.*"

// With flags
product.code.regexp("[A-Z]{3}-[0-9]+") {
    flags("ALL")
    maxDeterminizedStates(10000)
}

Use when:

  • Complex pattern matching
  • Validation-style queries
  • Advanced filtering logic

IDs Query ​

Match documents by their _id, via the IDs query. Unlike the field-based functions above, idsQuery is a top-level function (it doesn't take a Metamodel receiver):

kotlin
idsQuery(listOf("PROD-1", "PROD-2", "PROD-3"))

If ids is null or empty, no query is added — see Null and Empty Handling (note the same "empty means no constraint" caveat applies here as for terms).

Use when:

  • You already resolved a specific set of document IDs (e.g. from a first search pass) and want to fetch or filter on exactly those documents

Boolean Queries ​

Boolean queries combine multiple queries using boolean logic (must, should, filter, must_not). Remember, you can use either operator syntax (must +) or classical syntax (mustDsl) - or mix both!

Basic Boolean Query (Operator Syntax) ​

Using the modern + operator for clause additions:

kotlin
import com.ekino.oss.metalastic.elasticsearch.dsl.*
import com.example.MetaProduct.Companion.product

val query = BoolQuery.of {
    it.boolQueryDsl {
        // must: All conditions must match (affects score)
        must + {
            product.title match "laptop"
            product.status term Status.ACTIVE
        }

        // filter: All conditions must match (no scoring)
        filter + {
            product.price range 500.0.fromInclusive()..2000.0
            product.category term "electronics"
        }

        // should: At least one should match (boosts score)
        should + {
            product.brand term "Apple"
            product.brand term "Dell"
            product.brand term "Lenovo"
        }

        // must_not: Must not match any
        mustNot + {
            product.tags containsTerm "discontinued"
        }

        // Minimum should match
        minimumShouldMatch(1)
    }
}

Basic Boolean Query (Classical Syntax) ​

Same query using explicit method names:

kotlin
import com.ekino.oss.metalastic.elasticsearch.dsl.*
import com.example.MetaProduct.Companion.product

val query = BoolQuery.of {
    it.boolQueryDsl {
        // must: All conditions must match (affects score)
        mustDsl {
            product.title match "laptop"
            product.status term Status.ACTIVE
        }

        // filter: All conditions must match (no scoring)
        filterDsl {
            product.price range 500.0.fromInclusive()..2000.0
            product.category term "electronics"
        }

        // should: At least one should match (boosts score)
        shouldDsl {
            product.brand term "Apple"
            product.brand term "Dell"
            product.brand term "Lenovo"
        }

        // must_not: Must not match any
        mustNotDsl {
            product.tags containsTerm "discontinued"
        }

        // Minimum should match
        minimumShouldMatch(1)
    }
}

Nested Boolean Queries ​

The DSL provides a built-in bool { } function to create nested boolean queries inside any clause. This is cleaner than manually constructing BoolQuery.of { }:

kotlin
val complexQuery = BoolQuery.of {
    it.boolQueryDsl {
        must + {
            // Use the built-in bool { } function for nested logic
            bool {
                should + {
                    product.title match "laptop"
                    product.description match "laptop"
                }
                minimumShouldMatch(1)
            }
        }

        filter + {
            product.inStock term true
            product.price range 0.0.fromExclusive()..null  // Price > 0
        }
    }
}

Benefits of bool { } function:

  • ✨ Cleaner syntax - no need for BoolQuery.of { boolQueryDsl { } }
  • 🎯 Automatic empty query handling - skips query if no conditions added
  • 📖 Better readability - clear intent for nested boolean logic

Multiple Nested Boolean Queries ​

kotlin
val advancedQuery = BoolQuery.of {
    it.boolQueryDsl {
        must + {
            // First nested bool: title OR description contains search term
            bool {
                should + {
                    product.title match "laptop"
                    product.description match "laptop"
                }
                minimumShouldMatch(1)
            }
        }

        filter + {
            // Second nested bool: brand filters
            bool {
                should + {
                    product.brand term "Apple"
                    product.brand term "Dell"
                    product.brand term "Lenovo"
                }
                minimumShouldMatch(1)
            }
        }

        // Simple conditions alongside nested bools
        mustNot + {
            product.tags containsTerm "discontinued"
        }
    }
}

Mixing Both Syntaxes ​

Use whichever syntax makes your code most readable:

kotlin
val mixedQuery = BoolQuery.of {
    it.boolQueryDsl {
        // Operator syntax for simple conditions
        must + {
            product.status term Status.ACTIVE
        }

        // Classical syntax for complex filters
        filterDsl {
            product.price range 100.0.fromInclusive()..1000.0
            product.category term "electronics"
            product.rating range 4.0.fromInclusive()..null
        }

        // Back to operator for simple should clause
        should + { product.featured term true }
    }
}

Boolean Logic Breakdown ​

OccurrenceBehaviorAffects ScoreUse For
must / mustDslAll queries must matchYesRequired conditions with relevance scoring
filter / filterDslAll queries must matchNoRequired conditions without scoring (faster)
should / shouldDslAt least one should matchYesOptional conditions that boost score
mustNot / mustNotDslMust not match anyNoExclusion filters

Performance Tip: Use filter instead of must for conditions that don't need relevance scoring - it's more efficient!

shouldAtLeastOneOf ​

Builds a nested bool query with one should clause per distinct value, requiring at least one of them to match — see minimum_should_match. It's skipped entirely when values is null or empty:

kotlin
filter + {
    shouldAtLeastOneOf(listOf("premium", "enterprise")) { tier ->
        product.tier term tier
    }
}

Use when:

  • You have a runtime collection of values and want an "any of" match built from a per-value block, rather than a single terms query — e.g. when each value needs its own multi-clause logic

Dis Max Query ​

Creates a Disjunction max query, which takes the highest matching score among its clauses rather than summing them:

kotlin
must + {
    disMax({ tieBreaker(0.3) }) {
        product.title.match(searchTerm) { boost(3.0f) }
        product.description.match(searchTerm) { boost(1.0f) }
    }
}

disMax is omitted entirely when its block doesn't add any query (see Null and Empty Handling).

Use when:

  • Several fields can independently match the same query and you want the best single match to drive scoring, instead of combining scores additively (as bool / should would)

Range Queries ​

Query numeric, date, or string fields with range constraints.

Using Guava Range ​

kotlin
import com.google.common.collect.Range

// Closed range [min, max]
product.price.range(Range.closed(100.0, 500.0))

// Open range (min, max)
product.price.range(Range.open(0.0, 1000.0))

// Half-open ranges
product.price.range(Range.closedOpen(100.0, 500.0))  // [100, 500)
product.price.range(Range.openClosed(100.0, 500.0))  // (100, 500]

// Unbounded ranges
product.price.range(Range.atLeast(500.0))   // >= 500
product.price.range(Range.atMost(1000.0))   // <= 1000
product.price.range(Range.greaterThan(100.0))  // > 100
product.price.range(Range.lessThan(1000.0))    // < 1000

Null, Empty, and Inverted Ranges ​

range follows the same null/empty contract as the rest of the DSL, with two range-specific behaviors worth calling out:

  • A null Range is skipped entirely — no query is added.

  • A Range.all() (no lower bound and no upper bound) produces a match_none query instead of an unbounded range query, since an unconstrained range would otherwise match every document with a value for the field:

    kotlin
    product.price range Range.all<Double>()  // → match_none
  • With the mathematical (StartBound) notation, when the lower bound is greater than the upper bound (e.g. 10.fromInclusive()..5), the resulting range collapses to Range.all() — and therefore also produces match_none:

    kotlin
    product.price range 10.0.fromInclusive()..5.0  // → Range.all() → match_none

Because nullable bounds already fold into Range.all() when both sides are absent, minPrice.fromInclusive()..maxPrice (with minPrice/maxPrice both nullable) is the idiomatic way to express "optional bounds" — no manual null-checking required:

kotlin
// minPrice: Double?, maxPrice: Double?
product.price range minPrice.fromInclusive()..maxPrice

Convenience Methods ​

kotlin
// Greater than (>)
product.price greaterThan 100.0
product.quantity greaterThan 0

// Less than (<)
product.price lowerThan 1000.0
product.rating lowerThan 5.0

// "This value is between two fields" — useful for active-period checks.
// Signature: value.mustBeBetween(from: Metamodel<T>, to: Metamodel<T>)
val today = LocalDate.now()
today.mustBeBetween(product.validFrom, product.validUntil)

Mathematical Notation with Kotlin Ranges ​

Metalastic provides mathematical notation for range queries using Kotlin operators (.. and ..<) combined with fromInclusive() and fromExclusive() extension functions. This approach offers a natural, type-safe way to express range boundaries.

StartBound Overview ​

The StartBound inline value class enables zero-overhead range notation with compile-time type safety:

kotlin
// Basic pattern:
// lowerBound.fromInclusive()..upperBound    →  [lowerBound, upperBound]
// lowerBound.fromInclusive()..<upperBound   →  [lowerBound, upperBound)
// lowerBound.fromExclusive()..upperBound    →  (lowerBound, upperBound]
// lowerBound.fromExclusive()..<upperBound   →  (lowerBound, upperBound)

All Bracket Combinations ​

Mathematical NotationKotlin SyntaxMeaning
[10, 100]10.fromInclusive()..10010 ≤ x ≤ 100 (both included)
[10, 100)10.fromInclusive()..<10010 ≤ x < 100 (exclude upper)
(10, 100]10.fromExclusive()..10010 < x ≤ 100 (exclude lower)
(10, 100)10.fromExclusive()..<10010 < x < 100 (both excluded)

Closed Ranges (Both Bounds Included) ​

kotlin
// [10, 100] → 10 ≤ x ≤ 100
product.price range 10.0.fromInclusive()..100.0

// Same with variables
val minPrice = 10.0
val maxPrice = 100.0
product.price range minPrice.fromInclusive()..maxPrice

When to use:

  • Most common range type
  • Include boundary values in results
  • Equivalent to Range.closed(10.0, 100.0)

Half-Open Ranges ​

kotlin
// [10, 100) → 10 ≤ x < 100 (exclude upper bound)
product.price range 10.0.fromInclusive()..<100.0

// (10, 100] → 10 < x ≤ 100 (exclude lower bound)
product.price range 10.0.fromExclusive()..100.0

// (10, 100) → 10 < x < 100 (exclude both bounds)
product.price range 10.0.fromExclusive()..<100.0

When to use:

  • Precise boundary control
  • Avoid overlapping ranges
  • Mathematical accuracy requirements

Unbounded Ranges with Null ​

Use null to represent infinity (∞):

kotlin
// [10, ∞) → x ≥ 10
product.price range 10.0.fromInclusive()..null

// (10, ∞) → x > 10
product.price range 10.0.fromExclusive()..null

// (-∞, 100] → x ≤ 100
product.price range null.fromInclusive()..100.0

// (-∞, 100) → x < 100
product.price range null.fromInclusive()..<100.0

When to use:

  • Open-ended ranges
  • "At least" or "at most" constraints
  • Clearer than separate greaterThan/lowerThan calls

Complete Example ​

kotlin
import com.ekino.oss.metalastic.elasticsearch.dsl.*
import com.example.MetaProduct.Companion.product

val searchQuery = BoolQuery.of {
    it.boolQueryDsl {
        filter + {
            // Price between $50 and $200 (inclusive)
            product.price range 50.0.fromInclusive()..200.0

            // Rating at least 4.0 (no upper limit)
            product.rating range 4.0.fromInclusive()..null

            // Stock level greater than 0 (exclusive lower bound)
            product.stockLevel range 0.fromExclusive()..null

            // Discount less than 50% (exclusive upper bound)
            product.discount range null.fromInclusive()..<50.0
        }
    }
}

Comparison with Guava Range ​

Both syntaxes are supported - choose based on preference:

MathematicalStartBound SyntaxGuava Range Syntax
[10, 100]10.fromInclusive()..100Range.closed(10, 100)
[10, 100)10.fromInclusive()..<100Range.closedOpen(10, 100)
(10, 100]10.fromExclusive()..100Range.openClosed(10, 100)
(10, 100)10.fromExclusive()..<100Range.open(10, 100)
[10, ∞)10.fromInclusive()..nullRange.atLeast(10)
(10, ∞)10.fromExclusive()..nullRange.greaterThan(10)
(-∞, 100]null.fromInclusive()..100Range.atMost(100)
(-∞, 100)null.fromInclusive()..<100Range.lessThan(100)

StartBound advantages:

  • 📐 Natural mathematical notation
  • 🎯 Kotlin-idiomatic operators (.. and ..<)
  • 📝 Visual clarity with bracket semantics
  • ∞ Explicit unbounded ranges with null

Guava Range advantages:

  • 🔧 Familiar to Java developers
  • 📚 Standard library (Google Guava)
  • 🔄 Reusable Range objects
  • 🎓 Well-documented API

Date Range Queries ​

kotlin
import java.time.LocalDate
import java.time.Instant

// Mathematical notation with dates
product.createdAt range LocalDate.now().minusDays(30).fromInclusive()..LocalDate.now()

// Unbounded date range (last 30 days to now)
product.publishedAt range Instant.now().minusSeconds(2592000).fromInclusive()..null

// Traditional Guava Range
product.createdAt.range(
    Range.closed(
        LocalDate.now().minusDays(30),
        LocalDate.now()
    )
)

// Convenience operators
product.publishedAt greaterThan Instant.now().minusSeconds(86400)
product.expiresAt lowerThan LocalDate.now().plusDays(7)

Nested Queries ​

Query nested objects while maintaining their independent document structure. The DSL provides a built-in nested { } function on nested fields for cleaner syntax.

The cleanest way to query nested fields:

kotlin
val query = BoolQuery.of {
    it.boolQueryDsl {
        must + {
            // Use the built-in nested { } function
            product.reviews.nested {
                must + {
                    product.reviews.rating greaterThan 4.0
                    product.reviews.verified term true
                }
            }
        }
    }
}

Benefits of nested { } function:

  • ✨ Automatic path detection - No need to specify path()
  • 🎯 Cleaner syntax - Nested query directly on the field
  • 🛡️ Type safety - Only works on actual nested fields
  • ⚠️ Automatic validation - Warns at runtime if used on non-nested fields
  • 🔄 Graceful fallback - Applies as regular bool query if field isn't nested

Nested with Complex Logic ​

kotlin
val searchQuery = BoolQuery.of {
    it.boolQueryDsl {
        must + {
            product.title match "laptop"
        }

        filter + {
            // Nested query with complex boolean logic
            product.reviews.nested {
                must + {
                    product.reviews.rating range 4.0.fromInclusive()..5.0
                }

                should + {
                    product.reviews.author match "verified_buyer"
                    product.reviews.helpful greaterThan 10
                }

                filter + {
                    product.reviews.createdAt greaterThan LocalDate.now().minusMonths(6)
                }

                minimumShouldMatch(1)
            }
        }
    }
}

Safety Validation and Logging ​

The nested { } function includes automatic validation to prevent common mistakes:

kotlin
// ⚠️ Attempting to use nested on a non-nested field
product.address.nested {  // address is @Field(type = FieldType.Object), NOT Nested
    must + {
        product.address.city term "Paris"
    }
}

What happens:

  1. The DSL detects that address is not marked as @Field(type = FieldType.Nested)
  2. Logs a warning message:
    WARN: Nested query used on non-nested field 'address'.
    The field should be marked with @Field(type = FieldType.Nested) in the Elasticsearch mapping.
    The query will be applied as a regular bool query instead.
  3. Gracefully falls back to a regular bool query (doesn't fail)

Enabling logging:

The DSL uses io.github.oshai:kotlin-logging for structured logging. To see warnings:

kotlin
// In your logback.xml or similar
<logger name="com.ekino.oss.metalastic.elasticsearch.dsl" level="WARN"/>

Benefits:

  • 🐛 Catch mapping errors early - Identifies misconfigured fields during development
  • 🔍 Clear error messages - Explains exactly what's wrong and how to fix it
  • 🛡️ No runtime failures - Query still executes, just without nested semantics
  • 📊 Production monitoring - Helps identify mapping issues in production

Customizing the NestedQuery.Builder ​

For options like a custom scoreMode, pass a setupBlock as the first argument of nested. The block (the bool DSL) is the trailing lambda:

kotlin
import co.elastic.clients.elasticsearch._types.query_dsl.ChildScoreMode

product.reviews.nested({ scoreMode(ChildScoreMode.Avg) }) {
    must + {
        product.reviews.rating greaterThan 4.0
        product.reviews.verified term true
    }
}

Use when:

  • Need a non-default scoreMode, ignoreUnmapped, or other NestedQuery.Builder settings
  • Path is still derived automatically from the receiver (product.reviews) — no manual .path("…") needed

Why Use Nested Queries? ​

Nested queries are essential when you need to maintain relationships between fields in array/collection objects.

Without nested (flattened objects - ❌ INCORRECT):

kotlin
// ❌ These would match incorrectly if reviews are flattened
// Could match review1.rating=5 AND review2.verified=true (wrong!)
BoolQuery.of {
    it.boolQueryDsl {
        must + {
            product.reviews.rating greaterThan 4.0
            product.reviews.verified term true
        }
    }
}

With nested (maintains relationships - ✅ CORRECT):

kotlin
// ✅ Ensures SAME review has rating > 4 AND verified = true
BoolQuery.of {
    it.boolQueryDsl {
        must + {
            product.reviews.nested {
                must + {
                    product.reviews.rating greaterThan 4.0
                    product.reviews.verified term true
                }
            }
        }
    }
}

Key Point: Without nested queries, Elasticsearch flattens arrays and loses field relationships. With nested queries, each nested object is indexed as a separate document, maintaining field correlations.

Specialized Queries ​

Fuzzy Query ​

Find terms similar to the search term (typo tolerance):

kotlin
// Simple fuzzy
product.title fuzzy "laptpo"  // finds "laptop"

// With fuzziness control
product.name.fuzzy("jhon") {
    fuzziness("AUTO")  // or "1", "2", etc.
    prefixLength(0)
    maxExpansions(50)
}

Fuzziness levels:

  • "AUTO" - automatic based on term length (recommended)
  • "0" - no fuzziness (exact match)
  • "1" - one character difference
  • "2" - two character difference

Exists Query ​

Check if a field has a value:

kotlin
// Field must exist and have a non-null value
product.description.exist()
product.tags.exist()

// In boolean query
BoolQuery.of {
    it.boolQueryDsl {
        filter + {
            product.price.exist()
        }
    }
}

Geo Distance Query ​

Find documents within a distance from a point:

kotlin
import co.elastic.clients.elasticsearch._types.DistanceUnit
import co.elastic.clients.elasticsearch._types.query_dsl.GeoValidationMethod

// Simple geo distance — defaults to kilometers
product.location.geoDistance(latitude = 48.8566, longitude = 2.3522, distance = 10.0)

// Explicit unit + validation method
product.storeLocation.geoDistance(
    latitude = 40.7128,
    longitude = -74.0060,
    distance = 5.0,
    unit = DistanceUnit.Kilometers,
) {
    validationMethod(GeoValidationMethod.IgnoreMalformed)
}

More Like This Query ​

Find documents similar to given text or documents:

kotlin
// Call moreLikeThis on the Collection<Metamodel<*>> of fields to analyze
listOf(product.title, product.description).moreLikeThis {
    // Text to compare against
    like { it.text("high performance gaming laptop") }

    // Or compare against documents
    // like { it.document { d -> d.id("product-123").index("products") } }

    // Tuning parameters
    minTermFreq(1)           // minimum term frequency
    maxQueryTerms(12)        // maximum query terms
    minDocFreq(2)            // minimum document frequency
    minWordLength(3)         // minimum word length
}

Match All and Match None ​

matchAll() and matchNone() add the corresponding match-all / match-none query unconditionally — unlike every other function in this DSL, they take no arguments and are never skipped:

kotlin
filter + {
    matchAll()   // matches every document
}

must + {
    if (allowedTenants.isEmpty()) matchNone() else doc.tenantId terms allowedTenants
}

Use when:

  • matchAll(): as a deliberate default/fallback clause
  • matchNone(): as the explicit "match nothing" escape hatch described in Null and Empty Handling — the same query range falls back to when given Range.all()

Value Conversion ​

The DSL automatically converts Kotlin/Java types to Elasticsearch-compatible values.

Supported Types ​

Primitive Types:

kotlin
product.id term "PROD-123"              // String
product.quantity term 100               // Int
product.price term 99.99                // Double
product.active term true                // Boolean

Date Types:

kotlin
import java.time.*

product.createdAt greaterThan Date()                    // java.util.Date
product.publishedAt greaterThan Instant.now()           // Instant
product.scheduleDate greaterThan LocalDate.now()        // LocalDate
product.timestamp greaterThan LocalDateTime.now()       // LocalDateTime
product.eventTime greaterThan ZonedDateTime.now()       // ZonedDateTime

Collections:

For scalar fields, pass values via the typed vararg form:

kotlin
// Match any of several values on a scalar field
product.country.terms("FR", "BE", "CH")
product.category.terms("electronics", "computers")
product.sku.terms("sku-1", "sku-2", "sku-3")

For collection-typed fields (e.g. tags: KeywordField<Collection<String>>), use the collection-aware counterpart containsTerms:

kotlin
product.tags.containsTerms("featured", "new", "sale")

If you already hold a runtime collection of Strings, you can pass it directly:

kotlin
val countrySet: Set<String> = userInput.countries
product.country terms countrySet
product.tags containsTerms countrySet  // for collection-typed fields

For other element types, use the Collection<FieldValue> escape hatch:

kotlin
import co.elastic.clients.elasticsearch._types.FieldValue

val timestamps: List<Long> = userInput.parseTimestamps()
product.createdAt terms timestamps.map { FieldValue.of(it) }

See Typed vararg vs FieldValue collection escape hatch for the rationale.

Enums:

kotlin
enum class ProductStatus {
    ACTIVE, INACTIVE, DISCONTINUED
}

// Automatic enum name conversion
product.status term ProductStatus.ACTIVE  // converts to "ACTIVE"

// Both vararg and Collection forms are supported for enums
product.status.terms(ProductStatus.ACTIVE, ProductStatus.INACTIVE)
product.status terms listOf(ProductStatus.ACTIVE, ProductStatus.INACTIVE)

Custom Type Conversion ​

The DSL uses Kotlin's KType reflection for type conversion. Complex types are automatically handled through the type system.

Best Practices ​

1. Choose Your Syntax Style Consistently ​

Pick a primary syntax style for your project and use it consistently:

kotlin
// ✅ Good - Consistent operator syntax throughout
val query = BoolQuery.of {
    it.boolQueryDsl {
        must + { product.title match "laptop" }
        filter + { product.status term Status.ACTIVE }
        should + { product.featured term true }
    }
}

// ✅ Also good - Consistent classical syntax throughout
val query = BoolQuery.of {
    it.boolQueryDsl {
        mustDsl { product.title match "laptop" }
        filterDsl { product.status term Status.ACTIVE }
        shouldDsl { product.featured term true }
    }
}

// ⚠️ Acceptable - Mix for readability if needed
val query = BoolQuery.of {
    it.boolQueryDsl {
        must + { product.title match "laptop" }  // Simple: operator
        filterDsl {  // Complex: classical for clarity
            product.status term Status.ACTIVE
            product.price range 100.0.fromInclusive()..1000.0
            product.rating range 4.0.fromInclusive()..null
        }
    }
}

Team Guidelines:

  • Document your team's preferred syntax in coding standards
  • Use code reviews to maintain consistency
  • Both syntaxes are equally valid - choose what works for your team

2. Prefer Mathematical Notation for Ranges ​

Use StartBound notation for clearer intent:

kotlin
// ✅ Best - Mathematical notation is most readable
product.price range 50.0.fromInclusive()..200.0     // [50, 200]
product.rating range 4.0.fromInclusive()..null      // [4.0, ∞)
product.discount range null.fromInclusive()..<50.0  // (-∞, 50)

// ✅ Good - Guava Range for complex ranges
product.price.range(Range.closed(50.0, 200.0))

// ⚠️ Less clear - Use for simple comparisons only
product.price greaterThan 50.0
product.rating lowerThan 5.0

3. Import Metamodels from Companion Objects ​

kotlin
// ✅ Good
import com.example.MetaProduct.Companion.product
product.title match "laptop"

// ❌ Avoid
val product = MetaProduct<Product>()  // creates unnecessary instances

4. Use Type-safe Enums ​

kotlin
// ✅ Good - compile-time safety
product.status term ProductStatus.ACTIVE

// ❌ Avoid - typo-prone
product.status term "ACTIVE"

5. Leverage IDE Autocomplete ​

The metamodels provide full IDE support:

  • ✅ Field name completion
  • ✅ Type checking
  • ✅ Refactoring support
  • ✅ Documentation hints

6. Use filter Instead of must for Non-scoring ​

kotlin
// ✅ Good - more efficient
BoolQuery.of {
    it.boolQueryDsl {
        filter + {
            product.category term "electronics"
            product.inStock term true
        }
    }
}

// ❌ Slower - unnecessary scoring
BoolQuery.of {
    it.boolQueryDsl {
        must + {
            product.category term "electronics"
            product.inStock term true
        }
    }
}

Why: filter clauses are cached and don't calculate relevance scores, making them significantly faster for exact-match conditions.

7. Combine Query Types Appropriately ​

kotlin
val searchQuery = BoolQuery.of {
    it.boolQueryDsl {
        // Full-text search for relevance
        must + {
            product.title match searchTerm
        }

        // Exact filters (use filter for performance)
        filter + {
            product.status term Status.ACTIVE
            product.price range minPrice.fromInclusive()..maxPrice
        }

        // Optional boosts
        should + {
            product.featured term true  // boost featured products
        }
    }
}

8. Use Nested Queries for Nested Fields ​

kotlin
// ✅ Good - maintains relationships
product.reviews.nested {
    must + {
        product.reviews.rating greaterThan 4.0
        product.reviews.verified term true
    }
}

// ❌ Wrong - loses relationships
BoolQuery.of {
    it.boolQueryDsl {
        must + {
            product.reviews.rating greaterThan 4.0  // Wrong context
        }
    }
}

Next Steps ​

Released under the MIT License.