Optic Joins Starter Pack

Pick the right join shape before your plan becomes harder than the data

personClever Llamas
CleverLlamasMinimum Llamaverse Version: 2.5.0
databaseMinimum MarkLogic Version: 11

Row sources, join condition, and resulting row shape sit side by side here, so you can see exactly why one join type kept a row and another one quietly dropped it.

This starter pack is meant to be read as a decision guide. It helps you choose the right join shape before your query grows into a tangle of partial matches, missing rows, and confusing output columns.

The examples in this article assume the llamaverse (v2.5.0+) is deployed. The llamaverse sample data is freely available from github.com/cleverllamas/llamaverse - see the llamaverse article for full setup instructions.

API Context for This Pack

Context ItemWhat this pack uses
Primary row sourcesllamaverse.wildLlamas and llamaverse.secretPowers views
Primary join functionsop:join-inner, op:join-left-outer, op:join-full-outer, op:exists-join, op:not-exists-join, op:join-cross-product
Join key patternwildLlamas.secretPowerId -> secretPowers.id
Output styleShort row samples showing the shape difference between join types

Join Type Reference

FunctionWhat it keepsUse when
op:join-inner()Only matched left/right pairsBoth sides are required for the answer
op:join-left-outer()All left rows plus matched right rowsThe left side is primary and missing matches are still meaningful
op:join-full-outer()All rows from both sidesYou need to see unmatched rows on either side
op:exists-join()Only left rows that have a matchThe join is really a filter, not a merge
op:not-exists-join()Only left rows that do not have a matchYou need the gaps, exceptions, or missing relationships
op:join-cross-product()Every left/right combination, optionally filteredYou need non-equality comparisons or deliberately broad pairing logic

op:join-inner()

op:join-inner() is the default relational join shape. Use it when the answer is only meaningful for rows that match on both sides.

The full signature is:

op:join-inner($leftPlan, $rightPlan, [$keys as op:on()*], [$condition as boolean-expression?])
ParameterTypeWhat it controls
$leftPlanplanThe left-side row set (the plan you are chaining from)
$rightPlanplanThe right-side row set to join against
$keysop:on()* — optionalOne or more equality conditions built with op:on(). Each op:on() compares one left column to one right column. Pass an empty sequence () / null when using $condition only.
$conditionboolean expression — optionalAny boolean expression built with the Optic boolean expression functions (op:eq, op:gt, op:ge, op:lt, op:le, op:ne, op:and, op:or, op:not). Used for non-equality or complex join conditions.

When both $keys and $condition are provided they are combined with AND at plan evaluation time — a row must satisfy all key equalities AND the condition to appear in the result.

Equality join using op:on

The most common form: one or more op:on() calls providing the equality condition. Pass multiple op:on() calls as a sequence to join on compound keys.

In the llamaverse, both llamas and secretPowers are TDE projections of the same source document. Each llama document carries an embedded secretPower object; the TDE extracts the llama fields into the llamas view and the embedded power fields into a separate secretPowers view row. The join re-unites those two projections by matching llamas.secretPowerId against secretPowers.id.

{
  "id": "0c8bdb0d-ac62-49b7-ac74-94dbba46efa5",
  "name": "Aaron",
  "heightCm": 180,
  "breed": "Huacaya",
  "placeOfBirth": "Cusco, Peru",
  "secretPower": {
    "name": "Snake Charmer",
    "id": "f6236908-9c0e-416e-a591-a1bd3986dc05"
  }
}
xquery version "1.0-ml";
import module namespace op = "http://marklogic.com/optic" at "/MarkLogic/optic.xqy";

let $llamas := op:from-view("llamaverse", "llamas")
let $powers := op:from-view("llamaverse", "secretPowers")

return
$llamas
  => op:join-inner(
       $powers,
       op:on(
         op:view-col("llamas", "secretPowerId"),
         op:view-col("secretPowers", "id")
       )
     )
  => op:select((
       op:view-col("llamas", "name"),
       op:view-col("secretPowers", "name"),
       op:view-col("secretPowers", "description")
     ))
  => op:limit(3)
  => op:result()
'use strict';

const op = require('/MarkLogic/optic');

const llamas = op.fromView('llamaverse', 'llamas');
const powers = op.fromView('llamaverse', 'secretPowers');

