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'(?