Skip to main content

MRQL Query Language

MRQL (Mahresources Query Language) is a structured query language for searching across resources, notes, and groups with precise field-level filtering, ordering, and pagination.

When to Use MRQL

Use caseBest tool
Quick keyword searchGlobal search (Ctrl+K)
Filter by one or two fieldsEntity list filters
Complex multi-field conditionsMRQL
Date range + tag + file size combinationsMRQL
Reusable cross-entity queriesMRQL + saved MRQL queries
Raw SQL with joins and aggregatesSaved Queries (SQL)

Accessing MRQL

Navigate to /mrql in the web UI. The page provides:

  • A syntax-highlighted editor with autocompletion (Ctrl+Space)
  • Real-time validation with inline error markers
  • Run button or Ctrl+Enter to execute
  • Save to persist a query for later reuse
  • Saved Queries panel listing all stored queries
  • Recent Queries history (the last 20, saved to your account and shared across browsers)

Natural-Language Generation

When DEEPSEEK_API_KEY is configured, the /mrql editor can draft MRQL from a "Describe results" prompt. The server sends only the text you type and syntax-only MRQL instructions to DeepSeek. It does not send local tag lists, category names, note types, resource categories, saved queries, or database contents.

Generated MRQL is parsed, validated, and linted locally, then shown with an explanation. It is not executed until you press Run. Generation is CSRF-protected and requires write access when authentication is enabled. DEEPSEEK_MODEL (default deepseek-v4-pro) and DEEPSEEK_TIMEOUT (default 20s) tune the call, and generation is limited to 10 requests per minute per client address, beyond which the editor reports HTTP 429.

Filtering List Pages

The /resources, /notes, and /groups list pages carry a single-line MRQL filter bar above the list. Type a bare filter expression. The entity type is implied by the page, so you write only the conditions:

tags = "vacation" AND created > -30d
notes IS EMPTY AND fileSize > 10mb
descendants.category = "Archive"

Submitting sets ?mrql=<expr> on the same list URL and ANDs the filter with every sidebar filter, the current sort, and pagination. The bar accepts the filter (WHERE-clause) grammar only. LIMIT, OFFSET, GROUP BY, SCOPE, and $name parameters are rejected, and you do not write type (the page sets it). The SIMILAR TO resource(N) predicate is allowed.

The bar additionally accepts a trailing ORDER BY, which it applies as the list's own sort rather than passing to the filter; a sort key the list cannot express is dropped. ORDER BY is rejected on the mrql= API parameter and on --mrql.

An invalid expression fails closed: the page renders an error banner and zero results, never the unfiltered list, so a broken filter cannot widen a following bulk action.

Each bar has an Edit in MRQL editor link that opens /mrql?q=type = <entity> AND (<expr>), graduating the current filter to the full editor where ordering, limits, grouping, and saving become available.

JSON API

The list endpoints accept the same filter grammar as an mrql query parameter: GET /v1/resources, GET /v1/notes, and GET /v1/groups take mrql=<expr>. An invalid expression returns HTTP 400 with a positioned error.

CLI

mr resources list, mr notes list, and mr groups list accept --mrql "<expr>", applying the same filter grammar (type implied) alongside the other list flags:

mr resources list --mrql 'tags = "vacation" AND created > -30d'

Global search (Ctrl/Cmd+K) recognizes MRQL:

  • Run a query. Typing a valid MRQL query surfaces a pinned Run MRQL query row above the search results. Selecting it opens /mrql?q=<query>, which runs the query automatically. The row appears only when the query passes validation, so ordinary search terms are unaffected.
  • Open a saved query. A saved MRQL query is findable by its name or description. Selecting it opens /mrql?saved=<id>, loading it into the editor. A parameterized query focuses its first empty parameter input instead of running immediately.

Syntax Reference

Basic Structure

[type = "resource|note|group" AND] <conditions> [GROUP BY <field> [<aggregates>] [HAVING <aggregate-conditions>]] [ORDER BY <field> [ASC|DESC]] [LIMIT <n>] [OFFSET <n>]

