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:
- Schema flexibility - Documents can have different structures in the same collection
- Nested data - No joins needed when related data is embedded
- HTTP-first API - JSON over REST, not a separate query protocol
- 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:
- No type discrimination - Cannot distinguish between a filter object and a data object
- Operator collision - Field names starting with
$are reserved - Ambiguous semantics - Is
{a: 1, b: 2}an AND or just two fields? - 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:
- Unambiguous parsing -
typediscriminator makes structure explicit - Type-safe composition - Logical operators nest cleanly with proper typing
- Minimal reserved field names - Only
idandttlare reserved (vs MongoDB's$prefix for all operators) - Better error messages - Invalid queries caught during JSON schema validation
- Optimizable - Query planner can analyze structure before execution
- 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$gtean operator or a field name?)
- ekoDB:
- Fine-grained propagation control - Query options include
bypass_cacheandbypass_ripplefor 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
typeandcontentwrappers - 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
| Database | Query Format | Type Safety | Composability | Reserved Fields |
|---|---|---|---|---|
| ekoDB | Tagged unions | ✅ Strong | ✅ Explicit nesting | ⚠️ id, ttl only |
| MongoDB | Dollar operators | ❌ Runtime only | ⚠️ Implicit | ✅ All $ operators |
| CouchDB | Map-Reduce | ❌ None | ⚠️ JavaScript | N/A |
| DynamoDB | Condition expressions | ⚠️ Partial | ❌ String-based | ✅ Reserved words |
| Firestore | Method 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
- Condition - Single field comparison (leaf node in expression tree)
- Logical - Combine multiple expressions (branch node in expression tree)
Expressions form a tree structure where:
- Leaf nodes are
Conditionexpressions - Branch nodes are
Logicalexpressions 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:
- Start at root AND node
- Evaluate left child:
age >= 18(Condition) - Evaluate right child: OR node
- Evaluate
status = "active"(Condition) - Evaluate
role = "admin"(Condition)
- Evaluate
- 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
| Operator | Description | Aliases | Example Value |
|---|---|---|---|
Eq | Equals | Equal, Equals | "active", 25 |
Ne | Not equals | NotEqual, NotEquals | "deleted", 0 |
Gt | Greater than | GreaterThan | 18, 100.5 |
Gte | Greater than or equal | GreaterThanOrEqual | 18, 0 |
Lt | Less than | LessThan | 65, 1000 |
Lte | Less than or equal | LessThanOrEqual | 100, 999.99 |
In | Value in array | - | ["active", "pending"] |
NotIn | Value not in array | - | ["deleted", "archived"] |
String Operators
Special operators for string/text matching:
| Operator | Description | Case Sensitive | Example |
|---|---|---|---|
Contains | Substring match | Yes | "@gmail.com" |
StartsWith | Prefix match | Yes | "Dr. " |
EndsWith | Suffix match | Yes | ".com" |
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
| Option | Type | Description |
|---|---|---|
filter | QueryExpression | Filter condition (see above) |
sort | Array | Sort by one or more fields |
limit | Integer | Max results to return |
skip | Integer | Results to skip (pagination) |
bypass_cache | Boolean | Skip cache lookup (read directly from storage) |
bypass_ripple | Boolean | Don'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 misstrue: 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 peerstrue: 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:
- Direct API - See Basic Operations - Query Examples
- Client Libraries - See Querying with Filters
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:
- Client Libraries (Recommended)
- Direct API
- 🦀 Rust
- 🐍 Python
- 📘 TypeScript
- 📦 JavaScript
- 🟣 Kotlin
- 🔷 Go
let results = client.find(
"products",
Some(Filter::condition("category", Operator::Equals, "electronics")),
Some(FindOptions {
index_name: Some("category_index".to_string()),
..Default::default()
})
).await?;
results = client.find('products', {
'filter': {
'field': 'category',
'operator': 'Equals',
'value': 'electronics'
},
'index_name': 'category_index'
})
const results = await client.find('products', {
filter: {
field: 'category',
operator: 'Equals',
value: 'electronics'
},
index_name: 'category_index'
});
const results = await client.find('products', {
filter: {
field: 'category',
operator: 'Equals',
value: 'electronics'
},
index_name: 'category_index'
});
val results = client.find("products", mapOf(
"filter" to mapOf(
"field" to "category",
"operator" to "Equals",
"value" to "electronics"
),
"index_name" to "category_index"
))
results, err := client.Find("products", ekodb.FindOptions{
Filter: ekodb.Filter{
Field: "category",
Operator: "Equals",
Value: "electronics",
},
IndexName: "category_index",
})
curl -X POST https://{EKODB_API_URL}/api/indexes/users \
-H "Authorization: Bearer {ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"field": "status",
"index_type": "hash"
}'
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
- Basic Operations - Complete CRUD API reference
- Indexes - Speed up your queries
- Client Libraries - Type-safe query building
- Batch Operations - Query multiple records efficiently
Need Help?
- 📖 Client Library Examples: GitHub Examples (includes both client library and direct HTTP examples)
- 🐛 Issues: app.ekodb.io/support
- 💬 Support: support@ekodb.io