How to query with datalog (WOQL)
WOQL (Web Object Query Language) is a declarative query language built on datalog foundations. This tutorial teaches you WOQL through hands-on JavaScript examples that you can run against your local TerminusDB instance.
Individual deep dive sections:
- WOQL Basics
- WOQL Add Docs
- WOQL Edit Docs
- WOQL Delete Docs
- WOQL Read Docs
- WOQL Filter
- WOQL Order By
- WOQL Query Arrays
- WOQL Group Results
- WOQL Path Queries
- WOQL Math Queries
- WOQL Schema Queries
What You'll Learn
By the end of this tutorial, you'll understand:
- How declarative thinking differs from imperative programming
- Variable binding and unification in WOQL
- Reading, filtering, and transforming data
- Building complex queries step by step
Prerequisites
- TerminusDB server running locally (default:
http://127.0.0.1:6363) - See Docker installation guide - Node.js installed on your system
- Basic JavaScript knowledge
Complete this tutorial to learn WOQL through hands-on JavaScript examples. Check off each step as you complete it!
First, create a new directory and install the TerminusDB JavaScript client:
mkdir woql-tutorial
cd woql-tutorial
npm init -y
npm install @terminusdb/terminusdb-clientCreate a file named woql-tutorial.js and copy all the examples below into it. The main() function accepts arguments so you can easily run specific steps or all steps.
The Complete Tutorial Code
Below is the complete tutorial code. Copy this into your woql-tutorial.js file and follow along section by section. We suggest copying each section step by step for maximum learning.
On macOS, you can use the pbpaste command to paste the code into your file, like this: pbpaste > woql-tutorial.js. On other platforms, you can use cat > woql-tutorial.js and then paste the code into the file.
Or just use a standard text editor.
const TerminusClient = require('@terminusdb/terminusdb-client');
const { WOQL } = TerminusClient;
// Configuration
const SERVER_URL = 'http://127.0.0.1:6363';
const ACCOUNT = 'admin';
const DB_NAME = 'woql_tutorial';
const DB_LABEL = 'WOQL Tutorial Database';
const DB_DESCRIPTION = 'A hands-on tutorial database for learning WOQL';
// Initialize client with API key
const client = new TerminusClient.WOQLClient(SERVER_URL, {
user: ACCOUNT,
organization: ACCOUNT,
key: 'root' // Default password - change if you've set a different password
});
/**
* Main Function
*
* Accepts command-line arguments to control which steps to run.
*
* Usage:
* node woql-tutorial.js # Run all steps
* node woql-tutorial.js setup # Run steps 1-3 (setup only)
* node woql-tutorial.js queries # Run steps 4-11 (queries only)
* node woql-tutorial.js step5 # Run specific step
* node woql-tutorial.js step5-8 # Run range of steps
*/
async function main() {
const args = process.argv.slice(2);
const mode = args[0] || 'all';
console.log('='.repeat(60));
console.log(' WOQL Tutorial: Learn by Doing');
console.log('='.repeat(60));
// Map of available steps
const steps = {
1: step1_initializeDatabase,
2: step2_defineSchema,
3: step3_insertDocuments,
4: step4_readAllDocuments,
5: step5_filterByAge,
6: step6_multipleVariables,
7: step7_complexAnd,
8: step8_orQuery,
9: step9_optionalData,
10: step10_orderAndLimit,
11: step11_groupAndAggregate,
12: step12_deleteDocuments
};
try {
let stepsToRun = [];
if (mode === 'all') {
// Run all steps except delete
stepsToRun = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
} else if (mode === 'setup') {
// Run setup steps only
stepsToRun = [1, 2, 3];
} else if (mode === 'queries') {
// Run query steps only
stepsToRun = [4, 5, 6, 7, 8, 9, 10, 11];
} else if (mode.startsWith('step')) {
// Run specific step(s)
const stepMatch = mode.match(/step(\d+)(?:-(\d+))?/);
if (stepMatch) {
const start = parseInt(stepMatch[1]);
const end = stepMatch[2] ? parseInt(stepMatch[2]) : start;
for (let i = start; i <= end; i++) {
if (steps[i]) stepsToRun.push(i);
}
}
} else {
console.log('\nUsage:');
console.log(' node woql-tutorial.js # Run all steps');
console.log(' node woql-tutorial.js setup # Run setup (steps 1-3)');
console.log(' node woql-tutorial.js queries # Run queries (steps 4-11)');
console.log(' node woql-tutorial.js step5 # Run specific step');
console.log(' node woql-tutorial.js step5-8 # Run range of steps');
process.exit(0);
}
console.log(`\nRunning steps: ${stepsToRun.join(', ')}\n`);
for (const stepNum of stepsToRun) {
await steps[stepNum]();
}
console.log('\n' + '='.repeat(60));
console.log(' Tutorial Complete! 🎉');
console.log('='.repeat(60));
console.log('\nNext steps:');
console.log(' - Modify the queries to experiment');
console.log(' - Try adding your own functions');
console.log(' - Explore the advanced guides below');
console.log('');
} catch (error) {
console.error('\n❌ Tutorial failed:', error);
process.exit(1);
}
}
/**
* STEP 1: Initialize Database
*
* This function creates the database if it doesn't exist.
* This is your entry point - run this first!
*/
async function step1_initializeDatabase() {
console.log('\n=== STEP 1: Initialize Database ===');
try {
// Check if database exists
const databases = await client.getDatabases();
const dbExists = databases.some(db => db.name === DB_NAME || db.id === DB_NAME);
if (!dbExists) {
console.log(`Creating database: ${DB_NAME}`);
await client.createDatabase(DB_NAME, {
label: DB_LABEL,
comment: DB_DESCRIPTION,
schema: true
});
console.log('✓ Database created successfully');
} else {
console.log('✓ Database already exists');
}
// Connect to the database
client.db(DB_NAME);
console.log('✓ Connected to database');
} catch (error) {
console.error('Error initializing database:', error.message);
throw error;
}
}
/**
* STEP 2: Define Schema
*
* We'll create a simple schema with Person documents.
* Each Person has a name, age, and optional city.
*/
async function step2_defineSchema() {
console.log('\n=== STEP 2: Define Schema ===');
try {
client.db(DB_NAME);
// Check if Person class already exists
try {
const existingSchema = await client.getDocument({ id: 'Person', graph_type: 'schema' });
if (existingSchema) {
console.log('✓ Schema already exists');
console.log(' - Person class with name, age, city, email');
return;
}
} catch (e) {
// Schema doesn't exist, continue to create it
}
const schema = {
"@type": "Class",
"@id": "Person",
"@key": {
"@type": "Random"
},
"name": "xsd:string",
"age": "xsd:integer",
"city": {
"@type": "Optional",
"@class": "xsd:string"
},
"email": {
"@type": "Optional",
"@class": "xsd:string"
}
};
await client.addDocument(schema, { graph_type: "schema" });
console.log('✓ Schema defined successfully');
console.log(' - Person class with name, age, city, email');
} catch (error) {
console.error('Error defining schema:', error.message);
throw error;
}
}
/**
* STEP 3: Insert Sample Documents
*
* Let's add some people to our database.
* We'll use different ages and cities for interesting queries later.
*/
async function step3_insertDocuments() {
console.log('\n=== STEP 3: Insert Sample Documents ===');
try {
client.db(DB_NAME);
// Check if documents already exist
const existing = await client.getDocument({ type: "Person" });
const existingDocs = Array.isArray(existing) ? existing : (existing ? [existing] : []);
if (existingDocs.length >= 5) {
console.log(`✓ Documents already exist (${existingDocs.length} found)`);
console.log(' Skipping insertion to avoid duplicates');
return;
}
const people = [
{
"@id": "Person/1",
"@type": "Person",
"name": "Alice Johnson",
"age": 28,
"city": "New York",
"email": "alice@example.com"
},
{
"@id": "Person/2",
"@type": "Person",
"name": "Bob Smith",
"age": 35,
"city": "San Francisco",
"email": "bob@example.com"
},
{
"@id": "Person/3",
"@type": "Person",
"name": "Carol Williams",
"age": 28,
"city": "New York"
},
{
"@id": "Person/4",
"@type": "Person",
"name": "David Brown",
"age": 42,
"city": "Austin",
"email": "david@example.com"
},
{
"@id": "Person/5",
"@type": "Person",
"name": "Eve Davis",
"age": 31,
"city": "San Francisco"
}
];
for (const person of people) {
await client.addDocument(person);
console.log(`✓ Inserted: ${person.name}`);
}
console.log(`\n✓ Total documents inserted: ${people.length}`);
} catch (error) {
console.error('Error inserting documents:', error.message);
throw error;
}
}
/**
* STEP 4: Read All Documents
*
* Let's learn our first WOQL query!
*
* CONCEPT: Variable Binding and Type Checking
* - Variables in WOQL start with "v:"
* - isa() checks if a document is of a specific type
* - read_document() reads the full document into a variable
* - select() chooses which variables to return
*/
async function step4_readAllDocuments() {
console.log('\n=== STEP 4: Read All Documents ===');
try {
client.db(DB_NAME);
// Get all Person documents using WOQL
const query = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.read_document("v:docid", "v:doc")
);
const results = await client.query(query);
console.log(`\n✓ Found ${results.bindings.length} documents:`);
results.bindings.forEach((binding, index) => {
const doc = binding.doc; // Note: binding keys don't include "v:" prefix
console.log(` ${index + 1}. ${doc.name} (${doc.age} years old) - ${doc.city || 'no city'}`);
});
} catch (error) {
console.error('Error reading documents:', error.message);
throw error;
}
}
/**
* STEP 5: Filter Documents with triple()
*
* CONCEPT: Property Filtering with Triples
* - triple() matches property values
* - Use literal() to create typed values for matching
* - literal(value, type) ensures type compatibility with stored data
* - Combines isa() for type checking with property filters
* - read_document() retrieves the full document after filtering
*/
async function step5_filterByAge() {
console.log('\n=== STEP 5: Filter by Age ===');
try {
client.db(DB_NAME);
// Find all people aged 28 using WOQL
const query = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "age", WOQL.literal(28, "xsd:integer")),
WOQL.read_document("v:docid", "v:doc")
);
const results = await client.query(query);
console.log(`✓ Found ${results.bindings.length} people aged 28:`);
results.bindings.forEach(binding => {
const doc = binding.doc;
console.log(` - ${doc.name} from ${doc.city || 'unknown city'}`);
});
} catch (error) {
console.error('Error filtering documents:', error.message);
throw error;
}
}
/**
* STEP 6: Extract Multiple Properties with Triples
*
* CONCEPT: Multiple Property Extraction
* - Use multiple triple() calls to extract different properties
* - Variables bind to actual values from the document
* - Can filter to only documents with all required properties
*/
async function step6_multipleVariables() {
console.log('\n=== STEP 6: Extract Specific Fields ===');
try {
client.db(DB_NAME);
// Get name, age, and city for all people who have a city
const query = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "name", "v:name"),
WOQL.triple("v:docid", "age", "v:age"),
WOQL.triple("v:docid", "city", "v:city")
);
const results = await client.query(query);
console.log(`✓ Found ${results.bindings.length} people with cities:`);
results.bindings.forEach(binding => {
console.log(` - ${binding.name['@value']}, age ${binding.age['@value']}, lives in ${binding.city['@value']}`);
});
} catch (error) {
console.error('Error with multiple variables:', error.message);
throw error;
}
}
/**
* STEP 7: Complex Filtering with Comparisons
*
* CONCEPT: Combining Filters with Comparisons
* - greater() checks if a value is greater than another
* - literal() creates a typed value for matching concrete data
* - Why literal()? Properties are stored as typed RDF literals (xsd:string, xsd:integer)
* - Plain strings/numbers won't match - need proper type wrapping
* - Combine property matches with comparison operators
* - All conditions in and() must be satisfied
*/
async function step7_complexAnd() {
console.log('\n=== STEP 7: Complex AND Query ===');
try {
client.db(DB_NAME);
// Find people over 30 in San Francisco
const query = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "age", "v:age"),
WOQL.triple("v:docid", "city", WOQL.literal("San Francisco", "xsd:string")),
WOQL.triple("v:docid", "name", "v:name"),
WOQL.greater("v:age", 30)
);
const results = await client.query(query);
console.log(`✓ Found ${results.bindings.length} people over 30 in San Francisco:`);
results.bindings.forEach(binding => {
console.log(` - ${binding.name['@value']}, age ${binding.age['@value']}`);
});
} catch (error) {
console.error('Error with AND query:', error.message);
throw error;
}
}
/**
* STEP 8: Alternative Queries with or()
*
* CONCEPT: Disjunction (OR logic)
* - or() means AT LEAST ONE condition must be true
* - Each alternative branch can succeed independently
* - Useful for matching multiple possible values
*/
async function step8_orQuery() {
console.log('\n=== STEP 8: OR Query ===');
try {
client.db(DB_NAME);
// Find people in New York OR San Francisco
const query = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "name", "v:name"),
WOQL.or(
WOQL.triple("v:docid", "city", WOQL.literal("New York", "xsd:string")),
WOQL.triple("v:docid", "city", WOQL.literal("San Francisco", "xsd:string"))
)
);
const results = await client.query(query);
console.log(`✓ Found ${results.bindings.length} people in NY or SF:`);
results.bindings.forEach(binding => {
console.log(` - ${binding.name['@value']}`);
});
} catch (error) {
console.error('Error with OR query:', error.message);
throw error;
}
}
/**
* STEP 9: Optional Data with opt()
*
* CONCEPT: Optional Patterns
* - opt() tries to match, but doesn't fail if it can't
* - Essential for optional properties (like email)
* - Query succeeds even when optional part fails
*/
async function step9_optionalData() {
console.log('\n=== STEP 9: Optional Data ===');
try {
client.db(DB_NAME);
// Get all people, with email if available
const query = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "name", "v:name"),
WOQL.opt(WOQL.triple("v:docid", "email", "v:email"))
);
const results = await client.query(query);
console.log(`✓ Found ${results.bindings.length} people:`);
results.bindings.forEach(binding => {
const email = binding.email ? binding.email['@value'] : 'no email';
console.log(` - ${binding.name['@value']}: ${email}`);
});
} catch (error) {
console.error('Error with optional query:', error.message);
throw error;
}
}
/**
* STEP 10: Order and Limit Results
*
* CONCEPT: Result Ordering and Limiting
* - Extract properties as variables for sorting
* - order_by() sorts results by a variable (ascending/descending)
* - limit() restricts the number of results returned
* - These operations compose with triple patterns
*/
async function step10_orderAndLimit() {
console.log('\n=== STEP 10: Order and Limit ===');
try {
client.db(DB_NAME);
// Get the 3 youngest people
const query = WOQL.limit(3,
WOQL.order_by("v:age", "asc",
WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "name", "v:name"),
WOQL.triple("v:docid", "age", "v:age")
)
)
);
const results = await client.query(query);
console.log('✓ The 3 youngest people:');
results.bindings.forEach((binding, index) => {
console.log(` ${index + 1}. ${binding.name['@value']}, age ${binding.age['@value']}`);
});
} catch (error) {
console.error('Error with order/limit:', error.message);
throw error;
}
}
/**
* STEP 11: Group and Aggregate
*
* CONCEPT: Aggregation and Grouping in WOQL
* - group_by(GroupVars, TemplateVars, OutputVar, Query) groups results
* - GroupVars: variables to group by (e.g., ["v:city"])
* - TemplateVars: what to collect in each group (e.g., ["v:docid"])
* - OutputVar: list variable holding all items in each group
* - Query: the query pattern to execute and group
* - length() counts elements in the grouped list
* - member() can iterate over each group member if needed
* - This is pure WOQL aggregation, no JavaScript needed
*/
async function step11_groupAndAggregate() {
console.log('\n=== STEP 11: Group and Aggregate ===');
try {
client.db(DB_NAME);
// Group people by city and count each group
const query = WOQL.and(
WOQL.group_by(
["city"], // Group by city
["docid"], // Collect docids in each group
"v:city_group", // Output variable for grouped list
WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "city", "v:city")
)
),
WOQL.length("v:city_group", "v:count")
);
const results = await client.query(query);
console.log('✓ People per city:');
results.bindings.forEach(binding => {
console.log(` - ${binding.city['@value']}: ${binding.count['@value']} people`);
});
} catch (error) {
console.error('Error with grouping:', error.message);
throw error;
}
}
/**
* STEP 12: Negation with not() and Document Deletion
*
* CONCEPT: Declarative Deletion
* - not() operator succeeds when inner pattern fails
* - Use to find documents MISSING properties
* - delete_document() as part of WOQL query (not JavaScript loop)
* - Demonstrates complete CRUD operations declaratively
*/
async function step12_deleteDocuments() {
console.log('\n=== STEP 12: Negation and Deletion ===');
try {
client.db(DB_NAME);
// Find and delete people WITHOUT an email address using not()
const deleteQuery = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.not(WOQL.triple("v:docid", "email", "v:email")), // Negation: no email
WOQL.delete_document("v:docid") // Delete matched documents
);
const results = await client.query(deleteQuery);
console.log(`✓ Deleted ${results.bindings.length} people without email`);
} catch (error) {
console.error('Error deleting documents:', error.message);
throw error;
}
}
// Run the tutorial
main();Running the Tutorial
Save the code above to woql-tutorial.js and run:
node woql-tutorial.jsYou should see output for each step showing the queries being executed and their results.
Understanding Each Step
Now let's build the tutorial step by step! We'll start with the boilerplate code that sets up your file, then add each step function one at a time.
SetupBoilerplate Code
Boilerplate Code
Learning Objective: Set up the foundation file structure that all tutorial steps will build upon.
What This Does: This boilerplate code provides the imports, configuration, and main function that orchestrates running your tutorial steps. You'll paste this first, then add each step function below it.
Start your woql-tutorial.js file with this:
const TerminusClient = require('@terminusdb/terminusdb-client');
const { WOQL } = TerminusClient;
// Configuration - adjust these if your setup differs
const SERVER_URL = 'http://127.0.0.1:6363';
const ACCOUNT = 'admin';
const DB_NAME = 'woql_tutorial';
const DB_LABEL = 'WOQL Tutorial Database';
const DB_DESCRIPTION = 'A hands-on tutorial database for learning WOQL';
// Initialize client with API key
const client = new TerminusClient.WOQLClient(SERVER_URL, {
user: ACCOUNT,
organization: ACCOUNT,
key: 'root' // Default password - change if you've set a different password
});
/**
* Main Function - Orchestrates tutorial steps
*
* Usage examples:
* node woql-tutorial.js # Run all steps
* node woql-tutorial.js setup # Run steps 1-3 only
* node woql-tutorial.js step5 # Run specific step
* node woql-tutorial.js step5-8 # Run range of steps
*/
async function main() {
const args = process.argv.slice(2);
const mode = args[0] || 'all';
console.log('='.repeat(60));
console.log(' WOQL Tutorial: Learn by Doing');
console.log('='.repeat(60));
// Map of available steps (you'll add these functions below)
const steps = {
1: step1_initializeDatabase,
2: step2_defineSchema,
3: step3_insertDocuments,
4: step4_readAllDocuments,
5: step5_filterByAge,
6: step6_multipleVariables,
7: step7_complexAnd,
8: step8_orQuery,
9: step9_optionalData,
10: step10_orderAndLimit,
11: step11_groupAndAggregate
};
try {
let stepsToRun = [];
if (mode === 'all') {
stepsToRun = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
} else if (mode === 'setup') {
stepsToRun = [1, 2, 3];
} else if (mode === 'queries') {
stepsToRun = [4, 5, 6, 7, 8, 9, 10, 11];
} else if (mode.startsWith('step')) {
const stepMatch = mode.match(/step(\d+)(?:-(\d+))?/);
if (stepMatch) {
const start = parseInt(stepMatch[1]);
const end = stepMatch[2] ? parseInt(stepMatch[2]) : start;
for (let i = start; i <= end; i++) {
if (steps[i]) stepsToRun.push(i);
}
}
}
console.log(`\nRunning steps: ${stepsToRun.join(', ')}\n`);
for (const stepNum of stepsToRun) {
await steps[stepNum]();
}
console.log('\n' + '='.repeat(60));
console.log(' Tutorial Complete! 🎉');
console.log('='.repeat(60));
console.log('\nNext: Try modifying queries or add your own!');
} catch (error) {
console.error('\n❌ Tutorial failed:', error.message);
process.exit(1);
}
}
// Add your step functions below this line
// Each section below will show you what to add
// Run the tutorial
main();What's in the Boilerplate:
- Imports - TerminusClient and WOQL from the client library
- Configuration - Server URL, credentials, database name
- Client initialization - Connected and ready to use
- Main function - Handles command-line arguments and runs your steps
- Steps map - References to functions you'll add below
Next Steps: After pasting this boilerplate, you'll add each step function below it. Start with Step 1!
Learning Objective: Understand how to create and connect to a TerminusDB database programmatically.
Concept: While WOQL can be even be used to reason without a database, most TerminusDB workflows starts with database initialization, or connecting to a database. This step creates a new database (if it doesn't exist) and establishes a connection for subsequent operations.
Key Operations:
- Check if database already exists
- Create database with metadata (label, description)
- Connect client to the database
Code:
async function step1_initializeDatabase() {
console.log('\n=== STEP 1: Initialize Database ===');
try {
// Check if database already exists
const databases = await client.getDatabaseList(ACCOUNT);
const exists = databases.some(db => db.name === DB_NAME);
if (exists) {
console.log(`✓ Database '${DB_NAME}' already exists`);
} else {
// Create new database
console.log(`Creating database: ${DB_NAME}`);
await client.createDatabase(DB_NAME, {
label: DB_LABEL,
comment: DB_DESCRIPTION
});
console.log('✓ Database created successfully');
}
// Connect to the database
client.db(DB_NAME);
console.log('✓ Connected to database');
} catch (error) {
console.error('Error initializing database:', error.message);
throw error;
}
}What's Happening:
getDatabaseList()retrieves all databases for your account- Check if our tutorial database already exists to avoid errors
createDatabase()creates a new database with metadataclient.db()connects the client to work with this specific database
Learning Objective: Learn how to define document classes with typed properties and optional fields.
Concept: Most of the time a schema is used to define the structure of your documents. Schemas provide type safety, validation, and enable powerful querying capabilities. TerminusDB can be used schemaless too with pure JSON objects, or in hybrid mode.
Key Schema Features:
@type: "Class"- Defines a document class@key- Specifies how document IDs are generated (Random, Lexical, Hash)- Typed properties (
xsd:string,xsd:integer) - Optional fields using
@type: "Optional"
Code:
async function step2_defineSchema() {
console.log('\n=== STEP 2: Define Schema ===');
try {
client.db(DB_NAME);
// Check if schema already exists
try {
const existingSchema = await client.getDocument({
id: 'Person',
graph_type: 'schema'
});
if (existingSchema) {
console.log('✓ Schema already exists');
return;
}
} catch (e) {
// Schema doesn't exist, continue to create it
}
const schema = {
"@type": "Class",
"@id": "Person",
"@key": {
"@type": "Random"
},
"name": "xsd:string",
"age": "xsd:integer",
"city": {
"@type": "Optional",
"@class": "xsd:string"
},
"email": {
"@type": "Optional",
"@class": "xsd:string"
}
};
await client.addDocument(schema, { graph_type: 'schema' });
console.log('✓ Schema defined successfully');
console.log(' - Person class with name, age, city, email');
} catch (error) {
console.error('Error defining schema:', error.message);
throw error;
}
}What's Happening:
- Check if schema already exists to avoid error from adding it twice
- Define Person class with required properties (name, age)
- Add optional properties (city, email) that may be omitted
- Use
graph_type: 'schema'to store in schema graph (not instance data)
Learning Objective: Understand how to insert documents with explicit IDs and handle duplicate prevention.
Concept: With your schema defined, you can now insert documents. Documents must conform to the schema. We use explicit @id values here for predictable, stable document identifiers.
Key Operations:
- Explicit
@idassignment for stable identifiers - Document validation against schema
- Duplicate prevention checks
- Batch insertion patterns
Code:
async function step3_insertDocuments() {
console.log('\n=== STEP 3: Insert Sample Documents ===');
try {
client.db(DB_NAME);
// Check if documents already exist
const existing = await client.getDocument({ id: 'Person/1' });
if (existing) {
console.log('✓ Documents already exist (5 found)');
console.log(' Skipping insertion to avoid duplicates');
return;
}
} catch (e) {
// Documents don't exist, proceed with insertion
}
const people = [
{
"@id": "Person/1",
"@type": "Person",
"name": "Alice Johnson",
"age": 28,
"city": "New York",
"email": "alice@example.com"
},
{
"@id": "Person/2",
"@type": "Person",
"name": "Bob Smith",
"age": 35,
"city": "San Francisco",
"email": "bob@example.com"
},
{
"@id": "Person/3",
"@type": "Person",
"name": "Carol Williams",
"age": 28,
"city": "New York"
},
{
"@id": "Person/4",
"@type": "Person",
"name": "David Brown",
"age": 42,
"city": "Austin",
"email": "david@example.com"
},
{
"@id": "Person/5",
"@type": "Person",
"name": "Eve Davis",
"age": 31,
"city": "San Francisco"
}
];
await client.addDocument(people);
console.log(`✓ Inserted: ${people}`);
console.log(`\n✓ Total documents inserted: ${people.length}`);
}What's Happening:
- Check if documents exist to prevent duplicates on repeated runs
- Define array of Person documents with varied data
- Use explicit IDs (
Person/1,Person/2, etc.) for predictable references - Notice Carol and Eve omit
emailfield (allowed by Optional schema) - Insert all documents at once with
addDocument(people)- the client accepts arrays for batch insertion
Learning Objective: Master the fundamental WOQL pattern for reading documents using type checking and document retrieval.
Concept: Variable Binding and Type Checking
WOQL is a declarative query language based on datalog. Instead of telling the system how to find data, you describe what you want:
- Variables start with
v:prefix (e.g.,v:docid,v:doc) isa(Variable, Type)checks if a document is of a specific typeread_document(IDVar, DocVar)retrieves the full document- Variables bind to values that satisfy the query conditions
Key WOQL Operations:
and()- Combines multiple conditions (all must be true)isa()- Type checking predicateread_document()- Document retrieval predicate
Code:
async function step4_readAllDocuments() {
console.log('\n=== STEP 4: Read All Documents ===');
try {
client.db(DB_NAME);
// Get all Person documents using WOQL
const query = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.read_document("v:docid", "v:doc")
);
const results = await client.query(query);
console.log(`\n✓ Found ${results.bindings.length} documents:`);
results.bindings.forEach((binding, index) => {
const doc = binding.doc; // Note: binding keys don't include "v:" prefix
console.log(` ${index + 1}. ${doc.name} (${doc.age} years old) - ${doc.city || 'no city'}`);
});
} catch (error) {
console.error('Error reading documents:', error.message);
throw error;
}
}What's Happening:
isa("v:docid", "Person")- Finds all document IDs where type is a Person, or a subclass of Personread_document("v:docid", "v:doc")- For each ID, retrieves the full documentand()- Both conditions must be satisfied- Results in
bindingsarray where each binding hasdocidanddocproperties - Access bound variables without the
v:prefix in results
Declarative Thinking: We didn't write a loop or filtering logic. We declared "find documents of type Person and read them" - the query engine figures out how.
Learning Objective: Learn to filter documents by property values using triple patterns and typed literals.
Concept: Triple Patterns and Typed Literals
Under the hood, TerminusDB stores all data as RDF triples: (subject, predicate, object). The triple() predicate lets you match specific property patterns:
triple(Subject, Property, Value)- Matches subject-property-value patterns- Properties are stored as typed RDF literals (e.g.,
28^^xsd:integer) - Use
literal(value, type)to specify typed values for matching - Combine with
isa()for type-safe filtering
Key Operations:
triple()- Property pattern matchingliteral()- Specifies typed literal values- Combining type checking (
isa) with property filters (triple)
Code:
async function step5_filterByAge() {
console.log('\n=== STEP 5: Filter by Age ===');
try {
client.db(DB_NAME);
// Find all people aged 28
const query = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "age", WOQL.literal("28", "xsd:integer")),
WOQL.read_document("v:docid", "v:doc")
);
const results = await client.query(query);
console.log(`✓ Found ${results.bindings.length} people aged 28:`);
results.bindings.forEach(binding => {
const doc = binding.doc;
console.log(` - ${doc.name} from ${doc.city}`);
});
} catch (error) {
console.error('Error filtering documents:', error.message);
throw error;
}
}What's Happening:
isa("v:docid", "Person")- Start with all Person documentstriple("v:docid", "age", literal(28, "xsd:integer"))- Filter to only those with age=28literal(28, "xsd:integer")- Creates a typed literal that matches stored dataread_document()- Retrieves full document for matched IDs- Only documents satisfying ALL three conditions are returned
Why literal()?
Plain 28 would be interpreted as a URI. Properties are stored as typed literals (28^^xsd:integer), so we need literal() to specify matching typed values.
When high precision is needed, submitting a string is preferred over submitting a number due to many clients having narrow precision limits in computing numbers (floats and doubles, vs use of decimals and rationals in TerminusDB).
Learning Objective: Extract multiple properties from documents and understand variable unification in WOQL.
Concept: Multiple Property Extraction and Unification
When you use multiple triple() patterns with the same subject variable, you're extracting multiple properties from each document. This demonstrates a key datalog principle: unification.
- Same variable appearing multiple times must bind to the same value
v:docidunifies across all triple patterns - same document for all properties- Only documents with all specified properties will match
- Each property binds to a separate variable
Key Pattern:
triple("v:docid", "property1", "v:var1"),
triple("v:docid", "property2", "v:var2"),
triple("v:docid", "property3", "v:var3")Code:
async function step6_multipleVariables() {
console.log('\n=== STEP 6: Extract Specific Fields ===');
try {
client.db(DB_NAME);
// Get name, age, and city for all people who have a city
const query = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "name", "v:name"),
WOQL.triple("v:docid", "age", "v:age"),
WOQL.triple("v:docid", "city", "v:city")
);
const results = await client.query(query);
console.log(`✓ Found ${results.bindings.length} people with cities:`);
results.bindings.forEach(binding => {
console.log(` - ${binding.name['@value']}, age ${binding.age['@value']}, lives in ${binding.city['@value']}`);
});
} catch (error) {
console.error('Error with multiple variables:', error.message);
throw error;
}
}What's Happening:
- Four patterns all use
v:docid- must be the same document for all - Extract three separate properties into three variables
triple("v:docid", "city", "v:city")- Only matches documents that have a city property- Result bindings contain
name,age, andcityas separate values - Access typed literals with
['@value']to get the actual value
Unification in Action: Since v:docid appears in all patterns, WOQL ensures all properties come from the same document, for each document where all constraints are fulfilled. This is automatic constraint enforcement! Think of each possible solution (each docid) as it's own "world", where variables get bound to possible value(s).
Learning Objective: Combine multiple filter conditions using comparisons and typed literals.
Concept: Complex Filtering with Comparisons
Building on what we've learned, we can combine property matching with comparison operators for powerful filtering:
greater(Var, Value)- Checks if variable value is greater than anotherless(Var, Value)- Checks if variable value is less than another- Mix property filters with comparisons in
and()blocks - All conditions must be satisfied simultaneously
Key Operations:
- Comparison operators (
greater,less,equals) - Combining
literal()matching with comparisons - Multiple property constraints
Code:
async function step7_complexAnd() {
console.log('\n=== STEP 7: Complex AND Query ===');
try {
client.db(DB_NAME);
// Find people over 30 in San Francisco
const query = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "age", "v:age"),
WOQL.triple("v:docid", "city", WOQL.literal("San Francisco", "xsd:string")),
WOQL.triple("v:docid", "name", "v:name"),
WOQL.greater("v:age", 30)
);
const results = await client.query(query);
console.log(`✓ Found ${results.bindings.length} people over 30 in San Francisco:`);
results.bindings.forEach(binding => {
console.log(` - ${binding.name['@value']}, age ${binding.age['@value']}`);
});
} catch (error) {
console.error('Error with AND query:', error.message);
throw error;
}
}What's Happening:
isa("v:docid", "Person")- Type constrainttriple("v:docid", "age", "v:age")- Extract age for comparisontriple("v:docid", "city", literal("San Francisco", "xsd:string"))- City filter using typed literalgreater("v:age", 30)- Age comparison constraint- All five conditions must be true for a match
Why This Pattern: Extract value to variable (v:age), then use comparison operator on that variable. This is more flexible than inline comparisons.
Why use literal()?
When you write a plain string in a WOQL triple like "San Francisco", the system interprets it as a URI, prefixed by the base IRI configured in your graph (e.g., http://mybase.com/San Francisco).
Properties in TerminusDB are stored as typed RDF literals with explicit datatypes:
- Strings:
"San Francisco"^^xsd:string - Integers:
28^^xsd:integer - Decimals:
3.14^^xsd:decimal
Use WOQL.literal(value, type) to create properly typed literals that match your stored data. Without it, plain values won't match because they're interpreted as URIs instead of typed literals.
Learning Objective: Use disjunction (OR logic) to match documents satisfying any of multiple conditions.
Concept: Disjunction in WOQL
While and() requires all conditions to be true, or() succeeds if at least one condition is true:
- Each branch of
or()is evaluated independently (own "world") - A document matches if it satisfies any branch
- Useful for matching multiple possible values
- Can combine with
and()for complex logic
Code:
async function step8_orQuery() {
console.log('\n=== STEP 8: OR Query ===');
try {
client.db(DB_NAME);
// Find people in New York OR San Francisco
const query = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "name", "v:name"),
WOQL.or(
WOQL.triple("v:docid", "city", WOQL.literal("New York", "xsd:string")),
WOQL.triple("v:docid", "city", WOQL.literal("San Francisco", "xsd:string"))
)
);
const results = await client.query(query);
console.log(`✓ Found ${results.bindings.length} people in NY or SF:`);
results.bindings.forEach(binding => {
console.log(` - ${binding.name['@value']}`);
});
} catch (error) {
console.error('Error with OR query:', error.message);
throw error;
}
}What's Happening:
and()wrapper ensures ALL outer conditions are metor()nested inside - city must be "New York" OR "San Francisco"- Document matches if city equals either value
- Both branches use
literal()for proper type matching - Results include people from both cities
Logical Structure: and(type=Person, has_name, (city=NY or city=SF)) - combination of conjunction (and) and disjunction (or).
Learning Objective: Handle optional fields gracefully using WOQL's opt() operator.
Concept: Optional Patterns
Not all documents have all properties (e.g., some people don't have email addresses). The opt() operator handles this:
opt(Pattern)- Tries to match the pattern- If it succeeds: variable is bound to the value
- If it fails: query continues (doesn't fail), variable remains unbound
- Essential for queries that should succeed regardless of optional field presence
Without opt(): Query fails if any document lacks the property With opt(): Query succeeds, variable is undefined if property is missing
Code:
async function step9_optionalData() {
console.log('\n=== STEP 9: Optional Data ===');
try {
client.db(DB_NAME);
// Get all people, with email if it exists
const query = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "name", "v:name"),
WOQL.opt(
WOQL.triple("v:docid", "email", "v:email")
)
);
const results = await client.query(query);
console.log(`✓ Found ${results.bindings.length} people:`);
results.bindings.forEach(binding => {
const email = binding.email ? binding.email['@value'] : 'no email';
console.log(` - ${binding.name['@value']}: ${email}`);
});
} catch (error) {
console.error('Error with optional data:', error.message);
throw error;
}
}What's Happening:
isa()and firsttriple()are required - must matchopt(triple("v:docid", "email", "v:email"))- Email triple is optional- If document has email:
binding.emailis bound to the email value - If document lacks email:
binding.emailisundefined, query still succeeds - Check
binding.emailexistence before accessing to avoid errors
Use Case: Query all people, showing email when available, without filtering out people without emails.
Learning Objective: Control result ordering and quantity using WOQL's ordering and limiting operators.
Concept: Result Ordering and Limiting
Once you've matched documents, you often want to control how many results and in what order:
order_by(Variable, Direction, Query)- Sorts results by a variable- Direction:
"asc"(ascending) or"desc"(descending)
- Direction:
limit(Count, Query)- Returns only the first N results- These compose:
limit(3, order_by("v:age", "asc", ...))= "3 youngest"
Pattern: Extract the property to sort by, then order and limit:
limit(N,
order_by("v:property", "asc",
and(
isa(...),
triple("v:docid", "property", "v:property"),
...
)
)
)Code:
async function step10_orderAndLimit() {
console.log('\n=== STEP 10: Order and Limit ===');
try {
client.db(DB_NAME);
// Get the 3 youngest people
const query = WOQL.limit(3,
WOQL.order_by("v:age", "asc",
WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "name", "v:name"),
WOQL.triple("v:docid", "age", "v:age")
)
)
);
const results = await client.query(query);
console.log('✓ The 3 youngest people:');
results.bindings.forEach((binding, index) => {
console.log(` ${index + 1}. ${binding.name['@value']}, age ${binding.age['@value']}`);
});
} catch (error) {
console.error('Error with order/limit:', error.message);
throw error;
}
}What's Happening:
- Inner
and()- Match all Persons, extract name and age order_by("v:age", "asc", ...)- Sort results by age in ascending order (youngest first)limit(3, ...)- Take only the first 3 results after sorting- Result: 3 youngest people in order
Composition: WOQL operators wrap each other like functions. Read inside-out: match → sort → limit.
Learning Objective: Perform aggregations by grouping documents and counting group members using pure WOQL.
Concept: Grouping and Aggregation
WOQL provides powerful aggregation through group_by() - think SQL's GROUP BY:
group_by(GroupVars, TemplateVars, OutputVar, Query)groups results- GroupVars: Array of variables to group by (e.g.,
["city"]) - TemplateVars: Array of variables to collect in each group (e.g.,
["docid"]) - OutputVar: Variable holding the list of collected items per group
- Query: The pattern to execute and group
- GroupVars: Array of variables to group by (e.g.,
length(ListVar, CountVar)- Counts elements in a list- Combine them for "count by category" queries
Pattern: Group → Count
and(
group_by(["v:category"], ["v:item"], "v:group",
query_pattern
),
length("v:group", "v:count")
)Code:
async function step11_groupAndAggregate() {
console.log('\n=== STEP 11: Group and Aggregate ===');
try {
client.db(DB_NAME);
// Group people by city and count each group
const query = WOQL.and(
WOQL.group_by(
["city"], // Group by city
["docid"], // Collect docids in each group
"v:city_group", // Output variable for grouped list
WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.triple("v:docid", "city", "v:city")
)
),
WOQL.length("v:city_group", "v:count")
);
const results = await client.query(query);
console.log('✓ People per city:');
results.bindings.forEach(binding => {
console.log(` - ${binding.city['@value']}: ${binding.count['@value']} people`);
});
} catch (error) {
console.error('Error with grouping:', error.message);
throw error;
}
}What's Happening:
- Inner query finds all Persons with cities
group_by(["city"], ["docid"], "v:city_group", ...)groups results:- For each unique
v:cityvalue (specifies the variable name) - Collect all
docidvalues into a list (variable name) - Store list in
v:city_groupvariable
- For each unique
length("v:city_group", "v:count")- Count items in each group's list- Results: One binding per city with count
Pure WOQL Aggregation: No JavaScript loops or counting needed. The query engine handles all aggregation. This is the declarative power of datalog!
Concept: Aggregation
group_by()groups results by a variablecount()gives us the number in each group
Learning Objective: Use negation to find documents that DON'T match a pattern, then delete them based on that criteria.
Concept: Negation in WOQL
WOQL supports logical negation with not(), which succeeds when the inner pattern fails to match:
not(Pattern)- True when Pattern is false- Use to find documents missing properties
- Combine with deletion to remove incomplete records
- Demonstrates complete CRUD operations (Create, Read, Update, Delete)
Key Operations:
not()- Logical negation operatortriple()insidenot()- Tests for absence of a property- Query to find, then delete by ID
- Clean ID extraction (remove prefix)
Code:
async function step12_deleteDocuments() {
console.log('\n=== STEP 12: Negation and Deletion ===');
try {
client.db(DB_NAME);
// Find and delete people WITHOUT an email address using not()
const deleteQuery = WOQL.and(
WOQL.isa("v:docid", "Person"),
WOQL.not(WOQL.triple("v:docid", "email", "v:email")), // Negation: no email
WOQL.delete_document("v:docid") // Delete matched documents
);
const results = await client.query(deleteQuery);
console.log(`✓ Deleted ${results.bindings.length} people without email`);
} catch (error) {
console.error('Error deleting documents:', error.message);
throw error;
}
}What's Happening:
isa("v:docid", "Person")- Find all Person documentsnot(triple("v:docid", "email", "v:email"))- Key: Matches documents that DON'T have an email propertydelete_document("v:docid")- Deletes all matched documents in one declarative operation- No JavaScript loops needed - WOQL handles the iteration
Negation Logic:
triple("v:docid", "email", "v:email")succeeds if email existsnot(triple(...))inverts: succeeds if email does NOT existdelete_document()operates on all bindings that satisfy the negation
Declarative Deletion: Unlike imperative loops, delete_document() is part of the WOQL query itself. The query finds and deletes in a single operation - this is the power of declarative data modification!
Why This Pattern: Negation with delete_document() is powerful for data cleanup - find and remove incomplete records, test for missing relationships, or implement "must not have" constraints, all in pure WOQL.
Key WOQL Concepts Explained
1. Variables and Binding
Variables in WOQL use the v: prefix. When a variable appears in multiple predicates, it must unify to the same value:
WOQL.and(
WOQL.triple("v:person", "@schema:age", "v:age"), // Binds v:age
WOQL.greater("v:age", 30) // Uses bound v:age
)2. Triple Patterns
TerminusDB stores data as triples (subject, predicate, object). You query by matching patterns:
// Match: subject=v:person, predicate=@schema:name, object=v:name
WOQL.triple("v:person", "@schema:name", "v:name")3. Declarative vs Imperative
Imperative (JavaScript): "Do these steps in order"
const people = await fetchPeople();
const filtered = people.filter(p => p.age > 30);
const names = filtered.map(p => p.name);Declarative (WOQL): "Describe the logic of what you want"
WOQL.and(
WOQL.triple("v:person", "@schema:age", "v:age"),
WOQL.greater("v:age", 30),
WOQL.triple("v:person", "@schema:name", "v:name")
)4. Unification
When the same variable appears multiple times, WOQL ensures it has the same value everywhere:
// Find two people with the same age
WOQL.and(
WOQL.triple("v:person1", "@schema:age", "v:age"),
WOQL.triple("v:person2", "@schema:age", "v:age"), // Same v:age!
WOQL.not().eq("v:person1", "v:person2") // But different people
)Experiment and Extend
Now that you've completed the tutorial, try these challenges:
- Add more fields: Extend the Person schema with
occupationandcountry - Complex filters: Find people aged 25-35 in specific cities
- Update documents: Create a function that updates a person's age
- Relationships: Add a
Friendclass connecting people together - Path queries: Find friends of friends using WOQL's path capabilities
Advanced WOQL Guides
Ready to go deeper? Explore these specialized guides:
Core Operations
- WOQL Basics - Comprehensive language reference
- WOQL Add Docs - Document creation patterns
- WOQL Edit Docs - Update strategies
- WOQL Delete Docs - Safe deletion patterns
- WOQL Read Docs - Advanced reading techniques
Advanced Queries
- WOQL Filter - Complex filtering patterns
- WOQL Order By - Sorting and pagination
- WOQL Query Arrays - Working with collections
- WOQL Group Results - Aggregation and analytics
- WOQL Path Queries - Graph traversal
- WOQL Math Queries - Calculations and expressions
- WOQL Schema Queries - Querying your schema
Troubleshooting
Connection Errors
If you can't connect to TerminusDB:
// Check your server URL
const SERVER_URL = 'http://127.0.0.1:6363';
// Verify authentication
client.setApiKey('root'); // Default passwordSchema Errors
If schema creation fails, the fastest way is to fix it might be to recreate the database:
await client.deleteDatabase(DB_NAME);
await client.createDatabase(DB_NAME, { label: DB_LABEL });Empty Results
If queries return no results:
- Check that you've run steps 1-3 first
- Verify documents exist:
await step4_readAllDocuments() - Check variable names match exactly (case-sensitive)
Understanding the Output
When you run a WOQL query, you get back bindings - solutions that satisfy your query:
{
bindings: [
{ "v:name": "Alice Johnson", "v:age": 28 },
{ "v:name": "Carol Williams", "v:age": 28 }
]
}Each binding is one complete solution. Multiple bindings mean multiple solutions were found.
Cleanup
After completing the tutorial, you may want to clean up the test database and data.
Be Careful with Deletion
Deleting a database or documents is permanent and cannot be undone. Make sure you're targeting the correct database before running cleanup commands.
Remove Tutorial Documents
To remove the specific documents created in this tutorial:
# Delete all Person documents created in the tutorial
curl -X DELETE "http://127.0.0.1:6363/api/document/admin/woql_tutorial?author=admin&message=Cleanup%20tutorial%20documents" \
-u "admin:root" \
-H "Content-Type: application/json" \
-d '["Person/1", "Person/2", "Person/3", "Person/4", "Person/5"]'Or delete documents individually:
# Delete one document at a time
curl -X DELETE "http://127.0.0.1:6363/api/document/admin/woql_tutorial?id=Person/1&author=admin&message=Delete%20Person1" \
-u "admin:root"
curl -X DELETE "http://127.0.0.1:6363/api/document/admin/woql_tutorial?id=Person/2&author=admin&message=Delete%20Person2" \
-u "admin:root"
# ... and so on for Person/3, Person/4, Person/5Delete the Tutorial Database
To completely remove the tutorial database:
const TerminusClient = require('@terminusdb/terminusdb-client');
const client = new TerminusClient.WOQLClient('http://127.0.0.1:6363', {
user: 'admin',
organization: 'admin',
key: 'root'
});
// Delete the tutorial database
await client.deleteDatabase('woql_tutorial');
console.log('✓ Tutorial database deleted');Or run from command line:
curl -X DELETE "http://127.0.0.1:6363/api/db/admin/woql_tutorial" \
-u "admin:root"Next Steps
- ✅ Complete this hands-on tutorial
- 📖 Read WOQL Explanation for deeper concepts
- 🔧 Explore the JavaScript Client API
- 🐍 Try the Python Client for Python developers
- 🎯 Build your first real application with TerminusDB
Congratulations! You've learned the fundamentals of WOQL through hands-on practice. You now understand variable binding, unification, triple patterns, and how to build complex declarative queries. Keep experimenting and building!