Skip to main content

Query Expressions

ekoDB's query language uses a structured JSON format with tagged unions instead of SQL strings or MongoDB-style query documents. This technical reference explains the design rationale, expression structure, and complete operator specification.

Design Rationale

Why Not SQL?

SQL was designed for relational databases with fixed schemas and table joins. ekoDB is a document database optimized for:

  1. Schema flexibility - Documents can have different structures in the same collection
  2. Nested data - No joins needed when related data is embedded
  3. HTTP-first API - JSON over REST, not a separate query protocol
  4. Type-safe query builders - Statically typed in Rust, TypeScript, Kotlin, Go; runtime checked in Python

SQL's limitations for document databases:

  • String concatenation creates injection vulnerabilities
  • No type checking until runtime
  • Impedance mismatch between tabular results and nested documents
  • Query parsers are complex and error-prone

Why Not MongoDB Query Language?

MongoDB uses a JSON-like query format, but with significant limitations:

// MongoDB - operators are magic strings at runtime
{ age: { $gte: 18, $lte: 65 }, status: "active" }

Problems with this approach:

  1. No type discrimination - Cannot distinguish between a filter object and a data object
  2. Operator collision - Field names starting with $ are reserved
  3. Ambiguous semantics - Is {a: 1, b: 2} an AND or just two fields?
  4. Harder to parse - Must scan all keys to detect operators

ekoDB's Tagged Union Approach

{
"type": "Condition",
"content": {
"field": "age",
"operator": "Gte",
"value": 18
}
}

