Skip to content

[1] Core

Jan Sigrist edited this page Apr 23, 2026 · 7 revisions

Core module in DOPE Query Builder

The Core module is the type-safe AST behind DOPE. It contains all Clauses, Expressions, Operators, and ValidTypes that replicate SQL++/N1QL language constructs. Core on its own does not render a query string — pair it with a QueryResolver (for Couchbase: the couchbase module's CouchbaseResolver) to get a concrete query.

General

With DOPE Core, you build an AST consisting of Clauses and Expressions. The AST is rendered by calling build(resolver), which returns a DopeQuery. For Couchbase, this is a CouchbaseDopeQuery with:

  • queryString — the rendered SQL++/N1QL string.
  • parameters — a DopeParameters value holding namedParameters (Map<String, Any>) and positionalParameters (List<Any>).
import ch.ergon.dope.QueryBuilder
import ch.ergon.dope.couchbase.CouchbaseResolver

val query = QueryBuilder.select(personName, personAge).build(CouchbaseResolver())
query.queryString   // "SELECT person.name, person.age"
query.parameters    // DopeParameters(namedParameters = {}, positionalParameters = [])

QueryBuilder is a Kotlin object (singleton), so entry points such as QueryBuilder.select(...), QueryBuilder.selectAsterisk(), QueryBuilder.selectDistinct(...), QueryBuilder.selectRaw(...), QueryBuilder.selectFrom(...), QueryBuilder.with(...), QueryBuilder.update(...), and QueryBuilder.deleteFrom(...) are called without parentheses on QueryBuilder itself.


Expressions

NumberExpression

NumberExpressions produce a numeric result. Use them for arithmetic within a query — add a constant to a field, multiply a field, or combine operations.

personAge.add(3)         // -> "person.age + 3"
2.mul(personAge).sub(5)  // -> "2 * person.age - 5"

StringExpression

StringExpressions produce a string result. Handy for string manipulation — concatenation, trimming, padding, etc.

"_".concat(personName, "_")   // -> "CONCAT(\"_\", person.name, \"_\")"
personName.ltrim()            // -> "LTRIM(person.name)"

BooleanExpression

BooleanExpressions produce a boolean result. Used in WHERE and JOIN conditions for equality, pattern matching, null checks, and logical combinations.

personAge.isEqualTo(18)      // -> "person.age = 18"
personName.isLike("A%")      // -> "person.name LIKE \"A%\""
personName.isNotNull()       // -> "person.name IS NOT NULL"

Fields

A Field represents a property in a JSON document and is typed with a ValidType (NumberType, StringType, BooleanType, ArrayType<T>, ObjectType, …).

val personName = Field<StringType>(name = "name", path = "person")
val personAge = Field<NumberType>(name = "age", path = "person")
val isPersonEmployed = Field<BooleanType>(name = "isEmployed", path = "person")

Here we have a String property person.name, a Number property person.age, and a Boolean property person.isEmployed.

Literals

Use toDopeType() to lift a Kotlin primitive into an expression:

import ch.ergon.dope.resolvable.expression.type.toDopeType

val literalNumber = 42.toDopeType()
val literalString = "hello".toDopeType()
val literalBoolean = true.toDopeType()

Parameters

build() returns parameters in a DopeParameters value, split into namedParameters and positionalParameters. Create parameters with the asParameter() extension — it works for Number, String, Boolean, Map<String, Any>, and collections of those types.

Named Parameters

Pass a name to get a named parameter ($name).

7.asParameter("number") // -> "$number"

Positional Parameters

Omit the name to get a positional parameter ($1, $2, …).

7.asParameter() // -> "$1"

Example

val parameter1 = 2.asParameter()
val parameter2 = 5.asParameter("param")
val parameter3 = "peter".asParameter()

val result = QueryBuilder
    .select(parameter1.isEqualTo(parameter2))
    .where(personName.isNotEqualTo(parameter3))
    .build(CouchbaseResolver())

// result.queryString -> "SELECT $1 = $param WHERE person.name != $2"
// result.parameters  -> DopeParameters(
//     namedParameters       = { "param" to 5 },
//     positionalParameters  = [2, "peter"]
// )

Buckets

A Bucket is any table-like source you can read from. DOPE ships two:

import ch.ergon.dope.resolvable.bucket.UnaliasedBucket

val personBucket = UnaliasedBucket("person")     // -> "person"
val personAlias  = personBucket.alias("p")       // -> "person AS p"

Buckets also support scope and collection paths:

UnaliasedBucket("person").withScope("hr")                           // -> "hr.person"
UnaliasedBucket("person").withScopeAndCollection("hr", "employees") // -> "hr.employees.person"

Clauses

Select

DOPE has several SELECT entry points, all on QueryBuilder. The basic SELECT takes one or more selectables:

val result = QueryBuilder
    .select(personName, personAge)
    .build(CouchbaseResolver())

// result.queryString -> "SELECT person.name, person.age"

Other SELECT variants: selectAsterisk(), selectDistinct(...), selectRaw(expression), and the shortcut selectFrom(bucket) (which is equivalent to selectAsterisk().from(bucket)).

Clauses can be chained:

val personBucket = UnaliasedBucket("person")

val result = QueryBuilder
    .select(personBucket.asterisk())
    .from(personBucket)
    .join(UnaliasedBucket("city"), condition = personCityId.isEqualTo(cityId))
    .where(personAge.isGreaterThan(14))
    .groupBy(personName)
    .orderBy(personAge)
    .limit(10)
    .offset(5)
    .build(CouchbaseResolver())

// result.queryString -> "SELECT person.* FROM person JOIN city ON person.cityId = city.id WHERE person.age > 14 GROUP BY person.name ORDER BY person.age LIMIT 10 OFFSET 5"

LET

LET bindings are introduced with withVariables(...) and assignTo(...):

import ch.ergon.dope.resolvable.expression.type.assignTo

val isAdult = "isAdult".assignTo(personAge.isGreaterThan(18))

val result = QueryBuilder
    .select(personName)
    .from(personBucket)
    .withVariables(isAdult)
    .where(isAdult)
    .build(CouchbaseResolver())

WITH (Common Table Expressions)

QueryBuilder.with(...) starts a CTE:

val sub = QueryBuilder
    .select(personName)
    .from(personBucket)

val cte = "p".assignTo(sub)

val result = QueryBuilder
    .with(cte)
    .selectAsterisk()
    .from(UnaliasedBucket("p"))
    .build(CouchbaseResolver())

Update / Delete

QueryBuilder.update(bucket) starts an UPDATE, QueryBuilder.deleteFrom(bucket) starts a DELETE. Both support where, limit, and the returning* family.

import ch.ergon.dope.resolvable.clause.model.toNewValue

val update = QueryBuilder
    .update(personBucket)
    .set(personAge.toNewValue(30))
    .where(personAge.isGreaterThan(18))
    .build(CouchbaseResolver())

val delete = QueryBuilder
    .deleteFrom(personBucket)
    .where(personAge.isGreaterThan(18))
    .build(CouchbaseResolver())

For the full list of supported clauses, operators, and functions, see [[3]-DOPE-functionality].