const results = llamas
  .joinInner(
    powers,
    op.on(
      op.viewCol('llamas', 'secretPowerId'),
      op.viewCol('secretPowers', 'id')
    )
  )
  .select([
    op.viewCol('llamas', 'name'),
    op.viewCol('secretPowers', 'name')
  ])
  .limit(5)
  .result();

({
  sample: 'optic/joins-starter-pack/assets/op-join-inner.sjs',
  kind: Array.isArray(results) ? (results.every((item) => typeof item === 'object' && 'subject' in item && 'predicate' in item && 'object' in item) ? 'triples' : 'rows') : ((results !== null && typeof results === 'object') ? 'object' : 'scalar'),
  count: Array.isArray(results) ? results.length : 0,
  data: results
});
{"llamaverse.llamas.name":"Cory", "llamaverse.secretPowers.name":"Beard of the Bards", "llamaverse.secretPowers.description":"Possesses a musical beard that tells epic tales and sings backup harmonies during karaoke battles."}
{"llamaverse.llamas.name":"Angela", "llamaverse.secretPowers.name":"Leaf Dancer", "llamaverse.secretPowers.description":"Performs enchanted dances that make leaves swirl, squirrels applaud, and acorns roll in rhythm."}
{"llamaverse.llamas.name":"Jaime", "llamaverse.secretPowers.name":"Featherlight Step", "llamaverse.secretPowers.description":"So light-footed they’ve been mistaken for a gentle breeze. Ideal for sneaking into cookie jars undetected."}
llamaverse.llamas.namellamaverse.secretPowers.namellamaverse.secretPowers.description
CoryBeard of the BardsPossesses a musical beard that tells epic tales and sings backup harmonies during karaoke battles.
AngelaLeaf DancerPerforms enchanted dances that make leaves swirl, squirrels applaud, and acorns roll in rhythm.
JaimeFeatherlight StepSo light-footed they’ve been mistaken for a gentle breeze. Ideal for sneaking into cookie jars undetected.

What to notice: the answer only exists where both row sets contribute something — no llama, no power, no row. That's the whole deal with an inner join.

Combining keys and condition

Pass both $keys and $condition when the equality join needs an additional inequality constraint applied during the join. The equality key narrows the candidate pairs; the condition filters within those pairs. This is more efficient than adding a separate op:where() after a broad join because the condition participates in the plan’s join optimisation.

Here, we join llamas to their secret powers by the equality key, but keep only llamas taller than 155 cm:

xquery version "1.0-ml";
import module namespace op = "http://marklogic.com/optic" at "/MarkLogic/optic.xqy";

(: Equality key (op:on) combined with an additional boolean condition.      :)
(: The two parameters are ANDed at plan evaluation time:                     :)
(:   - keys: wildLlamas.secretPowerId = secretPowers.id  (equality)          :)
(:   - condition: llamas.heightCm > 155                  (inequality filter) :)
(: Only tall llamas with a matching power row appear in the result.          :)

let $llamas := op:from-view("llamaverse", "llamas")
let $powers := op:from-view("llamaverse", "secretPowers")

return
$llamas
  => op:join-inner(
       $powers,
       op:on(
         op:view-col("llamas", "secretPowerId"),
         op:view-col("secretPowers", "id")
       ),
       op:gt(
         op:view-col("llamas", "heightCm"),
         155
       )
     )
  => op:select((
       op:view-col("llamas",        "name"),
       op:view-col("llamas",        "heightCm"),
       op:view-col("secretPowers",  "name")
     ))
  => op:order-by(op:desc(op:view-col("llamas", "heightCm")))
  => op:limit(5)
  => op:result()
'use strict';
const op = require('/MarkLogic/optic');

// Equality key (op.on) combined with an additional boolean condition.
// The two parameters are ANDed at plan evaluation time:
//   - keys: wildLlamas.secretPowerId = secretPowers.id  (equality)
//   - condition: llamas.heightCm > 155                  (inequality filter)
// Only tall llamas with a matching power row appear in the result.

const llamas = op.fromView('llamaverse', 'llamas');
const powers = op.fromView('llamaverse', 'secretPowers');

