diff --git a/Makefile b/Makefile index 2ea8f64ed..e03027b8e 100644 --- a/Makefile +++ b/Makefile @@ -199,6 +199,7 @@ REGRESS = scan \ expr \ cypher_create \ cypher_match \ + cypher_match_rel_label_alternation \ cypher_unwind \ cypher_set \ cypher_remove \ diff --git a/regress/expected/cypher_match_rel_label_alternation.out b/regress/expected/cypher_match_rel_label_alternation.out new file mode 100644 index 000000000..a119d2756 --- /dev/null +++ b/regress/expected/cypher_match_rel_label_alternation.out @@ -0,0 +1,185 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * Tests for relationship-type alternation in MATCH patterns: + * `[:A|B|C]` and the equivalent `[:A|:B|:C]` openCypher form. + */ +LOAD 'age'; +SET search_path TO ag_catalog; +SELECT create_graph('rel_label_alt'); +NOTICE: graph "rel_label_alt" has been created + create_graph +-------------- + +(1 row) + +SELECT create_elabel('rel_label_alt', 'WORKS_AT'); +NOTICE: ELabel "WORKS_AT" has been created + create_elabel +--------------- + +(1 row) + +SELECT create_elabel('rel_label_alt', 'REPORTS_TO'); +NOTICE: ELabel "REPORTS_TO" has been created + create_elabel +--------------- + +(1 row) + +SELECT create_elabel('rel_label_alt', 'OWNS'); +NOTICE: ELabel "OWNS" has been created + create_elabel +--------------- + +(1 row) + +SELECT * FROM cypher('rel_label_alt', $$ + CREATE (a:Person {n:'alice'})-[:WORKS_AT]->(:Company {n:'acme'}), + (a)-[:REPORTS_TO]->(:Person {n:'bob'}), + (a)-[:OWNS]->(:Asset {n:'car'}) +$$) AS (v agtype); + v +--- +(0 rows) + +-- Two-label alternation. Returns two rows ordered by destination name. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:WORKS_AT|REPORTS_TO]->(c) RETURN c.n ORDER BY c.n +$$) AS (n agtype); + n +-------- + "acme" + "bob" +(2 rows) + +-- Three-label alternation. All three outgoing edges match. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:WORKS_AT|REPORTS_TO|OWNS]->(c) RETURN c.n ORDER BY c.n +$$) AS (n agtype); + n +-------- + "acme" + "bob" + "car" +(3 rows) + +-- Redundant-colon openCypher form [:A|:B] is equivalent to [:A|B]. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:WORKS_AT|:REPORTS_TO]->(c) RETURN c.n ORDER BY c.n +$$) AS (n agtype); + n +-------- + "acme" + "bob" +(2 rows) + +-- Single-label edge is unchanged (regression check). +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:WORKS_AT]->(c) RETURN c.n +$$) AS (n agtype); + n +-------- + "acme" +(1 row) + +-- Edge variable + multi-label, projecting type(r). +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[r:WORKS_AT|REPORTS_TO]->(c) + RETURN type(r) AS t, c.n AS n ORDER BY n +$$) AS (t agtype, n agtype); + t | n +--------------+-------- + "WORKS_AT" | "acme" + "REPORTS_TO" | "bob" +(2 rows) + +-- Unknown labels in the alternation are silently ignored — only the +-- valid label contributes rows. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:WORKS_AT|DOES_NOT_EXIST]->(c) RETURN c.n +$$) AS (n agtype); + n +-------- + "acme" +(1 row) + +-- All labels in the alternation are unknown — yields zero rows, not an +-- error. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:DOES_NOT_EXIST|ALSO_MISSING]->(c) RETURN c.n +$$) AS (n agtype); + n +--- +(0 rows) + +-- Multi-label edge inside a two-edge path; checks the qual integrates +-- with the join-tree quals correctly. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (a:Person {n:'alice'})-[:WORKS_AT|OWNS]->(x) + RETURN x.n ORDER BY x.n +$$) AS (n agtype); + n +-------- + "acme" + "car" +(2 rows) + +-- Undirected alternation `-[:A|B]-` matches edges in either direction. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p:Person {n:'alice'})-[:WORKS_AT|REPORTS_TO]-(c) RETURN c.n ORDER BY c.n +$$) AS (n agtype); + n +-------- + "acme" + "bob" +(2 rows) + +-- Reverse-directed alternation `<-[:A|B]-`. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (b:Person {n:'bob'})<-[:WORKS_AT|REPORTS_TO]-(p) RETURN p.n ORDER BY p.n +$$) AS (n agtype); + n +--------- + "alice" +(1 row) + +-- Variable-length relationship combined with alternation is not yet +-- supported and must be rejected with a clear error. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:WORKS_AT|REPORTS_TO*1..2]->(c) RETURN c.n +$$) AS (n agtype); +ERROR: variable length relationships with type alternation is not yet supported +LINE 2: MATCH (p)-[:WORKS_AT|REPORTS_TO*1..2]->(c) RETURN c.n + ^ +SELECT drop_graph('rel_label_alt', true); +NOTICE: drop cascades to 8 other objects +DETAIL: drop cascades to table rel_label_alt._ag_label_vertex +drop cascades to table rel_label_alt._ag_label_edge +drop cascades to table rel_label_alt."WORKS_AT" +drop cascades to table rel_label_alt."REPORTS_TO" +drop cascades to table rel_label_alt."OWNS" +drop cascades to table rel_label_alt."Person" +drop cascades to table rel_label_alt."Company" +drop cascades to table rel_label_alt."Asset" +NOTICE: graph "rel_label_alt" has been dropped + drop_graph +------------ + +(1 row) + diff --git a/regress/sql/cypher_match_rel_label_alternation.sql b/regress/sql/cypher_match_rel_label_alternation.sql new file mode 100644 index 000000000..80483e730 --- /dev/null +++ b/regress/sql/cypher_match_rel_label_alternation.sql @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * Tests for relationship-type alternation in MATCH patterns: + * `[:A|B|C]` and the equivalent `[:A|:B|:C]` openCypher form. + */ +LOAD 'age'; +SET search_path TO ag_catalog; + +SELECT create_graph('rel_label_alt'); + +SELECT create_elabel('rel_label_alt', 'WORKS_AT'); +SELECT create_elabel('rel_label_alt', 'REPORTS_TO'); +SELECT create_elabel('rel_label_alt', 'OWNS'); + +SELECT * FROM cypher('rel_label_alt', $$ + CREATE (a:Person {n:'alice'})-[:WORKS_AT]->(:Company {n:'acme'}), + (a)-[:REPORTS_TO]->(:Person {n:'bob'}), + (a)-[:OWNS]->(:Asset {n:'car'}) +$$) AS (v agtype); + +-- Two-label alternation. Returns two rows ordered by destination name. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:WORKS_AT|REPORTS_TO]->(c) RETURN c.n ORDER BY c.n +$$) AS (n agtype); + +-- Three-label alternation. All three outgoing edges match. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:WORKS_AT|REPORTS_TO|OWNS]->(c) RETURN c.n ORDER BY c.n +$$) AS (n agtype); + +-- Redundant-colon openCypher form [:A|:B] is equivalent to [:A|B]. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:WORKS_AT|:REPORTS_TO]->(c) RETURN c.n ORDER BY c.n +$$) AS (n agtype); + +-- Single-label edge is unchanged (regression check). +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:WORKS_AT]->(c) RETURN c.n +$$) AS (n agtype); + +-- Edge variable + multi-label, projecting type(r). +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[r:WORKS_AT|REPORTS_TO]->(c) + RETURN type(r) AS t, c.n AS n ORDER BY n +$$) AS (t agtype, n agtype); + +-- Unknown labels in the alternation are silently ignored — only the +-- valid label contributes rows. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:WORKS_AT|DOES_NOT_EXIST]->(c) RETURN c.n +$$) AS (n agtype); + +-- All labels in the alternation are unknown — yields zero rows, not an +-- error. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:DOES_NOT_EXIST|ALSO_MISSING]->(c) RETURN c.n +$$) AS (n agtype); + +-- Multi-label edge inside a two-edge path; checks the qual integrates +-- with the join-tree quals correctly. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (a:Person {n:'alice'})-[:WORKS_AT|OWNS]->(x) + RETURN x.n ORDER BY x.n +$$) AS (n agtype); + +-- Undirected alternation `-[:A|B]-` matches edges in either direction. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p:Person {n:'alice'})-[:WORKS_AT|REPORTS_TO]-(c) RETURN c.n ORDER BY c.n +$$) AS (n agtype); + +-- Reverse-directed alternation `<-[:A|B]-`. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (b:Person {n:'bob'})<-[:WORKS_AT|REPORTS_TO]-(p) RETURN p.n ORDER BY p.n +$$) AS (n agtype); + +-- Variable-length relationship combined with alternation is not yet +-- supported and must be rejected with a clear error. +SELECT * FROM cypher('rel_label_alt', $$ + MATCH (p)-[:WORKS_AT|REPORTS_TO*1..2]->(c) RETURN c.n +$$) AS (n agtype); + +SELECT drop_graph('rel_label_alt', true); diff --git a/src/backend/nodes/cypher_outfuncs.c b/src/backend/nodes/cypher_outfuncs.c index 4a35be02f..1b5a718e4 100644 --- a/src/backend/nodes/cypher_outfuncs.c +++ b/src/backend/nodes/cypher_outfuncs.c @@ -259,6 +259,7 @@ void out_cypher_relationship(StringInfo str, const ExtensibleNode *node) WRITE_NODE_FIELD(varlen); WRITE_ENUM_FIELD(dir, cypher_rel_dir); WRITE_LOCATION_FIELD(location); + WRITE_NODE_FIELD(labels); } /* serialization function for the cypher_bool_const ExtensibleNode. */ diff --git a/src/backend/parser/cypher_clause.c b/src/backend/parser/cypher_clause.c index 147e3e74e..b124c7733 100644 --- a/src/backend/parser/cypher_clause.c +++ b/src/backend/parser/cypher_clause.c @@ -5293,6 +5293,75 @@ static A_Expr *filter_vertices_on_label_id(cypher_parsestate *cpstate, return makeSimpleA_Expr(AEXPR_OP, "=", (Node *)fc, (Node *)n, -1); } +/* + * Build a raw parser-node qual for relationship-type alternation patterns + * such as `[:A|B|C]`. Resolves each listed label against the graph catalog, + * keeps only those that exist as edge labels, and emits + * + * ag_catalog._extract_label_id(.id) IN (id_A, id_B, ...) + * + * which is semantically equivalent to `WHERE type(r) IN ['A','B',...]`. + * If no listed label resolves to an existing edge label, the qual + * short-circuits to FALSE. + * + * The pattern's `rel->label` is set to NULL by the parser action, so the + * edge variable is already resolved against the generic edge parent table; + * this qual narrows it back to the requested set. + */ +static Node *make_edge_label_alternation_qual(cypher_parsestate *cpstate, + transform_entity *entity) +{ + cypher_relationship *rel; + Node *id_field; + FuncCall *extract_id_fc; + List *id_consts = NIL; + ListCell *lc; + + Assert(entity != NULL && entity->type == ENT_EDGE); + rel = entity->entity.rel; + Assert(list_length(rel->labels) > 1); + + foreach (lc, rel->labels) + { + char *label_name = strVal((String *) lfirst(lc)); + label_cache_data *lcd; + A_Const *c; + + lcd = search_label_name_graph_cache(label_name, cpstate->graph_oid); + if (lcd == NULL || lcd->kind != LABEL_KIND_EDGE) + { + /* Unknown or non-edge label contributes no rows; keep walking + * the alternation so any valid sibling can still match. */ + continue; + } + + c = makeNode(A_Const); + c->val.ival.type = T_Integer; + c->val.ival.ival = lcd->id; + c->location = -1; + id_consts = lappend(id_consts, c); + } + + /* + * No listed label corresponds to an existing edge label. Emit a constant + * FALSE qual so the edge produces no rows but planning still succeeds. + */ + if (id_consts == NIL) + { + return make_bool_a_const(false); + } + + id_field = make_qual(cpstate, entity, AG_EDGE_COLNAME_ID); + + extract_id_fc = makeFuncCall(list_make2(makeString("ag_catalog"), + makeString("_extract_label_id")), + list_make1(id_field), + COERCE_EXPLICIT_CALL, -1); + + return (Node *) makeSimpleA_Expr(AEXPR_IN, "=", (Node *) extract_id_fc, + (Node *) id_consts, rel->location); +} + /* * Makes property constraint using indirection(s). This is an * alternative to using the containment operator (@>). @@ -5664,6 +5733,29 @@ static List *transform_match_path(cypher_parsestate *cpstate, Query *query, join_quals = make_path_join_quals(cpstate, entities); qual = list_concat(qual, join_quals); + /* + * For each edge in the pattern that used relationship-type alternation + * (`[:A|B|...]`), emit a synthetic qual restricting the edge's label to + * the listed set. Single-label and unlabeled edges are unaffected. + */ + { + ListCell *lc; + + foreach (lc, entities) + { + transform_entity *entity = lfirst(lc); + + if (entity->type == ENT_EDGE && + entity->entity.rel != NULL && + list_length(entity->entity.rel->labels) > 1) + { + qual = lappend(qual, + make_edge_label_alternation_qual(cpstate, + entity)); + } + } + } + /* construct the qual to prevent duplicate edges */ if (list_length(entities) > 3) { @@ -5687,6 +5779,20 @@ static transform_entity *transform_VLE_edge_entity(cypher_parsestate *cpstate, transform_entity *vle_entity = NULL; ParseNamespaceItem *pnsi; + /* + * Relationship-type alternation (`[:A|B*..]`) is not yet wired into the + * VLE path: the per-hop matcher built here ignores the new `labels` + * list, so silently accepting it would return all edges. Reject early + * until VLE matching learns to honour the alternation. + */ + if (list_length(rel->labels) > 1) + { + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("variable length relationships with type alternation is not yet supported"), + parser_errposition(&cpstate->pstate, rel->location))); + } + /* it better be a function call node */ Assert(IsA(rel->varlen, FuncCall)); diff --git a/src/backend/parser/cypher_gram.y b/src/backend/parser/cypher_gram.y index 71b95bde3..4813463ce 100644 --- a/src/backend/parser/cypher_gram.y +++ b/src/backend/parser/cypher_gram.y @@ -195,6 +195,7 @@ path_node path_relationship path_relationship_body properties_opt %type label_opt +%type rel_label_opt rel_labels /* expression */ %type expr expr_opt expr_atom expr_literal map list @@ -1479,30 +1480,52 @@ path_relationship: ; path_relationship_body: - '[' var_name_opt label_opt cypher_varlen_opt properties_opt ']' + '[' var_name_opt rel_label_opt cypher_varlen_opt properties_opt ']' { cypher_relationship *n; n = make_ag_node(cypher_relationship); n->name = $2; n->parsed_name = $2; - n->label = $3; - n->parsed_label = $3; + if (list_length($3) <= 1) + { + n->label = (list_length($3) == 1) + ? strVal(linitial($3)) : NULL; + n->parsed_label = n->label; + n->labels = NIL; + } + else + { + n->label = NULL; + n->parsed_label = NULL; + n->labels = $3; + } n->varlen = $4; n->use_equals = false; n->props = $5; $$ = (Node *)n; } - | '[' var_name_opt label_opt cypher_varlen_opt '='properties_opt ']' + | '[' var_name_opt rel_label_opt cypher_varlen_opt '='properties_opt ']' { cypher_relationship *n; n = make_ag_node(cypher_relationship); n->name = $2; n->parsed_name = $2; - n->label = $3; - n->parsed_label = $3; + if (list_length($3) <= 1) + { + n->label = (list_length($3) == 1) + ? strVal(linitial($3)) : NULL; + n->parsed_label = n->label; + n->labels = NIL; + } + else + { + n->label = NULL; + n->parsed_label = NULL; + n->labels = $3; + } n->varlen = $4; n->use_equals = true; n->props = $6; @@ -1519,6 +1542,7 @@ path_relationship_body: n->parsed_name = NULL; n->label = NULL; n->parsed_label = NULL; + n->labels = NIL; n->varlen = NULL; n->use_equals = false; n->props = NULL; @@ -1538,6 +1562,37 @@ label_opt: } ; +/* + * Relationship-type alternation: openCypher allows multiple edge labels + * to be listed inside a single edge pattern, e.g. `[:A|B|C]` or + * `[:A|:B|:C]`. Returns a List of String, NIL for no labels. + */ +rel_label_opt: + /* empty */ + { + $$ = NIL; + } + | rel_labels + { + $$ = $1; + } + ; + +rel_labels: + ':' label_name + { + $$ = list_make1(makeString($2)); + } + | rel_labels '|' label_name + { + $$ = lappend($1, makeString($3)); + } + | rel_labels '|' ':' label_name + { + $$ = lappend($1, makeString($4)); + } + ; + properties_opt: /* empty */ { diff --git a/src/include/nodes/cypher_nodes.h b/src/include/nodes/cypher_nodes.h index 5efbe95f7..c46725fac 100644 --- a/src/include/nodes/cypher_nodes.h +++ b/src/include/nodes/cypher_nodes.h @@ -161,7 +161,7 @@ typedef enum CYPHER_REL_DIR_RIGHT = 1 } cypher_rel_dir; -/* -[ name :label props ]- */ +/* -[ name :label|... props ]- */ typedef struct cypher_relationship { ExtensibleNode extensible; @@ -174,6 +174,21 @@ typedef struct cypher_relationship Node *varlen; /* variable length relationships (A_Indices) */ cypher_rel_dir dir; int location; + /* + * Relationship-type alternation: [:A|B|C]. NIL or a single-element + * list means the pattern carries at most one label; in that case + * `label`/`parsed_label` above are used as before. When the list has + * more than one element, `label` is NULL (so the edge resolves to the + * generic edge parent table) and the transform layer injects a + * type-filter qual equivalent to `type(rel) IN (...)`. + * + * NOTE: This field MUST stay at the end of the struct. Several call + * sites type-pun an edge through `cypher_node` (a union member) and + * read shared prefix fields such as `use_equals`/`props`; those fields + * must keep the same offsets as in cypher_node, so new fields can only + * be appended after the shared prefix. + */ + List *labels; /* List of String; >1 element enables alternation */ } cypher_relationship; /*