Conditions are field-value comparisons joined with AND, OR, and NOT.

Entity Selector

Use type = "<value>" anywhere in the query to target a specific entity type:

type = resource AND name ~ "photo"
type = note AND tags = "todo"
type = group AND category = 3

Valid values: resource, note, group.

Omit the type selector entirely to search all entity types at once (cross-entity mode).

Fields

Common fields (available on all entity types):

FieldTypeDescription
idnumberEntity ID
namestringDisplay name
descriptionstringDescription or body text
createddatetimeCreation timestamp
updateddatetimeLast-updated timestamp
tagsrelationAssociated tags (match by name)
guidstringStable UUIDv7 identifier
meta.<key>string/numberDynamic metadata value

Resource-only fields:

FieldTypeDescription
groups / grouprelationAssociated groups (match by name)
ownerrelationOwner group (match by ID or by name, supports traversal)
categorynumberResource category ID. A name here matches nothing rather than erroring
contentTypestringMIME type (e.g. image/png)
fileSizenumberFile size in bytes (supports kb, mb, gb units)
widthnumberImage/video width in pixels
heightnumberImage/video height in pixels
originalNamestringOriginal filename at upload
originalLocationstringOriginal path or source location at upload
hashstringContent hash
notesrelationLinked notes (match by name)
similarImagesrelationResources sharing an exact DHash. Query as similarImages IS [NOT] EMPTY

Note-only fields:

FieldTypeDescription
groups / grouprelationAssociated groups (match by name)
ownerrelationOwner group (match by ID or by name, supports traversal)
noteTypenumberNote type ID. A name here matches nothing rather than erroring
startDatedatetimeEvent start date
endDatedatetimeEvent end date
sharedbooleanWhether the note has a share token. Only = true / != false and their inverses
resourcesrelationLinked resources (match by name)

Group-only fields:

FieldTypeDescription
categorynumberGroup category ID. A name here matches nothing rather than erroring
urlstringAssociated URL
parentrelationParent group (match by ID or by name)
childrenrelationChild groups (match by name)
resourcesrelationRelated resources (match by name)
notesrelationRelated notes (match by name)

Relation fields also support .count comparisons against a non-negative integer -- tags.count = 0, resources.count >= 100 -- with =, !=, >, >=, <, <=, in filters and ORDER BY. owner and parent are single references and cannot be counted (use IS NULL).

Comparison Operators

OperatorMeaningExample
=Equal (case-insensitive for strings)name = "Report"
!=Not equalcontentType != "application/pdf"
>Greater thanfileSize > 1mb
>=Greater than or equalcreated >= -30d
<Less thanwidth < 800
<=Less than or equalfileSize <= 500kb

String comparisons with = and != are always case-insensitive.

Pattern Matching

The ~ operator performs a contains match by default. Without wildcards, the value is matched anywhere in the field:

contentType ~ "image"     # matches "image/png", "image/jpeg", etc.
name ~ "report" # matches "Q1 Report", "Annual reporting", etc.

Use * for any sequence of characters and ? for a single character to create anchored patterns:

name ~ "project*"         # starts with "project" (no implicit wrapping)
contentType ~ "image/*" # matches "image/png" but not "text/image"
originalName ~ "*.jpg" # ends with .jpg
name ~ "Q?-report" # Q1-report, Q2-report, etc.
Wildcard behavior

When your value contains no * or ? wildcards, ~ automatically wraps it with * on both sides, making it a substring/contains match. As soon as you include any wildcard, the value is used as-is, giving you precise control over anchoring.

The !~ operator is the negated form:

name !~ "draft*"          # does not start with "draft"
contentType !~ "image" # does not contain "image"

Both ~ and !~ are case-insensitive.

Regex Matching (PostgreSQL only)

On PostgreSQL deployments, ~* and !~* match against a case-insensitive POSIX regular expression. Unlike ~, the pattern is a real regex -- no */? wildcard shortcuts, no implicit anchoring or %...% wrapping:

