Introduction
Writing WOQL queries effectively requires understanding not just the language syntax, but also practical patterns for handling real-world scenarios. This guide covers techniques that make your queries more robust, easier to debug, and simpler to maintain.
The easiest first way to think about WOQL is that it is similar to SQL, but for RDF data. In a regular database, you select columns from tables, and you can use the "AS" keyword to rename columns in the result set. The result set in WOQL is similar, but instead of tables, you select variables from triples, and the names of the variables are returned in the result set.
What is unique about WOQL is that it is easy to transform and perform post-processing of the found data using logic. You can use the opt() predicate to make optional bindings, and the comment() predicate to disable code blocks.
Tip 1: Use opt() for Optional Bindings
The Problem
When a WOQL query fails to bind a variable, the entire query fails. This can be frustrating when you want to retrieve data even if some fields are missing or don't match.
// This query fails entirely if ANY person lacks an email
let v = Vars("person", "name", "email");
and(
triple(v.person, "rdf:type", "@schema:Person"),
triple(v.person, "name", v.name),
triple(v.person, "email", v.email) // Fails if email doesn't exist
)The Solution
Wrap optional bindings in opt() to make them non-blocking. The query succeeds even when the optional binding fails, returning results with unbound variables where data is missing.
let v = Vars("person", "name", "email");
and(
triple(v.person, "rdf:type", "@schema:Person"),
triple(v.person, "name", v.name),
opt().triple(v.person, "email", v.email) // Optional: query succeeds even without email
)When to Use opt()
- Missing properties: When documents may not have all fields populated
- Exploratory queries: When discovering what data exists
- Partial matches: When you want results even if some conditions don't match
- Schema variations: When querying data that evolved over time with different required fields
Practical Example
Query all employees with their manager, where manager is optional:
let v = Vars("employee", "name", "department", "manager", "manager_name");
and(
triple(v.employee, "rdf:type", "@schema:Employee"),
triple(v.employee, "name", v.name),
triple(v.employee, "department", v.department),
opt().and(
triple(v.employee, "reports_to", v.manager),
triple(v.manager, "name", v.manager_name)
)
)This returns all employees, with manager information where it exists.
Tip 2: Use comment() to Disable Code Blocks
The Problem
When debugging complex queries, you often want to temporarily disable parts of the query without deleting them. Traditional approaches like commenting out JSON or JavaScript are error-prone and can break query structure.
The Solution
Use the comment() predicate to wrap query blocks you want to disable. The wrapped code is syntactically valid but not executed.
let v = Vars("person", "name", "age", "city");
and(
triple(v.person, "rdf:type", "@schema:Person"),
triple(v.person, "name", v.name),
// Temporarily disabled while debugging
comment("Disabled age filter for testing").and(
triple(v.person, "age", v.age),
greater(v.age, 18)
),
triple(v.person, "city", v.city)
)Benefits
- Preserves query structure: No syntax errors from incomplete JSON
- Self-documenting: The comment string explains why it's disabled
- Easy toggling: Simple to re-enable by removing the
comment()wrapper - Version control friendly: Changes are clear in diffs
Practical Example: Iterative Query Development
Build complex queries incrementally by commenting out sections:
let v = Vars("order", "customer", "product", "total", "date");
and(
// Core query - always runs
triple(v.order, "rdf:type", "@schema:Order"),
triple(v.order, "customer", v.customer),
// Step 1: Enable to add product details
comment("Product join - enable when customer query works").and(
triple(v.order, "line_items", v.item),
triple(v.item, "product", v.product)
),
// Step 2: Enable to add aggregation
comment("Total calculation - enable after product join works").and(
triple(v.order, "total", v.total),
greater(v.total, 100)
),
// Step 3: Enable to add date filtering
comment("Date filter - final step").and(
triple(v.order, "date", v.date),
greater(v.date, "2024-01-01")
)
)Combining Tips: Robust Query Patterns
Combine opt() and comment() for maximum flexibility during development:
let v = Vars("person", "name", "email", "phone", "address");
and(
triple(v.person, "rdf:type", "@schema:Person"),
triple(v.person, "name", v.name),
// Optional fields that may not exist
opt().triple(v.person, "email", v.email),
opt().triple(v.person, "phone", v.phone),
// Commented out while testing core query
comment("Address lookup - re-enable after basic query works").and(
opt().triple(v.person, "address", v.address)
)
)Complete example to show the techniques
This example uses the high performance set_member() predicate to check if a value is in a set. It also uses the opt() predicate to make optional bindings, and the comment() predicate to disable code blocks.
// Pick specific variables to materialize in the result (check and result)
select("v:check","v:result").and(
// Create a set from a list
eq("v:list", [1, 2, 3, 4]),
list_to_set("v:list", "v:set"),
// Check if the number 1 is in the set, typecast in different ways
or(
and(
// As expected, the default type is xsd:decimal above, and there is a match
eq("v:check", "decimal 1 is in the set"),
opt().and(
set_member(literal(1, "xsd:decimal"), "v:set"),
eq("v:result", "consistent"),
),
),
and(
// There is no automatic type conversion, so the string "1" is not in the set
eq("v:check", "string \"1\" is NOT in the set"),
opt().and(
not().set_member(literal("1", "xsd:string"), "v:set"),
eq("v:result", "consistent"),
),
),
and(
// There is an important distinction between xsd:decimal and xsd:double
// All number types are distinct in sets, so the double 1 is not in the decimal set
eq("v:check", "double 1 is NOT in the decimal set"),
opt().and(
// Use a variable to store the member value
eq("v:member", literal(1, "xsd:double")),
not().set_member("v:member", "v:set"),
eq("v:result", "consistent"),
// We could use a type_of predicate to check the type of the member variable
//type_of("v:member", "v:member_type"),
),
),
),
// If any of the solutions do not set the bind result to be consistent,
// the result is inconsistent, so we can use a late binding of the variable.
opt().eq("v:result", "inconsistent"),
)This is the WOQL result, when converting the bindings to a table representation (the result of the query):
| Check | Result |
|---|---|
| decimal 1 is in the set | consistent |
| string "1" is NOT in the set | consistent |
| double 1 is NOT in the decimal set | consistent |
What we learned:
- we can use the
opt()predicate to make optional bindings - the
or()predicate can be used to branch out multiple solutions - using
opt()late binding of variables, we can provide a default value - we can use
type_of()to check the type of a variable - it's easy to test for many solutions using a common core with
or()
Summary
| Technique | Use Case | Benefit |
|---|---|---|
opt() | Missing or optional data | Query succeeds with partial results |
comment() | Debugging and development | Disable code without breaking syntax |
These patterns make WOQL queries more resilient and easier to develop iteratively. Start with a simple query, add complexity gradually using comment() to isolate new sections, and use opt() to handle the inherent variability in real-world data.
Related Documentation
- WOQL Reference — Complete WOQL operator reference
- Path Queries — Graph traversal patterns
- Learning TerminusDB — Fundamentals of WOQL and triple patterns