Skip to content

MJ Query Language v1

MJ Query is the language of MemberJunction stored queries: SQL in one dialect, with template tags for parameters, conditions, loops and composition. This document is the specification of version 1. It is written for MemberJunction developers and for the AI agents that write queries (Skip’s query writers).

  • A defined, allow-listed subset of Nunjucks over SQL. Every tag, expression form, filter and Context token the language accepts is listed here. Anything not listed is refused.
  • One dialect per query. The SQL between the tags is read with the lexical rules of the query’s dialect (SQL Server, PostgreSQL, MySQL, Oracle, Snowflake or Databricks).
  • MJ parses and checks; Nunjucks renders. MJ reads the query, finds its parameters and checks every rule in this document when the query is saved. At run time Nunjucks renders the template with the caller’s values and the MJ SQL filters.
  • Strictness. A query that breaks a rule is refused when it is saved new or edited (a save that changes its SQL). Queries already saved keep running, unless they use a construct outside the language or put a value tag inside a SQL string literal or a quoted identifier (see Running a saved query). A refresh of an existing query reports what breaks the rules, and three rules are only warnings there (see Save-time checks reference).

The reference implementation is @memberjunction/sql-parser:

APIWhat it does
SqlTokens.Lex(text, dialect)The token stream: every SQL token and template tag, with positions. Never throws.
QueryTemplate.Parse(text, dialect)The template tree, plus analyses: Parameters(), ContextTokens(), CompositionReferences(), DocComments(), BranchVariants(), Validate(options). Never throws.
MJQueryDiagnosticCodesEvery diagnostic code below. Codes are stable and never reused.

Every analysis returns { Value, Complete, Diagnostics }. Complete is false when part of the text could not be read (an unsupported tag, an unclosed block), so the value may be missing something.