name ~* "^IMG_[0-9]{4}\.(jpe?g|png)$"    # names like IMG_0421.jpg
originalName !~* "\.(tmp|bak)$" # not ending in .tmp or .bak

Allowed on string and meta.<key> fields (and string traversal leaves like owner.name). Not on numeric, datetime, or relation fields. On SQLite (no native regex) ~*/!~* return an error. An invalid pattern surfaces the database's "invalid regular expression" message.

Ranges -- BETWEEN

BETWEEN matches an inclusive range on both ends; NOT BETWEEN is the complement. It works wherever >=/<= do -- dates, numbers (including size units), strings (lexicographic), and meta.<key>. Bounds can be any value, including relative dates, NOW(), and $params:

created BETWEEN "2024-01-01" AND "2024-06-30"
fileSize NOT BETWEEN 1mb AND 10mb
created BETWEEN -30d AND NOW()

f BETWEEN a AND b is exactly (f >= a AND f <= b).

Existence Checks

description IS EMPTY          # description is empty string or null
description IS NOT EMPTY # description has a non-empty value
meta.rating IS NULL # meta key not present
meta.rating IS NOT NULL # meta key is present
tags IS EMPTY # no tags associated

Set Operators

contentType IN ("image/png", "image/jpeg", "image/webp")
tags IN ("urgent", "review", "blocked")
contentType NOT IN ("video/mp4", "video/webm")

Search indexed text across the entity's name, description, and content fields:

TEXT ~ "quarterly earnings"
type = note AND TEXT ~ "retrospective action items"

On SQLite the search uses the FTS5 index; on PostgreSQL it matches a tsvector column via plainto_tsquery. Both backends AND the search terms together, so every word must match, rather than matching the value as an exact phrase. When the full-text index is unavailable (for example the server was started with -skip-fts), TEXT ~ falls back to a case-insensitive substring match on name and description.

Boolean Logic

Combine conditions with AND, OR, and NOT. Use parentheses for explicit grouping.

Operator precedence (highest to lowest):

  1. NOT
  2. AND
  3. OR
# AND binds tighter than OR:
type = resource AND (tags = "photo" OR tags = "video")

# NOT applies to the next expression:
type = resource AND NOT tags = "archived"

# Explicit grouping:
(type = resource OR type = note) AND created > -7d

Case Sensitivity

All comparisons are case-insensitive. name = "Report" matches "report", "REPORT", and "Report". Pattern matching with ~ is also case-insensitive.

String Escaping

Strings are double-quoted. Use \" to include a literal quote and \\ for a backslash:

name = "O\"Brien"
originalName ~ "C:\\Users\\*"

Relative Dates

Use relative date literals in datetime comparisons to express time offsets from the current moment:

LiteralMeaning
-7d7 days ago
-2w2 weeks ago
-3m3 months ago
-1y1 year ago
-30d30 days ago
created > -7d                  # created in the last 7 days
updated < -1y # not updated in over a year
created >= -3m AND created <= -1m # created 1-3 months ago

Date Functions

Use built-in functions for date boundaries:

FunctionReturns
NOW()Current timestamp
START_OF_DAY()Midnight of the current day
START_OF_WEEK()Midnight of the current week's Monday
START_OF_MONTH()Midnight of the first day of the current month
START_OF_YEAR()Midnight of January 1 of the current year
created >= START_OF_WEEK()     # created this week
updated < START_OF_MONTH() # not updated this month
created >= START_OF_YEAR() # created this year

File Size Units

Numeric values for fileSize accept unit suffixes (case-insensitive):

SuffixMultiplier
kb1,024 bytes
mb1,048,576 bytes
gb1,073,741,824 bytes
fileSize > 10mb
fileSize < 500kb
fileSize >= 1gb

Ordering and Pagination

ORDER BY <field> [ASC|DESC]
LIMIT <n>
OFFSET <n>

Multiple ORDER BY columns are supported:

type = resource ORDER BY created DESC LIMIT 20
type = note ORDER BY updated ASC, name ASC LIMIT 50 OFFSET 100

