Writing a filter¶
Filter queries are used to filter the results of need collection, for example in the Needs Index tree view.
They can take one of two forms:
A Cypher graph query — selects needs by type, fields, and how they link to one another.
A Python expression — a need-local boolean filter over a single need’s attributes.
Cypher syntax¶
This syntax is a read-only subset of the openCypher graph query language (the language popularised by Neo4j). Unlike a flat field filter, it treats your needs as a graph, so you can select needs not only by their own fields but by how they link to one another.
Inside a document the query is written as the ubCode-only :cypher: option
of a view directive — needlist, needtable and needflow each offer one.
Both charts, needpie and needbar, offer one too, and read it as a
scope rather than a selection:
the needs the query returns are the universe each of the chart’s content cells is
counted in.
A chart reads that same scope in a portable python spelling as well
(:filter:, :status:, :tags:, :types:), with :cypher: taking
precedence when both are written
(see A chart’s filter options SCOPE its slices).
On a chart, that pairing is portable from Sphinx-Needs 8.5.0 onwards;
all five of those options are unknown to 8.4.0 and earlier, where the build
reports unknown option and drops the directive entirely.
Selecting with Cypher walks through writing one,
and through pairing it with the python filter surfaces
so that a Sphinx build renders the same view —
including a chart whose scope is written in both spellings
and drawn live by both engines.
When used as a filter (for example in the Needs Index tree view),
the query selects a set of needs.
A filter surface accepts either of two forms:
A bare
WHERE-expression over the implicit variablen, evaluated asMATCH (n) WHERE <expr> RETURN n. For a filter box this is usually all you need — for examplen.status = 'open'.A full query that returns a single column of nodes; the needs backing those nodes are the filtered result.
Note
A needtable reads its :cypher: more widely than a filter surface does.
A query returning more than the bare node defines the table’s
columns and headers —
see Projections in a needtable.
Important
A needtable with a :cypher: renders in the query’s returned
order. This governs every :cypher: table — a bare WHERE
expression and a full query alike, whether or not it carries an ORDER BY
— not only the projecting form below.
So a :cypher: with no ORDER BY now renders in the query’s returned
order rather than in id_complete order.
Those two are not the same order:
the returned order is case-sensitive id order
(uppercase ids sort before lowercase ones),
where the needtable’s default sort was case-insensitive.
For a project whose need ids differ only in case, the rows move —
and a traversing or UNWIND-ing query returns in match order,
which is not id order at all,
so its rows can move whatever the ids look like.
Add :sort: id_complete to keep the previous order.
An explicit :sort: always re-sorts, in either mode.
The two are distinguished by the first word:
an input that begins (case-insensitively) with a clause keyword
— MATCH, OPTIONAL, UNWIND, WITH, or RETURN —
is treated as a full query;
anything else is parsed as a bare expression.
So both of these select every open need:
n.status = 'open'
MATCH (n) WHERE n.status = 'open' RETURN n
The simplest filter, matching every need, is:
MATCH (n) RETURN n
The bare-expression form is filter-only:
the ubc query cypher command-line tool and the query_cypher MCP tool
always expect a full query.
Note
The bare-expression shorthand replaces the earlier pseudo-Cypher filter dialect,
so a legacy field filter such as n.status = 'open' or n.id STARTS WITH 'A'
is valid again verbatim.
Differences from the old dialect:
Regular expressions (
=~) are now an anchored full match (the legacy=~was a substring search, like Pythonre.search):n.id =~ 'REQ'no longer matchesREQ-1— usen.id =~ 'REQ.*'orSTARTS WITH.upper()/lower()are now the openCypher functionstoUpper()/toLower().Link-type filtering can be written as a typed pattern
-[:linktype]->, thetype(l)function, or the built-inl.typeproperty — all three are equivalent. A relationship carries no other properties.Negating a property inside
MATCH (n)-[l]->(o) WHERE NOT …keeps the existential reading (some link whose target fails the test); for “needs with no such link” useWHERE NOT (n)-[:t]->(…)orNOT EXISTS { … }.The implicit one-hop namespaces
l(link) ando(linked need) are gone: write the pattern explicitly, for exampleMATCH (n)-[l:blocks]->(o) RETURN n(or, equivalently,MATCH (n)-[l]->(o) WHERE l.type = 'blocks' RETURN n).A link whose target need does not exist in the project is not part of the graph, so it can never match a pattern.
See full syntax grammar
Written in pest, the grammar for the query language is as follows:
// Grammar for the read-only subset of openCypher.
//
// Ported (by hand) from the canonical openCypher grammar; see
// `design/openCypher.bnf` for the reference and `design/grammar.md` for scope notes.
// This covers reading queries only: MATCH / OPTIONAL MATCH, WHERE, WITH, UNWIND,
// RETURN, UNION, ORDER BY / SKIP / LIMIT, patterns (incl. variable-length), and the
// expression language. Write clauses are intentionally absent.
WHITESPACE = _{ " " | "\t" | "\r" | "\n" }
COMMENT = _{ ("//" ~ (!NEWLINE ~ ANY)*) | ("/*" ~ (!"*/" ~ ANY)* ~ "*/") }
// ---------------------------------------------------------------------------
// Top level
// ---------------------------------------------------------------------------
cypher = { SOI ~ regular_query ~ ";"? ~ EOI }
regular_query = { single_query ~ union_clause* }
// A standalone WHERE-expression over an implicit variable, the filter-box shorthand. Anchored
// (`SOI`/`EOI`) so trailing junk is a syntax error rather than a silent partial parse; the
// implicit `WHITESPACE` rule already skips surrounding whitespace, so no explicit `WS*` is needed.
expression_only = { SOI ~ expression ~ EOI }
union_clause = { KW_UNION ~ KW_ALL? ~ single_query }
single_query = { clause+ }
clause = {
match_clause
| unwind_clause
| create_clause
| with_clause
| return_clause
}
match_clause = { KW_OPTIONAL? ~ KW_MATCH ~ pattern ~ where_clause? }
// CREATE is parsed (it reuses the read pattern grammar) but rejected by the read-only
// executor; it exists so the TCK harness can build fixture graphs from setup queries.
create_clause = { KW_CREATE ~ pattern }
unwind_clause = { KW_UNWIND ~ expression ~ KW_AS ~ variable }
with_clause = { KW_WITH ~ projection_body ~ where_clause? }
return_clause = { KW_RETURN ~ projection_body }
where_clause = { KW_WHERE ~ expression }
projection_body = { KW_DISTINCT? ~ projection_items ~ order_clause? ~ skip_clause? ~ limit_clause? }
projection_items = {
(star ~ ("," ~ projection_item)*)
| (projection_item ~ ("," ~ projection_item)*)
}
star = { "*" }
projection_item = { expression ~ (KW_AS ~ variable)? }
order_clause = { KW_ORDER ~ KW_BY ~ sort_item ~ ("," ~ sort_item)* }
sort_item = { expression ~ (KW_ASCENDING | KW_ASC | KW_DESCENDING | KW_DESC)? }
skip_clause = { KW_SKIP ~ expression }
limit_clause = { KW_LIMIT ~ expression }
// ---------------------------------------------------------------------------
// Patterns
// ---------------------------------------------------------------------------
pattern = { pattern_part ~ ("," ~ pattern_part)* }
pattern_part = { (variable ~ "=" ~ pattern_element) | pattern_element }
pattern_element = { node_pattern ~ (relationship_pattern ~ node_pattern)* }
node_pattern = { "(" ~ variable? ~ node_labels? ~ properties? ~ ")" }
node_labels = { node_label+ }
node_label = { ":" ~ label_name }
relationship_pattern = {
(left_arrow ~ "-" ~ rel_detail? ~ "-" ~ right_arrow)
| (left_arrow ~ "-" ~ rel_detail? ~ "-")
| ("-" ~ rel_detail? ~ "-" ~ right_arrow)
| ("-" ~ rel_detail? ~ "-")
}
left_arrow = { "<" }
right_arrow = { ">" }
rel_detail = { "[" ~ variable? ~ rel_types? ~ range_literal? ~ properties? ~ "]" }
rel_types = { ":" ~ label_name ~ ("|" ~ ":"? ~ label_name)* }
range_literal = { "*" ~ (range_bound ~ (".." ~ range_bound?)? | ".." ~ range_bound?)? }
range_bound = { integer_literal }
properties = { map_literal }
// ---------------------------------------------------------------------------
// Expressions (operator precedence resolved by a Pratt parser in Rust)
// ---------------------------------------------------------------------------
expression = { or_expr }
or_expr = { xor_expr ~ (KW_OR ~ xor_expr)* }
xor_expr = { and_expr ~ (KW_XOR ~ and_expr)* }
and_expr = { not_expr ~ (KW_AND ~ not_expr)* }
not_expr = { KW_NOT* ~ comparison_expr }
comparison_expr = { string_op_expr ~ (comparison_op ~ string_op_expr)* }
comparison_op = { "<>" | "<=" | ">=" | "=" | "<" | ">" }
// String / list / null predicates bind looser than arithmetic but tighter than the
// comparison operators, so `a + b IN list` parses as `(a + b) IN list` (per openCypher,
// whose StringListNullPredicateExpression is built from AddOrSubtractExpression).
string_op_expr = { add_sub_expr ~ string_predicate* }
string_predicate = {
(KW_STARTS ~ KW_WITH ~ add_sub_expr)
| (KW_ENDS ~ KW_WITH ~ add_sub_expr)
| (KW_CONTAINS ~ add_sub_expr)
| (regex_match ~ add_sub_expr)
| (KW_IN ~ add_sub_expr)
| (KW_IS ~ KW_NOT ~ KW_NULL)
| (KW_IS ~ KW_NULL)
}
// The regex-match operator `=~` (openCypher). Modelled as a string predicate so it binds
// tighter than the comparison operators (like `STARTS WITH` / `CONTAINS`): `a =~ b` attaches
// as a predicate on `a`. It is a distinct token from the `=` comparison operator (which lives
// one layer up, in `comparison_op`) — `=~` requires the trailing `~`, so `a = b` never matches
// it. Full-string (anchored) matching is applied at evaluation time.
regex_match = { "=~" }
add_sub_expr = { mul_div_expr ~ (add_sub_op ~ mul_div_expr)* }
add_sub_op = { "+" | "-" }
mul_div_expr = { power_expr ~ (mul_div_op ~ power_expr)* }
mul_div_op = { "*" | "/" | "%" }
power_expr = { unary_expr ~ ("^" ~ unary_expr)* }
unary_expr = { unary_op* ~ postfix_expr }
unary_op = { "+" | "-" }
postfix_expr = { atom ~ postfix* }
postfix = {
property_lookup
| label_check
| index_or_slice
}
property_lookup = { "." ~ property_key }
label_check = { node_label+ }
index_or_slice = { "[" ~ (slice | expression) ~ "]" }
slice = { expression? ~ ".." ~ expression? }
atom = {
literal
| parameter
| case_expression
| count_star
| count_subquery
| quantifier_expr
| reduce_expr
| exists_subquery
| function_invocation
| pattern_comprehension
| list_comprehension
| pattern_predicate
| parenthesized_expr
| variable
}
parenthesized_expr = { "(" ~ expression ~ ")" }
count_star = { KW_COUNT ~ "(" ~ "*" ~ ")" }
// A path pattern used as a boolean expression (e.g. `WHERE (a)-[:R]->(b)`). Requires at
// least one relationship chain so that a bare `(expr)` stays a parenthesized expression.
pattern_predicate = { node_pattern ~ (relationship_pattern ~ node_pattern)+ }
// Existential subquery: `exists { <pattern> [WHERE ...] }` or `exists { <full query> }`.
exists_subquery = { KW_EXISTS ~ "{" ~ (regular_query | (pattern ~ where_clause?)) ~ "}" }
// Counting subquery: `count { <pattern> [WHERE ...] }` or `count { <full query> }`. Same
// body grammar as `exists`, but evaluates to the number of matching rows.
count_subquery = { KW_COUNT ~ "{" ~ (regular_query | (pattern ~ where_clause?)) ~ "}" }
// Pattern comprehension: `[ (path) [WHERE ...] | projection ]`.
pattern_comprehension = {
"[" ~ (variable ~ "=")? ~ pattern_element ~ where_clause? ~ "|" ~ expression ~ "]"
}
// Filtering predicate functions: all/any/none/single(x IN coll WHERE pred).
quantifier_expr = { quantifier_kw ~ "(" ~ filter_predicate ~ ")" }
quantifier_kw = { KW_ALL | KW_ANY | KW_NONE | KW_SINGLE }
filter_predicate = { variable ~ KW_IN ~ expression ~ where_clause? }
// reduce(acc = init, x IN coll | expr)
reduce_expr = {
KW_REDUCE ~ "(" ~ variable ~ "=" ~ expression ~ "," ~ variable ~ KW_IN ~ expression ~ "|" ~ expression ~ ")"
}
function_invocation = { function_name ~ "(" ~ KW_DISTINCT? ~ (expression ~ ("," ~ expression)*)? ~ ")" }
function_name = { symbolic_name ~ ("." ~ symbolic_name)* }
list_comprehension = {
"[" ~ variable ~ KW_IN ~ expression ~ where_clause? ~ ("|" ~ expression)? ~ "]"
}
// The optional case operand must not start with a structural keyword, otherwise the
// non-reserving identifier rule would swallow `WHEN` as a variable in a searched `CASE`.
case_expression = {
KW_CASE
~ (!(KW_WHEN | KW_THEN | KW_ELSE | KW_END) ~ expression)?
~ case_alternative+
~ (KW_ELSE ~ expression)?
~ KW_END
}
case_alternative = { KW_WHEN ~ expression ~ KW_THEN ~ expression }
// ---------------------------------------------------------------------------
// Literals
// ---------------------------------------------------------------------------
literal = {
number_literal
| string_literal
| boolean_literal
| null_literal
| list_literal
| map_literal
}
number_literal = { float_literal | integer_literal }
integer_literal = @{
(^"0x" ~ ASCII_HEX_DIGIT+)
| (^"0o" ~ ASCII_OCT_DIGIT+)
| (ASCII_DIGIT+)
}
// A digit is required after the decimal point so that `1..3` lexes as
// `1` `..` `3` (a list range) rather than the float `1.` followed by `.3`.
float_literal = @{
(ASCII_DIGIT+ ~ "." ~ ASCII_DIGIT+ ~ exponent?)
| ("." ~ ASCII_DIGIT+ ~ exponent?)
| (ASCII_DIGIT+ ~ exponent)
}
exponent = @{ ^"e" ~ ("+" | "-")? ~ ASCII_DIGIT+ }
// A quote inside a string is written either backslash-escaped (`\'`, `\"`) or doubled
// (`''`, `""`) — the SQL-style escape the openCypher grammar lists as `<double single quote>` /
// `<double double quote>`. The doubled form must precede the catch-all so the pair is taken as
// one escape rather than ending the literal; `build_string_value` collapses it back to one quote.
string_literal = ${ ("'" ~ single_inner ~ "'") | ("\"" ~ double_inner ~ "\"") }
single_inner = @{ (escape_seq | "''" | (!("'" | "\\") ~ ANY))* }
double_inner = @{ (escape_seq | "\"\"" | (!("\"" | "\\") ~ ANY))* }
escape_seq = @{ "\\" ~ ANY }
boolean_literal = { KW_TRUE | KW_FALSE }
null_literal = { KW_NULL }
list_literal = { "[" ~ (expression ~ ("," ~ expression)*)? ~ "]" }
map_literal = { "{" ~ (map_entry ~ ("," ~ map_entry)*)? ~ "}" }
map_entry = { property_key ~ ":" ~ expression }
parameter = { "$" ~ (symbolic_name | integer_literal) }
// ---------------------------------------------------------------------------
// Names
// ---------------------------------------------------------------------------
variable = { symbolic_name }
label_name = { symbolic_name }
property_key = { symbolic_name }
// openCypher is largely non-reserving: keywords such as `End`, `Contains`, or `null`
// are valid label / property / variable names. Keyword *boundary* guards
// (`!ident_continue` on each `KW_*`) already prevent matching an identifier prefix, so
// `symbolic_name` does not exclude keywords. Disambiguation between a keyword and a name
// is by grammar position (ordered choice), not by a reserved-word set.
symbolic_name = @{ escaped_name | (ident_start ~ ident_continue*) }
escaped_name = @{ "`" ~ (!"`" ~ ANY)* ~ "`" }
ident_start = _{ ASCII_ALPHA | "_" }
ident_continue = _{ ASCII_ALPHANUMERIC | "_" }
// ---------------------------------------------------------------------------
// Keywords (case-insensitive, bounded so they do not match identifier prefixes)
// ---------------------------------------------------------------------------
KW_MATCH = @{ ^"match" ~ !ident_continue }
KW_CREATE = @{ ^"create" ~ !ident_continue }
KW_OPTIONAL = @{ ^"optional" ~ !ident_continue }
KW_WHERE = @{ ^"where" ~ !ident_continue }
KW_RETURN = @{ ^"return" ~ !ident_continue }
KW_WITH = @{ ^"with" ~ !ident_continue }
KW_UNWIND = @{ ^"unwind" ~ !ident_continue }
KW_AS = @{ ^"as" ~ !ident_continue }
KW_ORDER = @{ ^"order" ~ !ident_continue }
KW_BY = @{ ^"by" ~ !ident_continue }
KW_SKIP = @{ ^"skip" ~ !ident_continue }
KW_LIMIT = @{ ^"limit" ~ !ident_continue }
KW_UNION = @{ ^"union" ~ !ident_continue }
KW_ALL = @{ ^"all" ~ !ident_continue }
KW_DISTINCT = @{ ^"distinct" ~ !ident_continue }
KW_AND = @{ ^"and" ~ !ident_continue }
KW_OR = @{ ^"or" ~ !ident_continue }
KW_XOR = @{ ^"xor" ~ !ident_continue }
KW_NOT = @{ ^"not" ~ !ident_continue }
KW_IN = @{ ^"in" ~ !ident_continue }
KW_STARTS = @{ ^"starts" ~ !ident_continue }
KW_ENDS = @{ ^"ends" ~ !ident_continue }
KW_CONTAINS = @{ ^"contains" ~ !ident_continue }
KW_IS = @{ ^"is" ~ !ident_continue }
KW_NULL = @{ ^"null" ~ !ident_continue }
KW_TRUE = @{ ^"true" ~ !ident_continue }
KW_FALSE = @{ ^"false" ~ !ident_continue }
KW_ASC = @{ ^"asc" ~ !ident_continue }
KW_ASCENDING = @{ ^"ascending" ~ !ident_continue }
KW_DESC = @{ ^"desc" ~ !ident_continue }
KW_DESCENDING = @{ ^"descending" ~ !ident_continue }
KW_CASE = @{ ^"case" ~ !ident_continue }
KW_WHEN = @{ ^"when" ~ !ident_continue }
KW_THEN = @{ ^"then" ~ !ident_continue }
KW_ELSE = @{ ^"else" ~ !ident_continue }
KW_END = @{ ^"end" ~ !ident_continue }
KW_COUNT = @{ ^"count" ~ !ident_continue }
KW_ANY = @{ ^"any" ~ !ident_continue }
KW_NONE = @{ ^"none" ~ !ident_continue }
KW_SINGLE = @{ ^"single" ~ !ident_continue }
KW_REDUCE = @{ ^"reduce" ~ !ident_continue }
KW_EXISTS = @{ ^"exists" ~ !ident_continue }
The needs graph¶
The engine presents your project as a property graph:
Each need is a node whose single label is its type. A need of type
requirementis therefore a(:requirement)node.A need’s fields are the node’s properties, along with the built-ins
id,type, andcontent, the computeddocname(see The docname node property) and the section-location propertiessections/section_name(see The section-location node properties).Each typed link is a directed relationship whose type is the link option name. If need
Alinks to needBthrough thelinksoption, the graph contains(A)-[:links]->(B).A relationship’s only property is the built-in
type(the link option name), sol.type, thetype(l)function, and matching-[l:option]->are equivalent; relationships carry no other properties.Links whose target need does not exist in the project are skipped.
Note
The is_directive / is_external / is_import / is_modified source-metadata
predicates are not available in the Cypher syntax —
they are need-local features of the Python syntax.
The section-location fields sections and section_name are available here,
as node properties — see The section-location node properties below.
The project’s variant data — the var.* namespace of the
Python syntax — is available here, through the reserved $var parameter;
see Parameters below.
Matching needs¶
Restrict to a need type with a label, and filter on fields with WHERE:
MATCH (n:requirement) WHERE n.status = 'open' RETURN n
Field predicates can also be written inline in the pattern:
MATCH (n:requirement {status: 'open'}) RETURN n
Access a property with the dot operator, for example n.title.
If the property name contains spaces or other non-identifier characters,
wrap it in backticks, for example n.`my attribute`.
Filtering by links¶
The reason to use the graph syntax is to select needs by their relationships. Where the earlier filter dialects stopped at a single hop, a graph filter can follow whole chains of links. Match an outgoing or incoming link, optionally constrained by relationship type and by the type of the need on the other end:
// needs that link to any requirement through a `links` relationship
MATCH (n)-[:links]->(:requirement) RETURN n
// requirements that are satisfied by at least one other need
MATCH (n:requirement)<-[:satisfies]-() RETURN n
Use an EXISTS { ... } subquery to filter by the presence
(or, when negated, the absence) of a pattern,
and a variable-length relationship (*min..max) to follow a chain of links:
// requirements with no incoming `satisfies` link
MATCH (n:requirement) WHERE NOT EXISTS { (n)<-[:satisfies]-() } RETURN n
// needs reachable from a spec through 1 to 3 `links` hops
MATCH (n)-[:links*1..3]->(:spec) RETURN n
Chain several link steps to express a multi-hop traceability walk:
// requirements whose satisfying spec is itself implemented
// (a trace across two different link types)
MATCH (n:requirement)<-[:satisfies]-(:spec)<-[:links]-() RETURN n
Each hop can name a different relationship type —
a heterogeneous chain that a variable-length *min..max segment,
which repeats a single type, cannot express.
Values¶
Values can be:
Strings (in single or double quotes), e.g.
"my value"or'my value'.Numbers, e.g.
42,3.14, or0.1e-3.Booleans,
trueorfalse(case-insensitive).Null,
null(case-insensitive).Other properties, e.g.
n.title.Lists of values, e.g.
["value1", "value2"].
Comparison operators¶
a = b: Equal to.a <> b: Not equal to.a < b: Less than.a <= b: Less than or equal to.a > b: Greater than.a >= b: Greater than or equal to.
String operators¶
n.attribute STARTS WITH "value": Starts with the string.n.attribute ENDS WITH "value": Ends with the string.n.attribute CONTAINS "value": Contains the string.n.attribute =~ "pattern": Matches the regular expressionpattern.
Note
=~ is a full-string (anchored) match:
the entire attribute value must match the pattern, not merely a substring —
so n.id =~ 'REQ' does not match REQ-1
(use n.id =~ 'REQ.*', or STARTS WITH / CONTAINS for substrings).
Patterns use Rust regex syntax,
a linear-time engine with no catastrophic backtracking.
Prefix a pattern with (?i) for case-insensitive matching,
e.g. n.title =~ '(?i).*todo.*'.
Null operators¶
n.attribute IS NULL: Is null.n.attribute IS NOT NULL: Is not null.
Note
A field a need does not have reads as null, and openCypher uses three-valued logic:
any comparison with null evaluates to null (not false),
so needs missing the field are silently dropped from a filter.
null is absorbing, so negation does not rescue them —
WHERE NOT (n.priority > 7) and WHERE n.priority <= 7
both still drop needs that have no priority.
To include needs missing the field, say so explicitly, for example
WHERE n.priority > 7 OR n.priority IS NULL or WHERE coalesce(n.priority, 0) > 7.
In a RETURN projection the need is not dropped —
RETURN n.priority just shows an empty (null) cell.
Use 'priority' IN keys(n) to tell an absent field from one present but null,
and note that aggregates such as count(n.priority) / avg(n.priority) skip nulls.
Note
To catch the most common form of this footgun,
a query that references vocabulary absent from the whole project —
a mistyped label, relationship type, or property key —
emits a warning with a did-you-mean suggestion,
since such a token can only ever match nothing.
On the CLI the warning goes to stderr
(pass --strict to instead list the warnings and exit with code 1 without running the query,
distinct from the exit code 2 a malformed query returns);
from the MCP query_cypher tool it is appended as a note: line.
A field that is declared but merely missing on some needs stays silent by design —
null-for-missing is intended for sparse fields, as described above.
List operators¶
n.attribute IN ["value1", "value2"]: Attribute is one of the listed values."value" IN n.attribute: Value is contained in a list-type attribute.
Functions¶
The openCypher scalar and aggregation function library is available, including:
String:
toLower,toUpper,substring,split,reverse,size.Numeric:
abs,ceil,floor,round,sqrt,sign.Conversion:
toString,toInteger,toFloat,toBoolean.Graph / list:
labels,type,keys,properties,head,last,tail,coalesce,range.Aggregation:
count,sum,avg,min,max,collect.
Function names follow openCypher (for example toLower(n.title), not lower(...)) and are case-insensitive.
See the openCypher function reference for the full list and semantics.
Logical operators¶
NOT: Negate the following expression.AND: Logical AND.OR: Logical OR.XOR: Logical exclusive-OR.
Keywords (MATCH, WHERE, AND, STARTS WITH, …) are case-insensitive.
Use parentheses to group expressions:
MATCH (n)
WHERE (n.status = 'open' OR n.status = 'review') AND n.priority >= 7
RETURN n
Supported clauses¶
The engine implements the read subset of openCypher:
MATCH / OPTIONAL MATCH, WHERE, WITH, UNWIND, RETURN, UNION,
ORDER BY / SKIP / LIMIT, aggregation, variable-length paths,
pattern expressions (EXISTS { }, COUNT { }), and the function library above.
Write clauses are rejected.
CREATE,MERGE,SET,DELETE, andREMOVEare not supported: the needs graph is a read-only, derived view of your project.Temporal types are not supported (
date/time/datetime/durationand their functions).Resource limits. A query is bounded by a 30-second timeout and a fixed variable-length traversal depth, so a pathological query over a large or cyclic graph cannot run unbounded; exceeding a limit returns an error. The MCP
query_cyphertool additionally caps its rendered result table (currently 200 rows), so a large result set cannot flood the model’s context; when the cap is exceeded the table is truncated to whole rows and a note is appended. This cap applies to the MCP tool only, not to the CLI, whose output is left uncapped. For large result sets, aggregate (count,collect) or add aWHERE/LIMITclause.
Beyond filtering¶
The same query language powers the ubc query cypher command-line tool, the
query_cypher MCP tool, and a needtable’s :cypher: option.
There, RETURN may project any columns (not only a single node)
and the result is rendered as a table — useful for exploring a project:
// count needs by type, most common first
MATCH (n) RETURN n.type AS type, count(*) AS count ORDER BY count DESC
// list the distinct link types in the project
MATCH ()-[r]->() RETURN DISTINCT type(r) AS link
OPTIONAL MATCH keeps rows that find no match, null-padding the missing columns,
so a table can pair every need with an optionally-related one:
// every requirement with the id of a satisfying spec, if any
MATCH (n:requirement) OPTIONAL MATCH (n)<-[:satisfies]-(s) RETURN n.id, s.id
A requirement with no satisfying spec still appears as a row,
its s.id shown as an empty (null) cell.
Projections in a needtable¶
A needtable’s :cypher: option reads a projecting query too:
each RETURN item becomes a column, in RETURN order,
and its header is the AS alias where one is written
and the expression text where none is.
.. needtable::
:cypher: MATCH (n:spec)-[:satisfies]->(m:req)
RETURN n, n.status AS State, m.title AS Satisfies
ORDER BY n.id
Which of the two column models applies is decided from the query alone:
a query returning a single column of nodes (
RETURN n,RETURN n AS Need,RETURN DISTINCT n, a single-nodeRETURN *) is a selection, and the columns come from:columns:as they always did;anything else that returns the matched node — as a bare node variable or as its id (
n.id) — in at least one column is a projection, and the columns come from the query.
Every table valid before projections existed is therefore unaffected.
The anchor column¶
A projection must say which need each row belongs to,
so every row has a need behind it —
which is what keeps the linked id cell,
the need / need_part row classes,
:style_row: and :show_parts: working.
Two kinds of column say it:
a node column — a bare node variable,
RETURN n;an id column — that node’s own id,
RETURN n.id(anASalias is fine; a computed form such astoUpper(n.id)is not, because what comes out is a string the query made rather than the need’s identity).
So both of these are tables, and the second is often what you want to write:
MATCH (n:spec) RETURN n, n.status AS State
MATCH (n:spec) RETURN n.id, n.title, n.status AS State
The first node column in RETURN order anchors the row;
with no node column, the first id column does.
Neither need be the first column, and
a node column outranks an id column wherever the two sit —
RETURN n.id, m anchors on m,
because a node value is the need itself
where an id is a property that names one.
A query with neither
(RETURN n.title, n.status, RETURN toUpper(n.id), RETURN count(n))
is reported as needs.cypher_invalid.
Cell rendering¶
Projected value |
Cell |
|---|---|
a node |
the standard linked id chip — every node column, not only the anchor |
a node’s |
the same linked id cell, identical to what |
a string, number or boolean |
its display text |
|
a blank cell |
a |
a list of linked chips, the same cell a Recognized only when the Two differences from a |
any other list |
its entries joined with |
a map, relationship or path |
reported — there is no sensible table cell for one |
Warning
A need’s links are relationships, not node properties
(see the needs graph above),
so n.links and n.satisfies are not properties and cannot be projected.
They come back as null — a blank column — and the query is reported as
needs.cypher_vocabulary, with a note naming the link type:
unknown property `satisfies` — `satisfies` is a link type, not a field:
traverse instead, e.g. `MATCH (n)-[:satisfies]->(m) RETURN n, m`
Traverse instead, which also renders linked chips:
MATCH (n)-[:satisfies]->(m) RETURN n, m
A need’s own fields are properties and project normally:
n.tags renders its entries joined by a semicolon and a space,
exactly as a :columns: cell does.
Rows, order and limits¶
The query’s order is the row order. With a
:cypher:present and no:sort:, the returned order —ORDER BYincluded — is what renders. An explicit:sort:still re-sorts. This rule is not specific to projections; see the row-order note above for what it changes for a query with noORDER BY.A traversal repeats its anchor.
RETURN n, m.titleyields one row per matchedm, which is ordinary Cypher and usually the point.``:max_items:`` caps result rows, not needs, under a projection, and the truncation notice says “result rows”. The cap counts the rows the QUERY returned: with
:show_parts:on, a surviving anchor keeps all of its part rows, so the table can show more rows than the notice names — the same way the parity path’s “needs” cap keeps every part of a surviving need. ALIMITin the query composes with it: the smaller wins.``:show_parts:`` shows the part’s prefixed id in the anchor column and the parent’s value in every projected column.
An OPTIONAL MATCH whose anchor column can be null
fails the table rather than silently dropping the unmatched rows.
Match the node instead, or project a column that is always bound.
Edges in a needflow¶
A needflow’s :cypher: reads a query that returns paths or
relationships as the graph to draw —
its nodes and its edges —
rather than as a selection whose edges are worked out afterwards.
.. needflow::
:cypher: MATCH p = (r {id: "T_CAR"})<-[:specifies]-()<-[:tests]-()
RETURN p
Which of the two edge models applies is decided from the query alone,
exactly as the two column models are for a needtable:
a query returning a single column of nodes (
RETURN n,RETURN n AS Need,RETURN DISTINCT n, a single-nodeRETURN *) is a selection, and the diagram derives its own edges as it always did;a query whose columns are all nodes, relationships and paths, with at least one relationship or path, gives the diagram its edges.
Every diagram valid before this existed is therefore unaffected.
RETURN n, m is two node columns with no edge in it,
so it stays a selection defect:
a diagram that took it would have to invent the edges it draws.
The two edge models¶
Without a graph-returning query, a needflow draws
every allowed link between the needs it selected —
where “allowed” means :link_types:, or every configured link type
except parent_needs when that option is unset.
The edge set is a function of the node set,
so given a set of needs the edges are decided for you.
With one, the edges are the ones the query matched, and nothing else.
That is the difference worth reaching for:
the same needs can be drawn with different edges,
which the option model cannot express at all,
because :link_types: is its only edge lever
and narrowing the edges narrows the node set with them.
Three spellings reduce to the same graph, so use whichever reads best:
MATCH p = (a)-[:specifies]->(b) RETURN p // paths
MATCH (a)-[r:specifies]->(b) RETURN a, r, b // triples
MATCH ()-[r:specifies]->() RETURN r // just the edges
A relationship carries its endpoints, so the last form draws its nodes without naming them. Edges are de-duplicated per source, target and link type, however many rows matched them, and two different link types between one pair stay two edges.
The query owns the whole diagram¶
Beside a graph-returning query,
:root_id:, :root_direction:, :root_depth: and :link_types:
are ignored — as are the python selection surfaces,
which any :cypher: already overrides.
Once the query supplies the edges there is nothing left for :link_types:
to gate, and letting :root_id: narrow the nodes
would mean the drawn edges are no longer the matched paths.
They are not wasted, though: they are what a Sphinx build reads. See the portability note.
The rest of the directive is unchanged¶
:show_link_names:labels each edge from the link type the query matched, through the same display names a derived edge uses.The link legend lists the types an edge was actually drawn for, so a query pinned to one link type legends only that one even in a project that configures several.
:max_items:caps the drawn needs, as it always has, and an edge whose endpoint the cap removed is dropped with it.Node order is the query’s — first seen wins, so an
ORDER BYreaches the diagram — and:sort_by:re-orders it.:highlight:and the styling options are untouched.
One thing is lost: a link to a part draws as a plain edge to the part’s parent need. Parts are not nodes in the needs graph and a matched relationship carries only its type, so a query-given edge cannot know it crossed one. The option model draws such a link dotted; a graph query cannot. And a link to the need’s OWN part is not drawn at all — in the graph the two spellings are one relationship, so it arrives as a self-loop, and the default model suppresses that loop too.
Warning
Bound your * patterns.
A variable-length pattern is path enumeration, not reachability,
and its cost grows exponentially with the bound.
On a couple of hundred densely linked needs,
raising the bound by one step can cost five times as much,
and an unbounded * runs for far longer than the build budget allows.
A view directive runs under a short build budget,
so an unbounded pattern is likely to time out
and render an error block in place of the diagram.
Write *1..3, not *.
Parameters¶
A query can reference the project’s out-of-band data through $name parameters,
so a filter need not hard-code a value the build already knows.
For example, to select the needs whose status matches a build variant:
MATCH (n) WHERE n.status = $var.build_variant RETURN n
A $name resolves against the query context to one of the reserved names:
$build_tags(the project’s build tags as a list of strings),$current_id(the id of the “current” need — reserved for future need-relative surfaces, not currently supplied by any surface),$docname(the document the query originates from),and
$var(the project’s variant data as one nested map).
The flat $name namespace is therefore the reserved names only:
a $name is a reserved name or an error, nothing else.
Variant data is not in the flat namespace —
it is reached only through the reserved $var tree (see below),
so a top-level variant key deploy is read as $var.deploy, never as a bare $deploy.
Note
needs.filter_data is not exposed to Cypher.
It exists for the legacy Python filter syntax
(and mirrors sphinx-needs’ needs_filter_data, which is deprecated upstream);
use variant data and $var instead.
A build tag membership test reads the reserved $build_tags list:
// needs selected only when the 'html' build tag is active
MATCH (n) WHERE 'html' IN $build_tags RETURN n
Variant data via $var¶
The project’s variant data is reached through the reserved $var tree with the dot operator,
so a leaf deploy.region.zone in the variant data is read as $var.deploy.region.zone:
MATCH (n) WHERE n.zone = $var.deploy.region.zone RETURN n
This is the only spelling for variant access — a bare $deploy.region.zone is not bound
(the flat $name namespace is the reserved names only),
so referencing one errors with a hint pointing at the $var. form.
$var.a.b.c is also the migration spelling for the legacy var.a.b.c
of the Python syntax —
now the only spelling, which keeps the migration one-to-one.
Availability by surface¶
Which parameters exist depends on the surface running the query:
ubc query cypher(CLI):$var(and the variant tree beneath it), and$build_tags(there is no per-need or per-document context, so$current_idand$docnameare absent).query_cypher(MCP): as the CLI, plus$docname(resolved against the queried file).The
Needs Indextree-view filter:$var(and the variant tree beneath it),$build_tags, and$docname(resolved against the document holding the filter). A tree-view filter has no single “current” need, so$current_idis not supplied.Filters over an external
needs.json: none — that surface carries no project context, so every$nameis unavailable.
$current_id is reserved for future need-relative surfaces
and is not currently supplied by any surface.
Referencing an unknown parameter is an error,
and the message lists the names that are available.
Referencing a reserved name whose backing context is unset
(e.g. $docname where no origin is known)
is also an error, but a targeted “not available in this context” one — without a list
(when a variant key shares the reserved name,
the message additionally points at its reachable $var. spelling).
Either way this is deliberately unlike a missing node property,
which reads as null (see the null-operator note above):
a mistyped or unavailable $name almost always signals a bug, so it fails loudly rather than silently.
The docname node property¶
Every node also carries a computed docname property —
the document a need was defined in, relative to the project source directory —
available whenever that source directory is known.
For a need defined inside an .. include::d file
this is the host document the file is included into,
not the included file itself,
because an include is a textual splice
and the need’s card is rendered on the host’s page.
Paired with $docname it subsumes the legacy c.this_doc():
// needs defined in the same document the query originates from
MATCH (n) WHERE n.docname = $docname RETURN n
If a project instead models docname as a real schema field,
that field takes precedence and is read verbatim (the computed property is not synthesized).
The section-location node properties¶
Every node carries the need’s position in its document’s heading structure:
sections— the titles of the enclosing sections, innermost first, as a list of strings. It is always present, and is an empty list for a need that sits under no heading, so it appears inkeys(n)for every need.section_name— the innermost enclosing title, i.e. the first entry ofsections. It is absent for a need under no heading, so it reads asnulland is not inkeys(n)there — the same conventioncontentanddocnamefollow.
// every need written under a "Detailed Requirements" heading, at any depth
MATCH (n) WHERE 'Detailed Requirements' IN n.sections RETURN n
// only those whose IMMEDIATELY enclosing heading is that one
MATCH (n) WHERE n.section_name = 'Detailed Requirements' RETURN n
These are the same values the Python syntax reads,
so a section filter can be written in the recommended portable form —
:cypher: for ubCode beside the python surfaces for a Sphinx-Needs build:
.. needtable::
:cypher: MATCH (n:spec) WHERE n.section_name = 'Detailed Requirements' RETURN n
:types: spec
:filter: section_name == 'Detailed Requirements'
If a project declares sections or section_name itself —
as a field or as a link type —
that declaration takes precedence and the structural property is not synthesized,
matching how the Python syntax resolves the same collision.
A declared field is then read verbatim;
a declared link behaves as any link name does when read as a property
(null, absent from keys(n), and diagnosed with the “traverse instead” note) —
links are relationships, so MATCH (n)-[:name]->(m) is how you read one.
Note that the two names decouple under such a declaration:
section_name always derives from the structural captured list,
never from the declared sections,
so section_name can differ from sections[0] in that configuration.
Note
Every $var.… access reads from the reserved $var map, which the engine copies
per evaluation, so in a hot predicate keep the variant tree lean:
bind hot scalars as top-level variant keys and read them as $var.key,
rather than burying them deep inside a large nested map.
Discovering the schema¶
Before writing a query it helps to know what the graph contains: which node labels (need types) exist, which relationship types (link options) connect them, and which node properties are queryable and of what data type.
Two surfaces report this directly:
The
get_graph_schemaMCP tool returns the schema as structured JSON — node labels with counts and their configured type titles, relationship types with counts, and node properties with their data types and whether they are mandatory. Labels cover every declared need type, so a type not yet used by any need still appears (with a count of 0). Its shape follows the Neo4jdb.schema.nodeTypeProperties()conventions.The
ubc query cypher --schemacommand prints the same schema as a compact table (or as JSON with--format json).
Both surfaces also list the $parameters available in that context
(see Parameters), each with its data type and source.
You can also discover a project in the language itself.
List the node labels, most common first, by aggregating over n.type:
MATCH (n) RETURN n.type AS type, count(*) AS count ORDER BY count DESC
Inspect the properties present on a matched need with keys(n):
MATCH (n {id: 'REQ-1'}) RETURN keys(n)
Note
keys(n) reflects only the fields present on the matched needs —
not the full set of declared fields — and carries no type information.
For the complete, typed property list, use get_graph_schema or ubc query cypher --schema.
The schema’s property list is currently global (the same for every label);
a per-need-type property schema is future work.
Examples¶
Filter for all needs whose title starts with a case-insensitive string:
MATCH (n) WHERE toLower(n.title) STARTS WITH "my string" RETURN n
Filter for all needs that link to a need of type requirement through a link of type needs:
MATCH (n)-[:needs]->(:requirement) RETURN n
Python syntax¶
This syntax of a filter query uses a subset of the Python expression syntax.
See full syntax grammar
Written in pest, the grammar for the filter query language is as follows:
// A grammar for supporting a subset of python expressions
start = { SOI ~ ws* ~ or_expr ~ ws* ~ EOI }
or_expr = { and_expr ~ (ws+ ~ or_keyword ~ ws+ ~ and_expr)* }
or_keyword = _{ "or" }
and_expr = { (expr | not_expr) ~ (ws+ ~ and_keyword ~ ws+ ~ (expr | not_expr))* }
and_keyword = _{ "and" }
expr = { paren_expr | this_doc_check | search_check | var_field_op_expr | literal_in_var_field_expr | literal_not_in_var_field_expr | literal_cmp_var_field_expr | bool_literal_expr }
not_expr = { not_keyword ~ ws+ ~ expr }
not_keyword = _{ "not" }
paren_expr = { "(" ~ ws* ~ or_expr ~ ws* ~ ")" }
literal_in_var_field_expr = { literal_single ~ ws+ ~ in_keyword ~ ws+ ~ var_field_with_func }
// A literal on the LEFT of `not in` (e.g. `"x" not in tags`); mirror of
// `literal_in_var_field_expr`, canonicalised at parse to a negated membership.
literal_not_in_var_field_expr = { literal_single ~ ws+ ~ not_keyword ~ ws+ ~ in_keyword ~ ws+ ~ var_field_with_func }
// A literal on the LEFT of a comparison ("Yoda" order, e.g. `"spec" == type`);
// the field is on the RIGHT. Canonicalised at parse to the field-on-left form,
// flipping the ordering operators (`5 < field` ⇒ `field > 5`).
literal_cmp_var_field_expr = {
literal_single ~ ws* ~ equals_keyword ~ ws* ~ var_field_with_func |
literal_single ~ ws* ~ not_equals_keyword ~ ws* ~ var_field_with_func |
number_literal ~ ws* ~ less_than_keyword ~ ws* ~ var_field_with_func |
number_literal ~ ws* ~ greater_than_keyword ~ ws* ~ var_field_with_func |
number_literal ~ ws* ~ less_than_or_equals_keyword ~ ws* ~ var_field_with_func |
number_literal ~ ws* ~ greater_than_or_equals_keyword ~ ws* ~ var_field_with_func
}
// A bare boolean literal as a complete expression (`True` / `False`), also
// valid as an operand of `and`/`or`/`not`. Tried LAST in `expr` so a var whose
// name merely starts with `True`/`False` still parses as a field.
bool_literal_expr = { boolean_literal }
var_field_op_expr = {
var_field_with_func ~
(in_list_expr | not_in_list_expr | is_null_expr | is_not_null_expr | comparison_expr | str_predicate_method)?
}
comparison_expr = {
ws* ~ equals_keyword ~ ws* ~ (literal | var_field_with_func) |
ws* ~ not_equals_keyword ~ ws* ~ (literal | var_field_with_func) |
ws* ~ less_than_keyword ~ ws* ~ (number_literal | var_field_with_func) |
ws* ~ greater_than_keyword ~ ws* ~ (number_literal | var_field_with_func) |
ws* ~ less_than_or_equals_keyword ~ ws* ~ (number_literal | var_field_with_func) |
ws* ~ greater_than_or_equals_keyword ~ ws* ~ (number_literal | var_field_with_func)
}
equals_keyword = { "==" }
not_equals_keyword = { "!=" }
less_than_keyword = { "<" }
greater_than_keyword = { ">" }
less_than_or_equals_keyword = { "<=" }
greater_than_or_equals_keyword = { ">=" }
is_null_expr = { ws+ ~ is_keyword ~ ws+ ~ null_keyword }
is_not_null_expr = { ws+ ~ is_keyword ~ ws+ ~ not_keyword ~ ws+ ~ null_keyword }
in_list_expr = { ws+ ~ in_keyword ~ ws+ ~ (list_literal | var_field) }
not_in_list_expr = { ws+ ~ "not" ~ ws+ ~ in_keyword ~ ws+ ~ (list_literal | var_field) }
var_field = { symbolic_name_simple ~ ("." ~ symbolic_name_simple ~ !("("))* }
var_field_with_func = { var_field_with_len | var_field_with_upper | var_field_with_lower | var_field }
var_field_with_len = { ("len(") ~ var_field ~ (")") }
var_field_with_lower = { var_field ~ (".lower()") }
var_field_with_upper = { var_field ~ (".upper()") }
reserved = { "None" | "True" | "False" | "and" | "or" | "not" | "in" | "is" }
// A reserved word is a field name ONLY when it continues into a longer
// identifier (`Trueish`, `is_external`); a reserved word at an identifier
// boundary — before whitespace, EOI, or any operator/bracket — is the keyword,
// never a field. (Python keywords can never be identifiers, so this is always
// the correct reading.)
symbolic_name_simple = @{ !(reserved ~ !id_part) ~ id_start ~ id_part* }
id_start = @{ "_" | ASCII_ALPHA }
id_part = @{ id_start | ASCII_DIGIT }
literal_single = { null_literal | boolean_literal | number_literal | string_literal }
literal = { null_literal | boolean_literal | number_literal | string_literal | list_literal }
boolean_literal = { true_literal | false_literal }
null_literal = { "None" }
true_literal = { "True" }
false_literal = { "False" }
number_literal = { float_literal | decimal_literal | integer_literal }
integer_literal = @{ "-"? ~ ("0" | ASCII_NONZERO_DIGIT ~ ASCII_DIGIT*) }
decimal_literal = @{ integer_literal ~ "." ~ ASCII_DIGIT* }
float_literal = @{ integer_literal ~ exp | decimal_literal ~ exp? }
exp = @{ ^"E" ~ integer_literal }
string_literal = { string_single | string_double }
string_single = @{ "'" ~ string_single_char* ~ "'" }
string_single_char = @{ "\\" ~ ANY | !("'" | "\\") ~ ANY }
string_double = @{ "\"" ~ string_double_char* ~ "\"" }
string_double_char = @{ "\\" ~ ANY | !("\"" | "\\") ~ ANY }
list_literal = {
"[" ~ ws* ~ (literal_single ~ ws* ~ ("," ~ ws* ~ literal_single ~ ws*)*)? ~ "]"
}
str_predicate_method = {"." ~ str_predicate_method_name ~ "(" ~ string_literal ~ ")"}
str_predicate_method_name = { "startswith" | "endswith" }
this_doc_check = { "c.this_doc()" }
search_check = { "search(" ~ string_literal ~ "," ~ ws* ~ var_field_with_func ~ ")" }
in_keyword = _{ "in" }
is_keyword = _{ "is" }
null_keyword = _{ "None" }
ws = _{ " " | "\t" | "\n" }
Variables¶
Variables relate to attributes of the need, e.g. id or title
In addition to node attributes, you can query by the source type of the node:
is_directive: True if the need originated from a directive.is_external: True if the need originated from theexternal_needsconfiguration.is_import: True if the need originated from aneedimportdirective.
You can also query if the need is modified by one or more needextend directives using the is_modified attribute.
docname resolves to the document the need is defined in,
relative to the project source directory
(available whenever that directory is known —
every surface that resolves a project configuration knows it,
including the view directives, needextend filters,
ubc query filter and the editor extension’s filter surfaces).
For a need defined inside an .. include::d file
this is the host document the file is included into —
an include is a textual splice, so the need belongs to the host,
and it is the host’s page that renders its card.
A need with no source document —
one loaded from external_needs, for example —
has no docname, so no comparison against it matches.
The location of a need relative to the document’s section headings is available too:
sections is the list of enclosing section titles, innermost first,
and section_name is the innermost section title (or null when the need is not under any section).
Two boolean context attributes are always available, whatever the project schema:
is_need: alwaysTrue.is_part: alwaysFalse.
ubcode models needs only (need parts are not modelled),
so every item a filter evaluates is a need.
These constants exist for compatibility with filters written for Sphinx-Needs,
where parts invert them.
A schema field or link type with the same name takes precedence over the constant,
while the constants take precedence over [needs.filter_data] keys with the same name.
The var.* namespace exposes the project’s variant data —
a build-wide, nested key-value store independent of the current need,
accessed with the dot operator, for example var.platform or var.build.compiler.
Link fields¶
A link type resolves to the list of ids it points at — blocks for a blocks link —
in the same form the need card, a table cell and needs.json show it:
deduplicated and naturally sorted,
so REQ_2 comes before REQ_10 and a target written twice counts once.
A reference to a part (REQ_1.section) is its own entry,
distinct from a reference to the need itself,
so "REQ_1" in blocks and "REQ_1.section" in blocks are independent questions.
Sphinx-Needs sorts and deduplicates these lists too, so len() agrees across both tools,
with one corner exception: the same target authored twice with different link conditions
counts as two entries in Sphinx-Needs and one here,
because ubcode’s link values do not carry conditions.
Backlink fields¶
Every link type also has an incoming half, written <link>_back:
blocks_back for a blocks link, links_back for the built-in links, and so on.
It holds the ids of the needs that link to this need through that link type —
a deduplicated list of plain need ids, naturally sorted, so REQ_2 comes before REQ_10.
A reference to one of the need’s parts (REQ_1.section) is a reference to the need,
and collapses into the same single entry.
A need nothing links to has an empty list;
that is a value rather than an error, so a filter can select on it.
not implements_back # nothing implements this need
len(blocks_back) > 1 # more than one need blocks this one
"REQ_1" in links_back # REQ_1 links to this need
A link type of your own named <something>_back takes precedence over the backlink field,
exactly as it does in Sphinx-Needs:
the option you declared is read, and the incoming half of <something> becomes unreachable
under that name.
A field named after an existing link’s backlink field — a field blocks_back
alongside a link blocks — has no Sphinx-Needs behaviour to match:
Sphinx-Needs rejects that configuration outright and refuses to build
(“Same name for automatically created link and field”).
ubcode accepts it and resolves your field first,
which is the only self-consistent choice once the configuration is allowed at all;
avoid the collision if you want a project that both tools can build.
Backlink fields resolve only in the :filter: option of the view directives —
needtable, needlist, needflow (including its :highlight: option),
and the :need_count: role.
Those filters run at render time, against a fully resolved link graph.
They are deliberately not available:
in
needextendfilters, because the extends being applied are still changing the link graph the backlinks are read from (Sphinx-Needs has the same ordering, and a backlink predicate there quietly matches nothing);in the
ubc query filtercommand line, and in the filter surfaces of the editor extension and the MCP tools.
Using one in those places reports that backlink fields are not available in that filter context.
A needimport filter is a different case:
it filters the imported JSON,
so a *_back key written into the imported needs.json is matched against that file’s value.
Truthiness¶
Variables can be used directly in boolean context using Python truthiness rules:
Numbers:
0and0.0are falsy, all other numbers are truthyStrings: Empty string
""is falsy, all other strings (including whitespace) are truthyLists: Empty list
[]is falsy, lists with any items are truthyNull:
None(null) values are falsyBooleans:
Trueis truthy,Falseis falsy
Examples:
title # True if title is not empty, False, 0, or None
not title # True if title is empty, False, 0, or None
tags # True if tags list has items
not tags # True if tags list is empty
Values¶
Values can be:
Strings (in single or double quotes), e.g.
"my value"or'my value'.Numbers, e.g.
42,3.14, or0.1e-3.Booleans, e.g.
TrueorFalse.Null, e.g.
None.Other attributes, e.g.
title.Lists of values, e.g.
["value1", "value2"].
Comparison operators¶
The following boolean operators are available:
attribute == value: Equal to.attribute != value: Not equal to.attribute < value: Less than.attribute <= value: Less than or equal to.attribute > value: Greater than.attribute >= value: Greater than or equal to.
Note that number types (integers and floats) can be compared with each other, and 1 == 1.0 evaluates to True.
Mixed type comparisons (e.g., string to number) are strict and will return False for equality.
String operators¶
The following string mutators and comparators are available:
attribute.lower(): Convert string attribute to lower-case before comparison.attribute.upper(): Convert string attribute to upper-case before comparison."value" in attribute: Check if string contains the value (substring match).attribute.startswith("value"): Check if string starts with the value.attribute.endswith("value"): Check if string ends with the value.
Note these can be combined, e.g. attribute.lower().startswith("value") or used with the in operator like "TEST" in attribute.upper().
String operations are case-sensitive unless you use .lower() or .upper() methods.
Built-in functions¶
The following built-in functions are available:
len(attribute): Get the length of a string or list attribute.search(pattern, attribute): Test if the attribute matches a regular expression pattern.
Examples:
len(title) > 10 # Title has more than 10 characters
len(tags) == 0 # Tags list is empty
len(description) >= 100 # Description is at least 100 characters
Regular expression search¶
The search(pattern, attribute) function tests if a string attribute matches a regular expression pattern.
It uses Rust regex syntax, which is compatible with most common regex features.
The function returns True if the pattern is found anywhere in the attribute value, False otherwise.
# Simple text search
search('test', title) # Title contains "test"
# Case-insensitive search
search('(?i)urgent', description) # Description contains "urgent" (case-insensitive)
# Word boundaries
search('\\bREQ\\b', id) # ID contains "REQ" as a complete word
# Email pattern matching
search('[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', author)
# URL pattern matching
search('https?://[^\\s]+', description) # Description contains a URL
# Digit patterns
search('\\d{4}', id) # ID contains 4 consecutive digits
# Combined with other operations
search('^REQ-', id) and status == 'open' # ID starts with "REQ-" and status is open
# With string functions
search('test', title.lower()) # Search in lowercase version of title
# Negation
not search('deprecated', description) # Description does not contain "deprecated"
Note
The search() function only works with string attributes. Lists and other types will result in an error.
Use .lower() or .upper() with the attribute if you need case-insensitive matching across the entire string,
or use the (?i) flag at the start of the regex pattern.
Warning
Complex regular expressions may impact performance when filtering large numbers of needs.
Consider using simpler string operations like in, .startswith(), or .endswith() when possible.
Null operators¶
The following null operators are available:
attribute is None: Is null.attribute is not None: Is not null.
List operators¶
The following list operators are available:
attribute in ["value1", "value2"]: Attribute is in the list."value" in attribute: Value is in a list type attribute."value" not in attribute: Value is not in a list type attribute.
Logical operators¶
The following logical operators are available:
not: Negate the following expression.and: Logical AND.or: Logical OR.
Parentheses¶
You can use parentheses to group expressions, for example:
(attribute1 == "value1" or attribute2 == "value2") and attribute3 == "value3"
not can be used to negate an entire group, for example:
not (attribute1 == "value1" or attribute2 == "value2")
Examples¶
Here are some comprehensive examples demonstrating various features:
Basic filtering:
# Filter by status
status == 'open'
# Filter by priority with number comparison
priority > 5
# Check if title is not empty
title
String operations:
# Case-insensitive search
type.lower() == 'requirement'
# Check if description contains specific text
'security' in description.lower()
# Title starts with specific prefix
title.startswith('REQ-')
List operations:
# Check if status is one of several values
status in ['open', 'in_progress', 'review']
# Check if tags contain a specific tag
'critical' in tags
# Exclude items with certain tags
'deprecated' not in tags
Complex conditions:
# Multiple conditions with AND
type == 'requirement' and status == 'open' and priority >= 7
# Multiple conditions with OR
priority >= 8 or 'critical' in tags or 'security' in description.lower()
# Grouping with parentheses
(status == 'open' or status == 'in_progress') and assignee != None
Length and null checks:
# Check for non-empty descriptions
description is not None and len(description) > 50
# Filter items with tags
len(tags) > 0
# Unassigned items
assignee is None
Real-world scenarios:
# High priority open requirements
type == 'requirement' and status == 'open' and priority >= 8
# Security-related items needing attention
('security' in title.lower() or 'CVE' in description) and status != 'closed'
# Requirements ready for review
type == 'requirement' and status == 'draft' and len(description) >= 100 and assignee is not None
Special functions¶
The following special functions are available:
c.this_doc(): True if the need is in the same document as the query originates from.It needs an originating document, which a filter written in a document always has (a view directive’s
:filter:, aneedextendargument, the editor extension’s filter surfaces). On theubc query filtercommand line there is none, soc.this_doc()is an error there rather than a silently-empty result; compare ondocnameinstead —docname == 'index'.
The needextend argument: id or filter?¶
The argument of a needextend directive is either a need id or a filter expression,
and which one it is depends only on its shape:
a single whitespace-delimited token is a need id —
.. needextend:: REQ_1;so is an explicitly delimited
.. needextend:: <REQ_1>;a quoted argument is a filter expression —
.. needextend:: "status == 'open'";anything with a space in it is also read as a filter expression;
an argument that is empty once its delimiters come off —
<>, or a stray quote — names nothing and expresses nothing, so it is reported and the directive is ignored.
This is Sphinx-Needs’ rule, kept for compatibility, and it has one sharp edge:
a filter that happens to contain no spaces is a single token,
so it is read as a need id rather than as a filter.
c.this_doc() is the case that bites —
quote it:
.. needextend:: "c.this_doc()"
:status: reviewed
Quoted, that extends every need in the document holding it.
Unquoted, the same text is one token,
so it asks for the need whose id is literally c.this_doc() — and there is none.
An argument read as a need id names a need that is expected to exist,
so one that matches nothing is reported:
Provided id '…' for needextend does not exist,
under its own needs.extend_missing_id lint code.
A filter matching no needs is not:
selecting nothing is a legitimate result for a filter, and stays silent.
The code is deliberately separate from needs.extend,
which covers errors raised while applying an extend.
A project that cannot act on a dead id —
one naming a need in a document this build does not include, say —
can therefore silence just that:
[lint]
ignore = ["needs.extend_missing_id"]
without also muting the application errors that would point at a genuinely broken extend.