Prise en charge des requêtes Cypher¶
Provisa traduit un sous-ensemble d'openCypher en SQL via le module provisa/cypher/. (REQ-345, REQ-347) Les requêtes sont analysées par un analyseur syntaxique à descente récursive maison (sans bibliothèque Cypher externe) (REQ-571), résolues au niveau du schéma par rapport à la couche sémantique (REQ-351), puis émises en SQL avant d'être acheminées vers le moteur d'exécution cible. (REQ-066, REQ-067, REQ-347)
Fonctionnalités implémentées¶
Clauses¶
| Clause | État | Remarques |
|---|---|---|
MATCH (n:Label) |
✓ | Modèles de nœud avec étiquettes, variables, propriétés en ligne |
OPTIONAL MATCH |
✓ | Émet un LEFT JOIN |
WHERE |
✓ | Prise en charge complète des expressions ; appliquée après MATCH |
RETURN |
✓ | Astérisque, accès aux propriétés, expressions, alias |
RETURN DISTINCT |
✓ | Émet un SELECT DISTINCT |
WITH |
✓ | Émet une CTE nommée (_w0, _w1, …) ; prend en charge WITH … WHERE |
ORDER BY |
✓ | ASC / DESC |
SKIP / LIMIT |
✓ | Correspond à SQL OFFSET / LIMIT |
UNION / UNION ALL |
✓ | Union récursive entre sous-AST |
CALL { … } |
✓ | Décomposition de sous-requête CALL de premier niveau via cypher_calls_to_sql_list |
CALL { WITH x … } |
✓ | Sous-requête corrélée → CROSS JOIN LATERAL ; voir §CALL corrélé |
CALL db.labels() |
✓ | Renvoie les étiquettes de nœud de la couche sémantique ; aucune traduction SQL (REQ-572) |
CALL db.relationshipTypes() |
✓ | Renvoie les types de relation de la couche sémantique (REQ-572) |
CALL db.propertyKeys() |
✓ | Renvoie tous les noms de clés de propriété pour tous les types de nœud (REQ-572) |
UNWIND |
✓ | Expansion d'un tableau en lignes ; le premier élément devient le FROM, les suivants deviennent des CROSS JOIN UNNEST |
Modèles de correspondance¶
| Modèle | État | Remarques |
|---|---|---|
(n) — nœud sans étiquette |
✓ | UNION ALL sur tous les types connus |
(n:Label) |
✓ | Correspond à la table enregistrée pour ce type GraphQL |
(n:Label {prop: val}) |
✓ | Le filtre de propriété en ligne devient un WHERE |
(a)-[:TYPE]->(b) |
✓ | Dirigé, à un seul saut |
(a)<-[:TYPE]-(b) |
✓ | Parcours inverse ; colonnes de jointure inversées |
(a)-[]->(b) |
✓ | Toute relation dirigée a→b ; UNION ALL si plusieurs types correspondent |
(a)-[]-(b) |
✓ | Bidirectionnel ; se transforme en UNION ALL de toutes les relations directes et inverses |
(a)-[:TYPE*..N]->(b) |
✓ | Longueur variable avec borne supérieure ; CTE récursive pour les cas autoréférentiels, JOIN simple sinon |
(a)-[]->(b)-[]->(c) |
✓ | JOIN chaînés multi-sauts |
(n:DomainLabel) |
✓ | Étiquette de domaine → sous-requête UNION ALL sur tous les types du domaine |
(n:A\|B) |
✓ | Alternance d'étiquettes → domaine ad hoc injecté dans le mappage d'étiquettes ; UNION ALL sur les types correspondants |
shortestPath(…) |
✓ | JOIN simple pour les extrémités hétérogènes ; CTE WITH RECURSIVE pour les cas de même type/autoréférentiels |
allShortestPaths(…) |
✓ | Identique à shortestPath mais sans LIMIT 1 |
Expressions et prédicats¶
| Fonctionnalité | État | Correspondance SQL |
|---|---|---|
Accès aux propriétés n.prop |
✓ | n."prop" |
Paramètres $name |
✓ | Positionnel $N |
Paramètres hérités {name} |
✓ | Normalisé en $name au moment de l'analyse |
Comparaison =, <>, <, >, <=, >= |
✓ | Directe |
AND, OR, NOT |
✓ | Directe |
IS NULL / IS NOT NULL |
✓ | Directe |
IN [list] |
✓ | SQL IN ; la syntaxe entre crochets [...] de Cypher est réécrite en (...) |
STARTS WITH |
✓ | starts_with(col, val) |
ENDS WITH |
✓ | col LIKE CONCAT('%', val) |
CONTAINS |
✓ | strpos(col, val) > 0 |
=~ regex |
✓ | regexp_like(col, pattern) |
exists(n.prop) |
✓ | (n.prop) IS NOT NULL |
EXISTS { MATCH … } |
✓ | Sous-requête corrélée EXISTS (SELECT 1 FROM …) |
COUNT { MATCH … } |
✓ | Sous-requête corrélée (SELECT count(*) FROM …) |
COLLECT { MATCH … RETURN x } |
✓ | Sous-requête corrélée ARRAY(SELECT x FROM …) |
id(n) |
✓ | Résolu vers la colonne d'ID configurée du nœud |
labels(n) |
✓ | ARRAY['Label'] |
keys(n) |
✓ | ARRAY['prop1', 'prop2', …] |
type(r) |
✓ | Résolu au moment de la compilation vers le littéral de chaîne 'REL_TYPE' ; aucune colonne à l'exécution |
length(p) |
✓ | _t.hops pour les chemins de CTE récursive ; 1 pour les chemins de JOIN simple |
CASE WHEN … THEN … ELSE … END |
✓ | Directe (formes recherchée et simple) |
| GROUP BY implicite | ✓ | Les éléments RETURN non agrégés deviennent des clés de GROUP BY dès qu'un élément comporte un agrégat |
Projections de carte (map)¶
| Syntaxe | Correspondance SQL |
|---|---|
n { .prop1, .prop2 } |
MAP(ARRAY['prop1','prop2'], ARRAY[n."prop1",n."prop2"]) |
n { .* } |
MAP(ARRAY[all props...], ARRAY[n."col",...]) — développé à partir du schéma |
n { .*, extra: expr } |
Toutes les propriétés du schéma plus la clé nommée ; MAP combinée |
n { key: expr } |
MAP(ARRAY['key'], ARRAY[expr]) |
Fonctions d'agrégation¶
| Cypher | SQL |
|---|---|
count(*), count(x) |
directe |
count(DISTINCT x) |
count(DISTINCT x) |
collect(x) |
array_agg(x) |
avg, sum, min, max |
directe |
stDev(x) |
stddev_samp(x) |
stDevP(x) |
stddev_pop(x) |
percentileCont(x, p) |
approx_percentile(x, p) |
percentileDisc(x, p) |
approx_percentile(x, p) |
Fonctions de chaîne¶
| Cypher | SQL |
|---|---|
toLower(x) |
lower(x) |
toUpper(x) |
upper(x) |
ltrim(x), rtrim(x), trim(x) |
directe |
replace(x, a, b) |
directe |
reverse(x) |
directe |
split(x, d) |
directe |
left(x, n) |
left(x, n) |
right(x, n) |
right(x, n) |
substring(x, start, len) |
substr(x, start+1, len) (index 0→1) |
size(string) |
char_length(string) |
size(list) |
cardinality(list) |
Fonctions de conversion de type¶
| Cypher | SQL |
|---|---|
toString(x) |
CAST(x AS VARCHAR) |
toInteger(x) |
TRY_CAST(x AS BIGINT) |
toFloat(x) |
TRY_CAST(x AS DOUBLE) |
toBoolean(x) |
TRY_CAST(x AS BOOLEAN) |
toStringOrNull, toIntegerOrNull, toFloatOrNull, toBooleanOrNull |
variantes TRY_CAST |
Fonctions mathématiques¶
| Cypher | SQL |
|---|---|
log(x) |
ln(x) (logarithme naturel) |
log2(x) |
log2(x) |
range(start, end) |
sequence(start, end) |
abs, sqrt, ceil, floor, round, sign |
transmises telles quelles |
Fonctions de liste¶
| Cypher | SQL |
|---|---|
head(list) |
element_at(list, 1) |
last(list) |
element_at(list, -1) |
tail(list) |
slice(list, 2, cardinality(list)) |
isEmpty(list) |
cardinality(list) = 0 |
Compréhensions de liste¶
| Syntaxe | Correspondance SQL |
|---|---|
[x IN list \| f(x)] |
transform(list, x -> f(x)) |
[x IN list WHERE p(x)] |
filter(list, x -> p(x)) |
[x IN list WHERE p(x) \| f(x)] |
transform(filter(list, x -> p(x)), x -> f(x)) |
any(x IN list WHERE p(x)) |
any_match(list, x -> p(x)) |
all(x IN list WHERE p(x)) |
all_match(list, x -> p(x)) |
none(x IN list WHERE p(x)) |
none_match(list, x -> p(x)) |
single(x IN list WHERE p(x)) |
cardinality(filter(list, x -> p(x))) = 1 |
reduce(acc = init, x IN list \| expr) |
reduce(list, init, (acc, x) -> expr, acc -> acc) |
Compréhensions de modèle¶
| Syntaxe | Correspondance SQL |
|---|---|
[(a)-[:R]->(b) \| b.prop] |
ARRAY(SELECT b."prop" FROM ... WHERE a.fk = b.pk) |
[(a)-[]->(b:Label) \| b.prop] |
type inféré à partir de la couche sémantique ; même forme de sous-requête ARRAY |
Sous-requêtes CALL corrélées¶
CALL { WITH x MATCH (x)-[:R]->(n) RETURN n.prop AS alias } se traduit par CROSS JOIN LATERAL (SELECT n."prop" AS alias FROM ... WHERE x."pk" = n."fk"). (REQ-573) Règles :
- La variable de portée externe (
x) doit apparaître dansWITH - Plusieurs variables importées (
WITH a, b) sont prises en charge - La première relation du MATCH interne dont la source est une variable liée par lateral détermine le
FROMinterne et la condition de jointure - Les blocs
CALL { ... }de premier niveau non corrélés (sansWITH) sont pris en charge parcypher_calls_to_sql_list
Écritures¶
Cypher prend en charge trois modèles d'écriture via le endpoint /data/cypher, exécutés par provisa/cypher/write_translator.py. (REQ-818) [tool-verified : provisa/api/rest/cypher_router.py:415-545]
| Cypher | SQL | Exigence |
|---|---|---|
CREATE (n:Label {props}) |
INSERT INTO catalog.schema.table (cols) VALUES (vals) |
REQ-666 |
MATCH (n:Label) WHERE … DELETE n |
DELETE FROM catalog.schema.table WHERE … |
REQ-667 |
MATCH (n:Label) WHERE … SET n.prop = val, … |
UPDATE catalog.schema.table SET col = val, … WHERE … |
REQ-668 |
Les noms de propriété sont mappés aux colonnes par suppression du préfixe de domaine et résolution d'alias ; les valeurs scalaires Cypher sont converties vers le type de colonne cible. (REQ-666, REQ-668) Le corps de la réponse contient un compteur affected_rows. (REQ-670)
Règles :
- L'étiquette doit correspondre à exactement une table enregistrée. Les étiquettes ambiguës ou inconnues provoquent des erreurs bloquantes ; aucune correspondance approximative. (REQ-661) Aucune nouvelle étiquette ni aucun nouveau type ne peut être créé via Cypher. (REQ-662)
- Chaque écriture est conditionnée par la liste de contrôle d'accès
writable_byde la table cible ; un rôle sans droit d'écriture est rejeté au moment de la compilation. (REQ-663) - Le connecteur de la source sous-jacente doit prendre en charge le DML. Les sources en lecture seule (fédérées via Trino, Iceberg sans connecteur Delta) rejettent les écritures au moment de la traduction. (REQ-664)
- Les relations ne peuvent pas être écrites — elles sont dérivées de jointures par clé étrangère, et non stockées comme des arêtes. Cibler une relation constitue une erreur bloquante. (REQ-665)
- Les écritures traversent l'intégralité du pipeline d'écriture : injection RLS et hooks post-mutation (invalidation du cache de réponse, marquage des vues matérialisées comme obsolètes, événements de changement Kafka, rechargement des tables actives). (REQ-798)
MERGE,DETACH DELETEetREMOVEne sont pas pris en charge et sont rejetés au moment de l'analyse. (REQ-671)
Accès par protocole¶
Cypher atteint le même pipeline gouverné via deux transports :
- HTTP —
POST /data/cypheravec un corps JSON ({"query": "...", "params": {...}}). Renvoie des lignes typées, ouaffected_rowspour les écritures. Les variables de graphe de la clauseRETURNsont sérialisées en JSON : les nœuds portentid,label,tableLabeletproperties; les arêtes portentidentity,start,end,type,properties,startNodeetendNode; les chemins portentnodes,edgesetlength/hops. (REQ-750) Les commandes enregistrées sont également appelables ici viaCALL fn(args) YIELD col1, col2— les arguments positionnels correspondent aux noms d'arguments déclarés de la commande, dans l'ordre. (REQ-1156) [tool-verified :provisa/api/rest/registered_call.py:113-143] - Bolt — un serveur de protocole binaire compatible Neo4j (codec PackStream, découpage en trames) qui permet à Neo4j Browser, Bloom et aux pilotes Bolt d'exécuter des requêtes Cypher sur le graphe fédéré. (REQ-802) Il démarre lorsque
PROVISA_BOLT_PORTest défini avec une valeur non nulle et est désactivé par défaut ; définissezPROVISA_BOLT_CERT/PROVISA_BOLT_KEYpour le TLS. [tool-verified :provisa/api/app_startup.py:317-338] L'authentification Bolt fait correspondre le principal à un utilisateur et la base de données à un rôle :SHOW DATABASESliste une entrée par paire (vue × rôle), nomméeprovisa_<role>(domaines métier) ouprovisa_ops_<role>(avec les domaines system/meta/ops) ;:usesélectionne le rôle et la vue actifs. (REQ-807) Les relations reçoivent des ID entiers durables via une tablerel_ids, sur le modèle denode_ids. (REQ-806) Les commandes enregistrées sont appelables avecCALL command(args)— les arguments positionnels correspondent aux noms d'arguments déclarés, dans l'ordre ; les procéduresCALL dbms.*/CALL db.*sont prioritaires. (REQ-1156) [tool-verified :provisa/bolt/session.py:722-749]
Analytique de graphe¶
POST /data/graph-analytics exécute une requête Cypher, construit un graphe NetworkX en mémoire à partir des nœuds et arêtes obtenus, exécute un algorithme nommé, puis fusionne un dictionnaire _analytics dans chaque nœud et arête avant de les renvoyer en JSON avec un champ elapsed_ms. (REQ-642) Les clés de _analytics varient selon l'algorithme : la centralité produit score ; la détection de communautés produit cluster ; le k-core produit core_number ; la centralité de degré ajoute in_degree et out_degree. (REQ-643) Le endpoint rejette les graphes dépassant une taille configurable (10 000 nœuds / 50 000 arêtes par défaut) avec un code HTTP 413 ; Girvan-Newman est plafonné à 500 nœuds sauf si l'appelant transmet force=true. (REQ-650, REQ-651)
Limitations¶
Contraintes de conception¶
-
Les écritures se limitent à
CREATE,SETetDELETE. Elles s'exécutent comme des écritures directes en table via le même pipeline que les mutations GraphQL et SQL. (REQ-818, REQ-666, REQ-667, REQ-668) Voir §Écritures ci-dessus.MERGE,DETACH DELETEetREMOVEsont rejetés au moment de l'analyse. (REQ-671, REQ-818) Les procédures APOC sont également rejetées. -
Aucune propriété de relation. Les relations (
-[r:TYPE]->) existent uniquement comme métadonnées de jointure dans la couche sémantique. (REQ-574) Elles ne portent aucun attribut stocké, si bien queWHERE r.since > 2020ouRETURN r.weightn'ont aucun sens et ne sont pas pris en charge. -
Le parcours bidirectionnel
(a)-[]-(b)est réécrit sous forme d'UNION ALL direct + inverse de toutes les relations dirigées correspondantes de la couche sémantique. (REQ-575) Chaque relation de la couche sémantique est directionnelle ; la syntaxe bidirectionnelle est un sucre syntaxique qui se développe dans les deux directions. Les branches supplémentaires sont émises au niveau le plus externe de la requête — les modèles MATCH suivants dans la même requête ne sont pas dupliqués entre les branches (limitation pour le cas bidirectionnel multi-MATCH). -
Les chemins récursifs nécessitent une borne. Les modèles de longueur variable (
[*]) doivent inclure une borne supérieure (par exemple[*..10]). (REQ-348) Le parcours non borné est rejeté au moment de l'analyse afin d'éviter des CTE récursives incontrôlées.
Notes de comportement¶
-
shortestPathsur des chemins non autoréférentiels utilise un JOIN simple, pas un tri par nombre de sauts. Lorsque les types de départ et d'arrivée diffèrent et qu'aucune relation autoréférentielle n'existe dans le schéma, le traducteur émet une chaîne de JOIN simple (le chemin de schéma le plus court). (REQ-576) Il n'émet pas deORDER BY hops, car les sauts ne sont pas suivis dans ce chemin de code. Le résultat est le chemin de schéma structurellement le plus court, et non le chemin le plus court en données parmi plusieurs lignes. -
Plusieurs chemins de schéma produisent un
UNION ALL. Lorsque deux chemins de schéma comportant le même nombre de sauts relient les mêmes types de départ et d'arrivée (par exemplePerson -[WORKS_AT]-> CompanyetPerson -[MANAGES]-> Company), les deux sont émis sous forme de branchesUNION ALL. (REQ-577) La déduplication des lignes apparaissant dans les deux branches n'est pas effectuée. -
Un seul
RelationshipMappingpar combinaison de paire source→cible et de rel_type. Si deux champs GraphQL du même type source produisent la même chaînerel_type(après mise en majuscules) vers le même type cible, le second enregistrement écrase le premier dansCypherLabelMap.relationships. La clé de la relation inclut les noms des types source et cible ; des paires source/cible distinctes portant le même nom de type obtiennent donc chacune leur propre entrée et ne sont pas affectées. -
Les CTE de la clause
WITHsont nommées_w0,_w1, … (REQ-578) Les noms sont attribués de manière positionnelle au sein d'un seul appel de traduction. La composition de plusieurs requêtes traduites (par exemple dans un lot) peut produire des collisions de noms de CTE si elles sont concaténées sans précaution.
Couverture des expressions et des modèles (REQ-913)¶
Les expressions Cypher sont analysées en un AST puis réduites nœud par nœud en SQL (provisa/cypher/expr_parser.py, provisa/cypher/expr_visitor.py). La grammaire suit la tour de précédence oC_Expression d'openCypher. Pris en charge : littéraux, paramètres, accès aux propriétés, n.prop, index et découpe (slice), arithmétique (+ - * / % ^), comparaison, IN, STARTS WITH / ENDS WITH / CONTAINS / =~, IS [NOT] NULL, booléens AND / OR / XOR / NOT, CASE, littéraux de liste et de carte, compréhensions de liste et de modèle (y compris la liaison de chemin p = (…)), projection de carte, reduce, les quantificateurs all / any / none / single, les sous-requêtes existentielles et les appels de fonction.
-
Les étiquettes sont fixes ; il est impossible de créer des types d'objet via Cypher. Une étiquette se résout vers un domaine connu, un type d'objet connu, ou un
domain:object_typequalifié — l'ensemble fermé défini par le schéma enregistré. Cypher n'introduit jamais de nouvelle étiquette ni de nouveau type. La création d'instances n'est possible que pour des types déjà définis dans une source de données inscriptible ;CREATEécrit des lignes dans une telle table (voir §Écritures) mais ne peut pas définir de nouvelle étiquette ni de nouveau type. (REQ-662) Les deux formes d'étiquette sont acceptées et représentent le même test : la forme postfixéen:Labelet la forme longuen IS :Label(ainsi que leur négationn IS NOT :Label). Une étiquette qualifiée s'écritn:domain:object_type. -
shortestPathetallShortestPathsne sont pris en charge qu'à l'intérieur deMATCH, pas en tant qu'expressions. Dans un modèle (MATCH p = shortestPath((a:Person)-[:KNOWS*..5]->(b:Person))), ils se traduisent par une CTEWITH RECURSIVEet exigent des nœuds source et cible étiquetés. Utilisés en position d'expression — par exempleRETURN shortestPath((a)-[*]->(b))ouWHERE length(shortestPath((a)-[*]->(b))) < 5— ils ne sont pas pris en charge, car la réécriture récursive est pilotée par la clauseMATCHplutôt que par une sous-requête corrélée. -
Les compréhensions de liste,
REDUCEet les quantificateurs opèrent sur des valeurs de liste ; les compréhensions de modèle parcourent le graphe.reduce(...),all/any/none/single(...)et la compréhension de liste[x IN list | …]opèrent sur une expression de liste et se réduisent vers les fonctions de liste d'ordre supérieur du moteur — elles ne parcourent pas elles-mêmes le graphe. La compréhension de modèle[(a)-[:R]->(b) WHERE p | e]parcourt bien le graphe : son modèle de graphe est résolu comme une sous-requête corrélée ; il s'agit donc d'une compréhension dont la source est un parcours. Injectez les résultats du parcours dans les formes de liste avecnodes(p)/relationships(p)/collect(...), ou utilisez directement une compréhension de modèle.