With no ORDER BY, no ordering is sent to the database and the row order is undefined. Paging with OFFSET is only stable when the query names an ORDER BY.

A query with no LIMIT does not return everything. The server applies a default limit, set by -mrql-default-limit / MRQL_DEFAULT_LIMIT (500 by default) and editable at runtime as mrql_default_limit, and reports that it did so in the response as default_limit_applied and applied_limit. Write an explicit LIMIT when the count matters.

Each MRQL statement runs under a deadline set by -mrql-query-timeout / MRQL_QUERY_TIMEOUT, 10 seconds by default and editable at runtime as mrql_query_timeout. A query that exceeds it fails and is recorded as a warning at /logs with entity type mrql. In cross-entity mode a timed-out entity branch is reported as a warning in the response instead of failing the whole query.

Random Order -- RANDOM()

ORDER BY RANDOM() returns rows in a random order -- handy for a random sample with LIMIT:

type = resource AND tags IS EMPTY ORDER BY RANDOM() LIMIT 20
type = note ORDER BY name, RANDOM() # random tiebreak within equal names

RANDOM() takes no ASC/DESC and cannot be combined with GROUP BY. Because the order is re-rolled on every request, paging past the first page (LIMIT/OFFSET) draws a fresh random sample that can repeat earlier rows -- this is the expected "give me N random items" behavior, not stable pagination.

Random sampling must examine every matching row before applying LIMIT. A small limit bounds the result size, but does not bound the scan. MRQL's Explain output flags this when RANDOM() is the first sort key.

For PostgreSQL queries ordered only by RANDOM() with a limit, MRQL selects IDs and their random keys first, then fetches the selected entities in the same SQL statement. This keeps descriptions and other payload columns out of the sample sort and preserves filtering, scope, offset, and random order. Metadata filters can still require a full scan unless a suitable index exists.

For a frequently used numeric filter such as meta.score = 10, add score / Numeric under Indexed metadata keys in the relevant resource-category editor. See indexed metadata keys for background build status, supported filters, and CLI/API configuration.

Choose indexes for metadata keys queried frequently; each adds storage and maintenance on writes. Managed numeric indexes handle long metadata values separately so adding an index does not restrict what can be stored. The database chooses whether to use an index based on its statistics and the number of matches; Explain with a native plan shows that choice. An index on raw JSON or text extraction alone does not match MRQL's numeric comparison expressions.

Relevance Order -- RANK

ORDER BY RANK sorts full-text results by relevance, most relevant first (no direction needed; RANK DESC reverses to least-relevant first):

type = note AND TEXT ~ "kubernetes migration" ORDER BY RANK LIMIT 10

RANK requires exactly one TEXT ~ predicate (its term defines the relevance), a single entity type, and no GROUP BY. It errors if the server was started with full-text search disabled (-skip-fts) -- a relevance sort over the non-indexed fallback would be meaningless.

Scope

The SCOPE clause filters query results to entities within a group's ownership subtree. Place SCOPE after the filter expression and before GROUP BY:

type = "resource" SCOPE 42 ORDER BY created LIMIT 10
type = "note" SCOPE "My Project"

Scope by ID

SCOPE <number> filters to the group with that ID and all its descendants:

type = resource SCOPE 42

This returns all resources owned by group 42 or any group underneath it in the hierarchy.

Scope by Name

SCOPE "group name" looks up the group by name (case-insensitive):

type = resource SCOPE "Vacation Photos"

If multiple groups share the same name, MRQL returns an error listing all matches with their IDs so you can switch to SCOPE <id>.

Scope with GROUP BY

Scope is applied before grouping:

type = resource SCOPE 42 GROUP BY contentType COUNT()

No Scope

Omitting SCOPE or using SCOPE 0 returns all matching entities regardless of ownership.

Entity Types

  • Resources and Notes: Scope filters by owner_id -- entities owned by groups in the subtree.
  • Groups: Scope filters by id -- the scoped group itself and all its descendants.

GROUP BY and Aggregation