llamas
  .joinInner(
    powers,
    op.on(
      op.viewCol('llamas', 'secretPowerId'),
      op.viewCol('secretPowers', 'id')
    ),
    op.gt(
      op.viewCol('llamas', 'heightCm'),
      155
    )
  )
  .select([
    op.viewCol('llamas',       'name'),
    op.viewCol('llamas',       'heightCm'),
    op.viewCol('secretPowers', 'name')
  ])
  .orderBy(op.desc(op.viewCol('llamas', 'heightCm')))
  .limit(5)
  .result();
{"llamaverse.llamas.name": "Meredith", "llamaverse.llamas.heightCm": 163, "llamaverse.secretPowers.name": "Storm Caller"}
{"llamaverse.llamas.name": "Glen",     "llamaverse.llamas.heightCm": 161, "llamaverse.secretPowers.name": "Thunder Stomper"}
{"llamaverse.llamas.name": "Patrick",  "llamaverse.llamas.heightCm": 158, "llamaverse.secretPowers.name": "Fog Weaver"}
{"llamaverse.llamas.name": "Tina",     "llamaverse.llamas.heightCm": 157, "llamaverse.secretPowers.name": "Cactus Hugger"}
{"llamaverse.llamas.name": "Jaime",    "llamaverse.llamas.heightCm": 156, "llamaverse.secretPowers.name": "Featherlight Step"}

(Results need validation against live MarkLogic. Names, heights, and power names
are representative of the llamaverse dataset. The key behaviour to verify:
every returned row has heightCm > 155 AND a non-null secretPowers.name.)
llamaverse.llamas.namellamaverse.llamas.heightCmllamaverse.secretPowers.name
Meredith163Storm Caller
Glen161Thunder Stomper
Patrick158Fog Weaver
Tina157Cactus Hugger
Jaime156Featherlight Step

What to notice: only rows satisfying both the key equality AND the condition appear. The condition is not a post-filter — it is part of the join predicate evaluated by the plan engine.

Condition-only join

Pass an empty sequence (() in XQuery, null in JavaScript) for $keys and supply only $condition. Without equality keys, the join engine evaluates the condition against every pair from the cross-product of the two row sets — so the condition is the sole criterion for inclusion.

This is the right shape when no natural equality key exists between the two sources and you need a range-based, inequality, or expression-based pairing. The example below uses a literal right-side bucket table to assign each llama a height category based on a [lo, hi) range condition:

xquery version "1.0-ml";
import module namespace op = "http://marklogic.com/optic" at "/MarkLogic/optic.xqy";