FormWrittenPurpose
Hole{{ … }}Writes one value into the SQL, or composes another query ({{query:"…"}}).
Block tag{% … %}if / elif / else / endif, for / endfor, set.
Template comment{# … #}Never rendered. Holds doc comments (@param, @field).

Whitespace control. A - right after the opener or right before the closer ({{-, -}}, {%-, -%}, {#-, -#}) trims whitespace on that side when Nunjucks renders. MJ’s analysis ignores the dash.

SQL comments are found first, by the dialect’s rules over the raw text, because the render pipeline removes SQL comments before the template is evaluated. Everything inside a -- or /* */ comment is therefore inert for the template layer: a {{ x }} or {{query:"…"}} mentioned in a comment is not a parameter use, not a composition and not a block, and it is never rendered. Validate notes each such comment with MJQ_EXPRESSION_IN_COMMENT (Info, never a refusal), so a header that documents its own parameters keeps validating:

/* PARAMETERS
{{ agentRunID }} — the run at the top of the tree -- inert: not a use of agentRunID
Compose via {{query:"MJ/Tags/Tags For Entity Record"}} -- inert: not a composition
*/

{# always opens a template comment, in every dialect. It takes precedence over a dialect’s # line comment (MySQL), so a # right after { never starts a SQL comment, and neither does the # of #}. On MySQL, {# @param x … #} SELECT … keeps its SELECT and its doc comment. A # anywhere else still starts a MySQL comment. {{ and {% mean nothing to any dialect’s SQL rules, so they are always template tags outside comments.

Write notes about the template itself in a {# #} template comment, which is the language’s own comment form (and where doc comments go). A SQL comment is still the right place for notes about the SQL.

Template tags are then found over the rest of the text, as Nunjucks finds them. SQL quoting does not hide a tag: '{{ x }}' is still a tag. Inside {{ }} and {% %}, a closer inside a quoted template string does not end the tag ({{ x | default('}}') }} is one tag). A {# #} ends at its first #}. Because comments are found first, a {{ mentioned in a comment never pairs with a }} after it.

The SQL around the tags is then read by the dialect’s rules as if each tag were blank. Each tag records the SQL context it sits in:

SQL contextMeaningAllowed?
CodeOrdinary SQL textYes
StringLiteralInside a SQL string literalNo: MJQ_TAG_IN_STRING_LITERAL
QuotedIdentifierInside a quoted identifier ([…], "…" or `…`, as the dialect defines them)No: MJQ_TAG_IN_QUOTED_IDENTIFIER
CommentInside a SQL commentInert (Info MJQ_EXPRESSION_IN_COMMENT). Normally such text is not a tag at all; this context appears only when a tag’s own quotes change where a comment starts.

A tag stands in for whole SQL tokens. It never builds part of a string literal or part of a quoted identifier, because there its value is not escaped. Use the filter that builds the whole token:

Instead ofWrite
Name LIKE '%{{ Search }}%'Name LIKE {{ Search | sqlLikeContains }}
Name LIKE '{{ Prefix }}%'Name LIKE {{ Prefix | sqlLikeBegins }}
WHERE Code = '{{ Code }}'WHERE Code = {{ Code | sqlString }}
SELECT [{{ Column }}]SELECT {{ Column | sqlIdentifier }}

An opener with no closer ({{, {% or {# alone) is MJQ_UNCLOSED_TAG.


Expressions appear in holes, {% if %} / {% elif %} conditions, {% for %} iterables, {% set %} values and composition arguments.

expression := or ["if" or ["else" or]] (inline if: body if condition else otherwise)
or := and ("or" and)*
and := not ("and" not)*
not := "not" not | compare
compare := additive (("==" | "!=" | "<" | ">" | "<=" | ">=") additive)*
additive := term (("+" | "-") term)*
term := unary (("*" | "/" | "%") unary)*
unary := ("-" | "+") unary | postfix, then filters ("|" name ["(" args ")"])*
postfix := primary ("." name | "(" args ")" | "[" expression "]")*
primary := name | number | string | true | false | null | "(" expression ")"

Only three postfix forms are accepted: Context.<Token>, loop.<member> and <name>.length. Calls, subscripts and any other attribute are parsed so they can be reported, then refused.

<name>.length is the length of the array or string a name (a parameter, loop variable or {% set %} variable) holds. It is the only attribute a name has. As in Nunjucks, a name with no value, a null value, or a value with no length (a number, a boolean) gives undefined, and every comparison with undefined but != is false:

{% if Codes and Codes.length > 0 %} AND Code IN {{ Codes | sqlIn }} {% endif %}

An empty array is truthy on its own, so {% if Codes %} alone takes the branch for []; Codes.length > 0 does not. .length takes no further postfix: x.length.length, x.length() and x['length'] are refused, as is .length on anything but a plain name (Context.UserID.length, 'abc'.length). In a hole, .length is a number and takes sqlNumber: {{ Codes.length | sqlNumber }}.

body if condition else otherwise is body when the condition is truthy, otherwise otherwise. Without else (body if condition) a false condition gives the empty string. It is an expression, allowed wherever an expression is: in a hole, a condition, a {% set %} value or a composition argument. Each part is read at the or level, so a nested inline if needs parentheses: a if b else (c if d else e).

SELECT TOP {{ MaxRows | sqlNumber if MaxRows else 12 }} …
LevelOperators
1Unary -, +
2Filters: x | f, x | f(arg)
3*, /, %
4+, -
5==, !=, <, >, <=, >=
6not
7and
8or
9Inline if … else

Filters bind tighter than every binary operator, as in Nunjucks. Year - 1 | sqlNumber means Year - (1 | sqlNumber). A unary sign binds tighter than a filter: -x | f means (-x) | f. Every operator binds tighter than the inline if: x | sqlNumber if x else 12 means (x | sqlNumber) if x else 12, so the filter applies to the body only; (x if x else 12) | sqlNumber filters both branches.

KindForms
String'text' or "text". A backslash escapes the next character; \n, \t, \r are a newline, tab and carriage return.
NumberDigits with an optional fraction: 10, 2.5. No exponent. A negative number is unary - on a number.
Booleantrue, True, false, False
Nullnull, none, None
NameA letter or _, then letters, digits or _. The words and, or, not, if, else, in, is are not names.
Context tokenContext.<Token>: one of the nine tokens in Context tokens.
Loop memberloop.<member>, inside a {% for %} body only.
Length<name>.length: see .length.
ConstructExampleCode
Function or method callupper(x), x.upper()MJQ_FUNCTION_CALL (a method call is also MJQ_MEMBER_ACCESS)
Subscriptx[0], x['key']MJQ_SUBSCRIPT
Attribute access other than Context., loop. and <name>.lengthx.Name, x.constructor, Context.UserID.lengthMJQ_MEMBER_ACCESS
in, not in, is, ~, **, //, ===, !==x in Items, x is definedMJQ_UNSUPPORTED_OPERATOR
List or object literal[1, 2], {a: 1}MJQ_UNSUPPORTED_OPERATOR
Anything else that does not parse!x, an empty tag, an unclosed stringMJQ_SYNTAX

A hole {{ }} writes one value into the SQL:

{{ value [| default(literal)] [| sqlFilter] }}

value is a parameter, a Context token, a loop variable, a {% set %} variable, a loop member or <name>.length. Filters follow.

A hole may also be an inline if, {{ value if condition else value }}. Each branch is checked as the value of a hole, with its own filters and the filters written after the whole inline if: {{ MaxRows | sqlNumber if MaxRows else 12 }} and {{ (A if A else B) | sqlNumber }} both filter every parameter they write. A branch may be a literal (the author wrote it); the condition is not written into the SQL, so it needs no filter.

  • No operators inside a hole (MJQ_ARITHMETIC_IN_HOLE). Do the arithmetic in SQL around the hole, or in a {% set %}:

    WHERE FiscalYear = {{ Year | sqlNumber }} - 1 -- arithmetic in SQL
    {% set PriorYear = Year - 1 %}
    WHERE FiscalYear = {{ PriorYear }} -- arithmetic in a set

    Parentheses do not help: {{ (Year - 1) | sqlNumber }} is still refused.

  • A literal is not a value (MJQ_HOLE_NOT_A_VALUE): write 'abc' in the SQL, not {{ 'abc' }}. A branch of an inline if may be a literal.

  • Arithmetic and comparisons are allowed in {% if %} conditions, {% set %} values and composition arguments.


Each MJ SQL filter turns a value into safe SQL text for one parameter type. The output shown is SQL Server’s; each dialect formats for itself.

FilterFormats typeExampleRenders (SQL Server)
sqlStringstringName = {{ Name | sqlString }}'O''Brien'
sqlNumbernumberAge > {{ MinAge | sqlNumber }}25
sqlDatedateCreatedAt >= {{ Start | sqlDate }}'2024-01-15T00:00:00.000Z'
sqlBooleanbooleanIsActive = {{ Active | sqlBoolean }}1
sqlInarrayStatus IN {{ Statuses | sqlIn }}('Active', 'Pending')
sqlIdentifierstringORDER BY {{ SortColumn | sqlIdentifier }}[Revenue]
sqlLikeContainsstringName LIKE {{ Search | sqlLikeContains }}'%Gala%'
sqlLikeBeginsstringName LIKE {{ Prefix | sqlLikeBegins }}'Gala%'
sqlLikeEndsstringName LIKE {{ Suffix | sqlLikeEnds }}'%Gala'
sqlNoKeywordsExpressionstringORDER BY {{ OrderBy | sqlNoKeywordsExpression }}Revenue DESC

default(literal) supplies a value when the caller gives none. Its arguments must be literals: a string, number, boolean or null (MJQ_DEFAULT_NOT_LITERAL otherwise). It formats nothing, so put it before the SQL filter: {{ Start | default('2020-01-01') | sqlDate }}.

Any other filter name (safe, upper, join, …) is MJQ_UNKNOWN_FILTER, wherever it appears.

The filter must fit the type. When a parameter declares a type in its metadata (see Unfiltered values for which rows declare), the last SQL filter of each hole must be one that formats that type (MJQ_FILTER_TYPE_MISMATCH). All five string filters fit a string parameter. Context tokens have their own fixed list (see below).


The allowed block tags are if, elif (also elseif), else, endif, for, endfor and set. Every other tag (macro, include, import, extends, block, call, filter, raw, endset, …) is MJQ_UNSUPPORTED_TAG.

{% if Search %}
AND Name LIKE {{ Search | sqlLikeContains }}
{% elif MinTotal > 0 %}
AND Total >= {{ MinTotal | sqlNumber }}
{% else %}
AND IsFeatured = 1
{% endif %}
  • A condition is any expression. Comparisons, arithmetic, and, or, not and filters are allowed.
  • An elif or else after the else, or outside any if, is MJQ_UNEXPECTED_TAG. So is an endif with no open if.
  • An if with no endif is MJQ_UNCLOSED_BLOCK. So is a block left open when an outer block closes.
{% for Tag in Tags %}
{% if not loop.first %}OR {% endif %}t.Tag = {{ Tag | sqlString }}
{% endfor %}
  • Exactly one loop variable over one array parameter: {% for Item in ArrayParameter %}. Two loop variables (for a, b in …), a Context token, a loop variable, a set variable or any expression as the iterable is MJQ_FOR_ITERABLE. So is an iterable whose declared type is not array.

  • No for … else (MJQ_UNSUPPORTED_TAG).

  • Inside the body, loop has these members (any other is MJQ_LOOP_OUTSIDE_FOR):

    MemberValue
    loop.index / loop.index0Position, counting from 1 / from 0
    loop.revindex / loop.revindex0Position from the end, counting to 1 / to 0
    loop.first / loop.lastWhether this is the first / last item
    loop.lengthThe number of items

    loop outside a {% for %} body is MJQ_LOOP_OUTSIDE_FOR.

{% set PriorYear = Year - 1 %}
  • One name, =, one expression. The name is a template variable, not a parameter.
  • The block form ({% set X %}…{% endset %}) and any set without = are MJQ_UNSUPPORTED_TAG.

Blocks may nest up to 64 deep (MAX_BLOCK_NESTING). A deeper if or for is MJQ_SYNTAX.


Context is a reserved object the server fills from the context user and the clock. The caller never sets it. The list is closed: any other Context.<Name>, or Context on its own, is MJQ_UNKNOWN_CONTEXT_TOKEN.

TokenHoldsFilters that fit
Context.UserIDThe context user’s IDsqlString
Context.UserEmailThe context user’s emailsqlString
Context.UserNameThe context user’s namesqlString
Context.NowThe current instant (ISO 8601)sqlDate, sqlString
Context.TodayToday’s datesqlDate, sqlString
Context.StartOfWeekThe first day of this weeksqlDate, sqlString
Context.StartOfMonthThe first day of this monthsqlDate, sqlString
Context.StartOfQuarterThe first day of this quartersqlDate, sqlString
Context.StartOfYearThe first day of this yearsqlDate, sqlString

A Context token in a hole always needs a filter (MJQ_UNFILTERED_VALUE), and the filter must be on its list (MJQ_FILTER_TYPE_MISMATCH). Context tokens are not parameters.

WHERE o.OwnerUserID = {{ Context.UserID | sqlString }}
AND o.CreatedAt >= {{ Context.StartOfMonth | sqlDate }}

A parameter is a name the caller supplies. Every name the template reads is a parameter, except:

  • a {% for %} loop variable, inside its loop;
  • a name assigned by {% set %} anywhere in the template;
  • loop and Context.

Uses are counted in holes (and their filter arguments), {% if %} / {% elif %} conditions, {% for %} iterables, {% set %} values and composition arguments. Parameters are listed in order of first use. Names are case-sensitive.

A parameter is required when at least one of its uses sits outside every {% if %} block and every inline if, and has no default(…). Otherwise it is optional.

  • A use inside any branch of an {% if %} (including elif and else) is conditional.
  • A use in an {% if %} or {% elif %} condition is conditional.
  • <name>.length is a use of the name, like any other: {% if X and X.length > 0 %} leaves X optional.
  • Any use in an inline if is conditional: its condition (a Condition use) and its branches. A parameter used in an inline if is required only when it is also used unconditionally elsewhere.
  • A {% for %} body is not conditional. A loop at the top level makes its iterable required.
  • default(…) makes only the hole it sits in optional. Only a hole has filters, so a use in a {% set %} value or a composition argument outside every {% if %} is required.
TemplateRegionSearch
WHERE r = {{ Region | sqlString }}required
{% if Search %} AND n LIKE {{ Search | sqlLikeContains }} {% endif %}optional
WHERE r = {{ Region | default('WEST') | sqlString }}optional
{% if Region %}…{% endif %} WHERE r = {{ Region | sqlString }}required (the second use)
{% for R in Regions %}…{% endfor %} at the top levelRegions required
{% if Search and Search.length > 2 %}…{% endif %}optional
TOP {{ Region | sqlNumber if Region else 12 }}optional
  • A hole’s type is the type of its last SQL filter (default does not count). {{ x | sqlNumber }} makes x a number.
  • A {% for %} iterable is an array.
  • A branch of an inline if in a hole is typed like a hole, by its last SQL filter (its own or one written after the inline if).
  • <name>.length implies no type: arrays and strings both have a length. When another use types the parameter a number or boolean, which has no length, the .length use is MJQ_PARAMETER_TYPE_CONFLICT (a warning).
  • The first use that implies a type sets it. A later use that implies a different type is MJQ_PARAMETER_TYPE_CONFLICT (a warning); the first type is kept.
  • With no typed use the type is unknown.

Parameters() reports, for each parameter: Name, Filters (every filter name, in order of first appearance), InferredType, IsRequired, DefaultValue (the literal of its first default(…), or null) and Uses (each with its role, position, filters, whether it is conditional, whether it reads .length, whether it has a default, and its SQL context).

A value written into the SQL without an MJ SQL filter is checked by its type. The type is the declared type when the query’s metadata declares the parameter, otherwise the inferred type. A parameter row declares when a person wrote it (DetectionMethod Manual) or it is marked IsServerSet. When neither gives a type, a type recorded by a parameter row that extraction detected (AI or Parser) is used. Such a row only types an unfiltered value: it never decides whether a filter fits, so an edit may change that parameter’s filter.

ValueWithout a filter
A parameter declared IsServerSetAllowed: only the server sets it.
A number or boolean parameterAllowed.
A string, array or date parameterMJQ_UNFILTERED_VALUE (error)
A parameter of any other or unknown typeMJQ_UNFILTERED_UNTYPED (warning)
A {% for %} loop variableMJQ_UNFILTERED_VALUE (error)
A Context tokenMJQ_UNFILTERED_VALUE (error)
A {% set %} variable whose value is a constant (reads no parameter, Context token or loop value)Allowed: the author wrote it.
A {% set %} variable whose value is a -, *, / or % expressionAllowed: the result is a number.
Any other {% set %} variable (for example {% set Nm = Name %}, or Name + '')MJQ_UNFILTERED_VALUE (error): it carries a caller’s value.
A loop member (loop.index, …)Allowed.
<name>.lengthMJQ_UNFILTERED_VALUE (error): pipe it through sqlNumber. Any other SQL filter is MJQ_FILTER_TYPE_MISMATCH.

A composition writes another stored query in as a table source:

{{query:"Category/Path/Name(Arg=Expr, …)"}}
SELECT c.CustomerID, c.Name, s.Total
FROM {{query:"Sales/Reports/Active Customers(Region=Region, Since=Context.StartOfYear)"}} c
JOIN {{query:"Sales/Reports/Customer Totals(Year=Year - 1)"}} s ON s.CustomerID = c.CustomerID
  • The path is in double quotes, directly after query:. Nothing may follow the closing quote.
  • It is the full category path and the query name, joined by /. There is no name-only fallback: a query in a category is always named with its category path. Segments are trimmed; an empty segment is MJQ_COMPOSITION_MALFORMED.
  • Partial paths. A path that matches no query exactly still finds one when it is a whole-segment suffix of exactly one same-named query’s category path: "Demos/Active Users" finds MJ/Demos/Active Users. Such a reference runs, with a warning (MJQ_COMPOSITION_PARTIAL_PATH, logged once per process at run time); saving it is an error whose fix hint names the full path. A path that is not such a suffix, or is the suffix of more than one query’s path, finds nothing. MatchCompositionPath implements the rule for resolvers.
  • Parentheses in names. The argument list is the parenthesized group that ends the path, when it is empty or starts with Name=. Otherwise the parentheses are part of the name: "Finance/Year-End (2024) Summary" names a query, and "Finance/Year-End (2024) Summary(Region='WEST')" passes Region.
  • Because the path ends at the next ", string arguments use single quotes. A quote inside the string can be written two ways, and both are accepted: a backslash (Owner='O\'Brien', the expression grammar’s form, recommended) or a doubled quote (Owner='O''Brien', the SQL form older compositions use). The doubled form works only in composition arguments, not in other expressions.
  • Each argument is Name=Expression, separated by commas. The expression uses the full expression grammar: literals, outer parameters, Context tokens and arithmetic (Year=Year - 1). An argument list that does not parse is MJQ_COMPOSITION_MALFORMED; a refused construct inside a value keeps its own code (Region=upper(R) is MJQ_FUNCTION_CALL).
  • An argument with no names, Context tokens or loop values is static (Region='WEST'). One that reads them is pass-through (Region=Region). CompositionReferences() reports IsStatic and ReferencedNames per argument.
RuleCode
Position. A composition stands only where a table source goes: directly after FROM, JOIN or APPLY, or after a comma in a FROM list. Not in a select list, a WHERE, or after ( or IN.MJQ_COMPOSITION_POSITION
The target must exist at that full path.MJQ_COMPOSITION_NOT_FOUND
The path must be the target’s full path, not a suffix of it. Error on save, warning on refresh.MJQ_COMPOSITION_PARTIAL_PATH
The target must be marked Reusable.MJQ_COMPOSITION_NOT_REUSABLE
Every required parameter of the target must be passed.MJQ_COMPOSITION_MISSING_ARGUMENT
An unpassed target parameter named like an outer parameter (case-insensitive) is refused. Error on save, warning on refresh.MJQ_COMPOSITION_IMPLICIT_ARGUMENT
Every argument name must be a parameter of the target (case-insensitive).MJQ_COMPOSITION_UNKNOWN_ARGUMENT
Each argument is passed once (case-insensitive).MJQ_COMPOSITION_DUPLICATE_ARGUMENT
Compositions must not lead back to the query being saved, or to a query already on the path.MJQ_COMPOSITION_CYCLE

Arguments are explicit, like a function call. No value flows into the composed query by name. A target parameter that is not passed has no value, even when the outer query has a parameter of the same name. That is why an unpassed same-named parameter is refused: write (Region=Region).

Render-then-compose. The composed query is rendered with its own arguments, and the result becomes a CTE of the outer query.


Descriptions of parameters and output fields can live in the SQL, versioned with it.

{#
Orders for one region.
@param {string} Region The sales region code.
@param {date} StartDate Only orders on or after this date.
Leave empty for all dates.
@field OrderID The order's ID.
@field [Order Total] The total in the order's currency.
#}
  • Tags are @param Name description and @field Name description. A line may start with *, JSDoc style.
  • An optional {type} may follow the tag. It is recorded as DeclaredType.
  • A name is a plain name (letters, digits, _, @, #, $), or quoted as "Name", [Name] or `Name` for field names with spaces. An optional - may separate the name from the description.
  • Lines after a tag line, up to the next tag, continue its description. They are joined to it with a newline, each with its indentation (and any leading *) removed.
  • Text before the first tag of the first doc comment that has tags is the query’s summary.
  • A {# #} comment with no tags is an ordinary comment.
SELECT o.OrderID,
o.CustomerName, --: The customer's display name.
o.Total AS OrderTotal, --: The order total.
COUNT(*) AS LineCount --: Lines on the order.
FROM …

A --: comment describes the select item that ends just before it on the same line. The name is found from the alias (expr AS Name, expr Name, Name = expr) or, for a plain column, the column’s name. Quotes are removed ([Total] gives Total). A leading DISTINCT, ALL or TOP n is skipped.

These are reported as MJQ_DOC_TRAILING_AMBIGUOUS and ignored:

  • a --: comment on its own line;
  • one that does not follow a select-list item;
  • one in a nested or later SELECT (only the outermost, first select list names the query’s fields);
  • one after an item with no alias or column name, such as COUNT(*).
  • A header beats a trailing comment. Header entries are read first; a second description of the same name (case-insensitive) is MJQ_DOC_DUPLICATE and ignored.
  • A comment description beats an LLM-written one. An authored description is never overwritten; the LLM fills only what is still empty.
  • An @tag other than @param / @field is MJQ_DOC_UNKNOWN_TAG. A tag with no name is MJQ_DOC_MISSING_NAME. An @param that names no parameter of the query is MJQ_DOC_UNKNOWN_PARAMETER.
  • All doc-comment findings are warnings. DocComments() reports them.

A template with {% if %} blocks renders different SQL, and possibly different output columns, for different values. BranchVariants() returns the parameter assignments to render so that every branch’s SQL is seen:

  • Variant 0, the base: takes the first branch of every {% if %} it can.
  • One variant per {% if %}, in source order: makes that block take a different branch, with every block around it taking the branch that contains it.

So a template with n {% if %} blocks needs n + 1 renders. Each variant gives every parameter a value:

ValueMeaning
SampleA truthy value of the parameter’s type. The caller picks the actual value.
AbsentNo value: the parameter is left out, so it is falsy. Never used for a required parameter.
LiteralExactly this value, to satisfy a comparison such as Mode == 'big' or MinTotal > 0.

Each variant also carries Toggle (which block, from which branch to which), Overrides (the values that differ from the base) and Satisfiable. Satisfiable is false when parameter values alone cannot move the block to another branch, for example a condition on loop.first or a comparison of a Context token. Such a variant repeats the base values.

Conditions are evaluated as Nunjucks would. <name>.length of a Sample is a whole number of at least 1, so X.length > 0 is true and X.length == 0 false for it, while X.length > 5 cannot be known; of an Absent value it is undefined. An inline if in a condition takes the branch its own condition picks, and a comparison with an inline if tries the literals compared against each of its branches. An inline if in a hole adds no variant: both branches write a value at the same place.

Why: when MJ extracts a query’s output fields, it describes each render with the database and takes the union of the output columns. Describing one sample render would miss columns that only some branches produce.


A query’s metadata may declare its parameters and fields as MJ: Query Parameters and MJ: Query Fields rows (child collections of MJ: Queries).

  • Declared rows keep their IDs, matched to extracted rows by (QueryID, Name).
  • Facts are always taken from the SQL and the database server, on every row, declared and Manual ones included: a field’s name casing, order, types, nullability, source and computed flag; whether a parameter exists; the entities and dependencies.
  • Annotations are never overwritten when authored (DetectionMethod Manual, or a non-empty value): descriptions, ComputationDescription, IsSummary / SummaryDescription, and a parameter’s type, required flag, default, sample, validation filters and server-set flag. Doc comments (§9) count as authored.
  • An authored parameter that is missing from the SQL is kept and flagged on the query (ExtractionStatus Warning); a value passed for it is accepted and ignored.
  • An auto-detected parameter that is missing from the SQL is deleted.
  • Saving a new or edited query applies §12 in Save mode: an error refuses the save, with the diagnostics in the save’s result. So does an output that repeats a column name (compared without case).

Severity is for Mode: 'Save' (a new query, or a save that changes its SQL) unless noted. Any other save that re-extracts a query uses Mode: 'Refresh'. Error refuses the save. Warning is reported. Info is a note.

CodeSeverityTriggered byFix
MJQ_UNCLOSED_TAGError{{, {% or {# with no closerClose the tag, or remove the opener.
MJQ_TAG_IN_STRING_LITERALError; also stops a run for a {{ }} value tagA tag inside a SQL string literalPut the tag outside the quotes and use a filter (sqlString for '{{ x }}', sqlLikeContains for '%…%').
MJQ_TAG_IN_QUOTED_IDENTIFIERError; also stops a run for a {{ }} value tagA tag inside […], "…" or `…`Use sqlIdentifier outside any quotes, in place of [{{ x }}], or write the identifier out.
MJQ_EXPRESSION_IN_COMMENTInfoA template tag mentioned inside a SQL comment: inert, never rendered or analyzedWrite template notes in a {# #} comment.
MJQ_UNCLOSED_BLOCKErrorAn if or for with no endif / endfor, or left open when an outer block closesClose the block.
MJQ_UNEXPECTED_TAGErrorelif, else, endif or endfor with no block to belong toRemove the tag, or add its block.
MJQ_SYNTAXErrorA tag body that does not parse; blocks nested more than 64 deepFix the expression; flatten the conditions.
MJQ_UNSUPPORTED_TAGErrorA block tag outside the allow-list; for … else; block setUse only if / elif / else / endif, for / endfor, set.
MJQ_FUNCTION_CALLErrorA function or method callCompute in SQL, or pass a parameter.
MJQ_SUBSCRIPTErrorx[0], x['k']Pass the value as its own parameter, or loop with for.
MJQ_INLINE_IF—No longer reported: the inline if is part of the language. The code is kept and never reused.—
MJQ_MEMBER_ACCESSErrorx.y other than Context.<Token>, loop.<member> and <name>.lengthPass the value as its own parameter.
MJQ_UNSUPPORTED_OPERATORErrorin, is, ~, **, //, ===, !==, list or object literalsUse + - * / %, == != < > <= >=, and, or, not, parentheses.
MJQ_ARITHMETIC_IN_HOLEErrorAn operator inside a {{ }} holeDo the arithmetic in SQL around the hole, or in a {% set %}.
MJQ_HOLE_NOT_A_VALUEErrorA hole that starts from a literalWrite the literal into the SQL.
MJQ_UNKNOWN_FILTERErrorA filter that is not an MJ SQL filter or defaultUse an MJ SQL filter or default.
MJQ_DEFAULT_NOT_LITERALErrordefault(…) with a non-literal or no argumentdefault('value'), default(10), default(true), default(null).
MJQ_LOOP_OUTSIDE_FORErrorloop outside a for body, or an unknown loop memberMove it into the loop; use a listed member.
MJQ_FOR_ITERABLEErrorA for that is not for Item in ArrayParameter, or whose iterable is declared non-arrayLoop over one array parameter with one variable.
MJQ_UNFILTERED_VALUEErrorA string, array or date parameter, a loop variable or a Context token in a hole with no SQL filterAdd the filter for its type.
MJQ_UNFILTERED_UNTYPEDWarningA value with no filter whose type is not knownAdd the SQL filter, or declare it number or boolean.
MJQ_UNKNOWN_CONTEXT_TOKENErrorContext.<Name> not in the list, or bare ContextUse one of the nine tokens.
MJQ_FILTER_TYPE_MISMATCHError; Warning in RefreshThe last SQL filter does not format the declared type, or is not on a Context token’s listUse the filter for the type.
MJQ_PARAMETER_TYPE_CONFLICTWarningTwo uses of one parameter imply different typesUse one type and its filter at every use.
MJQ_COMPOSITION_MALFORMEDErrorA {{query:…}} whose path or arguments do not parse{{query:"Category/Path/Name(Arg=Value, …)"}}, path in double quotes.
MJQ_COMPOSITION_POSITIONErrorA composition outside a table-source positionFROM {{query:"…"}} alias or JOIN {{query:"…"}} alias ON ….
MJQ_COMPOSITION_NOT_FOUNDErrorNo query at that full pathGive the full category path and name.
MJQ_COMPOSITION_PARTIAL_PATHError; Warning in RefreshThe path is a whole-segment suffix of the target’s full path (the resolver returned FullPath)Write the full path the hint names.
MJQ_COMPOSITION_NOT_REUSABLEErrorThe target is not marked ReusableMark it Reusable, or copy its SQL in.
MJQ_COMPOSITION_MISSING_ARGUMENTErrorA required target parameter is not passedPass it: (Name=Name) or (Name='value').
MJQ_COMPOSITION_IMPLICIT_ARGUMENTError; Warning in RefreshAn unpassed target parameter named like an outer parameterPass it as (Name=Name), or rename one.
MJQ_COMPOSITION_UNKNOWN_ARGUMENTErrorAn argument the target does not declareUse one of the target’s parameters.
MJQ_COMPOSITION_DUPLICATE_ARGUMENTErrorThe same argument twicePass each argument once.
MJQ_COMPOSITION_CYCLEErrorCompositions lead back to a query already being composedRemove one composition in the loop.
MJQ_COMPOSITION_UNCHECKEDInfoCompositions present, but no resolver was givenValidate with ResolveComposition.
MJQ_DOC_UNKNOWN_TAGWarningAn @tag other than @param / @fieldUse @param or @field.
MJQ_DOC_MISSING_NAMEWarning@param / @field with no name@param Name description.
MJQ_DOC_DUPLICATEWarningA second description of one nameDescribe each once.
MJQ_DOC_UNKNOWN_PARAMETERWarning@param naming no parameter of the queryFix the name, or remove it.
MJQ_DOC_TRAILING_AMBIGUOUSWarningA --: comment not attachable to exactly one outer select itemPut it at the end of an aliased item’s line, or use @field.

Where they come from: lexing, structure and expression-syntax codes (and MJQ_COMPOSITION_MALFORMED) are in QueryTemplate.Diagnostics; Validate() returns those plus the value, type, parameter and composition codes; DocComments() returns the MJQ_DOC_* codes.

Before a saved query is rendered, it is validated in Refresh mode (without a composition resolver). These errors are authoring rules that a query saved before the language existed may rely on, so they never stop a run: MJQ_UNFILTERED_VALUE, MJQ_UNFILTERED_UNTYPED, MJQ_ARITHMETIC_IN_HOLE, MJQ_HOLE_NOT_A_VALUE, MJQ_DEFAULT_NOT_LITERAL, MJQ_FOR_ITERABLE, MJQ_FILTER_TYPE_MISMATCH, MJQ_PARAMETER_TYPE_CONFLICT and MJQ_COMPOSITION_POSITION. Every other error refuses the run, so a construct outside the language (an unsupported tag, a function call, attribute access other than .length, a subscript, an unknown filter, a malformed tag) never reaches Nunjucks.

A {{ }} value tag inside a SQL string literal (MJQ_TAG_IN_STRING_LITERAL, such as WHERE Region = '{{ Region }}') also refuses the run, however old the query. The value is pasted between the quotes without escaping, so a value that contains a quote can change the statement. The refusal names the query (or, for a composed query, its full path), each tag with its parameter, line and column, and the exact fix: replace '{{ Region }}' with {{ Region | sqlString }} and drop the quotes (sqlString adds the quotes and escapes the value), use sqlLikeContains / sqlLikeBegins / sqlLikeEnds for '%{{ x }}%' / '{{ x }}%' / '%{{ x }}', or the filter for the value’s type (sqlNumber, sqlDate, sqlBoolean). mj query refresh --all --dry-run lists every saved query with the problem as an Error. The run is authorized first (the query’s run permissions, and those of each query it composes), so a caller who may not run the query gets the usual not-found message, never these details. A composition tag ('{{query:"…"}}'), a block or a {# #} comment inside a literal pastes no value and does not stop a run.

A {{ }} value tag inside a quoted identifier (MJQ_TAG_IN_QUOTED_IDENTIFIER: [{{ Col }}] on SQL Server, "{{ Col }}" on PostgreSQL, `{{ Col }}` on MySQL and Databricks, as each dialect’s lexer defines quoted identifiers) refuses the run in the same way, with the same authorization first. The value is pasted inside the identifier quotes without escaping, so a value that contains the closing quote character (], " or `) can end the identifier and change the statement. The fix: replace [{{ Col }}] with {{ Col | sqlIdentifier }} and drop the surrounding brackets or quotes; sqlIdentifier quotes the name for the dialect and refuses a value that is not a plain name (letters, digits and underscores, not starting with a digit). A composition tag, a block or a {# #} comment inside an identifier does not stop a run. When a query has tags of both kinds, one refusal lists every tag and gives both fixes; refresh reports each as an Error.

-- MJQ_UNCLOSED_TAG
WHERE Region = {{ Region | sqlString
WHERE Region = {{ Region | sqlString }}
-- MJQ_TAG_IN_STRING_LITERAL
WHERE Name LIKE '%{{ Search }}%'
WHERE Name LIKE {{ Search | sqlLikeContains }}
-- MJQ_TAG_IN_QUOTED_IDENTIFIER
SELECT [{{ Column }}] FROM dbo.Orders
SELECT {{ Column | sqlIdentifier }} FROM dbo.Orders
-- MJQ_UNCLOSED_BLOCK
{% if Search %} AND Name LIKE {{ Search | sqlLikeContains }}
{% if Search %} AND Name LIKE {{ Search | sqlLikeContains }} {% endif %}
-- MJQ_UNEXPECTED_TAG
WHERE 1 = 1 {% endif %}
WHERE 1 = 1
-- MJQ_SYNTAX
{% if !Active %}…{% endif %}
{% if not Active %}…{% endif %}
-- MJQ_UNSUPPORTED_TAG
{% include "Common/Filters" %}
FROM {{query:"Common/Filters"}} f
-- MJQ_FUNCTION_CALL
{{ upper(Name) | sqlString }}
UPPER({{ Name | sqlString }})
-- MJQ_SUBSCRIPT
{{ Regions[0] | sqlString }}
{{ FirstRegion | sqlString }}
-- MJQ_MEMBER_ACCESS
{{ Filter.Region | sqlString }}
{{ Region | sqlString }}
{% if Regions.size > 0 %}…{% endif %}
{% if Regions and Regions.length > 0 %}…{% endif %}
-- MJQ_UNSUPPORTED_OPERATOR
{% if Status in ['Open', 'Held'] %}…{% endif %}
{% if Status == 'Open' or Status == 'Held' %}…{% endif %}
-- MJQ_ARITHMETIC_IN_HOLE
WHERE FiscalYear = {{ Year - 1 | sqlNumber }}
WHERE FiscalYear = {{ Year | sqlNumber }} - 1
-- MJQ_HOLE_NOT_A_VALUE
WHERE Status = {{ 'Open' | sqlString }}
WHERE Status = 'Open'
-- MJQ_UNKNOWN_FILTER
WHERE Name = {{ Name | safe }}
WHERE Name = {{ Name | sqlString }}
-- MJQ_DEFAULT_NOT_LITERAL
{{ Region | default(HomeRegion) | sqlString }}
{{ Region | default('WEST') | sqlString }}
-- MJQ_LOOP_OUTSIDE_FOR
WHERE Rank = {{ loop.index }}
{% for T in Tags %} … {{ loop.index }} … {% endfor %}
-- MJQ_FOR_ITERABLE
{% for Code, Label in Pairs %}…{% endfor %}
{% for Code in Codes %}…{% endfor %}
-- MJQ_UNFILTERED_VALUE (Name declared string; with no known type it is the warning MJQ_UNFILTERED_UNTYPED)
WHERE Name = {{ Name }}
WHERE Name = {{ Name | sqlString }}
-- MJQ_UNKNOWN_CONTEXT_TOKEN
WHERE TenantID = {{ Context.TenantID | sqlString }}
WHERE TenantID = {{ TenantID | sqlString }} -- a parameter, declared IsServerSet if the server sets it
-- MJQ_FILTER_TYPE_MISMATCH (MinTotal declared number)
WHERE Total >= {{ MinTotal | sqlString }}
WHERE Total >= {{ MinTotal | sqlNumber }}
-- MJQ_COMPOSITION_MALFORMED
FROM {{query:Sales/Active Customers}} c
FROM {{query:"Sales/Active Customers"}} c
-- MJQ_COMPOSITION_POSITION
WHERE ID IN {{query:"Sales/Active Customer IDs"}}
WHERE ID IN (SELECT ID FROM {{query:"Sales/Active Customer IDs"}} a)
-- MJQ_COMPOSITION_NOT_FOUND
FROM {{query:"Active Customers"}} c
FROM {{query:"Sales/Reports/Active Customers"}} c
-- MJQ_COMPOSITION_NOT_REUSABLE
FROM {{query:"Sales/Scratch/Draft"}} d -- Draft is not Reusable
FROM {{query:"Sales/Reports/Approved"}} d -- a Reusable query
-- MJQ_COMPOSITION_MISSING_ARGUMENT (Region required by the target)
FROM {{query:"Sales/Active Customers"}} c
FROM {{query:"Sales/Active Customers(Region=Region)"}} c
-- MJQ_COMPOSITION_IMPLICIT_ARGUMENT (both queries have an optional Since)
FROM {{query:"Sales/Active Customers(Region=Region)"}} c
FROM {{query:"Sales/Active Customers(Region=Region, Since=Since)"}} c
-- MJQ_COMPOSITION_UNKNOWN_ARGUMENT
FROM {{query:"Sales/Active Customers(Region=Region, Zone=3)"}} c
FROM {{query:"Sales/Active Customers(Region=Region)"}} c
-- MJQ_COMPOSITION_DUPLICATE_ARGUMENT
FROM {{query:"Sales/Active Customers(Region='W', region='E')"}} c
FROM {{query:"Sales/Active Customers(Region='W')"}} c
-- MJQ_COMPOSITION_CYCLE (saving Sales/Me; Sales/A composes Sales/B, which composes Sales/Me)
FROM {{query:"Sales/A"}} a
FROM dbo.Orders a -- break the loop

import { QueryTemplate, MJQueryDiagnosticCodes, MatchCompositionPath } from '@memberjunction/sql-parser';
import { GetQueryDialect } from '@memberjunction/sql-dialect';
const template = QueryTemplate.Parse(sqlText, GetQueryDialect('sqlserver'));
const check = template.Validate({
Mode: 'Save', // or 'Refresh' for a query already saved
DeclaredParameters: [
{ Name: 'Region', Type: 'string' },
{ Name: 'TenantID', Type: 'string', IsServerSet: true },
],
QueryPath: 'Sales/Reports/Regional Orders', // this query's full path, for cycle detection
ResolveComposition: fullPath => {
const match = MatchCompositionPath(categoryPathOf(fullPath), nameOf(fullPath), allQueries);
if (match.Kind !== 'Exact' && match.Kind !== 'PartialPath') return null;
const query = match.Target;
return {
Exists: true,
FullPath: match.Kind === 'PartialPath' ? match.FullPath : undefined, // reports a partial path
Reusable: query.Reusable,
Parameters: query.Parameters.map(p => ({ Name: p.Name, IsRequired: p.IsRequired })),
Compositions: query.ComposedPaths, // optional: full paths it composes, for cycles
};
},
});
if (!check.Value.IsValid) {
for (const d of check.Diagnostics.filter(d => d.Severity === 'Error')) {
console.log(`${d.Code} at ${d.Span.Start}: ${d.Message} ${d.FixHint ?? ''}`);
}
}
OptionDefaultEffect
Mode'Save''Refresh' makes MJQ_FILTER_TYPE_MISMATCH, MJQ_COMPOSITION_IMPLICIT_ARGUMENT and MJQ_COMPOSITION_PARTIAL_PATH warnings.
DeclaredParametersnone{ Name, Type, IsServerSet? } from the query’s metadata. Type drives the filter-fit and unfiltered rules and the for iterable check; IsServerSet lets a value go unfiltered. Their names also count as outer names for MJQ_COMPOSITION_IMPLICIT_ARGUMENT.
KnownParameterTypesnone{ Name, Type } for parameters the metadata does not declare, such as detected parameter rows. Used only by the unfiltered-value rule, after DeclaredParameters and the inferred type; never by the filter-fit or for iterable rules.
ContextTokensthe nine tokensThe allowed Context tokens.
ResolveCompositionnone(fullPath) => { Exists, Reusable, Parameters: [{ Name, IsRequired }], FullPath?, Compositions? } | null; FullPath is the target’s own full path when it was found by a partial path. Without it, targets are not checked, MJQ_COMPOSITION_UNCHECKED is reported, and the result is not Complete. Position and duplicate-argument checks run either way.
QueryPathnoneThe full path of the query being validated, so a composition that leads back to it is a cycle.

The result is { Value: { IsValid, ErrorCount, WarningCount }, Complete, Diagnostics }. Each diagnostic has Code, Severity, Span (UTF-16 offsets into the text, end exclusive), Message and FixHint.


{#
Orders for one region and year, with optional filters.
@param {string} Region The sales region code, such as 'WEST'.
@param {number} Year The order year.
@param {date} StartDate Only orders on or after this date.
@param {array} Statuses Order statuses to include.
@param {string} Search Text to find in the customer name.
@param {number} MinTotal The smallest order total to include
when no search text is given.
@param {array} Tags Order tags; an order matches if it has any of them.
@field OrderID The order's ID.
#}
{% set PriorYear = Year - 1 %}
SELECT o.OrderID,
o.CustomerName, --: The customer's display name.
o.Total AS OrderTotal, --: The order total in the order's currency.
{{ PriorYear }} AS TargetYear,
t.TargetAmount
FROM dbo.Orders o
JOIN {{query:"Sales/Targets/Regional Targets(Region=Region, Year=Year - 1)"}} t
ON t.Region = o.Region
WHERE o.Region = {{ Region | sqlString }}
AND YEAR(o.OrderDate) = {{ Year | sqlNumber }}
AND o.OrderDate >= {{ StartDate | default('2020-01-01') | sqlDate }}
AND o.OwnerUserID = {{ Context.UserID | sqlString }}
{%- if Statuses %}
AND o.Status IN {{ Statuses | sqlIn }}
{%- endif %}
{%- if Search %}
AND o.CustomerName LIKE {{ Search | sqlLikeContains }}
{%- elif MinTotal > 0 %}
AND o.Total >= {{ MinTotal | sqlNumber }}
{%- endif %}
{%- if Tags %}
AND o.OrderID IN (
SELECT ot.OrderID FROM dbo.OrderTags ot WHERE
{%- for Tag in Tags %}
{% if not loop.first %}OR {% endif %}ot.Tag = {{ Tag | sqlString }}
{%- endfor %}
)
{%- endif %}
ORDER BY o.OrderDate DESC

What it uses: a doc-comment header with a summary and a continuation line, trailing --: descriptions, a numeric {% set %} written unfiltered, a composition with a pass-through and an arithmetic argument, default, a Context token, if / elif, a for loop with loop.first, and whitespace control.

Parameters() reports, in order of first use (PriorYear, Tag, loop and Context.UserID are not parameters):

NameInferredTypeIsRequiredDefaultValueFiltersWhy
YearnumbertruenullsqlNumberUsed outside every if (the set, the composition argument, a hole).
RegionstringtruenullsqlStringUsed outside every if.
StartDatedatefalse'2020-01-01'default, sqlDateIts only use has default.
StatusesarrayfalsenullsqlInUsed only in an if condition and body.
SearchstringfalsenullsqlLikeContainsOnly inside an if.
MinTotalnumberfalsenullsqlNumberOnly in an elif condition and body.
Tagsarrayfalsenull(none)The loop iterable, inside an if.

DocComments() reports the summary “Orders for one region and year, with optional filters.”, the seven @param descriptions (with types), and three fields: OrderID (header), CustomerName and OrderTotal (trailing).

BranchVariants() returns five variants for the four {% if %} blocks:

VariantTogglesOverrides from the baseSatisfiable
0 (base)every parameter Sampletrue
1if Statuses to its elseStatuses: Absenttrue
2if Search to the elifSearch: Absent, MinTotal: Literal 1true
3if Tags to its elseTags: Absenttrue
4if not loop.firstnonefalse (the condition reads a loop value)

With a resolver that reports Sales/Targets/Regional Targets as Reusable with required Region and Year, Validate({ Mode: 'Save', ResolveComposition }) finds no errors. If that target also had an optional Search parameter, the save would be refused with MJQ_COMPOSITION_IMPLICIT_ARGUMENT until the composition passes Search=Search (or the parameter is renamed).