Group results by field values with optional aggregate functions. GROUP BY requires an explicit entity type (type = "resource", type = "note", or type = "group").

type = "<entity>" [<conditions>] GROUP BY <field>[, <field>...] [<aggregates>] [ORDER BY ...] [LIMIT <n>]

Two Modes

ModeTriggerReturns
AggregatedGROUP BY with aggregate functionsFlat rows with computed values
BucketedGROUP BY without aggregate functionsEntity rows organized into groups

Aggregate Functions

FunctionArgumentField typesOutput key
COUNT()nonen/acount
SUM(field)requirednumeric, metasum_{field}
AVG(field)requirednumeric, metaavg_{field}
MIN(field)requirednumeric, datetime, metamin_{field}
MAX(field)requirednumeric, datetime, metamax_{field}

Aggregate functions are case-insensitive (count(), COUNT(), Count() all work).

Aggregated Mode

When aggregate functions are present, GROUP BY returns flat rows of computed values, one row per unique combination of the grouped fields.

type = resource GROUP BY contentType COUNT()
type = resource GROUP BY contentType COUNT() SUM(fileSize) AVG(fileSize)
type = resource GROUP BY contentType COUNT() ORDER BY count DESC
type = resource GROUP BY meta.source COUNT()
type = note GROUP BY owner, noteType COUNT()
type = resource AND fileSize > 10mb GROUP BY contentType MIN(fileSize) MAX(fileSize)

Each result row includes the grouped field values plus one key per aggregate function (e.g., count, sum_fileSize, avg_fileSize).

The response also carries a columns array: the column names in the order the query wrote them, grouped fields first and then aggregates. The /mrql results table, mr mrql run and mrql export --format csv all follow it, so the three agree. Read columns rather than the key order of a rows entry -- a JSON object carries no order, and enumerating a row's keys gives you the parser's order, not the query's.

Add HAVING after the aggregate list to keep only buckets whose aggregates match; conditions use aggregate functions (never plain fields) and combine with AND / OR / NOT:

type = resource GROUP BY hash COUNT() HAVING COUNT() > 1 ORDER BY count DESC
type = resource GROUP BY tags COUNT() HAVING SUM(fileSize) > 1gb AND COUNT() >= 10

Datetime fields can be bucketed by calendar period with .day, .week (Monday start), .month, or .year -- valid in GROUP BY (both modes) and its ORDER BY only:

type = note GROUP BY created.month COUNT() ORDER BY created.month ASC

Bucketed Mode

When no aggregate functions are specified, GROUP BY returns entities organized into named buckets, one bucket per unique value of the grouped field.

type = resource GROUP BY contentType LIMIT 5
type = resource GROUP BY meta.camera_model LIMIT 10
type = note GROUP BY owner ORDER BY name ASC LIMIT 3

In bucketed mode, LIMIT applies per bucket (maximum items per group), not to the total result set.

A bucketed response carries a keyColumns array, the counterpart of columns above: the group-by key names in the order the query wrote them, matching the leading columns of mrql export --format csv. Each bucket's key object may carry entries keyColumns does not name -- a bucket keyed on a relation field also gets <field>_id so two same-named groups stay distinguishable -- so read keyColumns for order and the key object for values.

ORDER BY with GROUP BY

  • Aggregated mode: ORDER BY can reference group fields or aggregate output keys (count, sum_fileSize, etc.)
  • Bucketed mode: ORDER BY applies to items within each bucket

Constraints

  • GROUP BY requires type = "resource|note|group" (cross-entity grouping is not supported)
  • Traversal paths are supported: owner.name, owner.parent.name, owner.meta.key, etc.
  • Maximum 1000 buckets in bucketed mode

Traversal

MRQL supports filtering by properties of related groups through dotted field paths. Traversal works on:

  • Resources and notes: owner accesses the owner group
  • Groups: parent accesses the parent group, children accesses child groups

Single-Level Traversal

type = resource AND owner.name = "Project Alpha"
type = resource AND owner.tags = "active"
type = resource AND owner.category = 3
type = group AND parent.name = "Acme Corp"
type = group AND children.name ~ "Q*"