Benefits:

  1. Unambiguous parsing - type discriminator makes structure explicit
  2. Type-safe composition - Logical operators nest cleanly with proper typing
  3. Minimal reserved field names - Only id and ttl are reserved (vs MongoDB's $ prefix for all operators)
  4. Better error messages - Invalid queries caught during JSON schema validation
  5. Optimizable - Query planner can analyze structure before execution
  6. Query intent is obvious - Looking at the JSON, you immediately know what's being filtered. Compare:
    • ekoDB: {"type": "Condition", "content": {"field": "age", "operator": "Gte", "value": 18}}
    • MongoDB: {"age": {"$gte": 18}} (is $gte an operator or a field name?)
  7. Fine-grained propagation control - Query options include bypass_cache and bypass_ripple for direct control over the data propagation waterfall. Most databases don't expose this level of control at the query level.

Trade-offs:

  • More verbose - Requires type and content wrappers
  • Unfamiliar syntax - Not SQL or MongoDB query language
  • More JSON payload - Slightly larger wire format (mitigated by HTTP compression)

Why we chose verbosity: Explicit query intent and type safety outweigh brevity. While the syntax may be unfamiliar initially, it's far more obvious what you're querying for—there's no ambiguity between data and query operators. Client libraries abstract the verbosity with fluent APIs like QueryBuilder().gte("age", 18).

Comparison to Other NoSQL Databases

DatabaseQuery FormatType SafetyComposabilityReserved Fields
ekoDBTagged unions✅ Strong✅ Explicit nesting⚠️ id, ttl only
MongoDBDollar operators❌ Runtime only⚠️ Implicit✅ All $ operators
CouchDBMap-Reduce❌ None⚠️ JavaScriptN/A
DynamoDBCondition expressions⚠️ Partial❌ String-based✅ Reserved words
FirestoreMethod chaining✅ Client-side only✅ Good⚠️ Minimal

ekoDB's approach is most similar to Firestore's, but with explicit JSON representation that works identically across all clients and the REST API.


Expression Structure

ekoDB uses a tagged union (also called discriminated union or sum type) for query expressions. The type field discriminates between different expression variants at parse time.

{
"type": "Condition" | "Logical",
"content": { ... }
}

Type Discrimination

The parser uses the type field to determine how to interpret content:

// Rust deserialization (simplified)
enum QueryExpression {
Condition { field: String, operator: Operator, value: Value },
Logical { operator: LogicOp, expressions: Vec<QueryExpression> }
}

This enables:

  • Compile-time type checking in typed languages (Rust, TypeScript, Kotlin, Go)
  • Validation before execution - malformed queries rejected immediately
  • Query plan optimization - structure is known before accessing data

Two Expression Types

  1. Condition - Single field comparison (leaf node in expression tree)
  2. Logical - Combine multiple expressions (branch node in expression tree)

Expressions form a tree structure where:

  • Leaf nodes are Condition expressions
  • Branch nodes are Logical expressions containing child expressions
  • Tree depth is unlimited (for complex business logic)

Visual Example: Query Expression Tree

Here's how the query age >= 18 AND (status = "active" OR role = "admin") is represented as a tree:

Legend:

  • Logical expressions (AND, OR, NOT) = branch nodes that combine child expressions
  • Condition expressions (age >= 18, status = "active") = leaf nodes with field comparisons

The parser evaluates this tree recursively:

  1. Start at root AND node
  2. Evaluate left child: age >= 18 (Condition)
  3. Evaluate right child: OR node
    • Evaluate status = "active" (Condition)
    • Evaluate role = "admin" (Condition)
  4. Combine results with AND logic

Comparison Operators

All comparison operators follow this structure:

{
"type": "Condition",
"content": {
"field": "field_name",
"operator": "OperatorName",
"value": "comparison_value"
}
}

Available Operators

OperatorDescriptionAliasesExample Value
EqEqualsEqual, Equals"active", 25
NeNot equalsNotEqual, NotEquals"deleted", 0
GtGreater thanGreaterThan18, 100.5
GteGreater than or equalGreaterThanOrEqual18, 0
LtLess thanLessThan65, 1000
LteLess than or equalLessThanOrEqual100, 999.99
InValue in array-["active", "pending"]
NotInValue not in array-["deleted", "archived"]

String Operators

Special operators for string/text matching:

OperatorDescriptionCase SensitiveExample
ContainsSubstring matchYes"@gmail.com"
StartsWithPrefix matchYes"Dr. "
EndsWithSuffix matchYes".com"
String Matching

All string operators are case-sensitive. To perform case-insensitive searches, normalize your data (e.g., lowercase) on insert.


Logical Operators

Combine multiple expressions with logical operators:

AND - All conditions must match

{
"type": "Logical",
"content": {
"operator": "And",
"expressions": [
{
"type": "Condition",
"content": {
"field": "age",
"operator": "Gte",
"value": 18
}
},
{
"type": "Condition",
"content": {
"field": "status",
"operator": "Eq",
"value": "active"
}
}
]
}
}

OR - At least one condition must match

{
"type": "Logical",
"content": {
"operator": "Or",
"expressions": [
{
"type": "Condition",
"content": {
"field": "role",
"operator": "Eq",
"value": "admin"
}
},
{
"type": "Condition",
"content": {
"field": "role",
"operator": "Eq",
"value": "moderator"
}
}
]
}
}

NOT - Negate a condition

{
"type": "Logical",
"content": {
"operator": "Not",
"expressions": [
{
"type": "Condition",
"content": {
"field": "status",
"operator": "Eq",
"value": "deleted"
}
}
]
}
}

Complex Nested Logic

You can nest logical operators for complex queries:

{
"type": "Logical",
"content": {
"operator": "And",
"expressions": [
{
"type": "Condition",
"content": {
"field": "age",
"operator": "Gte",
"value": 18
}
},
{
"type": "Logical",
"content": {
"operator": "Or",
"expressions": [
{
"type": "Condition",
"content": {
"field": "country",
"operator": "Eq",
"value": "US"
}
},
{
"type": "Condition",
"content": {
"field": "country",
"operator": "Eq",
"value": "CA"
}
}
]
}
}
]
}
}

This finds records where: age >= 18 AND (country = "US" OR country = "CA")


Query Options

Beyond filters, ekoDB provides fine-grained control over sorting, pagination, and data propagation at the query level:

{
"filter": { ... },
"sort": [
{
"field": "created_at",
"ascending": false
},
{
"field": "name",
"ascending": true
}
],
"limit": 10,
"skip": 20,
"bypass_cache": false,
"bypass_ripple": true
}

Options Reference

OptionTypeDescription
filterQueryExpressionFilter condition (see above)
sortArraySort by one or more fields
limitIntegerMax results to return
skipIntegerResults to skip (pagination)
bypass_cacheBooleanSkip cache lookup (read directly from storage)
bypass_rippleBooleanDon't propagate writes to ripple peers (for bulk imports)

Propagation Control (Unique to ekoDB)

Unlike most databases, ekoDB exposes direct control over the data propagation waterfall at the query level:

bypass_cache - Control read path:

  • false (default): Check cache first, then storage if cache miss
  • true: Skip cache entirely, read directly from durable storage
  • Use case: When you need guaranteed fresh data and can tolerate slightly higher latency

bypass_ripple - Control write replication:

  • false (default): Writes propagate to all configured ripple peers
  • true: Writes stay local, skip replication
  • Use case: Bulk data imports, maintenance operations, temporary data

Most databases treat caching and replication as infrastructure concerns outside the query language. ekoDB makes these first-class query options for maximum control.


Usage Examples

For practical examples of using query expressions:


Common Query Patterns

Find All Active Records

{
"filter": {
"type": "Condition",
"content": {
"field": "status",
"operator": "Eq",
"value": "active"
}
}
}

Range Query (Between Values)

{
"filter": {
"type": "Logical",
"content": {
"operator": "And",
"expressions": [
{
"type": "Condition",
"content": {
"field": "price",
"operator": "Gte",
"value": 10.00
}
},
{
"type": "Condition",
"content": {
"field": "price",
"operator": "Lte",
"value": 100.00
}
}
]
}
}
}

Exclude Values (NOT IN)

{
"filter": {
"type": "Condition",
"content": {
"field": "status",
"operator": "NotIn",
"value": ["deleted", "archived", "spam"]
}
}
}

Search Multiple Fields (OR)

{
"filter": {
"type": "Logical",
"content": {
"operator": "Or",
"expressions": [
{
"type": "Condition",
"content": {
"field": "title",
"operator": "Contains",
"value": "urgent"
}
},
{
"type": "Condition",
"content": {
"field": "description",
"operator": "Contains",
"value": "urgent"
}
}
]
}
}
}

Complex Business Logic

Find premium users who are either:

  • Over 30 years old, OR
  • Have made more than 10 purchases
{
"filter": {
"type": "Logical",
"content": {
"operator": "And",
"expressions": [
{
"type": "Condition",
"content": {
"field": "tier",
"operator": "Eq",
"value": "premium"
}
},
{
"type": "Logical",
"content": {
"operator": "Or",
"expressions": [
{
"type": "Condition",
"content": {
"field": "age",
"operator": "Gt",
"value": 30
}
},
{
"type": "Condition",
"content": {
"field": "purchase_count",
"operator": "Gt",
"value": 10
}
}
]
}
}
]
}
}
}

Performance Tips

Use Indexes

Create indexes on fields you query frequently:

let results = client.find(
"products",
Some(Filter::condition("category", Operator::Equals, "electronics")),
Some(FindOptions {
index_name: Some("category_index".to_string()),
..Default::default()
})
).await?;

See Indexes Documentation for more details.

Limit Results

Always use limit to prevent loading excessive data:

{
"filter": { ... },
"limit": 100
}

Bypass Cache for Fresh Data

When you need the absolute latest data:

{
"filter": { ... },
"bypass_cache": true
}

Use Specific Fields

Only select fields you need (reduces bandwidth):

{
"filter": { ... },
"select_fields": ["id", "name", "email"]
}

Troubleshooting

400 Bad Request - Invalid Filter Format

Wrong:

{
"filter": {
"field": "status",
"operator": "Eq",
"value": "active"
}
}

Correct:

{
"filter": {
"type": "Condition",
"content": {
"field": "status",
"operator": "Eq",
"value": "active"
}
}
}

Missing type and content wrapper!

400 Bad Request - Unknown Operator

Wrong:

{
"operator": "equals" // lowercase
}

Correct:

{
"operator": "Eq" // PascalCase
}

Operators are case-sensitive and use PascalCase.

Empty Results When Data Exists

  • Check if field names are correct (case-sensitive)
  • Verify value types match (string vs number)
  • For string matching, remember it's case-sensitive

Next Steps

Need Help?