(: Condition-only join — no equality keys.                                   :)
(: This is semantically equivalent to a cross-product filtered by condition. :)
(: Each llama row is tested against every bucket row; a llama is kept where  :)
(: its heightCm falls within the bucket's [lo, hi) range.                    :)
(:                                                                            :)
(: The right-side literal rows have non-overlapping ranges, so each llama    :)
(: lands in exactly one bucket.                                               :)

let $llamas := op:from-view("llamaverse", "llamas")
let $buckets := op:from-literals((
  map:entry("bucket", xs:string("compact"))  => map:with("lo", xs:integer(120))
                                             => map:with("hi", xs:integer(140)),
  map:entry("bucket", xs:string("standard")) => map:with("lo", xs:integer(140))
                                             => map:with("hi", xs:integer(155)),
  map:entry("bucket", xs:string("tall"))     => map:with("lo", xs:integer(155))
                                             => map:with("hi", xs:integer(200))
), "sizes")

return
$llamas
  => op:join-inner(
       $buckets,
       (),                                   (: no equality keys :)
       op:and(
         op:ge(op:view-col("llamas", "heightCm"), op:view-col("sizes", "lo")),
         op:lt(op:view-col("llamas", "heightCm"), op:view-col("sizes", "hi"))
       )
     )
  => op:select((
       op:view-col("llamas",  "name"),
       op:view-col("llamas",  "heightCm"),
       op:view-col("sizes",   "bucket")
     ))
  => op:order-by((
       op:view-col("sizes",   "bucket"),
       op:view-col("llamas",  "heightCm")
     ))
  => op:limit(6)
  => op:result()
'use strict';
const op = require('/MarkLogic/optic');

// Condition-only join — no equality keys.
// Semantically equivalent to a cross-product filtered by condition.
// Each llama row is tested against every bucket row; a llama is kept where
// its heightCm falls within the bucket's [lo, hi) range.
//
// The right-side literal rows have non-overlapping ranges, so each llama
// lands in exactly one bucket.

const llamas = op.fromView('llamaverse', 'llamas');
const buckets = op.fromLiterals([
  { bucket: 'compact',  lo: 120, hi: 140 },
  { bucket: 'standard', lo: 140, hi: 155 },
  { bucket: 'tall',     lo: 155, hi: 200 }
], 'sizes');

llamas
  .joinInner(
    buckets,
    null,                                    // no equality keys
    op.and(
      op.ge(op.viewCol('llamas', 'heightCm'), op.viewCol('sizes', 'lo')),
      op.lt(op.viewCol('llamas', 'heightCm'), op.viewCol('sizes', 'hi'))
    )
  )
  .select([
    op.viewCol('llamas', 'name'),
    op.viewCol('llamas', 'heightCm'),
    op.viewCol('sizes',  'bucket')
  ])
  .orderBy([
    op.viewCol('sizes',  'bucket'),
    op.viewCol('llamas', 'heightCm')
  ])
  .limit(6)
  .result();
{"llamaverse.llamas.name": "Hannah",  "llamaverse.llamas.heightCm": 136, "sizes.bucket": "compact"}
{"llamaverse.llamas.name": "Angela",  "llamaverse.llamas.heightCm": 138, "sizes.bucket": "compact"}
{"llamaverse.llamas.name": "Bradley", "llamaverse.llamas.heightCm": 143, "sizes.bucket": "standard"}
{"llamaverse.llamas.name": "Aaron",   "llamaverse.llamas.heightCm": 148, "sizes.bucket": "standard"}
{"llamaverse.llamas.name": "Tina",    "llamaverse.llamas.heightCm": 157, "sizes.bucket": "tall"}
{"llamaverse.llamas.name": "Glen",    "llamaverse.llamas.heightCm": 161, "sizes.bucket": "tall"}

(Results need validation against live MarkLogic. Names and heights are
representative of the llamaverse dataset. The key behaviour to verify:
every llama appears exactly once, in the bucket whose [lo, hi) range
contains that llama's heightCm. No llama should appear in multiple buckets.)
llamaverse.llamas.namellamaverse.llamas.heightCmsizes.bucket
Hannah136compact
Angela138compact
Bradley143standard
Aaron148standard
Tina157tall
Glen161tall

What to notice: the right side is a literal row set — no view, no index. Each llama lands in exactly one bucket because the ranges are non-overlapping. Without equality keys, the plan must evaluate the condition against every possible pair, so keep the right-side row count small for condition-only joins.

op:join-left-outer()

op:join-left-outer() is what you want when the left side defines the candidate set and the right side only enriches it when a match exists.

Option / ArgumentWhat it controlsUsed here
rightPlanThe right-side row set used for enrichmentllamaverse.secretPowers view plan
onJoin condition for matching left/right rowswildLlamas.secretPowerId = secretPowers.id
{
  "name": "Aaron",
  "breed": "Huacaya",
  "placeOfBirth": "Cusco, Peru",
  "secretPowerId": "d8839ba6-2b77-4bcc-9927-b86cdfecb9fb"
}
xquery version "1.0-ml";
import module namespace op = "http://marklogic.com/optic" at "/MarkLogic/optic.xqy";

let $llamas := op:from-view("llamaverse", "llamas")
let $powers := op:from-view("llamaverse", "secretPowers")

return
$llamas
  => op:join-left-outer(
       $powers,
       op:on(
         op:view-col("llamas", "secretPowerId"),
         op:view-col("secretPowers", "id")
       )
     )
  => op:select((
       op:view-col("llamas", "name"),
       op:view-col("llamas", "secretPowerId"),
       op:view-col("secretPowers", "name")
     ))
  => op:limit(3)
  => op:result()
'use strict';

const op = require('/MarkLogic/optic');

const llamas = op.fromView('llamaverse', 'llamas');
const powers = op.fromView('llamaverse', 'secretPowers');

const results = llamas
  .joinLeftOuter(
    powers,
    op.on(
      op.viewCol('llamas', 'secretPowerId'),
      op.viewCol('secretPowers', 'id')
    )
  )
  .select([
    op.viewCol('llamas', 'name'),
    op.viewCol('llamas', 'secretPowerId'),
    op.viewCol('secretPowers', 'name')
  ])
  .limit(5)
  .result();

({
  sample: 'optic/joins-starter-pack/assets/op-join-left-outer.sjs',
  kind: Array.isArray(results) ? (results.every((item) => typeof item === 'object' && 'subject' in item && 'predicate' in item && 'object' in item) ? 'triples' : 'rows') : ((results !== null && typeof results === 'object') ? 'object' : 'scalar'),
  count: Array.isArray(results) ? results.length : 0,
  data: results
});
{"llamaverse.llamas.name": "Bradley", "llamaverse.llamas.secretPowerId": "027d5ed8-cd2f-4b72-9d89-3c85c0e4840d", "llamaverse.secretPowers.name": "Rain Rhymester"}
{"llamaverse.llamas.name": "Glen", "llamaverse.llamas.secretPowerId": "1ca5ed0c-069f-4e26-b8ea-59e10ed13710", "llamaverse.secretPowers.name": "Thunder Stomper"}
{"llamaverse.llamas.name": "Jeanette", "llamaverse.llamas.secretPowerId": "01f5441b-5875-49dc-a4c5-4fb324fc2b02", "llamaverse.secretPowers.name": "Wind Whisperer"}
llamaverse.llamas.namellamaverse.llamas.secretPowerIdllamaverse.secretPowers.name
Bradley027d5ed8-cd2f-4b72-9d89-3c85c0e4840dRain Rhymester
Glen1ca5ed0c-069f-4e26-b8ea-59e10ed13710Thunder Stomper
Jeanette01f5441b-5875-49dc-a4c5-4fb324fc2b02Wind Whisperer

What to notice: unmatched rows stay visible because the left side is the story.

op:join-full-outer()

op:join-full-outer() is for reconciliation work. Use it when you need to see both matched pairs and orphaned rows on either side.

Option / ArgumentWhat it controlsUsed here
rightPlanThe right-side row set to reconcile with the left sideSeeded right-side demo rows
onJoin condition used to detect matchespowerId equality across left/right rows
{
  "left": [
    { "powerId": "p1", "llama": "Aaron" },
    { "powerId": "p2", "llama": "Angela" }
  ],
  "right": [
    { "powerId": "p2", "power": "Fog Walker" },
    { "powerId": "p3", "power": "Potion Sniffer" }
  ]
}
xquery version "1.0-ml";
import module namespace op = "http://marklogic.com/optic" at "/MarkLogic/optic.xqy";

let $left := op:from-literals((
  map:entry("powerId", "p1") => map:with("llama", "Aaron"),
  map:entry("powerId", "p2") => map:with("llama", "Angela")
))
let $right := op:from-literals((
  map:entry("powerId", "p2") => map:with("power", "Fog Walker"),
  map:entry("powerId", "p3") => map:with("power", "Potion Sniffer")
))

return
$left
  => op:join-full-outer($right, op:on("powerId", "powerId"))
  => op:result()
'use strict';

const op = require('/MarkLogic/optic');

const leftRows = [
  { id: 1, leftName: 'Aaron' },
  { id: 2, leftName: 'Angela' }
];
const rightRows = [
  { id: 2, rightName: 'X-Ray Vision' },
  { id: 3, rightName: 'Invisibility' }
];

const leftPlan = op.fromLiterals(leftRows);
const rightPlan = op.fromLiterals(rightRows);

const results = leftPlan
  .joinFullOuter(rightPlan, op.on(op.col('id'), op.col('id')))
  .result();

({
  sample: 'optic/joins-starter-pack/assets/op-join-full-outer.sjs',
  kind: Array.isArray(results) ? (results.every((item) => typeof item === 'object' && 'subject' in item && 'predicate' in item && 'object' in item) ? 'triples' : 'rows') : ((results !== null && typeof results === 'object') ? 'object' : 'scalar'),
  count: Array.isArray(results) ? results.length : 0,
  data: results
});
{"powerId": "p1", "power": null, "llama": "Aaron"}
{"powerId": "p2", "power": "Fog Walker", "llama": "Angela"}
{"powerId": "p3", "power": "Potion Sniffer", "llama": null}
powerIdpowerllama
p1Aaron
p2Fog WalkerAngela
p3Potion Sniffer

What to notice: full outer joins are less about enrichment and more about reconciliation.

op:exists-join() and op:not-exists-join()

These are filtering joins. They do not add right-side columns. They answer the narrower question: does a match exist?

Option / ArgumentWhat it controlsUsed here
rightPlanThe right-side row set used only for existence checksllamaverse.secretPowers view plan
onCondition that determines whether a match existswildLlamas.secretPowerId = secretPowers.id
{
  "name": "Aaron",
  "breed": "Huacaya",
  "placeOfBirth": "Cusco, Peru",
  "secretPowerId": "d8839ba6-2b77-4bcc-9927-b86cdfecb9fb"
}
xquery version "1.0-ml";
import module namespace op = "http://marklogic.com/optic" at "/MarkLogic/optic.xqy";

let $llamas := op:from-view("llamaverse", "llamas")
let $powers := op:from-view("llamaverse", "secretPowers")

return
$llamas
  => op:exists-join(
       $powers,
       op:on(
         op:view-col("llamas", "secretPowerId"),
         op:view-col("secretPowers", "id")
       )
     )
  => op:select((op:view-col("llamas", "name"), op:view-col("llamas", "secretPowerId")))
  => op:limit(3)
  => op:result()
'use strict';

const op = require('/MarkLogic/optic');

const llamas = op.fromView('llamaverse', 'llamas');
const powers = op.fromView('llamaverse', 'secretPowers');

const results = llamas
  .existsJoin(
    powers,
    op.on(
      op.viewCol('llamas', 'secretPowerId'),
      op.viewCol('secretPowers', 'id')
    )
  )
  .select([
    op.viewCol('llamas', 'name'),
    op.viewCol('llamas', 'secretPowerId')
  ])
  .limit(5)
  .result();

({
  sample: 'optic/joins-starter-pack/assets/op-exists-join.sjs',
  kind: Array.isArray(results) ? (results.every((item) => typeof item === 'object' && 'subject' in item && 'predicate' in item && 'object' in item) ? 'triples' : 'rows') : ((results !== null && typeof results === 'object') ? 'object' : 'scalar'),
  count: Array.isArray(results) ? results.length : 0,
  data: results
});
{"llamaverse.llamas.name": "Bradley", "llamaverse.llamas.secretPowerId": "027d5ed8-cd2f-4b72-9d89-3c85c0e4840d"}
{"llamaverse.llamas.name": "Glen", "llamaverse.llamas.secretPowerId": "1ca5ed0c-069f-4e26-b8ea-59e10ed13710"}
{"llamaverse.llamas.name": "Jeanette", "llamaverse.llamas.secretPowerId": "01f5441b-5875-49dc-a4c5-4fb324fc2b02"}
llamaverse.llamas.namellamaverse.llamas.secretPowerId
Bradley027d5ed8-cd2f-4b72-9d89-3c85c0e4840d
Glen1ca5ed0c-069f-4e26-b8ea-59e10ed13710
Jeanette01f5441b-5875-49dc-a4c5-4fb324fc2b02

The inverse shape is op:not-exists-join(): use it to find llamas without a matching power row, documents without companion data, or any other gap report.

op:join-cross-product()

op:join-cross-product() is the right shape when equality is not the join rule. It pairs every left row with every right row, then applies a condition to keep only the meaningful combinations. The right side is typically a small literal table of categories, thresholds, or buckets.

Option / ArgumentWhat it controlsUsed here
rightPlanThe row set paired with every row from the current left planLiteral care-schedule tier table
condition (optional)Predicate applied across every left/right pairRange condition assigning each llama to exactly one tier

Here, each llamaverse llama is paired against a three-tier care schedule table. No equality key exists between the two sources — a llama lands in a tier when its height falls within the tier's [minCm, maxCm) range:

xquery version "1.0-ml";
import module namespace op = "http://marklogic.com/optic"
    at "/MarkLogic/optic.xqy";

(: Left: real llamas with their heights from the llamaverse view :)
let $llamas :=
  op:from-view("llamaverse", "llamas")
  => op:select((
       op:view-col("llamas", "name"),
       op:view-col("llamas", "heightCm")
     ))

(: Right: literal care-schedule tier table — height ranges, no equality key :)
let $schedule := op:from-literals((
  map:entry("careTask", "routine-check")     => map:with("minCm", 100) => map:with("maxCm", 165),
  map:entry("careTask", "growth-watch")      => map:with("minCm", 165) => map:with("maxCm", 180),
  map:entry("careTask", "heavyweight-watch") => map:with("minCm", 180) => map:with("maxCm", 300)
))

return
$llamas
  => op:join-cross-product(
       $schedule,
       op:and(
         op:ge(op:view-col("llamas", "heightCm"), op:col("minCm")),
         op:lt(op:view-col("llamas", "heightCm"), op:col("maxCm"))
       )
     )
  => op:select((
       op:view-col("llamas", "name"),
       op:view-col("llamas", "heightCm"),
       op:col("careTask")
     ))
  => op:order-by(op:view-col("llamas", "heightCm"))
  => op:result()
'use strict';
const op = require('/MarkLogic/optic');

// Left: real llamas with their heights from the llamaverse view
const llamas =
  op.fromView('llamaverse', 'llamas')
    .select([
      op.viewCol('llamas', 'name'),
      op.viewCol('llamas', 'heightCm')
    ]);

// Right: literal care-schedule tier table — height ranges, no equality key
const schedule = op.fromLiterals([
  { careTask: 'routine-check',     minCm: 100, maxCm: 165 },
  { careTask: 'growth-watch',      minCm: 165, maxCm: 180 },
  { careTask: 'heavyweight-watch', minCm: 180, maxCm: 300 }
]);

llamas
  .joinCrossProduct(
    schedule,
    op.and(
      op.ge(op.viewCol('llamas', 'heightCm'), op.col('minCm')),
      op.lt(op.viewCol('llamas', 'heightCm'), op.col('maxCm'))
    )
  )
  .select([
    op.viewCol('llamas', 'name'),
    op.viewCol('llamas', 'heightCm'),
    op.col('careTask')
  ])
  .orderBy(op.viewCol('llamas', 'heightCm'))
  .result();
{"llamaverse.llamas.name":"Debbie", "llamaverse.llamas.heightCm":156, "careTask":"routine-check"}
{"llamaverse.llamas.name":"Glen", "llamaverse.llamas.heightCm":158, "careTask":"routine-check"}
{"llamaverse.llamas.name":"Brandon", "llamaverse.llamas.heightCm":161, "careTask":"routine-check"}
{"llamaverse.llamas.name":"Jaime", "llamaverse.llamas.heightCm":162, "careTask":"routine-check"}
{"llamaverse.llamas.name":"Katrina", "llamaverse.llamas.heightCm":166, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Ashley", "llamaverse.llamas.heightCm":167, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Angela", "llamaverse.llamas.heightCm":167, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Teresa", "llamaverse.llamas.heightCm":169, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Jeanette", "llamaverse.llamas.heightCm":170, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Anthony", "llamaverse.llamas.heightCm":171, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Bradley", "llamaverse.llamas.heightCm":172, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Cory", "llamaverse.llamas.heightCm":173, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Tina", "llamaverse.llamas.heightCm":173, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Richard", "llamaverse.llamas.heightCm":174, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Tim", "llamaverse.llamas.heightCm":177, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Kimberly", "llamaverse.llamas.heightCm":178, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Cameron", "llamaverse.llamas.heightCm":179, "careTask":"growth-watch"}
{"llamaverse.llamas.name":"Aaron", "llamaverse.llamas.heightCm":180, "careTask":"heavyweight-watch"}
{"llamaverse.llamas.name":"William", "llamaverse.llamas.heightCm":182, "careTask":"heavyweight-watch"}
{"llamaverse.llamas.name":"James", "llamaverse.llamas.heightCm":184, "careTask":"heavyweight-watch"}
llamaverse.llamas.namellamaverse.llamas.heightCmcareTask
Debbie156routine-check
Glen158routine-check
Brandon161routine-check
Jaime162routine-check
Katrina166growth-watch
Ashley167growth-watch
Angela167growth-watch
Teresa169growth-watch
Jeanette170growth-watch
Anthony171growth-watch
Bradley172growth-watch
Cory173growth-watch
Tina173growth-watch
Richard174growth-watch
Tim177growth-watch
Kimberly178growth-watch
Cameron179growth-watch
Aaron180heavyweight-watch
William182heavyweight-watch
James184heavyweight-watch

What to notice: every llama is evaluated against every tier (3 tiers × 20 llamas = 60 pairs considered), but each llama appears exactly once in the output because the ranges are non-overlapping. Keep the right-side row count small — cross-product scales as N×M before the condition filters it down.

Join Hygiene

Two join mistakes cause most confusion:

  1. Forgetting that matching column identifiers can trigger natural join behaviour you did not mean.
  2. Choosing a row-merging join when the real requirement was only existence filtering.

If the right side adds no columns to your answer, check whether exists-join or not-exists-join is the clearer expression.

Multi-Source Mastery Unlocked

Decision rule: if the right side adds no answer columns, use an existence join. Save row-merging joins for true enrichment work.

Joins sit in the middle of the Optic pipeline — sources feed into them and analysis or manipulation follows:

Need Some Help?


Looking for more information on this subject or any other topic related to MarkLogic? Contact Us (info@cleverllamas.com) to find out how we can assist you with consulting or training!