Multi-Level Traversal

Chain traversal fields to reach groups further up or down the hierarchy. After the first step, you're always in group context, so parent and children are the valid intermediate steps:

type = resource AND owner.parent.name = "Acme Corp"
type = resource AND owner.parent.tags = "active"
type = note AND owner.children.name ~ "Sprint*"
type = group AND parent.parent.name = "Root"
type = group AND parent.parent.tags = "org-level"

Maximum traversal depth is 8 parts (7 traversal steps + 1 leaf field).

Valid Traversal Subfields

At the end of a traversal chain, you can access these group fields:

  • Scalar: name, description, category, url, id, guid, created, updated
  • Relation: tags (match by tag name)
  • Meta: meta.<key> (e.g., owner.meta.region)

parent, children and owner are valid as a traversal leaf only under IS NULL / IS NOT NULL, as in owner.parent IS NULL or parent.parent IS NOT NULL. Comparing them is an error.

Traversal fields follow the same operators as regular fields. Traversal deeper than 8 parts is not supported.

Recursive Traversal: ancestors. / descendants.

Multi-level traversal (parent.parent.name) requires you to know the depth. When you want to match at any depth, use the ancestors. and descendants. roots, which walk the group hierarchy transitively. They are valid on every entity type.

type = group AND ancestors.name = "Archive"        # groups anywhere below "Archive"
type = group AND descendants.tags = "wip" # groups with a WIP-tagged descendant, at any depth
type = resource AND ancestors.meta.region = "eu" # resources whose owner sits under an EU group
  • Base group. For a group, itself; for a resource or note, its owner group.
  • Strict. ancestors/descendants exclude the base group. A resource stored directly in "Archive" does not match ancestors.name = "Archive" -- write owner.name = "Archive" OR ancestors.name = "Archive" for "in Archive or anywhere below it".
  • One leaf field. The predicate takes exactly one group field: a scalar (name, category, id, ...), tags, or meta.<key>. Chaining further (ancestors.parent.name) is not supported.
  • Existential negation. ancestors.category != 3 means no ancestor has category 3 (and owner-less rows, which have no ancestors, match). IN, IS EMPTY/IS NULL, ORDER BY, and GROUP BY are not supported on these roots.

Similarity Search: SIMILAR TO

SIMILAR TO resource(<id>) matches resources that are perceptually similar to the target resource. It reads the precomputed similarity pairs -- the same data behind the resource page's similarity sidebar -- so it is fast at any library size and never computes hashes at query time.

type = resource AND SIMILAR TO resource(1234)                    # similar images, runtime threshold
type = resource AND SIMILAR TO resource(1234) WITHIN 2 # near-duplicates only
type = resource AND SIMILAR TO resource(1234) AND tags != "reviewed"
type = resource AND SIMILAR TO resource(1234) ORDER BY distance ASC LIMIT 20
  • Thresholds. Without WITHIN, the live hash_similarity_threshold runtime setting applies (default 10), and the hash_ahash_threshold secondary filter applies whenever set above 0 (its normal state) -- MRQL results match the similarity sidebar exactly, and tuning the settings applies to saved queries instantly. WITHIN <d> overrides the primary distance; the valid range is 0-11 because pairs are only stored up to distance 11.
  • The target never matches itself. Consequently NOT SIMILAR TO resource(N) includes resource N.
  • Missing data means empty, not an error. A nonexistent target, a non-image, or a resource the hash worker has not processed yet matches nothing.
  • Sorting. ORDER BY distance (ASC or DESC) sorts by the perceptual distance to the target and requires exactly one SIMILAR TO predicate in the query. Rows matched by other OR branches that have no stored pair sort last.
  • Resource entity only. type = note/group queries reject it; in a type-guarded OR, the similarity branch simply matches nothing for other entities.

Cross-Entity Queries

Omitting type causes MRQL to fan out the query across resources, notes, and groups simultaneously. Only common fields (id, name, description, created, updated, tags, guid, meta.<key>) and TEXT ~ full-text search are valid in cross-entity mode.

