diff --git a/.github/workflows/docs-checks.yml b/.github/workflows/docs-checks.yml
index eb8f7eaaf..fd738fa90 100644
--- a/.github/workflows/docs-checks.yml
+++ b/.github/workflows/docs-checks.yml
@@ -18,3 +18,19 @@ jobs:
with:
node-version: 22
- run: node scripts/check-docs.mjs
+
+ examples-and-names:
+ name: Examples and names
+ # Only PRs into main: docs on a release branch describe a Memgraph that isn't out yet, so
+ # their examples are checked when the release branch is merged into main.
+ if: github.event_name == 'pull_request' && github.base_ref == 'main'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+ # The pages this PR changes: their Cypher examples run on the latest Memgraph, and the names in them are checked.
+ - run: python3 scripts/check-examples.py --base HEAD^1
+ env:
+ MEMGRAPH_ENTERPRISE_LICENSE: ${{ secrets.MEMGRAPH_ENTERPRISE_LICENSE }}
+ MEMGRAPH_ORGANIZATION_NAME: ${{ secrets.MEMGRAPH_ORGANIZATION_NAME }}
diff --git a/AGENTS.md b/AGENTS.md
index 67d4d0acd..7d7b46660 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,7 +22,9 @@ The short version, for everyone:
old address that redirects, and to a `#section` that exists.
- Images go in `public/pages/` and are linked as `/pages/...`, with alt text.
- Every code block has a language; `cypher` blocks run as written on the
- current release, and Cypher comments use `//`.
+ current release, and Cypher comments use `//`. A `cypher` block that isn't
+ meant to run says why in its fence: ` ```cypher template` (grammar such as
+ `[IF NOT EXISTS]`), `invalid` (wrong on purpose), `neo4j` or `output`.
- A new page is added to its folder's `_meta.ts`, and no page is hidden. A
renamed, moved or deleted page gets a redirect in `next.config.mjs`, and
links to it are updated.
@@ -43,8 +45,10 @@ The short version, for everyone:
pnpm i
pnpm dev # http://localhost:3000/docs
pnpm check # links, section links, redirects, sidebar and release-note versions
+python3 scripts/check-examples.py --base origin/main # Cypher examples and names on the pages you changed; needs Docker
```
-`pnpm check` also runs on every PR, and a PR that fails it can't be merged. It reads only the
-checkout, takes a second and needs no install; `node scripts/check-docs.mjs`
-does the same.
+Both run on every PR.
+`pnpm check` reads only the checkout and takes a second. The examples check
+runs each changed page's `cypher` blocks on Memgraph in Docker and fails only
+on the blocks the PR changes; problems elsewhere on the page show as warnings.
diff --git a/pages/data-migration/migrate-from-neo4j/using-csv-files.mdx b/pages/data-migration/migrate-from-neo4j/using-csv-files.mdx
index bd33c0831..95fb72757 100644
--- a/pages/data-migration/migrate-from-neo4j/using-csv-files.mdx
+++ b/pages/data-migration/migrate-from-neo4j/using-csv-files.mdx
@@ -85,7 +85,7 @@ schema below:
To create this graph in your Neo4j instance run the following queries:
-```cypher
+```cypher neo4j
LOAD CSV WITH HEADERS FROM 'https://gist.githubusercontent.com/jexp/054bc6baf36604061bf407aa8cd08608/raw/8bdd36dfc88381995e6823ff3f419b5a0cb8ac4f/orders.csv' AS column
MERGE (order:Order {orderID: column.OrderID})
ON CREATE SET order.shipName = column.ShipName;
diff --git a/pages/database-management/authentication-and-authorization/mlbac-migration-guide.mdx b/pages/database-management/authentication-and-authorization/mlbac-migration-guide.mdx
index 597c67ff4..c93a9256f 100644
--- a/pages/database-management/authentication-and-authorization/mlbac-migration-guide.mdx
+++ b/pages/database-management/authentication-and-authorization/mlbac-migration-guide.mdx
@@ -28,7 +28,7 @@ Current label-based access control implementation was good for a number of use c
**Before (v3.6):**
Rules matched exact label sets only. By executing the following query:
-```cypher
+```cypher invalid
GRANT READ ON LABELS :User
```
@@ -104,7 +104,7 @@ Global permissions only (grants on `*`)
| CREATE_DELETE | CREATE, READ, UPDATE, DELETE |
Example:
-```cypher
+```cypher invalid
// v3.6
GRANT UPDATE ON LABELS * TO alice;
diff --git a/pages/fundamentals/data-types.mdx b/pages/fundamentals/data-types.mdx
index db1f8e257..91e4658d4 100644
--- a/pages/fundamentals/data-types.mdx
+++ b/pages/fundamentals/data-types.mdx
@@ -72,7 +72,7 @@ CREATE (:Node {property: {key: "value"}});
But these are not:
-```cypher
+```cypher invalid
MATCH (n:Node) SET n.property[0] = 0;
MATCH (n:Node) SET n.property.key = "other value";
```
diff --git a/pages/querying/clauses/call.mdx b/pages/querying/clauses/call.mdx
index ba8761b19..0d4774bfc 100644
--- a/pages/querying/clauses/call.mdx
+++ b/pages/querying/clauses/call.mdx
@@ -312,7 +312,7 @@ RETURN DISTINCT n;
The scoped `CALL (...)` form cannot be combined with `OPTIONAL`:
-```cypher
+```cypher invalid
MATCH (p:Player)
OPTIONAL CALL (p) {
MATCH (p)-[:PLAYS_FOR]->(team:Team)
diff --git a/pages/querying/differences-in-cypher-implementations.mdx b/pages/querying/differences-in-cypher-implementations.mdx
index 3351599d0..831738272 100644
--- a/pages/querying/differences-in-cypher-implementations.mdx
+++ b/pages/querying/differences-in-cypher-implementations.mdx
@@ -100,7 +100,7 @@ Relationship uniqueness, existence, and type constraints are **not** supported a
In Neo4j, to find the shortest possible path between two nodes, you would use the `shortestPath` algorithm:
-```cypher
+```cypher neo4j
MATCH p=shortestPath(
(:Person {name:"Keanu Reeves"})-[*]-(:Person {name:"Tom Hanks"})
)
@@ -118,7 +118,7 @@ RETURN p
In Neo4j, to find K shortest paths between two nodes, you would use the `SHORTEST` algorithm with a number:
-```cypher
+```cypher neo4j
MATCH p = SHORTEST 3 (start:A)-[:E]->(end:B)
RETURN p;
```
@@ -136,7 +136,7 @@ Note that Memgraph requires both source and target nodes to be matched first usi
### NOT label expression
In Neo4j, you can use the `NOT` label expression (`!`):
-```cypher
+```cypher neo4j
MATCH (:Person {name:'Tom Hanks'})-[r:!ACTED_IN]->(m:Movie)
Return type(r) AS type, m.title AS movies
```
@@ -150,7 +150,7 @@ RETURN type(r) AS type, m.title AS movies;
### Search for patterns of a fixed length
In Neo4j, to search for patterns of a fixed length, you would use the following construct:
-```cypher
+```cypher neo4j
MATCH (tom:Person {name:'Tom Hanks'})--{2}(colleagues:Person)
RETURN DISTINCT colleagues.name AS name, colleagues.born AS bornIn
ORDER BY bornIn
@@ -165,7 +165,7 @@ LIMIT 5
```
Similarly, to match a graph for patterns of a variable length, you would run the following query in Neo4j:
-```cypher
+```cypher neo4j
MATCH (p:Person {name:'Tom Hanks'})--{1,4}(colleagues:Person)
RETURN DISTINCT colleagues.name AS name, colleagues.born AS bornIn
ORDER BY bornIn, name
@@ -198,7 +198,7 @@ In other cases, Memgraph does not yet support patterns in functions, e.g. `size(
{
Conditional expressions (CASE)
}
- More than one value after `WHEN` operator:
- ```cypher
+ ```cypher neo4j
MATCH (n:Person)
RETURN
CASE n.eyes
@@ -221,7 +221,7 @@ In other cases, Memgraph does not yet support patterns in functions, e.g. `size(
{Type predicate expressions
}
The following expression is not supported in Memgraph:
-```cypher
+```cypher neo4j
UNWIND [42, true, 'abc'] AS val
RETURN val, val IS :: INTEGER AS isInteger
```
diff --git a/scripts/check-examples.py b/scripts/check-examples.py
new file mode 100644
index 000000000..861989288
--- /dev/null
+++ b/scripts/check-examples.py
@@ -0,0 +1,486 @@
+#!/usr/bin/env python3
+"""Run the docs' Cypher examples on Memgraph and check the names they use.
+
+ python3 scripts/check-examples.py --base origin/main # pages this branch changed
+ python3 scripts/check-examples.py --pages querying/clauses/call.mdx
+ python3 scripts/check-examples.py --all # every page (about an hour)
+
+Needs Docker. Each page runs on a fresh Memgraph MAGE container, its ```cypher blocks
+in page order, so an example can use data an earlier one created.
+
+What fails the check, in the blocks a PR changed:
+ - a query that doesn't parse, or a stray character in it
+ - a procedure or function that doesn't exist, or a YIELD field it doesn't return
+ - CALL without YIELD on a procedure that returns fields
+ - -- or # comments (Memgraph only accepts //)
+ - an Enterprise procedure on a page that doesn't say it needs a licence
+ - a flag in the configuration tables that Memgraph doesn't have
+Problems in blocks the PR didn't change are shown as warnings.
+
+Blocks that aren't meant to run are marked in the fence: ```cypher template (grammar
+such as [IF NOT EXISTS]), ```cypher invalid (shown as wrong on purpose), ```cypher neo4j
+(Neo4j syntax), ```cypher output. Examples with , ... or $parameters are
+not run, but the names in them are still checked.
+
+The image is the newest Memgraph release in pages/release-notes.mdx. On PRs the check
+runs only for PRs into main: docs on a release branch describe a Memgraph that isn't out
+yet, so their examples are checked when the release branch is merged into main. If that
+version's image isn't published yet, the check fails and says to rerun it once it is.
+
+With MEMGRAPH_ENTERPRISE_LICENSE and MEMGRAPH_ORGANIZATION_NAME set, containers start
+with the licence. Docker reads the values from the environment, so they never appear in
+a command line; examples that set the licence are skipped, query output is never
+printed, and every message is scrubbed of both values before it is printed.
+"""
+import argparse, concurrent.futures as cf, csv, json, os, re, subprocess, sys, threading, time
+from collections import Counter, defaultdict
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+PAGES = ROOT / 'pages'
+REPO = 'memgraph/memgraph-mage'
+
+ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ap.add_argument('--base', help='git ref to compare with; checks the pages changed since it')
+ap.add_argument('--pages', nargs='*', default=[], help='pages under pages/ to check in full')
+ap.add_argument('--all', action='store_true', help='check every page in full')
+ap.add_argument('--image', help='Docker image (default: the newest release in the release notes)')
+ap.add_argument('--workers', type=int, default=4)
+ap.add_argument('--timeout', type=int, default=20, help='seconds per statement')
+a = ap.parse_args()
+
+GH = os.environ.get('GITHUB_ACTIONS') == 'true'
+LICENSED = bool(os.environ.get('MEMGRAPH_ENTERPRISE_LICENSE') and os.environ.get('MEMGRAPH_ORGANIZATION_NAME'))
+LICENCE_ARGS = ['-e', 'MEMGRAPH_ENTERPRISE_LICENSE', '-e', 'MEMGRAPH_ORGANIZATION_NAME'] if LICENSED else []
+SECRETS = [v for v in (os.environ.get('MEMGRAPH_ENTERPRISE_LICENSE'), os.environ.get('MEMGRAPH_ORGANIZATION_NAME')) if v and len(v) >= 4]
+
+
+def scrub(text):
+ """Remove the licence key and organization name from anything this script prints."""
+ for v in SECRETS:
+ text = text.replace(v, '***')
+ return text
+
+
+def sh(*args, inp=None, timeout=120):
+ return subprocess.run(args, input=inp, capture_output=True, text=True, errors='replace', timeout=timeout)
+
+
+# ---------------------------------------------------------------- what to check
+
+def changed_lines(base):
+ """{page path under pages/: set of changed line numbers} for .md/.mdx pages changed since base."""
+ diff = sh('git', '-C', str(ROOT), 'diff', '-U0', '--no-color', f'{base}...HEAD', '--', 'pages').stdout
+ out, cur = defaultdict(set), None
+ for line in diff.splitlines():
+ if line.startswith('+++ '):
+ p = line[6:] if line.startswith('+++ b/') else None
+ cur = p[len('pages/'):] if p and p.endswith(('.md', '.mdx')) else None
+ elif line.startswith('@@') and cur:
+ m = re.match(r'@@ -\S+ \+(\d+)(?:,(\d+))? @@', line)
+ start, n = int(m.group(1)), int(m.group(2) if m.group(2) is not None else 1)
+ out[cur].update(range(start, start + n))
+ return out
+
+
+def has_cypher(rel):
+ return '```cypher' in (PAGES / rel).read_text(errors='replace')
+
+
+if a.all:
+ targets = {str(p.relative_to(PAGES)): None for p in sorted(PAGES.rglob('*.md*'))}
+elif a.pages:
+ targets = {p: None for p in a.pages}
+elif a.base:
+ targets = dict(changed_lines(a.base))
+else:
+ ap.error('give --base, --pages or --all')
+# None means every line of the page counts as changed.
+targets = {p: lines for p, lines in targets.items() if (PAGES / p).exists() and not p.startswith('memgraph-zero/')}
+CONFIG_PAGE = 'database-management/configuration.mdx'
+run_pages = sorted(p for p in targets if has_cypher(p))
+check_flags = CONFIG_PAGE in targets
+if not run_pages and not check_flags:
+ print('No changed page has Cypher examples or configuration flags to check.')
+ sys.exit(0)
+
+
+# ---------------------------------------------------------------- image
+
+def releases():
+ """Memgraph versions in the release notes, newest first."""
+ text = (PAGES / 'release-notes.mdx').read_text(errors='replace')
+ vs = {tuple(map(int, v)) for v in re.findall(r'^#{2,4}\s+Memgraph v(\d+)\.(\d+)\.(\d+)', text, re.M)}
+ return ['.'.join(map(str, v)) for v in sorted(vs, reverse=True)]
+
+
+def image_exists(image):
+ return sh('docker', 'manifest', 'inspect', image).returncode == 0
+
+
+if a.image:
+ IMAGE = a.image
+else:
+ versions = releases()
+ IMAGE = f'{REPO}:{versions[0]}' if versions else f'{REPO}:latest'
+ if versions and not image_exists(IMAGE):
+ print(f'{IMAGE} is not published yet. The examples describe Memgraph {versions[0]}, so they can only be '
+ f'checked on it: rerun this check once the image is out.')
+ sys.exit(1)
+print(f'Image: {IMAGE}{" with an Enterprise licence" if LICENSED else ""}. Pages: {len(run_pages)}.', flush=True)
+
+
+# ---------------------------------------------------------------- blocks and statements
+
+HIGH = ('syntax', 'bad_character', 'no_procedure', 'no_function', 'yield_field', 'missing_yield', 'comment_style')
+EXPLAIN = {
+ 'syntax': "This example doesn't parse",
+ 'bad_character': 'This example has a character the parser does not expect (a curly quote, a stray symbol)',
+ 'no_procedure': "This example calls a procedure that doesn't exist",
+ 'no_function': "This example calls a function that doesn't exist",
+ 'yield_field': "This example yields a field the procedure doesn't return",
+ 'missing_yield': 'This example needs YIELD: the procedure returns fields',
+ 'comment_style': 'This example uses -- or # comments; Memgraph only accepts //',
+ 'enterprise_unsaid': 'This example uses an Enterprise procedure, and the page never says it needs a licence',
+ 'no_flag': "This flag isn't in Memgraph",
+}
+PLACEHOLDER = re.compile(r'<[A-Za-z_][\w \-]*>|\.\.\.|…')
+PARAM = re.compile(r'\$[A-Za-z_]\w*')
+MARKS = re.compile(r'\b(invalid|template|neo4j|output|gql)\b', re.I)
+SQL_COMMENT = re.compile(r'(?:^\s*|;\s*)(--|#)', re.M)
+OUTPUT = re.compile(r'^\s*(\+-|\||>>|memgraph>|->)', re.M)
+OTHER_LANGUAGE = re.compile(r'^\s*(ADD CONNECTOR|ADD MAPPING|CONNECT \w+ AS|USE CONNECTION|CYPHER\b|SET SESSION LANGUAGE)', re.I)
+TEMPLATE = re.compile(r'\w\?(?=\s|$)|\)\s*\?|\[[A-Za-z][A-Za-z _]*\]|\[[A-Z]{2,} [^\]]*\]|\(\s*[A-Z_]+\s*\|\s*[A-Z_]+|\{[A-Z_]+\|[A-Z_]+\}|\bDATA_TYPE\b|\b(permission|permission_list|privilege_list|edge_type_list|label_list|property_list|user_or_role|role_name|user_name|database_name)\b|\bnum_rows\b|user/role|list of users')
+KEYWORD = re.compile(r'^\s*(MATCH|CREATE|MERGE|CALL|RETURN|WITH|UNWIND|LOAD|SHOW|DROP|SET|DELETE|DETACH|OPTIONAL|FOREACH|ALTER|ANALYZE|STORAGE|FREE|ENABLE|DISABLE|GRANT|DENY|REVOKE|USE|EXPLAIN|PROFILE|BEGIN|COMMIT|DUMP|TERMINATE|REGISTER)\b', re.I)
+SETS_LICENCE = re.compile(r"SET\s+DATABASE\s+SETTING\s+['\"](enterprise\.license|organization\.name)['\"]", re.I)
+CREATE_USER = re.compile(r"CREATE\s+USER\s+(?:IF\s+NOT\s+EXISTS\s+)?[`'\"]?(\w+)[`'\"]?(?:\s+IDENTIFIED\s+BY\s+'([^']*)')?", re.I)
+LICENCE = re.compile(r'enterprise|licen[cs]e', re.I)
+
+
+def blocks(rel):
+ """(first line, last line, fence meta, code) for each ```cypher block, in page order."""
+ out, lines, i = [], (PAGES / rel).read_text(errors='replace').splitlines(), 0
+ while i < len(lines):
+ m = re.match(r'^(\s*)```cypher\b(.*)$', lines[i])
+ if m:
+ indent, start, body = len(m.group(1)), i + 1, []
+ i += 1
+ while i < len(lines) and not re.match(r'^\s*```\s*$', lines[i]):
+ body.append(lines[i][indent:] if lines[i][:indent].strip() == '' else lines[i])
+ i += 1
+ out.append((start, i + 1, m.group(2).strip(), '\n'.join(body)))
+ i += 1
+ return out
+
+
+def strip_comment(line):
+ """Drop a trailing // comment, but not // inside a string such as a URL."""
+ quote = None
+ for i, ch in enumerate(line):
+ if quote:
+ quote = None if ch == quote else quote
+ elif ch in '"\'`':
+ quote = ch
+ elif line.startswith('//', i):
+ return line[:i].rstrip()
+ return line
+
+
+def statements(code):
+ code = '\n'.join(strip_comment(l) for l in code.splitlines())
+ if ';' in code:
+ parts = re.split(r';\s*(?:\n|$)', code)
+ else:
+ # Several queries separated by blank lines, each starting with a keyword, or one query.
+ chunks = [c for c in re.split(r'\n\s*\n', code) if c.strip()]
+ parts = chunks if len(chunks) > 1 and all(KEYWORD.match(c) for c in chunks) else [code]
+ return [p.strip() for p in parts if p.strip()]
+
+
+def classify(msg):
+ m = msg.lower()
+ if 'has no result field named' in m:
+ return 'yield_field'
+ if 'there is no procedure named' in m:
+ return 'no_procedure'
+ if re.search(r"function '.*' doesn't exist", m):
+ return 'no_function'
+ if 'licen' in m or 'enterprise' in m:
+ return 'enterprise'
+ if 'parsing error' in m or 'error on line' in m:
+ return 'syntax'
+ if 'call without yield may only be used' in m:
+ return 'missing_yield'
+ if 'there is a wrong token at position' in m:
+ return 'bad_character'
+ if 'should either create or update something, or return results' in m:
+ return 'incomplete'
+ return 'other'
+
+
+# ---------------------------------------------------------------- Memgraph
+
+class Box:
+ def __init__(self, name):
+ self.name, self.login = name, []
+
+ def fresh(self):
+ self.login = []
+ sh('docker', 'rm', '-f', self.name)
+ sh('docker', 'run', '-d', '--name', self.name, *LICENCE_ARGS, IMAGE, '--telemetry-enabled=false', timeout=600)
+ for _ in range(180):
+ r = sh('docker', 'exec', '-i', self.name, 'mgconsole', inp='RETURN 1;\n')
+ if r.returncode == 0 and 'Failed' not in r.stdout + r.stderr:
+ return
+ time.sleep(0.5)
+ logs = sh('docker', 'logs', '--tail', '3', self.name)
+ raise RuntimeError(scrub('Memgraph did not start: ' + (logs.stdout + logs.stderr).strip()[-200:]))
+
+ def q(self, stmt, retried=False):
+ try:
+ r = sh('docker', 'exec', '-i', self.name, 'mgconsole', *self.login, inp=stmt + ';\n', timeout=a.timeout)
+ except subprocess.TimeoutExpired:
+ self.fresh() # a runaway query would slow every later example on the page
+ return 'timeout', ''
+ out = r.stdout + r.stderr
+ if 'Authentication failure' in out and not retried:
+ self.fresh()
+ return self.q(stmt, retried=True)
+ if r.returncode == 0 and 'Failed query' not in out and 'exception' not in out.lower():
+ m = CREATE_USER.search(stmt)
+ if m and not self.login:
+ # The first user turns on authentication; carry on as that user so later examples
+ # still see the users and roles the page made.
+ self.login = ['--username', m.group(1), '--password', m.group(2) or '']
+ return 'ok', ''
+ msg = next((l.split('exception:', 1)[1].strip() for l in out.splitlines() if 'exception:' in l), out.strip()[-200:])
+ return classify(msg), msg[:300]
+
+ def csv(self, query):
+ rows = list(csv.reader(sh('docker', 'exec', '-i', self.name, 'mgconsole', '--output-format=csv', inp=query + '\n').stdout.splitlines()))
+ return [[c.strip('"') for c in r] for r in rows[1:]]
+
+ def stop(self):
+ sh('docker', 'rm', '-f', self.name)
+
+
+# ---------------------------------------------------------------- rules shared by examples and names
+
+PLACEHOLDER_MODULES = {'module', 'my_module', 'query_module', 'query_module_name'}
+TUTORIAL_PAGES = ('custom-query-modules', 'memgraph-lab/features/query-modules')
+
+
+def module_page(mod):
+ return PAGES / 'advanced-algorithms' / 'available-algorithms' / f'{mod}.mdx'
+
+
+def is_enterprise_module(mod):
+ page = module_page(mod)
+ return page.exists() and bool(re.search(r'^#\s.*\(Enterprise\)', page.read_text(errors='replace'), re.M))
+
+
+def says_licence(rel):
+ return bool(LICENCE.search((PAGES / rel).read_text(errors='replace')))
+
+
+def expected_missing(name, rel):
+ """Why a name is missing from the image for a reason that isn't a docs bug, or None."""
+ mod = name.split('.')[0]
+ if mod in PLACEHOLDER_MODULES or rel.startswith(TUTORIAL_PAGES):
+ return 'a placeholder or a module the tutorial builds'
+ if mod in ('apoc', 'db') and 'neo4j' in rel:
+ return 'runs in Neo4j'
+ if mod == 'cugraph':
+ return 'needs the GPU image'
+ if not LICENSED and is_enterprise_module(mod) and says_licence(rel):
+ return 'an Enterprise module, and the page says so'
+ return None
+
+
+def own_names(rel):
+ """Procedures and functions the page defines in its own query module."""
+ text = (PAGES / rel).read_text(errors='replace')
+ return set(re.findall(r'(?:def|fn|void)\s+(\w+)\s*\(', text)) if re.search(r'@mgp\.|mgp::|mgp_|rsmgp', text) else set()
+
+
+CALL = re.compile(r'\bCALL\s+([A-Za-z_]\w*(?:\.\w+)+)\s*\(', re.I)
+FUNC = re.compile(r'(?