name ~ "budget*"                              # search all entity types
tags = "urgent" LIMIT 30 # across all types
TEXT ~ "quarterly review" LIMIT 30 # full-text across all types

Results are returned grouped by entity type (resources, then notes, then groups). ORDER BY, LIMIT, and OFFSET apply globally across the merged result set. Cross-entity sorting supports name, created, updated, and RANDOM(), which draws a sample proportional to how many of each type matched. Any other ORDER BY field is accepted but has no effect on the merged order.

Saved Queries

Any query can be saved for later reuse:

  1. Write and run a query in the /mrql editor
  2. Click Save, provide a name and optional description
  3. The query appears in the Saved Queries panel

Saved queries can be:

  • Loaded by clicking them in the panel (populates the editor)
  • Run directly via the CLI with mr mrql run <name-or-id>
  • Deleted by hovering a query and clicking the Delete button
  • Updated via the API (PUT /v1/mrql/saved?id=N)

Server-Side Rendering

The MRQL execute endpoints (POST /v1/mrql and POST /v1/mrql/saved/run) accept a render=1 query parameter. When set, the server processes each result entity's CustomMRQLResult template (if defined on its Category, Resource Category, or Note Type) and populates a renderedHTML field in the JSON response.

curl -X POST "http://localhost:8181/v1/mrql?render=1" \
-H "Content-Type: application/json" \
-d '{"query": "type = resource AND tags = \"photos\""}'

Entities without a CustomMRQLResult template omit the renderedHTML field from the JSON response. The /mrql web UI uses this field to display custom-rendered results inline.

Examples Cookbook

Finding resources by type and size

type = resource AND contentType ~ "image/*" AND fileSize > 5mb

Recently modified notes with a specific tag

type = note AND tags = "todo" AND updated > -7d ORDER BY updated DESC

Resources added this week without any tags

type = resource AND tags IS EMPTY AND created >= START_OF_WEEK()

Large video files

type = resource AND contentType ~ "video/*" AND fileSize > 500mb ORDER BY fileSize DESC

Groups with no parent (top-level only)

type = group AND parent IS EMPTY

Notes in a specific group updated recently

type = note AND groups = "Project Alpha" AND updated > -30d

Resources matching multiple content types

type = resource AND contentType IN ("image/png", "image/jpeg", "image/webp", "image/gif")

Resources with missing descriptions

type = resource AND description IS EMPTY

Full-text search within a date range

type = note AND TEXT ~ "budget forecast" AND created >= -90d ORDER BY created DESC

Groups in a specific category added this year

type = group AND category = 5 AND created >= START_OF_YEAR()

Resources with metadata rating above threshold

type = resource AND meta.rating > 4

Everything tagged "urgent" across all entity types

tags = "urgent" LIMIT 50

Resources with a specific original filename pattern

type = resource AND originalName ~ "screenshot_*" ORDER BY created DESC

High-resolution images from the last month

type = resource AND contentType ~ "image/*" AND width >= 1920 AND created > -30d

Notes not updated in over six months

type = note AND updated < -180d ORDER BY updated ASC

Groups with children named after a pattern

type = group AND children.name ~ "Q* 2025"

Resources excluding drafts and archived

type = resource AND NOT (tags IN ("draft", "archived")) ORDER BY created DESC LIMIT 25

Resources owned by a specific group

type = resource AND owner = "Project Alpha"

Resources whose owner has a specific tag

type = resource AND tags = "photo" AND owner.tags = "active"

Resources whose owner's parent matches

type = resource AND owner.parent.name = "Acme Corp"

Groups with deeply nested parent

type = group AND parent.parent.name = "Root Organization"

Count resources by content type

type = resource GROUP BY contentType COUNT() ORDER BY count DESC

Total and average file size per content type

type = resource GROUP BY contentType COUNT() SUM(fileSize) AVG(fileSize)

Size extremes for large files by content type

type = resource AND fileSize > 10mb GROUP BY contentType MIN(fileSize) MAX(fileSize)

Notes by owner and note type

type = note GROUP BY owner, noteType COUNT()

Resources bucketed by content type (5 per bucket)

type = resource GROUP BY contentType LIMIT 5

Resources bucketed by metadata field

type = resource GROUP BY meta.camera_model LIMIT 10

Parameterized Queries (Reports)

A query may contain $name placeholders in value positions -- anywhere a literal value is accepted (comparison right-hand side, IN (...) list items, and HAVING comparison right-hand side). A placeholder name is [a-zA-Z_][a-zA-Z0-9_]*. Placeholders are not allowed in field names, LIMIT/OFFSET, SCOPE, WITHIN, or GROUP BY keys. A $name inside a quoted string stays literal text.

type = resource AND tags = $tag AND created > $since
type = note AND name ~ $needle LIMIT 50
type = resource GROUP BY contentType COUNT() HAVING COUNT() > $min
tags IN ($a, $b)

Parameters make a saved query reusable as a report: save it once with placeholders, then supply values at run time.

  • Binding is at the value level, never string interpolation - bound values translate to database bind placeholders exactly like typed literals, so they are injection-safe by construction. tag = $t with t = 'x" OR 1=1' is just an unusual tag string that matches nothing.
  • Value coercion mirrors the lexer. A supplied string behaves as if typed at that position: a bare number (42, 10mb), a relative date (-7d), or a date function (NOW()) is parsed as that literal; anything else becomes a plain string. Force a string by wrapping in quotes (CLI: --param n='"42"').
  • Every placeholder must be supplied. A missing value is a 400 error listing the missing name; an unknown/extra parameter is also rejected (typo protection). Names are case-sensitive.
  • Saving is allowed with unbound placeholders - validation accepts a placeholder against any field type and re-checks compatibility once bound.

On the /mrql page, one labeled input appears per placeholder above the Run button; loading a saved report focuses the first empty input instead of running.

From the CLI, bind with repeatable --param:

mr mrql 'type = resource AND created > $since' --param since=-7d
mr mrql run monthly-report --param month=2026-07

Via the API, POST /v1/mrql accepts a params object; POST /v1/mrql/saved/run also accepts param.<name>=<value> query parameters. In [mrql] shortcodes and plugin mah.db.mrql_query, supply param-<name> attributes / a params table.

EXPLAIN

POST /v1/mrql/explain (and mr mrql explain) returns the SQL statement(s) a query would run without executing it. The reported SQL reflects what would actually run: the default LIMIT is applied, SCOPE is resolved, and RBAC forced scoping is included. A flat single-entity query yields one statement; a cross-entity query yields one per entity table; aggregated GROUP BY yields one; bucketed GROUP BY shows the key-discovery query and notes the per-bucket fan-out.

mr mrql explain 'type = resource AND fileSize > 1mb'
mr mrql explain --saved my-report --param since=-7d --json

On the /mrql page, the Explain button (or Mod-Shift-Enter) opens a panel above the results showing the interpolated SQL per statement, with a toggle for the raw parameterized SQL and its bind variables.

Export

GET|POST /v1/mrql/export (and mr mrql export) streams query results as a download. format=csv (default) or format=json. The same inputs as execution apply (query or id/name, params, limit, page, buckets, offset).

  • CSV -- aggregated: the GROUP BY keys then the aggregate aliases, in query order.
  • CSV -- flat: a fixed scalar column set per entity (meta as a JSON string). CSV requires a single entity type -- use format=json for cross-entity results.
  • CSV -- bucketed: the bucket-key columns prepended to the flat item columns.
  • JSON: the exact /v1/mrql response body as a download.

When no explicit LIMIT is present the default is applied and reported via the X-MRQL-Default-Limit-Applied response header.

mr mrql export 'type = resource' --format csv -o resources.csv
mr mrql export --saved my-report --format json --param since=-7d

The /mrql results header has Export CSV / Export JSON buttons that re-submit the current query and parameters.

See Also