diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index f860c2b19..000000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,26 +0,0 @@ -PG11: -- base-branch: 'PG11' - -PG12: -- base-branch: 'PG12' - -PG13: -- base-branch: 'PG13' - -PG14: -- base-branch: 'PG14' - -PG15: -- base-branch: 'PG15' - -PG16: -- base-branch: 'PG16' - -PG17: -- base-branch: 'PG17' - -PG18: -- base-branch: 'PG18' - -master: -- base-branch: 'master' diff --git a/.github/workflows/installcheck.yaml b/.github/workflows/installcheck.yaml index 3ee97296c..e83300ad6 100644 --- a/.github/workflows/installcheck.yaml +++ b/.github/workflows/installcheck.yaml @@ -9,7 +9,12 @@ on: jobs: build: - runs-on: ubuntu-latest + # Pinned (not ubuntu-latest) so the Bison version stays fixed at 3.8.x. + # The Cypher GLR grammar pins exact conflict counts via %expect / %expect-rr + # in src/backend/parser/cypher_gram.y, and Bison treats %expect as exact-match: + # a different Bison version can report different counts and break the build. + # Freezing the runner image freezes Bison; bump both together, intentionally. + runs-on: ubuntu-24.04 steps: - name: Get latest commit id of PostgreSQL 18 @@ -28,6 +33,27 @@ jobs: sudo apt-get update sudo apt-get install -y build-essential libreadline-dev zlib1g-dev flex bison + - name: Verify Bison version (grammar conflict counts are pinned) + run: | + ver=$(bison --version | awk 'NR==1 {print $NF}') + if [ -z "$ver" ]; then + echo "::error::Could not determine Bison version from 'bison --version'." + echo "::error::Expected the first line to end with a version (e.g. '... 3.8.2')." + exit 1 + fi + echo "bison $ver" + case "$ver" in + 3.8.*) ;; + *) + echo "::error::Bison $ver != 3.8.x. The Cypher GLR grammar pins exact" + echo "::error::%expect / %expect-rr conflict counts in src/backend/parser/cypher_gram.y." + echo "::error::A new Bison version may report different counts. Re-run bison locally," + echo "::error::update the %expect/%expect-rr numbers (and the comment block), then bump" + echo "::error::the pinned runner image and this guard together." + exit 1 + ;; + esac + - name: Install PostgreSQL 18 and some extensions if: steps.pg18cache.outputs.cache-hit != 'true' run: | @@ -42,11 +68,13 @@ jobs: make PG_CONFIG=$HOME/pg18/bin/pg_config install -j$(nproc) > /dev/null - uses: actions/checkout@v3 + with: + fetch-depth: 100 - name: Build AGE id: build run: | - make PG_CONFIG=$HOME/pg18/bin/pg_config install -j$(nproc) + make PG_CONFIG=$HOME/pg18/bin/pg_config COPT=-Werror install -j$(nproc) - name: Pull and build pgvector id: pgvector diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index d5fc8c835..000000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: "Pull Request Labeler" -on: -- pull_request_target - -jobs: - triage: - permissions: - contents: write - pull-requests: write - issues: write - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Apply branch labels - uses: actions/labeler@v5.0.0 - - - name: Apply label based on author - if: | - contains('["jrgemignani", "dehowef", "eyab" "rafsun42", "Zainab-Saad", "MuhammadTahaNaveed"]', github.event.pull_request.user.login) - uses: actions/github-script@v7 - with: - script: | - const labelsToAdd = ['override-stale']; - github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: labelsToAdd - }); diff --git a/.github/workflows/nodejs-driver.yaml b/.github/workflows/nodejs-driver.yaml index c2da7fe60..379c769c9 100644 --- a/.github/workflows/nodejs-driver.yaml +++ b/.github/workflows/nodejs-driver.yaml @@ -22,9 +22,9 @@ jobs: run: docker compose up -d - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: latest + node-version: 20 - name: Install dependencies run: npm install diff --git a/.github/workflows/python-driver.yaml b/.github/workflows/python-driver.yaml index 860cbcf25..9d26e9e01 100644 --- a/.github/workflows/python-driver.yaml +++ b/.github/workflows/python-driver.yaml @@ -22,14 +22,14 @@ jobs: run: docker compose up -d - name: Set up python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install pre-requisites run: | sudo apt-get install python3-dev libpq-dev - pip install -r requirements.txt + pip install --no-binary psycopg -r requirements.txt - name: Build run: | @@ -40,3 +40,4 @@ jobs: python test_age_py.py -db "postgres" -u "postgres" -pass "agens" python test_networkx.py -db "postgres" -u "postgres" -pass "agens" python -m unittest -v test_agtypes.py + python -m unittest -v test_security.py diff --git a/.gitignore b/.gitignore index 03923b03e..98f4a7f9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.o +*.d *.so build.sh .idea @@ -15,3 +16,4 @@ __pycache__ **/apache_age_python.egg-info drivers/python/build +*.bc diff --git a/META.json b/META.json index bb63a0563..9ac550a87 100644 --- a/META.json +++ b/META.json @@ -2,7 +2,7 @@ "name": "ApacheAGE", "abstract": "Apache AGE is a PostgreSQL Extension that provides graph database functionality", "description": "Apache AGE is a PostgreSQL Extension that provides graph database functionality. AGE is an acronym for A Graph Extension, and is inspired by Bitnine's fork of PostgreSQL 10, AgensGraph, which is a multi-model database. The goal of the project is to create single storage that can handle both relational and graph model data so that users can use standard ANSI SQL along with openCypher, the Graph query language. A graph consists of a set of vertices (also called nodes) and edges, where each individual vertex and edge possesses a map of properties. A vertex is the basic object of a graph, that can exist independently of everything else in the graph. An edge creates a directed connection between two vertices. A graph database is simply composed of vertices and edges. This type of database is useful when the meaning is in the relationships between the data. Relational databases can easily handle direct relationships, but indirect relationships are more difficult to deal with in relational databases. A graph database stores relationship information as a first-class entity. Apache AGE gives you the best of both worlds, simultaneously.", - "version": "1.3.0", + "version": "1.8.0", "maintainer": [ "users@age.apache.org" ], @@ -10,15 +10,15 @@ "provides": { "ApacheAGE": { "abstract": "Apache AGE is a PostgreSQL Extension that provides graph database functionality", - "file": "age--1.3.0.sql", + "file": "age--1.8.0.sql", "docfile": "README.md", - "version": "1.3.0" + "version": "1.8.0" } }, "prereqs": { "runtime": { "requires": { - "PostgreSQL": "14.0.0" + "PostgreSQL": "18.0.0" } } }, diff --git a/Makefile b/Makefile index 394951ca0..2ea8f64ed 100644 --- a/Makefile +++ b/Makefile @@ -15,10 +15,99 @@ # specific language governing permissions and limitations # under the License. +# =========================================================================== +# Apache AGE extension build +# +# File layout (top to bottom): +# * Module +# * Upgrade regression-test support (1/2: variables) +# * Extension SQL & data files +# * Regression test suite (REGRESS / REGRESS_OPTS) +# * PGXS include +# * Build rules +# * Upgrade regression-test support (2/2: rules + installcheck lifecycle) +# * installcheck-existing (run against a running server) +# * help +# +# Common targets: +# all Build the extension (default) +# install Install into the PostgreSQL tree +# installcheck Run regression tests in a private temp instance +# installcheck-existing Run regression tests against a running server +# clean Remove build artifacts +# help Show the target list +# =========================================================================== + +# ===== Module ===== MODULE_big = age -age_sql = age--1.7.0.sql +# ===== Upgrade regression-test support (1/2: variables) ===== +# +# This feature spans two sections (the PGXS include forces the split): +# * 1/2 (here, pre-include): variables -- must be defined before DATA, +# REGRESS, and EXTRA_CLEAN reference them. +# * 2/2 (below the PGXS include): build rules + installcheck lifecycle. +# +# Validates the upgrade template (age----y.y.y.sql) by simulating an +# extension version upgrade entirely within "make installcheck". The test: +# +# 1. Builds the default install SQL (age--.sql) from current HEAD's +# sql/sql_files. This is what CREATE EXTENSION age installs. +# 2. Builds a synthetic "initial" version install SQL from the version-bump +# commit in git history. This represents the pre-upgrade state. +# 3. Stamps the upgrade template to upgrade from the initial version to the +# current version. +# 4. Temporarily installs the synthetic files into the PG extension directory +# so that CREATE EXTENSION age VERSION '' and ALTER EXTENSION +# age UPDATE TO '' can find them. +# 5. The age_upgrade regression test snapshots the ag_catalog schema from +# a fresh install, then installs at INIT, upgrades to CURR, and compares +# the catalog across seven system catalogs to detect missing, extra, +# or changed objects. +# 6. The test SQL cleans up the synthetic files via a generated shell script. +# +# This forces developers to keep the upgrade template in sync: any SQL object +# added after the version-bump commit must also appear in the template, or the +# upgrade test will fail (the object will be missing after ALTER EXTENSION UPDATE). +# +# Because the default install SQL comes from HEAD, all non-upgrade tests +# run with every SQL function registered — no functions are missing. +# +# Graceful degradation — the upgrade test is silently skipped when: +# - No git history (tarball build): AGE_VER_COMMIT is empty. +# - No upgrade template: age----y.y.y.sql does not exist. +# - A real (git-tracked) upgrade script from already exists +# (e.g., age--1.7.0--1.8.0.sql is committed): the synthetic test is +# redundant because the real script ships with the extension. +# Current version from age.control (e.g., "1.7.0") +AGE_CURR_VER := $(shell awk -F"'" '/^default_version/ {print $$2}' age.control 2>/dev/null) +# Git commit that last changed age.control — the "initial release" commit +AGE_VER_COMMIT := $(shell git log -1 --format=%H -- age.control 2>/dev/null) +# Synthetic initial version: current version with _initial suffix +AGE_INIT_VER := $(AGE_CURR_VER)_initial +# The upgrade template file (e.g., age--1.7.0--y.y.y.sql); empty if not present +AGE_UPGRADE_TEMPLATE := $(wildcard age--$(AGE_CURR_VER)--y.y.y.sql) + +# Synthetic filenames — these are NOT installed permanently; they are temporarily +# placed in $(SHAREDIR)/extension/ during installcheck and removed after. +age_init_sql = $(if $(AGE_INIT_VER),age--$(AGE_INIT_VER).sql) +age_upgrade_test_sql = $(if $(AGE_INIT_VER),age--$(AGE_INIT_VER)--$(AGE_CURR_VER).sql) +# Real (git-tracked, non-template) upgrade scripts FROM the current version. +# If any exist (e.g., age--1.7.0--1.8.0.sql is committed), the synthetic +# upgrade test is redundant because a real upgrade path already ships. +# Uses git ls-files so untracked synthetic files are NOT matched. +AGE_REAL_UPGRADE := $(shell git ls-files 'age--$(AGE_CURR_VER)--*.sql' 2>/dev/null | grep -v 'y\.y\.y') + +# Non-empty when ALL of these hold: +# 1. Git history is available (AGE_VER_COMMIT non-empty) +# 2. The upgrade template exists (AGE_UPGRADE_TEMPLATE non-empty) +# 3. No real upgrade script from current version exists (AGE_REAL_UPGRADE empty) +# When a real upgrade script ships, the test is skipped — the real script +# supersedes the synthetic one and has its own validation path. +AGE_HAS_UPGRADE_TEST = $(and $(AGE_VER_COMMIT),$(AGE_UPGRADE_TEMPLATE),$(if $(AGE_REAL_UPGRADE),,yes)) + +# ===== Object files ===== OBJS = src/backend/age.o \ src/backend/catalog/ag_catalog.o \ src/backend/catalog/ag_graph.o \ @@ -66,12 +155,18 @@ OBJS = src/backend/age.o \ src/backend/utils/ag_func.o \ src/backend/utils/graph_generation.o \ src/backend/utils/cache/ag_cache.o \ + src/backend/utils/cache/agehash.o \ src/backend/utils/load/ag_load_labels.o \ src/backend/utils/load/ag_load_edges.o \ src/backend/utils/load/age_load.o \ src/backend/utils/name_validation.o \ src/backend/utils/ag_guc.o +# Per-object header-dependency files (see "Automatic header-dependency +# tracking" below the PGXS include). One .d is generated beside each .o. +DEPFILES = $(OBJS:.o=.d) + +# ===== Extension SQL & data files ===== EXTENSION = age # to allow cleaning of previous (old) age--.sql files @@ -81,13 +176,24 @@ SQLS := $(shell cat sql/sql_files) SQLS := $(addprefix sql/,$(SQLS)) SQLS := $(addsuffix .sql,$(SQLS)) +# Name of the generated install SQL (age--.sql). +# Derived from AGE_CURR_VER (read from age.control above) so the version +# number lives in exactly one place. +age_sql = age--$(AGE_CURR_VER).sql + DATA_built = $(age_sql) +# Git-tracked upgrade scripts shipped with the extension (e.g., age--1.6.0--1.7.0.sql). +# Excludes the upgrade template (y.y.y) and the synthetic stamped test file. +DATA = $(filter-out age--%--y.y.y.sql $(age_upgrade_test_sql),$(wildcard age--*--*.sql)) + +# ===== Regression test suite ===== # sorted in dependency order REGRESS = scan \ graphid \ agtype \ agtype_hash_cmp \ + agehash \ catalog \ cypher \ expr \ @@ -99,6 +205,7 @@ REGRESS = scan \ cypher_delete \ cypher_with \ cypher_vle \ + age_shortest_path \ cypher_union \ cypher_call \ cypher_merge \ @@ -111,23 +218,53 @@ REGRESS = scan \ name_validation \ jsonb_operators \ list_comprehension \ + predicate_functions \ + pattern_expression \ + age_reduce \ map_projection \ direct_field_access \ - security + generated_columns \ + security \ + reserved_keyword_alias \ + agtype_jsonb_cast \ + containment_selectivity \ + subgraph \ + extension_security ifneq ($(EXTRA_TESTS),) REGRESS += $(EXTRA_TESTS) endif +# Extension upgrade test — included when git history is available, the upgrade +# template exists, and no real upgrade script from the current version is +# committed. Runs between "security" and "drop" in test order. +ifneq ($(AGE_HAS_UPGRADE_TEST),) + REGRESS += age_upgrade +endif + REGRESS += drop srcdir=`pwd` ag_regress_dir = $(srcdir)/regress -REGRESS_OPTS = --load-extension=age --inputdir=$(ag_regress_dir) --outputdir=$(ag_regress_dir) --temp-instance=$(ag_regress_dir)/instance --port=61958 --encoding=UTF-8 --temp-config $(ag_regress_dir)/age_regression.conf +REGRESS_OPTS = --load-extension=age \ + --inputdir=$(ag_regress_dir) \ + --outputdir=$(ag_regress_dir) \ + --temp-instance=$(ag_regress_dir)/instance \ + --port=61958 \ + --encoding=UTF-8 \ + --temp-config $(ag_regress_dir)/age_regression.conf ag_regress_out = instance/ log/ results/ regression.* -EXTRA_CLEAN = $(addprefix $(ag_regress_dir)/, $(ag_regress_out)) src/backend/parser/cypher_gram.c src/include/parser/cypher_gram_def.h src/include/parser/cypher_kwlist_d.h $(all_age_sql) +EXTRA_CLEAN = $(addprefix $(ag_regress_dir)/, $(ag_regress_out)) \ + src/backend/parser/cypher_gram.c \ + src/include/parser/cypher_gram_def.h \ + src/include/parser/cypher_kwlist_d.h \ + $(all_age_sql) \ + $(age_init_sql) \ + $(age_upgrade_test_sql) \ + $(ag_regress_dir)/age_upgrade_cleanup.sh \ + $(DEPFILES) GEN_KEYWORDLIST = $(PERL) -I ./tools/ ./tools/gen_keywordlist.pl GEN_KEYWORDLIST_DEPS = ./tools/gen_keywordlist.pl tools/PerfectHash.pm @@ -135,10 +272,30 @@ GEN_KEYWORDLIST_DEPS = ./tools/gen_keywordlist.pl tools/PerfectHash.pm ag_include_dir = $(srcdir)/src/include PG_CPPFLAGS = -I$(ag_include_dir) -I$(ag_include_dir)/parser +# ===== PGXS ===== PG_CONFIG ?= pg_config PGXS := $(shell $(PG_CONFIG) --pgxs) include $(PGXS) +# ===== Automatic header-dependency tracking ===== +# +# AGE lists OBJS explicitly, and PGXS's built-in .deps tracking only runs when +# the *server* was built with --enable-depend (often off). Without the lines +# below, editing a header does NOT rebuild the .c files that include it, leaving +# STALE .o files. This is especially dangerous for node/struct headers: a stale +# ag_nodes.o keeps an old node_size, so _readExtensibleNode under-allocates and +# readNode corrupts the heap ("unrecognized node type: "). +# +# The compiler emits a .d file next to each object (-MMD = user headers only; +# -MP adds phony targets so deleting a header does not break the build). With +# "-o foo.o", -MMD writes "foo.d" automatically (no -MF, no basename clashes). +# On servers that DO set --enable-depend, PGXS appends its own "-MF .deps/*.Po" +# after $(CFLAGS) (last -MF wins), so this degrades cleanly to that mechanism. +override CFLAGS += -MMD -MP +-include $(DEPFILES) + +# ===== Build rules ===== + # 32-bit platform support: pass SIZEOF_DATUM=4 to enable (e.g., make SIZEOF_DATUM=4) # When SIZEOF_DATUM=4, PASSEDBYVALUE is stripped from graphid type for pass-by-reference. # If not specified, normal 64-bit behavior is used (PASSEDBYVALUE preserved). @@ -150,22 +307,146 @@ src/include/parser/cypher_kwlist_d.h: src/include/parser/cypher_kwlist.h $(GEN_K src/include/parser/cypher_gram_def.h: src/backend/parser/cypher_gram.c -src/backend/parser/cypher_gram.c: BISONFLAGS += --defines=src/include/parser/cypher_gram_def.h +# +# The Cypher grammar uses GLR mode with a number of inherent shift/reduce +# and reduce/reduce conflicts arising from the ambiguity between +# parenthesized expressions and graph patterns (both start with '('). +# GLR handles these correctly at runtime by forking at the conflict +# point; %dprec annotations resolve cases where both forks succeed. +# +# We keep -Werror so any unexpected Bison warning (unused rules, undeclared +# types, etc.) still fails the build; we downgrade only the two conflict +# categories to plain warnings via -Wno-error=. The exact conflict totals +# are pinned by %expect / %expect-rr in cypher_gram.y, which Bison treats +# as exact-match: any deviation fails the build and forces an audit of +# the new conflicts. +# +src/backend/parser/cypher_gram.c: BISONFLAGS += --defines=src/include/parser/cypher_gram_def.h -Werror -Wno-error=conflicts-sr -Wno-error=conflicts-rr src/backend/parser/cypher_parser.o: src/backend/parser/cypher_gram.c src/include/parser/cypher_gram_def.h src/backend/parser/cypher_parser.bc: src/backend/parser/cypher_gram.c src/include/parser/cypher_gram_def.h src/backend/parser/cypher_keywords.o: src/backend/parser/cypher_gram.c src/include/parser/cypher_gram_def.h src/backend/parser/cypher_keywords.bc: src/backend/parser/cypher_gram.c src/include/parser/cypher_gram_def.h +src/backend/parser/ag_scanner.c: FLEX_NO_BACKUP=yes -# Strip PASSEDBYVALUE on 32-bit (SIZEOF_DATUM=4) for graphid pass-by-reference +# Build the default install SQL (age--.sql) from current HEAD's sql/sql_files. +# This is what CREATE EXTENSION age installs — it contains ALL current functions. +# Every non-upgrade regression test runs against this complete SQL. $(age_sql): $(SQLS) + @echo "Building install SQL: $@ from HEAD" @cat $(SQLS) > $@ ifeq ($(SIZEOF_DATUM),4) @echo "32-bit build: removing PASSEDBYVALUE from graphid type" @sed 's/^ PASSEDBYVALUE,$$/ -- PASSEDBYVALUE removed for 32-bit (see Makefile)/' $@ > $@.tmp && mv $@.tmp $@ - @grep -q 'PASSEDBYVALUE removed for 32-bit' $@ || { echo "Error: PASSEDBYVALUE replacement failed in $@"; exit 1; } endif -src/backend/parser/ag_scanner.c: FLEX_NO_BACKUP=yes +# ===== Upgrade regression-test support (2/2: rules + installcheck lifecycle) ===== +# +# Part 1/2 (variables) is above the PGXS include; the rules and target +# hooks below must follow the include. +# +# --- Synthetic SQL rules --- +# Build synthetic "initial" version install SQL from the version-bump commit. +# This represents the pre-upgrade state — the SQL at the time the version was +# bumped in age.control. Used only by the upgrade test. +ifneq ($(AGE_HAS_UPGRADE_TEST),) +$(age_init_sql): age.control + @echo "Building synthetic initial version install SQL: $@ from commit $(AGE_VER_COMMIT)" + @for f in $$(git show $(AGE_VER_COMMIT):sql/sql_files 2>/dev/null); do \ + git show $(AGE_VER_COMMIT):sql/$${f}.sql 2>/dev/null; \ + done > $@ +ifeq ($(SIZEOF_DATUM),4) + @sed 's/^ PASSEDBYVALUE,$$/ -- PASSEDBYVALUE removed for 32-bit (see Makefile)/' $@ > $@.tmp && mv $@.tmp $@ +endif + +# Stamp upgrade template as upgrade from initial to current version +$(age_upgrade_test_sql): $(AGE_UPGRADE_TEMPLATE) + @echo "Stamping upgrade template: $< -> $@ ($(AGE_INIT_VER) -> $(AGE_CURR_VER))" + @sed -e "s/1\.X\.0/$(AGE_CURR_VER)/g" -e "s/y\.y\.y/$(AGE_CURR_VER)/g" $< > $@ +endif + +# --- installcheck lifecycle: stage synthetic files, then clean up --- +# +# Problem: The upgrade test needs age--.sql and age----.sql +# in the PG extension directory for CREATE EXTENSION VERSION and ALTER +# EXTENSION UPDATE to find them, but we must not leave them installed +# permanently (they would confuse users). +# +# Solution: A Make prerequisite installs them before pg_regress runs, and the +# test SQL removes them at the end via \! (shell escape in psql). A generated +# cleanup script (regress/age_upgrade_cleanup.sh) contains the exact absolute +# paths so the removal works regardless of the working directory. EXTRA_CLEAN +# also removes them on "make clean" as a safety net. +# +# This adds a prerequisite to "installcheck" but does NOT override the PGXS +# recipe, so there are no Makefile warnings. +SHAREDIR = $(shell $(PG_CONFIG) --sharedir) installcheck: export LC_COLLATE=C +ifneq ($(AGE_HAS_UPGRADE_TEST),) +.PHONY: _install_upgrade_test_files +_install_upgrade_test_files: $(age_init_sql) $(age_upgrade_test_sql) # Build, install synthetic files, generate cleanup script + @echo "Installing upgrade test files to $(SHAREDIR)/extension/" + @$(INSTALL_DATA) $(age_init_sql) $(age_upgrade_test_sql) '$(SHAREDIR)/extension/' + @printf '#!/bin/sh\nrm -f "$(SHAREDIR)/extension/$(age_init_sql)" "$(SHAREDIR)/extension/$(age_upgrade_test_sql)"\nrm -f "$(age_init_sql)" "$(age_upgrade_test_sql)" "$(ag_regress_dir)/age_upgrade_cleanup.sh"\n' > $(ag_regress_dir)/age_upgrade_cleanup.sh + @chmod +x $(ag_regress_dir)/age_upgrade_cleanup.sh + +installcheck: _install_upgrade_test_files +endif + +# ===== installcheck-existing: run tests against a running server ===== +# +# Runs the regression suite against an already-running PostgreSQL server +# instead of the private temp instance built by "make installcheck". +# +# "make installcheck" appends --temp-instance to REGRESS_OPTS, so it builds +# its own throwaway cluster and needs no running server. This target instead +# connects to the server selected by the standard libpq environment variables +# (PGHOST/PGPORT/PGUSER); PGDATABASE defaults to contrib_regression. Override +# any of them on the command line, e.g.: +# +# make installcheck-existing PGHOST=localhost PGPORT=5432 PGUSER=postgres +# +# pg_regress creates the database and loads the extension itself through +# --load-extension=age -- exactly as the temp-instance path does -- so no +# manual "CREATE EXTENSION" step is required. The connecting role must be +# allowed to CREATE DATABASE. +# +# This deliberately does NOT pass pg_regress --use-existing: that option skips +# database creation (which also disables --load-extension) and is only needed +# on clusters where the test role cannot CREATE DATABASE. For that narrow +# case, pre-create the database and extension and add --use-existing to +# EXTRA_REGRESS_OPTS. +# +# The upgrade test (age_upgrade) is excluded here: it installs synthetic +# extension files into the local $(SHAREDIR), which an existing or remote +# server would not see. Validate the upgrade path with "make installcheck". +# +# Locale note: locale-sensitive comparisons follow the existing server's own +# collation (fixed at its initdb time); the temp-instance locale flags do not +# apply to an already-running server. +PGDATABASE ?= contrib_regression +REGRESS_EXISTING = $(filter-out age_upgrade,$(REGRESS)) + +.PHONY: installcheck-existing +installcheck-existing: + $(pg_regress_installcheck) \ + --inputdir=$(ag_regress_dir) \ + --outputdir=$(ag_regress_dir) \ + --load-extension=age \ + $(if $(PGHOST),--host=$(PGHOST)) \ + $(if $(PGPORT),--port=$(PGPORT)) \ + $(if $(PGUSER),--user=$(PGUSER)) \ + --dbname=$(PGDATABASE) \ + $(REGRESS_EXISTING) + +# ===== Help ===== +.PHONY: help +help: + @echo "Apache AGE - common make targets:" + @echo " all Build the extension (default target)" + @echo " install Install the extension into the PostgreSQL tree" + @echo " installcheck Run the regression suite in a private temp instance" + @echo " installcheck-existing Run the regression suite against a running server" + @echo " clean Remove build artifacts" + @echo " help Show this message" diff --git a/README.md b/README.md index dad7f1032..262f06c02 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@   - +   @@ -215,7 +215,115 @@ LOAD 'age'; SET search_path = ag_catalog, "$user", public; ``` +### Note on `ag_catalog` ownership +AGE installs all of its objects into the `ag_catalog` schema. Install AGE +(`CREATE EXTENSION age`) **before** granting the `CREATE` privilege on the +database to other roles. A role that can create schemas could otherwise +pre-create `ag_catalog` and own it; `CREATE EXTENSION age` therefore refuses to +install when `ag_catalog` already exists and is owned by a different role. If you +hit that error, drop the stray schema (`DROP SCHEMA ag_catalog CASCADE`) or +transfer its ownership to the installing role, then retry. + +

  Using AGE with Non-Autocommit Clients (psycopg, JDBC, etc.)

+ +If you are using AGE from a database client that does **not** default to autocommit — most commonly `psycopg` v3 or JDBC — you must understand how PostgreSQL's transaction semantics apply to AGE's setup and DDL-like functions. Otherwise, you may see graphs or labels that appear to be created successfully, but are not visible from new connections. + +This is **not** a bug in AGE — it is standard PostgreSQL behavior. AGE's DDL-like functions write to the catalog, and catalog writes only become visible to other sessions after the enclosing transaction is committed. + +### What is and isn't transactional + +| Statement | Scope | Needs commit to be visible elsewhere? | +|---|---|---| +| `LOAD 'age'` | Session-local (loads the .so into the current backend) | No | +| `SET search_path = ag_catalog, "$user", public` | Session-local | No | +| `SELECT create_graph('g')` | **Writes** to `ag_graph` and creates a schema | **Yes** | +| `SELECT create_vlabel('g', 'L')` / `create_elabel(...)` | **Writes** to `ag_label` and creates a table | **Yes** | +| `SELECT drop_graph('g', true)` / `drop_label(...)` | **Writes** to catalog | **Yes** | +| `SELECT load_labels_from_file(...)` / `load_edges_from_file(...)` | **Writes** to catalog + data | **Yes** | +| `cypher('g', $$ CREATE (:L {...}) $$)` | **Writes** data | **Yes** | + +In a client that defaults to autocommit (e.g. `psql`), every statement commits automatically, so this is never noticed. In a non-autocommit client, the first statement you run implicitly opens a transaction that stays open until you call `commit()`, `rollback()`, or close the connection. + +### psycopg v3 — the "savepoint gotcha" + +The common pitfall is that `with connection.transaction():` in psycopg does **not** start a new top-level transaction when one is already open — it creates a **savepoint** inside the existing outer transaction. Releasing a savepoint is not a commit, so your `create_graph` write stays invisible to other sessions until the outer transaction is explicitly committed. + +#### ❌ Broken: graph is not visible from a new connection + +```python +import psycopg + +params = {"host": "localhost", "port": 5432, "user": "postgres", + "password": "pw", "dbname": "mydb"} + +# --- First connection --- +conn = psycopg.connect(**params) +conn.execute("LOAD 'age'") # implicitly opens a txn +conn.execute("SET search_path = ag_catalog, '$user', public") + +with conn.transaction(), conn.cursor() as cur: # <-- SAVEPOINT, not a real txn + cur.execute("SELECT * FROM create_graph('my_graph')") +# outer transaction is STILL OPEN here + +conn.close() # outer transaction is rolled back on close → my_graph is gone + +# --- New connection --- +conn = psycopg.connect(**params) +conn.execute("LOAD 'age'") +conn.execute("SET search_path = ag_catalog, '$user', public") +with conn.cursor() as cur: + cur.execute("SELECT name FROM ag_graph;") + # 'my_graph' is NOT in the results +``` + +#### ✅ Fix 1: explicit `commit()` after setup + +```python +conn = psycopg.connect(**params) +conn.execute("LOAD 'age'") +conn.execute("SET search_path = ag_catalog, '$user', public") +conn.commit() # <-- closes the implicit outer txn + +with conn.transaction(), conn.cursor() as cur: + cur.execute("SELECT * FROM create_graph('my_graph')") +# this transaction block is now top-level and commits on exit +conn.close() +``` + +#### ✅ Fix 2: enable autocommit on the connection + +```python +conn = psycopg.connect(**params, autocommit=True) +conn.execute("LOAD 'age'") +conn.execute("SET search_path = ag_catalog, '$user', public") +conn.execute("SELECT * FROM create_graph('my_graph')") # commits immediately +conn.close() +``` + +You can also toggle autocommit at runtime with `conn.set_autocommit(True)`. + +### JDBC + +JDBC connections also default to autocommit **true** per the JDBC spec, but many frameworks (Spring, etc.) flip it off. If you are running AGE DDL-like calls from JDBC, either: + +```java +connection.setAutoCommit(true); +// ... LOAD 'age'; SET search_path ...; SELECT create_graph(...); +``` + +or keep autocommit off and explicitly commit after DDL-like calls: + +```java +stmt.execute("LOAD 'age'"); +stmt.execute("SET search_path = ag_catalog, \"$user\", public;"); +stmt.execute("SELECT create_graph('my_graph');"); +connection.commit(); // make the graph visible to other sessions +``` + +### Rule of thumb + +> If an AGE call creates, drops, or modifies a graph, label, vertex, edge, or property, it is a **transactional write**. In a non-autocommit client, it will not be visible to other sessions until you explicitly `commit()`.

  Quick Start

diff --git a/RELEASE b/RELEASE index 4e8b09032..7776b6d50 100644 --- a/RELEASE +++ b/RELEASE @@ -15,41 +15,90 @@ # specific language governing permissions and limitations # under the License. -Release Notes for Apache AGE release 1.7.0 for PG18 branch +Release Notes for Apache AGE release 1.8.0 for master branch (currently PG18) -Apache AGE 1.7.0 - Release Notes +# +# WARNING! +# +# Always back up your database before executing any upgrade scripts +# +# WARNING! +# -NOTE: There isn't an upgrade path for PostgreSQL PG18 1.7.0 as - there isn't a prior version. +Apache AGE 1.8.0 - Release Notes -Advance PG18 branch to Apache AGE version 1.7.0 -Add RLS support and fix permission checks (#2309) -Replace libcsv with pg COPY for csv loading (#2310) -Fix Issue 1884: Ambiguous column reference (#2306) -Upgrade Jest to v29 for node: protocol compatibility (#2307) -Optimize vertex/edge field access with direct array indexing (#2302) -feat: Add 32-bit platform support for graphid type (#2286) -Fix and improve index.sql addendum (#2301) -Fix and improve index.sql regression test coverage (#2300) -Fix Issue 2289: handle empty list in IN expression (#2294) -Revise README for Python driver updates (#2298) -Makefile: fix race condition on cypher_gram_def.h (#2273) -Restrict age_load commands (#2274) -Migrate python driver configuration to pyproject.toml (#2272) -Convert string to raw string to remove invalid escape sequence warning (#2267) -Update grammar file for maintainability (#2270) -Fix ORDER BY alias resolution with AS in Cypher queries (#2269) -Fix possible memory and file descriptors leaks (#2258) -Adjust 'could not find rte for' ERROR message (#2266) -Fix Issue 2256: segmentation fault when calling coalesce function (#2259) -Add index on id columns (#2117) -Fix issue 2245 - Creating more than 41 vlabels causes crash in drop_graph (#2248) -Fix DockerHub build warning messages (#2252) -Update CI for BaseDockerizedTest (#2254) -PG18 port for AGE (#2251) -Updated CI, Labeler, Docker, and branch security files for PG18 (#2246) -Fix issue 2243 - Regression in string concatenation (#2244) -Add fast functions for checking edge uniqueness (#2227) -Bump gopkg.in/yaml.v3 from 3.0.0 to 3.0.1 in /drivers/golang (#2212) -Update branch security (#2219) -Fix issue with CALL/YIELD for user defined and qualified functions. (#2217) +Fix installcheck fetch-depth parameter to 100 (#2461) +Fix segfault on CREATE/MERGE/SET with generated or extra label columns (#2458) +Propagate null through unnest and single() three-valued logic (#2406) +Add automatic header-dependency tracking to the Makefile (#2454) +ci: pin Build/Regression runner to ubuntu-24.04 and guard Bison version for GLR grammar (#2445) +Fix segfault and out-of-bounds reads in file loaders on malformed rows (#2453) +Support outer references in reduce() fold bodies (#2448) +Fix stack overflow and precision loss in toFloatList() conversion (#2451) +Fix Node.js driver CI build broken by @types/node drift (#2452) +Preserve null-valued keys in map literals (#2391) (#2412) +Fix single-node labeled pattern expressions not filtering by label (#2443) (#2444) +Support relationship-type filters and a minimum hop count (#2442) +resolve subgraph staging sequences via regclass (#2446) +Add reduce() list folding function (#2435) +Support pattern expressions as boolean expressions (#2360) +Add shortest_path / all_shortest_paths SRFs (#2430) +cypher_with: add ORDER BY to non-deterministic RETURN queries (#2436) +Make ag_catalog ownership and built-in resolution explicit (#2440) +Makefile: add installcheck-existing target and improve readability (#2437) +age_global_graph: stabilize regression tests (#2431) +cypher_vle: add ORDER BY to non-deterministic RETURN queries (#2434) +feature: add create_subgraph() (#2441) +Make age extension usable from shared_preload_libraries (#2438) +Fix locale-dependent string comparison in direct_field_access test (#2439) +Fix VLE [*0..N] zero-hop self-binding when edge label is missing (#2382) (#2419) +fix: remove pthread_mutex that causes self-deadlock on VLE queries (#2433) +Restore contsel/contjoinsel for containment & key-existence operators (#2356) (#2417) +perf: VLE hash-adjacency overhaul — agehash + flat-array VertexEdgeArray (#2421) +python driver: preserve trailing quotes in agtype string values (#2425) +perf(agtype): arena passthrough + binary-direct id extraction (#2424) +Add vertex and edge composite types with direct field access optimization (#2303) +perf: VLE terminal-qual rewrite (#2420) +Add agtype <-> jsonb bidirectional casts (#350) (#2361) +feat(python-driver): add public API for connection pooling and model dict conversion (#2374) +Fix upgrade test: allow function removal (#2422) +Zero-initialize parent_cpstate in analyze_cypher (#2423) +Fix upgrade test: replace data-integrity checks with catalog comparison (#2403) +Allow safe Cypher reserved keywords in alias positions (#2355) (#2415) +Propagate null through agtype arithmetic operators (#2405) +Update README.md (#2414) +Add VLE semantics and cost model design note (#2349) (#2413) +Fix property access on list comprehension / predicate loop variables (#2402) +Add MERGE ON CREATE SET / ON MATCH SET support (#2347) +Propagate null in agtype_access_slice bounds (#2400) +VLE cache + performance improvements (#2376) +CI: fail build on compiler and bison warnings (#2398) +fix(python-driver): add null-guards in ANTLR parser and relax runtime version pin (#2372) +Return an empty list from tail() for empty and singleton lists (#2399) +Fix substring() crash when start-offset is NULL and length is supplied (#2401) +fix(python-driver): quote-escape column aliases in buildCypher() (#2373) +Implement predicate functions: all(), any(), none(), single() (#2359) +Fix OPTIONAL MATCH dropping null-preserving rows with subquery WHERE (#2380) +Python driver: Add `skip_load` parameter to skip `LOAD 'age'` statement (#2366) +Fix upgrade test: build default install SQL from HEAD, not initial commit (#2397) +Improve extension upgrade regression test (addendum to #2364) (#2377) +Add missing include for PR: Add index scan (#2351) (#2379) +Add index scan (#2351) +Fix MATCH on brand-new label after CREATE returning 0 rows (#2341) +Fix nondeterministic age_global_graph regression test (#2365) +Add extension upgrade template regression test (#2364) +Fix crash in PREPARE with property parameter when enable_containment is off (#2339) +Fix entity_exists() CID visibility for CREATE + WITH + MERGE (#2343) +Support doubled-quote escaping in Cypher string literals (issue #2222) (#2342) +Fix MATCH after CREATE returning 0 rows (issue #2308) (#2340) +Fix chained MERGE not seeing sibling MERGE's changes (#1446) (#2344) +Fix VLE queries failing on read-only replicas (#2160) (#2345) +Fix VLE NULL handling for chained OPTIONAL MATCH (#2337) +fix incorrect variable assignment (#2336) +Remove labeler github action (#2335) +Fix ISO C90 forbids mixed declarations and code warning (#2334) +Fix null pointer handling in array iteration (#2313) +Fix security vulnerabilities in Node.js driver (#2329) +Update python-driver security and formatting (#2330) +Fix JDBC driver CI test failures (#2333) +Add pg_upgrade support functions for PostgreSQL (#2326) diff --git a/age--1.6.0--1.7.0.sql b/age--1.6.0--1.7.0.sql new file mode 100644 index 000000000..768cb6f1b --- /dev/null +++ b/age--1.6.0--1.7.0.sql @@ -0,0 +1,142 @@ +/* + * 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. + */ + +-- This will only work within a major version of PostgreSQL, not across +-- major versions. + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "ALTER EXTENSION age UPDATE TO '1.7.0'" to load this file. \quit + +CREATE FUNCTION ag_catalog._ag_enforce_edge_uniqueness2(graphid, graphid) + RETURNS bool + LANGUAGE c + STABLE +PARALLEL SAFE +as 'MODULE_PATHNAME'; + +CREATE FUNCTION ag_catalog._ag_enforce_edge_uniqueness3(graphid, graphid, graphid) + RETURNS bool + LANGUAGE c + STABLE +PARALLEL SAFE +as 'MODULE_PATHNAME'; + +CREATE FUNCTION ag_catalog._ag_enforce_edge_uniqueness4(graphid, graphid, graphid, graphid) + RETURNS bool + LANGUAGE c + STABLE +PARALLEL SAFE +as 'MODULE_PATHNAME'; + +-- Create indexes on id columns for existing labels +-- Vertex labels get PRIMARY KEY on id, Edge labels get indexes on start_id/end_id +DO $$ +DECLARE + label_rec record; + schema_name text; + table_name text; + idx_exists boolean; + pk_exists boolean; + idx_name text; +BEGIN + FOR label_rec IN + SELECT l.relation, l.kind + FROM ag_catalog.ag_label l + LOOP + SELECT n.nspname, c.relname INTO schema_name, table_name + FROM pg_class c + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE c.oid = label_rec.relation; + + IF label_rec.kind = 'e' THEN + -- Edge: check/create index on start_id + SELECT EXISTS ( + SELECT 1 FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0] + WHERE i.indrelid = label_rec.relation + AND a.attname = 'start_id' + AND i.indpred IS NULL -- not a partial index + AND i.indexprs IS NULL -- not an expression index + AND am.amname = 'btree' -- btree access method + ) INTO idx_exists; + + IF NOT idx_exists THEN + EXECUTE format('CREATE INDEX %I ON %I.%I USING btree (start_id)', + table_name || '_start_id_idx', schema_name, table_name); + END IF; + + -- Edge: check/create index on end_id + SELECT EXISTS ( + SELECT 1 FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0] + WHERE i.indrelid = label_rec.relation + AND a.attname = 'end_id' + AND i.indpred IS NULL -- not a partial index + AND i.indexprs IS NULL -- not an expression index + AND am.amname = 'btree' -- btree access method + ) INTO idx_exists; + + IF NOT idx_exists THEN + EXECUTE format('CREATE INDEX %I ON %I.%I USING btree (end_id)', + table_name || '_end_id_idx', schema_name, table_name); + END IF; + ELSE + -- Vertex: check/create PRIMARY KEY on id + SELECT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = label_rec.relation AND contype = 'p' + ) INTO pk_exists; + + IF NOT pk_exists THEN + -- Check if a usable unique index on id already exists + SELECT c.relname INTO idx_name + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + WHERE i.indrelid = label_rec.relation + AND i.indisunique = true + AND i.indpred IS NULL -- not a partial index + AND i.indexprs IS NULL -- not an expression index + AND am.amname = 'btree' -- btree access method + AND i.indnkeyatts = 1 -- single column index + AND EXISTS ( + SELECT 1 FROM pg_attribute a + WHERE a.attrelid = i.indrelid + AND a.attnum = i.indkey[0] + AND a.attname = 'id' + ) + LIMIT 1; + + IF idx_name IS NOT NULL THEN + -- Reuse existing unique index for primary key + EXECUTE format('ALTER TABLE %I.%I ADD CONSTRAINT %I PRIMARY KEY USING INDEX %I', + schema_name, table_name, table_name || '_pkey', idx_name); + ELSE + -- Create new primary key + EXECUTE format('ALTER TABLE %I.%I ADD PRIMARY KEY (id)', + schema_name, table_name); + END IF; + END IF; + END IF; + END LOOP; +END $$; diff --git a/age--1.7.0--1.8.0.sql b/age--1.7.0--1.8.0.sql new file mode 100644 index 000000000..fe4c9a5f2 --- /dev/null +++ b/age--1.7.0--1.8.0.sql @@ -0,0 +1,1120 @@ +/* + * 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. + */ + +-- This will only work within a major version of PostgreSQL, not across +-- major versions. + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "ALTER EXTENSION age UPDATE TO '1.8.0'" to load this file. \quit + +-- +-- pg_upgrade support functions +-- +-- These functions help users upgrade PostgreSQL major versions using pg_upgrade +-- while preserving Apache AGE graph data. +-- + +CREATE FUNCTION ag_catalog.age_prepare_pg_upgrade() + RETURNS void + LANGUAGE plpgsql + -- Resolve built-in functions and operators from pg_catalog first so they + -- are not overridden by same-named objects defined in ag_catalog. The + -- ag_catalog objects referenced here are schema-qualified. + SET search_path = pg_catalog, ag_catalog + AS $function$ +DECLARE + graph_count integer; +BEGIN + -- Check if namespace column is already oid type (already prepared) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'ag_catalog' + AND table_name = 'ag_graph' + AND column_name = 'namespace' + AND data_type = 'oid' + ) THEN + RAISE NOTICE 'Database already prepared for pg_upgrade (namespace is oid type).'; + RETURN; + END IF; + + -- Drop existing backup table if it exists (from a previous failed attempt) + DROP TABLE IF EXISTS public._age_pg_upgrade_backup; + + -- Create backup table with graph names mapped to namespace names + -- We store nspname directly (not regnamespace::text) to avoid quoting issues + -- Names survive pg_upgrade while OIDs don't + CREATE TABLE public._age_pg_upgrade_backup AS + SELECT + g.graphid AS old_graphid, + g.name AS graph_name, + n.nspname AS namespace_name + FROM ag_catalog.ag_graph g + JOIN pg_namespace n ON n.oid = g.namespace::oid; + + SELECT count(*) INTO graph_count FROM public._age_pg_upgrade_backup; + + RAISE NOTICE 'Created backup table public._age_pg_upgrade_backup with % graph(s)', graph_count; + + -- Even with zero graphs, we still need to convert the column type + -- because the regnamespace type itself blocks pg_upgrade + + -- Drop the existing regnamespace-based index + DROP INDEX IF EXISTS ag_catalog.ag_graph_namespace_index; + + -- Convert namespace column from regnamespace to oid + ALTER TABLE ag_catalog.ag_graph + ALTER COLUMN namespace TYPE oid USING namespace::oid; + + -- Recreate the index with oid type + CREATE UNIQUE INDEX ag_graph_namespace_index + ON ag_catalog.ag_graph USING btree (namespace); + + -- Create a view for backward-compatible display of namespace as schema name + CREATE OR REPLACE VIEW ag_catalog.ag_graph_view AS + SELECT graphid, name, namespace::regnamespace AS namespace + FROM ag_catalog.ag_graph; + + RAISE NOTICE 'Successfully prepared database for pg_upgrade.'; + RAISE NOTICE 'The ag_graph.namespace column has been converted from regnamespace to oid.'; + RAISE NOTICE 'You can now run pg_upgrade.'; + RAISE NOTICE 'After pg_upgrade completes, run: SELECT age_finish_pg_upgrade();'; +END; +$function$; + +COMMENT ON FUNCTION ag_catalog.age_prepare_pg_upgrade() IS +'Prepares an AGE database for pg_upgrade by converting ag_graph.namespace from regnamespace to oid type. Run this before pg_upgrade.'; + +CREATE FUNCTION ag_catalog.age_finish_pg_upgrade() + RETURNS void + LANGUAGE plpgsql + -- Resolve built-in functions and operators from pg_catalog first so they + -- are not overridden by same-named objects defined in ag_catalog. The + -- ag_catalog objects referenced here are schema-qualified. + SET search_path = pg_catalog, ag_catalog + AS $function$ +DECLARE + mapping_count integer; + updated_labels integer; + updated_graphs integer; +BEGIN + -- Check if backup table exists + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = '_age_pg_upgrade_backup' + ) THEN + RAISE EXCEPTION 'Backup table public._age_pg_upgrade_backup not found. ' + 'Did you run age_prepare_pg_upgrade() before pg_upgrade?'; + END IF; + + -- Check if namespace column is oid type (was properly prepared) + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'ag_catalog' + AND table_name = 'ag_graph' + AND column_name = 'namespace' + AND data_type = 'oid' + ) THEN + RAISE EXCEPTION 'ag_graph.namespace is not oid type. ' + 'Did you run age_prepare_pg_upgrade() before pg_upgrade?'; + END IF; + + -- Create temporary mapping table with old and new OIDs + CREATE TEMP TABLE _graphid_mapping AS + SELECT + b.old_graphid, + b.graph_name, + n.oid AS new_graphid + FROM public._age_pg_upgrade_backup b + JOIN pg_namespace n ON n.nspname = b.namespace_name; + + GET DIAGNOSTICS mapping_count = ROW_COUNT; + + -- Verify all backup rows were mapped (detect missing schemas) + DECLARE + backup_count integer; + BEGIN + SELECT count(*) INTO backup_count FROM public._age_pg_upgrade_backup; + IF mapping_count < backup_count THEN + RAISE EXCEPTION 'Only % of % graphs could be mapped. Some schema names may have changed or been dropped.', + mapping_count, backup_count; + END IF; + END; + + -- Handle zero-graph case (still need to restore schema) + IF mapping_count = 0 THEN + RAISE NOTICE 'No graphs to remap (empty backup table).'; + DROP TABLE _graphid_mapping; + -- Skip to schema restoration + ELSE + RAISE NOTICE 'Found % graph(s) to remap', mapping_count; + + -- Temporarily drop foreign key constraint + ALTER TABLE ag_catalog.ag_label DROP CONSTRAINT IF EXISTS fk_graph_oid; + + -- Update ag_label.graph references to use new OIDs + UPDATE ag_catalog.ag_label l + SET graph = m.new_graphid + FROM _graphid_mapping m + WHERE l.graph = m.old_graphid; + + GET DIAGNOSTICS updated_labels = ROW_COUNT; + RAISE NOTICE 'Updated % label record(s)', updated_labels; + + -- Update ag_graph.graphid and ag_graph.namespace to new OIDs + UPDATE ag_catalog.ag_graph g + SET graphid = m.new_graphid, + namespace = m.new_graphid + FROM _graphid_mapping m + WHERE g.graphid = m.old_graphid; + + GET DIAGNOSTICS updated_graphs = ROW_COUNT; + RAISE NOTICE 'Updated % graph record(s)', updated_graphs; + + -- Restore foreign key constraint + ALTER TABLE ag_catalog.ag_label + ADD CONSTRAINT fk_graph_oid + FOREIGN KEY(graph) REFERENCES ag_catalog.ag_graph(graphid); + + -- Clean up temporary mapping table + DROP TABLE _graphid_mapping; + + RAISE NOTICE 'Successfully completed pg_upgrade OID remapping.'; + END IF; + + -- + -- Restore original schema (revert namespace to regnamespace) + -- + RAISE NOTICE 'Restoring original schema...'; + + -- Drop the view (no longer needed with regnamespace) + DROP VIEW IF EXISTS ag_catalog.ag_graph_view; + + -- Drop the existing oid-based index + DROP INDEX IF EXISTS ag_catalog.ag_graph_namespace_index; + + -- Convert namespace column back to regnamespace + ALTER TABLE ag_catalog.ag_graph + ALTER COLUMN namespace TYPE regnamespace USING namespace::regnamespace; + + -- Recreate the index with regnamespace type + CREATE UNIQUE INDEX ag_graph_namespace_index + ON ag_catalog.ag_graph USING btree (namespace); + + RAISE NOTICE 'Successfully restored ag_graph.namespace to regnamespace type.'; + + -- + -- Invalidate AGE's internal caches by touching each graph's namespace + -- AGE registers a syscache callback on NAMESPACEOID, so altering a schema + -- triggers cache invalidation. This ensures cypher queries work immediately + -- without requiring a session reconnect. + -- + -- We use xact-level advisory lock (auto-released at transaction end) + -- and preserve original schema ownership. + -- + RAISE NOTICE 'Invalidating AGE caches...'; + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('age_finish_pg_upgrade')); + DECLARE + graph_rec RECORD; + cache_invalidated boolean := false; + BEGIN + FOR graph_rec IN + SELECT n.nspname AS ns_name, r.rolname AS owner_name + FROM ag_catalog.ag_graph g + JOIN pg_namespace n ON n.oid = g.namespace + JOIN pg_roles r ON r.oid = n.nspowner + LOOP + BEGIN + -- Touch schema by changing owner to current_user then back to original + -- This triggers cache invalidation without permanently changing ownership + EXECUTE pg_catalog.format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, current_user); + EXECUTE pg_catalog.format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, graph_rec.owner_name); + cache_invalidated := true; + EXCEPTION WHEN insufficient_privilege THEN + -- If we can't change ownership, skip this schema + -- The cache will be invalidated on first use anyway + RAISE NOTICE 'Could not invalidate cache for schema % (insufficient privileges)', graph_rec.ns_name; + END; + END LOOP; + IF NOT cache_invalidated AND (SELECT count(*) FROM ag_catalog.ag_graph) > 0 THEN + RAISE NOTICE 'Cache invalidation skipped. You may need to reconnect for cypher queries to work.'; + END IF; + END; + + -- Now that all steps succeeded, clean up the backup table + DROP TABLE IF EXISTS public._age_pg_upgrade_backup; + + RAISE NOTICE ''; + RAISE NOTICE 'pg_upgrade complete. All graph data has been preserved.'; +END; +$function$; + +COMMENT ON FUNCTION ag_catalog.age_finish_pg_upgrade() IS +'Completes pg_upgrade by remapping stale OIDs and restoring the original schema. Run this after pg_upgrade.'; + +CREATE FUNCTION ag_catalog.age_revert_pg_upgrade_changes() + RETURNS void + LANGUAGE plpgsql + -- Resolve built-in functions and operators from pg_catalog first so they + -- are not overridden by same-named objects defined in ag_catalog. The + -- ag_catalog objects referenced here are schema-qualified. + SET search_path = pg_catalog, ag_catalog + AS $function$ +BEGIN + -- Check if namespace column is oid type (needs reverting) + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'ag_catalog' + AND table_name = 'ag_graph' + AND column_name = 'namespace' + AND data_type = 'oid' + ) THEN + RAISE NOTICE 'ag_graph.namespace is already regnamespace type. Nothing to revert.'; + RETURN; + END IF; + + -- Drop the view (no longer needed with regnamespace) + DROP VIEW IF EXISTS ag_catalog.ag_graph_view; + + -- Drop the existing oid-based index + DROP INDEX IF EXISTS ag_catalog.ag_graph_namespace_index; + + -- Convert namespace column back to regnamespace + ALTER TABLE ag_catalog.ag_graph + ALTER COLUMN namespace TYPE regnamespace USING namespace::regnamespace; + + -- Recreate the index with regnamespace type + CREATE UNIQUE INDEX ag_graph_namespace_index + ON ag_catalog.ag_graph USING btree (namespace); + + -- + -- Invalidate AGE's internal caches by touching each graph's namespace + -- We use xact-level advisory lock and preserve original ownership + -- + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('age_revert_pg_upgrade')); + DECLARE + graph_rec RECORD; + BEGIN + FOR graph_rec IN + SELECT n.nspname AS ns_name, r.rolname AS owner_name + FROM ag_catalog.ag_graph g + JOIN pg_namespace n ON n.oid = g.namespace + JOIN pg_roles r ON r.oid = n.nspowner + LOOP + BEGIN + -- Touch schema by changing owner to current_user then back to original + EXECUTE pg_catalog.format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, current_user); + EXECUTE pg_catalog.format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, graph_rec.owner_name); + EXCEPTION WHEN insufficient_privilege THEN + RAISE NOTICE 'Could not invalidate cache for schema % (insufficient privileges)', graph_rec.ns_name; + END; + END LOOP; + END; + + RAISE NOTICE 'Successfully reverted ag_graph.namespace to regnamespace type.'; + RAISE NOTICE ''; + RAISE NOTICE 'Upgrade preparation has been cancelled.'; + RAISE NOTICE 'You may want to drop the backup table: DROP TABLE IF EXISTS public._age_pg_upgrade_backup;'; +END; +$function$; + +COMMENT ON FUNCTION ag_catalog.age_revert_pg_upgrade_changes() IS +'Reverts schema changes if you need to cancel after age_prepare_pg_upgrade() but before pg_upgrade. Not needed after age_finish_pg_upgrade().'; + +CREATE FUNCTION ag_catalog.age_pg_upgrade_status() + RETURNS TABLE ( + status text, + namespace_type text, + graph_count bigint, + backup_exists boolean, + message text + ) + LANGUAGE plpgsql + -- Resolve built-in functions and operators from pg_catalog first so they + -- are not overridden by same-named objects defined in ag_catalog. The + -- ag_catalog objects referenced here are schema-qualified. + SET search_path = pg_catalog, ag_catalog + AS $function$ +DECLARE + ns_type text; + g_count bigint; + backup_exists boolean; +BEGIN + -- Get namespace column type + SELECT data_type INTO ns_type + FROM information_schema.columns + WHERE table_schema = 'ag_catalog' + AND table_name = 'ag_graph' + AND column_name = 'namespace'; + + -- Get graph count + SELECT count(*) INTO g_count FROM ag_catalog.ag_graph; + + -- Check for backup table + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = '_age_pg_upgrade_backup' + ) INTO backup_exists; + + -- Determine status and message + IF ns_type = 'regnamespace' AND NOT backup_exists THEN + -- Normal state - ready for use, needs prep before pg_upgrade + RETURN QUERY SELECT + 'NORMAL'::text, + ns_type, + g_count, + backup_exists, + 'Run SELECT age_prepare_pg_upgrade(); before pg_upgrade'::text; + ELSIF ns_type = 'regnamespace' AND backup_exists THEN + -- Unusual state - backup exists but schema wasn't converted + RETURN QUERY SELECT + 'WARNING'::text, + ns_type, + g_count, + backup_exists, + 'Backup table exists but schema not converted. Run age_prepare_pg_upgrade() again.'::text; + ELSIF ns_type = 'oid' AND backup_exists THEN + -- Prepared and ready for pg_upgrade, or awaiting finish after pg_upgrade + RETURN QUERY SELECT + 'PREPARED - AWAITING FINISH'::text, + ns_type, + g_count, + backup_exists, + 'After pg_upgrade, run SELECT age_finish_pg_upgrade();'::text; + ELSE + -- oid type without backup - manually converted or partial state + RETURN QUERY SELECT + 'CONVERTED'::text, + ns_type, + g_count, + backup_exists, + 'Namespace is oid type. If upgrading, ensure backup table exists.'::text; + END IF; +END; +$function$; + +COMMENT ON FUNCTION ag_catalog.age_pg_upgrade_status() IS +'Returns the current pg_upgrade readiness status of the AGE installation.'; + +-- +-- VLE cache invalidation trigger function +-- Installed on graph label tables to catch SQL-level mutations +-- and increment the per-graph version counter for VLE cache invalidation. +-- +CREATE FUNCTION ag_catalog.age_invalidate_graph_cache() + RETURNS trigger + LANGUAGE c +AS 'MODULE_PATHNAME'; + +-- +-- Install the cache invalidation trigger on all pre-existing label tables. +-- New label tables created after this upgrade will get the trigger +-- automatically via label_commands.c. This DO block handles tables +-- that were created before the upgrade. +-- +DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN + SELECT n.nspname AS schema_name, c.relname AS table_name + FROM ag_catalog.ag_label l + JOIN pg_catalog.pg_class c ON c.oid = l.relation + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE l.name != '_ag_label_vertex' + AND l.name != '_ag_label_edge' + LOOP + -- Skip if trigger already exists on this table + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_trigger t + JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = r.schema_name + AND c.relname = r.table_name + AND t.tgname = '_age_cache_invalidate' + ) + THEN + EXECUTE pg_catalog.format( + 'CREATE TRIGGER _age_cache_invalidate ' + 'AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ' + 'ON %I.%I ' + 'FOR EACH STATEMENT ' + 'EXECUTE FUNCTION ag_catalog.age_invalidate_graph_cache()', + r.schema_name, r.table_name + ); + END IF; + END LOOP; +END; +$$; + +-- +-- agtype <-> jsonb bidirectional casts +-- + +-- agtype -> jsonb (explicit) +-- Uses json intermediate (agtype_to_json -> json::jsonb) because agtype +-- extends jsonb's binary format with types (AGTV_INTEGER, AGTV_FLOAT, +-- AGTV_VERTEX, AGTV_EDGE, AGTV_PATH) that jsonb does not recognize. +CREATE FUNCTION ag_catalog.agtype_to_jsonb(agtype) + RETURNS jsonb + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'SELECT ag_catalog.agtype_to_json($1)::jsonb'; + +CREATE CAST (agtype AS jsonb) + WITH FUNCTION ag_catalog.agtype_to_jsonb(agtype); + +-- jsonb -> agtype (explicit) +CREATE FUNCTION ag_catalog.jsonb_to_agtype(jsonb) + RETURNS agtype + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'SELECT $1::text::agtype'; + +CREATE CAST (jsonb AS agtype) + WITH FUNCTION ag_catalog.jsonb_to_agtype(jsonb); + +-- +-- S4: VLE SRF signature change +-- +-- The age_vle SRF now emits start_id and end_id as scalar graphid columns +-- alongside the existing `edges` column. This allows the cypher transformer +-- to rewrite terminal-edge match quals as plain integer equalities, +-- removing the per-row age_match_vle_terminal_edge and age_match_two_vle_edges +-- function calls from VLE query plans. Both qual functions are dropped. +-- +-- BREAKING CHANGE for any external SQL that called age_vle(...) directly +-- and relied on `RETURNS SETOF agtype`, or called age_match_vle_terminal_edge +-- / age_match_two_vle_edges directly. Internal AGE callers (the cypher +-- transformer) are not affected. +-- +DROP FUNCTION IF EXISTS ag_catalog.age_match_vle_terminal_edge(variadic "any"); +DROP FUNCTION IF EXISTS ag_catalog.age_match_two_vle_edges(agtype, agtype); + +DROP FUNCTION IF EXISTS ag_catalog.age_vle(agtype, agtype, agtype, agtype, + agtype, agtype, agtype); +DROP FUNCTION IF EXISTS ag_catalog.age_vle(agtype, agtype, agtype, agtype, + agtype, agtype, agtype, agtype); + +CREATE FUNCTION ag_catalog.age_vle(IN agtype, IN agtype, IN agtype, IN agtype, + IN agtype, IN agtype, IN agtype, + OUT edges agtype, + OUT start_id graphid, + OUT end_id graphid) + RETURNS SETOF record +LANGUAGE C +STABLE +CALLED ON NULL INPUT +PARALLEL UNSAFE +AS 'MODULE_PATHNAME'; + +CREATE FUNCTION ag_catalog.age_vle(IN agtype, IN agtype, IN agtype, IN agtype, + IN agtype, IN agtype, IN agtype, IN agtype, + OUT edges agtype, + OUT start_id graphid, + OUT end_id graphid) + RETURNS SETOF record +LANGUAGE C +STABLE +CALLED ON NULL INPUT +PARALLEL UNSAFE +AS 'MODULE_PATHNAME'; + +-- Unweighted (hop-count) shortest path between two vertices, computed over the +-- cached global graph adjacency via BFS. Returns a single path (0 or 1 rows). +-- Argument order mirrors the Cypher shortestPath() pattern +-- (a)-[:type*min_hops..max_hops]->(b): +-- (graph_name, start, end, edge_types, direction, min_hops, max_hops) +CREATE FUNCTION ag_catalog.age_shortest_path(IN agtype, IN agtype, IN agtype, + IN agtype DEFAULT NULL, + IN agtype DEFAULT NULL, + IN agtype DEFAULT NULL, + IN agtype DEFAULT NULL) + RETURNS SETOF agtype +LANGUAGE C +STABLE +CALLED ON NULL INPUT +PARALLEL UNSAFE +AS 'MODULE_PATHNAME'; + +-- All unweighted shortest paths between two vertices (one path per row). +-- Same argument order as age_shortest_path. +CREATE FUNCTION ag_catalog.age_all_shortest_paths(IN agtype, IN agtype, IN agtype, + IN agtype DEFAULT NULL, + IN agtype DEFAULT NULL, + IN agtype DEFAULT NULL, + IN agtype DEFAULT NULL) + RETURNS SETOF agtype +LANGUAGE C +STABLE +CALLED ON NULL INPUT +PARALLEL UNSAFE +AS 'MODULE_PATHNAME'; + +-- +-- Composite types for vertex and edge +-- +CREATE TYPE ag_catalog.vertex AS ( + id graphid, + label agtype, + properties agtype +); + +CREATE TYPE ag_catalog.edge AS ( + id graphid, + label agtype, + end_id graphid, + start_id graphid, + properties agtype +); + +-- +-- vertex/edge to agtype cast functions +-- +CREATE FUNCTION ag_catalog.vertex_to_agtype(vertex) + RETURNS agtype + LANGUAGE c + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +CREATE FUNCTION ag_catalog.edge_to_agtype(edge) + RETURNS agtype + LANGUAGE c + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +-- +-- Implicit casts from vertex/edge to agtype +-- +CREATE CAST (vertex AS agtype) + WITH FUNCTION ag_catalog.vertex_to_agtype(vertex) +AS IMPLICIT; + +CREATE CAST (edge AS agtype) + WITH FUNCTION ag_catalog.edge_to_agtype(edge) +AS IMPLICIT; + +CREATE FUNCTION ag_catalog.vertex_to_json(vertex) + RETURNS json + LANGUAGE c + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +CREATE FUNCTION ag_catalog.edge_to_json(edge) + RETURNS json + LANGUAGE c + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +CREATE CAST (vertex AS json) + WITH FUNCTION ag_catalog.vertex_to_json(vertex); + +CREATE CAST (edge AS json) + WITH FUNCTION ag_catalog.edge_to_json(edge); + +CREATE FUNCTION ag_catalog.vertex_to_jsonb(vertex) + RETURNS jsonb + LANGUAGE c + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +CREATE FUNCTION ag_catalog.edge_to_jsonb(edge) + RETURNS jsonb + LANGUAGE c + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +CREATE CAST (vertex AS jsonb) + WITH FUNCTION ag_catalog.vertex_to_jsonb(vertex); + +CREATE CAST (edge AS jsonb) + WITH FUNCTION ag_catalog.edge_to_jsonb(edge); + +-- +-- Equality operators for vertex and edge (compare by id) +-- +CREATE FUNCTION ag_catalog.vertex_eq(vertex, vertex) + RETURNS boolean + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS $$ SELECT $1.id = $2.id $$; + +CREATE OPERATOR = ( + FUNCTION = ag_catalog.vertex_eq, + LEFTARG = vertex, + RIGHTARG = vertex, + COMMUTATOR = =, + NEGATOR = <>, + RESTRICT = eqsel, + JOIN = eqjoinsel +); + +CREATE FUNCTION ag_catalog.vertex_ne(vertex, vertex) + RETURNS boolean + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS $$ SELECT $1.id <> $2.id $$; + +CREATE OPERATOR <> ( + FUNCTION = ag_catalog.vertex_ne, + LEFTARG = vertex, + RIGHTARG = vertex, + COMMUTATOR = <>, + NEGATOR = =, + RESTRICT = neqsel, + JOIN = neqjoinsel +); + +CREATE FUNCTION ag_catalog.edge_eq(edge, edge) + RETURNS boolean + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS $$ SELECT $1.id = $2.id $$; + +CREATE OPERATOR = ( + FUNCTION = ag_catalog.edge_eq, + LEFTARG = edge, + RIGHTARG = edge, + COMMUTATOR = =, + NEGATOR = <>, + RESTRICT = eqsel, + JOIN = eqjoinsel +); + +CREATE FUNCTION ag_catalog.edge_ne(edge, edge) + RETURNS boolean + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS $$ SELECT $1.id <> $2.id $$; + +CREATE OPERATOR <> ( + FUNCTION = ag_catalog.edge_ne, + LEFTARG = edge, + RIGHTARG = edge, + COMMUTATOR = <>, + NEGATOR = =, + RESTRICT = neqsel, + JOIN = neqjoinsel +); + +-- +-- Drop and recreate _label_name with new return type (cstring -> agtype) +-- +DROP FUNCTION IF EXISTS ag_catalog._label_name(oid, graphid); + +CREATE FUNCTION ag_catalog._label_name(graph_oid oid, graphid) + RETURNS agtype + LANGUAGE c + IMMUTABLE +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +-- +-- Drop and recreate _agtype_build_vertex with new signature (cstring -> agtype for label) +-- +DROP FUNCTION IF EXISTS ag_catalog._agtype_build_vertex(graphid, cstring, agtype); + +CREATE FUNCTION ag_catalog._agtype_build_vertex(graphid, agtype, agtype) + RETURNS agtype + LANGUAGE c + IMMUTABLE +CALLED ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +-- +-- Drop and recreate _agtype_build_edge with new signature (cstring -> agtype for label) +-- +DROP FUNCTION IF EXISTS ag_catalog._agtype_build_edge(graphid, graphid, graphid, cstring, agtype); + +CREATE FUNCTION ag_catalog._agtype_build_edge(graphid, graphid, graphid, + agtype, agtype) + RETURNS agtype + LANGUAGE c + IMMUTABLE +CALLED ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +-- +-- Helper function for optimized startNode/endNode +-- +CREATE FUNCTION ag_catalog._get_vertex_by_graphid(text, graphid) + RETURNS agtype + LANGUAGE c + STABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +-- Internal selftest for the agehash open-addressing hashtable. Returns "OK" +-- on success or "FAIL: ..." with a diagnostic message. Intended for the +-- agehash regression test only. +CREATE FUNCTION ag_catalog._agehash_self_test() + RETURNS text + LANGUAGE c + VOLATILE + PARALLEL UNSAFE +AS 'MODULE_PATHNAME'; + +-- +-- Issue #2356: rebind containment and key-existence operators to the +-- lightweight contsel / contjoinsel selectivity estimators. +-- +-- @>, <@, @>>, <<@, ?, ?|, ?& on agtype were bound to matchingsel / +-- matchingjoinsel. During planning matchingsel invokes the operator's +-- underlying function (agtype_contains) once per pg_statistic MCV entry and +-- histogram bin; with a realistic default_statistics_target that planning +-- cost dominates simple point queries (the regression reported in #2356). +-- +-- contsel / contjoinsel return fixed selectivity constants without calling the +-- operator function, so planning is constant-time. This is a deliberate +-- planning-speed vs. estimate-accuracy trade-off. Note it DIVERGES from +-- PostgreSQL core, which keeps jsonb's @>, <@, ?, ?|, ?& on matchingsel / +-- matchingjoinsel; it is an AGE-specific choice favoring workloads where these +-- operators appear in selective point lookups. +-- +ALTER OPERATOR ag_catalog.@>(agtype, agtype) + SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.<@(agtype, agtype) + SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.@>>(agtype, agtype) + SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.<<@(agtype, agtype) + SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?(agtype, text) + SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?(agtype, agtype) + SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?|(agtype, text[]) + SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?|(agtype, agtype) + SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?&(agtype, text[]) + SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?&(agtype, agtype) + SET (RESTRICT = contsel, JOIN = contjoinsel); + +-- +-- create_subgraph(): materialized subgraph extraction (see sql/age_subgraph.sql). +-- Induced-subgraph semantics matching Neo4j GDS gds.graph.filter(): a vertex is +-- kept iff node_filter holds ('*' = all); an edge is kept iff relationship_filter +-- holds AND both endpoints are kept. Produces a persistent, Cypher-queryable graph. +-- +CREATE FUNCTION ag_catalog.create_subgraph(new_graph name, + from_graph name, + node_filter text DEFAULT '*', + relationship_filter text DEFAULT '*') + RETURNS TABLE(node_count bigint, relationship_count bigint) + LANGUAGE plpgsql + VOLATILE + SET search_path = ag_catalog, pg_catalog + AS $function$ +DECLARE + from_oid oid; + new_oid oid; + v_node_count bigint := 0; + v_rel_count bigint := 0; + rec RECORD; + cypher_q text; + where_clause text; + dst_label_id int; + dst_seq_fqn text; + dst_relation text; + inserted bigint; + has_rows boolean; +BEGIN + -- Argument validation. + IF new_graph IS NULL THEN + RAISE EXCEPTION 'new graph name must not be NULL'; + END IF; + IF from_graph IS NULL THEN + RAISE EXCEPTION 'source graph name must not be NULL'; + END IF; + IF new_graph = from_graph THEN + RAISE EXCEPTION 'cannot extract a subgraph of "%" into itself', from_graph; + END IF; + + -- NULL predicate is treated as the '*' wildcard (keep all). + IF node_filter IS NULL THEN + node_filter := '*'; + END IF; + IF relationship_filter IS NULL THEN + relationship_filter := '*'; + END IF; + + -- The predicates are embedded into a dollar-quoted cypher() query using the + -- $age_subgraph$ tag; reject predicates that contain the tag to keep the + -- quoting unambiguous. + IF position('$age_subgraph$' IN node_filter) > 0 + OR position('$age_subgraph$' IN relationship_filter) > 0 THEN + RAISE EXCEPTION 'filter predicate must not contain the reserved token $age_subgraph$'; + END IF; + + -- Validate source graph exists. + SELECT graphid INTO from_oid + FROM ag_catalog.ag_graph WHERE name = from_graph; + IF from_oid IS NULL THEN + RAISE EXCEPTION 'graph "%" does not exist', from_graph; + END IF; + + -- Validate destination graph does not exist (create_graph also enforces + -- naming rules and uniqueness, but we give a clear early error). + IF EXISTS (SELECT 1 FROM ag_catalog.ag_graph WHERE name = new_graph) THEN + RAISE EXCEPTION 'graph "%" already exists', new_graph; + END IF; + + -- Create the destination graph (default labels are created automatically). + PERFORM ag_catalog.create_graph(new_graph); + + SELECT graphid INTO new_oid + FROM ag_catalog.ag_graph WHERE name = new_graph; + + -- Working sets / mapping (uniquely named to avoid colliding with user temps). + DROP TABLE IF EXISTS _ag_sg_kept_v; + DROP TABLE IF EXISTS _ag_sg_kept_e; + DROP TABLE IF EXISTS _ag_sg_vmap; + DROP TABLE IF EXISTS _ag_sg_vstage; + DROP TABLE IF EXISTS _ag_sg_estage; + + -- + -- Kept vertices: evaluate node_filter with AGE's Cypher engine. The node + -- variable `n` is bound exactly as in the spec; '*' selects all vertices. + -- + IF node_filter IS NULL OR btrim(node_filter) = '*' THEN + where_clause := ''; + ELSE + where_clause := ' WHERE ' || node_filter; + END IF; + cypher_q := 'MATCH (n)' || where_clause || ' RETURN id(n)'; + + EXECUTE format( + 'CREATE TEMP TABLE _ag_sg_kept_v ON COMMIT DROP AS ' + 'SELECT DISTINCT ag_catalog.agtype_to_graphid(vid) AS gid ' + 'FROM ag_catalog.cypher(%L, $age_subgraph$%s$age_subgraph$) AS (vid agtype)', + from_graph, cypher_q); + CREATE INDEX ON _ag_sg_kept_v (gid); + + -- + -- Kept edges: evaluate relationship_filter with AGE's Cypher engine. The + -- relationship variable `r` is bound exactly as in the spec. + -- + IF relationship_filter IS NULL OR btrim(relationship_filter) = '*' THEN + where_clause := ''; + ELSE + where_clause := ' WHERE ' || relationship_filter; + END IF; + cypher_q := 'MATCH ()-[r]->()' || where_clause || ' RETURN id(r)'; + + EXECUTE format( + 'CREATE TEMP TABLE _ag_sg_kept_e ON COMMIT DROP AS ' + 'SELECT DISTINCT ag_catalog.agtype_to_graphid(eid) AS gid ' + 'FROM ag_catalog.cypher(%L, $age_subgraph$%s$age_subgraph$) AS (eid agtype)', + from_graph, cypher_q); + CREATE INDEX ON _ag_sg_kept_e (gid); + + -- old -> new vertex id mapping (graphid is unique within a graph). + CREATE TEMP TABLE _ag_sg_vmap (old_id graphid PRIMARY KEY, + new_id graphid NOT NULL) ON COMMIT DROP; + + -- + -- PASS 1: copy kept vertices, label by label, assigning new graphids and + -- recording the old->new mapping for edge remapping. + -- + FOR rec IN + SELECT name, id, relation, seq_name + FROM ag_catalog.ag_label + WHERE graph = from_oid AND kind = 'v' + ORDER BY id + LOOP + -- Skip labels with no surviving vertices. Read ONLY this label's own + -- rows: AGE label tables use table inheritance (custom labels inherit + -- from _ag_label_vertex), so a plain scan of a parent would also return + -- its children and copy them twice. + EXECUTE format( + 'SELECT EXISTS (SELECT 1 FROM ONLY %s t ' + 'WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_v k WHERE k.gid = t.id))', + rec.relation::regclass::text) + INTO has_rows; + IF NOT has_rows THEN + CONTINUE; + END IF; + + -- Ensure the label exists in the destination graph. + IF rec.name <> '_ag_label_vertex' THEN + PERFORM 1 FROM ag_catalog.ag_label + WHERE graph = new_oid AND name = rec.name; + IF NOT FOUND THEN + EXECUTE format('SELECT ag_catalog.create_vlabel(%L, %L)', + new_graph, rec.name); + END IF; + END IF; + + SELECT id, seq_name, relation::regclass::text + INTO dst_label_id, dst_seq_fqn, dst_relation + FROM ag_catalog.ag_label + WHERE graph = new_oid AND name = rec.name; + dst_seq_fqn := format('%I.%I', new_graph, dst_seq_fqn); + + -- Stage surviving vertices with freshly generated ids in a real temp + -- table (single evaluation), then copy to the label table and record + -- the old->new mapping. A materialized stage avoids any ambiguity from + -- referencing a nextval-bearing CTE more than once. + DROP TABLE IF EXISTS _ag_sg_vstage; + EXECUTE format( + 'CREATE TEMP TABLE _ag_sg_vstage ON COMMIT DROP AS ' + 'SELECT t.id AS old_id, ' + ' ag_catalog._graphid(%s, nextval(%L::regclass)) AS new_id, ' + ' t.properties AS props ' + 'FROM ONLY %s t ' + 'WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_v k WHERE k.gid = t.id)', + dst_label_id, dst_seq_fqn, rec.relation::regclass::text); + + EXECUTE format('INSERT INTO %s (id, properties) ' + 'SELECT new_id, props FROM _ag_sg_vstage', dst_relation); + + INSERT INTO _ag_sg_vmap (old_id, new_id) + SELECT old_id, new_id FROM _ag_sg_vstage; + + DROP TABLE _ag_sg_vstage; + END LOOP; + + SELECT count(*) INTO v_node_count FROM _ag_sg_vmap; + + -- + -- PASS 2: copy kept edges, remapping endpoints. The joins to _ag_sg_vmap + -- enforce the induced rule (an edge survives only if BOTH endpoints were + -- kept); membership in _ag_sg_kept_e applies relationship_filter. + -- + FOR rec IN + SELECT name, id, relation, seq_name + FROM ag_catalog.ag_label + WHERE graph = from_oid AND kind = 'e' + ORDER BY id + LOOP + -- Skip labels with no surviving edges. Read ONLY this label's own rows + -- (see the vertex pass for why inheritance requires ONLY). + EXECUTE format( + 'SELECT EXISTS (' + ' SELECT 1 FROM ONLY %s x ' + ' JOIN _ag_sg_vmap vs ON vs.old_id = x.start_id ' + ' JOIN _ag_sg_vmap ve ON ve.old_id = x.end_id ' + ' WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_e k WHERE k.gid = x.id))', + rec.relation::regclass::text) + INTO has_rows; + IF NOT has_rows THEN + CONTINUE; + END IF; + + IF rec.name <> '_ag_label_edge' THEN + PERFORM 1 FROM ag_catalog.ag_label + WHERE graph = new_oid AND name = rec.name; + IF NOT FOUND THEN + EXECUTE format('SELECT ag_catalog.create_elabel(%L, %L)', + new_graph, rec.name); + END IF; + END IF; + + SELECT id, seq_name, relation::regclass::text + INTO dst_label_id, dst_seq_fqn, dst_relation + FROM ag_catalog.ag_label + WHERE graph = new_oid AND name = rec.name; + dst_seq_fqn := format('%I.%I', new_graph, dst_seq_fqn); + + -- Stage surviving edges, remapping endpoints through _ag_sg_vmap. The + -- joins enforce the induced rule (both endpoints kept); membership in + -- _ag_sg_kept_e applies relationship_filter. + DROP TABLE IF EXISTS _ag_sg_estage; + EXECUTE format( + 'CREATE TEMP TABLE _ag_sg_estage ON COMMIT DROP AS ' + 'SELECT ag_catalog._graphid(%s, nextval(%L::regclass)) AS new_id, ' + ' vs.new_id AS new_start, ve.new_id AS new_end, ' + ' x.properties AS props ' + 'FROM ONLY %s x ' + 'JOIN _ag_sg_vmap vs ON vs.old_id = x.start_id ' + 'JOIN _ag_sg_vmap ve ON ve.old_id = x.end_id ' + 'WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_e k WHERE k.gid = x.id)', + dst_label_id, dst_seq_fqn, rec.relation::regclass::text); + + EXECUTE format('INSERT INTO %s (id, start_id, end_id, properties) ' + 'SELECT new_id, new_start, new_end, props ' + 'FROM _ag_sg_estage', dst_relation); + GET DIAGNOSTICS inserted = ROW_COUNT; + v_rel_count := v_rel_count + inserted; + + DROP TABLE _ag_sg_estage; + END LOOP; + + RETURN QUERY SELECT v_node_count, v_rel_count; +END; +$function$; + +COMMENT ON FUNCTION ag_catalog.create_subgraph(name, name, text, text) IS +'Materializes a new persistent graph as the induced subgraph of from_graph selected by a Cypher node predicate (on n) and relationship predicate (on r); ''*'' keeps all. An edge is kept only if its predicate holds and both endpoints are kept. Returns (node_count, relationship_count).'; + +-- +-- reduce(acc = init, var IN list | body) fold support +-- +-- Transition function for the age_reduce aggregate. The fold body is compiled +-- by transform_cypher_reduce() with the accumulator and element rewritten to +-- PARAM_EXEC params 0 and 1 and serialized into the text argument; the +-- transition evaluates it for each element in list order. The trailing +-- agtype[] argument carries the loop-invariant outer values (outer-query +-- variables and cypher() parameters) referenced by the body, bound to +-- PARAM_EXEC params 2, 3, ... It must be callable with a NULL transition state +-- (no initcond), so it is intentionally not STRICT. +CREATE FUNCTION ag_catalog.age_reduce_transfn(agtype, agtype, text, agtype, agtype[]) + RETURNS agtype + LANGUAGE c +PARALLEL UNSAFE +AS 'MODULE_PATHNAME'; + +-- aggregate definition for reduce(); direct arguments are +-- (init, serialized-body, element, captured-outer-values), with the element +-- fed ORDER BY ordinality. +CREATE AGGREGATE ag_catalog.age_reduce(agtype, text, agtype, agtype[]) +( + stype = agtype, + sfunc = ag_catalog.age_reduce_transfn +); diff --git a/age--1.7.0--y.y.y.sql b/age--1.8.0--y.y.y.sql similarity index 93% rename from age--1.7.0--y.y.y.sql rename to age--1.8.0--y.y.y.sql index c95bb0002..4c85b28ca 100644 --- a/age--1.7.0--y.y.y.sql +++ b/age--1.8.0--y.y.y.sql @@ -17,6 +17,9 @@ * under the License. */ +-- This will only work within a major version of PostgreSQL, not across +-- major versions. + --* This is a TEMPLATE for upgrading from the previous version of Apache AGE --* Please adjust the below ALTER EXTENSION to reflect the -- correct version it --* is upgrading to. diff --git a/age.control b/age.control index 57f9fbc12..8b1d3aef2 100644 --- a/age.control +++ b/age.control @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -default_version = '1.7.0' +default_version = '1.8.0' comment = 'AGE database extension' module_pathname = '$libdir/age' diff --git a/docker/Dockerfile b/docker/Dockerfile index abdf0ccd0..22d912c46 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -49,7 +49,7 @@ ENV LC_COLLATE=en_US.UTF-8 ENV LC_CTYPE=en_US.UTF-8 COPY --from=build /usr/lib/postgresql/18/lib/age.so /usr/lib/postgresql/18/lib/ -COPY --from=build /usr/share/postgresql/18/extension/age--1.7.0.sql /usr/share/postgresql/18/extension/ +COPY --from=build /usr/share/postgresql/18/extension/age--1.8.0.sql /usr/share/postgresql/18/extension/ COPY --from=build /usr/share/postgresql/18/extension/age.control /usr/share/postgresql/18/extension/ COPY docker/docker-entrypoint-initdb.d/00-create-extension-age.sql /docker-entrypoint-initdb.d/00-create-extension-age.sql diff --git a/drivers/jdbc/lib/build.gradle.kts b/drivers/jdbc/lib/build.gradle.kts index 0b63bc5a6..4965c2a59 100644 --- a/drivers/jdbc/lib/build.gradle.kts +++ b/drivers/jdbc/lib/build.gradle.kts @@ -38,13 +38,17 @@ dependencies { testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation("org.testcontainers:testcontainers:1.18.0") + testImplementation("org.testcontainers:testcontainers:1.21.4") testImplementation("org.postgresql:postgresql:42.6.0") testImplementation("org.slf4j:slf4j-api:2.0.7") testImplementation("org.slf4j:slf4j-simple:2.0.7") } +tasks.withType { + options.encoding = "UTF-8" +} + tasks.generateGrammarSource { maxHeapSize = "64m" source = project.objects diff --git a/drivers/jdbc/lib/src/test/java/org/apache/age/jdbc/BaseDockerizedTest.java b/drivers/jdbc/lib/src/test/java/org/apache/age/jdbc/BaseDockerizedTest.java index d9caa4bb0..529dfc47b 100644 --- a/drivers/jdbc/lib/src/test/java/org/apache/age/jdbc/BaseDockerizedTest.java +++ b/drivers/jdbc/lib/src/test/java/org/apache/age/jdbc/BaseDockerizedTest.java @@ -21,6 +21,7 @@ import java.sql.DriverManager; import java.sql.Statement; +import java.time.Duration; import org.apache.age.jdbc.base.Agtype; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -28,6 +29,7 @@ import org.junit.jupiter.api.TestInstance.Lifecycle; import org.postgresql.jdbc.PgConnection; import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.DockerImageName; @TestInstance(Lifecycle.PER_CLASS) @@ -54,20 +56,19 @@ public void beforeAll() throws Exception { agensGraphContainer = new GenericContainer<>(DockerImageName .parse("apache/age:dev_snapshot_PG18")) .withEnv("POSTGRES_PASSWORD", CORRECT_DB_PASSWORDS) - .withExposedPorts(5432); + .withExposedPorts(5432) + .waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2) + .withStartupTimeout(Duration.ofSeconds(60))); agensGraphContainer.start(); + String host = agensGraphContainer.getHost(); int mappedPort = agensGraphContainer.getMappedPort(5432); String jdbcUrl = String - .format("jdbc:postgresql://%s:%d/%s", "localhost", mappedPort, "postgres"); + .format("jdbc:postgresql://%s:%d/%s?sslmode=disable", host, mappedPort, "postgres"); - try { - this.connection = DriverManager.getConnection(jdbcUrl, "postgres", CORRECT_DB_PASSWORDS) - .unwrap(PgConnection.class); - this.connection.addDataType("agtype", Agtype.class); - } catch (Exception e) { - System.out.println(e); - } + this.connection = DriverManager.getConnection(jdbcUrl, "postgres", CORRECT_DB_PASSWORDS) + .unwrap(PgConnection.class); + this.connection.addDataType("agtype", Agtype.class); try (Statement statement = connection.createStatement()) { statement.execute("CREATE EXTENSION IF NOT EXISTS age;"); statement.execute("LOAD 'age'"); diff --git a/drivers/nodejs/package.json b/drivers/nodejs/package.json index 6be11c780..d17aa3b32 100644 --- a/drivers/nodejs/package.json +++ b/drivers/nodejs/package.json @@ -30,10 +30,11 @@ "author": "Alex Kwak ", "dependencies": { "antlr4ts": "^0.5.0-alpha.4", - "pg": ">=6.0.0" + "pg": ">=8.0.0" }, "devDependencies": { "@types/jest": "^29.5.14", + "@types/node": "^20.19.0", "@types/pg": "^7.14.10", "@typescript-eslint/eslint-plugin": "^4.22.1", "@typescript-eslint/parser": "^4.22.1", diff --git a/drivers/nodejs/src/antlr4/CustomAgTypeListener.ts b/drivers/nodejs/src/antlr4/CustomAgTypeListener.ts index fceabee2b..6089ee994 100644 --- a/drivers/nodejs/src/antlr4/CustomAgTypeListener.ts +++ b/drivers/nodejs/src/antlr4/CustomAgTypeListener.ts @@ -92,7 +92,11 @@ class CustomAgTypeListener implements AgtypeListener, ParseTreeListener { } exitIntegerValue (ctx: IntegerValueContext): void { - const value = parseInt(ctx.text) + // Use BigInt for values that exceed Number.MAX_SAFE_INTEGER to + // prevent silent precision loss with large AGE graph IDs (64-bit). + const text = ctx.text + const num = Number(text) + const value = Number.isSafeInteger(num) ? num : BigInt(text) if (!this.pushIfArray(value)) { this.lastValue = value } diff --git a/drivers/nodejs/src/index.ts b/drivers/nodejs/src/index.ts index 416cc3da5..dad004a81 100644 --- a/drivers/nodejs/src/index.ts +++ b/drivers/nodejs/src/index.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Client } from 'pg' +import { Client, QueryResult, QueryResultRow } from 'pg' import pgTypes from 'pg-types' import { CharStreams, CommonTokenStream } from 'antlr4ts' import { AgtypeLexer } from './antlr4/AgtypeLexer' @@ -25,6 +25,122 @@ import { AgtypeParser } from './antlr4/AgtypeParser' import CustomAgTypeListener from './antlr4/CustomAgTypeListener' import { ParseTreeWalker } from 'antlr4ts/tree' +/** + * Valid graph name pattern based on Apache AGE's naming conventions + * (Neo4j/openCypher compatible). Allows ASCII letters, digits, + * underscores, plus dots and hyphens in middle positions. + * + * DESIGN NOTE: AGE's server-side validation (name_validation.h) uses + * full Unicode ID_Start/ID_Continue character classes, accepting names + * like 'mydätabase' or 'mydঅtabase'. This driver intentionally restricts + * to ASCII-only as a security hardening measure — it reduces the attack + * surface for homoglyph and encoding-based injection vectors. Server-side + * validation remains the authoritative check for Unicode names. + * + * Start: ASCII letter or underscore + * Middle: ASCII letter, digit, underscore, dot, or hyphen + * End: ASCII letter, digit, or underscore + */ +const VALID_GRAPH_NAME = /^[A-Za-z_][A-Za-z0-9_.\-]*[A-Za-z0-9_]$/ + +/** + * Valid label name pattern based on Apache AGE's naming rules. + * Labels follow stricter identifier rules than graph names — dots and + * hyphens are NOT permitted in label names. + * + * Note: ASCII-only restriction (see VALID_GRAPH_NAME design note above). + */ +const VALID_LABEL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** + * Valid SQL identifier pattern for column names and types in the + * query result AS clause. Standard PostgreSQL unquoted identifier rules. + * + * Note: ASCII-only restriction (see VALID_GRAPH_NAME design note above). + */ +const VALID_SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** + * Validates that a graph name conforms to Apache AGE's naming conventions + * and is safe for use in SQL queries. + * + * Graph names must: + * - Be at least 3 characters and at most 63 characters + * - Start with an ASCII letter or underscore + * - Contain only ASCII letters, digits, underscores, dots, and hyphens + * - End with an ASCII letter, digit, or underscore + * + * Note: This is intentionally stricter than AGE's server-side validation + * (which accepts Unicode letters). See VALID_GRAPH_NAME design note. + * + * @param graphName - The graph name to validate + * @throws Error if the graph name is invalid + */ +function validateGraphName (graphName: string): void { + if (!graphName || typeof graphName !== 'string') { + throw new Error('Graph name must be a non-empty string') + } + if (graphName.length < 3) { + throw new Error( + `Invalid graph name: '${graphName}'. Graph names must be at least 3 characters.` + ) + } + if (graphName.length > 63) { + throw new Error('Graph name must not exceed 63 characters (PostgreSQL name limit)') + } + if (!VALID_GRAPH_NAME.test(graphName)) { + throw new Error( + `Invalid graph name: '${graphName}'. Graph names must start with a letter ` + + 'or underscore, may contain letters, digits, underscores, dots, and hyphens, ' + + 'and must end with a letter, digit, or underscore.' + ) + } +} + +/** + * Validates that a label name conforms to Apache AGE's naming rules. + * Label names are stricter than graph names — only letters, digits, + * and underscores are permitted (no dots or hyphens). + * + * @param labelName - The label name to validate + * @throws Error if the label name is invalid + */ +function validateLabelName (labelName: string): void { + if (!labelName || typeof labelName !== 'string') { + throw new Error('Label name must be a non-empty string') + } + if (labelName.length > 63) { + throw new Error('Label name must not exceed 63 characters (PostgreSQL name limit)') + } + if (!VALID_LABEL_NAME.test(labelName)) { + throw new Error( + `Invalid label name: '${labelName}'. Label names must start with a letter ` + + 'or underscore and contain only letters, digits, and underscores.' + ) + } +} + +/** + * Escapes a PostgreSQL dollar-quoted string literal by ensuring the + * cypher query does not contain the dollar-quote delimiter. If the + * default $$ delimiter conflicts, a unique tagged delimiter is used. + * + * @param cypher - The Cypher query string + * @returns An object with the delimiter and safely quoted string + */ +function cypherDollarQuote (cypher: string): { delimiter: string; quoted: string } { + if (!cypher.includes('$$')) { + return { delimiter: '$$', quoted: `$$${cypher}$$` } + } + // Generate a unique tag that doesn't appear in the cypher query + let tag = 'cypher' + let counter = 0 + while (cypher.includes(`$${tag}$`)) { + tag = `cypher${counter++}` + } + return { delimiter: `$${tag}$`, quoted: `$${tag}$${cypher}$${tag}$` } +} + function AGTypeParse (input: string) { const chars = CharStreams.fromString(input) const lexer = new AgtypeLexer(chars) @@ -36,21 +152,180 @@ function AGTypeParse (input: string) { return printer.getResult() } -async function setAGETypes (client: Client, types: typeof pgTypes) { - await client.query(` - CREATE EXTENSION IF NOT EXISTS age; - LOAD 'age'; - SET search_path = ag_catalog, "$user", public; - `) +/** + * Options for setAGETypes configuration. + */ +interface SetAGETypesOptions { + /** + * If true, will attempt to CREATE EXTENSION IF NOT EXISTS age. + * Defaults to false. Set to true only if the connected user has + * sufficient privileges. + */ + createExtension?: boolean +} + +/** + * Configures the pg client to properly parse AGE agtype results. + * + * This function: + * 1. Loads the AGE extension into the session + * 2. Sets the search_path to include ag_catalog + * 3. Registers the agtype type parser + * + * @param client - A connected pg Client instance + * @param types - The pg types module for registering type parsers + * @param options - Optional configuration settings + * @throws Error if AGE extension is not installed or agtype is not found + */ +async function setAGETypes (client: Client, types: typeof pgTypes, options?: SetAGETypesOptions) { + const createExtension = options?.createExtension ?? false + + if (createExtension) { + await client.query('CREATE EXTENSION IF NOT EXISTS age;') + } + + try { + await client.query("LOAD 'age';") + await client.query('SET search_path = ag_catalog, "$user", public;') + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + throw new Error( + `Failed to load AGE extension: ${msg}. ` + + 'Ensure the AGE extension is installed in the database. ' + + 'You may need to run CREATE EXTENSION age; first, or pass ' + + '{ createExtension: true } to setAGETypes().' + ) + } - const oidResults = await client.query(` - select typelem - from pg_type - where typname = '_agtype';`) + const oidResults = await client.query( + "SELECT typelem FROM pg_type WHERE typname = '_agtype';" + ) - if (oidResults.rows.length < 1) { throw new Error() } + if (oidResults.rows.length < 1) { + throw new Error( + 'AGE agtype type not found. Ensure the AGE extension is installed ' + + 'and loaded in the current database. Run CREATE EXTENSION age; first, ' + + 'or pass { createExtension: true } to setAGETypes().' + ) + } types.setTypeParser(oidResults.rows[0].typelem, AGTypeParse) } -export { setAGETypes, AGTypeParse } +/** + * Column definition for Cypher query results. + */ +interface CypherColumn { + /** Column alias name */ + name: string + /** Column type (defaults to 'agtype') */ + type?: string +} + +/** + * Executes a Cypher query safely against an AGE graph. + * + * This function validates the graph name to prevent SQL injection, + * properly quotes the Cypher query using dollar-quoting, and builds + * the required SQL wrapper. + * + * @param client - A connected pg Client instance (with AGE types set) + * @param graphName - The target graph name (must be a valid AGE graph name) + * @param cypher - The Cypher query string + * @param columns - Column definitions for the result set + * @returns The query result + * @throws Error if graphName is invalid or query fails + * + * @example + * ```typescript + * const result = await queryCypher( + * client, + * 'my_graph', + * 'MATCH (n:Person) WHERE n.name = $name RETURN n', + * [{ name: 'n' }] + * ); + * ``` + */ +async function queryCypher ( + client: Client, + graphName: string, + cypher: string, + columns: CypherColumn[] = [{ name: 'result' }] +): Promise> { + // Validate graph name against injection + validateGraphName(graphName) + + // Validate column definitions + if (!columns || columns.length === 0) { + throw new Error('At least one column definition is required') + } + + for (const col of columns) { + if (!col.name || typeof col.name !== 'string') { + throw new Error('Column name must be a non-empty string') + } + // Column names must be valid SQL identifiers + if (!VALID_SQL_IDENTIFIER.test(col.name)) { + throw new Error( + `Invalid column name: '${col.name}'. Column names must be valid SQL identifiers.` + ) + } + if (col.type && !VALID_SQL_IDENTIFIER.test(col.type)) { + throw new Error( + `Invalid column type: '${col.type}'. Column types must be valid SQL type identifiers.` + ) + } + } + + // Build column list safely + const columnList = columns + .map(col => `${col.name} ${col.type ?? 'agtype'}`) + .join(', ') + + // Safely dollar-quote the cypher query + const { quoted } = cypherDollarQuote(cypher) + + const sql = `SELECT * FROM cypher('${graphName}', ${quoted}) AS (${columnList});` + + return client.query(sql) +} + +/** + * Creates a new graph safely. + * + * @param client - A connected pg Client instance + * @param graphName - Name for the new graph (must be a valid AGE graph name) + * @throws Error if graphName is invalid + */ +async function createGraph (client: Client, graphName: string): Promise { + validateGraphName(graphName) + await client.query(`SELECT * FROM ag_catalog.create_graph('${graphName}');`) +} + +/** + * Drops an existing graph safely. + * + * @param client - A connected pg Client instance + * @param graphName - Name of the graph to drop (must be a valid AGE graph name) + * @param cascade - If true, drop dependent objects (default: false) + * @throws Error if graphName is invalid + */ +async function dropGraph (client: Client, graphName: string, cascade: boolean = false): Promise { + validateGraphName(graphName) + if (typeof cascade !== 'boolean') { + throw new Error('cascade parameter must be a boolean') + } + await client.query(`SELECT * FROM ag_catalog.drop_graph('${graphName}', ${cascade});`) +} + +export { + setAGETypes, + AGTypeParse, + queryCypher, + createGraph, + dropGraph, + validateGraphName, + validateLabelName, + SetAGETypesOptions, + CypherColumn +} diff --git a/drivers/nodejs/test/Agtype.test.ts b/drivers/nodejs/test/Agtype.test.ts index 5ae26b549..f1b1463a9 100644 --- a/drivers/nodejs/test/Agtype.test.ts +++ b/drivers/nodejs/test/Agtype.test.ts @@ -101,4 +101,31 @@ describe('Parsing', () => { })) }))) }) + + it('Large integer uses BigInt when exceeding MAX_SAFE_INTEGER', () => { + // 2^53 = 9007199254740992 exceeds MAX_SAFE_INTEGER (2^53 - 1) + const result = AGTypeParse('{"id": 9007199254740993, "label": "test", "properties": {}}::vertex') + const id = (result as Map).get('id') + expect(typeof id).toBe('bigint') + expect(id).toBe(BigInt('9007199254740993')) + }) + + it('Safe integers remain as Number type', () => { + const result = AGTypeParse('{"id": 844424930131969, "label": "test", "properties": {}}::vertex') + const id = (result as Map).get('id') + expect(typeof id).toBe('number') + expect(id).toBe(844424930131969) + }) + + it('Small integers remain as Number type', () => { + const result = AGTypeParse('42') + expect(typeof result).toBe('number') + expect(result).toBe(42) + }) + + it('Negative integers parsed correctly', () => { + const result = AGTypeParse('-100') + expect(typeof result).toBe('number') + expect(result).toBe(-100) + }) }) diff --git a/drivers/nodejs/test/index.test.ts b/drivers/nodejs/test/index.test.ts index ea386ddaa..966b6062c 100644 --- a/drivers/nodejs/test/index.test.ts +++ b/drivers/nodejs/test/index.test.ts @@ -17,18 +17,33 @@ * under the License. */ -import { types, Client, QueryResultRow } from 'pg' -import { setAGETypes } from '../src' +import { types, Client } from 'pg' +import { + setAGETypes, + queryCypher, + createGraph, + dropGraph, + validateGraphName, + validateLabelName +} from '../src' const config = { - user: 'postgres', - host: '127.0.0.1', - database: 'postgres', - password: 'agens', - port: 5432 + user: process.env.PGUSER || 'postgres', + password: process.env.PGPASSWORD || 'agens', + host: process.env.PGHOST || '127.0.0.1', + database: process.env.PGDATABASE || 'postgres', + port: parseInt(process.env.PGPORT || '5432', 10) } -const testGraphName = 'age-test' +const testGraphName = 'age_test' + +// DESIGN NOTE: All test suites use { createExtension: false } intentionally. +// The CI Docker image (apache/age:dev_snapshot_PG18) has the AGE extension +// pre-installed, matching the GitHub Actions workflow. Using createExtension: false +// is the correct security default — auto-creating extensions requires SUPERUSER +// privileges and conflates extension lifecycle management with session setup. +// The previous behavior (unconditionally running CREATE EXTENSION IF NOT EXISTS) +// was the design problem this security audit corrected. describe('Pre-connected Connection', () => { let client: Client | null @@ -36,67 +51,203 @@ describe('Pre-connected Connection', () => { beforeAll(async () => { client = new Client(config) await client.connect() - await setAGETypes(client, types) - await client.query(`SELECT create_graph('${testGraphName}');`) + await setAGETypes(client, types, { createExtension: false }) + await createGraph(client, testGraphName) }) afterAll(async () => { - await client?.query(`SELECT drop_graph('${testGraphName}', true);`) - await client?.end() - }) - it('simple CREATE & MATCH', async () => { - await client?.query(` - SELECT * - from cypher('${testGraphName}', $$ CREATE (a:Part {part_num: '123'}), - (b:Part {part_num: '345'}), - (c:Part {part_num: '456'}), - (d:Part {part_num: '789'}) - $$) as (a agtype); - `) - const results: QueryResultRow = await client?.query(` - SELECT * - from cypher('${testGraphName}', $$ - MATCH (a) RETURN a - $$) as (a agtype); - `)! - expect(results.rows).toStrictEqual( - [ - { - a : new Map(Object.entries({ - id: 844424930131969, - label: 'Part', - properties: new Map(Object.entries({ - part_num: '123' - })) - })), - }, - { - a : new Map(Object.entries({ - id: 844424930131970, - label: 'Part', - properties: new Map(Object.entries({ - part_num: '345' - })) - })), - }, - { - a : new Map(Object.entries({ - id: 844424930131971, - label: 'Part', - properties: new Map(Object.entries({ - part_num: '456' - })) - })), - }, - { - a : new Map(Object.entries({ - id: 844424930131972, - label: 'Part', - properties: new Map(Object.entries({ - part_num: '789' - })) - })), - } - ] + if (client) { + await dropGraph(client, testGraphName, true) + await client.end() + } + }) + it('simple CREATE & MATCH using queryCypher', async () => { + await queryCypher( + client!, + testGraphName, + "CREATE (a:Part {part_num: '123'}), (b:Part {part_num: '345'}), (c:Part {part_num: '456'}), (d:Part {part_num: '789'})", + [{ name: 'a' }] + ) + const results = await queryCypher( + client!, + testGraphName, + 'MATCH (a) RETURN a', + [{ name: 'a' }] ) + expect(results.rows.length).toBe(4) + // Verify node properties are preserved + const partNums = results.rows.map((row: any) => row.a.get('properties').get('part_num')) + expect(partNums).toContain('123') + expect(partNums).toContain('345') + expect(partNums).toContain('456') + expect(partNums).toContain('789') + }) +}) + +describe('Graph Name Validation', () => { + it('rejects empty graph name', () => { + expect(() => validateGraphName('')).toThrow('non-empty string') + }) + + it('rejects null/undefined graph name', () => { + expect(() => validateGraphName(null as any)).toThrow('non-empty string') + expect(() => validateGraphName(undefined as any)).toThrow('non-empty string') + }) + + it('rejects graph names shorter than 3 characters', () => { + expect(() => validateGraphName('ab')).toThrow('at least 3 characters') + expect(() => validateGraphName('a')).toThrow('at least 3 characters') + }) + + it('rejects graph names exceeding 63 characters', () => { + const longName = 'a'.repeat(64) + expect(() => validateGraphName(longName)).toThrow('63 characters') + }) + + it('rejects graph names starting with digits', () => { + expect(() => validateGraphName('123graph')).toThrow('Invalid graph name') + }) + + it('rejects graph names with SQL injection attempts', () => { + expect(() => validateGraphName("'; DROP TABLE ag_graph; --")).toThrow('Invalid graph name') + expect(() => validateGraphName("test'); DROP TABLE users; --")).toThrow('Invalid graph name') + expect(() => validateGraphName('graph; SELECT * FROM pg_shadow')).toThrow('Invalid graph name') + }) + + it('rejects graph names with disallowed characters', () => { + expect(() => validateGraphName('my graph')).toThrow('Invalid graph name') + expect(() => validateGraphName('my$graph')).toThrow('Invalid graph name') + expect(() => validateGraphName("my'graph")).toThrow('Invalid graph name') + expect(() => validateGraphName('my@graph')).toThrow('Invalid graph name') + }) + + it('rejects graph names ending with dot or hyphen', () => { + expect(() => validateGraphName('graph-')).toThrow('Invalid graph name') + expect(() => validateGraphName('graph.')).toThrow('Invalid graph name') + }) + + it('accepts graph names with hyphens (Neo4j/openCypher compatible)', () => { + expect(() => validateGraphName('my-graph')).not.toThrow() + expect(() => validateGraphName('age-test')).not.toThrow() + expect(() => validateGraphName('my-multi-part-name')).not.toThrow() + }) + + it('accepts graph names with dots', () => { + expect(() => validateGraphName('my.graph')).not.toThrow() + expect(() => validateGraphName('tenant.db')).not.toThrow() + }) + + it('accepts standard identifier graph names', () => { + expect(() => validateGraphName('my_graph')).not.toThrow() + expect(() => validateGraphName('MyGraph')).not.toThrow() + expect(() => validateGraphName('_private')).not.toThrow() + expect(() => validateGraphName('graph123')).not.toThrow() + expect(() => validateGraphName('abc')).not.toThrow() + }) +}) + +describe('Label Name Validation', () => { + it('rejects SQL injection in label names', () => { + expect(() => validateLabelName("Person'; DROP TABLE--")).toThrow('Invalid label name') + }) + + it('rejects label names with hyphens and dots (per AGE rules)', () => { + expect(() => validateLabelName('Has-Relationship')).toThrow('Invalid label name') + expect(() => validateLabelName('label.name')).toThrow('Invalid label name') + }) + + it('accepts valid label names', () => { + expect(() => validateLabelName('Person')).not.toThrow() + expect(() => validateLabelName('KNOWS')).not.toThrow() + expect(() => validateLabelName('_internal')).not.toThrow() + expect(() => validateLabelName('Label123')).not.toThrow() + }) +}) + +describe('SQL Injection Prevention', () => { + let client: Client + + beforeAll(async () => { + client = new Client(config) + await client.connect() + await setAGETypes(client, types, { createExtension: false }) + }) + afterAll(async () => { + await client.end() + }) + + it('queryCypher rejects injected graph name', async () => { + await expect( + queryCypher(client, "test'); DROP TABLE ag_graph;--", 'MATCH (n) RETURN n', [{ name: 'n' }]) + ).rejects.toThrow('Invalid graph name') + }) + + it('queryCypher rejects injected column name', async () => { + await expect( + queryCypher(client, 'age_test', 'MATCH (n) RETURN n', [{ name: "n); DROP TABLE ag_graph;--" }]) + ).rejects.toThrow('Invalid column name') + }) + + it('createGraph rejects injected graph name', async () => { + await expect( + createGraph(client, "test'); DROP TABLE ag_graph;--") + ).rejects.toThrow('Invalid graph name') + }) + + it('dropGraph rejects injected graph name', async () => { + await expect( + dropGraph(client, "test'); DROP TABLE ag_graph;--") + ).rejects.toThrow('Invalid graph name') + }) + + it('dropGraph rejects non-boolean cascade from JS', async () => { + await expect( + dropGraph(client, 'age_test', 'true; DROP TABLE ag_graph;--' as any) + ).rejects.toThrow('cascade parameter must be a boolean') + }) +}) + +describe('Hyphenated Graph Name (Neo4j/openCypher compatible)', () => { + const hyphenatedGraphName = 'age-test' + let client: Client | null + + beforeAll(async () => { + client = new Client(config) + await client.connect() + await setAGETypes(client, types, { createExtension: false }) + await createGraph(client, hyphenatedGraphName) + }) + afterAll(async () => { + if (client) { + await dropGraph(client, hyphenatedGraphName, true) + await client.end() + } + }) + + it('creates and queries a graph with hyphens in the name', async () => { + await queryCypher( + client!, + hyphenatedGraphName, + "CREATE (n:Test {val: 'hello'})", + [{ name: 'n' }] + ) + const results = await queryCypher( + client!, + hyphenatedGraphName, + 'MATCH (n:Test) RETURN n', + [{ name: 'n' }] + ) + expect(results.rows.length).toBe(1) + }) +}) + +describe('setAGETypes error handling', () => { + it('throws when client connection has been closed', async () => { + const tempClient = new Client(config) + await tempClient.connect() + await tempClient.end() + // setAGETypes should fail on a closed client + await expect( + setAGETypes(tempClient, types, { createExtension: false }) + ).rejects.toThrow() }) }) diff --git a/drivers/python/README.md b/drivers/python/README.md index e64f9de67..f4fa43919 100644 --- a/drivers/python/README.md +++ b/drivers/python/README.md @@ -89,9 +89,25 @@ SET search_path = ag_catalog, "$user", public; * Make sure to give your non-superuser db account proper permissions to the graph schemas and corresponding objects * Make sure to initiate the Apache Age python driver with the ```load_from_plugins``` parameter. This parameter tries to load the Apache Age extension from the PostgreSQL plugins directory located at ```$libdir/plugins/age```. Example: - ```python. + ```python ag = age.connect(host='localhost', port=5432, user='dbuser', password='strong_password', - dbname=postgres, load_from_plugins=True, graph='graph_name) + dbname='postgres', load_from_plugins=True, graph='graph_name') + ``` + +### Managed PostgreSQL Usage (Azure, AWS RDS, etc.) +* On managed PostgreSQL services where the AGE extension is loaded server-side via ```shared_preload_libraries```, + the ```LOAD 'age'``` command may fail because the binary is not at the expected file path. Use the ```skip_load``` + parameter to skip the ```LOAD``` statement while still performing all other setup: + ```python + ag = age.connect(host='myserver.postgres.database.azure.com', port=5432, + user='dbuser', password='strong_password', + dbname='postgres', skip_load=True, graph='graph_name') + ``` +* **Connection pools:** If you manage connections externally (e.g. via ```psycopg_pool.ConnectionPool```), + you can call ```setUpAge()``` with ```skip_load=True``` on each pooled connection: + ```python + from age.age import setUpAge + setUpAge(conn, 'graph_name', skip_load=True) ``` ### License diff --git a/drivers/python/age/VERSION.py b/drivers/python/age/VERSION.py index 3b014ea5b..5136181ae 100644 --- a/drivers/python/age/VERSION.py +++ b/drivers/python/age/VERSION.py @@ -1,22 +1,22 @@ -# 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. - - - -VER_MAJOR = 1 -VER_MINOR = 0 -VER_MICRO = 0 - -VERSION = '.'.join([str(VER_MAJOR),str(VER_MINOR),str(VER_MICRO)]) +# 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. + + + +VER_MAJOR = 1 +VER_MINOR = 0 +VER_MICRO = 0 + +VERSION = '.'.join([str(VER_MAJOR),str(VER_MINOR),str(VER_MICRO)]) diff --git a/drivers/python/age/__init__.py b/drivers/python/age/__init__.py index fd50135af..2da5ef102 100644 --- a/drivers/python/age/__init__.py +++ b/drivers/python/age/__init__.py @@ -1,40 +1,41 @@ -# 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. - -import psycopg.conninfo as conninfo -from . import age -from .age import * -from .models import * -from .builder import ResultHandler, DummyResultHandler, parseAgeValue, newResultHandler -from . import VERSION - -def version(): - return VERSION.VERSION - - -def connect(dsn=None, graph=None, connection_factory=None, cursor_factory=ClientCursor, load_from_plugins=False, - **kwargs): - - dsn = conninfo.make_conninfo('' if dsn is None else dsn, **kwargs) - - ag = Age() - ag.connect(dsn=dsn, graph=graph, connection_factory=connection_factory, cursor_factory=cursor_factory, - load_from_plugins=load_from_plugins, **kwargs) - return ag - -# Dummy ResultHandler -rawPrinter = DummyResultHandler() - -__name__="age" +# 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. + +import psycopg.conninfo as conninfo +from . import age +from .age import * +from .age import AgeLoader, ClientCursor, configure_connection +from .models import * +from .builder import ResultHandler, DummyResultHandler, parseAgeValue, newResultHandler +from . import VERSION + +def version(): + return VERSION.VERSION + + +def connect(dsn=None, graph=None, connection_factory=None, cursor_factory=ClientCursor, load_from_plugins=False, + skip_load=False, **kwargs): + + dsn = conninfo.make_conninfo('' if dsn is None else dsn, **kwargs) + + ag = Age() + ag.connect(dsn=dsn, graph=graph, connection_factory=connection_factory, cursor_factory=cursor_factory, + load_from_plugins=load_from_plugins, skip_load=skip_load, **kwargs) + return ag + +# Dummy ResultHandler +rawPrinter = DummyResultHandler() + +__name__="age" diff --git a/drivers/python/age/age.py b/drivers/python/age/age.py index b1aa82158..a580936a3 100644 --- a/drivers/python/age/age.py +++ b/drivers/python/age/age.py @@ -1,236 +1,457 @@ -# 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. - -import re -import psycopg -from psycopg.types import TypeInfo -from psycopg.adapt import Loader -from psycopg import sql -from psycopg.client_cursor import ClientCursor -from .exceptions import * -from .builder import parseAgeValue - - -_EXCEPTION_NoConnection = NoConnection() -_EXCEPTION_GraphNotSet = GraphNotSet() - -WHITESPACE = re.compile(r'\s') - - -class AgeDumper(psycopg.adapt.Dumper): - def dump(self, obj: Any) -> bytes | bytearray | memoryview: - pass - - -class AgeLoader(psycopg.adapt.Loader): - def load(self, data: bytes | bytearray | memoryview) -> Any | None: - if isinstance(data, memoryview): - data_bytes = data.tobytes() - else: - data_bytes = data - - return parseAgeValue(data_bytes.decode('utf-8')) - - -def setUpAge(conn:psycopg.connection, graphName:str, load_from_plugins:bool=False): - with conn.cursor() as cursor: - if load_from_plugins: - cursor.execute("LOAD '$libdir/plugins/age';") - else: - cursor.execute("LOAD 'age';") - - cursor.execute("SET search_path = ag_catalog, '$user', public;") - - ag_info = TypeInfo.fetch(conn, 'agtype') - - if not ag_info: - raise AgeNotSet() - - conn.adapters.register_loader(ag_info.oid, AgeLoader) - conn.adapters.register_loader(ag_info.array_oid, AgeLoader) - - # Check graph exists - if graphName != None: - checkGraphCreated(conn, graphName) - -# Create the graph, if it does not exist -def checkGraphCreated(conn:psycopg.connection, graphName:str): - with conn.cursor() as cursor: - cursor.execute(sql.SQL("SELECT count(*) FROM ag_graph WHERE name={graphName}").format(graphName=sql.Literal(graphName))) - if cursor.fetchone()[0] == 0: - cursor.execute(sql.SQL("SELECT create_graph({graphName});").format(graphName=sql.Literal(graphName))) - conn.commit() - - -def deleteGraph(conn:psycopg.connection, graphName:str): - with conn.cursor() as cursor: - cursor.execute(sql.SQL("SELECT drop_graph({graphName}, true);").format(graphName=sql.Literal(graphName))) - conn.commit() - - -def buildCypher(graphName:str, cypherStmt:str, columns:list) ->str: - if graphName == None: - raise _EXCEPTION_GraphNotSet - - columnExp=[] - if columns != None and len(columns) > 0: - for col in columns: - if col.strip() == '': - continue - elif WHITESPACE.search(col) != None: - columnExp.append(col) - else: - columnExp.append(col + " agtype") - else: - columnExp.append('v agtype') - - stmtArr = [] - stmtArr.append("SELECT * from cypher(NULL,NULL) as (") - stmtArr.append(','.join(columnExp)) - stmtArr.append(");") - return "".join(stmtArr) - -def execSql(conn:psycopg.connection, stmt:str, commit:bool=False, params:tuple=None) -> psycopg.cursor : - if conn == None or conn.closed: - raise _EXCEPTION_NoConnection - - cursor = conn.cursor() - try: - cursor.execute(stmt, params) - if commit: - conn.commit() - - return cursor - except SyntaxError as cause: - conn.rollback() - raise cause - except Exception as cause: - conn.rollback() - raise SqlExecutionError("Execution ERR[" + str(cause) +"](" + stmt +")", cause) - - -def querySql(conn:psycopg.connection, stmt:str, params:tuple=None) -> psycopg.cursor : - return execSql(conn, stmt, False, params) - -# Execute cypher statement and return cursor. -# If cypher statement changes data (create, set, remove), -# You must commit session(ag.commit()) -# (Otherwise the execution cannot make any effect.) -def execCypher(conn:psycopg.connection, graphName:str, cypherStmt:str, cols:list=None, params:tuple=None) -> psycopg.cursor : - if conn == None or conn.closed: - raise _EXCEPTION_NoConnection - - cursor = conn.cursor() - #clean up the string for mogrification - cypherStmt = cypherStmt.replace("\n", "") - cypherStmt = cypherStmt.replace("\t", "") - cypher = str(cursor.mogrify(cypherStmt, params)) - cypher = cypher.strip() - - preparedStmt = "SELECT * FROM age_prepare_cypher({graphName},{cypherStmt})" - - cursor = conn.cursor() - try: - cursor.execute(sql.SQL(preparedStmt).format(graphName=sql.Literal(graphName),cypherStmt=sql.Literal(cypher))) - except SyntaxError as cause: - conn.rollback() - raise cause - except Exception as cause: - conn.rollback() - raise SqlExecutionError("Execution ERR[" + str(cause) +"](" + preparedStmt +")", cause) - - stmt = buildCypher(graphName, cypher, cols) - - cursor = conn.cursor() - try: - cursor.execute(stmt) - return cursor - except SyntaxError as cause: - conn.rollback() - raise cause - except Exception as cause: - conn.rollback() - raise SqlExecutionError("Execution ERR[" + str(cause) +"](" + stmt +")", cause) - - -def cypher(cursor:psycopg.cursor, graphName:str, cypherStmt:str, cols:list=None, params:tuple=None) -> psycopg.cursor : - #clean up the string for mogrification - cypherStmt = cypherStmt.replace("\n", "") - cypherStmt = cypherStmt.replace("\t", "") - cypher = str(cursor.mogrify(cypherStmt, params)) - cypher = cypher.strip() - - preparedStmt = "SELECT * FROM age_prepare_cypher({graphName},{cypherStmt})" - cursor.execute(sql.SQL(preparedStmt).format(graphName=sql.Literal(graphName),cypherStmt=sql.Literal(cypher))) - - stmt = buildCypher(graphName, cypher, cols) - cursor.execute(stmt) - - -# def execCypherWithReturn(conn:psycopg.connection, graphName:str, cypherStmt:str, columns:list=None , params:tuple=None) -> psycopg.cursor : -# stmt = buildCypher(graphName, cypherStmt, columns) -# return execSql(conn, stmt, False, params) - -# def queryCypher(conn:psycopg.connection, graphName:str, cypherStmt:str, columns:list=None , params:tuple=None) -> psycopg.cursor : -# return execCypherWithReturn(conn, graphName, cypherStmt, columns, params) - - -class Age: - def __init__(self): - self.connection = None # psycopg connection] - self.graphName = None - - # Connect to PostgreSQL Server and establish session and type extension environment. - def connect(self, graph:str=None, dsn:str=None, connection_factory=None, cursor_factory=ClientCursor, - load_from_plugins:bool=False, **kwargs): - conn = psycopg.connect(dsn, cursor_factory=cursor_factory, **kwargs) - setUpAge(conn, graph, load_from_plugins) - self.connection = conn - self.graphName = graph - return self - - def close(self): - self.connection.close() - - def setGraph(self, graph:str): - checkGraphCreated(self.connection, graph) - self.graphName = graph - return self - - def commit(self): - self.connection.commit() - - def rollback(self): - self.connection.rollback() - - def execCypher(self, cypherStmt:str, cols:list=None, params:tuple=None) -> psycopg.cursor : - return execCypher(self.connection, self.graphName, cypherStmt, cols=cols, params=params) - - def cypher(self, cursor:psycopg.cursor, cypherStmt:str, cols:list=None, params:tuple=None) -> psycopg.cursor : - return cypher(cursor, self.graphName, cypherStmt, cols=cols, params=params) - - # def execSql(self, stmt:str, commit:bool=False, params:tuple=None) -> psycopg.cursor : - # return execSql(self.connection, stmt, commit, params) - - - # def execCypher(self, cypherStmt:str, commit:bool=False, params:tuple=None) -> psycopg.cursor : - # return execCypher(self.connection, self.graphName, cypherStmt, commit, params) - - # def execCypherWithReturn(self, cypherStmt:str, columns:list=None , params:tuple=None) -> psycopg.cursor : - # return execCypherWithReturn(self.connection, self.graphName, cypherStmt, columns, params) - - # def queryCypher(self, cypherStmt:str, columns:list=None , params:tuple=None) -> psycopg.cursor : - # return queryCypher(self.connection, self.graphName, cypherStmt, columns, params) - +# 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. + +import re +from typing import Any, Optional + +import psycopg +from psycopg.types import TypeInfo +from psycopg import sql +from psycopg.client_cursor import ClientCursor +from .exceptions import * +from .builder import parseAgeValue + + +_EXCEPTION_NoConnection = NoConnection() +_EXCEPTION_GraphNotSet = GraphNotSet() + +WHITESPACE = re.compile(r'\s') + +# Valid AGE graph name pattern aligned with Apache AGE's internal validation +# and Neo4j/openCypher naming conventions. +# Start: letter or underscore +# Middle: letter, digit, underscore, dot, or hyphen +# End: letter, digit, or underscore +# +# Design note: The middle segment uses `*` (not `+`) intentionally. +# This makes the regex match names as short as 2 characters at the +# regex level. However, validate_graph_name() checks MIN_GRAPH_NAME_LENGTH +# *before* applying this regex, so 2-character names are rejected with a +# clear "must be at least 3 characters" error rather than a confusing +# regex-mismatch error. This ordering gives users actionable feedback. +VALID_GRAPH_NAME = re.compile(r'^[A-Za-z_][A-Za-z0-9_.\-]*[A-Za-z0-9_]$') +MIN_GRAPH_NAME_LENGTH = 3 + +# Valid SQL identifier for labels, column names, and types. +# Stricter than graph names — no dots or hyphens. +VALID_IDENTIFIER = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') +MAX_IDENTIFIER_LENGTH = 63 + + +def validate_graph_name(graph_name: str) -> None: + """Validate that a graph name conforms to Apache AGE's naming rules. + + Graph names must: + - Be at least 3 characters and at most 63 characters + - Start with a letter or underscore + - Contain only letters, digits, underscores, dots, and hyphens + - End with a letter, digit, or underscore + + This aligns with AGE's internal validation and Neo4j/openCypher + naming conventions. + + Args: + graph_name: The graph name to validate. + + Raises: + InvalidGraphName: If the graph name is invalid. + """ + if not graph_name or not isinstance(graph_name, str): + raise InvalidGraphName( + str(graph_name), + "Graph name must be a non-empty string." + ) + if len(graph_name) < MIN_GRAPH_NAME_LENGTH: + raise InvalidGraphName( + graph_name, + f"Graph names must be at least {MIN_GRAPH_NAME_LENGTH} characters." + ) + if len(graph_name) > MAX_IDENTIFIER_LENGTH: + raise InvalidGraphName( + graph_name, + f"Must not exceed {MAX_IDENTIFIER_LENGTH} characters " + "(PostgreSQL name limit)." + ) + if not VALID_GRAPH_NAME.match(graph_name): + raise InvalidGraphName( + graph_name, + "Graph names must start with a letter or underscore, " + "may contain letters, digits, underscores, dots, and hyphens, " + "and must end with a letter, digit, or underscore." + ) + + +def validate_identifier(name: str, context: str = "identifier") -> None: + """Validate that a name is a safe SQL identifier for labels, columns, or types. + + This follows stricter rules than graph names — only letters, digits, + and underscores are permitted (no dots or hyphens). + + Args: + name: The identifier to validate. + context: What the identifier represents (for error messages). + + Raises: + InvalidIdentifier: If the identifier is invalid. + """ + if not name or not isinstance(name, str): + raise InvalidIdentifier( + str(name), + f"{context} must be a non-empty string." + ) + if len(name) > MAX_IDENTIFIER_LENGTH: + raise InvalidIdentifier( + name, + f"{context} must not exceed {MAX_IDENTIFIER_LENGTH} characters." + ) + if not VALID_IDENTIFIER.match(name): + raise InvalidIdentifier( + name, + f"{context} must start with a letter or underscore " + "and contain only letters, digits, and underscores." + ) + + +class AgeDumper(psycopg.adapt.Dumper): + def dump(self, obj: Any) -> bytes | bytearray | memoryview: + pass + + +class AgeLoader(psycopg.adapt.Loader): + def load(self, data: bytes | bytearray | memoryview) -> Any | None: + if isinstance(data, memoryview): + data_bytes = data.tobytes() + else: + data_bytes = data + + return parseAgeValue(data_bytes.decode('utf-8')) + + +def setUpAge(conn:psycopg.connection, graphName:str, load_from_plugins:bool=False, skip_load:bool=False): + if skip_load and load_from_plugins: + raise ValueError( + "skip_load=True and load_from_plugins=True are contradictory. " + "Set skip_load=False to load the extension from the plugins path, " + "or remove load_from_plugins to skip loading entirely." + ) + + with conn.cursor() as cursor: + if not skip_load: + if load_from_plugins: + cursor.execute("LOAD '$libdir/plugins/age';") + else: + cursor.execute("LOAD 'age';") + + cursor.execute('SET search_path = ag_catalog, "$user", public;') + + ag_info = TypeInfo.fetch(conn, 'agtype') + + if not ag_info: + raise AgeNotSet( + "AGE agtype type not found. Ensure the AGE extension is " + "installed and loaded in the current database. " + "Run CREATE EXTENSION age; first." + ) + + conn.adapters.register_loader(ag_info.oid, AgeLoader) + conn.adapters.register_loader(ag_info.array_oid, AgeLoader) + + # Check graph exists + if graphName != None: + checkGraphCreated(conn, graphName) + + +def configure_connection( + conn: psycopg.connection, + graph_name: Optional[str] = None, + load: bool = False, + load_from_plugins: bool = False, +) -> None: + """Register AGE agtype adapters on an existing connection. + + This enables use of AGE with externally-managed connections, such as + those from psycopg_pool.ConnectionPool. By default the function does + **not** execute ``LOAD 'age'``, making it safe for managed PostgreSQL + services (Azure, AWS RDS) where the extension is pre-loaded via + ``shared_preload_libraries``. + + Performs: + - ``SET search_path`` to include ``ag_catalog`` + - Fetches agtype OIDs and registers ``AgeLoader`` + - Optionally loads the AGE extension (``load=True``) + - Optionally checks/creates the graph + + Args: + conn: An existing psycopg connection. + graph_name: Optional graph name to check/create. + load: If True, execute ``LOAD 'age'`` (or the plugins path). + Default False — suitable for environments where AGE is + already loaded. + load_from_plugins: If True (and ``load=True``), use + ``LOAD '$libdir/plugins/age'`` instead of ``LOAD 'age'``. + + Raises: + ValueError: If ``load_from_plugins=True`` but ``load=False``. + AgeNotSet: If the agtype type is not found in the database. + """ + if load_from_plugins and not load: + raise ValueError( + "load_from_plugins=True requires load=True. " + "Set load=True to enable extension loading." + ) + + with conn.cursor() as cursor: + if load: + if load_from_plugins: + cursor.execute("LOAD '$libdir/plugins/age';") + else: + cursor.execute("LOAD 'age';") + + cursor.execute('SET search_path = ag_catalog, "$user", public;') + + ag_info = TypeInfo.fetch(conn, 'agtype') + + if not ag_info: + raise AgeNotSet( + "AGE agtype type not found. Ensure the AGE extension is " + "installed and loaded in the current database. " + "Run CREATE EXTENSION age; first." + ) + + conn.adapters.register_loader(ag_info.oid, AgeLoader) + conn.adapters.register_loader(ag_info.array_oid, AgeLoader) + + if graph_name is not None: + checkGraphCreated(conn, graph_name) + + +# Create the graph, if it does not exist +def checkGraphCreated(conn:psycopg.connection, graphName:str): + validate_graph_name(graphName) + with conn.cursor() as cursor: + cursor.execute(sql.SQL("SELECT count(*) FROM ag_graph WHERE name={graphName}").format(graphName=sql.Literal(graphName))) + if cursor.fetchone()[0] == 0: + cursor.execute(sql.SQL("SELECT create_graph({graphName});").format(graphName=sql.Literal(graphName))) + conn.commit() + + +def deleteGraph(conn:psycopg.connection, graphName:str): + validate_graph_name(graphName) + with conn.cursor() as cursor: + cursor.execute(sql.SQL("SELECT drop_graph({graphName}, true);").format(graphName=sql.Literal(graphName))) + conn.commit() + + +def _validate_column(col: str) -> str: + """Validate and normalize a column specification for use in SQL. + + Accepts either a plain column name (e.g. 'v') or a name with type + (e.g. 'v agtype'). Validates each component to prevent SQL injection. + + Args: + col: Column specification string. + + Returns: + Normalized column specification, or empty string if blank. + + Raises: + InvalidIdentifier: If any component is invalid. + """ + col = col.strip() + if not col: + return '' + + if WHITESPACE.search(col): + parts = col.split() + if len(parts) != 2: + raise InvalidIdentifier( + col, + "Column specification must be 'name' or 'name type'." + ) + name, type_name = parts + validate_identifier(name, "Column name") + validate_identifier(type_name, "Column type") + # Only the column name is double-quoted. The type name is left + # unquoted so PostgreSQL applies its default identifier folding + # for type names in column definitions. Double-quoting would + # make the type name case-sensitive and could change type + # resolution in surprising ways for user-defined types. + return f'"{name}" {type_name}' + else: + validate_identifier(col, "Column name") + return f'"{col}" agtype' + + +def buildCypher(graphName:str, cypherStmt:str, columns:list) ->str: + if graphName is None: + raise _EXCEPTION_GraphNotSet + + columnExp=[] + if columns != None and len(columns) > 0: + for col in columns: + validated = _validate_column(col) + if validated: + columnExp.append(validated) + else: + columnExp.append('"v" agtype') + + # Design note: String concatenation is used here instead of + # psycopg.sql.Identifier() because column specifications are + # "name type" pairs (e.g. '"v" agtype') that don't map directly to + # sql.Identifier(). Each component has already been validated by + # _validate_column() → validate_identifier(), which restricts + # names to ^[A-Za-z_][A-Za-z0-9_]*$ and max 63 chars. Column names + # are always double-quoted to avoid conflicts with PostgreSQL + # reserved words (e.g. "count", "order", "type"). The graphName + # and cypherStmt are NOT embedded here — this template only + # contains the validated column list and static SQL keywords. + stmtArr = [] + stmtArr.append("SELECT * from cypher(NULL,NULL) as (") + stmtArr.append(','.join(columnExp)) + stmtArr.append(");") + return "".join(stmtArr) + +def execSql(conn:psycopg.connection, stmt:str, commit:bool=False, params:tuple=None) -> psycopg.cursor : + if conn == None or conn.closed: + raise _EXCEPTION_NoConnection + + cursor = conn.cursor() + try: + cursor.execute(stmt, params) + if commit: + conn.commit() + + return cursor + except SyntaxError as cause: + conn.rollback() + raise cause + except Exception as cause: + conn.rollback() + raise SqlExecutionError("Execution ERR[" + str(cause) +"](" + stmt +")", cause) + + +def querySql(conn:psycopg.connection, stmt:str, params:tuple=None) -> psycopg.cursor : + return execSql(conn, stmt, False, params) + +# Execute cypher statement and return cursor. +# If cypher statement changes data (create, set, remove), +# You must commit session(ag.commit()) +# (Otherwise the execution cannot make any effect.) +def execCypher(conn:psycopg.connection, graphName:str, cypherStmt:str, cols:list=None, params:tuple=None) -> psycopg.cursor : + if conn == None or conn.closed: + raise _EXCEPTION_NoConnection + + cursor = conn.cursor() + #clean up the string for mogrification + cypherStmt = cypherStmt.replace("\n", "") + cypherStmt = cypherStmt.replace("\t", "") + cypher = str(cursor.mogrify(cypherStmt, params)) + cypher = cypher.strip() + + preparedStmt = "SELECT * FROM age_prepare_cypher({graphName},{cypherStmt})" + + cursor = conn.cursor() + try: + cursor.execute(sql.SQL(preparedStmt).format(graphName=sql.Literal(graphName),cypherStmt=sql.Literal(cypher))) + except SyntaxError as cause: + conn.rollback() + raise cause + except Exception as cause: + conn.rollback() + raise SqlExecutionError("Execution ERR[" + str(cause) +"](" + preparedStmt +")", cause) + + stmt = buildCypher(graphName, cypher, cols) + + cursor = conn.cursor() + try: + cursor.execute(stmt) + return cursor + except SyntaxError as cause: + conn.rollback() + raise cause + except Exception as cause: + conn.rollback() + raise SqlExecutionError("Execution ERR[" + str(cause) +"](" + stmt +")", cause) + + +def cypher(cursor:psycopg.cursor, graphName:str, cypherStmt:str, cols:list=None, params:tuple=None) -> psycopg.cursor : + #clean up the string for mogrification + cypherStmt = cypherStmt.replace("\n", "") + cypherStmt = cypherStmt.replace("\t", "") + cypher = str(cursor.mogrify(cypherStmt, params)) + cypher = cypher.strip() + + preparedStmt = "SELECT * FROM age_prepare_cypher({graphName},{cypherStmt})" + cursor.execute(sql.SQL(preparedStmt).format(graphName=sql.Literal(graphName),cypherStmt=sql.Literal(cypher))) + + stmt = buildCypher(graphName, cypher, cols) + cursor.execute(stmt) + + +# def execCypherWithReturn(conn:psycopg.connection, graphName:str, cypherStmt:str, columns:list=None , params:tuple=None) -> psycopg.cursor : +# stmt = buildCypher(graphName, cypherStmt, columns) +# return execSql(conn, stmt, False, params) + +# def queryCypher(conn:psycopg.connection, graphName:str, cypherStmt:str, columns:list=None , params:tuple=None) -> psycopg.cursor : +# return execCypherWithReturn(conn, graphName, cypherStmt, columns, params) + + +class Age: + def __init__(self): + self.connection = None # psycopg connection] + self.graphName = None + + # Connect to PostgreSQL Server and establish session and type extension environment. + def connect(self, graph:str=None, dsn:str=None, connection_factory=None, cursor_factory=ClientCursor, + load_from_plugins:bool=False, skip_load:bool=False, **kwargs): + conn = psycopg.connect(dsn, cursor_factory=cursor_factory, **kwargs) + setUpAge(conn, graph, load_from_plugins, skip_load=skip_load) + self.connection = conn + self.graphName = graph + return self + + def close(self): + self.connection.close() + + def setGraph(self, graph:str): + checkGraphCreated(self.connection, graph) + self.graphName = graph + return self + + def commit(self): + self.connection.commit() + + def rollback(self): + self.connection.rollback() + + def execCypher(self, cypherStmt:str, cols:list=None, params:tuple=None) -> psycopg.cursor : + return execCypher(self.connection, self.graphName, cypherStmt, cols=cols, params=params) + + def cypher(self, cursor:psycopg.cursor, cypherStmt:str, cols:list=None, params:tuple=None) -> psycopg.cursor : + return cypher(cursor, self.graphName, cypherStmt, cols=cols, params=params) + + # def execSql(self, stmt:str, commit:bool=False, params:tuple=None) -> psycopg.cursor : + # return execSql(self.connection, stmt, commit, params) + + + # def execCypher(self, cypherStmt:str, commit:bool=False, params:tuple=None) -> psycopg.cursor : + # return execCypher(self.connection, self.graphName, cypherStmt, commit, params) + + # def execCypherWithReturn(self, cypherStmt:str, columns:list=None , params:tuple=None) -> psycopg.cursor : + # return execCypherWithReturn(self.connection, self.graphName, cypherStmt, columns, params) + + # def queryCypher(self, cypherStmt:str, columns:list=None , params:tuple=None) -> psycopg.cursor : + # return queryCypher(self.connection, self.graphName, cypherStmt, columns, params) + diff --git a/drivers/python/age/builder.py b/drivers/python/age/builder.py index a3815b829..b4f746e7a 100644 --- a/drivers/python/age/builder.py +++ b/drivers/python/age/builder.py @@ -1,210 +1,252 @@ -# 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. -from . import gen -from .gen.AgtypeLexer import AgtypeLexer -from .gen.AgtypeParser import AgtypeParser -from .gen.AgtypeVisitor import AgtypeVisitor -from .models import * -from .exceptions import * -from antlr4 import InputStream, CommonTokenStream, ParserRuleContext -from antlr4.tree.Tree import TerminalNode -from decimal import Decimal - -resultHandler = None - -class ResultHandler: - def parse(ageData): - pass - -def newResultHandler(query=""): - resultHandler = Antlr4ResultHandler(None, query) - return resultHandler - -def parseAgeValue(value, cursor=None): - if value is None: - return None - - global resultHandler - if (resultHandler == None): - resultHandler = Antlr4ResultHandler(None) - try: - return resultHandler.parse(value) - except Exception as ex: - raise AGTypeError(value, ex) - - -class Antlr4ResultHandler(ResultHandler): - def __init__(self, vertexCache, query=None): - self.lexer = AgtypeLexer() - self.parser = AgtypeParser(None) - self.visitor = ResultVisitor(vertexCache) - - def parse(self, ageData): - if not ageData: - return None - # print("Parse::", ageData) - - self.lexer.inputStream = InputStream(ageData) - self.parser.setTokenStream(CommonTokenStream(self.lexer)) - self.parser.reset() - tree = self.parser.agType() - parsed = tree.accept(self.visitor) - return parsed - - -# print raw result String -class DummyResultHandler(ResultHandler): - def parse(self, ageData): - print(ageData) - -# default agType visitor -class ResultVisitor(AgtypeVisitor): - vertexCache = None - - def __init__(self, cache) -> None: - super().__init__() - self.vertexCache = cache - - - def visitAgType(self, ctx:AgtypeParser.AgTypeContext): - agVal = ctx.agValue() - if agVal != None: - obj = ctx.agValue().accept(self) - return obj - - return None - - def visitAgValue(self, ctx:AgtypeParser.AgValueContext): - annoCtx = ctx.typeAnnotation() - valueCtx = ctx.value() - - if annoCtx is not None: - annoCtx.accept(self) - anno = annoCtx.IDENT().getText() - return self.handleAnnotatedValue(anno, valueCtx) - else: - return valueCtx.accept(self) - - - # Visit a parse tree produced by AgtypeParser#StringValue. - def visitStringValue(self, ctx:AgtypeParser.StringValueContext): - return ctx.STRING().getText().strip('"') - - - # Visit a parse tree produced by AgtypeParser#IntegerValue. - def visitIntegerValue(self, ctx:AgtypeParser.IntegerValueContext): - return int(ctx.INTEGER().getText()) - - # Visit a parse tree produced by AgtypeParser#floatLiteral. - def visitFloatLiteral(self, ctx:AgtypeParser.FloatLiteralContext): - c = ctx.getChild(0) - tp = c.symbol.type - text = ctx.getText() - if tp == AgtypeParser.RegularFloat: - return float(text) - elif tp == AgtypeParser.ExponentFloat: - return float(text) - else: - if text == 'NaN': - return float('nan') - elif text == '-Infinity': - return float('-inf') - elif text == 'Infinity': - return float('inf') - else: - return Exception("Unknown float expression:"+text) - - - # Visit a parse tree produced by AgtypeParser#TrueBoolean. - def visitTrueBoolean(self, ctx:AgtypeParser.TrueBooleanContext): - return True - - - # Visit a parse tree produced by AgtypeParser#FalseBoolean. - def visitFalseBoolean(self, ctx:AgtypeParser.FalseBooleanContext): - return False - - - # Visit a parse tree produced by AgtypeParser#NullValue. - def visitNullValue(self, ctx:AgtypeParser.NullValueContext): - return None - - - # Visit a parse tree produced by AgtypeParser#obj. - def visitObj(self, ctx:AgtypeParser.ObjContext): - obj = dict() - for c in ctx.getChildren(): - if isinstance(c, AgtypeParser.PairContext): - namVal = self.visitPair(c) - name = namVal[0] - valCtx = namVal[1] - val = valCtx.accept(self) - obj[name] = val - return obj - - - # Visit a parse tree produced by AgtypeParser#pair. - def visitPair(self, ctx:AgtypeParser.PairContext): - self.visitChildren(ctx) - return (ctx.STRING().getText().strip('"') , ctx.agValue()) - - - # Visit a parse tree produced by AgtypeParser#array. - def visitArray(self, ctx:AgtypeParser.ArrayContext): - li = list() - for c in ctx.getChildren(): - if not isinstance(c, TerminalNode): - val = c.accept(self) - li.append(val) - return li - - def handleAnnotatedValue(self, anno:str, ctx:ParserRuleContext): - if anno == "numeric": - return Decimal(ctx.getText()) - elif anno == "vertex": - dict = ctx.accept(self) - vid = dict["id"] - vertex = None - if self.vertexCache != None and vid in self.vertexCache : - vertex = self.vertexCache[vid] - else: - vertex = Vertex() - vertex.id = dict["id"] - vertex.label = dict["label"] - vertex.properties = dict["properties"] - - if self.vertexCache != None: - self.vertexCache[vid] = vertex - - return vertex - - elif anno == "edge": - edge = Edge() - dict = ctx.accept(self) - edge.id = dict["id"] - edge.label = dict["label"] - edge.end_id = dict["end_id"] - edge.start_id = dict["start_id"] - edge.properties = dict["properties"] - - return edge - - elif anno == "path": - arr = ctx.accept(self) - path = Path(arr) - - return path - - return ctx.accept(self) +# 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. +from . import gen +from .gen.AgtypeLexer import AgtypeLexer +from .gen.AgtypeParser import AgtypeParser +from .gen.AgtypeVisitor import AgtypeVisitor +from .models import * +from .exceptions import * +from antlr4 import InputStream, CommonTokenStream, ParserRuleContext +from antlr4.tree.Tree import TerminalNode +from decimal import Decimal + +resultHandler = None + +class ResultHandler: + def parse(ageData): + pass + +def newResultHandler(query=""): + resultHandler = Antlr4ResultHandler(None, query) + return resultHandler + +def parseAgeValue(value, cursor=None): + if value is None: + return None + + global resultHandler + if (resultHandler == None): + resultHandler = Antlr4ResultHandler(None) + try: + return resultHandler.parse(value) + except Exception as ex: + raise AGTypeError(value, ex) + + +class Antlr4ResultHandler(ResultHandler): + def __init__(self, vertexCache, query=None): + self.lexer = AgtypeLexer() + self.parser = AgtypeParser(None) + self.visitor = ResultVisitor(vertexCache) + + def parse(self, ageData): + if not ageData: + return None + # print("Parse::", ageData) + + self.lexer.inputStream = InputStream(ageData) + self.parser.setTokenStream(CommonTokenStream(self.lexer)) + self.parser.reset() + tree = self.parser.agType() + parsed = tree.accept(self.visitor) + return parsed + + +# print raw result String +class DummyResultHandler(ResultHandler): + def parse(self, ageData): + print(ageData) + +# default agType visitor +class ResultVisitor(AgtypeVisitor): + vertexCache = None + + def __init__(self, cache) -> None: + super().__init__() + self.vertexCache = cache + + + def visitAgType(self, ctx:AgtypeParser.AgTypeContext): + agVal = ctx.agValue() + if agVal != None: + obj = ctx.agValue().accept(self) + return obj + + return None + + def visitAgValue(self, ctx:AgtypeParser.AgValueContext): + annoCtx = ctx.typeAnnotation() + valueCtx = ctx.value() + + if annoCtx is not None: + annoCtx.accept(self) + identNode = annoCtx.IDENT() + if identNode is None: + raise AGTypeError(ctx.getText(), "Missing type annotation identifier") + anno = identNode.getText() + if valueCtx is None: + raise AGTypeError(ctx.getText(), "Missing value for annotated type") + return self.handleAnnotatedValue(anno, valueCtx) + else: + if valueCtx is None: + return None + return valueCtx.accept(self) + + + @staticmethod + def _stripStringDelimiters(stringToken): + # The STRING token always has surrounding '"' delimiters per the + # Agtype grammar; slice rather than strip('"') so escaped quotes + # at the boundaries are preserved. + return stringToken.getText()[1:-1] + + # Visit a parse tree produced by AgtypeParser#StringValue. + def visitStringValue(self, ctx:AgtypeParser.StringValueContext): + return self._stripStringDelimiters(ctx.STRING()) + + + # Visit a parse tree produced by AgtypeParser#IntegerValue. + def visitIntegerValue(self, ctx:AgtypeParser.IntegerValueContext): + return int(ctx.INTEGER().getText()) + + # Visit a parse tree produced by AgtypeParser#floatLiteral. + def visitFloatLiteral(self, ctx:AgtypeParser.FloatLiteralContext): + text = ctx.getText() + c = ctx.getChild(0) + if c is None or not hasattr(c, 'symbol') or c.symbol is None: + raise AGTypeError( + str(text), + "Malformed float literal: missing or invalid child node" + ) + tp = c.symbol.type + if tp == AgtypeParser.RegularFloat: + return float(text) + elif tp == AgtypeParser.ExponentFloat: + return float(text) + else: + if text == 'NaN': + return float('nan') + elif text == '-Infinity': + return float('-inf') + elif text == 'Infinity': + return float('inf') + else: + raise ValueError("Unknown float expression: " + text) + + + # Visit a parse tree produced by AgtypeParser#TrueBoolean. + def visitTrueBoolean(self, ctx:AgtypeParser.TrueBooleanContext): + return True + + + # Visit a parse tree produced by AgtypeParser#FalseBoolean. + def visitFalseBoolean(self, ctx:AgtypeParser.FalseBooleanContext): + return False + + + # Visit a parse tree produced by AgtypeParser#NullValue. + def visitNullValue(self, ctx:AgtypeParser.NullValueContext): + return None + + + # Visit a parse tree produced by AgtypeParser#obj. + def visitObj(self, ctx:AgtypeParser.ObjContext): + obj = dict() + for c in ctx.getChildren(): + if isinstance(c, AgtypeParser.PairContext): + namVal = self.visitPair(c) + name = namVal[0] + valCtx = namVal[1] + # visitPair() raises AGTypeError when the value node is + # missing, so valCtx should never be None here. The + # guard is kept as a defensive fallback only. + if valCtx is not None: + val = valCtx.accept(self) + obj[name] = val + else: + obj[name] = None + return obj + + + # Visit a parse tree produced by AgtypeParser#pair. + def visitPair(self, ctx:AgtypeParser.PairContext): + self.visitChildren(ctx) + strNode = ctx.STRING() + agValNode = ctx.agValue() + if strNode is None: + raise AGTypeError(ctx.getText(), "Missing key in object pair") + if agValNode is None: + raise AGTypeError(ctx.getText(), "Missing value in object pair") + return (self._stripStringDelimiters(strNode), agValNode) + + + # Visit a parse tree produced by AgtypeParser#array. + def visitArray(self, ctx:AgtypeParser.ArrayContext): + li = list() + for c in ctx.getChildren(): + if not isinstance(c, TerminalNode): + val = c.accept(self) + li.append(val) + return li + + def handleAnnotatedValue(self, anno:str, ctx:ParserRuleContext): + # Each branch below constructs a model object (Vertex, Edge, Path) + # and populates it from the parsed dict/list. If a type check + # fails (e.g. the parsed value is not a dict), AGTypeError is + # raised and the partially-constructed object is discarded — no + # cleanup is needed because the caller propagates the exception. + if anno == "numeric": + return Decimal(ctx.getText()) + elif anno == "vertex": + d = ctx.accept(self) + if not isinstance(d, dict): + raise AGTypeError(str(ctx.getText()), "Expected dict for vertex, got " + type(d).__name__) + vid = d.get("id") + vertex = None + if self.vertexCache is not None and vid in self.vertexCache: + vertex = self.vertexCache[vid] + else: + vertex = Vertex() + vertex.id = d.get("id") + vertex.label = d.get("label") + vertex.properties = d.get("properties") or {} + + if self.vertexCache is not None: + self.vertexCache[vid] = vertex + + return vertex + + elif anno == "edge": + edge = Edge() + d = ctx.accept(self) + if not isinstance(d, dict): + raise AGTypeError(str(ctx.getText()), "Expected dict for edge, got " + type(d).__name__) + edge.id = d.get("id") + edge.label = d.get("label") + edge.end_id = d.get("end_id") + edge.start_id = d.get("start_id") + edge.properties = d.get("properties") or {} + + return edge + + elif anno == "path": + arr = ctx.accept(self) + if not isinstance(arr, list): + raise AGTypeError(str(ctx.getText()), "Expected list for path, got " + type(arr).__name__) + path = Path(arr) + + return path + + return ctx.accept(self) diff --git a/drivers/python/age/exceptions.py b/drivers/python/age/exceptions.py index 3aa94f4b8..18292cc08 100644 --- a/drivers/python/age/exceptions.py +++ b/drivers/python/age/exceptions.py @@ -16,39 +16,74 @@ from psycopg.errors import * class AgeNotSet(Exception): - def __init__(self, name): + def __init__(self, name=None): self.name = name + super().__init__(name or 'AGE extension is not set.') - def __repr__(self) : + def __repr__(self): return 'AGE extension is not set.' class GraphNotFound(Exception): - def __init__(self, name): + def __init__(self, name=None): self.name = name + super().__init__(f'Graph[{name}] does not exist.' if name else 'Graph does not exist.') - def __repr__(self) : - return 'Graph[' + self.name + '] does not exist.' + def __repr__(self): + if self.name: + return 'Graph[' + self.name + '] does not exist.' + return 'Graph does not exist.' class GraphAlreadyExists(Exception): - def __init__(self, name): + def __init__(self, name=None): self.name = name + super().__init__(f'Graph[{name}] already exists.' if name else 'Graph already exists.') - def __repr__(self) : - return 'Graph[' + self.name + '] already exists.' + def __repr__(self): + if self.name: + return 'Graph[' + self.name + '] already exists.' + return 'Graph already exists.' + + +class InvalidGraphName(Exception): + """Raised when a graph name contains invalid characters.""" + def __init__(self, name, reason=None): + self.name = name + self.reason = reason + msg = f"Invalid graph name: '{name}'." + if reason: + msg += f" {reason}" + super().__init__(msg) + + def __repr__(self): + return f"InvalidGraphName('{self.name}')" + + +class InvalidIdentifier(Exception): + """Raised when an identifier (column, label, etc.) is invalid.""" + def __init__(self, name, context=None): + self.name = name + self.context = context + msg = f"Invalid identifier: '{name}'." + if context: + msg += f" {context}" + super().__init__(msg) + + def __repr__(self): + return f"InvalidIdentifier('{self.name}')" class GraphNotSet(Exception): - def __repr__(self) : + def __repr__(self): return 'Graph name is not set.' class NoConnection(Exception): - def __repr__(self) : + def __repr__(self): return 'No Connection' class NoCursor(Exception): - def __repr__(self) : + def __repr__(self): return 'No Cursor' class SqlExecutionError(Exception): @@ -57,7 +92,7 @@ def __init__(self, msg, cause): self.cause = cause super().__init__(msg, cause) - def __repr__(self) : + def __repr__(self): return 'SqlExecution [' + self.msg + ']' class AGTypeError(Exception): diff --git a/drivers/python/age/gen/AgtypeLexer.py b/drivers/python/age/gen/AgtypeLexer.py index 54f19c7db..f94dc828b 100644 --- a/drivers/python/age/gen/AgtypeLexer.py +++ b/drivers/python/age/gen/AgtypeLexer.py @@ -12,8 +12,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -# Generated from ../Agtype.g4 by ANTLR 4.11.1 - +# Generated from ../Agtype.g4 by ANTLR 4.13.2 from antlr4 import * from io import StringIO import sys @@ -142,9 +141,7 @@ class AgtypeLexer(Lexer): def __init__(self, input=None, output:TextIO = sys.stdout): super().__init__(input, output) - self.checkVersion("4.11.1") + self.checkVersion("4.13.2") self._interp = LexerATNSimulator(self, self.atn, self.decisionsToDFA, PredictionContextCache()) self._actions = None self._predicates = None - - diff --git a/drivers/python/age/gen/AgtypeListener.py b/drivers/python/age/gen/AgtypeListener.py index 6e60da421..39751ac51 100644 --- a/drivers/python/age/gen/AgtypeListener.py +++ b/drivers/python/age/gen/AgtypeListener.py @@ -12,10 +12,9 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -# Generated from ../Agtype.g4 by ANTLR 4.11.1 - +# Generated from ../Agtype.g4 by ANTLR 4.13.2 from antlr4 import * -if __name__ is not None and "." in __name__: +if "." in __name__: from .AgtypeParser import AgtypeParser else: from AgtypeParser import AgtypeParser @@ -159,4 +158,4 @@ def exitFloatLiteral(self, ctx:AgtypeParser.FloatLiteralContext): -del AgtypeParser \ No newline at end of file +del AgtypeParser diff --git a/drivers/python/age/gen/AgtypeParser.py b/drivers/python/age/gen/AgtypeParser.py index daaf578e8..19a43d08f 100644 --- a/drivers/python/age/gen/AgtypeParser.py +++ b/drivers/python/age/gen/AgtypeParser.py @@ -12,9 +12,8 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -# Generated from ../Agtype.g4 by ANTLR 4.11.1 +# Generated from ../Agtype.g4 by ANTLR 4.13.2 # encoding: utf-8 - from antlr4 import * from io import StringIO import sys @@ -108,7 +107,7 @@ class AgtypeParser ( Parser ): def __init__(self, input:TokenStream, output:TextIO = sys.stdout): super().__init__(input, output) - self.checkVersion("4.11.1") + self.checkVersion("4.13.2") self._interp = ParserATNSimulator(self, self.atn, self.decisionsToDFA, self.sharedContextCache) self._predicates = None @@ -854,5 +853,3 @@ def floatLiteral(self): finally: self.exitRule() return localctx - - diff --git a/drivers/python/age/gen/AgtypeVisitor.py b/drivers/python/age/gen/AgtypeVisitor.py index 824eb512e..8ea2f1479 100644 --- a/drivers/python/age/gen/AgtypeVisitor.py +++ b/drivers/python/age/gen/AgtypeVisitor.py @@ -12,10 +12,9 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -# Generated from ../Agtype.g4 by ANTLR 4.11.1 - +# Generated from ../Agtype.g4 by ANTLR 4.13.2 from antlr4 import * -if __name__ is not None and "." in __name__: +if "." in __name__: from .AgtypeParser import AgtypeParser else: from AgtypeParser import AgtypeParser @@ -100,4 +99,4 @@ def visitFloatLiteral(self, ctx:AgtypeParser.FloatLiteralContext): -del AgtypeParser \ No newline at end of file +del AgtypeParser diff --git a/drivers/python/age/models.py b/drivers/python/age/models.py index aee1b7599..62215c160 100644 --- a/drivers/python/age/models.py +++ b/drivers/python/age/models.py @@ -1,294 +1,334 @@ -# 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. - -import json -from io import StringIO - - -TP_NONE = 0 -TP_VERTEX = 1 -TP_EDGE = 2 -TP_PATH = 3 - - -class Graph(): - def __init__(self, stmt=None) -> None: - self.statement = stmt - self.rows = list() - self.vertices = dict() - - def __iter__(self): - return self.rows.__iter__() - - def __len__(self): - return self.rows.__len__() - - def __getitem__(self,index): - return self.rows[index] - - def size(self): - return self.rows.__len__() - - def append(self, agObj): - self.rows.append(agObj) - - def getVertices(self): - return self.vertices - - def getVertex(self, id): - if id in self.vertices: - return self.vertices[id] - else: - return None - -class AGObj: - @property - def gtype(self): - return TP_NONE - - -class Path(AGObj): - entities = [] - def __init__(self, entities=None) -> None: - self.entities = entities - - @property - def gtype(self): - return TP_PATH - - def __iter__(self): - return self.entities.__iter__() - - def __len__(self): - return self.entities.__len__() - - def __getitem__(self,index): - return self.entities[index] - - def size(self): - return self.entities.__len__() - - def append(self, agObj:AGObj ): - self.entities.append(agObj) - - def __str__(self) -> str: - return self.toString() - - def __repr__(self) -> str: - return self.toString() - - def toString(self) -> str: - buf = StringIO() - buf.write("[") - max = len(self.entities) - idx = 0 - while idx < max: - if idx > 0: - buf.write(",") - self.entities[idx]._toString(buf) - idx += 1 - buf.write("]::PATH") - - return buf.getvalue() - - def toJson(self) -> str: - buf = StringIO() - buf.write("{\"gtype\": \"path\", \"elements\": [") - - max = len(self.entities) - idx = 0 - while idx < max: - if idx > 0: - buf.write(",") - self.entities[idx]._toJson(buf) - idx += 1 - buf.write("]}") - - return buf.getvalue() - - - - -class Vertex(AGObj): - def __init__(self, id=None, label=None, properties=None) -> None: - self.id = id - self.label = label - self.properties = properties - - @property - def gtype(self): - return TP_VERTEX - - def __setitem__(self,name, value): - self.properties[name]=value - - def __getitem__(self,name): - if name in self.properties: - return self.properties[name] - else: - return None - - def __str__(self) -> str: - return self.toString() - - def __repr__(self) -> str: - return self.toString() - - def toString(self) -> str: - return nodeToString(self) - - def _toString(self, buf): - _nodeToString(self, buf) - - def toJson(self) -> str: - return nodeToJson(self) - - def _toJson(self, buf): - _nodeToJson(self, buf) - - -class Edge(AGObj): - def __init__(self, id=None, label=None, properties=None) -> None: - self.id = id - self.label = label - self.start_id = None - self.end_id = None - self.properties = properties - - @property - def gtype(self): - return TP_EDGE - - def __setitem__(self,name, value): - self.properties[name]=value - - def __getitem__(self,name): - if name in self.properties: - return self.properties[name] - else: - return None - - def __str__(self) -> str: - return self.toString() - - def __repr__(self) -> str: - return self.toString() - - def extraStrFormat(node, buf): - if node.start_id != None: - buf.write(", start_id:") - buf.write(str(node.start_id)) - - if node.end_id != None: - buf.write(", end_id:") - buf.write(str(node.end_id)) - - - def toString(self) -> str: - return nodeToString(self, Edge.extraStrFormat) - - def _toString(self, buf): - _nodeToString(self, buf, Edge.extraStrFormat) - - def extraJsonFormat(node, buf): - if node.start_id != None: - buf.write(", \"start_id\": \"") - buf.write(str(node.start_id)) - buf.write("\"") - - if node.end_id != None: - buf.write(", \"end_id\": \"") - buf.write(str(node.end_id)) - buf.write("\"") - - def toJson(self) -> str: - return nodeToJson(self, Edge.extraJsonFormat) - - def _toJson(self, buf): - _nodeToJson(self, buf, Edge.extraJsonFormat) - - -def nodeToString(node, extraFormatter=None): - buf = StringIO() - _nodeToString(node,buf,extraFormatter=extraFormatter) - return buf.getvalue() - - -def _nodeToString(node, buf, extraFormatter=None): - buf.write("{") - if node.label != None: - buf.write("label:") - buf.write(node.label) - - if node.id != None: - buf.write(", id:") - buf.write(str(node.id)) - - if node.properties != None: - buf.write(", properties:{") - prop_list = [] - for k, v in node.properties.items(): - prop_list.append(f"{k}: {str(v)}") - - # Join properties with comma and write to buffer - buf.write(", ".join(prop_list)) - buf.write("}") - - if extraFormatter != None: - extraFormatter(node, buf) - - if node.gtype == TP_VERTEX: - buf.write("}::VERTEX") - if node.gtype == TP_EDGE: - buf.write("}::EDGE") - - -def nodeToJson(node, extraFormatter=None): - buf = StringIO() - _nodeToJson(node, buf, extraFormatter=extraFormatter) - return buf.getvalue() - - -def _nodeToJson(node, buf, extraFormatter=None): - buf.write("{\"gtype\": ") - if node.gtype == TP_VERTEX: - buf.write("\"vertex\", ") - if node.gtype == TP_EDGE: - buf.write("\"edge\", ") - - if node.label != None: - buf.write("\"label\":\"") - buf.write(node.label) - buf.write("\"") - - if node.id != None: - buf.write(", \"id\":") - buf.write(str(node.id)) - - if extraFormatter != None: - extraFormatter(node, buf) - - if node.properties != None: - buf.write(", \"properties\":{") - - prop_list = [] - for k, v in node.properties.items(): - prop_list.append(f"\"{k}\": \"{str(v)}\"") - - # Join properties with comma and write to buffer - buf.write(", ".join(prop_list)) - buf.write("}") - buf.write("}") +# 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. + +import json +from io import StringIO + + +TP_NONE = 0 +TP_VERTEX = 1 +TP_EDGE = 2 +TP_PATH = 3 + + +class Graph(): + def __init__(self, stmt=None) -> None: + self.statement = stmt + self.rows = list() + self.vertices = dict() + + def __iter__(self): + return self.rows.__iter__() + + def __len__(self): + return self.rows.__len__() + + def __getitem__(self,index): + return self.rows[index] + + def size(self): + return self.rows.__len__() + + def append(self, agObj): + self.rows.append(agObj) + + def getVertices(self): + return self.vertices + + def getVertex(self, id): + if id in self.vertices: + return self.vertices[id] + else: + return None + +class AGObj: + @property + def gtype(self): + return TP_NONE + + +class Path(AGObj): + entities = [] + def __init__(self, entities=None) -> None: + self.entities = entities + + @property + def gtype(self): + return TP_PATH + + def __iter__(self): + return self.entities.__iter__() + + def __len__(self): + return self.entities.__len__() + + def __getitem__(self,index): + return self.entities[index] + + def size(self): + return self.entities.__len__() + + def append(self, agObj:AGObj ): + self.entities.append(agObj) + + def __str__(self) -> str: + return self.toString() + + def __repr__(self) -> str: + return self.toString() + + def toString(self) -> str: + buf = StringIO() + buf.write("[") + max = len(self.entities) + idx = 0 + while idx < max: + if idx > 0: + buf.write(",") + self.entities[idx]._toString(buf) + idx += 1 + buf.write("]::PATH") + + return buf.getvalue() + + def toJson(self) -> str: + buf = StringIO() + buf.write("{\"gtype\": \"path\", \"elements\": [") + + max = len(self.entities) + idx = 0 + while idx < max: + if idx > 0: + buf.write(",") + self.entities[idx]._toJson(buf) + idx += 1 + buf.write("]}") + + return buf.getvalue() + + def to_dict(self) -> list: + # AGObj elements are recursively converted; JSON-native types + # (dict, list, str, int, float, bool, None) pass through unchanged. + # Non-serializable objects fall back to str() as a safety net. + result = [] + for e in (self.entities or []): + if isinstance(e, AGObj): + result.append(e.to_dict()) + elif isinstance(e, (dict, list, str, int, float, bool, type(None))): + result.append(e) + else: + result.append(str(e)) + return result + + + + +class Vertex(AGObj): + def __init__(self, id=None, label=None, properties=None) -> None: + self.id = id + self.label = label + self.properties = properties + + @property + def gtype(self): + return TP_VERTEX + + def __setitem__(self,name, value): + self.properties[name]=value + + def __getitem__(self,name): + if name in self.properties: + return self.properties[name] + else: + return None + + def __str__(self) -> str: + return self.toString() + + def __repr__(self) -> str: + return self.toString() + + def to_dict(self) -> dict: + """Return a plain dict suitable for JSON serialization. + + Properties are shallow-copied; nested mutable values will share + references with the original Vertex. + """ + return { + "id": self.id, + "label": self.label, + "properties": dict(self.properties) if self.properties else {}, + } + + def toString(self) -> str: + return nodeToString(self) + + def _toString(self, buf): + _nodeToString(self, buf) + + def toJson(self) -> str: + return nodeToJson(self) + + def _toJson(self, buf): + _nodeToJson(self, buf) + + +class Edge(AGObj): + def __init__(self, id=None, label=None, properties=None) -> None: + self.id = id + self.label = label + self.start_id = None + self.end_id = None + self.properties = properties + + @property + def gtype(self): + return TP_EDGE + + def __setitem__(self,name, value): + self.properties[name]=value + + def __getitem__(self,name): + if name in self.properties: + return self.properties[name] + else: + return None + + def __str__(self) -> str: + return self.toString() + + def __repr__(self) -> str: + return self.toString() + + def to_dict(self) -> dict: + """Return a plain dict suitable for JSON serialization. + + Properties are shallow-copied; nested mutable values will share + references with the original Edge. + """ + return { + "id": self.id, + "label": self.label, + "start_id": self.start_id, + "end_id": self.end_id, + "properties": dict(self.properties) if self.properties else {}, + } + + def extraStrFormat(node, buf): + if node.start_id != None: + buf.write(", start_id:") + buf.write(str(node.start_id)) + + if node.end_id != None: + buf.write(", end_id:") + buf.write(str(node.end_id)) + + + def toString(self) -> str: + return nodeToString(self, Edge.extraStrFormat) + + def _toString(self, buf): + _nodeToString(self, buf, Edge.extraStrFormat) + + def extraJsonFormat(node, buf): + if node.start_id != None: + buf.write(", \"start_id\": \"") + buf.write(str(node.start_id)) + buf.write("\"") + + if node.end_id != None: + buf.write(", \"end_id\": \"") + buf.write(str(node.end_id)) + buf.write("\"") + + def toJson(self) -> str: + return nodeToJson(self, Edge.extraJsonFormat) + + def _toJson(self, buf): + _nodeToJson(self, buf, Edge.extraJsonFormat) + + +def nodeToString(node, extraFormatter=None): + buf = StringIO() + _nodeToString(node,buf,extraFormatter=extraFormatter) + return buf.getvalue() + + +def _nodeToString(node, buf, extraFormatter=None): + buf.write("{") + if node.label != None: + buf.write("label:") + buf.write(node.label) + + if node.id != None: + buf.write(", id:") + buf.write(str(node.id)) + + if node.properties != None: + buf.write(", properties:{") + prop_list = [] + for k, v in node.properties.items(): + prop_list.append(f"{k}: {str(v)}") + + # Join properties with comma and write to buffer + buf.write(", ".join(prop_list)) + buf.write("}") + + if extraFormatter != None: + extraFormatter(node, buf) + + if node.gtype == TP_VERTEX: + buf.write("}::VERTEX") + if node.gtype == TP_EDGE: + buf.write("}::EDGE") + + +def nodeToJson(node, extraFormatter=None): + buf = StringIO() + _nodeToJson(node, buf, extraFormatter=extraFormatter) + return buf.getvalue() + + +def _nodeToJson(node, buf, extraFormatter=None): + buf.write("{\"gtype\": ") + if node.gtype == TP_VERTEX: + buf.write("\"vertex\", ") + if node.gtype == TP_EDGE: + buf.write("\"edge\", ") + + if node.label != None: + buf.write("\"label\":\"") + buf.write(node.label) + buf.write("\"") + + if node.id != None: + buf.write(", \"id\":") + buf.write(str(node.id)) + + if extraFormatter != None: + extraFormatter(node, buf) + + if node.properties != None: + buf.write(", \"properties\":{") + + prop_list = [] + for k, v in node.properties.items(): + prop_list.append(f"\"{k}\": \"{str(v)}\"") + + # Join properties with comma and write to buffer + buf.write(", ".join(prop_list)) + buf.write("}") + buf.write("}") \ No newline at end of file diff --git a/drivers/python/age/networkx/lib.py b/drivers/python/age/networkx/lib.py index 308658620..5df761eae 100644 --- a/drivers/python/age/networkx/lib.py +++ b/drivers/python/age/networkx/lib.py @@ -20,17 +20,18 @@ from psycopg import sql from typing import Dict, Any, List, Set from age.models import Vertex, Edge, Path +from age.age import validate_graph_name, validate_identifier def checkIfGraphNameExistInAGE(connection: psycopg.connect, graphName: str): """Check if the age graph exists""" + validate_graph_name(graphName) with connection.cursor() as cursor: - cursor.execute(sql.SQL(""" - SELECT count(*) - FROM ag_catalog.ag_graph - WHERE name='%s' - """ % (graphName))) + cursor.execute( + sql.SQL("SELECT count(*) FROM ag_catalog.ag_graph WHERE name={gn}") + .format(gn=sql.Literal(graphName)) + ) if cursor.fetchone()[0] == 0: raise GraphNotFound(graphName) @@ -38,11 +39,13 @@ def checkIfGraphNameExistInAGE(connection: psycopg.connect, def getOidOfGraph(connection: psycopg.connect, graphName: str) -> int: """Returns oid of a graph""" + validate_graph_name(graphName) try: with connection.cursor() as cursor: - cursor.execute(sql.SQL(""" - SELECT graphid FROM ag_catalog.ag_graph WHERE name='%s' ; - """ % (graphName))) + cursor.execute( + sql.SQL("SELECT graphid FROM ag_catalog.ag_graph WHERE name={gn}") + .format(gn=sql.Literal(graphName)) + ) oid = cursor.fetchone()[0] return oid except Exception as e: @@ -56,7 +59,9 @@ def get_vlabel(connection: psycopg.connect, try: with connection.cursor() as cursor: cursor.execute( - """SELECT name FROM ag_catalog.ag_label WHERE kind='v' AND graph=%s;""" % oid) + sql.SQL("SELECT name FROM ag_catalog.ag_label WHERE kind='v' AND graph={oid}") + .format(oid=sql.Literal(oid)) + ) for row in cursor: node_label_list.append(row[0]) @@ -69,18 +74,19 @@ def create_vlabel(connection: psycopg.connect, graphName: str, node_label_list: List): """create_vlabels from list if not exist""" + validate_graph_name(graphName) try: node_label_set = set(get_vlabel(connection, graphName)) - crete_label_statement = '' for label in node_label_list: if label in node_label_set: continue - crete_label_statement += """SELECT create_vlabel('%s','%s');\n""" % ( - graphName, label) - if crete_label_statement != '': + validate_identifier(label, "Vertex label") with connection.cursor() as cursor: - cursor.execute(crete_label_statement) - connection.commit() + cursor.execute( + sql.SQL("SELECT create_vlabel({gn},{lbl})") + .format(gn=sql.Literal(graphName), lbl=sql.Literal(label)) + ) + connection.commit() except Exception as e: raise Exception(e) @@ -92,7 +98,9 @@ def get_elabel(connection: psycopg.connect, try: with connection.cursor() as cursor: cursor.execute( - """SELECT name FROM ag_catalog.ag_label WHERE kind='e' AND graph=%s;""" % oid) + sql.SQL("SELECT name FROM ag_catalog.ag_label WHERE kind='e' AND graph={oid}") + .format(oid=sql.Literal(oid)) + ) for row in cursor: edge_label_list.append(row[0]) except Exception as ex: @@ -103,19 +111,20 @@ def get_elabel(connection: psycopg.connect, def create_elabel(connection: psycopg.connect, graphName: str, edge_label_list: List): - """create_vlabels from list if not exist""" + """create_elabels from list if not exist""" + validate_graph_name(graphName) try: edge_label_set = set(get_elabel(connection, graphName)) - crete_label_statement = '' for label in edge_label_list: if label in edge_label_set: continue - crete_label_statement += """SELECT create_elabel('%s','%s');\n""" % ( - graphName, label) - if crete_label_statement != '': + validate_identifier(label, "Edge label") with connection.cursor() as cursor: - cursor.execute(crete_label_statement) - connection.commit() + cursor.execute( + sql.SQL("SELECT create_elabel({gn},{lbl})") + .format(gn=sql.Literal(graphName), lbl=sql.Literal(label)) + ) + connection.commit() except Exception as e: raise Exception(e) @@ -171,6 +180,7 @@ def getEdgeLabelListAfterPreprocessing(G: nx.DiGraph): def addAllNodesIntoAGE(connection: psycopg.connect, graphName: str, G: nx.DiGraph, node_label_list: Set): """Add all node to AGE""" + validate_graph_name(graphName) try: queue_data = {label: [] for label in node_label_list} id_data = {} @@ -180,8 +190,11 @@ def addAllNodesIntoAGE(connection: psycopg.connect, graphName: str, G: nx.DiGrap queue_data[data['label']].append((json_string,)) for label, rows in queue_data.items(): - table_name = """%s."%s" """ % (graphName, label) - insert_query = f"INSERT INTO {table_name} (properties) VALUES (%s) RETURNING id" + validate_identifier(label, "Node label") + insert_query = sql.SQL("INSERT INTO {schema}.{table} (properties) VALUES (%s) RETURNING id").format( + schema=sql.Identifier(graphName), + table=sql.Identifier(label) + ) cursor = connection.cursor() cursor.executemany(insert_query, rows, returning=True) ids = [] @@ -205,6 +218,7 @@ def addAllNodesIntoAGE(connection: psycopg.connect, graphName: str, G: nx.DiGrap def addAllEdgesIntoAGE(connection: psycopg.connect, graphName: str, G: nx.DiGraph, edge_label_list: Set): """Add all edge to AGE""" + validate_graph_name(graphName) try: queue_data = {label: [] for label in edge_label_list} for u, v, data in G.edges(data=True): @@ -213,8 +227,11 @@ def addAllEdgesIntoAGE(connection: psycopg.connect, graphName: str, G: nx.DiGrap (G.nodes[u]['properties']['__gid__'], G.nodes[v]['properties']['__gid__'], json_string,)) for label, rows in queue_data.items(): - table_name = """%s."%s" """ % (graphName, label) - insert_query = f"INSERT INTO {table_name} (start_id,end_id,properties) VALUES (%s, %s, %s)" + validate_identifier(label, "Edge label") + insert_query = sql.SQL("INSERT INTO {schema}.{table} (start_id,end_id,properties) VALUES (%s, %s, %s)").format( + schema=sql.Identifier(graphName), + table=sql.Identifier(label) + ) cursor = connection.cursor() cursor.executemany(insert_query, rows) connection.commit() @@ -225,14 +242,19 @@ def addAllEdgesIntoAGE(connection: psycopg.connect, graphName: str, G: nx.DiGrap def addAllNodesIntoNetworkx(connection: psycopg.connect, graphName: str, G: nx.DiGraph): """Add all nodes to Networkx""" + validate_graph_name(graphName) node_label_list = get_vlabel(connection, graphName) try: for label in node_label_list: + validate_identifier(label, "Node label") with connection.cursor() as cursor: - cursor.execute(""" - SELECT id, CAST(properties AS VARCHAR) - FROM %s."%s"; - """ % (graphName, label)) + cursor.execute( + sql.SQL("SELECT id, CAST(properties AS VARCHAR) FROM {schema}.{table}") + .format( + schema=sql.Identifier(graphName), + table=sql.Identifier(label) + ) + ) rows = cursor.fetchall() for row in rows: G.add_node(int(row[0]), label=label, @@ -243,14 +265,19 @@ def addAllNodesIntoNetworkx(connection: psycopg.connect, graphName: str, G: nx.D def addAllEdgesIntoNetworkx(connection: psycopg.connect, graphName: str, G: nx.DiGraph): """Add All edges to Networkx""" + validate_graph_name(graphName) try: edge_label_list = get_elabel(connection, graphName) for label in edge_label_list: + validate_identifier(label, "Edge label") with connection.cursor() as cursor: - cursor.execute(""" - SELECT start_id, end_id, CAST(properties AS VARCHAR) - FROM %s."%s"; - """ % (graphName, label)) + cursor.execute( + sql.SQL("SELECT start_id, end_id, CAST(properties AS VARCHAR) FROM {schema}.{table}") + .format( + schema=sql.Identifier(graphName), + table=sql.Identifier(label) + ) + ) rows = cursor.fetchall() for row in rows: G.add_edge(int(row[0]), int( diff --git a/drivers/python/pyproject.toml b/drivers/python/pyproject.toml index 18112381c..651fc2057 100644 --- a/drivers/python/pyproject.toml +++ b/drivers/python/pyproject.toml @@ -29,6 +29,7 @@ authors = [ {name = "Ikchan Kwon, Apache AGE", email = "dev-subscribe@age.apache.org"} ] classifiers = [ + "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -37,7 +38,11 @@ classifiers = [ ] dependencies = [ "psycopg", - "antlr4-python3-runtime==4.11.1", + # Parser is generated with ANTLR 4.13.2. Runtime is forward- and + # backward-compatible within the 4.x series (checkVersion() emits a + # warning on mismatch but parsing still works); tested on 4.11.1–4.13.2 + # with Python 3.9–3.14. + "antlr4-python3-runtime>=4.13.2,<5.0", ] [project.urls] diff --git a/drivers/python/requirements.txt b/drivers/python/requirements.txt index b0593b792..ceadc06ce 100644 Binary files a/drivers/python/requirements.txt and b/drivers/python/requirements.txt differ diff --git a/drivers/python/setup.py b/drivers/python/setup.py index d0eed26be..853f1006a 100644 --- a/drivers/python/setup.py +++ b/drivers/python/setup.py @@ -1,22 +1,22 @@ -# 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. - -# This setup.py is maintained for backward compatibility. -# All package configuration is in pyproject.toml. For installation, -# use: pip install . - -from setuptools import setup - -setup() +# 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. + +# This setup.py is maintained for backward compatibility. +# All package configuration is in pyproject.toml. For installation, +# use: pip install . + +from setuptools import setup + +setup() diff --git a/drivers/python/test_age_py.py b/drivers/python/test_age_py.py index f904fb9e3..bb92f7d51 100644 --- a/drivers/python/test_age_py.py +++ b/drivers/python/test_age_py.py @@ -13,11 +13,18 @@ # specific language governing permissions and limitations # under the License. import json +import re -from age.models import Vertex +from age.models import Vertex, Edge, Path import unittest +import unittest.mock import decimal import age +# _validate_column is private but tested directly because its quoting +# behavior is security-relevant and the public surface (buildCypher) +# makes it difficult to isolate quoting assertions. +from age.age import buildCypher, _validate_column +from age.exceptions import InvalidIdentifier import argparse TEST_HOST = "localhost" @@ -28,6 +35,296 @@ TEST_GRAPH_NAME = "test_graph" +class TestSetUpAge(unittest.TestCase): + """Unit tests for setUpAge() skip_load parameter — no DB required.""" + + def _make_mock_conn(self): + mock_conn = unittest.mock.MagicMock() + mock_cursor = unittest.mock.MagicMock() + mock_conn.cursor.return_value.__enter__ = unittest.mock.Mock(return_value=mock_cursor) + mock_conn.cursor.return_value.__exit__ = unittest.mock.Mock(return_value=False) + mock_conn.adapters = unittest.mock.MagicMock() + mock_type_info = unittest.mock.MagicMock() + mock_type_info.oid = 1 + mock_type_info.array_oid = 2 + return mock_conn, mock_cursor, mock_type_info + + def test_skip_load_true_does_not_execute_load(self): + """When skip_load=True, LOAD 'age' must not be executed.""" + mock_conn, mock_cursor, mock_type_info = self._make_mock_conn() + with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=mock_type_info), \ + unittest.mock.patch("age.age.checkGraphCreated"): + age.age.setUpAge(mock_conn, "test_graph", skip_load=True) + mock_cursor.execute.assert_called_once_with( + 'SET search_path = ag_catalog, "$user", public;' + ) + + def test_skip_load_false_executes_load(self): + """When skip_load=False (default), LOAD 'age' must be executed.""" + mock_conn, mock_cursor, mock_type_info = self._make_mock_conn() + with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=mock_type_info), \ + unittest.mock.patch("age.age.checkGraphCreated"): + age.age.setUpAge(mock_conn, "test_graph", skip_load=False) + mock_cursor.execute.assert_any_call("LOAD 'age';") + + def test_skip_load_with_load_from_plugins(self): + """When skip_load=False and load_from_plugins=True, LOAD from plugins path.""" + mock_conn, mock_cursor, mock_type_info = self._make_mock_conn() + with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=mock_type_info), \ + unittest.mock.patch("age.age.checkGraphCreated"): + age.age.setUpAge(mock_conn, "test_graph", load_from_plugins=True, skip_load=False) + mock_cursor.execute.assert_any_call("LOAD '$libdir/plugins/age';") + + def test_skip_load_true_still_sets_search_path(self): + """When skip_load=True, search_path must still be set.""" + mock_conn, mock_cursor, mock_type_info = self._make_mock_conn() + with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=mock_type_info), \ + unittest.mock.patch("age.age.checkGraphCreated"): + age.age.setUpAge(mock_conn, "test_graph", skip_load=True) + mock_cursor.execute.assert_any_call( + 'SET search_path = ag_catalog, "$user", public;' + ) + + def test_contradictory_skip_load_and_load_from_plugins_raises(self): + """skip_load=True + load_from_plugins=True must raise ValueError.""" + mock_conn, _, _ = self._make_mock_conn() + with self.assertRaises(ValueError): + age.age.setUpAge(mock_conn, "test_graph", load_from_plugins=True, skip_load=True) + + def test_connect_forwards_skip_load_to_setup(self): + """age.connect(skip_load=True) must forward skip_load through the full call chain.""" + with unittest.mock.patch("age.age.psycopg.connect") as mock_psycopg, \ + unittest.mock.patch("age.age.setUpAge") as mock_setup: + mock_psycopg.return_value = unittest.mock.MagicMock() + age.connect(dsn="host=localhost", graph="test_graph", skip_load=True) + mock_setup.assert_called_once() + _, kwargs = mock_setup.call_args + self.assertTrue( + kwargs.get("skip_load", False), + "skip_load must be forwarded from age.connect() to setUpAge()" + ) + + +class TestBuildCypher(unittest.TestCase): + """Unit tests for buildCypher() and _validate_column() — no DB required.""" + + def test_simple_column(self): + result = buildCypher("g", "MATCH (n) RETURN n", ["n"]) + self.assertIn('"n" agtype', result) + + def test_column_with_type(self): + result = buildCypher("g", "MATCH (n) RETURN n", ["n agtype"]) + self.assertIn('"n" agtype', result) + + def test_reserved_word_count(self): + """Issue #2370: 'count' is a PostgreSQL reserved word.""" + result = buildCypher("g", "MATCH (n) RETURN count(n)", ["count"]) + self.assertIn('"count" agtype', result) + # Verify 'count' never appears unquoted as a column name + self.assertIsNone( + re.search(r'(?() RETURN type(r)", ["type"]) + self.assertIn('"type" agtype', result) + + def test_reserved_word_select(self): + """Issue #2370: 'select' is a PostgreSQL reserved word.""" + result = buildCypher("g", "MATCH (n) RETURN n", ["select"]) + self.assertIn('"select" agtype', result) + + def test_reserved_word_group(self): + """Issue #2370: 'group' is a PostgreSQL reserved word.""" + result = buildCypher("g", "MATCH (n) RETURN n", ["group"]) + self.assertIn('"group" agtype', result) + + def test_multiple_columns(self): + result = buildCypher("g", "MATCH (n) RETURN n.name, count(n)", ["name", "count"]) + self.assertIn('"name" agtype', result) + self.assertIn('"count" agtype', result) + + def test_default_column(self): + result = buildCypher("g", "MATCH (n) RETURN n", None) + self.assertIn('"v" agtype', result) + + def test_invalid_column_rejected(self): + with self.assertRaises(InvalidIdentifier): + buildCypher("g", "MATCH (n) RETURN n", ["invalid;col"]) + + def test_reserved_word_in_name_type_pair(self): + """Quoting applies even when the column is specified as 'name type'.""" + result = buildCypher("g", "MATCH (n) RETURN n.order", ["order agtype"]) + self.assertIn('"order" agtype', result) + + def test_validate_column_quoting(self): + self.assertEqual(_validate_column("v"), '"v" agtype') + self.assertEqual(_validate_column("v agtype"), '"v" agtype') + self.assertEqual(_validate_column("count"), '"count" agtype') + self.assertEqual(_validate_column("my_col"), '"my_col" agtype') + + +class TestModelToDict(unittest.TestCase): + """Unit tests for Vertex/Edge/Path to_dict() — no DB required.""" + + def test_vertex_to_dict(self): + v = Vertex(id=123, label="Person", properties={"name": "Alice", "age": 30}) + d = v.to_dict() + self.assertEqual(d["id"], 123) + self.assertEqual(d["label"], "Person") + self.assertEqual(d["properties"], {"name": "Alice", "age": 30}) + # Verify it's a plain dict (JSON-serializable) + json_str = json.dumps(d) + self.assertIn("Alice", json_str) + + def test_vertex_to_dict_empty_properties(self): + v = Vertex(id=1, label="Empty", properties=None) + d = v.to_dict() + self.assertEqual(d["properties"], {}) + + def test_edge_to_dict(self): + e = Edge(id=456, label="KNOWS", properties={"since": 2020}) + e.start_id = 123 + e.end_id = 789 + d = e.to_dict() + self.assertEqual(d["id"], 456) + self.assertEqual(d["label"], "KNOWS") + self.assertEqual(d["start_id"], 123) + self.assertEqual(d["end_id"], 789) + self.assertEqual(d["properties"], {"since": 2020}) + json_str = json.dumps(d) + self.assertIn("KNOWS", json_str) + + def test_path_to_dict(self): + v1 = Vertex(id=1, label="A", properties={"name": "start"}) + e = Edge(id=10, label="r", properties={"w": 1}) + e.start_id = 1 + e.end_id = 2 + v2 = Vertex(id=2, label="B", properties={"name": "end"}) + p = Path([v1, e, v2]) + d = p.to_dict() + self.assertEqual(len(d), 3) + self.assertEqual(d[0]["label"], "A") + self.assertEqual(d[1]["label"], "r") + self.assertEqual(d[1]["start_id"], 1) + self.assertEqual(d[2]["label"], "B") + # Verify the whole path is JSON-serializable + json_str = json.dumps(d) + self.assertIn("start", json_str) + + def test_vertex_to_dict_is_plain_dict(self): + """to_dict() returns standard dict, not a model object.""" + v = Vertex(id=1, label="X", properties={"k": "v"}) + d = v.to_dict() + self.assertIsInstance(d, dict) + self.assertIsInstance(d["properties"], dict) + + +class TestPublicImports(unittest.TestCase): + """Verify that public API symbols are importable without type: ignore.""" + + def test_import_configure_connection(self): + from age import configure_connection + self.assertTrue(callable(configure_connection)) + + def test_import_age_loader(self): + from age import AgeLoader + self.assertIsNotNone(AgeLoader) + + def test_import_client_cursor(self): + from age import ClientCursor + self.assertIsNotNone(ClientCursor) + + +class TestConfigureConnection(unittest.TestCase): + """Unit tests for configure_connection() — no DB required.""" + + def _make_mock_conn(self): + mock_conn = unittest.mock.MagicMock() + mock_cursor = unittest.mock.MagicMock() + mock_conn.cursor.return_value.__enter__ = unittest.mock.Mock(return_value=mock_cursor) + mock_conn.cursor.return_value.__exit__ = unittest.mock.Mock(return_value=False) + mock_conn.adapters = unittest.mock.MagicMock() + mock_type_info = unittest.mock.MagicMock() + mock_type_info.oid = 1 + mock_type_info.array_oid = 2 + return mock_conn, mock_cursor, mock_type_info + + def test_default_does_not_load(self): + """By default, configure_connection should NOT execute LOAD.""" + mock_conn, mock_cursor, mock_type_info = self._make_mock_conn() + with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=mock_type_info), \ + unittest.mock.patch("age.age.checkGraphCreated"): + age.age.configure_connection(mock_conn) + mock_cursor.execute.assert_called_once_with( + 'SET search_path = ag_catalog, "$user", public;' + ) + + def test_load_true_executes_load(self): + """When load=True, LOAD 'age' must be executed.""" + mock_conn, mock_cursor, mock_type_info = self._make_mock_conn() + with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=mock_type_info), \ + unittest.mock.patch("age.age.checkGraphCreated"): + age.age.configure_connection(mock_conn, load=True) + mock_cursor.execute.assert_any_call("LOAD 'age';") + + def test_load_from_plugins(self): + """When load=True and load_from_plugins=True, use plugins path.""" + mock_conn, mock_cursor, mock_type_info = self._make_mock_conn() + with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=mock_type_info), \ + unittest.mock.patch("age.age.checkGraphCreated"): + age.age.configure_connection(mock_conn, load=True, load_from_plugins=True) + mock_cursor.execute.assert_any_call("LOAD '$libdir/plugins/age';") + + def test_load_from_plugins_without_load_raises(self): + """load_from_plugins=True without load=True must raise ValueError.""" + mock_conn, _, _ = self._make_mock_conn() + with self.assertRaises(ValueError): + age.age.configure_connection(mock_conn, load_from_plugins=True) + + def test_always_sets_search_path(self): + """search_path must always be set regardless of load parameter.""" + mock_conn, mock_cursor, mock_type_info = self._make_mock_conn() + with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=mock_type_info), \ + unittest.mock.patch("age.age.checkGraphCreated"): + age.age.configure_connection(mock_conn) + mock_cursor.execute.assert_any_call( + 'SET search_path = ag_catalog, "$user", public;' + ) + + def test_registers_agtype_adapters(self): + """AgeLoader must be registered for agtype OIDs.""" + mock_conn, mock_cursor, mock_type_info = self._make_mock_conn() + with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=mock_type_info), \ + unittest.mock.patch("age.age.checkGraphCreated"): + age.age.configure_connection(mock_conn) + mock_conn.adapters.register_loader.assert_any_call(1, age.age.AgeLoader) + mock_conn.adapters.register_loader.assert_any_call(2, age.age.AgeLoader) + + def test_graph_name_triggers_check(self): + """When graph_name is provided, checkGraphCreated must be called.""" + mock_conn, mock_cursor, mock_type_info = self._make_mock_conn() + with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=mock_type_info), \ + unittest.mock.patch("age.age.checkGraphCreated") as mock_check: + age.age.configure_connection(mock_conn, graph_name="my_graph") + mock_check.assert_called_once_with(mock_conn, "my_graph") + + def test_age_not_set_when_type_info_is_none(self): + """AgeNotSet must be raised when TypeInfo.fetch returns None.""" + from age.exceptions import AgeNotSet + mock_conn, _, _ = self._make_mock_conn() + with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=None): + with self.assertRaises(AgeNotSet): + age.age.configure_connection(mock_conn) + + class TestAgeBasic(unittest.TestCase): ag = None args: argparse.Namespace = argparse.Namespace( @@ -448,6 +745,78 @@ def testSerialization(self): self.assertFalse(as_str.endswith(", }}::VERTEX")) print("Vertex.toString() 'properties' field is formatted properly.") + def testConfigureConnection(self): + """Integration: configure_connection() on an externally-opened + connection must register agtype adapters so cypher queries return + real Vertex/Edge/Path objects, and to_dict() must round-trip those + parser-produced objects through json.dumps().""" + print("\n-------------------------------------------------------") + print("Test 8: configure_connection + to_dict end-to-end.....") + print("-------------------------------------------------------\n") + + import psycopg + + from age import configure_connection + + dsn = "host={host} port={port} dbname={database} user={user} password={password}".format( + **vars(self.args) + ) + # Deliberately bypass age.connect(): the whole point of + # configure_connection() is to enable AGE on a caller-managed + # connection (e.g. one obtained from psycopg_pool.ConnectionPool). + raw = psycopg.connect(dsn) + try: + configure_connection(raw, graph_name=self.args.graphName, load=True) + + graph = self.args.graphName + with raw.cursor() as cur: + cur.execute( + f"SELECT * FROM cypher('{graph}', $$ " + "CREATE (a:Person {name: 'Alice'})-[r:KNOWS {since: 2020}]->(b:Person {name: 'Bob'}) " + "RETURN a, r, b " + "$$) AS (a agtype, r agtype, b agtype);" + ) + row = cur.fetchone() + raw.commit() + + v_a, e, v_b = row + self.assertIsInstance(v_a, Vertex) + self.assertIsInstance(e, Edge) + self.assertIsInstance(v_b, Vertex) + self.assertEqual(v_a["name"], "Alice") + self.assertEqual(v_b["name"], "Bob") + self.assertEqual(e["since"], 2020) + + payload = { + "start": v_a.to_dict(), + "edge": e.to_dict(), + "end": v_b.to_dict(), + } + serialised = json.loads(json.dumps(payload)) + self.assertEqual(serialised["start"]["label"], "Person") + self.assertEqual(serialised["edge"]["label"], "KNOWS") + self.assertEqual(serialised["edge"]["start_id"], v_a.id) + self.assertEqual(serialised["edge"]["end_id"], v_b.id) + self.assertEqual(serialised["start"]["properties"]["name"], "Alice") + + with raw.cursor() as cur: + cur.execute( + f"SELECT * FROM cypher('{graph}', $$ " + "MATCH p=(:Person)-[:KNOWS]->(:Person) RETURN p " + "$$) AS (p agtype);" + ) + path = cur.fetchone()[0] + + self.assertIsInstance(path, Path) + path_dict = json.loads(json.dumps(path.to_dict())) + self.assertEqual(len(path_dict), 3) + self.assertEqual(path_dict[0]["label"], "Person") + self.assertEqual(path_dict[1]["label"], "KNOWS") + self.assertEqual(path_dict[2]["label"], "Person") + print("\nTest 8 Successful....") + finally: + raw.close() + if __name__ == "__main__": parser = argparse.ArgumentParser() @@ -485,6 +854,14 @@ def testSerialization(self): args = parser.parse_args() suite = unittest.TestSuite() + # Unit tests (no DB required) + loader = unittest.TestLoader() + suite.addTests(loader.loadTestsFromTestCase(TestSetUpAge)) + suite.addTests(loader.loadTestsFromTestCase(TestBuildCypher)) + suite.addTests(loader.loadTestsFromTestCase(TestModelToDict)) + suite.addTests(loader.loadTestsFromTestCase(TestPublicImports)) + suite.addTests(loader.loadTestsFromTestCase(TestConfigureConnection)) + # Integration tests (require DB) suite.addTest(TestAgeBasic("testExec")) suite.addTest(TestAgeBasic("testQuery")) suite.addTest(TestAgeBasic("testChangeData")) @@ -492,5 +869,6 @@ def testSerialization(self): suite.addTest(TestAgeBasic("testMultipleEdges")) suite.addTest(TestAgeBasic("testCollect")) suite.addTest(TestAgeBasic("testSerialization")) + suite.addTest(TestAgeBasic("testConfigureConnection")) TestAgeBasic.args = args unittest.TextTestRunner().run(suite) diff --git a/drivers/python/test_agtypes.py b/drivers/python/test_agtypes.py index 69bbbc298..7de0f5f52 100644 --- a/drivers/python/test_agtypes.py +++ b/drivers/python/test_agtypes.py @@ -1,132 +1,322 @@ -# 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. - -import unittest -from decimal import Decimal -import math -import age - - -class TestAgtype(unittest.TestCase): - resultHandler = None - - def __init__(self, methodName: str) -> None: - super().__init__(methodName=methodName) - self.resultHandler = age.newResultHandler() - - def parse(self, exp): - return self.resultHandler.parse(exp) - - def test_scalar(self): - print("\nTesting Scalar Value Parsing. Result : ", end='') - - mapStr = '{"name": "Smith", "num":123, "yn":true, "bigInt":123456789123456789123456789123456789::numeric}' - arrStr = '["name", "Smith", "num", 123, "yn", true, 123456789123456789123456789123456789.8888::numeric]' - strStr = '"abcd"' - intStr = '1234' - floatStr = '1234.56789' - floatStr2 = '6.45161290322581e+46' - numericStr1 = '12345678901234567890123456789123456789.789::numeric' - numericStr2 = '12345678901234567890123456789123456789::numeric' - boolStr = 'true' - nullStr = '' - nanStr = "NaN" - infpStr = "Infinity" - infnStr = "-Infinity" - - mapVal = self.parse(mapStr) - arrVal = self.parse(arrStr) - str = self.parse(strStr) - intVal = self.parse(intStr) - floatVal = self.parse(floatStr) - floatVal2 = self.parse(floatStr2) - bigFloat = self.parse(numericStr1) - bigInt = self.parse(numericStr2) - boolVal = self.parse(boolStr) - nullVal = self.parse(nullStr) - nanVal = self.parse(nanStr) - infpVal = self.parse(infpStr) - infnVal = self.parse(infnStr) - - self.assertEqual(mapVal, {'name': 'Smith', 'num': 123, 'yn': True, 'bigInt': Decimal( - '123456789123456789123456789123456789')}) - self.assertEqual(arrVal, ["name", "Smith", "num", 123, "yn", True, Decimal( - "123456789123456789123456789123456789.8888")]) - self.assertEqual(str, "abcd") - self.assertEqual(intVal, 1234) - self.assertEqual(floatVal, 1234.56789) - self.assertEqual(floatVal2, 6.45161290322581e+46) - self.assertEqual(bigFloat, Decimal( - "12345678901234567890123456789123456789.789")) - self.assertEqual(bigInt, Decimal( - "12345678901234567890123456789123456789")) - self.assertEqual(boolVal, True) - self.assertTrue(math.isnan(nanVal)) - self.assertTrue(math.isinf(infpVal)) - self.assertTrue(math.isinf(infnVal)) - - def test_vertex(self): - - print("\nTesting vertex Parsing. Result : ", end='') - - vertexExp = '''{"id": 2251799813685425, "label": "Person", - "properties": {"name": "Smith", "numInt":123, "numFloat": 384.23424, - "bigInt":123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789::numeric, - "bigFloat":123456789123456789123456789123456789.12345::numeric, - "yn":true, "nullVal": null}}::vertex''' - - vertex = self.parse(vertexExp) - self.assertEqual(vertex.id, 2251799813685425) - self.assertEqual(vertex.label, "Person") - self.assertEqual(vertex["name"], "Smith") - self.assertEqual(vertex["numInt"], 123) - self.assertEqual(vertex["numFloat"], 384.23424) - self.assertEqual(vertex["bigInt"], Decimal( - "123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789")) - self.assertEqual(vertex["bigFloat"], Decimal( - "123456789123456789123456789123456789.12345")) - self.assertEqual(vertex["yn"], True) - self.assertEqual(vertex["nullVal"], None) - - def test_path(self): - - print("\nTesting Path Parsing. Result : ", end='') - - pathExp = '''[{"id": 2251799813685425, "label": "Person", "properties": {"name": "Smith"}}::vertex, - {"id": 2533274790396576, "label": "workWith", "end_id": 2251799813685425, "start_id": 2251799813685424, - "properties": {"weight": 3, "bigFloat":123456789123456789123456789.12345::numeric}}::edge, - {"id": 2251799813685424, "label": "Person", "properties": {"name": "Joe"}}::vertex]::path''' - - path = self.parse(pathExp) - vertexStart = path[0] - edge = path[1] - vertexEnd = path[2] - self.assertEqual(vertexStart.id, 2251799813685425) - self.assertEqual(vertexStart.label, "Person") - self.assertEqual(vertexStart["name"], "Smith") - - self.assertEqual(edge.id, 2533274790396576) - self.assertEqual(edge.label, "workWith") - self.assertEqual(edge["weight"], 3) - self.assertEqual(edge["bigFloat"], Decimal( - "123456789123456789123456789.12345")) - - self.assertEqual(vertexEnd.id, 2251799813685424) - self.assertEqual(vertexEnd.label, "Person") - self.assertEqual(vertexEnd["name"], "Joe") - - -if __name__ == '__main__': - unittest.main() +# 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. + +import json +import math +import unittest +from decimal import Decimal + +import age + + +class TestAgtype(unittest.TestCase): + resultHandler = None + + def __init__(self, methodName: str) -> None: + super().__init__(methodName=methodName) + self.resultHandler = age.newResultHandler() + + def parse(self, exp): + return self.resultHandler.parse(exp) + + def test_scalar(self): + print("\nTesting Scalar Value Parsing. Result : ", end='') + + mapStr = '{"name": "Smith", "num":123, "yn":true, "bigInt":123456789123456789123456789123456789::numeric}' + arrStr = '["name", "Smith", "num", 123, "yn", true, 123456789123456789123456789123456789.8888::numeric]' + strStr = '"abcd"' + intStr = '1234' + floatStr = '1234.56789' + floatStr2 = '6.45161290322581e+46' + numericStr1 = '12345678901234567890123456789123456789.789::numeric' + numericStr2 = '12345678901234567890123456789123456789::numeric' + boolStr = 'true' + nullStr = '' + nanStr = "NaN" + infpStr = "Infinity" + infnStr = "-Infinity" + + mapVal = self.parse(mapStr) + arrVal = self.parse(arrStr) + str = self.parse(strStr) + intVal = self.parse(intStr) + floatVal = self.parse(floatStr) + floatVal2 = self.parse(floatStr2) + bigFloat = self.parse(numericStr1) + bigInt = self.parse(numericStr2) + boolVal = self.parse(boolStr) + nullVal = self.parse(nullStr) + nanVal = self.parse(nanStr) + infpVal = self.parse(infpStr) + infnVal = self.parse(infnStr) + + self.assertEqual(mapVal, {'name': 'Smith', 'num': 123, 'yn': True, 'bigInt': Decimal( + '123456789123456789123456789123456789')}) + self.assertEqual(arrVal, ["name", "Smith", "num", 123, "yn", True, Decimal( + "123456789123456789123456789123456789.8888")]) + self.assertEqual(str, "abcd") + self.assertEqual(intVal, 1234) + self.assertEqual(floatVal, 1234.56789) + self.assertEqual(floatVal2, 6.45161290322581e+46) + self.assertEqual(bigFloat, Decimal( + "12345678901234567890123456789123456789.789")) + self.assertEqual(bigInt, Decimal( + "12345678901234567890123456789123456789")) + self.assertEqual(boolVal, True) + self.assertTrue(math.isnan(nanVal)) + self.assertTrue(math.isinf(infpVal)) + self.assertTrue(math.isinf(infnVal)) + + def test_vertex(self): + + print("\nTesting vertex Parsing. Result : ", end='') + + vertexExp = '''{"id": 2251799813685425, "label": "Person", + "properties": {"name": "Smith", "numInt":123, "numFloat": 384.23424, + "bigInt":123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789::numeric, + "bigFloat":123456789123456789123456789123456789.12345::numeric, + "yn":true, "nullVal": null}}::vertex''' + + vertex = self.parse(vertexExp) + self.assertEqual(vertex.id, 2251799813685425) + self.assertEqual(vertex.label, "Person") + self.assertEqual(vertex["name"], "Smith") + self.assertEqual(vertex["numInt"], 123) + self.assertEqual(vertex["numFloat"], 384.23424) + self.assertEqual(vertex["bigInt"], Decimal( + "123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789123456789")) + self.assertEqual(vertex["bigFloat"], Decimal( + "123456789123456789123456789123456789.12345")) + self.assertEqual(vertex["yn"], True) + self.assertEqual(vertex["nullVal"], None) + + def test_path(self): + + print("\nTesting Path Parsing. Result : ", end='') + + pathExp = '''[{"id": 2251799813685425, "label": "Person", "properties": {"name": "Smith"}}::vertex, + {"id": 2533274790396576, "label": "workWith", "end_id": 2251799813685425, "start_id": 2251799813685424, + "properties": {"weight": 3, "bigFloat":123456789123456789123456789.12345::numeric}}::edge, + {"id": 2251799813685424, "label": "Person", "properties": {"name": "Joe"}}::vertex]::path''' + + path = self.parse(pathExp) + vertexStart = path[0] + edge = path[1] + vertexEnd = path[2] + self.assertEqual(vertexStart.id, 2251799813685425) + self.assertEqual(vertexStart.label, "Person") + self.assertEqual(vertexStart["name"], "Smith") + + self.assertEqual(edge.id, 2533274790396576) + self.assertEqual(edge.label, "workWith") + self.assertEqual(edge["weight"], 3) + self.assertEqual(edge["bigFloat"], Decimal( + "123456789123456789123456789.12345")) + + self.assertEqual(vertexEnd.id, 2251799813685424) + self.assertEqual(vertexEnd.label, "Person") + self.assertEqual(vertexEnd["name"], "Joe") + + def test_vertex_large_array_properties(self): + """Issue #2367: Parser should handle vertices with large array properties.""" + vertexExp = ( + '{"id": 1125899906842625, "label": "TestNode", ' + '"properties": {"name": "test", ' + '"tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", ' + '"tag8", "tag9", "tag10", "tag11", "tag12"]}}::vertex' + ) + vertex = self.parse(vertexExp) + self.assertEqual(vertex.id, 1125899906842625) + self.assertEqual(vertex.label, "TestNode") + self.assertEqual(vertex["name"], "test") + self.assertEqual(len(vertex["tags"]), 12) + self.assertEqual(vertex["tags"][0], "tag1") + self.assertEqual(vertex["tags"][11], "tag12") + + def test_vertex_special_characters_in_properties(self): + """Issue #2367: Parser accepts JSON-escaped property strings and UTF-8.""" + # Input uses json.dumps so quotes, backslashes, and newlines are valid JSON. + logical_description = 'Quoted "text", path C:\\tmp\\file, line1\nline2, café 雪' + props = json.dumps( + {"name": "test", "description": logical_description}, + ensure_ascii=False, + ) + vertexExp = ( + '{"id": 1125899906842626, "label": "TestNode", ' + '"properties": ' + props + '}::vertex' + ) + vertex = self.parse(vertexExp) + self.assertEqual(vertex.id, 1125899906842626) + self.assertEqual(vertex["name"], "test") + # The agtype visitor keeps JSON string escapes as literal characters + # (except UTF-8 code points, which decode normally). + self.assertEqual( + vertex["description"], + 'Quoted \\"text\\", path C:\\\\tmp\\\\file, line1\\nline2, café 雪', + ) + + def test_vertex_nested_properties(self): + """Issue #2367: Parser should handle deeply nested property structures.""" + vertexExp = ( + '{"id": 1125899906842627, "label": "TestNode", ' + '"properties": {"name": "test", ' + '"metadata": {"level1": {"level2": {"level3": "deep_value"}}}}}::vertex' + ) + vertex = self.parse(vertexExp) + self.assertEqual(vertex.id, 1125899906842627) + self.assertEqual(vertex["name"], "test") + self.assertEqual(vertex["metadata"]["level1"]["level2"]["level3"], "deep_value") + + def test_vertex_empty_properties(self): + """Parser should handle vertices with empty properties dict.""" + vertexExp = '{"id": 1125899906842628, "label": "EmptyNode", "properties": {}}::vertex' + vertex = self.parse(vertexExp) + self.assertEqual(vertex.id, 1125899906842628) + self.assertEqual(vertex.label, "EmptyNode") + self.assertEqual(vertex.properties, {}) + + def test_vertex_null_property_values(self): + """Parser should handle vertices with null property values.""" + vertexExp = ( + '{"id": 1125899906842629, "label": "TestNode", ' + '"properties": {"name": "test", "optional": null, "also_null": null}}::vertex' + ) + vertex = self.parse(vertexExp) + self.assertEqual(vertex["name"], "test") + self.assertIsNone(vertex["optional"]) + self.assertIsNone(vertex["also_null"]) + + def test_edge_with_complex_properties(self): + """Parser should handle edges with complex property structures.""" + edgeExp = ( + '{"id": 2533274790396577, "label": "HAS_RELATION", ' + '"end_id": 1125899906842625, "start_id": 1125899906842626, ' + '"properties": {"weight": 3, "tags": ["a", "b", "c"], "active": true}}::edge' + ) + edge = self.parse(edgeExp) + self.assertEqual(edge.id, 2533274790396577) + self.assertEqual(edge.label, "HAS_RELATION") + self.assertEqual(edge.start_id, 1125899906842626) + self.assertEqual(edge.end_id, 1125899906842625) + self.assertEqual(edge["weight"], 3) + self.assertEqual(edge["tags"], ["a", "b", "c"]) + self.assertEqual(edge["active"], True) + + def test_path_with_multiple_edges(self): + """Parser should handle paths with multiple edges and complex properties.""" + pathExp = ( + '[{"id": 1, "label": "A", "properties": {"name": "start"}}::vertex, ' + '{"id": 10, "label": "r1", "end_id": 2, "start_id": 1, "properties": {"w": 1}}::edge, ' + '{"id": 2, "label": "B", "properties": {"name": "middle"}}::vertex, ' + '{"id": 11, "label": "r2", "end_id": 3, "start_id": 2, "properties": {"w": 2}}::edge, ' + '{"id": 3, "label": "C", "properties": {"name": "end"}}::vertex]::path' + ) + path = self.parse(pathExp) + self.assertEqual(len(path), 5) + self.assertEqual(path[0]["name"], "start") + self.assertEqual(path[2]["name"], "middle") + self.assertEqual(path[4]["name"], "end") + + def test_empty_input(self): + """Parser should handle empty/null input gracefully.""" + self.assertIsNone(self.parse('')) + self.assertIsNone(self.parse(None)) + + def test_array_of_mixed_types(self): + """Parser should handle arrays with mixed types including nested arrays.""" + arrStr = '["str", 42, true, null, [1, 2, 3], {"key": "val"}]' + result = self.parse(arrStr) + self.assertEqual(result[0], "str") + self.assertEqual(result[1], 42) + self.assertEqual(result[2], True) + self.assertIsNone(result[3]) + self.assertEqual(result[4], [1, 2, 3]) + self.assertEqual(result[5], {"key": "val"}) + + def test_string_value_preserves_inner_quotes(self): + """Issue #2418: preserve escaped boundary quotes when stripping. + + visitStringValue must strip only the outer delimiters; otherwise + values like '"foo \\"bar\\""' lose their trailing escaped quote. + """ + self.assertEqual(self.parse('"foo \\"bar\\""'), 'foo \\"bar\\"') + self.assertEqual(self.parse('"\\"leading"'), '\\"leading') + self.assertEqual(self.parse('"trailing\\""'), 'trailing\\"') + self.assertEqual(self.parse('""'), '') + # Same fix applies to visitPair() for object keys. + self.assertEqual( + self.parse('{"key\\"q": 1}'), + {'key\\"q': 1}, + ) + + def test_malformed_vertex_raises_agtypeerror_or_recovers(self): + """Issue #2367: Malformed agtype must raise AGTypeError or recover gracefully.""" + from age.exceptions import AGTypeError + + malformed_inputs = [ + '{"id": 1, "label":}::vertex', + '{"id": 1, "label": "X", "properties": {}::vertex', + '{::vertex', + '{"id": 1, "label": "X", "properties": {"key":}}::vertex', + ] + for inp in malformed_inputs: + try: + result = self.parse(inp) + # Parser recovery is acceptable — verify the result is a + # usable Python value (None, container, or model object). + self.assertTrue( + result is None + or isinstance(result, (dict, list, tuple)) + or hasattr(result, "__dict__"), + f"Recovered to unexpected type {type(result).__name__}: {inp}" + ) + except AGTypeError: + pass # expected + except AttributeError: + self.fail( + f"Malformed input raised AttributeError instead of " + f"AGTypeError: {inp}" + ) + + def test_truncated_agtype_does_not_crash(self): + """Issue #2367: Truncated agtype must raise AGTypeError or recover, never AttributeError.""" + from age.exceptions import AGTypeError + + truncated_inputs = [ + '{"id": 1, "label": "X", "properties": {"name": "te', + '{"id": 1, "label": "X"', + '[{"id": 1}::vertex, {"id": 2', + ] + for inp in truncated_inputs: + try: + result = self.parse(inp) + # Recovery is acceptable for truncated input + self.assertTrue( + result is None + or isinstance(result, (dict, list, tuple)) + or hasattr(result, "__dict__"), + f"Recovered to unexpected type {type(result).__name__}: {inp}" + ) + except AGTypeError: + pass # expected + except AttributeError: + self.fail( + f"Truncated input raised AttributeError instead of " + f"AGTypeError: {inp}" + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/drivers/python/test_networkx.py b/drivers/python/test_networkx.py index 310d2cf5e..dbaaf8664 100644 --- a/drivers/python/test_networkx.py +++ b/drivers/python/test_networkx.py @@ -224,7 +224,7 @@ def test_existing_graph(self): with self.assertRaises(GraphNotFound) as context: age_to_networkx(ag.connection, graphName=non_existing_graph) # Check the raised exception has the expected error message - self.assertEqual(str(context.exception), non_existing_graph) + self.assertIn(non_existing_graph, str(context.exception)) class TestNetworkxToAGE(unittest.TestCase): diff --git a/drivers/python/test_security.py b/drivers/python/test_security.py new file mode 100644 index 000000000..01afee793 --- /dev/null +++ b/drivers/python/test_security.py @@ -0,0 +1,274 @@ +# 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. + +"""Security tests for the Apache AGE Python driver. + +Tests input validation, SQL injection prevention, and exception handling. +""" + +import unittest +from age.age import ( + validate_graph_name, + validate_identifier, + buildCypher, + _validate_column, +) +from age.exceptions import ( + AgeNotSet, + GraphNotFound, + GraphAlreadyExists, + GraphNotSet, + InvalidGraphName, + InvalidIdentifier, +) + + +class TestGraphNameValidation(unittest.TestCase): + """Test validate_graph_name rejects dangerous inputs.""" + + def test_rejects_empty_string(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name('') + + def test_rejects_none(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name(None) + + def test_rejects_non_string(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name(123) + + def test_rejects_digit_start(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name('123graph') + + def test_rejects_sql_injection_drop_table(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name("'; DROP TABLE ag_graph; --") + + def test_rejects_sql_injection_semicolon(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name("test'); DROP TABLE users; --") + + def test_rejects_sql_injection_select(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name("graph; SELECT * FROM pg_shadow") + + def test_accepts_hyphenated_graph_name(self): + # AGE allows hyphens in middle positions of graph names. + validate_graph_name('my-graph') + + def test_rejects_space(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name('my graph') + + def test_accepts_dotted_graph_name(self): + # AGE allows dots in middle positions of graph names. + validate_graph_name('my.graph') + + def test_rejects_dollar(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name('my$graph') + + def test_rejects_exceeding_63_chars(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name('a' * 64) + + def test_accepts_valid_names(self): + # These should NOT raise + validate_graph_name('my_graph') + validate_graph_name('MyGraph') + validate_graph_name('_pr_ivate') + validate_graph_name('graph123') + validate_graph_name('my-graph') + validate_graph_name('my.graph') + validate_graph_name('a-b.c_d') + validate_graph_name('abc') + validate_graph_name('a' * 63) + + def test_rejects_shorter_than_3_chars(self): + # AGE requires minimum 3 character graph names. + with self.assertRaises(InvalidGraphName): + validate_graph_name('a') + with self.assertRaises(InvalidGraphName): + validate_graph_name('ab') + + def test_rejects_name_ending_with_hyphen(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name('graph-') + + def test_rejects_name_ending_with_dot(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name('graph.') + + def test_rejects_name_starting_with_hyphen(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name('-graph') + + def test_rejects_name_starting_with_dot(self): + with self.assertRaises(InvalidGraphName): + validate_graph_name('.graph') + + def test_error_message_contains_name(self): + try: + validate_graph_name("bad;name") + self.fail("Expected InvalidGraphName") + except InvalidGraphName as e: + self.assertIn("bad;name", str(e)) + self.assertIn("Invalid graph name", str(e)) + + +class TestIdentifierValidation(unittest.TestCase): + """Test validate_identifier rejects dangerous inputs.""" + + def test_rejects_empty_string(self): + with self.assertRaises(InvalidIdentifier): + validate_identifier('') + + def test_rejects_none(self): + with self.assertRaises(InvalidIdentifier): + validate_identifier(None) + + def test_rejects_sql_injection(self): + with self.assertRaises(InvalidIdentifier): + validate_identifier("Person'; DROP TABLE--") + + def test_rejects_special_chars(self): + with self.assertRaises(InvalidIdentifier): + validate_identifier("col; DROP TABLE") + + def test_accepts_valid_identifiers(self): + validate_identifier('Person') + validate_identifier('KNOWS') + validate_identifier('_internal') + validate_identifier('col1') + + def test_error_includes_context(self): + try: + validate_identifier("bad;name", "Column name") + self.fail("Expected InvalidIdentifier") + except InvalidIdentifier as e: + self.assertIn("Column name", str(e)) + + +class TestColumnValidation(unittest.TestCase): + """Test _validate_column prevents injection through column specs.""" + + def test_plain_column_name(self): + self.assertEqual(_validate_column('v'), '"v" agtype') + + def test_column_with_type(self): + self.assertEqual(_validate_column('n agtype'), '"n" agtype') + + def test_empty_column(self): + self.assertEqual(_validate_column(''), '') + self.assertEqual(_validate_column(' '), '') + + def test_rejects_injection_in_column_name(self): + with self.assertRaises(InvalidIdentifier): + _validate_column("v); DROP TABLE ag_graph; --") + + def test_rejects_injection_in_column_type(self): + with self.assertRaises(InvalidIdentifier): + _validate_column("v agtype); DROP TABLE") + + def test_rejects_three_part_column(self): + with self.assertRaises(InvalidIdentifier): + _validate_column("a b c") + + def test_rejects_semicolon_in_name(self): + with self.assertRaises(InvalidIdentifier): + _validate_column("col;") + + +class TestBuildCypher(unittest.TestCase): + """Test buildCypher validates columns and rejects injection.""" + + def test_default_column(self): + result = buildCypher('test_graph', 'MATCH (n) RETURN n', None) + self.assertIn('"v" agtype', result) + + def test_single_column(self): + result = buildCypher('test_graph', 'MATCH (n) RETURN n', ['n']) + self.assertIn('"n" agtype', result) + + def test_typed_column(self): + result = buildCypher('test_graph', 'MATCH (n) RETURN n', ['n agtype']) + self.assertIn('"n" agtype', result) + + def test_multiple_columns(self): + result = buildCypher('test_graph', 'MATCH (n) RETURN n', ['a', 'b']) + self.assertIn('"a" agtype', result) + self.assertIn('"b" agtype', result) + + def test_rejects_injection_in_column(self): + with self.assertRaises(InvalidIdentifier): + buildCypher('test_graph', 'MATCH (n) RETURN n', + ["v); DROP TABLE ag_graph;--"]) + + def test_rejects_none_graph_name(self): + with self.assertRaises(GraphNotSet): + buildCypher(None, 'MATCH (n) RETURN n', None) + + +class TestExceptionConstructors(unittest.TestCase): + """Test that exception constructors work correctly.""" + + def test_age_not_set_no_args(self): + """AgeNotSet() must work without arguments (previously crashed).""" + e = AgeNotSet() + self.assertIsNone(e.name) + self.assertIn('not set', repr(e)) + + def test_age_not_set_with_message(self): + e = AgeNotSet("custom message") + self.assertEqual(e.name, "custom message") + + def test_graph_not_found_no_args(self): + e = GraphNotFound() + self.assertIsNone(e.name) + self.assertIn('does not exist', repr(e)) + + def test_graph_not_found_with_name(self): + e = GraphNotFound("test_graph") + self.assertEqual(e.name, "test_graph") + self.assertIn('test_graph', repr(e)) + + def test_graph_already_exists_no_args(self): + e = GraphAlreadyExists() + self.assertIsNone(e.name) + self.assertIn('already exists', repr(e)) + + def test_graph_already_exists_with_name(self): + e = GraphAlreadyExists("test_graph") + self.assertEqual(e.name, "test_graph") + self.assertIn('test_graph', repr(e)) + + def test_invalid_graph_name_fields(self): + e = InvalidGraphName("bad;name", "must be valid") + self.assertEqual(e.name, "bad;name") + self.assertEqual(e.reason, "must be valid") + self.assertIn("bad;name", str(e)) + self.assertIn("must be valid", str(e)) + + def test_invalid_identifier_fields(self): + e = InvalidIdentifier("col;drop", "Column name") + self.assertEqual(e.name, "col;drop") + self.assertEqual(e.context, "Column name") + self.assertIn("col;drop", str(e)) + + +if __name__ == '__main__': + unittest.main() diff --git a/regress/age_load/data/bad_delim_edges.csv b/regress/age_load/data/bad_delim_edges.csv new file mode 100644 index 000000000..c72170a6a --- /dev/null +++ b/regress/age_load/data/bad_delim_edges.csv @@ -0,0 +1,2 @@ +start_id|start_vertex_type|end_id|end_vertex_type +1|V|2|V diff --git a/regress/age_load/data/edges_long_row.csv b/regress/age_load/data/edges_long_row.csv new file mode 100644 index 000000000..2036f534a --- /dev/null +++ b/regress/age_load/data/edges_long_row.csv @@ -0,0 +1,2 @@ +start_id,start_vertex_type,end_id,end_vertex_type +1,V,2,V,extra diff --git a/regress/age_load/data/edges_short_row.csv b/regress/age_load/data/edges_short_row.csv new file mode 100644 index 000000000..e307927b3 --- /dev/null +++ b/regress/age_load/data/edges_short_row.csv @@ -0,0 +1,2 @@ +start_id,start_vertex_type,end_id,end_vertex_type +1,V diff --git a/regress/age_load/data/labels_long_row.csv b/regress/age_load/data/labels_long_row.csv new file mode 100644 index 000000000..72ec2a305 --- /dev/null +++ b/regress/age_load/data/labels_long_row.csv @@ -0,0 +1,2 @@ +id,name +1,Alice,extra diff --git a/regress/expected/age_global_graph.out b/regress/expected/age_global_graph.out index f2d9b059f..4833511a7 100644 --- a/regress/expected/age_global_graph.out +++ b/regress/expected/age_global_graph.out @@ -44,27 +44,36 @@ SELECT * FROM cypher('ag_graph_3', $$ CREATE (v:vertex3) RETURN v $$) AS (v agt (1 row) -- load contexts using the vertex_stats command -SELECT * FROM cypher('ag_graph_3', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); +-- Build all three graph contexts under one snapshot. The vertex_stats() +-- calls are wrapped in a single REPEATABLE READ transaction so they share +-- one snapshot; this keeps the snapshot-fallback path in is_ggctx_invalid() +-- from purging an already-built context when concurrent xid activity +-- (autovacuum, parallel installcheck, replication) advances the snapshot +-- between calls. Read Committed is insufficient: it takes a fresh snapshot +-- per statement. +BEGIN ISOLATION LEVEL REPEATABLE READ; +SELECT * FROM cypher('ag_graph_3', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); result ----------------------------------------------------------------------------------------------- {"id": 844424930131969, "label": "vertex3", "in_degree": 0, "out_degree": 0, "self_loops": 0} (1 row) -SELECT * FROM cypher('ag_graph_2', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_2', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); result ----------------------------------------------------------------------------------------------- {"id": 844424930131969, "label": "vertex2", "in_degree": 0, "out_degree": 0, "self_loops": 0} (1 row) -SELECT * FROM cypher('ag_graph_1', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_1', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); result ----------------------------------------------------------------------------------------------- {"id": 844424930131969, "label": "vertex1", "in_degree": 0, "out_degree": 0, "self_loops": 0} (1 row) +COMMIT; --- loading undefined contexts --- should throw exception - graph "ag_graph_4" does not exist -SELECT * FROM cypher('ag_graph_4', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_4', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); ERROR: graph "ag_graph_4" does not exist LINE 1: SELECT * FROM cypher('ag_graph_4', $$ MATCH (u) RETURN verte... ^ @@ -130,24 +139,28 @@ LINE 1: SELECT * FROM cypher('ag_graph_4', $$ RETURN delete_global_g... -- delete_GRAPH_global_contexts -- -- load contexts again -SELECT * FROM cypher('ag_graph_3', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); +-- Same REPEATABLE READ wrap as the first build phase above, for the same +-- snapshot-stability reason. +BEGIN ISOLATION LEVEL REPEATABLE READ; +SELECT * FROM cypher('ag_graph_3', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); result ----------------------------------------------------------------------------------------------- {"id": 844424930131969, "label": "vertex3", "in_degree": 0, "out_degree": 0, "self_loops": 0} (1 row) -SELECT * FROM cypher('ag_graph_2', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_2', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); result ----------------------------------------------------------------------------------------------- {"id": 844424930131969, "label": "vertex2", "in_degree": 0, "out_degree": 0, "self_loops": 0} (1 row) -SELECT * FROM cypher('ag_graph_1', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_1', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); result ----------------------------------------------------------------------------------------------- {"id": 844424930131969, "label": "vertex1", "in_degree": 0, "out_degree": 0, "self_loops": 0} (1 row) +COMMIT; -- delete all graph contexts -- should return true SELECT * FROM cypher('ag_graph_1', $$ RETURN delete_global_graphs(NULL) $$) AS (result agtype); @@ -193,14 +206,14 @@ SELECT * FROM cypher('ag_graph_2', $$ CREATE (:Person) $$) as (v agtype); (0 rows) ---adding edges between nodes -SELECT * FROM cypher('ag_graph_2', $$ MATCH (a:Person), (b:Person) WHERE a.name = 'A' AND b.name = 'B' CREATE (a)-[e:RELTYPE]->(b) RETURN e $$) as (e agtype); +SELECT * FROM cypher('ag_graph_2', $$ MATCH (a:Person), (b:Person) WHERE a.name = 'A' AND b.name = 'B' CREATE (a)-[e:RELTYPE]->(b) RETURN e ORDER BY id(e) $$) as (e agtype); e --- (0 rows) --checking if vertex stats have been updated along with the new label --should return 3 vertices -SELECT * FROM cypher('ag_graph_1', $$ MATCH (n) RETURN vertex_stats(n) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_1', $$ MATCH (n) RETURN vertex_stats(n) ORDER BY id(n) $$) AS (result agtype); result ----------------------------------------------------------------------------------------------- {"id": 281474976710657, "label": "", "in_degree": 0, "out_degree": 0, "self_loops": 0} @@ -209,7 +222,7 @@ SELECT * FROM cypher('ag_graph_1', $$ MATCH (n) RETURN vertex_stats(n) $$) AS (r (3 rows) --should return 1 vertice and 1 label -SELECT * FROM cypher('ag_graph_2', $$ MATCH (a) RETURN vertex_stats(a) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_2', $$ MATCH (a) RETURN vertex_stats(a) ORDER BY id(a) $$) AS (result agtype); result ----------------------------------------------------------------------------------------------- {"id": 844424930131969, "label": "vertex2", "in_degree": 0, "out_degree": 0, "self_loops": 0} @@ -271,7 +284,7 @@ SELECT * FROM cypher('ag_graph_1', $$ MATCH (u)-[]->(v) SET u.id = id(u) SET v.id = id(v) SET u.name = 'u' SET v.name = 'v' - RETURN u,v $$) AS (u agtype, v agtype); + RETURN u,v ORDER BY id(u), id(v) $$) AS (u agtype, v agtype); u | v --------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------- {"id": 281474976710659, "label": "", "properties": {"id": 281474976710659, "name": "u"}}::vertex | {"id": 281474976710660, "label": "", "properties": {"id": 281474976710660, "name": "v"}}::vertex @@ -285,17 +298,17 @@ SELECT * FROM cypher('ag_graph_1', $$ MATCH (u)-[]->(v) MERGE (v)-[:stalks]->(u) -------- (0 rows) -SELECT * FROM cypher('ag_graph_1', $$ MATCH (u)-[e]->(v) RETURN u, e, v $$) AS (u agtype, e agtype, v agtype); +SELECT * FROM cypher('ag_graph_1', $$ MATCH (u)-[e]->(v) RETURN u, e, v ORDER BY id(e) $$) AS (u agtype, e agtype, v agtype); u | e | v --------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------- - {"id": 281474976710660, "label": "", "properties": {"id": 281474976710660, "name": "v"}}::vertex | {"id": 1407374883553281, "label": "stalks", "end_id": 281474976710659, "start_id": 281474976710660, "properties": {}}::edge | {"id": 281474976710659, "label": "", "properties": {"id": 281474976710659, "name": "u"}}::vertex {"id": 281474976710659, "label": "", "properties": {"id": 281474976710659, "name": "u"}}::vertex | {"id": 1125899906842625, "label": "knows", "end_id": 281474976710660, "start_id": 281474976710659, "properties": {}}::edge | {"id": 281474976710660, "label": "", "properties": {"id": 281474976710660, "name": "v"}}::vertex - {"id": 281474976710662, "label": "", "properties": {"id": 281474976710662, "name": "v"}}::vertex | {"id": 1407374883553282, "label": "stalks", "end_id": 281474976710661, "start_id": 281474976710662, "properties": {}}::edge | {"id": 281474976710661, "label": "", "properties": {"id": 281474976710661, "name": "u"}}::vertex {"id": 281474976710661, "label": "", "properties": {"id": 281474976710661, "name": "u"}}::vertex | {"id": 1125899906842626, "label": "knows", "end_id": 281474976710662, "start_id": 281474976710661, "properties": {}}::edge | {"id": 281474976710662, "label": "", "properties": {"id": 281474976710662, "name": "v"}}::vertex - {"id": 281474976710664, "label": "", "properties": {"id": 281474976710664, "name": "v"}}::vertex | {"id": 1407374883553283, "label": "stalks", "end_id": 281474976710663, "start_id": 281474976710664, "properties": {}}::edge | {"id": 281474976710663, "label": "", "properties": {"id": 281474976710663, "name": "u"}}::vertex {"id": 281474976710663, "label": "", "properties": {"id": 281474976710663, "name": "u"}}::vertex | {"id": 1125899906842627, "label": "knows", "end_id": 281474976710664, "start_id": 281474976710663, "properties": {}}::edge | {"id": 281474976710664, "label": "", "properties": {"id": 281474976710664, "name": "v"}}::vertex - {"id": 281474976710666, "label": "", "properties": {"id": 281474976710666, "name": "v"}}::vertex | {"id": 1407374883553284, "label": "stalks", "end_id": 281474976710665, "start_id": 281474976710666, "properties": {}}::edge | {"id": 281474976710665, "label": "", "properties": {"id": 281474976710665, "name": "u"}}::vertex {"id": 281474976710665, "label": "", "properties": {"id": 281474976710665, "name": "u"}}::vertex | {"id": 1125899906842628, "label": "knows", "end_id": 281474976710666, "start_id": 281474976710665, "properties": {}}::edge | {"id": 281474976710666, "label": "", "properties": {"id": 281474976710666, "name": "v"}}::vertex + {"id": 281474976710660, "label": "", "properties": {"id": 281474976710660, "name": "v"}}::vertex | {"id": 1407374883553281, "label": "stalks", "end_id": 281474976710659, "start_id": 281474976710660, "properties": {}}::edge | {"id": 281474976710659, "label": "", "properties": {"id": 281474976710659, "name": "u"}}::vertex + {"id": 281474976710662, "label": "", "properties": {"id": 281474976710662, "name": "v"}}::vertex | {"id": 1407374883553282, "label": "stalks", "end_id": 281474976710661, "start_id": 281474976710662, "properties": {}}::edge | {"id": 281474976710661, "label": "", "properties": {"id": 281474976710661, "name": "u"}}::vertex + {"id": 281474976710664, "label": "", "properties": {"id": 281474976710664, "name": "v"}}::vertex | {"id": 1407374883553283, "label": "stalks", "end_id": 281474976710663, "start_id": 281474976710664, "properties": {}}::edge | {"id": 281474976710663, "label": "", "properties": {"id": 281474976710663, "name": "u"}}::vertex + {"id": 281474976710666, "label": "", "properties": {"id": 281474976710666, "name": "v"}}::vertex | {"id": 1407374883553284, "label": "stalks", "end_id": 281474976710665, "start_id": 281474976710666, "properties": {}}::edge | {"id": 281474976710665, "label": "", "properties": {"id": 281474976710665, "name": "u"}}::vertex (8 rows) -- what is there now? @@ -306,7 +319,7 @@ SELECT * FROM cypher('ag_graph_1', $$ RETURN graph_stats('ag_graph_1') $$) AS (r (1 row) -- remove some vertices -SELECT * FROM ag_graph_1._ag_label_vertex; +SELECT * FROM ag_graph_1._ag_label_vertex ORDER BY id; id | properties -----------------+-------------------------------------- 281474976710657 | {} @@ -325,7 +338,7 @@ SELECT * FROM ag_graph_1._ag_label_vertex; DELETE FROM ag_graph_1._ag_label_vertex WHERE id::text = '281474976710661'; DELETE FROM ag_graph_1._ag_label_vertex WHERE id::text = '281474976710662'; DELETE FROM ag_graph_1._ag_label_vertex WHERE id::text = '281474976710664'; -SELECT * FROM ag_graph_1._ag_label_vertex; +SELECT * FROM ag_graph_1._ag_label_vertex ORDER BY id; id | properties -----------------+-------------------------------------- 281474976710657 | {} @@ -338,7 +351,7 @@ SELECT * FROM ag_graph_1._ag_label_vertex; 844424930131969 | {} (8 rows) -SELECT * FROM ag_graph_1._ag_label_edge; +SELECT * FROM ag_graph_1._ag_label_edge ORDER BY id; id | start_id | end_id | properties ------------------+-----------------+-----------------+------------ 1125899906842625 | 281474976710659 | 281474976710660 | {} @@ -351,21 +364,30 @@ SELECT * FROM ag_graph_1._ag_label_edge; 1407374883553284 | 281474976710666 | 281474976710665 | {} (8 rows) --- there should be warning messages +-- The graph_stats query below will produce warnings for the dangling edges +-- created by the DELETE commands above. The warnings appear in nondeterministic +-- order because they come from iterating edge label tables (knows, stalks), +-- so we suppress them with client_min_messages. Without suppression, the +-- output would include these warnings (in some order): +-- +-- WARNING: edge: [id: 1125899906842626, start: 281474976710661, end: 281474976710662, label: knows] start and end vertices not found +-- WARNING: ignored malformed or dangling edge +-- WARNING: edge: [id: 1125899906842627, start: 281474976710663, end: 281474976710664, label: knows] end vertex not found +-- WARNING: ignored malformed or dangling edge +-- WARNING: edge: [id: 1407374883553282, start: 281474976710662, end: 281474976710661, label: stalks] start and end vertices not found +-- WARNING: ignored malformed or dangling edge +-- WARNING: edge: [id: 1407374883553283, start: 281474976710664, end: 281474976710663, label: stalks] start vertex not found +-- WARNING: ignored malformed or dangling edge +-- +-- The result row validates that graph_stats handled the dangling edges correctly. +SET client_min_messages = error; SELECT * FROM cypher('ag_graph_1', $$ RETURN graph_stats('ag_graph_1') $$) AS (result agtype); -WARNING: edge: [id: 1125899906842626, start: 281474976710661, end: 281474976710662, label: knows] start and end vertices not found -WARNING: ignored malformed or dangling edge -WARNING: edge: [id: 1125899906842627, start: 281474976710663, end: 281474976710664, label: knows] end vertex not found -WARNING: ignored malformed or dangling edge -WARNING: edge: [id: 1407374883553282, start: 281474976710662, end: 281474976710661, label: stalks] start and end vertices not found -WARNING: ignored malformed or dangling edge -WARNING: edge: [id: 1407374883553283, start: 281474976710664, end: 281474976710663, label: stalks] start vertex not found -WARNING: ignored malformed or dangling edge result -------------------------------------------------------------------------- {"graph": "ag_graph_1", "num_loaded_edges": 8, "num_loaded_vertices": 8} (1 row) +RESET client_min_messages; --drop graphs SELECT * FROM drop_graph('ag_graph_1', true); NOTICE: drop cascades to 5 other objects @@ -404,6 +426,294 @@ NOTICE: graph "ag_graph_3" has been dropped (1 row) +----------------------------------------------------------------------------------------------------------------------------- +-- +-- VLE cache invalidation tests +-- +-- These tests verify that the graph version counter properly invalidates +-- the VLE hash table cache when the graph is mutated, and that thin +-- entry lazy property fetch returns correct data. +-- +-- Setup: create a graph with a chain a->b->c->d +SELECT * FROM create_graph('vle_cache_test'); +NOTICE: graph "vle_cache_test" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('vle_cache_test', $$ + CREATE (a:Node {name: 'a'})-[:Edge]->(b:Node {name: 'b'})-[:Edge]->(c:Node {name: 'c'})-[:Edge]->(d:Node {name: 'd'}) +$$) AS (v agtype); + v +--- +(0 rows) + +-- VLE query: find all paths from a's neighbors (should find b, b->c, b->c->d) +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..3]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + name +------ + "b" + "c" + "d" +(3 rows) + +-- Now add a new node e connected to d. This should invalidate the cache. +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (d:Node {name: 'd'}) + CREATE (d)-[:Edge]->(:Node {name: 'e'}) +$$) AS (v agtype); + v +--- +(0 rows) + +-- VLE query again: should now also find e via a->b->c->d->e (4 hops won't reach, +-- but d->e is 1 hop from d, and a->b->c->d->e would be 4 hops from a). +-- Increase range to *1..4 to include e +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..4]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + name +------ + "b" + "c" + "d" + "e" +(4 rows) + +-- Test cache invalidation on DELETE: remove node c and its edges +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (c:Node {name: 'c'}) + DETACH DELETE c +$$) AS (v agtype); + v +--- +(0 rows) + +-- VLE query: should only find b now (c is gone, so b->c path is broken) +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..4]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + name +------ + "b" +(1 row) + +-- Test cache invalidation on SET: change b's name property +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (b:Node {name: 'b'}) + SET b.name = 'b_modified' + RETURN b.name +$$) AS (name agtype); + name +-------------- + "b_modified" +(1 row) + +-- VLE query: verify the updated property is returned via lazy fetch +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..4]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + name +-------------- + "b_modified" +(1 row) + +-- Test VLE with edge properties (exercises thin entry edge property fetch) +SELECT * FROM drop_graph('vle_cache_test', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table vle_cache_test._ag_label_vertex +drop cascades to table vle_cache_test._ag_label_edge +drop cascades to table vle_cache_test."Node" +drop cascades to table vle_cache_test."Edge" +NOTICE: graph "vle_cache_test" has been dropped + drop_graph +------------ + +(1 row) + +SELECT * FROM create_graph('vle_cache_test2'); +NOTICE: graph "vle_cache_test2" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('vle_cache_test2', $$ + CREATE (a:N {name: 'a'})-[:E {weight: 1}]->(b:N {name: 'b'})-[:E {weight: 2}]->(c:N {name: 'c'}) +$$) AS (v agtype); + v +--- +(0 rows) + +-- VLE path output to verify edge properties are fetched correctly via +-- thin entry lazy fetch. Returning the full path forces build_path() +-- to call get_edge_entry_properties() for each edge in the result. +-- The output must contain the correct weight values (1 and 2). +SELECT * FROM cypher('vle_cache_test2', $$ + MATCH p=(a:N {name: 'a'})-[:E *1..2]->(n:N) + RETURN p + ORDER BY n.name +$$) AS (p agtype); + p +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + [{"id": 844424930131969, "label": "N", "properties": {"name": "a"}}::vertex, {"id": 1125899906842626, "label": "E", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {"weight": 1}}::edge, {"id": 844424930131970, "label": "N", "properties": {"name": "b"}}::vertex]::path + [{"id": 844424930131969, "label": "N", "properties": {"name": "a"}}::vertex, {"id": 1125899906842626, "label": "E", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {"weight": 1}}::edge, {"id": 844424930131970, "label": "N", "properties": {"name": "b"}}::vertex, {"id": 1125899906842625, "label": "E", "end_id": 844424930131971, "start_id": 844424930131970, "properties": {"weight": 2}}::edge, {"id": 844424930131971, "label": "N", "properties": {"name": "c"}}::vertex]::path +(2 rows) + +-- VLE edge properties via UNWIND + relationships() to individually verify +-- each edge's properties are correctly fetched from the heap via TID. +SELECT * FROM cypher('vle_cache_test2', $$ + MATCH p=(a:N {name: 'a'})-[:E *1..2]->(n:N) + WITH p, n + UNWIND relationships(p) AS e + RETURN n.name, e.weight + ORDER BY n.name, e.weight +$$) AS (name agtype, weight agtype); + name | weight +------+-------- + "b" | 1 + "c" | 1 + "c" | 2 +(3 rows) + +-- Cleanup +SELECT * FROM drop_graph('vle_cache_test2', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table vle_cache_test2._ag_label_vertex +drop cascades to table vle_cache_test2._ag_label_edge +drop cascades to table vle_cache_test2."N" +drop cascades to table vle_cache_test2."E" +NOTICE: graph "vle_cache_test2" has been dropped + drop_graph +------------ + +(1 row) + +----------------------------------------------------------------------------------------------------------------------------- +-- +-- VLE cache invalidation via direct SQL (trigger tests) +-- +-- These tests verify that the SQL trigger (age_invalidate_graph_cache) +-- properly invalidates the VLE cache when label tables are mutated +-- via direct SQL INSERT, UPDATE, DELETE, and TRUNCATE — not via Cypher. +-- +-- Setup: create graph with a chain a->b->c using Cypher +SELECT * FROM create_graph('vle_trigger_test'); +NOTICE: graph "vle_trigger_test" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('vle_trigger_test', $$ + CREATE (a:Node {name: 'a'})-[:Edge]->(b:Node {name: 'b'})-[:Edge]->(c:Node {name: 'c'}) +$$) AS (v agtype); + v +--- +(0 rows) + +-- Prime the VLE cache: find all nodes reachable from a +SELECT * FROM cypher('vle_trigger_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..3]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + name +------ + "b" + "c" +(2 rows) + +-- Direct SQL INSERT on vertex: add a new vertex via SQL. +-- This should fire the trigger and invalidate the VLE cache. +-- Use _graphid() with the label's id and nextval for the entry id. +INSERT INTO vle_trigger_test."Node" (id, properties) +SELECT ag_catalog._graphid(l.id, + nextval('vle_trigger_test."Node_id_seq"'::regclass)), + '{"name": "d"}'::agtype +FROM ag_catalog.ag_label l +JOIN ag_catalog.ag_graph g ON g.graphid = l.graph +WHERE g.name = 'vle_trigger_test' AND l.name = 'Node'; +-- VLE query: results should be unchanged (d has no edges) but cache was rebuilt +SELECT * FROM cypher('vle_trigger_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..3]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + name +------ + "b" + "c" +(2 rows) + +-- Direct SQL UPDATE on vertex: change b's name to 'b_updated' +-- This should fire the trigger and invalidate the VLE cache. +UPDATE vle_trigger_test."Node" +SET properties = '{"name": "b_updated"}'::agtype +WHERE properties @> '{"name": "b"}'::agtype; +-- VLE query: verify updated property is visible (cache invalidated by trigger) +SELECT * FROM cypher('vle_trigger_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..3]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + name +------------- + "b_updated" + "c" +(2 rows) + +-- Direct SQL DELETE on edge: remove the edge from b_updated to c +DELETE FROM vle_trigger_test."Edge" +WHERE end_id = (SELECT id FROM vle_trigger_test."Node" + WHERE properties @> '{"name": "c"}'::agtype); +-- VLE query: only b_updated reachable now (edge to c is gone) +SELECT * FROM cypher('vle_trigger_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..3]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + name +------------- + "b_updated" +(1 row) + +-- Direct SQL TRUNCATE on edge table: remove all edges +TRUNCATE vle_trigger_test."Edge"; +-- VLE query: no edges exist, should return no rows +SELECT * FROM cypher('vle_trigger_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..3]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + name +------ +(0 rows) + +-- Cleanup +SELECT * FROM drop_graph('vle_trigger_test', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table vle_trigger_test._ag_label_vertex +drop cascades to table vle_trigger_test._ag_label_edge +drop cascades to table vle_trigger_test."Node" +drop cascades to table vle_trigger_test."Edge" +NOTICE: graph "vle_trigger_test" has been dropped + drop_graph +------------ + +(1 row) + ----------------------------------------------------------------------------------------------------------------------------- -- -- End of tests diff --git a/regress/expected/age_load.out b/regress/expected/age_load.out index 1f76c31ce..17c5ecc27 100644 --- a/regress/expected/age_load.out +++ b/regress/expected/age_load.out @@ -454,6 +454,63 @@ NOTICE: graph "agload_conversion" has been dropped (1 row) +-- +-- Issue 2449: mis-delimited / malformed load files must fail with a clear +-- error instead of segfaulting or silently corrupting data. Edge files +-- require the 4 fixed columns; a file that is not comma-delimited parses as +-- a single column, so this must be rejected at the header. +-- +SELECT create_graph('agload_delim'); +NOTICE: graph "agload_delim" has been created + create_graph +-------------- + +(1 row) + +SELECT create_vlabel('agload_delim', 'V'); +NOTICE: VLabel "V" has been created + create_vlabel +--------------- + +(1 row) + +SELECT create_elabel('agload_delim', 'E'); +NOTICE: ELabel "E" has been created + create_elabel +--------------- + +(1 row) + +-- pipe-delimited edge file -> parses to 1 column -> clean error at the header +-- (was a segfault) +SELECT load_edges_from_file('agload_delim', 'E', 'age_load/bad_delim_edges.csv'); +ERROR: edge file must have at least 4 columns (start_id, start_vertex_type, end_id, end_vertex_type), but the header has 1 +HINT: load_edges_from_file expects a comma-delimited CSV; check the file's delimiter. +-- per-row guards (header is valid, but an individual data row is ragged): +-- an edge row with fewer than 4 columns -> clean error (was an OOB read of +-- the fixed fields[1..3]) +SELECT load_edges_from_file('agload_delim', 'E', 'age_load/edges_short_row.csv'); +ERROR: edge file row has 2 columns; expected at least 4 and no more than the header's 4 columns +-- an edge row with more columns than the header -> clean error (was an OOB +-- read of header[i] in create_agtype_from_list_i) +SELECT load_edges_from_file('agload_delim', 'E', 'age_load/edges_long_row.csv'); +ERROR: edge file row has 5 columns; expected at least 4 and no more than the header's 4 columns +-- a label row with more columns than the header -> clean error (was an OOB +-- read of header[i] in create_agtype_from_list) +SELECT load_labels_from_file('agload_delim', 'V', 'age_load/labels_long_row.csv'); +ERROR: label file row has 3 columns, more than the header's 2 columns +SELECT drop_graph('agload_delim', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table agload_delim._ag_label_vertex +drop cascades to table agload_delim._ag_label_edge +drop cascades to table agload_delim."V" +drop cascades to table agload_delim."E" +NOTICE: graph "agload_delim" has been dropped + drop_graph +------------ + +(1 row) + -- -- Test security and permissions -- diff --git a/regress/expected/age_reduce.out b/regress/expected/age_reduce.out new file mode 100644 index 000000000..5f3bf29f8 --- /dev/null +++ b/regress/expected/age_reduce.out @@ -0,0 +1,850 @@ +/* + * 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. + */ +LOAD 'age'; +SET search_path TO ag_catalog; +SELECT create_graph('reduce'); +NOTICE: graph "reduce" has been created + create_graph +-------------- + +(1 row) + +-- +-- Basic folds +-- +-- sum +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x) +$$) AS (result agtype); + result +-------- + 6 +(1 row) + +-- sum of a longer list +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] | s + x) +$$) AS (result agtype); + result +-------- + 55 +(1 row) + +-- product (factorial) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(p = 1, x IN [1, 2, 3, 4, 5] | p * x) +$$) AS (result agtype); + result +-------- + 120 +(1 row) + +-- non-zero initial accumulator +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 100, x IN [1, 2, 3] | s + x) +$$) AS (result agtype); + result +-------- + 106 +(1 row) + +-- single element +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [42] | s + x) +$$) AS (result agtype); + result +-------- + 42 +(1 row) + +-- +-- List order is significant +-- +-- left-associative subtraction: ((((0-1)-2)-3)-4) = -10 +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3, 4] | s - x) +$$) AS (result agtype); + result +-------- + -10 +(1 row) + +-- forward string concatenation +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = '', x IN ['a', 'b', 'c'] | s + x) +$$) AS (result agtype); + result +-------- + "abc" +(1 row) + +-- reverse string concatenation (element before accumulator) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = '', x IN ['a', 'b', 'c'] | x + s) +$$) AS (result agtype); + result +-------- + "cba" +(1 row) + +-- +-- Empty and NULL list semantics +-- +-- empty list returns the initial value +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [] | s + x) +$$) AS (result agtype); + result +-------- + 0 +(1 row) + +-- empty list returns the initial value (non-zero) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 999, x IN [] | s + x) +$$) AS (result agtype); + result +-------- + 999 +(1 row) + +-- NULL list returns NULL +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN null | s + x) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- empty list with a NULL initial value yields NULL: the list is empty (not +-- null) so the fold runs over zero rows, and COALESCE(, init) is +-- COALESCE(NULL, NULL) -> NULL. (Distinct from a NULL *list*, which the outer +-- CASE short-circuits to NULL, and from a non-empty list with a NULL init, +-- which seeds the accumulator with agtype 'null'.) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = null, x IN [] | s + x) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- +-- NULL handling within the fold +-- +-- a NULL element propagates through arithmetic to NULL +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, null, 3] | s + x) +$$) AS (result agtype); + result +-------- + null +(1 row) + +-- NULL initial value +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = null, x IN [1, 2, 3] | s) +$$) AS (result agtype); + result +-------- + null +(1 row) + +-- a body that always evaluates to null yields null, NOT the initial value: +-- every step stores agtype 'null' as the running state, so the final state is +-- a real agtype 'null' and the empty-list COALESCE(..., init) guard must not +-- resurrect the initial value here (the load-bearing fold-to-null vs empty-list +-- distinction) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 7, x IN [1, 2, 3] | null) +$$) AS (result agtype); + result +-------- + null +(1 row) + +-- the accumulator legitimately becomes null mid-fold and the body climbs back +-- out of it: element 2 sets the accumulator to null, element 3 produces a fresh +-- non-null value, and element 4 reads that recovered state (999 + 4), proving a +-- null intermediate state does not poison the rest of the fold +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3, 4] | + CASE WHEN x = 2 THEN null + WHEN x = 3 THEN 999 + ELSE s + x END) +$$) AS (result agtype); + result +-------- + 1003 +(1 row) + +-- +-- Errors raised from the fold body propagate cleanly +-- +-- a type error in the body (agtype number + map) aborts the statement rather +-- than corrupting the running aggregate state or crashing the backend +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2] | s + {a: 1}) +$$) AS (result agtype); +ERROR: invalid left operand for agtype concatenation +-- a runtime arithmetic error in the body (division by zero) likewise aborts +-- the fold; the error surfaces from the standalone per-element evaluator +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 1, x IN [1, 0, 2] | s / x) +$$) AS (result agtype); +ERROR: division by zero +-- +-- Building a list with the accumulator +-- +-- collect squares +SELECT * FROM cypher('reduce', $$ + RETURN reduce(acc = [], x IN [1, 2, 3] | acc + [x * x]) +$$) AS (result agtype); + result +----------- + [1, 4, 9] +(1 row) + +-- +-- Value types in the fold +-- +-- a float accumulator and float elements +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0.0, x IN [1.5, 2.5, 3.0] | s + x) +$$) AS (result agtype); + result +-------- + 7.0 +(1 row) + +-- negative numbers +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [-1, -2, -3] | s + x) +$$) AS (result agtype); + result +-------- + -6 +(1 row) + +-- a map accumulator passed through unchanged +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = {n: 0}, x IN [1, 2, 3] | s) +$$) AS (result agtype); + result +---------- + {"n": 0} +(1 row) + +-- elements that are themselves lists, indexed in the body +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [[1, 2], [3, 4], [5, 6]] | s + x[0]) +$$) AS (result agtype); + result +-------- + 9 +(1 row) + +-- +-- Function calls in the fold body +-- +-- a scalar function applied to the element +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN ['a', 'bb', 'ccc'] | s + size(x)) +$$) AS (result agtype); + result +-------- + 6 +(1 row) + +-- the list itself produced by a function +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN range(1, 5) | s + x) +$$) AS (result agtype); + result +-------- + 15 +(1 row) + +-- +-- Composing reduce() with surrounding expressions +-- +-- the reduce() result consumed by another function +SELECT * FROM cypher('reduce', $$ + RETURN size(reduce(s = [], x IN [1, 2, 3, 4] | s + [x])) +$$) AS (result agtype); + result +-------- + 4 +(1 row) + +-- the reduce() result used in a comparison +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x) = 6 +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- +-- A conditional body (CASE) +-- +-- sum of even elements only +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3, 4, 5, 6] | CASE WHEN x % 2 = 0 THEN s + x ELSE s END) +$$) AS (result agtype); + result +-------- + 12 +(1 row) + +-- +-- Boolean and comparison fold bodies +-- +-- the body evaluates to a boolean, which is normalized to an agtype boolean +-- (a boolean accumulator is a real Cypher use case for "all"/"any" style folds) +-- logical AND fold: all true? +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = true, x IN [true, true, false] | s AND x) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- logical OR fold: any true? +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = false, x IN [false, true, false] | s OR x) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- a comparison body +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = false, x IN [1, 2, 3] | x = 2) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- "does any element equal 2?" (search fold) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(found = false, x IN [1, 2, 3] | found OR x = 2) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- "are all elements positive?" (using a comparison inside the fold) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = true, x IN [1, 2, 3] | s AND x > 0) +$$) AS (result agtype); + result +-------- + true +(1 row) + +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = true, x IN [1, -2, 3] | s AND x > 0) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- +-- Property access on the element variable +-- +-- sum a field across a list of maps +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [{n: 1}, {n: 2}, {n: 3}] | s + x.n) +$$) AS (result agtype); + result +-------- + 6 +(1 row) + +-- concatenate a string field across a list of maps +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = '', x IN [{w: 'a'}, {w: 'b'}, {w: 'c'}] | s + x.w) +$$) AS (result agtype); + result +-------- + "abc" +(1 row) + +-- +-- Multiple reduce() in one expression +-- +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x) + reduce(p = 1, y IN [2, 3] | p * y) +$$) AS (result agtype); + result +-------- + 12 +(1 row) + +-- +-- reduce() in a boolean expression +-- +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x) > 5 + AND reduce(p = 1, y IN [2, 3] | p * y) < 10 +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- +-- reduce() nested in the list or initial value of another reduce() +-- +-- nesting is allowed in the list and the initial value (both evaluated in the +-- outer context) even though it is rejected inside the fold body. +-- nested reduce() in the list +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [reduce(a = 0, y IN [1, 2, 3] | a + y), 10] | s + x) +$$) AS (result agtype); + result +-------- + 16 +(1 row) + +-- nested reduce() in the initial value +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = reduce(a = 0, y IN [1, 2, 3] | a + y), x IN [10, 20] | s + x) +$$) AS (result agtype); + result +-------- + 36 +(1 row) + +-- +-- reduce() over a correlated (per-row) list +-- +SELECT * FROM cypher('reduce', $$ + UNWIND [[1, 2], [3, 4, 5], []] AS arr + RETURN reduce(s = 0, x IN arr | s + x) AS total + ORDER BY total +$$) AS (result agtype); + result +-------- + 0 + 3 + 12 +(3 rows) + +-- +-- reduce() with the list and initial value bound in an outer clause +-- +SELECT * FROM cypher('reduce', $$ + WITH [10, 20, 30] AS ns + RETURN reduce(t = 0, n IN ns | t + n) +$$) AS (result agtype); + result +-------- + 60 +(1 row) + +-- the initial value may reference an outer variable (correlation is allowed +-- in the init and the list, only not in the body) +SELECT * FROM cypher('reduce', $$ + WITH 5 AS base + RETURN reduce(s = base, x IN [1, 2, 3] | s + x) +$$) AS (result agtype); + result +-------- + 11 +(1 row) + +-- +-- reduce() nested inside a list comprehension +-- +SELECT * FROM cypher('reduce', $$ + RETURN [v IN [1, 2, 3] | reduce(s = 0, x IN [v, v, v] | s + x)] +$$) AS (result agtype); + result +----------- + [3, 6, 9] +(1 row) + +-- +-- reduce() in a WHERE clause +-- +SELECT * FROM cypher('reduce', $$ + UNWIND [[1, 2, 3], [1, 1], [10]] AS l + WITH l WHERE reduce(s = 0, x IN l | s + x) > 3 + RETURN l + ORDER BY l +$$) AS (result agtype); + result +----------- + [1, 2, 3] + [10] +(2 rows) + +-- +-- reduce() over graph data (the canonical Cypher example) +-- +SELECT * FROM cypher('reduce', $$ + CREATE (:person {name: 'Alice', age: 38}), + (:person {name: 'Bob', age: 25}), + (:person {name: 'Daniel', age: 54}) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- sum the ages of all person nodes +SELECT * FROM cypher('reduce', $$ + MATCH (p:person) + WITH collect(p) AS people + RETURN reduce(total = 0, n IN people | total + n.age) +$$) AS (result agtype); + result +-------- + 117 +(1 row) + +-- +-- reduce() over a graph node's list property +-- +SELECT * FROM cypher('reduce', $$ + CREATE (:bag {name: 'low', vals: [1, 2, 3]}), + (:bag {name: 'mid', vals: [5, 5, 5]}), + (:bag {name: 'high', vals: [10, 20, 30]}) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- filter nodes by a reduce() over their list property +SELECT * FROM cypher('reduce', $$ + MATCH (u:bag) WHERE reduce(s = 0, x IN u.vals | s + x) > 10 + RETURN u.name + ORDER BY u.name +$$) AS (result agtype); + result +-------- + "high" + "mid" +(2 rows) + +-- compute a reduce() value per node and order by it +SELECT * FROM cypher('reduce', $$ + MATCH (u:bag) + RETURN u.name AS name, reduce(s = 0, x IN u.vals | s + x) AS total + ORDER BY total +$$) AS (name agtype, total agtype); + name | total +--------+------- + "low" | 6 + "mid" | 15 + "high" | 60 +(3 rows) + +-- +-- Outer references in the fold body +-- +-- The body may reference loop-invariant values from the enclosing query: an +-- outer variable, a property of an outer variable, or a cypher() parameter. +-- a plain outer variable in the body +SELECT * FROM cypher('reduce', $$ + WITH 5 AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x + w) +$$) AS (result agtype); + result +-------- + 21 +(1 row) + +-- an outer variable used as a multiplier +SELECT * FROM cypher('reduce', $$ + WITH 3 AS factor + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x * factor) +$$) AS (result agtype); + result +-------- + 18 +(1 row) + +-- two distinct outer variables in the body +SELECT * FROM cypher('reduce', $$ + WITH 2 AS a, 100 AS b + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x * a + b) +$$) AS (result agtype); + result +-------- + 312 +(1 row) + +-- a property of an outer (graph) variable in the body +SELECT * FROM cypher('reduce', $$ + MATCH (u:bag) WHERE u.name = 'mid' + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x + u.vals[0]) +$$) AS (result agtype); + result +-------- + 21 +(1 row) + +-- the same outer variable referenced more than once in the body +SELECT * FROM cypher('reduce', $$ + WITH 7 AS k + RETURN reduce(s = 0, x IN [1, 2, 3] | s + k + k) +$$) AS (result agtype); + result +-------- + 42 +(1 row) + +-- a property of an outer map referenced in the body +SELECT * FROM cypher('reduce', $$ + WITH {factor: 10} AS m + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x * m.factor) +$$) AS (result agtype); + result +-------- + 60 +(1 row) + +-- a subexpression that mixes an outer reference with the element: only the +-- loop-invariant part (the outer list) is captured, the element index is not +SELECT * FROM cypher('reduce', $$ + WITH [10, 20, 30] AS lookup + RETURN reduce(s = 0, x IN [1, 2, 3] | s + lookup[x - 1]) +$$) AS (result agtype); + result +-------- + 60 +(1 row) + +-- an outer reference inside a CASE branch of the body is captured +SELECT * FROM cypher('reduce', $$ + WITH 10 AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | CASE WHEN x % 2 = 0 THEN s + w ELSE s + x END) +$$) AS (result agtype); + result +-------- + 14 +(1 row) + +-- a NULL outer value propagates through the fold +SELECT * FROM cypher('reduce', $$ + WITH null AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x + w) +$$) AS (result agtype); + result +-------- + null +(1 row) + +-- multiple outer captures with a mix of NULL and non-NULL: each is bound to its +-- own slot (the non-NULL multiplier is bound and the NULL still propagates) +SELECT * FROM cypher('reduce', $$ + WITH 3 AS a, null AS b + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x * a + b) +$$) AS (result agtype); + result +-------- + null +(1 row) + +-- an outer variable that changes per row is captured per group +SELECT * FROM cypher('reduce', $$ + UNWIND [1, 2, 3] AS m + RETURN reduce(s = 0, x IN [1, 2, 3, 4] | s + x * m) AS total + ORDER BY total +$$) AS (result agtype); + result +-------- + 10 + 20 + 30 +(3 rows) + +-- +-- Short-circuit evaluation is preserved for outer references in the body +-- +-- Only the outer leaf is captured; operators and CASE/AND/OR branches stay in +-- the body, so a guarded outer sub-expression is not evaluated on a branch +-- that is not taken. Each case below would divide by zero if the whole "1/w" +-- were hoisted into an eagerly evaluated aggregate argument instead. +-- the THEN branch is never taken, so "1/w" is not evaluated (expect 6) +SELECT * FROM cypher('reduce', $$ + WITH 0 AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | CASE WHEN false THEN s + 1/w ELSE s + x END) +$$) AS (result agtype); + result +-------- + 6 +(1 row) + +-- the ELSE branch is never taken, so "1/w" is not evaluated (expect 6) +SELECT * FROM cypher('reduce', $$ + WITH 0 AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | CASE WHEN true THEN s + x ELSE s + 1/w END) +$$) AS (result agtype); + result +-------- + 6 +(1 row) + +-- OR short-circuits once "w = 0" is true, so "1/w > 0" is not evaluated +SELECT * FROM cypher('reduce', $$ + WITH 0 AS w + RETURN reduce(s = true, x IN [1, 2, 3] | s AND (w = 0 OR 1/w > 0)) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- AND short-circuits once "w <> 0" is false, so "1/w > 0" is not evaluated +SELECT * FROM cypher('reduce', $$ + WITH 0 AS w + RETURN reduce(s = true, x IN [1, 2, 3] | s AND (w <> 0 AND 1/w > 0)) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- coalesce short-circuits: "1/w" is not evaluated when arg 1 is non-null +SELECT * FROM cypher('reduce', $$ + WITH 0 AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | s + coalesce(w, 1/w)) +$$) AS (result agtype); + result +-------- + 0 +(1 row) + +-- when the guarded branch is taken, the outer sub-expression is evaluated +-- normally (division by a non-zero outer value): x = 2 -> s + 10/2 (expect 9) +SELECT * FROM cypher('reduce', $$ + WITH 2 AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | CASE WHEN x % 2 = 0 THEN s + 10/w ELSE s + x END) +$$) AS (result agtype); + result +-------- + 9 +(1 row) + +-- +-- Not-yet-supported constructs raise a clean feature error +-- +-- a nested reduce() in the body (any subquery in the body is unsupported) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2] | s + reduce(t = 0, y IN [x] | t + y)) +$$) AS (result agtype); +ERROR: subqueries (including a nested reduce()) are not supported in a reduce() expression +LINE 1: SELECT * FROM cypher('reduce', $$ + ^ +-- an aggregate function in the body +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2] | s + count(x)) +$$) AS (result agtype); +ERROR: aggregate functions are not supported in a reduce() expression +LINE 1: SELECT * FROM cypher('reduce', $$ + ^ +-- +-- Syntax errors: each required piece of the reduce() form is enforced +-- +-- missing "= init" +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s, x IN [1, 2] | s + x) +$$) AS (result agtype); +ERROR: syntax error at or near "," +LINE 2: RETURN reduce(s, x IN [1, 2] | s + x) + ^ +-- missing ", var IN list" +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0 | s) +$$) AS (result agtype); +ERROR: syntax error at or near "|" +LINE 2: RETURN reduce(s = 0 | s) + ^ +-- missing "| body" +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2]) +$$) AS (result agtype); +ERROR: syntax error at or near ")" +LINE 2: RETURN reduce(s = 0, x IN [1, 2]) + ^ +-- a qualified iterator variable is not allowed +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x.y IN [1, 2] | s) +$$) AS (result agtype); +ERROR: syntax error at or near "." +LINE 2: RETURN reduce(s = 0, x.y IN [1, 2] | s) + ^ +-- +-- cypher() parameter referenced in the fold body (via a prepared statement) +-- +PREPARE reduce_param(agtype) AS + SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x + $p) + $$, $1) AS (result agtype); +EXECUTE reduce_param('{"p": 10}'); + result +-------- + 36 +(1 row) + +EXECUTE reduce_param('{"p": 100}'); + result +-------- + 306 +(1 row) + +DEALLOCATE reduce_param; +-- +-- "reduce" as a property key name (safe_keywords backward compatibility): +-- because reduce() introduced a reserved keyword, confirm the word is still +-- usable as a map key, the same way any/none/single are. +-- +SELECT * FROM cypher('reduce', $$ + RETURN {reduce: 1, any: 2, none: 3} +$$) AS (result agtype); + result +------------------------------------ + {"any": 2, "none": 3, "reduce": 1} +(1 row) + +-- +-- Cleanup +-- +SELECT * FROM drop_graph('reduce', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table reduce._ag_label_vertex +drop cascades to table reduce._ag_label_edge +drop cascades to table reduce.person +drop cascades to table reduce.bag +NOTICE: graph "reduce" has been dropped + drop_graph +------------ + +(1 row) + diff --git a/regress/expected/age_shortest_path.out b/regress/expected/age_shortest_path.out new file mode 100644 index 000000000..f7f8f1300 --- /dev/null +++ b/regress/expected/age_shortest_path.out @@ -0,0 +1,1729 @@ +/* + * 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. + */ +LOAD 'age'; +SET search_path TO ag_catalog; +-- +-- age_shortest_path / age_all_shortest_paths +-- +SELECT * FROM create_graph('sp_graph'); +NOTICE: graph "sp_graph" has been created + create_graph +-------------- + +(1 row) + +-- Build a small deterministic graph: +-- +-- A +-- / \ +-- B C (A->B, A->C, B->D, C->D : two shortest A..D paths) +-- \ / +-- D +-- | +-- E (D->E : unique 3-hop path A..E) +-- +-- Z (isolated, unreachable) +-- +SELECT * FROM cypher('sp_graph', $$ + CREATE (a:Person {name: 'A'}), + (b:Person {name: 'B'}), + (c:Person {name: 'C'}), + (d:Person {name: 'D'}), + (e:Person {name: 'E'}), + (z:Person {name: 'Z'}), + (a)-[:KNOWS]->(b), + (a)-[:KNOWS]->(c), + (b)-[:KNOWS]->(d), + (c)-[:KNOWS]->(d), + (d)-[:KNOWS]->(e) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- materialize the global graph context +SELECT * FROM cypher('sp_graph', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + result +---------------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "Person", "in_degree": 0, "out_degree": 2, "self_loops": 0} + {"id": 844424930131970, "label": "Person", "in_degree": 1, "out_degree": 1, "self_loops": 0} + {"id": 844424930131971, "label": "Person", "in_degree": 1, "out_degree": 1, "self_loops": 0} + {"id": 844424930131972, "label": "Person", "in_degree": 2, "out_degree": 1, "self_loops": 0} + {"id": 844424930131973, "label": "Person", "in_degree": 1, "out_degree": 0, "self_loops": 0} + {"id": 844424930131974, "label": "Person", "in_degree": 0, "out_degree": 0, "self_loops": 0} +(6 rows) + +-- A -> D shortest path (length 2); expected: path_count = 1 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)) +); + path_count +------------ + 1 +(1 row) + +-- all shortest A -> D; expected: 2 paths (A-B-D and A-C-D), each length 2 +SELECT path +FROM age_all_shortest_paths( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)) +) AS path +ORDER BY path; + path +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + [{"id": 844424930131969, "label": "Person", "properties": {"name": "A"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970, "label": "Person", "properties": {"name": "B"}}::vertex, {"id": 1125899906842627, "label": "KNOWS", "end_id": 844424930131972, "start_id": 844424930131970, "properties": {}}::edge, {"id": 844424930131972, "label": "Person", "properties": {"name": "D"}}::vertex]::path + [{"id": 844424930131969, "label": "Person", "properties": {"name": "A"}}::vertex, {"id": 1125899906842626, "label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131971, "label": "Person", "properties": {"name": "C"}}::vertex, {"id": 1125899906842628, "label": "KNOWS", "end_id": 844424930131972, "start_id": 844424930131971, "properties": {}}::edge, {"id": 844424930131972, "label": "Person", "properties": {"name": "D"}}::vertex]::path +(2 rows) + +-- A -> E unique 3-hop path; expected: path_count = 1 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'E'}) RETURN id(n) $$) AS (id agtype)) +); + path_count +------------ + 1 +(1 row) + +-- A -> E with max_hops = 2; expected: path_count = 0 (E is 3 hops away) +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'E'}) RETURN id(n) $$) AS (id agtype)), + NULL, NULL, NULL, 2::agtype +); + path_count +------------ + 0 +(1 row) + +-- zero-length path, start == end; expected: path_count = 1 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)) +); + path_count +------------ + 1 +(1 row) + +-- unreachable vertex Z; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'Z'}) RETURN id(n) $$) AS (id agtype)) +); + path_count +------------ + 0 +(1 row) + +-- direction 'in': D -> A traversing edges backwards; expected: path_count = 2 +SELECT count(*) AS path_count +FROM age_all_shortest_paths( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + NULL, '"in"'::agtype +); + path_count +------------ + 2 +(1 row) + +-- single shortest path with direction 'in': D -> A backwards; expected: +-- path_count = 1 (the single-path variant picks one of the two routes) +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + NULL, '"in"'::agtype +); + path_count +------------ + 1 +(1 row) + +-- direction 'out': D -> A not reachable forwards; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + NULL, '"out"'::agtype +); + path_count +------------ + 0 +(1 row) + +-- label filter 'KNOWS': A -> D still found; expected: path_count = 1 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype +); + path_count +------------ + 1 +(1 row) + +-- error: invalid direction string; expected: ERROR (must be 'out', 'in', or 'any') +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + NULL, '"sideways"'::agtype +); +ERROR: age_shortest_path: direction argument must be one of 'out', 'in', or 'any' +-- error: start argument is neither a vertex nor an integer id; expected: ERROR +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + '"not_a_vertex"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)) +); +ERROR: start vertex argument must be a vertex or the integer id +-- error: end argument is neither a vertex nor an integer id; expected: ERROR +-- (symmetric to the start-vertex check above) +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"not_a_vertex"'::agtype +); +ERROR: end vertex argument must be a vertex or the integer id +-- +-- Non-existent endpoint guards. These must NOT crash the backend and must +-- return no rows (a path can only exist between vertices in the graph). +-- Previously, start == end on a non-existent vertex id was matched at BFS +-- depth 0 and path reconstruction dereferenced a missing vertex, crashing +-- the server. +-- +-- start == end on a non-existent integer id; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path('"sp_graph"'::agtype, 999999::agtype, 999999::agtype); + path_count +------------ + 0 +(1 row) + +-- existing start -> non-existent end; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + 999999::agtype +); + path_count +------------ + 0 +(1 row) + +-- non-existent start -> existing end; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + 999999::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)) +); + path_count +------------ + 0 +(1 row) + +-- all-shortest-paths with start == end non-existent; expected: 0 rows +SELECT count(*) AS path_count +FROM age_all_shortest_paths('"sp_graph"'::agtype, 999999::agtype, 999999::agtype); + path_count +------------ + 0 +(1 row) + +-- cleanup +SELECT * FROM drop_graph('sp_graph', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table sp_graph._ag_label_vertex +drop cascades to table sp_graph._ag_label_edge +drop cascades to table sp_graph."Person" +drop cascades to table sp_graph."KNOWS" +NOTICE: graph "sp_graph" has been dropped + drop_graph +------------ + +(1 row) + +-- +-- Empty graph: a graph that exists but has no vertices must return no rows +-- (and must not hang or crash) for any endpoint query. +-- +SELECT * FROM create_graph('sp_empty'); +NOTICE: graph "sp_empty" has been created + create_graph +-------------- + +(1 row) + +SELECT count(*) AS path_count +FROM age_shortest_path('"sp_empty"'::agtype, 0::agtype, 1::agtype); + path_count +------------ + 0 +(1 row) + +SELECT count(*) AS path_count +FROM age_all_shortest_paths('"sp_empty"'::agtype, 0::agtype, 0::agtype); + path_count +------------ + 0 +(1 row) + +SELECT * FROM drop_graph('sp_empty', true); +NOTICE: drop cascades to 2 other objects +DETAIL: drop cascades to table sp_empty._ag_label_vertex +drop cascades to table sp_empty._ag_label_edge +NOTICE: graph "sp_empty" has been dropped + drop_graph +------------ + +(1 row) + +-- +-- A large, programmatically generated graph (120 nodes) exercising long +-- shortest paths (length up to 20), high-multiplicity all-shortest-paths, +-- label filtering, and directed vs. undirected reachability. +-- +-- Nodes: (:N {id: 0..119}). Structures built on top of them: +-- +-- * Main chain 0 -> 1 -> ... -> 20 (unique 20-hop path) +-- * Alternate chain 0 -> 50 -> 51 -> ... -> 68 -> 20 +-- (a second, disjoint 20-hop path 0..20) +-- => all-shortest-paths 0..20 under KNOWS = 2 paths of length 20 +-- * 3x3 lattice on ids 70..78, id = 70 + 3*row + col, edges go right +-- (id->id+1) and down (id->id+3). Monotone 70..78 paths: +-- => all-shortest-paths 70..78 = C(4,2) = 6 paths of length 4 +-- * LIKES shortcut 0 -[:LIKES]-> 20 (1 hop; only visible when the edge +-- label filter is NOT restricted to KNOWS) +-- * Back-edge triangle 0 -> 96 -> 95 -> 0 +-- => directed 0->95 = 2 hops (0-96-95); undirected 0..95 = 1 hop +-- * Many unused ids (e.g. 119) remain isolated / unreachable. +-- +SELECT * FROM create_graph('sp_big'); +NOTICE: graph "sp_big" has been created + create_graph +-------------- + +(1 row) + +-- 120 vertices, ids 0..119 +SELECT * FROM cypher('sp_big', $$ + UNWIND range(0, 119) AS i CREATE (:N {id: i}) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- main chain 0->1->...->20 (KNOWS) +SELECT * FROM cypher('sp_big', $$ + UNWIND range(0, 19) AS i + MATCH (a:N {id: i}), (b:N {id: i + 1}) + CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- alternate, disjoint 20-hop path 0->50->51->...->68->20 (KNOWS) +SELECT * FROM cypher('sp_big', $$ + MATCH (a:N {id: 0}), (b:N {id: 50}) CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + result +-------- +(0 rows) + +SELECT * FROM cypher('sp_big', $$ + UNWIND range(50, 67) AS i + MATCH (a:N {id: i}), (b:N {id: i + 1}) + CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + result +-------- +(0 rows) + +SELECT * FROM cypher('sp_big', $$ + MATCH (a:N {id: 68}), (b:N {id: 20}) CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- 3x3 lattice on ids 70..78: right edges (id -> id+1) +SELECT * FROM cypher('sp_big', $$ + UNWIND [0, 1, 2] AS r + UNWIND [0, 1] AS c + MATCH (a:N {id: 70 + 3 * r + c}), (b:N {id: 70 + 3 * r + c + 1}) + CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- 3x3 lattice: down edges (id -> id+3) +SELECT * FROM cypher('sp_big', $$ + UNWIND [0, 1] AS r + UNWIND [0, 1, 2] AS c + MATCH (a:N {id: 70 + 3 * r + c}), (b:N {id: 70 + 3 * (r + 1) + c}) + CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- back-edge triangle 0 -> 96 -> 95 -> 0 (KNOWS) +SELECT * FROM cypher('sp_big', $$ + MATCH (a:N {id: 0}), (b:N {id: 96}) CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + result +-------- +(0 rows) + +SELECT * FROM cypher('sp_big', $$ + MATCH (a:N {id: 96}), (b:N {id: 95}) CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + result +-------- +(0 rows) + +SELECT * FROM cypher('sp_big', $$ + MATCH (a:N {id: 95}), (b:N {id: 0}) CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- labelled shortcut 0 -[:LIKES]-> 20 +SELECT * FROM cypher('sp_big', $$ + MATCH (a:N {id: 0}), (b:N {id: 20}) CREATE (a)-[:LIKES]->(b) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- sanity: vertex count (also materializes the global context); expected: count = 120 +SELECT * FROM cypher('sp_big', $$ MATCH (n) RETURN count(n) $$) AS (n agtype); + n +----- + 120 +(1 row) + +-- all shortest 0 -> 20 under KNOWS (main chain + disjoint alternate); +-- expected: 2 paths, each exactly 20 hops +SELECT path +FROM age_all_shortest_paths( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype +) AS path +ORDER BY path; + path +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + [{"id": 844424930131969, "label": "N", "properties": {"id": 0}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970, "label": "N", "properties": {"id": 1}}::vertex, {"id": 1125899906842626, "label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131970, "properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties": {"id": 2}}::vertex, {"id": 1125899906842627, "label": "KNOWS", "end_id": 844424930131972, "start_id": 844424930131971, "properties": {}}::edge, {"id": 844424930131972, "label": "N", "properties": {"id": 3}}::vertex, {"id": 1125899906842628, "label": "KNOWS", "end_id": 844424930131973, "start_id": 844424930131972, "properties": {}}::edge, {"id": 844424930131973, "label": "N", "properties": {"id": 4}}::vertex, {"id": 1125899906842629, "label": "KNOWS", "end_id": 844424930131974, "start_id": 844424930131973, "properties": {}}::edge, {"id": 844424930131974, "label": "N", "properties": {"id": 5}}::vertex, {"id": 1125899906842630, "label": "KNOWS", "end_id": 844424930131975, "start_id": 844424930131974, "properties": {}}::edge, {"id": 844424930131975, "label": "N", "properties": {"id": 6}}::vertex, {"id": 1125899906842631, "label": "KNOWS", "end_id": 844424930131976, "start_id": 844424930131975, "properties": {}}::edge, {"id": 844424930131976, "label": "N", "properties": {"id": 7}}::vertex, {"id": 1125899906842632, "label": "KNOWS", "end_id": 844424930131977, "start_id": 844424930131976, "properties": {}}::edge, {"id": 844424930131977, "label": "N", "properties": {"id": 8}}::vertex, {"id": 1125899906842633, "label": "KNOWS", "end_id": 844424930131978, "start_id": 844424930131977, "properties": {}}::edge, {"id": 844424930131978, "label": "N", "properties": {"id": 9}}::vertex, {"id": 1125899906842634, "label": "KNOWS", "end_id": 844424930131979, "start_id": 844424930131978, "properties": {}}::edge, {"id": 844424930131979, "label": "N", "properties": {"id": 10}}::vertex, {"id": 1125899906842635, "label": "KNOWS", "end_id": 844424930131980, "start_id": 844424930131979, "properties": {}}::edge, {"id": 844424930131980, "label": "N", "properties": {"id": 11}}::vertex, {"id": 1125899906842636, "label": "KNOWS", "end_id": 844424930131981, "start_id": 844424930131980, "properties": {}}::edge, {"id": 844424930131981, "label": "N", "properties": {"id": 12}}::vertex, {"id": 1125899906842637, "label": "KNOWS", "end_id": 844424930131982, "start_id": 844424930131981, "properties": {}}::edge, {"id": 844424930131982, "label": "N", "properties": {"id": 13}}::vertex, {"id": 1125899906842638, "label": "KNOWS", "end_id": 844424930131983, "start_id": 844424930131982, "properties": {}}::edge, {"id": 844424930131983, "label": "N", "properties": {"id": 14}}::vertex, {"id": 1125899906842639, "label": "KNOWS", "end_id": 844424930131984, "start_id": 844424930131983, "properties": {}}::edge, {"id": 844424930131984, "label": "N", "properties": {"id": 15}}::vertex, {"id": 1125899906842640, "label": "KNOWS", "end_id": 844424930131985, "start_id": 844424930131984, "properties": {}}::edge, {"id": 844424930131985, "label": "N", "properties": {"id": 16}}::vertex, {"id": 1125899906842641, "label": "KNOWS", "end_id": 844424930131986, "start_id": 844424930131985, "properties": {}}::edge, {"id": 844424930131986, "label": "N", "properties": {"id": 17}}::vertex, {"id": 1125899906842642, "label": "KNOWS", "end_id": 844424930131987, "start_id": 844424930131986, "properties": {}}::edge, {"id": 844424930131987, "label": "N", "properties": {"id": 18}}::vertex, {"id": 1125899906842643, "label": "KNOWS", "end_id": 844424930131988, "start_id": 844424930131987, "properties": {}}::edge, {"id": 844424930131988, "label": "N", "properties": {"id": 19}}::vertex, {"id": 1125899906842644, "label": "KNOWS", "end_id": 844424930131989, "start_id": 844424930131988, "properties": {}}::edge, {"id": 844424930131989, "label": "N", "properties": {"id": 20}}::vertex]::path + [{"id": 844424930131969, "label": "N", "properties": {"id": 0}}::vertex, {"id": 1125899906842645, "label": "KNOWS", "end_id": 844424930132019, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930132019, "label": "N", "properties": {"id": 50}}::vertex, {"id": 1125899906842646, "label": "KNOWS", "end_id": 844424930132020, "start_id": 844424930132019, "properties": {}}::edge, {"id": 844424930132020, "label": "N", "properties": {"id": 51}}::vertex, {"id": 1125899906842647, "label": "KNOWS", "end_id": 844424930132021, "start_id": 844424930132020, "properties": {}}::edge, {"id": 844424930132021, "label": "N", "properties": {"id": 52}}::vertex, {"id": 1125899906842648, "label": "KNOWS", "end_id": 844424930132022, "start_id": 844424930132021, "properties": {}}::edge, {"id": 844424930132022, "label": "N", "properties": {"id": 53}}::vertex, {"id": 1125899906842649, "label": "KNOWS", "end_id": 844424930132023, "start_id": 844424930132022, "properties": {}}::edge, {"id": 844424930132023, "label": "N", "properties": {"id": 54}}::vertex, {"id": 1125899906842650, "label": "KNOWS", "end_id": 844424930132024, "start_id": 844424930132023, "properties": {}}::edge, {"id": 844424930132024, "label": "N", "properties": {"id": 55}}::vertex, {"id": 1125899906842651, "label": "KNOWS", "end_id": 844424930132025, "start_id": 844424930132024, "properties": {}}::edge, {"id": 844424930132025, "label": "N", "properties": {"id": 56}}::vertex, {"id": 1125899906842652, "label": "KNOWS", "end_id": 844424930132026, "start_id": 844424930132025, "properties": {}}::edge, {"id": 844424930132026, "label": "N", "properties": {"id": 57}}::vertex, {"id": 1125899906842653, "label": "KNOWS", "end_id": 844424930132027, "start_id": 844424930132026, "properties": {}}::edge, {"id": 844424930132027, "label": "N", "properties": {"id": 58}}::vertex, {"id": 1125899906842654, "label": "KNOWS", "end_id": 844424930132028, "start_id": 844424930132027, "properties": {}}::edge, {"id": 844424930132028, "label": "N", "properties": {"id": 59}}::vertex, {"id": 1125899906842655, "label": "KNOWS", "end_id": 844424930132029, "start_id": 844424930132028, "properties": {}}::edge, {"id": 844424930132029, "label": "N", "properties": {"id": 60}}::vertex, {"id": 1125899906842656, "label": "KNOWS", "end_id": 844424930132030, "start_id": 844424930132029, "properties": {}}::edge, {"id": 844424930132030, "label": "N", "properties": {"id": 61}}::vertex, {"id": 1125899906842657, "label": "KNOWS", "end_id": 844424930132031, "start_id": 844424930132030, "properties": {}}::edge, {"id": 844424930132031, "label": "N", "properties": {"id": 62}}::vertex, {"id": 1125899906842658, "label": "KNOWS", "end_id": 844424930132032, "start_id": 844424930132031, "properties": {}}::edge, {"id": 844424930132032, "label": "N", "properties": {"id": 63}}::vertex, {"id": 1125899906842659, "label": "KNOWS", "end_id": 844424930132033, "start_id": 844424930132032, "properties": {}}::edge, {"id": 844424930132033, "label": "N", "properties": {"id": 64}}::vertex, {"id": 1125899906842660, "label": "KNOWS", "end_id": 844424930132034, "start_id": 844424930132033, "properties": {}}::edge, {"id": 844424930132034, "label": "N", "properties": {"id": 65}}::vertex, {"id": 1125899906842661, "label": "KNOWS", "end_id": 844424930132035, "start_id": 844424930132034, "properties": {}}::edge, {"id": 844424930132035, "label": "N", "properties": {"id": 66}}::vertex, {"id": 1125899906842662, "label": "KNOWS", "end_id": 844424930132036, "start_id": 844424930132035, "properties": {}}::edge, {"id": 844424930132036, "label": "N", "properties": {"id": 67}}::vertex, {"id": 1125899906842663, "label": "KNOWS", "end_id": 844424930132037, "start_id": 844424930132036, "properties": {}}::edge, {"id": 844424930132037, "label": "N", "properties": {"id": 68}}::vertex, {"id": 1125899906842664, "label": "KNOWS", "end_id": 844424930131989, "start_id": 844424930132037, "properties": {}}::edge, {"id": 844424930131989, "label": "N", "properties": {"id": 20}}::vertex]::path +(2 rows) + +-- any label: the LIKES shortcut collapses 0 -> 20; expected: path_count = 1 +SELECT count(*) AS path_count +FROM age_all_shortest_paths( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$) AS (id agtype)), + NULL, '"out"'::agtype +); + path_count +------------ + 1 +(1 row) + +-- all shortest 70 -> 78 across the 3x3 lattice; expected: path_count = 6 (C(4,2)) +SELECT count(*) AS path_count +FROM age_all_shortest_paths( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype +); + path_count +------------ + 6 +(1 row) + +-- the lattice paths listed; expected: 6 paths, each 4 hops +SELECT path +FROM age_all_shortest_paths( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype +) AS path +ORDER BY path; + path +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + [{"id": 844424930132039, "label": "N", "properties": {"id": 70}}::vertex, {"id": 1125899906842665, "label": "KNOWS", "end_id": 844424930132040, "start_id": 844424930132039, "properties": {}}::edge, {"id": 844424930132040, "label": "N", "properties": {"id": 71}}::vertex, {"id": 1125899906842666, "label": "KNOWS", "end_id": 844424930132041, "start_id": 844424930132040, "properties": {}}::edge, {"id": 844424930132041, "label": "N", "properties": {"id": 72}}::vertex, {"id": 1125899906842673, "label": "KNOWS", "end_id": 844424930132044, "start_id": 844424930132041, "properties": {}}::edge, {"id": 844424930132044, "label": "N", "properties": {"id": 75}}::vertex, {"id": 1125899906842676, "label": "KNOWS", "end_id": 844424930132047, "start_id": 844424930132044, "properties": {}}::edge, {"id": 844424930132047, "label": "N", "properties": {"id": 78}}::vertex]::path + [{"id": 844424930132039, "label": "N", "properties": {"id": 70}}::vertex, {"id": 1125899906842665, "label": "KNOWS", "end_id": 844424930132040, "start_id": 844424930132039, "properties": {}}::edge, {"id": 844424930132040, "label": "N", "properties": {"id": 71}}::vertex, {"id": 1125899906842672, "label": "KNOWS", "end_id": 844424930132043, "start_id": 844424930132040, "properties": {}}::edge, {"id": 844424930132043, "label": "N", "properties": {"id": 74}}::vertex, {"id": 1125899906842668, "label": "KNOWS", "end_id": 844424930132044, "start_id": 844424930132043, "properties": {}}::edge, {"id": 844424930132044, "label": "N", "properties": {"id": 75}}::vertex, {"id": 1125899906842676, "label": "KNOWS", "end_id": 844424930132047, "start_id": 844424930132044, "properties": {}}::edge, {"id": 844424930132047, "label": "N", "properties": {"id": 78}}::vertex]::path + [{"id": 844424930132039, "label": "N", "properties": {"id": 70}}::vertex, {"id": 1125899906842665, "label": "KNOWS", "end_id": 844424930132040, "start_id": 844424930132039, "properties": {}}::edge, {"id": 844424930132040, "label": "N", "properties": {"id": 71}}::vertex, {"id": 1125899906842672, "label": "KNOWS", "end_id": 844424930132043, "start_id": 844424930132040, "properties": {}}::edge, {"id": 844424930132043, "label": "N", "properties": {"id": 74}}::vertex, {"id": 1125899906842675, "label": "KNOWS", "end_id": 844424930132046, "start_id": 844424930132043, "properties": {}}::edge, {"id": 844424930132046, "label": "N", "properties": {"id": 77}}::vertex, {"id": 1125899906842670, "label": "KNOWS", "end_id": 844424930132047, "start_id": 844424930132046, "properties": {}}::edge, {"id": 844424930132047, "label": "N", "properties": {"id": 78}}::vertex]::path + [{"id": 844424930132039, "label": "N", "properties": {"id": 70}}::vertex, {"id": 1125899906842671, "label": "KNOWS", "end_id": 844424930132042, "start_id": 844424930132039, "properties": {}}::edge, {"id": 844424930132042, "label": "N", "properties": {"id": 73}}::vertex, {"id": 1125899906842667, "label": "KNOWS", "end_id": 844424930132043, "start_id": 844424930132042, "properties": {}}::edge, {"id": 844424930132043, "label": "N", "properties": {"id": 74}}::vertex, {"id": 1125899906842668, "label": "KNOWS", "end_id": 844424930132044, "start_id": 844424930132043, "properties": {}}::edge, {"id": 844424930132044, "label": "N", "properties": {"id": 75}}::vertex, {"id": 1125899906842676, "label": "KNOWS", "end_id": 844424930132047, "start_id": 844424930132044, "properties": {}}::edge, {"id": 844424930132047, "label": "N", "properties": {"id": 78}}::vertex]::path + [{"id": 844424930132039, "label": "N", "properties": {"id": 70}}::vertex, {"id": 1125899906842671, "label": "KNOWS", "end_id": 844424930132042, "start_id": 844424930132039, "properties": {}}::edge, {"id": 844424930132042, "label": "N", "properties": {"id": 73}}::vertex, {"id": 1125899906842667, "label": "KNOWS", "end_id": 844424930132043, "start_id": 844424930132042, "properties": {}}::edge, {"id": 844424930132043, "label": "N", "properties": {"id": 74}}::vertex, {"id": 1125899906842675, "label": "KNOWS", "end_id": 844424930132046, "start_id": 844424930132043, "properties": {}}::edge, {"id": 844424930132046, "label": "N", "properties": {"id": 77}}::vertex, {"id": 1125899906842670, "label": "KNOWS", "end_id": 844424930132047, "start_id": 844424930132046, "properties": {}}::edge, {"id": 844424930132047, "label": "N", "properties": {"id": 78}}::vertex]::path + [{"id": 844424930132039, "label": "N", "properties": {"id": 70}}::vertex, {"id": 1125899906842671, "label": "KNOWS", "end_id": 844424930132042, "start_id": 844424930132039, "properties": {}}::edge, {"id": 844424930132042, "label": "N", "properties": {"id": 73}}::vertex, {"id": 1125899906842674, "label": "KNOWS", "end_id": 844424930132045, "start_id": 844424930132042, "properties": {}}::edge, {"id": 844424930132045, "label": "N", "properties": {"id": 76}}::vertex, {"id": 1125899906842669, "label": "KNOWS", "end_id": 844424930132046, "start_id": 844424930132045, "properties": {}}::edge, {"id": 844424930132046, "label": "N", "properties": {"id": 77}}::vertex, {"id": 1125899906842670, "label": "KNOWS", "end_id": 844424930132047, "start_id": 844424930132046, "properties": {}}::edge, {"id": 844424930132047, "label": "N", "properties": {"id": 78}}::vertex]::path +(6 rows) + +-- max_hops = 19, one short of the 20-hop route; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, NULL, 19::agtype +); + path_count +------------ + 0 +(1 row) + +-- max_hops = 20 admits the full route; expected: path_count = 2 +SELECT count(*) AS path_count +FROM age_all_shortest_paths( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, NULL, 20::agtype +); + path_count +------------ + 2 +(1 row) + +-- DIRECTED out: 0 -> 95 must traverse 0->96->95; expected: 1 path (length 2) +SELECT path +FROM age_shortest_path( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 95}) RETURN id(n) $$) AS (id agtype)), + NULL, '"out"'::agtype +) AS path +ORDER BY path; + path +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + [{"id": 844424930131969, "label": "N", "properties": {"id": 0}}::vertex, {"id": 1125899906842677, "label": "KNOWS", "end_id": 844424930132065, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930132065, "label": "N", "properties": {"id": 96}}::vertex, {"id": 1125899906842678, "label": "KNOWS", "end_id": 844424930132064, "start_id": 844424930132065, "properties": {}}::edge, {"id": 844424930132064, "label": "N", "properties": {"id": 95}}::vertex]::path +(1 row) + +-- UNDIRECTED: 0 .. 95 via the 95->0 back edge; expected: 1 path (length 1) +SELECT path +FROM age_shortest_path( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 95}) RETURN id(n) $$) AS (id agtype)) +) AS path +ORDER BY path; + path +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + [{"id": 844424930131969, "label": "N", "properties": {"id": 0}}::vertex, {"id": 1125899906842679, "label": "KNOWS", "end_id": 844424930131969, "start_id": 844424930132064, "properties": {}}::edge, {"id": 844424930132064, "label": "N", "properties": {"id": 95}}::vertex]::path +(1 row) + +-- DIRECTED out: 78 -> 70 against lattice flow; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$) AS (id agtype)), + NULL, '"out"'::agtype +); + path_count +------------ + 0 +(1 row) + +-- UNDIRECTED: 78 .. 70 reverses the lattice; expected: path_count = 6 +SELECT count(*) AS path_count +FROM age_all_shortest_paths( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$) AS (id agtype)) +); + path_count +------------ + 6 +(1 row) + +-- isolated id 119 unreachable from 0; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 119}) RETURN id(n) $$) AS (id agtype)) +); + path_count +------------ + 0 +(1 row) + +-- zero-length path, start == end; expected: path_count = 1 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)) +); + path_count +------------ + 1 +(1 row) + +-- cleanup +SELECT * FROM drop_graph('sp_big', true); +NOTICE: drop cascades to 5 other objects +DETAIL: drop cascades to table sp_big._ag_label_vertex +drop cascades to table sp_big._ag_label_edge +drop cascades to table sp_big."N" +drop cascades to table sp_big."KNOWS" +drop cascades to table sp_big."LIKES" +NOTICE: graph "sp_big" has been dropped + drop_graph +------------ + +(1 row) + +-- +-- Calling the age_* SRFs from inside cypher() (Tier 1). +-- +-- Because the functions are prefixed with age_, the cypher() parser resolves +-- the unqualified names 'shortest_path' and 'all_shortest_paths' to +-- ag_catalog.age_shortest_path / ag_catalog.age_all_shortest_paths, and the +-- graph name is auto-injected as the first argument (like vle/vertex_stats), +-- so callers pass only the bound endpoints. A whole vertex implicitly casts to +-- agtype, so the argument types resolve. The SRFs are set-returning and now +-- work in a cypher RETURN projection (ProjectSet), returning one row per path. +-- +SELECT * FROM create_graph('sp_cy'); +NOTICE: graph "sp_cy" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('sp_cy', $$ + CREATE (a:N {name: 'A'}), + (b:N {name: 'B'}), + (c:N {name: 'C'}), + (a)-[:KNOWS]->(b), + (b)-[:KNOWS]->(c) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- materialize the global graph context +SELECT * FROM cypher('sp_cy', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + result +----------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "N", "in_degree": 0, "out_degree": 1, "self_loops": 0} + {"id": 844424930131970, "label": "N", "in_degree": 1, "out_degree": 1, "self_loops": 0} + {"id": 844424930131971, "label": "N", "in_degree": 1, "out_degree": 0, "self_loops": 0} +(3 rows) + +-- shortest_path() inside a cypher RETURN; the graph name is auto-injected and +-- the bound vertices are passed; expected: 1 path A..C (length 2) +SELECT * FROM cypher('sp_cy', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN shortest_path(a, c) +$$) AS (path agtype); + path +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + [{"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970, "label": "N", "properties": {"name": "B"}}::vertex, {"id": 1125899906842626, "label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131970, "properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties": {"name": "C"}}::vertex]::path +(1 row) + +-- all_shortest_paths() inside a cypher RETURN; expected: 1 path A..C (length 2) +SELECT * FROM cypher('sp_cy', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN all_shortest_paths(a, c) +$$) AS (path agtype); + path +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + [{"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970, "label": "N", "properties": {"name": "B"}}::vertex, {"id": 1125899906842626, "label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131970, "properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties": {"name": "C"}}::vertex]::path +(1 row) + +-- in-cypher with an explicit edge-label filter; expected: 1 path A..C (length 2) +SELECT * FROM cypher('sp_cy', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN shortest_path(a, c, 'KNOWS') +$$) AS (path agtype); + path +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + [{"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970, "label": "N", "properties": {"name": "B"}}::vertex, {"id": 1125899906842626, "label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131970, "properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties": {"name": "C"}}::vertex]::path +(1 row) + +-- still supported: call the SRF at the top level; expected: 1 path A..C (length 2) +SELECT path +FROM age_shortest_path( + '"sp_cy"'::agtype, + (SELECT id FROM cypher('sp_cy', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_cy', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)) +) AS path +ORDER BY path; + path +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + [{"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970, "label": "N", "properties": {"name": "B"}}::vertex, {"id": 1125899906842626, "label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131970, "properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties": {"name": "C"}}::vertex]::path +(1 row) + +-- cleanup +SELECT * FROM drop_graph('sp_cy', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table sp_cy._ag_label_vertex +drop cascades to table sp_cy._ag_label_edge +drop cascades to table sp_cy."N" +drop cascades to table sp_cy."KNOWS" +NOTICE: graph "sp_cy" has been dropped + drop_graph +------------ + +(1 row) + +-- +-- Edge cases: parallel/multi-edges, self-loops, unknown edge labels, +-- max_hops boundaries (0 and negative), explicit 'any' direction, and +-- NULL / unknown-graph argument errors. +-- +SELECT * FROM create_graph('sp_edge'); +NOTICE: graph "sp_edge" has been created + create_graph +-------------- + +(1 row) + +-- A and B are connected by TWO parallel KNOWS edges plus one LIKES edge. +-- B->C is a single KNOWS edge. S has a self-loop. These exercise the +-- multi-predecessor (parallel edge) logic and the label filter. +SELECT * FROM cypher('sp_edge', $$ + CREATE (a:N {name: 'A'}), + (b:N {name: 'B'}), + (c:N {name: 'C'}), + (s:N {name: 'S'}), + (a)-[:KNOWS]->(b), + (a)-[:KNOWS]->(b), + (a)-[:LIKES]->(b), + (b)-[:KNOWS]->(c), + (s)-[:KNOWS]->(s) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- materialize the global graph context +SELECT * FROM cypher('sp_edge', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + result +----------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "N", "in_degree": 0, "out_degree": 3, "self_loops": 0} + {"id": 844424930131970, "label": "N", "in_degree": 3, "out_degree": 1, "self_loops": 0} + {"id": 844424930131971, "label": "N", "in_degree": 1, "out_degree": 0, "self_loops": 0} + {"id": 844424930131972, "label": "N", "in_degree": 1, "out_degree": 1, "self_loops": 1} +(4 rows) + +-- parallel edges: two distinct KNOWS edges A->B are two distinct shortest +-- paths; expected count 2 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype); + count +------- + 2 +(1 row) + +-- no label filter: 2 KNOWS + 1 LIKES edge A->B are three distinct shortest +-- paths; expected count 3 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype, '"out"'::agtype); + count +------- + 3 +(1 row) + +-- single shortest path A->B picks exactly one of the parallel edges; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype))); + count +------- + 1 +(1 row) + +-- self-loop: a vertex with an edge to itself yields only the zero-length +-- path for start == end (the self-loop is never used); count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$) AS (id agtype))); + count +------- + 1 +(1 row) + +-- all_shortest_paths with start == end (existing vertex): one zero-length +-- path; count 1 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$) AS (id agtype))); + count +------- + 1 +(1 row) + +-- unknown relationship type matches no edges: A..C filtered by a label that +-- does not exist must return no path (NOT silently fall back to all edges); +-- count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"NOSUCHLABEL"'::agtype, '"out"'::agtype); + count +------- + 0 +(1 row) + +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"NOSUCHLABEL"'::agtype, '"out"'::agtype); + count +------- + 0 +(1 row) + +-- the zero-length (start == end) path has no edges, so an unknown label +-- still matches it; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$) AS (id agtype)), + '"NOSUCHLABEL"'::agtype, '"out"'::agtype); + count +------- + 1 +(1 row) + +-- existing label that does not connect the endpoints: LIKES only exists on +-- A->B, so A..C filtered by LIKES is unreachable; count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"LIKES"'::agtype, '"out"'::agtype); + count +------- + 0 +(1 row) + +-- max_hops = 0 with start == end: the zero-length path is still returned; +-- count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype, NULL::agtype, NULL::agtype, '0'::agtype); + count +------- + 1 +(1 row) + +-- max_hops = 0 with adjacent distinct endpoints: no path within zero hops; +-- count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype, NULL::agtype, NULL::agtype, '0'::agtype); + count +------- + 0 +(1 row) + +-- negative max_hops is treated as unbounded: A..C (length 2) is found; +-- count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype, NULL::agtype, NULL::agtype, '-1'::agtype); + count +------- + 1 +(1 row) + +-- explicit 'any' direction string (vs the default NULL == undirected); +-- two parallel KNOWS edges A->B give two shortest paths; count 2 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"any"'::agtype); + count +------- + 2 +(1 row) + +-- NULL start (or end) vertex yields no rows (Cypher null semantics: a null +-- endpoint simply produces no match, it is not an error); count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + NULL::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype))); + count +------- + 0 +(1 row) + +-- NULL end vertex likewise yields no rows; count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype); + count +------- + 0 +(1 row) + +-- all_shortest_paths with a NULL endpoint also yields no rows; count 0 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype); + count +------- + 0 +(1 row) + +-- a single relationship type may be passed as a one-element array; expected: +-- same as the bare-string form, A..C under KNOWS (length 2); count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["KNOWS"]'::agtype, '"out"'::agtype); + count +------- + 1 +(1 row) + +-- multiple relationship types: an array of types matches an edge whose type +-- is any one of them. A..C single shortest under {KNOWS, LIKES} (length 2); +-- count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["KNOWS", "LIKES"]'::agtype, '"out"'::agtype); + count +------- + 1 +(1 row) + +-- all shortest A..C under {KNOWS, LIKES}: three A->B edges (2 KNOWS + 1 LIKES) +-- each extend by the single B->C KNOWS edge; count 3 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["KNOWS", "LIKES"]'::agtype, '"out"'::agtype); + count +------- + 3 +(1 row) + +-- all shortest A..B under {KNOWS, LIKES}: the two parallel KNOWS edges plus +-- the one LIKES edge are three distinct one-hop paths; count 3 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + '["KNOWS", "LIKES"]'::agtype, '"any"'::agtype); + count +------- + 3 +(1 row) + +-- a multi-type array containing an unknown type ignores the unknown member: +-- {NOSUCHLABEL, KNOWS} still finds A..C via KNOWS; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["NOSUCHLABEL", "KNOWS"]'::agtype, '"out"'::agtype); + count +------- + 1 +(1 row) + +-- a multi-type array of only types that do not connect the endpoints: +-- {LIKES} reaches B but B..C has no LIKES edge, so A..C is unreachable; +-- count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["LIKES", "NOSUCHLABEL"]'::agtype, '"out"'::agtype); + count +------- + 0 +(1 row) + +-- an empty relationship-type array imposes no filter (same as NULL): A..C +-- (length 2) is found; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '[]'::agtype, '"out"'::agtype); + count +------- + 1 +(1 row) + +-- a non-string element in the relationship-type array is an error +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["KNOWS", 7]'::agtype, '"out"'::agtype); +ERROR: age_shortest_path: relationship type must be a string +-- a minimum hop count that does not exceed the shortest distance imposes no +-- extra constraint; A..C via KNOWS has length 2, so min_hops=2 is accepted and +-- returns the length-2 path; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype); + count +------- + 1 +(1 row) + +-- a minimum hop count greater than the shortest distance falls back to the +-- exhaustive (VLE) search; A..C in this DAG has no length-3 path (every longer +-- route would have to reuse an edge), so min_hops=3 yields no rows; count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 3::agtype); + count +------- + 0 +(1 row) + +-- a minimum hop count of 0 is the default and is accepted; A..C (length 2); +-- count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 0::agtype); + count +------- + 1 +(1 row) + +-- a graph name that does not exist is an error +SELECT count(*) FROM age_shortest_path('"no_such_graph"'::agtype, '1'::agtype, '2'::agtype); +ERROR: schema "no_such_graph" does not exist +-- a NULL graph name is an error (the graph name is required, unlike the +-- endpoints which accept NULL as "no match") +SELECT count(*) FROM age_shortest_path( + NULL::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype))); +ERROR: age_shortest_path: graph name cannot be NULL +-- a non-integer max_hops is an error (the hop bounds must be integers) +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, NULL::agtype, '"not_an_int"'::agtype); +ERROR: age_shortest_path: agtype argument of wrong type +-- a non-integer min_hops is an error (symmetric to max_hops above) +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, '"not_an_int"'::agtype); +ERROR: age_shortest_path: agtype argument of wrong type +-- a negative min_hops is clamped to 0 (no constraint), so A..C (length 2) is +-- still found; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, '-3'::agtype); + count +------- + 1 +(1 row) + +-- cleanup +SELECT * FROM drop_graph('sp_edge', true); +NOTICE: drop cascades to 5 other objects +DETAIL: drop cascades to table sp_edge._ag_label_vertex +drop cascades to table sp_edge._ag_label_edge +drop cascades to table sp_edge."N" +drop cascades to table sp_edge."KNOWS" +drop cascades to table sp_edge."LIKES" +NOTICE: graph "sp_edge" has been dropped + drop_graph +------------ + +(1 row) + +-- +-- Calling shortest_path / all_shortest_paths from inside cypher() (Tier 1) +-- WITH a relationship-type filter -- both a single type (bare string) and +-- multiple types (a list literal). The graph name is auto-injected, so the +-- in-cypher call passes only the bound endpoints and the type filter. +-- +-- Graph: A and B are joined by two parallel KNOWS edges plus one LIKES edge; +-- B->C is a single KNOWS edge. This lets the all_shortest_paths variant return +-- more than one path so the multiplicity is visible. +-- +SELECT * FROM create_graph('sp_cy_lbl'); +NOTICE: graph "sp_cy_lbl" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('sp_cy_lbl', $$ + CREATE (a:N {name: 'A'}), + (b:N {name: 'B'}), + (c:N {name: 'C'}), + (a)-[:KNOWS]->(b), + (a)-[:KNOWS]->(b), + (a)-[:LIKES]->(b), + (b)-[:KNOWS]->(c) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- materialize the global graph context +SELECT * FROM cypher('sp_cy_lbl', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + result +----------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "N", "in_degree": 0, "out_degree": 3, "self_loops": 0} + {"id": 844424930131970, "label": "N", "in_degree": 3, "out_degree": 1, "self_loops": 0} + {"id": 844424930131971, "label": "N", "in_degree": 1, "out_degree": 0, "self_loops": 0} +(3 rows) + +-- shortest_path() in-cypher with a single relationship type; A..C via KNOWS +-- (length 2); expected: 1 path +SELECT count(*) FROM cypher('sp_cy_lbl', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN shortest_path(a, c, 'KNOWS') +$$) AS (path agtype); + count +------- + 1 +(1 row) + +-- all_shortest_paths() in-cypher with a single relationship type; the two +-- parallel KNOWS edges A->B make two distinct shortest A..C paths; expected: 2 +SELECT count(*) FROM cypher('sp_cy_lbl', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN all_shortest_paths(a, c, 'KNOWS') +$$) AS (path agtype); + count +------- + 2 +(1 row) + +-- shortest_path() in-cypher with multiple relationship types passed as a list +-- literal; A..C under {KNOWS, LIKES} (length 2); expected: 1 path +SELECT count(*) FROM cypher('sp_cy_lbl', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN shortest_path(a, c, ['KNOWS', 'LIKES']) +$$) AS (path agtype); + count +------- + 1 +(1 row) + +-- all_shortest_paths() in-cypher with multiple relationship types; the three +-- A->B edges (2 KNOWS + 1 LIKES) each extend by the single B->C KNOWS edge, +-- giving three distinct shortest A..C paths; expected: 3 +SELECT count(*) FROM cypher('sp_cy_lbl', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN all_shortest_paths(a, c, ['KNOWS', 'LIKES']) +$$) AS (path agtype); + count +------- + 3 +(1 row) + +-- all_shortest_paths() in-cypher, multiple types, adjacent endpoints: the two +-- parallel KNOWS edges plus the one LIKES edge are three one-hop A..B paths; +-- expected: 3 +SELECT count(*) FROM cypher('sp_cy_lbl', $$ + MATCH (a:N {name:'A'}), (b:N {name:'B'}) + RETURN all_shortest_paths(a, b, ['KNOWS', 'LIKES']) +$$) AS (path agtype); + count +------- + 3 +(1 row) + +-- multiple types where only one connects the endpoints: {LIKES} reaches B but +-- B->C has no LIKES edge, so A..C is unreachable in-cypher; expected: 0 +SELECT count(*) FROM cypher('sp_cy_lbl', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN all_shortest_paths(a, c, ['LIKES']) +$$) AS (path agtype); + count +------- + 0 +(1 row) + +-- cleanup +SELECT * FROM drop_graph('sp_cy_lbl', true); +NOTICE: drop cascades to 5 other objects +DETAIL: drop cascades to table sp_cy_lbl._ag_label_vertex +drop cascades to table sp_cy_lbl._ag_label_edge +drop cascades to table sp_cy_lbl."N" +drop cascades to table sp_cy_lbl."KNOWS" +drop cascades to table sp_cy_lbl."LIKES" +NOTICE: graph "sp_cy_lbl" has been dropped + drop_graph +------------ + +(1 row) + +-- +-- Minimum hop count fallback (Tier: VLE exhaustive search). When the requested +-- minimum hop count exceeds the true shortest distance, the BFS shortest-path +-- cannot satisfy it (it needs longer paths), so the implementation falls back +-- to the variable-length-edge depth-first engine and returns the shortest +-- path(s) whose length is at least min_hops. +-- +-- Graph: A reaches C directly (length 1) and also via two distinct +-- intermediate vertices B1 and B2 (length 2 each): +-- A->C, A->B1->C, A->B2->C +-- +SELECT * FROM create_graph('sp_min'); +NOTICE: graph "sp_min" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('sp_min', $$ + CREATE (a:N {name: 'A'}), + (c:N {name: 'C'}), + (b1:N {name: 'B1'}), + (b2:N {name: 'B2'}), + (a)-[:KNOWS]->(c), + (a)-[:KNOWS]->(b1), + (b1)-[:KNOWS]->(c), + (a)-[:KNOWS]->(b2), + (b2)-[:KNOWS]->(c) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- materialize the global graph context +SELECT * FROM cypher('sp_min', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + result +----------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "N", "in_degree": 0, "out_degree": 3, "self_loops": 0} + {"id": 844424930131970, "label": "N", "in_degree": 3, "out_degree": 0, "self_loops": 0} + {"id": 844424930131971, "label": "N", "in_degree": 1, "out_degree": 1, "self_loops": 0} + {"id": 844424930131972, "label": "N", "in_degree": 1, "out_degree": 1, "self_loops": 0} +(4 rows) + +-- baseline: the shortest A..C is the direct length-1 edge; count 1 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype); + count +------- + 1 +(1 row) + +-- min_hops=2 excludes the direct edge and falls back to the exhaustive search; +-- the two length-2 routes A->B1->C and A->B2->C are the shortest qualifying +-- paths; all_shortest_paths returns both; count 2 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype); + count +------- + 2 +(1 row) + +-- single shortest_path with min_hops=2 picks exactly one of the two length-2 +-- routes; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype); + count +------- + 1 +(1 row) + +-- the qualifying length-2 paths materialize correctly; all_shortest_paths +-- returns the full, order-stable set (the single shortest_path variant would +-- return an arbitrary one of the two equal-length routes, which is not a +-- deterministic choice), so both A->B1->C and A->B2->C are listed +SELECT path FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype) AS path +ORDER BY path; + path +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + [{"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex, {"id": 1125899906842626, "label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties": {"name": "B1"}}::vertex, {"id": 1125899906842627, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131971, "properties": {}}::edge, {"id": 844424930131970, "label": "N", "properties": {"name": "C"}}::vertex]::path + [{"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex, {"id": 1125899906842628, "label": "KNOWS", "end_id": 844424930131972, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131972, "label": "N", "properties": {"name": "B2"}}::vertex, {"id": 1125899906842629, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131972, "properties": {}}::edge, {"id": 844424930131970, "label": "N", "properties": {"name": "C"}}::vertex]::path +(2 rows) + +-- min_hops=2 with a matching max_hops=2 returns the same two length-2 paths; +-- count 2 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype, 2::agtype); + count +------- + 2 +(1 row) + +-- min_hops greater than max_hops is unsatisfiable; count 0 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype, 1::agtype); + count +------- + 0 +(1 row) + +-- min_hops=3 has no qualifying path (a length-3 A..C would have to reuse an +-- edge, which relationship-uniqueness forbids); count 0 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 3::agtype); + count +------- + 0 +(1 row) + +-- no edge-type filter also reaches the fallback; the two length-2 routes are +-- returned; count 2 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype, '"out"'::agtype, 2::agtype); + count +------- + 2 +(1 row) + +-- the fallback honours direction: traversing edges backwards (C..A, 'in') with +-- min_hops=2 also returns the two length-2 routes; count 2 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"in"'::agtype, 2::agtype); + count +------- + 2 +(1 row) + +-- the fallback respects direction: there is no forward C..A path, so 'out' +-- with min_hops=2 returns nothing; count 0 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype); + count +------- + 0 +(1 row) + +-- an unknown relationship type in the fallback matches no edges, so even the +-- shortest qualifying path cannot be formed; count 0 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"NOSUCHLABEL"'::agtype, '"out"'::agtype, 2::agtype); + count +------- + 0 +(1 row) + +-- a minimum hop count greater than the shortest distance combined with +-- multiple relationship types is not supported (the VLE engine matches a +-- single label only); expected: ERROR +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["KNOWS", "LIKES"]'::agtype, '"out"'::agtype, 2::agtype); +ERROR: age_all_shortest_paths: a minimum hop count greater than the shortest path length is not supported with multiple relationship types +-- cleanup +SELECT * FROM drop_graph('sp_min', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table sp_min._ag_label_vertex +drop cascades to table sp_min._ag_label_edge +drop cascades to table sp_min."N" +drop cascades to table sp_min."KNOWS" +NOTICE: graph "sp_min" has been dropped + drop_graph +------------ + +(1 row) + +-- +-- Minimum hop count fallback with a VERTEX-REVISITING longer path. This is the +-- defining behaviour of the exhaustive-search regime: relationship-uniqueness +-- (Cypher trail semantics) forbids reusing an EDGE but permits revisiting a +-- VERTEX, so a qualifying path longer than the shortest distance may loop back +-- through an already-seen node. +-- +-- Graph: A -> B -> C, C -> B (back edge), B -> D +-- A..D shortest distance = 2 (A->B->D) +-- there is no edge-distinct length-3 A..D path +-- the only edge-distinct length-4 A..D path is A->B->C->B->D, which +-- revisits vertex B but uses each of the four edges exactly once +-- +SELECT * FROM create_graph('sp_revisit'); +NOTICE: graph "sp_revisit" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('sp_revisit', $$ + CREATE (a:N {name: 'A'}), + (b:N {name: 'B'}), + (c:N {name: 'C'}), + (d:N {name: 'D'}), + (a)-[:KNOWS]->(b), + (b)-[:KNOWS]->(c), + (c)-[:KNOWS]->(b), + (b)-[:KNOWS]->(d) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- materialize the global graph context +SELECT * FROM cypher('sp_revisit', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + result +----------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "N", "in_degree": 0, "out_degree": 1, "self_loops": 0} + {"id": 844424930131970, "label": "N", "in_degree": 2, "out_degree": 2, "self_loops": 0} + {"id": 844424930131971, "label": "N", "in_degree": 1, "out_degree": 1, "self_loops": 0} + {"id": 844424930131972, "label": "N", "in_degree": 1, "out_degree": 0, "self_loops": 0} +(4 rows) + +-- min_hops=2 equals the shortest distance, so the easy (BFS) regime returns the +-- direct A->B->D route; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_revisit"'::agtype, + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype); + count +------- + 1 +(1 row) + +-- min_hops is a lower bound, not an exact length: with min_hops=3 there is no +-- length-3 edge-distinct path, so the search returns the next-shortest +-- qualifying path, the length-4 route A->B->C->B->D; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_revisit"'::agtype, + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 3::agtype); + count +------- + 1 +(1 row) + +-- min_hops=4 is satisfied only by the vertex-revisiting A->B->C->B->D path; +-- count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_revisit"'::agtype, + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 4::agtype); + count +------- + 1 +(1 row) + +-- the length-4 path is unique, so its materialized form is deterministic; it +-- visits B twice (B appears at positions 2 and 4) yet repeats no edge +SELECT path FROM age_shortest_path( + '"sp_revisit"'::agtype, + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 4::agtype) AS path +ORDER BY path; + path +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + [{"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970, "label": "N", "properties": {"name": "B"}}::vertex, {"id": 1125899906842626, "label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131970, "properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties": {"name": "C"}}::vertex, {"id": 1125899906842627, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131971, "properties": {}}::edge, {"id": 844424930131970, "label": "N", "properties": {"name": "B"}}::vertex, {"id": 1125899906842628, "label": "KNOWS", "end_id": 844424930131972, "start_id": 844424930131970, "properties": {}}::edge, {"id": 844424930131972, "label": "N", "properties": {"name": "D"}}::vertex]::path +(1 row) + +-- min_hops=5 exhausts the four edges without an edge-distinct path; count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_revisit"'::agtype, + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 5::agtype); + count +------- + 0 +(1 row) + +-- cleanup +SELECT * FROM drop_graph('sp_revisit', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table sp_revisit._ag_label_vertex +drop cascades to table sp_revisit._ag_label_edge +drop cascades to table sp_revisit."N" +drop cascades to table sp_revisit."KNOWS" +NOTICE: graph "sp_revisit" has been dropped + drop_graph +------------ + +(1 row) + +-- +-- Minimum hop count fallback with a CLOSED WALK (start == end through a cycle). +-- When start and end are the same vertex the shortest distance is 0, so any +-- positive min_hops forces the exhaustive search to look for a cycle that +-- returns to the start using edge-distinct steps. +-- +-- Graph: a single directed triangle A -> B -> C -> A +-- A..A shortest distance = 0 (the zero-length path) +-- the only edge-distinct closed walk is the length-3 triangle A->B->C->A +-- +SELECT * FROM create_graph('sp_tri'); +NOTICE: graph "sp_tri" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('sp_tri', $$ + CREATE (a:N {name: 'A'}), + (b:N {name: 'B'}), + (c:N {name: 'C'}), + (a)-[:KNOWS]->(b), + (b)-[:KNOWS]->(c), + (c)-[:KNOWS]->(a) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- materialize the global graph context +SELECT * FROM cypher('sp_tri', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + result +----------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "N", "in_degree": 1, "out_degree": 1, "self_loops": 0} + {"id": 844424930131970, "label": "N", "in_degree": 1, "out_degree": 1, "self_loops": 0} + {"id": 844424930131971, "label": "N", "in_degree": 1, "out_degree": 1, "self_loops": 0} +(3 rows) + +-- no min_hops: start == end yields the zero-length path; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_tri"'::agtype, + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype); + count +------- + 1 +(1 row) + +-- min_hops=3 forces the exhaustive search to find the closed triangle walk +-- A->B->C->A; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_tri"'::agtype, + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 3::agtype); + count +------- + 1 +(1 row) + +-- the closed walk is unique, so its materialized form is deterministic; it +-- starts and ends at A and traverses each triangle edge once +SELECT path FROM age_shortest_path( + '"sp_tri"'::agtype, + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 3::agtype) AS path +ORDER BY path; + path +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + [{"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131970, "label": "N", "properties": {"name": "B"}}::vertex, {"id": 1125899906842626, "label": "KNOWS", "end_id": 844424930131971, "start_id": 844424930131970, "properties": {}}::edge, {"id": 844424930131971, "label": "N", "properties": {"name": "C"}}::vertex, {"id": 1125899906842627, "label": "KNOWS", "end_id": 844424930131969, "start_id": 844424930131971, "properties": {}}::edge, {"id": 844424930131969, "label": "N", "properties": {"name": "A"}}::vertex]::path +(1 row) + +-- min_hops=4 cannot be met without reusing an edge of the three-edge triangle; +-- count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_tri"'::agtype, + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 4::agtype); + count +------- + 0 +(1 row) + +-- cleanup +SELECT * FROM drop_graph('sp_tri', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table sp_tri._ag_label_vertex +drop cascades to table sp_tri._ag_label_edge +drop cascades to table sp_tri."N" +drop cascades to table sp_tri."KNOWS" +NOTICE: graph "sp_tri" has been dropped + drop_graph +------------ + +(1 row) + +-- +-- Error messages report the actual SRF that was called. age_shortest_path and +-- age_all_shortest_paths share their argument-resolution helpers; these cases +-- confirm each reports its own name in the error text rather than a single +-- hard-coded "age_shortest_path" prefix. +-- +SELECT * FROM create_graph('sp_errname'); +NOTICE: graph "sp_errname" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('sp_errname', $$ + CREATE (a:N {name: 'A'})-[:KNOWS]->(b:N {name: 'B'}) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- materialize the global graph context +SELECT * FROM cypher('sp_errname', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + result +----------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "N", "in_degree": 0, "out_degree": 1, "self_loops": 0} + {"id": 844424930131970, "label": "N", "in_degree": 1, "out_degree": 0, "self_loops": 0} +(2 rows) + +-- a NULL graph name errors with the called function's name as the prefix +SELECT count(*) FROM age_shortest_path(NULL::agtype, 0::agtype, 1::agtype); +ERROR: age_shortest_path: graph name cannot be NULL +SELECT count(*) FROM age_all_shortest_paths(NULL::agtype, 0::agtype, 1::agtype); +ERROR: age_all_shortest_paths: graph name cannot be NULL +-- a non-string relationship type errors with the called function's name as the +-- prefix (the array element 1 is an integer, not a string) +SELECT count(*) FROM age_shortest_path( + '"sp_errname"'::agtype, + (SELECT id FROM cypher('sp_errname', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_errname', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + '[1]'::agtype); +ERROR: age_shortest_path: relationship type must be a string +SELECT count(*) FROM age_all_shortest_paths( + '"sp_errname"'::agtype, + (SELECT id FROM cypher('sp_errname', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_errname', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + '[1]'::agtype); +ERROR: age_all_shortest_paths: relationship type must be a string +-- cleanup +SELECT * FROM drop_graph('sp_errname', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table sp_errname._ag_label_vertex +drop cascades to table sp_errname._ag_label_edge +drop cascades to table sp_errname."N" +drop cascades to table sp_errname."KNOWS" +NOTICE: graph "sp_errname" has been dropped + drop_graph +------------ + +(1 row) + diff --git a/regress/expected/age_upgrade.out b/regress/expected/age_upgrade.out new file mode 100644 index 000000000..446e0eb76 --- /dev/null +++ b/regress/expected/age_upgrade.out @@ -0,0 +1,579 @@ +/* + * 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. + */ +-- +-- Extension upgrade template regression test +-- +-- Validates the upgrade template (age----y.y.y.sql) by comparing +-- the pg_catalog entries of a fresh install against an upgraded install. +-- If any object exists in the fresh install but is missing after upgrade, +-- the template is incomplete. This catches the case where a developer adds +-- a new SQL object to sql/ and sql_files but forgets to add it to the +-- upgrade template. +-- +-- Compared catalogs: +-- pg_proc — functions, aggregates, procedures (name, args, properties +-- including probin/prosrc to catch C-symbol renames and +-- SQL-body changes) +-- pg_class — tables, views, sequences, indexes (name, kind) +-- pg_type — types (name, type category) +-- pg_operator — operators (name, left/right types) +-- pg_cast — casts involving AGE types (source, target, context) +-- pg_opclass — operator classes (name, access method) +-- pg_constraint — constraints (name, type, table, referenced table) +-- pg_depend — extension membership (every AGE-owned object must be +-- linked back to the extension via deptype='e'; catches +-- a missing ALTER EXTENSION age ADD ... in the template) +-- +-- All comparison queries should return 0 rows. +-- +-- Note on synthetic-initial install (step 10): the synthetic '*_initial' +-- snapshot is built from a fixed historical commit, so its CREATE FUNCTION +-- statements may reference C symbols that have since been removed from the +-- current age.so. Step 10 disables check_function_bodies so that dlsym is +-- deferred to call time; the immediately-following ALTER EXTENSION UPDATE +-- (step 11) DROPs any such retired functions before any plan can call them. +-- This lets developers cleanly remove deprecated C entry points without +-- needing to keep error-raising stubs in age.so. +-- +LOAD 'age'; +SET search_path TO ag_catalog; +-- Step 1: Clean up any graphs left by prior tests (deterministic, no output). +DO $$ +DECLARE + graph_name ag_graph.name%TYPE; +BEGIN + PERFORM set_config('client_min_messages', 'warning', true); + + FOR graph_name IN + SELECT name + FROM ag_graph + ORDER BY name + LOOP + PERFORM drop_graph(graph_name, true); + END LOOP; +END +$$; +-- ===================================================================== +-- FRESH INSTALL SNAPSHOTS (Steps 2-7b) +-- Capture the catalog state from the default CREATE EXTENSION install. +-- ===================================================================== +-- Step 2: Snapshot functions (includes aggregates via prokind). +-- probin/prosrc capture the binding to the implementation: +-- * LANGUAGE c : probin = '$libdir/age', prosrc = C symbol name +-- (a renamed/retargeted symbol shows up here) +-- * LANGUAGE sql/plpgsql: probin = NULL, prosrc = function body text +-- (a body change in the upgrade template shows up here) +-- * LANGUAGE internal : probin = NULL, prosrc = builtin name +CREATE TEMP TABLE _fresh_funcs AS +SELECT proname::text, + pg_get_function_identity_arguments(oid) AS args, + provolatile::text, proisstrict::text, prokind::text, + prorettype::regtype::text AS rettype, proretset::text, + COALESCE(probin, '') AS probin, + COALESCE(prosrc, '') AS prosrc +FROM pg_proc +WHERE pronamespace = 'ag_catalog'::regnamespace +ORDER BY proname, args; +-- Step 3: Snapshot relations (tables, views, sequences, indexes). +CREATE TEMP TABLE _fresh_rels AS +SELECT relname::text, relkind::text +FROM pg_class +WHERE relnamespace = 'ag_catalog'::regnamespace + AND relkind IN ('r', 'v', 'S', 'i') +ORDER BY relname; +-- Step 4: Snapshot types. +CREATE TEMP TABLE _fresh_types AS +SELECT typname::text, typtype::text +FROM pg_type +WHERE typnamespace = 'ag_catalog'::regnamespace + AND typname NOT LIKE 'pg_toast%' +ORDER BY typname; +-- Step 5: Snapshot operators. +CREATE TEMP TABLE _fresh_ops AS +SELECT oprname::text, + oprleft::regtype::text AS lefttype, + oprright::regtype::text AS righttype +FROM pg_operator +WHERE oprnamespace = 'ag_catalog'::regnamespace +ORDER BY oprname, lefttype, righttype; +-- Step 6: Snapshot casts involving AGE types, and operator classes. +CREATE TEMP TABLE _fresh_casts AS +SELECT castsource::regtype::text AS source_type, + casttarget::regtype::text AS target_type, + castcontext::text +FROM pg_cast +WHERE castsource IN (SELECT oid FROM pg_type WHERE typnamespace = 'ag_catalog'::regnamespace) + OR casttarget IN (SELECT oid FROM pg_type WHERE typnamespace = 'ag_catalog'::regnamespace) +ORDER BY source_type, target_type; +CREATE TEMP TABLE _fresh_opclass AS +SELECT opcname::text, + (SELECT amname FROM pg_am WHERE oid = opcmethod)::text AS amname +FROM pg_opclass +WHERE opcnamespace = 'ag_catalog'::regnamespace +ORDER BY opcname; +-- Step 7: Snapshot constraints. +CREATE TEMP TABLE _fresh_constraints AS +SELECT conname::text, contype::text, + conrelid::regclass::text AS table_name, + confrelid::regclass::text AS ref_table +FROM pg_constraint +WHERE connamespace = 'ag_catalog'::regnamespace +ORDER BY conname; +-- Step 7b: Snapshot extension membership (pg_depend deptype='e'). +-- Every object that CREATE EXTENSION owns has a row in pg_depend linking +-- it to the extension. The upgrade template must produce the same set: +-- if it CREATEs an object but forgets to ALTER EXTENSION ADD it, the +-- catalog row exists (so funcs_match/rels_match would pass) but the +-- pg_depend link is absent and pg_dump --extension would diverge. +CREATE TEMP TABLE _fresh_extmembers AS +SELECT + CASE d.classid + WHEN 'pg_proc'::regclass + THEN 'function: ' || d.objid::regprocedure::text + WHEN 'pg_type'::regclass + THEN 'type: ' || d.objid::regtype::text + WHEN 'pg_class'::regclass + THEN 'relation: ' || d.objid::regclass::text + || ' (' || (SELECT relkind::text FROM pg_class WHERE oid = d.objid) || ')' + WHEN 'pg_operator'::regclass + THEN 'operator: ' || d.objid::regoperator::text + WHEN 'pg_cast'::regclass + THEN 'cast: ' || (SELECT castsource::regtype::text || ' -> ' || casttarget::regtype::text + FROM pg_cast WHERE oid = d.objid) + WHEN 'pg_opclass'::regclass + THEN 'opclass: ' || (SELECT opcname || ' (' || (SELECT amname FROM pg_am WHERE oid = opcmethod) || ')' + FROM pg_opclass WHERE oid = d.objid) + WHEN 'pg_constraint'::regclass + THEN 'constraint: ' || (SELECT conname || ' on ' || conrelid::regclass::text + FROM pg_constraint WHERE oid = d.objid) + WHEN 'pg_opfamily'::regclass + THEN 'opfamily: ' || (SELECT opfname || ' (' || (SELECT amname FROM pg_am WHERE oid = opfmethod) || ')' + FROM pg_opfamily WHERE oid = d.objid) + WHEN 'pg_amop'::regclass + THEN 'amop: ' || (SELECT (SELECT opfname FROM pg_opfamily WHERE oid = a.amopfamily) + || ' [strategy ' || a.amopstrategy || '] ' + || a.amoplefttype::regtype::text || ',' + || a.amoprighttype::regtype::text || ' op ' + || a.amopopr::regoperator::text + FROM pg_amop a WHERE a.oid = d.objid) + WHEN 'pg_amproc'::regclass + THEN 'amproc: ' || (SELECT (SELECT opfname FROM pg_opfamily WHERE oid = p.amprocfamily) + || ' [proc ' || p.amprocnum || '] ' + || p.amproclefttype::regtype::text || ',' + || p.amprocrighttype::regtype::text || ' fn ' + || p.amproc::regprocedure::text + FROM pg_amproc p WHERE p.oid = d.objid) + ELSE 'unhandled[' || d.classid::regclass::text || ']' + END AS member +FROM pg_depend d +WHERE d.deptype = 'e' + AND d.refclassid = 'pg_extension'::regclass + AND d.refobjid = (SELECT oid FROM pg_extension WHERE extname = 'age') +ORDER BY 1; +-- Step 8: Drop AGE entirely. +DROP EXTENSION age; +-- Step 9: Verify we have an upgrade path available. +SELECT count(*) > 1 AS has_upgrade_path +FROM pg_available_extension_versions WHERE name = 'age'; + has_upgrade_path +------------------ + t +(1 row) + +-- Step 10: Install AGE at the synthetic initial version. +-- +-- Disable check_function_bodies for this CREATE only. The synthetic +-- '*_initial' SQL is pulled from a fixed historical commit and may +-- declare C functions whose symbols have since been removed from the +-- current age.so. With check_function_bodies=on, PostgreSQL would dlsym +-- each such symbol at CREATE FUNCTION time and abort. Deferring the +-- symbol probe to call time is safe because step 11 (ALTER EXTENSION +-- UPDATE) immediately runs the upgrade template, which DROPs any +-- removed-in-HEAD functions before the test (or any user) can call them. +-- The fresh CREATE EXTENSION at step 35 keeps the GUC at its default, +-- so any inconsistency between HEAD's SQL and HEAD's age.so is still +-- caught at install time on the production code path. +SET check_function_bodies = off; +DO $$ +DECLARE init_ver text; +BEGIN + SELECT version INTO init_ver + FROM pg_available_extension_versions + WHERE name = 'age' AND version LIKE '%_initial' + ORDER BY version DESC + LIMIT 1; + IF init_ver IS NULL THEN + RAISE EXCEPTION 'No initial version available for upgrade test'; + END IF; + EXECUTE format('CREATE EXTENSION age VERSION %L', init_ver); +END; +$$; +RESET check_function_bodies; +-- Step 11: Upgrade to the current (default) version via the stamped template. +DO $$ +DECLARE curr_ver text; +BEGIN + SELECT default_version INTO curr_ver + FROM pg_available_extensions + WHERE name = 'age'; + IF curr_ver IS NULL THEN + RAISE EXCEPTION 'No default version found for upgrade test'; + END IF; + EXECUTE format('ALTER EXTENSION age UPDATE TO %L', curr_ver); +END; +$$; +-- Step 12: Confirm the upgrade succeeded. +SELECT installed_version = default_version AS upgraded_to_current +FROM pg_available_extensions WHERE name = 'age'; + upgraded_to_current +--------------------- + t +(1 row) + +-- ===================================================================== +-- UPGRADED INSTALL SNAPSHOTS (Steps 13-18b) +-- Capture the catalog state after upgrade from initial to current. +-- ===================================================================== +-- Step 13: Snapshot functions (probin/prosrc included; see step 2). +CREATE TEMP TABLE _upgraded_funcs AS +SELECT proname::text, + pg_get_function_identity_arguments(oid) AS args, + provolatile::text, proisstrict::text, prokind::text, + prorettype::regtype::text AS rettype, proretset::text, + COALESCE(probin, '') AS probin, + COALESCE(prosrc, '') AS prosrc +FROM pg_proc +WHERE pronamespace = 'ag_catalog'::regnamespace +ORDER BY proname, args; +-- Step 14: Snapshot relations. +CREATE TEMP TABLE _upgraded_rels AS +SELECT relname::text, relkind::text +FROM pg_class +WHERE relnamespace = 'ag_catalog'::regnamespace + AND relkind IN ('r', 'v', 'S', 'i') +ORDER BY relname; +-- Step 15: Snapshot types. +CREATE TEMP TABLE _upgraded_types AS +SELECT typname::text, typtype::text +FROM pg_type +WHERE typnamespace = 'ag_catalog'::regnamespace + AND typname NOT LIKE 'pg_toast%' +ORDER BY typname; +-- Step 16: Snapshot operators. +CREATE TEMP TABLE _upgraded_ops AS +SELECT oprname::text, + oprleft::regtype::text AS lefttype, + oprright::regtype::text AS righttype +FROM pg_operator +WHERE oprnamespace = 'ag_catalog'::regnamespace +ORDER BY oprname, lefttype, righttype; +-- Step 17: Snapshot casts and operator classes. +CREATE TEMP TABLE _upgraded_casts AS +SELECT castsource::regtype::text AS source_type, + casttarget::regtype::text AS target_type, + castcontext::text +FROM pg_cast +WHERE castsource IN (SELECT oid FROM pg_type WHERE typnamespace = 'ag_catalog'::regnamespace) + OR casttarget IN (SELECT oid FROM pg_type WHERE typnamespace = 'ag_catalog'::regnamespace) +ORDER BY source_type, target_type; +CREATE TEMP TABLE _upgraded_opclass AS +SELECT opcname::text, + (SELECT amname FROM pg_am WHERE oid = opcmethod)::text AS amname +FROM pg_opclass +WHERE opcnamespace = 'ag_catalog'::regnamespace +ORDER BY opcname; +-- Step 18: Snapshot constraints. +CREATE TEMP TABLE _upgraded_constraints AS +SELECT conname::text, contype::text, + conrelid::regclass::text AS table_name, + confrelid::regclass::text AS ref_table +FROM pg_constraint +WHERE connamespace = 'ag_catalog'::regnamespace +ORDER BY conname; +-- Step 18b: Snapshot extension membership after upgrade (see step 7b). +CREATE TEMP TABLE _upgraded_extmembers AS +SELECT + CASE d.classid + WHEN 'pg_proc'::regclass + THEN 'function: ' || d.objid::regprocedure::text + WHEN 'pg_type'::regclass + THEN 'type: ' || d.objid::regtype::text + WHEN 'pg_class'::regclass + THEN 'relation: ' || d.objid::regclass::text + || ' (' || (SELECT relkind::text FROM pg_class WHERE oid = d.objid) || ')' + WHEN 'pg_operator'::regclass + THEN 'operator: ' || d.objid::regoperator::text + WHEN 'pg_cast'::regclass + THEN 'cast: ' || (SELECT castsource::regtype::text || ' -> ' || casttarget::regtype::text + FROM pg_cast WHERE oid = d.objid) + WHEN 'pg_opclass'::regclass + THEN 'opclass: ' || (SELECT opcname || ' (' || (SELECT amname FROM pg_am WHERE oid = opcmethod) || ')' + FROM pg_opclass WHERE oid = d.objid) + WHEN 'pg_constraint'::regclass + THEN 'constraint: ' || (SELECT conname || ' on ' || conrelid::regclass::text + FROM pg_constraint WHERE oid = d.objid) + WHEN 'pg_opfamily'::regclass + THEN 'opfamily: ' || (SELECT opfname || ' (' || (SELECT amname FROM pg_am WHERE oid = opfmethod) || ')' + FROM pg_opfamily WHERE oid = d.objid) + WHEN 'pg_amop'::regclass + THEN 'amop: ' || (SELECT (SELECT opfname FROM pg_opfamily WHERE oid = a.amopfamily) + || ' [strategy ' || a.amopstrategy || '] ' + || a.amoplefttype::regtype::text || ',' + || a.amoprighttype::regtype::text || ' op ' + || a.amopopr::regoperator::text + FROM pg_amop a WHERE a.oid = d.objid) + WHEN 'pg_amproc'::regclass + THEN 'amproc: ' || (SELECT (SELECT opfname FROM pg_opfamily WHERE oid = p.amprocfamily) + || ' [proc ' || p.amprocnum || '] ' + || p.amproclefttype::regtype::text || ',' + || p.amprocrighttype::regtype::text || ' fn ' + || p.amproc::regprocedure::text + FROM pg_amproc p WHERE p.oid = d.objid) + ELSE 'unhandled[' || d.classid::regclass::text || ']' + END AS member +FROM pg_depend d +WHERE d.deptype = 'e' + AND d.refclassid = 'pg_extension'::regclass + AND d.refobjid = (SELECT oid FROM pg_extension WHERE extname = 'age') +ORDER BY 1; +-- ===================================================================== +-- COMPARISON: Missing or extra objects (Steps 19-33b) +-- Any rows returned indicate a template deficiency. +-- ===================================================================== +-- Step 19: Functions MISSING after upgrade. +SELECT f.proname || '(' || f.args || ')' AS missing_function +FROM _fresh_funcs f +LEFT JOIN _upgraded_funcs u USING (proname, args) +WHERE u.proname IS NULL +ORDER BY 1; + missing_function +------------------ +(0 rows) + +-- Step 20: Functions EXTRA after upgrade. +SELECT u.proname || '(' || u.args || ')' AS extra_function +FROM _upgraded_funcs u +LEFT JOIN _fresh_funcs f USING (proname, args) +WHERE f.proname IS NULL +ORDER BY 1; + extra_function +---------------- +(0 rows) + +-- Step 21: Function PROPERTY changes +-- (kind, volatility, strictness, return type, return-set, binding, +-- body/symbol). The probin/prosrc check catches: +-- * a C function whose symbol was renamed in the upgrade template +-- * a SQL/plpgsql function whose body was changed in either path +-- * a language change between the fresh and upgrade installs. +SELECT f.proname || '(' || f.args || ')' AS function_name, + CASE WHEN f.prokind <> u.prokind THEN 'prokind: ' || f.prokind || '->' || u.prokind END AS kind_change, + CASE WHEN f.provolatile <> u.provolatile THEN 'volatile: ' || f.provolatile || '->' || u.provolatile END AS volatility_change, + CASE WHEN f.proisstrict <> u.proisstrict THEN 'strict: ' || f.proisstrict || '->' || u.proisstrict END AS strict_change, + CASE WHEN f.rettype <> u.rettype THEN 'rettype: ' || f.rettype || '->' || u.rettype END AS rettype_change, + CASE WHEN f.proretset <> u.proretset THEN 'retset: ' || f.proretset || '->' || u.proretset END AS retset_change, + CASE WHEN f.probin <> u.probin THEN 'probin: ' || f.probin || '->' || u.probin END AS probin_change, + CASE WHEN f.prosrc <> u.prosrc THEN 'prosrc changed' END AS prosrc_change +FROM _fresh_funcs f +JOIN _upgraded_funcs u USING (proname, args) +WHERE f.provolatile <> u.provolatile + OR f.proisstrict <> u.proisstrict + OR f.prokind <> u.prokind + OR f.rettype <> u.rettype + OR f.proretset <> u.proretset + OR f.probin <> u.probin + OR f.prosrc <> u.prosrc +ORDER BY 1; + function_name | kind_change | volatility_change | strict_change | rettype_change | retset_change | probin_change | prosrc_change +---------------+-------------+-------------------+---------------+----------------+---------------+---------------+--------------- +(0 rows) + +-- Step 22: Relations MISSING after upgrade. +SELECT f.relname || ' (' || f.relkind || ')' AS missing_relation +FROM _fresh_rels f +LEFT JOIN _upgraded_rels u USING (relname, relkind) +WHERE u.relname IS NULL +ORDER BY 1; + missing_relation +------------------ +(0 rows) + +-- Step 23: Relations EXTRA after upgrade. +SELECT u.relname || ' (' || u.relkind || ')' AS extra_relation +FROM _upgraded_rels u +LEFT JOIN _fresh_rels f USING (relname, relkind) +WHERE f.relname IS NULL +ORDER BY 1; + extra_relation +---------------- +(0 rows) + +-- Step 24: Types MISSING after upgrade. +SELECT f.typname || ' (' || f.typtype || ')' AS missing_type +FROM _fresh_types f +LEFT JOIN _upgraded_types u USING (typname, typtype) +WHERE u.typname IS NULL +ORDER BY 1; + missing_type +-------------- +(0 rows) + +-- Step 25: Types EXTRA after upgrade. +SELECT u.typname || ' (' || u.typtype || ')' AS extra_type +FROM _upgraded_types u +LEFT JOIN _fresh_types f USING (typname, typtype) +WHERE f.typname IS NULL +ORDER BY 1; + extra_type +------------ +(0 rows) + +-- Step 26: Operators MISSING after upgrade. +SELECT f.oprname || ' (' || f.lefttype || ', ' || f.righttype || ')' AS missing_operator +FROM _fresh_ops f +LEFT JOIN _upgraded_ops u USING (oprname, lefttype, righttype) +WHERE u.oprname IS NULL +ORDER BY 1; + missing_operator +------------------ +(0 rows) + +-- Step 27: Operators EXTRA after upgrade. +SELECT u.oprname || ' (' || u.lefttype || ', ' || u.righttype || ')' AS extra_operator +FROM _upgraded_ops u +LEFT JOIN _fresh_ops f USING (oprname, lefttype, righttype) +WHERE f.oprname IS NULL +ORDER BY 1; + extra_operator +---------------- +(0 rows) + +-- Step 28: Casts MISSING after upgrade. +SELECT f.source_type || ' -> ' || f.target_type || ' (' || f.castcontext || ')' AS missing_cast +FROM _fresh_casts f +LEFT JOIN _upgraded_casts u USING (source_type, target_type, castcontext) +WHERE u.source_type IS NULL +ORDER BY 1; + missing_cast +-------------- +(0 rows) + +-- Step 29: Casts EXTRA after upgrade. +SELECT u.source_type || ' -> ' || u.target_type || ' (' || u.castcontext || ')' AS extra_cast +FROM _upgraded_casts u +LEFT JOIN _fresh_casts f USING (source_type, target_type, castcontext) +WHERE f.source_type IS NULL +ORDER BY 1; + extra_cast +------------ +(0 rows) + +-- Step 30: Operator classes MISSING after upgrade. +SELECT f.opcname || ' (' || f.amname || ')' AS missing_opclass +FROM _fresh_opclass f +LEFT JOIN _upgraded_opclass u USING (opcname, amname) +WHERE u.opcname IS NULL +ORDER BY 1; + missing_opclass +----------------- +(0 rows) + +-- Step 31: Operator classes EXTRA after upgrade. +SELECT u.opcname || ' (' || u.amname || ')' AS extra_opclass +FROM _upgraded_opclass u +LEFT JOIN _fresh_opclass f USING (opcname, amname) +WHERE f.opcname IS NULL +ORDER BY 1; + extra_opclass +--------------- +(0 rows) + +-- Step 32: Constraints MISSING after upgrade. +SELECT f.conname || ' (' || f.contype || ' on ' || f.table_name || ')' AS missing_constraint +FROM _fresh_constraints f +LEFT JOIN _upgraded_constraints u USING (conname, contype, table_name) +WHERE u.conname IS NULL +ORDER BY 1; + missing_constraint +-------------------- +(0 rows) + +-- Step 33: Constraints EXTRA after upgrade. +SELECT u.conname || ' (' || u.contype || ' on ' || u.table_name || ')' AS extra_constraint +FROM _upgraded_constraints u +LEFT JOIN _fresh_constraints f USING (conname, contype, table_name) +WHERE f.conname IS NULL +ORDER BY 1; + extra_constraint +------------------ +(0 rows) + +-- Step 33a: Extension members MISSING after upgrade +-- (object exists in pg_proc/pg_class/etc. but is not linked to the AGE +-- extension via pg_depend, i.e. ALTER EXTENSION age ADD ... was forgotten). +SELECT f.member AS missing_extension_member +FROM _fresh_extmembers f +LEFT JOIN _upgraded_extmembers u USING (member) +WHERE u.member IS NULL +ORDER BY 1; + missing_extension_member +-------------------------- +(0 rows) + +-- Step 33b: Extension members EXTRA after upgrade. +SELECT u.member AS extra_extension_member +FROM _upgraded_extmembers u +LEFT JOIN _fresh_extmembers f USING (member) +WHERE f.member IS NULL +ORDER BY 1; + extra_extension_member +------------------------ +(0 rows) + +-- ===================================================================== +-- SUMMARY (Step 34) +-- ===================================================================== +-- Step 34: Verify all counts match (result: single row, all true). +SELECT + (SELECT count(*) FROM _fresh_funcs) = (SELECT count(*) FROM _upgraded_funcs) AS funcs_match, + (SELECT count(*) FROM _fresh_rels) = (SELECT count(*) FROM _upgraded_rels) AS rels_match, + (SELECT count(*) FROM _fresh_types) = (SELECT count(*) FROM _upgraded_types) AS types_match, + (SELECT count(*) FROM _fresh_ops) = (SELECT count(*) FROM _upgraded_ops) AS ops_match, + (SELECT count(*) FROM _fresh_casts) = (SELECT count(*) FROM _upgraded_casts) AS casts_match, + (SELECT count(*) FROM _fresh_opclass) = (SELECT count(*) FROM _upgraded_opclass) AS opclass_match, + (SELECT count(*) FROM _fresh_constraints) = (SELECT count(*) FROM _upgraded_constraints) AS constraints_match, + (SELECT count(*) FROM _fresh_extmembers) = (SELECT count(*) FROM _upgraded_extmembers) AS extmembers_match; + funcs_match | rels_match | types_match | ops_match | casts_match | opclass_match | constraints_match | extmembers_match +-------------+------------+-------------+-----------+-------------+---------------+-------------------+------------------ + t | t | t | t | t | t | t | t +(1 row) + +-- ===================================================================== +-- CLEANUP (Steps 35-36) +-- ===================================================================== +-- Step 35: Drop temp tables, restore AGE at default version. +DROP TABLE _fresh_funcs, _upgraded_funcs, _fresh_rels, _upgraded_rels, + _fresh_types, _upgraded_types, _fresh_ops, _upgraded_ops, + _fresh_casts, _upgraded_casts, _fresh_opclass, _upgraded_opclass, + _fresh_constraints, _upgraded_constraints, + _fresh_extmembers, _upgraded_extmembers; +DROP EXTENSION age; +CREATE EXTENSION age; +-- Step 36: Remove synthetic upgrade test files from the extension directory. +\! sh ./regress/age_upgrade_cleanup.sh diff --git a/regress/expected/agehash.out b/regress/expected/agehash.out new file mode 100644 index 000000000..a6e647978 --- /dev/null +++ b/regress/expected/agehash.out @@ -0,0 +1,14 @@ +/* + * agehash internal selftest. + * + * Exercises the Robin Hood open-addressing hashtable used by AGE's hot-path + * caches at boundary sizes (1, 7, 8, 9, 63, 64, 65, 1024, ...) and across + * grow thresholds. A success returns the literal "OK"; any failure returns + * "FAIL: " describing the offending case. + */ +SELECT ag_catalog._agehash_self_test(); + _agehash_self_test +-------------------- + OK +(1 row) + diff --git a/regress/expected/agtype.out b/regress/expected/agtype.out index 065f357f1..0f4775b88 100644 --- a/regress/expected/agtype.out +++ b/regress/expected/agtype.out @@ -704,6 +704,48 @@ SELECT '3.14::numeric'::agtype + '3.14::numeric'::agtype; 6.28::numeric (1 row) +SELECT 'null'::agtype - '1'; + ?column? +---------- + +(1 row) + +SELECT 'null'::agtype + '1'; + ?column? +---------- + +(1 row) + +SELECT 'null'::agtype * '1'; + ?column? +---------- + +(1 row) + +SELECT 'null'::agtype / '1'; + ?column? +---------- + +(1 row) + +SELECT 'null'::agtype % '1'; + ?column? +---------- + +(1 row) + +SELECT 'null'::agtype ^ '1'; + ?column? +---------- + +(1 row) + +SELECT -'null'::agtype; + ?column? +---------- + +(1 row) + -- -- Test operator - for extended functionality -- @@ -1065,8 +1107,6 @@ SELECT '{"a":1 , "b":2, "c":3}'::agtype - 'null'; ERROR: expected agtype string, not agtype NULL SELECT '{"a":1 , "b":2, "c":3}'::agtype - '["c","b"]' - '[1]' - '["a"]'; ERROR: expected agtype string, not agtype integer -SELECT 'null'::agtype - '1'; -ERROR: Invalid input parameter types for agtype_sub SELECT 'null'::agtype - '[1]'; ERROR: must be object or array, not a scalar value SELECT '{"id": 1125899906842625, "label": "Vertex", "properties": {"a": "xyz", "b": true, "c": -19.888, "e": {"f": "abcdef", "g": {}, "h": [[], {}]}, "i": {"j": 199, "k": {"l": "mnopq"}}}}::vertex'::agtype - '"a"'; @@ -2167,8 +2207,8 @@ SELECT * FROM cypher('orderability_graph', $$ MATCH (n) RETURN n ORDER BY n.prop {"id": 844424930131981, "label": "vertex", "properties": {"prop": [{"id": 0, "label": "v", "properties": {"i": 0}}::vertex, {"id": 2, "label": "e", "end_id": 1, "start_id": 0, "properties": {"i": 0}}::edge, {"id": 1, "label": "v", "properties": {"i": 0}}::vertex]::path}}::vertex {"id": 844424930131980, "label": "vertex", "properties": {"prop": {"id": 2, "label": "e", "end_id": 1, "start_id": 0, "properties": {"i": 0}}::edge}}::vertex {"id": 844424930131979, "label": "vertex", "properties": {"prop": {"id": 0, "label": "v", "properties": {"i": 0}}::vertex}}::vertex - {"id": 844424930131978, "label": "vertex", "properties": {"prop": {"bool": true}}}::vertex {"id": 844424930131977, "label": "vertex", "properties": {"prop": {"i": 0, "bool": true}}}::vertex + {"id": 844424930131978, "label": "vertex", "properties": {"prop": {"i": null, "bool": true}}}::vertex {"id": 844424930131975, "label": "vertex", "properties": {"prop": [1, 2, 3]}}::vertex {"id": 844424930131976, "label": "vertex", "properties": {"prop": [1, 2, 3, 4, 5]}}::vertex {"id": 844424930131973, "label": "vertex", "properties": {"prop": "string"}}::vertex @@ -2190,8 +2230,8 @@ SELECT * FROM cypher('orderability_graph', $$ MATCH (n) RETURN n ORDER BY n.prop {"id": 844424930131973, "label": "vertex", "properties": {"prop": "string"}}::vertex {"id": 844424930131976, "label": "vertex", "properties": {"prop": [1, 2, 3, 4, 5]}}::vertex {"id": 844424930131975, "label": "vertex", "properties": {"prop": [1, 2, 3]}}::vertex + {"id": 844424930131978, "label": "vertex", "properties": {"prop": {"i": null, "bool": true}}}::vertex {"id": 844424930131977, "label": "vertex", "properties": {"prop": {"i": 0, "bool": true}}}::vertex - {"id": 844424930131978, "label": "vertex", "properties": {"prop": {"bool": true}}}::vertex {"id": 844424930131979, "label": "vertex", "properties": {"prop": {"id": 0, "label": "v", "properties": {"i": 0}}::vertex}}::vertex {"id": 844424930131980, "label": "vertex", "properties": {"prop": {"id": 2, "label": "e", "end_id": 1, "start_id": 0, "properties": {"i": 0}}::edge}}::vertex {"id": 844424930131981, "label": "vertex", "properties": {"prop": [{"id": 0, "label": "v", "properties": {"i": 0}}::vertex, {"id": 2, "label": "e", "end_id": 1, "start_id": 0, "properties": {"i": 0}}::edge, {"id": 1, "label": "v", "properties": {"i": 0}}::vertex]::path}}::vertex @@ -3251,39 +3291,39 @@ NOTICE: graph "agtype_null_duplicate_test" has been dropped -- Vertex -- --Basic Vertex Creation -SELECT _agtype_build_vertex('1'::graphid, $$label_name$$, agtype_build_map()); +SELECT _agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map()); _agtype_build_vertex ------------------------------------------------------------ {"id": 1, "label": "label_name", "properties": {}}::vertex (1 row) -SELECT _agtype_build_vertex('1'::graphid, $$label$$, agtype_build_map('id', 2)); +SELECT _agtype_build_vertex('1'::graphid, '"label"', agtype_build_map('id', 2)); _agtype_build_vertex -------------------------------------------------------------- {"id": 1, "label": "label", "properties": {"id": 2}}::vertex (1 row) --Null properties -SELECT _agtype_build_vertex('1'::graphid, $$label_name$$, NULL); +SELECT _agtype_build_vertex('1'::graphid, '"label_name"', NULL); _agtype_build_vertex ------------------------------------------------------------ {"id": 1, "label": "label_name", "properties": {}}::vertex (1 row) --Test access operator -SELECT agtype_access_operator(_agtype_build_vertex('1'::graphid, $$label$$, +SELECT agtype_access_operator(_agtype_build_vertex('1'::graphid, '"label"', agtype_build_map('id', 2)), '"id"'); agtype_access_operator ------------------------ 2 (1 row) -SELECT _agtype_build_vertex('1'::graphid, $$label$$, agtype_build_list()); +SELECT _agtype_build_vertex('1'::graphid, '"label"', agtype_build_list()); ERROR: _agtype_build_vertex() properties argument must be an object --Vertex in a map SELECT agtype_build_map( 'vertex', - _agtype_build_vertex('1'::graphid, $$label_name$$, agtype_build_map())); + _agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map())); agtype_build_map ------------------------------------------------------------------------ {"vertex": {"id": 1, "label": "label_name", "properties": {}}::vertex} @@ -3291,9 +3331,9 @@ SELECT agtype_build_map( SELECT agtype_access_operator( agtype_build_map( - 'vertex', _agtype_build_vertex('1'::graphid, $$label_name$$, + 'vertex', _agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map('key', 'value')), - 'other_vertex', _agtype_build_vertex('1'::graphid, $$label_name$$, + 'other_vertex', _agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map('key', 'other_value'))), '"vertex"'); agtype_access_operator @@ -3303,8 +3343,8 @@ SELECT agtype_access_operator( --Vertex in a list SELECT agtype_build_list( - _agtype_build_vertex('1'::graphid, $$label_name$$, agtype_build_map()), - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map())); + _agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map())); agtype_build_list -------------------------------------------------------------------------------------------------------------------------- [{"id": 1, "label": "label_name", "properties": {}}::vertex, {"id": 2, "label": "label_name", "properties": {}}::vertex] @@ -3312,9 +3352,9 @@ SELECT agtype_build_list( SELECT agtype_access_operator( agtype_build_list( - _agtype_build_vertex('1'::graphid, $$label_name$$, + _agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map('id', 3)), - _agtype_build_vertex('2'::graphid, $$label_name$$, + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map('id', 4))), '0'); agtype_access_operator ------------------------------------------------------------------- @@ -3326,14 +3366,14 @@ SELECT agtype_access_operator( -- --Basic Edge Creation SELECT _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map()); + '"label_name"', agtype_build_map()); _agtype_build_edge -------------------------------------------------------------------------------------- {"id": 1, "label": "label_name", "end_id": 3, "start_id": 2, "properties": {}}::edge (1 row) SELECT _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)); + '"label"', agtype_build_map('id', 2)); _agtype_build_edge ---------------------------------------------------------------------------------------- {"id": 1, "label": "label", "end_id": 3, "start_id": 2, "properties": {"id": 2}}::edge @@ -3341,7 +3381,7 @@ SELECT _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, --Null properties SELECT _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, NULL); + '"label_name"', NULL); _agtype_build_edge -------------------------------------------------------------------------------------- {"id": 1, "label": "label_name", "end_id": 3, "start_id": 2, "properties": {}}::edge @@ -3349,7 +3389,7 @@ SELECT _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, --Test access operator SELECT agtype_access_operator(_agtype_build_edge('1'::graphid, '2'::graphid, - '3'::graphid, $$label$$, agtype_build_map('id', 2)),'"id"'); + '3'::graphid, '"label"', agtype_build_map('id', 2)),'"id"'); agtype_access_operator ------------------------ 2 @@ -3359,7 +3399,7 @@ SELECT agtype_access_operator(_agtype_build_edge('1'::graphid, '2'::graphid, SELECT agtype_build_map( 'edge', _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map())); + '"label_name"', agtype_build_map())); agtype_build_map ------------------------------------------------------------------------------------------------ {"edge": {"id": 1, "label": "label_name", "end_id": 3, "start_id": 2, "properties": {}}::edge} @@ -3368,9 +3408,9 @@ SELECT agtype_build_map( SELECT agtype_access_operator( agtype_build_map( 'edge', _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map('key', 'value')), + '"label_name"', agtype_build_map('key', 'value')), 'other_edge', _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map('key', 'other_value'))), + '"label_name"', agtype_build_map('key', 'other_value'))), '"edge"'); agtype_access_operator ---------------------------------------------------------------------------------------------------- @@ -3380,9 +3420,9 @@ SELECT agtype_access_operator( --Edge in a list SELECT agtype_build_list( _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map()), + '"label_name"', agtype_build_map()), _agtype_build_edge('2'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map())); + '"label_name"', agtype_build_map())); agtype_build_list ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ [{"id": 1, "label": "label_name", "end_id": 3, "start_id": 2, "properties": {}}::edge, {"id": 2, "label": "label_name", "end_id": 3, "start_id": 2, "properties": {}}::edge] @@ -3390,9 +3430,9 @@ SELECT agtype_build_list( SELECT agtype_access_operator( agtype_build_list( - _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, $$label_name$$, + _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, '"label_name"', agtype_build_map('id', 3)), - _agtype_build_edge('2'::graphid, '2'::graphid, '3'::graphid, $$label_name$$, + _agtype_build_edge('2'::graphid, '2'::graphid, '3'::graphid, '"label_name"', agtype_build_map('id', 4))), '0'); agtype_access_operator --------------------------------------------------------------------------------------------- @@ -3401,10 +3441,10 @@ SELECT agtype_access_operator( -- Path SELECT _agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)), - _agtype_build_vertex('3'::graphid, $$label_name$$, agtype_build_map()) + '"label"', agtype_build_map('id', 2)), + _agtype_build_vertex('3'::graphid, '"label_name"', agtype_build_map()) ); _agtype_build_path ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ @@ -3413,78 +3453,78 @@ SELECT _agtype_build_path( --All these paths should produce Errors SELECT _agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)) + '"label"', agtype_build_map('id', 2)) ); ERROR: a path is of the form: [vertex, (edge, vertex)*i] where i >= 0 SELECT _agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)), - _agtype_build_vertex('3'::graphid, $$label_name$$, agtype_build_map()), + '"label"', agtype_build_map('id', 2)), + _agtype_build_vertex('3'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '4'::graphid, '5'::graphid, - $$label$$, agtype_build_map('id', 2)) + '"label"', agtype_build_map('id', 2)) ); ERROR: a path is of the form: [vertex, (edge, vertex)*i] where i >= 0 SELECT _agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)), + '"label"', agtype_build_map('id', 2)), NULL ); ERROR: argument 3 must not be null SELECT _agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)), + '"label"', agtype_build_map('id', 2)), 1 ); ERROR: argument 3 must be an agtype SELECT _agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)), + '"label"', agtype_build_map('id', 2)), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)) + '"label"', agtype_build_map('id', 2)) ); ERROR: paths consist of alternating vertices and edges HINT: argument 3 must be an vertex -- -- id, startid, endid -- -SELECT age_id(_agtype_build_vertex('1'::graphid, $$label_name$$, agtype_build_map())); +SELECT age_id(_agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map())); age_id -------- 1 (1 row) SELECT age_id(_agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map('id', 2))); + '"label_name"', agtype_build_map('id', 2))); age_id -------- 1 (1 row) SELECT age_start_id(_agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map('id', 2))); + '"label_name"', agtype_build_map('id', 2))); age_start_id -------------- 2 (1 row) SELECT age_end_id(_agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map('id', 2))); + '"label_name"', agtype_build_map('id', 2))); age_end_id ------------ 3 (1 row) SELECT age_id(_agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)), - _agtype_build_vertex('3'::graphid, $$label$$, agtype_build_map('id', 2)) + '"label"', agtype_build_map('id', 2)), + _agtype_build_vertex('3'::graphid, '"label"', agtype_build_map('id', 2)) )); ERROR: id() argument must be a vertex, an edge or null SELECT age_id(agtype_in('1')); diff --git a/regress/expected/agtype_jsonb_cast.out b/regress/expected/agtype_jsonb_cast.out new file mode 100644 index 000000000..49bf1f887 --- /dev/null +++ b/regress/expected/agtype_jsonb_cast.out @@ -0,0 +1,261 @@ +/* + * 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. + */ +LOAD 'age'; +SET search_path TO ag_catalog; +-- +-- agtype -> jsonb casts +-- +-- String scalar +SELECT '"hello"'::agtype::jsonb; + jsonb +--------- + "hello" +(1 row) + +-- Null +SELECT 'null'::agtype::jsonb; + jsonb +------- + null +(1 row) + +-- Array +SELECT '[1, "two", null]'::agtype::jsonb; + jsonb +------------------ + [1, "two", null] +(1 row) + +-- Nested array +SELECT '[[1, 2], [3, 4]]'::agtype::jsonb; + jsonb +------------------ + [[1, 2], [3, 4]] +(1 row) + +-- Empty array +SELECT '[]'::agtype::jsonb; + jsonb +------- + [] +(1 row) + +-- Object +SELECT '{"name": "Alice", "age": 30}'::agtype::jsonb; + jsonb +------------------------------ + {"age": 30, "name": "Alice"} +(1 row) + +-- Nested object +SELECT '{"a": {"b": {"c": 1}}}'::agtype::jsonb; + jsonb +------------------------ + {"a": {"b": {"c": 1}}} +(1 row) + +-- Object with array values +SELECT '{"tags": ["a", "b"], "count": 2}'::agtype::jsonb; + jsonb +---------------------------------- + {"tags": ["a", "b"], "count": 2} +(1 row) + +-- Empty object +SELECT '{}'::agtype::jsonb; + jsonb +------- + {} +(1 row) + +-- +-- jsonb -> agtype casts +-- +-- String scalar +SELECT '"hello"'::jsonb::agtype; + agtype +--------- + "hello" +(1 row) + +-- Numeric scalar +SELECT '42'::jsonb::agtype; + agtype +-------- + 42 +(1 row) + +-- Float scalar +SELECT '3.14'::jsonb::agtype; + agtype +-------- + 3.14 +(1 row) + +-- Boolean +SELECT 'true'::jsonb::agtype; + agtype +-------- + true +(1 row) + +-- Null +SELECT 'null'::jsonb::agtype; + agtype +-------- + null +(1 row) + +-- Array +SELECT '[1, "two", null]'::jsonb::agtype; + agtype +------------------ + [1, "two", null] +(1 row) + +-- Nested array +SELECT '[[1, 2], [3, 4]]'::jsonb::agtype; + agtype +------------------ + [[1, 2], [3, 4]] +(1 row) + +-- Empty array +SELECT '[]'::jsonb::agtype; + agtype +-------- + [] +(1 row) + +-- Object +SELECT '{"name": "Alice", "age": 30}'::jsonb::agtype; + agtype +------------------------------ + {"age": 30, "name": "Alice"} +(1 row) + +-- Nested object +SELECT '{"a": {"b": {"c": 1}}}'::jsonb::agtype; + agtype +------------------------ + {"a": {"b": {"c": 1}}} +(1 row) + +-- Empty object +SELECT '{}'::jsonb::agtype; + agtype +-------- + {} +(1 row) + +-- +-- Roundtrip: jsonb -> agtype -> jsonb +-- +SELECT ('{"key": "value"}'::jsonb::agtype)::jsonb; + jsonb +------------------ + {"key": "value"} +(1 row) + +SELECT ('[1, 2, 3]'::jsonb::agtype)::jsonb; + jsonb +----------- + [1, 2, 3] +(1 row) + +SELECT ('null'::jsonb::agtype)::jsonb; + jsonb +------- + null +(1 row) + +-- +-- Graph data -> jsonb (vertex and edge) +-- +SELECT create_graph('agtype_jsonb_test'); +NOTICE: graph "agtype_jsonb_test" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('agtype_jsonb_test', $$ + CREATE (a:Person {name: 'Alice', age: 30})-[:KNOWS {since: 2020}]->(b:Person {name: 'Bob', age: 25}) + RETURN a, b +$$) AS (a agtype, b agtype); + a | b +------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "Person", "properties": {"age": 30, "name": "Alice"}}::vertex | {"id": 844424930131970, "label": "Person", "properties": {"age": 25, "name": "Bob"}}::vertex +(1 row) + +-- Vertex to jsonb: check structure +SELECT v::jsonb ? 'label' AS has_label, + v::jsonb ? 'properties' AS has_properties, + (v::jsonb -> 'properties' ->> 'name') AS name +FROM cypher('agtype_jsonb_test', $$ + MATCH (n:Person) RETURN n ORDER BY n.name +$$) AS (v agtype); + has_label | has_properties | name +-----------+----------------+------- + t | t | Alice + t | t | Bob +(2 rows) + +-- Edge to jsonb: check structure +SELECT e::jsonb ? 'label' AS has_label, + (e::jsonb ->> 'label') AS label, + (e::jsonb -> 'properties' ->> 'since') AS since +FROM cypher('agtype_jsonb_test', $$ + MATCH ()-[r:KNOWS]->() RETURN r +$$) AS (e agtype); + has_label | label | since +-----------+-------+------- + t | KNOWS | 2020 +(1 row) + +-- +-- NULL handling +-- +SELECT NULL::agtype::jsonb; + jsonb +------- + +(1 row) + +SELECT NULL::jsonb::agtype; + agtype +-------- + +(1 row) + +-- +-- Cleanup +-- +SELECT drop_graph('agtype_jsonb_test', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table agtype_jsonb_test._ag_label_vertex +drop cascades to table agtype_jsonb_test._ag_label_edge +drop cascades to table agtype_jsonb_test."Person" +drop cascades to table agtype_jsonb_test."KNOWS" +NOTICE: graph "agtype_jsonb_test" has been dropped + drop_graph +------------ + +(1 row) + diff --git a/regress/expected/containment_selectivity.out b/regress/expected/containment_selectivity.out new file mode 100644 index 000000000..a0626d554 --- /dev/null +++ b/regress/expected/containment_selectivity.out @@ -0,0 +1,245 @@ +/* + * 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. + */ +/* + * Regression coverage for issue #2356: + * The containment (@>, <@, @>>, <<@) and key-existence (?, ?|, ?&) + * operators on agtype must be bound to the lightweight selectivity + * helpers contsel / contjoinsel during planning. Earlier PG14+ + * branches used matchingsel / matchingjoinsel, which caused planning + * to invoke agtype_contains() against pg_statistic MCVs and produced + * a 30%+ planning-time regression on point queries (severe TPS drop + * reported on the PG18 branch). + * + * This test pins the bindings by querying pg_operator directly. If + * someone re-introduces matchingsel here, the test diff is loud and + * precise. + */ +LOAD 'age'; +SET search_path TO ag_catalog; +-- Selectivity helpers for the four containment operators. +SELECT o.oprname, + pg_catalog.format_type(o.oprleft, NULL) AS lhs, + pg_catalog.format_type(o.oprright, NULL) AS rhs, + o.oprrest::text AS restrict_fn, + o.oprjoin::text AS join_fn +FROM pg_catalog.pg_operator o +JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace +WHERE n.nspname = 'ag_catalog' + AND o.oprname IN ('@>', '<@', '@>>', '<<@') +ORDER BY o.oprname, lhs, rhs; + oprname | lhs | rhs | restrict_fn | join_fn +---------+--------+--------+-------------+------------- + <<@ | agtype | agtype | contsel | contjoinsel + <@ | agtype | agtype | contsel | contjoinsel + @> | agtype | agtype | contsel | contjoinsel + @>> | agtype | agtype | contsel | contjoinsel +(4 rows) + +-- Selectivity helpers for all key-existence operator overloads +-- (right-hand side may be text, text[], or agtype). +SELECT o.oprname, + pg_catalog.format_type(o.oprleft, NULL) AS lhs, + pg_catalog.format_type(o.oprright, NULL) AS rhs, + o.oprrest::text AS restrict_fn, + o.oprjoin::text AS join_fn +FROM pg_catalog.pg_operator o +JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace +WHERE n.nspname = 'ag_catalog' + AND o.oprname IN ('?', '?|', '?&') +ORDER BY o.oprname, lhs, rhs; + oprname | lhs | rhs | restrict_fn | join_fn +---------+--------+--------+-------------+------------- + ? | agtype | agtype | contsel | contjoinsel + ? | agtype | text | contsel | contjoinsel + ?& | agtype | agtype | contsel | contjoinsel + ?& | agtype | text[] | contsel | contjoinsel + ?| | agtype | agtype | contsel | contjoinsel + ?| | agtype | text[] | contsel | contjoinsel +(6 rows) + +-- Scoped guard for issue #2356: assert that none of the specific containment +-- and key-existence operators on agtype are bound to matchingsel / +-- matchingjoinsel. We deliberately limit the check to these operator names +-- (rather than every operator in ag_catalog) so unrelated operators that +-- legitimately use matchingsel for their own semantics are not affected by +-- this regression test. +SELECT COUNT(*) AS leaked_matchingsel_bindings +FROM pg_catalog.pg_operator o +JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace +WHERE n.nspname = 'ag_catalog' + AND o.oprname IN ('@>', '<@', '@>>', '<<@', '?', '?|', '?&') + AND (o.oprrest::text = 'matchingsel' + OR o.oprjoin::text = 'matchingjoinsel'); + leaked_matchingsel_bindings +----------------------------- + 0 +(1 row) + +-- Smoke test: each operator still works functionally. Selectivity binding +-- only affects the planner; this guards against an inadvertent operator +-- removal as part of any future cleanup. +SELECT '{"a":1,"b":2}'::agtype @> '{"a":1}'::agtype AS contains_yes; + contains_yes +-------------- + t +(1 row) + +SELECT '{"a":1}'::agtype <@ '{"a":1,"b":2}'::agtype AS contained_yes; + contained_yes +--------------- + t +(1 row) + +SELECT '{"a":{"b":1}}'::agtype @>> '{"a":{"b":1}}'::agtype AS top_contains_yes; + top_contains_yes +------------------ + t +(1 row) + +SELECT '{"a":{"b":1}}'::agtype <<@ '{"a":{"b":1}}'::agtype AS top_contained_yes; + top_contained_yes +------------------- + t +(1 row) + +SELECT '{"a":1}'::agtype ? 'a'::text AS exists_text_yes; + exists_text_yes +----------------- + t +(1 row) + +SELECT '{"a":1}'::agtype ? '"a"'::agtype AS exists_agtype_yes; + exists_agtype_yes +------------------- + t +(1 row) + +SELECT '{"a":1,"b":2}'::agtype ?| ARRAY['a','c'] AS exists_any_text_yes; + exists_any_text_yes +--------------------- + t +(1 row) + +SELECT '{"a":1,"b":2}'::agtype ?| '["a","c"]'::agtype AS exists_any_agtype_yes; + exists_any_agtype_yes +----------------------- + t +(1 row) + +SELECT '{"a":1,"b":2}'::agtype ?& ARRAY['a','b'] AS exists_all_text_yes; + exists_all_text_yes +--------------------- + t +(1 row) + +SELECT '{"a":1,"b":2}'::agtype ?& '["a","b"]'::agtype AS exists_all_agtype_yes; + exists_all_agtype_yes +----------------------- + t +(1 row) + +-- Upgrade-path assertion for issue #2356. +-- +-- The checks above cover a FRESH install: contsel / contjoinsel come straight +-- from agtype_operators.sql and agtype_exists.sql. Existing installs instead +-- pick up the fix from the ALTER OPERATOR ... SET (RESTRICT, JOIN) block that +-- age--1.7.0--y.y.y.sql ships and "ALTER EXTENSION age UPDATE" replays. Nothing +-- above exercises that block, so a silent regression in it would go unnoticed. +-- +-- We replay the shipped ALTER OPERATOR statements directly rather than running +-- ALTER EXTENSION age UPDATE: the dev upgrade script targets the placeholder +-- version "y.y.y" and is not a stable version-chain target inside the +-- regression harness. The whole section runs in a transaction that is rolled +-- back, so it observes the flip without permanently mutating the operator +-- catalog (PostgreSQL DDL is transactional). +BEGIN; +-- Simulate a stale (pre-fix) install: force all ten overloads back onto +-- matchingsel / matchingjoinsel. +ALTER OPERATOR ag_catalog.@>(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.<@(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.@>>(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.<<@(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.?(agtype, text) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.?(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.?|(agtype, text[]) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.?|(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.?&(agtype, text[]) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.?&(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +-- Stale state: every overload now reports matchingsel / matchingjoinsel. +SELECT o.oprname, + pg_catalog.format_type(o.oprleft, NULL) AS lhs, + pg_catalog.format_type(o.oprright, NULL) AS rhs, + o.oprrest::text AS restrict_fn, + o.oprjoin::text AS join_fn +FROM pg_catalog.pg_operator o +JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace +WHERE n.nspname = 'ag_catalog' + AND o.oprname IN ('@>', '<@', '@>>', '<<@', '?', '?|', '?&') +ORDER BY o.oprname, lhs, rhs; + oprname | lhs | rhs | restrict_fn | join_fn +---------+--------+--------+-------------+----------------- + <<@ | agtype | agtype | matchingsel | matchingjoinsel + <@ | agtype | agtype | matchingsel | matchingjoinsel + ? | agtype | agtype | matchingsel | matchingjoinsel + ? | agtype | text | matchingsel | matchingjoinsel + ?& | agtype | agtype | matchingsel | matchingjoinsel + ?& | agtype | text[] | matchingsel | matchingjoinsel + ?| | agtype | agtype | matchingsel | matchingjoinsel + ?| | agtype | text[] | matchingsel | matchingjoinsel + @> | agtype | agtype | matchingsel | matchingjoinsel + @>> | agtype | agtype | matchingsel | matchingjoinsel +(10 rows) + +-- Replay the exact ALTER OPERATOR block shipped in age--1.7.0--y.y.y.sql. +ALTER OPERATOR ag_catalog.@>(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.<@(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.@>>(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.<<@(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?(agtype, text) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?|(agtype, text[]) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?|(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?&(agtype, text[]) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?&(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +-- After the upgrade replay every overload is back on contsel / contjoinsel. +SELECT o.oprname, + pg_catalog.format_type(o.oprleft, NULL) AS lhs, + pg_catalog.format_type(o.oprright, NULL) AS rhs, + o.oprrest::text AS restrict_fn, + o.oprjoin::text AS join_fn +FROM pg_catalog.pg_operator o +JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace +WHERE n.nspname = 'ag_catalog' + AND o.oprname IN ('@>', '<@', '@>>', '<<@', '?', '?|', '?&') +ORDER BY o.oprname, lhs, rhs; + oprname | lhs | rhs | restrict_fn | join_fn +---------+--------+--------+-------------+------------- + <<@ | agtype | agtype | contsel | contjoinsel + <@ | agtype | agtype | contsel | contjoinsel + ? | agtype | agtype | contsel | contjoinsel + ? | agtype | text | contsel | contjoinsel + ?& | agtype | agtype | contsel | contjoinsel + ?& | agtype | text[] | contsel | contjoinsel + ?| | agtype | agtype | contsel | contjoinsel + ?| | agtype | text[] | contsel | contjoinsel + @> | agtype | agtype | contsel | contjoinsel + @>> | agtype | agtype | contsel | contjoinsel +(10 rows) + +ROLLBACK; diff --git a/regress/expected/cypher_match.out b/regress/expected/cypher_match.out index ea425e463..ab51486b3 100644 --- a/regress/expected/cypher_match.out +++ b/regress/expected/cypher_match.out @@ -710,10 +710,10 @@ $$) AS (r0 agtype); --------------------------------------------------------------------------------------------------------------------------- {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge - {"id": 1970324836974594, "label": "e2", "end_id": 1688849860263937, "start_id": 1688849860263938, "properties": {}}::edge {"id": 1970324836974593, "label": "e2", "end_id": 1688849860263939, "start_id": 1688849860263938, "properties": {}}::edge - {"id": 2533274790395905, "label": "e3", "end_id": 2251799813685250, "start_id": 2251799813685251, "properties": {}}::edge + {"id": 1970324836974594, "label": "e2", "end_id": 1688849860263937, "start_id": 1688849860263938, "properties": {}}::edge {"id": 2533274790395906, "label": "e3", "end_id": 2251799813685250, "start_id": 2251799813685249, "properties": {}}::edge + {"id": 2533274790395905, "label": "e3", "end_id": 2251799813685250, "start_id": 2251799813685251, "properties": {}}::edge (6 rows) SELECT * FROM cypher('cypher_match', $$ @@ -730,8 +730,8 @@ SELECT * FROM cypher('cypher_match', $$ $$) AS (r0 agtype); r0 --------------------------------------------------------------------------------------------------------------------------- - {"id": 1970324836974594, "label": "e2", "end_id": 1688849860263937, "start_id": 1688849860263938, "properties": {}}::edge {"id": 1970324836974593, "label": "e2", "end_id": 1688849860263939, "start_id": 1688849860263938, "properties": {}}::edge + {"id": 1970324836974594, "label": "e2", "end_id": 1688849860263937, "start_id": 1688849860263938, "properties": {}}::edge (2 rows) SELECT * FROM cypher('cypher_match', $$ @@ -2404,21 +2404,21 @@ SELECT * FROM cypher('cypher_match', $$ MATCH (a {name:a.name}) MATCH (a {age:a. {"id": 281474976710659, "label": "", "properties": {"age": 3, "name": "orphan"}}::vertex (3 rows) -SELECT * FROM cypher('cypher_match', $$ MATCH p=(a)-[u {relationship: u.relationship}]->(b) RETURN p $$) as (a agtype); +SELECT * FROM cypher('cypher_match', $$ MATCH p=(a)-[u {relationship: u.relationship}]->(b) RETURN p ORDER BY id(u) $$) as (a agtype); a ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 281474976710661, "label": "", "properties": {"age": 4, "name": "T"}}::vertex, {"id": 4785074604081153, "label": "knows", "end_id": 281474976710666, "start_id": 281474976710661, "properties": {"years": 3, "relationship": "friends"}}::edge, {"id": 281474976710666, "label": "", "properties": {"age": 6}}::vertex]::path [{"id": 281474976710659, "label": "", "properties": {"age": 3, "name": "orphan"}}::vertex, {"id": 4785074604081154, "label": "knows", "end_id": 281474976710666, "start_id": 281474976710659, "properties": {"years": 4, "relationship": "enemies"}}::edge, {"id": 281474976710666, "label": "", "properties": {"age": 6}}::vertex]::path (2 rows) -SELECT * FROM cypher('cypher_match', $$ MATCH p=(a)-[u {relationship: u.relationship, years: u.years}]->(b) RETURN p $$) as (a agtype); +SELECT * FROM cypher('cypher_match', $$ MATCH p=(a)-[u {relationship: u.relationship, years: u.years}]->(b) RETURN p ORDER BY id(u) $$) as (a agtype); a ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 281474976710661, "label": "", "properties": {"age": 4, "name": "T"}}::vertex, {"id": 4785074604081153, "label": "knows", "end_id": 281474976710666, "start_id": 281474976710661, "properties": {"years": 3, "relationship": "friends"}}::edge, {"id": 281474976710666, "label": "", "properties": {"age": 6}}::vertex]::path [{"id": 281474976710659, "label": "", "properties": {"age": 3, "name": "orphan"}}::vertex, {"id": 4785074604081154, "label": "knows", "end_id": 281474976710666, "start_id": 281474976710659, "properties": {"years": 4, "relationship": "enemies"}}::edge, {"id": 281474976710666, "label": "", "properties": {"age": 6}}::vertex]::path (2 rows) -SELECT * FROM cypher('cypher_match', $$ MATCH p=(a {name:a.name})-[u {relationship: u.relationship}]->(b {age:b.age}) RETURN p $$) as (a agtype); +SELECT * FROM cypher('cypher_match', $$ MATCH p=(a {name:a.name})-[u {relationship: u.relationship}]->(b {age:b.age}) RETURN p ORDER BY id(u) $$) as (a agtype); a ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 281474976710661, "label": "", "properties": {"age": 4, "name": "T"}}::vertex, {"id": 4785074604081153, "label": "knows", "end_id": 281474976710666, "start_id": 281474976710661, "properties": {"years": 3, "relationship": "friends"}}::edge, {"id": 281474976710666, "label": "", "properties": {"age": 6}}::vertex]::path @@ -2784,13 +2784,13 @@ SELECT * FROM cypher('cypher_match', $$ MATCH p=()-[*]->() RETURN length(p) $$) length -------- 1 - 2 1 + 2 1 1 2 - 1 2 + 1 (8 rows) SELECT * FROM cypher('cypher_match', $$ MATCH p=()-[*]->() WHERE length(p) > 1 RETURN length(p) $$) as (length agtype); @@ -2812,8 +2812,8 @@ SELECT * FROM cypher('cypher_match', $$ MATCH p=()-[*]->() WHERE size(nodes(p)) SELECT * FROM cypher('cypher_match', $$ MATCH (n {name:'Dave'}) MATCH p=()-[*]->() WHERE nodes(p)[0] = n RETURN length(p) $$) as (length agtype); length -------- - 1 2 + 1 (2 rows) SELECT * FROM cypher('cypher_match', $$ MATCH p1=(n {name:'Dave'})-[]->() MATCH p2=()-[*]->() WHERE p2=p1 RETURN p2=p1 $$) as (path agtype); @@ -3398,19 +3398,17 @@ SELECT count(*) FROM cypher('test_enable_containment', $$ MATCH p=(x:Customer)-[ (1 row) SELECT * FROM cypher('test_enable_containment', $$ EXPLAIN (costs off) MATCH (x:Customer)-[:bought ={store: 'Amazon', addr:{city: 'Vancouver', street: 30}}]->(y:Product) RETURN 0 $$) as (a agtype); - QUERY PLAN -------------------------------------------------------------------------------------------------------------------------------- - Hash Join - Hash Cond: (y.id = _age_default_alias_0.end_id) - -> Seq Scan on "Product" y - -> Hash - -> Hash Join - Hash Cond: (x.id = _age_default_alias_0.start_id) - -> Seq Scan on "Customer" x - -> Hash - -> Seq Scan on bought _age_default_alias_0 - Filter: (properties @>> '{"addr": {"city": "Vancouver", "street": 30}, "store": "Amazon"}'::agtype) -(10 rows) + QUERY PLAN +------------------------------------------------------------------------------------------------------------------- + Nested Loop + -> Nested Loop + -> Seq Scan on bought _age_default_alias_0 + Filter: (properties @>> '{"addr": {"city": "Vancouver", "street": 30}, "store": "Amazon"}'::agtype) + -> Index Only Scan using "Customer_pkey" on "Customer" x + Index Cond: (id = _age_default_alias_0.start_id) + -> Index Only Scan using "Product_pkey" on "Product" y + Index Cond: (id = _age_default_alias_0.end_id) +(8 rows) SELECT * FROM cypher('test_enable_containment', $$ EXPLAIN (costs off) MATCH (x:Customer ={school: { name: 'XYZ College',program: { major: 'Psyc', degree: 'BSc'} },phone: [ 123456789, 987654321, 456987123 ]}) RETURN 0 $$) as (a agtype); QUERY PLAN @@ -3533,6 +3531,431 @@ SELECT * FROM cypher('test_enable_containment', $$ EXPLAIN (costs off) MATCH (x: Filter: ((agtype_access_operator(VARIADIC ARRAY[properties, '"school"'::agtype]) = '{"name": "XYZ College", "program": {"major": "Psyc", "degree": "BSc"}}'::agtype) AND (agtype_access_operator(VARIADIC ARRAY[properties, '"phone"'::agtype]) = '[123456789, 987654321, 456987123]'::agtype)) (2 rows) +-- +-- issue 2308: MATCH after CREATE returns 0 rows +-- +-- When all MATCH variables are already bound from a preceding CREATE + WITH, +-- the MATCH filter quals must evaluate after CREATE, not before. +-- +SELECT create_graph('issue_2308'); +NOTICE: graph "issue_2308" has been created + create_graph +-------------- + +(1 row) + +-- Reporter's exact case: CREATE + WITH + MATCH + SET + RETURN +SELECT * FROM cypher('issue_2308', $$ + CREATE (a:TestB3)-[e:B3REL]->(b:TestB3) + WITH a, e, b + MATCH p = (a)-[e]->(b) + SET a.something = 'something' + RETURN a +$$) AS (a agtype); + a +---------------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "TestB3", "properties": {"something": "something"}}::vertex +(1 row) + +-- Bound variables, no SET +SELECT * FROM cypher('issue_2308', $$ + CREATE (a:T2)-[e:R2]->(b:T2) + WITH a, e, b + MATCH (a)-[e]->(b) + RETURN a, e, b +$$) AS (a agtype, e agtype, b agtype); + a | e | b +-------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------+------------------------------------------------------------------- + {"id": 1407374883553281, "label": "T2", "properties": {}}::vertex | {"id": 1688849860263937, "label": "R2", "end_id": 1407374883553282, "start_id": 1407374883553281, "properties": {}}::edge | {"id": 1407374883553282, "label": "T2", "properties": {}}::vertex +(1 row) + +-- Reversed direction: filter should reject (0 rows expected) +SELECT * FROM cypher('issue_2308', $$ + CREATE (a:T3)-[e:R3]->(b:T3) + WITH a, e, b + MATCH (b)-[e]->(a) + RETURN a +$$) AS (a agtype); + a +--- +(0 rows) + +-- Node-only MATCH with bound variable +SELECT * FROM cypher('issue_2308', $$ + CREATE (a:T4 {name: 'test'}) + WITH a + MATCH (a) + RETURN a +$$) AS (a agtype); + a +--------------------------------------------------------------------------------- + {"id": 2533274790395905, "label": "T4", "properties": {"name": "test"}}::vertex +(1 row) + +-- MATCH after SET (SET is also DML, chain must be protected) +SELECT * FROM cypher('issue_2308', $$ + CREATE (a:T5 {val: 1})-[e:R5]->(b:T5 {val: 2}) +$$) AS (r agtype); + r +--- +(0 rows) + +SELECT * FROM cypher('issue_2308', $$ + MATCH (a:T5)-[e:R5]->(b:T5) + SET a.val = 10 + WITH a, e, b + MATCH (a)-[e]->(b) + RETURN a.val +$$) AS (val agtype); + val +----- + 10 +(1 row) + +SELECT drop_graph('issue_2308', true); +NOTICE: drop cascades to 11 other objects +DETAIL: drop cascades to table issue_2308._ag_label_vertex +drop cascades to table issue_2308._ag_label_edge +drop cascades to table issue_2308."TestB3" +drop cascades to table issue_2308."B3REL" +drop cascades to table issue_2308."T2" +drop cascades to table issue_2308."R2" +drop cascades to table issue_2308."T3" +drop cascades to table issue_2308."R3" +drop cascades to table issue_2308."T4" +drop cascades to table issue_2308."T5" +drop cascades to table issue_2308."R5" +NOTICE: graph "issue_2308" has been dropped + drop_graph +------------ + +(1 row) + +-- Issue 1964 +-- +-- PREPARE with property parameter ($props) crashed the server when +-- age.enable_containment was set to off. The crash was in +-- transform_map_to_ind_recursive which blindly cast cypher_param +-- nodes to cypher_map, accessing invalid memory. +-- +SELECT create_graph('issue_1964'); +NOTICE: graph "issue_1964" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('issue_1964', $$ + CREATE (:Person {name: 'Alice', age: 30}), + (:Person {name: 'Bob', age: 25}) +$$) AS (result agtype); + result +-------- +(0 rows) + +SELECT * FROM cypher('issue_1964', $$ + CREATE (:Person {name: 'Alice'})-[:KNOWS {since: 2020}]->(:Person {name: 'Bob'}) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- Test PREPARE with enable_containment off (was crashing) +SET age.enable_containment = off; +PREPARE issue_1964_vertex(agtype) AS + SELECT * FROM cypher('issue_1964', + $$MATCH (n $props) RETURN n $$, $1) AS (p agtype); +EXECUTE issue_1964_vertex('{"props": {"name": "Alice"}}'); + p +------------------------------------------------------------------------------------------------ + {"id": 844424930131969, "label": "Person", "properties": {"age": 30, "name": "Alice"}}::vertex + {"id": 844424930131971, "label": "Person", "properties": {"name": "Alice"}}::vertex +(2 rows) + +EXECUTE issue_1964_vertex('{"props": {"age": 25}}'); + p +---------------------------------------------------------------------------------------------- + {"id": 844424930131970, "label": "Person", "properties": {"age": 25, "name": "Bob"}}::vertex +(1 row) + +DEALLOCATE issue_1964_vertex; +-- Test edge property parameter with enable_containment off +PREPARE issue_1964_edge(agtype) AS + SELECT * FROM cypher('issue_1964', + $$MATCH ()-[r $props]->() RETURN r $$, $1) AS (p agtype); +EXECUTE issue_1964_edge('{"props": {"since": 2020}}'); + p +----------------------------------------------------------------------------------------------------------------------------------------- + {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131972, "start_id": 844424930131971, "properties": {"since": 2020}}::edge +(1 row) + +DEALLOCATE issue_1964_edge; +-- Verify enable_containment on still works with PREPARE +SET age.enable_containment = on; +PREPARE issue_1964_vertex_on(agtype) AS + SELECT * FROM cypher('issue_1964', + $$MATCH (n $props) RETURN n $$, $1) AS (p agtype); +EXECUTE issue_1964_vertex_on('{"props": {"name": "Alice"}}'); + p +------------------------------------------------------------------------------------------------ + {"id": 844424930131969, "label": "Person", "properties": {"age": 30, "name": "Alice"}}::vertex + {"id": 844424930131971, "label": "Person", "properties": {"name": "Alice"}}::vertex +(2 rows) + +DEALLOCATE issue_1964_vertex_on; +-- Test =properties form with PREPARE (uses @>> top-level containment) +SET age.enable_containment = off; +PREPARE issue_1964_vertex_eq(agtype) AS + SELECT * FROM cypher('issue_1964', + $$MATCH (n = $props) RETURN n $$, $1) AS (p agtype); +EXECUTE issue_1964_vertex_eq('{"props": {"name": "Alice", "age": 25}}'); + p +--- +(0 rows) + +DEALLOCATE issue_1964_vertex_eq; +PREPARE issue_1964_edge_eq(agtype) AS + SELECT * FROM cypher('issue_1964', + $$MATCH ()-[r = $props]->() RETURN r $$, $1) AS (p agtype); +EXECUTE issue_1964_edge_eq('{"props": {"since": 2020}}'); + p +----------------------------------------------------------------------------------------------------------------------------------------- + {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131972, "start_id": 844424930131971, "properties": {"since": 2020}}::edge +(1 row) + +DEALLOCATE issue_1964_edge_eq; +-- Same with enable_containment on +SET age.enable_containment = on; +PREPARE issue_1964_vertex_eq_on(agtype) AS + SELECT * FROM cypher('issue_1964', + $$MATCH (n = $props) RETURN n $$, $1) AS (p agtype); +EXECUTE issue_1964_vertex_eq_on('{"props": {"name": "Alice", "age": 25}}'); + p +--- +(0 rows) + +DEALLOCATE issue_1964_vertex_eq_on; +-- +-- Issue 2193: CREATE ... WITH ... MATCH on brand-new label returns 0 rows +-- on first execution because match_check_valid_label() runs before +-- transform_prev_cypher_clause() creates the label table. +-- +SELECT create_graph('issue_2193'); +NOTICE: graph "issue_2193" has been created + create_graph +-------------- + +(1 row) + +-- Reporter's exact case: CREATE two Person nodes, then MATCH on Person +-- Should return 2 rows on the very first execution +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:Person {name: 'Jane', livesIn: 'London'}), + (b:Person {name: 'Tom', livesIn: 'Copenhagen'}) + WITH a, b + MATCH (p:Person) + RETURN p.name ORDER BY p.name +$$) AS (result agtype); + result +-------- + "Jane" + "Tom" +(2 rows) + +-- Single CREATE + MATCH on brand-new label +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:City {name: 'Berlin'}) + WITH a + MATCH (c:City) + RETURN c.name ORDER BY c.name +$$) AS (result agtype); + result +---------- + "Berlin" +(1 row) + +-- MATCH on a label that now exists (second execution) still works +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:City {name: 'Paris'}) + WITH a + MATCH (c:City) + RETURN c.name ORDER BY c.name +$$) AS (result agtype); + result +---------- + "Berlin" + "Paris" +(2 rows) + +-- MATCH on non-existent label without DML predecessor still returns 0 rows +SELECT * FROM cypher('issue_2193', $$ + MATCH (x:NonExistentLabel) + RETURN x +$$) AS (result agtype); + result +-------- +(0 rows) + +-- MATCH on non-existent label after DML predecessor still returns 0 rows +-- and MATCH-introduced variable (p) is properly registered +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:Person {name: 'Alice'}) + WITH a + MATCH (p:NonExistentLabel) + RETURN p +$$) AS (result agtype); + result +-------- +(0 rows) + +-- Verify that the CREATE side effect was preserved even though MATCH +-- returned 0 rows (guards against plan-elimination regressions where +-- a constant-false predicate causes PG to skip the DML predecessor) +SELECT * FROM cypher('issue_2193', $$ + MATCH (a:Person {name: 'Alice'}) + RETURN a.name +$$) AS (result agtype); + result +--------- + "Alice" +(1 row) + +SELECT drop_graph('issue_2193', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table issue_2193._ag_label_vertex +drop cascades to table issue_2193._ag_label_edge +drop cascades to table issue_2193."Person" +drop cascades to table issue_2193."City" +NOTICE: graph "issue_2193" has been dropped + drop_graph +------------ + +(1 row) + +-- +-- Issue 2378: OPTIONAL MATCH may incorrectly drop null-preserving outer +-- rows when its WHERE clause contains a correlated sub-pattern predicate. +-- +-- Cypher OPTIONAL MATCH semantics: the WHERE applies to the optional +-- binding; when no right-hand row survives the predicate, the outer row +-- is still emitted with NULLs in the optional columns. Before the fix, +-- a WHERE containing EXISTS { ... } or COUNT { ... } was attached as an +-- outer filter on the transformed subquery, so it ran after the LATERAL +-- LEFT JOIN produced null-preserving rows and then incorrectly dropped +-- them when the predicate evaluated NULL/false on the nulled side. +-- +SELECT create_graph('issue_2378'); +NOTICE: graph "issue_2378" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('issue_2378', $$ + CREATE (a:Person {name: 'Alice'}), + (b:Person {name: 'Bob'}), + (c:Person {name: 'Charlie'}), + (a)-[:KNOWS]->(b), + (a)-[:KNOWS]->(c) +$$) AS (v agtype); + v +--- +(0 rows) + +-- Correlated EXISTS referencing the optional variable (friend). +-- Neither Bob nor Charlie knows anyone, so for every outer p the +-- predicate fails on all optional matches; expect one row per person +-- with friend = NULL. +SELECT * FROM cypher('issue_2378', $$ + MATCH (p:Person) + OPTIONAL MATCH (p)-[:KNOWS]->(friend:Person) + WHERE EXISTS { (friend)-[:KNOWS]->(:Person) } + RETURN p.name AS name, friend.name AS friend + ORDER BY name +$$) AS (name agtype, friend agtype); + name | friend +-----------+-------- + "Alice" | + "Bob" | + "Charlie" | +(3 rows) + +-- Correlated EXISTS referencing the outer variable (p). +-- Alice knows someone so her optional matches pass; Bob and Charlie +-- don't, so they are emitted with NULL friend. +SELECT * FROM cypher('issue_2378', $$ + MATCH (p:Person) + OPTIONAL MATCH (p)-[:KNOWS]->(friend:Person) + WHERE EXISTS { (p)-[:KNOWS]->(:Person) } + RETURN p.name AS name, friend.name AS friend + ORDER BY name, friend +$$) AS (name agtype, friend agtype); + name | friend +-----------+----------- + "Alice" | "Bob" + "Alice" | "Charlie" + "Bob" | + "Charlie" | +(4 rows) + +-- Non-correlated EXISTS (was already working; kept as a regression guard). +SELECT * FROM cypher('issue_2378', $$ + MATCH (p:Person) + OPTIONAL MATCH (p)-[:KNOWS]->(friend:Person) + WHERE EXISTS { MATCH (x:Person) RETURN x } + RETURN p.name AS name, friend.name AS friend + ORDER BY name, friend +$$) AS (name agtype, friend agtype); + name | friend +-----------+----------- + "Alice" | "Bob" + "Alice" | "Charlie" + "Bob" | + "Charlie" | +(4 rows) + +-- Plain scalar predicate on the optional variable (was already working). +SELECT * FROM cypher('issue_2378', $$ + MATCH (p:Person) + OPTIONAL MATCH (p)-[:KNOWS]->(friend:Person) + WHERE friend.name = 'Bob' + RETURN p.name AS name, friend.name AS friend + ORDER BY name +$$) AS (name agtype, friend agtype); + name | friend +-----------+-------- + "Alice" | "Bob" + "Bob" | + "Charlie" | +(3 rows) + +-- Constant-false WHERE on the optional side (was already working). +SELECT * FROM cypher('issue_2378', $$ + MATCH (p:Person) + OPTIONAL MATCH (p)-[:KNOWS]->(friend:Person) + WHERE false + RETURN p.name AS name, friend.name AS friend + ORDER BY name +$$) AS (name agtype, friend agtype); + name | friend +-----------+-------- + "Alice" | + "Bob" | + "Charlie" | +(3 rows) + +SELECT drop_graph('issue_2378', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table issue_2378._ag_label_vertex +drop cascades to table issue_2378._ag_label_edge +drop cascades to table issue_2378."Person" +drop cascades to table issue_2378."KNOWS" +NOTICE: graph "issue_2378" has been dropped + drop_graph +------------ + +(1 row) + -- -- Clean up -- @@ -3621,6 +4044,18 @@ NOTICE: graph "issue_1393" has been dropped (1 row) +SELECT drop_graph('issue_1964', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table issue_1964._ag_label_vertex +drop cascades to table issue_1964._ag_label_edge +drop cascades to table issue_1964."Person" +drop cascades to table issue_1964."KNOWS" +NOTICE: graph "issue_1964" has been dropped + drop_graph +------------ + +(1 row) + -- -- End -- diff --git a/regress/expected/cypher_merge.out b/regress/expected/cypher_merge.out index 56a23f513..ac08a971a 100644 --- a/regress/expected/cypher_merge.out +++ b/regress/expected/cypher_merge.out @@ -1717,6 +1717,262 @@ SELECT * FROM cypher('issue_1907', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype {"id": 1125899906842626, "label": "RELATED_TO", "end_id": 281474976710660, "start_id": 281474976710659, "properties": {"property1": "something", "property2": "else"}}::edge (1 row) +-- +-- Fix issue 1446: First MERGE does not see the second MERGE's changes +-- +-- When chained MERGEs appear (MATCH ... MERGE ... MERGE ...), the first +-- (non-terminal) MERGE returned rows one at a time, so the second MERGE's +-- lateral join was materialized before the first finished all iterations. +-- This caused duplicate nodes. The fix makes non-terminal MERGE eager: +-- it processes ALL input rows before returning any. +-- +SELECT * FROM create_graph('issue_1446'); +NOTICE: graph "issue_1446" has been created + create_graph +-------------- + +(1 row) + +-- Reporter's exact setup: two initial nodes +SELECT * FROM cypher('issue_1446', $$ CREATE (:A), (:C) $$) AS (a agtype); + a +--- +(0 rows) + +-- Reporter's exact reproduction case: two chained MERGEs +-- Without fix: C is created multiple times (once per MATCH row) because the +-- second MERGE's lateral join materializes before the first MERGE finishes. +-- With fix: returns 2 rows, C is found and reused by the second MERGE. +SELECT * FROM cypher('issue_1446', $$ + MATCH (x) + MERGE (x)-[:r]->(:t) + MERGE (:C)-[:r]->(:t) + RETURN x +$$) AS (a agtype); + a +------------------------------------------------------------------ + {"id": 844424930131969, "label": "A", "properties": {}}::vertex + {"id": 1125899906842625, "label": "C", "properties": {}}::vertex +(2 rows) + +-- Verify: A(1), C(1), t(2) = 4 nodes, 2 edges +SELECT * FROM cypher('issue_1446', $$ + MATCH (n) + RETURN labels(n) AS label, count(*) AS cnt + ORDER BY label +$$) AS (label agtype, cnt agtype); + label | cnt +-------+----- + ["A"] | 1 + ["C"] | 1 + ["t"] | 2 +(3 rows) + +SELECT * FROM cypher('issue_1446', $$ + MATCH ()-[e]->() + RETURN count(*) AS edge_count +$$) AS (edge_count agtype); + edge_count +------------ + 2 +(1 row) + +-- Test with 3 initial nodes: ensures eager buffering works for larger sets +SELECT * FROM cypher('issue_1446', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); + a +--- +(0 rows) + +SELECT * FROM cypher('issue_1446', $$ CREATE (:X), (:Y), (:Z) $$) AS (a agtype); + a +--- +(0 rows) + +SELECT * FROM cypher('issue_1446', $$ + MATCH (n) + MERGE (n)-[:link]->(:shared) + MERGE (:hub)-[:link]->(:shared) + RETURN n +$$) AS (a agtype); + a +------------------------------------------------------------------ + {"id": 1970324836974593, "label": "X", "properties": {}}::vertex + {"id": 2251799813685249, "label": "Y", "properties": {}}::vertex + {"id": 2533274790395905, "label": "Z", "properties": {}}::vertex +(3 rows) + +-- Without fix: hub is created 3 times (once per MATCH row). +-- With fix: hub(1), shared(4), X(1), Y(1), Z(1) = 8 nodes, 4 edges +-- (3 n->shared edges + 1 hub->shared edge; hub reused for rows 2 & 3) +SELECT * FROM cypher('issue_1446', $$ + MATCH (n) + RETURN labels(n) AS label, count(*) AS cnt + ORDER BY label +$$) AS (label agtype, cnt agtype); + label | cnt +------------+----- + ["X"] | 1 + ["Y"] | 1 + ["Z"] | 1 + ["hub"] | 1 + ["shared"] | 4 +(5 rows) + +SELECT * FROM cypher('issue_1446', $$ + MATCH ()-[e]->() + RETURN count(*) AS edge_count +$$) AS (edge_count agtype); + edge_count +------------ + 4 +(1 row) + +-- Test chained MERGE with empty MATCH result (empty buffer scenario) +-- Without fix: non-terminal MERGE falls through to terminal logic, +-- incorrectly creating entities when MATCH returns 0 rows. +SELECT * FROM cypher('issue_1446', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); + a +--- +(0 rows) + +SELECT * FROM cypher('issue_1446', $$ + MATCH (x:NonExistent) + MERGE (x)-[:r]->(:t) + MERGE (:C)-[:r]->(:t) + RETURN count(*) AS cnt +$$) AS (cnt agtype); + cnt +----- + 0 +(1 row) + +-- Verify no nodes or edges were created +SELECT * FROM cypher('issue_1446', $$ + MATCH (n) + RETURN count(*) AS node_count +$$) AS (node_count agtype); + node_count +------------ + 0 +(1 row) + +SELECT * FROM cypher('issue_1446', $$ + MATCH ()-[e]->() + RETURN count(*) AS edge_count +$$) AS (edge_count agtype); + edge_count +------------ + 0 +(1 row) + +-- Issue 1954: CREATE + WITH + MERGE causes "vertex was deleted" error +-- when the number of input rows exceeds the snapshot's command ID window. +-- entity_exists() used a stale curcid, making recently-created vertices +-- invisible on later iterations. +-- +SELECT * FROM create_graph('issue_1954'); +NOTICE: graph "issue_1954" has been created + create_graph +-------------- + +(1 row) + +-- Setup: create source nodes and relationships (3 rows to trigger the bug) +SELECT * FROM cypher('issue_1954', $$ + CREATE (:A {name: 'a1'})-[:R]->(:B {name: 'b1'}), + (:A {name: 'a2'})-[:R]->(:B {name: 'b2'}), + (:A {name: 'a3'})-[:R]->(:B {name: 'b3'}) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- This query would fail with "vertex assigned to variable c was deleted" +-- on the 3rd row before the fix. +SELECT * FROM cypher('issue_1954', $$ + MATCH (a:A)-[:R]->(b:B) + CREATE (c:C {name: a.name + '|' + b.name}) + WITH a, b, c + MERGE (a)-[:LINK]->(c) + RETURN a.name, b.name, c.name + ORDER BY a.name +$$) AS (a agtype, b agtype, c agtype); + a | b | c +------+------+--------- + "a1" | "b1" | "a1|b1" + "a2" | "b2" | "a2|b2" + "a3" | "b3" | "a3|b3" +(3 rows) + +-- Verify edges were created +SELECT * FROM cypher('issue_1954', $$ + MATCH (a:A)-[:LINK]->(c:C) + RETURN a.name, c.name + ORDER BY a.name +$$) AS (a agtype, c agtype); + a | c +------+--------- + "a1" | "a1|b1" + "a2" | "a2|b2" + "a3" | "a3|b3" +(3 rows) + +-- Test with two MERGEs (more complex case from the original report) +SELECT * FROM cypher('issue_1954', $$ + MATCH ()-[e:LINK]->() DELETE e +$$) AS (result agtype); + result +-------- +(0 rows) + +SELECT * FROM cypher('issue_1954', $$ + MATCH (c:C) DELETE c +$$) AS (result agtype); + result +-------- +(0 rows) + +SELECT * FROM cypher('issue_1954', $$ + MATCH (a:A)-[:R]->(b:B) + CREATE (c:C {name: a.name + '|' + b.name}) + WITH a, b, c + MERGE (a)-[:LINK1]->(c) + MERGE (b)-[:LINK2]->(c) + RETURN a.name, b.name, c.name + ORDER BY a.name +$$) AS (a agtype, b agtype, c agtype); + a | b | c +------+------+--------- + "a1" | "b1" | "a1|b1" + "a2" | "b2" | "a2|b2" + "a3" | "b3" | "a3|b3" +(3 rows) + +-- Verify both sets of edges +SELECT * FROM cypher('issue_1954', $$ + MATCH (a:A)-[:LINK1]->(c:C) + RETURN a.name, c.name + ORDER BY a.name +$$) AS (a agtype, c agtype); + a | c +------+--------- + "a1" | "a1|b1" + "a2" | "a2|b2" + "a3" | "a3|b3" +(3 rows) + +SELECT * FROM cypher('issue_1954', $$ + MATCH (b:B)-[:LINK2]->(c:C) + RETURN b.name, c.name + ORDER BY b.name +$$) AS (b agtype, c agtype); + b | c +------+--------- + "b1" | "a1|b1" + "b2" | "a2|b2" + "b3" | "a3|b3" +(3 rows) + -- -- clean up graphs -- @@ -1735,9 +1991,268 @@ SELECT * FROM cypher('issue_1709', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype --- (0 rows) +SELECT * FROM cypher('issue_1446', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); + a +--- +(0 rows) + +SELECT * FROM cypher('issue_1954', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); + a +--- +(0 rows) + +-- +-- ON CREATE SET / ON MATCH SET tests (issue #1619) +-- +SELECT create_graph('merge_actions'); +NOTICE: graph "merge_actions" has been created + create_graph +-------------- + +(1 row) + +-- Basic ON CREATE SET: first run creates the node +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Alice'}) + ON CREATE SET n.created = true + RETURN n.name, n.created +$$) AS (name agtype, created agtype); + name | created +---------+--------- + "Alice" | true +(1 row) + +-- ON MATCH SET: second run matches the existing node +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Alice'}) + ON MATCH SET n.found = true + RETURN n.name, n.created, n.found +$$) AS (name agtype, created agtype, found agtype); + name | created | found +---------+---------+------- + "Alice" | true | true +(1 row) + +-- Both ON CREATE SET and ON MATCH SET (first run = create) +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Bob'}) + ON CREATE SET n.created = true + ON MATCH SET n.matched = true + RETURN n.name, n.created, n.matched +$$) AS (name agtype, created agtype, matched agtype); + name | created | matched +-------+---------+--------- + "Bob" | true | +(1 row) + +-- Both ON CREATE SET and ON MATCH SET (second run = match) +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Bob'}) + ON CREATE SET n.created = true + ON MATCH SET n.matched = true + RETURN n.name, n.created, n.matched +$$) AS (name agtype, created agtype, matched agtype); + name | created | matched +-------+---------+--------- + "Bob" | true | true +(1 row) + +-- ON CREATE SET with MERGE after MATCH (Case 1: has predecessor, first run = create) +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Alice'}) + MERGE (a)-[:KNOWS]->(b:Person {name: 'Charlie'}) + ON CREATE SET b.source = 'merge_create' + RETURN a.name, b.name, b.source +$$) AS (a agtype, b agtype, source agtype); + a | b | source +---------+-----------+---------------- + "Alice" | "Charlie" | "merge_create" +(1 row) + +-- ON MATCH SET with MERGE after MATCH (Case 1: has predecessor, second run = match) +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Alice'}) + MERGE (a)-[:KNOWS]->(b:Person {name: 'Charlie'}) + ON MATCH SET b.visited = true + RETURN a.name, b.name, b.visited +$$) AS (a agtype, b agtype, visited agtype); + a | b | visited +---------+-----------+--------- + "Alice" | "Charlie" | true +(1 row) + +-- Multiple SET items in a single ON CREATE SET +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Dave'}) + ON CREATE SET n.a = 1, n.b = 2 + RETURN n.name, n.a, n.b +$$) AS (name agtype, a agtype, b agtype); + name | a | b +--------+---+--- + "Dave" | 1 | 2 +(1 row) + +-- Reverse order: ON MATCH before ON CREATE should work +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Eve'}) + ON MATCH SET n.seen = true + ON CREATE SET n.new = true + RETURN n.name, n.new +$$) AS (name agtype, new agtype); + name | new +-------+------ + "Eve" | true +(1 row) + +-- Error: ON CREATE SET specified more than once +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Bad'}) + ON CREATE SET n.a = 1 + ON CREATE SET n.b = 2 + RETURN n +$$) AS (n agtype); +ERROR: ON CREATE SET specified more than once +LINE 1: SELECT * FROM cypher('merge_actions', $$ + ^ +-- Error: ON MATCH SET specified more than once +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Bad'}) + ON MATCH SET n.a = 1 + ON MATCH SET n.b = 2 + RETURN n +$$) AS (n agtype); +ERROR: ON MATCH SET specified more than once +LINE 1: SELECT * FROM cypher('merge_actions', $$ + ^ +-- Chained (non-terminal) MERGE with ON CREATE SET (eager-buffering path) +SELECT * FROM cypher('merge_actions', $$ + MERGE (a:Person {name: 'Frank'}) + ON CREATE SET a.created = true + MERGE (a)-[:KNOWS]->(b:Person {name: 'Grace'}) + ON CREATE SET b.created = true + RETURN a.name, a.created, b.name, b.created +$$) AS (a_name agtype, a_created agtype, b_name agtype, b_created agtype); + a_name | a_created | b_name | b_created +---------+-----------+---------+----------- + "Frank" | true | "Grace" | true +(1 row) + +-- Chained (non-terminal) MERGE with ON MATCH SET (second run = match) +SELECT * FROM cypher('merge_actions', $$ + MERGE (a:Person {name: 'Frank'}) + ON MATCH SET a.matched = true + MERGE (a)-[:KNOWS]->(b:Person {name: 'Grace'}) + ON MATCH SET b.matched = true + RETURN a.name, a.matched, b.name, b.matched +$$) AS (a_name agtype, a_matched agtype, b_name agtype, b_matched agtype); + a_name | a_matched | b_name | b_matched +---------+-----------+---------+----------- + "Frank" | true | "Grace" | true +(1 row) + +-- ON keyword as label name (backward compat via safe_keywords) +SELECT * FROM cypher('merge_actions', $$ + CREATE (n:on {name: 'test'}) + RETURN n.name +$$) AS (name agtype); + name +-------- + "test" +(1 row) + +-- Issue #2347: RHS of ON CREATE / ON MATCH SET referencing a bound +-- variable crashed the backend when MERGE had a previous clause, because +-- the lateral-join's ParseNamespaceItem had p_nscolumns=NULL. +-- ON CREATE SET with RHS referencing the outer MATCH's variable +SELECT * FROM cypher('merge_actions', $$ CREATE (:Person {name:'Anchor'}) $$) AS (a agtype); + a +--- +(0 rows) + +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Anchor'}) + MERGE (b:Person {name: 'FromOuter'}) + ON CREATE SET b.source_name = a.name + RETURN a.name, b.name, b.source_name +$$) AS (a_name agtype, b_name agtype, b_source agtype); + a_name | b_name | b_source +----------+-------------+---------- + "Anchor" | "FromOuter" | "Anchor" +(1 row) + +-- ON CREATE SET with RHS referencing the MERGE-bound variable itself +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Anchor'}) + MERGE (b:Person {name: 'SelfRef'}) + ON CREATE SET b.echo_name = b.name + RETURN b.name, b.echo_name +$$) AS (b_name agtype, b_echo agtype); + b_name | b_echo +-----------+----------- + "SelfRef" | "SelfRef" +(1 row) + +-- ON CREATE SET driven by UNWIND with self-reference on the RHS +-- (Muhammad's second reproducer) +SELECT * FROM cypher('merge_actions', $$ + UNWIND ['U1', 'U2'] AS nm + MERGE (n:Person {name: nm}) + ON CREATE SET n.copy_name = n.name + RETURN n.name, n.copy_name +$$) AS (n_name agtype, n_copy agtype); + n_name | n_copy +--------+-------- + "U1" | "U1" + "U2" | "U2" +(2 rows) + +-- Multiple SET items mixing outer-ref, self-ref, and literal RHS +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Anchor'}) + MERGE (b:Person {name: 'MultiItem'}) + ON CREATE SET b.from_a = a.name, b.self = b.name, b.lit = 'literal' + RETURN b.from_a, b.self, b.lit +$$) AS (fa agtype, sf agtype, lit agtype); + fa | sf | lit +----------+-------------+----------- + "Anchor" | "MultiItem" | "literal" +(1 row) + +-- ON MATCH SET with variable RHS (second run on existing node) +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Anchor'}) + MERGE (b:Person {name: 'FromOuter'}) + ON CREATE SET b.source_name = a.name + ON MATCH SET b.last_seen_by = a.name + RETURN b.source_name, b.last_seen_by +$$) AS (src agtype, last agtype); + src | last +----------+---------- + "Anchor" | "Anchor" +(1 row) + +-- cleanup +SELECT * FROM cypher('merge_actions', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); + a +--- +(0 rows) + -- -- delete graphs -- +SELECT drop_graph('merge_actions', true); +NOTICE: drop cascades to 5 other objects +DETAIL: drop cascades to table merge_actions._ag_label_vertex +drop cascades to table merge_actions._ag_label_edge +drop cascades to table merge_actions."Person" +drop cascades to table merge_actions."KNOWS" +drop cascades to table merge_actions."on" +NOTICE: graph "merge_actions" has been dropped + drop_graph +------------ + +(1 row) + SELECT drop_graph('issue_1907', true); NOTICE: drop cascades to 4 other objects DETAIL: drop cascades to table issue_1907._ag_label_vertex @@ -1812,6 +2327,43 @@ NOTICE: graph "issue_1709" has been dropped (1 row) +SELECT drop_graph('issue_1446', true); +NOTICE: drop cascades to 12 other objects +DETAIL: drop cascades to table issue_1446._ag_label_vertex +drop cascades to table issue_1446._ag_label_edge +drop cascades to table issue_1446."A" +drop cascades to table issue_1446."C" +drop cascades to table issue_1446.r +drop cascades to table issue_1446.t +drop cascades to table issue_1446."X" +drop cascades to table issue_1446."Y" +drop cascades to table issue_1446."Z" +drop cascades to table issue_1446.link +drop cascades to table issue_1446.shared +drop cascades to table issue_1446.hub +NOTICE: graph "issue_1446" has been dropped + drop_graph +------------ + +(1 row) + +SELECT drop_graph('issue_1954', true); +NOTICE: drop cascades to 9 other objects +DETAIL: drop cascades to table issue_1954._ag_label_vertex +drop cascades to table issue_1954._ag_label_edge +drop cascades to table issue_1954."A" +drop cascades to table issue_1954."R" +drop cascades to table issue_1954."B" +drop cascades to table issue_1954."C" +drop cascades to table issue_1954."LINK" +drop cascades to table issue_1954."LINK1" +drop cascades to table issue_1954."LINK2" +NOTICE: graph "issue_1954" has been dropped + drop_graph +------------ + +(1 row) + -- -- End -- diff --git a/regress/expected/cypher_vle.out b/regress/expected/cypher_vle.out index 57f930d98..de85176b6 100644 --- a/regress/expected/cypher_vle.out +++ b/regress/expected/cypher_vle.out @@ -281,7 +281,7 @@ SELECT * FROM cypher('cypher_vle', $$MATCH ()-[*]->(v) RETURN count(*) $$) AS (e (1 row) -- Should find 2 -SELECT * FROM cypher('cypher_vle', $$MATCH (u:begin)<-[e*]-(v:end) RETURN e $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH (u:begin)<-[e*]-(v:end) RETURN e ORDER BY e ASC $$) AS (e agtype); e ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge] @@ -289,7 +289,7 @@ SELECT * FROM cypher('cypher_vle', $$MATCH (u:begin)<-[e*]-(v:end) RETURN e $$) (2 rows) -- Should find 5 -SELECT * FROM cypher('cypher_vle', $$MATCH p=(:begin)<-[*1..1]-()-[]-() RETURN p ORDER BY p $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(:begin)<-[*1..1]-()-[]-() RETURN p ORDER BY p ASC $$) AS (e agtype); e --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842626, "label": "edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "main edge", "number": 3, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex]::path @@ -307,54 +307,54 @@ SELECT * FROM cypher('cypher_vle', $$MATCH p=()-[*]->(v) RETURN count(*) $$) AS (1 row) -- Should find 2 -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)-[*3..3]->(v:end) RETURN p $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)-[*3..3]->(v:end) RETURN p ORDER BY p ASC $$) AS (e agtype); e ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2251799813685249, "label": "alternate_edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "alternate edge", "number": 1, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842627, "label": "edge", "end_id": 1407374883553282, "start_id": 1407374883553281, "properties": {"name": "main edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395905, "label": "bypass_edge", "end_id": 1688849860263937, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 1125899906842628, "label": "edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "main edge", "number": 1, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842627, "label": "edge", "end_id": 1407374883553282, "start_id": 1407374883553281, "properties": {"name": "main edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395905, "label": "bypass_edge", "end_id": 1688849860263937, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2251799813685249, "label": "alternate_edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "alternate edge", "number": 1, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842627, "label": "edge", "end_id": 1407374883553282, "start_id": 1407374883553281, "properties": {"name": "main edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395905, "label": "bypass_edge", "end_id": 1688849860263937, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path (2 rows) -- Should find 12 -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)-[*3..3]-(v:end) RETURN p $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)-[*3..3]-(v:end) RETURN p ORDER BY p ASC $$) AS (e agtype); e ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path - [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685251, "label": "alternate_edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "alternate edge", "number": 3, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 1125899906842628, "label": "edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "main edge", "number": 1, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842627, "label": "edge", "end_id": 1407374883553282, "start_id": 1407374883553281, "properties": {"name": "main edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395905, "label": "bypass_edge", "end_id": 1688849860263937, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2251799813685249, "label": "alternate_edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "alternate edge", "number": 1, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842627, "label": "edge", "end_id": 1407374883553282, "start_id": 1407374883553281, "properties": {"name": "main edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395905, "label": "bypass_edge", "end_id": 1688849860263937, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842626, "label": "edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "main edge", "number": 3, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842625, "label": "edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "main edge", "number": 4, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842626, "label": "edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "main edge", "number": 3, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685251, "label": "alternate_edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "alternate edge", "number": 3, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842626, "label": "edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "main edge", "number": 3, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685250, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "alternate edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842625, "label": "edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "main edge", "number": 4, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685250, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "alternate edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685251, "label": "alternate_edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "alternate edge", "number": 3, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685250, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "alternate edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842625, "label": "edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "main edge", "number": 4, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685251, "label": "alternate_edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "alternate edge", "number": 3, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395905, "label": "bypass_edge", "end_id": 1688849860263937, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex, {"id": 1970324836974594, "label": "self_loop", "end_id": 1688849860263937, "start_id": 1688849860263937, "properties": {"name": "self loop", "number": 2, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path - [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685250, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "alternate edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path - [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685250, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "alternate edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685251, "label": "alternate_edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "alternate edge", "number": 3, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path - [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685250, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "alternate edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842625, "label": "edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "main edge", "number": 4, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path - [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842626, "label": "edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "main edge", "number": 3, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path - [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842626, "label": "edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "main edge", "number": 3, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685251, "label": "alternate_edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "alternate edge", "number": 3, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path - [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842626, "label": "edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "main edge", "number": 3, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842625, "label": "edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "main edge", "number": 4, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path - [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2251799813685249, "label": "alternate_edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "alternate edge", "number": 1, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842627, "label": "edge", "end_id": 1407374883553282, "start_id": 1407374883553281, "properties": {"name": "main edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395905, "label": "bypass_edge", "end_id": 1688849860263937, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path - [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 1125899906842628, "label": "edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "main edge", "number": 1, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842627, "label": "edge", "end_id": 1407374883553282, "start_id": 1407374883553281, "properties": {"name": "main edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395905, "label": "bypass_edge", "end_id": 1688849860263937, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path (12 rows) -- Each should find 2 -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)<-[*]-(v:end) RETURN p $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)<-[*]-(v:end) RETURN p ORDER BY p ASC $$) AS (e agtype); e ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex, {"id": 1970324836974594, "label": "self_loop", "end_id": 1688849860263937, "start_id": 1688849860263937, "properties": {"name": "self loop", "number": 2, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path (2 rows) -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)<-[e*]-(v:end) RETURN p $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)<-[e*]-(v:end) RETURN p ORDER BY p ASC $$) AS (e agtype); e ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex, {"id": 1970324836974594, "label": "self_loop", "end_id": 1688849860263937, "start_id": 1688849860263937, "properties": {"name": "self loop", "number": 2, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path (2 rows) -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)<-[e*]-(v:end) RETURN e $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)<-[e*]-(v:end) RETURN e ORDER BY e ASC $$) AS (e agtype); e ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge] [{"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1970324836974594, "label": "self_loop", "end_id": 1688849860263937, "start_id": 1688849860263937, "properties": {"name": "self loop", "number": 2, "dangerous": {"type": "all", "level": "all"}}}::edge] (2 rows) -SELECT * FROM cypher('cypher_vle', $$MATCH p=(:begin)<-[*]-()<-[]-(:end) RETURN p $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(:begin)<-[*]-()<-[]-(:end) RETURN p ORDER BY p ASC $$) AS (e agtype); e ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path @@ -460,31 +460,31 @@ $$) AS (e1 agtype, e2 agtype); (1 row) -- Should return 1 path -SELECT * FROM cypher('cypher_vle', $$ MATCH p=()<-[e1*]-(:end)-[e2*]->(:begin) RETURN p $$) AS (result agtype); +SELECT * FROM cypher('cypher_vle', $$ MATCH p=()<-[e1*]-(:end)-[e2*]->(:begin) RETURN p ORDER BY p ASC $$) AS (result agtype); result ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ [{"id": 1688849860263937, "label": "end", "properties": {}}::vertex, {"id": 1970324836974594, "label": "self_loop", "end_id": 1688849860263937, "start_id": 1688849860263937, "properties": {"name": "self loop", "number": 2, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 844424930131969, "label": "begin", "properties": {}}::vertex]::path (1 row) -- Each should return 3 -SELECT * FROM cypher('cypher_vle', $$MATCH (u:begin)-[e*0..1]->(v) RETURN id(u), e, id(v) $$) AS (u agtype, e agtype, v agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH (u:begin)-[e*0..1]->(v) RETURN id(u), e, id(v) ORDER BY id(u) ASC, e ASC, id(v) ASC $$) AS (u agtype, e agtype, v agtype); u | e | v -----------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------------------ 844424930131969 | [] | 844424930131969 - 844424930131969 | [{"id": 2251799813685249, "label": "alternate_edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "alternate edge", "number": 1, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge] | 1407374883553281 844424930131969 | [{"id": 1125899906842628, "label": "edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "main edge", "number": 1, "dangerous": {"type": "all", "level": "all"}}}::edge] | 1407374883553281 + 844424930131969 | [{"id": 2251799813685249, "label": "alternate_edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "alternate edge", "number": 1, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge] | 1407374883553281 (3 rows) -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)-[e*0..1]->(v) RETURN p $$) AS (p agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)-[e*0..1]->(v) RETURN p ORDER BY p ASC $$) AS (p agtype); p -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex]::path - [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2251799813685249, "label": "alternate_edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "alternate edge", "number": 1, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex]::path [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 1125899906842628, "label": "edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "main edge", "number": 1, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex]::path + [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2251799813685249, "label": "alternate_edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "alternate edge", "number": 1, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex]::path (3 rows) -- Each should return 5 -SELECT * FROM cypher('cypher_vle', $$MATCH (u)-[e*0..0]->(v) RETURN id(u), e, id(v) $$) AS (u agtype, e agtype, v agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH (u)-[e*0..0]->(v) RETURN id(u), e, id(v) ORDER BY id(u) ASC, e ASC, id(v) ASC $$) AS (u agtype, e agtype, v agtype); u | e | v ------------------+----+------------------ 844424930131969 | [] | 844424930131969 @@ -494,7 +494,7 @@ SELECT * FROM cypher('cypher_vle', $$MATCH (u)-[e*0..0]->(v) RETURN id(u), e, id 1688849860263937 | [] | 1688849860263937 (5 rows) -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u)-[e*0..0]->(v) RETURN id(u), p, id(v) $$) AS (u agtype, p agtype, v agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u)-[e*0..0]->(v) RETURN id(u), p, id(v) ORDER BY id(u) ASC, p ASC, id(v) ASC $$) AS (u agtype, p agtype, v agtype); u | p | v ------------------+-------------------------------------------------------------------------------+------------------ 844424930131969 | [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex]::path | 844424930131969 @@ -505,31 +505,31 @@ SELECT * FROM cypher('cypher_vle', $$MATCH p=(u)-[e*0..0]->(v) RETURN id(u), p, (5 rows) -- Each should return 13 and will be the same -SELECT * FROM cypher('cypher_vle', $$MATCH p=()-[*0..0]->()-[]->() RETURN p $$) AS (p agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=()-[*0..0]->()-[]->() RETURN p ORDER BY p ASC $$) AS (p agtype); p ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - [{"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 844424930131969, "label": "begin", "properties": {}}::vertex]::path [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 1125899906842628, "label": "edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "main edge", "number": 1, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex]::path - [{"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1970324836974593, "label": "self_loop", "end_id": 1407374883553281, "start_id": 1407374883553281, "properties": {"name": "self loop", "number": 1, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex]::path [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2251799813685249, "label": "alternate_edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "alternate edge", "number": 1, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex]::path - [{"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex]::path [{"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842627, "label": "edge", "end_id": 1407374883553282, "start_id": 1407374883553281, "properties": {"name": "main edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex]::path + [{"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1970324836974593, "label": "self_loop", "end_id": 1407374883553281, "start_id": 1407374883553281, "properties": {"name": "self loop", "number": 1, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex]::path [{"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842626, "label": "edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "main edge", "number": 3, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex]::path [{"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685250, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "alternate edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex]::path - [{"id": 1688849860263937, "label": "end", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex]::path - [{"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685251, "label": "alternate_edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "alternate edge", "number": 3, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path [{"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395905, "label": "bypass_edge", "end_id": 1688849860263937, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path - [{"id": 1688849860263937, "label": "end", "properties": {}}::vertex, {"id": 1970324836974594, "label": "self_loop", "end_id": 1688849860263937, "start_id": 1688849860263937, "properties": {"name": "self loop", "number": 2, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395906, "label": "bypass_edge", "end_id": 844424930131969, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 2, "packages": [1, 3, 5, 7], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 844424930131969, "label": "begin", "properties": {}}::vertex]::path [{"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842625, "label": "edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "main edge", "number": 4, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685251, "label": "alternate_edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "alternate edge", "number": 3, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex]::path + [{"id": 1688849860263937, "label": "end", "properties": {}}::vertex, {"id": 1970324836974594, "label": "self_loop", "end_id": 1688849860263937, "start_id": 1688849860263937, "properties": {"name": "self loop", "number": 2, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 1688849860263937, "label": "end", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex]::path (13 rows) -SELECT * FROM cypher('cypher_vle', $$MATCH p=()-[]->()-[*0..0]->() RETURN p $$) AS (p agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=()-[]->()-[*0..0]->() RETURN p ORDER BY p ASC $$) AS (p agtype); p ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 1125899906842628, "label": "edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "main edge", "number": 1, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex]::path [{"id": 844424930131969, "label": "begin", "properties": {}}::vertex, {"id": 2251799813685249, "label": "alternate_edge", "end_id": 1407374883553281, "start_id": 844424930131969, "properties": {"name": "alternate edge", "number": 1, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex]::path - [{"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1970324836974593, "label": "self_loop", "end_id": 1407374883553281, "start_id": 1407374883553281, "properties": {"name": "self loop", "number": 1, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex]::path [{"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842627, "label": "edge", "end_id": 1407374883553282, "start_id": 1407374883553281, "properties": {"name": "main edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex]::path + [{"id": 1407374883553281, "label": "middle", "properties": {}}::vertex, {"id": 1970324836974593, "label": "self_loop", "end_id": 1407374883553281, "start_id": 1407374883553281, "properties": {"name": "self loop", "number": 1, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553281, "label": "middle", "properties": {}}::vertex]::path [{"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842626, "label": "edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "main edge", "number": 3, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex]::path [{"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685250, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1407374883553282, "properties": {"name": "alternate edge", "number": 2, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex]::path [{"id": 1407374883553282, "label": "middle", "properties": {}}::vertex, {"id": 2533274790395905, "label": "bypass_edge", "end_id": 1688849860263937, "start_id": 1407374883553282, "properties": {"name": "bypass edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path @@ -537,8 +537,8 @@ SELECT * FROM cypher('cypher_vle', $$MATCH p=()-[]->()-[*0..0]->() RETURN p $$) [{"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 1125899906842625, "label": "edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "main edge", "number": 4, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path [{"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685251, "label": "alternate_edge", "end_id": 1688849860263937, "start_id": 1407374883553283, "properties": {"name": "alternate edge", "number": 3, "packages": [2, 4, 6], "dangerous": {"type": "poisons", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path [{"id": 1407374883553283, "label": "middle", "properties": {}}::vertex, {"id": 2251799813685253, "label": "alternate_edge", "end_id": 1407374883553282, "start_id": 1407374883553283, "properties": {"name": "backup edge", "number": 2, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553282, "label": "middle", "properties": {}}::vertex]::path - [{"id": 1688849860263937, "label": "end", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex]::path [{"id": 1688849860263937, "label": "end", "properties": {}}::vertex, {"id": 1970324836974594, "label": "self_loop", "end_id": 1688849860263937, "start_id": 1688849860263937, "properties": {"name": "self loop", "number": 2, "dangerous": {"type": "all", "level": "all"}}}::edge, {"id": 1688849860263937, "label": "end", "properties": {}}::vertex]::path + [{"id": 1688849860263937, "label": "end", "properties": {}}::vertex, {"id": 2251799813685252, "label": "alternate_edge", "end_id": 1407374883553283, "start_id": 1688849860263937, "properties": {"name": "backup edge", "number": 1, "packages": [1, 3, 5, 7]}}::edge, {"id": 1407374883553283, "label": "middle", "properties": {}}::vertex]::path (13 rows) -- @@ -563,7 +563,7 @@ $$) AS (g1 agtype); /* should return 1 path with 1 edge */ SELECT * FROM cypher('mygraph', $$ MATCH p = ()-[:Edge*]->() - RETURN p + RETURN p ORDER BY p ASC $$) AS (g2 agtype); g2 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -583,24 +583,24 @@ $$) AS (g3 agtype); /* should find 2 paths with 1 edge */ SELECT * FROM cypher('mygraph', $$ MATCH p = ()-[:Edge]->() - RETURN p + RETURN p ORDER BY p ASC $$) AS (g4 agtype); g4 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - [{"id": 844424930131971, "label": "Node", "properties": {"name": "b"}}::vertex, {"id": 1125899906842626, "label": "Edge", "end_id": 844424930131970, "start_id": 844424930131971, "properties": {}}::edge, {"id": 844424930131970, "label": "Node", "properties": {"name": "c"}}::vertex]::path [{"id": 844424930131969, "label": "Node", "properties": {"name": "a"}}::vertex, {"id": 1125899906842627, "label": "Edge", "end_id": 844424930131971, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131971, "label": "Node", "properties": {"name": "b"}}::vertex]::path + [{"id": 844424930131971, "label": "Node", "properties": {"name": "b"}}::vertex, {"id": 1125899906842626, "label": "Edge", "end_id": 844424930131970, "start_id": 844424930131971, "properties": {}}::edge, {"id": 844424930131970, "label": "Node", "properties": {"name": "c"}}::vertex]::path (2 rows) /* should return 3 paths, 2 with 1 edge, 1 with 2 edges */ SELECT * FROM cypher('mygraph', $$ MATCH p = ()-[:Edge*]->() - RETURN p + RETURN p ORDER BY p ASC $$) AS (g5 agtype); g5 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 844424930131969, "label": "Node", "properties": {"name": "a"}}::vertex, {"id": 1125899906842627, "label": "Edge", "end_id": 844424930131971, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131971, "label": "Node", "properties": {"name": "b"}}::vertex]::path - [{"id": 844424930131969, "label": "Node", "properties": {"name": "a"}}::vertex, {"id": 1125899906842627, "label": "Edge", "end_id": 844424930131971, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131971, "label": "Node", "properties": {"name": "b"}}::vertex, {"id": 1125899906842626, "label": "Edge", "end_id": 844424930131970, "start_id": 844424930131971, "properties": {}}::edge, {"id": 844424930131970, "label": "Node", "properties": {"name": "c"}}::vertex]::path [{"id": 844424930131971, "label": "Node", "properties": {"name": "b"}}::vertex, {"id": 1125899906842626, "label": "Edge", "end_id": 844424930131970, "start_id": 844424930131971, "properties": {}}::edge, {"id": 844424930131970, "label": "Node", "properties": {"name": "c"}}::vertex]::path + [{"id": 844424930131969, "label": "Node", "properties": {"name": "a"}}::vertex, {"id": 1125899906842627, "label": "Edge", "end_id": 844424930131971, "start_id": 844424930131969, "properties": {}}::edge, {"id": 844424930131971, "label": "Node", "properties": {"name": "b"}}::vertex, {"id": 1125899906842626, "label": "Edge", "end_id": 844424930131970, "start_id": 844424930131971, "properties": {}}::edge, {"id": 844424930131970, "label": "Node", "properties": {"name": "c"}}::vertex]::path (3 rows) SELECT drop_graph('mygraph', true); @@ -691,7 +691,7 @@ BEGIN RETURN QUERY SELECT * FROM cypher('mygraph', $CYPHER$ MATCH (h:head {name: $list_name})-[e:next*]->(v:node) - RETURN v + RETURN v ORDER BY id(v) $CYPHER$, ag_param) AS (node agtype); END $$; -- create a list @@ -726,8 +726,8 @@ SELECT prepend_node('list01', 'b'); SELECT * FROM show_list_use_vle('list01'); node ----------------------------------------------------------------------------------- - {"id": 1407374883553282, "label": "node", "properties": {"content": "b"}}::vertex {"id": 1407374883553281, "label": "node", "properties": {"content": "a"}}::vertex + {"id": 1407374883553282, "label": "node", "properties": {"content": "b"}}::vertex (2 rows) -- prepend a node 'c' @@ -741,9 +741,9 @@ SELECT prepend_node('list01', 'c'); SELECT * FROM show_list_use_vle('list01'); node ----------------------------------------------------------------------------------- - {"id": 1407374883553283, "label": "node", "properties": {"content": "c"}}::vertex - {"id": 1407374883553282, "label": "node", "properties": {"content": "b"}}::vertex {"id": 1407374883553281, "label": "node", "properties": {"content": "a"}}::vertex + {"id": 1407374883553282, "label": "node", "properties": {"content": "b"}}::vertex + {"id": 1407374883553283, "label": "node", "properties": {"content": "c"}}::vertex (3 rows) DROP FUNCTION show_list_use_vle; @@ -842,22 +842,22 @@ SELECT * FROM cypher('access',$$ CREATE ()-[:knows {id:2, arry:[0,1,2,3,{name: " --------- (0 rows) -SELECT * FROM cypher('access', $$ MATCH (u)-[e*]->(v) RETURN e $$)as (edges agtype); +SELECT * FROM cypher('access', $$ MATCH (u)-[e*]->(v) RETURN e ORDER BY e ASC $$)as (edges agtype); edges ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 844424930131969, "label": "knows", "end_id": 281474976710658, "start_id": 281474976710657, "properties": {}}::edge] + [{"id": 844424930131970, "label": "knows", "end_id": 281474976710661, "start_id": 281474976710660, "properties": {}}::edge] [{"id": 844424930131971, "label": "knows", "end_id": 281474976710660, "start_id": 281474976710659, "properties": {}}::edge] [{"id": 844424930131971, "label": "knows", "end_id": 281474976710660, "start_id": 281474976710659, "properties": {}}::edge, {"id": 844424930131970, "label": "knows", "end_id": 281474976710661, "start_id": 281474976710660, "properties": {}}::edge] - [{"id": 844424930131970, "label": "knows", "end_id": 281474976710661, "start_id": 281474976710660, "properties": {}}::edge] + [{"id": 844424930131972, "label": "knows", "end_id": 281474976710664, "start_id": 281474976710663, "properties": {"id": 1}}::edge] [{"id": 844424930131973, "label": "knows", "end_id": 281474976710663, "start_id": 281474976710662, "properties": {"id": 0}}::edge] [{"id": 844424930131973, "label": "knows", "end_id": 281474976710663, "start_id": 281474976710662, "properties": {"id": 0}}::edge, {"id": 844424930131972, "label": "knows", "end_id": 281474976710664, "start_id": 281474976710663, "properties": {"id": 1}}::edge] - [{"id": 844424930131972, "label": "knows", "end_id": 281474976710664, "start_id": 281474976710663, "properties": {"id": 1}}::edge] + [{"id": 844424930131974, "label": "knows", "end_id": 281474976710667, "start_id": 281474976710666, "properties": {"id": 3, "arry": [1, 3, {"name": "john", "stats": {"age": 1000}}]}}::edge] [{"id": 844424930131975, "label": "knows", "end_id": 281474976710666, "start_id": 281474976710665, "properties": {"id": 2, "arry": [0, 1, 2, 3, {"name": "joe"}]}}::edge] [{"id": 844424930131975, "label": "knows", "end_id": 281474976710666, "start_id": 281474976710665, "properties": {"id": 2, "arry": [0, 1, 2, 3, {"name": "joe"}]}}::edge, {"id": 844424930131974, "label": "knows", "end_id": 281474976710667, "start_id": 281474976710666, "properties": {"id": 3, "arry": [1, 3, {"name": "john", "stats": {"age": 1000}}]}}::edge] - [{"id": 844424930131974, "label": "knows", "end_id": 281474976710667, "start_id": 281474976710666, "properties": {"id": 3, "arry": [1, 3, {"name": "john", "stats": {"age": 1000}}]}}::edge] (10 rows) -SELECT * FROM cypher('access', $$ MATCH (u)-[e*2..2]->(v) RETURN e $$)as (edges agtype); +SELECT * FROM cypher('access', $$ MATCH (u)-[e*2..2]->(v) RETURN e ORDER BY e ASC $$)as (edges agtype); edges ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 844424930131971, "label": "knows", "end_id": 281474976710660, "start_id": 281474976710659, "properties": {}}::edge, {"id": 844424930131970, "label": "knows", "end_id": 281474976710661, "start_id": 281474976710660, "properties": {}}::edge] @@ -865,7 +865,7 @@ SELECT * FROM cypher('access', $$ MATCH (u)-[e*2..2]->(v) RETURN e $$)as (edges [{"id": 844424930131975, "label": "knows", "end_id": 281474976710666, "start_id": 281474976710665, "properties": {"id": 2, "arry": [0, 1, 2, 3, {"name": "joe"}]}}::edge, {"id": 844424930131974, "label": "knows", "end_id": 281474976710667, "start_id": 281474976710666, "properties": {"id": 3, "arry": [1, 3, {"name": "john", "stats": {"age": 1000}}]}}::edge] (3 rows) -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[0]) $$) as (prop_first_edge agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[0]) ORDER BY id(e[0]) ASC $$) as (prop_first_edge agtype); prop_first_edge -------------------------------------------------- {} @@ -873,7 +873,7 @@ SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[0]) $ {"id": 2, "arry": [0, 1, 2, 3, {"name": "joe"}]} (3 rows) -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[0].id $$) as (results agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[0].id ORDER BY id(e[0]) ASC $$) as (results agtype); results --------- @@ -881,7 +881,7 @@ SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[0].id $$) as (re 2 (3 rows) -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[0].arry[2] $$) as (results agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[0].arry[2] ORDER BY id(e[0]) ASC $$) as (results agtype); results --------- @@ -889,7 +889,7 @@ SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[0].arry[2] $$) a 2 (3 rows) -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[1]) $$) as (prop_second_edge agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[1]) ORDER BY id(e[1]) ASC $$) as (prop_second_edge agtype); prop_second_edge --------------------------------------------------------------------- {} @@ -897,7 +897,7 @@ SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[1]) $ {"id": 3, "arry": [1, 3, {"name": "john", "stats": {"age": 1000}}]} (3 rows) -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].id $$) as (results agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].id ORDER BY id(e[1]) ASC $$) as (results agtype); results --------- @@ -905,7 +905,7 @@ SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].id $$) as (re 3 (3 rows) -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].arry[2] $$) as (results agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].arry[2] ORDER BY id(e[1]) ASC $$) as (results agtype); results ------------------------------------------ @@ -913,7 +913,7 @@ SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].arry[2] $$) a {"name": "john", "stats": {"age": 1000}} (3 rows) -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].arry[2].stats $$) as (results agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].arry[2].stats ORDER BY id(e[1]) ASC $$) as (results agtype); results --------------- @@ -921,7 +921,7 @@ SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].arry[2].stats {"age": 1000} (3 rows) -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[2]) $$) as (prop_third_edge agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[2]) ORDER BY id(e[2]) ASC $$) as (prop_third_edge agtype); prop_third_edge ----------------- @@ -929,37 +929,37 @@ SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[2]) $ (3 rows) -SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN properties(e[0]), properties(e[1]) $$) as (prop_1st agtype, prop_2nd agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN properties(e[0]), properties(e[1]) ORDER BY id(e[0]) ASC, id(e[1]) ASC $$) as (prop_1st agtype, prop_2nd agtype); prop_1st | prop_2nd ---------------------------------------------------------------------+--------------------------------------------------------------------- {} | {} | {} | {} {} | - {"id": 0} | - {"id": 0} | {"id": 1} {"id": 1} | - {"id": 2, "arry": [0, 1, 2, 3, {"name": "joe"}]} | - {"id": 2, "arry": [0, 1, 2, 3, {"name": "joe"}]} | {"id": 3, "arry": [1, 3, {"name": "john", "stats": {"age": 1000}}]} + {"id": 0} | {"id": 1} + {"id": 0} | {"id": 3, "arry": [1, 3, {"name": "john", "stats": {"age": 1000}}]} | + {"id": 2, "arry": [0, 1, 2, 3, {"name": "joe"}]} | {"id": 3, "arry": [1, 3, {"name": "john", "stats": {"age": 1000}}]} + {"id": 2, "arry": [0, 1, 2, 3, {"name": "joe"}]} | (10 rows) -SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].id, e[1].id $$) as (results_1st agtype, results_2nd agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].id, e[1].id ORDER BY id(e[0]) ASC, id(e[1]) ASC $$) as (results_1st agtype, results_2nd agtype); results_1st | results_2nd -------------+------------- | | | | - 0 | - 0 | 1 1 | - 2 | - 2 | 3 + 0 | 1 + 0 | 3 | + 2 | 3 + 2 | (10 rows) -SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].arry, e[1].arry $$) as (results_1st agtype, results_2nd agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].arry, e[1].arry ORDER BY id(e[0]) ASC, id(e[1]) ASC $$) as (results_1st agtype, results_2nd agtype); results_1st | results_2nd --------------------------------------------------+-------------------------------------------------- | @@ -969,12 +969,12 @@ SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].arry, e[1].arry $ | | | - [0, 1, 2, 3, {"name": "joe"}] | - [0, 1, 2, 3, {"name": "joe"}] | [1, 3, {"name": "john", "stats": {"age": 1000}}] [1, 3, {"name": "john", "stats": {"age": 1000}}] | + [0, 1, 2, 3, {"name": "joe"}] | [1, 3, {"name": "john", "stats": {"age": 1000}}] + [0, 1, 2, 3, {"name": "joe"}] | (10 rows) -SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].arry[2], e[1].arry[2] $$) as (results_1st agtype, results_2nd agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].arry[2], e[1].arry[2] ORDER BY id(e[0]) ASC, id(e[1]) ASC $$) as (results_1st agtype, results_2nd agtype); results_1st | results_2nd ------------------------------------------+------------------------------------------ | @@ -984,9 +984,9 @@ SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].arry[2], e[1].arr | | | - 2 | - 2 | {"name": "john", "stats": {"age": 1000}} {"name": "john", "stats": {"age": 1000}} | + 2 | {"name": "john", "stats": {"age": 1000}} + 2 | (10 rows) SELECT drop_graph('access', true); @@ -1013,7 +1013,7 @@ SELECT * FROM cypher('issue_1043', $$ CREATE (n)-[:KNOWS {n:'hello'}]->({n:'hell --- (0 rows) -SELECT * FROM cypher('issue_1043', $$ MATCH (x)<-[y *]-(),({n:y[0].n}) RETURN x $$) as (a agtype); +SELECT * FROM cypher('issue_1043', $$ MATCH (x)<-[y *]-(),({n:y[0].n}) RETURN x ORDER BY id(x) ASC $$) as (a agtype); a ---------------------------------------------------------------------------- {"id": 281474976710658, "label": "", "properties": {"n": "hello"}}::vertex @@ -1024,7 +1024,7 @@ SELECT * FROM cypher('issue_1043', $$ CREATE (n)-[:KNOWS {n:'hello'}]->({n:'hell --- (0 rows) -SELECT * FROM cypher('issue_1043', $$ MATCH (x)<-[y *]-(),({n:y[0].n}) RETURN x $$) as (a agtype); +SELECT * FROM cypher('issue_1043', $$ MATCH (x)<-[y *]-(),({n:y[0].n}) RETURN x ORDER BY id(x) ASC $$) as (a agtype); a ---------------------------------------------------------------------------- {"id": 281474976710658, "label": "", "properties": {"n": "hello"}}::vertex @@ -1053,7 +1053,7 @@ NOTICE: graph "issue_1910" has been created (1 row) SELECT * FROM cypher('issue_1910', $$ MATCH (n) WHERE EXISTS((n)-[*1]-({name: 'Willem Defoe'})) - RETURN n.full_name $$) AS (full_name agtype); + RETURN n.full_name ORDER BY id(n) ASC $$) AS (full_name agtype); full_name ----------- (0 rows) @@ -1075,7 +1075,7 @@ SELECT * FROM cypher('issue_1910', $$ MATCH (u {name: 'John Doe'}) (0 rows) SELECT * FROM cypher('issue_1910', $$ MATCH (n) WHERE EXISTS((n)-[*]-({name: 'Willem Defoe'})) - RETURN n.name $$) AS (name agtype); + RETURN n.name ORDER BY id(n) ASC $$) AS (name agtype); name ---------------- "Jane Doe" @@ -1084,7 +1084,7 @@ SELECT * FROM cypher('issue_1910', $$ MATCH (n) WHERE EXISTS((n)-[*]-({name: 'Wi (3 rows) SELECT * FROM cypher('issue_1910', $$ MATCH (n) WHERE EXISTS((n)-[*1]-({name: 'Willem Defoe'})) - RETURN n.name $$) AS (name agtype); + RETURN n.name ORDER BY id(n) ASC $$) AS (name agtype); name ---------------- "John Doe" @@ -1092,7 +1092,7 @@ SELECT * FROM cypher('issue_1910', $$ MATCH (n) WHERE EXISTS((n)-[*1]-({name: 'W (2 rows) SELECT * FROM cypher('issue_1910', $$ MATCH (n) WHERE EXISTS((n)-[*2..2]-({name: 'Willem Defoe'})) - RETURN n.name $$) AS (name agtype); + RETURN n.name ORDER BY id(n) ASC $$) AS (name agtype); name ------------ "Jane Doe" @@ -1109,6 +1109,95 @@ NOTICE: graph "issue_1910" has been dropped (1 row) +-- issue 2092: VLE with chained OPTIONAL MATCH and NULL handling +-- Previously, chained OPTIONAL MATCH with VLE would either segfault +-- (with WHERE IS NOT NULL) or error with "arguments cannot be NULL" +-- (without WHERE) instead of producing correct NULL-extended rows. +SELECT create_graph('issue_2092'); +NOTICE: graph "issue_2092" has been created + create_graph +-------------- + +(1 row) + +-- Set up a small graph where some OPTIONAL MATCH paths exist and some don't +SELECT * FROM cypher('issue_2092', $$ + CREATE (a:Person {name: 'Alice'}) + CREATE (b:Person {name: 'Bob'}) + CREATE (c:City {name: 'NYC'}) + CREATE (d:City {name: 'LA'}) + CREATE (e:Place {name: 'Central Park'}) + CREATE (a)-[:LIVES_IN]->(c) + CREATE (c)-[:HAS_PLACE]->(e) + CREATE (b)-[:LIVES_IN]->(d) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- Alice lives in NYC which has Central Park. +-- Bob lives in LA which has no places. +-- VLE + chained OPTIONAL MATCH + WHERE IS NOT NULL: should return rows +-- without crashing (was: segfault) +SELECT * FROM cypher('issue_2092', $$ + MATCH (p:Person)-[:LIVES_IN*]->(c:City) + OPTIONAL MATCH (c)-[:HAS_PLACE*]->(place) + OPTIONAL MATCH (place)-[:NEARBY*]->(other) + WHERE place IS NOT NULL + RETURN p.name, place.name, other + ORDER BY p.name +$$) AS (person agtype, place agtype, other agtype); + person | place | other +---------+----------------+------- + "Alice" | "Central Park" | + "Bob" | | +(2 rows) + +-- VLE + chained OPTIONAL MATCH without WHERE: should return NULL-extended +-- rows without error (was: "match_vle_terminal_edge() arguments cannot be +-- NULL") +SELECT * FROM cypher('issue_2092', $$ + MATCH (p:Person)-[:LIVES_IN*]->(c:City) + OPTIONAL MATCH (c)-[:HAS_PLACE*]->(place) + OPTIONAL MATCH (place)-[:NEARBY*]->(other) + RETURN p.name, place.name, other + ORDER BY p.name +$$) AS (person agtype, place agtype, other agtype); + person | place | other +---------+----------------+------- + "Alice" | "Central Park" | + "Bob" | | +(2 rows) + +-- Verify the happy path still works: Alice's full chain resolves +SELECT * FROM cypher('issue_2092', $$ + MATCH (p:Person)-[:LIVES_IN*]->(c:City) + OPTIONAL MATCH (c)-[:HAS_PLACE*]->(place) + WHERE place IS NOT NULL + RETURN p.name, c.name, place.name + ORDER BY p.name +$$) AS (person agtype, city agtype, place agtype); + person | city | place +---------+-------+---------------- + "Alice" | "NYC" | "Central Park" + "Bob" | "LA" | +(2 rows) + +SELECT drop_graph('issue_2092', true); +NOTICE: drop cascades to 7 other objects +DETAIL: drop cascades to table issue_2092._ag_label_vertex +drop cascades to table issue_2092._ag_label_edge +drop cascades to table issue_2092."Person" +drop cascades to table issue_2092."City" +drop cascades to table issue_2092."Place" +drop cascades to table issue_2092."LIVES_IN" +drop cascades to table issue_2092."HAS_PLACE" +NOTICE: graph "issue_2092" has been dropped + drop_graph +------------ + +(1 row) + -- -- Clean up -- @@ -1130,6 +1219,121 @@ NOTICE: graph "cypher_vle" has been dropped (1 row) +-- +-- Issue #2382: variable-length relationships with a zero lower bound must +-- still produce the zero-hop self-binding even when the edge label does not +-- exist in the graph (Neo4j/openCypher semantics). +-- +SELECT create_graph('issue_2382'); +NOTICE: graph "issue_2382" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('issue_2382', $$ + CREATE (:Person {name: 'Alice'})-[:KNOWS]->(:Person {name: 'Bob'}) +$$) AS (v agtype); + v +--- +(0 rows) + +-- Plain MATCH on a non-existent edge label with [*0..N] must return the +-- zero-hop self-binding row (Alice -> Alice). It must NOT match arbitrary +-- edges of other labels (e.g. KNOWS). +SELECT * FROM cypher('issue_2382', $$ + MATCH (p:Person {name: 'Alice'}) + MATCH (p)-[:NOEXIST*0..1]-(f:Person) + RETURN p.name AS person, f.name AS friend +$$) AS (person agtype, friend agtype); + person | friend +---------+--------- + "Alice" | "Alice" +(1 row) + +-- OPTIONAL MATCH form (the exact shape from the issue report). +SELECT * FROM cypher('issue_2382', $$ + MATCH (p:Person {name: 'Alice'}) + OPTIONAL MATCH (p)-[:NOEXIST*0..1]-(f:Person) + RETURN p.name AS person, f.name AS friend +$$) AS (person agtype, friend agtype); + person | friend +---------+--------- + "Alice" | "Alice" +(1 row) + +-- [*0..0] still emits exactly the zero-hop self-binding. +SELECT * FROM cypher('issue_2382', $$ + MATCH (p:Person {name: 'Alice'}) + MATCH (p)-[:NOEXIST*0..0]-(f:Person) + RETURN p.name AS person, f.name AS friend +$$) AS (person agtype, friend agtype); + person | friend +---------+--------- + "Alice" | "Alice" +(1 row) + +-- Fixed-length (lower bound > 0) on a missing label must still return zero +-- rows: there is no edge of that label, so the pattern is unsatisfiable. +SELECT * FROM cypher('issue_2382', $$ + MATCH (p:Person {name: 'Alice'}) + MATCH (p)-[:NOEXIST*1..1]-(f:Person) + RETURN p.name AS person, f.name AS friend +$$) AS (person agtype, friend agtype); + person | friend +--------+-------- +(0 rows) + +-- OPTIONAL MATCH on the unsatisfiable fixed-length pattern still preserves +-- the outer row with NULL bindings. +SELECT * FROM cypher('issue_2382', $$ + MATCH (p:Person {name: 'Alice'}) + OPTIONAL MATCH (p)-[:NOEXIST*1..1]-(f:Person) + RETURN p.name AS person, f.name AS friend +$$) AS (person agtype, friend agtype); + person | friend +---------+-------- + "Alice" | +(1 row) + +-- Mixed pattern: a zero-bound VLE on a missing label combined with another +-- fixed-length missing label segment must still yield zero rows. The other +-- segment is impossible regardless of the zero-hop case. +SELECT * FROM cypher('issue_2382', $$ + MATCH (a:Person {name: 'Alice'}) + MATCH (a)-[:NOEXIST*0..1]-(b:Person)-[:STILL_MISSING]-(c:Person) + RETURN a.name, b.name, c.name +$$) AS (a agtype, b agtype, c agtype); + a | b | c +---+---+--- +(0 rows) + +-- Sanity: zero-bound VLE on an EXISTING label still works the way it did +-- before (Alice via zero-hop, Bob via 1-hop KNOWS). +SELECT * FROM cypher('issue_2382', $$ + MATCH (p:Person {name: 'Alice'}) + MATCH (p)-[:KNOWS*0..1]-(f:Person) + RETURN p.name AS person, f.name AS friend + ORDER BY f.name +$$) AS (person agtype, friend agtype); + person | friend +---------+--------- + "Alice" | "Alice" + "Alice" | "Bob" +(2 rows) + +SELECT drop_graph('issue_2382', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table issue_2382._ag_label_vertex +drop cascades to table issue_2382._ag_label_edge +drop cascades to table issue_2382."Person" +drop cascades to table issue_2382."KNOWS" +NOTICE: graph "issue_2382" has been dropped + drop_graph +------------ + +(1 row) + -- -- End -- diff --git a/regress/expected/cypher_with.out b/regress/expected/cypher_with.out index 99ea320a0..2fc330616 100644 --- a/regress/expected/cypher_with.out +++ b/regress/expected/cypher_with.out @@ -52,13 +52,14 @@ SELECT * FROM cypher('cypher_with', $$ MATCH (n)-[e]->(m) WITH n,e,m RETURN n,e,m + ORDER BY id(n) ASC, id(e) ASC, id(m) ASC $$) AS (N1 agtype, edge agtype, N2 agtype); n1 | edge | n2 --------------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------------------------- {"id": 281474976710657, "label": "", "properties": {"age": 36, "name": "Andres"}}::vertex | {"id": 844424930131969, "label": "BLOCKS", "end_id": 281474976710658, "start_id": 281474976710657, "properties": {}}::edge | {"id": 281474976710658, "label": "", "properties": {"age": 25, "name": "Caesar"}}::vertex - {"id": 281474976710659, "label": "", "properties": {"age": 55, "name": "Bossman"}}::vertex | {"id": 844424930131970, "label": "BLOCKS", "end_id": 281474976710660, "start_id": 281474976710659, "properties": {}}::edge | {"id": 281474976710660, "label": "", "properties": {"age": 35, "name": "David"}}::vertex {"id": 281474976710657, "label": "", "properties": {"age": 36, "name": "Andres"}}::vertex | {"id": 1125899906842625, "label": "KNOWS", "end_id": 281474976710659, "start_id": 281474976710657, "properties": {}}::edge | {"id": 281474976710659, "label": "", "properties": {"age": 55, "name": "Bossman"}}::vertex {"id": 281474976710658, "label": "", "properties": {"age": 25, "name": "Caesar"}}::vertex | {"id": 1125899906842626, "label": "KNOWS", "end_id": 281474976710661, "start_id": 281474976710658, "properties": {}}::edge | {"id": 281474976710661, "label": "", "properties": {"age": 37, "name": "George"}}::vertex + {"id": 281474976710659, "label": "", "properties": {"age": 55, "name": "Bossman"}}::vertex | {"id": 844424930131970, "label": "BLOCKS", "end_id": 281474976710660, "start_id": 281474976710659, "properties": {}}::edge | {"id": 281474976710660, "label": "", "properties": {"age": 35, "name": "David"}}::vertex {"id": 281474976710659, "label": "", "properties": {"age": 55, "name": "Bossman"}}::vertex | {"id": 1125899906842627, "label": "KNOWS", "end_id": 281474976710661, "start_id": 281474976710659, "properties": {}}::edge | {"id": 281474976710661, "label": "", "properties": {"age": 37, "name": "George"}}::vertex {"id": 281474976710660, "label": "", "properties": {"age": 35, "name": "David"}}::vertex | {"id": 1125899906842628, "label": "KNOWS", "end_id": 281474976710657, "start_id": 281474976710660, "properties": {}}::edge | {"id": 281474976710657, "label": "", "properties": {"age": 36, "name": "Andres"}}::vertex (6 rows) @@ -68,6 +69,7 @@ SELECT * FROM cypher('cypher_with', $$ MATCH (n)-[e]->(m) WITH n.name AS n1, e as edge, m.name as n2 RETURN n1,label(edge),n2 + ORDER BY id(edge) ASC $$) AS (start_node agtype,edge agtype, end_node agtype); start_node | edge | end_node ------------+----------+----------- @@ -83,13 +85,14 @@ SELECT * FROM cypher('cypher_with',$$ MATCH (person)-[r]->(otherPerson) WITH *, type(r) AS connectionType RETURN person.name, connectionType, otherPerson.name + ORDER BY id(person) ASC, id(otherPerson) ASC $$) AS (start_node agtype, connection agtype, end_node agtype); start_node | connection | end_node ------------+------------+----------- "Andres" | "BLOCKS" | "Caesar" - "Bossman" | "BLOCKS" | "David" "Andres" | "KNOWS" | "Bossman" "Caesar" | "KNOWS" | "George" + "Bossman" | "BLOCKS" | "David" "Bossman" | "KNOWS" | "George" "David" | "KNOWS" | "Andres" (6 rows) @@ -109,6 +112,7 @@ MATCH (george {name: 'George'})<-[]-(otherPerson) WITH otherPerson, toUpper(otherPerson.name) AS upperCaseName WHERE upperCaseName STARTS WITH 'C' RETURN otherPerson.name + ORDER BY id(otherPerson) ASC $$) as (name agtype); name ---------- @@ -120,6 +124,7 @@ SELECT * FROM cypher('cypher_with', $$ WITH otherPerson, count(*) AS foaf WHERE foaf > 1 RETURN otherPerson.name + ORDER BY id(otherPerson) ASC $$) as (name agtype); name ---------- @@ -131,15 +136,16 @@ SELECT * FROM cypher('cypher_with', $$ WITH p, length(p) AS path_length WHERE path_length > 1 RETURN p + ORDER BY p ASC $$) AS (pattern agtype); pattern -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - [{"id": 281474976710657, "label": "_ag_label_vertex", "properties": {"age": 36, "name": "Andres"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 281474976710659, "start_id": 281474976710657, "properties": {}}::edge, {"id": 281474976710659, "label": "_ag_label_vertex", "properties": {"age": 55, "name": "Bossman"}}::vertex, {"id": 1125899906842627, "label": "KNOWS", "end_id": 281474976710661, "start_id": 281474976710659, "properties": {}}::edge, {"id": 281474976710661, "label": "_ag_label_vertex", "properties": {"age": 37, "name": "George"}}::vertex]::path - [{"id": 281474976710657, "label": "_ag_label_vertex", "properties": {"age": 36, "name": "Andres"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 281474976710659, "start_id": 281474976710657, "properties": {}}::edge, {"id": 281474976710659, "label": "_ag_label_vertex", "properties": {"age": 55, "name": "Bossman"}}::vertex, {"id": 844424930131970, "label": "BLOCKS", "end_id": 281474976710660, "start_id": 281474976710659, "properties": {}}::edge, {"id": 281474976710660, "label": "_ag_label_vertex", "properties": {"age": 35, "name": "David"}}::vertex]::path [{"id": 281474976710657, "label": "_ag_label_vertex", "properties": {"age": 36, "name": "Andres"}}::vertex, {"id": 844424930131969, "label": "BLOCKS", "end_id": 281474976710658, "start_id": 281474976710657, "properties": {}}::edge, {"id": 281474976710658, "label": "_ag_label_vertex", "properties": {"age": 25, "name": "Caesar"}}::vertex, {"id": 1125899906842626, "label": "KNOWS", "end_id": 281474976710661, "start_id": 281474976710658, "properties": {}}::edge, {"id": 281474976710661, "label": "_ag_label_vertex", "properties": {"age": 37, "name": "George"}}::vertex]::path + [{"id": 281474976710657, "label": "_ag_label_vertex", "properties": {"age": 36, "name": "Andres"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 281474976710659, "start_id": 281474976710657, "properties": {}}::edge, {"id": 281474976710659, "label": "_ag_label_vertex", "properties": {"age": 55, "name": "Bossman"}}::vertex, {"id": 844424930131970, "label": "BLOCKS", "end_id": 281474976710660, "start_id": 281474976710659, "properties": {}}::edge, {"id": 281474976710660, "label": "_ag_label_vertex", "properties": {"age": 35, "name": "David"}}::vertex]::path + [{"id": 281474976710657, "label": "_ag_label_vertex", "properties": {"age": 36, "name": "Andres"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 281474976710659, "start_id": 281474976710657, "properties": {}}::edge, {"id": 281474976710659, "label": "_ag_label_vertex", "properties": {"age": 55, "name": "Bossman"}}::vertex, {"id": 1125899906842627, "label": "KNOWS", "end_id": 281474976710661, "start_id": 281474976710659, "properties": {}}::edge, {"id": 281474976710661, "label": "_ag_label_vertex", "properties": {"age": 37, "name": "George"}}::vertex]::path [{"id": 281474976710659, "label": "_ag_label_vertex", "properties": {"age": 55, "name": "Bossman"}}::vertex, {"id": 844424930131970, "label": "BLOCKS", "end_id": 281474976710660, "start_id": 281474976710659, "properties": {}}::edge, {"id": 281474976710660, "label": "_ag_label_vertex", "properties": {"age": 35, "name": "David"}}::vertex, {"id": 1125899906842628, "label": "KNOWS", "end_id": 281474976710657, "start_id": 281474976710660, "properties": {}}::edge, {"id": 281474976710657, "label": "_ag_label_vertex", "properties": {"age": 36, "name": "Andres"}}::vertex]::path - [{"id": 281474976710660, "label": "_ag_label_vertex", "properties": {"age": 35, "name": "David"}}::vertex, {"id": 1125899906842628, "label": "KNOWS", "end_id": 281474976710657, "start_id": 281474976710660, "properties": {}}::edge, {"id": 281474976710657, "label": "_ag_label_vertex", "properties": {"age": 36, "name": "Andres"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 281474976710659, "start_id": 281474976710657, "properties": {}}::edge, {"id": 281474976710659, "label": "_ag_label_vertex", "properties": {"age": 55, "name": "Bossman"}}::vertex]::path [{"id": 281474976710660, "label": "_ag_label_vertex", "properties": {"age": 35, "name": "David"}}::vertex, {"id": 1125899906842628, "label": "KNOWS", "end_id": 281474976710657, "start_id": 281474976710660, "properties": {}}::edge, {"id": 281474976710657, "label": "_ag_label_vertex", "properties": {"age": 36, "name": "Andres"}}::vertex, {"id": 844424930131969, "label": "BLOCKS", "end_id": 281474976710658, "start_id": 281474976710657, "properties": {}}::edge, {"id": 281474976710658, "label": "_ag_label_vertex", "properties": {"age": 25, "name": "Caesar"}}::vertex]::path + [{"id": 281474976710660, "label": "_ag_label_vertex", "properties": {"age": 35, "name": "David"}}::vertex, {"id": 1125899906842628, "label": "KNOWS", "end_id": 281474976710657, "start_id": 281474976710660, "properties": {}}::edge, {"id": 281474976710657, "label": "_ag_label_vertex", "properties": {"age": 36, "name": "Andres"}}::vertex, {"id": 1125899906842625, "label": "KNOWS", "end_id": 281474976710659, "start_id": 281474976710657, "properties": {}}::edge, {"id": 281474976710659, "label": "_ag_label_vertex", "properties": {"age": 55, "name": "Bossman"}}::vertex]::path (6 rows) -- MATCH/WHERE with WITH/WHERE @@ -149,6 +155,7 @@ SELECT * FROM cypher('cypher_with', $$ WITH * WHERE m.name = 'Andres' RETURN m.name,label(e),b.name + ORDER BY id(m) ASC, id(e) ASC, id(b) ASC $$) AS (N1 agtype, edge agtype, N2 agtype); n1 | edge | n2 ----------+---------+----------- @@ -201,9 +208,10 @@ SELECT * FROM cypher('cypher_with', $$ MATCH (n)-[e]->(m) WITH n, e, m WHERE label(e) = 'KNOWS' - WITH n.name as n1, label(e) as edge, m.name as n2 + WITH id(e) AS eid, n.name as n1, label(e) as edge, m.name as n2 WHERE n1 = 'Andres' RETURN n1,edge,n2 + ORDER BY eid ASC $$) AS (N1 agtype, edge agtype, N2 agtype); n1 | edge | n2 ----------+---------+----------- @@ -217,6 +225,7 @@ SELECT * FROM cypher('cypher_with', $$ WITH x LIMIT 5 RETURN x + ORDER BY x ASC $$) as (name agtype); name ------ @@ -233,11 +242,12 @@ SELECT * FROM cypher('cypher_with', $$ WITH m as start_node, b as end_node WHERE end_node.name = 'George' RETURN id(start_node),start_node.name,id(end_node),end_node.name + ORDER BY id(start_node) ASC, id(end_node) ASC $$) AS (id1 agtype, name1 agtype, id2 agtype, name2 agtype); id1 | name1 | id2 | name2 -----------------+-----------+-----------------+---------- - 281474976710659 | "Bossman" | 281474976710661 | "George" 281474976710658 | "Caesar" | 281474976710661 | "George" + 281474976710659 | "Bossman" | 281474976710661 | "George" (2 rows) -- Expression item must be aliased. @@ -357,6 +367,319 @@ NOTICE: graph "graph" has been dropped (1 row) +-- +-- Test accessor optimizations in WITH clause +-- +SELECT * FROM create_graph('with_accessor_opt'); +NOTICE: graph "with_accessor_opt" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('with_accessor_opt', $$ + CREATE (a:Person {name: 'Alice', age: 30})-[r:KNOWS {since: 2020}]->(b:Person {name: 'Bob', age: 25}) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- Single with +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH id(n) AS node_id + RETURN node_id +$$) AS (node_id agtype); + QUERY PLAN +------------------------------------------ + Seq Scan on with_accessor_opt."Person" n + Output: (n.id)::agtype +(2 rows) + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH properties(n) AS props + RETURN props +$$) AS (props agtype); + QUERY PLAN +------------------------------------------ + Seq Scan on with_accessor_opt."Person" n + Output: n.properties +(2 rows) + +SELECT * FROM cypher('with_accessor_opt', $$ + MATCH (n:Person) + WITH label(n) AS lbl + RETURN lbl +$$) AS (lbl agtype); + lbl +---------- + "Person" + "Person" +(2 rows) + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH n.name AS name, n.age AS age + RETURN name, age +$$) AS (name agtype, age agtype); + QUERY PLAN +--------------------------------------------------------------------------------------------------------------------------------------------------------- + Seq Scan on with_accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]), agtype_access_operator(VARIADIC ARRAY[n.properties, '"age"'::agtype]) +(2 rows) + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + WITH id(r) AS rid + RETURN rid +$$) AS (rid agtype); + QUERY PLAN +---------------------------------------------------------------------------------------------------------------------- + Merge Join + Output: (r.id)::agtype + Merge Cond: (_age_default_alias_1.id = r.end_id) + -> Merge Append + Sort Key: _age_default_alias_1.id + -> Index Only Scan using _ag_label_vertex_pkey on with_accessor_opt._ag_label_vertex _age_default_alias_1_1 + Output: _age_default_alias_1_1.id + -> Index Only Scan using "Person_pkey" on with_accessor_opt."Person" _age_default_alias_1_2 + Output: _age_default_alias_1_2.id + -> Materialize + Output: r.id, r.end_id + -> Nested Loop + Output: r.id, r.end_id + -> Index Scan using "KNOWS_end_id_idx" on with_accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Append + -> Seq Scan on with_accessor_opt._ag_label_vertex _age_default_alias_0_1 + Output: _age_default_alias_0_1.id + Filter: (_age_default_alias_0_1.id = r.start_id) + -> Index Only Scan using "Person_pkey" on with_accessor_opt."Person" _age_default_alias_0_2 + Output: _age_default_alias_0_2.id + Index Cond: (_age_default_alias_0_2.id = r.start_id) +(22 rows) + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH n, id(n) AS nid + WITH n.name AS name, nid + RETURN name, nid +$$) AS (name agtype, nid agtype); + QUERY PLAN +-------------------------------------------------------------------------------------------------- + Seq Scan on with_accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]), (n.id)::agtype +(2 rows) + +SELECT * FROM cypher('with_accessor_opt', $$ + MATCH (n:Person) + WITH n as m + RETURN m + ORDER BY id(m) ASC +$$) AS (n vertex); + n +--------------------------------------------------------------------- + (844424930131969,"""Person""","{""age"": 30, ""name"": ""Alice""}") + (844424930131970,"""Person""","{""age"": 25, ""name"": ""Bob""}") +(2 rows) + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH n + RETURN id(n), n.name +$$) AS (id agtype, name agtype); + QUERY PLAN +-------------------------------------------------------------------------------------------------- + Seq Scan on with_accessor_opt."Person" n + Output: (n.id)::agtype, agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]) +(2 rows) + +SELECT * FROM cypher('with_accessor_opt', $$ + MATCH ()-[r:KNOWS]->() + WITH r + RETURN id(r), type(r) +$$) AS (id agtype, typ agtype); + id | typ +------------------+--------- + 1125899906842625 | "KNOWS" +(1 row) + +-- +-- Chained WITH tests +-- +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person)-[r:KNOWS]->(b:Person) + WITH a, b, id(a) AS aid + WITH a.name AS aname, b.name AS bname, aid + RETURN aname, bname, aid +$$) AS (aname agtype, bname agtype, aid agtype); + QUERY PLAN +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Hash Join + Output: agtype_access_operator(VARIADIC ARRAY[a.properties, '"name"'::agtype]), agtype_access_operator(VARIADIC ARRAY[b.properties, '"name"'::agtype]), (a.id)::agtype + Inner Unique: true + Hash Cond: (r.end_id = b.id) + -> Hash Join + Output: a.properties, a.id, r.end_id + Inner Unique: true + Hash Cond: (r.start_id = a.id) + -> Seq Scan on with_accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Hash + Output: a.properties, a.id + -> Seq Scan on with_accessor_opt."Person" a + Output: a.properties, a.id + -> Hash + Output: b.properties, b.id + -> Seq Scan on with_accessor_opt."Person" b + Output: b.properties, b.id +(18 rows) + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH n, n.age AS age + WHERE age > 20 + RETURN n.name +$$) AS (name agtype); + QUERY PLAN +-------------------------------------------------------------------------------------------------- + Seq Scan on with_accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]) + Filter: (agtype_access_operator(VARIADIC ARRAY[n.properties, '"age"'::agtype]) > '20'::agtype) +(3 rows) + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH n.name as name, id(n) AS nid + ORDER BY nid + RETURN name +$$) AS (name agtype); + QUERY PLAN +---------------------------------------------------------------------------------------------- + Subquery Scan on _age_default_alias_previous_cypher_clause + Output: _age_default_alias_previous_cypher_clause.name + -> Index Scan using "Person_pkey" on with_accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]), n.id +(4 rows) + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH id(n) AS id, count(*) AS cnt + RETURN id, cnt +$$) AS (id agtype, cnt agtype); + QUERY PLAN +----------------------------------------------------------------------------------------------------------------- + Subquery Scan on _age_default_alias_previous_cypher_clause + Output: (_age_default_alias_previous_cypher_clause.id)::agtype, _age_default_alias_previous_cypher_clause.cnt + -> HashAggregate + Output: n.id, (count(*))::agtype + Group Key: n.id + -> Seq Scan on with_accessor_opt."Person" n + Output: n.id, n.properties +(7 rows) + +SELECT * FROM cypher('with_accessor_opt', $$ + MATCH (n:Person) + WITH DISTINCT label(n) AS lbl + RETURN lbl +$$) AS (lbl agtype); + lbl +---------- + "Person" +(1 row) + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH n + WITH n, id(n) AS nid + WITH n.name AS name, nid + RETURN name, nid +$$) AS (name agtype, nid agtype); + QUERY PLAN +-------------------------------------------------------------------------------------------------- + Seq Scan on with_accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]), (n.id)::agtype +(2 rows) + +-- MATCH -> WITH accessors -> MATCH -> RETURN +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person) + WITH a, id(a) AS aid + MATCH (a)-[r:KNOWS]->(b) + RETURN aid, b.name +$$) AS (aid agtype, bname agtype); + QUERY PLAN +-------------------------------------------------------------------------------------------------- + Merge Join + Output: (a.id)::agtype, agtype_access_operator(VARIADIC ARRAY[b.properties, '"name"'::agtype]) + Merge Cond: (b.id = r.end_id) + -> Merge Append + Sort Key: b.id + -> Index Scan using _ag_label_vertex_pkey on with_accessor_opt._ag_label_vertex b_1 + Output: b_1.properties, b_1.id + -> Index Scan using "Person_pkey" on with_accessor_opt."Person" b_2 + Output: b_2.properties, b_2.id + -> Sort + Output: a.id, r.end_id + Sort Key: r.end_id + -> Hash Join + Output: a.id, r.end_id + Inner Unique: true + Hash Cond: (r.start_id = a.id) + -> Seq Scan on with_accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Hash + Output: a.id + -> Seq Scan on with_accessor_opt."Person" a + Output: a.id +(22 rows) + +-- WITH + UNWIND with accessors +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH collect(id(n)) AS ids + UNWIND ids AS nid + RETURN nid +$$) AS (nid agtype); + QUERY PLAN +--------------------------------------------------------------- + Subquery Scan on _age_default_alias_previous_cypher_clause + Output: _age_default_alias_previous_cypher_clause.nid + -> ProjectSet + Output: NULL::agtype, age_unnest((age_collect(n.id))) + -> Aggregate + Output: age_collect(n.id) + -> Seq Scan on with_accessor_opt."Person" n + Output: n.id, n.properties +(8 rows) + +-- Clean up +SELECT drop_graph('with_accessor_opt', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table with_accessor_opt._ag_label_vertex +drop cascades to table with_accessor_opt._ag_label_edge +drop cascades to table with_accessor_opt."Person" +drop cascades to table with_accessor_opt."KNOWS" +NOTICE: graph "with_accessor_opt" has been dropped + drop_graph +------------ + +(1 row) + -- -- End of test -- diff --git a/regress/expected/direct_field_access.out b/regress/expected/direct_field_access.out index 0a059cdd9..25082d9fa 100644 --- a/regress/expected/direct_field_access.out +++ b/regress/expected/direct_field_access.out @@ -78,7 +78,7 @@ $$) AS (lt agtype, gt agtype, eq agtype, ne agtype); (1 row) SELECT * FROM cypher('direct_access', $$ - RETURN 'hello world' < 'hello worlds', 'test' > 'TEST' + RETURN 'hello world' < 'hello worlds', 'abd' > 'abc' $$) AS (lt agtype, gt agtype); lt | gt ------+------ diff --git a/regress/expected/drop.out b/regress/expected/drop.out index 3cfa2cf28..43e0bf41a 100644 --- a/regress/expected/drop.out +++ b/regress/expected/drop.out @@ -40,6 +40,14 @@ SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'ag_catalog'; ----------- (0 rows) +-- When ag_catalog is missing extension hooks shouldn't fail with the +-- ERROR schema "ag_catalog" does not exist. +-- It might happen when 'age' is loaded but extension isn't created yet. +SET client_min_messages TO WARNING; +DROP SCHEMA IF EXISTS ag_catalog CASCADE; +RESET client_min_messages; +CREATE SCHEMA _regress_drop; +DROP SCHEMA _regress_drop; -- should'n produce the ERROR -- Recreate the extension and validate we can recreate a graph CREATE EXTENSION age; SELECT create_graph('drop'); @@ -115,7 +123,7 @@ NOTICE: label "issue_1305"."r" has been dropped (1 row) SELECT drop_label('issue_1305', 'r'); -ERROR: rel_name not found for label "r" +ERROR: label "r" does not exist SELECT drop_label('issue_1305', 'n', false); NOTICE: label "issue_1305"."n" has been dropped drop_label @@ -124,7 +132,7 @@ NOTICE: label "issue_1305"."n" has been dropped (1 row) SELECT drop_label('issue_1305', 'n'); -ERROR: rel_name not found for label "n" +ERROR: label "n" does not exist SELECT * FROM drop_graph('issue_1305', true); NOTICE: drop cascades to 2 other objects DETAIL: drop cascades to table issue_1305._ag_label_vertex diff --git a/regress/expected/expr.out b/regress/expected/expr.out index 6d9341451..806a6f65c 100644 --- a/regress/expected/expr.out +++ b/regress/expected/expr.out @@ -40,18 +40,18 @@ SELECT * FROM cypher('expr', $$RETURN {}$$) AS r(c agtype); SELECT * FROM cypher('expr', $$ RETURN {s: 's', i: 1, f: 1.0, b: true, z: null} $$) AS r(c agtype); - c ------------------------------------------ - {"b": true, "f": 1.0, "i": 1, "s": "s"} + c +---------------------------------------------------- + {"b": true, "f": 1.0, "i": 1, "s": "s", "z": null} (1 row) -- nested maps SELECT * FROM cypher('expr', $$ RETURN {s: {s: 's'}, t: {i: 1, e: {f: 1.0}, s: {a: {b: true}}}, z: null} $$) AS r(c agtype); - c ----------------------------------------------------------------------------- - {"s": {"s": "s"}, "t": {"e": {"f": 1.0}, "i": 1, "s": {"a": {"b": true}}}} + c +--------------------------------------------------------------------------------------- + {"s": {"s": "s"}, "t": {"e": {"f": 1.0}, "i": 1, "s": {"a": {"b": true}}}, "z": null} (1 row) -- @@ -489,13 +489,13 @@ $$RETURN [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10][-1..10]$$) AS r(c agtype); SELECT agtype_access_slice('[0]'::agtype, 'null'::agtype, '1'::agtype); agtype_access_slice --------------------- - [0] + (1 row) SELECT agtype_access_slice('[0]'::agtype, '0'::agtype, 'null'::agtype); agtype_access_slice --------------------- - [0] + (1 row) -- should error - ERROR: slice must access a list @@ -1544,12 +1544,16 @@ SELECT * FROM cypher('type_coercion', $$ MATCH (v) RETURN v $$) AS (i bigint); -ERROR: cannot cast agtype vertex to type int +ERROR: cannot cast type vertex to bigint for column "i" +LINE 1: SELECT * FROM cypher('type_coercion', $$ + ^ SELECT * FROM cypher('type_coercion', $$ MATCH ()-[e]-() RETURN e $$) AS (i bigint); -ERROR: cannot cast agtype edge to type int +ERROR: cannot cast type edge to bigint for column "i" +LINE 1: SELECT * FROM cypher('type_coercion', $$ + ^ SELECT * FROM cypher('type_coercion', $$ MATCH p=()-[]-() RETURN p @@ -2762,6 +2766,8 @@ SELECT * FROM cypher('expr', $$ MATCH (v) RETURN start_id(v) $$) AS (start_id agtype); ERROR: start_id() argument must be an edge or null +LINE 2: MATCH (v) RETURN start_id(v) + ^ SELECT * FROM cypher('expr', $$ RETURN start_id() $$) AS (start_id agtype); @@ -2795,6 +2801,8 @@ SELECT * FROM cypher('expr', $$ MATCH (v) RETURN end_id(v) $$) AS (end_id agtype); ERROR: end_id() argument must be an edge or null +LINE 2: MATCH (v) RETURN end_id(v) + ^ SELECT * FROM cypher('expr', $$ RETURN end_id() $$) AS (end_id agtype); @@ -2828,6 +2836,8 @@ SELECT * FROM cypher('expr', $$ MATCH (v) RETURN startNode(v) $$) AS (startNode agtype); ERROR: startNode() argument must be an edge or null +LINE 2: MATCH (v) RETURN startNode(v) + ^ SELECT * FROM cypher('expr', $$ RETURN startNode() $$) AS (startNode agtype); @@ -2861,6 +2871,8 @@ SELECT * FROM cypher('expr', $$ MATCH (v) RETURN endNode(v) $$) AS (endNode agtype); ERROR: endNode() argument must be an edge or null +LINE 2: MATCH (v) RETURN endNode(v) + ^ SELECT * FROM cypher('expr', $$ RETURN endNode() $$) AS (endNode agtype); @@ -2894,6 +2906,8 @@ SELECT * FROM cypher('expr', $$ MATCH (v) RETURN type(v) $$) AS (type agtype); ERROR: type() argument must be an edge or null +LINE 2: MATCH (v) RETURN type(v) + ^ SELECT * FROM cypher('expr', $$ RETURN type() $$) AS (type agtype); @@ -3534,6 +3548,26 @@ $$) AS (toFloatList agtype); [1.20002] (1 row) +-- large magnitudes must not overflow the conversion (regression: unbounded +-- sprintf into a fixed stack buffer overflowed for values like 1.0e308) +SELECT * FROM cypher('expr', $$ + RETURN toFloatList([1.0e308, -1.0e308]) +$$) AS (toFloatList agtype); + tofloatlist +------------------- + [1e+308, -1e+308] +(1 row) + +-- precision must be preserved (regression: "%f" format truncated to 6 digits, +-- so 0.123456789 came back as 0.123457) +SELECT * FROM cypher('expr', $$ + RETURN toFloatList([0.123456789]) +$$) AS (toFloatList agtype); + tofloatlist +--------------- + [0.123456789] +(1 row) + -- should return null SELECT * FROM cypher('expr', $$ RETURN toFloatList(['true']) @@ -4114,15 +4148,15 @@ $$) as (u agtype); SELECT * FROM cypher('expr', $$ RETURN reverse(true) $$) AS (results agtype); -ERROR: reverse() unsupported argument agtype 5 +ERROR: reverse() unsupported argument agtype SELECT * FROM age_reverse(true); -ERROR: reverse() unsupported argument type 16 +ERROR: reverse() unsupported argument type SELECT * FROM cypher('expr', $$ RETURN reverse(3.14) $$) AS (results agtype); -ERROR: reverse() unsupported argument agtype 4 +ERROR: reverse() unsupported argument agtype SELECT * FROM age_reverse(3.14); -ERROR: reverse() unsupported argument type 1700 +ERROR: reverse() unsupported argument type SELECT * FROM cypher('expr', $$ RETURN reverse() $$) AS (results agtype); @@ -4139,7 +4173,7 @@ SELECT * FROM cypher('expr', $$ MATCH (v) RETURN reverse(v) $$) AS (results agtype); -ERROR: reverse() unsupported argument agtype 6 +ERROR: reverse() unsupported argument type SELECT * FROM cypher('expr', $$ RETURN reverse({}) $$) AS (results agtype); @@ -7436,12 +7470,12 @@ SELECT * FROM cypher('case_statement', $$ $$ ) AS (n agtype, case_statement agtype); n | case_statement ------------------------------------------------------------------------------------------------+---------------- - {"id": 281474976710658, "label": "", "properties": {"i": "a", "j": "b", "id": 2}}::vertex | "not count" {"id": 281474976710661, "label": "", "properties": {"i": [], "j": [0, 1, 2], "id": 5}}::vertex | "not count" {"id": 281474976710657, "label": "", "properties": {"i": 1, "id": 1}}::vertex | "not count" {"id": 281474976710659, "label": "", "properties": {"i": 0, "j": 1, "id": 3}}::vertex | 1 - {"id": 281474976710660, "label": "", "properties": {"i": true, "j": false, "id": 4}}::vertex | "not count" + {"id": 281474976710658, "label": "", "properties": {"i": "a", "j": "b", "id": 2}}::vertex | "not count" {"id": 281474976710662, "label": "", "properties": {"i": {}, "j": {"i": 1}, "id": 6}}::vertex | "not count" + {"id": 281474976710660, "label": "", "properties": {"i": true, "j": false, "id": 4}}::vertex | "not count" (6 rows) --concatenated @@ -7454,12 +7488,12 @@ SELECT * FROM cypher('case_statement', $$ $$ ) AS (n agtype, case_statement agtype); n | case_statement ------------------------------------------------------------------------------------------------+---------------- - {"id": 281474976710658, "label": "", "properties": {"i": "a", "j": "b", "id": 2}}::vertex | "not count" {"id": 281474976710661, "label": "", "properties": {"i": [], "j": [0, 1, 2], "id": 5}}::vertex | "not count" {"id": 281474976710657, "label": "", "properties": {"i": 1, "id": 1}}::vertex | "not count" {"id": 281474976710659, "label": "", "properties": {"i": 0, "j": 1, "id": 3}}::vertex | 6 - {"id": 281474976710660, "label": "", "properties": {"i": true, "j": false, "id": 4}}::vertex | "not count" + {"id": 281474976710658, "label": "", "properties": {"i": "a", "j": "b", "id": 2}}::vertex | "not count" {"id": 281474976710662, "label": "", "properties": {"i": {}, "j": {"i": 1}, "id": 6}}::vertex | "not count" + {"id": 281474976710660, "label": "", "properties": {"i": true, "j": false, "id": 4}}::vertex | "not count" (6 rows) --count(n) @@ -7472,12 +7506,12 @@ SELECT * FROM cypher('case_statement', $$ $$ ) AS (n agtype, case_statement agtype); n | case_statement ------------------------------------------------------------------------------------------------+---------------- - {"id": 281474976710658, "label": "", "properties": {"i": "a", "j": "b", "id": 2}}::vertex | "not count" {"id": 281474976710661, "label": "", "properties": {"i": [], "j": [0, 1, 2], "id": 5}}::vertex | "not count" {"id": 281474976710657, "label": "", "properties": {"i": 1, "id": 1}}::vertex | "not count" {"id": 281474976710659, "label": "", "properties": {"i": 0, "j": 1, "id": 3}}::vertex | 1 - {"id": 281474976710660, "label": "", "properties": {"i": true, "j": false, "id": 4}}::vertex | "not count" + {"id": 281474976710658, "label": "", "properties": {"i": "a", "j": "b", "id": 2}}::vertex | "not count" {"id": 281474976710662, "label": "", "properties": {"i": {}, "j": {"i": 1}, "id": 6}}::vertex | "not count" + {"id": 281474976710660, "label": "", "properties": {"i": true, "j": false, "id": 4}}::vertex | "not count" (6 rows) --concatenated @@ -7490,12 +7524,12 @@ SELECT * FROM cypher('case_statement', $$ $$ ) AS (n agtype, case_statement agtype); n | case_statement ------------------------------------------------------------------------------------------------+---------------- - {"id": 281474976710658, "label": "", "properties": {"i": "a", "j": "b", "id": 2}}::vertex | "not count" {"id": 281474976710661, "label": "", "properties": {"i": [], "j": [0, 1, 2], "id": 5}}::vertex | "not count" {"id": 281474976710657, "label": "", "properties": {"i": 1, "id": 1}}::vertex | "not count" {"id": 281474976710659, "label": "", "properties": {"i": 0, "j": 1, "id": 3}}::vertex | 6 - {"id": 281474976710660, "label": "", "properties": {"i": true, "j": false, "id": 4}}::vertex | "not count" + {"id": 281474976710658, "label": "", "properties": {"i": "a", "j": "b", "id": 2}}::vertex | "not count" {"id": 281474976710662, "label": "", "properties": {"i": {}, "j": {"i": 1}, "id": 6}}::vertex | "not count" + {"id": 281474976710660, "label": "", "properties": {"i": true, "j": false, "id": 4}}::vertex | "not count" (6 rows) --count(1) @@ -7508,12 +7542,12 @@ SELECT * FROM cypher('case_statement', $$ $$ ) AS (n agtype, case_statement agtype); n | case_statement ------------------------------------------------------------------------------------------------+---------------- - {"id": 281474976710658, "label": "", "properties": {"i": "a", "j": "b", "id": 2}}::vertex | "not count" {"id": 281474976710661, "label": "", "properties": {"i": [], "j": [0, 1, 2], "id": 5}}::vertex | "not count" {"id": 281474976710657, "label": "", "properties": {"i": 1, "id": 1}}::vertex | "not count" {"id": 281474976710659, "label": "", "properties": {"i": 0, "j": 1, "id": 3}}::vertex | 1 - {"id": 281474976710660, "label": "", "properties": {"i": true, "j": false, "id": 4}}::vertex | "not count" + {"id": 281474976710658, "label": "", "properties": {"i": "a", "j": "b", "id": 2}}::vertex | "not count" {"id": 281474976710662, "label": "", "properties": {"i": {}, "j": {"i": 1}, "id": 6}}::vertex | "not count" + {"id": 281474976710660, "label": "", "properties": {"i": true, "j": false, "id": 4}}::vertex | "not count" (6 rows) --concatenated @@ -7526,12 +7560,12 @@ SELECT * FROM cypher('case_statement', $$ $$ ) AS (n agtype, case_statement agtype); n | case_statement ------------------------------------------------------------------------------------------------+---------------- - {"id": 281474976710658, "label": "", "properties": {"i": "a", "j": "b", "id": 2}}::vertex | "not count" {"id": 281474976710661, "label": "", "properties": {"i": [], "j": [0, 1, 2], "id": 5}}::vertex | "not count" {"id": 281474976710657, "label": "", "properties": {"i": 1, "id": 1}}::vertex | "not count" {"id": 281474976710659, "label": "", "properties": {"i": 0, "j": 1, "id": 3}}::vertex | 6 - {"id": 281474976710660, "label": "", "properties": {"i": true, "j": false, "id": 4}}::vertex | "not count" + {"id": 281474976710658, "label": "", "properties": {"i": "a", "j": "b", "id": 2}}::vertex | "not count" {"id": 281474976710662, "label": "", "properties": {"i": {}, "j": {"i": 1}, "id": 6}}::vertex | "not count" + {"id": 281474976710660, "label": "", "properties": {"i": true, "j": false, "id": 4}}::vertex | "not count" (6 rows) --CASE with EXISTS() @@ -8039,17 +8073,17 @@ SELECT * FROM cypher('list', $$ RETURN tail(["a","b","c","d","e"]) $$) AS (tail ["b", "c", "d", "e"] (1 row) --- should return null +-- should return an empty list SELECT * FROM cypher('list', $$ RETURN tail([1]) $$) AS (tail agtype); tail ------ - + [] (1 row) SELECT * FROM cypher('list', $$ RETURN tail([]) $$) AS (tail agtype); tail ------ - + [] (1 row) -- should throw errors @@ -8354,11 +8388,11 @@ $$) AS (vle_array agtype); vle_array -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] - [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] (6 rows) SELECT * FROM cypher('expr', $$ @@ -8368,8 +8402,8 @@ SELECT * FROM cypher('expr', $$ $$) AS (vle_array agtype); vle_array -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] (2 rows) SELECT * FROM cypher('expr', $$ @@ -8388,12 +8422,12 @@ SELECT * FROM cypher('expr', $$ $$) AS (vle_array agtype); vle_array -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] - [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] (6 rows) -- head @@ -8403,11 +8437,11 @@ SELECT * FROM cypher('expr', $$ $$) AS (head agtype); head --------------------------------------------------------------------------------------------------------------------------- - {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge - {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge + {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge + {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge (6 rows) @@ -8418,12 +8452,12 @@ SELECT * FROM cypher('expr', $$ $$) AS (head agtype); head -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] - [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] (6 rows) SELECT * FROM cypher('expr', $$ @@ -8433,12 +8467,12 @@ SELECT * FROM cypher('expr', $$ $$) AS (head agtype); head -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] - [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] (6 rows) SELECT * FROM cypher('expr', $$ @@ -8459,11 +8493,11 @@ $$) AS (head agtype); head --------------------------------------------------------------------------------------------------------------------------- {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge - {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge + {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge - {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge + {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge (6 rows) SELECT * FROM cypher('expr', $$ @@ -8473,9 +8507,9 @@ SELECT * FROM cypher('expr', $$ $$) AS (head agtype); head ----------------------------------------------------------------------------------------------------------------------------- - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] (4 rows) @@ -8486,12 +8520,12 @@ SELECT * FROM cypher('expr', $$ $$) AS (head agtype); head -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] - [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] (6 rows) SELECT * FROM cypher('expr', $$ @@ -8522,11 +8556,11 @@ $$) AS (head agtype); head -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] - [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] (6 rows) SELECT * FROM cypher('expr', $$ @@ -8537,11 +8571,11 @@ $$) AS (head agtype); head -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] - [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] (6 rows) SELECT * FROM cypher('expr', $$ @@ -8552,11 +8586,11 @@ $$) AS (head agtype); head -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] - [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] (6 rows) -- reverse @@ -8567,11 +8601,11 @@ $$) as (u agtype); u -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] - [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] (6 rows) SELECT * FROM cypher('expr', $$ @@ -8581,12 +8615,12 @@ SELECT * FROM cypher('expr', $$ $$) as (u agtype); u -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] - [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge, {"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] (6 rows) -- IN operator @@ -8611,8 +8645,8 @@ $$) AS (a agtype); a -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge] - [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] [{"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] + [{"id": 1407374883553282, "label": "e1", "end_id": 1125899906842626, "start_id": 1125899906842625, "properties": {}}::edge, {"id": 1407374883553281, "label": "e1", "end_id": 1125899906842627, "start_id": 1125899906842626, "properties": {}}::edge] (3 rows) SELECT * FROM cypher('expr', $$ @@ -9217,9 +9251,1099 @@ SELECT * FROM cypher('issue_2289', $$ RETURN (1 IN []) AS v $$) AS (v agtype); false (1 row) +-- +-- Accessor functions and properties extraction +-- without _agtype_build.. functions +-- +SELECT * FROM create_graph('accessor_opt'); +NOTICE: graph "accessor_opt" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('accessor_opt', $$ + CREATE (a:Person {name: 'Alice', age: 30})-[r:KNOWS {since: 2020}]->(b:Person {name: 'Bob', age: 25}) +$$) AS (a agtype); + a +--- +(0 rows) + +-- +-- Vertex accessor tests +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN id(n) +$$) AS (plan agtype); + QUERY PLAN +------------------------------------- + Seq Scan on accessor_opt."Person" n + Output: (n.id)::agtype +(2 rows) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN properties(n) +$$) AS (plan agtype); + QUERY PLAN +------------------------------------- + Seq Scan on accessor_opt."Person" n + Output: n.properties +(2 rows) + +SELECT * FROM cypher('accessor_opt', $$ + MATCH (n:Person) + RETURN label(n) +$$) AS (plan agtype); + plan +---------- + "Person" + "Person" +(2 rows) + +-- should use n.properties in _agtype_access_operator +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN n.name, n.age +$$) AS (a agtype, b agtype); + QUERY PLAN +--------------------------------------------------------------------------------------------------------------------------------------------------------- + Seq Scan on accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]), agtype_access_operator(VARIADIC ARRAY[n.properties, '"age"'::agtype]) +(2 rows) + +-- +-- Edge accessor tests +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN id(r) +$$) AS (plan agtype); + QUERY PLAN +----------------------------------------------------------------------------------------------------------------- + Merge Join + Output: (r.id)::agtype + Merge Cond: (_age_default_alias_1.id = r.end_id) + -> Merge Append + Sort Key: _age_default_alias_1.id + -> Index Only Scan using _ag_label_vertex_pkey on accessor_opt._ag_label_vertex _age_default_alias_1_1 + Output: _age_default_alias_1_1.id + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_1_2 + Output: _age_default_alias_1_2.id + -> Materialize + Output: r.id, r.end_id + -> Nested Loop + Output: r.id, r.end_id + -> Index Scan using "KNOWS_end_id_idx" on accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Append + -> Seq Scan on accessor_opt._ag_label_vertex _age_default_alias_0_1 + Output: _age_default_alias_0_1.id + Filter: (_age_default_alias_0_1.id = r.start_id) + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_0_2 + Output: _age_default_alias_0_2.id + Index Cond: (_age_default_alias_0_2.id = r.start_id) +(22 rows) + +SELECT * FROM cypher('accessor_opt', $$ + MATCH ()-[r:KNOWS]->() + RETURN type(r) +$$) AS (plan agtype); + plan +--------- + "KNOWS" +(1 row) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN start_id(r) +$$) AS (plan agtype); + QUERY PLAN +----------------------------------------------------------------------------------------------------------------- + Merge Join + Output: (r.start_id)::agtype + Merge Cond: (_age_default_alias_1.id = r.end_id) + -> Merge Append + Sort Key: _age_default_alias_1.id + -> Index Only Scan using _ag_label_vertex_pkey on accessor_opt._ag_label_vertex _age_default_alias_1_1 + Output: _age_default_alias_1_1.id + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_1_2 + Output: _age_default_alias_1_2.id + -> Materialize + Output: r.start_id, r.end_id + -> Nested Loop + Output: r.start_id, r.end_id + -> Index Scan using "KNOWS_end_id_idx" on accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Append + -> Seq Scan on accessor_opt._ag_label_vertex _age_default_alias_0_1 + Output: _age_default_alias_0_1.id + Filter: (_age_default_alias_0_1.id = r.start_id) + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_0_2 + Output: _age_default_alias_0_2.id + Index Cond: (_age_default_alias_0_2.id = r.start_id) +(22 rows) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN end_id(r) +$$) AS (plan agtype); + QUERY PLAN +----------------------------------------------------------------------------------------------------------------- + Merge Join + Output: (r.end_id)::agtype + Merge Cond: (_age_default_alias_1.id = r.end_id) + -> Merge Append + Sort Key: _age_default_alias_1.id + -> Index Only Scan using _ag_label_vertex_pkey on accessor_opt._ag_label_vertex _age_default_alias_1_1 + Output: _age_default_alias_1_1.id + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_1_2 + Output: _age_default_alias_1_2.id + -> Materialize + Output: r.end_id + -> Nested Loop + Output: r.end_id + -> Index Scan using "KNOWS_end_id_idx" on accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Append + -> Seq Scan on accessor_opt._ag_label_vertex _age_default_alias_0_1 + Output: _age_default_alias_0_1.id + Filter: (_age_default_alias_0_1.id = r.start_id) + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_0_2 + Output: _age_default_alias_0_2.id + Index Cond: (_age_default_alias_0_2.id = r.start_id) +(22 rows) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN properties(r) +$$) AS (plan agtype); + QUERY PLAN +----------------------------------------------------------------------------------------------------------------- + Merge Join + Output: r.properties + Merge Cond: (_age_default_alias_1.id = r.end_id) + -> Merge Append + Sort Key: _age_default_alias_1.id + -> Index Only Scan using _ag_label_vertex_pkey on accessor_opt._ag_label_vertex _age_default_alias_1_1 + Output: _age_default_alias_1_1.id + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_1_2 + Output: _age_default_alias_1_2.id + -> Materialize + Output: r.properties, r.end_id + -> Nested Loop + Output: r.properties, r.end_id + -> Index Scan using "KNOWS_end_id_idx" on accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Append + -> Seq Scan on accessor_opt._ag_label_vertex _age_default_alias_0_1 + Output: _age_default_alias_0_1.id + Filter: (_age_default_alias_0_1.id = r.start_id) + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_0_2 + Output: _age_default_alias_0_2.id + Index Cond: (_age_default_alias_0_2.id = r.start_id) +(22 rows) + +-- should use r.properties in _agtype_access_operator +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN r.since +$$) AS (plan agtype); + QUERY PLAN +----------------------------------------------------------------------------------------------------------------- + Merge Join + Output: agtype_access_operator(VARIADIC ARRAY[r.properties, '"since"'::agtype]) + Merge Cond: (_age_default_alias_1.id = r.end_id) + -> Merge Append + Sort Key: _age_default_alias_1.id + -> Index Only Scan using _ag_label_vertex_pkey on accessor_opt._ag_label_vertex _age_default_alias_1_1 + Output: _age_default_alias_1_1.id + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_1_2 + Output: _age_default_alias_1_2.id + -> Materialize + Output: r.properties, r.end_id + -> Nested Loop + Output: r.properties, r.end_id + -> Index Scan using "KNOWS_end_id_idx" on accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Append + -> Seq Scan on accessor_opt._ag_label_vertex _age_default_alias_0_1 + Output: _age_default_alias_0_1.id + Filter: (_age_default_alias_0_1.id = r.start_id) + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_0_2 + Output: _age_default_alias_0_2.id + Index Cond: (_age_default_alias_0_2.id = r.start_id) +(22 rows) + +-- +-- Multiple accessors in same query +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN id(n), properties(n), n.name +$$) AS (a agtype, c agtype, d agtype); + QUERY PLAN +---------------------------------------------------------------------------------------------------------------- + Seq Scan on accessor_opt."Person" n + Output: (n.id)::agtype, n.properties, agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]) +(2 rows) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN id(r), start_id(r), end_id(r), properties(r) +$$) AS (a agtype, c agtype, d agtype, e agtype); + QUERY PLAN +----------------------------------------------------------------------------------------------------------------- + Merge Join + Output: (r.id)::agtype, (r.start_id)::agtype, (r.end_id)::agtype, r.properties + Merge Cond: (_age_default_alias_1.id = r.end_id) + -> Merge Append + Sort Key: _age_default_alias_1.id + -> Index Only Scan using _ag_label_vertex_pkey on accessor_opt._ag_label_vertex _age_default_alias_1_1 + Output: _age_default_alias_1_1.id + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_1_2 + Output: _age_default_alias_1_2.id + -> Materialize + Output: r.id, r.start_id, r.end_id, r.properties + -> Nested Loop + Output: r.id, r.start_id, r.end_id, r.properties + -> Index Scan using "KNOWS_end_id_idx" on accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Append + -> Seq Scan on accessor_opt._ag_label_vertex _age_default_alias_0_1 + Output: _age_default_alias_0_1.id + Filter: (_age_default_alias_0_1.id = r.start_id) + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_0_2 + Output: _age_default_alias_0_2.id + Index Cond: (_age_default_alias_0_2.id = r.start_id) +(22 rows) + +-- +-- Accessors in WHERE clause +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WHERE id(n) > 0 + RETURN n.name +$$) AS (plan agtype); + QUERY PLAN +---------------------------------------------------------------------------------- + Bitmap Heap Scan on accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]) + Recheck Cond: (n.id > '0'::graphid) + -> Bitmap Index Scan on "Person_pkey" + Index Cond: (n.id > '0'::graphid) +(5 rows) + +-- Compare two node ids +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person)-[r:KNOWS]->(b:Person) + WHERE id(a) < id(b) + RETURN a.name, b.name +$$) AS (aname agtype, bname agtype); + QUERY PLAN +---------------------------------------------------------------------------------------------------------------------------------------------------------- + Hash Join + Output: agtype_access_operator(VARIADIC ARRAY[a.properties, '"name"'::agtype]), agtype_access_operator(VARIADIC ARRAY[b.properties, '"name"'::agtype]) + Inner Unique: true + Hash Cond: (r.end_id = b.id) + Join Filter: (a.id < b.id) + -> Hash Join + Output: a.properties, a.id, r.end_id + Inner Unique: true + Hash Cond: (r.start_id = a.id) + -> Seq Scan on accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Hash + Output: a.properties, a.id + -> Seq Scan on accessor_opt."Person" a + Output: a.properties, a.id + -> Hash + Output: b.properties, b.id + -> Seq Scan on accessor_opt."Person" b + Output: b.properties, b.id +(19 rows) + +-- Compare edge start_id and end_id +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + WHERE start_id(r) <> end_id(r) + RETURN id(r) +$$) AS (rid agtype); + QUERY PLAN +----------------------------------------------------------------------------------------------------------------- + Merge Join + Output: (r.id)::agtype + Merge Cond: (_age_default_alias_1.id = r.end_id) + -> Merge Append + Sort Key: _age_default_alias_1.id + -> Index Only Scan using _ag_label_vertex_pkey on accessor_opt._ag_label_vertex _age_default_alias_1_1 + Output: _age_default_alias_1_1.id + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_1_2 + Output: _age_default_alias_1_2.id + -> Materialize + Output: r.id, r.end_id + -> Nested Loop + Output: r.id, r.end_id + -> Index Scan using "KNOWS_end_id_idx" on accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + Filter: (r.start_id <> r.end_id) + -> Append + -> Seq Scan on accessor_opt._ag_label_vertex _age_default_alias_0_1 + Output: _age_default_alias_0_1.id + Filter: (_age_default_alias_0_1.id = r.start_id) + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_0_2 + Output: _age_default_alias_0_2.id + Index Cond: (_age_default_alias_0_2.id = r.start_id) +(23 rows) + +-- Property comparison +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WHERE n.age >= 25 + RETURN n.name +$$) AS (name agtype); + QUERY PLAN +--------------------------------------------------------------------------------------------------- + Seq Scan on accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]) + Filter: (agtype_access_operator(VARIADIC ARRAY[n.properties, '"age"'::agtype]) >= '25'::agtype) +(3 rows) + +-- Multiple property comparisons +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WHERE n.age > 20 AND n.name <> 'Unknown' + RETURN n.name +$$) AS (name agtype); + QUERY PLAN +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Seq Scan on accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]) + Filter: ((agtype_access_operator(VARIADIC ARRAY[n.properties, '"age"'::agtype]) > '20'::agtype) AND (agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]) <> '"Unknown"'::agtype)) +(3 rows) + +-- Compare properties of two nodes +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person)-[r:KNOWS]->(b:Person) + WHERE a.age > b.age + RETURN a.name, b.name +$$) AS (aname agtype, bname agtype); + QUERY PLAN +---------------------------------------------------------------------------------------------------------------------------------------------------------------- + Hash Join + Output: agtype_access_operator(VARIADIC ARRAY[a.properties, '"name"'::agtype]), agtype_access_operator(VARIADIC ARRAY[b.properties, '"name"'::agtype]) + Inner Unique: true + Hash Cond: (r.end_id = b.id) + Join Filter: (agtype_access_operator(VARIADIC ARRAY[a.properties, '"age"'::agtype]) > agtype_access_operator(VARIADIC ARRAY[b.properties, '"age"'::agtype])) + -> Hash Join + Output: a.properties, r.end_id + Inner Unique: true + Hash Cond: (r.start_id = a.id) + -> Seq Scan on accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Hash + Output: a.properties, a.id + -> Seq Scan on accessor_opt."Person" a + Output: a.properties, a.id + -> Hash + Output: b.properties, b.id + -> Seq Scan on accessor_opt."Person" b + Output: b.properties, b.id +(19 rows) + +-- Compare id with property +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WHERE id(n) > n.age + RETURN n.name +$$) AS (name agtype); + QUERY PLAN +----------------------------------------------------------------------------------------------------- + Seq Scan on accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]) + Filter: (n.id > (agtype_access_operator(VARIADIC ARRAY[n.properties, '"age"'::agtype]))::graphid) +(3 rows) + +-- Edge property comparison +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + WHERE r.since > 2015 + RETURN r.since +$$) AS (since agtype); + QUERY PLAN +------------------------------------------------------------------------------------------------------------------------ + Merge Join + Output: agtype_access_operator(VARIADIC ARRAY[r.properties, '"since"'::agtype]) + Merge Cond: (_age_default_alias_1.id = r.end_id) + -> Merge Append + Sort Key: _age_default_alias_1.id + -> Index Only Scan using _ag_label_vertex_pkey on accessor_opt._ag_label_vertex _age_default_alias_1_1 + Output: _age_default_alias_1_1.id + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_1_2 + Output: _age_default_alias_1_2.id + -> Materialize + Output: r.properties, r.end_id + -> Nested Loop + Output: r.properties, r.end_id + -> Index Scan using "KNOWS_end_id_idx" on accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + Filter: (agtype_access_operator(VARIADIC ARRAY[r.properties, '"since"'::agtype]) > '2015'::agtype) + -> Append + -> Seq Scan on accessor_opt._ag_label_vertex _age_default_alias_0_1 + Output: _age_default_alias_0_1.id + Filter: (_age_default_alias_0_1.id = r.start_id) + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_0_2 + Output: _age_default_alias_0_2.id + Index Cond: (_age_default_alias_0_2.id = r.start_id) +(23 rows) + +-- IS NOT NULL on accessor +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WHERE properties(n) IS NOT NULL + RETURN n.name +$$) AS (name agtype); + QUERY PLAN +---------------------------------------------------------------------------------- + Seq Scan on accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]) +(2 rows) + +-- label() comparison +SELECT * FROM cypher('accessor_opt', $$ + MATCH (n) + WHERE label(n) = 'Person' + RETURN n.name +$$) AS (name agtype); + name +--------- + "Alice" + "Bob" +(2 rows) + +-- type() comparison on edge +SELECT * FROM cypher('accessor_opt', $$ + MATCH ()-[r]->() + WHERE type(r) = 'KNOWS' + RETURN id(r) +$$) AS (rid agtype); + rid +------------------ + 1125899906842625 +(1 row) + +-- +-- Accessors in ORDER BY +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN n.name + ORDER BY n.name +$$) AS (plan agtype); + QUERY PLAN +---------------------------------------------------------------------------------------- + Sort + Output: (agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype])) + Sort Key: (agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype])) + -> Seq Scan on accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]) +(5 rows) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN n.name + ORDER BY id(n) +$$) AS (plan agtype); + QUERY PLAN +---------------------------------------------------------------------------------------------- + Subquery Scan on _ + Output: _.name + -> Index Scan using "Person_pkey" on accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]), n.id +(4 rows) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN n.name, n.age + ORDER BY n.age, n.name +$$) AS (name agtype, age agtype); + QUERY PLAN +--------------------------------------------------------------------------------------------------------------------------------------------------------------- + Sort + Output: (agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype])), (agtype_access_operator(VARIADIC ARRAY[n.properties, '"age"'::agtype])) + Sort Key: (agtype_access_operator(VARIADIC ARRAY[n.properties, '"age"'::agtype])), (agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype])) + -> Seq Scan on accessor_opt."Person" n + Output: agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]), agtype_access_operator(VARIADIC ARRAY[n.properties, '"age"'::agtype]) +(5 rows) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN r.since + ORDER BY start_id(r) +$$) AS (since agtype); + QUERY PLAN +----------------------------------------------------------------------------------------------------------------------- + Subquery Scan on _ + Output: _.since + -> Merge Join + Output: agtype_access_operator(VARIADIC ARRAY[r.properties, '"since"'::agtype]), r.start_id + Merge Cond: (_age_default_alias_0.id = r.start_id) + -> Merge Append + Sort Key: _age_default_alias_0.id + -> Index Only Scan using _ag_label_vertex_pkey on accessor_opt._ag_label_vertex _age_default_alias_0_1 + Output: _age_default_alias_0_1.id + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_0_2 + Output: _age_default_alias_0_2.id + -> Materialize + Output: r.properties, r.start_id + -> Nested Loop + Output: r.properties, r.start_id + -> Index Scan using "KNOWS_start_id_idx" on accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Append + -> Seq Scan on accessor_opt._ag_label_vertex _age_default_alias_1_1 + Output: _age_default_alias_1_1.id + Filter: (_age_default_alias_1_1.id = r.end_id) + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" _age_default_alias_1_2 + Output: _age_default_alias_1_2.id + Index Cond: (_age_default_alias_1_2.id = r.end_id) +(24 rows) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person)-[r:KNOWS]->(b:Person) + RETURN a.name, b.name + ORDER BY id(a), id(r), id(b) +$$) AS (aname agtype, bname agtype); + QUERY PLAN +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Subquery Scan on _ + Output: _.name, _.name_1 + -> Sort + Output: (agtype_access_operator(VARIADIC ARRAY[a.properties, '"name"'::agtype])), (agtype_access_operator(VARIADIC ARRAY[b.properties, '"name"'::agtype])), a.id, r.id, b.id + Sort Key: a.id, r.id, b.id + -> Hash Join + Output: agtype_access_operator(VARIADIC ARRAY[a.properties, '"name"'::agtype]), agtype_access_operator(VARIADIC ARRAY[b.properties, '"name"'::agtype]), a.id, r.id, b.id + Inner Unique: true + Hash Cond: (r.end_id = b.id) + -> Hash Join + Output: a.properties, a.id, r.id, r.end_id + Inner Unique: true + Hash Cond: (r.start_id = a.id) + -> Seq Scan on accessor_opt."KNOWS" r + Output: r.id, r.start_id, r.end_id, r.properties + -> Hash + Output: a.properties, a.id + -> Seq Scan on accessor_opt."Person" a + Output: a.properties, a.id + -> Hash + Output: b.properties, b.id + -> Seq Scan on accessor_opt."Person" b + Output: b.properties, b.id +(23 rows) + +-- +-- Accessors in expressions +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN [id(n), n.name] +$$) AS (plan agtype); + QUERY PLAN +----------------------------------------------------------------------------------------------------------- + Seq Scan on accessor_opt."Person" n + Output: agtype_build_list(n.id, agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype])) +(2 rows) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN {id: id(n), name: n.name} +$$) AS (plan agtype); + QUERY PLAN +------------------------------------------------------------------------------------------------------------------------------------ + Seq Scan on accessor_opt."Person" n + Output: agtype_build_map('id'::text, n.id, 'name'::text, agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype])) +(2 rows) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN n {.name, .age} +$$) AS (plan agtype); + QUERY PLAN +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Seq Scan on accessor_opt."Person" n + Output: agtype_build_map_nonull('name'::text, agtype_access_operator(VARIADIC ARRAY[n.properties, '"name"'::agtype]), 'age'::text, agtype_access_operator(VARIADIC ARRAY[n.properties, '"age"'::agtype])) +(2 rows) + +-- +-- Accessor function in multiple match +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person) + WHERE id(a) > 0 + MATCH (a)-[r]->(b) + WHERE id(b) > 0 + RETURN properties(a), start_id(r), end_id(r) +$$) AS (props_a agtype, sid agtype, eid agtype); + QUERY PLAN +------------------------------------------------------------------ + Hash Join + Output: a.properties, (r.start_id)::agtype, (r.end_id)::agtype + Hash Cond: (r.end_id = b.id) + -> Hash Join + Output: a.properties, r.start_id, r.end_id + Inner Unique: true + Hash Cond: (r.start_id = a.id) + -> Append + -> Seq Scan on accessor_opt._ag_label_edge r_1 + Output: r_1.start_id, r_1.end_id + -> Seq Scan on accessor_opt."KNOWS" r_2 + Output: r_2.start_id, r_2.end_id + -> Hash + Output: a.properties, a.id + -> Bitmap Heap Scan on accessor_opt."Person" a + Output: a.properties, a.id + Recheck Cond: (a.id > '0'::graphid) + -> Bitmap Index Scan on "Person_pkey" + Index Cond: (a.id > '0'::graphid) + -> Hash + Output: b.id + -> Append + -> Seq Scan on accessor_opt._ag_label_vertex b_1 + Output: b_1.id + Filter: (b_1.id > '0'::graphid) + -> Bitmap Heap Scan on accessor_opt."Person" b_2 + Output: b_2.id + Recheck Cond: (b_2.id > '0'::graphid) + -> Bitmap Index Scan on "Person_pkey" + Index Cond: (b_2.id > '0'::graphid) +(30 rows) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person) + MATCH (b:Person) + WHERE b.name <> a.name + RETURN a.name, b.name +$$) AS (a_name agtype, b_name agtype); + QUERY PLAN +------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Nested Loop + Output: agtype_access_operator(VARIADIC ARRAY[a.properties, '"name"'::agtype]), agtype_access_operator(VARIADIC ARRAY[b.properties, '"name"'::agtype]) + Join Filter: (agtype_access_operator(VARIADIC ARRAY[b.properties, '"name"'::agtype]) <> agtype_access_operator(VARIADIC ARRAY[a.properties, '"name"'::agtype])) + -> Seq Scan on accessor_opt."Person" a + Output: a.id, a.properties + -> Materialize + Output: b.properties + -> Seq Scan on accessor_opt."Person" b + Output: b.properties +(9 rows) + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person) + MATCH (a)-[r1]->(b) + MATCH (b)-[r2]->(c) + RETURN id(a), id(b), id(c), a.name +$$) AS (a_id agtype, b_id agtype, c_id agtype, a_name agtype); + QUERY PLAN +---------------------------------------------------------------------------------------------------------------------------------- + Merge Join + Output: (a.id)::agtype, (b.id)::agtype, (c.id)::agtype, agtype_access_operator(VARIADIC ARRAY[a.properties, '"name"'::agtype]) + Merge Cond: (r2.start_id = r1.end_id) + -> Nested Loop + Output: r2.start_id, c.id + -> Merge Append + Sort Key: r2.start_id + -> Index Scan using _ag_label_edge_start_id_idx on accessor_opt._ag_label_edge r2_1 + Output: r2_1.start_id, r2_1.end_id + -> Index Scan using "KNOWS_start_id_idx" on accessor_opt."KNOWS" r2_2 + Output: r2_2.start_id, r2_2.end_id + -> Append + -> Seq Scan on accessor_opt._ag_label_vertex c_1 + Output: c_1.id + Filter: (c_1.id = r2.end_id) + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" c_2 + Output: c_2.id + Index Cond: (c_2.id = r2.end_id) + -> Materialize + Output: a.id, a.properties, r1.end_id, b.id + -> Merge Join + Output: a.id, a.properties, r1.end_id, b.id + Merge Cond: (b.id = r1.end_id) + -> Merge Append + Sort Key: b.id + -> Index Only Scan using _ag_label_vertex_pkey on accessor_opt._ag_label_vertex b_1 + Output: b_1.id + -> Index Only Scan using "Person_pkey" on accessor_opt."Person" b_2 + Output: b_2.id + -> Sort + Output: a.id, a.properties, r1.end_id + Sort Key: r1.end_id + -> Hash Join + Output: a.id, a.properties, r1.end_id + Inner Unique: true + Hash Cond: (r1.start_id = a.id) + -> Append + -> Seq Scan on accessor_opt._ag_label_edge r1_1 + Output: r1_1.start_id, r1_1.end_id + -> Seq Scan on accessor_opt."KNOWS" r1_2 + Output: r1_2.start_id, r1_2.end_id + -> Hash + Output: a.id, a.properties + -> Seq Scan on accessor_opt."Person" a + Output: a.id, a.properties +(45 rows) + +-- +-- Composite types, vertex and edge +-- +SELECT * FROM create_graph('composite_types'); +NOTICE: graph "composite_types" has been created + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('composite_types', $$ + CREATE (a:Person {name: 'Alice', age: 30})-[r:KNOWS {since: 2020}]->(b:Person {name: 'Bob', age: 25}) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- Return vertex as vertex type +SELECT * FROM cypher('composite_types', $$ + MATCH (n:Person) + RETURN n +$$) AS (n vertex); + n +--------------------------------------------------------------------- + (844424930131969,"""Person""","{""age"": 30, ""name"": ""Alice""}") + (844424930131970,"""Person""","{""age"": 25, ""name"": ""Bob""}") +(2 rows) + +SELECT * FROM cypher('composite_types', $$ + MATCH (n:Person) + RETURN n + ORDER BY n.name +$$) AS (n vertex); + n +--------------------------------------------------------------------- + (844424930131969,"""Person""","{""age"": 30, ""name"": ""Alice""}") + (844424930131970,"""Person""","{""age"": 25, ""name"": ""Bob""}") +(2 rows) + +-- Return edge as edge type +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r:KNOWS]->() + RETURN r +$$) AS (r edge); + r +------------------------------------------------------------------------------------ + (1125899906842625,"""KNOWS""",844424930131970,844424930131969,"{""since"": 2020}") +(1 row) + +-- Return multiple entities +SELECT * FROM cypher('composite_types', $$ + MATCH (a:Person)-[r:KNOWS]->(b:Person) + RETURN a, b +$$) AS (a vertex, b vertex); + a | b +---------------------------------------------------------------------+------------------------------------------------------------------- + (844424930131969,"""Person""","{""age"": 30, ""name"": ""Alice""}") | (844424930131970,"""Person""","{""age"": 25, ""name"": ""Bob""}") +(1 row) + +SELECT * FROM cypher('composite_types', $$ + MATCH (a:Person)-[r:KNOWS]->(b:Person) + RETURN a, r, b +$$) AS (a vertex, r edge, b vertex); + a | r | b +---------------------------------------------------------------------+------------------------------------------------------------------------------------+------------------------------------------------------------------- + (844424930131969,"""Person""","{""age"": 30, ""name"": ""Alice""}") | (1125899906842625,"""KNOWS""",844424930131970,844424930131969,"{""since"": 2020}") | (844424930131970,"""Person""","{""age"": 25, ""name"": ""Bob""}") +(1 row) + +-- Mixed return: entity and agtype property +SELECT * FROM cypher('composite_types', $$ + MATCH (n:Person) + RETURN n, n.name +$$) AS (n vertex, name agtype); + n | name +---------------------------------------------------------------------+--------- + (844424930131969,"""Person""","{""age"": 30, ""name"": ""Alice""}") | "Alice" + (844424930131970,"""Person""","{""age"": 25, ""name"": ""Bob""}") | "Bob" +(2 rows) + +SELECT * FROM cypher('composite_types', $$ + MATCH (n:Person) + RETURN n, n.name + ORDER BY n.name +$$) AS (n vertex, name agtype); + n | name +---------------------------------------------------------------------+--------- + (844424930131969,"""Person""","{""age"": 30, ""name"": ""Alice""}") | "Alice" + (844424930131970,"""Person""","{""age"": 25, ""name"": ""Bob""}") | "Bob" +(2 rows) + +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r:KNOWS]->() + RETURN r, r.since +$$) AS (r edge, since agtype); + r | since +------------------------------------------------------------------------------------+------- + (1125899906842625,"""KNOWS""",844424930131970,844424930131969,"{""since"": 2020}") | 2020 +(1 row) + +-- IN operator with vertex and edge types +EXPLAIN (COSTS OFF) +SELECT * FROM cypher('composite_types', $$ + MATCH (a:Person), (b:Person) + WHERE a <> b AND a IN [a, b] + RETURN a.name +$$) AS (name agtype); + QUERY PLAN +---------------------------------------------------------------------- + Nested Loop + Join Filter: ((a.id <> b.id) AND ((a.id = a.id) OR (a.id = b.id))) + -> Seq Scan on "Person" a + -> Materialize + -> Seq Scan on "Person" b +(5 rows) + +SELECT * FROM cypher('composite_types', $$ + MATCH (a:Person), (b:Person) + WHERE a <> b AND a IN [a, b] + RETURN a.name +$$) AS (name agtype); + name +--------- + "Alice" + "Bob" +(2 rows) + +EXPLAIN (COSTS OFF) +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r1:KNOWS]->(), ()-[r2:KNOWS]->() + WHERE r1 IN [r1, r2] + RETURN id(r1) +$$) AS (id agtype); + QUERY PLAN +------------------------------------------------------------------------------------------------------------ + Merge Join + Merge Cond: (_age_default_alias_1.id = r1.end_id) + -> Merge Append + Sort Key: _age_default_alias_1.id + -> Index Only Scan using _ag_label_vertex_pkey on _ag_label_vertex _age_default_alias_1_1 + -> Index Only Scan using "Person_pkey" on "Person" _age_default_alias_1_2 + -> Materialize + -> Nested Loop + -> Nested Loop + -> Nested Loop + -> Nested Loop + Join Filter: ((r1.id = r1.id) OR (r1.id = r2.id)) + -> Index Scan using "KNOWS_end_id_idx" on "KNOWS" r1 + -> Materialize + -> Seq Scan on "KNOWS" r2 + -> Append + -> Seq Scan on _ag_label_vertex _age_default_alias_2_1 + Filter: (id = r2.start_id) + -> Index Only Scan using "Person_pkey" on "Person" _age_default_alias_2_2 + Index Cond: (id = r2.start_id) + -> Append + -> Seq Scan on _ag_label_vertex _age_default_alias_3_1 + Filter: (id = r2.end_id) + -> Index Only Scan using "Person_pkey" on "Person" _age_default_alias_3_2 + Index Cond: (id = r2.end_id) + -> Append + -> Seq Scan on _ag_label_vertex _age_default_alias_0_1 + Filter: (id = r1.start_id) + -> Index Only Scan using "Person_pkey" on "Person" _age_default_alias_0_2 + Index Cond: (id = r1.start_id) +(30 rows) + +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r1:KNOWS]->(), ()-[r2:KNOWS]->() + WHERE r1 IN [r1, r2] + RETURN id(r1) +$$) AS (id agtype); + id +------------------ + 1125899906842625 +(1 row) + +-- Equality operators with vertex and edge types +EXPLAIN (COSTS OFF) +SELECT * FROM cypher('composite_types', $$ + MATCH (a:Person), (b:Person) + WHERE a = b + RETURN a.name +$$) AS (name agtype); + QUERY PLAN +------------------------ + Seq Scan on "Person" b +(1 row) + +SELECT * FROM cypher('composite_types', $$ + MATCH (a:Person), (b:Person) + WHERE a = b + RETURN a.name +$$) AS (name agtype); + name +--------- + "Alice" + "Bob" +(2 rows) + +EXPLAIN (COSTS OFF) +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r1:KNOWS]->(), ()-[r2:KNOWS]->() + WHERE r1 = r2 + RETURN id(r1) +$$) AS (id agtype); + QUERY PLAN +---------------------------------------------------------------------------------------------------------------- + Merge Join + Merge Cond: (r1.id = r2.id) + -> Sort + Sort Key: r1.id + -> Merge Join + Merge Cond: (_age_default_alias_1.id = r1.end_id) + -> Merge Append + Sort Key: _age_default_alias_1.id + -> Index Only Scan using _ag_label_vertex_pkey on _ag_label_vertex _age_default_alias_1_1 + -> Index Only Scan using "Person_pkey" on "Person" _age_default_alias_1_2 + -> Materialize + -> Nested Loop + -> Index Scan using "KNOWS_end_id_idx" on "KNOWS" r1 + -> Append + -> Seq Scan on _ag_label_vertex _age_default_alias_0_1 + Filter: (id = r1.start_id) + -> Index Only Scan using "Person_pkey" on "Person" _age_default_alias_0_2 + Index Cond: (id = r1.start_id) + -> Sort + Sort Key: r2.id + -> Merge Join + Merge Cond: (_age_default_alias_3.id = r2.end_id) + -> Merge Append + Sort Key: _age_default_alias_3.id + -> Index Only Scan using _ag_label_vertex_pkey on _ag_label_vertex _age_default_alias_3_1 + -> Index Only Scan using "Person_pkey" on "Person" _age_default_alias_3_2 + -> Materialize + -> Nested Loop + -> Index Scan using "KNOWS_end_id_idx" on "KNOWS" r2 + -> Append + -> Seq Scan on _ag_label_vertex _age_default_alias_2_1 + Filter: (id = r2.start_id) + -> Index Only Scan using "Person_pkey" on "Person" _age_default_alias_2_2 + Index Cond: (id = r2.start_id) +(34 rows) + +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r1:KNOWS]->(), ()-[r2:KNOWS]->() + WHERE r1 = r2 + RETURN id(r1) +$$) AS (id agtype); + id +------------------ + 1125899906842625 +(1 row) + +-- Cast vertex and edge to json +SELECT * FROM cypher('composite_types', $$ + MATCH (n:Person) + RETURN n +$$) AS (n json); + n +---------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "Person", "properties": {"age": 30, "name": "Alice"}} + {"id": 844424930131970, "label": "Person", "properties": {"age": 25, "name": "Bob"}} +(2 rows) + +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r:KNOWS]->() + RETURN r +$$) AS (r json); + r +----------------------------------------------------------------------------------------------------------------------------------- + {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {"since": 2020}} +(1 row) + +-- Cast vertex and edge to jsonb +SELECT * FROM cypher('composite_types', $$ + MATCH (n:Person) + RETURN n +$$) AS (n jsonb); + n +---------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "Person", "properties": {"age": 30, "name": "Alice"}} + {"id": 844424930131970, "label": "Person", "properties": {"age": 25, "name": "Bob"}} +(2 rows) + +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r:KNOWS]->() + RETURN r +$$) AS (r jsonb); + r +----------------------------------------------------------------------------------------------------------------------------------- + {"id": 1125899906842625, "label": "KNOWS", "end_id": 844424930131970, "start_id": 844424930131969, "properties": {"since": 2020}} +(1 row) + -- -- Cleanup -- +SELECT * FROM drop_graph('composite_types', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table composite_types._ag_label_vertex +drop cascades to table composite_types._ag_label_edge +drop cascades to table composite_types."Person" +drop cascades to table composite_types."KNOWS" +NOTICE: graph "composite_types" has been dropped + drop_graph +------------ + +(1 row) + +SELECT * FROM drop_graph('accessor_opt', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table accessor_opt._ag_label_vertex +drop cascades to table accessor_opt._ag_label_edge +drop cascades to table accessor_opt."Person" +drop cascades to table accessor_opt."KNOWS" +NOTICE: graph "accessor_opt" has been dropped + drop_graph +------------ + +(1 row) + SELECT * FROM drop_graph('issue_2289', true); NOTICE: drop cascades to 2 other objects DETAIL: drop cascades to table issue_2289._ag_label_vertex @@ -9454,6 +10578,136 @@ NOTICE: graph "list" has been dropped (1 row) +-- +-- Issue 2391 - map literals must preserve keys whose values are null +-- +SELECT create_graph('issue_2391'); +NOTICE: graph "issue_2391" has been created + create_graph +-------------- + +(1 row) + +-- single-key null +SELECT * FROM cypher('issue_2391', $$ + RETURN {a: null} AS m +$$) AS (m agtype); + m +------------- + {"a": null} +(1 row) + +-- multiple null values +SELECT * FROM cypher('issue_2391', $$ + RETURN {companyName: null, sinceYear: null} AS m +$$) AS (m agtype); + m +------------------------------------------ + {"sinceYear": null, "companyName": null} +(1 row) + +-- keys() must see the null-valued key +SELECT * FROM cypher('issue_2391', $$ + RETURN keys({a: null}) AS ks +$$) AS (ks agtype); + ks +------- + ["a"] +(1 row) + +-- coalesce passes a non-null map (map itself is not null) through +SELECT * FROM cypher('issue_2391', $$ + RETURN coalesce({a: null}, null) AS m +$$) AS (m agtype); + m +------------- + {"a": null} +(1 row) + +-- nested map values inside an expression also preserve nulls +SELECT * FROM cypher('issue_2391', $$ + RETURN {outer: {inner: null, kept: 1}} AS m +$$) AS (m agtype); + m +--------------------------------------- + {"outer": {"kept": 1, "inner": null}} +(1 row) + +-- mixed non-null and null values are all preserved +SELECT * FROM cypher('issue_2391', $$ + RETURN {a: 1, b: null, c: 'x'} AS m +$$) AS (m agtype); + m +------------------------------- + {"a": 1, "b": null, "c": "x"} +(1 row) + +-- control: empty map is still empty +SELECT * FROM cypher('issue_2391', $$ + RETURN {} AS m +$$) AS (m agtype); + m +---- + {} +(1 row) + +-- control: CREATE must still strip top-level null properties so +-- setting a property to null removes it from storage +SELECT * FROM cypher('issue_2391', $$ + CREATE (n:Item {keep: 1, drop: null}) RETURN n +$$) AS (n agtype); + n +----------------------------------------------------------------------------- + {"id": 844424930131969, "label": "Item", "properties": {"keep": 1}}::vertex +(1 row) + +SELECT * FROM cypher('issue_2391', $$ + MATCH (n:Item) RETURN n +$$) AS (n agtype); + n +----------------------------------------------------------------------------- + {"id": 844424930131969, "label": "Item", "properties": {"keep": 1}}::vertex +(1 row) + +-- nested map values under a write (CREATE / SET =) are preserved: the +-- top-level property map is null-stripped, but a nested map literal is +-- its own node and keeps its null-valued keys +SELECT * FROM cypher('issue_2391', $$ + CREATE (n:Nested {a: {b: null}}) RETURN n +$$) AS (n agtype); + n +--------------------------------------------------------------------------------------- + {"id": 1125899906842625, "label": "Nested", "properties": {"a": {"b": null}}}::vertex +(1 row) + +SELECT * FROM cypher('issue_2391', $$ + MATCH (n:Nested) SET n = {a: {b: null}} RETURN n +$$) AS (n agtype); + n +--------------------------------------------------------------------------------------- + {"id": 1125899906842625, "label": "Nested", "properties": {"a": {"b": null}}}::vertex +(1 row) + +SELECT * FROM cypher('issue_2391', $$ + MATCH (n:Nested) RETURN n +$$) AS (n agtype); + n +--------------------------------------------------------------------------------------- + {"id": 1125899906842625, "label": "Nested", "properties": {"a": {"b": null}}}::vertex +(1 row) + +SELECT * FROM drop_graph('issue_2391', true); +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table issue_2391._ag_label_vertex +drop cascades to table issue_2391._ag_label_edge +drop cascades to table issue_2391."Item" +drop cascades to table issue_2391."Nested" +NOTICE: graph "issue_2391" has been dropped + drop_graph +------------ + +(1 row) + -- -- End of tests -- diff --git a/regress/expected/extension_security.out b/regress/expected/extension_security.out new file mode 100644 index 000000000..30241623a --- /dev/null +++ b/regress/expected/extension_security.out @@ -0,0 +1,99 @@ +/* + * 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. + */ +LOAD 'age'; +SET search_path TO ag_catalog; +-- +-- pg_upgrade helper functions resolve built-ins from pg_catalog first. +-- +-- Each helper must place pg_catalog ahead of ag_catalog in its search_path, so +-- that built-in functions and operators always resolve to pg_catalog and are +-- not overridden by same-named objects defined in ag_catalog. +-- +SELECT p.proname, + array_to_string(p.proconfig, ', ') AS proconfig +FROM pg_proc p +JOIN pg_namespace n ON n.oid = p.pronamespace +WHERE n.nspname = 'ag_catalog' + AND p.proname IN ('age_prepare_pg_upgrade', 'age_finish_pg_upgrade', + 'age_revert_pg_upgrade_changes', 'age_pg_upgrade_status') +ORDER BY p.proname; + proname | proconfig +-------------------------------+------------------------------------ + age_finish_pg_upgrade | search_path=pg_catalog, ag_catalog + age_pg_upgrade_status | search_path=pg_catalog, ag_catalog + age_prepare_pg_upgrade | search_path=pg_catalog, ag_catalog + age_revert_pg_upgrade_changes | search_path=pg_catalog, ag_catalog +(4 rows) + +-- +-- The helper bodies must not contain unqualified format()/hashtext() calls; +-- those built-ins are explicitly schema-qualified to pg_catalog. +-- +SELECT p.proname, + (p.prosrc ~ '[^.]\mformat\s*\(') AS has_unqualified_format, + (p.prosrc ~ '[^.]\mhashtext\s*\(') AS has_unqualified_hashtext +FROM pg_proc p +JOIN pg_namespace n ON n.oid = p.pronamespace +WHERE n.nspname = 'ag_catalog' + AND p.proname IN ('age_finish_pg_upgrade', 'age_revert_pg_upgrade_changes') +ORDER BY p.proname; + proname | has_unqualified_format | has_unqualified_hashtext +-------------------------------+------------------------+-------------------------- + age_finish_pg_upgrade | f | f + age_revert_pg_upgrade_changes | f | f +(2 rows) + +-- +-- Install-time ownership check: CREATE EXTENSION age installs into ag_catalog +-- only when that schema does not already exist under a different owner. The +-- check compares schema ownership against the installing role. Verify the +-- underlying detection both ways with a probe schema, without disturbing the +-- already-installed extension. +-- +CREATE ROLE age_probe_role NOLOGIN; +CREATE SCHEMA age_probe AUTHORIZATION age_probe_role; +-- A schema owned by a different role is detected as foreign-owned. +SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_namespace n + WHERE n.nspname = 'age_probe' + AND n.nspowner <> (SELECT r.oid FROM pg_catalog.pg_roles r + WHERE r.rolname = current_user) +) AS foreign_owner_detected; + foreign_owner_detected +------------------------ + t +(1 row) + +-- ag_catalog, owned by the current (installing) role here, is not flagged +-- (the check does not false-positive on a normal install). +SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_namespace n + WHERE n.nspname = 'ag_catalog' + AND n.nspowner <> (SELECT r.oid FROM pg_catalog.pg_roles r + WHERE r.rolname = current_user) +) AS installer_owned_flagged; + installer_owned_flagged +------------------------- + f +(1 row) + +DROP SCHEMA age_probe; +DROP ROLE age_probe_role; diff --git a/regress/expected/generated_columns.out b/regress/expected/generated_columns.out new file mode 100644 index 000000000..4e929110c --- /dev/null +++ b/regress/expected/generated_columns.out @@ -0,0 +1,222 @@ +/* + * 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. + */ +/* + * Regression test for issue #2450. + * + * A GENERATED ALWAYS ... STORED column (or any user-added column) on a label + * table adds an attribute AGE's CREATE/MERGE/SET paths do not populate. The + * slot's tuple descriptor is the full relation descriptor, so heap_form_tuple() + * used to read the uninitialized slot entry and segfault. AGE now null-inits the + * entity slot and computes stored generated columns before materializing the + * tuple. Generated columns must be computed on insert and recomputed on update; + * plain user columns must default to NULL rather than crash. + */ +LOAD 'age'; +SET search_path TO ag_catalog; +SELECT create_graph('generated_columns'); +NOTICE: graph "generated_columns" has been created + create_graph +-------------- + +(1 row) + +-- +-- GENERATED ALWAYS ... STORED column on a vertex label +-- +SELECT create_vlabel('generated_columns', 'Product'); +NOTICE: VLabel "Product" has been created + create_vlabel +--------------- + +(1 row) + +ALTER TABLE generated_columns."Product" + ADD COLUMN category varchar(25) + GENERATED ALWAYS AS (agtype_access_operator(properties, '"category"')) STORED; +-- CREATE: the generated column is computed from the new properties +SELECT * FROM cypher('generated_columns', $$ + CREATE (p:Product {category: 'disk', type: 'M1234'}) RETURN p +$$) AS (p agtype); + p +---------------------------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "Product", "properties": {"type": "M1234", "category": "disk"}}::vertex +(1 row) + +-- CREATE where the generation expression yields NULL (no such key) +SELECT * FROM cypher('generated_columns', $$ + CREATE (p:Product {type: 'no-cat'}) RETURN p +$$) AS (p agtype); + p +--------------------------------------------------------------------------------------- + {"id": 844424930131970, "label": "Product", "properties": {"type": "no-cat"}}::vertex +(1 row) + +-- MERGE also computes the generated column +SELECT * FROM cypher('generated_columns', $$ + MERGE (p:Product {category: 'merged'}) RETURN p +$$) AS (p agtype); + p +------------------------------------------------------------------------------------------- + {"id": 844424930131971, "label": "Product", "properties": {"category": "merged"}}::vertex +(1 row) + +-- MERGE again on the same pattern must MATCH (not re-insert); this exercises the +-- matched path where the slot is intentionally left uncleared (issue #2450) +SELECT * FROM cypher('generated_columns', $$ + MERGE (p:Product {category: 'merged'}) RETURN p +$$) AS (p agtype); + p +------------------------------------------------------------------------------------------- + {"id": 844424930131971, "label": "Product", "properties": {"category": "merged"}}::vertex +(1 row) + +-- SET recomputes the generated column from the updated properties +SELECT * FROM cypher('generated_columns', $$ + MATCH (p:Product {category: 'disk'}) SET p.category = 'ssd', p.note = 'x' RETURN p +$$) AS (p agtype); + p +---------------------------------------------------------------------------------------------------------------------- + {"id": 844424930131969, "label": "Product", "properties": {"note": "x", "type": "M1234", "category": "ssd"}}::vertex +(1 row) + +-- The stored generated column always mirrors properties.category +SELECT category, properties FROM generated_columns."Product" ORDER BY category NULLS LAST; + category | properties +----------+--------------------------------------------------- + "merged" | {"category": "merged"} + "ssd" | {"note": "x", "type": "M1234", "category": "ssd"} + | {"type": "no-cat"} +(3 rows) + +-- +-- GENERATED ALWAYS ... STORED column on an edge label +-- +SELECT create_elabel('generated_columns', 'REL'); +NOTICE: ELabel "REL" has been created + create_elabel +--------------- + +(1 row) + +ALTER TABLE generated_columns."REL" + ADD COLUMN kind varchar(25) + GENERATED ALWAYS AS (agtype_access_operator(properties, '"kind"')) STORED; +SELECT * FROM cypher('generated_columns', $$ + CREATE (:Product {category: 'a'})-[r:REL {kind: 'link'}]->(:Product {category: 'b'}) + RETURN r +$$) AS (r agtype); + r +---------------------------------------------------------------------------------------------------------------------------------------- + {"id": 1125899906842625, "label": "REL", "end_id": 844424930131973, "start_id": 844424930131972, "properties": {"kind": "link"}}::edge +(1 row) + +SELECT kind FROM generated_columns."REL"; + kind +-------- + "link" +(1 row) + +-- +-- Plain (non-generated) user column: must not crash; defaults to NULL +-- +SELECT create_vlabel('generated_columns', 'Plain'); +NOTICE: VLabel "Plain" has been created + create_vlabel +--------------- + +(1 row) + +ALTER TABLE generated_columns."Plain" ADD COLUMN tag text; +SELECT * FROM cypher('generated_columns', $$ + CREATE (p:Plain {n: 1}) RETURN p +$$) AS (p agtype); + p +---------------------------------------------------------------------------- + {"id": 1407374883553281, "label": "Plain", "properties": {"n": 1}}::vertex +(1 row) + +SELECT * FROM cypher('generated_columns', $$ + MATCH (p:Plain) SET p.n = 2 RETURN p +$$) AS (p agtype); + p +---------------------------------------------------------------------------- + {"id": 1407374883553281, "label": "Plain", "properties": {"n": 2}}::vertex +(1 row) + +SELECT properties, tag FROM generated_columns."Plain"; + properties | tag +------------+----- + {"n": 2} | +(1 row) + +-- +-- Generated column referencing the tableoid system column: the entity slot must +-- carry tts_tableOid before the generated columns are computed, otherwise the +-- expression sees InvalidOid instead of the label table's OID (issue #2450). +-- +SELECT create_vlabel('generated_columns', 'T'); +NOTICE: VLabel "T" has been created + create_vlabel +--------------- + +(1 row) + +ALTER TABLE generated_columns."T" + ADD COLUMN tbl oid GENERATED ALWAYS AS (tableoid) STORED; +SELECT * FROM cypher('generated_columns', $$ + CREATE (n:T {k: 1}) RETURN n +$$) AS (n agtype); + n +------------------------------------------------------------------------ + {"id": 1688849860263937, "label": "T", "properties": {"k": 1}}::vertex +(1 row) + +SELECT * FROM cypher('generated_columns', $$ + MATCH (n:T) SET n.k = 2 RETURN n +$$) AS (n agtype); + n +------------------------------------------------------------------------ + {"id": 1688849860263937, "label": "T", "properties": {"k": 2}}::vertex +(1 row) + +-- tbl must equal the real label-table OID on both the CREATE and SET paths +SELECT tbl = 'generated_columns."T"'::regclass::oid AS tableoid_ok +FROM generated_columns."T"; + tableoid_ok +------------- + t +(1 row) + +-- +-- Cleanup +-- +SELECT drop_graph('generated_columns', true); +NOTICE: drop cascades to 6 other objects +DETAIL: drop cascades to table generated_columns._ag_label_vertex +drop cascades to table generated_columns._ag_label_edge +drop cascades to table generated_columns."Product" +drop cascades to table generated_columns."REL" +drop cascades to table generated_columns."Plain" +drop cascades to table generated_columns."T" +NOTICE: graph "generated_columns" has been dropped + drop_graph +------------ + +(1 row) + diff --git a/regress/expected/list_comprehension.out b/regress/expected/list_comprehension.out index 5a3756422..63b246479 100644 --- a/regress/expected/list_comprehension.out +++ b/regress/expected/list_comprehension.out @@ -503,6 +503,31 @@ SELECT * FROM cypher('list_comprehension', $$ CREATE n=()-[:edge]->() RETURN [n [{"id": 281474976710664, "label": "", "properties": {}}::vertex, {"id": 281474976710665, "label": "", "properties": {}}::vertex] (1 row) +-- Property access on the list-comprehension loop variable +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [{name:'a'}, {name:'b'}] | x.name] $$) AS (result agtype); + result +------------ + ["a", "b"] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [{n:1}, {n:2}, {n:3}] WHERE x.n > 1 | x.n] $$) AS (result agtype); + result +-------- + [2, 3] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ MATCH p=()-[:edge]->() RETURN [n IN nodes(p) | n.name] $$) AS (result agtype); + result +-------------- + [null, null] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ MATCH (u:csm_match) WITH collect(u) AS ns RETURN [x IN ns | x.list] $$) AS (result agtype); + result +------------------------- + [["abc", "def", "ghi"]] +(1 row) + -- Multiple list comprehensions in RETURN and WITH clause SELECT * FROM cypher('list_comprehension', $$ RETURN [u IN [1,2,3]], [u IN [1,2,3]] $$) AS (result agtype, result2 agtype); result | result2 @@ -577,11 +602,11 @@ LINE 1: ...$$ RETURN [i IN range(0, 10, 2) WHERE i>5 | i^2], i $$) AS (... HINT: variable i does not exist within scope of usage -- Invalid list comprehension SELECT * FROM cypher('list_comprehension', $$ RETURN [1 IN range(0, 10, 2) WHERE 2>5] $$) AS (result agtype); -ERROR: Syntax error at or near IN +ERROR: syntax error at or near IN LINE 1: SELECT * FROM cypher('list_comprehension', $$ RETURN [1 IN r... ^ SELECT * FROM cypher('list_comprehension', $$ RETURN [1 IN range(0, 10, 2) | 1] $$) AS (result agtype); -ERROR: Syntax error at or near IN +ERROR: syntax error at or near IN LINE 1: SELECT * FROM cypher('list_comprehension', $$ RETURN [1 IN r... ^ -- Issue - error using list comprehension with WITH * @@ -721,6 +746,102 @@ SELECT * FROM cypher('list_comprehension', $$ MATCH (u {list: [0, 2, 4, 6, 8, 10 {"id": 281474976710668, "label": "", "properties": {"b": [0, 1, 2, 3, 4, 5], "c": [0, 2, 4, 6, 8, 10, 12], "list": [0, 2, 4, 6, 8, 10, 12]}}::vertex (2 rows) +-- Issue 2394 - projection over a null element should propagate null, +-- not concatenate the null and the projection result into a sublist and +-- not error out for operators other than '+'. +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null] | x + 1] $$) AS (result agtype); + result +-------- + [null] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x + 1] $$) AS (result agtype); + result +-------------- + [2, null, 3] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | 1 + x] $$) AS (result agtype); + result +-------------- + [2, null, 3] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x] $$) AS (result agtype); + result +-------------- + [1, null, 2] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x - 1] $$) AS (result agtype); + result +-------------- + [0, null, 1] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x * 2] $$) AS (result agtype); + result +-------------- + [2, null, 4] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x / 1] $$) AS (result agtype); + result +-------------- + [1, null, 2] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x % 2] $$) AS (result agtype); + result +-------------- + [1, null, 0] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x ^ 2] $$) AS (result agtype); + result +------------------ + [1.0, null, 4.0] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | -x] $$) AS (result agtype); + result +---------------- + [-1, null, -2] +(1 row) + +-- Issue 2393 - WHERE filter over null elements should use openCypher's +-- three-valued logic: IS NULL must keep nulls, IS NOT NULL must drop them. +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1] WHERE x IS NULL] $$) AS (result agtype); + result +-------- + [null] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1, null] WHERE x IS NULL] $$) AS (result agtype); + result +-------------- + [null, null] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1] WHERE x IS NOT NULL] $$) AS (result agtype); + result +-------- + [1] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, 2, 3] WHERE x IS NULL] $$) AS (result agtype); + result +-------- + [] +(1 row) + +SELECT * FROM cypher('list_comprehension', $$ UNWIND [null, 1] AS x RETURN x, x IS NULL, x IS NOT NULL $$) AS (x agtype, a agtype, b agtype); + x | a | b +---+-------+------- + | true | false + 1 | false | true +(2 rows) + -- Clean up SELECT * FROM drop_graph('list_comprehension', true); NOTICE: drop cascades to 4 other objects diff --git a/regress/expected/pattern_expression.out b/regress/expected/pattern_expression.out new file mode 100644 index 000000000..93a02e3fa --- /dev/null +++ b/regress/expected/pattern_expression.out @@ -0,0 +1,513 @@ +/* + * 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. + */ +LOAD 'age'; +SET search_path TO ag_catalog; +SELECT create_graph('pattern_expr'); +NOTICE: graph "pattern_expr" has been created + create_graph +-------------- + +(1 row) + +-- +-- Setup test data +-- +SELECT * FROM cypher('pattern_expr', $$ + CREATE (alice:Person {name: 'Alice'})-[:KNOWS]->(bob:Person {name: 'Bob'}), + (alice)-[:WORKS_WITH]->(charlie:Person {name: 'Charlie'}), + (dave:Person {name: 'Dave'}) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- +-- Basic pattern expression in WHERE +-- +-- Bare pattern: (a)-[:REL]->(b) +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (b:Person) + WHERE (a)-[:KNOWS]->(b) + RETURN a.name, b.name + ORDER BY a.name, b.name +$$) AS (a agtype, b agtype); + a | b +---------+------- + "Alice" | "Bob" +(1 row) + +-- +-- NOT pattern expression +-- +-- Find people who don't KNOW anyone +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WHERE NOT (a)-[:KNOWS]->(:Person) + RETURN a.name + ORDER BY a.name +$$) AS (result agtype); + result +----------- + "Bob" + "Charlie" + "Dave" +(3 rows) + +-- +-- Pattern with labeled first node +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (b:Person) + WHERE (a:Person)-[:KNOWS]->(b) + RETURN a.name, b.name + ORDER BY a.name +$$) AS (a agtype, b agtype); + a | b +---------+------- + "Alice" | "Bob" +(1 row) + +-- +-- Pattern combined with AND +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (b:Person) + WHERE (a)-[:KNOWS]->(b) AND a.name = 'Alice' + RETURN a.name, b.name +$$) AS (a agtype, b agtype); + a | b +---------+------- + "Alice" | "Bob" +(1 row) + +-- +-- Pattern combined with OR +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (b:Person) + WHERE (a)-[:KNOWS]->(b) OR (a)-[:WORKS_WITH]->(b) + RETURN a.name, b.name + ORDER BY a.name, b.name +$$) AS (a agtype, b agtype); + a | b +---------+----------- + "Alice" | "Bob" + "Alice" | "Charlie" +(2 rows) + +-- +-- Left-directed pattern +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (b:Person) + WHERE (a)<-[:KNOWS]-(b) + RETURN a.name, b.name + ORDER BY a.name +$$) AS (a agtype, b agtype); + a | b +-------+--------- + "Bob" | "Alice" +(1 row) + +-- +-- Pattern with anonymous nodes +-- +-- Find anyone who has any outgoing KNOWS relationship +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WHERE (a)-[:KNOWS]->() + RETURN a.name + ORDER BY a.name +$$) AS (result agtype); + result +--------- + "Alice" +(1 row) + +-- +-- Multiple relationship pattern +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (c:Person) + WHERE (a)-[:KNOWS]->()-[:WORKS_WITH]->(c) + RETURN a.name, c.name + ORDER BY a.name +$$) AS (a agtype, c agtype); + a | c +---+--- +(0 rows) + +-- +-- Existing EXISTS() syntax still works (backward compatibility) +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (b:Person) + WHERE EXISTS((a)-[:KNOWS]->(b)) + RETURN a.name, b.name + ORDER BY a.name +$$) AS (a agtype, b agtype); + a | b +---------+------- + "Alice" | "Bob" +(1 row) + +-- +-- Pattern expression produces same results as EXISTS() +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WHERE (a)-[:KNOWS]->(:Person) + RETURN a.name + ORDER BY a.name +$$) AS (result agtype); + result +--------- + "Alice" +(1 row) + +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WHERE EXISTS((a)-[:KNOWS]->(:Person)) + RETURN a.name + ORDER BY a.name +$$) AS (result agtype); + result +--------- + "Alice" +(1 row) + +-- +-- Regular (non-pattern) expressions still work (no regression) +-- +SELECT * FROM cypher('pattern_expr', $$ + RETURN (1 + 2) +$$) AS (result agtype); + result +-------- + 3 +(1 row) + +SELECT * FROM cypher('pattern_expr', $$ + MATCH (n:Person) + WHERE n.name = 'Alice' + RETURN (n.name) +$$) AS (result agtype); + result +--------- + "Alice" +(1 row) + +-- +-- Pattern expressions in RETURN (boolean projection) +-- +-- Each person gets a column showing whether they know someone +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, (a)-[:KNOWS]->(:Person) AS knows_someone + ORDER BY a.name +$$) AS (name agtype, knows_someone agtype); + name | knows_someone +-----------+--------------- + "Alice" | true + "Bob" | false + "Charlie" | false + "Dave" | false +(4 rows) + +-- Mix pattern expression with other projections +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, (a)-[:KNOWS]->(:Person), (a)-[:WORKS_WITH]->(:Person) + ORDER BY a.name +$$) AS (name agtype, knows agtype, works_with agtype); + name | knows | works_with +-----------+-------+------------ + "Alice" | true | true + "Bob" | false | false + "Charlie" | false | false + "Dave" | false | false +(4 rows) + +-- +-- Pattern expressions in CASE WHEN +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, + CASE WHEN (a)-[:KNOWS]->(:Person) THEN 'social' + ELSE 'loner' + END + ORDER BY a.name +$$) AS (name agtype, kind agtype); + name | kind +-----------+---------- + "Alice" | "social" + "Bob" | "loner" + "Charlie" | "loner" + "Dave" | "loner" +(4 rows) + +-- +-- Pattern expressions combined with boolean operators in RETURN +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, + (a)-[:KNOWS]->(:Person) AND (a)-[:WORKS_WITH]->(:Person) AS has_both, + (a)-[:KNOWS]->(:Person) OR (a)-[:WORKS_WITH]->(:Person) AS has_either + ORDER BY a.name +$$) AS (name agtype, has_both agtype, has_either agtype); + name | has_both | has_either +-----------+----------+------------ + "Alice" | true | true + "Bob" | false | false + "Charlie" | false | false + "Dave" | false | false +(4 rows) + +-- +-- Pattern expression in SET (store boolean as property) +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + SET a.is_social = (a)-[:KNOWS]->(:Person) + RETURN a.name, a.is_social + ORDER BY a.name +$$) AS (name agtype, is_social agtype); + name | is_social +-----------+----------- + "Alice" | true + "Bob" | false + "Charlie" | false + "Dave" | false +(4 rows) + +-- +-- Pattern expression in WITH (carry boolean through pipeline) +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WITH a.name AS name, (a)-[:KNOWS]->(:Person) AS knows + WHERE knows + RETURN name + ORDER BY name +$$) AS (result agtype); + result +--------- + "Alice" +(1 row) + +-- +-- Follow-up coverage (review #2360): pattern expressions in additional +-- expression contexts opened up by allowing anonymous_path as an expr_atom. +-- +-- +-- Single-node pattern on an already-bound variable: (a:Label) +-- +-- NOTE: as of #2443 a single-node labeled pattern is a correlated label +-- predicate -- in WHERE / EXISTS it tests whether the bound vertex actually +-- has the label (see the WHERE (a:Person) / EXISTS((a:Company)) cases in the +-- #2443 section below). Here the variable is already bound to the SAME label, +-- so the predicate is trivially true (the label matches). A *different* label +-- on an already-bound variable is still rejected by AGE's pre-existing +-- "multiple labels for variable" restriction rather than evaluating to false; +-- that is an orthogonal limitation, captured here so any future change to +-- single-node-pattern semantics is caught by this test. +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, (a:Person) + ORDER BY a.name +$$) AS (name agtype, is_person agtype); + name | is_person +-----------+----------- + "Alice" | true + "Bob" | true + "Charlie" | true + "Dave" | true +(4 rows) + +-- A non-matching label errors (pre-existing limitation, not a regression) +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, (a:Animal) + ORDER BY a.name +$$) AS (name agtype, is_animal agtype); +ERROR: multiple labels for variable 'a' are not supported +LINE 3: RETURN a.name, (a:Animal) + ^ +-- +-- Pattern expressions inside a list literal +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, [(a)-[:KNOWS]->(:Person), (a)-[:WORKS_WITH]->(:Person)] + ORDER BY a.name +$$) AS (name agtype, flags agtype); + name | flags +-----------+---------------- + "Alice" | [true, true] + "Bob" | [false, false] + "Charlie" | [false, false] + "Dave" | [false, false] +(4 rows) + +-- +-- Pattern expressions inside a map literal +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, {knows: (a)-[:KNOWS]->(:Person), works: (a)-[:WORKS_WITH]->(:Person)} + ORDER BY a.name +$$) AS (name agtype, m agtype); + name | m +-----------+---------------------------------- + "Alice" | {"knows": true, "works": true} + "Bob" | {"knows": false, "works": false} + "Charlie" | {"knows": false, "works": false} + "Dave" | {"knows": false, "works": false} +(4 rows) + +-- +-- Pattern expressions as function arguments +-- +-- collect() shows the per-row boolean values are correct (ORDER BY before +-- the aggregate so the collected order is deterministic across scan plans). +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WITH a ORDER BY a.name + RETURN collect((a)-[:KNOWS]->(:Person)) +$$) AS (vals agtype); + vals +----------------------------- + [true, false, false, false] +(1 row) + +-- count() counts non-null values; a boolean (including false) is non-null, +-- so this counts every row rather than only the matching ones. This is the +-- expected SQL aggregate semantics, documented here so the value is not +-- mistaken for a bug. +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN count((a)-[:KNOWS]->(:Person)) +$$) AS (c agtype); + c +--- + 4 +(1 row) + +-- +-- Pattern expression in OPTIONAL MATCH ... WHERE (null-preserving) +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + OPTIONAL MATCH (b:Person) WHERE (a)-[:KNOWS]->(b) + RETURN a.name, b.name + ORDER BY a.name, b.name +$$) AS (a agtype, b agtype); + a | b +-----------+------- + "Alice" | "Bob" + "Bob" | + "Charlie" | + "Dave" | +(4 rows) + +-- +-- EXISTS() and a bare pattern combined in a single predicate +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WHERE EXISTS((a)-[:KNOWS]->(:Person)) AND (a)-[:WORKS_WITH]->(:Person) + RETURN a.name + ORDER BY a.name +$$) AS (name agtype); + name +--------- + "Alice" +(1 row) + +-- +-- Single-node labeled pattern as a boolean (#2443) +-- +-- A bound vertex carrying a label, e.g. (a:Person), must test that vertex's +-- label rather than be trivially true. Add a non-Person vertex so the filter +-- is observable (every other vertex in this graph is a :Person). +SELECT * FROM cypher('pattern_expr', $$ + CREATE (:Company {name: 'Acme'}) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- bare single-node label predicate in WHERE: only the :Person vertices +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a) + WHERE (a:Person) + RETURN a.name + ORDER BY a.name +$$) AS (name agtype); + name +----------- + "Alice" + "Bob" + "Charlie" + "Dave" +(4 rows) + +-- negated: only the non-Person vertex +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a) + WHERE NOT (a:Person) + RETURN a.name + ORDER BY a.name +$$) AS (name agtype); + name +-------- + "Acme" +(1 row) + +-- EXISTS() form of a single-node label predicate +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a) + WHERE EXISTS((a:Company)) + RETURN a.name + ORDER BY a.name +$$) AS (name agtype); + name +-------- + "Acme" +(1 row) + +-- +-- Cleanup +-- +SELECT * FROM drop_graph('pattern_expr', true); +NOTICE: drop cascades to 6 other objects +DETAIL: drop cascades to table pattern_expr._ag_label_vertex +drop cascades to table pattern_expr._ag_label_edge +drop cascades to table pattern_expr."Person" +drop cascades to table pattern_expr."KNOWS" +drop cascades to table pattern_expr."WORKS_WITH" +drop cascades to table pattern_expr."Company" +NOTICE: graph "pattern_expr" has been dropped + drop_graph +------------ + +(1 row) + diff --git a/regress/expected/pgvector.out b/regress/expected/pgvector.out index bbc558349..4af6106ea 100644 --- a/regress/expected/pgvector.out +++ b/regress/expected/pgvector.out @@ -576,7 +576,7 @@ BEGIN USING hnsw ((( agtype_access_operator( VARIADIC ARRAY[ - _agtype_build_vertex(id, _label_name(%L::oid, id), properties), + properties, '"embedding"'::agtype ] )::text @@ -654,7 +654,7 @@ BEGIN USING hnsw (( agtype_access_operator( VARIADIC ARRAY[ - _agtype_build_vertex(id, _label_name(%L::oid, id), properties), + properties, '"embedding"'::agtype ] )::vector(4)) vector_cosine_ops); diff --git a/regress/expected/predicate_functions.out b/regress/expected/predicate_functions.out new file mode 100644 index 000000000..f6957c49f --- /dev/null +++ b/regress/expected/predicate_functions.out @@ -0,0 +1,641 @@ +/* + * 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. + */ +LOAD 'age'; +SET search_path TO ag_catalog; +SELECT create_graph('predicate_functions'); +NOTICE: graph "predicate_functions" has been created + create_graph +-------------- + +(1 row) + +-- +-- all() predicate function +-- +-- all elements satisfy predicate -> true +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [1, 2, 3] WHERE x > 0) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- not all elements satisfy predicate -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [1, 2, 3] WHERE x > 1) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- empty list -> true (vacuous truth) +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [] WHERE x > 0) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- +-- any() predicate function +-- +-- at least one element satisfies -> true +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [1, 2, 3] WHERE x > 2) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- no element satisfies -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [1, 2, 3] WHERE x > 5) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- empty list -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [] WHERE x > 0) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- +-- none() predicate function +-- +-- no element satisfies predicate -> true +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [1, 2, 3] WHERE x > 5) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- at least one satisfies -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [1, 2, 3] WHERE x > 2) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- empty list -> true +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [] WHERE x > 0) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- +-- single() predicate function +-- +-- exactly one element satisfies -> true +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [1, 2, 3] WHERE x > 2) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- more than one satisfies -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [1, 2, 3] WHERE x > 1) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- none satisfies -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [1, 2, 3] WHERE x > 5) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- empty list -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [] WHERE x > 0) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- +-- NULL list input: all four return null +-- (NULL-list guard in the grammar produces CASE WHEN expr IS NULL +-- THEN NULL ELSE END) +-- +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN null WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN null WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN null WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN null WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- +-- NULL predicate results: three-valued logic +-- +-- Null list elements arrive at the predicate as SQL NULL, so the usual +-- strict-operator short-circuit applies: `null > 0` yields NULL, and the +-- predicate functions combine NULLs with the openCypher three-valued +-- logic (true trumps null in any(), false trumps null in all(), etc.). +-- [null]: only null predicate, no true -> any() = NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [null] WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- one true (1 > 0) is enough: any() = true +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [null, 1, 2] WHERE x > 0) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- literal null predicate: pred = SQL NULL -> three-valued logic +-- all([1] WHERE null) = null (unknown) +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [1] WHERE null) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- no false, but one null -> all() = NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [1, null, 2] WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- -1 > 0 = false dominates the null -> all() = false +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [1, null, -1] WHERE x > 0) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- [null]: only null predicate, no true -> none() = NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [null] WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- one true (5 > 0) dominates: none() = false +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [null, 5] WHERE x > 0) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- one definite true (5 > 0) and one null predicate: the null could also +-- be a match, so we cannot conclude exactly-one -> single() = NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [null, 5] WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- two definite trues dominate any null -> single() = false +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [null, 5, 6] WHERE x > 0) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- only null predicates -> single() = NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [null, null] WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- one true + one false + one null: the null could be the second true +-- so we cannot conclude exactly-one -> single() = NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [1, null, -1] WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- +-- Additional null/three-valued coverage for any()/all()/none() +-- +-- any() with no true and one null -> NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [null, -1] WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- all() with one true and one null -> NULL (the null might be false) +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [1, null] WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- all() with one definite false dominates any null -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [null, -1] WHERE x > 0) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- none() with no true and one null -> NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [null, -1] WHERE x > 0) +$$) AS (result agtype); + result +-------- + +(1 row) + +-- IS NULL predicate now sees unwound null elements (issue #2393) +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [1, null] WHERE x IS NULL) +$$) AS (result agtype); + result +-------- + true +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [1, null] WHERE x IS NULL) +$$) AS (result agtype); + result +-------- + false +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [null, null] WHERE x IS NULL) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- +-- Integration with graph data +-- +SELECT * FROM cypher('predicate_functions', $$ + CREATE ({name: 'even', vals: [2, 4, 6, 8]}) +$$) AS (result agtype); + result +-------- +(0 rows) + +SELECT * FROM cypher('predicate_functions', $$ + CREATE ({name: 'mixed', vals: [1, 2, 3, 4]}) +$$) AS (result agtype); + result +-------- +(0 rows) + +SELECT * FROM cypher('predicate_functions', $$ + CREATE ({name: 'odd', vals: [1, 3, 5, 7]}) +$$) AS (result agtype); + result +-------- +(0 rows) + +-- all() with graph properties +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WHERE all(x IN u.vals WHERE x % 2 = 0) + RETURN u.name + ORDER BY u.name +$$) AS (result agtype); + result +-------- + "even" +(1 row) + +-- any() with graph properties +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WHERE any(x IN u.vals WHERE x > 6) + RETURN u.name + ORDER BY u.name +$$) AS (result agtype); + result +-------- + "even" + "odd" +(2 rows) + +-- none() with graph properties +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WHERE none(x IN u.vals WHERE x < 0) + RETURN u.name + ORDER BY u.name +$$) AS (result agtype); + result +--------- + "even" + "mixed" + "odd" +(3 rows) + +-- single() with graph properties +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WHERE single(x IN u.vals WHERE x = 8) + RETURN u.name + ORDER BY u.name +$$) AS (result agtype); + result +-------- + "even" +(1 row) + +-- +-- Property access on the loop variable +-- +-- any: true (2 > 1) / false (none > 5) +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [{n: 1}, {n: 2}] WHERE x.n > 1) +$$) AS (result agtype); + result +-------- + true +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [{n: 1}, {n: 2}] WHERE x.n > 5) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- all: true (both > 0) / false (not all > 1) +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [{n: 1}, {n: 2}] WHERE x.n > 0) +$$) AS (result agtype); + result +-------- + true +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [{n: 1}, {n: 2}] WHERE x.n > 1) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- none: true (neither > 2) / false (one matches) +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [{n: 1}, {n: 2}] WHERE x.n > 2) +$$) AS (result agtype); + result +-------- + true +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [{n: 1}, {n: 2}] WHERE x.n = 1) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- single: true (exactly one) / false (both match) +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [{n: 1}, {n: 2}] WHERE x.n = 1) +$$) AS (result agtype); + result +-------- + true +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [{n: 1}, {n: 2}] WHERE x.n > 0) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- Property access on vertex loop variables over a collected node list +-- any: true ('even' exists) / false (no 'missing') +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN any(x IN ns WHERE x.name = 'even') +$$) AS (result agtype); + result +-------- + true +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN any(x IN ns WHERE x.name = 'missing') +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- all: true (all have non-empty vals) / false (not all named 'even') +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN all(x IN ns WHERE size(x.vals) > 0) +$$) AS (result agtype); + result +-------- + true +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN all(x IN ns WHERE x.name = 'even') +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- none: true (none 'missing') / false ('even' matches) +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN none(x IN ns WHERE x.name = 'missing') +$$) AS (result agtype); + result +-------- + true +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN none(x IN ns WHERE x.name = 'even') +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- single: true (only one 'odd') / false (all have non-empty vals) +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN single(x IN ns WHERE x.name = 'odd') +$$) AS (result agtype); + result +-------- + true +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN single(x IN ns WHERE size(x.vals) > 0) +$$) AS (result agtype); + result +-------- + false +(1 row) + +-- +-- Predicate functions in boolean expressions +-- +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [1, 2, 3] WHERE x > 2) + AND all(y IN [4, 5, 6] WHERE y > 0) +$$) AS (result agtype); + result +-------- + true +(1 row) + +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [1, 2, 3] WHERE x > 5) + OR single(y IN [1, 2, 3] WHERE y = 2) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- +-- Nested predicate functions +-- +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [1, 2, 3] WHERE all(y IN [1, 2] WHERE y < x)) +$$) AS (result agtype); + result +-------- + true +(1 row) + +-- +-- Keywords as property key names (safe_keywords backward compatibility) +-- +SELECT * FROM cypher('predicate_functions', $$ + RETURN {any: 1, none: 2, single: 3} +$$) AS (result agtype); + result +------------------------------------ + {"any": 1, "none": 2, "single": 3} +(1 row) + +-- +-- Cleanup +-- +SELECT * FROM drop_graph('predicate_functions', true); +NOTICE: drop cascades to 2 other objects +DETAIL: drop cascades to table predicate_functions._ag_label_vertex +drop cascades to table predicate_functions._ag_label_edge +NOTICE: graph "predicate_functions" has been dropped + drop_graph +------------ + +(1 row) + diff --git a/regress/expected/reserved_keyword_alias.out b/regress/expected/reserved_keyword_alias.out new file mode 100644 index 000000000..0127dbb0e --- /dev/null +++ b/regress/expected/reserved_keyword_alias.out @@ -0,0 +1,248 @@ +/* + * 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. + */ +/* + * Regression coverage for issue #2355: + * Cypher reserved keywords from the `safe_keywords` set must be accepted + * in alias positions (RETURN/WITH/YIELD ... AS , UNWIND ... AS ). + * Conflicting tokens (END / NULL / TRUE / FALSE) must remain rejected. + * Pattern variable bindings are intentionally still restricted to plain + * identifiers. + */ +LOAD 'age'; +SET search_path TO ag_catalog; +SELECT * FROM create_graph('issue_2355'); +NOTICE: graph "issue_2355" has been created + create_graph +-------------- + +(1 row) + +-- The exact reproducer from the issue (previously failed with +-- "syntax error at or near "count""). +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS count $$) AS (a agtype); + a +--- + 1 +(1 row) + +-- Representative coverage across the safe_keywords set as RETURN aliases. +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS exists $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS coalesce $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS match $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS return $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS where $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS order $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS limit $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS distinct $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS optional $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS detach $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS contains $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS starts $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS ends $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS in $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS is $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS not $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS yield $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS call $$) AS (a agtype); + a +--- + 1 +(1 row) + +-- Multiple keyword aliases in one projection. +SELECT * FROM cypher('issue_2355', + $$ RETURN 1 AS count, 2 AS exists, 3 AS where $$ +) AS (count agtype, ex agtype, w agtype); + count | ex | w +-------+----+--- + 1 | 2 | 3 +(1 row) + +-- WITH ... AS : alias binding works. +SELECT * FROM cypher('issue_2355', + $$ WITH 1 AS count RETURN 1 AS x $$ +) AS (a agtype); + a +--- + 1 +(1 row) + +-- UNWIND ... AS : alias binding works. +SELECT * FROM cypher('issue_2355', + $$ UNWIND [1, 2, 3] AS row RETURN 1 AS x $$ +) AS (a agtype); + a +--- + 1 + 1 + 1 +(3 rows) + +-- conflicted_keywords (END, NULL, TRUE, FALSE) MUST still be rejected +-- because they introduce real grammar ambiguity with literal/expression +-- productions. +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS null $$) AS (a agtype); +ERROR: syntax error at or near "null" +LINE 1: SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS null $$) ... + ^ +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS true $$) AS (a agtype); +ERROR: syntax error at or near "true" +LINE 1: SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS true $$) ... + ^ +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS false $$) AS (a agtype); +ERROR: syntax error at or near "false" +LINE 1: SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS false $$) ... + ^ +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS end $$) AS (a agtype); +ERROR: syntax error at or near "end" +LINE 1: SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS end $$) ... + ^ +-- Pattern-variable positions are intentionally NOT broadened (would +-- create shift/reduce conflicts). Confirm they still error. +SELECT * FROM cypher('issue_2355', + $$ MATCH (count) RETURN 1 AS x $$ +) AS (a agtype); +ERROR: syntax error at or near "count" +LINE 2: $$ MATCH (count) RETURN 1 AS x $$ + ^ +-- Plain identifiers naturally remain unaffected. +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS my_alias $$) AS (a agtype); + a +--- + 1 +(1 row) + +-- Backtick-quoted alias positive case: forces the IDENTIFIER token +-- path, so future grammar refactors don't accidentally regress quoted +-- identifiers when the unquoted form is also a keyword. +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS `count` $$) AS (a agtype); + a +--- + 1 +(1 row) + +SELECT * FROM cypher('issue_2355', $$ WITH 1 AS `count` RETURN `count` $$) AS (a agtype); + a +--- + 1 +(1 row) + +-- Known limitation: reading a keyword-named alias back fails because +-- expr_var reads through var_name (which is unchanged here). Tracked +-- in issue #2416. Captured in the expected output so the next +-- contributor who fixes expr_var has a precise file to update. +SELECT * FROM cypher('issue_2355', $$ WITH 1 AS count RETURN count $$) AS (a agtype); +ERROR: syntax error at end of input +LINE 1: ...her('issue_2355', $$ WITH 1 AS count RETURN count $$) AS (a ... + ^ +SELECT * FROM drop_graph('issue_2355', true); +NOTICE: drop cascades to 2 other objects +DETAIL: drop cascades to table issue_2355._ag_label_vertex +drop cascades to table issue_2355._ag_label_edge +NOTICE: graph "issue_2355" has been dropped + drop_graph +------------ + +(1 row) + diff --git a/regress/expected/scan.out b/regress/expected/scan.out index 46d5676d0..fb7c626a3 100644 --- a/regress/expected/scan.out +++ b/regress/expected/scan.out @@ -294,6 +294,47 @@ $$) AS t(a agtype, b agtype, c agtype); " \" \" ' ' " | " ' ' \" \" " | " / / \\ \b \f \n \r \t " (1 row) +-- doubled-quote escape sequences for SQL driver compatibility (issue #2222) +SELECT * FROM cypher('scan', $$ +RETURN 'isn''t' +$$) AS t(a agtype); + a +--------- + "isn't" +(1 row) + +SELECT * FROM cypher('scan', $$ +RETURN '''hello' +$$) AS t(a agtype); + a +---------- + "'hello" +(1 row) + +SELECT * FROM cypher('scan', $$ +RETURN 'hello''' +$$) AS t(a agtype); + a +---------- + "hello'" +(1 row) + +SELECT * FROM cypher('scan', $$ +RETURN 'it''s a ''test''' +$$) AS t(a agtype); + a +----------------- + "it's a 'test'" +(1 row) + +SELECT * FROM cypher('scan', $$ +RETURN "she said ""hello""" +$$) AS t(a agtype); + a +---------------------- + "she said \"hello\"" +(1 row) + -- invalid escape sequence SELECT * FROM cypher('scan', $$ RETURN "\a" diff --git a/regress/expected/subgraph.out b/regress/expected/subgraph.out new file mode 100644 index 000000000..a27569a5d --- /dev/null +++ b/regress/expected/subgraph.out @@ -0,0 +1,341 @@ +/* + * 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. + */ +LOAD 'age'; +SET search_path TO ag_catalog; +-- Suppress the create_graph / create_vlabel NOTICE chatter so the assertions +-- below are the deterministic output. (The feature is exercised regardless.) +SET client_min_messages = warning; +-- +-- Build a "somewhat large" source graph with NO MATCH (fast bulk CREATE): +-- * 2000 isolated components, each (:Person{pid,age})-[:KNOWS{w}]->(:Friend{pid}) +-- => 2000 Person + 2000 Friend vertices, 2000 KNOWS edges +-- * 500 isolated :Company vertices (no edges) +-- Totals: 4500 vertices, 2000 edges, label set {Person,Friend,Company,KNOWS}. +-- +SELECT create_graph('sg_src'); + create_graph +-------------- + +(1 row) + +SELECT count(*) FROM cypher('sg_src', $$ + UNWIND range(1, 2000) AS i + CREATE (:Person {pid: i, age: i % 100})-[:KNOWS {w: i}]->(:Friend {pid: i}) +$$) AS (a agtype); + count +------- + 0 +(1 row) + +SELECT count(*) FROM cypher('sg_src', $$ + UNWIND range(1, 500) AS i CREATE (:Company {cid: i}) +$$) AS (a agtype); + count +------- + 0 +(1 row) + +-- Source baseline (printed for reference; deterministic). +SELECT + (SELECT count(*) FROM cypher('sg_src', $$ MATCH (n) RETURN n $$) AS (n agtype)) AS src_vertices, + (SELECT count(*) FROM cypher('sg_src', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype)) AS src_edges; + src_vertices | src_edges +--------------+----------- + 4500 | 2000 +(1 row) + +-- +-- 1. Full copy ('*','*'): counts equal the source, and the new graph round-trips. +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_all', 'sg_src', '*', '*'); + node_count | relationship_count +------------+-------------------- + 4500 | 2000 +(1 row) + +SELECT + (SELECT count(*) FROM cypher('sg_all', $$ MATCH (n) RETURN n $$) AS (n agtype)) + = (SELECT count(*) FROM cypher('sg_src', $$ MATCH (n) RETURN n $$) AS (n agtype)) AS nodes_match, + (SELECT count(*) FROM cypher('sg_all', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype)) + = (SELECT count(*) FROM cypher('sg_src', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype)) AS edges_match; + nodes_match | edges_match +-------------+------------- + t | t +(1 row) + +-- +-- 2. Vertex-induced (node filter only): keep pid <= 1000. An edge survives iff +-- BOTH endpoints survive (induced rule), with no relationship filter. +-- node_count is asserted against the function return; correctness is verified +-- by recomputing the induced set from the source (robust booleans). +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_v', 'sg_src', 'n.pid <= 1000', '*'); + node_count | relationship_count +------------+-------------------- + 2000 | 1000 +(1 row) + +SELECT + (SELECT count(*) FROM cypher('sg_v', $$ MATCH (n) RETURN n $$) AS (n agtype)) + = (SELECT count(*) FROM cypher('sg_src', + $$ MATCH (n) WHERE n.pid <= 1000 RETURN n $$) AS (n agtype)) AS nodes_ok, + (SELECT count(*) FROM cypher('sg_v', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype)) + = (SELECT count(*) FROM cypher('sg_src', + $$ MATCH (a)-[r]->(b) WHERE a.pid <= 1000 AND b.pid <= 1000 RETURN r $$) + AS (r agtype)) AS edges_ok; + nodes_ok | edges_ok +----------+---------- + t | t +(1 row) + +-- +-- 3. Node + relationship predicate: keep pid <= 1000 vertices and w <= 300 edges. +-- Edge survives iff w<=300 AND both endpoints pid<=1000. +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_nr', 'sg_src', 'n.pid <= 1000', 'r.w <= 300'); + node_count | relationship_count +------------+-------------------- + 2000 | 300 +(1 row) + +SELECT + (SELECT count(*) FROM cypher('sg_nr', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype)) + = (SELECT count(*) FROM cypher('sg_src', + $$ MATCH (a)-[r]->(b) WHERE r.w <= 300 AND a.pid <= 1000 AND b.pid <= 1000 + RETURN r $$) AS (r agtype)) AS edges_ok; + edges_ok +---------- + t +(1 row) + +-- +-- 4. Label filter excludes one endpoint type: keep only :Person. Every KNOWS +-- edge points Person->Friend, so all edges must be dropped (induced rule). +-- (AGE evaluates label predicates with label(n); GDS uses n:Person -- same +-- containment semantics, different predicate syntax.) +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_person', 'sg_src', $f$label(n) = 'Person'$f$, '*'); + node_count | relationship_count +------------+-------------------- + 2000 | 0 +(1 row) + +-- +-- 5. Bipartite (type filter): keep Person+Friend and KNOWS edges => all 2000. +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_bip', 'sg_src', + $f$label(n) = 'Person' OR label(n) = 'Friend'$f$, + $f$label(r) = 'KNOWS'$f$); + node_count | relationship_count +------------+-------------------- + 4000 | 2000 +(1 row) + +-- +-- 6. Empty result: a predicate matching nothing yields an empty subgraph +-- (not an error), with the default labels only. +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_empty', 'sg_src', 'n.pid < 0', '*'); + node_count | relationship_count +------------+-------------------- + 0 | 0 +(1 row) + +SELECT count(*) AS empty_vertices +FROM cypher('sg_empty', $$ MATCH (n) RETURN n $$) AS (n agtype); + empty_vertices +---------------- + 0 +(1 row) + +-- +-- 7. Composability: extract a subgraph from an already-extracted subgraph. +-- From sg_v (pid<=1000) keep pid<=500; verify against recomputation on sg_v. +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_v2', 'sg_v', 'n.pid <= 500', '*'); + node_count | relationship_count +------------+-------------------- + 1000 | 500 +(1 row) + +SELECT + (SELECT count(*) FROM cypher('sg_v2', $$ MATCH (n) RETURN n $$) AS (n agtype)) + = (SELECT count(*) FROM cypher('sg_v', + $$ MATCH (n) WHERE n.pid <= 500 RETURN n $$) AS (n agtype)) AS nodes_ok, + (SELECT count(*) FROM cypher('sg_v2', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype)) + = (SELECT count(*) FROM cypher('sg_v', + $$ MATCH (a)-[r]->(b) WHERE a.pid <= 500 AND b.pid <= 500 RETURN r $$) + AS (r agtype)) AS edges_ok; + nodes_ok | edges_ok +----------+---------- + t | t +(1 row) + +-- +-- 8. Self-loops and parallel edges (multigraph structure) are preserved. +-- +SELECT create_graph('sg_multi'); + create_graph +-------------- + +(1 row) + +SELECT * FROM cypher('sg_multi', $$ + CREATE (a:N {k: 1}) CREATE (a)-[:E {t: 1}]->(a) +$$) AS (a agtype); + a +--- +(0 rows) + +SELECT * FROM cypher('sg_multi', $$ + CREATE (a:N {k: 2}), (b:N {k: 3}), + (a)-[:E {t: 2}]->(b), (a)-[:E {t: 3}]->(b) +$$) AS (a agtype); + a +--- +(0 rows) + +SELECT node_count, relationship_count +FROM create_subgraph('sg_multi_sub', 'sg_multi', '*', '*'); + node_count | relationship_count +------------+-------------------- + 3 | 3 +(1 row) + +-- self-loop preserved (exactly one edge from a node to itself) +SELECT count(*) AS self_loops +FROM cypher('sg_multi_sub', $$ MATCH (a)-[r]->(a) RETURN r $$) AS (r agtype); + self_loops +------------ + 1 +(1 row) + +-- parallel edges preserved (two edges between k=2 and k=3) +SELECT count(*) AS parallel_edges +FROM cypher('sg_multi_sub', $$ MATCH (a {k: 2})-[r]->(b {k: 3}) RETURN r $$) AS (r agtype); + parallel_edges +---------------- + 2 +(1 row) + +-- +-- 9. Property fidelity: a copied vertex keeps its properties verbatim. +-- +SELECT count(*) AS person_500_age_ok +FROM cypher('sg_v', $$ MATCH (n:Person {pid: 500}) WHERE n.age = 0 RETURN n $$) AS (n agtype); + person_500_age_ok +------------------- + 1 +(1 row) + +-- +-- 10. Error handling / edge cases. +-- +-- NULL graph name +SELECT create_subgraph(NULL, 'sg_src', '*', '*'); +ERROR: new graph name must not be NULL +CONTEXT: PL/pgSQL function create_subgraph(name,name,text,text) line 18 at RAISE +-- source does not exist +SELECT create_subgraph('sg_x', 'no_such_graph', '*', '*'); +ERROR: graph "no_such_graph" does not exist +CONTEXT: PL/pgSQL function create_subgraph(name,name,text,text) line 47 at RAISE +-- extracting into the source itself +SELECT create_subgraph('sg_src', 'sg_src', '*', '*'); +ERROR: cannot extract a subgraph of "sg_src" into itself +CONTEXT: PL/pgSQL function create_subgraph(name,name,text,text) line 24 at RAISE +-- destination already exists +SELECT create_subgraph('sg_all', 'sg_src', '*', '*'); +ERROR: graph "sg_all" already exists +CONTEXT: PL/pgSQL function create_subgraph(name,name,text,text) line 53 at RAISE +-- invalid Cypher predicate is reported (propagated from the engine) +SELECT create_subgraph('sg_bad', 'sg_src', 'n.pid <<>> 1', '*'); +ERROR: operator does not exist: agtype <<>> agtype +LINE 1: ...her('sg_src', $age_subgraph$MATCH (n) WHERE n.pid <<>> 1 RET... + ^ +HINT: No operator matches the given name and argument types. You might need to add explicit type casts. +QUERY: CREATE TEMP TABLE _ag_sg_kept_v ON COMMIT DROP AS SELECT DISTINCT ag_catalog.agtype_to_graphid(vid) AS gid FROM ag_catalog.cypher('sg_src', $age_subgraph$MATCH (n) WHERE n.pid <<>> 1 RETURN id(n)$age_subgraph$) AS (vid agtype) +CONTEXT: PL/pgSQL function create_subgraph(name,name,text,text) line 80 at EXECUTE +-- cleanup +SELECT drop_graph('sg_v2', true); + drop_graph +------------ + +(1 row) + +SELECT drop_graph('sg_multi_sub', true); + drop_graph +------------ + +(1 row) + +SELECT drop_graph('sg_multi', true); + drop_graph +------------ + +(1 row) + +SELECT drop_graph('sg_empty', true); + drop_graph +------------ + +(1 row) + +SELECT drop_graph('sg_bip', true); + drop_graph +------------ + +(1 row) + +SELECT drop_graph('sg_person', true); + drop_graph +------------ + +(1 row) + +SELECT drop_graph('sg_nr', true); + drop_graph +------------ + +(1 row) + +SELECT drop_graph('sg_v', true); + drop_graph +------------ + +(1 row) + +SELECT drop_graph('sg_all', true); + drop_graph +------------ + +(1 row) + +SELECT drop_graph('sg_src', true); + drop_graph +------------ + +(1 row) + diff --git a/regress/sql/age_global_graph.sql b/regress/sql/age_global_graph.sql index acd95fbf0..9f4a1ce2d 100644 --- a/regress/sql/age_global_graph.sql +++ b/regress/sql/age_global_graph.sql @@ -16,13 +16,22 @@ SELECT * FROM create_graph('ag_graph_3'); SELECT * FROM cypher('ag_graph_3', $$ CREATE (v:vertex3) RETURN v $$) AS (v agtype); -- load contexts using the vertex_stats command -SELECT * FROM cypher('ag_graph_3', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); -SELECT * FROM cypher('ag_graph_2', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); -SELECT * FROM cypher('ag_graph_1', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); +-- Build all three graph contexts under one snapshot. The vertex_stats() +-- calls are wrapped in a single REPEATABLE READ transaction so they share +-- one snapshot; this keeps the snapshot-fallback path in is_ggctx_invalid() +-- from purging an already-built context when concurrent xid activity +-- (autovacuum, parallel installcheck, replication) advances the snapshot +-- between calls. Read Committed is insufficient: it takes a fresh snapshot +-- per statement. +BEGIN ISOLATION LEVEL REPEATABLE READ; +SELECT * FROM cypher('ag_graph_3', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_2', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_1', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); +COMMIT; --- loading undefined contexts --- should throw exception - graph "ag_graph_4" does not exist -SELECT * FROM cypher('ag_graph_4', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_4', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); --- delete with invalid parameter ---should return false @@ -55,9 +64,13 @@ SELECT * FROM cypher('ag_graph_4', $$ RETURN delete_global_graphs('ag_graph_4') -- -- load contexts again -SELECT * FROM cypher('ag_graph_3', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); -SELECT * FROM cypher('ag_graph_2', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); -SELECT * FROM cypher('ag_graph_1', $$ MATCH (u) RETURN vertex_stats(u) $$) AS (result agtype); +-- Same REPEATABLE READ wrap as the first build phase above, for the same +-- snapshot-stability reason. +BEGIN ISOLATION LEVEL REPEATABLE READ; +SELECT * FROM cypher('ag_graph_3', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_2', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_1', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) AS (result agtype); +COMMIT; -- delete all graph contexts -- should return true @@ -81,14 +94,14 @@ SELECT * FROM cypher('ag_graph_1', $$ CREATE (n), (m) $$) as (v agtype); SELECT * FROM cypher('ag_graph_2', $$ CREATE (:Person) $$) as (v agtype); ---adding edges between nodes -SELECT * FROM cypher('ag_graph_2', $$ MATCH (a:Person), (b:Person) WHERE a.name = 'A' AND b.name = 'B' CREATE (a)-[e:RELTYPE]->(b) RETURN e $$) as (e agtype); +SELECT * FROM cypher('ag_graph_2', $$ MATCH (a:Person), (b:Person) WHERE a.name = 'A' AND b.name = 'B' CREATE (a)-[e:RELTYPE]->(b) RETURN e ORDER BY id(e) $$) as (e agtype); --checking if vertex stats have been updated along with the new label --should return 3 vertices -SELECT * FROM cypher('ag_graph_1', $$ MATCH (n) RETURN vertex_stats(n) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_1', $$ MATCH (n) RETURN vertex_stats(n) ORDER BY id(n) $$) AS (result agtype); --should return 1 vertice and 1 label -SELECT * FROM cypher('ag_graph_2', $$ MATCH (a) RETURN vertex_stats(a) $$) AS (result agtype); +SELECT * FROM cypher('ag_graph_2', $$ MATCH (a) RETURN vertex_stats(a) ORDER BY id(a) $$) AS (result agtype); -- -- graph_stats command @@ -109,26 +122,219 @@ SELECT * FROM cypher('ag_graph_1', $$ MATCH (u)-[]->(v) SET u.id = id(u) SET v.id = id(v) SET u.name = 'u' SET v.name = 'v' - RETURN u,v $$) AS (u agtype, v agtype); + RETURN u,v ORDER BY id(u), id(v) $$) AS (u agtype, v agtype); SELECT * FROM cypher('ag_graph_1', $$ MATCH (u)-[]->(v) MERGE (v)-[:stalks]->(u) $$) AS (result agtype); -SELECT * FROM cypher('ag_graph_1', $$ MATCH (u)-[e]->(v) RETURN u, e, v $$) AS (u agtype, e agtype, v agtype); +SELECT * FROM cypher('ag_graph_1', $$ MATCH (u)-[e]->(v) RETURN u, e, v ORDER BY id(e) $$) AS (u agtype, e agtype, v agtype); -- what is there now? SELECT * FROM cypher('ag_graph_1', $$ RETURN graph_stats('ag_graph_1') $$) AS (result agtype); -- remove some vertices -SELECT * FROM ag_graph_1._ag_label_vertex; +SELECT * FROM ag_graph_1._ag_label_vertex ORDER BY id; DELETE FROM ag_graph_1._ag_label_vertex WHERE id::text = '281474976710661'; DELETE FROM ag_graph_1._ag_label_vertex WHERE id::text = '281474976710662'; DELETE FROM ag_graph_1._ag_label_vertex WHERE id::text = '281474976710664'; -SELECT * FROM ag_graph_1._ag_label_vertex; -SELECT * FROM ag_graph_1._ag_label_edge; --- there should be warning messages +SELECT * FROM ag_graph_1._ag_label_vertex ORDER BY id; +SELECT * FROM ag_graph_1._ag_label_edge ORDER BY id; +-- The graph_stats query below will produce warnings for the dangling edges +-- created by the DELETE commands above. The warnings appear in nondeterministic +-- order because they come from iterating edge label tables (knows, stalks), +-- so we suppress them with client_min_messages. Without suppression, the +-- output would include these warnings (in some order): +-- +-- WARNING: edge: [id: 1125899906842626, start: 281474976710661, end: 281474976710662, label: knows] start and end vertices not found +-- WARNING: ignored malformed or dangling edge +-- WARNING: edge: [id: 1125899906842627, start: 281474976710663, end: 281474976710664, label: knows] end vertex not found +-- WARNING: ignored malformed or dangling edge +-- WARNING: edge: [id: 1407374883553282, start: 281474976710662, end: 281474976710661, label: stalks] start and end vertices not found +-- WARNING: ignored malformed or dangling edge +-- WARNING: edge: [id: 1407374883553283, start: 281474976710664, end: 281474976710663, label: stalks] start vertex not found +-- WARNING: ignored malformed or dangling edge +-- +-- The result row validates that graph_stats handled the dangling edges correctly. +SET client_min_messages = error; SELECT * FROM cypher('ag_graph_1', $$ RETURN graph_stats('ag_graph_1') $$) AS (result agtype); +RESET client_min_messages; --drop graphs SELECT * FROM drop_graph('ag_graph_1', true); SELECT * FROM drop_graph('ag_graph_2', true); SELECT * FROM drop_graph('ag_graph_3', true); + +----------------------------------------------------------------------------------------------------------------------------- +-- +-- VLE cache invalidation tests +-- +-- These tests verify that the graph version counter properly invalidates +-- the VLE hash table cache when the graph is mutated, and that thin +-- entry lazy property fetch returns correct data. +-- + +-- Setup: create a graph with a chain a->b->c->d +SELECT * FROM create_graph('vle_cache_test'); + +SELECT * FROM cypher('vle_cache_test', $$ + CREATE (a:Node {name: 'a'})-[:Edge]->(b:Node {name: 'b'})-[:Edge]->(c:Node {name: 'c'})-[:Edge]->(d:Node {name: 'd'}) +$$) AS (v agtype); + +-- VLE query: find all paths from a's neighbors (should find b, b->c, b->c->d) +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..3]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + +-- Now add a new node e connected to d. This should invalidate the cache. +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (d:Node {name: 'd'}) + CREATE (d)-[:Edge]->(:Node {name: 'e'}) +$$) AS (v agtype); + +-- VLE query again: should now also find e via a->b->c->d->e (4 hops won't reach, +-- but d->e is 1 hop from d, and a->b->c->d->e would be 4 hops from a). +-- Increase range to *1..4 to include e +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..4]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + +-- Test cache invalidation on DELETE: remove node c and its edges +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (c:Node {name: 'c'}) + DETACH DELETE c +$$) AS (v agtype); + +-- VLE query: should only find b now (c is gone, so b->c path is broken) +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..4]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + +-- Test cache invalidation on SET: change b's name property +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (b:Node {name: 'b'}) + SET b.name = 'b_modified' + RETURN b.name +$$) AS (name agtype); + +-- VLE query: verify the updated property is returned via lazy fetch +SELECT * FROM cypher('vle_cache_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..4]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + +-- Test VLE with edge properties (exercises thin entry edge property fetch) +SELECT * FROM drop_graph('vle_cache_test', true); +SELECT * FROM create_graph('vle_cache_test2'); + +SELECT * FROM cypher('vle_cache_test2', $$ + CREATE (a:N {name: 'a'})-[:E {weight: 1}]->(b:N {name: 'b'})-[:E {weight: 2}]->(c:N {name: 'c'}) +$$) AS (v agtype); + +-- VLE path output to verify edge properties are fetched correctly via +-- thin entry lazy fetch. Returning the full path forces build_path() +-- to call get_edge_entry_properties() for each edge in the result. +-- The output must contain the correct weight values (1 and 2). +SELECT * FROM cypher('vle_cache_test2', $$ + MATCH p=(a:N {name: 'a'})-[:E *1..2]->(n:N) + RETURN p + ORDER BY n.name +$$) AS (p agtype); + +-- VLE edge properties via UNWIND + relationships() to individually verify +-- each edge's properties are correctly fetched from the heap via TID. +SELECT * FROM cypher('vle_cache_test2', $$ + MATCH p=(a:N {name: 'a'})-[:E *1..2]->(n:N) + WITH p, n + UNWIND relationships(p) AS e + RETURN n.name, e.weight + ORDER BY n.name, e.weight +$$) AS (name agtype, weight agtype); + +-- Cleanup +SELECT * FROM drop_graph('vle_cache_test2', true); + +----------------------------------------------------------------------------------------------------------------------------- +-- +-- VLE cache invalidation via direct SQL (trigger tests) +-- +-- These tests verify that the SQL trigger (age_invalidate_graph_cache) +-- properly invalidates the VLE cache when label tables are mutated +-- via direct SQL INSERT, UPDATE, DELETE, and TRUNCATE — not via Cypher. +-- + +-- Setup: create graph with a chain a->b->c using Cypher +SELECT * FROM create_graph('vle_trigger_test'); + +SELECT * FROM cypher('vle_trigger_test', $$ + CREATE (a:Node {name: 'a'})-[:Edge]->(b:Node {name: 'b'})-[:Edge]->(c:Node {name: 'c'}) +$$) AS (v agtype); + +-- Prime the VLE cache: find all nodes reachable from a +SELECT * FROM cypher('vle_trigger_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..3]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + +-- Direct SQL INSERT on vertex: add a new vertex via SQL. +-- This should fire the trigger and invalidate the VLE cache. +-- Use _graphid() with the label's id and nextval for the entry id. +INSERT INTO vle_trigger_test."Node" (id, properties) +SELECT ag_catalog._graphid(l.id, + nextval('vle_trigger_test."Node_id_seq"'::regclass)), + '{"name": "d"}'::agtype +FROM ag_catalog.ag_label l +JOIN ag_catalog.ag_graph g ON g.graphid = l.graph +WHERE g.name = 'vle_trigger_test' AND l.name = 'Node'; + +-- VLE query: results should be unchanged (d has no edges) but cache was rebuilt +SELECT * FROM cypher('vle_trigger_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..3]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + +-- Direct SQL UPDATE on vertex: change b's name to 'b_updated' +-- This should fire the trigger and invalidate the VLE cache. +UPDATE vle_trigger_test."Node" +SET properties = '{"name": "b_updated"}'::agtype +WHERE properties @> '{"name": "b"}'::agtype; + +-- VLE query: verify updated property is visible (cache invalidated by trigger) +SELECT * FROM cypher('vle_trigger_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..3]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + +-- Direct SQL DELETE on edge: remove the edge from b_updated to c +DELETE FROM vle_trigger_test."Edge" +WHERE end_id = (SELECT id FROM vle_trigger_test."Node" + WHERE properties @> '{"name": "c"}'::agtype); + +-- VLE query: only b_updated reachable now (edge to c is gone) +SELECT * FROM cypher('vle_trigger_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..3]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + +-- Direct SQL TRUNCATE on edge table: remove all edges +TRUNCATE vle_trigger_test."Edge"; + +-- VLE query: no edges exist, should return no rows +SELECT * FROM cypher('vle_trigger_test', $$ + MATCH (a:Node {name: 'a'})-[:Edge*1..3]->(n:Node) + RETURN n.name + ORDER BY n.name +$$) AS (name agtype); + +-- Cleanup +SELECT * FROM drop_graph('vle_trigger_test', true); + ----------------------------------------------------------------------------------------------------------------------------- -- -- End of tests diff --git a/regress/sql/age_load.sql b/regress/sql/age_load.sql index 976f050af..196b09806 100644 --- a/regress/sql/age_load.sql +++ b/regress/sql/age_load.sql @@ -194,6 +194,35 @@ SELECT load_edges_from_file('agload_conversion', 'Edges1', '../../etc/passwd', t -- SELECT drop_graph('agload_conversion', true); +-- +-- Issue 2449: mis-delimited / malformed load files must fail with a clear +-- error instead of segfaulting or silently corrupting data. Edge files +-- require the 4 fixed columns; a file that is not comma-delimited parses as +-- a single column, so this must be rejected at the header. +-- +SELECT create_graph('agload_delim'); +SELECT create_vlabel('agload_delim', 'V'); +SELECT create_elabel('agload_delim', 'E'); + +-- pipe-delimited edge file -> parses to 1 column -> clean error at the header +-- (was a segfault) +SELECT load_edges_from_file('agload_delim', 'E', 'age_load/bad_delim_edges.csv'); + +-- per-row guards (header is valid, but an individual data row is ragged): +-- an edge row with fewer than 4 columns -> clean error (was an OOB read of +-- the fixed fields[1..3]) +SELECT load_edges_from_file('agload_delim', 'E', 'age_load/edges_short_row.csv'); + +-- an edge row with more columns than the header -> clean error (was an OOB +-- read of header[i] in create_agtype_from_list_i) +SELECT load_edges_from_file('agload_delim', 'E', 'age_load/edges_long_row.csv'); + +-- a label row with more columns than the header -> clean error (was an OOB +-- read of header[i] in create_agtype_from_list) +SELECT load_labels_from_file('agload_delim', 'V', 'age_load/labels_long_row.csv'); + +SELECT drop_graph('agload_delim', true); + -- -- Test security and permissions -- diff --git a/regress/sql/age_reduce.sql b/regress/sql/age_reduce.sql new file mode 100644 index 000000000..fbe324cd1 --- /dev/null +++ b/regress/sql/age_reduce.sql @@ -0,0 +1,548 @@ +/* + * 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. + */ + +LOAD 'age'; +SET search_path TO ag_catalog; + +SELECT create_graph('reduce'); + +-- +-- Basic folds +-- +-- sum +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x) +$$) AS (result agtype); + +-- sum of a longer list +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] | s + x) +$$) AS (result agtype); + +-- product (factorial) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(p = 1, x IN [1, 2, 3, 4, 5] | p * x) +$$) AS (result agtype); + +-- non-zero initial accumulator +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 100, x IN [1, 2, 3] | s + x) +$$) AS (result agtype); + +-- single element +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [42] | s + x) +$$) AS (result agtype); + +-- +-- List order is significant +-- +-- left-associative subtraction: ((((0-1)-2)-3)-4) = -10 +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3, 4] | s - x) +$$) AS (result agtype); + +-- forward string concatenation +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = '', x IN ['a', 'b', 'c'] | s + x) +$$) AS (result agtype); + +-- reverse string concatenation (element before accumulator) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = '', x IN ['a', 'b', 'c'] | x + s) +$$) AS (result agtype); + +-- +-- Empty and NULL list semantics +-- +-- empty list returns the initial value +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [] | s + x) +$$) AS (result agtype); + +-- empty list returns the initial value (non-zero) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 999, x IN [] | s + x) +$$) AS (result agtype); + +-- NULL list returns NULL +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN null | s + x) +$$) AS (result agtype); + +-- empty list with a NULL initial value yields NULL: the list is empty (not +-- null) so the fold runs over zero rows, and COALESCE(, init) is +-- COALESCE(NULL, NULL) -> NULL. (Distinct from a NULL *list*, which the outer +-- CASE short-circuits to NULL, and from a non-empty list with a NULL init, +-- which seeds the accumulator with agtype 'null'.) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = null, x IN [] | s + x) +$$) AS (result agtype); + +-- +-- NULL handling within the fold +-- +-- a NULL element propagates through arithmetic to NULL +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, null, 3] | s + x) +$$) AS (result agtype); + +-- NULL initial value +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = null, x IN [1, 2, 3] | s) +$$) AS (result agtype); + +-- a body that always evaluates to null yields null, NOT the initial value: +-- every step stores agtype 'null' as the running state, so the final state is +-- a real agtype 'null' and the empty-list COALESCE(..., init) guard must not +-- resurrect the initial value here (the load-bearing fold-to-null vs empty-list +-- distinction) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 7, x IN [1, 2, 3] | null) +$$) AS (result agtype); + +-- the accumulator legitimately becomes null mid-fold and the body climbs back +-- out of it: element 2 sets the accumulator to null, element 3 produces a fresh +-- non-null value, and element 4 reads that recovered state (999 + 4), proving a +-- null intermediate state does not poison the rest of the fold +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3, 4] | + CASE WHEN x = 2 THEN null + WHEN x = 3 THEN 999 + ELSE s + x END) +$$) AS (result agtype); + +-- +-- Errors raised from the fold body propagate cleanly +-- +-- a type error in the body (agtype number + map) aborts the statement rather +-- than corrupting the running aggregate state or crashing the backend +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2] | s + {a: 1}) +$$) AS (result agtype); + +-- a runtime arithmetic error in the body (division by zero) likewise aborts +-- the fold; the error surfaces from the standalone per-element evaluator +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 1, x IN [1, 0, 2] | s / x) +$$) AS (result agtype); + +-- +-- Building a list with the accumulator +-- +-- collect squares +SELECT * FROM cypher('reduce', $$ + RETURN reduce(acc = [], x IN [1, 2, 3] | acc + [x * x]) +$$) AS (result agtype); + +-- +-- Value types in the fold +-- +-- a float accumulator and float elements +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0.0, x IN [1.5, 2.5, 3.0] | s + x) +$$) AS (result agtype); + +-- negative numbers +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [-1, -2, -3] | s + x) +$$) AS (result agtype); + +-- a map accumulator passed through unchanged +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = {n: 0}, x IN [1, 2, 3] | s) +$$) AS (result agtype); + +-- elements that are themselves lists, indexed in the body +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [[1, 2], [3, 4], [5, 6]] | s + x[0]) +$$) AS (result agtype); + +-- +-- Function calls in the fold body +-- +-- a scalar function applied to the element +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN ['a', 'bb', 'ccc'] | s + size(x)) +$$) AS (result agtype); + +-- the list itself produced by a function +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN range(1, 5) | s + x) +$$) AS (result agtype); + +-- +-- Composing reduce() with surrounding expressions +-- +-- the reduce() result consumed by another function +SELECT * FROM cypher('reduce', $$ + RETURN size(reduce(s = [], x IN [1, 2, 3, 4] | s + [x])) +$$) AS (result agtype); + +-- the reduce() result used in a comparison +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x) = 6 +$$) AS (result agtype); + +-- +-- A conditional body (CASE) +-- +-- sum of even elements only +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3, 4, 5, 6] | CASE WHEN x % 2 = 0 THEN s + x ELSE s END) +$$) AS (result agtype); + +-- +-- Boolean and comparison fold bodies +-- +-- the body evaluates to a boolean, which is normalized to an agtype boolean +-- (a boolean accumulator is a real Cypher use case for "all"/"any" style folds) +-- logical AND fold: all true? +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = true, x IN [true, true, false] | s AND x) +$$) AS (result agtype); + +-- logical OR fold: any true? +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = false, x IN [false, true, false] | s OR x) +$$) AS (result agtype); + +-- a comparison body +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = false, x IN [1, 2, 3] | x = 2) +$$) AS (result agtype); + +-- "does any element equal 2?" (search fold) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(found = false, x IN [1, 2, 3] | found OR x = 2) +$$) AS (result agtype); + +-- "are all elements positive?" (using a comparison inside the fold) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = true, x IN [1, 2, 3] | s AND x > 0) +$$) AS (result agtype); + +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = true, x IN [1, -2, 3] | s AND x > 0) +$$) AS (result agtype); + +-- +-- Property access on the element variable +-- +-- sum a field across a list of maps +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [{n: 1}, {n: 2}, {n: 3}] | s + x.n) +$$) AS (result agtype); + +-- concatenate a string field across a list of maps +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = '', x IN [{w: 'a'}, {w: 'b'}, {w: 'c'}] | s + x.w) +$$) AS (result agtype); + +-- +-- Multiple reduce() in one expression +-- +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x) + reduce(p = 1, y IN [2, 3] | p * y) +$$) AS (result agtype); + +-- +-- reduce() in a boolean expression +-- +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x) > 5 + AND reduce(p = 1, y IN [2, 3] | p * y) < 10 +$$) AS (result agtype); + +-- +-- reduce() nested in the list or initial value of another reduce() +-- +-- nesting is allowed in the list and the initial value (both evaluated in the +-- outer context) even though it is rejected inside the fold body. +-- nested reduce() in the list +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [reduce(a = 0, y IN [1, 2, 3] | a + y), 10] | s + x) +$$) AS (result agtype); + +-- nested reduce() in the initial value +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = reduce(a = 0, y IN [1, 2, 3] | a + y), x IN [10, 20] | s + x) +$$) AS (result agtype); + +-- +-- reduce() over a correlated (per-row) list +-- +SELECT * FROM cypher('reduce', $$ + UNWIND [[1, 2], [3, 4, 5], []] AS arr + RETURN reduce(s = 0, x IN arr | s + x) AS total + ORDER BY total +$$) AS (result agtype); + +-- +-- reduce() with the list and initial value bound in an outer clause +-- +SELECT * FROM cypher('reduce', $$ + WITH [10, 20, 30] AS ns + RETURN reduce(t = 0, n IN ns | t + n) +$$) AS (result agtype); + +-- the initial value may reference an outer variable (correlation is allowed +-- in the init and the list, only not in the body) +SELECT * FROM cypher('reduce', $$ + WITH 5 AS base + RETURN reduce(s = base, x IN [1, 2, 3] | s + x) +$$) AS (result agtype); + +-- +-- reduce() nested inside a list comprehension +-- +SELECT * FROM cypher('reduce', $$ + RETURN [v IN [1, 2, 3] | reduce(s = 0, x IN [v, v, v] | s + x)] +$$) AS (result agtype); + +-- +-- reduce() in a WHERE clause +-- +SELECT * FROM cypher('reduce', $$ + UNWIND [[1, 2, 3], [1, 1], [10]] AS l + WITH l WHERE reduce(s = 0, x IN l | s + x) > 3 + RETURN l + ORDER BY l +$$) AS (result agtype); + +-- +-- reduce() over graph data (the canonical Cypher example) +-- +SELECT * FROM cypher('reduce', $$ + CREATE (:person {name: 'Alice', age: 38}), + (:person {name: 'Bob', age: 25}), + (:person {name: 'Daniel', age: 54}) +$$) AS (result agtype); + +-- sum the ages of all person nodes +SELECT * FROM cypher('reduce', $$ + MATCH (p:person) + WITH collect(p) AS people + RETURN reduce(total = 0, n IN people | total + n.age) +$$) AS (result agtype); + +-- +-- reduce() over a graph node's list property +-- +SELECT * FROM cypher('reduce', $$ + CREATE (:bag {name: 'low', vals: [1, 2, 3]}), + (:bag {name: 'mid', vals: [5, 5, 5]}), + (:bag {name: 'high', vals: [10, 20, 30]}) +$$) AS (result agtype); + +-- filter nodes by a reduce() over their list property +SELECT * FROM cypher('reduce', $$ + MATCH (u:bag) WHERE reduce(s = 0, x IN u.vals | s + x) > 10 + RETURN u.name + ORDER BY u.name +$$) AS (result agtype); + +-- compute a reduce() value per node and order by it +SELECT * FROM cypher('reduce', $$ + MATCH (u:bag) + RETURN u.name AS name, reduce(s = 0, x IN u.vals | s + x) AS total + ORDER BY total +$$) AS (name agtype, total agtype); + +-- +-- Outer references in the fold body +-- +-- The body may reference loop-invariant values from the enclosing query: an +-- outer variable, a property of an outer variable, or a cypher() parameter. +-- a plain outer variable in the body +SELECT * FROM cypher('reduce', $$ + WITH 5 AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x + w) +$$) AS (result agtype); + +-- an outer variable used as a multiplier +SELECT * FROM cypher('reduce', $$ + WITH 3 AS factor + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x * factor) +$$) AS (result agtype); + +-- two distinct outer variables in the body +SELECT * FROM cypher('reduce', $$ + WITH 2 AS a, 100 AS b + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x * a + b) +$$) AS (result agtype); + +-- a property of an outer (graph) variable in the body +SELECT * FROM cypher('reduce', $$ + MATCH (u:bag) WHERE u.name = 'mid' + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x + u.vals[0]) +$$) AS (result agtype); + +-- the same outer variable referenced more than once in the body +SELECT * FROM cypher('reduce', $$ + WITH 7 AS k + RETURN reduce(s = 0, x IN [1, 2, 3] | s + k + k) +$$) AS (result agtype); + +-- a property of an outer map referenced in the body +SELECT * FROM cypher('reduce', $$ + WITH {factor: 10} AS m + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x * m.factor) +$$) AS (result agtype); + +-- a subexpression that mixes an outer reference with the element: only the +-- loop-invariant part (the outer list) is captured, the element index is not +SELECT * FROM cypher('reduce', $$ + WITH [10, 20, 30] AS lookup + RETURN reduce(s = 0, x IN [1, 2, 3] | s + lookup[x - 1]) +$$) AS (result agtype); + +-- an outer reference inside a CASE branch of the body is captured +SELECT * FROM cypher('reduce', $$ + WITH 10 AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | CASE WHEN x % 2 = 0 THEN s + w ELSE s + x END) +$$) AS (result agtype); + +-- a NULL outer value propagates through the fold +SELECT * FROM cypher('reduce', $$ + WITH null AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x + w) +$$) AS (result agtype); + +-- multiple outer captures with a mix of NULL and non-NULL: each is bound to its +-- own slot (the non-NULL multiplier is bound and the NULL still propagates) +SELECT * FROM cypher('reduce', $$ + WITH 3 AS a, null AS b + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x * a + b) +$$) AS (result agtype); + +-- an outer variable that changes per row is captured per group +SELECT * FROM cypher('reduce', $$ + UNWIND [1, 2, 3] AS m + RETURN reduce(s = 0, x IN [1, 2, 3, 4] | s + x * m) AS total + ORDER BY total +$$) AS (result agtype); + +-- +-- Short-circuit evaluation is preserved for outer references in the body +-- +-- Only the outer leaf is captured; operators and CASE/AND/OR branches stay in +-- the body, so a guarded outer sub-expression is not evaluated on a branch +-- that is not taken. Each case below would divide by zero if the whole "1/w" +-- were hoisted into an eagerly evaluated aggregate argument instead. +-- the THEN branch is never taken, so "1/w" is not evaluated (expect 6) +SELECT * FROM cypher('reduce', $$ + WITH 0 AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | CASE WHEN false THEN s + 1/w ELSE s + x END) +$$) AS (result agtype); + +-- the ELSE branch is never taken, so "1/w" is not evaluated (expect 6) +SELECT * FROM cypher('reduce', $$ + WITH 0 AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | CASE WHEN true THEN s + x ELSE s + 1/w END) +$$) AS (result agtype); + +-- OR short-circuits once "w = 0" is true, so "1/w > 0" is not evaluated +SELECT * FROM cypher('reduce', $$ + WITH 0 AS w + RETURN reduce(s = true, x IN [1, 2, 3] | s AND (w = 0 OR 1/w > 0)) +$$) AS (result agtype); + +-- AND short-circuits once "w <> 0" is false, so "1/w > 0" is not evaluated +SELECT * FROM cypher('reduce', $$ + WITH 0 AS w + RETURN reduce(s = true, x IN [1, 2, 3] | s AND (w <> 0 AND 1/w > 0)) +$$) AS (result agtype); + +-- coalesce short-circuits: "1/w" is not evaluated when arg 1 is non-null +SELECT * FROM cypher('reduce', $$ + WITH 0 AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | s + coalesce(w, 1/w)) +$$) AS (result agtype); + +-- when the guarded branch is taken, the outer sub-expression is evaluated +-- normally (division by a non-zero outer value): x = 2 -> s + 10/2 (expect 9) +SELECT * FROM cypher('reduce', $$ + WITH 2 AS w + RETURN reduce(s = 0, x IN [1, 2, 3] | CASE WHEN x % 2 = 0 THEN s + 10/w ELSE s + x END) +$$) AS (result agtype); + +-- +-- Not-yet-supported constructs raise a clean feature error +-- +-- a nested reduce() in the body (any subquery in the body is unsupported) +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2] | s + reduce(t = 0, y IN [x] | t + y)) +$$) AS (result agtype); + +-- an aggregate function in the body +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2] | s + count(x)) +$$) AS (result agtype); + +-- +-- Syntax errors: each required piece of the reduce() form is enforced +-- +-- missing "= init" +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s, x IN [1, 2] | s + x) +$$) AS (result agtype); + +-- missing ", var IN list" +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0 | s) +$$) AS (result agtype); + +-- missing "| body" +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2]) +$$) AS (result agtype); + +-- a qualified iterator variable is not allowed +SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x.y IN [1, 2] | s) +$$) AS (result agtype); + +-- +-- cypher() parameter referenced in the fold body (via a prepared statement) +-- +PREPARE reduce_param(agtype) AS + SELECT * FROM cypher('reduce', $$ + RETURN reduce(s = 0, x IN [1, 2, 3] | s + x + $p) + $$, $1) AS (result agtype); + +EXECUTE reduce_param('{"p": 10}'); + +EXECUTE reduce_param('{"p": 100}'); + +DEALLOCATE reduce_param; + +-- +-- "reduce" as a property key name (safe_keywords backward compatibility): +-- because reduce() introduced a reserved keyword, confirm the word is still +-- usable as a map key, the same way any/none/single are. +-- +SELECT * FROM cypher('reduce', $$ + RETURN {reduce: 1, any: 2, none: 3} +$$) AS (result agtype); + +-- +-- Cleanup +-- +SELECT * FROM drop_graph('reduce', true); diff --git a/regress/sql/age_shortest_path.sql b/regress/sql/age_shortest_path.sql new file mode 100644 index 000000000..5bf86d364 --- /dev/null +++ b/regress/sql/age_shortest_path.sql @@ -0,0 +1,1113 @@ +/* + * 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. + */ + +LOAD 'age'; +SET search_path TO ag_catalog; + +-- +-- age_shortest_path / age_all_shortest_paths +-- + +SELECT * FROM create_graph('sp_graph'); + +-- Build a small deterministic graph: +-- +-- A +-- / \ +-- B C (A->B, A->C, B->D, C->D : two shortest A..D paths) +-- \ / +-- D +-- | +-- E (D->E : unique 3-hop path A..E) +-- +-- Z (isolated, unreachable) +-- +SELECT * FROM cypher('sp_graph', $$ + CREATE (a:Person {name: 'A'}), + (b:Person {name: 'B'}), + (c:Person {name: 'C'}), + (d:Person {name: 'D'}), + (e:Person {name: 'E'}), + (z:Person {name: 'Z'}), + (a)-[:KNOWS]->(b), + (a)-[:KNOWS]->(c), + (b)-[:KNOWS]->(d), + (c)-[:KNOWS]->(d), + (d)-[:KNOWS]->(e) +$$) AS (result agtype); + +-- materialize the global graph context +SELECT * FROM cypher('sp_graph', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + +-- A -> D shortest path (length 2); expected: path_count = 1 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)) +); + +-- all shortest A -> D; expected: 2 paths (A-B-D and A-C-D), each length 2 +SELECT path +FROM age_all_shortest_paths( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)) +) AS path +ORDER BY path; + +-- A -> E unique 3-hop path; expected: path_count = 1 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'E'}) RETURN id(n) $$) AS (id agtype)) +); + +-- A -> E with max_hops = 2; expected: path_count = 0 (E is 3 hops away) +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'E'}) RETURN id(n) $$) AS (id agtype)), + NULL, NULL, NULL, 2::agtype +); + +-- zero-length path, start == end; expected: path_count = 1 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)) +); + +-- unreachable vertex Z; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'Z'}) RETURN id(n) $$) AS (id agtype)) +); + +-- direction 'in': D -> A traversing edges backwards; expected: path_count = 2 +SELECT count(*) AS path_count +FROM age_all_shortest_paths( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + NULL, '"in"'::agtype +); + +-- single shortest path with direction 'in': D -> A backwards; expected: +-- path_count = 1 (the single-path variant picks one of the two routes) +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + NULL, '"in"'::agtype +); + +-- direction 'out': D -> A not reachable forwards; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + NULL, '"out"'::agtype +); + +-- label filter 'KNOWS': A -> D still found; expected: path_count = 1 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype +); + +-- error: invalid direction string; expected: ERROR (must be 'out', 'in', or 'any') +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + NULL, '"sideways"'::agtype +); + +-- error: start argument is neither a vertex nor an integer id; expected: ERROR +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + '"not_a_vertex"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)) +); + +-- error: end argument is neither a vertex nor an integer id; expected: ERROR +-- (symmetric to the start-vertex check above) +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"not_a_vertex"'::agtype +); + +-- +-- Non-existent endpoint guards. These must NOT crash the backend and must +-- return no rows (a path can only exist between vertices in the graph). +-- Previously, start == end on a non-existent vertex id was matched at BFS +-- depth 0 and path reconstruction dereferenced a missing vertex, crashing +-- the server. +-- + +-- start == end on a non-existent integer id; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path('"sp_graph"'::agtype, 999999::agtype, 999999::agtype); + +-- existing start -> non-existent end; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + 999999::agtype +); + +-- non-existent start -> existing end; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_graph"'::agtype, + 999999::agtype, + (SELECT id FROM cypher('sp_graph', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)) +); + +-- all-shortest-paths with start == end non-existent; expected: 0 rows +SELECT count(*) AS path_count +FROM age_all_shortest_paths('"sp_graph"'::agtype, 999999::agtype, 999999::agtype); + +-- cleanup +SELECT * FROM drop_graph('sp_graph', true); + + +-- +-- Empty graph: a graph that exists but has no vertices must return no rows +-- (and must not hang or crash) for any endpoint query. +-- +SELECT * FROM create_graph('sp_empty'); +SELECT count(*) AS path_count +FROM age_shortest_path('"sp_empty"'::agtype, 0::agtype, 1::agtype); +SELECT count(*) AS path_count +FROM age_all_shortest_paths('"sp_empty"'::agtype, 0::agtype, 0::agtype); +SELECT * FROM drop_graph('sp_empty', true); + + + +-- +-- A large, programmatically generated graph (120 nodes) exercising long +-- shortest paths (length up to 20), high-multiplicity all-shortest-paths, +-- label filtering, and directed vs. undirected reachability. +-- +-- Nodes: (:N {id: 0..119}). Structures built on top of them: +-- +-- * Main chain 0 -> 1 -> ... -> 20 (unique 20-hop path) +-- * Alternate chain 0 -> 50 -> 51 -> ... -> 68 -> 20 +-- (a second, disjoint 20-hop path 0..20) +-- => all-shortest-paths 0..20 under KNOWS = 2 paths of length 20 +-- * 3x3 lattice on ids 70..78, id = 70 + 3*row + col, edges go right +-- (id->id+1) and down (id->id+3). Monotone 70..78 paths: +-- => all-shortest-paths 70..78 = C(4,2) = 6 paths of length 4 +-- * LIKES shortcut 0 -[:LIKES]-> 20 (1 hop; only visible when the edge +-- label filter is NOT restricted to KNOWS) +-- * Back-edge triangle 0 -> 96 -> 95 -> 0 +-- => directed 0->95 = 2 hops (0-96-95); undirected 0..95 = 1 hop +-- * Many unused ids (e.g. 119) remain isolated / unreachable. +-- +SELECT * FROM create_graph('sp_big'); + +-- 120 vertices, ids 0..119 +SELECT * FROM cypher('sp_big', $$ + UNWIND range(0, 119) AS i CREATE (:N {id: i}) +$$) AS (result agtype); + +-- main chain 0->1->...->20 (KNOWS) +SELECT * FROM cypher('sp_big', $$ + UNWIND range(0, 19) AS i + MATCH (a:N {id: i}), (b:N {id: i + 1}) + CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + +-- alternate, disjoint 20-hop path 0->50->51->...->68->20 (KNOWS) +SELECT * FROM cypher('sp_big', $$ + MATCH (a:N {id: 0}), (b:N {id: 50}) CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); +SELECT * FROM cypher('sp_big', $$ + UNWIND range(50, 67) AS i + MATCH (a:N {id: i}), (b:N {id: i + 1}) + CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); +SELECT * FROM cypher('sp_big', $$ + MATCH (a:N {id: 68}), (b:N {id: 20}) CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + +-- 3x3 lattice on ids 70..78: right edges (id -> id+1) +SELECT * FROM cypher('sp_big', $$ + UNWIND [0, 1, 2] AS r + UNWIND [0, 1] AS c + MATCH (a:N {id: 70 + 3 * r + c}), (b:N {id: 70 + 3 * r + c + 1}) + CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + +-- 3x3 lattice: down edges (id -> id+3) +SELECT * FROM cypher('sp_big', $$ + UNWIND [0, 1] AS r + UNWIND [0, 1, 2] AS c + MATCH (a:N {id: 70 + 3 * r + c}), (b:N {id: 70 + 3 * (r + 1) + c}) + CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + +-- back-edge triangle 0 -> 96 -> 95 -> 0 (KNOWS) +SELECT * FROM cypher('sp_big', $$ + MATCH (a:N {id: 0}), (b:N {id: 96}) CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); +SELECT * FROM cypher('sp_big', $$ + MATCH (a:N {id: 96}), (b:N {id: 95}) CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); +SELECT * FROM cypher('sp_big', $$ + MATCH (a:N {id: 95}), (b:N {id: 0}) CREATE (a)-[:KNOWS]->(b) +$$) AS (result agtype); + +-- labelled shortcut 0 -[:LIKES]-> 20 +SELECT * FROM cypher('sp_big', $$ + MATCH (a:N {id: 0}), (b:N {id: 20}) CREATE (a)-[:LIKES]->(b) +$$) AS (result agtype); + +-- sanity: vertex count (also materializes the global context); expected: count = 120 +SELECT * FROM cypher('sp_big', $$ MATCH (n) RETURN count(n) $$) AS (n agtype); + +-- all shortest 0 -> 20 under KNOWS (main chain + disjoint alternate); +-- expected: 2 paths, each exactly 20 hops +SELECT path +FROM age_all_shortest_paths( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype +) AS path +ORDER BY path; + +-- any label: the LIKES shortcut collapses 0 -> 20; expected: path_count = 1 +SELECT count(*) AS path_count +FROM age_all_shortest_paths( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$) AS (id agtype)), + NULL, '"out"'::agtype +); + +-- all shortest 70 -> 78 across the 3x3 lattice; expected: path_count = 6 (C(4,2)) +SELECT count(*) AS path_count +FROM age_all_shortest_paths( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype +); + +-- the lattice paths listed; expected: 6 paths, each 4 hops +SELECT path +FROM age_all_shortest_paths( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype +) AS path +ORDER BY path; + +-- max_hops = 19, one short of the 20-hop route; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, NULL, 19::agtype +); + +-- max_hops = 20 admits the full route; expected: path_count = 2 +SELECT count(*) AS path_count +FROM age_all_shortest_paths( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 20}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, NULL, 20::agtype +); + +-- DIRECTED out: 0 -> 95 must traverse 0->96->95; expected: 1 path (length 2) +SELECT path +FROM age_shortest_path( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 95}) RETURN id(n) $$) AS (id agtype)), + NULL, '"out"'::agtype +) AS path +ORDER BY path; + +-- UNDIRECTED: 0 .. 95 via the 95->0 back edge; expected: 1 path (length 1) +SELECT path +FROM age_shortest_path( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 95}) RETURN id(n) $$) AS (id agtype)) +) AS path +ORDER BY path; + +-- DIRECTED out: 78 -> 70 against lattice flow; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$) AS (id agtype)), + NULL, '"out"'::agtype +); + +-- UNDIRECTED: 78 .. 70 reverses the lattice; expected: path_count = 6 +SELECT count(*) AS path_count +FROM age_all_shortest_paths( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 78}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 70}) RETURN id(n) $$) AS (id agtype)) +); + +-- isolated id 119 unreachable from 0; expected: path_count = 0 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 119}) RETURN id(n) $$) AS (id agtype)) +); + +-- zero-length path, start == end; expected: path_count = 1 +SELECT count(*) AS path_count +FROM age_shortest_path( + '"sp_big"'::agtype, + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_big', $$ MATCH (n:N {id: 0}) RETURN id(n) $$) AS (id agtype)) +); + +-- cleanup +SELECT * FROM drop_graph('sp_big', true); + +-- +-- Calling the age_* SRFs from inside cypher() (Tier 1). +-- +-- Because the functions are prefixed with age_, the cypher() parser resolves +-- the unqualified names 'shortest_path' and 'all_shortest_paths' to +-- ag_catalog.age_shortest_path / ag_catalog.age_all_shortest_paths, and the +-- graph name is auto-injected as the first argument (like vle/vertex_stats), +-- so callers pass only the bound endpoints. A whole vertex implicitly casts to +-- agtype, so the argument types resolve. The SRFs are set-returning and now +-- work in a cypher RETURN projection (ProjectSet), returning one row per path. +-- +SELECT * FROM create_graph('sp_cy'); + +SELECT * FROM cypher('sp_cy', $$ + CREATE (a:N {name: 'A'}), + (b:N {name: 'B'}), + (c:N {name: 'C'}), + (a)-[:KNOWS]->(b), + (b)-[:KNOWS]->(c) +$$) AS (result agtype); + +-- materialize the global graph context +SELECT * FROM cypher('sp_cy', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + +-- shortest_path() inside a cypher RETURN; the graph name is auto-injected and +-- the bound vertices are passed; expected: 1 path A..C (length 2) +SELECT * FROM cypher('sp_cy', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN shortest_path(a, c) +$$) AS (path agtype); + +-- all_shortest_paths() inside a cypher RETURN; expected: 1 path A..C (length 2) +SELECT * FROM cypher('sp_cy', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN all_shortest_paths(a, c) +$$) AS (path agtype); + +-- in-cypher with an explicit edge-label filter; expected: 1 path A..C (length 2) +SELECT * FROM cypher('sp_cy', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN shortest_path(a, c, 'KNOWS') +$$) AS (path agtype); + +-- still supported: call the SRF at the top level; expected: 1 path A..C (length 2) +SELECT path +FROM age_shortest_path( + '"sp_cy"'::agtype, + (SELECT id FROM cypher('sp_cy', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_cy', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)) +) AS path +ORDER BY path; + +-- cleanup +SELECT * FROM drop_graph('sp_cy', true); + +-- +-- Edge cases: parallel/multi-edges, self-loops, unknown edge labels, +-- max_hops boundaries (0 and negative), explicit 'any' direction, and +-- NULL / unknown-graph argument errors. +-- +SELECT * FROM create_graph('sp_edge'); + +-- A and B are connected by TWO parallel KNOWS edges plus one LIKES edge. +-- B->C is a single KNOWS edge. S has a self-loop. These exercise the +-- multi-predecessor (parallel edge) logic and the label filter. +SELECT * FROM cypher('sp_edge', $$ + CREATE (a:N {name: 'A'}), + (b:N {name: 'B'}), + (c:N {name: 'C'}), + (s:N {name: 'S'}), + (a)-[:KNOWS]->(b), + (a)-[:KNOWS]->(b), + (a)-[:LIKES]->(b), + (b)-[:KNOWS]->(c), + (s)-[:KNOWS]->(s) +$$) AS (result agtype); + +-- materialize the global graph context +SELECT * FROM cypher('sp_edge', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + +-- parallel edges: two distinct KNOWS edges A->B are two distinct shortest +-- paths; expected count 2 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype); + +-- no label filter: 2 KNOWS + 1 LIKES edge A->B are three distinct shortest +-- paths; expected count 3 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype, '"out"'::agtype); + +-- single shortest path A->B picks exactly one of the parallel edges; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype))); + +-- self-loop: a vertex with an edge to itself yields only the zero-length +-- path for start == end (the self-loop is never used); count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$) AS (id agtype))); + +-- all_shortest_paths with start == end (existing vertex): one zero-length +-- path; count 1 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$) AS (id agtype))); + +-- unknown relationship type matches no edges: A..C filtered by a label that +-- does not exist must return no path (NOT silently fall back to all edges); +-- count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"NOSUCHLABEL"'::agtype, '"out"'::agtype); + +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"NOSUCHLABEL"'::agtype, '"out"'::agtype); + +-- the zero-length (start == end) path has no edges, so an unknown label +-- still matches it; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'S'}) RETURN id(n) $$) AS (id agtype)), + '"NOSUCHLABEL"'::agtype, '"out"'::agtype); + +-- existing label that does not connect the endpoints: LIKES only exists on +-- A->B, so A..C filtered by LIKES is unreachable; count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"LIKES"'::agtype, '"out"'::agtype); + +-- max_hops = 0 with start == end: the zero-length path is still returned; +-- count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype, NULL::agtype, NULL::agtype, '0'::agtype); + +-- max_hops = 0 with adjacent distinct endpoints: no path within zero hops; +-- count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype, NULL::agtype, NULL::agtype, '0'::agtype); + +-- negative max_hops is treated as unbounded: A..C (length 2) is found; +-- count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype, NULL::agtype, NULL::agtype, '-1'::agtype); + +-- explicit 'any' direction string (vs the default NULL == undirected); +-- two parallel KNOWS edges A->B give two shortest paths; count 2 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"any"'::agtype); + +-- NULL start (or end) vertex yields no rows (Cypher null semantics: a null +-- endpoint simply produces no match, it is not an error); count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + NULL::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype))); + +-- NULL end vertex likewise yields no rows; count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype); + +-- all_shortest_paths with a NULL endpoint also yields no rows; count 0 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype); + +-- a single relationship type may be passed as a one-element array; expected: +-- same as the bare-string form, A..C under KNOWS (length 2); count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["KNOWS"]'::agtype, '"out"'::agtype); + +-- multiple relationship types: an array of types matches an edge whose type +-- is any one of them. A..C single shortest under {KNOWS, LIKES} (length 2); +-- count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["KNOWS", "LIKES"]'::agtype, '"out"'::agtype); + +-- all shortest A..C under {KNOWS, LIKES}: three A->B edges (2 KNOWS + 1 LIKES) +-- each extend by the single B->C KNOWS edge; count 3 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["KNOWS", "LIKES"]'::agtype, '"out"'::agtype); + +-- all shortest A..B under {KNOWS, LIKES}: the two parallel KNOWS edges plus +-- the one LIKES edge are three distinct one-hop paths; count 3 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + '["KNOWS", "LIKES"]'::agtype, '"any"'::agtype); + +-- a multi-type array containing an unknown type ignores the unknown member: +-- {NOSUCHLABEL, KNOWS} still finds A..C via KNOWS; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["NOSUCHLABEL", "KNOWS"]'::agtype, '"out"'::agtype); + +-- a multi-type array of only types that do not connect the endpoints: +-- {LIKES} reaches B but B..C has no LIKES edge, so A..C is unreachable; +-- count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["LIKES", "NOSUCHLABEL"]'::agtype, '"out"'::agtype); + +-- an empty relationship-type array imposes no filter (same as NULL): A..C +-- (length 2) is found; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '[]'::agtype, '"out"'::agtype); + +-- a non-string element in the relationship-type array is an error +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["KNOWS", 7]'::agtype, '"out"'::agtype); + +-- a minimum hop count that does not exceed the shortest distance imposes no +-- extra constraint; A..C via KNOWS has length 2, so min_hops=2 is accepted and +-- returns the length-2 path; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype); + +-- a minimum hop count greater than the shortest distance falls back to the +-- exhaustive (VLE) search; A..C in this DAG has no length-3 path (every longer +-- route would have to reuse an edge), so min_hops=3 yields no rows; count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 3::agtype); + +-- a minimum hop count of 0 is the default and is accepted; A..C (length 2); +-- count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 0::agtype); + +-- a graph name that does not exist is an error +SELECT count(*) FROM age_shortest_path('"no_such_graph"'::agtype, '1'::agtype, '2'::agtype); + +-- a NULL graph name is an error (the graph name is required, unlike the +-- endpoints which accept NULL as "no match") +SELECT count(*) FROM age_shortest_path( + NULL::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype))); + +-- a non-integer max_hops is an error (the hop bounds must be integers) +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, NULL::agtype, '"not_an_int"'::agtype); + +-- a non-integer min_hops is an error (symmetric to max_hops above) +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, '"not_an_int"'::agtype); + +-- a negative min_hops is clamped to 0 (no constraint), so A..C (length 2) is +-- still found; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_edge"'::agtype, + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_edge', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, '-3'::agtype); + +-- cleanup +SELECT * FROM drop_graph('sp_edge', true); + +-- +-- Calling shortest_path / all_shortest_paths from inside cypher() (Tier 1) +-- WITH a relationship-type filter -- both a single type (bare string) and +-- multiple types (a list literal). The graph name is auto-injected, so the +-- in-cypher call passes only the bound endpoints and the type filter. +-- +-- Graph: A and B are joined by two parallel KNOWS edges plus one LIKES edge; +-- B->C is a single KNOWS edge. This lets the all_shortest_paths variant return +-- more than one path so the multiplicity is visible. +-- +SELECT * FROM create_graph('sp_cy_lbl'); + +SELECT * FROM cypher('sp_cy_lbl', $$ + CREATE (a:N {name: 'A'}), + (b:N {name: 'B'}), + (c:N {name: 'C'}), + (a)-[:KNOWS]->(b), + (a)-[:KNOWS]->(b), + (a)-[:LIKES]->(b), + (b)-[:KNOWS]->(c) +$$) AS (result agtype); + +-- materialize the global graph context +SELECT * FROM cypher('sp_cy_lbl', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + +-- shortest_path() in-cypher with a single relationship type; A..C via KNOWS +-- (length 2); expected: 1 path +SELECT count(*) FROM cypher('sp_cy_lbl', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN shortest_path(a, c, 'KNOWS') +$$) AS (path agtype); + +-- all_shortest_paths() in-cypher with a single relationship type; the two +-- parallel KNOWS edges A->B make two distinct shortest A..C paths; expected: 2 +SELECT count(*) FROM cypher('sp_cy_lbl', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN all_shortest_paths(a, c, 'KNOWS') +$$) AS (path agtype); + +-- shortest_path() in-cypher with multiple relationship types passed as a list +-- literal; A..C under {KNOWS, LIKES} (length 2); expected: 1 path +SELECT count(*) FROM cypher('sp_cy_lbl', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN shortest_path(a, c, ['KNOWS', 'LIKES']) +$$) AS (path agtype); + +-- all_shortest_paths() in-cypher with multiple relationship types; the three +-- A->B edges (2 KNOWS + 1 LIKES) each extend by the single B->C KNOWS edge, +-- giving three distinct shortest A..C paths; expected: 3 +SELECT count(*) FROM cypher('sp_cy_lbl', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN all_shortest_paths(a, c, ['KNOWS', 'LIKES']) +$$) AS (path agtype); + +-- all_shortest_paths() in-cypher, multiple types, adjacent endpoints: the two +-- parallel KNOWS edges plus the one LIKES edge are three one-hop A..B paths; +-- expected: 3 +SELECT count(*) FROM cypher('sp_cy_lbl', $$ + MATCH (a:N {name:'A'}), (b:N {name:'B'}) + RETURN all_shortest_paths(a, b, ['KNOWS', 'LIKES']) +$$) AS (path agtype); + +-- multiple types where only one connects the endpoints: {LIKES} reaches B but +-- B->C has no LIKES edge, so A..C is unreachable in-cypher; expected: 0 +SELECT count(*) FROM cypher('sp_cy_lbl', $$ + MATCH (a:N {name:'A'}), (c:N {name:'C'}) + RETURN all_shortest_paths(a, c, ['LIKES']) +$$) AS (path agtype); + +-- cleanup +SELECT * FROM drop_graph('sp_cy_lbl', true); + +-- +-- Minimum hop count fallback (Tier: VLE exhaustive search). When the requested +-- minimum hop count exceeds the true shortest distance, the BFS shortest-path +-- cannot satisfy it (it needs longer paths), so the implementation falls back +-- to the variable-length-edge depth-first engine and returns the shortest +-- path(s) whose length is at least min_hops. +-- +-- Graph: A reaches C directly (length 1) and also via two distinct +-- intermediate vertices B1 and B2 (length 2 each): +-- A->C, A->B1->C, A->B2->C +-- +SELECT * FROM create_graph('sp_min'); + +SELECT * FROM cypher('sp_min', $$ + CREATE (a:N {name: 'A'}), + (c:N {name: 'C'}), + (b1:N {name: 'B1'}), + (b2:N {name: 'B2'}), + (a)-[:KNOWS]->(c), + (a)-[:KNOWS]->(b1), + (b1)-[:KNOWS]->(c), + (a)-[:KNOWS]->(b2), + (b2)-[:KNOWS]->(c) +$$) AS (result agtype); + +-- materialize the global graph context +SELECT * FROM cypher('sp_min', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + +-- baseline: the shortest A..C is the direct length-1 edge; count 1 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype); + +-- min_hops=2 excludes the direct edge and falls back to the exhaustive search; +-- the two length-2 routes A->B1->C and A->B2->C are the shortest qualifying +-- paths; all_shortest_paths returns both; count 2 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype); + +-- single shortest_path with min_hops=2 picks exactly one of the two length-2 +-- routes; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype); + +-- the qualifying length-2 paths materialize correctly; all_shortest_paths +-- returns the full, order-stable set (the single shortest_path variant would +-- return an arbitrary one of the two equal-length routes, which is not a +-- deterministic choice), so both A->B1->C and A->B2->C are listed +SELECT path FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype) AS path +ORDER BY path; + +-- min_hops=2 with a matching max_hops=2 returns the same two length-2 paths; +-- count 2 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype, 2::agtype); + +-- min_hops greater than max_hops is unsatisfiable; count 0 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype, 1::agtype); + +-- min_hops=3 has no qualifying path (a length-3 A..C would have to reuse an +-- edge, which relationship-uniqueness forbids); count 0 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 3::agtype); + +-- no edge-type filter also reaches the fallback; the two length-2 routes are +-- returned; count 2 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + NULL::agtype, '"out"'::agtype, 2::agtype); + +-- the fallback honours direction: traversing edges backwards (C..A, 'in') with +-- min_hops=2 also returns the two length-2 routes; count 2 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"in"'::agtype, 2::agtype); + +-- the fallback respects direction: there is no forward C..A path, so 'out' +-- with min_hops=2 returns nothing; count 0 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype); + +-- an unknown relationship type in the fallback matches no edges, so even the +-- shortest qualifying path cannot be formed; count 0 +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '"NOSUCHLABEL"'::agtype, '"out"'::agtype, 2::agtype); + +-- a minimum hop count greater than the shortest distance combined with +-- multiple relationship types is not supported (the VLE engine matches a +-- single label only); expected: ERROR +SELECT count(*) FROM age_all_shortest_paths( + '"sp_min"'::agtype, + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_min', $$ MATCH (n {name:'C'}) RETURN id(n) $$) AS (id agtype)), + '["KNOWS", "LIKES"]'::agtype, '"out"'::agtype, 2::agtype); + +-- cleanup +SELECT * FROM drop_graph('sp_min', true); + +-- +-- Minimum hop count fallback with a VERTEX-REVISITING longer path. This is the +-- defining behaviour of the exhaustive-search regime: relationship-uniqueness +-- (Cypher trail semantics) forbids reusing an EDGE but permits revisiting a +-- VERTEX, so a qualifying path longer than the shortest distance may loop back +-- through an already-seen node. +-- +-- Graph: A -> B -> C, C -> B (back edge), B -> D +-- A..D shortest distance = 2 (A->B->D) +-- there is no edge-distinct length-3 A..D path +-- the only edge-distinct length-4 A..D path is A->B->C->B->D, which +-- revisits vertex B but uses each of the four edges exactly once +-- +SELECT * FROM create_graph('sp_revisit'); + +SELECT * FROM cypher('sp_revisit', $$ + CREATE (a:N {name: 'A'}), + (b:N {name: 'B'}), + (c:N {name: 'C'}), + (d:N {name: 'D'}), + (a)-[:KNOWS]->(b), + (b)-[:KNOWS]->(c), + (c)-[:KNOWS]->(b), + (b)-[:KNOWS]->(d) +$$) AS (result agtype); + +-- materialize the global graph context +SELECT * FROM cypher('sp_revisit', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + +-- min_hops=2 equals the shortest distance, so the easy (BFS) regime returns the +-- direct A->B->D route; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_revisit"'::agtype, + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 2::agtype); + +-- min_hops is a lower bound, not an exact length: with min_hops=3 there is no +-- length-3 edge-distinct path, so the search returns the next-shortest +-- qualifying path, the length-4 route A->B->C->B->D; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_revisit"'::agtype, + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 3::agtype); + +-- min_hops=4 is satisfied only by the vertex-revisiting A->B->C->B->D path; +-- count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_revisit"'::agtype, + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 4::agtype); + +-- the length-4 path is unique, so its materialized form is deterministic; it +-- visits B twice (B appears at positions 2 and 4) yet repeats no edge +SELECT path FROM age_shortest_path( + '"sp_revisit"'::agtype, + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 4::agtype) AS path +ORDER BY path; + +-- min_hops=5 exhausts the four edges without an edge-distinct path; count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_revisit"'::agtype, + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_revisit', $$ MATCH (n {name:'D'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 5::agtype); + +-- cleanup +SELECT * FROM drop_graph('sp_revisit', true); + +-- +-- Minimum hop count fallback with a CLOSED WALK (start == end through a cycle). +-- When start and end are the same vertex the shortest distance is 0, so any +-- positive min_hops forces the exhaustive search to look for a cycle that +-- returns to the start using edge-distinct steps. +-- +-- Graph: a single directed triangle A -> B -> C -> A +-- A..A shortest distance = 0 (the zero-length path) +-- the only edge-distinct closed walk is the length-3 triangle A->B->C->A +-- +SELECT * FROM create_graph('sp_tri'); + +SELECT * FROM cypher('sp_tri', $$ + CREATE (a:N {name: 'A'}), + (b:N {name: 'B'}), + (c:N {name: 'C'}), + (a)-[:KNOWS]->(b), + (b)-[:KNOWS]->(c), + (c)-[:KNOWS]->(a) +$$) AS (result agtype); + +-- materialize the global graph context +SELECT * FROM cypher('sp_tri', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + +-- no min_hops: start == end yields the zero-length path; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_tri"'::agtype, + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype); + +-- min_hops=3 forces the exhaustive search to find the closed triangle walk +-- A->B->C->A; count 1 +SELECT count(*) FROM age_shortest_path( + '"sp_tri"'::agtype, + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 3::agtype); + +-- the closed walk is unique, so its materialized form is deterministic; it +-- starts and ends at A and traverses each triangle edge once +SELECT path FROM age_shortest_path( + '"sp_tri"'::agtype, + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 3::agtype) AS path +ORDER BY path; + +-- min_hops=4 cannot be met without reusing an edge of the three-edge triangle; +-- count 0 +SELECT count(*) FROM age_shortest_path( + '"sp_tri"'::agtype, + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_tri', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + '"KNOWS"'::agtype, '"out"'::agtype, 4::agtype); + +-- cleanup +SELECT * FROM drop_graph('sp_tri', true); + +-- +-- Error messages report the actual SRF that was called. age_shortest_path and +-- age_all_shortest_paths share their argument-resolution helpers; these cases +-- confirm each reports its own name in the error text rather than a single +-- hard-coded "age_shortest_path" prefix. +-- +SELECT * FROM create_graph('sp_errname'); + +SELECT * FROM cypher('sp_errname', $$ + CREATE (a:N {name: 'A'})-[:KNOWS]->(b:N {name: 'B'}) +$$) AS (result agtype); + +-- materialize the global graph context +SELECT * FROM cypher('sp_errname', $$ MATCH (u) RETURN vertex_stats(u) ORDER BY id(u) $$) + AS (result agtype); + +-- a NULL graph name errors with the called function's name as the prefix +SELECT count(*) FROM age_shortest_path(NULL::agtype, 0::agtype, 1::agtype); +SELECT count(*) FROM age_all_shortest_paths(NULL::agtype, 0::agtype, 1::agtype); + +-- a non-string relationship type errors with the called function's name as the +-- prefix (the array element 1 is an integer, not a string) +SELECT count(*) FROM age_shortest_path( + '"sp_errname"'::agtype, + (SELECT id FROM cypher('sp_errname', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_errname', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + '[1]'::agtype); +SELECT count(*) FROM age_all_shortest_paths( + '"sp_errname"'::agtype, + (SELECT id FROM cypher('sp_errname', $$ MATCH (n {name:'A'}) RETURN id(n) $$) AS (id agtype)), + (SELECT id FROM cypher('sp_errname', $$ MATCH (n {name:'B'}) RETURN id(n) $$) AS (id agtype)), + '[1]'::agtype); + +-- cleanup +SELECT * FROM drop_graph('sp_errname', true); diff --git a/regress/sql/age_upgrade.sql b/regress/sql/age_upgrade.sql new file mode 100644 index 000000000..98ce3e21e --- /dev/null +++ b/regress/sql/age_upgrade.sql @@ -0,0 +1,546 @@ +/* + * 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. + */ + +-- +-- Extension upgrade template regression test +-- +-- Validates the upgrade template (age----y.y.y.sql) by comparing +-- the pg_catalog entries of a fresh install against an upgraded install. +-- If any object exists in the fresh install but is missing after upgrade, +-- the template is incomplete. This catches the case where a developer adds +-- a new SQL object to sql/ and sql_files but forgets to add it to the +-- upgrade template. +-- +-- Compared catalogs: +-- pg_proc — functions, aggregates, procedures (name, args, properties +-- including probin/prosrc to catch C-symbol renames and +-- SQL-body changes) +-- pg_class — tables, views, sequences, indexes (name, kind) +-- pg_type — types (name, type category) +-- pg_operator — operators (name, left/right types) +-- pg_cast — casts involving AGE types (source, target, context) +-- pg_opclass — operator classes (name, access method) +-- pg_constraint — constraints (name, type, table, referenced table) +-- pg_depend — extension membership (every AGE-owned object must be +-- linked back to the extension via deptype='e'; catches +-- a missing ALTER EXTENSION age ADD ... in the template) +-- +-- All comparison queries should return 0 rows. +-- +-- Note on synthetic-initial install (step 10): the synthetic '*_initial' +-- snapshot is built from a fixed historical commit, so its CREATE FUNCTION +-- statements may reference C symbols that have since been removed from the +-- current age.so. Step 10 disables check_function_bodies so that dlsym is +-- deferred to call time; the immediately-following ALTER EXTENSION UPDATE +-- (step 11) DROPs any such retired functions before any plan can call them. +-- This lets developers cleanly remove deprecated C entry points without +-- needing to keep error-raising stubs in age.so. +-- + +LOAD 'age'; +SET search_path TO ag_catalog; + +-- Step 1: Clean up any graphs left by prior tests (deterministic, no output). +DO $$ +DECLARE + graph_name ag_graph.name%TYPE; +BEGIN + PERFORM set_config('client_min_messages', 'warning', true); + + FOR graph_name IN + SELECT name + FROM ag_graph + ORDER BY name + LOOP + PERFORM drop_graph(graph_name, true); + END LOOP; +END +$$; + +-- ===================================================================== +-- FRESH INSTALL SNAPSHOTS (Steps 2-7b) +-- Capture the catalog state from the default CREATE EXTENSION install. +-- ===================================================================== + +-- Step 2: Snapshot functions (includes aggregates via prokind). +-- probin/prosrc capture the binding to the implementation: +-- * LANGUAGE c : probin = '$libdir/age', prosrc = C symbol name +-- (a renamed/retargeted symbol shows up here) +-- * LANGUAGE sql/plpgsql: probin = NULL, prosrc = function body text +-- (a body change in the upgrade template shows up here) +-- * LANGUAGE internal : probin = NULL, prosrc = builtin name +CREATE TEMP TABLE _fresh_funcs AS +SELECT proname::text, + pg_get_function_identity_arguments(oid) AS args, + provolatile::text, proisstrict::text, prokind::text, + prorettype::regtype::text AS rettype, proretset::text, + COALESCE(probin, '') AS probin, + COALESCE(prosrc, '') AS prosrc +FROM pg_proc +WHERE pronamespace = 'ag_catalog'::regnamespace +ORDER BY proname, args; + +-- Step 3: Snapshot relations (tables, views, sequences, indexes). +CREATE TEMP TABLE _fresh_rels AS +SELECT relname::text, relkind::text +FROM pg_class +WHERE relnamespace = 'ag_catalog'::regnamespace + AND relkind IN ('r', 'v', 'S', 'i') +ORDER BY relname; + +-- Step 4: Snapshot types. +CREATE TEMP TABLE _fresh_types AS +SELECT typname::text, typtype::text +FROM pg_type +WHERE typnamespace = 'ag_catalog'::regnamespace + AND typname NOT LIKE 'pg_toast%' +ORDER BY typname; + +-- Step 5: Snapshot operators. +CREATE TEMP TABLE _fresh_ops AS +SELECT oprname::text, + oprleft::regtype::text AS lefttype, + oprright::regtype::text AS righttype +FROM pg_operator +WHERE oprnamespace = 'ag_catalog'::regnamespace +ORDER BY oprname, lefttype, righttype; + +-- Step 6: Snapshot casts involving AGE types, and operator classes. +CREATE TEMP TABLE _fresh_casts AS +SELECT castsource::regtype::text AS source_type, + casttarget::regtype::text AS target_type, + castcontext::text +FROM pg_cast +WHERE castsource IN (SELECT oid FROM pg_type WHERE typnamespace = 'ag_catalog'::regnamespace) + OR casttarget IN (SELECT oid FROM pg_type WHERE typnamespace = 'ag_catalog'::regnamespace) +ORDER BY source_type, target_type; + +CREATE TEMP TABLE _fresh_opclass AS +SELECT opcname::text, + (SELECT amname FROM pg_am WHERE oid = opcmethod)::text AS amname +FROM pg_opclass +WHERE opcnamespace = 'ag_catalog'::regnamespace +ORDER BY opcname; + +-- Step 7: Snapshot constraints. +CREATE TEMP TABLE _fresh_constraints AS +SELECT conname::text, contype::text, + conrelid::regclass::text AS table_name, + confrelid::regclass::text AS ref_table +FROM pg_constraint +WHERE connamespace = 'ag_catalog'::regnamespace +ORDER BY conname; + +-- Step 7b: Snapshot extension membership (pg_depend deptype='e'). +-- Every object that CREATE EXTENSION owns has a row in pg_depend linking +-- it to the extension. The upgrade template must produce the same set: +-- if it CREATEs an object but forgets to ALTER EXTENSION ADD it, the +-- catalog row exists (so funcs_match/rels_match would pass) but the +-- pg_depend link is absent and pg_dump --extension would diverge. +CREATE TEMP TABLE _fresh_extmembers AS +SELECT + CASE d.classid + WHEN 'pg_proc'::regclass + THEN 'function: ' || d.objid::regprocedure::text + WHEN 'pg_type'::regclass + THEN 'type: ' || d.objid::regtype::text + WHEN 'pg_class'::regclass + THEN 'relation: ' || d.objid::regclass::text + || ' (' || (SELECT relkind::text FROM pg_class WHERE oid = d.objid) || ')' + WHEN 'pg_operator'::regclass + THEN 'operator: ' || d.objid::regoperator::text + WHEN 'pg_cast'::regclass + THEN 'cast: ' || (SELECT castsource::regtype::text || ' -> ' || casttarget::regtype::text + FROM pg_cast WHERE oid = d.objid) + WHEN 'pg_opclass'::regclass + THEN 'opclass: ' || (SELECT opcname || ' (' || (SELECT amname FROM pg_am WHERE oid = opcmethod) || ')' + FROM pg_opclass WHERE oid = d.objid) + WHEN 'pg_constraint'::regclass + THEN 'constraint: ' || (SELECT conname || ' on ' || conrelid::regclass::text + FROM pg_constraint WHERE oid = d.objid) + WHEN 'pg_opfamily'::regclass + THEN 'opfamily: ' || (SELECT opfname || ' (' || (SELECT amname FROM pg_am WHERE oid = opfmethod) || ')' + FROM pg_opfamily WHERE oid = d.objid) + WHEN 'pg_amop'::regclass + THEN 'amop: ' || (SELECT (SELECT opfname FROM pg_opfamily WHERE oid = a.amopfamily) + || ' [strategy ' || a.amopstrategy || '] ' + || a.amoplefttype::regtype::text || ',' + || a.amoprighttype::regtype::text || ' op ' + || a.amopopr::regoperator::text + FROM pg_amop a WHERE a.oid = d.objid) + WHEN 'pg_amproc'::regclass + THEN 'amproc: ' || (SELECT (SELECT opfname FROM pg_opfamily WHERE oid = p.amprocfamily) + || ' [proc ' || p.amprocnum || '] ' + || p.amproclefttype::regtype::text || ',' + || p.amprocrighttype::regtype::text || ' fn ' + || p.amproc::regprocedure::text + FROM pg_amproc p WHERE p.oid = d.objid) + ELSE 'unhandled[' || d.classid::regclass::text || ']' + END AS member +FROM pg_depend d +WHERE d.deptype = 'e' + AND d.refclassid = 'pg_extension'::regclass + AND d.refobjid = (SELECT oid FROM pg_extension WHERE extname = 'age') +ORDER BY 1; + +-- Step 8: Drop AGE entirely. +DROP EXTENSION age; + +-- Step 9: Verify we have an upgrade path available. +SELECT count(*) > 1 AS has_upgrade_path +FROM pg_available_extension_versions WHERE name = 'age'; + +-- Step 10: Install AGE at the synthetic initial version. +-- +-- Disable check_function_bodies for this CREATE only. The synthetic +-- '*_initial' SQL is pulled from a fixed historical commit and may +-- declare C functions whose symbols have since been removed from the +-- current age.so. With check_function_bodies=on, PostgreSQL would dlsym +-- each such symbol at CREATE FUNCTION time and abort. Deferring the +-- symbol probe to call time is safe because step 11 (ALTER EXTENSION +-- UPDATE) immediately runs the upgrade template, which DROPs any +-- removed-in-HEAD functions before the test (or any user) can call them. +-- The fresh CREATE EXTENSION at step 35 keeps the GUC at its default, +-- so any inconsistency between HEAD's SQL and HEAD's age.so is still +-- caught at install time on the production code path. +SET check_function_bodies = off; +DO $$ +DECLARE init_ver text; +BEGIN + SELECT version INTO init_ver + FROM pg_available_extension_versions + WHERE name = 'age' AND version LIKE '%_initial' + ORDER BY version DESC + LIMIT 1; + IF init_ver IS NULL THEN + RAISE EXCEPTION 'No initial version available for upgrade test'; + END IF; + EXECUTE format('CREATE EXTENSION age VERSION %L', init_ver); +END; +$$; +RESET check_function_bodies; + +-- Step 11: Upgrade to the current (default) version via the stamped template. +DO $$ +DECLARE curr_ver text; +BEGIN + SELECT default_version INTO curr_ver + FROM pg_available_extensions + WHERE name = 'age'; + IF curr_ver IS NULL THEN + RAISE EXCEPTION 'No default version found for upgrade test'; + END IF; + EXECUTE format('ALTER EXTENSION age UPDATE TO %L', curr_ver); +END; +$$; + +-- Step 12: Confirm the upgrade succeeded. +SELECT installed_version = default_version AS upgraded_to_current +FROM pg_available_extensions WHERE name = 'age'; + +-- ===================================================================== +-- UPGRADED INSTALL SNAPSHOTS (Steps 13-18b) +-- Capture the catalog state after upgrade from initial to current. +-- ===================================================================== + +-- Step 13: Snapshot functions (probin/prosrc included; see step 2). +CREATE TEMP TABLE _upgraded_funcs AS +SELECT proname::text, + pg_get_function_identity_arguments(oid) AS args, + provolatile::text, proisstrict::text, prokind::text, + prorettype::regtype::text AS rettype, proretset::text, + COALESCE(probin, '') AS probin, + COALESCE(prosrc, '') AS prosrc +FROM pg_proc +WHERE pronamespace = 'ag_catalog'::regnamespace +ORDER BY proname, args; + +-- Step 14: Snapshot relations. +CREATE TEMP TABLE _upgraded_rels AS +SELECT relname::text, relkind::text +FROM pg_class +WHERE relnamespace = 'ag_catalog'::regnamespace + AND relkind IN ('r', 'v', 'S', 'i') +ORDER BY relname; + +-- Step 15: Snapshot types. +CREATE TEMP TABLE _upgraded_types AS +SELECT typname::text, typtype::text +FROM pg_type +WHERE typnamespace = 'ag_catalog'::regnamespace + AND typname NOT LIKE 'pg_toast%' +ORDER BY typname; + +-- Step 16: Snapshot operators. +CREATE TEMP TABLE _upgraded_ops AS +SELECT oprname::text, + oprleft::regtype::text AS lefttype, + oprright::regtype::text AS righttype +FROM pg_operator +WHERE oprnamespace = 'ag_catalog'::regnamespace +ORDER BY oprname, lefttype, righttype; + +-- Step 17: Snapshot casts and operator classes. +CREATE TEMP TABLE _upgraded_casts AS +SELECT castsource::regtype::text AS source_type, + casttarget::regtype::text AS target_type, + castcontext::text +FROM pg_cast +WHERE castsource IN (SELECT oid FROM pg_type WHERE typnamespace = 'ag_catalog'::regnamespace) + OR casttarget IN (SELECT oid FROM pg_type WHERE typnamespace = 'ag_catalog'::regnamespace) +ORDER BY source_type, target_type; + +CREATE TEMP TABLE _upgraded_opclass AS +SELECT opcname::text, + (SELECT amname FROM pg_am WHERE oid = opcmethod)::text AS amname +FROM pg_opclass +WHERE opcnamespace = 'ag_catalog'::regnamespace +ORDER BY opcname; + + +-- Step 18: Snapshot constraints. +CREATE TEMP TABLE _upgraded_constraints AS +SELECT conname::text, contype::text, + conrelid::regclass::text AS table_name, + confrelid::regclass::text AS ref_table +FROM pg_constraint +WHERE connamespace = 'ag_catalog'::regnamespace +ORDER BY conname; + +-- Step 18b: Snapshot extension membership after upgrade (see step 7b). +CREATE TEMP TABLE _upgraded_extmembers AS +SELECT + CASE d.classid + WHEN 'pg_proc'::regclass + THEN 'function: ' || d.objid::regprocedure::text + WHEN 'pg_type'::regclass + THEN 'type: ' || d.objid::regtype::text + WHEN 'pg_class'::regclass + THEN 'relation: ' || d.objid::regclass::text + || ' (' || (SELECT relkind::text FROM pg_class WHERE oid = d.objid) || ')' + WHEN 'pg_operator'::regclass + THEN 'operator: ' || d.objid::regoperator::text + WHEN 'pg_cast'::regclass + THEN 'cast: ' || (SELECT castsource::regtype::text || ' -> ' || casttarget::regtype::text + FROM pg_cast WHERE oid = d.objid) + WHEN 'pg_opclass'::regclass + THEN 'opclass: ' || (SELECT opcname || ' (' || (SELECT amname FROM pg_am WHERE oid = opcmethod) || ')' + FROM pg_opclass WHERE oid = d.objid) + WHEN 'pg_constraint'::regclass + THEN 'constraint: ' || (SELECT conname || ' on ' || conrelid::regclass::text + FROM pg_constraint WHERE oid = d.objid) + WHEN 'pg_opfamily'::regclass + THEN 'opfamily: ' || (SELECT opfname || ' (' || (SELECT amname FROM pg_am WHERE oid = opfmethod) || ')' + FROM pg_opfamily WHERE oid = d.objid) + WHEN 'pg_amop'::regclass + THEN 'amop: ' || (SELECT (SELECT opfname FROM pg_opfamily WHERE oid = a.amopfamily) + || ' [strategy ' || a.amopstrategy || '] ' + || a.amoplefttype::regtype::text || ',' + || a.amoprighttype::regtype::text || ' op ' + || a.amopopr::regoperator::text + FROM pg_amop a WHERE a.oid = d.objid) + WHEN 'pg_amproc'::regclass + THEN 'amproc: ' || (SELECT (SELECT opfname FROM pg_opfamily WHERE oid = p.amprocfamily) + || ' [proc ' || p.amprocnum || '] ' + || p.amproclefttype::regtype::text || ',' + || p.amprocrighttype::regtype::text || ' fn ' + || p.amproc::regprocedure::text + FROM pg_amproc p WHERE p.oid = d.objid) + ELSE 'unhandled[' || d.classid::regclass::text || ']' + END AS member +FROM pg_depend d +WHERE d.deptype = 'e' + AND d.refclassid = 'pg_extension'::regclass + AND d.refobjid = (SELECT oid FROM pg_extension WHERE extname = 'age') +ORDER BY 1; + +-- ===================================================================== +-- COMPARISON: Missing or extra objects (Steps 19-33b) +-- Any rows returned indicate a template deficiency. +-- ===================================================================== + +-- Step 19: Functions MISSING after upgrade. +SELECT f.proname || '(' || f.args || ')' AS missing_function +FROM _fresh_funcs f +LEFT JOIN _upgraded_funcs u USING (proname, args) +WHERE u.proname IS NULL +ORDER BY 1; + +-- Step 20: Functions EXTRA after upgrade. +SELECT u.proname || '(' || u.args || ')' AS extra_function +FROM _upgraded_funcs u +LEFT JOIN _fresh_funcs f USING (proname, args) +WHERE f.proname IS NULL +ORDER BY 1; + +-- Step 21: Function PROPERTY changes +-- (kind, volatility, strictness, return type, return-set, binding, +-- body/symbol). The probin/prosrc check catches: +-- * a C function whose symbol was renamed in the upgrade template +-- * a SQL/plpgsql function whose body was changed in either path +-- * a language change between the fresh and upgrade installs. +SELECT f.proname || '(' || f.args || ')' AS function_name, + CASE WHEN f.prokind <> u.prokind THEN 'prokind: ' || f.prokind || '->' || u.prokind END AS kind_change, + CASE WHEN f.provolatile <> u.provolatile THEN 'volatile: ' || f.provolatile || '->' || u.provolatile END AS volatility_change, + CASE WHEN f.proisstrict <> u.proisstrict THEN 'strict: ' || f.proisstrict || '->' || u.proisstrict END AS strict_change, + CASE WHEN f.rettype <> u.rettype THEN 'rettype: ' || f.rettype || '->' || u.rettype END AS rettype_change, + CASE WHEN f.proretset <> u.proretset THEN 'retset: ' || f.proretset || '->' || u.proretset END AS retset_change, + CASE WHEN f.probin <> u.probin THEN 'probin: ' || f.probin || '->' || u.probin END AS probin_change, + CASE WHEN f.prosrc <> u.prosrc THEN 'prosrc changed' END AS prosrc_change +FROM _fresh_funcs f +JOIN _upgraded_funcs u USING (proname, args) +WHERE f.provolatile <> u.provolatile + OR f.proisstrict <> u.proisstrict + OR f.prokind <> u.prokind + OR f.rettype <> u.rettype + OR f.proretset <> u.proretset + OR f.probin <> u.probin + OR f.prosrc <> u.prosrc +ORDER BY 1; + +-- Step 22: Relations MISSING after upgrade. +SELECT f.relname || ' (' || f.relkind || ')' AS missing_relation +FROM _fresh_rels f +LEFT JOIN _upgraded_rels u USING (relname, relkind) +WHERE u.relname IS NULL +ORDER BY 1; + +-- Step 23: Relations EXTRA after upgrade. +SELECT u.relname || ' (' || u.relkind || ')' AS extra_relation +FROM _upgraded_rels u +LEFT JOIN _fresh_rels f USING (relname, relkind) +WHERE f.relname IS NULL +ORDER BY 1; + +-- Step 24: Types MISSING after upgrade. +SELECT f.typname || ' (' || f.typtype || ')' AS missing_type +FROM _fresh_types f +LEFT JOIN _upgraded_types u USING (typname, typtype) +WHERE u.typname IS NULL +ORDER BY 1; + +-- Step 25: Types EXTRA after upgrade. +SELECT u.typname || ' (' || u.typtype || ')' AS extra_type +FROM _upgraded_types u +LEFT JOIN _fresh_types f USING (typname, typtype) +WHERE f.typname IS NULL +ORDER BY 1; + +-- Step 26: Operators MISSING after upgrade. +SELECT f.oprname || ' (' || f.lefttype || ', ' || f.righttype || ')' AS missing_operator +FROM _fresh_ops f +LEFT JOIN _upgraded_ops u USING (oprname, lefttype, righttype) +WHERE u.oprname IS NULL +ORDER BY 1; + +-- Step 27: Operators EXTRA after upgrade. +SELECT u.oprname || ' (' || u.lefttype || ', ' || u.righttype || ')' AS extra_operator +FROM _upgraded_ops u +LEFT JOIN _fresh_ops f USING (oprname, lefttype, righttype) +WHERE f.oprname IS NULL +ORDER BY 1; + +-- Step 28: Casts MISSING after upgrade. +SELECT f.source_type || ' -> ' || f.target_type || ' (' || f.castcontext || ')' AS missing_cast +FROM _fresh_casts f +LEFT JOIN _upgraded_casts u USING (source_type, target_type, castcontext) +WHERE u.source_type IS NULL +ORDER BY 1; + +-- Step 29: Casts EXTRA after upgrade. +SELECT u.source_type || ' -> ' || u.target_type || ' (' || u.castcontext || ')' AS extra_cast +FROM _upgraded_casts u +LEFT JOIN _fresh_casts f USING (source_type, target_type, castcontext) +WHERE f.source_type IS NULL +ORDER BY 1; + +-- Step 30: Operator classes MISSING after upgrade. +SELECT f.opcname || ' (' || f.amname || ')' AS missing_opclass +FROM _fresh_opclass f +LEFT JOIN _upgraded_opclass u USING (opcname, amname) +WHERE u.opcname IS NULL +ORDER BY 1; + +-- Step 31: Operator classes EXTRA after upgrade. +SELECT u.opcname || ' (' || u.amname || ')' AS extra_opclass +FROM _upgraded_opclass u +LEFT JOIN _fresh_opclass f USING (opcname, amname) +WHERE f.opcname IS NULL +ORDER BY 1; + +-- Step 32: Constraints MISSING after upgrade. +SELECT f.conname || ' (' || f.contype || ' on ' || f.table_name || ')' AS missing_constraint +FROM _fresh_constraints f +LEFT JOIN _upgraded_constraints u USING (conname, contype, table_name) +WHERE u.conname IS NULL +ORDER BY 1; + +-- Step 33: Constraints EXTRA after upgrade. +SELECT u.conname || ' (' || u.contype || ' on ' || u.table_name || ')' AS extra_constraint +FROM _upgraded_constraints u +LEFT JOIN _fresh_constraints f USING (conname, contype, table_name) +WHERE f.conname IS NULL +ORDER BY 1; + +-- Step 33a: Extension members MISSING after upgrade +-- (object exists in pg_proc/pg_class/etc. but is not linked to the AGE +-- extension via pg_depend, i.e. ALTER EXTENSION age ADD ... was forgotten). +SELECT f.member AS missing_extension_member +FROM _fresh_extmembers f +LEFT JOIN _upgraded_extmembers u USING (member) +WHERE u.member IS NULL +ORDER BY 1; + +-- Step 33b: Extension members EXTRA after upgrade. +SELECT u.member AS extra_extension_member +FROM _upgraded_extmembers u +LEFT JOIN _fresh_extmembers f USING (member) +WHERE f.member IS NULL +ORDER BY 1; + +-- ===================================================================== +-- SUMMARY (Step 34) +-- ===================================================================== + +-- Step 34: Verify all counts match (result: single row, all true). +SELECT + (SELECT count(*) FROM _fresh_funcs) = (SELECT count(*) FROM _upgraded_funcs) AS funcs_match, + (SELECT count(*) FROM _fresh_rels) = (SELECT count(*) FROM _upgraded_rels) AS rels_match, + (SELECT count(*) FROM _fresh_types) = (SELECT count(*) FROM _upgraded_types) AS types_match, + (SELECT count(*) FROM _fresh_ops) = (SELECT count(*) FROM _upgraded_ops) AS ops_match, + (SELECT count(*) FROM _fresh_casts) = (SELECT count(*) FROM _upgraded_casts) AS casts_match, + (SELECT count(*) FROM _fresh_opclass) = (SELECT count(*) FROM _upgraded_opclass) AS opclass_match, + (SELECT count(*) FROM _fresh_constraints) = (SELECT count(*) FROM _upgraded_constraints) AS constraints_match, + (SELECT count(*) FROM _fresh_extmembers) = (SELECT count(*) FROM _upgraded_extmembers) AS extmembers_match; + +-- ===================================================================== +-- CLEANUP (Steps 35-36) +-- ===================================================================== + +-- Step 35: Drop temp tables, restore AGE at default version. +DROP TABLE _fresh_funcs, _upgraded_funcs, _fresh_rels, _upgraded_rels, + _fresh_types, _upgraded_types, _fresh_ops, _upgraded_ops, + _fresh_casts, _upgraded_casts, _fresh_opclass, _upgraded_opclass, + _fresh_constraints, _upgraded_constraints, + _fresh_extmembers, _upgraded_extmembers; +DROP EXTENSION age; +CREATE EXTENSION age; + +-- Step 36: Remove synthetic upgrade test files from the extension directory. +\! sh ./regress/age_upgrade_cleanup.sh diff --git a/regress/sql/agehash.sql b/regress/sql/agehash.sql new file mode 100644 index 000000000..4e6f827e8 --- /dev/null +++ b/regress/sql/agehash.sql @@ -0,0 +1,9 @@ +/* + * agehash internal selftest. + * + * Exercises the Robin Hood open-addressing hashtable used by AGE's hot-path + * caches at boundary sizes (1, 7, 8, 9, 63, 64, 65, 1024, ...) and across + * grow thresholds. A success returns the literal "OK"; any failure returns + * "FAIL: " describing the offending case. + */ +SELECT ag_catalog._agehash_self_test(); diff --git a/regress/sql/agtype.sql b/regress/sql/agtype.sql index 6dab6bc30..e13edb16d 100644 --- a/regress/sql/agtype.sql +++ b/regress/sql/agtype.sql @@ -229,6 +229,13 @@ SELECT '3'::agtype + '3.14'::agtype; SELECT '3'::agtype + '3.14::numeric'::agtype; SELECT '3.14'::agtype + '3.14::numeric'::agtype; SELECT '3.14::numeric'::agtype + '3.14::numeric'::agtype; +SELECT 'null'::agtype - '1'; +SELECT 'null'::agtype + '1'; +SELECT 'null'::agtype * '1'; +SELECT 'null'::agtype / '1'; +SELECT 'null'::agtype % '1'; +SELECT 'null'::agtype ^ '1'; +SELECT -'null'::agtype; -- -- Test operator - for extended functionality @@ -305,7 +312,6 @@ SELECT '{"a":1 , "b":2, "c":3}'::agtype - '[null]'; SELECT '{"a":1 , "b":2, "c":3}'::agtype - '1'; SELECT '{"a":1 , "b":2, "c":3}'::agtype - 'null'; SELECT '{"a":1 , "b":2, "c":3}'::agtype - '["c","b"]' - '[1]' - '["a"]'; -SELECT 'null'::agtype - '1'; SELECT 'null'::agtype - '[1]'; SELECT '{"id": 1125899906842625, "label": "Vertex", "properties": {"a": "xyz", "b": true, "c": -19.888, "e": {"f": "abcdef", "g": {}, "h": [[], {}]}, "i": {"j": 199, "k": {"l": "mnopq"}}}}::vertex'::agtype - '"a"'; SELECT '{"id": 1125899906842625, "label": "Vertex", "properties": {"a": "xyz", "b": true, "c": -19.888, "e": {"f": "abcdef", "g": {}, "h": [[], {}]}, "i": {"j": 199, "k": {"l": "mnopq"}}}}::vertex'::agtype - '["a"]'; @@ -823,40 +829,40 @@ SELECT * FROM drop_graph('agtype_null_duplicate_test', true); -- Vertex -- --Basic Vertex Creation -SELECT _agtype_build_vertex('1'::graphid, $$label_name$$, agtype_build_map()); -SELECT _agtype_build_vertex('1'::graphid, $$label$$, agtype_build_map('id', 2)); +SELECT _agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map()); +SELECT _agtype_build_vertex('1'::graphid, '"label"', agtype_build_map('id', 2)); --Null properties -SELECT _agtype_build_vertex('1'::graphid, $$label_name$$, NULL); +SELECT _agtype_build_vertex('1'::graphid, '"label_name"', NULL); --Test access operator -SELECT agtype_access_operator(_agtype_build_vertex('1'::graphid, $$label$$, +SELECT agtype_access_operator(_agtype_build_vertex('1'::graphid, '"label"', agtype_build_map('id', 2)), '"id"'); -SELECT _agtype_build_vertex('1'::graphid, $$label$$, agtype_build_list()); +SELECT _agtype_build_vertex('1'::graphid, '"label"', agtype_build_list()); --Vertex in a map SELECT agtype_build_map( 'vertex', - _agtype_build_vertex('1'::graphid, $$label_name$$, agtype_build_map())); + _agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map())); SELECT agtype_access_operator( agtype_build_map( - 'vertex', _agtype_build_vertex('1'::graphid, $$label_name$$, + 'vertex', _agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map('key', 'value')), - 'other_vertex', _agtype_build_vertex('1'::graphid, $$label_name$$, + 'other_vertex', _agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map('key', 'other_value'))), '"vertex"'); --Vertex in a list SELECT agtype_build_list( - _agtype_build_vertex('1'::graphid, $$label_name$$, agtype_build_map()), - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map())); + _agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map())); SELECT agtype_access_operator( agtype_build_list( - _agtype_build_vertex('1'::graphid, $$label_name$$, + _agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map('id', 3)), - _agtype_build_vertex('2'::graphid, $$label_name$$, + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map('id', 4))), '0'); -- @@ -864,18 +870,18 @@ SELECT agtype_access_operator( -- --Basic Edge Creation SELECT _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map()); + '"label_name"', agtype_build_map()); SELECT _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)); + '"label"', agtype_build_map('id', 2)); --Null properties SELECT _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, NULL); + '"label_name"', NULL); --Test access operator SELECT agtype_access_operator(_agtype_build_edge('1'::graphid, '2'::graphid, - '3'::graphid, $$label$$, agtype_build_map('id', 2)),'"id"'); + '3'::graphid, '"label"', agtype_build_map('id', 2)),'"id"'); @@ -883,97 +889,97 @@ SELECT agtype_access_operator(_agtype_build_edge('1'::graphid, '2'::graphid, SELECT agtype_build_map( 'edge', _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map())); + '"label_name"', agtype_build_map())); SELECT agtype_access_operator( agtype_build_map( 'edge', _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map('key', 'value')), + '"label_name"', agtype_build_map('key', 'value')), 'other_edge', _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map('key', 'other_value'))), + '"label_name"', agtype_build_map('key', 'other_value'))), '"edge"'); --Edge in a list SELECT agtype_build_list( _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map()), + '"label_name"', agtype_build_map()), _agtype_build_edge('2'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map())); + '"label_name"', agtype_build_map())); SELECT agtype_access_operator( agtype_build_list( - _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, $$label_name$$, + _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, '"label_name"', agtype_build_map('id', 3)), - _agtype_build_edge('2'::graphid, '2'::graphid, '3'::graphid, $$label_name$$, + _agtype_build_edge('2'::graphid, '2'::graphid, '3'::graphid, '"label_name"', agtype_build_map('id', 4))), '0'); -- Path SELECT _agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)), - _agtype_build_vertex('3'::graphid, $$label_name$$, agtype_build_map()) + '"label"', agtype_build_map('id', 2)), + _agtype_build_vertex('3'::graphid, '"label_name"', agtype_build_map()) ); --All these paths should produce Errors SELECT _agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)) + '"label"', agtype_build_map('id', 2)) ); SELECT _agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)), - _agtype_build_vertex('3'::graphid, $$label_name$$, agtype_build_map()), + '"label"', agtype_build_map('id', 2)), + _agtype_build_vertex('3'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '4'::graphid, '5'::graphid, - $$label$$, agtype_build_map('id', 2)) + '"label"', agtype_build_map('id', 2)) ); SELECT _agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)), + '"label"', agtype_build_map('id', 2)), NULL ); SELECT _agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)), + '"label"', agtype_build_map('id', 2)), 1 ); SELECT _agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)), + '"label"', agtype_build_map('id', 2)), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)) + '"label"', agtype_build_map('id', 2)) ); -- -- id, startid, endid -- -SELECT age_id(_agtype_build_vertex('1'::graphid, $$label_name$$, agtype_build_map())); +SELECT age_id(_agtype_build_vertex('1'::graphid, '"label_name"', agtype_build_map())); SELECT age_id(_agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map('id', 2))); + '"label_name"', agtype_build_map('id', 2))); SELECT age_start_id(_agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map('id', 2))); + '"label_name"', agtype_build_map('id', 2))); SELECT age_end_id(_agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label_name$$, agtype_build_map('id', 2))); + '"label_name"', agtype_build_map('id', 2))); SELECT age_id(_agtype_build_path( - _agtype_build_vertex('2'::graphid, $$label_name$$, agtype_build_map()), + _agtype_build_vertex('2'::graphid, '"label_name"', agtype_build_map()), _agtype_build_edge('1'::graphid, '2'::graphid, '3'::graphid, - $$label$$, agtype_build_map('id', 2)), - _agtype_build_vertex('3'::graphid, $$label$$, agtype_build_map('id', 2)) + '"label"', agtype_build_map('id', 2)), + _agtype_build_vertex('3'::graphid, '"label"', agtype_build_map('id', 2)) )); SELECT age_id(agtype_in('1')); diff --git a/regress/sql/agtype_jsonb_cast.sql b/regress/sql/agtype_jsonb_cast.sql new file mode 100644 index 000000000..c3a6a8e27 --- /dev/null +++ b/regress/sql/agtype_jsonb_cast.sql @@ -0,0 +1,137 @@ +/* + * 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. + */ + +LOAD 'age'; +SET search_path TO ag_catalog; + +-- +-- agtype -> jsonb casts +-- + +-- String scalar +SELECT '"hello"'::agtype::jsonb; + +-- Null +SELECT 'null'::agtype::jsonb; + +-- Array +SELECT '[1, "two", null]'::agtype::jsonb; + +-- Nested array +SELECT '[[1, 2], [3, 4]]'::agtype::jsonb; + +-- Empty array +SELECT '[]'::agtype::jsonb; + +-- Object +SELECT '{"name": "Alice", "age": 30}'::agtype::jsonb; + +-- Nested object +SELECT '{"a": {"b": {"c": 1}}}'::agtype::jsonb; + +-- Object with array values +SELECT '{"tags": ["a", "b"], "count": 2}'::agtype::jsonb; + +-- Empty object +SELECT '{}'::agtype::jsonb; + +-- +-- jsonb -> agtype casts +-- + +-- String scalar +SELECT '"hello"'::jsonb::agtype; + +-- Numeric scalar +SELECT '42'::jsonb::agtype; + +-- Float scalar +SELECT '3.14'::jsonb::agtype; + +-- Boolean +SELECT 'true'::jsonb::agtype; + +-- Null +SELECT 'null'::jsonb::agtype; + +-- Array +SELECT '[1, "two", null]'::jsonb::agtype; + +-- Nested array +SELECT '[[1, 2], [3, 4]]'::jsonb::agtype; + +-- Empty array +SELECT '[]'::jsonb::agtype; + +-- Object +SELECT '{"name": "Alice", "age": 30}'::jsonb::agtype; + +-- Nested object +SELECT '{"a": {"b": {"c": 1}}}'::jsonb::agtype; + +-- Empty object +SELECT '{}'::jsonb::agtype; + +-- +-- Roundtrip: jsonb -> agtype -> jsonb +-- + +SELECT ('{"key": "value"}'::jsonb::agtype)::jsonb; +SELECT ('[1, 2, 3]'::jsonb::agtype)::jsonb; +SELECT ('null'::jsonb::agtype)::jsonb; + +-- +-- Graph data -> jsonb (vertex and edge) +-- + +SELECT create_graph('agtype_jsonb_test'); + +SELECT * FROM cypher('agtype_jsonb_test', $$ + CREATE (a:Person {name: 'Alice', age: 30})-[:KNOWS {since: 2020}]->(b:Person {name: 'Bob', age: 25}) + RETURN a, b +$$) AS (a agtype, b agtype); + +-- Vertex to jsonb: check structure +SELECT v::jsonb ? 'label' AS has_label, + v::jsonb ? 'properties' AS has_properties, + (v::jsonb -> 'properties' ->> 'name') AS name +FROM cypher('agtype_jsonb_test', $$ + MATCH (n:Person) RETURN n ORDER BY n.name +$$) AS (v agtype); + +-- Edge to jsonb: check structure +SELECT e::jsonb ? 'label' AS has_label, + (e::jsonb ->> 'label') AS label, + (e::jsonb -> 'properties' ->> 'since') AS since +FROM cypher('agtype_jsonb_test', $$ + MATCH ()-[r:KNOWS]->() RETURN r +$$) AS (e agtype); + +-- +-- NULL handling +-- + +SELECT NULL::agtype::jsonb; +SELECT NULL::jsonb::agtype; + +-- +-- Cleanup +-- + +SELECT drop_graph('agtype_jsonb_test', true); diff --git a/regress/sql/containment_selectivity.sql b/regress/sql/containment_selectivity.sql new file mode 100755 index 000000000..c35ad3fc8 --- /dev/null +++ b/regress/sql/containment_selectivity.sql @@ -0,0 +1,156 @@ +/* + * 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. + */ + +/* + * Regression coverage for issue #2356: + * The containment (@>, <@, @>>, <<@) and key-existence (?, ?|, ?&) + * operators on agtype must be bound to the lightweight selectivity + * helpers contsel / contjoinsel during planning. Earlier PG14+ + * branches used matchingsel / matchingjoinsel, which caused planning + * to invoke agtype_contains() against pg_statistic MCVs and produced + * a 30%+ planning-time regression on point queries (severe TPS drop + * reported on the PG18 branch). + * + * This test pins the bindings by querying pg_operator directly. If + * someone re-introduces matchingsel here, the test diff is loud and + * precise. + */ + +LOAD 'age'; +SET search_path TO ag_catalog; + +-- Selectivity helpers for the four containment operators. +SELECT o.oprname, + pg_catalog.format_type(o.oprleft, NULL) AS lhs, + pg_catalog.format_type(o.oprright, NULL) AS rhs, + o.oprrest::text AS restrict_fn, + o.oprjoin::text AS join_fn +FROM pg_catalog.pg_operator o +JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace +WHERE n.nspname = 'ag_catalog' + AND o.oprname IN ('@>', '<@', '@>>', '<<@') +ORDER BY o.oprname, lhs, rhs; + +-- Selectivity helpers for all key-existence operator overloads +-- (right-hand side may be text, text[], or agtype). +SELECT o.oprname, + pg_catalog.format_type(o.oprleft, NULL) AS lhs, + pg_catalog.format_type(o.oprright, NULL) AS rhs, + o.oprrest::text AS restrict_fn, + o.oprjoin::text AS join_fn +FROM pg_catalog.pg_operator o +JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace +WHERE n.nspname = 'ag_catalog' + AND o.oprname IN ('?', '?|', '?&') +ORDER BY o.oprname, lhs, rhs; + +-- Scoped guard for issue #2356: assert that none of the specific containment +-- and key-existence operators on agtype are bound to matchingsel / +-- matchingjoinsel. We deliberately limit the check to these operator names +-- (rather than every operator in ag_catalog) so unrelated operators that +-- legitimately use matchingsel for their own semantics are not affected by +-- this regression test. +SELECT COUNT(*) AS leaked_matchingsel_bindings +FROM pg_catalog.pg_operator o +JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace +WHERE n.nspname = 'ag_catalog' + AND o.oprname IN ('@>', '<@', '@>>', '<<@', '?', '?|', '?&') + AND (o.oprrest::text = 'matchingsel' + OR o.oprjoin::text = 'matchingjoinsel'); + +-- Smoke test: each operator still works functionally. Selectivity binding +-- only affects the planner; this guards against an inadvertent operator +-- removal as part of any future cleanup. +SELECT '{"a":1,"b":2}'::agtype @> '{"a":1}'::agtype AS contains_yes; +SELECT '{"a":1}'::agtype <@ '{"a":1,"b":2}'::agtype AS contained_yes; +SELECT '{"a":{"b":1}}'::agtype @>> '{"a":{"b":1}}'::agtype AS top_contains_yes; +SELECT '{"a":{"b":1}}'::agtype <<@ '{"a":{"b":1}}'::agtype AS top_contained_yes; +SELECT '{"a":1}'::agtype ? 'a'::text AS exists_text_yes; +SELECT '{"a":1}'::agtype ? '"a"'::agtype AS exists_agtype_yes; +SELECT '{"a":1,"b":2}'::agtype ?| ARRAY['a','c'] AS exists_any_text_yes; +SELECT '{"a":1,"b":2}'::agtype ?| '["a","c"]'::agtype AS exists_any_agtype_yes; +SELECT '{"a":1,"b":2}'::agtype ?& ARRAY['a','b'] AS exists_all_text_yes; +SELECT '{"a":1,"b":2}'::agtype ?& '["a","b"]'::agtype AS exists_all_agtype_yes; + +-- Upgrade-path assertion for issue #2356. +-- +-- The checks above cover a FRESH install: contsel / contjoinsel come straight +-- from agtype_operators.sql and agtype_exists.sql. Existing installs instead +-- pick up the fix from the ALTER OPERATOR ... SET (RESTRICT, JOIN) block that +-- age--1.7.0--y.y.y.sql ships and "ALTER EXTENSION age UPDATE" replays. Nothing +-- above exercises that block, so a silent regression in it would go unnoticed. +-- +-- We replay the shipped ALTER OPERATOR statements directly rather than running +-- ALTER EXTENSION age UPDATE: the dev upgrade script targets the placeholder +-- version "y.y.y" and is not a stable version-chain target inside the +-- regression harness. The whole section runs in a transaction that is rolled +-- back, so it observes the flip without permanently mutating the operator +-- catalog (PostgreSQL DDL is transactional). +BEGIN; + +-- Simulate a stale (pre-fix) install: force all ten overloads back onto +-- matchingsel / matchingjoinsel. +ALTER OPERATOR ag_catalog.@>(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.<@(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.@>>(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.<<@(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.?(agtype, text) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.?(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.?|(agtype, text[]) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.?|(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.?&(agtype, text[]) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); +ALTER OPERATOR ag_catalog.?&(agtype, agtype) SET (RESTRICT = matchingsel, JOIN = matchingjoinsel); + +-- Stale state: every overload now reports matchingsel / matchingjoinsel. +SELECT o.oprname, + pg_catalog.format_type(o.oprleft, NULL) AS lhs, + pg_catalog.format_type(o.oprright, NULL) AS rhs, + o.oprrest::text AS restrict_fn, + o.oprjoin::text AS join_fn +FROM pg_catalog.pg_operator o +JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace +WHERE n.nspname = 'ag_catalog' + AND o.oprname IN ('@>', '<@', '@>>', '<<@', '?', '?|', '?&') +ORDER BY o.oprname, lhs, rhs; + +-- Replay the exact ALTER OPERATOR block shipped in age--1.7.0--y.y.y.sql. +ALTER OPERATOR ag_catalog.@>(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.<@(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.@>>(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.<<@(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?(agtype, text) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?|(agtype, text[]) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?|(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?&(agtype, text[]) SET (RESTRICT = contsel, JOIN = contjoinsel); +ALTER OPERATOR ag_catalog.?&(agtype, agtype) SET (RESTRICT = contsel, JOIN = contjoinsel); + +-- After the upgrade replay every overload is back on contsel / contjoinsel. +SELECT o.oprname, + pg_catalog.format_type(o.oprleft, NULL) AS lhs, + pg_catalog.format_type(o.oprright, NULL) AS rhs, + o.oprrest::text AS restrict_fn, + o.oprjoin::text AS join_fn +FROM pg_catalog.pg_operator o +JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace +WHERE n.nspname = 'ag_catalog' + AND o.oprname IN ('@>', '<@', '@>>', '<<@', '?', '?|', '?&') +ORDER BY o.oprname, lhs, rhs; + +ROLLBACK; diff --git a/regress/sql/cypher_match.sql b/regress/sql/cypher_match.sql index 2817f36f6..8da012ff8 100644 --- a/regress/sql/cypher_match.sql +++ b/regress/sql/cypher_match.sql @@ -1068,9 +1068,9 @@ SELECT * FROM cypher('cypher_match', $$ MATCH (a {name:a.name}) RETURN a $$) as SELECT * FROM cypher('cypher_match', $$ MATCH (a {name:a.name, age:a.age}) RETURN a $$) as (a agtype); SELECT * FROM cypher('cypher_match', $$ MATCH (a {name:a.name}) MATCH (a {age:a.age}) RETURN a $$) as (a agtype); -SELECT * FROM cypher('cypher_match', $$ MATCH p=(a)-[u {relationship: u.relationship}]->(b) RETURN p $$) as (a agtype); -SELECT * FROM cypher('cypher_match', $$ MATCH p=(a)-[u {relationship: u.relationship, years: u.years}]->(b) RETURN p $$) as (a agtype); -SELECT * FROM cypher('cypher_match', $$ MATCH p=(a {name:a.name})-[u {relationship: u.relationship}]->(b {age:b.age}) RETURN p $$) as (a agtype); +SELECT * FROM cypher('cypher_match', $$ MATCH p=(a)-[u {relationship: u.relationship}]->(b) RETURN p ORDER BY id(u) $$) as (a agtype); +SELECT * FROM cypher('cypher_match', $$ MATCH p=(a)-[u {relationship: u.relationship, years: u.years}]->(b) RETURN p ORDER BY id(u) $$) as (a agtype); +SELECT * FROM cypher('cypher_match', $$ MATCH p=(a {name:a.name})-[u {relationship: u.relationship}]->(b {age:b.age}) RETURN p ORDER BY id(u) $$) as (a agtype); SELECT * FROM cypher('cypher_match', $$ CREATE () WITH * MATCH (x{n0:x.n1}) RETURN 0 $$) as (a agtype); @@ -1437,6 +1437,258 @@ SELECT count(*) FROM cypher('test_enable_containment', $$ MATCH p=(x:Customer)-[ SELECT * FROM cypher('test_enable_containment', $$ EXPLAIN (costs off) MATCH (x:Customer)-[:bought ={store: 'Amazon', addr:{city: 'Vancouver', street: 30}}]->(y:Product) RETURN 0 $$) as (a agtype); SELECT * FROM cypher('test_enable_containment', $$ EXPLAIN (costs off) MATCH (x:Customer ={school: { name: 'XYZ College',program: { major: 'Psyc', degree: 'BSc'} },phone: [ 123456789, 987654321, 456987123 ]}) RETURN 0 $$) as (a agtype); +-- +-- issue 2308: MATCH after CREATE returns 0 rows +-- +-- When all MATCH variables are already bound from a preceding CREATE + WITH, +-- the MATCH filter quals must evaluate after CREATE, not before. +-- +SELECT create_graph('issue_2308'); + +-- Reporter's exact case: CREATE + WITH + MATCH + SET + RETURN +SELECT * FROM cypher('issue_2308', $$ + CREATE (a:TestB3)-[e:B3REL]->(b:TestB3) + WITH a, e, b + MATCH p = (a)-[e]->(b) + SET a.something = 'something' + RETURN a +$$) AS (a agtype); + +-- Bound variables, no SET +SELECT * FROM cypher('issue_2308', $$ + CREATE (a:T2)-[e:R2]->(b:T2) + WITH a, e, b + MATCH (a)-[e]->(b) + RETURN a, e, b +$$) AS (a agtype, e agtype, b agtype); + +-- Reversed direction: filter should reject (0 rows expected) +SELECT * FROM cypher('issue_2308', $$ + CREATE (a:T3)-[e:R3]->(b:T3) + WITH a, e, b + MATCH (b)-[e]->(a) + RETURN a +$$) AS (a agtype); + +-- Node-only MATCH with bound variable +SELECT * FROM cypher('issue_2308', $$ + CREATE (a:T4 {name: 'test'}) + WITH a + MATCH (a) + RETURN a +$$) AS (a agtype); + +-- MATCH after SET (SET is also DML, chain must be protected) +SELECT * FROM cypher('issue_2308', $$ + CREATE (a:T5 {val: 1})-[e:R5]->(b:T5 {val: 2}) +$$) AS (r agtype); +SELECT * FROM cypher('issue_2308', $$ + MATCH (a:T5)-[e:R5]->(b:T5) + SET a.val = 10 + WITH a, e, b + MATCH (a)-[e]->(b) + RETURN a.val +$$) AS (val agtype); + +SELECT drop_graph('issue_2308', true); +-- Issue 1964 +-- +-- PREPARE with property parameter ($props) crashed the server when +-- age.enable_containment was set to off. The crash was in +-- transform_map_to_ind_recursive which blindly cast cypher_param +-- nodes to cypher_map, accessing invalid memory. +-- + +SELECT create_graph('issue_1964'); +SELECT * FROM cypher('issue_1964', $$ + CREATE (:Person {name: 'Alice', age: 30}), + (:Person {name: 'Bob', age: 25}) +$$) AS (result agtype); +SELECT * FROM cypher('issue_1964', $$ + CREATE (:Person {name: 'Alice'})-[:KNOWS {since: 2020}]->(:Person {name: 'Bob'}) +$$) AS (result agtype); + +-- Test PREPARE with enable_containment off (was crashing) +SET age.enable_containment = off; + +PREPARE issue_1964_vertex(agtype) AS + SELECT * FROM cypher('issue_1964', + $$MATCH (n $props) RETURN n $$, $1) AS (p agtype); +EXECUTE issue_1964_vertex('{"props": {"name": "Alice"}}'); +EXECUTE issue_1964_vertex('{"props": {"age": 25}}'); +DEALLOCATE issue_1964_vertex; + +-- Test edge property parameter with enable_containment off +PREPARE issue_1964_edge(agtype) AS + SELECT * FROM cypher('issue_1964', + $$MATCH ()-[r $props]->() RETURN r $$, $1) AS (p agtype); +EXECUTE issue_1964_edge('{"props": {"since": 2020}}'); +DEALLOCATE issue_1964_edge; + +-- Verify enable_containment on still works with PREPARE +SET age.enable_containment = on; + +PREPARE issue_1964_vertex_on(agtype) AS + SELECT * FROM cypher('issue_1964', + $$MATCH (n $props) RETURN n $$, $1) AS (p agtype); +EXECUTE issue_1964_vertex_on('{"props": {"name": "Alice"}}'); +DEALLOCATE issue_1964_vertex_on; + +-- Test =properties form with PREPARE (uses @>> top-level containment) +SET age.enable_containment = off; + +PREPARE issue_1964_vertex_eq(agtype) AS + SELECT * FROM cypher('issue_1964', + $$MATCH (n = $props) RETURN n $$, $1) AS (p agtype); +EXECUTE issue_1964_vertex_eq('{"props": {"name": "Alice", "age": 25}}'); +DEALLOCATE issue_1964_vertex_eq; + +PREPARE issue_1964_edge_eq(agtype) AS + SELECT * FROM cypher('issue_1964', + $$MATCH ()-[r = $props]->() RETURN r $$, $1) AS (p agtype); +EXECUTE issue_1964_edge_eq('{"props": {"since": 2020}}'); +DEALLOCATE issue_1964_edge_eq; + +-- Same with enable_containment on +SET age.enable_containment = on; + +PREPARE issue_1964_vertex_eq_on(agtype) AS + SELECT * FROM cypher('issue_1964', + $$MATCH (n = $props) RETURN n $$, $1) AS (p agtype); +EXECUTE issue_1964_vertex_eq_on('{"props": {"name": "Alice", "age": 25}}'); +DEALLOCATE issue_1964_vertex_eq_on; + +-- +-- Issue 2193: CREATE ... WITH ... MATCH on brand-new label returns 0 rows +-- on first execution because match_check_valid_label() runs before +-- transform_prev_cypher_clause() creates the label table. +-- +SELECT create_graph('issue_2193'); + +-- Reporter's exact case: CREATE two Person nodes, then MATCH on Person +-- Should return 2 rows on the very first execution +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:Person {name: 'Jane', livesIn: 'London'}), + (b:Person {name: 'Tom', livesIn: 'Copenhagen'}) + WITH a, b + MATCH (p:Person) + RETURN p.name ORDER BY p.name +$$) AS (result agtype); + +-- Single CREATE + MATCH on brand-new label +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:City {name: 'Berlin'}) + WITH a + MATCH (c:City) + RETURN c.name ORDER BY c.name +$$) AS (result agtype); + +-- MATCH on a label that now exists (second execution) still works +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:City {name: 'Paris'}) + WITH a + MATCH (c:City) + RETURN c.name ORDER BY c.name +$$) AS (result agtype); + +-- MATCH on non-existent label without DML predecessor still returns 0 rows +SELECT * FROM cypher('issue_2193', $$ + MATCH (x:NonExistentLabel) + RETURN x +$$) AS (result agtype); + +-- MATCH on non-existent label after DML predecessor still returns 0 rows +-- and MATCH-introduced variable (p) is properly registered +SELECT * FROM cypher('issue_2193', $$ + CREATE (a:Person {name: 'Alice'}) + WITH a + MATCH (p:NonExistentLabel) + RETURN p +$$) AS (result agtype); + +-- Verify that the CREATE side effect was preserved even though MATCH +-- returned 0 rows (guards against plan-elimination regressions where +-- a constant-false predicate causes PG to skip the DML predecessor) +SELECT * FROM cypher('issue_2193', $$ + MATCH (a:Person {name: 'Alice'}) + RETURN a.name +$$) AS (result agtype); + +SELECT drop_graph('issue_2193', true); + +-- +-- Issue 2378: OPTIONAL MATCH may incorrectly drop null-preserving outer +-- rows when its WHERE clause contains a correlated sub-pattern predicate. +-- +-- Cypher OPTIONAL MATCH semantics: the WHERE applies to the optional +-- binding; when no right-hand row survives the predicate, the outer row +-- is still emitted with NULLs in the optional columns. Before the fix, +-- a WHERE containing EXISTS { ... } or COUNT { ... } was attached as an +-- outer filter on the transformed subquery, so it ran after the LATERAL +-- LEFT JOIN produced null-preserving rows and then incorrectly dropped +-- them when the predicate evaluated NULL/false on the nulled side. +-- +SELECT create_graph('issue_2378'); +SELECT * FROM cypher('issue_2378', $$ + CREATE (a:Person {name: 'Alice'}), + (b:Person {name: 'Bob'}), + (c:Person {name: 'Charlie'}), + (a)-[:KNOWS]->(b), + (a)-[:KNOWS]->(c) +$$) AS (v agtype); + +-- Correlated EXISTS referencing the optional variable (friend). +-- Neither Bob nor Charlie knows anyone, so for every outer p the +-- predicate fails on all optional matches; expect one row per person +-- with friend = NULL. +SELECT * FROM cypher('issue_2378', $$ + MATCH (p:Person) + OPTIONAL MATCH (p)-[:KNOWS]->(friend:Person) + WHERE EXISTS { (friend)-[:KNOWS]->(:Person) } + RETURN p.name AS name, friend.name AS friend + ORDER BY name +$$) AS (name agtype, friend agtype); + +-- Correlated EXISTS referencing the outer variable (p). +-- Alice knows someone so her optional matches pass; Bob and Charlie +-- don't, so they are emitted with NULL friend. +SELECT * FROM cypher('issue_2378', $$ + MATCH (p:Person) + OPTIONAL MATCH (p)-[:KNOWS]->(friend:Person) + WHERE EXISTS { (p)-[:KNOWS]->(:Person) } + RETURN p.name AS name, friend.name AS friend + ORDER BY name, friend +$$) AS (name agtype, friend agtype); + +-- Non-correlated EXISTS (was already working; kept as a regression guard). +SELECT * FROM cypher('issue_2378', $$ + MATCH (p:Person) + OPTIONAL MATCH (p)-[:KNOWS]->(friend:Person) + WHERE EXISTS { MATCH (x:Person) RETURN x } + RETURN p.name AS name, friend.name AS friend + ORDER BY name, friend +$$) AS (name agtype, friend agtype); + +-- Plain scalar predicate on the optional variable (was already working). +SELECT * FROM cypher('issue_2378', $$ + MATCH (p:Person) + OPTIONAL MATCH (p)-[:KNOWS]->(friend:Person) + WHERE friend.name = 'Bob' + RETURN p.name AS name, friend.name AS friend + ORDER BY name +$$) AS (name agtype, friend agtype); + +-- Constant-false WHERE on the optional side (was already working). +SELECT * FROM cypher('issue_2378', $$ + MATCH (p:Person) + OPTIONAL MATCH (p)-[:KNOWS]->(friend:Person) + WHERE false + RETURN p.name AS name, friend.name AS friend + ORDER BY name +$$) AS (name agtype, friend agtype); + +SELECT drop_graph('issue_2378', true); + -- -- Clean up -- @@ -1446,6 +1698,7 @@ SELECT drop_graph('test_enable_containment', true); SELECT drop_graph('issue_945', true); SELECT drop_graph('issue_1399', true); SELECT drop_graph('issue_1393', true); +SELECT drop_graph('issue_1964', true); -- -- End diff --git a/regress/sql/cypher_merge.sql b/regress/sql/cypher_merge.sql index 02c9d21c2..86b3e0235 100644 --- a/regress/sql/cypher_merge.sql +++ b/regress/sql/cypher_merge.sql @@ -785,21 +785,320 @@ SELECT * FROM cypher('issue_1907', $$ MERGE (a {name: 'Test Node A'})-[r:RELATED -- should return properties added SELECT * FROM cypher('issue_1907', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype); +-- +-- Fix issue 1446: First MERGE does not see the second MERGE's changes +-- +-- When chained MERGEs appear (MATCH ... MERGE ... MERGE ...), the first +-- (non-terminal) MERGE returned rows one at a time, so the second MERGE's +-- lateral join was materialized before the first finished all iterations. +-- This caused duplicate nodes. The fix makes non-terminal MERGE eager: +-- it processes ALL input rows before returning any. +-- +SELECT * FROM create_graph('issue_1446'); +-- Reporter's exact setup: two initial nodes +SELECT * FROM cypher('issue_1446', $$ CREATE (:A), (:C) $$) AS (a agtype); +-- Reporter's exact reproduction case: two chained MERGEs +-- Without fix: C is created multiple times (once per MATCH row) because the +-- second MERGE's lateral join materializes before the first MERGE finishes. +-- With fix: returns 2 rows, C is found and reused by the second MERGE. +SELECT * FROM cypher('issue_1446', $$ + MATCH (x) + MERGE (x)-[:r]->(:t) + MERGE (:C)-[:r]->(:t) + RETURN x +$$) AS (a agtype); +-- Verify: A(1), C(1), t(2) = 4 nodes, 2 edges +SELECT * FROM cypher('issue_1446', $$ + MATCH (n) + RETURN labels(n) AS label, count(*) AS cnt + ORDER BY label +$$) AS (label agtype, cnt agtype); +SELECT * FROM cypher('issue_1446', $$ + MATCH ()-[e]->() + RETURN count(*) AS edge_count +$$) AS (edge_count agtype); + +-- Test with 3 initial nodes: ensures eager buffering works for larger sets +SELECT * FROM cypher('issue_1446', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); +SELECT * FROM cypher('issue_1446', $$ CREATE (:X), (:Y), (:Z) $$) AS (a agtype); +SELECT * FROM cypher('issue_1446', $$ + MATCH (n) + MERGE (n)-[:link]->(:shared) + MERGE (:hub)-[:link]->(:shared) + RETURN n +$$) AS (a agtype); +-- Without fix: hub is created 3 times (once per MATCH row). +-- With fix: hub(1), shared(4), X(1), Y(1), Z(1) = 8 nodes, 4 edges +-- (3 n->shared edges + 1 hub->shared edge; hub reused for rows 2 & 3) +SELECT * FROM cypher('issue_1446', $$ + MATCH (n) + RETURN labels(n) AS label, count(*) AS cnt + ORDER BY label +$$) AS (label agtype, cnt agtype); +SELECT * FROM cypher('issue_1446', $$ + MATCH ()-[e]->() + RETURN count(*) AS edge_count +$$) AS (edge_count agtype); + +-- Test chained MERGE with empty MATCH result (empty buffer scenario) +-- Without fix: non-terminal MERGE falls through to terminal logic, +-- incorrectly creating entities when MATCH returns 0 rows. +SELECT * FROM cypher('issue_1446', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); +SELECT * FROM cypher('issue_1446', $$ + MATCH (x:NonExistent) + MERGE (x)-[:r]->(:t) + MERGE (:C)-[:r]->(:t) + RETURN count(*) AS cnt +$$) AS (cnt agtype); +-- Verify no nodes or edges were created +SELECT * FROM cypher('issue_1446', $$ + MATCH (n) + RETURN count(*) AS node_count +$$) AS (node_count agtype); +SELECT * FROM cypher('issue_1446', $$ + MATCH ()-[e]->() + RETURN count(*) AS edge_count +$$) AS (edge_count agtype); + +-- Issue 1954: CREATE + WITH + MERGE causes "vertex was deleted" error +-- when the number of input rows exceeds the snapshot's command ID window. +-- entity_exists() used a stale curcid, making recently-created vertices +-- invisible on later iterations. +-- +SELECT * FROM create_graph('issue_1954'); + +-- Setup: create source nodes and relationships (3 rows to trigger the bug) +SELECT * FROM cypher('issue_1954', $$ + CREATE (:A {name: 'a1'})-[:R]->(:B {name: 'b1'}), + (:A {name: 'a2'})-[:R]->(:B {name: 'b2'}), + (:A {name: 'a3'})-[:R]->(:B {name: 'b3'}) +$$) AS (result agtype); + +-- This query would fail with "vertex assigned to variable c was deleted" +-- on the 3rd row before the fix. +SELECT * FROM cypher('issue_1954', $$ + MATCH (a:A)-[:R]->(b:B) + CREATE (c:C {name: a.name + '|' + b.name}) + WITH a, b, c + MERGE (a)-[:LINK]->(c) + RETURN a.name, b.name, c.name + ORDER BY a.name +$$) AS (a agtype, b agtype, c agtype); + +-- Verify edges were created +SELECT * FROM cypher('issue_1954', $$ + MATCH (a:A)-[:LINK]->(c:C) + RETURN a.name, c.name + ORDER BY a.name +$$) AS (a agtype, c agtype); + +-- Test with two MERGEs (more complex case from the original report) +SELECT * FROM cypher('issue_1954', $$ + MATCH ()-[e:LINK]->() DELETE e +$$) AS (result agtype); +SELECT * FROM cypher('issue_1954', $$ + MATCH (c:C) DELETE c +$$) AS (result agtype); + +SELECT * FROM cypher('issue_1954', $$ + MATCH (a:A)-[:R]->(b:B) + CREATE (c:C {name: a.name + '|' + b.name}) + WITH a, b, c + MERGE (a)-[:LINK1]->(c) + MERGE (b)-[:LINK2]->(c) + RETURN a.name, b.name, c.name + ORDER BY a.name +$$) AS (a agtype, b agtype, c agtype); + +-- Verify both sets of edges +SELECT * FROM cypher('issue_1954', $$ + MATCH (a:A)-[:LINK1]->(c:C) + RETURN a.name, c.name + ORDER BY a.name +$$) AS (a agtype, c agtype); + +SELECT * FROM cypher('issue_1954', $$ + MATCH (b:B)-[:LINK2]->(c:C) + RETURN b.name, c.name + ORDER BY b.name +$$) AS (b agtype, c agtype); + -- -- clean up graphs -- SELECT * FROM cypher('cypher_merge', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); SELECT * FROM cypher('issue_1630', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); SELECT * FROM cypher('issue_1709', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); +SELECT * FROM cypher('issue_1446', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); +SELECT * FROM cypher('issue_1954', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); + +-- +-- ON CREATE SET / ON MATCH SET tests (issue #1619) +-- +SELECT create_graph('merge_actions'); + +-- Basic ON CREATE SET: first run creates the node +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Alice'}) + ON CREATE SET n.created = true + RETURN n.name, n.created +$$) AS (name agtype, created agtype); + +-- ON MATCH SET: second run matches the existing node +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Alice'}) + ON MATCH SET n.found = true + RETURN n.name, n.created, n.found +$$) AS (name agtype, created agtype, found agtype); + +-- Both ON CREATE SET and ON MATCH SET (first run = create) +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Bob'}) + ON CREATE SET n.created = true + ON MATCH SET n.matched = true + RETURN n.name, n.created, n.matched +$$) AS (name agtype, created agtype, matched agtype); + +-- Both ON CREATE SET and ON MATCH SET (second run = match) +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Bob'}) + ON CREATE SET n.created = true + ON MATCH SET n.matched = true + RETURN n.name, n.created, n.matched +$$) AS (name agtype, created agtype, matched agtype); + +-- ON CREATE SET with MERGE after MATCH (Case 1: has predecessor, first run = create) +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Alice'}) + MERGE (a)-[:KNOWS]->(b:Person {name: 'Charlie'}) + ON CREATE SET b.source = 'merge_create' + RETURN a.name, b.name, b.source +$$) AS (a agtype, b agtype, source agtype); + +-- ON MATCH SET with MERGE after MATCH (Case 1: has predecessor, second run = match) +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Alice'}) + MERGE (a)-[:KNOWS]->(b:Person {name: 'Charlie'}) + ON MATCH SET b.visited = true + RETURN a.name, b.name, b.visited +$$) AS (a agtype, b agtype, visited agtype); + +-- Multiple SET items in a single ON CREATE SET +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Dave'}) + ON CREATE SET n.a = 1, n.b = 2 + RETURN n.name, n.a, n.b +$$) AS (name agtype, a agtype, b agtype); + +-- Reverse order: ON MATCH before ON CREATE should work +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Eve'}) + ON MATCH SET n.seen = true + ON CREATE SET n.new = true + RETURN n.name, n.new +$$) AS (name agtype, new agtype); + +-- Error: ON CREATE SET specified more than once +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Bad'}) + ON CREATE SET n.a = 1 + ON CREATE SET n.b = 2 + RETURN n +$$) AS (n agtype); + +-- Error: ON MATCH SET specified more than once +SELECT * FROM cypher('merge_actions', $$ + MERGE (n:Person {name: 'Bad'}) + ON MATCH SET n.a = 1 + ON MATCH SET n.b = 2 + RETURN n +$$) AS (n agtype); + +-- Chained (non-terminal) MERGE with ON CREATE SET (eager-buffering path) +SELECT * FROM cypher('merge_actions', $$ + MERGE (a:Person {name: 'Frank'}) + ON CREATE SET a.created = true + MERGE (a)-[:KNOWS]->(b:Person {name: 'Grace'}) + ON CREATE SET b.created = true + RETURN a.name, a.created, b.name, b.created +$$) AS (a_name agtype, a_created agtype, b_name agtype, b_created agtype); + +-- Chained (non-terminal) MERGE with ON MATCH SET (second run = match) +SELECT * FROM cypher('merge_actions', $$ + MERGE (a:Person {name: 'Frank'}) + ON MATCH SET a.matched = true + MERGE (a)-[:KNOWS]->(b:Person {name: 'Grace'}) + ON MATCH SET b.matched = true + RETURN a.name, a.matched, b.name, b.matched +$$) AS (a_name agtype, a_matched agtype, b_name agtype, b_matched agtype); + +-- ON keyword as label name (backward compat via safe_keywords) +SELECT * FROM cypher('merge_actions', $$ + CREATE (n:on {name: 'test'}) + RETURN n.name +$$) AS (name agtype); + +-- Issue #2347: RHS of ON CREATE / ON MATCH SET referencing a bound +-- variable crashed the backend when MERGE had a previous clause, because +-- the lateral-join's ParseNamespaceItem had p_nscolumns=NULL. + +-- ON CREATE SET with RHS referencing the outer MATCH's variable +SELECT * FROM cypher('merge_actions', $$ CREATE (:Person {name:'Anchor'}) $$) AS (a agtype); +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Anchor'}) + MERGE (b:Person {name: 'FromOuter'}) + ON CREATE SET b.source_name = a.name + RETURN a.name, b.name, b.source_name +$$) AS (a_name agtype, b_name agtype, b_source agtype); + +-- ON CREATE SET with RHS referencing the MERGE-bound variable itself +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Anchor'}) + MERGE (b:Person {name: 'SelfRef'}) + ON CREATE SET b.echo_name = b.name + RETURN b.name, b.echo_name +$$) AS (b_name agtype, b_echo agtype); + +-- ON CREATE SET driven by UNWIND with self-reference on the RHS +-- (Muhammad's second reproducer) +SELECT * FROM cypher('merge_actions', $$ + UNWIND ['U1', 'U2'] AS nm + MERGE (n:Person {name: nm}) + ON CREATE SET n.copy_name = n.name + RETURN n.name, n.copy_name +$$) AS (n_name agtype, n_copy agtype); + +-- Multiple SET items mixing outer-ref, self-ref, and literal RHS +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Anchor'}) + MERGE (b:Person {name: 'MultiItem'}) + ON CREATE SET b.from_a = a.name, b.self = b.name, b.lit = 'literal' + RETURN b.from_a, b.self, b.lit +$$) AS (fa agtype, sf agtype, lit agtype); + +-- ON MATCH SET with variable RHS (second run on existing node) +SELECT * FROM cypher('merge_actions', $$ + MATCH (a:Person {name: 'Anchor'}) + MERGE (b:Person {name: 'FromOuter'}) + ON CREATE SET b.source_name = a.name + ON MATCH SET b.last_seen_by = a.name + RETURN b.source_name, b.last_seen_by +$$) AS (src agtype, last agtype); + +-- cleanup +SELECT * FROM cypher('merge_actions', $$ MATCH (n) DETACH DELETE n $$) AS (a agtype); -- -- delete graphs -- +SELECT drop_graph('merge_actions', true); SELECT drop_graph('issue_1907', true); SELECT drop_graph('cypher_merge', true); SELECT drop_graph('issue_1630', true); SELECT drop_graph('issue_1691', true); SELECT drop_graph('issue_1709', true); +SELECT drop_graph('issue_1446', true); +SELECT drop_graph('issue_1954', true); -- -- End diff --git a/regress/sql/cypher_vle.sql b/regress/sql/cypher_vle.sql index 5835627cc..5f3f54ed2 100644 --- a/regress/sql/cypher_vle.sql +++ b/regress/sql/cypher_vle.sql @@ -105,20 +105,20 @@ SELECT * FROM cypher('cypher_vle', $$MATCH ()-[*]->() RETURN count(*) $$) AS (e SELECT * FROM cypher('cypher_vle', $$MATCH (u)-[*]->() RETURN count(*) $$) AS (e agtype); SELECT * FROM cypher('cypher_vle', $$MATCH ()-[*]->(v) RETURN count(*) $$) AS (e agtype); -- Should find 2 -SELECT * FROM cypher('cypher_vle', $$MATCH (u:begin)<-[e*]-(v:end) RETURN e $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH (u:begin)<-[e*]-(v:end) RETURN e ORDER BY e ASC $$) AS (e agtype); -- Should find 5 -SELECT * FROM cypher('cypher_vle', $$MATCH p=(:begin)<-[*1..1]-()-[]-() RETURN p ORDER BY p $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(:begin)<-[*1..1]-()-[]-() RETURN p ORDER BY p ASC $$) AS (e agtype); -- Should find 2922 SELECT * FROM cypher('cypher_vle', $$MATCH p=()-[*]->(v) RETURN count(*) $$) AS (e agtype); -- Should find 2 -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)-[*3..3]->(v:end) RETURN p $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)-[*3..3]->(v:end) RETURN p ORDER BY p ASC $$) AS (e agtype); -- Should find 12 -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)-[*3..3]-(v:end) RETURN p $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)-[*3..3]-(v:end) RETURN p ORDER BY p ASC $$) AS (e agtype); -- Each should find 2 -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)<-[*]-(v:end) RETURN p $$) AS (e agtype); -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)<-[e*]-(v:end) RETURN p $$) AS (e agtype); -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)<-[e*]-(v:end) RETURN e $$) AS (e agtype); -SELECT * FROM cypher('cypher_vle', $$MATCH p=(:begin)<-[*]-()<-[]-(:end) RETURN p $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)<-[*]-(v:end) RETURN p ORDER BY p ASC $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)<-[e*]-(v:end) RETURN p ORDER BY p ASC $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)<-[e*]-(v:end) RETURN e ORDER BY e ASC $$) AS (e agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(:begin)<-[*]-()<-[]-(:end) RETURN p ORDER BY p ASC $$) AS (e agtype); -- Each should return 31 SELECT count(*) FROM cypher('cypher_vle', $$ MATCH ()-[e1]->(v)-[e2]->() RETURN e1,e2 $$) AS (e1 agtype, e2 agtype); SELECT count(*) FROM cypher('cypher_vle', $$ @@ -163,16 +163,16 @@ FROM cypher('cypher_vle', $$ RETURN a, e $$) AS (e1 agtype, e2 agtype); -- Should return 1 path -SELECT * FROM cypher('cypher_vle', $$ MATCH p=()<-[e1*]-(:end)-[e2*]->(:begin) RETURN p $$) AS (result agtype); +SELECT * FROM cypher('cypher_vle', $$ MATCH p=()<-[e1*]-(:end)-[e2*]->(:begin) RETURN p ORDER BY p ASC $$) AS (result agtype); -- Each should return 3 -SELECT * FROM cypher('cypher_vle', $$MATCH (u:begin)-[e*0..1]->(v) RETURN id(u), e, id(v) $$) AS (u agtype, e agtype, v agtype); -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)-[e*0..1]->(v) RETURN p $$) AS (p agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH (u:begin)-[e*0..1]->(v) RETURN id(u), e, id(v) ORDER BY id(u) ASC, e ASC, id(v) ASC $$) AS (u agtype, e agtype, v agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u:begin)-[e*0..1]->(v) RETURN p ORDER BY p ASC $$) AS (p agtype); -- Each should return 5 -SELECT * FROM cypher('cypher_vle', $$MATCH (u)-[e*0..0]->(v) RETURN id(u), e, id(v) $$) AS (u agtype, e agtype, v agtype); -SELECT * FROM cypher('cypher_vle', $$MATCH p=(u)-[e*0..0]->(v) RETURN id(u), p, id(v) $$) AS (u agtype, p agtype, v agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH (u)-[e*0..0]->(v) RETURN id(u), e, id(v) ORDER BY id(u) ASC, e ASC, id(v) ASC $$) AS (u agtype, e agtype, v agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=(u)-[e*0..0]->(v) RETURN id(u), p, id(v) ORDER BY id(u) ASC, p ASC, id(v) ASC $$) AS (u agtype, p agtype, v agtype); -- Each should return 13 and will be the same -SELECT * FROM cypher('cypher_vle', $$MATCH p=()-[*0..0]->()-[]->() RETURN p $$) AS (p agtype); -SELECT * FROM cypher('cypher_vle', $$MATCH p=()-[]->()-[*0..0]->() RETURN p $$) AS (p agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=()-[*0..0]->()-[]->() RETURN p ORDER BY p ASC $$) AS (p agtype); +SELECT * FROM cypher('cypher_vle', $$MATCH p=()-[]->()-[*0..0]->() RETURN p ORDER BY p ASC $$) AS (p agtype); -- -- Test VLE inside of a BEGIN/COMMIT block @@ -189,7 +189,7 @@ $$) AS (g1 agtype); /* should return 1 path with 1 edge */ SELECT * FROM cypher('mygraph', $$ MATCH p = ()-[:Edge*]->() - RETURN p + RETURN p ORDER BY p ASC $$) AS (g2 agtype); /* should delete the original path and replace it with a path with 2 edges */ @@ -202,13 +202,13 @@ $$) AS (g3 agtype); /* should find 2 paths with 1 edge */ SELECT * FROM cypher('mygraph', $$ MATCH p = ()-[:Edge]->() - RETURN p + RETURN p ORDER BY p ASC $$) AS (g4 agtype); /* should return 3 paths, 2 with 1 edge, 1 with 2 edges */ SELECT * FROM cypher('mygraph', $$ MATCH p = ()-[:Edge*]->() - RETURN p + RETURN p ORDER BY p ASC $$) AS (g5 agtype); SELECT drop_graph('mygraph', true); @@ -264,7 +264,7 @@ BEGIN RETURN QUERY SELECT * FROM cypher('mygraph', $CYPHER$ MATCH (h:head {name: $list_name})-[e:next*]->(v:node) - RETURN v + RETURN v ORDER BY id(v) $CYPHER$, ag_param) AS (node agtype); END $$; @@ -312,51 +312,104 @@ SELECT * FROM cypher('access',$$ CREATE ()-[:knows]->() $$) as (results agtype); SELECT * FROM cypher('access',$$ CREATE ()-[:knows]->()-[:knows]->()$$) as (results agtype); SELECT * FROM cypher('access',$$ CREATE ()-[:knows {id:0}]->()-[:knows {id: 1}]->() $$) as (results agtype); SELECT * FROM cypher('access',$$ CREATE ()-[:knows {id:2, arry:[0,1,2,3,{name: "joe"}]}]->()-[:knows {id: 3, arry:[1,3,{name:"john", stats: {age: 1000}}]}]->() $$) as (results agtype); -SELECT * FROM cypher('access', $$ MATCH (u)-[e*]->(v) RETURN e $$)as (edges agtype); -SELECT * FROM cypher('access', $$ MATCH (u)-[e*2..2]->(v) RETURN e $$)as (edges agtype); -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[0]) $$) as (prop_first_edge agtype); -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[0].id $$) as (results agtype); -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[0].arry[2] $$) as (results agtype); -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[1]) $$) as (prop_second_edge agtype); -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].id $$) as (results agtype); -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].arry[2] $$) as (results agtype); -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].arry[2].stats $$) as (results agtype); -SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[2]) $$) as (prop_third_edge agtype); - -SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN properties(e[0]), properties(e[1]) $$) as (prop_1st agtype, prop_2nd agtype); -SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].id, e[1].id $$) as (results_1st agtype, results_2nd agtype); -SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].arry, e[1].arry $$) as (results_1st agtype, results_2nd agtype); -SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].arry[2], e[1].arry[2] $$) as (results_1st agtype, results_2nd agtype); +SELECT * FROM cypher('access', $$ MATCH (u)-[e*]->(v) RETURN e ORDER BY e ASC $$)as (edges agtype); +SELECT * FROM cypher('access', $$ MATCH (u)-[e*2..2]->(v) RETURN e ORDER BY e ASC $$)as (edges agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[0]) ORDER BY id(e[0]) ASC $$) as (prop_first_edge agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[0].id ORDER BY id(e[0]) ASC $$) as (results agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[0].arry[2] ORDER BY id(e[0]) ASC $$) as (results agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[1]) ORDER BY id(e[1]) ASC $$) as (prop_second_edge agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].id ORDER BY id(e[1]) ASC $$) as (results agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].arry[2] ORDER BY id(e[1]) ASC $$) as (results agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN e[1].arry[2].stats ORDER BY id(e[1]) ASC $$) as (results agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*2..2]->() RETURN properties(e[2]) ORDER BY id(e[2]) ASC $$) as (prop_third_edge agtype); + +SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN properties(e[0]), properties(e[1]) ORDER BY id(e[0]) ASC, id(e[1]) ASC $$) as (prop_1st agtype, prop_2nd agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].id, e[1].id ORDER BY id(e[0]) ASC, id(e[1]) ASC $$) as (results_1st agtype, results_2nd agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].arry, e[1].arry ORDER BY id(e[0]) ASC, id(e[1]) ASC $$) as (results_1st agtype, results_2nd agtype); +SELECT * FROM cypher('access',$$ MATCH ()-[e*]->() RETURN e[0].arry[2], e[1].arry[2] ORDER BY id(e[0]) ASC, id(e[1]) ASC $$) as (results_1st agtype, results_2nd agtype); SELECT drop_graph('access', true); -- issue 1043 SELECT create_graph('issue_1043'); SELECT * FROM cypher('issue_1043', $$ CREATE (n)-[:KNOWS {n:'hello'}]->({n:'hello'}) $$) as (a agtype); -SELECT * FROM cypher('issue_1043', $$ MATCH (x)<-[y *]-(),({n:y[0].n}) RETURN x $$) as (a agtype); +SELECT * FROM cypher('issue_1043', $$ MATCH (x)<-[y *]-(),({n:y[0].n}) RETURN x ORDER BY id(x) ASC $$) as (a agtype); SELECT * FROM cypher('issue_1043', $$ CREATE (n)-[:KNOWS {n:'hello'}]->({n:'hello'}) $$) as (a agtype); -SELECT * FROM cypher('issue_1043', $$ MATCH (x)<-[y *]-(),({n:y[0].n}) RETURN x $$) as (a agtype); +SELECT * FROM cypher('issue_1043', $$ MATCH (x)<-[y *]-(),({n:y[0].n}) RETURN x ORDER BY id(x) ASC $$) as (a agtype); SELECT drop_graph('issue_1043', true); -- issue 1910 SELECT create_graph('issue_1910'); SELECT * FROM cypher('issue_1910', $$ MATCH (n) WHERE EXISTS((n)-[*1]-({name: 'Willem Defoe'})) - RETURN n.full_name $$) AS (full_name agtype); + RETURN n.full_name ORDER BY id(n) ASC $$) AS (full_name agtype); SELECT * FROM cypher('issue_1910', $$ CREATE ({name: 'Jane Doe'})-[:KNOWS]->({name: 'John Doe'}) $$) AS (result agtype); SELECT * FROM cypher('issue_1910', $$ CREATE ({name: 'Donald Defoe'})-[:KNOWS]->({name: 'Willem Defoe'}) $$) AS (result agtype); SELECT * FROM cypher('issue_1910', $$ MATCH (u {name: 'John Doe'}) MERGE (u)-[:KNOWS]->({name: 'Willem Defoe'}) $$) AS (result agtype); SELECT * FROM cypher('issue_1910', $$ MATCH (n) WHERE EXISTS((n)-[*]-({name: 'Willem Defoe'})) - RETURN n.name $$) AS (name agtype); + RETURN n.name ORDER BY id(n) ASC $$) AS (name agtype); SELECT * FROM cypher('issue_1910', $$ MATCH (n) WHERE EXISTS((n)-[*1]-({name: 'Willem Defoe'})) - RETURN n.name $$) AS (name agtype); + RETURN n.name ORDER BY id(n) ASC $$) AS (name agtype); SELECT * FROM cypher('issue_1910', $$ MATCH (n) WHERE EXISTS((n)-[*2..2]-({name: 'Willem Defoe'})) - RETURN n.name $$) AS (name agtype); + RETURN n.name ORDER BY id(n) ASC $$) AS (name agtype); SELECT drop_graph('issue_1910', true); +-- issue 2092: VLE with chained OPTIONAL MATCH and NULL handling +-- Previously, chained OPTIONAL MATCH with VLE would either segfault +-- (with WHERE IS NOT NULL) or error with "arguments cannot be NULL" +-- (without WHERE) instead of producing correct NULL-extended rows. +SELECT create_graph('issue_2092'); + +-- Set up a small graph where some OPTIONAL MATCH paths exist and some don't +SELECT * FROM cypher('issue_2092', $$ + CREATE (a:Person {name: 'Alice'}) + CREATE (b:Person {name: 'Bob'}) + CREATE (c:City {name: 'NYC'}) + CREATE (d:City {name: 'LA'}) + CREATE (e:Place {name: 'Central Park'}) + CREATE (a)-[:LIVES_IN]->(c) + CREATE (c)-[:HAS_PLACE]->(e) + CREATE (b)-[:LIVES_IN]->(d) +$$) AS (result agtype); + +-- Alice lives in NYC which has Central Park. +-- Bob lives in LA which has no places. +-- VLE + chained OPTIONAL MATCH + WHERE IS NOT NULL: should return rows +-- without crashing (was: segfault) +SELECT * FROM cypher('issue_2092', $$ + MATCH (p:Person)-[:LIVES_IN*]->(c:City) + OPTIONAL MATCH (c)-[:HAS_PLACE*]->(place) + OPTIONAL MATCH (place)-[:NEARBY*]->(other) + WHERE place IS NOT NULL + RETURN p.name, place.name, other + ORDER BY p.name +$$) AS (person agtype, place agtype, other agtype); + +-- VLE + chained OPTIONAL MATCH without WHERE: should return NULL-extended +-- rows without error (was: "match_vle_terminal_edge() arguments cannot be +-- NULL") +SELECT * FROM cypher('issue_2092', $$ + MATCH (p:Person)-[:LIVES_IN*]->(c:City) + OPTIONAL MATCH (c)-[:HAS_PLACE*]->(place) + OPTIONAL MATCH (place)-[:NEARBY*]->(other) + RETURN p.name, place.name, other + ORDER BY p.name +$$) AS (person agtype, place agtype, other agtype); + +-- Verify the happy path still works: Alice's full chain resolves +SELECT * FROM cypher('issue_2092', $$ + MATCH (p:Person)-[:LIVES_IN*]->(c:City) + OPTIONAL MATCH (c)-[:HAS_PLACE*]->(place) + WHERE place IS NOT NULL + RETURN p.name, c.name, place.name + ORDER BY p.name +$$) AS (person agtype, city agtype, place agtype); + +SELECT drop_graph('issue_2092', true); + -- -- Clean up -- @@ -364,6 +417,75 @@ SELECT drop_graph('issue_1910', true); DROP TABLE start_and_end_points; SELECT drop_graph('cypher_vle', true); +-- +-- Issue #2382: variable-length relationships with a zero lower bound must +-- still produce the zero-hop self-binding even when the edge label does not +-- exist in the graph (Neo4j/openCypher semantics). +-- +SELECT create_graph('issue_2382'); + +SELECT * FROM cypher('issue_2382', $$ + CREATE (:Person {name: 'Alice'})-[:KNOWS]->(:Person {name: 'Bob'}) +$$) AS (v agtype); + +-- Plain MATCH on a non-existent edge label with [*0..N] must return the +-- zero-hop self-binding row (Alice -> Alice). It must NOT match arbitrary +-- edges of other labels (e.g. KNOWS). +SELECT * FROM cypher('issue_2382', $$ + MATCH (p:Person {name: 'Alice'}) + MATCH (p)-[:NOEXIST*0..1]-(f:Person) + RETURN p.name AS person, f.name AS friend +$$) AS (person agtype, friend agtype); + +-- OPTIONAL MATCH form (the exact shape from the issue report). +SELECT * FROM cypher('issue_2382', $$ + MATCH (p:Person {name: 'Alice'}) + OPTIONAL MATCH (p)-[:NOEXIST*0..1]-(f:Person) + RETURN p.name AS person, f.name AS friend +$$) AS (person agtype, friend agtype); + +-- [*0..0] still emits exactly the zero-hop self-binding. +SELECT * FROM cypher('issue_2382', $$ + MATCH (p:Person {name: 'Alice'}) + MATCH (p)-[:NOEXIST*0..0]-(f:Person) + RETURN p.name AS person, f.name AS friend +$$) AS (person agtype, friend agtype); + +-- Fixed-length (lower bound > 0) on a missing label must still return zero +-- rows: there is no edge of that label, so the pattern is unsatisfiable. +SELECT * FROM cypher('issue_2382', $$ + MATCH (p:Person {name: 'Alice'}) + MATCH (p)-[:NOEXIST*1..1]-(f:Person) + RETURN p.name AS person, f.name AS friend +$$) AS (person agtype, friend agtype); + +-- OPTIONAL MATCH on the unsatisfiable fixed-length pattern still preserves +-- the outer row with NULL bindings. +SELECT * FROM cypher('issue_2382', $$ + MATCH (p:Person {name: 'Alice'}) + OPTIONAL MATCH (p)-[:NOEXIST*1..1]-(f:Person) + RETURN p.name AS person, f.name AS friend +$$) AS (person agtype, friend agtype); + +-- Mixed pattern: a zero-bound VLE on a missing label combined with another +-- fixed-length missing label segment must still yield zero rows. The other +-- segment is impossible regardless of the zero-hop case. +SELECT * FROM cypher('issue_2382', $$ + MATCH (a:Person {name: 'Alice'}) + MATCH (a)-[:NOEXIST*0..1]-(b:Person)-[:STILL_MISSING]-(c:Person) + RETURN a.name, b.name, c.name +$$) AS (a agtype, b agtype, c agtype); + +-- Sanity: zero-bound VLE on an EXISTING label still works the way it did +-- before (Alice via zero-hop, Bob via 1-hop KNOWS). +SELECT * FROM cypher('issue_2382', $$ + MATCH (p:Person {name: 'Alice'}) + MATCH (p)-[:KNOWS*0..1]-(f:Person) + RETURN p.name AS person, f.name AS friend + ORDER BY f.name +$$) AS (person agtype, friend agtype); + +SELECT drop_graph('issue_2382', true); -- -- End diff --git a/regress/sql/cypher_with.sql b/regress/sql/cypher_with.sql index 93413f2c9..145356446 100644 --- a/regress/sql/cypher_with.sql +++ b/regress/sql/cypher_with.sql @@ -47,6 +47,7 @@ SELECT * FROM cypher('cypher_with', $$ MATCH (n)-[e]->(m) WITH n,e,m RETURN n,e,m + ORDER BY id(n) ASC, id(e) ASC, id(m) ASC $$) AS (N1 agtype, edge agtype, N2 agtype); -- WITH/AS @@ -55,12 +56,14 @@ SELECT * FROM cypher('cypher_with', $$ MATCH (n)-[e]->(m) WITH n.name AS n1, e as edge, m.name as n2 RETURN n1,label(edge),n2 + ORDER BY id(edge) ASC $$) AS (start_node agtype,edge agtype, end_node agtype); SELECT * FROM cypher('cypher_with',$$ MATCH (person)-[r]->(otherPerson) WITH *, type(r) AS connectionType RETURN person.name, connectionType, otherPerson.name + ORDER BY id(person) ASC, id(otherPerson) ASC $$) AS (start_node agtype, connection agtype, end_node agtype); SELECT * FROM cypher('cypher_with', $$ @@ -75,6 +78,7 @@ MATCH (george {name: 'George'})<-[]-(otherPerson) WITH otherPerson, toUpper(otherPerson.name) AS upperCaseName WHERE upperCaseName STARTS WITH 'C' RETURN otherPerson.name + ORDER BY id(otherPerson) ASC $$) as (name agtype); SELECT * FROM cypher('cypher_with', $$ @@ -82,6 +86,7 @@ SELECT * FROM cypher('cypher_with', $$ WITH otherPerson, count(*) AS foaf WHERE foaf > 1 RETURN otherPerson.name + ORDER BY id(otherPerson) ASC $$) as (name agtype); SELECT * FROM cypher('cypher_with', $$ @@ -89,6 +94,7 @@ SELECT * FROM cypher('cypher_with', $$ WITH p, length(p) AS path_length WHERE path_length > 1 RETURN p + ORDER BY p ASC $$) AS (pattern agtype); -- MATCH/WHERE with WITH/WHERE @@ -99,6 +105,7 @@ SELECT * FROM cypher('cypher_with', $$ WITH * WHERE m.name = 'Andres' RETURN m.name,label(e),b.name + ORDER BY id(m) ASC, id(e) ASC, id(b) ASC $$) AS (N1 agtype, edge agtype, N2 agtype); -- WITH/ORDER BY @@ -133,9 +140,10 @@ SELECT * FROM cypher('cypher_with', $$ MATCH (n)-[e]->(m) WITH n, e, m WHERE label(e) = 'KNOWS' - WITH n.name as n1, label(e) as edge, m.name as n2 + WITH id(e) AS eid, n.name as n1, label(e) as edge, m.name as n2 WHERE n1 = 'Andres' RETURN n1,edge,n2 + ORDER BY eid ASC $$) AS (N1 agtype, edge agtype, N2 agtype); SELECT * FROM cypher('cypher_with', $$ @@ -145,6 +153,7 @@ SELECT * FROM cypher('cypher_with', $$ WITH x LIMIT 5 RETURN x + ORDER BY x ASC $$) as (name agtype); SELECT * FROM cypher('cypher_with', $$ @@ -154,6 +163,7 @@ SELECT * FROM cypher('cypher_with', $$ WITH m as start_node, b as end_node WHERE end_node.name = 'George' RETURN id(start_node),start_node.name,id(end_node),end_node.name + ORDER BY id(start_node) ASC, id(end_node) ASC $$) AS (id1 agtype, name1 agtype, id2 agtype, name2 agtype); -- Expression item must be aliased. @@ -227,6 +237,150 @@ $$) as (a agtype,b agtype); SELECT drop_graph('graph', true); + +-- +-- Test accessor optimizations in WITH clause +-- +SELECT * FROM create_graph('with_accessor_opt'); + +SELECT * FROM cypher('with_accessor_opt', $$ + CREATE (a:Person {name: 'Alice', age: 30})-[r:KNOWS {since: 2020}]->(b:Person {name: 'Bob', age: 25}) +$$) AS (result agtype); + +-- Single with +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH id(n) AS node_id + RETURN node_id +$$) AS (node_id agtype); + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH properties(n) AS props + RETURN props +$$) AS (props agtype); + +SELECT * FROM cypher('with_accessor_opt', $$ + MATCH (n:Person) + WITH label(n) AS lbl + RETURN lbl +$$) AS (lbl agtype); + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH n.name AS name, n.age AS age + RETURN name, age +$$) AS (name agtype, age agtype); + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + WITH id(r) AS rid + RETURN rid +$$) AS (rid agtype); + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH n, id(n) AS nid + WITH n.name AS name, nid + RETURN name, nid +$$) AS (name agtype, nid agtype); + +SELECT * FROM cypher('with_accessor_opt', $$ + MATCH (n:Person) + WITH n as m + RETURN m + ORDER BY id(m) ASC +$$) AS (n vertex); + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH n + RETURN id(n), n.name +$$) AS (id agtype, name agtype); + +SELECT * FROM cypher('with_accessor_opt', $$ + MATCH ()-[r:KNOWS]->() + WITH r + RETURN id(r), type(r) +$$) AS (id agtype, typ agtype); + +-- +-- Chained WITH tests +-- +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person)-[r:KNOWS]->(b:Person) + WITH a, b, id(a) AS aid + WITH a.name AS aname, b.name AS bname, aid + RETURN aname, bname, aid +$$) AS (aname agtype, bname agtype, aid agtype); + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH n, n.age AS age + WHERE age > 20 + RETURN n.name +$$) AS (name agtype); + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH n.name as name, id(n) AS nid + ORDER BY nid + RETURN name +$$) AS (name agtype); + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH id(n) AS id, count(*) AS cnt + RETURN id, cnt +$$) AS (id agtype, cnt agtype); + +SELECT * FROM cypher('with_accessor_opt', $$ + MATCH (n:Person) + WITH DISTINCT label(n) AS lbl + RETURN lbl +$$) AS (lbl agtype); + +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH n + WITH n, id(n) AS nid + WITH n.name AS name, nid + RETURN name, nid +$$) AS (name agtype, nid agtype); + +-- MATCH -> WITH accessors -> MATCH -> RETURN +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person) + WITH a, id(a) AS aid + MATCH (a)-[r:KNOWS]->(b) + RETURN aid, b.name +$$) AS (aid agtype, bname agtype); + +-- WITH + UNWIND with accessors +SELECT * FROM cypher('with_accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WITH collect(id(n)) AS ids + UNWIND ids AS nid + RETURN nid +$$) AS (nid agtype); + +-- Clean up +SELECT drop_graph('with_accessor_opt', true); + + -- -- End of test -- diff --git a/regress/sql/direct_field_access.sql b/regress/sql/direct_field_access.sql index c8060be4a..3ca2259f4 100644 --- a/regress/sql/direct_field_access.sql +++ b/regress/sql/direct_field_access.sql @@ -61,7 +61,7 @@ SELECT * FROM cypher('direct_access', $$ $$) AS (lt agtype, gt agtype, eq agtype, ne agtype); SELECT * FROM cypher('direct_access', $$ - RETURN 'hello world' < 'hello worlds', 'test' > 'TEST' + RETURN 'hello world' < 'hello worlds', 'abd' > 'abc' $$) AS (lt agtype, gt agtype); -- Boolean comparisons diff --git a/regress/sql/drop.sql b/regress/sql/drop.sql index 564492bbc..a71d5a74c 100644 --- a/regress/sql/drop.sql +++ b/regress/sql/drop.sql @@ -28,6 +28,15 @@ SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname = 'drop'; SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'ag_catalog'; +-- When ag_catalog is missing extension hooks shouldn't fail with the +-- ERROR schema "ag_catalog" does not exist. +-- It might happen when 'age' is loaded but extension isn't created yet. +SET client_min_messages TO WARNING; +DROP SCHEMA IF EXISTS ag_catalog CASCADE; +RESET client_min_messages; +CREATE SCHEMA _regress_drop; +DROP SCHEMA _regress_drop; -- should'n produce the ERROR + -- Recreate the extension and validate we can recreate a graph CREATE EXTENSION age; diff --git a/regress/sql/expr.sql b/regress/sql/expr.sql index 445e2d237..d4d900a1c 100644 --- a/regress/sql/expr.sql +++ b/regress/sql/expr.sql @@ -1520,6 +1520,16 @@ $$) AS (toFloatList agtype); SELECT * FROM cypher('expr', $$ RETURN toFloatList([1.20002]) $$) AS (toFloatList agtype); +-- large magnitudes must not overflow the conversion (regression: unbounded +-- sprintf into a fixed stack buffer overflowed for values like 1.0e308) +SELECT * FROM cypher('expr', $$ + RETURN toFloatList([1.0e308, -1.0e308]) +$$) AS (toFloatList agtype); +-- precision must be preserved (regression: "%f" format truncated to 6 digits, +-- so 0.123456789 came back as 0.123457) +SELECT * FROM cypher('expr', $$ + RETURN toFloatList([0.123456789]) +$$) AS (toFloatList agtype); -- should return null SELECT * FROM cypher('expr', $$ RETURN toFloatList(['true']) @@ -3269,7 +3279,7 @@ SELECT * from cypher('list', $$RETURN range(0, -10.0, -3.0)$$) as (range agtype) -- should return the last elements of the list SELECT * FROM cypher('list', $$ RETURN tail([1,2,3,4,5]) $$) AS (tail agtype); SELECT * FROM cypher('list', $$ RETURN tail(["a","b","c","d","e"]) $$) AS (tail agtype); --- should return null +-- should return an empty list SELECT * FROM cypher('list', $$ RETURN tail([1]) $$) AS (tail agtype); SELECT * FROM cypher('list', $$ RETURN tail([]) $$) AS (tail agtype); -- should throw errors @@ -3712,9 +3722,409 @@ $$) AS (out agtype); SELECT * FROM create_graph('issue_2289'); SELECT * FROM cypher('issue_2289', $$ RETURN (1 IN []) AS v $$) AS (v agtype); +-- +-- Accessor functions and properties extraction +-- without _agtype_build.. functions +-- +SELECT * FROM create_graph('accessor_opt'); + +SELECT * FROM cypher('accessor_opt', $$ + CREATE (a:Person {name: 'Alice', age: 30})-[r:KNOWS {since: 2020}]->(b:Person {name: 'Bob', age: 25}) +$$) AS (a agtype); + +-- +-- Vertex accessor tests +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN id(n) +$$) AS (plan agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN properties(n) +$$) AS (plan agtype); + +SELECT * FROM cypher('accessor_opt', $$ + MATCH (n:Person) + RETURN label(n) +$$) AS (plan agtype); + +-- should use n.properties in _agtype_access_operator +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN n.name, n.age +$$) AS (a agtype, b agtype); + +-- +-- Edge accessor tests +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN id(r) +$$) AS (plan agtype); + +SELECT * FROM cypher('accessor_opt', $$ + MATCH ()-[r:KNOWS]->() + RETURN type(r) +$$) AS (plan agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN start_id(r) +$$) AS (plan agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN end_id(r) +$$) AS (plan agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN properties(r) +$$) AS (plan agtype); + +-- should use r.properties in _agtype_access_operator +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN r.since +$$) AS (plan agtype); + +-- +-- Multiple accessors in same query +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN id(n), properties(n), n.name +$$) AS (a agtype, c agtype, d agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN id(r), start_id(r), end_id(r), properties(r) +$$) AS (a agtype, c agtype, d agtype, e agtype); + +-- +-- Accessors in WHERE clause +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WHERE id(n) > 0 + RETURN n.name +$$) AS (plan agtype); + +-- Compare two node ids +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person)-[r:KNOWS]->(b:Person) + WHERE id(a) < id(b) + RETURN a.name, b.name +$$) AS (aname agtype, bname agtype); + +-- Compare edge start_id and end_id +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + WHERE start_id(r) <> end_id(r) + RETURN id(r) +$$) AS (rid agtype); + +-- Property comparison +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WHERE n.age >= 25 + RETURN n.name +$$) AS (name agtype); + +-- Multiple property comparisons +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WHERE n.age > 20 AND n.name <> 'Unknown' + RETURN n.name +$$) AS (name agtype); + +-- Compare properties of two nodes +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person)-[r:KNOWS]->(b:Person) + WHERE a.age > b.age + RETURN a.name, b.name +$$) AS (aname agtype, bname agtype); + +-- Compare id with property +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WHERE id(n) > n.age + RETURN n.name +$$) AS (name agtype); + +-- Edge property comparison +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + WHERE r.since > 2015 + RETURN r.since +$$) AS (since agtype); + +-- IS NOT NULL on accessor +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + WHERE properties(n) IS NOT NULL + RETURN n.name +$$) AS (name agtype); + +-- label() comparison +SELECT * FROM cypher('accessor_opt', $$ + MATCH (n) + WHERE label(n) = 'Person' + RETURN n.name +$$) AS (name agtype); + +-- type() comparison on edge +SELECT * FROM cypher('accessor_opt', $$ + MATCH ()-[r]->() + WHERE type(r) = 'KNOWS' + RETURN id(r) +$$) AS (rid agtype); + +-- +-- Accessors in ORDER BY +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN n.name + ORDER BY n.name +$$) AS (plan agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN n.name + ORDER BY id(n) +$$) AS (plan agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN n.name, n.age + ORDER BY n.age, n.name +$$) AS (name agtype, age agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH ()-[r:KNOWS]->() + RETURN r.since + ORDER BY start_id(r) +$$) AS (since agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person)-[r:KNOWS]->(b:Person) + RETURN a.name, b.name + ORDER BY id(a), id(r), id(b) +$$) AS (aname agtype, bname agtype); + +-- +-- Accessors in expressions +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN [id(n), n.name] +$$) AS (plan agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN {id: id(n), name: n.name} +$$) AS (plan agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (n:Person) + RETURN n {.name, .age} +$$) AS (plan agtype); + +-- +-- Accessor function in multiple match +-- +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person) + WHERE id(a) > 0 + MATCH (a)-[r]->(b) + WHERE id(b) > 0 + RETURN properties(a), start_id(r), end_id(r) +$$) AS (props_a agtype, sid agtype, eid agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person) + MATCH (b:Person) + WHERE b.name <> a.name + RETURN a.name, b.name +$$) AS (a_name agtype, b_name agtype); + +SELECT * FROM cypher('accessor_opt', $$ + EXPLAIN (VERBOSE, COSTS OFF) + MATCH (a:Person) + MATCH (a)-[r1]->(b) + MATCH (b)-[r2]->(c) + RETURN id(a), id(b), id(c), a.name +$$) AS (a_id agtype, b_id agtype, c_id agtype, a_name agtype); + +-- +-- Composite types, vertex and edge +-- +SELECT * FROM create_graph('composite_types'); + +SELECT * FROM cypher('composite_types', $$ + CREATE (a:Person {name: 'Alice', age: 30})-[r:KNOWS {since: 2020}]->(b:Person {name: 'Bob', age: 25}) +$$) AS (result agtype); + +-- Return vertex as vertex type +SELECT * FROM cypher('composite_types', $$ + MATCH (n:Person) + RETURN n +$$) AS (n vertex); + +SELECT * FROM cypher('composite_types', $$ + MATCH (n:Person) + RETURN n + ORDER BY n.name +$$) AS (n vertex); + +-- Return edge as edge type +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r:KNOWS]->() + RETURN r +$$) AS (r edge); + +-- Return multiple entities +SELECT * FROM cypher('composite_types', $$ + MATCH (a:Person)-[r:KNOWS]->(b:Person) + RETURN a, b +$$) AS (a vertex, b vertex); + +SELECT * FROM cypher('composite_types', $$ + MATCH (a:Person)-[r:KNOWS]->(b:Person) + RETURN a, r, b +$$) AS (a vertex, r edge, b vertex); + +-- Mixed return: entity and agtype property +SELECT * FROM cypher('composite_types', $$ + MATCH (n:Person) + RETURN n, n.name +$$) AS (n vertex, name agtype); + +SELECT * FROM cypher('composite_types', $$ + MATCH (n:Person) + RETURN n, n.name + ORDER BY n.name +$$) AS (n vertex, name agtype); + +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r:KNOWS]->() + RETURN r, r.since +$$) AS (r edge, since agtype); + +-- IN operator with vertex and edge types +EXPLAIN (COSTS OFF) +SELECT * FROM cypher('composite_types', $$ + MATCH (a:Person), (b:Person) + WHERE a <> b AND a IN [a, b] + RETURN a.name +$$) AS (name agtype); + +SELECT * FROM cypher('composite_types', $$ + MATCH (a:Person), (b:Person) + WHERE a <> b AND a IN [a, b] + RETURN a.name +$$) AS (name agtype); + +EXPLAIN (COSTS OFF) +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r1:KNOWS]->(), ()-[r2:KNOWS]->() + WHERE r1 IN [r1, r2] + RETURN id(r1) +$$) AS (id agtype); + +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r1:KNOWS]->(), ()-[r2:KNOWS]->() + WHERE r1 IN [r1, r2] + RETURN id(r1) +$$) AS (id agtype); + +-- Equality operators with vertex and edge types +EXPLAIN (COSTS OFF) +SELECT * FROM cypher('composite_types', $$ + MATCH (a:Person), (b:Person) + WHERE a = b + RETURN a.name +$$) AS (name agtype); + +SELECT * FROM cypher('composite_types', $$ + MATCH (a:Person), (b:Person) + WHERE a = b + RETURN a.name +$$) AS (name agtype); + +EXPLAIN (COSTS OFF) +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r1:KNOWS]->(), ()-[r2:KNOWS]->() + WHERE r1 = r2 + RETURN id(r1) +$$) AS (id agtype); + +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r1:KNOWS]->(), ()-[r2:KNOWS]->() + WHERE r1 = r2 + RETURN id(r1) +$$) AS (id agtype); + +-- Cast vertex and edge to json +SELECT * FROM cypher('composite_types', $$ + MATCH (n:Person) + RETURN n +$$) AS (n json); + +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r:KNOWS]->() + RETURN r +$$) AS (r json); + +-- Cast vertex and edge to jsonb +SELECT * FROM cypher('composite_types', $$ + MATCH (n:Person) + RETURN n +$$) AS (n jsonb); + +SELECT * FROM cypher('composite_types', $$ + MATCH ()-[r:KNOWS]->() + RETURN r +$$) AS (r jsonb); + -- -- Cleanup -- + +SELECT * FROM drop_graph('composite_types', true); +SELECT * FROM drop_graph('accessor_opt', true); SELECT * FROM drop_graph('issue_2289', true); SELECT * FROM drop_graph('issue_2263', true); SELECT * FROM drop_graph('issue_1988', true); @@ -3736,6 +4146,60 @@ SELECT * FROM drop_graph('regex', true); SELECT * FROM drop_graph('keys', true); SELECT * FROM drop_graph('list', true); +-- +-- Issue 2391 - map literals must preserve keys whose values are null +-- +SELECT create_graph('issue_2391'); +-- single-key null +SELECT * FROM cypher('issue_2391', $$ + RETURN {a: null} AS m +$$) AS (m agtype); +-- multiple null values +SELECT * FROM cypher('issue_2391', $$ + RETURN {companyName: null, sinceYear: null} AS m +$$) AS (m agtype); +-- keys() must see the null-valued key +SELECT * FROM cypher('issue_2391', $$ + RETURN keys({a: null}) AS ks +$$) AS (ks agtype); +-- coalesce passes a non-null map (map itself is not null) through +SELECT * FROM cypher('issue_2391', $$ + RETURN coalesce({a: null}, null) AS m +$$) AS (m agtype); +-- nested map values inside an expression also preserve nulls +SELECT * FROM cypher('issue_2391', $$ + RETURN {outer: {inner: null, kept: 1}} AS m +$$) AS (m agtype); +-- mixed non-null and null values are all preserved +SELECT * FROM cypher('issue_2391', $$ + RETURN {a: 1, b: null, c: 'x'} AS m +$$) AS (m agtype); +-- control: empty map is still empty +SELECT * FROM cypher('issue_2391', $$ + RETURN {} AS m +$$) AS (m agtype); +-- control: CREATE must still strip top-level null properties so +-- setting a property to null removes it from storage +SELECT * FROM cypher('issue_2391', $$ + CREATE (n:Item {keep: 1, drop: null}) RETURN n +$$) AS (n agtype); +SELECT * FROM cypher('issue_2391', $$ + MATCH (n:Item) RETURN n +$$) AS (n agtype); +-- nested map values under a write (CREATE / SET =) are preserved: the +-- top-level property map is null-stripped, but a nested map literal is +-- its own node and keeps its null-valued keys +SELECT * FROM cypher('issue_2391', $$ + CREATE (n:Nested {a: {b: null}}) RETURN n +$$) AS (n agtype); +SELECT * FROM cypher('issue_2391', $$ + MATCH (n:Nested) SET n = {a: {b: null}} RETURN n +$$) AS (n agtype); +SELECT * FROM cypher('issue_2391', $$ + MATCH (n:Nested) RETURN n +$$) AS (n agtype); +SELECT * FROM drop_graph('issue_2391', true); + -- -- End of tests -- diff --git a/regress/sql/extension_security.sql b/regress/sql/extension_security.sql new file mode 100644 index 000000000..433283989 --- /dev/null +++ b/regress/sql/extension_security.sql @@ -0,0 +1,82 @@ +/* + * 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. + */ + +LOAD 'age'; +SET search_path TO ag_catalog; + +-- +-- pg_upgrade helper functions resolve built-ins from pg_catalog first. +-- +-- Each helper must place pg_catalog ahead of ag_catalog in its search_path, so +-- that built-in functions and operators always resolve to pg_catalog and are +-- not overridden by same-named objects defined in ag_catalog. +-- +SELECT p.proname, + array_to_string(p.proconfig, ', ') AS proconfig +FROM pg_proc p +JOIN pg_namespace n ON n.oid = p.pronamespace +WHERE n.nspname = 'ag_catalog' + AND p.proname IN ('age_prepare_pg_upgrade', 'age_finish_pg_upgrade', + 'age_revert_pg_upgrade_changes', 'age_pg_upgrade_status') +ORDER BY p.proname; + +-- +-- The helper bodies must not contain unqualified format()/hashtext() calls; +-- those built-ins are explicitly schema-qualified to pg_catalog. +-- +SELECT p.proname, + (p.prosrc ~ '[^.]\mformat\s*\(') AS has_unqualified_format, + (p.prosrc ~ '[^.]\mhashtext\s*\(') AS has_unqualified_hashtext +FROM pg_proc p +JOIN pg_namespace n ON n.oid = p.pronamespace +WHERE n.nspname = 'ag_catalog' + AND p.proname IN ('age_finish_pg_upgrade', 'age_revert_pg_upgrade_changes') +ORDER BY p.proname; + +-- +-- Install-time ownership check: CREATE EXTENSION age installs into ag_catalog +-- only when that schema does not already exist under a different owner. The +-- check compares schema ownership against the installing role. Verify the +-- underlying detection both ways with a probe schema, without disturbing the +-- already-installed extension. +-- +CREATE ROLE age_probe_role NOLOGIN; +CREATE SCHEMA age_probe AUTHORIZATION age_probe_role; + +-- A schema owned by a different role is detected as foreign-owned. +SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_namespace n + WHERE n.nspname = 'age_probe' + AND n.nspowner <> (SELECT r.oid FROM pg_catalog.pg_roles r + WHERE r.rolname = current_user) +) AS foreign_owner_detected; + +-- ag_catalog, owned by the current (installing) role here, is not flagged +-- (the check does not false-positive on a normal install). +SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_namespace n + WHERE n.nspname = 'ag_catalog' + AND n.nspowner <> (SELECT r.oid FROM pg_catalog.pg_roles r + WHERE r.rolname = current_user) +) AS installer_owned_flagged; + +DROP SCHEMA age_probe; +DROP ROLE age_probe_role; diff --git a/regress/sql/generated_columns.sql b/regress/sql/generated_columns.sql new file mode 100644 index 000000000..5229c28f1 --- /dev/null +++ b/regress/sql/generated_columns.sql @@ -0,0 +1,129 @@ +/* + * 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. + */ + +/* + * Regression test for issue #2450. + * + * A GENERATED ALWAYS ... STORED column (or any user-added column) on a label + * table adds an attribute AGE's CREATE/MERGE/SET paths do not populate. The + * slot's tuple descriptor is the full relation descriptor, so heap_form_tuple() + * used to read the uninitialized slot entry and segfault. AGE now null-inits the + * entity slot and computes stored generated columns before materializing the + * tuple. Generated columns must be computed on insert and recomputed on update; + * plain user columns must default to NULL rather than crash. + */ + +LOAD 'age'; +SET search_path TO ag_catalog; + +SELECT create_graph('generated_columns'); + +-- +-- GENERATED ALWAYS ... STORED column on a vertex label +-- +SELECT create_vlabel('generated_columns', 'Product'); +ALTER TABLE generated_columns."Product" + ADD COLUMN category varchar(25) + GENERATED ALWAYS AS (agtype_access_operator(properties, '"category"')) STORED; + +-- CREATE: the generated column is computed from the new properties +SELECT * FROM cypher('generated_columns', $$ + CREATE (p:Product {category: 'disk', type: 'M1234'}) RETURN p +$$) AS (p agtype); + +-- CREATE where the generation expression yields NULL (no such key) +SELECT * FROM cypher('generated_columns', $$ + CREATE (p:Product {type: 'no-cat'}) RETURN p +$$) AS (p agtype); + +-- MERGE also computes the generated column +SELECT * FROM cypher('generated_columns', $$ + MERGE (p:Product {category: 'merged'}) RETURN p +$$) AS (p agtype); + +-- MERGE again on the same pattern must MATCH (not re-insert); this exercises the +-- matched path where the slot is intentionally left uncleared (issue #2450) +SELECT * FROM cypher('generated_columns', $$ + MERGE (p:Product {category: 'merged'}) RETURN p +$$) AS (p agtype); + +-- SET recomputes the generated column from the updated properties +SELECT * FROM cypher('generated_columns', $$ + MATCH (p:Product {category: 'disk'}) SET p.category = 'ssd', p.note = 'x' RETURN p +$$) AS (p agtype); + +-- The stored generated column always mirrors properties.category +SELECT category, properties FROM generated_columns."Product" ORDER BY category NULLS LAST; + +-- +-- GENERATED ALWAYS ... STORED column on an edge label +-- +SELECT create_elabel('generated_columns', 'REL'); +ALTER TABLE generated_columns."REL" + ADD COLUMN kind varchar(25) + GENERATED ALWAYS AS (agtype_access_operator(properties, '"kind"')) STORED; + +SELECT * FROM cypher('generated_columns', $$ + CREATE (:Product {category: 'a'})-[r:REL {kind: 'link'}]->(:Product {category: 'b'}) + RETURN r +$$) AS (r agtype); + +SELECT kind FROM generated_columns."REL"; + +-- +-- Plain (non-generated) user column: must not crash; defaults to NULL +-- +SELECT create_vlabel('generated_columns', 'Plain'); +ALTER TABLE generated_columns."Plain" ADD COLUMN tag text; + +SELECT * FROM cypher('generated_columns', $$ + CREATE (p:Plain {n: 1}) RETURN p +$$) AS (p agtype); + +SELECT * FROM cypher('generated_columns', $$ + MATCH (p:Plain) SET p.n = 2 RETURN p +$$) AS (p agtype); + +SELECT properties, tag FROM generated_columns."Plain"; + +-- +-- Generated column referencing the tableoid system column: the entity slot must +-- carry tts_tableOid before the generated columns are computed, otherwise the +-- expression sees InvalidOid instead of the label table's OID (issue #2450). +-- +SELECT create_vlabel('generated_columns', 'T'); +ALTER TABLE generated_columns."T" + ADD COLUMN tbl oid GENERATED ALWAYS AS (tableoid) STORED; + +SELECT * FROM cypher('generated_columns', $$ + CREATE (n:T {k: 1}) RETURN n +$$) AS (n agtype); + +SELECT * FROM cypher('generated_columns', $$ + MATCH (n:T) SET n.k = 2 RETURN n +$$) AS (n agtype); + +-- tbl must equal the real label-table OID on both the CREATE and SET paths +SELECT tbl = 'generated_columns."T"'::regclass::oid AS tableoid_ok +FROM generated_columns."T"; + +-- +-- Cleanup +-- +SELECT drop_graph('generated_columns', true); diff --git a/regress/sql/list_comprehension.sql b/regress/sql/list_comprehension.sql index 572b2e6bb..e493da6df 100644 --- a/regress/sql/list_comprehension.sql +++ b/regress/sql/list_comprehension.sql @@ -123,6 +123,12 @@ SELECT * FROM cypher('list_comprehension', $$ WITH 1 AS m RETURN [m IN [1, 2, 3] SELECT * FROM cypher('list_comprehension', $$ WITH [m IN [1,2,3]] AS m RETURN [m IN [1, 2, 3]], m $$) AS (result agtype, result2 agtype); SELECT * FROM cypher('list_comprehension', $$ CREATE n=()-[:edge]->() RETURN [n IN nodes(n)] $$) AS (u agtype); +-- Property access on the list-comprehension loop variable +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [{name:'a'}, {name:'b'}] | x.name] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [{n:1}, {n:2}, {n:3}] WHERE x.n > 1 | x.n] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ MATCH p=()-[:edge]->() RETURN [n IN nodes(p) | n.name] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ MATCH (u:csm_match) WITH collect(u) AS ns RETURN [x IN ns | x.list] $$) AS (result agtype); + -- Multiple list comprehensions in RETURN and WITH clause SELECT * FROM cypher('list_comprehension', $$ RETURN [u IN [1,2,3]], [u IN [1,2,3]] $$) AS (result agtype, result2 agtype); SELECT * FROM cypher('list_comprehension', $$ RETURN [u IN [1,2,3] WHERE u>1], [u IN [1,2,3] WHERE u>2] $$) AS (result agtype, result2 agtype); @@ -174,5 +180,27 @@ SELECT * FROM cypher('list_comprehension', $$ MATCH (u {list: [0, 2, 4, 6, 8, 10 SELECT * FROM cypher('list_comprehension', $$ MATCH (u {list: [0, 2, 4, 6, 8, 10, 12]}) WHERE u.list = [u IN [1, u]] RETURN u $$) AS (u agtype); SELECT * FROM cypher('list_comprehension', $$ MATCH (u {list: [0, 2, 4, 6, 8, 10, 12]}) WHERE u.list IN [u IN [1, u.list]] RETURN u $$) AS (u agtype); +-- Issue 2394 - projection over a null element should propagate null, +-- not concatenate the null and the projection result into a sublist and +-- not error out for operators other than '+'. +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null] | x + 1] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x + 1] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | 1 + x] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x - 1] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x * 2] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x / 1] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x % 2] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | x ^ 2] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, null, 2] | -x] $$) AS (result agtype); + +-- Issue 2393 - WHERE filter over null elements should use openCypher's +-- three-valued logic: IS NULL must keep nulls, IS NOT NULL must drop them. +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1] WHERE x IS NULL] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1, null] WHERE x IS NULL] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [null, 1] WHERE x IS NOT NULL] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ RETURN [x IN [1, 2, 3] WHERE x IS NULL] $$) AS (result agtype); +SELECT * FROM cypher('list_comprehension', $$ UNWIND [null, 1] AS x RETURN x, x IS NULL, x IS NOT NULL $$) AS (x agtype, a agtype, b agtype); + -- Clean up SELECT * FROM drop_graph('list_comprehension', true); \ No newline at end of file diff --git a/regress/sql/pattern_expression.sql b/regress/sql/pattern_expression.sql new file mode 100644 index 000000000..9ded819ef --- /dev/null +++ b/regress/sql/pattern_expression.sql @@ -0,0 +1,342 @@ +/* + * 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. + */ + +LOAD 'age'; +SET search_path TO ag_catalog; + +SELECT create_graph('pattern_expr'); + +-- +-- Setup test data +-- +SELECT * FROM cypher('pattern_expr', $$ + CREATE (alice:Person {name: 'Alice'})-[:KNOWS]->(bob:Person {name: 'Bob'}), + (alice)-[:WORKS_WITH]->(charlie:Person {name: 'Charlie'}), + (dave:Person {name: 'Dave'}) +$$) AS (result agtype); + +-- +-- Basic pattern expression in WHERE +-- +-- Bare pattern: (a)-[:REL]->(b) +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (b:Person) + WHERE (a)-[:KNOWS]->(b) + RETURN a.name, b.name + ORDER BY a.name, b.name +$$) AS (a agtype, b agtype); + +-- +-- NOT pattern expression +-- +-- Find people who don't KNOW anyone +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WHERE NOT (a)-[:KNOWS]->(:Person) + RETURN a.name + ORDER BY a.name +$$) AS (result agtype); + +-- +-- Pattern with labeled first node +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (b:Person) + WHERE (a:Person)-[:KNOWS]->(b) + RETURN a.name, b.name + ORDER BY a.name +$$) AS (a agtype, b agtype); + +-- +-- Pattern combined with AND +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (b:Person) + WHERE (a)-[:KNOWS]->(b) AND a.name = 'Alice' + RETURN a.name, b.name +$$) AS (a agtype, b agtype); + +-- +-- Pattern combined with OR +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (b:Person) + WHERE (a)-[:KNOWS]->(b) OR (a)-[:WORKS_WITH]->(b) + RETURN a.name, b.name + ORDER BY a.name, b.name +$$) AS (a agtype, b agtype); + +-- +-- Left-directed pattern +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (b:Person) + WHERE (a)<-[:KNOWS]-(b) + RETURN a.name, b.name + ORDER BY a.name +$$) AS (a agtype, b agtype); + +-- +-- Pattern with anonymous nodes +-- +-- Find anyone who has any outgoing KNOWS relationship +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WHERE (a)-[:KNOWS]->() + RETURN a.name + ORDER BY a.name +$$) AS (result agtype); + +-- +-- Multiple relationship pattern +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (c:Person) + WHERE (a)-[:KNOWS]->()-[:WORKS_WITH]->(c) + RETURN a.name, c.name + ORDER BY a.name +$$) AS (a agtype, c agtype); + +-- +-- Existing EXISTS() syntax still works (backward compatibility) +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person), (b:Person) + WHERE EXISTS((a)-[:KNOWS]->(b)) + RETURN a.name, b.name + ORDER BY a.name +$$) AS (a agtype, b agtype); + +-- +-- Pattern expression produces same results as EXISTS() +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WHERE (a)-[:KNOWS]->(:Person) + RETURN a.name + ORDER BY a.name +$$) AS (result agtype); + +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WHERE EXISTS((a)-[:KNOWS]->(:Person)) + RETURN a.name + ORDER BY a.name +$$) AS (result agtype); + +-- +-- Regular (non-pattern) expressions still work (no regression) +-- +SELECT * FROM cypher('pattern_expr', $$ + RETURN (1 + 2) +$$) AS (result agtype); + +SELECT * FROM cypher('pattern_expr', $$ + MATCH (n:Person) + WHERE n.name = 'Alice' + RETURN (n.name) +$$) AS (result agtype); + +-- +-- Pattern expressions in RETURN (boolean projection) +-- +-- Each person gets a column showing whether they know someone +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, (a)-[:KNOWS]->(:Person) AS knows_someone + ORDER BY a.name +$$) AS (name agtype, knows_someone agtype); + +-- Mix pattern expression with other projections +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, (a)-[:KNOWS]->(:Person), (a)-[:WORKS_WITH]->(:Person) + ORDER BY a.name +$$) AS (name agtype, knows agtype, works_with agtype); + +-- +-- Pattern expressions in CASE WHEN +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, + CASE WHEN (a)-[:KNOWS]->(:Person) THEN 'social' + ELSE 'loner' + END + ORDER BY a.name +$$) AS (name agtype, kind agtype); + +-- +-- Pattern expressions combined with boolean operators in RETURN +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, + (a)-[:KNOWS]->(:Person) AND (a)-[:WORKS_WITH]->(:Person) AS has_both, + (a)-[:KNOWS]->(:Person) OR (a)-[:WORKS_WITH]->(:Person) AS has_either + ORDER BY a.name +$$) AS (name agtype, has_both agtype, has_either agtype); + +-- +-- Pattern expression in SET (store boolean as property) +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + SET a.is_social = (a)-[:KNOWS]->(:Person) + RETURN a.name, a.is_social + ORDER BY a.name +$$) AS (name agtype, is_social agtype); + +-- +-- Pattern expression in WITH (carry boolean through pipeline) +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WITH a.name AS name, (a)-[:KNOWS]->(:Person) AS knows + WHERE knows + RETURN name + ORDER BY name +$$) AS (result agtype); + +-- +-- Follow-up coverage (review #2360): pattern expressions in additional +-- expression contexts opened up by allowing anonymous_path as an expr_atom. +-- + +-- +-- Single-node pattern on an already-bound variable: (a:Label) +-- +-- NOTE: as of #2443 a single-node labeled pattern is a correlated label +-- predicate -- in WHERE / EXISTS it tests whether the bound vertex actually +-- has the label (see the WHERE (a:Person) / EXISTS((a:Company)) cases in the +-- #2443 section below). Here the variable is already bound to the SAME label, +-- so the predicate is trivially true (the label matches). A *different* label +-- on an already-bound variable is still rejected by AGE's pre-existing +-- "multiple labels for variable" restriction rather than evaluating to false; +-- that is an orthogonal limitation, captured here so any future change to +-- single-node-pattern semantics is caught by this test. +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, (a:Person) + ORDER BY a.name +$$) AS (name agtype, is_person agtype); + +-- A non-matching label errors (pre-existing limitation, not a regression) +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, (a:Animal) + ORDER BY a.name +$$) AS (name agtype, is_animal agtype); + +-- +-- Pattern expressions inside a list literal +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, [(a)-[:KNOWS]->(:Person), (a)-[:WORKS_WITH]->(:Person)] + ORDER BY a.name +$$) AS (name agtype, flags agtype); + +-- +-- Pattern expressions inside a map literal +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN a.name, {knows: (a)-[:KNOWS]->(:Person), works: (a)-[:WORKS_WITH]->(:Person)} + ORDER BY a.name +$$) AS (name agtype, m agtype); + +-- +-- Pattern expressions as function arguments +-- +-- collect() shows the per-row boolean values are correct (ORDER BY before +-- the aggregate so the collected order is deterministic across scan plans). +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WITH a ORDER BY a.name + RETURN collect((a)-[:KNOWS]->(:Person)) +$$) AS (vals agtype); + +-- count() counts non-null values; a boolean (including false) is non-null, +-- so this counts every row rather than only the matching ones. This is the +-- expected SQL aggregate semantics, documented here so the value is not +-- mistaken for a bug. +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + RETURN count((a)-[:KNOWS]->(:Person)) +$$) AS (c agtype); + +-- +-- Pattern expression in OPTIONAL MATCH ... WHERE (null-preserving) +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + OPTIONAL MATCH (b:Person) WHERE (a)-[:KNOWS]->(b) + RETURN a.name, b.name + ORDER BY a.name, b.name +$$) AS (a agtype, b agtype); + +-- +-- EXISTS() and a bare pattern combined in a single predicate +-- +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a:Person) + WHERE EXISTS((a)-[:KNOWS]->(:Person)) AND (a)-[:WORKS_WITH]->(:Person) + RETURN a.name + ORDER BY a.name +$$) AS (name agtype); + +-- +-- Single-node labeled pattern as a boolean (#2443) +-- +-- A bound vertex carrying a label, e.g. (a:Person), must test that vertex's +-- label rather than be trivially true. Add a non-Person vertex so the filter +-- is observable (every other vertex in this graph is a :Person). +SELECT * FROM cypher('pattern_expr', $$ + CREATE (:Company {name: 'Acme'}) +$$) AS (result agtype); + +-- bare single-node label predicate in WHERE: only the :Person vertices +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a) + WHERE (a:Person) + RETURN a.name + ORDER BY a.name +$$) AS (name agtype); + +-- negated: only the non-Person vertex +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a) + WHERE NOT (a:Person) + RETURN a.name + ORDER BY a.name +$$) AS (name agtype); + +-- EXISTS() form of a single-node label predicate +SELECT * FROM cypher('pattern_expr', $$ + MATCH (a) + WHERE EXISTS((a:Company)) + RETURN a.name + ORDER BY a.name +$$) AS (name agtype); + +-- +-- Cleanup +-- +SELECT * FROM drop_graph('pattern_expr', true); diff --git a/regress/sql/pgvector.sql b/regress/sql/pgvector.sql index 677e78586..9c6c2c412 100644 --- a/regress/sql/pgvector.sql +++ b/regress/sql/pgvector.sql @@ -219,7 +219,7 @@ BEGIN USING hnsw ((( agtype_access_operator( VARIADIC ARRAY[ - _agtype_build_vertex(id, _label_name(%L::oid, id), properties), + properties, '"embedding"'::agtype ] )::text @@ -274,7 +274,7 @@ BEGIN USING hnsw (( agtype_access_operator( VARIADIC ARRAY[ - _agtype_build_vertex(id, _label_name(%L::oid, id), properties), + properties, '"embedding"'::agtype ] )::vector(4)) vector_cosine_ops); diff --git a/regress/sql/predicate_functions.sql b/regress/sql/predicate_functions.sql new file mode 100644 index 000000000..ad99a5825 --- /dev/null +++ b/regress/sql/predicate_functions.sql @@ -0,0 +1,383 @@ +/* + * 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. + */ + +LOAD 'age'; +SET search_path TO ag_catalog; + +SELECT create_graph('predicate_functions'); + +-- +-- all() predicate function +-- +-- all elements satisfy predicate -> true +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [1, 2, 3] WHERE x > 0) +$$) AS (result agtype); + +-- not all elements satisfy predicate -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [1, 2, 3] WHERE x > 1) +$$) AS (result agtype); + +-- empty list -> true (vacuous truth) +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [] WHERE x > 0) +$$) AS (result agtype); + +-- +-- any() predicate function +-- +-- at least one element satisfies -> true +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [1, 2, 3] WHERE x > 2) +$$) AS (result agtype); + +-- no element satisfies -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [1, 2, 3] WHERE x > 5) +$$) AS (result agtype); + +-- empty list -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [] WHERE x > 0) +$$) AS (result agtype); + +-- +-- none() predicate function +-- +-- no element satisfies predicate -> true +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [1, 2, 3] WHERE x > 5) +$$) AS (result agtype); + +-- at least one satisfies -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [1, 2, 3] WHERE x > 2) +$$) AS (result agtype); + +-- empty list -> true +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [] WHERE x > 0) +$$) AS (result agtype); + +-- +-- single() predicate function +-- +-- exactly one element satisfies -> true +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [1, 2, 3] WHERE x > 2) +$$) AS (result agtype); + +-- more than one satisfies -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [1, 2, 3] WHERE x > 1) +$$) AS (result agtype); + +-- none satisfies -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [1, 2, 3] WHERE x > 5) +$$) AS (result agtype); + +-- empty list -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [] WHERE x > 0) +$$) AS (result agtype); + +-- +-- NULL list input: all four return null +-- (NULL-list guard in the grammar produces CASE WHEN expr IS NULL +-- THEN NULL ELSE END) +-- +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN null WHERE x > 0) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN null WHERE x > 0) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN null WHERE x > 0) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN null WHERE x > 0) +$$) AS (result agtype); + +-- +-- NULL predicate results: three-valued logic +-- +-- Null list elements arrive at the predicate as SQL NULL, so the usual +-- strict-operator short-circuit applies: `null > 0` yields NULL, and the +-- predicate functions combine NULLs with the openCypher three-valued +-- logic (true trumps null in any(), false trumps null in all(), etc.). + +-- [null]: only null predicate, no true -> any() = NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [null] WHERE x > 0) +$$) AS (result agtype); + +-- one true (1 > 0) is enough: any() = true +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [null, 1, 2] WHERE x > 0) +$$) AS (result agtype); + +-- literal null predicate: pred = SQL NULL -> three-valued logic +-- all([1] WHERE null) = null (unknown) +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [1] WHERE null) +$$) AS (result agtype); + +-- no false, but one null -> all() = NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [1, null, 2] WHERE x > 0) +$$) AS (result agtype); + +-- -1 > 0 = false dominates the null -> all() = false +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [1, null, -1] WHERE x > 0) +$$) AS (result agtype); + +-- [null]: only null predicate, no true -> none() = NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [null] WHERE x > 0) +$$) AS (result agtype); + +-- one true (5 > 0) dominates: none() = false +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [null, 5] WHERE x > 0) +$$) AS (result agtype); + +-- one definite true (5 > 0) and one null predicate: the null could also +-- be a match, so we cannot conclude exactly-one -> single() = NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [null, 5] WHERE x > 0) +$$) AS (result agtype); + +-- two definite trues dominate any null -> single() = false +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [null, 5, 6] WHERE x > 0) +$$) AS (result agtype); + +-- only null predicates -> single() = NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [null, null] WHERE x > 0) +$$) AS (result agtype); + +-- one true + one false + one null: the null could be the second true +-- so we cannot conclude exactly-one -> single() = NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [1, null, -1] WHERE x > 0) +$$) AS (result agtype); + +-- +-- Additional null/three-valued coverage for any()/all()/none() +-- + +-- any() with no true and one null -> NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [null, -1] WHERE x > 0) +$$) AS (result agtype); + +-- all() with one true and one null -> NULL (the null might be false) +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [1, null] WHERE x > 0) +$$) AS (result agtype); + +-- all() with one definite false dominates any null -> false +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [null, -1] WHERE x > 0) +$$) AS (result agtype); + +-- none() with no true and one null -> NULL +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [null, -1] WHERE x > 0) +$$) AS (result agtype); + +-- IS NULL predicate now sees unwound null elements (issue #2393) +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [1, null] WHERE x IS NULL) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [1, null] WHERE x IS NULL) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [null, null] WHERE x IS NULL) +$$) AS (result agtype); + +-- +-- Integration with graph data +-- +SELECT * FROM cypher('predicate_functions', $$ + CREATE ({name: 'even', vals: [2, 4, 6, 8]}) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + CREATE ({name: 'mixed', vals: [1, 2, 3, 4]}) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + CREATE ({name: 'odd', vals: [1, 3, 5, 7]}) +$$) AS (result agtype); + +-- all() with graph properties +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WHERE all(x IN u.vals WHERE x % 2 = 0) + RETURN u.name + ORDER BY u.name +$$) AS (result agtype); + +-- any() with graph properties +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WHERE any(x IN u.vals WHERE x > 6) + RETURN u.name + ORDER BY u.name +$$) AS (result agtype); + +-- none() with graph properties +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WHERE none(x IN u.vals WHERE x < 0) + RETURN u.name + ORDER BY u.name +$$) AS (result agtype); + +-- single() with graph properties +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WHERE single(x IN u.vals WHERE x = 8) + RETURN u.name + ORDER BY u.name +$$) AS (result agtype); + +-- +-- Property access on the loop variable +-- +-- any: true (2 > 1) / false (none > 5) +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [{n: 1}, {n: 2}] WHERE x.n > 1) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [{n: 1}, {n: 2}] WHERE x.n > 5) +$$) AS (result agtype); + +-- all: true (both > 0) / false (not all > 1) +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [{n: 1}, {n: 2}] WHERE x.n > 0) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + RETURN all(x IN [{n: 1}, {n: 2}] WHERE x.n > 1) +$$) AS (result agtype); + +-- none: true (neither > 2) / false (one matches) +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [{n: 1}, {n: 2}] WHERE x.n > 2) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [{n: 1}, {n: 2}] WHERE x.n = 1) +$$) AS (result agtype); + +-- single: true (exactly one) / false (both match) +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [{n: 1}, {n: 2}] WHERE x.n = 1) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + RETURN single(x IN [{n: 1}, {n: 2}] WHERE x.n > 0) +$$) AS (result agtype); + +-- Property access on vertex loop variables over a collected node list +-- any: true ('even' exists) / false (no 'missing') +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN any(x IN ns WHERE x.name = 'even') +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN any(x IN ns WHERE x.name = 'missing') +$$) AS (result agtype); + +-- all: true (all have non-empty vals) / false (not all named 'even') +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN all(x IN ns WHERE size(x.vals) > 0) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN all(x IN ns WHERE x.name = 'even') +$$) AS (result agtype); + +-- none: true (none 'missing') / false ('even' matches) +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN none(x IN ns WHERE x.name = 'missing') +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN none(x IN ns WHERE x.name = 'even') +$$) AS (result agtype); + +-- single: true (only one 'odd') / false (all have non-empty vals) +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN single(x IN ns WHERE x.name = 'odd') +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + MATCH (u) WITH collect(u) AS ns + RETURN single(x IN ns WHERE size(x.vals) > 0) +$$) AS (result agtype); + +-- +-- Predicate functions in boolean expressions +-- +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [1, 2, 3] WHERE x > 2) + AND all(y IN [4, 5, 6] WHERE y > 0) +$$) AS (result agtype); + +SELECT * FROM cypher('predicate_functions', $$ + RETURN none(x IN [1, 2, 3] WHERE x > 5) + OR single(y IN [1, 2, 3] WHERE y = 2) +$$) AS (result agtype); + +-- +-- Nested predicate functions +-- +SELECT * FROM cypher('predicate_functions', $$ + RETURN any(x IN [1, 2, 3] WHERE all(y IN [1, 2] WHERE y < x)) +$$) AS (result agtype); + +-- +-- Keywords as property key names (safe_keywords backward compatibility) +-- +SELECT * FROM cypher('predicate_functions', $$ + RETURN {any: 1, none: 2, single: 3} +$$) AS (result agtype); + +-- +-- Cleanup +-- +SELECT * FROM drop_graph('predicate_functions', true); diff --git a/regress/sql/reserved_keyword_alias.sql b/regress/sql/reserved_keyword_alias.sql new file mode 100755 index 000000000..eab27ee37 --- /dev/null +++ b/regress/sql/reserved_keyword_alias.sql @@ -0,0 +1,102 @@ +/* + * 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. + */ + +/* + * Regression coverage for issue #2355: + * Cypher reserved keywords from the `safe_keywords` set must be accepted + * in alias positions (RETURN/WITH/YIELD ... AS , UNWIND ... AS ). + * Conflicting tokens (END / NULL / TRUE / FALSE) must remain rejected. + * Pattern variable bindings are intentionally still restricted to plain + * identifiers. + */ + +LOAD 'age'; +SET search_path TO ag_catalog; + +SELECT * FROM create_graph('issue_2355'); + +-- The exact reproducer from the issue (previously failed with +-- "syntax error at or near "count""). +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS count $$) AS (a agtype); + +-- Representative coverage across the safe_keywords set as RETURN aliases. +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS exists $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS coalesce $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS match $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS return $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS where $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS order $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS limit $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS distinct $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS optional $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS detach $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS contains $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS starts $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS ends $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS in $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS is $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS not $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS yield $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS call $$) AS (a agtype); + +-- Multiple keyword aliases in one projection. +SELECT * FROM cypher('issue_2355', + $$ RETURN 1 AS count, 2 AS exists, 3 AS where $$ +) AS (count agtype, ex agtype, w agtype); + +-- WITH ... AS : alias binding works. +SELECT * FROM cypher('issue_2355', + $$ WITH 1 AS count RETURN 1 AS x $$ +) AS (a agtype); + +-- UNWIND ... AS : alias binding works. +SELECT * FROM cypher('issue_2355', + $$ UNWIND [1, 2, 3] AS row RETURN 1 AS x $$ +) AS (a agtype); + +-- conflicted_keywords (END, NULL, TRUE, FALSE) MUST still be rejected +-- because they introduce real grammar ambiguity with literal/expression +-- productions. +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS null $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS true $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS false $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS end $$) AS (a agtype); + +-- Pattern-variable positions are intentionally NOT broadened (would +-- create shift/reduce conflicts). Confirm they still error. +SELECT * FROM cypher('issue_2355', + $$ MATCH (count) RETURN 1 AS x $$ +) AS (a agtype); + +-- Plain identifiers naturally remain unaffected. +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS my_alias $$) AS (a agtype); + +-- Backtick-quoted alias positive case: forces the IDENTIFIER token +-- path, so future grammar refactors don't accidentally regress quoted +-- identifiers when the unquoted form is also a keyword. +SELECT * FROM cypher('issue_2355', $$ RETURN 1 AS `count` $$) AS (a agtype); +SELECT * FROM cypher('issue_2355', $$ WITH 1 AS `count` RETURN `count` $$) AS (a agtype); + +-- Known limitation: reading a keyword-named alias back fails because +-- expr_var reads through var_name (which is unchanged here). Tracked +-- in issue #2416. Captured in the expected output so the next +-- contributor who fixes expr_var has a precise file to update. +SELECT * FROM cypher('issue_2355', $$ WITH 1 AS count RETURN count $$) AS (a agtype); + +SELECT * FROM drop_graph('issue_2355', true); diff --git a/regress/sql/scan.sql b/regress/sql/scan.sql index 4d35fe0fe..614447e33 100644 --- a/regress/sql/scan.sql +++ b/regress/sql/scan.sql @@ -202,6 +202,27 @@ SELECT * FROM cypher('scan', $$ RETURN " \" \" ' \' ", ' \' \' " \" ', " / \/ \\ \b \f \n \r \t " $$) AS t(a agtype, b agtype, c agtype); +-- doubled-quote escape sequences for SQL driver compatibility (issue #2222) +SELECT * FROM cypher('scan', $$ +RETURN 'isn''t' +$$) AS t(a agtype); + +SELECT * FROM cypher('scan', $$ +RETURN '''hello' +$$) AS t(a agtype); + +SELECT * FROM cypher('scan', $$ +RETURN 'hello''' +$$) AS t(a agtype); + +SELECT * FROM cypher('scan', $$ +RETURN 'it''s a ''test''' +$$) AS t(a agtype); + +SELECT * FROM cypher('scan', $$ +RETURN "she said ""hello""" +$$) AS t(a agtype); + -- invalid escape sequence SELECT * FROM cypher('scan', $$ RETURN "\a" diff --git a/regress/sql/subgraph.sql b/regress/sql/subgraph.sql new file mode 100644 index 000000000..0d01dfe60 --- /dev/null +++ b/regress/sql/subgraph.sql @@ -0,0 +1,189 @@ +/* + * 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. + */ + +LOAD 'age'; +SET search_path TO ag_catalog; + +-- Suppress the create_graph / create_vlabel NOTICE chatter so the assertions +-- below are the deterministic output. (The feature is exercised regardless.) +SET client_min_messages = warning; + +-- +-- Build a "somewhat large" source graph with NO MATCH (fast bulk CREATE): +-- * 2000 isolated components, each (:Person{pid,age})-[:KNOWS{w}]->(:Friend{pid}) +-- => 2000 Person + 2000 Friend vertices, 2000 KNOWS edges +-- * 500 isolated :Company vertices (no edges) +-- Totals: 4500 vertices, 2000 edges, label set {Person,Friend,Company,KNOWS}. +-- +SELECT create_graph('sg_src'); + +SELECT count(*) FROM cypher('sg_src', $$ + UNWIND range(1, 2000) AS i + CREATE (:Person {pid: i, age: i % 100})-[:KNOWS {w: i}]->(:Friend {pid: i}) +$$) AS (a agtype); + +SELECT count(*) FROM cypher('sg_src', $$ + UNWIND range(1, 500) AS i CREATE (:Company {cid: i}) +$$) AS (a agtype); + +-- Source baseline (printed for reference; deterministic). +SELECT + (SELECT count(*) FROM cypher('sg_src', $$ MATCH (n) RETURN n $$) AS (n agtype)) AS src_vertices, + (SELECT count(*) FROM cypher('sg_src', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype)) AS src_edges; + +-- +-- 1. Full copy ('*','*'): counts equal the source, and the new graph round-trips. +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_all', 'sg_src', '*', '*'); + +SELECT + (SELECT count(*) FROM cypher('sg_all', $$ MATCH (n) RETURN n $$) AS (n agtype)) + = (SELECT count(*) FROM cypher('sg_src', $$ MATCH (n) RETURN n $$) AS (n agtype)) AS nodes_match, + (SELECT count(*) FROM cypher('sg_all', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype)) + = (SELECT count(*) FROM cypher('sg_src', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype)) AS edges_match; + +-- +-- 2. Vertex-induced (node filter only): keep pid <= 1000. An edge survives iff +-- BOTH endpoints survive (induced rule), with no relationship filter. +-- node_count is asserted against the function return; correctness is verified +-- by recomputing the induced set from the source (robust booleans). +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_v', 'sg_src', 'n.pid <= 1000', '*'); + +SELECT + (SELECT count(*) FROM cypher('sg_v', $$ MATCH (n) RETURN n $$) AS (n agtype)) + = (SELECT count(*) FROM cypher('sg_src', + $$ MATCH (n) WHERE n.pid <= 1000 RETURN n $$) AS (n agtype)) AS nodes_ok, + (SELECT count(*) FROM cypher('sg_v', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype)) + = (SELECT count(*) FROM cypher('sg_src', + $$ MATCH (a)-[r]->(b) WHERE a.pid <= 1000 AND b.pid <= 1000 RETURN r $$) + AS (r agtype)) AS edges_ok; + +-- +-- 3. Node + relationship predicate: keep pid <= 1000 vertices and w <= 300 edges. +-- Edge survives iff w<=300 AND both endpoints pid<=1000. +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_nr', 'sg_src', 'n.pid <= 1000', 'r.w <= 300'); + +SELECT + (SELECT count(*) FROM cypher('sg_nr', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype)) + = (SELECT count(*) FROM cypher('sg_src', + $$ MATCH (a)-[r]->(b) WHERE r.w <= 300 AND a.pid <= 1000 AND b.pid <= 1000 + RETURN r $$) AS (r agtype)) AS edges_ok; + +-- +-- 4. Label filter excludes one endpoint type: keep only :Person. Every KNOWS +-- edge points Person->Friend, so all edges must be dropped (induced rule). +-- (AGE evaluates label predicates with label(n); GDS uses n:Person -- same +-- containment semantics, different predicate syntax.) +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_person', 'sg_src', $f$label(n) = 'Person'$f$, '*'); + +-- +-- 5. Bipartite (type filter): keep Person+Friend and KNOWS edges => all 2000. +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_bip', 'sg_src', + $f$label(n) = 'Person' OR label(n) = 'Friend'$f$, + $f$label(r) = 'KNOWS'$f$); + +-- +-- 6. Empty result: a predicate matching nothing yields an empty subgraph +-- (not an error), with the default labels only. +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_empty', 'sg_src', 'n.pid < 0', '*'); + +SELECT count(*) AS empty_vertices +FROM cypher('sg_empty', $$ MATCH (n) RETURN n $$) AS (n agtype); + +-- +-- 7. Composability: extract a subgraph from an already-extracted subgraph. +-- From sg_v (pid<=1000) keep pid<=500; verify against recomputation on sg_v. +-- +SELECT node_count, relationship_count +FROM create_subgraph('sg_v2', 'sg_v', 'n.pid <= 500', '*'); + +SELECT + (SELECT count(*) FROM cypher('sg_v2', $$ MATCH (n) RETURN n $$) AS (n agtype)) + = (SELECT count(*) FROM cypher('sg_v', + $$ MATCH (n) WHERE n.pid <= 500 RETURN n $$) AS (n agtype)) AS nodes_ok, + (SELECT count(*) FROM cypher('sg_v2', $$ MATCH ()-[r]->() RETURN r $$) AS (r agtype)) + = (SELECT count(*) FROM cypher('sg_v', + $$ MATCH (a)-[r]->(b) WHERE a.pid <= 500 AND b.pid <= 500 RETURN r $$) + AS (r agtype)) AS edges_ok; + +-- +-- 8. Self-loops and parallel edges (multigraph structure) are preserved. +-- +SELECT create_graph('sg_multi'); +SELECT * FROM cypher('sg_multi', $$ + CREATE (a:N {k: 1}) CREATE (a)-[:E {t: 1}]->(a) +$$) AS (a agtype); +SELECT * FROM cypher('sg_multi', $$ + CREATE (a:N {k: 2}), (b:N {k: 3}), + (a)-[:E {t: 2}]->(b), (a)-[:E {t: 3}]->(b) +$$) AS (a agtype); + +SELECT node_count, relationship_count +FROM create_subgraph('sg_multi_sub', 'sg_multi', '*', '*'); + +-- self-loop preserved (exactly one edge from a node to itself) +SELECT count(*) AS self_loops +FROM cypher('sg_multi_sub', $$ MATCH (a)-[r]->(a) RETURN r $$) AS (r agtype); + +-- parallel edges preserved (two edges between k=2 and k=3) +SELECT count(*) AS parallel_edges +FROM cypher('sg_multi_sub', $$ MATCH (a {k: 2})-[r]->(b {k: 3}) RETURN r $$) AS (r agtype); + +-- +-- 9. Property fidelity: a copied vertex keeps its properties verbatim. +-- +SELECT count(*) AS person_500_age_ok +FROM cypher('sg_v', $$ MATCH (n:Person {pid: 500}) WHERE n.age = 0 RETURN n $$) AS (n agtype); + +-- +-- 10. Error handling / edge cases. +-- +-- NULL graph name +SELECT create_subgraph(NULL, 'sg_src', '*', '*'); +-- source does not exist +SELECT create_subgraph('sg_x', 'no_such_graph', '*', '*'); +-- extracting into the source itself +SELECT create_subgraph('sg_src', 'sg_src', '*', '*'); +-- destination already exists +SELECT create_subgraph('sg_all', 'sg_src', '*', '*'); +-- invalid Cypher predicate is reported (propagated from the engine) +SELECT create_subgraph('sg_bad', 'sg_src', 'n.pid <<>> 1', '*'); + +-- cleanup +SELECT drop_graph('sg_v2', true); +SELECT drop_graph('sg_multi_sub', true); +SELECT drop_graph('sg_multi', true); +SELECT drop_graph('sg_empty', true); +SELECT drop_graph('sg_bip', true); +SELECT drop_graph('sg_person', true); +SELECT drop_graph('sg_nr', true); +SELECT drop_graph('sg_v', true); +SELECT drop_graph('sg_all', true); +SELECT drop_graph('sg_src', true); diff --git a/sql/age_aggregate.sql b/sql/age_aggregate.sql index a8ea425cc..9ad715683 100644 --- a/sql/age_aggregate.sql +++ b/sql/age_aggregate.sql @@ -216,3 +216,29 @@ CREATE AGGREGATE ag_catalog.age_collect(variadic "any") finalfunc = ag_catalog.age_collect_aggfinalfn, parallel = safe ); + +-- +-- reduce(acc = init, var IN list | body) fold support +-- +-- Transition function for the age_reduce aggregate. The fold body is compiled +-- by transform_cypher_reduce() with the accumulator and element rewritten to +-- PARAM_EXEC params 0 and 1 and serialized into the text argument; the +-- transition evaluates it for each element in list order. The trailing +-- agtype[] argument carries the loop-invariant outer values (outer-query +-- variables and cypher() parameters) referenced by the body, bound to +-- PARAM_EXEC params 2, 3, ... It must be callable with a NULL transition state +-- (no initcond), so it is intentionally not STRICT. +CREATE FUNCTION ag_catalog.age_reduce_transfn(agtype, agtype, text, agtype, agtype[]) + RETURNS agtype + LANGUAGE c +PARALLEL UNSAFE +AS 'MODULE_PATHNAME'; + +-- aggregate definition for reduce(); direct arguments are +-- (init, serialized-body, element, captured-outer-values), with the element +-- fed ORDER BY ordinality. +CREATE AGGREGATE ag_catalog.age_reduce(agtype, text, agtype, agtype[]) +( + stype = agtype, + sfunc = ag_catalog.age_reduce_transfn +); diff --git a/sql/age_main.sql b/sql/age_main.sql index 59ada0f9f..233d0d23f 100644 --- a/sql/age_main.sql +++ b/sql/age_main.sql @@ -20,6 +20,33 @@ -- complain if script is sourced in psql, rather than via CREATE EXTENSION \echo Use "CREATE EXTENSION age" to load this file. \quit +-- +-- Ensure ag_catalog is created and owned by the installing role. +-- +-- CREATE EXTENSION places all of AGE's objects in ag_catalog. A normal install +-- creates that schema, owned by the installer. If ag_catalog already exists and +-- is owned by a different role, that role would retain control over the schema +-- that holds AGE's catalog objects. To keep ownership well-defined, refuse to +-- install into a pre-existing ag_catalog owned by another role. Ownership is +-- compared directly (not via role membership) so the check is exact even for a +-- superuser, who is otherwise considered a member of every role. +-- +DO $age_install_guard$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_namespace n + WHERE n.nspname = 'ag_catalog' + AND n.nspowner <> (SELECT r.oid + FROM pg_catalog.pg_roles r + WHERE r.rolname = current_user) + ) THEN + RAISE EXCEPTION 'schema "ag_catalog" already exists and is not owned by the installing role "%"', current_user + USING HINT = 'Apache AGE will not install into a pre-existing ag_catalog owned by another role. Drop it (DROP SCHEMA ag_catalog CASCADE) or transfer its ownership to the installing role, then retry CREATE EXTENSION age.'; + END IF; +END +$age_install_guard$; + -- -- catalog tables -- @@ -86,6 +113,16 @@ CREATE FUNCTION ag_catalog._label_id(graph_name name, label_name name) PARALLEL SAFE AS 'MODULE_PATHNAME'; +-- Internal selftest for the agehash open-addressing hashtable. Returns "OK" +-- on success or "FAIL: ..." with a diagnostic message. Intended for the +-- agehash regression test only. +CREATE FUNCTION ag_catalog._agehash_self_test() + RETURNS text + LANGUAGE c + VOLATILE + PARALLEL UNSAFE +AS 'MODULE_PATHNAME'; + -- -- utility functions -- @@ -368,16 +405,20 @@ CREATE FUNCTION ag_catalog._graphid(label_id int, entry_id bigint) PARALLEL SAFE AS 'MODULE_PATHNAME'; -CREATE FUNCTION ag_catalog._label_name(graph_oid oid, graphid) - RETURNS cstring - LANGUAGE c - IMMUTABLE -PARALLEL SAFE -AS 'MODULE_PATHNAME'; - CREATE FUNCTION ag_catalog._extract_label_id(graphid) RETURNS label_id LANGUAGE c STABLE PARALLEL SAFE AS 'MODULE_PATHNAME'; + +-- +-- VLE cache invalidation trigger function. +-- Installed on graph label tables to catch SQL-level mutations +-- (INSERT/UPDATE/DELETE/TRUNCATE) and increment the graph's +-- version counter so VLE caches are properly invalidated. +-- +CREATE FUNCTION ag_catalog.age_invalidate_graph_cache() + RETURNS trigger + LANGUAGE c +AS 'MODULE_PATHNAME'; diff --git a/sql/age_pg_upgrade.sql b/sql/age_pg_upgrade.sql new file mode 100644 index 000000000..68fbd1513 --- /dev/null +++ b/sql/age_pg_upgrade.sql @@ -0,0 +1,487 @@ +/* + * 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. + */ + +-- +-- pg_upgrade support functions +-- +-- These functions help users upgrade PostgreSQL major versions using pg_upgrade +-- while preserving Apache AGE graph data. The ag_graph.namespace column uses +-- the regnamespace type which is not supported by pg_upgrade. These functions +-- temporarily convert the schema to be pg_upgrade compatible, then restore it +-- to the original state after the upgrade completes. +-- +-- Usage: +-- 1. Before pg_upgrade: SELECT age_prepare_pg_upgrade(); +-- 2. Run pg_upgrade as normal +-- 3. After pg_upgrade: SELECT age_finish_pg_upgrade(); +-- +-- To cancel an upgrade after preparation (before running pg_upgrade): +-- SELECT age_revert_pg_upgrade_changes(); +-- + +-- +-- age_prepare_pg_upgrade() +-- +-- Prepares an AGE database for pg_upgrade by converting the ag_graph.namespace +-- column from regnamespace to oid type. This is necessary because pg_upgrade +-- does not support the regnamespace type in user tables. +-- +-- This function: +-- 1. Creates a backup table with graph name to namespace name mappings +-- 2. Drops the existing namespace index +-- 3. Converts the namespace column from regnamespace to oid +-- 4. Recreates the namespace index with oid type +-- 5. Creates a view for backward-compatible namespace display +-- +-- Returns: void +-- Side effects: Modifies ag_graph table structure +-- +CREATE FUNCTION ag_catalog.age_prepare_pg_upgrade() + RETURNS void + LANGUAGE plpgsql + -- Resolve built-in functions and operators from pg_catalog first so they + -- are not overridden by same-named objects defined in ag_catalog. The + -- ag_catalog objects referenced here are schema-qualified. + SET search_path = pg_catalog, ag_catalog + AS $function$ +DECLARE + graph_count integer; +BEGIN + -- Check if namespace column is already oid type (already prepared) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'ag_catalog' + AND table_name = 'ag_graph' + AND column_name = 'namespace' + AND data_type = 'oid' + ) THEN + RAISE NOTICE 'Database already prepared for pg_upgrade (namespace is oid type).'; + RETURN; + END IF; + + -- Drop existing backup table if it exists (from a previous failed attempt) + DROP TABLE IF EXISTS public._age_pg_upgrade_backup; + + -- Create backup table with graph names mapped to namespace names + -- We store nspname directly (not regnamespace::text) to avoid quoting issues + -- Names survive pg_upgrade while OIDs don't + CREATE TABLE public._age_pg_upgrade_backup AS + SELECT + g.graphid AS old_graphid, + g.name AS graph_name, + n.nspname AS namespace_name + FROM ag_catalog.ag_graph g + JOIN pg_namespace n ON n.oid = g.namespace::oid; + + SELECT count(*) INTO graph_count FROM public._age_pg_upgrade_backup; + + RAISE NOTICE 'Created backup table public._age_pg_upgrade_backup with % graph(s)', graph_count; + + -- Even with zero graphs, we still need to convert the column type + -- because the regnamespace type itself blocks pg_upgrade + + -- Drop the existing regnamespace-based index + DROP INDEX IF EXISTS ag_catalog.ag_graph_namespace_index; + + -- Convert namespace column from regnamespace to oid + ALTER TABLE ag_catalog.ag_graph + ALTER COLUMN namespace TYPE oid USING namespace::oid; + + -- Recreate the index with oid type + CREATE UNIQUE INDEX ag_graph_namespace_index + ON ag_catalog.ag_graph USING btree (namespace); + + -- Create a view for backward-compatible display of namespace as schema name + CREATE OR REPLACE VIEW ag_catalog.ag_graph_view AS + SELECT graphid, name, namespace::regnamespace AS namespace + FROM ag_catalog.ag_graph; + + RAISE NOTICE 'Successfully prepared database for pg_upgrade.'; + RAISE NOTICE 'The ag_graph.namespace column has been converted from regnamespace to oid.'; + RAISE NOTICE 'You can now run pg_upgrade.'; + RAISE NOTICE 'After pg_upgrade completes, run: SELECT age_finish_pg_upgrade();'; +END; +$function$; + +COMMENT ON FUNCTION ag_catalog.age_prepare_pg_upgrade() IS +'Prepares an AGE database for pg_upgrade by converting ag_graph.namespace from regnamespace to oid type. Run this before pg_upgrade.'; + +-- +-- age_finish_pg_upgrade() +-- +-- Completes the pg_upgrade process by remapping stale OIDs in ag_graph and +-- ag_label tables to their new values, then restores the original schema. +-- After pg_upgrade, the namespace OIDs stored in these tables no longer match +-- the actual pg_namespace OIDs because PostgreSQL assigns new OIDs to schemas +-- during the upgrade. +-- +-- This function: +-- 1. Reads the backup table created by age_prepare_pg_upgrade() +-- 2. Looks up new namespace OIDs by schema name +-- 3. Updates ag_label.graph references +-- 4. Updates ag_graph.graphid and ag_graph.namespace +-- 5. Restores namespace column to regnamespace type +-- 6. Cleans up the backup table and view +-- 7. Invalidates AGE caches to ensure cypher queries work immediately +-- +-- Returns: void +-- Side effects: Updates OIDs in ag_graph and ag_label tables, restores schema +-- +CREATE FUNCTION ag_catalog.age_finish_pg_upgrade() + RETURNS void + LANGUAGE plpgsql + -- Resolve built-in functions and operators from pg_catalog first so they + -- are not overridden by same-named objects defined in ag_catalog. The + -- ag_catalog objects referenced here are schema-qualified. + SET search_path = pg_catalog, ag_catalog + AS $function$ +DECLARE + mapping_count integer; + updated_labels integer; + updated_graphs integer; +BEGIN + -- Check if backup table exists + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = '_age_pg_upgrade_backup' + ) THEN + RAISE EXCEPTION 'Backup table public._age_pg_upgrade_backup not found. ' + 'Did you run age_prepare_pg_upgrade() before pg_upgrade?'; + END IF; + + -- Check if namespace column is oid type (was properly prepared) + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'ag_catalog' + AND table_name = 'ag_graph' + AND column_name = 'namespace' + AND data_type = 'oid' + ) THEN + RAISE EXCEPTION 'ag_graph.namespace is not oid type. ' + 'Did you run age_prepare_pg_upgrade() before pg_upgrade?'; + END IF; + + -- Create temporary mapping table with old and new OIDs + CREATE TEMP TABLE _graphid_mapping AS + SELECT + b.old_graphid, + b.graph_name, + n.oid AS new_graphid + FROM public._age_pg_upgrade_backup b + JOIN pg_namespace n ON n.nspname = b.namespace_name; + + GET DIAGNOSTICS mapping_count = ROW_COUNT; + + -- Verify all backup rows were mapped (detect missing schemas) + DECLARE + backup_count integer; + BEGIN + SELECT count(*) INTO backup_count FROM public._age_pg_upgrade_backup; + IF mapping_count < backup_count THEN + RAISE EXCEPTION 'Only % of % graphs could be mapped. Some schema names may have changed or been dropped.', + mapping_count, backup_count; + END IF; + END; + + -- Handle zero-graph case (still need to restore schema) + IF mapping_count = 0 THEN + RAISE NOTICE 'No graphs to remap (empty backup table).'; + DROP TABLE _graphid_mapping; + -- Skip to schema restoration + ELSE + RAISE NOTICE 'Found % graph(s) to remap', mapping_count; + + -- Temporarily drop foreign key constraint + ALTER TABLE ag_catalog.ag_label DROP CONSTRAINT IF EXISTS fk_graph_oid; + + -- Update ag_label.graph references to use new OIDs + UPDATE ag_catalog.ag_label l + SET graph = m.new_graphid + FROM _graphid_mapping m + WHERE l.graph = m.old_graphid; + + GET DIAGNOSTICS updated_labels = ROW_COUNT; + RAISE NOTICE 'Updated % label record(s)', updated_labels; + + -- Update ag_graph.graphid and ag_graph.namespace to new OIDs + UPDATE ag_catalog.ag_graph g + SET graphid = m.new_graphid, + namespace = m.new_graphid + FROM _graphid_mapping m + WHERE g.graphid = m.old_graphid; + + GET DIAGNOSTICS updated_graphs = ROW_COUNT; + RAISE NOTICE 'Updated % graph record(s)', updated_graphs; + + -- Restore foreign key constraint + ALTER TABLE ag_catalog.ag_label + ADD CONSTRAINT fk_graph_oid + FOREIGN KEY(graph) REFERENCES ag_catalog.ag_graph(graphid); + + -- Clean up temporary mapping table + DROP TABLE _graphid_mapping; + + RAISE NOTICE 'Successfully completed pg_upgrade OID remapping.'; + END IF; + + -- + -- Restore original schema (revert namespace to regnamespace) + -- + RAISE NOTICE 'Restoring original schema...'; + + -- Drop the view (no longer needed with regnamespace) + DROP VIEW IF EXISTS ag_catalog.ag_graph_view; + + -- Drop the existing oid-based index + DROP INDEX IF EXISTS ag_catalog.ag_graph_namespace_index; + + -- Convert namespace column back to regnamespace + ALTER TABLE ag_catalog.ag_graph + ALTER COLUMN namespace TYPE regnamespace USING namespace::regnamespace; + + -- Recreate the index with regnamespace type + CREATE UNIQUE INDEX ag_graph_namespace_index + ON ag_catalog.ag_graph USING btree (namespace); + + RAISE NOTICE 'Successfully restored ag_graph.namespace to regnamespace type.'; + + -- + -- Invalidate AGE's internal caches by touching each graph's namespace + -- AGE registers a syscache callback on NAMESPACEOID, so altering a schema + -- triggers cache invalidation. This ensures cypher queries work immediately + -- without requiring a session reconnect. + -- + -- We use xact-level advisory lock (auto-released at transaction end) + -- and preserve original schema ownership. + -- + RAISE NOTICE 'Invalidating AGE caches...'; + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('age_finish_pg_upgrade')); + DECLARE + graph_rec RECORD; + cache_invalidated boolean := false; + BEGIN + FOR graph_rec IN + SELECT n.nspname AS ns_name, r.rolname AS owner_name + FROM ag_catalog.ag_graph g + JOIN pg_namespace n ON n.oid = g.namespace + JOIN pg_roles r ON r.oid = n.nspowner + LOOP + BEGIN + -- Touch schema by changing owner to current_user then back to original + -- This triggers cache invalidation without permanently changing ownership + EXECUTE pg_catalog.format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, current_user); + EXECUTE pg_catalog.format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, graph_rec.owner_name); + cache_invalidated := true; + EXCEPTION WHEN insufficient_privilege THEN + -- If we can't change ownership, skip this schema + -- The cache will be invalidated on first use anyway + RAISE NOTICE 'Could not invalidate cache for schema % (insufficient privileges)', graph_rec.ns_name; + END; + END LOOP; + IF NOT cache_invalidated AND (SELECT count(*) FROM ag_catalog.ag_graph) > 0 THEN + RAISE NOTICE 'Cache invalidation skipped. You may need to reconnect for cypher queries to work.'; + END IF; + END; + + -- Now that all steps succeeded, clean up the backup table + DROP TABLE IF EXISTS public._age_pg_upgrade_backup; + + RAISE NOTICE ''; + RAISE NOTICE 'pg_upgrade complete. All graph data has been preserved.'; +END; +$function$; + +COMMENT ON FUNCTION ag_catalog.age_finish_pg_upgrade() IS +'Completes pg_upgrade by remapping stale OIDs and restoring the original schema. Run this after pg_upgrade.'; + +-- +-- age_revert_pg_upgrade_changes() +-- +-- Reverts the schema changes made by age_prepare_pg_upgrade() if you need to +-- cancel the upgrade process before running pg_upgrade. This restores the +-- namespace column to its original regnamespace type. +-- +-- NOTE: This function is NOT needed after age_finish_pg_upgrade(), which +-- automatically restores the original schema. Use this only if you called +-- age_prepare_pg_upgrade() but decided not to proceed with pg_upgrade. +-- +-- This function: +-- 1. Drops the ag_graph_view (no longer needed) +-- 2. Drops the oid-based namespace index +-- 3. Converts namespace column back to regnamespace +-- 4. Recreates the namespace index with regnamespace type +-- 5. Invalidates AGE caches to ensure cypher queries work immediately +-- 6. Does NOT clean up the backup table (manual cleanup may be needed) +-- +-- Returns: void +-- Side effects: Modifies ag_graph table structure +-- +CREATE FUNCTION ag_catalog.age_revert_pg_upgrade_changes() + RETURNS void + LANGUAGE plpgsql + -- Resolve built-in functions and operators from pg_catalog first so they + -- are not overridden by same-named objects defined in ag_catalog. The + -- ag_catalog objects referenced here are schema-qualified. + SET search_path = pg_catalog, ag_catalog + AS $function$ +BEGIN + -- Check if namespace column is oid type (needs reverting) + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'ag_catalog' + AND table_name = 'ag_graph' + AND column_name = 'namespace' + AND data_type = 'oid' + ) THEN + RAISE NOTICE 'ag_graph.namespace is already regnamespace type. Nothing to revert.'; + RETURN; + END IF; + + -- Drop the view (no longer needed with regnamespace) + DROP VIEW IF EXISTS ag_catalog.ag_graph_view; + + -- Drop the existing oid-based index + DROP INDEX IF EXISTS ag_catalog.ag_graph_namespace_index; + + -- Convert namespace column back to regnamespace + ALTER TABLE ag_catalog.ag_graph + ALTER COLUMN namespace TYPE regnamespace USING namespace::regnamespace; + + -- Recreate the index with regnamespace type + CREATE UNIQUE INDEX ag_graph_namespace_index + ON ag_catalog.ag_graph USING btree (namespace); + + -- + -- Invalidate AGE's internal caches by touching each graph's namespace + -- We use xact-level advisory lock and preserve original ownership + -- + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('age_revert_pg_upgrade')); + DECLARE + graph_rec RECORD; + BEGIN + FOR graph_rec IN + SELECT n.nspname AS ns_name, r.rolname AS owner_name + FROM ag_catalog.ag_graph g + JOIN pg_namespace n ON n.oid = g.namespace + JOIN pg_roles r ON r.oid = n.nspowner + LOOP + BEGIN + -- Touch schema by changing owner to current_user then back to original + EXECUTE pg_catalog.format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, current_user); + EXECUTE pg_catalog.format('ALTER SCHEMA %I OWNER TO %I', graph_rec.ns_name, graph_rec.owner_name); + EXCEPTION WHEN insufficient_privilege THEN + RAISE NOTICE 'Could not invalidate cache for schema % (insufficient privileges)', graph_rec.ns_name; + END; + END LOOP; + END; + + RAISE NOTICE 'Successfully reverted ag_graph.namespace to regnamespace type.'; + RAISE NOTICE ''; + RAISE NOTICE 'Upgrade preparation has been cancelled.'; + RAISE NOTICE 'You may want to drop the backup table: DROP TABLE IF EXISTS public._age_pg_upgrade_backup;'; +END; +$function$; + +COMMENT ON FUNCTION ag_catalog.age_revert_pg_upgrade_changes() IS +'Reverts schema changes if you need to cancel after age_prepare_pg_upgrade() but before pg_upgrade. Not needed after age_finish_pg_upgrade().'; + +-- +-- age_pg_upgrade_status() +-- +-- Returns the current pg_upgrade readiness status of the AGE installation. +-- Useful for checking whether the database needs preparation before pg_upgrade. +-- +-- Returns: TABLE with status information +-- +CREATE FUNCTION ag_catalog.age_pg_upgrade_status() + RETURNS TABLE ( + status text, + namespace_type text, + graph_count bigint, + backup_exists boolean, + message text + ) + LANGUAGE plpgsql + -- Resolve built-in functions and operators from pg_catalog first so they + -- are not overridden by same-named objects defined in ag_catalog. The + -- ag_catalog objects referenced here are schema-qualified. + SET search_path = pg_catalog, ag_catalog + AS $function$ +DECLARE + ns_type text; + g_count bigint; + backup_exists boolean; +BEGIN + -- Get namespace column type + SELECT data_type INTO ns_type + FROM information_schema.columns + WHERE table_schema = 'ag_catalog' + AND table_name = 'ag_graph' + AND column_name = 'namespace'; + + -- Get graph count + SELECT count(*) INTO g_count FROM ag_catalog.ag_graph; + + -- Check for backup table + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = '_age_pg_upgrade_backup' + ) INTO backup_exists; + + -- Determine status and message + IF ns_type = 'regnamespace' AND NOT backup_exists THEN + -- Normal state - ready for use, needs prep before pg_upgrade + RETURN QUERY SELECT + 'NORMAL'::text, + ns_type, + g_count, + backup_exists, + 'Run SELECT age_prepare_pg_upgrade(); before pg_upgrade'::text; + ELSIF ns_type = 'regnamespace' AND backup_exists THEN + -- Unusual state - backup exists but schema wasn't converted + RETURN QUERY SELECT + 'WARNING'::text, + ns_type, + g_count, + backup_exists, + 'Backup table exists but schema not converted. Run age_prepare_pg_upgrade() again.'::text; + ELSIF ns_type = 'oid' AND backup_exists THEN + -- Prepared and ready for pg_upgrade, or awaiting finish after pg_upgrade + RETURN QUERY SELECT + 'PREPARED - AWAITING FINISH'::text, + ns_type, + g_count, + backup_exists, + 'After pg_upgrade, run SELECT age_finish_pg_upgrade();'::text; + ELSE + -- oid type without backup - manually converted or partial state + RETURN QUERY SELECT + 'CONVERTED'::text, + ns_type, + g_count, + backup_exists, + 'Namespace is oid type. If upgrading, ensure backup table exists.'::text; + END IF; +END; +$function$; + +COMMENT ON FUNCTION ag_catalog.age_pg_upgrade_status() IS +'Returns the current pg_upgrade readiness status of the AGE installation.'; diff --git a/sql/age_scalar.sql b/sql/age_scalar.sql index 5014fbbe8..ec098e8ee 100644 --- a/sql/age_scalar.sql +++ b/sql/age_scalar.sql @@ -93,6 +93,15 @@ RETURNS NULL ON NULL INPUT PARALLEL SAFE AS 'MODULE_PATHNAME'; +-- Helper function for optimized startNode/endNode +CREATE FUNCTION ag_catalog._get_vertex_by_graphid(text, graphid) + RETURNS agtype + LANGUAGE c + STABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + CREATE FUNCTION ag_catalog.age_length(agtype) RETURNS agtype LANGUAGE c diff --git a/sql/age_subgraph.sql b/sql/age_subgraph.sql new file mode 100644 index 000000000..0d7e3648d --- /dev/null +++ b/sql/age_subgraph.sql @@ -0,0 +1,294 @@ +/* + * 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. + */ + +-- +-- create_subgraph(): materialized subgraph extraction. +-- +-- Builds a new, persistent AGE graph that is the subgraph of an existing graph +-- selected by a node predicate and a relationship predicate. The semantics +-- follow the graph-theory "induced subgraph" definition as operationalized by +-- Neo4j GDS gds.graph.filter(): +-- +-- * a vertex is kept iff node_filter evaluates true ('*' keeps all); +-- * an edge is kept iff relationship_filter evaluates true AND BOTH of its +-- endpoints were kept (the induced rule -- no dangling edges). +-- +-- Unlike the Neo4j in-memory projection, the result is a real, ACID, +-- fully-Cypher-queryable AGE graph; properties of any agtype are preserved, and +-- self-loops / parallel edges (multigraph structure) are kept. +-- +-- node_filter / relationship_filter are Cypher predicates bound to a single +-- entity -- the node variable is `n`, the relationship variable is `r` -- or +-- the literal '*' to keep all. They are evaluated by AGE's own Cypher engine +-- against the source graph, so the full Cypher predicate language is available. +-- +-- Internal entity ids (graphids) are reassigned in the new graph (a graphid +-- encodes the source graph's label id, which differs in the destination), and +-- edge endpoints are remapped accordingly. Properties are copied verbatim. +-- +CREATE FUNCTION ag_catalog.create_subgraph(new_graph name, + from_graph name, + node_filter text DEFAULT '*', + relationship_filter text DEFAULT '*') + RETURNS TABLE(node_count bigint, relationship_count bigint) + LANGUAGE plpgsql + VOLATILE + SET search_path = ag_catalog, pg_catalog + AS $function$ +DECLARE + from_oid oid; + new_oid oid; + v_node_count bigint := 0; + v_rel_count bigint := 0; + rec RECORD; + cypher_q text; + where_clause text; + dst_label_id int; + dst_seq_fqn text; + dst_relation text; + inserted bigint; + has_rows boolean; +BEGIN + -- Argument validation. + IF new_graph IS NULL THEN + RAISE EXCEPTION 'new graph name must not be NULL'; + END IF; + IF from_graph IS NULL THEN + RAISE EXCEPTION 'source graph name must not be NULL'; + END IF; + IF new_graph = from_graph THEN + RAISE EXCEPTION 'cannot extract a subgraph of "%" into itself', from_graph; + END IF; + + -- NULL predicate is treated as the '*' wildcard (keep all). + IF node_filter IS NULL THEN + node_filter := '*'; + END IF; + IF relationship_filter IS NULL THEN + relationship_filter := '*'; + END IF; + + -- The predicates are embedded into a dollar-quoted cypher() query using the + -- $age_subgraph$ tag; reject predicates that contain the tag to keep the + -- quoting unambiguous. + IF position('$age_subgraph$' IN node_filter) > 0 + OR position('$age_subgraph$' IN relationship_filter) > 0 THEN + RAISE EXCEPTION 'filter predicate must not contain the reserved token $age_subgraph$'; + END IF; + + -- Validate source graph exists. + SELECT graphid INTO from_oid + FROM ag_catalog.ag_graph WHERE name = from_graph; + IF from_oid IS NULL THEN + RAISE EXCEPTION 'graph "%" does not exist', from_graph; + END IF; + + -- Validate destination graph does not exist (create_graph also enforces + -- naming rules and uniqueness, but we give a clear early error). + IF EXISTS (SELECT 1 FROM ag_catalog.ag_graph WHERE name = new_graph) THEN + RAISE EXCEPTION 'graph "%" already exists', new_graph; + END IF; + + -- Create the destination graph (default labels are created automatically). + PERFORM ag_catalog.create_graph(new_graph); + + SELECT graphid INTO new_oid + FROM ag_catalog.ag_graph WHERE name = new_graph; + + -- Working sets / mapping (uniquely named to avoid colliding with user temps). + DROP TABLE IF EXISTS _ag_sg_kept_v; + DROP TABLE IF EXISTS _ag_sg_kept_e; + DROP TABLE IF EXISTS _ag_sg_vmap; + DROP TABLE IF EXISTS _ag_sg_vstage; + DROP TABLE IF EXISTS _ag_sg_estage; + + -- + -- Kept vertices: evaluate node_filter with AGE's Cypher engine. The node + -- variable `n` is bound exactly as in the spec; '*' selects all vertices. + -- + IF node_filter IS NULL OR btrim(node_filter) = '*' THEN + where_clause := ''; + ELSE + where_clause := ' WHERE ' || node_filter; + END IF; + cypher_q := 'MATCH (n)' || where_clause || ' RETURN id(n)'; + + EXECUTE format( + 'CREATE TEMP TABLE _ag_sg_kept_v ON COMMIT DROP AS ' + 'SELECT DISTINCT ag_catalog.agtype_to_graphid(vid) AS gid ' + 'FROM ag_catalog.cypher(%L, $age_subgraph$%s$age_subgraph$) AS (vid agtype)', + from_graph, cypher_q); + CREATE INDEX ON _ag_sg_kept_v (gid); + + -- + -- Kept edges: evaluate relationship_filter with AGE's Cypher engine. The + -- relationship variable `r` is bound exactly as in the spec. + -- + IF relationship_filter IS NULL OR btrim(relationship_filter) = '*' THEN + where_clause := ''; + ELSE + where_clause := ' WHERE ' || relationship_filter; + END IF; + cypher_q := 'MATCH ()-[r]->()' || where_clause || ' RETURN id(r)'; + + EXECUTE format( + 'CREATE TEMP TABLE _ag_sg_kept_e ON COMMIT DROP AS ' + 'SELECT DISTINCT ag_catalog.agtype_to_graphid(eid) AS gid ' + 'FROM ag_catalog.cypher(%L, $age_subgraph$%s$age_subgraph$) AS (eid agtype)', + from_graph, cypher_q); + CREATE INDEX ON _ag_sg_kept_e (gid); + + -- old -> new vertex id mapping (graphid is unique within a graph). + CREATE TEMP TABLE _ag_sg_vmap (old_id graphid PRIMARY KEY, + new_id graphid NOT NULL) ON COMMIT DROP; + + -- + -- PASS 1: copy kept vertices, label by label, assigning new graphids and + -- recording the old->new mapping for edge remapping. + -- + FOR rec IN + SELECT name, id, relation, seq_name + FROM ag_catalog.ag_label + WHERE graph = from_oid AND kind = 'v' + ORDER BY id + LOOP + -- Skip labels with no surviving vertices. Read ONLY this label's own + -- rows: AGE label tables use table inheritance (custom labels inherit + -- from _ag_label_vertex), so a plain scan of a parent would also return + -- its children and copy them twice. + EXECUTE format( + 'SELECT EXISTS (SELECT 1 FROM ONLY %s t ' + 'WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_v k WHERE k.gid = t.id))', + rec.relation::regclass::text) + INTO has_rows; + IF NOT has_rows THEN + CONTINUE; + END IF; + + -- Ensure the label exists in the destination graph. + IF rec.name <> '_ag_label_vertex' THEN + PERFORM 1 FROM ag_catalog.ag_label + WHERE graph = new_oid AND name = rec.name; + IF NOT FOUND THEN + EXECUTE format('SELECT ag_catalog.create_vlabel(%L, %L)', + new_graph, rec.name); + END IF; + END IF; + + SELECT id, seq_name, relation::regclass::text + INTO dst_label_id, dst_seq_fqn, dst_relation + FROM ag_catalog.ag_label + WHERE graph = new_oid AND name = rec.name; + dst_seq_fqn := format('%I.%I', new_graph, dst_seq_fqn); + + -- Stage surviving vertices with freshly generated ids in a real temp + -- table (single evaluation), then copy to the label table and record + -- the old->new mapping. A materialized stage avoids any ambiguity from + -- referencing a nextval-bearing CTE more than once. + DROP TABLE IF EXISTS _ag_sg_vstage; + EXECUTE format( + 'CREATE TEMP TABLE _ag_sg_vstage ON COMMIT DROP AS ' + 'SELECT t.id AS old_id, ' + ' ag_catalog._graphid(%s, nextval(%L::regclass)) AS new_id, ' + ' t.properties AS props ' + 'FROM ONLY %s t ' + 'WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_v k WHERE k.gid = t.id)', + dst_label_id, dst_seq_fqn, rec.relation::regclass::text); + + EXECUTE format('INSERT INTO %s (id, properties) ' + 'SELECT new_id, props FROM _ag_sg_vstage', dst_relation); + + INSERT INTO _ag_sg_vmap (old_id, new_id) + SELECT old_id, new_id FROM _ag_sg_vstage; + + DROP TABLE _ag_sg_vstage; + END LOOP; + + SELECT count(*) INTO v_node_count FROM _ag_sg_vmap; + + -- + -- PASS 2: copy kept edges, remapping endpoints. The joins to _ag_sg_vmap + -- enforce the induced rule (an edge survives only if BOTH endpoints were + -- kept); membership in _ag_sg_kept_e applies relationship_filter. + -- + FOR rec IN + SELECT name, id, relation, seq_name + FROM ag_catalog.ag_label + WHERE graph = from_oid AND kind = 'e' + ORDER BY id + LOOP + -- Skip labels with no surviving edges. Read ONLY this label's own rows + -- (see the vertex pass for why inheritance requires ONLY). + EXECUTE format( + 'SELECT EXISTS (' + ' SELECT 1 FROM ONLY %s x ' + ' JOIN _ag_sg_vmap vs ON vs.old_id = x.start_id ' + ' JOIN _ag_sg_vmap ve ON ve.old_id = x.end_id ' + ' WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_e k WHERE k.gid = x.id))', + rec.relation::regclass::text) + INTO has_rows; + IF NOT has_rows THEN + CONTINUE; + END IF; + + IF rec.name <> '_ag_label_edge' THEN + PERFORM 1 FROM ag_catalog.ag_label + WHERE graph = new_oid AND name = rec.name; + IF NOT FOUND THEN + EXECUTE format('SELECT ag_catalog.create_elabel(%L, %L)', + new_graph, rec.name); + END IF; + END IF; + + SELECT id, seq_name, relation::regclass::text + INTO dst_label_id, dst_seq_fqn, dst_relation + FROM ag_catalog.ag_label + WHERE graph = new_oid AND name = rec.name; + dst_seq_fqn := format('%I.%I', new_graph, dst_seq_fqn); + + -- Stage surviving edges, remapping endpoints through _ag_sg_vmap. The + -- joins enforce the induced rule (both endpoints kept); membership in + -- _ag_sg_kept_e applies relationship_filter. + DROP TABLE IF EXISTS _ag_sg_estage; + EXECUTE format( + 'CREATE TEMP TABLE _ag_sg_estage ON COMMIT DROP AS ' + 'SELECT ag_catalog._graphid(%s, nextval(%L::regclass)) AS new_id, ' + ' vs.new_id AS new_start, ve.new_id AS new_end, ' + ' x.properties AS props ' + 'FROM ONLY %s x ' + 'JOIN _ag_sg_vmap vs ON vs.old_id = x.start_id ' + 'JOIN _ag_sg_vmap ve ON ve.old_id = x.end_id ' + 'WHERE EXISTS (SELECT 1 FROM _ag_sg_kept_e k WHERE k.gid = x.id)', + dst_label_id, dst_seq_fqn, rec.relation::regclass::text); + + EXECUTE format('INSERT INTO %s (id, start_id, end_id, properties) ' + 'SELECT new_id, new_start, new_end, props ' + 'FROM _ag_sg_estage', dst_relation); + GET DIAGNOSTICS inserted = ROW_COUNT; + v_rel_count := v_rel_count + inserted; + + DROP TABLE _ag_sg_estage; + END LOOP; + + RETURN QUERY SELECT v_node_count, v_rel_count; +END; +$function$; + +COMMENT ON FUNCTION ag_catalog.create_subgraph(name, name, text, text) IS +'Materializes a new persistent graph as the induced subgraph of from_graph selected by a Cypher node predicate (on n) and relationship predicate (on r); ''*'' keeps all. An edge is kept only if its predicate holds and both endpoints are kept. Returns (node_count, relationship_count).'; diff --git a/sql/agtype_coercions.sql b/sql/agtype_coercions.sql index 933375fc1..7763360f2 100644 --- a/sql/agtype_coercions.sql +++ b/sql/agtype_coercions.sql @@ -174,6 +174,33 @@ AS 'MODULE_PATHNAME'; CREATE CAST (agtype AS json) WITH FUNCTION ag_catalog.agtype_to_json(agtype); +-- agtype -> jsonb (explicit) +-- Uses json intermediate (agtype_to_json -> json::jsonb) because agtype +-- extends jsonb's binary format with types (AGTV_INTEGER, AGTV_FLOAT, +-- AGTV_VERTEX, AGTV_EDGE, AGTV_PATH) that jsonb does not recognize. +CREATE FUNCTION ag_catalog.agtype_to_jsonb(agtype) + RETURNS jsonb + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'SELECT ag_catalog.agtype_to_json($1)::jsonb'; + +CREATE CAST (agtype AS jsonb) + WITH FUNCTION ag_catalog.agtype_to_jsonb(agtype); + +-- jsonb -> agtype (explicit) +CREATE FUNCTION ag_catalog.jsonb_to_agtype(jsonb) + RETURNS agtype + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'SELECT $1::text::agtype'; + +CREATE CAST (jsonb AS agtype) + WITH FUNCTION ag_catalog.jsonb_to_agtype(jsonb); + CREATE FUNCTION ag_catalog.agtype_array_to_agtype(agtype[]) RETURNS agtype LANGUAGE c diff --git a/sql/agtype_exists.sql b/sql/agtype_exists.sql index 441af1755..f4ec4660b 100644 --- a/sql/agtype_exists.sql +++ b/sql/agtype_exists.sql @@ -32,8 +32,8 @@ CREATE OPERATOR ? ( LEFTARG = agtype, RIGHTARG = text, FUNCTION = ag_catalog.agtype_exists, - RESTRICT = matchingsel, - JOIN = matchingjoinsel + RESTRICT = contsel, + JOIN = contjoinsel ); CREATE FUNCTION ag_catalog.agtype_exists_agtype(agtype, agtype) @@ -48,8 +48,8 @@ CREATE OPERATOR ? ( LEFTARG = agtype, RIGHTARG = agtype, FUNCTION = ag_catalog.agtype_exists_agtype, - RESTRICT = matchingsel, - JOIN = matchingjoinsel + RESTRICT = contsel, + JOIN = contjoinsel ); CREATE FUNCTION ag_catalog.agtype_exists_any(agtype, text[]) @@ -64,8 +64,8 @@ CREATE OPERATOR ?| ( LEFTARG = agtype, RIGHTARG = text[], FUNCTION = ag_catalog.agtype_exists_any, - RESTRICT = matchingsel, - JOIN = matchingjoinsel + RESTRICT = contsel, + JOIN = contjoinsel ); CREATE FUNCTION ag_catalog.agtype_exists_any_agtype(agtype, agtype) @@ -80,8 +80,8 @@ CREATE OPERATOR ?| ( LEFTARG = agtype, RIGHTARG = agtype, FUNCTION = ag_catalog.agtype_exists_any_agtype, - RESTRICT = matchingsel, - JOIN = matchingjoinsel + RESTRICT = contsel, + JOIN = contjoinsel ); CREATE FUNCTION ag_catalog.agtype_exists_all(agtype, text[]) @@ -96,8 +96,8 @@ CREATE OPERATOR ?& ( LEFTARG = agtype, RIGHTARG = text[], FUNCTION = ag_catalog.agtype_exists_all, - RESTRICT = matchingsel, - JOIN = matchingjoinsel + RESTRICT = contsel, + JOIN = contjoinsel ); CREATE FUNCTION ag_catalog.agtype_exists_all_agtype(agtype, agtype) @@ -112,6 +112,6 @@ CREATE OPERATOR ?& ( LEFTARG = agtype, RIGHTARG = agtype, FUNCTION = ag_catalog.agtype_exists_all_agtype, - RESTRICT = matchingsel, - JOIN = matchingjoinsel + RESTRICT = contsel, + JOIN = contjoinsel ); diff --git a/sql/agtype_graphid.sql b/sql/agtype_graphid.sql index 0887db8a9..2970e6d54 100644 --- a/sql/agtype_graphid.sql +++ b/sql/agtype_graphid.sql @@ -43,6 +43,33 @@ CREATE CAST (agtype AS graphid) WITH FUNCTION ag_catalog.agtype_to_graphid(agtype) AS IMPLICIT; +-- +-- Composite types for vertex and edge +-- +CREATE TYPE ag_catalog.vertex AS ( + id graphid, + label agtype, + properties agtype +); + +CREATE TYPE ag_catalog.edge AS ( + id graphid, + label agtype, + end_id graphid, + start_id graphid, + properties agtype +); + +-- +-- Label name function +-- +CREATE FUNCTION ag_catalog._label_name(graph_oid oid, graphid) + RETURNS agtype + LANGUAGE c + IMMUTABLE +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + -- -- agtype - path -- @@ -57,7 +84,7 @@ AS 'MODULE_PATHNAME'; -- -- agtype - vertex -- -CREATE FUNCTION ag_catalog._agtype_build_vertex(graphid, cstring, agtype) +CREATE FUNCTION ag_catalog._agtype_build_vertex(graphid, agtype, agtype) RETURNS agtype LANGUAGE c IMMUTABLE @@ -69,7 +96,7 @@ AS 'MODULE_PATHNAME'; -- agtype - edge -- CREATE FUNCTION ag_catalog._agtype_build_edge(graphid, graphid, graphid, - cstring, agtype) + agtype, agtype) RETURNS agtype LANGUAGE c IMMUTABLE @@ -77,6 +104,155 @@ CALLED ON NULL INPUT PARALLEL SAFE AS 'MODULE_PATHNAME'; +-- +-- vertex/edge to agtype cast functions +-- +CREATE FUNCTION ag_catalog.vertex_to_agtype(vertex) + RETURNS agtype + LANGUAGE c + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +CREATE FUNCTION ag_catalog.edge_to_agtype(edge) + RETURNS agtype + LANGUAGE c + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +-- +-- Implicit casts from vertex/edge to agtype +-- +CREATE CAST (vertex AS agtype) + WITH FUNCTION ag_catalog.vertex_to_agtype(vertex) +AS IMPLICIT; + +CREATE CAST (edge AS agtype) + WITH FUNCTION ag_catalog.edge_to_agtype(edge) +AS IMPLICIT; + +CREATE FUNCTION ag_catalog.vertex_to_json(vertex) + RETURNS json + LANGUAGE c + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +CREATE FUNCTION ag_catalog.edge_to_json(edge) + RETURNS json + LANGUAGE c + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +CREATE CAST (vertex AS json) + WITH FUNCTION ag_catalog.vertex_to_json(vertex); + +CREATE CAST (edge AS json) + WITH FUNCTION ag_catalog.edge_to_json(edge); + +CREATE FUNCTION ag_catalog.vertex_to_jsonb(vertex) + RETURNS jsonb + LANGUAGE c + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +CREATE FUNCTION ag_catalog.edge_to_jsonb(edge) + RETURNS jsonb + LANGUAGE c + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS 'MODULE_PATHNAME'; + +CREATE CAST (vertex AS jsonb) + WITH FUNCTION ag_catalog.vertex_to_jsonb(vertex); + +CREATE CAST (edge AS jsonb) + WITH FUNCTION ag_catalog.edge_to_jsonb(edge); + +-- +-- Equality operators for vertex and edge (compare by id) +-- +CREATE FUNCTION ag_catalog.vertex_eq(vertex, vertex) + RETURNS boolean + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS $$ SELECT $1.id = $2.id $$; + +CREATE OPERATOR = ( + FUNCTION = ag_catalog.vertex_eq, + LEFTARG = vertex, + RIGHTARG = vertex, + COMMUTATOR = =, + NEGATOR = <>, + RESTRICT = eqsel, + JOIN = eqjoinsel +); + +CREATE FUNCTION ag_catalog.vertex_ne(vertex, vertex) + RETURNS boolean + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS $$ SELECT $1.id <> $2.id $$; + +CREATE OPERATOR <> ( + FUNCTION = ag_catalog.vertex_ne, + LEFTARG = vertex, + RIGHTARG = vertex, + COMMUTATOR = <>, + NEGATOR = =, + RESTRICT = neqsel, + JOIN = neqjoinsel +); + +CREATE FUNCTION ag_catalog.edge_eq(edge, edge) + RETURNS boolean + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS $$ SELECT $1.id = $2.id $$; + +CREATE OPERATOR = ( + FUNCTION = ag_catalog.edge_eq, + LEFTARG = edge, + RIGHTARG = edge, + COMMUTATOR = =, + NEGATOR = <>, + RESTRICT = eqsel, + JOIN = eqjoinsel +); + +CREATE FUNCTION ag_catalog.edge_ne(edge, edge) + RETURNS boolean + LANGUAGE sql + IMMUTABLE +RETURNS NULL ON NULL INPUT +PARALLEL SAFE +AS $$ SELECT $1.id <> $2.id $$; + +CREATE OPERATOR <> ( + FUNCTION = ag_catalog.edge_ne, + LEFTARG = edge, + RIGHTARG = edge, + COMMUTATOR = <>, + NEGATOR = =, + RESTRICT = neqsel, + JOIN = neqjoinsel +); + CREATE FUNCTION ag_catalog._ag_enforce_edge_uniqueness2(graphid, graphid) RETURNS bool LANGUAGE c diff --git a/sql/agtype_operators.sql b/sql/agtype_operators.sql index 36fedfe80..3fbc52f33 100644 --- a/sql/agtype_operators.sql +++ b/sql/agtype_operators.sql @@ -33,8 +33,8 @@ CREATE OPERATOR @> ( RIGHTARG = agtype, FUNCTION = ag_catalog.agtype_contains, COMMUTATOR = '<@', - RESTRICT = matchingsel, - JOIN = matchingjoinsel + RESTRICT = contsel, + JOIN = contjoinsel ); CREATE FUNCTION ag_catalog.agtype_contained_by(agtype, agtype) @@ -50,8 +50,8 @@ CREATE OPERATOR <@ ( RIGHTARG = agtype, FUNCTION = ag_catalog.agtype_contained_by, COMMUTATOR = '@>', - RESTRICT = matchingsel, - JOIN = matchingjoinsel + RESTRICT = contsel, + JOIN = contjoinsel ); CREATE FUNCTION ag_catalog.agtype_contains_top_level(agtype, agtype) @@ -67,8 +67,8 @@ CREATE OPERATOR @>> ( RIGHTARG = agtype, FUNCTION = ag_catalog.agtype_contains_top_level, COMMUTATOR = '<<@', - RESTRICT = matchingsel, - JOIN = matchingjoinsel + RESTRICT = contsel, + JOIN = contjoinsel ); CREATE FUNCTION ag_catalog.agtype_contained_by_top_level(agtype, agtype) @@ -84,6 +84,6 @@ CREATE OPERATOR <<@ ( RIGHTARG = agtype, FUNCTION = ag_catalog.agtype_contained_by_top_level, COMMUTATOR = '@>>', - RESTRICT = matchingsel, - JOIN = matchingjoinsel + RESTRICT = contsel, + JOIN = contjoinsel ); \ No newline at end of file diff --git a/sql/agtype_typecast.sql b/sql/agtype_typecast.sql index c29c0a657..f12f215f6 100644 --- a/sql/agtype_typecast.sql +++ b/sql/agtype_typecast.sql @@ -70,10 +70,14 @@ PARALLEL SAFE AS 'MODULE_PATHNAME'; -- original VLE function definition +-- S4: emit start_id/end_id as scalar columns to enable transformer rewrite +-- of terminal-edge quals as integer equalities (see PERF_VLE_TERMINAL_QUAL_PLAN). CREATE FUNCTION ag_catalog.age_vle(IN agtype, IN agtype, IN agtype, IN agtype, IN agtype, IN agtype, IN agtype, - OUT edges agtype) - RETURNS SETOF agtype + OUT edges agtype, + OUT start_id graphid, + OUT end_id graphid) + RETURNS SETOF record LANGUAGE C STABLE CALLED ON NULL INPUT @@ -84,14 +88,47 @@ AS 'MODULE_PATHNAME'; -- caching mechanism to coexist with the previous VLE version. CREATE FUNCTION ag_catalog.age_vle(IN agtype, IN agtype, IN agtype, IN agtype, IN agtype, IN agtype, IN agtype, IN agtype, - OUT edges agtype) - RETURNS SETOF agtype + OUT edges agtype, + OUT start_id graphid, + OUT end_id graphid) + RETURNS SETOF record LANGUAGE C STABLE CALLED ON NULL INPUT PARALLEL UNSAFE -- might be safe AS 'MODULE_PATHNAME'; +-- Unweighted (hop-count) shortest path between two vertices, computed over the +-- cached global graph adjacency via BFS. Returns a single path (0 or 1 rows). +-- Argument order mirrors the Cypher shortestPath() pattern +-- (a)-[:type*min_hops..max_hops]->(b): +-- (graph_name, start, end, edge_types, direction, min_hops, max_hops) +CREATE FUNCTION ag_catalog.age_shortest_path(IN agtype, IN agtype, IN agtype, + IN agtype DEFAULT NULL, + IN agtype DEFAULT NULL, + IN agtype DEFAULT NULL, + IN agtype DEFAULT NULL) + RETURNS SETOF agtype +LANGUAGE C +STABLE +CALLED ON NULL INPUT +PARALLEL UNSAFE +AS 'MODULE_PATHNAME'; + +-- All unweighted shortest paths between two vertices (one path per row). +-- Same argument order as age_shortest_path. +CREATE FUNCTION ag_catalog.age_all_shortest_paths(IN agtype, IN agtype, IN agtype, + IN agtype DEFAULT NULL, + IN agtype DEFAULT NULL, + IN agtype DEFAULT NULL, + IN agtype DEFAULT NULL) + RETURNS SETOF agtype +LANGUAGE C +STABLE +CALLED ON NULL INPUT +PARALLEL UNSAFE +AS 'MODULE_PATHNAME'; + -- function to build an edge for a VLE match CREATE FUNCTION ag_catalog.age_build_vle_match_edge(agtype, agtype) RETURNS agtype @@ -100,15 +137,6 @@ CREATE FUNCTION ag_catalog.age_build_vle_match_edge(agtype, agtype) PARALLEL SAFE AS 'MODULE_PATHNAME'; --- function to match a terminal vle edge -CREATE FUNCTION ag_catalog.age_match_vle_terminal_edge(variadic "any") - RETURNS boolean - LANGUAGE C - STABLE -CALLED ON NULL INPUT -PARALLEL SAFE -AS 'MODULE_PATHNAME'; - -- function to create an AGTV_PATH from a VLE_path_container CREATE FUNCTION ag_catalog.age_materialize_vle_path(agtype) RETURNS agtype @@ -135,14 +163,6 @@ RETURNS NULL ON NULL INPUT PARALLEL SAFE AS 'MODULE_PATHNAME'; -CREATE FUNCTION ag_catalog.age_match_two_vle_edges(agtype, agtype) - RETURNS boolean - LANGUAGE C - STABLE -RETURNS NULL ON NULL INPUT -PARALLEL SAFE -AS 'MODULE_PATHNAME'; - -- list functions CREATE FUNCTION ag_catalog.age_keys(agtype) RETURNS agtype diff --git a/sql/sql_files b/sql/sql_files index b10f1bcc6..996ad4b46 100644 --- a/sql/sql_files +++ b/sql/sql_files @@ -14,3 +14,5 @@ age_string age_trig age_aggregate agtype_typecast +age_pg_upgrade +age_subgraph diff --git a/src/backend/age.c b/src/backend/age.c index 2c016b021..24ae8456b 100644 --- a/src/backend/age.c +++ b/src/backend/age.c @@ -17,11 +17,40 @@ * under the License. */ +#include "postgres.h" +#include "miscadmin.h" + #include "catalog/ag_catalog.h" #include "nodes/ag_nodes.h" #include "optimizer/cypher_paths.h" #include "parser/cypher_analyze.h" #include "utils/ag_guc.h" +#include "utils/age_global_graph.h" + +#if PG_VERSION_NUM < 170000 + +/* saved hook pointers for PG < 17 shmem path */ +static shmem_request_hook_type prev_shmem_request_hook = NULL; +static shmem_startup_hook_type prev_shmem_startup_hook = NULL; + +static void age_shmem_request_hook(void) +{ + if (prev_shmem_request_hook) + { + prev_shmem_request_hook(); + } + age_graph_version_shmem_request(); +} + +static void age_shmem_startup_hook(void) +{ + if (prev_shmem_startup_hook) + { + prev_shmem_startup_hook(); + } + age_graph_version_shmem_startup(); +} +#endif /* PG_VERSION_NUM < 170000 */ PG_MODULE_MAGIC; @@ -29,12 +58,26 @@ void _PG_init(void); void _PG_init(void) { + if (IsBinaryUpgrade) + { + return; + } + register_ag_nodes(); set_rel_pathlist_init(); object_access_hook_init(); process_utility_hook_init(); post_parse_analyze_init(); define_config_params(); + +#if PG_VERSION_NUM < 170000 + /* Register shared memory hooks for graph version tracking. + * On PG 17+, DSM is used instead (no hooks needed). */ + prev_shmem_request_hook = shmem_request_hook; + shmem_request_hook = age_shmem_request_hook; + prev_shmem_startup_hook = shmem_startup_hook; + shmem_startup_hook = age_shmem_startup_hook; +#endif } void _PG_fini(void); diff --git a/src/backend/catalog/ag_catalog.c b/src/backend/catalog/ag_catalog.c index f4887f445..107de370d 100644 --- a/src/backend/catalog/ag_catalog.c +++ b/src/backend/catalog/ag_catalog.c @@ -19,19 +19,27 @@ #include "postgres.h" +#include "access/xact.h" #include "catalog/dependency.h" #include "catalog/namespace.h" #include "catalog/objectaccess.h" #include "catalog/pg_class_d.h" +#include "catalog/pg_extension_d.h" #include "catalog/pg_namespace_d.h" #include "commands/defrem.h" +#include "commands/extension.h" +#include "nodes/parsenodes.h" #include "tcop/utility.h" +#include "utils/inval.h" #include "utils/lsyscache.h" #include "catalog/ag_graph.h" #include "catalog/ag_label.h" #include "utils/ag_cache.h" +#include "utils/age_global_graph.h" +static bool extension_cache_is_valid = false; +static bool age_extension_exists = false; static object_access_hook_type prev_object_access_hook; static ProcessUtility_hook_type prev_process_utility_hook; static bool prev_object_hook_is_set; @@ -43,8 +51,46 @@ void ag_ProcessUtility_hook(PlannedStmt *pstmt, const char *queryString, bool re QueryEnvironment *queryEnv, DestReceiver *dest, QueryCompletion *qc); -static bool is_age_drop(PlannedStmt *pstmt); -static void drop_age_extension(DropStmt *stmt); +static bool is_age_drop(DropStmt *drop_stmt); + +static void +invalidate_extension_cache_callback(Datum argument, Oid relationId) +{ + if (!OidIsValid(relationId) || relationId == ExtensionRelationId) + { + extension_cache_is_valid = false; + } +} + +/* + * We don't want most of hooks to do anything if the "age" extension isn't + * created. However, scanning pg_extension is a costly operation, therefore we + * implement a caching mechanism and reset it with the help of the relcache + * callback mechanism. + * + * Please also see ag_ProcessUtility_hook() function for more details. + */ +bool +is_age_extension_exists(void) +{ + static bool callback_registered = false; + + if (extension_cache_is_valid) + return age_extension_exists; + + if (!callback_registered) + { + CacheRegisterRelcacheCallback(invalidate_extension_cache_callback, + (Datum) 0); + callback_registered = true; + } + + age_extension_exists = OidIsValid(get_extension_oid("age", true)); + + extension_cache_is_valid = true; + + return age_extension_exists; +} void object_access_hook_init(void) { @@ -84,61 +130,149 @@ void process_utility_hook_fini(void) * information in the indexes and tables being dropped. To prevent an error * from being thrown, we need to disable the object_access_hook before dropping * the extension. + * + * Besides that, we want to notify other backends about the fact that "age" + * extension was probably created/dropped so that they can enable/disable + * hooks. */ void ag_ProcessUtility_hook(PlannedStmt *pstmt, const char *queryString, bool readOnlyTree, ProcessUtilityContext context, ParamListInfo params, QueryEnvironment *queryEnv, DestReceiver *dest, QueryCompletion *qc) { - if (is_age_drop(pstmt)) + bool creating_age = false; + bool dropping_age = false; + + if (!IsAbortedTransactionBlockState()) { - drop_age_extension((DropStmt *)pstmt->utilityStmt); + Node *parsetree = pstmt->utilityStmt; + + switch (nodeTag(parsetree)) + { + case T_CreateExtensionStmt: + { + CreateExtensionStmt *stmt = + (CreateExtensionStmt *) parsetree; + creating_age = strcmp(stmt->extname, "age") == 0; + } + break; + case T_DropStmt: + { + DropStmt *stmt = (DropStmt *) parsetree; + + if (stmt->removeType != OBJECT_EXTENSION) + break; + + if (!is_age_drop(stmt)) + break; + + dropping_age = true; + } + break; + case T_TruncateStmt: + { + /* + * Check for TRUNCATE on graph label tables. If any + * truncated table is a graph label table, increment the + * version counter for that graph to invalidate VLE caches. + * We do this before the truncate executes so the cache is + * invalidated regardless. + */ + TruncateStmt *tstmt = (TruncateStmt *) parsetree; + ListCell *lc; + + foreach(lc, tstmt->relations) + { + RangeVar *rv = (RangeVar *) lfirst(lc); + Oid rel_oid = RangeVarGetRelid(rv, AccessShareLock, + true); + + if (OidIsValid(rel_oid)) + { + Oid graph_oid = + get_graph_oid_for_table(rel_oid); + + if (OidIsValid(graph_oid)) + { + increment_graph_version(graph_oid); + } + } + } + } + break; + default: + break; + } } - else if (prev_process_utility_hook) + + if (dropping_age) { - (*prev_process_utility_hook) (pstmt, queryString, readOnlyTree, context, - params, queryEnv, dest, qc); + /* Remove all graphs */ + drop_graphs(get_graphnames()); + + /* Remove the object access hook */ + object_access_hook_fini(); } - else + + PG_TRY(); { - Assert(IsA(pstmt, PlannedStmt)); - Assert(pstmt->commandType == CMD_UTILITY); - Assert(queryString != NULL); /* required as of 8.4 */ - Assert(qc == NULL || qc->commandTag == CMDTAG_UNKNOWN); - standard_ProcessUtility(pstmt, queryString, readOnlyTree, context, - params, queryEnv, dest, qc); + if (prev_process_utility_hook) + { + (*prev_process_utility_hook) (pstmt, queryString, readOnlyTree, + context, params, queryEnv, dest, + qc); + } + else + { + Assert(IsA(pstmt, PlannedStmt)); + Assert(pstmt->commandType == CMD_UTILITY); + Assert(queryString != NULL); + Assert(qc == NULL || qc->commandTag == CMDTAG_UNKNOWN); + standard_ProcessUtility(pstmt, queryString, readOnlyTree, context, + params, queryEnv, dest, qc); + } } -} - -static void drop_age_extension(DropStmt *stmt) -{ - /* Remove all graphs */ - drop_graphs(get_graphnames()); + PG_CATCH(); + { + if (dropping_age) + { + /* + * We have to restore the disabled object_access_hook if + * DROP EXTENSION age failed. + */ + object_access_hook_init(); + } + PG_RE_THROW(); + } + PG_END_TRY(); - /* Remove the object access hook */ - object_access_hook_fini(); + if (dropping_age) + { + /* reset global variables for OIDs */ + clear_global_Oids_AGTYPE(); + clear_global_Oids_GRAPHID(); + clear_global_Oids_VERTEX_EDGE(); - /* - * Run Postgres' logic to perform the remaining work to drop the - * extension. - */ - RemoveObjects(stmt); + /* Restore the object access hook */ + object_access_hook_init(); + } - /* reset global variables for OIDs */ - clear_global_Oids_AGTYPE(); - clear_global_Oids_GRAPHID(); + if (creating_age || dropping_age) + { + /* Notify all backends that pg_extension was modified. */ + CacheInvalidateRelcacheByRelid(ExtensionRelationId); + } } /* Check to see if the Utility Command is to drop the AGE Extension. */ -static bool is_age_drop(PlannedStmt *pstmt) +static bool is_age_drop(DropStmt *drop_stmt) { ListCell *lc; - DropStmt *drop_stmt; - if (!IsA(pstmt->utilityStmt, DropStmt)) + if (!is_age_extension_exists()) + { return false; - - drop_stmt = (DropStmt *)pstmt->utilityStmt; + } foreach(lc, drop_stmt->objects) { @@ -149,8 +283,10 @@ static bool is_age_drop(PlannedStmt *pstmt) String *val = (String *)obj; char *str = val->sval; - if (!pg_strcasecmp(str, "age")) + if (strcmp(str, "age") == 0) + { return true; + } } } @@ -171,16 +307,16 @@ static void object_access(ObjectAccessType access, Oid class_id, Oid object_id, if (prev_object_access_hook) prev_object_access_hook(access, class_id, object_id, sub_id, arg); - /* We are interested in DROP SCHEMA and DROP TABLE commands. */ - if (access != OAT_DROP) + if (!is_age_extension_exists()) + { return; + } - /* - * Age might be installed into shared_preload_libraries before extension is - * created. In this case we must bail out from this hook. - */ - if (!OidIsValid(get_namespace_oid("ag_catalog", true))) + /* We are interested in DROP SCHEMA and DROP TABLE commands. */ + if (access != OAT_DROP) + { return; + } drop_arg = arg; diff --git a/src/backend/catalog/ag_label.c b/src/backend/catalog/ag_label.c index 54c31ef36..78579fe8d 100644 --- a/src/backend/catalog/ag_label.c +++ b/src/backend/catalog/ag_label.c @@ -21,6 +21,7 @@ #include "access/genam.h" #include "catalog/indexing.h" +#include "catalog/namespace.h" #include "executor/executor.h" #include "nodes/makefuncs.h" #include "utils/builtins.h" @@ -167,6 +168,17 @@ char *get_label_seq_relation_name(const char *label_name) return psprintf("%s_id_seq", label_name); } +char *get_label_name(int32 label_id, Oid graph_oid) +{ + label_cache_data *cache_data; + + cache_data = search_label_graph_oid_cache(graph_oid, label_id); + if (cache_data) + return NameStr(cache_data->name); + else + return NULL; +} + PG_FUNCTION_INFO_V1(_label_name); /* @@ -176,9 +188,9 @@ PG_FUNCTION_INFO_V1(_label_name); Datum _label_name(PG_FUNCTION_ARGS) { char *label_name; - label_cache_data *label_cache; Oid graph; uint32 label_id; + agtype *result; if (PG_ARGISNULL(0) || PG_ARGISNULL(1)) { @@ -187,7 +199,7 @@ Datum _label_name(PG_FUNCTION_ARGS) } graph = PG_GETARG_OID(0); - + /* Check if the graph OID is valid */ if (!graph_namespace_exists(graph)) { @@ -195,11 +207,8 @@ Datum _label_name(PG_FUNCTION_ARGS) errmsg("graph with oid %u does not exist", graph))); } - label_id = (int32)(((uint64)AG_GETARG_GRAPHID(1)) >> ENTRY_ID_BITS); - - label_cache = search_label_graph_oid_cache(graph, label_id); - - label_name = NameStr(label_cache->name); + label_id = get_graphid_label_id(AG_GETARG_GRAPHID(1)); + label_name = get_label_name(label_id, graph); /* If label_name is not found, error out */ if (label_name == NULL) @@ -209,10 +218,17 @@ Datum _label_name(PG_FUNCTION_ARGS) label_id, graph))); } + /* Convert cstring to agtype string */ if (IS_AG_DEFAULT_LABEL(label_name)) - PG_RETURN_CSTRING(""); + { + result = DATUM_GET_AGTYPE_P(string_to_agtype("")); + } + else + { + result = DATUM_GET_AGTYPE_P(string_to_agtype(label_name)); + } - PG_RETURN_CSTRING(label_name); + PG_RETURN_POINTER(result); } PG_FUNCTION_INFO_V1(_label_id); @@ -297,49 +313,115 @@ List *get_all_edge_labels_per_graph(EState *estate, Oid graph_oid) HeapTuple tuple; TupleTableSlot *slot; ResultRelInfo *resultRelInfo; - - /* setup scan keys to get all edges for the given graph oid */ - ScanKeyInit(&scan_keys[1], Anum_ag_label_graph, BTEqualStrategyNumber, - F_OIDEQ, ObjectIdGetDatum(graph_oid)); - ScanKeyInit(&scan_keys[0], Anum_ag_label_kind, BTEqualStrategyNumber, - F_CHAREQ, CharGetDatum(LABEL_TYPE_EDGE)); + Oid index_oid; /* setup the table to be scanned */ - ag_label = table_open(ag_label_relation_id(), RowExclusiveLock); - scan_desc = table_beginscan(ag_label, estate->es_snapshot, 2, scan_keys); + ag_label = table_open(ag_label_relation_id(), AccessShareLock); + + index_oid = find_usable_btree_index_for_attr(ag_label, Anum_ag_label_graph); resultRelInfo = create_entity_result_rel_info(estate, "ag_catalog", "ag_label"); - slot = ExecInitExtraTupleSlot( - estate, RelationGetDescr(resultRelInfo->ri_RelationDesc), - &TTSOpsHeapTuple); - - /* scan through the results and get all the label names. */ - while(true) + if (OidIsValid(index_oid)) { - Name label; - bool isNull; - Datum datum; + Relation index_rel; + IndexScanDesc index_scan_desc; + + slot = ExecInitExtraTupleSlot( + estate, RelationGetDescr(resultRelInfo->ri_RelationDesc), + &TTSOpsBufferHeapTuple); + + index_rel = index_open(index_oid, AccessShareLock); + + /* + * Use 1 as the attribute number because 'graph' is the 1st column + * in the ag_label_graph_oid_index + */ + ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, + F_OIDEQ, ObjectIdGetDatum(graph_oid)); + + index_scan_desc = index_beginscan(ag_label, index_rel, estate->es_snapshot, NULL, 1, 0); + index_rescan(index_scan_desc, scan_keys, 1, NULL, 0); + + while (index_getnext_slot(index_scan_desc, ForwardScanDirection, slot)) + { + Name label; + Name lval; + bool isNull; + Datum datum; + char kind; + + /*There isn't field kind in index. So we should check it by hands*/ + datum = slot_getattr(slot, Anum_ag_label_kind, &isNull); + if (isNull) + { + continue; + } + + kind = DatumGetChar(datum); + + if (kind != LABEL_TYPE_EDGE) + { + continue; + } + + datum = slot_getattr(slot, Anum_ag_label_name, &isNull); + if (!isNull) + { + label = DatumGetName(datum); + lval = (Name) palloc(NAMEDATALEN); + namestrcpy(lval, NameStr(*label)); + labels = lappend(labels, lval); + } + } + + index_endscan(index_scan_desc); + index_close(index_rel, AccessShareLock); + } + else + { + slot = ExecInitExtraTupleSlot( + estate, RelationGetDescr(resultRelInfo->ri_RelationDesc), + &TTSOpsHeapTuple); - tuple = heap_getnext(scan_desc, ForwardScanDirection); + /* setup scan keys to get all edges for the given graph oid */ + ScanKeyInit(&scan_keys[1], Anum_ag_label_graph, BTEqualStrategyNumber, + F_OIDEQ, ObjectIdGetDatum(graph_oid)); + ScanKeyInit(&scan_keys[0], Anum_ag_label_kind, BTEqualStrategyNumber, + F_CHAREQ, CharGetDatum(LABEL_TYPE_EDGE)); - /* no more labels to process */ - if (!HeapTupleIsValid(tuple)) - break; + scan_desc = table_beginscan(ag_label, estate->es_snapshot, 2, scan_keys); - ExecStoreHeapTuple(tuple, slot, false); + /* scan through the results and get all the label names. */ + while(true) + { + Name label; + Name lval; + bool isNull; + Datum datum; - datum = slot_getattr(slot, Anum_ag_label_name, &isNull); - label = DatumGetName(datum); + tuple = heap_getnext(scan_desc, ForwardScanDirection); - labels = lappend(labels, label); - } + /* no more labels to process */ + if (!HeapTupleIsValid(tuple)) + break; + + ExecStoreHeapTuple(tuple, slot, false); - table_endscan(scan_desc); + datum = slot_getattr(slot, Anum_ag_label_name, &isNull); + label = DatumGetName(datum); + + lval = (Name) palloc(NAMEDATALEN); + namestrcpy(lval, NameStr(*label)); + labels = lappend(labels, lval); + } + + table_endscan(scan_desc); + } destroy_entity_result_rel_info(resultRelInfo); - table_close(resultRelInfo->ri_RelationDesc, RowExclusiveLock); + table_close(resultRelInfo->ri_RelationDesc, AccessShareLock); return labels; } diff --git a/src/backend/commands/label_commands.c b/src/backend/commands/label_commands.c index c9fdfad89..ac789ecce 100644 --- a/src/backend/commands/label_commands.c +++ b/src/backend/commands/label_commands.c @@ -25,9 +25,11 @@ #include "commands/defrem.h" #include "commands/sequence.h" #include "commands/tablecmds.h" +#include "catalog/pg_trigger.h" #include "miscadmin.h" #include "nodes/makefuncs.h" #include "parser/parser.h" +#include "parser/parse_func.h" #include "tcop/utility.h" #include "utils/acl.h" #include "utils/builtins.h" @@ -432,6 +434,64 @@ static void create_table_for_label(char *graph_name, char *label_name, create_index_on_column(schema_name, rel_name, "start_id", false); create_index_on_column(schema_name, rel_name, "end_id", false); } + + /* + * Install a cache invalidation trigger on the new label table, if the + * trigger function exists. The function is registered in the extension + * SQL (age_main.sql). It may not exist if running against an older + * version of the extension SQL that hasn't been upgraded yet. + * + * When installed, the trigger fires AFTER INSERT/UPDATE/DELETE/TRUNCATE + * (FOR EACH STATEMENT) and increments the graph's version counter so + * VLE caches are properly invalidated when the table is modified via SQL. + */ + { + Oid func_oid; + + /* check if the trigger function is registered in the catalog */ + func_oid = LookupFuncName( + list_make2(makeString("ag_catalog"), + makeString("age_invalidate_graph_cache")), + 0, NULL, true); + + if (OidIsValid(func_oid)) + { + CreateTrigStmt *trigger_stmt = makeNode(CreateTrigStmt); + PlannedStmt *trigger_wrapper; + + trigger_stmt->replace = false; + trigger_stmt->isconstraint = false; + trigger_stmt->trigname = "_age_cache_invalidate"; + trigger_stmt->relation = makeRangeVar(schema_name, rel_name, -1); + trigger_stmt->funcname = list_make2(makeString("ag_catalog"), + makeString("age_invalidate_graph_cache")); + trigger_stmt->args = NIL; + trigger_stmt->row = false; + trigger_stmt->timing = TRIGGER_TYPE_AFTER; + trigger_stmt->events = TRIGGER_TYPE_INSERT | TRIGGER_TYPE_UPDATE | + TRIGGER_TYPE_DELETE | TRIGGER_TYPE_TRUNCATE; + trigger_stmt->columns = NIL; + trigger_stmt->whenClause = NULL; + trigger_stmt->transitionRels = NIL; + trigger_stmt->deferrable = false; + trigger_stmt->initdeferred = false; + trigger_stmt->constrrel = NULL; + + trigger_wrapper = makeNode(PlannedStmt); + trigger_wrapper->commandType = CMD_UTILITY; + trigger_wrapper->canSetTag = false; + trigger_wrapper->utilityStmt = (Node *) trigger_stmt; + trigger_wrapper->stmt_location = -1; + trigger_wrapper->stmt_len = 0; + + ProcessUtility(trigger_wrapper, + "(generated CREATE TRIGGER command)", + false, PROCESS_UTILITY_SUBCOMMAND, + NULL, NULL, None_Receiver, NULL); + + CommandCounterIncrement(); + } + } } static void create_index_on_column(char *schema_name, @@ -448,7 +508,7 @@ static void create_index_on_column(char *schema_name, index_col->name = colname; index_col->expr = NULL; index_col->indexcolname = NULL; - index_col->collation = InvalidOid; + index_col->collation = NIL; index_col->opclass = list_make1(makeString("graphid_ops")); index_col->opclassopts = NIL; index_col->ordering = SORTBY_DEFAULT; diff --git a/src/backend/executor/cypher_create.c b/src/backend/executor/cypher_create.c index 495eb3a08..36ef61b32 100644 --- a/src/backend/executor/cypher_create.c +++ b/src/backend/executor/cypher_create.c @@ -25,6 +25,7 @@ #include "catalog/ag_label.h" #include "executor/cypher_executor.h" #include "executor/cypher_utils.h" +#include "utils/age_global_graph.h" static void begin_cypher_create(CustomScanState *node, EState *estate, int eflags); @@ -249,6 +250,9 @@ static TupleTableSlot *exec_cypher_create(CustomScanState *node) /* update the current command Id */ CommandCounterIncrement(); + /* invalidate VLE cache — graph was mutated */ + increment_graph_version(css->graph_oid); + /* if this was a terminal CREATE just return NULL */ if (terminal) { @@ -392,7 +396,7 @@ static void create_edge(cypher_create_custom_scan_state *css, estate->es_result_relations = &resultRelInfo; - ExecClearTuple(elemTupleSlot); + clear_entity_slot(elemTupleSlot); /* Graph Id for the edge */ id = ExecEvalExpr(node->id_expr_state, econtext, &isNull); @@ -432,7 +436,7 @@ static void create_edge(cypher_create_custom_scan_state *css, Datum result; result = make_edge( - id, start_id, end_id, CStringGetDatum(node->label_name), + id, start_id, end_id, string_to_agtype(node->label_name), scanTupleSlot->tts_values[node->prop_attr_num]); if (CYPHER_TARGET_NODE_IN_PATH(node->flags)) @@ -487,7 +491,7 @@ static Datum create_vertex(cypher_create_custom_scan_state *css, estate->es_result_relations = &resultRelInfo; - ExecClearTuple(elemTupleSlot); + clear_entity_slot(elemTupleSlot); /* get the next graphid for this vertex. */ id = ExecEvalExpr(node->id_expr_state, econtext, &isNull); @@ -522,7 +526,7 @@ static Datum create_vertex(cypher_create_custom_scan_state *css, scantuple = ps->ps_ExprContext->ecxt_scantuple; /* make the vertex agtype */ - result = make_vertex(id, CStringGetDatum(node->label_name), + result = make_vertex(id, string_to_agtype(node->label_name), scanTupleSlot->tts_values[node->prop_attr_num]); /* append to the path list */ diff --git a/src/backend/executor/cypher_delete.c b/src/backend/executor/cypher_delete.c index 0b486ad5e..19eca3ad6 100644 --- a/src/backend/executor/cypher_delete.c +++ b/src/backend/executor/cypher_delete.c @@ -28,6 +28,7 @@ #include "catalog/ag_label.h" #include "executor/cypher_executor.h" +#include "utils/age_global_graph.h" #include "executor/cypher_utils.h" static void begin_cypher_delete(CustomScanState *node, EState *estate, @@ -193,8 +194,14 @@ static TupleTableSlot *exec_cypher_delete(CustomScanState *node) */ static void end_cypher_delete(CustomScanState *node) { + cypher_delete_custom_scan_state *css = + (cypher_delete_custom_scan_state *)node; + check_for_connected_edges(node); + /* invalidate VLE cache — graph was mutated */ + increment_graph_version(css->delete_data->graph_oid); + hash_destroy(((cypher_delete_custom_scan_state *)node)->vertex_id_htab); ExecEndNode(node->ss.ps.lefttree); @@ -376,6 +383,8 @@ static void process_delete_list(CustomScanState *node) EState *estate = node->ss.ps.state; HTAB *qual_cache = NULL; HASHCTL hashctl; + HTAB *index_cache = NULL; + HASHCTL idx_hashctl; /* Hash table for caching compiled security quals per label */ MemSet(&hashctl, 0, sizeof(hashctl)); @@ -385,18 +394,34 @@ static void process_delete_list(CustomScanState *node) qual_cache = hash_create("delete_qual_cache", 8, &hashctl, HASH_ELEM | HASH_BLOBS | HASH_CONTEXT); + MemSet(&idx_hashctl, 0, sizeof(idx_hashctl)); + idx_hashctl.keysize = sizeof(Oid); + idx_hashctl.entrysize = sizeof(IndexCacheEntry); + idx_hashctl.hcxt = CurrentMemoryContext; + index_cache = hash_create("delete_index_cache", 8, &idx_hashctl, + HASH_ELEM | HASH_BLOBS | HASH_CONTEXT); + foreach(lc, css->delete_data->delete_items) { cypher_delete_item *item; agtype_value *original_entity_value, *id, *label; ScanKeyData scan_keys[1]; - TableScanDesc scan_desc; + TableScanDesc scan_desc = NULL; ResultRelInfo *resultRelInfo; - HeapTuple heap_tuple; + HeapTuple heap_tuple = NULL; char *label_name; Integer *pos; int entity_position; Oid relid; + Relation rel; + int id_attr_num; + Oid index_oid = InvalidOid; + TupleTableSlot *slot = NULL; + Relation index_rel = NULL; + IndexScanDesc index_scan_desc = NULL; + bool shouldFree = false; + IndexCacheEntry *idx_entry; + bool found_idx_entry; item = lfirst(lc); @@ -415,7 +440,8 @@ static void process_delete_list(CustomScanState *node) label_name = pnstrdup(label->val.string.val, label->val.string.len); resultRelInfo = create_entity_result_rel_info(estate, css->delete_data->graph_name, label_name); - relid = RelationGetRelid(resultRelInfo->ri_RelationDesc); + rel = resultRelInfo->ri_RelationDesc; + relid = RelationGetRelid(rel); /* * Setup the scan key to require the id field on-disc to match the @@ -423,12 +449,14 @@ static void process_delete_list(CustomScanState *node) */ if (original_entity_value->type == AGTV_VERTEX) { + id_attr_num = Anum_ag_label_vertex_table_id; ScanKeyInit(&scan_keys[0], Anum_ag_label_vertex_table_id, BTEqualStrategyNumber, F_GRAPHIDEQ, GRAPHID_GET_DATUM(id->val.int_value)); } else if (original_entity_value->type == AGTV_EDGE) { + id_attr_num = Anum_ag_label_edge_table_id; ScanKeyInit(&scan_keys[0], Anum_ag_label_edge_table_id, BTEqualStrategyNumber, F_GRAPHIDEQ, GRAPHID_GET_DATUM(id->val.int_value)); @@ -439,82 +467,214 @@ static void process_delete_list(CustomScanState *node) errmsg("DELETE clause can only delete vertices and edges"))); } + idx_entry = hash_search(index_cache, &relid, HASH_ENTER, &found_idx_entry); + + if (!found_idx_entry) + { + idx_entry->index_oid = find_usable_btree_index_for_attr(rel, id_attr_num); + } + + index_oid = idx_entry->index_oid; + /* * Setup the scan description, with the correct snapshot and scan keys. */ estate->es_snapshot->curcid = GetCurrentCommandId(false); estate->es_output_cid = GetCurrentCommandId(false); - scan_desc = table_beginscan(resultRelInfo->ri_RelationDesc, - estate->es_snapshot, 1, scan_keys); - /* Retrieve the tuple. */ - heap_tuple = heap_getnext(scan_desc, ForwardScanDirection); - - /* - * If the heap tuple still exists (It wasn't deleted after this variable - * was created) we can delete it. Otherwise, its safe to skip this - * delete. - */ - if (!HeapTupleIsValid(heap_tuple)) + if (OidIsValid(index_oid)) { - table_endscan(scan_desc); - destroy_entity_result_rel_info(resultRelInfo); + slot = table_slot_create(rel, NULL); - continue; + index_rel = index_open(index_oid, RowExclusiveLock); + index_scan_desc = index_beginscan(rel, index_rel, estate->es_snapshot, NULL, 1, 0); + index_rescan(index_scan_desc, scan_keys, 1, NULL, 0); + + if (index_getnext_slot(index_scan_desc, ForwardScanDirection, slot)) + { + heap_tuple = ExecFetchSlotHeapTuple(slot, true, &shouldFree); + } + } + else + { + scan_desc = table_beginscan(rel, estate->es_snapshot, 1, scan_keys); + /* Retrieve the tuple. */ + heap_tuple = heap_getnext(scan_desc, ForwardScanDirection); } - /* Check RLS security quals (USING policy) before delete */ - if (check_enable_rls(relid, InvalidOid, true) == RLS_ENABLED) + if (HeapTupleIsValid(heap_tuple)) { - RLSCacheEntry *entry; - bool found; + bool passed_rls = true; - /* Get cached security quals and slot for this label */ - entry = hash_search(qual_cache, &relid, HASH_ENTER, &found); - if (!found) + /* Check RLS security quals (USING policy) before delete */ + if (check_enable_rls(relid, InvalidOid, true) == RLS_ENABLED) { - entry->qualExprs = setup_security_quals(resultRelInfo, estate, - node, CMD_DELETE); - entry->slot = ExecInitExtraTupleSlot( - estate, RelationGetDescr(resultRelInfo->ri_RelationDesc), - &TTSOpsHeapTuple); - entry->withCheckOptions = NIL; - entry->withCheckOptionExprs = NIL; + RLSCacheEntry *entry; + bool found_rls; + + entry = hash_search(qual_cache, &relid, HASH_ENTER, &found_rls); + if (!found_rls) + { + entry->qualExprs = setup_security_quals(resultRelInfo, estate, node, CMD_DELETE); + entry->slot = ExecInitExtraTupleSlot(estate, RelationGetDescr(rel), &TTSOpsHeapTuple); + } + + ExecStoreHeapTuple(heap_tuple, entry->slot, false); + + if (!check_security_quals(entry->qualExprs, entry->slot, econtext)) + { + passed_rls = false; + } } - ExecStoreHeapTuple(heap_tuple, entry->slot, false); + if (passed_rls) + { + /* + * For vertices, we insert the vertex ID in the hashtable + * vertex_id_htab. This hashtable is used later to process + * connected edges. + */ + if (original_entity_value->type == AGTV_VERTEX) + { + bool found; + hash_search(css->vertex_id_htab, (void *)&(id->val.int_value), + HASH_ENTER, &found); + } + + /* At this point, we are ready to delete the node/vertex. */ + delete_entity(estate, resultRelInfo, heap_tuple); + } - /* Silently skip if USING policy filters out this row */ - if (!check_security_quals(entry->qualExprs, entry->slot, econtext)) + if (shouldFree) { - table_endscan(scan_desc); - destroy_entity_result_rel_info(resultRelInfo); - continue; + heap_freetuple(heap_tuple); } } - /* - * For vertices, we insert the vertex ID in the hashtable - * vertex_id_htab. This hashtable is used later to process - * connected edges. - */ - if (original_entity_value->type == AGTV_VERTEX) + if (OidIsValid(index_oid)) { - bool found; - hash_search(css->vertex_id_htab, (void *)&(id->val.int_value), - HASH_ENTER, &found); + ExecDropSingleTupleTableSlot(slot); + index_endscan(index_scan_desc); + index_close(index_rel, RowExclusiveLock); + } + else + { + table_endscan(scan_desc); } - /* At this point, we are ready to delete the node/vertex. */ - delete_entity(estate, resultRelInfo, heap_tuple); - - /* Close the scan and the relation. */ - table_endscan(scan_desc); destroy_entity_result_rel_info(resultRelInfo); } /* Clean up the cache */ hash_destroy(qual_cache); + hash_destroy(index_cache); +} + +/* + * Helper function to scan an edge table using a specific index (start_id or end_id) + * and delete the connected edges if the vertex is being deleted. + */ +static void process_edges_by_index(Oid index_oid, + Relation rel, + EState *estate, + cypher_delete_custom_scan_state *css, + ResultRelInfo *resultRelInfo, + Oid relid, + char *label_name, + bool rls_enabled, + List *qualExprs, + ExprContext *econtext, + bool is_pass_two) +{ + HASH_SEQ_STATUS hash_status; + graphid *vid; + Relation index_rel; + IndexScanDesc scan; + ScanKeyData key; + TupleTableSlot *slot; + + slot = table_slot_create(rel, NULL); + + index_rel = index_open(index_oid, RowExclusiveLock); + scan = index_beginscan(rel, index_rel, estate->es_snapshot, NULL, 1, 0); + + /* Initialize ScanKey with a dummy argument (0), updated in the loop */ + ScanKeyInit(&key, 1, BTEqualStrategyNumber, F_GRAPHIDEQ, 0); + hash_seq_init(&hash_status, css->vertex_id_htab); + + while ((vid = (graphid *) hash_seq_search(&hash_status)) != NULL) + { + /* Update search key with the current ID of the vertex being deleted */ + key.sk_argument = GRAPHID_GET_DATUM(*vid); + index_rescan(scan, &key, 1, NULL, 0); + + while (index_getnext_slot(scan, ForwardScanDirection, slot)) + { + if (is_pass_two) + { + bool is_null; + graphid startid; + bool found_startid = false; + + startid = GRAPHID_GET_DATUM(slot_getattr(slot, Anum_ag_label_edge_table_start_id, &is_null)); + + hash_search(css->vertex_id_htab, (void *)&startid, HASH_FIND, &found_startid); + + if (found_startid) + { + ExecClearTuple(slot); + continue; + } + } + + /* If edge found - delete it (or error if not DETACH) */ + if (css->delete_data->detach) + { + AclResult aclresult; + bool shouldFree; + HeapTuple tuple; + + /* Check that the user has DELETE permission on the edge table */ + aclresult = pg_class_aclcheck(relid, GetUserId(), ACL_DELETE); + if (aclresult != ACLCHECK_OK) + { + aclcheck_error(aclresult, OBJECT_TABLE, label_name); + } + + /* Check RLS security quals (USING policy) before delete */ + if (rls_enabled) + { + if (!check_security_quals(qualExprs, slot, econtext)) + { + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("cannot delete edge due to row-level security policy on \"%s\"", + label_name), + errhint("DETACH DELETE requires permission to delete all connected edges."))); + } + } + + tuple = ExecFetchSlotHeapTuple(slot, true, &shouldFree); + delete_entity(estate, resultRelInfo, tuple); + + if (shouldFree) + { + heap_freetuple(tuple); + } + } + else + { + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("Cannot delete a vertex that has edge(s). " + "Delete the edge(s) first, or try DETACH DELETE."))); + } + ExecClearTuple(slot); + } + } + index_endscan(scan); + index_close(index_rel, RowExclusiveLock); + ExecDropSingleTupleTableSlot(slot); } /* @@ -542,17 +702,16 @@ static void check_for_connected_edges(CustomScanState *node) bool rls_enabled = false; List *qualExprs = NIL; ExprContext *econtext = NULL; + Oid start_index_oid = InvalidOid; + Oid end_index_oid = InvalidOid; + Relation rel; resultRelInfo = create_entity_result_rel_info(estate, graph_name, label_name); - relid = RelationGetRelid(resultRelInfo->ri_RelationDesc); + rel = resultRelInfo->ri_RelationDesc; + relid = RelationGetRelid(rel); estate->es_snapshot->curcid = GetCurrentCommandId(false); estate->es_output_cid = GetCurrentCommandId(false); - scan_desc = table_beginscan(resultRelInfo->ri_RelationDesc, - estate->es_snapshot, 0, NULL); - slot = ExecInitExtraTupleSlot( - estate, RelationGetDescr(resultRelInfo->ri_RelationDesc), - &TTSOpsHeapTuple); /* * For DETACH DELETE with RLS enabled, compile the security qual @@ -570,86 +729,109 @@ static void check_for_connected_edges(CustomScanState *node) } } - /* for each row */ - while (true) - { - graphid startid; - graphid endid; - bool isNull; - bool found_startid = false; - bool found_endid = false; + /* Look for indexes on start_id and end_id columns. */ + start_index_oid = find_usable_btree_index_for_attr(rel, Anum_ag_label_edge_table_start_id); + end_index_oid = find_usable_btree_index_for_attr(rel, Anum_ag_label_edge_table_end_id); - tuple = heap_getnext(scan_desc, ForwardScanDirection); + if (OidIsValid(start_index_oid) && OidIsValid(end_index_oid)) + { + /* PASS 1: Find edges where the deleted vertex is the START_ID. */ + process_edges_by_index(start_index_oid, rel, estate, css, resultRelInfo, + relid, label_name, rls_enabled, qualExprs, econtext, false); + + /* PASS 2: Find edges where the deleted vertex is the END_ID. */ + process_edges_by_index(end_index_oid, rel, estate, css, resultRelInfo, + relid, label_name, rls_enabled, qualExprs, econtext, true); + } + else + { + scan_desc = table_beginscan(rel, estate->es_snapshot, 0, NULL); + slot = ExecInitExtraTupleSlot( + estate, RelationGetDescr(rel), + &TTSOpsHeapTuple); - /* no more tuples to process, break and scan the next label. */ - if (!HeapTupleIsValid(tuple)) + /* for each row */ + while (true) { - break; - } + graphid startid; + graphid endid; + bool isNull; + bool found_startid = false; + bool found_endid = false; + + tuple = heap_getnext(scan_desc, ForwardScanDirection); - ExecStoreHeapTuple(tuple, slot, false); + /* no more tuples to process, break and scan the next label. */ + if (!HeapTupleIsValid(tuple)) + { + break; + } - startid = GRAPHID_GET_DATUM(slot_getattr( - slot, Anum_ag_label_edge_table_start_id, &isNull)); - endid = GRAPHID_GET_DATUM( - slot_getattr(slot, Anum_ag_label_edge_table_end_id, &isNull)); + ExecStoreHeapTuple(tuple, slot, false); - hash_search(css->vertex_id_htab, (void *)&startid, HASH_FIND, - &found_startid); + startid = GRAPHID_GET_DATUM(slot_getattr( + slot, Anum_ag_label_edge_table_start_id, &isNull)); + endid = GRAPHID_GET_DATUM( + slot_getattr(slot, Anum_ag_label_edge_table_end_id, &isNull)); - if (!found_startid) - { - hash_search(css->vertex_id_htab, (void *)&endid, HASH_FIND, - &found_endid); - } + hash_search(css->vertex_id_htab, (void *)&startid, HASH_FIND, + &found_startid); - if (found_startid || found_endid) - { - if (css->delete_data->detach) + if (!found_startid) { - AclResult aclresult; + hash_search(css->vertex_id_htab, (void *)&endid, HASH_FIND, + &found_endid); + } - /* Check that the user has DELETE permission on the edge table */ - aclresult = pg_class_aclcheck(relid, GetUserId(), ACL_DELETE); - if (aclresult != ACLCHECK_OK) + if (found_startid || found_endid) + { + if (css->delete_data->detach) { - aclcheck_error(aclresult, OBJECT_TABLE, label_name); - } + AclResult aclresult; - /* Check RLS security quals (USING policy) before delete */ - if (rls_enabled) - { - /* - * For DETACH DELETE, error out if edge RLS check fails. - * Unlike normal DELETE which silently skips, we cannot - * silently skip edges here as it would leave dangling - * edges pointing to deleted vertices. - */ - if (!check_security_quals(qualExprs, slot, econtext)) + /* Check that the user has DELETE permission on the edge table */ + aclresult = pg_class_aclcheck(relid, GetUserId(), ACL_DELETE); + if (aclresult != ACLCHECK_OK) { - ereport(ERROR, - (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), - errmsg("cannot delete edge due to row-level security policy on \"%s\"", - label_name), - errhint("DETACH DELETE requires permission to delete all connected edges."))); + aclcheck_error(aclresult, OBJECT_TABLE, label_name); } - } - delete_entity(estate, resultRelInfo, tuple); - } - else - { - ereport( - ERROR, - (errcode(ERRCODE_INTERNAL_ERROR), - errmsg( - "Cannot delete a vertex that has edge(s). " - "Delete the edge(s) first, or try DETACH DELETE."))); + /* Check RLS security quals (USING policy) before delete */ + if (rls_enabled) + { + /* + * For DETACH DELETE, error out if edge RLS check fails. + * Unlike normal DELETE which silently skips, we cannot + * silently skip edges here as it would leave dangling + * edges pointing to deleted vertices. + */ + if (!check_security_quals(qualExprs, slot, econtext)) + { + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("cannot delete edge due to row-level security policy on \"%s\"", + label_name), + errhint("DETACH DELETE requires permission to delete all connected edges."))); + } + } + + delete_entity(estate, resultRelInfo, tuple); + } + else + { + ereport( + ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg( + "Cannot delete a vertex that has edge(s). " + "Delete the edge(s) first, or try DETACH DELETE."))); + } } } + + table_endscan(scan_desc); } - table_endscan(scan_desc); destroy_entity_result_rel_info(resultRelInfo); } } diff --git a/src/backend/executor/cypher_merge.c b/src/backend/executor/cypher_merge.c index a1bb4686c..9c52073c2 100644 --- a/src/backend/executor/cypher_merge.c +++ b/src/backend/executor/cypher_merge.c @@ -26,6 +26,7 @@ #include "catalog/ag_label.h" #include "executor/cypher_executor.h" #include "executor/cypher_utils.h" +#include "utils/age_global_graph.h" /* * The following structure is used to hold a single vertex or edge component @@ -80,6 +81,7 @@ static bool check_path(cypher_merge_custom_scan_state *css, static void process_path(cypher_merge_custom_scan_state *css, path_entry **path_array, bool should_insert); static void mark_tts_isnull(TupleTableSlot *slot); +static void mark_scan_slot_valid(TupleTableSlot *slot); const CustomExecMethods cypher_merge_exec_methods = {MERGE_SCAN_STATE_NAME, begin_cypher_merge, @@ -191,6 +193,35 @@ static void begin_cypher_merge(CustomScanState *node, EState *estate, } } + /* + * Pre-initialize ExprStates for ON CREATE SET / ON MATCH SET items. + * This must happen once at plan init time, not per-row. + */ + if (css->on_create_set_info != NULL) + { + foreach(lc, css->on_create_set_info->set_items) + { + cypher_update_item *item = (cypher_update_item *)lfirst(lc); + if (item->prop_expr != NULL) + { + item->prop_expr_state = ExecInitExpr( + (Expr *)item->prop_expr, (PlanState *)node); + } + } + } + if (css->on_match_set_info != NULL) + { + foreach(lc, css->on_match_set_info->set_items) + { + cypher_update_item *item = (cypher_update_item *)lfirst(lc); + if (item->prop_expr != NULL) + { + item->prop_expr_state = ExecInitExpr( + (Expr *)item->prop_expr, (PlanState *)node); + } + } + } + /* * Postgres does not assign the es_output_cid in queries that do * not write to disk, ie: SELECT commands. We need the command id @@ -321,11 +352,49 @@ static void process_simple_merge(CustomScanState *node) /* setup the scantuple that the process_path needs */ econtext->ecxt_scantuple = sss->ss.ss_ScanTupleSlot; + mark_tts_isnull(econtext->ecxt_scantuple); process_path(css, NULL, true); + + /* ON CREATE SET: path was just created */ + if (css->on_create_set_info) + { + mark_scan_slot_valid(econtext->ecxt_scantuple); + apply_update_list(&css->css, css->on_create_set_info); + } + } + else + { + /* ON MATCH SET: path already exists */ + if (css->on_match_set_info) + { + ExprContext *econtext = node->ss.ps.ps_ExprContext; + + econtext->ecxt_scantuple = + node->ss.ps.lefttree->ps_ProjInfo->pi_exprContext->ecxt_scantuple; + + apply_update_list(&css->css, css->on_match_set_info); + } } } +/* + * mark_scan_slot_valid - mark a scan slot as populated after direct writes + * to tts_values[] by process_path. + * + * This does the same bookkeeping as ExecStoreVirtualTuple (clear TTS_EMPTY, + * set tts_nvalid = natts) but without the TTS_EMPTY precondition assertion. + * We cannot use ExecStoreVirtualTuple here because process_path writes into + * a scan slot that already holds the subquery's output tuple -- the slot is + * NOT empty, and asserting it is would fire under --enable-cassert while + * silently clearing the flag on release builds. + */ +static void mark_scan_slot_valid(TupleTableSlot *slot) +{ + slot->tts_flags &= ~TTS_FLAG_EMPTY; + slot->tts_nvalid = slot->tts_tupleDescriptor->natts; +} + /* * Iterate through the TupleTableSlot's tts_values and marks the isnull field * with true. @@ -596,23 +665,143 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node) * it did, we don't need to create the pattern. If the lateral join did * not find the whole path, create the whole path. * - * If this is a terminal clause, process all tuples. If not, pass the - * tuple to up the execution tree. + * For non-terminal MERGE, we eagerly process ALL input rows before + * returning any results. This ensures that all entities created by + * this MERGE are committed to the database before any parent plan + * node (such as another MERGE's lateral join) scans the tables. + * Without eager processing, chained MERGEs cannot see each other's + * changes because the parent's join scan is materialized before the + * child MERGE finishes all its iterations. + * + * For terminal MERGE, all tuples are processed in a single pass + * and NULL is returned. + */ + + /* + * Non-terminal: eagerly process all rows and buffer the results. + */ + if (!terminal && !css->eager_buffer_filled) + { + css->eager_tuples = NIL; + css->eager_tuples_index = 0; + + while (true) + { + TupleTableSlot *projected; + HeapTuple htup; + + /* Process the subtree first */ + Decrement_Estate_CommandId(estate) + slot = ExecProcNode(node->ss.ps.lefttree); + Increment_Estate_CommandId(estate) + + if (TupIsNull(slot)) + { + break; + } + + /* setup the scantuple that the process_path needs */ + econtext->ecxt_scantuple = + node->ss.ps.lefttree->ps_ProjInfo->pi_exprContext->ecxt_scantuple; + + /* + * Check the subtree to see if the lateral join + * representing the MERGE path found results. If not, + * we need to create the path. + */ + if (check_path(css, econtext->ecxt_scantuple)) + { + path_entry **prebuilt_path_array = NULL; + path_entry **found_path_array = NULL; + int path_length = + list_length(css->path->target_nodes); + + prebuilt_path_array = prebuild_path(node); + + found_path_array = + find_duplicate_path(node, prebuilt_path_array); + + if (found_path_array) + { + free_path_entry_array(prebuilt_path_array, + path_length); + process_path(css, found_path_array, false); + + /* ON MATCH SET: path was found as duplicate */ + if (css->on_match_set_info) + apply_update_list(&css->css, + css->on_match_set_info); + } + else + { + created_path *new_path = + palloc0(sizeof(created_path)); + + new_path->next = css->created_paths_list; + new_path->entry = prebuilt_path_array; + css->created_paths_list = new_path; + + process_path(css, prebuilt_path_array, true); + mark_scan_slot_valid(econtext->ecxt_scantuple); + + /* ON CREATE SET: path was just created */ + if (css->on_create_set_info) + apply_update_list(&css->css, + css->on_create_set_info); + } + } + else + { + /* ON MATCH SET: path already existed from lateral join */ + if (css->on_match_set_info) + apply_update_list(&css->css, css->on_match_set_info); + } + + /* Project the result and save a copy */ + econtext->ecxt_scantuple = + ExecProject(node->ss.ps.lefttree->ps_ProjInfo); + projected = ExecProject(node->ss.ps.ps_ProjInfo); + + htup = ExecCopySlotHeapTuple(projected); + css->eager_tuples = + lappend(css->eager_tuples, htup); + } + + css->eager_buffer_filled = true; + } + + /* Non-terminal: return the next buffered row (or NULL if empty) */ + if (!terminal && css->eager_buffer_filled) + { + if (css->eager_tuples_index < list_length(css->eager_tuples)) + { + HeapTuple htup; + TupleTableSlot *result_slot = + node->ss.ps.ps_ResultTupleSlot; + + htup = (HeapTuple) + list_nth(css->eager_tuples, css->eager_tuples_index); + css->eager_tuples_index++; + + ExecForceStoreHeapTuple(htup, result_slot, false); + return result_slot; + } + + return NULL; + } + + /* + * Terminal: process all tuples and return NULL. */ do { - /*Process the subtree first */ + /* Process the subtree first */ Decrement_Estate_CommandId(estate) slot = ExecProcNode(node->ss.ps.lefttree); Increment_Estate_CommandId(estate) - /* - * We are done processing the subtree, mark as terminal - * so the function returns NULL. - */ if (TupIsNull(slot)) { - terminal = true; break; } @@ -622,7 +811,7 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node) /* * Check the subtree to see if the lateral join representing the - * MERGE path found results. If not, we need to create the path + * MERGE path found results. If not, we need to create the path. */ if (check_path(css, econtext->ecxt_scantuple)) { @@ -630,71 +819,46 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node) path_entry **found_path_array = NULL; int path_length = list_length(css->path->target_nodes); - /* - * Prebuild our path and verify that it wasn't already created. - * - * Note: This is currently only needed when there is a previous - * clause. This is due to the fact that MERGE can't see - * what it has just created. This isn't due to transaction - * or command ids, it's due to the join's scan not being - * able to add in the newly inserted tuples and rescan - * with these tuples. - * - * Note: The prebuilt path is purposely generic as it needs to - * only match a path. The more specific items will be - * added by merge_vertex and merge_edge if it is inserted. - * - * Note: The IDs are purposely not created here because we may - * need to throw them away if a path was previously - * created. Remember, the IDs are automatically - * incremented when fetched. - */ prebuilt_path_array = prebuild_path(node); found_path_array = find_duplicate_path(node, prebuilt_path_array); - /* if found we don't need to insert anything, just reuse it */ if (found_path_array) { - /* we don't need our prebuilt path anymore */ free_path_entry_array(prebuilt_path_array, path_length); - - /* as this path exists, we don't need to insert it */ process_path(css, found_path_array, false); + + /* ON MATCH SET: path was found as duplicate */ + if (css->on_match_set_info) + apply_update_list(&css->css, css->on_match_set_info); } - /* otherwise, we need to insert the new, prebuilt, path */ else { created_path *new_path = palloc0(sizeof(created_path)); - /* build the next linked list entry for our created_paths */ - new_path = palloc0(sizeof(created_path)); new_path->next = css->created_paths_list; new_path->entry = prebuilt_path_array; - - /* we need to push our prebuilt path onto the list */ css->created_paths_list = new_path; - /* - * We need to pass in the prebuilt path so that it can get - * filled in with more specific information - */ process_path(css, prebuilt_path_array, true); + mark_scan_slot_valid(econtext->ecxt_scantuple); + + /* ON CREATE SET: path was just created */ + if (css->on_create_set_info) + apply_update_list(&css->css, css->on_create_set_info); } } + else + { + /* ON MATCH SET: path already existed from lateral join */ + if (css->on_match_set_info) + apply_update_list(&css->css, css->on_match_set_info); + } - } while (terminal); - - /* if this was a terminal MERGE just return NULL */ - if (terminal) - { - return NULL; - } - - econtext->ecxt_scantuple = ExecProject(node->ss.ps.lefttree->ps_ProjInfo); + } while (true); - return ExecProject(node->ss.ps.ps_ProjInfo); + return NULL; } else if (terminal) @@ -763,6 +927,14 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node) */ css->found_a_path = true; + /* ON MATCH SET: path already exists */ + if (css->on_match_set_info) + { + econtext->ecxt_scantuple = + node->ss.ps.lefttree->ps_ProjInfo->pi_exprContext->ecxt_scantuple; + apply_update_list(&css->css, css->on_match_set_info); + } + econtext->ecxt_scantuple = ExecProject(node->ss.ps.lefttree->ps_ProjInfo); return ExecProject(node->ss.ps.ps_ProjInfo); } @@ -823,20 +995,25 @@ static TupleTableSlot *exec_cypher_merge(CustomScanState *node) /* setup the scantuple that the process_path needs */ econtext->ecxt_scantuple = sss->ss.ss_ScanTupleSlot; + /* + * Initialize the scan tuple slot as all-null before process_path + * populates it with the created entities. This ensures the slot + * is properly set up for apply_update_list. + */ + mark_tts_isnull(econtext->ecxt_scantuple); + /* create the path */ process_path(css, NULL, true); - /* mark the create_new_path flag to true. */ - css->created_new_path = true; + /* mark the slot as valid so tts_nvalid reflects natts */ + mark_scan_slot_valid(econtext->ecxt_scantuple); - /* - * find the tts_values that process_path did not populate and - * mark as null. - */ - mark_tts_isnull(econtext->ecxt_scantuple); + /* ON CREATE SET: path was just created */ + if (css->on_create_set_info) + apply_update_list(&css->css, css->on_create_set_info); - /* store the heap tuble */ - ExecStoreVirtualTuple(econtext->ecxt_scantuple); + /* mark the create_new_path flag to true. */ + css->created_new_path = true; /* * make the subquery's projection scan slot be the tuple table we @@ -873,6 +1050,12 @@ static void end_cypher_merge(CustomScanState *node) /* increment the command counter */ CommandCounterIncrement(); + /* invalidate VLE cache if merge created anything */ + if (css->created_new_path) + { + increment_graph_version(css->graph_oid); + } + ExecEndNode(node->ss.ps.lefttree); foreach (lc, path->target_nodes) @@ -910,6 +1093,18 @@ static void end_cypher_merge(CustomScanState *node) css->created_paths_list = next; } + /* free the eager buffer if it was used */ + if (css->eager_tuples != NIL) + { + ListCell *lc2; + + foreach(lc2, css->eager_tuples) + { + heap_freetuple((HeapTuple) lfirst(lc2)); + } + list_free(css->eager_tuples); + css->eager_tuples = NIL; + } } /* @@ -954,6 +1149,8 @@ Node *create_cypher_merge_plan_state(CustomScan *cscan) cypher_css->created_new_path = false; cypher_css->found_a_path = false; cypher_css->graph_oid = merge_information->graph_oid; + cypher_css->on_match_set_info = merge_information->on_match_set_info; + cypher_css->on_create_set_info = merge_information->on_create_set_info; cypher_css->css.ss.ps.type = T_CustomScanState; cypher_css->css.methods = &cypher_merge_exec_methods; @@ -1002,7 +1199,16 @@ static Datum merge_vertex(cypher_merge_custom_scan_state *css, estate->es_result_relations = &resultRelInfo; - ExecClearTuple(elemTupleSlot); + /* + * Only clear and null-init the slot when we will actually insert. On a + * matched MERGE the slot is never materialized (the output is built from + * id/prop directly), so the null-init memset would be wasted work that + * scales with any user-added columns (issue #2450 review follow-up). + */ + if (should_insert) + { + clear_entity_slot(elemTupleSlot); + } /* if we not are going to insert, we need our structure pointers */ if (should_insert == false && @@ -1128,7 +1334,7 @@ static Datum merge_vertex(cypher_merge_custom_scan_state *css, Datum result; /* make the vertex agtype */ - result = make_vertex(id, CStringGetDatum(node->label_name), prop); + result = make_vertex(id, string_to_agtype(node->label_name), prop); /* append to the path list */ if (CYPHER_TARGET_NODE_IN_PATH(node->flags)) @@ -1329,7 +1535,16 @@ static void merge_edge(cypher_merge_custom_scan_state *css, estate->es_result_relations = &resultRelInfo; - ExecClearTuple(elemTupleSlot); + /* + * Only clear and null-init the slot when we will actually insert. On a + * matched MERGE the slot is never materialized (the output is built from + * id/prop directly), so the null-init memset would be wasted work that + * scales with any user-added columns (issue #2450 review follow-up). + */ + if (should_insert) + { + clear_entity_slot(elemTupleSlot); + } /* if we not are going to insert, we need our structure pointers */ if (should_insert == false && @@ -1467,7 +1682,7 @@ static void merge_edge(cypher_merge_custom_scan_state *css, Datum result; result = make_edge(id, start_id, end_id, - CStringGetDatum(node->label_name), prop); + string_to_agtype(node->label_name), prop); /* add the Datum to the list of entities for creating the path variable */ if (CYPHER_TARGET_NODE_IN_PATH(node->flags)) diff --git a/src/backend/executor/cypher_set.c b/src/backend/executor/cypher_set.c index a1063af32..7a0d48f0c 100644 --- a/src/backend/executor/cypher_set.c +++ b/src/backend/executor/cypher_set.c @@ -21,11 +21,15 @@ #include "common/hashfn.h" #include "executor/executor.h" +#include "executor/nodeModifyTable.h" #include "storage/bufmgr.h" #include "utils/rls.h" #include "executor/cypher_executor.h" #include "executor/cypher_utils.h" +#include "utils/age_global_graph.h" +#include "catalog/ag_graph.h" +#include "utils/agtype.h" static void begin_cypher_set(CustomScanState *node, EState *estate, int eflags); @@ -128,6 +132,28 @@ static HeapTuple update_entity_tuple(ResultRelInfo *resultRelInfo, close_indices = true; } ExecStoreVirtualTuple(elemTupleSlot); + + /* + * Recompute any stored generated columns before materializing the heap + * tuple. The slot's tuple descriptor is the full relation descriptor, + * which may include a GENERATED ALWAYS ... STORED column that the SET + * path does not populate; leaving those slot entries uninitialized makes + * heap_form_tuple() segfault on the garbage values (issue #2450). + */ + if (resultRelInfo->ri_RelationDesc->rd_att->constr != NULL && + resultRelInfo->ri_RelationDesc->rd_att->constr->has_generated_stored) + { + /* + * A generation expression may reference the tableoid system column, + * so the slot must carry the relation's OID before we recompute the + * stored generated columns (mirrors PostgreSQL's own ExecUpdate path). + */ + elemTupleSlot->tts_tableOid = + RelationGetRelid(resultRelInfo->ri_RelationDesc); + ExecComputeStoredGenerated(resultRelInfo, estate, elemTupleSlot, + CMD_UPDATE); + } + tuple = ExecFetchSlotHeapTuple(elemTupleSlot, true, NULL); tuple->t_self = old_tuple->t_self; @@ -325,8 +351,7 @@ static agtype_value *replace_entity_in_path(agtype_value *path, static void update_all_paths(CustomScanState *node, graphid id, agtype *updated_entity) { - cypher_set_custom_scan_state *css = (cypher_set_custom_scan_state *)node; - ExprContext *econtext = css->css.ss.ps.ps_ExprContext; + ExprContext *econtext = node->ss.ps.ps_ExprContext; TupleTableSlot *scanTupleSlot = econtext->ecxt_scantuple; int i; @@ -372,17 +397,24 @@ static void update_all_paths(CustomScanState *node, graphid id, } } -static void process_update_list(CustomScanState *node) +/* + * Core SET logic that can be called from any executor (SET, MERGE, etc.). + * Takes the CustomScanState for expression context and a + * cypher_update_information describing which properties to set. + */ +void apply_update_list(CustomScanState *node, + cypher_update_information *set_info) { - cypher_set_custom_scan_state *css = (cypher_set_custom_scan_state *)node; - ExprContext *econtext = css->css.ss.ps.ps_ExprContext; + ExprContext *econtext = node->ss.ps.ps_ExprContext; TupleTableSlot *scanTupleSlot = econtext->ecxt_scantuple; ListCell *lc; - EState *estate = css->css.ss.ps.state; + EState *estate = node->ss.ps.state; int *luindex = NULL; int lidx = 0; HTAB *qual_cache = NULL; HASHCTL hashctl; + HTAB *index_cache = NULL; + HASHCTL idx_hashctl; /* allocate an array to hold the last update index of each 'entity' */ luindex = palloc0(sizeof(int) * scanTupleSlot->tts_nvalid); @@ -395,6 +427,13 @@ static void process_update_list(CustomScanState *node) qual_cache = hash_create("update_qual_cache", 8, &hashctl, HASH_ELEM | HASH_BLOBS | HASH_CONTEXT); + MemSet(&idx_hashctl, 0, sizeof(idx_hashctl)); + idx_hashctl.keysize = sizeof(Oid); + idx_hashctl.entrysize = sizeof(IndexCacheEntry); + idx_hashctl.hcxt = CurrentMemoryContext; + index_cache = hash_create("update_index_cache", 8, &idx_hashctl, + HASH_ELEM | HASH_BLOBS | HASH_CONTEXT); + /* * Iterate through the SET items list and store the loop index of each * 'entity' update. As there is only one entry for each entity, this will @@ -404,7 +443,7 @@ static void process_update_list(CustomScanState *node) * to correctly update an 'entity' after all other previous updates to that * 'entity' have been done. */ - foreach (lc, css->set_list->set_items) + foreach (lc, set_info->set_items) { cypher_update_item *update_item = NULL; @@ -419,7 +458,7 @@ static void process_update_list(CustomScanState *node) lidx = 0; /* iterate through SET set items */ - foreach (lc, css->set_list->set_items) + foreach (lc, set_info->set_items) { agtype_value *altered_properties; agtype_value *original_entity_value; @@ -427,7 +466,7 @@ static void process_update_list(CustomScanState *node) agtype_value *id; agtype_value *label; agtype *original_entity; - agtype *new_property_value; + agtype *new_property_value = NULL; TupleTableSlot *slot; ResultRelInfo *resultRelInfo; ScanKeyData scan_keys[1]; @@ -437,8 +476,13 @@ static void process_update_list(CustomScanState *node) cypher_update_item *update_item; Datum new_entity; HeapTuple heap_tuple; - char *clause_name = css->set_list->clause_name; + char *clause_name = set_info->clause_name; int cid; + Oid index_oid = InvalidOid; + Relation rel; + Oid relid; + IndexCacheEntry *idx_entry; + bool found_idx_entry; update_item = (cypher_update_item *)lfirst(lc); @@ -485,11 +529,43 @@ static void process_update_list(CustomScanState *node) * this is a REMOVE clause or the variable references a variable that is * NULL. It will be possible for a variable to be NULL when OPTIONAL * MATCH is implemented. + * + * If prop_expr is set (used by MERGE ON CREATE/MATCH SET), evaluate + * the expression directly rather than reading from the scan tuple. + * The planner may have stripped the target entry at prop_position. */ if (update_item->remove_item) { remove_property = true; } + else if (update_item->prop_expr != NULL) + { + ExprState *expr_state; + Datum val; + bool isnull; + + /* + * Use the pre-initialized ExprState if available (set during + * plan init in begin_cypher_merge). Fall back to per-row init + * for callers that haven't pre-initialized (e.g. plain SET). + */ + if (update_item->prop_expr_state != NULL) + { + expr_state = update_item->prop_expr_state; + } + else + { + expr_state = ExecInitExpr((Expr *)update_item->prop_expr, + (PlanState *)node); + } + val = ExecEvalExpr(expr_state, econtext, &isnull); + remove_property = isnull; + + if (!isnull) + { + new_property_value = DATUM_GET_AGTYPE_P(val); + } + } else { remove_property = scanTupleSlot->tts_isnull[update_item->prop_position - 1]; @@ -503,7 +579,7 @@ static void process_update_list(CustomScanState *node) { new_property_value = NULL; } - else + else if (update_item->prop_expr == NULL) { new_property_value = DATUM_GET_AGTYPE_P(scanTupleSlot->tts_values[update_item->prop_position - 1]); } @@ -536,7 +612,18 @@ static void process_update_list(CustomScanState *node) } resultRelInfo = create_entity_result_rel_info( - estate, css->set_list->graph_name, label_name); + estate, set_info->graph_name, label_name); + + rel = resultRelInfo->ri_RelationDesc; + relid = RelationGetRelid(rel); + + idx_entry = hash_search(index_cache, &relid, HASH_ENTER, &found_idx_entry); + if (!found_idx_entry) + { + /* Check if there is a valid index on the 'id' column */ + idx_entry->index_oid = find_usable_btree_index_for_attr(rel, 1); + } + index_oid = idx_entry->index_oid; slot = ExecInitExtraTupleSlot( estate, RelationGetDescr(resultRelInfo->ri_RelationDesc), @@ -546,7 +633,6 @@ static void process_update_list(CustomScanState *node) if (check_enable_rls(resultRelInfo->ri_RelationDesc->rd_id, InvalidOid, true) == RLS_ENABLED) { - Oid relid = RelationGetRelid(resultRelInfo->ri_RelationDesc); RLSCacheEntry *entry; bool found; @@ -582,7 +668,7 @@ static void process_update_list(CustomScanState *node) if (original_entity_value->type == AGTV_VERTEX) { new_entity = make_vertex(GRAPHID_GET_DATUM(id->val.int_value), - CStringGetDatum(label_name), + string_to_agtype(label_name), AGTYPE_P_GET_DATUM(agtype_value_to_agtype(altered_properties))); slot = populate_vertex_tts(slot, id, altered_properties); @@ -595,7 +681,7 @@ static void process_update_list(CustomScanState *node) new_entity = make_edge(GRAPHID_GET_DATUM(id->val.int_value), GRAPHID_GET_DATUM(startid->val.int_value), GRAPHID_GET_DATUM(endid->val.int_value), - CStringGetDatum(label_name), + string_to_agtype(label_name), AGTYPE_P_GET_DATUM(agtype_value_to_agtype(altered_properties))); slot = populate_edge_tts(slot, id, startid, endid, @@ -628,60 +714,132 @@ static void process_update_list(CustomScanState *node) if (luindex[update_item->entity_position - 1] == lidx) { - /* - * Setup the scan key to require the id field on-disc to match the - * entity's graphid. - */ - ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, F_GRAPHIDEQ, - GRAPHID_GET_DATUM(id->val.int_value)); - /* - * Setup the scan description, with the correct snapshot and scan - * keys. - */ - scan_desc = table_beginscan(resultRelInfo->ri_RelationDesc, - estate->es_snapshot, 1, scan_keys); - /* Retrieve the tuple. */ - heap_tuple = heap_getnext(scan_desc, ForwardScanDirection); - - /* - * If the heap tuple still exists (It wasn't deleted between the - * match and this SET/REMOVE) update the heap_tuple. - */ - if (HeapTupleIsValid(heap_tuple)) - { - bool should_update = true; - Oid relid = RelationGetRelid(resultRelInfo->ri_RelationDesc); - - /* Check RLS security quals (USING policy) before update */ - if (check_enable_rls(relid, InvalidOid, true) == RLS_ENABLED) + if (OidIsValid(index_oid)) + { + Relation index_rel; + IndexScanDesc idx_scan_desc; + TupleTableSlot *index_slot; + + index_rel = index_open(index_oid, RowExclusiveLock); + + /* + * Setup the scan key to require the id field on-disc to match the + * entity's graphid. + */ + ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, F_GRAPHIDEQ, + GRAPHID_GET_DATUM(id->val.int_value)); + + index_slot = table_slot_create(rel, NULL); + idx_scan_desc = index_beginscan(rel, index_rel, estate->es_snapshot, NULL, 1, 0); + index_rescan(idx_scan_desc, scan_keys, 1, NULL, 0); + + if (index_getnext_slot(idx_scan_desc, ForwardScanDirection, index_slot)) { - RLSCacheEntry *entry; + bool shouldFree; + + /* Retrieve the tuple from the slot */ + heap_tuple = ExecFetchSlotHeapTuple(index_slot, true, &shouldFree); - /* Entry was already created earlier when setting up WCOs */ - entry = hash_search(qual_cache, &relid, HASH_FIND, NULL); - if (!entry) + if (HeapTupleIsValid(heap_tuple)) { - ereport(ERROR, - (errcode(ERRCODE_INTERNAL_ERROR), - errmsg("missing RLS cache entry for relation %u", - relid))); + bool should_update = true; + HeapTuple original_tuple = heap_tuple; + + /* Check RLS security quals (USING policy) before update */ + if (check_enable_rls(relid, InvalidOid, true) == RLS_ENABLED) + { + RLSCacheEntry *entry; + + /* Entry was already created earlier when setting up WCOs */ + entry = hash_search(qual_cache, &relid, HASH_FIND, NULL); + if (!entry) + { + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("missing RLS cache entry for relation %u", + relid))); + } + + ExecStoreHeapTuple(heap_tuple, entry->slot, false); + should_update = check_security_quals(entry->qualExprs, + entry->slot, + econtext); + } + + /* Silently skip if USING policy filters out this row */ + if (should_update) + { + heap_tuple = update_entity_tuple(resultRelInfo, slot, estate, + original_tuple); + } + + if (shouldFree) + { + heap_freetuple(original_tuple); + } } - - ExecStoreHeapTuple(heap_tuple, entry->slot, false); - should_update = check_security_quals(entry->qualExprs, - entry->slot, - econtext); } - /* Silently skip if USING policy filters out this row */ - if (should_update) + ExecDropSingleTupleTableSlot(index_slot); + index_endscan(idx_scan_desc); + index_close(index_rel, RowExclusiveLock); + } + else + { + /* + * Setup the scan key to require the id field on-disc to match the + * entity's graphid. + */ + ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, F_GRAPHIDEQ, + GRAPHID_GET_DATUM(id->val.int_value)); + /* + * Setup the scan description, with the correct snapshot and scan + * keys. + */ + scan_desc = table_beginscan(resultRelInfo->ri_RelationDesc, + estate->es_snapshot, 1, scan_keys); + /* Retrieve the tuple. */ + heap_tuple = heap_getnext(scan_desc, ForwardScanDirection); + + /* + * If the heap tuple still exists (It wasn't deleted between the + * match and this SET/REMOVE) update the heap_tuple. + */ + if (HeapTupleIsValid(heap_tuple)) { - heap_tuple = update_entity_tuple(resultRelInfo, slot, estate, - heap_tuple); + bool should_update = true; + + /* Check RLS security quals (USING policy) before update */ + if (check_enable_rls(relid, InvalidOid, true) == RLS_ENABLED) + { + RLSCacheEntry *entry; + + /* Entry was already created earlier when setting up WCOs */ + entry = hash_search(qual_cache, &relid, HASH_FIND, NULL); + if (!entry) + { + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("missing RLS cache entry for relation %u", + relid))); + } + + ExecStoreHeapTuple(heap_tuple, entry->slot, false); + should_update = check_security_quals(entry->qualExprs, + entry->slot, + econtext); + } + + /* Silently skip if USING policy filters out this row */ + if (should_update) + { + heap_tuple = update_entity_tuple(resultRelInfo, slot, estate, + heap_tuple); + } } + /* close the ScanDescription */ + table_endscan(scan_desc); } - /* close the ScanDescription */ - table_endscan(scan_desc); } estate->es_snapshot->curcid = cid; @@ -695,11 +853,19 @@ static void process_update_list(CustomScanState *node) /* Clean up the cache */ hash_destroy(qual_cache); + hash_destroy(index_cache); /* free our lookup array */ pfree_if_not_null(luindex); } +static void process_update_list(CustomScanState *node) +{ + cypher_set_custom_scan_state *css = (cypher_set_custom_scan_state *)node; + + apply_update_list(node, css->set_list); +} + static TupleTableSlot *exec_cypher_set(CustomScanState *node) { cypher_set_custom_scan_state *css = (cypher_set_custom_scan_state *)node; @@ -732,6 +898,9 @@ static TupleTableSlot *exec_cypher_set(CustomScanState *node) /* increment the command counter to reflect the updates */ CommandCounterIncrement(); + /* invalidate VLE cache — graph was mutated */ + increment_graph_version(get_graph_oid(css->set_list->graph_name)); + return NULL; } @@ -740,6 +909,9 @@ static TupleTableSlot *exec_cypher_set(CustomScanState *node) /* increment the command counter to reflect the updates */ CommandCounterIncrement(); + /* invalidate VLE cache — graph was mutated */ + increment_graph_version(get_graph_oid(css->set_list->graph_name)); + estate->es_result_relations = saved_resultRels; econtext->ecxt_scantuple = ExecProject(node->ss.ps.lefttree->ps_ProjInfo); diff --git a/src/backend/executor/cypher_utils.c b/src/backend/executor/cypher_utils.c index eff829925..c697a560c 100644 --- a/src/backend/executor/cypher_utils.c +++ b/src/backend/executor/cypher_utils.c @@ -25,6 +25,7 @@ #include "postgres.h" #include "executor/executor.h" +#include "executor/nodeModifyTable.h" #include "miscadmin.h" #include "nodes/makefuncs.h" #include "parser/parse_relation.h" @@ -128,6 +129,24 @@ void destroy_entity_result_rel_info(ResultRelInfo *result_rel_info) table_close(result_rel_info->ri_RelationDesc, RowExclusiveLock); } +/* + * Clear an entity slot and mark every attribute NULL before AGE fills in the + * columns it manages (id/start_id/end_id/properties). + * + * The slot's tuple descriptor is the full label-table descriptor, which may + * contain columns AGE does not populate -- e.g. a user-added plain column, or a + * GENERATED ALWAYS ... STORED column. Without this, those attributes keep stale + * slot memory and heap_form_tuple() segfaults dereferencing the garbage + * (issue #2450). Plain columns then default to NULL; generated columns are + * recomputed via ExecComputeStoredGenerated() before the tuple is materialized. + */ +void clear_entity_slot(TupleTableSlot *elemTupleSlot) +{ + ExecClearTuple(elemTupleSlot); + memset(elemTupleSlot->tts_isnull, true, + elemTupleSlot->tts_tupleDescriptor->natts * sizeof(bool)); +} + TupleTableSlot *populate_vertex_tts( TupleTableSlot *elemTupleSlot, agtype_value *id, agtype_value *properties) { @@ -139,6 +158,8 @@ TupleTableSlot *populate_vertex_tts( errmsg("vertex id field cannot be NULL"))); } + clear_entity_slot(elemTupleSlot); + properties_isnull = properties == NULL; elemTupleSlot->tts_values[vertex_tuple_id] = GRAPHID_GET_DATUM(id->val.int_value); @@ -174,6 +195,8 @@ TupleTableSlot *populate_edge_tts( errmsg("edge end_id field cannot be NULL"))); } + clear_entity_slot(elemTupleSlot); + properties_isnull = properties == NULL; elemTupleSlot->tts_values[edge_tuple_id] = @@ -208,6 +231,9 @@ bool entity_exists(EState *estate, Oid graph_oid, graphid id) HeapTuple tuple; Relation rel; bool result = true; + TupleTableSlot *slot; + Oid index_oid = InvalidOid; + CommandId saved_curcid; /* * Extract the label id from the graph id and get the table name @@ -219,23 +245,70 @@ bool entity_exists(EState *estate, Oid graph_oid, graphid id) ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, F_GRAPHIDEQ, GRAPHID_GET_DATUM(id)); + /* + * Temporarily advance the snapshot's curcid so that entities inserted + * by preceding clauses (e.g., CREATE) in the same query are visible. + * CREATE calls CommandCounterIncrement() which advances the global + * CID, but does not update es_snapshot->curcid. The Decrement/Increment + * CID macros used by the executors can leave curcid behind the global + * CID, making recently created entities invisible to this scan. + * + * Use Max to ensure we never decrease curcid. The executor macros + * (Increment_Estate_CommandId) can push curcid above the global CID, + * and blindly assigning GetCurrentCommandId could make tuples that + * are visible at the current curcid become invisible. + */ + saved_curcid = estate->es_snapshot->curcid; + estate->es_snapshot->curcid = Max(saved_curcid, + GetCurrentCommandId(false)); + rel = table_open(label->relation, RowExclusiveLock); - scan_desc = table_beginscan(rel, estate->es_snapshot, 1, scan_keys); - tuple = heap_getnext(scan_desc, ForwardScanDirection); + index_oid = find_usable_btree_index_for_attr(rel, 1); - /* - * If a single tuple was returned, the tuple is still valid, otherwise' - * set to false. - */ - if (!HeapTupleIsValid(tuple)) + if (OidIsValid(index_oid)) { - result = false; + IndexScanDesc index_scan_desc; + Relation index_rel; + + slot = table_slot_create(rel, NULL); + + index_rel = index_open(index_oid, AccessShareLock); + + index_scan_desc = index_beginscan(rel, index_rel, estate->es_snapshot, NULL, 1, 0); + index_rescan(index_scan_desc, scan_keys, 1, NULL, 0); + + if (!index_getnext_slot(index_scan_desc, ForwardScanDirection, slot)) + { + result = false; + } + + index_endscan(index_scan_desc); + index_close(index_rel, AccessShareLock); + ExecDropSingleTupleTableSlot(slot); + } + else + { + scan_desc = table_beginscan(rel, estate->es_snapshot, 1, scan_keys); + tuple = heap_getnext(scan_desc, ForwardScanDirection); + + /* + * If a single tuple was returned, the tuple is still valid, otherwise' + * set to false. + */ + if (!HeapTupleIsValid(tuple)) + { + result = false; + } + + table_endscan(scan_desc); } - table_endscan(scan_desc); table_close(rel, RowExclusiveLock); + /* Restore the original curcid */ + estate->es_snapshot->curcid = saved_curcid; + return result; } @@ -268,6 +341,30 @@ HeapTuple insert_entity_tuple_cid(ResultRelInfo *resultRelInfo, HeapTuple tuple = NULL; ExecStoreVirtualTuple(elemTupleSlot); + + /* + * The slot's tuple descriptor is the full relation descriptor, which may + * contain columns AGE does not populate itself -- most notably a + * GENERATED ALWAYS ... STORED column added to the label table. Those slot + * entries are left uninitialized by the create/merge/set paths, so we must + * compute the stored generated columns here before materializing the heap + * tuple. Otherwise heap_form_tuple() reads the uninitialized slot values + * and segfaults dereferencing garbage (issue #2450). + */ + if (resultRelInfo->ri_RelationDesc->rd_att->constr != NULL && + resultRelInfo->ri_RelationDesc->rd_att->constr->has_generated_stored) + { + /* + * A generation expression may reference the tableoid system column, so + * the slot must carry the relation's OID before we compute the stored + * generated columns (mirrors PostgreSQL's own ExecInsert path). + */ + elemTupleSlot->tts_tableOid = + RelationGetRelid(resultRelInfo->ri_RelationDesc); + ExecComputeStoredGenerated(resultRelInfo, estate, elemTupleSlot, + CMD_INSERT); + } + tuple = ExecFetchSlotHeapTuple(elemTupleSlot, true, NULL); /* Check the constraints of the tuple */ diff --git a/src/backend/nodes/ag_nodes.c b/src/backend/nodes/ag_nodes.c index 7aaaecaa5..ac659e1b6 100644 --- a/src/backend/nodes/ag_nodes.c +++ b/src/backend/nodes/ag_nodes.c @@ -64,7 +64,9 @@ const char *node_names[] = { "cypher_update_item", "cypher_delete_information", "cypher_delete_item", - "cypher_merge_information" + "cypher_merge_information", + "cypher_predicate_function", + "cypher_reduce" }; /* @@ -132,7 +134,9 @@ const ExtensibleNodeMethods node_methods[] = { DEFINE_NODE_METHODS_EXTENDED(cypher_update_item), DEFINE_NODE_METHODS_EXTENDED(cypher_delete_information), DEFINE_NODE_METHODS_EXTENDED(cypher_delete_item), - DEFINE_NODE_METHODS_EXTENDED(cypher_merge_information) + DEFINE_NODE_METHODS_EXTENDED(cypher_merge_information), + DEFINE_NODE_METHODS_EXTENDED(cypher_predicate_function), + DEFINE_NODE_METHODS_EXTENDED(cypher_reduce) }; static bool equal_ag_node(const ExtensibleNode *a, const ExtensibleNode *b) diff --git a/src/backend/nodes/cypher_copyfuncs.c b/src/backend/nodes/cypher_copyfuncs.c index 56895ee06..549218759 100644 --- a/src/backend/nodes/cypher_copyfuncs.c +++ b/src/backend/nodes/cypher_copyfuncs.c @@ -136,6 +136,8 @@ void copy_cypher_update_item(ExtensibleNode *newnode, const ExtensibleNode *from COPY_NODE_FIELD(qualified_name); COPY_SCALAR_FIELD(remove_item); COPY_SCALAR_FIELD(is_add); + COPY_NODE_FIELD(prop_expr); + COPY_NODE_FIELD(prop_expr_state); } /* copy function for cypher_delete_information */ @@ -168,4 +170,30 @@ void copy_cypher_merge_information(ExtensibleNode *newnode, const ExtensibleNode COPY_SCALAR_FIELD(graph_oid); COPY_SCALAR_FIELD(merge_function_attr); COPY_NODE_FIELD(path); + COPY_NODE_FIELD(on_match_set_info); + COPY_NODE_FIELD(on_create_set_info); +} + +/* copy function for cypher_predicate_function */ +void copy_cypher_predicate_function(ExtensibleNode *newnode, + const ExtensibleNode *from) +{ + COPY_LOCALS(cypher_predicate_function); + + COPY_SCALAR_FIELD(kind); + COPY_STRING_FIELD(varname); + COPY_NODE_FIELD(expr); + COPY_NODE_FIELD(where); +} + +/* copy function for cypher_reduce */ +void copy_cypher_reduce(ExtensibleNode *newnode, const ExtensibleNode *from) +{ + COPY_LOCALS(cypher_reduce); + + COPY_STRING_FIELD(acc_varname); + COPY_NODE_FIELD(init_expr); + COPY_STRING_FIELD(elem_varname); + COPY_NODE_FIELD(list_expr); + COPY_NODE_FIELD(body_expr); } diff --git a/src/backend/nodes/cypher_outfuncs.c b/src/backend/nodes/cypher_outfuncs.c index 4772621c9..4a35be02f 100644 --- a/src/backend/nodes/cypher_outfuncs.c +++ b/src/backend/nodes/cypher_outfuncs.c @@ -189,12 +189,37 @@ void out_cypher_list_comprehension(StringInfo str, const ExtensibleNode *node) } -/* serialization function for the cypher_delete ExtensibleNode. */ +/* serialization function for the cypher_predicate_function ExtensibleNode. */ +void out_cypher_predicate_function(StringInfo str, const ExtensibleNode *node) +{ + DEFINE_AG_NODE(cypher_predicate_function); + + WRITE_ENUM_FIELD(kind, cypher_predicate_function_kind); + WRITE_STRING_FIELD(varname); + WRITE_NODE_FIELD(expr); + WRITE_NODE_FIELD(where); +} + +/* serialization function for the cypher_reduce ExtensibleNode. */ +void out_cypher_reduce(StringInfo str, const ExtensibleNode *node) +{ + DEFINE_AG_NODE(cypher_reduce); + + WRITE_STRING_FIELD(acc_varname); + WRITE_NODE_FIELD(init_expr); + WRITE_STRING_FIELD(elem_varname); + WRITE_NODE_FIELD(list_expr); + WRITE_NODE_FIELD(body_expr); +} + +/* serialization function for the cypher_merge ExtensibleNode. */ void out_cypher_merge(StringInfo str, const ExtensibleNode *node) { DEFINE_AG_NODE(cypher_merge); WRITE_NODE_FIELD(path); + WRITE_NODE_FIELD(on_match); + WRITE_NODE_FIELD(on_create); } /* serialization function for the cypher_path ExtensibleNode. */ @@ -427,6 +452,8 @@ void out_cypher_update_item(StringInfo str, const ExtensibleNode *node) WRITE_NODE_FIELD(qualified_name); WRITE_BOOL_FIELD(remove_item); WRITE_BOOL_FIELD(is_add); + WRITE_NODE_FIELD(prop_expr); + WRITE_NODE_FIELD(prop_expr_state); } /* serialization function for the cypher_delete_information ExtensibleNode. */ @@ -459,6 +486,8 @@ void out_cypher_merge_information(StringInfo str, const ExtensibleNode *node) WRITE_INT32_FIELD(graph_oid); WRITE_INT32_FIELD(merge_function_attr); WRITE_NODE_FIELD(path); + WRITE_NODE_FIELD(on_match_set_info); + WRITE_NODE_FIELD(on_create_set_info); } /* diff --git a/src/backend/nodes/cypher_readfuncs.c b/src/backend/nodes/cypher_readfuncs.c index a58b90cbc..a9a2ffabd 100644 --- a/src/backend/nodes/cypher_readfuncs.c +++ b/src/backend/nodes/cypher_readfuncs.c @@ -269,6 +269,8 @@ void read_cypher_update_item(struct ExtensibleNode *node) READ_NODE_FIELD(qualified_name); READ_BOOL_FIELD(remove_item); READ_BOOL_FIELD(is_add); + READ_NODE_FIELD(prop_expr); + READ_NODE_FIELD(prop_expr_state); } /* @@ -310,4 +312,34 @@ void read_cypher_merge_information(struct ExtensibleNode *node) READ_UINT_FIELD(graph_oid); READ_INT_FIELD(merge_function_attr); READ_NODE_FIELD(path); + READ_NODE_FIELD(on_match_set_info); + READ_NODE_FIELD(on_create_set_info); +} + +/* + * Deserialize a string representing the cypher_predicate_function + * data structure. + */ +void read_cypher_predicate_function(struct ExtensibleNode *node) +{ + READ_LOCALS(cypher_predicate_function); + + READ_ENUM_FIELD(kind, cypher_predicate_function_kind); + READ_STRING_FIELD(varname); + READ_NODE_FIELD(expr); + READ_NODE_FIELD(where); +} + +/* + * Deserialize a string representing the cypher_reduce data structure. + */ +void read_cypher_reduce(struct ExtensibleNode *node) +{ + READ_LOCALS(cypher_reduce); + + READ_STRING_FIELD(acc_varname); + READ_NODE_FIELD(init_expr); + READ_STRING_FIELD(elem_varname); + READ_NODE_FIELD(list_expr); + READ_NODE_FIELD(body_expr); } diff --git a/src/backend/optimizer/cypher_paths.c b/src/backend/optimizer/cypher_paths.c index 6c4fd7e07..6d2e400c0 100644 --- a/src/backend/optimizer/cypher_paths.c +++ b/src/backend/optimizer/cypher_paths.c @@ -19,6 +19,7 @@ #include "postgres.h" +#include "catalog/ag_catalog.h" #include "optimizer/pathnode.h" #include "optimizer/paths.h" @@ -64,7 +65,14 @@ static void set_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, Index rti, RangeTblEntry *rte) { if (prev_set_rel_pathlist_hook) + { prev_set_rel_pathlist_hook(root, rel, rti, rte); + } + + if (!is_age_extension_exists()) + { + return; + } switch (get_cypher_clause_kind(rte)) { diff --git a/src/backend/parser/ag_scanner.l b/src/backend/parser/ag_scanner.l index d5d72b926..c1e156c39 100644 --- a/src/backend/parser/ag_scanner.l +++ b/src/backend/parser/ag_scanner.l @@ -195,6 +195,8 @@ dquote \" dqchars [^"\\]+ squote ' sqchars [^'\\]+ +esdquote {dquote}{dquote} +essquote {squote}{squote} esascii \\["'/\\bfnrt] esasciifail \\[^Uu]? esunicode \\(U{hexdigit}{8}|u{hexdigit}{4}) @@ -420,6 +422,14 @@ ag_token token; strbuf_append_buf(&yyextra.literal_buf, yytext, yyleng); } +{esdquote} { + strbuf_append_char(&yyextra.literal_buf, '"'); +} + +{essquote} { + strbuf_append_char(&yyextra.literal_buf, '\''); +} + {esascii} { char c; diff --git a/src/backend/parser/cypher_analyze.c b/src/backend/parser/cypher_analyze.c index a408eea6e..5dd53dcd0 100644 --- a/src/backend/parser/cypher_analyze.c +++ b/src/backend/parser/cypher_analyze.c @@ -19,6 +19,7 @@ #include "postgres.h" +#include "catalog/ag_catalog.h" #include "nodes/makefuncs.h" #include "nodes/nodeFuncs.h" #include "parser/analyze.h" @@ -86,6 +87,11 @@ static void post_parse_analyze(ParseState *pstate, Query *query, JumbleState *js prev_post_parse_analyze_hook(pstate, query, jstate); } + if (!is_age_extension_exists()) + { + return; + } + /* * extra_node is set in the parsing stage to keep track of EXPLAIN. * So it needs to be set to NULL prior to any cypher parsing. @@ -843,6 +849,41 @@ bool cypher_raw_expr_tree_walker_impl(Node *node, return true; } } + else if (is_ag_node(node, cypher_predicate_function)) + { + cypher_predicate_function *pf; + + pf = (cypher_predicate_function *)node; + + if (WALK(pf->expr)) + { + return true; + } + + if (WALK(pf->where)) + { + return true; + } + } + else if (is_ag_node(node, cypher_reduce)) + { + cypher_reduce *rd = (cypher_reduce *)node; + + if (WALK(rd->init_expr)) + { + return true; + } + + if (WALK(rd->list_expr)) + { + return true; + } + + if (WALK(rd->body_expr)) + { + return true; + } + } /* Add more node types here as needed */ else { @@ -945,9 +986,8 @@ static Query *analyze_cypher(List *stmt, ParseState *parent_pstate, * convert ParseState into cypher_parsestate temporarily to pass it to * make_cypher_parsestate() */ + MemSet(&parent_cpstate, 0, sizeof(parent_cpstate)); parent_cpstate.pstate = *parent_pstate; - parent_cpstate.graph_name = NULL; - parent_cpstate.params = NULL; cpstate = make_cypher_parsestate(&parent_cpstate); diff --git a/src/backend/parser/cypher_clause.c b/src/backend/parser/cypher_clause.c index 991e3f785..147e3e74e 100644 --- a/src/backend/parser/cypher_clause.c +++ b/src/backend/parser/cypher_clause.c @@ -25,6 +25,7 @@ #include "postgres.h" #include "access/heapam.h" +#include "catalog/pg_aggregate.h" #include "miscadmin.h" #include "nodes/nodeFuncs.h" #include "optimizer/optimizer.h" @@ -38,6 +39,8 @@ #include "parser/parsetree.h" #include "parser/parse_relation.h" #include "rewrite/rewriteHandler.h" +#include "utils/builtins.h" +#include "utils/lsyscache.h" #include "catalog/ag_graph.h" #include "catalog/ag_label.h" @@ -47,11 +50,16 @@ #include "parser/cypher_expr.h" #include "parser/cypher_item.h" #include "parser/cypher_parse_agg.h" +#include "utils/agtype.h" #include "parser/cypher_transform_entity.h" #include "utils/ag_cache.h" #include "utils/ag_func.h" #include "utils/ag_guc.h" +#ifndef INT8PASSBYVAL +#define INT8PASSBYVAL FLOAT8PASSBYVAL +#endif + /* * Variable string names for makeTargetEntry. As they are going to be variable * names that will be hidden from the user, we need to do our best to make sure @@ -129,6 +137,60 @@ static Expr *transform_cypher_edge(cypher_parsestate *cpstate, static Expr *transform_cypher_node(cypher_parsestate *cpstate, cypher_node *node, List **target_list, bool output_node, bool valid_label); +/* + * Issue #2382: For variable-length relationships with a lower bound of 0 + * (e.g., [:LABEL*0..N]), the zero-hop self-binding case must succeed even + * when LABEL is missing from the cache, because Neo4j/openCypher semantics + * say a zero-hop pattern matches the same node regardless of any edges. + * + * By the time match_check_valid_label() runs, build_VLE_relation() (in + * cypher_gram.y) has rewritten cypher_relationship.varlen from A_Indices + * into a FuncCall named "vle" whose argument list is: + * (start_id, end_id, edge_match_proto, lidx, uidx, dir, unique_id) + * so the lower-bound is the 4th argument (1-based). + * + * This helper is intentionally defensive: every assumption about the shape + * of the FuncCall is guarded so any parser refactor that changes it will + * fall back to "not zero-bound", which is the safe behaviour (the existing + * false-where short-circuit will still kick in for impossible patterns). + */ +static bool is_zero_lower_bound_vle(Node *varlen) +{ + FuncCall *fc; + String *fname; + Node *lidx_node; + A_Const *lidx; + + if (varlen == NULL || !IsA(varlen, FuncCall)) + return false; + + fc = (FuncCall *) varlen; + + if (list_length(fc->funcname) != 1) + return false; + fname = (String *) linitial(fc->funcname); + if (fname == NULL || !IsA(fname, String)) + return false; + if (strcmp(strVal(fname), "vle") != 0) + return false; + + /* args = {start, end, edge_match, lidx, uidx, dir, uniq} */ + if (list_length(fc->args) < 5) + return false; + + lidx_node = (Node *) list_nth(fc->args, 3); + if (lidx_node == NULL || !IsA(lidx_node, A_Const)) + return false; + + lidx = (A_Const *) lidx_node; + if (lidx->isnull) + return false; + if (lidx->val.ival.type != T_Integer) + return false; + + return lidx->val.ival.ival == 0; +} + static bool match_check_valid_label(cypher_match *match, cypher_parsestate *cpstate); static Node *make_vertex_expr(cypher_parsestate *cpstate, @@ -254,6 +316,14 @@ static Query *transform_cypher_unwind(cypher_parsestate *cpstate, static Query *transform_cypher_list_comprehension(cypher_parsestate *cpstate, cypher_clause *clause); +/* predicate functions */ +static Query *transform_cypher_predicate_function(cypher_parsestate *cpstate, + cypher_clause *clause); + +/* reduce */ +static Query *transform_cypher_reduce(cypher_parsestate *cpstate, + cypher_clause *clause); + /* merge */ static Query *transform_cypher_merge(cypher_parsestate *cpstate, cypher_clause *clause); @@ -345,6 +415,9 @@ static bool isa_special_VLE_case(cypher_path *path); static ParseNamespaceItem *find_pnsi(cypher_parsestate *cpstate, char *varname); static bool has_list_comp_or_subquery(Node *expr, void *context); +static bool clause_is_dml(cypher_clause *clause); +static bool clause_chain_has_dml(cypher_clause *clause); +static Node *make_false_where_clause(bool volatile_needed); /* * Add required permissions to the RTEPermissionInfo for a relation. @@ -511,11 +584,32 @@ Query *transform_cypher_clause(cypher_parsestate *cpstate, { result = transform_cypher_list_comprehension(cpstate, clause); } + else if (is_ag_node(self, cypher_predicate_function)) + { + result = transform_cypher_predicate_function(cpstate, clause); + } + else if (is_ag_node(self, cypher_reduce)) + { + result = transform_cypher_reduce(cpstate, clause); + } else { ereport(ERROR, (errmsg_internal("unexpected Node for cypher_clause"))); } + /* + * Force a terminal data-modifying clause (CREATE/SET/DELETE/MERGE) to emit + * agtype instead of the vertex/edge composite. Its executor and the + * consuming cypher() SRF operate on agtype. Read clauses may emit composites + * directly, so this is restricted to a trailing DML clause. The appended + * clause FuncExpr is already agtype and is skipped by the coercion. + */ + if (clause->next == NULL && clause_is_dml(clause)) + { + coerce_target_entities_to_agtype((ParseState *) cpstate, + result->targetList); + } + result->querySource = QSRC_ORIGINAL; result->canSetTag = true; @@ -1004,6 +1098,15 @@ transform_cypher_union_tree(cypher_parsestate *cpstate, cypher_clause *clause, int32 rescoltypmod; Oid rescolcoll; + /* Cast vertex/edge to agtype for UNION compatibility */ + lcolnode = coerce_entity_to_agtype(pstate, lcolnode); + ltle->expr = (Expr *) lcolnode; + lcoltype = exprType(lcolnode); + + rcolnode = coerce_entity_to_agtype(pstate, rcolnode); + rtle->expr = (Expr *) rcolnode; + rcoltype = exprType(rcolnode); + /* select common type, same as CASE et al */ rescoltype = select_common_type(pstate, list_make2(lcolnode, rcolnode), @@ -1601,6 +1704,1091 @@ static Query *transform_cypher_list_comprehension(cypher_parsestate *cpstate, return query; } +/* + * Helper: build a BooleanTest node (pred IS TRUE, pred IS FALSE, etc.) + */ +static Node *make_boolean_test(Node *arg, BoolTestType testtype) +{ + BooleanTest *bt = makeNode(BooleanTest); + + bt->arg = (Expr *) arg; + bt->booltesttype = testtype; + bt->location = -1; + + return (Node *) bt; +} + +/* + * Helper: build a fully-transformed bool_or(expr) Aggref node. + * + * The argument must already be a transformed boolean expression. + * We construct the Aggref manually to avoid going through FuncCall + * + transformExpr, which expects raw parse tree nodes. + */ +static Node *make_bool_or_agg(ParseState *pstate, Node *arg) +{ + Aggref *agg; + TargetEntry *te; + Oid bool_or_oid; + Oid argtypes[1] = { BOOLOID }; + + /* Look up bool_or(boolean) */ + bool_or_oid = LookupFuncName(list_make1(makeString("bool_or")), + 1, argtypes, false); + + /* Build the TargetEntry for the aggregate argument */ + te = makeTargetEntry((Expr *) arg, 1, NULL, false); + + /* Construct the Aggref */ + agg = makeNode(Aggref); + agg->aggfnoid = bool_or_oid; + agg->aggtype = BOOLOID; + agg->aggcollid = InvalidOid; + agg->inputcollid = InvalidOid; + agg->aggtranstype = InvalidOid; /* filled by planner */ + agg->aggargtypes = list_make1_oid(BOOLOID); + agg->aggdirectargs = NIL; + agg->args = list_make1(te); + agg->aggorder = NIL; + agg->aggdistinct = NIL; + agg->aggfilter = NULL; + agg->aggstar = false; + agg->aggvariadic = false; + agg->aggkind = AGGKIND_NORMAL; + agg->aggpresorted = false; + agg->agglevelsup = 0; + agg->aggsplit = AGGSPLIT_SIMPLE; + agg->aggno = -1; + agg->aggtransno = -1; + agg->location = -1; + + /* Register the aggregate with the parse state */ + pstate->p_hasAggs = true; + + return (Node *) agg; +} + +/* + * Helper: build a fully-transformed `count(*) FILTER (WHERE filter)` Aggref. + * + * The filter must already be a transformed boolean expression. + */ +static Node *make_count_star_filter_agg(ParseState *pstate, Node *filter) +{ + Aggref *agg; + Oid count_oid; + + /* count() -- the zero-argument count-star form */ + count_oid = LookupFuncName(list_make1(makeString("count")), + 0, NULL, false); + + agg = makeNode(Aggref); + agg->aggfnoid = count_oid; + agg->aggtype = INT8OID; + agg->aggcollid = InvalidOid; + agg->inputcollid = InvalidOid; + agg->aggtranstype = InvalidOid; /* filled by planner */ + agg->aggargtypes = NIL; + agg->aggdirectargs = NIL; + agg->args = NIL; + agg->aggorder = NIL; + agg->aggdistinct = NIL; + agg->aggfilter = (Expr *) filter; + agg->aggstar = true; + agg->aggvariadic = false; + agg->aggkind = AGGKIND_NORMAL; + agg->aggpresorted = false; + agg->agglevelsup = 0; + agg->aggsplit = AGGSPLIT_SIMPLE; + agg->aggno = -1; + agg->aggtransno = -1; + agg->location = -1; + + pstate->p_hasAggs = true; + + return (Node *) agg; +} + +/* + * Helper: build a transformed CASE expression implementing three-valued + * predicate logic for all(), any(), none(), and single(). + * + * any(): CASE WHEN bool_or(pred IS TRUE) THEN true + * WHEN bool_or(pred IS NULL) THEN NULL + * ELSE false END + * + * all(): CASE WHEN bool_or(pred IS FALSE) THEN false + * WHEN bool_or(pred IS NULL) THEN NULL + * ELSE true END + * + * none(): CASE WHEN bool_or(pred IS TRUE) THEN false + * WHEN bool_or(pred IS NULL) THEN NULL + * ELSE true END + * + * single(): CASE WHEN count(*) FILTER (WHERE pred IS TRUE) >= 2 THEN false + * WHEN bool_or(pred IS NULL) THEN NULL + * WHEN count(*) FILTER (WHERE pred IS TRUE) = 1 THEN true + * ELSE false END + * + * Empty list: bool_or returns NULL on zero rows and count(*) returns 0, so + * the CASE falls through to the default: false for any()/single(), true for + * all()/none(). This matches Cypher's vacuous truth semantics. + */ +static Node *make_predicate_case_expr(ParseState *pstate, Node *pred, + cypher_predicate_function_kind kind) +{ + CaseExpr *cexpr; + CaseWhen *when1, *when2; + Node *bool_or_first, *bool_or_null; + Node *true_const, *false_const, *null_const; + + /* boolean constants */ + true_const = makeBoolConst(true, false); + false_const = makeBoolConst(false, false); + null_const = makeBoolConst(false, true); /* isnull = true */ + + /* Second branch is common to all: bool_or(pred IS NULL) -> NULL */ + bool_or_null = make_bool_or_agg(pstate, + make_boolean_test(pred, IS_UNKNOWN)); + + when2 = makeNode(CaseWhen); + when2->expr = (Expr *) bool_or_null; + when2->result = (Expr *) null_const; + when2->location = -1; + + if (kind == CPFK_ALL) + { + /* bool_or(pred IS FALSE) -> false */ + bool_or_first = make_bool_or_agg(pstate, + make_boolean_test(pred, IS_FALSE)); + when1 = makeNode(CaseWhen); + when1->expr = (Expr *) bool_or_first; + when1->result = (Expr *) false_const; + when1->location = -1; + + cexpr = makeNode(CaseExpr); + cexpr->casetype = BOOLOID; + cexpr->arg = NULL; + cexpr->args = list_make2(when1, when2); + cexpr->defresult = (Expr *) true_const; + cexpr->location = -1; + } + else if (kind == CPFK_ANY) + { + /* bool_or(pred IS TRUE) -> true */ + bool_or_first = make_bool_or_agg(pstate, + make_boolean_test(pred, IS_TRUE)); + when1 = makeNode(CaseWhen); + when1->expr = (Expr *) bool_or_first; + when1->result = (Expr *) true_const; + when1->location = -1; + + cexpr = makeNode(CaseExpr); + cexpr->casetype = BOOLOID; + cexpr->arg = NULL; + cexpr->args = list_make2(when1, when2); + cexpr->defresult = (Expr *) false_const; + cexpr->location = -1; + } + else if (kind == CPFK_NONE) + { + /* bool_or(pred IS TRUE) -> false */ + bool_or_first = make_bool_or_agg(pstate, + make_boolean_test(pred, IS_TRUE)); + when1 = makeNode(CaseWhen); + when1->expr = (Expr *) bool_or_first; + when1->result = (Expr *) false_const; + when1->location = -1; + + cexpr = makeNode(CaseExpr); + cexpr->casetype = BOOLOID; + cexpr->arg = NULL; + cexpr->args = list_make2(when1, when2); + cexpr->defresult = (Expr *) true_const; + cexpr->location = -1; + } + else /* CPFK_SINGLE */ + { + /* + * Three WHEN arms, in order: + * count(*) FILTER (pred IS TRUE) >= 2 -> false (definitely >1) + * bool_or(pred IS NULL) -> NULL (unknown arm) + * count(*) FILTER (pred IS TRUE) = 1 -> true (exactly one) + * else -> false (zero true) + * + * The >=2 arm is tested first so that 2-or-more definite trues + * win over any null predicates, matching Neo4j semantics. + */ + CaseWhen *when0, *when3; + Node *count_ge_2, *count_eq_1; + Node *count_true_a, *count_true_b; + Node *int_2 = (Node *) makeConst(INT8OID, -1, InvalidOid, sizeof(int64), + Int64GetDatum(2), false, INT8PASSBYVAL); + Node *int_1 = (Node *) makeConst(INT8OID, -1, InvalidOid, sizeof(int64), + Int64GetDatum(1), false, INT8PASSBYVAL); + + /* + * Two separate Aggref nodes for count(*) FILTER (pred IS TRUE); + * the planner will common-subexpression them back to a single + * aggregate evaluation. + */ + count_true_a = make_count_star_filter_agg(pstate, + make_boolean_test(pred, IS_TRUE)); + count_true_b = make_count_star_filter_agg(pstate, + make_boolean_test(pred, IS_TRUE)); + + count_ge_2 = (Node *) make_op(pstate, list_make1(makeString(">=")), + count_true_a, int_2, + pstate->p_last_srf, -1); + count_eq_1 = (Node *) make_op(pstate, list_make1(makeString("=")), + count_true_b, int_1, + pstate->p_last_srf, -1); + + /* first arm: count >= 2 -> false */ + when0 = makeNode(CaseWhen); + when0->expr = (Expr *) count_ge_2; + when0->result = (Expr *) false_const; + when0->location = -1; + + /* third arm: count = 1 -> true */ + when3 = makeNode(CaseWhen); + when3->expr = (Expr *) count_eq_1; + when3->result = (Expr *) true_const; + when3->location = -1; + + cexpr = makeNode(CaseExpr); + cexpr->casetype = BOOLOID; + cexpr->arg = NULL; + cexpr->args = list_make3(when0, when2, when3); + cexpr->defresult = (Expr *) false_const; + cexpr->location = -1; + } + + return (Node *) cexpr; +} + +/* + * Transform a cypher_predicate_function node into a query tree. + * + * Generates aggregate-based queries that preserve Cypher's three-valued + * NULL semantics. The grammar layer wraps the SubLink with a + * CASE WHEN list IS NULL THEN NULL ELSE (subquery) END guard so all + * four functions return NULL when the input list is NULL. + * + * For all()/any()/none(): + * SELECT CASE WHEN bool_or(pred IS TRUE/FALSE) THEN ... + * WHEN bool_or(pred IS NULL) THEN NULL + * ELSE ... END + * FROM unnest(list) AS x + * + * For single(): + * SELECT count(*) + * FROM unnest(list) AS x + * WHERE pred IS TRUE + * + * All four use EXPR_SUBLINK so the subquery returns a scalar value. + */ +static Query *transform_cypher_predicate_function(cypher_parsestate *cpstate, + cypher_clause *clause) +{ + Query *query; + RangeFunction *rf; + cypher_predicate_function *pred_func; + FuncCall *func_call; + Node *pred, *n; + RangeTblEntry *rte = NULL; + int rtindex; + List *namespace = NULL; + TargetEntry *te; + cypher_parsestate *child_cpstate = make_cypher_parsestate(cpstate); + ParseState *child_pstate = (ParseState *) child_cpstate; + + pred_func = (cypher_predicate_function *) clause->self; + + query = makeNode(Query); + query->commandType = CMD_SELECT; + + /* FROM unnest(expr) AS varname */ + func_call = makeFuncCall(list_make1(makeString("unnest")), + list_make1(pred_func->expr), + COERCE_SQL_SYNTAX, -1); + + rf = makeNode(RangeFunction); + rf->lateral = false; + rf->ordinality = false; + rf->is_rowsfrom = false; + rf->functions = list_make1(list_make2((Node *) func_call, NIL)); + rf->alias = makeAlias(pred_func->varname, NIL); + rf->coldeflist = NIL; + + n = transform_from_clause_item(child_cpstate, (Node *) rf, + &rte, &rtindex, &namespace); + checkNameSpaceConflicts(child_pstate, child_pstate->p_namespace, namespace); + child_pstate->p_joinlist = lappend(child_pstate->p_joinlist, n); + child_pstate->p_namespace = list_concat(child_pstate->p_namespace, + namespace); + + /* make all namespace items unconditionally visible */ + setNamespaceLateralState(child_pstate->p_namespace, false, true); + + /* Transform the predicate expression */ + pred = transform_cypher_expr(child_cpstate, pred_func->where, + EXPR_KIND_WHERE); + if (pred) + { + pred = coerce_to_boolean(child_pstate, pred, "WHERE"); + } + + /* + * Build a CASE expression that preserves three-valued NULL semantics. + * No WHERE clause -- the logic is entirely in the SELECT list, so the + * CASE can see null predicates and react to them. + */ + { + Node *case_expr; + + case_expr = make_predicate_case_expr(child_pstate, pred, + pred_func->kind); + + te = makeTargetEntry((Expr *) case_expr, + (AttrNumber) child_pstate->p_next_resno++, + "result", false); + + query->targetList = lappend(query->targetList, te); + query->jointree = makeFromExpr(child_pstate->p_joinlist, NULL); + query->rtable = child_pstate->p_rtable; + query->rteperminfos = child_pstate->p_rteperminfos; + query->hasAggs = child_pstate->p_hasAggs; + query->hasSubLinks = child_pstate->p_hasSubLinks; + query->hasTargetSRFs = child_pstate->p_hasTargetSRFs; + + assign_query_collations(child_pstate, query); + + if (child_pstate->p_hasAggs || + query->groupClause || query->groupingSets || query->havingQual) + { + parse_check_aggregates(child_pstate, query); + } + + free_cypher_parsestate(child_cpstate); + + return query; + } +} + +/* + * Mutator context for rewriting the fold body's accumulator/element Vars + * (columns 1 and 2 of the throwaway namespace RTE) into PARAM_EXEC params. + */ +typedef struct reduce_var_param_context +{ + int varno; /* rangetable index of the dummy (acc, elem) RTE */ +} reduce_var_param_context; + +/* + * Rewrite Var(varno, 1) -> Param(PARAM_EXEC, 0) [accumulator] and + * Var(varno, 2) -> Param(PARAM_EXEC, 1) [element] in the transformed fold + * body, so the body can be evaluated standalone inside age_reduce_transfn + * with the two params rebound for every element. + */ +static Node *reduce_var_to_param_mutator(Node *node, void *context) +{ + reduce_var_param_context *ctx = (reduce_var_param_context *) context; + + if (node == NULL) + { + return NULL; + } + + if (IsA(node, Var)) + { + Var *var = (Var *) node; + + /* + * Only the dummy (acc, elem) RTE at this level is rewritten. The + * varlevelsup == 0 check is essential: an outer-query RTE can share + * the same varno (each parse state's range table is numbered from 1), + * so without it a correlated outer reference at attno 1/2 would be + * silently rewritten into the accumulator/element param. Outer Vars + * are instead left in place and rejected by reduce_body_check_walker. + */ + if (var->varno == ctx->varno && var->varlevelsup == 0 && + (var->varattno == 1 || var->varattno == 2)) + { + Param *param = makeNode(Param); + + param->paramkind = PARAM_EXEC; + param->paramid = var->varattno - 1; + param->paramtype = AGTYPEOID; + param->paramtypmod = -1; + param->paramcollid = InvalidOid; + param->location = -1; + + return (Node *) param; + } + } + + return expression_tree_mutator(node, reduce_var_to_param_mutator, context); +} + +/* + * Build a throwaway subquery "SELECT NULL::agtype AS , NULL::agtype AS + * " used only to give the fold body a namespace in which the accumulator + * and element variables resolve to agtype columns. Those references are later + * rewritten to PARAM_EXEC params and the subquery is discarded. + */ +static Query *make_reduce_var_subquery(char *acc_name, char *elem_name) +{ + Query *subquery = makeNode(Query); + Const *acc_const; + Const *elem_const; + TargetEntry *acc_te; + TargetEntry *elem_te; + + acc_const = makeConst(AGTYPEOID, -1, InvalidOid, -1, (Datum) 0, true, false); + elem_const = makeConst(AGTYPEOID, -1, InvalidOid, -1, (Datum) 0, true, false); + + acc_te = makeTargetEntry((Expr *) acc_const, 1, acc_name, false); + elem_te = makeTargetEntry((Expr *) elem_const, 2, elem_name, false); + + subquery->commandType = CMD_SELECT; + subquery->targetList = list_make2(acc_te, elem_te); + subquery->jointree = makeFromExpr(NIL, NULL); + subquery->rtable = NIL; + subquery->rteperminfos = NIL; + + return subquery; +} + +/* + * Walker: true if the subtree references the reduce() accumulator or element, + * i.e. it contains PARAM_EXEC param 0 or 1 (assigned by + * reduce_var_to_param_mutator). Such a subtree changes per element and cannot + * be captured as a loop-invariant outer value. + */ +static bool reduce_expr_has_acc_elem(Node *node, void *context) +{ + if (node == NULL) + { + return false; + } + + if (IsA(node, Param)) + { + Param *param = (Param *) node; + + if (param->paramkind == PARAM_EXEC && + (param->paramid == 0 || param->paramid == 1)) + { + return true; + } + } + + return expression_tree_walker(node, reduce_expr_has_acc_elem, context); +} + +/* + * Walker: true if the subtree contains an aggregate, grouping, or window + * function. Such a node cannot be evaluated standalone and must not be folded + * into a captured outer value (it would become an illegal nested aggregate). + */ +static bool reduce_expr_has_aggregate(Node *node, void *context) +{ + if (node == NULL) + { + return false; + } + + if (IsA(node, Aggref) || IsA(node, GroupingFunc) || IsA(node, WindowFunc)) + { + return true; + } + + return expression_tree_walker(node, reduce_expr_has_aggregate, context); +} + +/* + * Walker: true if the subtree references anything that cannot be evaluated + * standalone -- an outer-query Var or a non-PARAM_EXEC parameter (e.g. a + * cypher() $parameter, which transforms to agtype_access_operator over a + * PARAM_EXTERN). Such a subtree must be captured and supplied to the fold via + * the extras array. A subtree of only constants does not need capturing. + */ +static bool reduce_expr_needs_capture(Node *node, void *context) +{ + if (node == NULL) + { + return false; + } + + if (IsA(node, Var)) + { + return true; + } + + if (IsA(node, Param)) + { + Param *param = (Param *) node; + + if (param->paramkind != PARAM_EXEC) + { + return true; + } + } + + return expression_tree_walker(node, reduce_expr_needs_capture, context); +} + +/* + * Walker: true if the subtree contains a subquery (SubLink). A captured outer + * value is supplied to the aggregate as a plain expression argument, which the + * standalone fold evaluator cannot plan, so a subtree containing a subquery is + * never captured -- it falls through to the explicit rejection instead. + */ +static bool reduce_expr_has_sublink(Node *node, void *context) +{ + if (node == NULL) + { + return false; + } + + if (IsA(node, SubLink)) + { + return true; + } + + return expression_tree_walker(node, reduce_expr_has_sublink, context); +} + +/* + * Walker: true if the subtree contains an outer reference that is not itself + * agtype-typed -- a non-agtype Var, or a non-agtype non-PARAM_EXEC Param. The + * common case is the graphid component of a graph vertex/edge variable: a + * pattern variable expands to a builder over its underlying columns, one of + * which is a graphid Var. Such a value cannot stand alone as an agtype[] extra, + * so its smallest enclosing agtype-typed subtree is captured whole rather than + * being decomposed to leaves. + */ +static bool reduce_expr_has_nonagtype_outer(Node *node, void *context) +{ + if (node == NULL) + { + return false; + } + + if (IsA(node, Var)) + { + if (((Var *) node)->vartype != AGTYPEOID) + { + return true; + } + } + + if (IsA(node, Param)) + { + Param *param = (Param *) node; + + if (param->paramkind != PARAM_EXEC && param->paramtype != AGTYPEOID) + { + return true; + } + } + + return expression_tree_walker(node, reduce_expr_has_nonagtype_outer, + context); +} + +/* + * Mutator context for capturing loop-invariant outer references in a reduce() + * fold body. Each captured subtree is assigned the next PARAM_EXEC id (starting + * at 2, after the accumulator and element) and collected, in id order, so the + * caller can supply the values to age_reduce_transfn through the extras array. + */ +typedef struct reduce_capture_context +{ + int next_slot; /* next PARAM_EXEC id to assign (starts at 2) */ + List *captured; /* captured outer-reference exprs, in slot order */ +} reduce_capture_context; + +/* + * Capture the loop-invariant outer references in a reduce() fold body. + * + * After reduce_var_to_param_mutator() has rewritten the accumulator and + * element to PARAM_EXEC params 0 and 1, the remaining outer references (outer- + * query variables and cypher() $parameters) are replaced by new PARAM_EXEC + * params 2, 3, ... and collected in slot order. Each captured value is + * loop-invariant within a fold, so the executor evaluates it once per row in + * the outer query context (as an aggregate argument) and binds it to its slot; + * the body expression is then evaluated per element by the fold. + * + * Capture is as fine-grained as the agtype[] extras argument allows, because a + * captured value becomes an aggregate argument that the executor evaluates + * eagerly and unconditionally. Hoisting a whole computed subtree out of the + * body would defeat short-circuiting: in + * reduce(s = 0, x IN [1] | CASE WHEN false THEN s + 1/z ELSE s END) + * capturing "1/z" would divide by zero even though the WHEN branch is never + * taken. So when every outer reference in a loop-invariant subtree is itself + * agtype-typed, the mutator recurses and captures only the bare leaves (here + * "z"), leaving the operators and CASE/AND/OR branches in the body under the + * fold's own control flow. A leaf read cannot raise an error, so evaluating it + * eagerly is safe; the only cost is re-evaluating a loop-invariant + * sub-computation per element, which is always correct. + * + * The exception is an outer reference that is not agtype-typed and so cannot be + * an agtype[] extra on its own -- most commonly the graphid inside a graph + * vertex/edge variable, which expands to a builder over its underlying columns. + * Such a subtree cannot be decomposed to agtype leaves, so its smallest + * enclosing agtype-typed subtree is captured whole (for example the scalar + * value of "u.vals[0]"). A property read like that cannot raise an error + * either, so eager evaluation is still safe. + * + * Aggregates and subqueries (including a nested reduce()) are rejected + * outright: an aggregate is undefined inside a per-element fold, and a subquery + * cannot be supplied as a plain aggregate argument or evaluated standalone. + */ +static Node *reduce_capture_mutator(Node *node, void *context) +{ + reduce_capture_context *ctx = (reduce_capture_context *) context; + + if (node == NULL) + { + return NULL; + } + + /* + * Container / support nodes that expression_tree_mutator hands us are not + * themselves typed expressions (calling exprType on them errors), so just + * recurse into them. For an agtype scalar fold body these are List nodes + * (argument lists) and CaseWhen nodes (CASE branches). + */ + if (IsA(node, List) || IsA(node, CaseWhen)) + { + return expression_tree_mutator(node, reduce_capture_mutator, context); + } + + /* an aggregate in the fold body is never supported */ + if (IsA(node, Aggref) || IsA(node, GroupingFunc) || IsA(node, WindowFunc)) + { + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("aggregate functions are not supported in a reduce() expression"))); + } + + /* + * A loop-invariant, agtype-typed subtree that references an outer value and + * embeds no aggregate or subquery is a capture candidate. The exprType + * guard is evaluated first and the accumulator/element and subquery tests + * short-circuit the rest, so the walkers are never run on a non-agtype + * wrapper or descended into a nested reduce()'s subquery -- both of which + * would otherwise trip the tree walker on a node it cannot type. + */ + if (exprType(node) == AGTYPEOID && + !reduce_expr_has_acc_elem(node, NULL) && + !reduce_expr_has_aggregate(node, NULL) && + !reduce_expr_has_sublink(node, NULL) && + reduce_expr_needs_capture(node, NULL)) + { + /* + * Capture the whole subtree when it is a bare outer leaf (a Var or a + * cypher() $parameter Param) or the smallest enclosing agtype-typed + * wrapper of a non-agtype outer reference -- most commonly the graphid + * of a graph vertex/edge variable, which cannot stand alone as an + * agtype[] extra. Such a value is a plain read that cannot raise an + * error, so evaluating it eagerly as an aggregate argument is safe. + */ + if (IsA(node, Var) || + (IsA(node, Param) && ((Param *) node)->paramkind != PARAM_EXEC) || + reduce_expr_has_nonagtype_outer(node, NULL)) + { + Param *param = makeNode(Param); + + param->paramkind = PARAM_EXEC; + param->paramid = ctx->next_slot++; + param->paramtype = AGTYPEOID; + param->paramtypmod = -1; + param->paramcollid = InvalidOid; + param->location = -1; + + ctx->captured = lappend(ctx->captured, copyObject(node)); + + return (Node *) param; + } + + /* + * Otherwise every outer reference in the subtree is agtype-typed and + * the node itself is a computation (an operator, function call, or CASE + * result). Recurse to capture those outer leaves individually and leave + * the computation in the body, so the fold's own control flow -- not an + * eagerly evaluated aggregate argument -- decides whether it runs. This + * is what preserves CASE/AND/OR short-circuiting. + */ + return expression_tree_mutator(node, reduce_capture_mutator, context); + } + + /* + * A subquery in the body (for example a nested reduce()) is never captured + * -- the capture test above excludes it -- and cannot be evaluated + * standalone by the fold; reject it. + */ + if (IsA(node, SubLink)) + { + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("subqueries (including a nested reduce()) are not supported in a reduce() expression"))); + } + + return expression_tree_mutator(node, reduce_capture_mutator, context); +} + +/* + * Safety net run after reduce_capture_mutator(). A valid body now references + * only PARAM_EXEC params (0/1 for the accumulator and element, 2.. for the + * captured outer values) and constants. Any remaining Var or non-PARAM_EXEC + * parameter is an outer reference that could not be captured (for example a + * non-agtype-typed one); reject it cleanly rather than letting it reach the + * standalone evaluator. + */ +static bool reduce_body_check_walker(Node *node, void *context) +{ + if (node == NULL) + { + return false; + } + + if (IsA(node, Var)) + { + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("a reduce() expression references a value that cannot be used in the fold body"))); + } + + if (IsA(node, Param)) + { + Param *param = (Param *) node; + + if (param->paramkind != PARAM_EXEC) + { + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("a reduce() expression references a value that cannot be used in the fold body"))); + } + } + + return expression_tree_walker(node, reduce_body_check_walker, context); +} + +/* + * Transform a cypher_reduce node into a query tree. + * + * reduce(acc = init, var IN list | body) is rewritten into a scalar subquery + * over the age_reduce aggregate, with the list unnested WITH ORDINALITY and the + * aggregate ordered by that ordinality so the fold runs in list order: + * + * SELECT ag_catalog.age_reduce(, ''::text, + * r.elem, + * ORDER BY r.ord) + * FROM unnest() WITH ORDINALITY AS r(elem, ord) + * + * The fold body is transformed separately with the accumulator and element + * rewritten to PARAM_EXEC params 0 and 1, serialized into the text argument, + * and evaluated per element inside age_reduce_transfn. Loop-invariant outer + * references in the body (outer-query variables and cypher() parameters) are + * captured as PARAM_EXEC params 2.. and passed through the trailing agtype[] + * argument so the body can use values from the enclosing query; correlated + * captures are re-evaluated per group. + * + * The null/empty-list guard + * (CASE WHEN list IS NULL THEN NULL ELSE COALESCE(, init) END) is built + * at the grammar level in build_reduce_node(). + */ +static Query *transform_cypher_reduce(cypher_parsestate *cpstate, + cypher_clause *clause) +{ + cypher_reduce *reduce = (cypher_reduce *) clause->self; + Query *query; + Query *var_subquery; + cypher_parsestate *body_cpstate; + ParseState *body_pstate; + ParseNamespaceItem *body_pnsi; + Node *body_node; + char *body_serialized; + reduce_var_param_context mutator_ctx; + reduce_capture_context capture_ctx; + cypher_parsestate *child_cpstate; + ParseState *child_pstate; + FuncCall *unnest_fc; + RangeFunction *rf; + RangeTblEntry *rte = NULL; + int rtindex = 0; + List *namespace = NULL; + Node *from_item; + Node *init_node; + Node *elem_var; + Var *ord_var; + TargetEntry *ord_te; + SortGroupClause *sortcl; + Oid sort_ltop; + Oid sort_eqop; + bool sort_hashable; + Const *body_const; + ArrayExpr *extras_arr; + List *extras_exprs = NIL; + Aggref *agg; + Oid agg_oid; + Oid agg_argtypes[4]; + TargetEntry *result_te; + + /* + * 1. Resolve the fold body's accumulator and element variables against a + * throwaway 2-column agtype subquery, rewrite those Vars to PARAM_EXEC + * params, validate it is a pure expression over those params, and + * serialize the body for age_reduce_transfn. + */ + body_cpstate = make_cypher_parsestate(cpstate); + body_pstate = (ParseState *) body_cpstate; + + var_subquery = make_reduce_var_subquery(reduce->acc_varname, + reduce->elem_varname); + body_pnsi = addRangeTableEntryForSubquery(body_pstate, var_subquery, + makeAlias("reduce_vars", NIL), + false, true); + addNSItemToQuery(body_pstate, body_pnsi, false, true, true); + + body_node = transform_cypher_expr(body_cpstate, reduce->body_expr, + EXPR_KIND_SELECT_TARGET); + + /* + * The accumulator is always an agtype value (the aggregate's stype is + * agtype). A fold body can legitimately produce a non-agtype scalar -- for + * example "s AND x" or "x = 2" yield a boolean -- so normalize the body to + * agtype here. Without this the transition function would treat a by-value + * Datum (e.g. bool) as a by-reference varlena and crash. A boolean is + * wrapped in ag_catalog.bool_to_agtype() (AGE registers no implicit + * boolean-to-agtype cast); any other non-agtype type is coerced through + * the normal cast machinery, which raises a clean error if impossible. + */ + if (exprType(body_node) != AGTYPEOID) + { + if (exprType(body_node) == BOOLOID) + { + Oid bool_to_agtype_oid = get_ag_func_oid("bool_to_agtype", 1, + BOOLOID); + + body_node = (Node *) makeFuncExpr(bool_to_agtype_oid, AGTYPEOID, + list_make1(body_node), + InvalidOid, InvalidOid, + COERCE_EXPLICIT_CALL); + } + else + { + body_node = coerce_to_common_type(body_pstate, body_node, + AGTYPEOID, "reduce"); + } + } + + mutator_ctx.varno = body_pnsi->p_rtindex; + body_node = reduce_var_to_param_mutator(body_node, &mutator_ctx); + + /* + * Capture loop-invariant outer references (outer-query variables and + * cypher() parameters) in the body as PARAM_EXEC params 2.. and collect + * them in slot order; their values are supplied to the fold through the + * extras array argument built below. + */ + capture_ctx.next_slot = 2; + capture_ctx.captured = NIL; + body_node = reduce_capture_mutator(body_node, &capture_ctx); + extras_exprs = capture_ctx.captured; + + /* reject anything in the body that could not be captured or evaluated */ + reduce_body_check_walker(body_node, NULL); + + body_serialized = nodeToString(body_node); + + free_cypher_parsestate(body_cpstate); + + /* + * 2. Build the outer aggregate query: + * SELECT age_reduce(, ''::text, r.elem ORDER BY r.ord) + * FROM unnest() WITH ORDINALITY AS r(elem, ord) + */ + query = makeNode(Query); + query->commandType = CMD_SELECT; + + child_cpstate = make_cypher_parsestate(cpstate); + child_pstate = (ParseState *) child_cpstate; + + unnest_fc = makeFuncCall(list_make1(makeString("unnest")), + list_make1(reduce->list_expr), + COERCE_SQL_SYNTAX, -1); + rf = makeNode(RangeFunction); + rf->lateral = false; + rf->ordinality = true; + rf->is_rowsfrom = false; + rf->functions = list_make1(list_make2((Node *) unnest_fc, NIL)); + rf->alias = makeAlias("reduce_src", + list_make2(makeString(reduce->elem_varname), + makeString("reduce_ordinality"))); + rf->coldeflist = NIL; + + from_item = transform_from_clause_item(child_cpstate, (Node *) rf, + &rte, &rtindex, &namespace); + checkNameSpaceConflicts(child_pstate, child_pstate->p_namespace, namespace); + child_pstate->p_joinlist = lappend(child_pstate->p_joinlist, from_item); + child_pstate->p_namespace = list_concat(child_pstate->p_namespace, + namespace); + setNamespaceLateralState(child_pstate->p_namespace, false, true); + + /* arguments to age_reduce: init, serialized body text, element column */ + init_node = transform_cypher_expr(child_cpstate, reduce->init_expr, + EXPR_KIND_SELECT_TARGET); + elem_var = colNameToVar(child_pstate, reduce->elem_varname, false, -1); + body_const = makeConst(TEXTOID, -1, InvalidOid, -1, + CStringGetTextDatum(body_serialized), false, false); + + /* the WITH ORDINALITY column (bigint), used only to order the fold */ + ord_var = makeVar(rtindex, 2, INT8OID, -1, InvalidOid, 0); + get_sort_group_operators(INT8OID, true, true, false, + &sort_ltop, &sort_eqop, NULL, &sort_hashable); + + ord_te = makeTargetEntry((Expr *) ord_var, 5, NULL, true); + ord_te->ressortgroupref = 1; + + sortcl = makeNode(SortGroupClause); + sortcl->tleSortGroupRef = 1; + sortcl->eqop = sort_eqop; + sortcl->sortop = sort_ltop; + sortcl->reverse_sort = false; + sortcl->nulls_first = false; + sortcl->hashable = sort_hashable; + + /* + * Evaluate exactly once per reduce() instead of once per element. + * A regular aggregate argument is evaluated by the executor for every + * input row, but age_reduce_transfn only reads the init argument on the + * first transition (when the running state is still NULL). Re-evaluating + * an expensive init wastes work, and a volatile init would fire its side + * effects once per element. + * + * Rows are fed to the aggregate in ascending ordinality order, so the + * first transition is always the row with ordinality 1. Wrapping init in + * CASE WHEN reduce_ordinality = 1 THEN ELSE NULL::agtype END + * computes on exactly that row (CASE only evaluates the matching + * branch's result) and passes a NULL init -- which the transition + * function ignores -- on every other row. The empty-list case is handled + * separately by the COALESCE(..., init) guard in build_reduce_node(). + */ + { + OpExpr *ord_is_first; + Const *one_const; + CaseWhen *init_when; + CaseExpr *init_case; + Const *null_init; + + one_const = makeConst(INT8OID, -1, InvalidOid, sizeof(int64), + Int64GetDatum(1), false, true); + + ord_is_first = makeNode(OpExpr); + ord_is_first->opno = sort_eqop; /* int8 equality */ + ord_is_first->opfuncid = get_opcode(sort_eqop); + ord_is_first->opresulttype = BOOLOID; + ord_is_first->opretset = false; + ord_is_first->opcollid = InvalidOid; + ord_is_first->inputcollid = InvalidOid; + ord_is_first->args = list_make2(copyObject(ord_var), one_const); + ord_is_first->location = -1; + + null_init = makeConst(AGTYPEOID, -1, InvalidOid, -1, (Datum) 0, + true, false); + + init_when = makeNode(CaseWhen); + init_when->expr = (Expr *) ord_is_first; + init_when->result = (Expr *) init_node; + init_when->location = -1; + + init_case = makeNode(CaseExpr); + init_case->casetype = AGTYPEOID; + init_case->casecollid = InvalidOid; + init_case->arg = NULL; + init_case->args = list_make1(init_when); + init_case->defresult = (Expr *) null_init; + init_case->location = -1; + + init_node = (Node *) init_case; + } + + /* + * The captured loop-invariant outer values (outer-query variables and + * cypher() parameters referenced by the body) are passed to the aggregate + * as an agtype[] argument, in the same order their PARAM_EXEC params 2, 3, + * ... were assigned. When the body references nothing outside the + * accumulator and element this is an empty array. + */ + extras_arr = makeNode(ArrayExpr); + extras_arr->array_typeid = AGTYPEARRAYOID; + extras_arr->element_typeid = AGTYPEOID; + extras_arr->elements = extras_exprs; + extras_arr->multidims = false; + extras_arr->location = -1; + + /* look up the age_reduce(agtype, text, agtype, agtype[]) aggregate */ + agg_argtypes[0] = AGTYPEOID; + agg_argtypes[1] = TEXTOID; + agg_argtypes[2] = AGTYPEOID; + agg_argtypes[3] = AGTYPEARRAYOID; + agg_oid = LookupFuncName(list_make2(makeString("ag_catalog"), + makeString("age_reduce")), + 4, agg_argtypes, false); + + agg = makeNode(Aggref); + agg->aggfnoid = agg_oid; + agg->aggtype = AGTYPEOID; + agg->aggcollid = InvalidOid; + agg->inputcollid = InvalidOid; + agg->aggtranstype = InvalidOid; /* filled by the planner */ + agg->aggargtypes = list_make4_oid(AGTYPEOID, TEXTOID, AGTYPEOID, + AGTYPEARRAYOID); + agg->aggdirectargs = NIL; + agg->args = list_make5(makeTargetEntry((Expr *) init_node, 1, NULL, false), + makeTargetEntry((Expr *) body_const, 2, NULL, false), + makeTargetEntry((Expr *) elem_var, 3, NULL, false), + makeTargetEntry((Expr *) extras_arr, 4, NULL, false), + ord_te); + agg->aggorder = list_make1(sortcl); + agg->aggdistinct = NIL; + agg->aggfilter = NULL; + agg->aggstar = false; + agg->aggvariadic = false; + agg->aggkind = AGGKIND_NORMAL; + agg->aggpresorted = false; + agg->agglevelsup = 0; + agg->aggsplit = AGGSPLIT_SIMPLE; + agg->aggno = -1; + agg->aggtransno = -1; + agg->location = -1; + + child_pstate->p_hasAggs = true; + + result_te = makeTargetEntry((Expr *) agg, + (AttrNumber) child_pstate->p_next_resno++, + "reduce", false); + + query->targetList = list_make1(result_te); + query->jointree = makeFromExpr(child_pstate->p_joinlist, NULL); + query->rtable = child_pstate->p_rtable; + query->rteperminfos = child_pstate->p_rteperminfos; + query->hasAggs = true; + query->hasSubLinks = child_pstate->p_hasSubLinks; + query->hasTargetSRFs = child_pstate->p_hasTargetSRFs; + + assign_query_collations(child_pstate, query); + parse_check_aggregates(child_pstate, query); + + free_cypher_parsestate(child_cpstate); + + return query; +} + /* * Iterate through the list of items to delete and extract the variable name. * Then find the resno that the variable name belongs to. @@ -1906,12 +3094,14 @@ cypher_update_information *transform_cypher_set_item_list( */ if (IsA(set_item->expr, ColumnRef)) { - List *qualified_name, *args; + List *fname; - qualified_name = list_make2(makeString("ag_catalog"), - makeString("age_properties")); - args = list_make1(set_item->expr); - set_item->expr = (Node *)makeFuncCall(qualified_name, args, + /* + * make unqualified function name so that potential + * optimization can kick in + */ + fname = list_make1(makeString("properties")); + set_item->expr = (Node *)makeFuncCall(fname, list_make1(set_item->expr), COERCE_SQL_SYNTAX, -1); } } @@ -2368,6 +3558,7 @@ static Query *transform_cypher_return(cypher_parsestate *cpstate, query->jointree = makeFromExpr(pstate->p_joinlist, NULL); query->hasAggs = pstate->p_hasAggs; query->hasSubLinks = pstate->p_hasSubLinks; + query->hasTargetSRFs = pstate->p_hasTargetSRFs; assign_query_collations(pstate, query); @@ -2564,7 +3755,14 @@ static bool match_check_valid_label(cypher_match *match, if (lcd == NULL || lcd->kind != LABEL_KIND_EDGE) { - return false; + /* + * Issue #2382: a missing edge label is fatal only if + * the pattern actually requires an edge of that label. + * For VLE with lower bound 0, the zero-hop self-bind + * case must still produce rows. + */ + if (!is_zero_lower_bound_vle(rel->varlen)) + return false; } } } @@ -2638,23 +3836,39 @@ static Query *transform_cypher_match(cypher_parsestate *cpstate, cypher_match *match_self = (cypher_match*) clause->self; Node *where = match_self->where; - if(!match_check_valid_label(match_self, cpstate)) - { - cypher_bool_const *l = make_ag_node(cypher_bool_const); - cypher_bool_const *r = make_ag_node(cypher_bool_const); - l->boolean = true; - l->location = -1; - r->boolean = false; - r->location = -1; - - /*if the label is invalid, create a paradoxical where to get null*/ - match_self->where = (Node *)makeSimpleA_Expr(AEXPR_OP, "=", - (Node *)l, - (Node *)r, -1); + /* + * Check label validity early unless the predecessor clause chain + * contains a data-modifying operation (CREATE, SET, DELETE, MERGE). + * DML predecessors may create new labels that are not yet in the + * cache, so the check is deferred to after transform_prev_cypher_clause() + * for those cases. + */ + if (!clause_chain_has_dml(clause->prev) && + !match_check_valid_label(match_self, cpstate)) + { + /* Label is invalid -- inject a false WHERE so the MATCH returns + * zero rows. No DML predecessor here, so constant-foldable is fine. */ + match_self->where = make_false_where_clause(false); } - if (has_list_comp_or_subquery((Node *)match_self->where, NULL)) + /* + * For a non-optional MATCH with a list comprehension or subquery in + * its WHERE clause, transform the match pattern as a subquery and + * then apply the WHERE as an outer filter. This keeps the parent's + * namespace available to the subquery-bearing predicate. + * + * This rewrite is NOT safe for OPTIONAL MATCH: wrapping the WHERE + * around the transformed clause turns it into a post-filter on the + * LATERAL LEFT JOIN produced by transform_cypher_optional_match_clause, + * which incorrectly drops the null-preserving outer rows that the + * LEFT JOIN generates when no right-hand match exists. For the + * optional case we fall through to the normal transform, which + * places the WHERE inside the right-hand subquery of the LEFT JOIN + * where it correctly scopes to the optional binding (issue #2378). + */ + if (!match_self->optional && + has_list_comp_or_subquery((Node *)match_self->where, NULL)) { match_self->where = NULL; return transform_cypher_clause_with_where(cpstate, @@ -2793,10 +4007,28 @@ static RangeTblEntry *transform_cypher_optional_match_clause(cypher_parsestate * List *res_colnames = NIL, *res_colvars = NIL; Alias *l_alias, *r_alias; ParseNamespaceItem *jnsitem; + cypher_match *match_self = (cypher_match *) clause->self; + Node *saved_where = match_self->where; int i = 0; j->jointype = JOIN_LEFT; + /* + * If the OPTIONAL MATCH carries a WHERE clause, temporarily detach + * it so that the recursive right-hand transform does NOT try to + * apply it inside the inner subquery. We re-apply the predicate + * below as a LEFT JOIN ON condition, which is the only placement + * that both (a) scopes the predicate to the optional binding and + * (b) preserves null-filled outer rows when the predicate fails. + * Without this, a WHERE that contains a sub-pattern predicate + * (e.g. EXISTS {...} referencing the optional variable) either + * gets silently dropped during the inner transform (namespace + * mismatch re-binds the variable in a fresh scope) or gets pulled + * up by the containing wrapper and filters out the null-preserving + * rows. See issue #2378. + */ + match_self->where = NULL; + l_alias = makeAlias(PREV_CYPHER_CLAUSE_ALIAS, NIL); r_alias = makeAlias(CYPHER_OPT_RIGHT_ALIAS, NIL); @@ -2818,6 +4050,32 @@ static RangeTblEntry *transform_cypher_optional_match_clause(cypher_parsestate * j->rarg = transform_clause_for_join(cpstate, clause, &r_rte, &r_nsitem, r_alias); + /* add right-side nsitem so the re-attached WHERE below can resolve + * newly-bound variables from the optional pattern */ + pstate->p_namespace = lappend(pstate->p_namespace, r_nsitem); + + /* + * Now that both sides are visible in the namespace, re-attach the + * OPTIONAL MATCH's WHERE predicate as the LEFT JOIN's ON clause. + * PostgreSQL correctly preserves left rows whose right side fails + * an ON condition (LEFT JOIN semantics), which is exactly what + * Cypher OPTIONAL MATCH ... WHERE requires: if the WHERE filters + * out all matches for a given outer row, that outer row is still + * emitted with nulls in the optional columns. + */ + if (saved_where != NULL) + { + Node *where_qual; + + where_qual = transform_cypher_expr(cpstate, saved_where, + EXPR_KIND_WHERE); + where_qual = coerce_to_boolean(pstate, where_qual, "WHERE"); + j->quals = where_qual; + } + + /* restore the WHERE on the node so we don't mutate caller state */ + match_self->where = saved_where; + /* * Since this is a left join, we need to mark j->rarg as it may potentially * emit NULL. The jindex argument holds rtindex of the join's RTE, which is @@ -2914,9 +4172,27 @@ static Query *transform_cypher_match_pattern(cypher_parsestate *cpstate, RangeTblEntry *rte; int rtindex; ParseNamespaceItem *pnsi; + bool has_dml; pnsi = transform_prev_cypher_clause(cpstate, clause->prev, true); rte = pnsi->p_rte; + + /* + * If the predecessor clause chain contains a data-modifying + * operation (CREATE, SET, DELETE, MERGE), mark the subquery + * RTE as a security barrier. This prevents PostgreSQL's + * optimizer from pushing MATCH filter quals down into the + * subquery, which would cause them to evaluate before the + * DML executes -- resulting in quals checking NULL values + * and filtering out all rows. + */ + has_dml = clause_chain_has_dml(clause->prev); + + if (has_dml) + { + rte->security_barrier = true; + } + rtindex = list_length(pstate->p_rtable); /* rte is the first RangeTblEntry in pstate */ if (rtindex != 1) @@ -2933,6 +4209,25 @@ static Query *transform_cypher_match_pattern(cypher_parsestate *cpstate, */ pnsi = get_namespace_item(pstate, rte); query->targetList = expandNSItemAttrs(pstate, pnsi, 0, true, -1); + + /* + * Now that the predecessor chain is fully transformed and + * any CREATE-generated labels exist in the cache, check + * whether the MATCH pattern references valid labels. This + * deferred check is only needed when the chain has DML, + * since labels created by CREATE are not in the cache at + * the time of the early check in transform_cypher_match(). + * + * We use a volatile false predicate (random() IS NULL) + * instead of a constant one (true = false) because PG's + * planner can constant-fold the latter into a One-Time + * Filter: false, eliminating the entire plan subtree -- + * including the DML predecessor scan -- without executing it. + */ + if (has_dml && !match_check_valid_label(self, cpstate)) + { + where = make_false_where_clause(true); + } } transform_match_pattern(cpstate, query, self->pattern, where); @@ -3549,10 +4844,6 @@ static List *make_join_condition_for_edge(cypher_parsestate *cpstate, { Node *left_id = NULL; Node *right_id = NULL; - String *ag_catalog = makeString("ag_catalog"); - String *func_name; - List *qualified_func_name; - List *args = NIL; List *quals = NIL; /* @@ -3565,56 +4856,107 @@ static List *make_join_condition_for_edge(cypher_parsestate *cpstate, } /* - * If the previous node and the next node are in the join tree, we need - * to create the age_match_vle_terminal_edge to compare the vle returned - * results against the two nodes. + * S5: if the previous and next nodes are both in the join tree, + * emit two graphid equality A_Exprs: + * .start_id = prev_node.id + * .end_id = next_node.id + * This replaces the historical per-row + * age_match_vle_terminal_edge(prev.id, next.id, edges) + * function call with plain integer (int8) equality quals on the + * SRF's S4 output columns. The planner can now drive the join + * directly on these keys (HashJoin hash keys, NestLoop index + * conditions where indexed). */ if (prev_node->in_join_tree) { - func_name = makeString("age_match_vle_terminal_edge"); - qualified_func_name = list_make2(ag_catalog, func_name); + ColumnRef *cr_start; + ColumnRef *cr_end; + A_Expr *eq_start; + A_Expr *eq_end; /* - * Get the vertex's id and pass to the function. Pass in NULL - * otherwise. + * Production-build runtime guard. Asserts compile out in + * non-debug builds, so a NULL vle_alias would otherwise reach + * makeString() and crash the backend during ColumnRef build. */ - left_id = (Node *)make_qual(cpstate, prev_node, "id"); + if (entity->vle_alias == NULL) + { + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("VLE edge entity is missing its alias; cannot emit terminal-edge join qual"))); + } + Assert(entity->vle_alias != NULL); + + cr_start = makeNode(ColumnRef); + cr_start->fields = list_make2(makeString(entity->vle_alias), + makeString("start_id")); + cr_start->location = -1; + + cr_end = makeNode(ColumnRef); + cr_end->fields = list_make2(makeString(entity->vle_alias), + makeString("end_id")); + cr_end->location = -1; + + left_id = (Node *)make_qual(cpstate, prev_node, "id"); right_id = (Node *)make_qual(cpstate, next_node, "id"); - /* create the argument list */ - args = list_make3(left_id, right_id, entity->expr); + eq_start = makeSimpleA_Expr(AEXPR_OP, "=", + (Node *)cr_start, left_id, -1); + eq_end = makeSimpleA_Expr(AEXPR_OP, "=", + (Node *)cr_end, right_id, -1); - /* add to quals */ - quals = lappend(quals, makeFuncCall(qualified_func_name, args, - COERCE_EXPLICIT_CALL, -1)); + quals = lappend(quals, eq_start); + quals = lappend(quals, eq_end); } /* - * When the previous node is not in the join tree, but there is a vle - * edge before that join, then we need to compare this vle's start node - * against the previous vle's end node. No need to check the next edge, - * because that would be redundant. + * S6: when the previous node is not in the join tree but there is + * a vle edge before that join, emit a single graphid equality + * connecting the two VLE SRFs: + * + * prev_vle.end_id = this_vle.start_id + * + * This replaces the per-row age_match_two_vle_edges(prev, this) + * function call with a plain int8 equality on the S4 scalar + * output columns of both age_vle SRFs. No detoasting of either + * VLE_path_container is needed. */ if (!prev_node->in_join_tree && prev_edge != NULL && prev_edge->type == ENT_VLE_EDGE) { - List *qualified_name; - String *match_qual; - FuncCall *fc; + ColumnRef *cr_prev_end; + ColumnRef *cr_this_start; + A_Expr *eq_chain; - match_qual = makeString("age_match_two_vle_edges"); + /* + * Production-build runtime guard for both VLE aliases; see + * note above on entity->vle_alias. + */ + if (prev_edge->vle_alias == NULL || entity->vle_alias == NULL) + { + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("VLE edge entity is missing its alias; cannot emit two-VLE-edge join qual"))); + } + Assert(prev_edge->vle_alias != NULL); + Assert(entity->vle_alias != NULL); - /* make the qualified function name */ - qualified_name = list_make2(ag_catalog, match_qual); + cr_prev_end = makeNode(ColumnRef); + cr_prev_end->fields = list_make2(makeString(prev_edge->vle_alias), + makeString("end_id")); + cr_prev_end->location = -1; - /* make the args */ - args = list_make2(prev_edge->expr, entity->expr); + cr_this_start = makeNode(ColumnRef); + cr_this_start->fields = list_make2(makeString(entity->vle_alias), + makeString("start_id")); + cr_this_start->location = -1; - /* create the function call */ - fc = makeFuncCall(qualified_name, args, COERCE_EXPLICIT_CALL, -1); + eq_chain = makeSimpleA_Expr(AEXPR_OP, "=", + (Node *)cr_prev_end, + (Node *)cr_this_start, -1); - quals = lappend(quals, fc); + quals = lappend(quals, eq_chain); } return quals; @@ -4245,6 +5587,38 @@ static Node *create_property_constraints(cypher_parsestate *cpstate, } else { + /* + * Map decomposition into individual index lookups requires known + * keys at parse time. When the property constraint is a parameter + * (cypher_param), the keys are not available until execution, so + * fall back to the containment operator. + */ + if (is_ag_node(property_constraints, cypher_param)) + { + /* + * Use @>> (top-level containment) for =properties form, + * @> (deep containment) otherwise — matching the + * enable_containment=on path above. + */ + if ((entity->type == ENT_VERTEX && + entity->entity.node->use_equals) || + ((entity->type == ENT_EDGE || + entity->type == ENT_VLE_EDGE) && + entity->entity.rel->use_equals)) + { + return (Node *)make_op(pstate, + list_make1(makeString("@>>")), + prop_expr, const_expr, + last_srf, -1); + } + else + { + return (Node *)make_op(pstate, + list_make1(makeString("@>")), + prop_expr, const_expr, + last_srf, -1); + } + } return (Node *)transform_map_to_ind( cpstate, entity, (cypher_map *)property_constraints); } @@ -4433,6 +5807,12 @@ static transform_entity *transform_VLE_edge_entity(cypher_parsestate *cpstate, vle_entity = make_transform_entity(cpstate, ENT_VLE_EDGE, (Node *)rel, (Expr *)var); + /* + * S5: stash the auto-generated alias name so make_join_condition_for_edge + * can build ColumnRefs for the SRF's start_id/end_id output columns. + */ + vle_entity->vle_alias = alias->aliasname; + /* return the vle entity */ return vle_entity; } @@ -4502,7 +5882,15 @@ static bool path_check_valid_label(cypher_path *path, if (lcd == NULL || lcd->kind != LABEL_KIND_EDGE) { - return false; + /* + * Issue #2382: Don't invalidate the whole path just + * because a VLE edge with lower bound 0 references a + * missing label. The zero-hop self-binding semantics + * still allow the surrounding nodes to bind, so the + * other vertex labels in this path must be honoured. + */ + if (!is_zero_lower_bound_vle(rel->varlen)) + return false; } } } @@ -4652,29 +6040,13 @@ static List *transform_match_entities(cypher_parsestate *cpstate, Query *query, */ else if (prop_var != NULL) { - /* - * Remember that prop_var is already transformed. We need - * to built the transform manually. - */ - FuncCall *fc = NULL; - List *targs = NIL; - List *fname = NIL; - - targs = lappend(targs, prop_var); - fname = list_make2(makeString("ag_catalog"), - makeString("age_properties")); - fc = makeFuncCall(fname, targs, COERCE_SQL_SYNTAX, -1); - - /* - * Hand off to ParseFuncOrColumn to create the function - * expression for properties(prop_var) - */ - prop_expr = ParseFuncOrColumn(pstate, fname, targs, - pstate->p_last_srf, fc, false, - -1); + prop_expr = make_properties_expr(prop_var); } - ((cypher_map*)node->props)->keep_null = true; + if (is_ag_node(node->props, cypher_map)) + { + ((cypher_map*)node->props)->keep_null = true; + } n = create_property_constraints(cpstate, entity, node->props, prop_expr); @@ -4781,29 +6153,13 @@ static List *transform_match_entities(cypher_parsestate *cpstate, Query *query, */ else if (prop_var != NULL) { - /* - * Remember that prop_var is already transformed. We need - * to built the transform manually. - */ - FuncCall *fc = NULL; - List *targs = NIL; - List *fname = NIL; - - targs = lappend(targs, prop_var); - fname = list_make2(makeString("ag_catalog"), - makeString("age_properties")); - fc = makeFuncCall(fname, targs, COERCE_SQL_SYNTAX, -1); - - /* - * Hand off to ParseFuncOrColumn to create the function - * expression for properties(prop_var) - */ - prop_expr = ParseFuncOrColumn(pstate, fname, targs, - pstate->p_last_srf, fc, - false, -1); + prop_expr = make_properties_expr(prop_var); } - ((cypher_map*)rel->props)->keep_null = true; + if (is_ag_node(rel->props, cypher_map)) + { + ((cypher_map*)rel->props)->keep_null = true; + } r = create_property_constraints(cpstate, entity, rel->props, prop_expr); @@ -4869,10 +6225,56 @@ static List *make_path_join_quals(cypher_parsestate *cpstate, List *entities) List *quals = NIL; List *join_quals; - /* for vertex only queries, there is no work to do */ + /* + * Vertex-only patterns have no edges, so the edge-driven correlation and + * label-filter logic below never runs. That is correct for a freshly + * scanned vertex -- its label comes from its label-table scan. But a + * vertex that refers to a variable from an ENCLOSING query -- e.g. the + * (a:Person) in MATCH (a) WHERE (a:Person) / EXISTS((a:Person)) -- is not + * scanned from its label table here. Without an explicit filter such a + * sub-pattern is uncorrelated and trivially true (the label is never + * tested). If the vertex carries a non-default label and its variable + * exists in an ancestor parse state, emit a label-id filter: make_qual + * builds a name-based id reference that resolves to the outer variable, + * which both correlates the sub-pattern to it and enforces the label. + */ if (list_length(entities) < 3) { - return NIL; + cypher_parsestate *parent_cpstate = + (cypher_parsestate *) cpstate->pstate.parentParseState; + ListCell *vlc; + + if (parent_cpstate != NULL) + { + foreach (vlc, entities) + { + transform_entity *ent = lfirst(vlc); + char *label; + char *name; + + if (ent->type != ENT_VERTEX) + { + continue; + } + + label = ent->entity.node->label; + name = ent->entity.node->name; + + if (label != NULL && !IS_DEFAULT_LABEL_VERTEX(label) && + name != NULL && + find_variable(parent_cpstate, name) != NULL) + { + Node *id_field = make_qual(cpstate, ent, "id"); + + quals = lappend(quals, + filter_vertices_on_label_id(cpstate, + id_field, + label)); + } + } + } + + return quals; } lc = list_head(entities); @@ -4956,17 +6358,25 @@ transform_match_create_path_variable(cypher_parsestate *cpstate, if (entity->expr != NULL) { + Node *entity_expr = (Node *)entity->expr; + /* * Is it a NULL constant, meaning there was an invalid label? * If so, flag it for later */ - if (IsA(entity->expr, Const) && - ((Const*)(entity->expr))->constisnull) + if (IsA(entity_expr, Const) && + ((Const*)(entity_expr))->constisnull) { null_path_entity = true; } - entity_exprs = lappend(entity_exprs, entity->expr); + /* + * If the entity is a vertex/edge, convert it to agtype since + * _agtype_build_path expects agtype arguments. + */ + entity_expr = coerce_entity_to_agtype(pstate, entity_expr); + + entity_exprs = lappend(entity_exprs, entity_expr); } } @@ -5062,8 +6472,36 @@ static Node *make_qual(cypher_parsestate *cpstate, { List *qualified_name, *args; Node *node; + char *entity_name; + + if (is_vertex_or_edge((Node *) entity->expr)) + { + A_Indirection *indir = makeNode(A_Indirection); + ColumnRef *cr = makeNode(ColumnRef); + + if (entity->type == ENT_VERTEX) + { + entity_name = entity->entity.node->name; + } + else if (entity->type == ENT_EDGE) + { + entity_name = entity->entity.rel->name; + } + else + { + ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("unknown entity type"))); + } - if (IsA(entity->expr, Var)) + cr->fields = list_make1(makeString(entity_name)); + cr->location = -1; + + indir->arg = (Node *)cr; + indir->indirection = list_make1(makeString(col_name)); + + node = (Node *)indir; + } + else if (IsA(entity->expr, Var)) { char *function_name; @@ -5072,14 +6510,12 @@ static Node *make_qual(cypher_parsestate *cpstate, qualified_name = list_make2(makeString("ag_catalog"), makeString(function_name)); - args = list_make1(entity->expr); node = (Node *)makeFuncCall(qualified_name, args, COERCE_EXPLICIT_CALL, -1); } else { - char *entity_name; ColumnRef *cr = makeNode(ColumnRef); if (entity->type == ENT_EDGE) @@ -5104,6 +6540,87 @@ static Node *make_qual(cypher_parsestate *cpstate, return node; } +/* + * Helper function to get field number and type for vertex/edge field access. + * Vertex: (id(1), label(2), properties(3)) + * Edge: (id(1), label(2), end_id(3), start_id(4), properties(5)) + */ +void get_record_field_info(char *field_name, Oid entity_type, + AttrNumber *fieldnum, Oid *fieldtype) +{ + bool is_vertex = (entity_type == VERTEXOID); + bool is_edge = (entity_type == EDGEOID); + + Assert(field_name != NULL); + Assert(fieldnum != NULL); + Assert(fieldtype != NULL); + Assert(is_vertex || is_edge); + + *fieldnum = InvalidAttrNumber; + *fieldtype = InvalidOid; + + if (strcasecmp(field_name, "id") == 0) + { + *fieldnum = 1; + *fieldtype = GRAPHIDOID; + } + else if (is_vertex) + { + if (strcasecmp(field_name, "label") == 0) + { + *fieldnum = 2; + *fieldtype = AGTYPEOID; + } + else if (strcasecmp(field_name, "properties") == 0) + { + *fieldnum = 3; + *fieldtype = AGTYPEOID; + } + } + else if (is_edge) + { + if (strcasecmp(field_name, "label") == 0 || + strcasecmp(field_name, "type") == 0) + { + *fieldnum = 2; + *fieldtype = AGTYPEOID; + } + else if (strcasecmp(field_name, "end_id") == 0 || + strcasecmp(field_name, "endnode") == 0) + { + *fieldnum = 3; + *fieldtype = GRAPHIDOID; + } + else if (strcasecmp(field_name, "start_id") == 0 || + strcasecmp(field_name, "startnode") == 0) + { + *fieldnum = 4; + *fieldtype = GRAPHIDOID; + } + else if (strcasecmp(field_name, "properties") == 0) + { + *fieldnum = 5; + *fieldtype = AGTYPEOID; + } + } +} + +/* + * Helper function to build a FieldSelect node + */ +FieldSelect *make_field_select(Var *var, AttrNumber fieldnum, Oid resulttype) +{ + FieldSelect *fselect = makeNode(FieldSelect); + + fselect->arg = (Expr *)copyObject(var); + fselect->fieldnum = fieldnum; + fselect->resulttype = resulttype; + fselect->resulttypmod = -1; + fselect->resultcollid = InvalidOid; + + return fselect; +} + static Expr *transform_cypher_edge(cypher_parsestate *cpstate, cypher_relationship *rel, List **target_list, @@ -5654,17 +7171,14 @@ static Node *make_edge_expr(cypher_parsestate *cpstate, { ParseState *pstate = (ParseState *)cpstate; Oid label_name_func_oid; - Oid func_oid; Node *id, *start_id, *end_id; Const *graph_oid_const; Node *props; - List *args, *label_name_args; - FuncExpr *func_expr; + List *label_name_args; FuncExpr *label_name_func_expr; + RowExpr *row_expr; - func_oid = get_ag_func_oid("_agtype_build_edge", 5, GRAPHIDOID, GRAPHIDOID, - GRAPHIDOID, CSTRINGOID, AGTYPEOID); - + /* Get raw column references */ id = scanNSItemForColumn(pstate, pnsi, 0, AG_EDGE_COLNAME_ID, -1); start_id = scanNSItemForColumn(pstate, pnsi, 0, AG_EDGE_COLNAME_START_ID, -1); @@ -5680,40 +7194,45 @@ static Node *make_edge_expr(cypher_parsestate *cpstate, label_name_args = list_make2(graph_oid_const, id); - label_name_func_expr = makeFuncExpr(label_name_func_oid, CSTRINGOID, + label_name_func_expr = makeFuncExpr(label_name_func_oid, AGTYPEOID, label_name_args, InvalidOid, InvalidOid, COERCE_EXPLICIT_CALL); label_name_func_expr->location = -1; props = scanNSItemForColumn(pstate, pnsi, 0, AG_EDGE_COLNAME_PROPERTIES, -1); - args = list_make4(id, start_id, end_id, label_name_func_expr); - args = lappend(args, props); - - func_expr = makeFuncExpr(func_oid, AGTYPEOID, args, InvalidOid, InvalidOid, - COERCE_EXPLICIT_CALL); - func_expr->location = -1; - - return (Node *)func_expr; + /* + * Create a RowExpr with the edge composite type. + * Edge format: (id, label, end_id, start_id, properties) + * Implicit cast to agtype is used when needed. + */ + row_expr = makeNode(RowExpr); + row_expr->args = list_make5(id, label_name_func_expr, end_id, start_id, props); + row_expr->row_typeid = EDGEOID; + row_expr->row_format = COERCE_EXPLICIT_CALL; + row_expr->colnames = list_make5(makeString("id"), + makeString("label"), + makeString("end_id"), + makeString("start_id"), + makeString("properties")); + row_expr->location = -1; + + return (Node *)row_expr; } static Node *make_vertex_expr(cypher_parsestate *cpstate, ParseNamespaceItem *pnsi) { ParseState *pstate = (ParseState *)cpstate; Oid label_name_func_oid; - Oid func_oid; Node *id; Const *graph_oid_const; Node *props; - List *args, *label_name_args; - FuncExpr *func_expr; + List *label_name_args; FuncExpr *label_name_func_expr; + RowExpr *row_expr; Assert(pnsi != NULL); - func_oid = get_ag_func_oid("_agtype_build_vertex", 3, GRAPHIDOID, - CSTRINGOID, AGTYPEOID); - id = scanNSItemForColumn(pstate, pnsi, 0, AG_VERTEX_COLNAME_ID, -1); label_name_func_oid = get_ag_func_oid("_label_name", 2, OIDOID, @@ -5725,7 +7244,7 @@ static Node *make_vertex_expr(cypher_parsestate *cpstate, label_name_args = list_make2(graph_oid_const, id); - label_name_func_expr = makeFuncExpr(label_name_func_oid, CSTRINGOID, + label_name_func_expr = makeFuncExpr(label_name_func_oid, AGTYPEOID, label_name_args, InvalidOid, InvalidOid, COERCE_EXPLICIT_CALL); label_name_func_expr->location = -1; @@ -5733,13 +7252,21 @@ static Node *make_vertex_expr(cypher_parsestate *cpstate, props = scanNSItemForColumn(pstate, pnsi, 0, AG_VERTEX_COLNAME_PROPERTIES, -1); - args = list_make3(id, label_name_func_expr, props); - - func_expr = makeFuncExpr(func_oid, AGTYPEOID, args, InvalidOid, InvalidOid, - COERCE_EXPLICIT_CALL); - func_expr->location = -1; - - return (Node *)func_expr; + /* + * Create a RowExpr with the vertex composite type. + * Vertex format: (id, label, properties) + * Implicit cast to agtype is used when needed. + */ + row_expr = makeNode(RowExpr); + row_expr->args = list_make3(id, label_name_func_expr, props); + row_expr->row_typeid = VERTEXOID; + row_expr->row_format = COERCE_EXPLICIT_CALL; + row_expr->colnames = list_make3(makeString("id"), + makeString("label"), + makeString("properties")); + row_expr->location = -1; + + return (Node *)row_expr; } static Query *transform_cypher_create(cypher_parsestate *cpstate, @@ -5788,7 +7315,6 @@ static Query *transform_cypher_create(cypher_parsestate *cpstate, target_nodes->flags |= CYPHER_CLAUSE_FLAG_TERMINAL; } - func_expr = make_clause_func_expr(CREATE_CLAUSE_FUNCTION_NAME, (Node *)target_nodes); @@ -6202,7 +7728,6 @@ static void add_volatile_wrapper_to_target_entry(List *target_list, int resno) TargetEntry *te = (TargetEntry *)lfirst(lc); if (te->resno == resno) { - /* wrap it */ te->expr = add_volatile_wrapper(te->expr); return; } @@ -6545,6 +8070,90 @@ static void advance_transform_entities_to_next_clause(List *entities) } } +/* + * Return true if this single clause is a data-modifying operation + * (CREATE, SET, DELETE, or MERGE). + */ +static bool clause_is_dml(cypher_clause *clause) +{ + return is_ag_node(clause->self, cypher_create) || + is_ag_node(clause->self, cypher_set) || + is_ag_node(clause->self, cypher_delete) || + is_ag_node(clause->self, cypher_merge); +} + +/* + * Walk the clause chain and return true if any clause is a + * data-modifying operation (CREATE, SET, DELETE, or MERGE). + */ +static bool clause_chain_has_dml(cypher_clause *clause) +{ + while (clause != NULL) + { + if (clause_is_dml(clause)) + { + return true; + } + + clause = clause->prev; + } + + return false; +} + +/* + * Build a false WHERE clause that forces a MATCH to return zero rows. + * Used when the MATCH pattern references a label that does not exist. + * + * When volatile_needed is false, returns a constant (true = false) that + * PG's planner may constant-fold -- this is fine when there is no DML + * predecessor whose execution must be preserved. + * + * When volatile_needed is true, returns (random() IS NULL) instead. + * random() is VOLATILE, so eval_const_expressions() cannot fold this, + * preventing PG from creating a One-Time Filter: false that would + * eliminate the DML predecessor scan without executing it. + * + * Note: AGE's add_volatile_wrapper() serves a similar anti-fold purpose + * but operates at the Expr level (post-transform) and returns agtype, + * so it cannot be used here in the parse-tree WHERE clause context. + */ +static Node *make_false_where_clause(bool volatile_needed) +{ + if (volatile_needed) + { + FuncCall *random_fn; + NullTest *nt; + + random_fn = makeFuncCall( + list_make2(makeString("pg_catalog"), makeString("random")), + NIL, + COERCE_EXPLICIT_CALL, + -1); + + nt = makeNode(NullTest); + nt->arg = (Expr *)random_fn; + nt->nulltesttype = IS_NULL; + nt->argisrow = false; + nt->location = -1; + + return (Node *)nt; + } + else + { + cypher_bool_const *l = make_ag_node(cypher_bool_const); + cypher_bool_const *r = make_ag_node(cypher_bool_const); + + l->boolean = true; + l->location = -1; + r->boolean = false; + r->location = -1; + + return (Node *)makeSimpleA_Expr(AEXPR_OP, "=", + (Node *)l, (Node *)r, -1); + } +} + static Query *analyze_cypher_clause(transform_method transform, cypher_clause *clause, cypher_parsestate *parent_cpstate) @@ -6683,6 +8292,33 @@ Query *cypher_parse_sub_analyze(Node *parseTree, return query; } +/* + * Resolve prop_expr for each SET item by looking up its target entry. + * The planner may strip SET expression target entries from the plan, + * so we embed the Expr in the update item for direct evaluation. + */ +static void +resolve_merge_set_exprs(List *set_items, List *targetList, + const char *clause_name) +{ + ListCell *lc; + + foreach(lc, set_items) + { + cypher_update_item *item = lfirst(lc); + TargetEntry *set_tle = get_tle_by_resno(targetList, + item->prop_position); + if (set_tle == NULL) + { + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("%s target entry not found at position %d", + clause_name, item->prop_position))); + } + item->prop_expr = (Node *)set_tle->expr; + } +} + /* * Function for transforming MERGE. * @@ -6795,6 +8431,32 @@ static Query *transform_cypher_merge(cypher_parsestate *cpstate, merge_information->graph_oid = cpstate->graph_oid; merge_information->path = merge_path; + /* Transform ON MATCH SET items, if any */ + if (self->on_match != NIL) + { + merge_information->on_match_set_info = + transform_cypher_set_item_list(cpstate, self->on_match, query); + merge_information->on_match_set_info->clause_name = "MERGE ON MATCH SET"; + merge_information->on_match_set_info->graph_name = cpstate->graph_name; + + resolve_merge_set_exprs( + merge_information->on_match_set_info->set_items, + query->targetList, "ON MATCH SET"); + } + + /* Transform ON CREATE SET items, if any */ + if (self->on_create != NIL) + { + merge_information->on_create_set_info = + transform_cypher_set_item_list(cpstate, self->on_create, query); + merge_information->on_create_set_info->clause_name = "MERGE ON CREATE SET"; + merge_information->on_create_set_info->graph_name = cpstate->graph_name; + + resolve_merge_set_exprs( + merge_information->on_create_set_info->set_items, + query->targetList, "ON CREATE SET"); + } + if (!clause->next) { merge_information->flags |= CYPHER_CLAUSE_FLAG_TERMINAL; @@ -6868,6 +8530,36 @@ transform_merge_make_lateral_join(cypher_parsestate *cpstate, Query *query, */ j->larg = transform_clause_for_join(cpstate, clause->prev, &l_rte, &l_nsitem, l_alias); + + /* + * Wrap vertex/edge columns in the left subquery with volatile wrapper + * before adding to namespace and building property expressions since + * our executors are strictly tied to agtype. + * + * This is necessary because: + * 1. The volatile wrapper will be applied to the final targetList later, + * changing column types from VERTEXOID/EDGEOID to AGTYPEOID + * 2. If we build property expressions before this, the Vars will have + * vartype=VERTEXOID but the execution slot will have AGTYPEOID + * 3. By wrapping now, the namespace lookups will see AGTYPEOID columns + * and build expressions with correct type expectations + */ + foreach(lc, l_rte->subquery->targetList) + { + TargetEntry *te = (TargetEntry *)lfirst(lc); + Oid te_type = exprType((Node *)te->expr); + + if (te_type == VERTEXOID || te_type == EDGEOID) + { + /* resno is 1-based */ + int col_idx = te->resno - 1; + + /* Wrap the expression in the subquery targetList */ + te->expr = add_volatile_wrapper(te->expr); + l_nsitem->p_nscolumns[col_idx].p_vartype = AGTYPEOID; + } + } + pstate->p_namespace = lappend(pstate->p_namespace, l_nsitem); /* @@ -6910,10 +8602,69 @@ transform_merge_make_lateral_join(cypher_parsestate *cpstate, Query *query, */ get_res_cols(pstate, l_nsitem, r_nsitem, &res_colnames, &res_colvars); - /* make the RTE for the join */ - jnsitem = addRangeTableEntryForJoin(pstate, res_colnames, NULL, j->jointype, - 0, res_colvars, NIL, NIL, j->alias, - NULL, true); + /* + * Build a ParseNamespaceColumn array for the join RTE so that + * subsequent name lookups (e.g. transform_cypher_set_item_list for an + * ON CREATE SET / ON MATCH SET expression) can resolve references to + * variables bound in the prev clause or the MERGE's path via + * colNameToVar → scanNSItemForColumn, which dereferences + * nsitem->p_nscolumns. Passing NULL here left p_nscolumns unset and + * caused a segfault whenever an ON SET item's RHS referenced a bound + * variable (issue #2347). + * + * Each column's nscolumn references the join RTE (via its rtindex) with + * p_varattno = position in res_colnames. This matches the scantuple + * layout that apply_update_list sees at execution time: the join's + * target list (built by make_target_list_from_join below) iterates + * eref->colnames in order, so scantuple[i-1] corresponds to the i-th + * entry in eref->colnames. Using the underlying RTE's varno/varattno + * would be semantically equivalent for planner-rewritten Vars in the + * query tree, but the Vars we produce here end up inside prop_expr -- + * opaque metadata the planner does not walk -- so they stay un-remapped + * and must index the scantuple layout directly. + * + * addRangeTableEntryForJoin appends the new RTE to pstate->p_rtable, so + * its rtindex is list_length(p_rtable) + 1 at this point. + */ + { + int colcount = list_length(res_colvars); + int join_rtindex = list_length(pstate->p_rtable) + 1; + ParseNamespaceColumn *nscolumns; + ListCell *lvar; + int col_idx = 0; + + nscolumns = (ParseNamespaceColumn *) + palloc0(colcount * sizeof(ParseNamespaceColumn)); + + foreach (lvar, res_colvars) + { + Var *v = (Var *) lfirst(lvar); + + /* res_colvars is populated by get_res_cols via expandRTE */ + Assert(IsA(v, Var)); + + nscolumns[col_idx].p_varno = join_rtindex; + nscolumns[col_idx].p_varattno = col_idx + 1; + nscolumns[col_idx].p_vartype = + (v->vartype == VERTEXOID || v->vartype == EDGEOID) ? + AGTYPEOID : v->vartype; + nscolumns[col_idx].p_vartypmod = + (nscolumns[col_idx].p_vartype == AGTYPEOID) ? -1 : + v->vartypmod; + nscolumns[col_idx].p_varcollid = + (nscolumns[col_idx].p_vartype == AGTYPEOID) ? InvalidOid : + v->varcollid; + nscolumns[col_idx].p_varnosyn = join_rtindex; + nscolumns[col_idx].p_varattnosyn = col_idx + 1; + col_idx++; + } + + /* make the RTE for the join */ + jnsitem = addRangeTableEntryForJoin(pstate, res_colnames, nscolumns, + j->jointype, 0, res_colvars, + NIL, NIL, j->alias, NULL, true); + Assert(jnsitem->p_rtindex == join_rtindex); + } j->rtindex = jnsitem->p_rtindex; diff --git a/src/backend/parser/cypher_expr.c b/src/backend/parser/cypher_expr.c index fc0335def..7e4f44600 100644 --- a/src/backend/parser/cypher_expr.c +++ b/src/backend/parser/cypher_expr.c @@ -116,6 +116,7 @@ static bool function_exists(char *funcname, char *extension); static Node *coerce_expr_flexible(ParseState *pstate, Node *expr, Oid source_oid, Oid target_oid, int32 t_typemod, bool error_out); +static Oid get_entity_record_type(Node *node); /* transform a cypher expression */ Node *transform_cypher_expr(cypher_parsestate *cpstate, Node *expr, @@ -537,6 +538,25 @@ static Node *transform_AEXPR_OP(cypher_parsestate *cpstate, A_Expr *a) Node *last_srf = pstate->p_last_srf; Node *lexpr = transform_cypher_expr_recurse(cpstate, a->lexpr); Node *rexpr = transform_cypher_expr_recurse(cpstate, a->rexpr); + char *opname = strVal(linitial(a->name)); + Oid lexpr_type = exprType(lexpr); + Oid rexpr_type = exprType(rexpr); + bool is_eq_op = (strcmp(opname, "=") == 0 || strcmp(opname, "<>") == 0); + + /* + * For equality/inequality operators between same vertex/edge types, + * use native operators instead of coercing to agtype. + */ + if (is_eq_op && (lexpr_type == rexpr_type) && + (lexpr_type == VERTEXOID || lexpr_type == EDGEOID)) + { + return (Node *)make_op(pstate, a->name, lexpr, rexpr, last_srf, + a->location); + } + + /* Cast vertex/edge to agtype for operator compatibility */ + lexpr = coerce_entity_to_agtype(pstate, lexpr); + rexpr = coerce_entity_to_agtype(pstate, rexpr); return (Node *)make_op(pstate, a->name, lexpr, rexpr, last_srf, a->location); @@ -871,8 +891,7 @@ static Node *transform_cypher_map_projection(cypher_parsestate *cpstate, FuncExpr *fexpr_new_map; bool has_all_prop_selector; Node *transformed_map_var; - Oid foid_age_properties; - FuncExpr *fexpr_orig_map; + Node *fexpr_orig_map; pstate = (ParseState *)cpstate; keyvals = NIL; @@ -886,11 +905,8 @@ static Node *transform_cypher_map_projection(cypher_parsestate *cpstate, */ transformed_map_var = transform_cypher_expr_recurse(cpstate, (Node *)cmp->map_var); - foid_age_properties = get_ag_func_oid("age_properties", 1, AGTYPEOID); - fexpr_orig_map = makeFuncExpr(foid_age_properties, AGTYPEOID, - list_make1(transformed_map_var), InvalidOid, - InvalidOid, COERCE_EXPLICIT_CALL); - fexpr_orig_map->location = cmp->location; + + fexpr_orig_map = make_properties_expr(transformed_map_var); /* * Builds a new map. Each map projection element is transformed into a key @@ -1161,6 +1177,67 @@ static Node *transform_cypher_map(cypher_parsestate *cpstate, cypher_map *cm) return (Node *)fexpr; } +/* + * Check if a node is a vertex or edge composite type. + */ +bool is_vertex_or_edge(Node *node) +{ + Oid typeid; + + if (node == NULL) + return false; + + if (IsA(node, RowExpr)) + typeid = ((RowExpr *)node)->row_typeid; + else if (IsA(node, Var)) + typeid = ((Var *)node)->vartype; + else + return false; + + return (typeid == VERTEXOID || typeid == EDGEOID); +} + +/* + * Extract a field from a vertex/edge Var or RowExpr. + */ +Node *extract_field_from_record(Node *node, char *field_name) +{ + AttrNumber fieldnum; + Oid fieldtype; + Oid type_id = get_entity_record_type(node); + + get_record_field_info(field_name, type_id, + &fieldnum, &fieldtype); + + if (fieldnum == InvalidAttrNumber || fieldtype == InvalidOid) + { + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_COLUMN), + errmsg("fieldname '%s' does not exist for the entity", field_name))); + } + + if (IsA(node, Var)) + { + return (Node *)make_field_select((Var *)node, fieldnum, fieldtype); + } + else if (IsA(node, RowExpr)) + { + RowExpr *re = (RowExpr *)node; + + if (list_length(re->args) < fieldnum) + { + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_COLUMN), + errmsg("field '%s' does not exist in RowExpr", field_name))); + } + + /* RowExpr args are 0-indexed, but fieldnum is 1-indexed */ + return (Node *)list_nth(re->args, fieldnum - 1); + } + + return NULL; +} + /* * Helper function to transform a cypher list into an agtype list. The function * will use agtype_add to concatenate argument lists when the number of list @@ -1198,9 +1275,10 @@ static Node *transform_cypher_list(cypher_parsestate *cpstate, cypher_list *cl) foreach (le, cl->elems) { Node *texpr = NULL; + Node *cexpr = lfirst(le); /* transform the argument */ - texpr = transform_cypher_expr_recurse(cpstate, lfirst(le)); + texpr = transform_cypher_expr_recurse(cpstate, cexpr); /* * If we have more than 100 elements we will need to add in the list @@ -1313,34 +1391,27 @@ static Node *transform_column_ref_for_indirection(cypher_parsestate *cpstate, pnsi = refnameNamespaceItem(pstate, NULL, relname, cr->location, &levels_up); - /* - * If we didn't find anything, try looking for a previous variable - * reference. Otherwise, return NULL (colNameToVar will return NULL - * if nothing is found). - */ - if (!pnsi) + if (pnsi) { - Node *prev_var = colNameToVar(pstate, relname, false, cr->location); - - return prev_var; + /* find the properties column of the NSI and return a var for it */ + node = scanNSItemForColumn(pstate, pnsi, levels_up, "properties", + cr->location); + } + else + { + node = colNameToVar(pstate, relname, false, cr->location); } - /* find the properties column of the NSI and return a var for it */ - node = scanNSItemForColumn(pstate, pnsi, levels_up, "properties", - cr->location); - - /* - * Error out if we couldn't find it. - * - * TODO: Should we error out or return NULL for further processing? - * For now, just error out. - */ - if (!node) + if (node && is_vertex_or_edge(node)) { - ereport(ERROR, (errcode(ERRCODE_UNDEFINED_OBJECT), - errmsg("could not find properties for %s", relname))); + return extract_field_from_record(node, "properties"); } + /* + * If there's no "properties" column, continue transforming the + * ColumnRef as an agtype value and try to apply the indirection via + * agtype_access_operator. + */ return node; } @@ -2001,6 +2072,145 @@ static bool function_exists(char *funcname, char *extension) return found; } +/* + * Check if function name is an accessor function that can be optimized + */ +static bool is_accessor_function(const char *func_name) +{ + return (strcasecmp("id", func_name) == 0 || + strcasecmp("properties", func_name) == 0 || + strcasecmp("type", func_name) == 0 || + strcasecmp("label", func_name) == 0 || + strcasecmp("start_id", func_name) == 0 || + strcasecmp("end_id", func_name) == 0 || + strcasecmp("startnode", func_name) == 0 || + strcasecmp("endnode", func_name) == 0); +} + +static Oid get_entity_record_type(Node *node) +{ + Oid typeid = InvalidOid; + + if (IsA(node, RowExpr)) + { + typeid = ((RowExpr *)node)->row_typeid; + } + else if (IsA(node, Var)) + { + typeid = ((Var *)node)->vartype; + } + else + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("argument must be a vertex or edge record"))); + } + + if (typeid != VERTEXOID && typeid != EDGEOID) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("argument must be a vertex or edge record"))); + } + + return typeid; +} + +/* + * Optimize accessor functions when called on RECORD entities + * Extracts fields directly using FieldSelect instead of building and parsing agtype + */ +static Node *optimize_accessor_function(cypher_parsestate *cpstate, + const char *func_name, + FuncCall *fn, + List *targs) +{ + ParseState *pstate = &cpstate->pstate; + Node *arg; + AttrNumber fieldnum; + Oid fieldtype; + Node *result; + Oid entity_type; + bool is_node_func = (strcasecmp(func_name, "startNode") == 0 || + strcasecmp(func_name, "endNode") == 0); + + if (is_node_func) + { + if (list_length(targs) < 2) + return NULL; + arg = (Node *)lsecond(targs); + } + else + { + if (list_length(targs) < 1) + return NULL; + arg = (Node *)linitial(targs); + } + + if (!is_vertex_or_edge(arg)) + { + return NULL; + } + + entity_type = get_entity_record_type(arg); + get_record_field_info((char *)func_name, entity_type, &fieldnum, &fieldtype); + + if (fieldnum != InvalidAttrNumber && fieldtype != InvalidOid) + { + Node *field = NULL; + + /* Validate RowExpr has enough fields */ + if (IsA(arg, RowExpr)) + { + RowExpr *re = (RowExpr *)arg; + if (list_length(re->args) < fieldnum) + { + return NULL; + } + + /* RowExpr args are 0-indexed, but fieldnum is 1-indexed */ + field = (Node *)list_nth(re->args, fieldnum - 1); + } + else if (IsA(arg, Var)) + { + field = (Node *)make_field_select((Var *)arg, fieldnum, fieldtype); + } + + /* For startNode/endNode functions on edges, look up the vertex */ + if (is_node_func && entity_type == EDGEOID) + { + Oid func_oid; + Node *graph_name; + + graph_name = (Node *) makeConst(TEXTOID, -1, InvalidOid, -1, + CStringGetTextDatum(cpstate->graph_name), + false, false); + + /* Call _get_vertex_by_graphid(graph_name, vertex_id) */ + func_oid = get_ag_func_oid("_get_vertex_by_graphid", 2, TEXTOID, GRAPHIDOID); + result = (Node *)makeFuncExpr(func_oid, AGTYPEOID, + list_make2(graph_name, field), + InvalidOid, InvalidOid, + COERCE_EXPLICIT_CALL); + } + else + { + result = field; + } + } + else + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("%s() argument must be an %s or null", + func_name, + (entity_type == VERTEXOID) ? "edge" : "vertex"), + parser_errposition(pstate, fn->location))); + } + + return result; +} + /* * Code borrowed from PG's transformFuncCall and updated for AGE */ @@ -2012,14 +2222,24 @@ static Node *transform_FuncCall(cypher_parsestate *cpstate, FuncCall *fn) List *fname = NIL; ListCell *arg; Node *retval = NULL; + bool is_accessor = false; + + /* Get function name to check if it has special optimizations */ + if (list_length(fn->funcname) == 1) + { + is_accessor = is_accessor_function(strVal(linitial(fn->funcname))); + } /* Transform the list of arguments ... */ foreach(arg, fn->args) { Node *farg = NULL; + Node *transformed_arg = NULL; farg = (Node *)lfirst(arg); - targs = lappend(targs, transform_cypher_expr_recurse(cpstate, farg)); + transformed_arg = transform_cypher_expr_recurse(cpstate, farg); + + targs = lappend(targs, transformed_arg); } /* within group should not happen */ @@ -2045,16 +2265,19 @@ static Node *transform_FuncCall(cypher_parsestate *cpstate, FuncCall *fn) fname = list_make2(makeString("ag_catalog"), makeString(ag_name)); /* - * Currently 3 functions need the graph name passed in as the first - * argument - in addition to the other arguments: startNode, endNode, - * and vle. So, check for those 3 functions here and that the arg list - * is not empty. Then prepend the graph name if necessary. + * Currently these functions need the graph name passed in as the + * first argument - in addition to the other arguments: startNode, + * endNode, vle, vertex_stats, shortest_path, and all_shortest_paths. + * So, check for those functions here and that the arg list is not + * empty. Then prepend the graph name if necessary. */ if ((list_length(targs) != 0) && - (strcmp("startNode", name) == 0 || - strcmp("endNode", name) == 0 || - strcmp("vle", name) == 0 || - strcmp("vertex_stats", name) == 0)) + (strcasecmp("startNode", name) == 0 || + strcasecmp("endNode", name) == 0 || + strcasecmp("vle", name) == 0 || + strcasecmp("vertex_stats", name) == 0 || + strcasecmp("shortest_path", name) == 0 || + strcasecmp("all_shortest_paths", name) == 0)) { char *graph_name = cpstate->graph_name; Datum d = string_to_agtype(graph_name); @@ -2063,6 +2286,16 @@ static Node *transform_FuncCall(cypher_parsestate *cpstate, FuncCall *fn) targs = lcons(c, targs); } + + /* + * Try to optimize accessor functions on RECORD entities + */ + if (is_accessor) + { + Node *optimized = optimize_accessor_function(cpstate, name, fn, targs); + if (optimized != NULL) + return optimized; + } } /* * If it's not in age, check if it's a potential call to some function @@ -2193,8 +2426,16 @@ static Node *transform_CaseExpr(cypher_parsestate *cpstate, CaseExpr /* generate placeholder for test expression */ if (arg) { - if (exprType(arg) == UNKNOWNOID) + Oid argtype = exprType(arg); + + if (argtype == UNKNOWNOID) + { arg = coerce_to_common_type(pstate, arg, TEXTOID, "CASE"); + } + else if (argtype == VERTEXOID || argtype == EDGEOID) + { + arg = coerce_to_common_type(pstate, arg, AGTYPEOID, "CASE"); + } assign_expr_collations(pstate, arg); @@ -2284,15 +2525,33 @@ static Node *transform_CaseExpr(cypher_parsestate *cpstate, CaseExpr /* * we pass a NULL context to select_common_type because the common types can * only be AGTYPEOID or BOOLOID. If it returns invalidoid, we know there is a - * boolean involved. + * boolean or record involved. */ ptype = select_common_type(pstate, resultexprs, NULL, NULL); - /* InvalidOid shows that there is a boolean in the result expr. */ + /* InvalidOid shows that there is a boolean or record in the result expr. */ if (ptype == InvalidOid) { - /* we manually set the type to boolean here to handle the bool casting. */ - ptype = BOOLOID; + /* + * If we have record type, we need to cast it to agtype here, + * else return boolean + */ + ListCell *lc; + bool has_vertex_edge = false; + + foreach(lc, resultexprs) + { + Node *expr = (Node *) lfirst(lc); + Oid exprtype = exprType(expr); + + if (exprtype == VERTEXOID || exprtype == EDGEOID) + { + has_vertex_edge = true; + break; + } + } + + ptype = has_vertex_edge ? AGTYPEOID : BOOLOID; } Assert(OidIsValid(ptype)); @@ -2433,3 +2692,70 @@ static Node *transform_SubLink(cypher_parsestate *cpstate, SubLink *sublink) return result; } + +/* + * coerce_entity_to_agtype + * + * If the node is a vertex or edge type, coerce it to agtype. + * Returns the coerced node, or the original node if no coercion needed. + */ +Node *coerce_entity_to_agtype(ParseState *pstate, Node *node) +{ + Oid node_type = exprType(node); + + if (node_type == VERTEXOID || node_type == EDGEOID) + { + return coerce_to_target_type(pstate, node, node_type, + AGTYPEOID, -1, COERCION_EXPLICIT, + COERCE_IMPLICIT_CAST, -1); + } + + return node; +} + +/* + * coerce_target_entities_to_agtype + * + * Iterate through a target list and coerce any vertex/edge types to agtype. + * Used for terminal clauses (SET, DELETE, CREATE, MERGE) to ensure the + * executor receives agtype. + */ +void coerce_target_entities_to_agtype(ParseState *pstate, List *target_list) +{ + ListCell *lc; + + foreach(lc, target_list) + { + TargetEntry *te = (TargetEntry *)lfirst(lc); + Oid te_type = exprType((Node *)te->expr); + + if (te_type == VERTEXOID || te_type == EDGEOID) + { + te->expr = (Expr *)coerce_to_target_type( + pstate, (Node *)te->expr, te_type, + AGTYPEOID, -1, COERCION_EXPLICIT, + COERCE_IMPLICIT_CAST, -1); + } + } +} + +Node *make_properties_expr(Node *prop_var) +{ + Node *prop_expr; + + if (is_vertex_or_edge(prop_var)) + { + prop_expr = extract_field_from_record(prop_var, "properties"); + } + else + { + /* Call age_properties for non-RECORD types */ + Oid foid = get_ag_func_oid("age_properties", 1, AGTYPEOID); + prop_expr = (Node *)makeFuncExpr(foid, AGTYPEOID, + list_make1(prop_var), + InvalidOid, InvalidOid, + COERCE_EXPLICIT_CALL); + } + + return prop_expr; +} diff --git a/src/backend/parser/cypher_gram.y b/src/backend/parser/cypher_gram.y index 5ba1e6354..71b95bde3 100644 --- a/src/backend/parser/cypher_gram.y +++ b/src/backend/parser/cypher_gram.y @@ -34,7 +34,7 @@ do \ { \ if ((n) > 0) \ - current = (rhs)[1]; \ + current = YYRHSLOC(rhs, 1); \ else \ current = -1; \ } while (0) @@ -49,6 +49,43 @@ %locations %name-prefix="cypher_yy" %pure-parser +/* + * GLR mode handles the ambiguity between parenthesized expressions and + * graph patterns. For example, WHERE (a)-[:KNOWS]->(b) starts with (a) + * which is valid as both an expression and a path_node. The parser forks + * at the conflict point and discards the failing path. %dprec annotations + * on expr_var/var_name_opt and '(' expr ')'/anonymous_path resolve cases + * where both paths succeed (bare (a) prefers the expression interpretation). + */ +%glr-parser +/* + * GLR conflicts are expected and correct for this grammar. They arise + * from the inherent ambiguity between parenthesized expressions and + * graph patterns: the shift/reduce conflicts on '-', '<', '{', + * PARAMETER and ')' all come from path extension vs. arithmetic or + * parenthesized-expression alternatives after a leading '(', and the + * reduce/reduce conflicts on ')', '}' and '=' come from the overlap + * between expr_var and var_name_opt. GLR handles all of these by + * forking at the conflict point and discarding the failing alternative; + * %dprec annotations on expr_var/var_name_opt and '(' expr ')' / + * anonymous_path resolve cases where both forks succeed (bare (a) + * prefers the expression interpretation). + * + * The %expect / %expect-rr counts below match the Bison-reported totals + * (7 SR / 3 RR on Bison 3.8.2). Bison treats %expect as exact, not as + * a ceiling: any deviation up or down fails the build. That is the + * alarm bell — if a grammar change moves either count, the build stops + * and the conflicts must be audited to confirm they remain the inherent + * '(' expr ')' vs anonymous_path ambiguities (resolved by %dprec at + * runtime) rather than an unintended new ambiguity. The Makefile + * downgrades -Wconflicts-sr / -Wconflicts-rr from errors to warnings + * (-Wno-error=conflicts-{sr,rr}) so %expect, not the warning category, + * controls the build-fail threshold. If a future Bison version reports + * different counts for the same grammar, update these numbers and note + * the version in the commit message. + */ +%expect 7 +%expect-rr 3 %lex-param {ag_scanner_t scanner} %parse-param {ag_scanner_t scanner} @@ -64,6 +101,10 @@ bool boolean; Node *node; List *list; + struct { + List *on_match; + List *on_create; + } merge_actions; } %token INTEGER @@ -79,7 +120,7 @@ %token NOT_EQ LT_EQ GT_EQ DOT_DOT TYPECAST PLUS_EQ /* keywords in alphabetical order */ -%token ALL ANALYZE AND AS ASC ASCENDING +%token ALL ANALYZE AND ANY_P AS ASC ASCENDING BY CALL CASE COALESCE CONTAINS COUNT CREATE DELETE DESC DESCENDING DETACH DISTINCT @@ -88,10 +129,10 @@ IN IS LIMIT MATCH MERGE - NOT NULL_P - OPERATOR OPTIONAL OR ORDER - REMOVE RETURN - SET SKIP STARTS + NONE NOT NULL_P + ON OPERATOR OPTIONAL OR ORDER + REDUCE REMOVE RETURN + SET SINGLE SKIP STARTS THEN TRUE_P UNION UNWIND VERBOSE @@ -139,6 +180,7 @@ /* MERGE clause */ %type merge +%type merge_actions_opt merge_actions merge_action /* CALL ... YIELD clause */ %type call_stmt yield_item @@ -170,7 +212,7 @@ %type expr_subquery /* names */ -%type property_key_name var_name var_name_opt label_name +%type property_key_name var_name var_name_alias var_name_opt label_name %type symbolic_name schema_name type_name %type reserved_keyword safe_keywords conflicted_keywords %type func_name @@ -274,11 +316,27 @@ static Node *build_comparison_expression(Node *left_grammar_node, Node *right_grammar_node, char *opr_name, int location); +/* shared helper for list iteration constructs */ +static char *extract_iter_variable_name(Node *var); + /* list_comprehension */ static Node *build_list_comprehension_node(Node *var, Node *expr, Node *where, Node *mapping_expr, int location); +/* predicate functions: all(), any(), none(), single() */ +static Node *build_predicate_function_node(cypher_predicate_function_kind kind, + Node *var, Node *expr, + Node *where, int location); + +/* pattern expression helper */ +static Node *make_exists_pattern_sublink(Node *pattern, int location); + +/* reduce(acc = init, var IN list | body) */ +static Node *build_reduce_node(char *acc_varname, Node *init_expr, + char *elem_varname, Node *list_expr, + Node *body_expr, int location); + /* helper functions */ static ExplainStmt *make_explain_stmt(List *options); static void validate_return_item_aliases(List *items, ag_scanner_t scanner); @@ -467,7 +525,7 @@ yield_item_list: ; yield_item: - expr AS var_name + expr AS var_name_alias { ResTarget *n; @@ -778,7 +836,7 @@ return_item_list: ; return_item: - expr AS var_name + expr AS var_name_alias { ResTarget *n; @@ -972,7 +1030,7 @@ optional_opt: unwind: - UNWIND expr AS var_name + UNWIND expr AS var_name_alias { ResTarget *res; cypher_unwind *n; @@ -1131,17 +1189,72 @@ detach_opt: * MERGE clause */ merge: - MERGE path + MERGE path merge_actions_opt { cypher_merge *n; n = make_ag_node(cypher_merge); n->path = $2; + n->on_match = $3.on_match; + n->on_create = $3.on_create; $$ = (Node *)n; } ; +merge_actions_opt: + /* empty */ + { + $$.on_match = NIL; + $$.on_create = NIL; + } + | merge_actions + { + $$ = $1; + } + ; + +merge_actions: + merge_action + { + $$ = $1; + } + | merge_actions merge_action + { + if ($2.on_match != NIL) + { + if ($1.on_match != NIL) + ereport(ERROR, + (errcode(ERRCODE_SYNTAX_ERROR), + errmsg("ON MATCH SET specified more than once"))); + $$.on_match = $2.on_match; + $$.on_create = $1.on_create; + } + else + { + if ($1.on_create != NIL) + ereport(ERROR, + (errcode(ERRCODE_SYNTAX_ERROR), + errmsg("ON CREATE SET specified more than once"))); + $$.on_create = $2.on_create; + $$.on_match = $1.on_match; + } + } + ; + +merge_action: + ON MATCH SET set_item_list + { + $$.on_match = $4; + $$.on_create = NIL; + } + | ON CREATE SET set_item_list + { + $$.on_match = NIL; + $$.on_create = $4; + } + ; + /* * common */ @@ -1808,21 +1921,7 @@ expr_func_subexpr: } | EXISTS '(' anonymous_path ')' { - cypher_sub_pattern *sub; - SubLink *n; - - sub = make_ag_node(cypher_sub_pattern); - sub->kind = CSP_EXISTS; - sub->pattern = list_make1($3); - - n = makeNode(SubLink); - n->subLinkType = EXISTS_SUBLINK; - n->subLinkId = 0; - n->testexpr = NULL; - n->operName = NIL; - n->subselect = (Node *) sub; - n->location = @1; - $$ = (Node *)node_to_agtype((Node *)n, "boolean", @1); + $$ = make_exists_pattern_sublink($3, @1); } | EXISTS '(' property_value ')' { @@ -1853,6 +1952,26 @@ expr_func_subexpr: list_make1(makeString("count")), NIL, @1); $$ = (Node *)n; } + | ALL '(' expr IN expr WHERE expr ')' + { + $$ = build_predicate_function_node(CPFK_ALL, $3, $5, $7, @1); + } + | ANY_P '(' expr IN expr WHERE expr ')' + { + $$ = build_predicate_function_node(CPFK_ANY, $3, $5, $7, @1); + } + | NONE '(' expr IN expr WHERE expr ')' + { + $$ = build_predicate_function_node(CPFK_NONE, $3, $5, $7, @1); + } + | SINGLE '(' expr IN expr WHERE expr ')' + { + $$ = build_predicate_function_node(CPFK_SINGLE, $3, $5, $7, @1); + } + | REDUCE '(' var_name '=' expr ',' var_name IN expr '|' expr ')' + { + $$ = build_reduce_node($3, $5, $7, $9, $11, @1); + } ; expr_subquery: @@ -1942,7 +2061,7 @@ expr_atom: $$ = (Node *)n; } - | '(' expr ')' + | '(' expr ')' %dprec 2 { Node *n = $2; @@ -1953,6 +2072,17 @@ expr_atom: } $$ = n; } + | anonymous_path %dprec 1 + { + /* + * Bare pattern in expression context is semantically + * equivalent to EXISTS(pattern). Example: + * WHERE (a)-[:KNOWS]->(b) + * becomes + * WHERE EXISTS((a)-[:KNOWS]->(b)) + */ + $$ = make_exists_pattern_sublink($1, @1); + } | expr_case | expr_var | expr_func @@ -1997,6 +2127,16 @@ map: n = make_ag_node(cypher_map); n->keyvals = $2; + /* + * By default, a Cypher map literal preserves keys whose + * values are null (openCypher / Neo4j semantics: e.g. + * RETURN {a: null} yields {a: null}, not {}). CREATE and + * SET = override this to false on the top-level property + * map in cypher_clause.c so null properties are stripped + * on write; a nested map value is its own node and keeps + * this default, preserving its null-valued keys. + */ + n->keep_null = true; $$ = (Node *)n; } @@ -2204,7 +2344,7 @@ expr_case_default: ; expr_var: - var_name + var_name %dprec 2 { ColumnRef *n; @@ -2253,12 +2393,48 @@ var_name: } ; +/* + * var_name_alias is used in alias positions (RETURN/WITH/YIELD ... AS x, + * UNWIND ... AS x) where the AS keyword removes any lookahead ambiguity. + * Beyond plain identifiers, it permits the same set of non-conflicting + * reserved keywords accepted by safe_keywords (already accepted in + * func_name; schema_name accepts the broader reserved_keyword), so that + * legitimate Cypher such as + * RETURN 1 AS count + * RETURN n AS exists + * UNWIND [1, 2] AS row + * is parsed correctly. Truly conflicting tokens (END, NULL, TRUE, FALSE) + * are listed under conflicted_keywords (not safe_keywords) and remain + * rejected here. See issue #2355. + * + * It is intentionally NOT used in pattern variable positions + * ((x:Label), [r:REL]) or named-path bindings (p = ...), because + * allowing reserved keywords there introduces shift/reduce ambiguity. + * + * NOTE: Reading a keyword-named alias back (e.g. WITH 1 AS count + * RETURN count) is intentionally still rejected -- broadening expr_var + * (which reads through var_name) to accept safe_keywords reintroduces + * ~156 shift/reduce conflicts in bison. That asymmetry (writable but + * not readable) is tracked in issue #2416. + */ +var_name_alias: + var_name + | safe_keywords + { + /* safe_keywords already returns a pnstrdup-allocated copy via + * KEYWORD_STRDUP, so no further pstrdup is needed. Mirrors the + * established pattern used by schema_name's reserved_keyword + * branch. */ + $$ = (char *) $1; + } + ; + var_name_opt: - /* empty */ + /* empty */ %dprec 1 { $$ = NULL; } - | var_name + | var_name %dprec 1 ; label_name: @@ -2372,6 +2548,7 @@ safe_keywords: ALL { $$ = KEYWORD_STRDUP($1); } | ANALYZE { $$ = KEYWORD_STRDUP($1); } | AND { $$ = KEYWORD_STRDUP($1); } + | ANY_P { $$ = KEYWORD_STRDUP($1); } | AS { $$ = KEYWORD_STRDUP($1); } | ASC { $$ = KEYWORD_STRDUP($1); } | ASCENDING { $$ = KEYWORD_STRDUP($1); } @@ -2396,14 +2573,18 @@ safe_keywords: | LIMIT { $$ = KEYWORD_STRDUP($1); } | MATCH { $$ = KEYWORD_STRDUP($1); } | MERGE { $$ = KEYWORD_STRDUP($1); } + | NONE { $$ = KEYWORD_STRDUP($1); } | NOT { $$ = KEYWORD_STRDUP($1); } + | ON { $$ = KEYWORD_STRDUP($1); } | OPERATOR { $$ = KEYWORD_STRDUP($1); } | OPTIONAL { $$ = KEYWORD_STRDUP($1); } | OR { $$ = KEYWORD_STRDUP($1); } | ORDER { $$ = KEYWORD_STRDUP($1); } + | REDUCE { $$ = KEYWORD_STRDUP($1); } | REMOVE { $$ = KEYWORD_STRDUP($1); } | RETURN { $$ = KEYWORD_STRDUP($1); } | SET { $$ = KEYWORD_STRDUP($1); } + | SINGLE { $$ = KEYWORD_STRDUP($1); } | SKIP { $$ = KEYWORD_STRDUP($1); } | STARTS { $$ = KEYWORD_STRDUP($1); } | THEN { $$ = KEYWORD_STRDUP($1); } @@ -3268,35 +3449,66 @@ static cypher_relationship *build_VLE_relation(List *left_arg, return cr; } -/* helper function to build a list_comprehension grammar node */ -static Node *build_list_comprehension_node(Node *var, Node *expr, - Node *where, Node *mapping_expr, - int location) +/* + * Extract and validate the iterator variable name from a ColumnRef node. + * Used by predicate functions (all/any/none/single) which share the + * "variable IN list" syntax with list comprehensions. + */ +static char *extract_iter_variable_name(Node *var) { - SubLink *sub; + ColumnRef *cref; String *val; - ColumnRef *cref = NULL; - cypher_list_comprehension *list_comp = NULL; if (!IsA(var, ColumnRef)) { ereport(ERROR, (errcode(ERRCODE_SYNTAX_ERROR), - errmsg("Syntax error at or near IN"))); + errmsg("syntax error at or near IN"))); } cref = (ColumnRef *)var; + + /* The iterator must be a simple unqualified name (single field) */ + if (list_length(cref->fields) != 1) + { + ereport(ERROR, + (errcode(ERRCODE_SYNTAX_ERROR), + errmsg("qualified name not allowed as iterator variable"))); + } + val = linitial(cref->fields); if (!IsA(val, String)) { ereport(ERROR, (errcode(ERRCODE_SYNTAX_ERROR), - errmsg("Invalid list comprehension variable name"))); + errmsg("invalid iterator variable name"))); } + return val->sval; +} + +/* helper function to build a list_comprehension grammar node */ +static Node *build_list_comprehension_node(Node *var, Node *expr, + Node *where, Node *mapping_expr, + int location) +{ + SubLink *sub; + ColumnRef *cref; + char *varname; + cypher_list_comprehension *list_comp = NULL; + + /* + * Reuse the shared iterator-name helper so list comprehensions and + * predicate functions (all/any/none/single) validate `var IN list` + * the same way — in particular, qualified ColumnRefs like `x.y` are + * rejected rather than silently treated as iterator `x`. + */ + varname = extract_iter_variable_name(var); + cref = (ColumnRef *) var; + /* build the list comprehension node */ list_comp = make_ag_node(cypher_list_comprehension); - list_comp->varname = val->sval; + list_comp->varname = varname; list_comp->expr = expr; list_comp->where = where; list_comp->mapping_expr = (mapping_expr != NULL) ? mapping_expr : @@ -3317,6 +3529,204 @@ static Node *build_list_comprehension_node(Node *var, Node *expr, return (Node *) node_to_agtype((Node *)sub, "agtype[]", location); } +/* + * Helper function to build a predicate function grammar node. + * + * Predicate functions follow the openCypher syntax: + * all(x IN list WHERE predicate) + * any(x IN list WHERE predicate) + * none(x IN list WHERE predicate) + * single(x IN list WHERE predicate) + * + * All four use EXPR_SUBLINK (scalar subquery). The transform layer + * generates aggregate-based queries that preserve three-valued NULL + * semantics: bool_or + CASE for all/any/none, and a CASE built on + * count(*) FILTER for single(). + */ +static Node *build_predicate_function_node(cypher_predicate_function_kind kind, + Node *var, Node *expr, + Node *where, int location) +{ + SubLink *sub; + cypher_predicate_function *pred_func = NULL; + Node *result; + + /* build the predicate function node */ + pred_func = make_ag_node(cypher_predicate_function); + pred_func->kind = kind; + pred_func->varname = extract_iter_variable_name(var); + pred_func->expr = expr; + pred_func->where = where; + + /* + * Wrap the predicate function in a SubLink. PostgreSQL's SubLink is + * reused here as the carrier for our custom subquery node -- the + * predicate function node is stored as the subselect and will be + * transformed into a real Query by transform_cypher_predicate_function() + * in cypher_clause.c. + * + * All predicate functions now use EXPR_SUBLINK: the transform layer + * generates aggregate-based queries that return a scalar boolean + * (for all/any/none) or integer (for single). + * + * The transform layer also wraps the result with a NULL-list guard + * (CASE WHEN list IS NULL THEN NULL ELSE END) to ensure + * all four functions return NULL when the input list is NULL. + */ + sub = makeNode(SubLink); + sub->subLinkId = 0; + sub->testexpr = NULL; + sub->operName = NIL; + sub->subselect = (Node *) pred_func; + sub->location = location; + sub->subLinkType = EXPR_SUBLINK; + + /* + * The subquery returns a boolean directly from the CASE+bool_or() + * aggregate expression (count(*) FILTER for single()). + */ + result = (Node *) node_to_agtype((Node *) sub, "boolean", location); + + /* + * NULL-list guard: CASE WHEN expr IS NULL THEN NULL ELSE result END + * + * Without this, unnest(NULL) produces zero rows, causing all/any/none + * to accidentally return NULL (via bool_or over empty input) and + * single to return false (count(*) = 0). Cypher semantics require + * all four to return NULL when the input list is NULL. + * + * The expr pointer is shared with pred_func->expr. This is safe + * because AGE's expression transformer (transform_cypher_expr_recurse) + * creates new nodes rather than modifying the parse tree in-place, + * so the two references are transformed independently. + */ + { + NullTest *null_test = makeNode(NullTest); + CaseWhen *case_when = makeNode(CaseWhen); + CaseExpr *guard = makeNode(CaseExpr); + + null_test->arg = (Expr *) expr; + null_test->nulltesttype = IS_NULL; + null_test->argisrow = false; + null_test->location = location; + + case_when->expr = (Expr *) null_test; + case_when->result = (Expr *) make_null_const(location); + case_when->location = location; + + guard->arg = NULL; + guard->args = list_make1(case_when); + guard->defresult = (Expr *) result; + guard->location = location; + + return (Node *) guard; + } +} + +/* + * Wrap a graph pattern in an EXISTS SubLink. Used by both + * EXISTS(pattern) syntax and bare pattern expressions in WHERE. + */ +static Node *make_exists_pattern_sublink(Node *pattern, int location) +{ + cypher_sub_pattern *sub; + SubLink *n; + + sub = make_ag_node(cypher_sub_pattern); + sub->kind = CSP_EXISTS; + sub->pattern = list_make1(pattern); + + n = makeNode(SubLink); + n->subLinkType = EXISTS_SUBLINK; + n->subLinkId = 0; + n->testexpr = NULL; + n->operName = NIL; + n->subselect = (Node *) sub; + n->location = location; + + return (Node *)node_to_agtype((Node *)n, "boolean", location); +} + +/* + * Helper function to build a reduce() grammar node. + * + * Follows the openCypher syntax: + * reduce(acc = init, var IN list | body) + * + * The accumulator `acc` is seeded with `init` and threaded across the + * elements of `list` (bound to `var`) in list order, with `body` producing + * the next accumulator value at each step. The result is the final + * accumulator value, or `init` when the list is empty. + * + * The reduce node is wrapped in an EXPR_SUBLINK (scalar subquery) whose + * subselect is the cypher_reduce node; transform_cypher_reduce() in + * cypher_clause.c rewrites it into a correlated scalar subquery over an + * ordered aggregate. + * + * The whole thing is then wrapped so the openCypher null/empty-list semantics + * hold without the transform layer having to special-case them: + * + * CASE WHEN list IS NULL THEN NULL + * ELSE COALESCE((reduce subquery), init) END + * + * A NULL list yields NULL; an empty list yields `init` (the aggregate runs + * over zero rows and returns SQL NULL, which COALESCE replaces with init); + * a non-empty list yields the fold result. The list and init grammar nodes + * are shared between the reduce node and this guard, which is safe because + * AGE's expression transformer builds new nodes rather than mutating in place. + */ +static Node *build_reduce_node(char *acc_varname, Node *init_expr, + char *elem_varname, Node *list_expr, + Node *body_expr, int location) +{ + SubLink *sub; + cypher_reduce *reduce_node = NULL; + CoalesceExpr *coalesce; + NullTest *null_test; + CaseWhen *case_when; + CaseExpr *guard; + + reduce_node = make_ag_node(cypher_reduce); + reduce_node->acc_varname = acc_varname; + reduce_node->init_expr = init_expr; + reduce_node->elem_varname = elem_varname; + reduce_node->list_expr = list_expr; + reduce_node->body_expr = body_expr; + + sub = makeNode(SubLink); + sub->subLinkId = 0; + sub->testexpr = NULL; + sub->operName = NIL; + sub->subselect = (Node *) reduce_node; + sub->location = location; + sub->subLinkType = EXPR_SUBLINK; + + /* COALESCE((reduce subquery), init) -- empty list falls back to init */ + coalesce = makeNode(CoalesceExpr); + coalesce->args = list_make2((Node *) sub, init_expr); + coalesce->location = location; + + /* CASE WHEN list IS NULL THEN NULL ELSE END */ + null_test = makeNode(NullTest); + null_test->arg = (Expr *) list_expr; + null_test->nulltesttype = IS_NULL; + null_test->argisrow = false; + null_test->location = location; + + case_when = makeNode(CaseWhen); + case_when->expr = (Expr *) null_test; + case_when->result = (Expr *) make_null_const(location); + case_when->location = location; + + guard = makeNode(CaseExpr); + guard->arg = NULL; + guard->args = list_make1(case_when); + guard->defresult = (Expr *) coalesce; + guard->location = location; + + return (Node *) guard; +} + /* Helper function to create an ExplainStmt node */ static ExplainStmt *make_explain_stmt(List *options) { diff --git a/src/backend/parser/cypher_transform_entity.c b/src/backend/parser/cypher_transform_entity.c index 1b2ab0edd..13f733608 100644 --- a/src/backend/parser/cypher_transform_entity.c +++ b/src/backend/parser/cypher_transform_entity.c @@ -52,6 +52,7 @@ transform_entity *make_transform_entity(cypher_parsestate *cpstate, entity->declared_in_current_clause = true; entity->expr = expr; entity->in_join_tree = expr != NULL; + entity->vle_alias = NULL; return entity; } diff --git a/src/backend/utils/adt/age_global_graph.c b/src/backend/utils/adt/age_global_graph.c index c34e51ee3..397e0511c 100644 --- a/src/backend/utils/adt/age_global_graph.c +++ b/src/backend/utils/adt/age_global_graph.c @@ -21,24 +21,81 @@ #include "access/heapam.h" #include "catalog/namespace.h" +#include "commands/trigger.h" #include "common/hashfn.h" #include "commands/label_commands.h" +#include "port/atomics.h" +#include "storage/lwlock.h" #include "utils/datum.h" #include "utils/lsyscache.h" #include "utils/memutils.h" #include "utils/snapmgr.h" +#include "utils/builtins.h" + +#if PG_VERSION_NUM >= 170000 +#include "storage/dsm_registry.h" +#else +#include "storage/ipc.h" +#include "storage/shmem.h" +#endif #include "utils/age_global_graph.h" +#include "utils/agehash.h" #include "catalog/ag_graph.h" #include "catalog/ag_label.h" +#include "utils/ag_cache.h" -#include /* defines */ #define VERTEX_HTAB_NAME "Vertex to edge lists " /* added a space at end for */ -#define EDGE_HTAB_NAME "Edge to vertex mapping " /* the graph name to follow */ -#define VERTEX_HTAB_INITIAL_SIZE 1000000 -#define EDGE_HTAB_INITIAL_SIZE 1000000 +#define VERTEX_HTAB_INITIAL_SIZE 10000 +#define EDGE_HTAB_INITIAL_SIZE 10000 + +/* Maximum number of graphs tracked for version counting */ +#define AGE_MAX_GRAPHS 128 + +/* + * Graph version counter entry. Stored in shared memory (DSM or shmem) + * so that all backends can see mutation events. The version counter is + * incremented by Cypher mutations (CREATE/DELETE/SET/MERGE) and by + * SQL triggers on label tables. VLE cache invalidation checks this + * counter instead of snapshot xmin/xmax/curcid. + */ +typedef struct GraphVersionEntry +{ + Oid graph_oid; /* graph identifier (0 = unused slot) */ + pg_atomic_uint64 version; /* monotonic change counter */ +} GraphVersionEntry; + +/* + * Shared memory state for graph version tracking. + * Contains a fixed-size array of per-graph version counters. + */ +typedef struct GraphVersionState +{ + LWLock lock; /* protects slot allocation only */ + int num_entries; /* number of active entries */ + GraphVersionEntry entries[AGE_MAX_GRAPHS]; +} GraphVersionState; + +/* + * Version mode detection — determined once per backend on first use. + * DSM: PG 17+ GetNamedDSMSegment (no shared_preload_libraries needed) + * SHMEM: PG < 17 with shared_preload_libraries + * SNAPSHOT: PG < 17 without shared_preload_libraries (current behavior) + */ +typedef enum +{ + VERSION_MODE_UNKNOWN = 0, + VERSION_MODE_DSM, + VERSION_MODE_SHMEM, + VERSION_MODE_SNAPSHOT +} VersionMode; + +static VersionMode version_mode = VERSION_MODE_UNKNOWN; + +/* For PG < 17 shmem path */ +static GraphVersionState *shmem_version_state = NULL; /* internal data structures implementation */ @@ -46,19 +103,28 @@ typedef struct vertex_entry { graphid vertex_id; /* vertex id, it is also the hash key */ - ListGraphId *edges_in; /* List of entering edges graphids (int64) */ - ListGraphId *edges_out; /* List of exiting edges graphids (int64) */ - ListGraphId *edges_self; /* List of selfloop edges graphids (int64) */ + VertexEdgeArray edges_in; /* incoming edge graphids (flat array) */ + VertexEdgeArray edges_out; /* outgoing edge graphids (flat array) */ + VertexEdgeArray edges_self; /* self-loop edge graphids (flat array) */ Oid vertex_label_table_oid; /* the label table oid */ - Datum vertex_properties; /* datum property value */ + ItemPointerData tid; /* physical tuple location for lazy fetch */ } vertex_entry; -/* edge entry for the edge_hashtable */ +/* + * edge entry for the edge_table. + * + * The edge_id is the hash key and is stored in the agehash slot header + * (immediately before the payload). It is intentionally NOT a field on this + * payload struct: duplicating it would add 8 bytes per edge to the slot, + * which on SF10 (~175M edges) is over a gigabyte of overhead. Use + * get_edge_entry_id(ee) when you need the id of an entry returned by + * get_edge_entry / get_edge_entry_with_hash; that helper recovers the key + * from the slot via agehash_key_from_payload. + */ typedef struct edge_entry { - graphid edge_id; /* edge id, it is also the hash key */ Oid edge_label_table_oid; /* the label table oid */ - Datum edge_properties; /* datum property value */ + ItemPointerData tid; /* physical tuple location for lazy fetch */ graphid start_vertex_id; /* start vertex */ graphid end_vertex_id; /* end vertex */ } edge_entry; @@ -73,29 +139,64 @@ typedef struct GRAPH_global_context char *graph_name; /* graph name */ Oid graph_oid; /* graph oid for searching */ HTAB *vertex_hashtable; /* hashtable to hold vertex edge lists */ - HTAB *edge_hashtable; /* hashtable to hold edge to vertex map */ - TransactionId xmin; /* transaction ids for this graph */ - TransactionId xmax; - CommandId curcid; /* currentCommandId graph was created with */ + AgeHashTable *edge_table; /* edge to vertex map (Robin Hood) */ + MemoryContext edge_table_mcxt; /* private context owning edge_table */ + uint64 graph_version; /* version counter for cache invalidation */ + TransactionId xmin; /* snapshot fallback: transaction xmin */ + TransactionId xmax; /* snapshot fallback: transaction xmax */ + CommandId curcid; /* snapshot fallback: command id */ int64 num_loaded_vertices; /* number of loaded vertices in this graph */ int64 num_loaded_edges; /* number of loaded edges in this graph */ ListGraphId *vertices; /* vertices for vertex hashtable cleanup */ - ListGraphId *edges; /* edges for edge hashtable cleanup */ struct GRAPH_global_context *next; /* next graph */ } GRAPH_global_context; -/* container for GRAPH_global_context and its mutex */ -typedef struct GRAPH_global_context_container +/* global variable to hold the per process GRAPH global contexts */ +static GRAPH_global_context *global_graph_contexts = NULL; + +/* + * VertexEdgeArray helpers — flat-array adjacency container used by + * vertex_entry's edges_in / edges_out / edges_self. + * + * Growth policy: start at 4 slots on first append, then double on each + * overflow. This keeps the average cost of n appends amortised O(n) and + * keeps the memory waste bounded by 2x. + */ +#define VEA_INITIAL_CAPACITY 4 + +static inline void vea_append(VertexEdgeArray *vea, graphid edge_id) { - /* head of the list */ - GRAPH_global_context *contexts; + if (vea->size == vea->capacity) + { + int32 new_capacity = (vea->capacity == 0) + ? VEA_INITIAL_CAPACITY + : vea->capacity * 2; + + if (vea->array == NULL) + { + vea->array = (graphid *) palloc(new_capacity * sizeof(graphid)); + } + else + { + vea->array = (graphid *) repalloc(vea->array, + new_capacity * sizeof(graphid)); + } - /* mutex to protect the list */ - pthread_mutex_t mutex_lock; -} GRAPH_global_context_container; + vea->capacity = new_capacity; + } + vea->array[vea->size++] = edge_id; +} -/* global variable to hold the per process GRAPH global contexts */ -static GRAPH_global_context_container global_graph_contexts_container = {0}; +static inline void vea_free(VertexEdgeArray *vea) +{ + if (vea->array != NULL) + { + pfree(vea->array); + vea->array = NULL; + } + vea->size = 0; + vea->capacity = 0; +} /* declarations */ /* GRAPH global context functions */ @@ -110,36 +211,111 @@ static void freeze_GRAPH_global_hashtables(GRAPH_global_context *ggctx); static List *get_ag_labels_names(Snapshot snapshot, Oid graph_oid, char label_type); static bool insert_edge_entry(GRAPH_global_context *ggctx, graphid edge_id, - Datum edge_properties, graphid start_vertex_id, + ItemPointerData tid, graphid start_vertex_id, graphid end_vertex_id, Oid edge_label_table_oid); static bool insert_vertex_edge(GRAPH_global_context *ggctx, graphid start_vertex_id, graphid end_vertex_id, graphid edge_id, char *edge_label_name); static bool insert_vertex_entry(GRAPH_global_context *ggctx, graphid vertex_id, Oid vertex_label_table_oid, - Datum vertex_properties); + ItemPointerData tid); /* definitions */ /* * Helper function to determine validity of the passed GRAPH_global_context. - * This is based off of the current active snapshot, to see if the graph could - * have been modified. Ideally, we should find a way to more accurately know - * whether the particular graph was modified. + * + * Uses graph-specific version counters (via DSM or shmem) when available. + * Falls back to snapshot-based invalidation when shared memory is not + * initialized (PG < 17 without shared_preload_libraries). + * + * The version counter approach only invalidates when the specific graph + * has been mutated (via Cypher operations or SQL triggers), avoiding false + * invalidation from unrelated transactions on the server. */ bool is_ggctx_invalid(GRAPH_global_context *ggctx) { - Snapshot snap = GetActiveSnapshot(); + /* use version counter if DSM or SHMEM mode is active */ + if (version_mode == VERSION_MODE_DSM || version_mode == VERSION_MODE_SHMEM) + { + uint64 current_version = get_graph_version(ggctx->graph_oid); + + /* + * If current_version is 0, no mutations have been tracked through + * the version counter system yet. Fall through to snapshot-based + * checking for safety — the graph may have been mutated via paths + * that don't increment the counter (e.g., before executor hooks + * are in place, or via direct SQL without triggers). + * + * Once current_version > 0, we know the counter is actively + * tracking this graph and can rely on it exclusively. + */ + if (current_version > 0) + { + return (ggctx->graph_version != current_version); + } + /* fall through to snapshot check */ + } - /* - * If the transaction ids (xmin or xmax) or currentCommandId (curcid) have - * changed, then we have a graph that was updated. This means that the - * global context for this graph is no longer valid. - */ - return (ggctx->xmin != snap->xmin || - ggctx->xmax != snap->xmax || - ggctx->curcid != snap->curcid); + /* SNAPSHOT fallback: original behavior — check snapshot ids */ + { + Snapshot snap = GetActiveSnapshot(); + return (ggctx->xmin != snap->xmin || + ggctx->xmax != snap->xmax || + ggctx->curcid != snap->curcid); + } } +/* + * Fast hash function for graphid (int64) keys. + * + * Replaces dynahash's tag_hash (Jenkins lookup3 → ~17 mixing ops) with the + * MurmurHash3 fmix64 finalizer (5 ops: 3 xorshifts + 2 multiplies). + * + * Quality: fmix64 is the avalanche stage of MurmurHash3 and passes all SMHasher + * tests for 64-bit integer inputs. The output is truncated to uint32 to match + * dynahash's HashValueFunc signature; bits 0..31 of fmix64 are well-mixed. + * + * Performance rationale: graphid lookups dominate hash_search_with_hash_value + * time (≈41% IC1 on SF3). Reducing the per-call mixing cost cuts both insert + * and lookup overhead in age_global_graph and age_vle hashtables. + */ +uint32 graphid_hash(const void *key, Size keysize) +{ + uint64 k; + + /* keysize is always sizeof(int64) for every graphid hashtable; assert in debug. */ + Assert(keysize == sizeof(int64)); + (void) keysize; + + /* graphid keys are stored as int64; load aligned (callers pass &graphid). */ + memcpy(&k, key, sizeof(uint64)); + + /* MurmurHash3 fmix64 (Austin Appleby, public domain). */ + k ^= k >> 33; + k *= UINT64CONST(0xff51afd7ed558ccd); + k ^= k >> 33; + k *= UINT64CONST(0xc4ceb9fe1a85ec53); + k ^= k >> 33; + + return (uint32) k; +} + +/* + * agehash key-equality callback for graphid (int64) keys. + * + * graphid_hash collisions are rare but real (32-bit hash space, billions of + * possible keys), so the equality check has to compare the full 8 bytes. + * memcmp on a fixed 8-byte length compiles to a single load + cmp on x86, + * which is just as fast as an int64 cast and avoids any alignment risk on + * other architectures. + */ +bool graphid_keyeq(const void *a, const void *b, Size keysize) +{ + Assert(keysize == sizeof(int64)); + (void) keysize; + return memcmp(a, b, sizeof(int64)) == 0; +} + /* * Helper function to create the global vertex and edge hashtables. One * hashtable will hold the vertex, its edges (both incoming and exiting) as a @@ -149,49 +325,50 @@ bool is_ggctx_invalid(GRAPH_global_context *ggctx) static void create_GRAPH_global_hashtables(GRAPH_global_context *ggctx) { HASHCTL vertex_ctl; - HASHCTL edge_ctl; char *graph_name = NULL; char *vhn = NULL; - char *ehn = NULL; int glen; int vlen; - int elen; /* get the graph name and length */ graph_name = ggctx->graph_name; glen = strlen(graph_name); /* get the vertex htab name length */ vlen = strlen(VERTEX_HTAB_NAME); - /* get the edge htab name length */ - elen = strlen(EDGE_HTAB_NAME); - /* allocate the space and build the names */ + /* allocate the space and build the name */ vhn = palloc0(vlen + glen + 1); - ehn = palloc0(elen + glen + 1); - /* copy in the names */ strcpy(vhn, VERTEX_HTAB_NAME); - strcpy(ehn, EDGE_HTAB_NAME); - /* add in the graph name */ vhn = strncat(vhn, graph_name, glen); - ehn = strncat(ehn, graph_name, glen); /* initialize the vertex hashtable */ MemSet(&vertex_ctl, 0, sizeof(vertex_ctl)); vertex_ctl.keysize = sizeof(int64); vertex_ctl.entrysize = sizeof(vertex_entry); - vertex_ctl.hash = tag_hash; + vertex_ctl.hash = graphid_hash; ggctx->vertex_hashtable = hash_create(vhn, VERTEX_HTAB_INITIAL_SIZE, &vertex_ctl, HASH_ELEM | HASH_FUNCTION); pfree_if_not_null(vhn); - /* initialize the edge hashtable */ - MemSet(&edge_ctl, 0, sizeof(edge_ctl)); - edge_ctl.keysize = sizeof(int64); - edge_ctl.entrysize = sizeof(edge_entry); - edge_ctl.hash = tag_hash; - ggctx->edge_hashtable = hash_create(ehn, EDGE_HTAB_INITIAL_SIZE, &edge_ctl, - HASH_ELEM | HASH_FUNCTION); - pfree_if_not_null(ehn); + /* + * Initialize the edge_table (agehash, INLINE mode). + * + * Owns its own MemoryContext as a child of CurrentMemoryContext (which, + * at the call site, is TopMemoryContext for the lifetime of the cached + * GRAPH_global_context). Cleanup is a single MemoryContextDelete in + * free_specific_GRAPH_global_context, so an elog during build cannot + * leak slots. + */ + ggctx->edge_table_mcxt = + AllocSetContextCreate(CurrentMemoryContext, + "AGE edge_table", + ALLOCSET_DEFAULT_SIZES); + ggctx->edge_table = agehash_create_inline(ggctx->edge_table_mcxt, + sizeof(graphid), + sizeof(edge_entry), + EDGE_HTAB_INITIAL_SIZE, + graphid_hash, + graphid_keyeq); } /* helper function to get a List of all label names for the specified graph */ @@ -204,44 +381,124 @@ static List *get_ag_labels_names(Snapshot snapshot, Oid graph_oid, TableScanDesc scan_desc; HeapTuple tuple; TupleDesc tupdesc; + Oid index_oid = InvalidOid; /* we need a valid snapshot */ Assert(snapshot != NULL); - /* setup scan keys to get all edges for the given graph oid */ - ScanKeyInit(&scan_keys[1], Anum_ag_label_graph, BTEqualStrategyNumber, - F_OIDEQ, ObjectIdGetDatum(graph_oid)); - ScanKeyInit(&scan_keys[0], Anum_ag_label_kind, BTEqualStrategyNumber, - F_CHAREQ, CharGetDatum(label_type)); - /* setup the table to be scanned, ag_label in this case */ - ag_label = table_open(ag_label_relation_id(), ShareLock); - scan_desc = table_beginscan(ag_label, snapshot, 2, scan_keys); + ag_label = table_open(ag_label_relation_id(), AccessShareLock); /* get the tupdesc - we don't need to release this one */ tupdesc = RelationGetDescr(ag_label); /* bail if the number of columns differs - this table has 5 */ Assert(tupdesc->natts == Natts_ag_label); - /* get all of the label names */ - while((tuple = heap_getnext(scan_desc, ForwardScanDirection)) != NULL) + /* + * Find a usable index whose first key column is ag_label.graph + * (Anum_ag_label_graph) + */ + index_oid = find_usable_btree_index_for_attr(ag_label, Anum_ag_label_graph); + + if (OidIsValid(index_oid)) + { + Relation index_rel; + IndexScanDesc idx_scan_desc; + ScanKeyData key; + TupleTableSlot *slot; + + index_rel = index_open(index_oid, AccessShareLock); + slot = table_slot_create(ag_label, NULL); + + /* + * Setup ScanKey: ag_label.graph = graph_oid + * Note: We CANNOT filter by 'kind' here because it is not in the index. + */ + ScanKeyInit(&key, 1, BTEqualStrategyNumber, + F_OIDEQ, ObjectIdGetDatum(graph_oid)); + + idx_scan_desc = index_beginscan(ag_label, index_rel, snapshot, NULL, 1, 0); + index_rescan(idx_scan_desc, &key, 1, NULL, 0); + + while (index_getnext_slot(idx_scan_desc, ForwardScanDirection, slot)) + { + bool shouldFree; + + tuple = ExecFetchSlotHeapTuple(slot, true, &shouldFree); + + if (HeapTupleIsValid(tuple)) + { + bool is_null; + Datum kind_datum; + + /* + * Since the index only gave us rows for the correct graph, + * we must now check if the label 'kind' matches (vertex 'v' or edge 'e'). + */ + kind_datum = heap_getattr(tuple, Anum_ag_label_kind, tupdesc, &is_null); + + if (!is_null && DatumGetChar(kind_datum) == label_type) + { + Datum name_datum = heap_getattr(tuple, Anum_ag_label_name, tupdesc, &is_null); + if (!is_null) + { + Name label_name_ptr; + Name lval; + + label_name_ptr = DatumGetName(name_datum); + lval = (Name) palloc(NAMEDATALEN); + namestrcpy(lval, NameStr(*label_name_ptr)); + labels = lappend(labels, lval); + } + } + } + + if (shouldFree) + { + heap_freetuple(tuple); + } + ExecClearTuple(slot); + } + + ExecDropSingleTupleTableSlot(slot); + index_endscan(idx_scan_desc); + index_close(index_rel, AccessShareLock); + } + else { - Name label; - bool is_null = false; - - /* something is wrong if this tuple isn't valid */ - Assert(HeapTupleIsValid(tuple)); - /* get the label name */ - label = DatumGetName(heap_getattr(tuple, Anum_ag_label_name, tupdesc, - &is_null)); - Assert(!is_null); - /* add it to our list */ - labels = lappend(labels, label); + /* setup scan keys to get all edges for the given graph oid */ + ScanKeyInit(&scan_keys[1], Anum_ag_label_graph, BTEqualStrategyNumber, + F_OIDEQ, ObjectIdGetDatum(graph_oid)); + ScanKeyInit(&scan_keys[0], Anum_ag_label_kind, BTEqualStrategyNumber, + F_CHAREQ, CharGetDatum(label_type)); + + scan_desc = table_beginscan(ag_label, snapshot, 2, scan_keys); + + /* get all of the label names */ + while((tuple = heap_getnext(scan_desc, ForwardScanDirection)) != NULL) + { + Name label; + Name lval; + bool is_null = false; + + /* something is wrong if this tuple isn't valid */ + Assert(HeapTupleIsValid(tuple)); + /* get the label name */ + label = DatumGetName(heap_getattr(tuple, Anum_ag_label_name, tupdesc, + &is_null)); + + Assert(!is_null); + /* add it to our list */ + lval = (Name) palloc(NAMEDATALEN); + namestrcpy(lval, NameStr(*label)); + labels = lappend(labels, lval); + } + + /* close up scan */ + table_endscan(scan_desc); } - /* close up scan */ - table_endscan(scan_desc); - table_close(ag_label, ShareLock); + table_close(ag_label, AccessShareLock); return labels; } @@ -251,17 +508,17 @@ static List *get_ag_labels_names(Snapshot snapshot, Oid graph_oid, * current GRAPH global edge hashtable. */ static bool insert_edge_entry(GRAPH_global_context *ggctx, graphid edge_id, - Datum edge_properties, graphid start_vertex_id, + ItemPointerData tid, graphid start_vertex_id, graphid end_vertex_id, Oid edge_label_table_oid) { edge_entry *ee = NULL; bool found = false; /* search for the edge */ - ee = (edge_entry *)hash_search(ggctx->edge_hashtable, (void *)&edge_id, - HASH_ENTER, &found); + ee = (edge_entry *) agehash_insert(ggctx->edge_table, + (void *) &edge_id, &found); - /* if the hash enter returned is NULL, error out */ + /* agehash never returns NULL on insert; a NULL would indicate a bug. */ if (ee == NULL) { elog(ERROR, "insert_edge_entry: hash table returned NULL for ee"); @@ -283,28 +540,22 @@ static bool insert_edge_entry(GRAPH_global_context *ggctx, graphid edge_id, ereport(WARNING, (errcode(ERRCODE_DATA_EXCEPTION), errmsg("previous edge: [id: %ld, start: %ld, end: %ld, label oid: %d]", - ee->edge_id, ee->start_vertex_id, ee->end_vertex_id, + edge_id, ee->start_vertex_id, ee->end_vertex_id, ee->edge_label_table_oid))); return false; } - /* not sure if we really need to zero out the entry, as we set everything */ - MemSet(ee, 0, sizeof(edge_entry)); - /* - * Set the edge id - this is important as this is the hash key value used - * for hash function collisions. + * agehash_insert zero-fills the payload on a fresh insert, so we can fill + * in only the fields we care about. The hash key (edge_id) lives in the + * slot header; recoverable via get_edge_entry_id() if needed. */ - ee->edge_id = edge_id; - ee->edge_properties = edge_properties; + ee->tid = tid; ee->start_vertex_id = start_vertex_id; ee->end_vertex_id = end_vertex_id; ee->edge_label_table_oid = edge_label_table_oid; - /* we also need to store the edge id for clean up of edge property datums */ - ggctx->edges = append_graphid(ggctx->edges, edge_id); - /* increment the number of loaded edges */ ggctx->num_loaded_edges++; @@ -317,7 +568,7 @@ static bool insert_edge_entry(GRAPH_global_context *ggctx, graphid edge_id, */ static bool insert_vertex_entry(GRAPH_global_context *ggctx, graphid vertex_id, Oid vertex_label_table_oid, - Datum vertex_properties) + ItemPointerData tid) { vertex_entry *ve = NULL; bool found = false; @@ -359,12 +610,12 @@ static bool insert_vertex_entry(GRAPH_global_context *ggctx, graphid vertex_id, ve->vertex_id = vertex_id; /* set the label table oid for this vertex */ ve->vertex_label_table_oid = vertex_label_table_oid; - /* set the datum vertex properties */ - ve->vertex_properties = vertex_properties; - /* set the NIL edge list */ - ve->edges_in = NULL; - ve->edges_out = NULL; - ve->edges_self = NULL; + /* set the TID for lazy property fetch */ + ve->tid = tid; + /* + * MemSet above already zeroed the embedded VertexEdgeArray fields + * (array=NULL, size=0, capacity=0); no explicit NIL assignment needed. + */ /* we also need to store the vertex id for clean up of vertex lists */ ggctx->vertices = append_graphid(ggctx->vertices, vertex_id); @@ -402,7 +653,7 @@ static bool insert_vertex_edge(GRAPH_global_context *ggctx, */ if (start_found && is_selfloop) { - value->edges_self = append_graphid(value->edges_self, edge_id); + vea_append(&value->edges_self, edge_id); return true; } /* @@ -411,7 +662,7 @@ static bool insert_vertex_edge(GRAPH_global_context *ggctx, */ else if (start_found) { - value->edges_out = append_graphid(value->edges_out, edge_id); + vea_append(&value->edges_out, edge_id); } /* search for the end vertex of the edge */ @@ -425,7 +676,7 @@ static bool insert_vertex_edge(GRAPH_global_context *ggctx, */ if (start_found && end_found) { - value->edges_in = append_graphid(value->edges_in, edge_id); + vea_append(&value->edges_in, edge_id); return true; } /* @@ -493,7 +744,7 @@ static void load_vertex_hashtable(GRAPH_global_context *ggctx) vertex_label_table_oid = get_relname_relid(vertex_label_name, graph_namespace_oid); /* open the relation (table) and begin the scan */ - graph_vertex_label = table_open(vertex_label_table_oid, ShareLock); + graph_vertex_label = table_open(vertex_label_table_oid, AccessShareLock); scan_desc = table_beginscan(graph_vertex_label, snapshot, 0, NULL); /* get the tupdesc - we don't need to release this one */ tupdesc = RelationGetDescr(graph_vertex_label); @@ -509,7 +760,6 @@ static void load_vertex_hashtable(GRAPH_global_context *ggctx) while((tuple = heap_getnext(scan_desc, ForwardScanDirection)) != NULL) { graphid vertex_id; - Datum vertex_properties; bool inserted = false; /* something is wrong if this isn't true */ @@ -522,16 +772,11 @@ static void load_vertex_hashtable(GRAPH_global_context *ggctx) /* get the vertex id */ vertex_id = DatumGetInt64(column_get_datum(tupdesc, tuple, 0, "id", GRAPHIDOID, true)); - /* get the vertex properties datum */ - vertex_properties = column_get_datum(tupdesc, tuple, 1, - "properties", AGTYPEOID, true); - /* we need to make a copy of the properties datum */ - vertex_properties = datumCopy(vertex_properties, false, -1); - /* insert vertex into vertex hashtable */ + /* insert vertex into vertex hashtable with TID (no property copy) */ inserted = insert_vertex_entry(ggctx, vertex_id, vertex_label_table_oid, - vertex_properties); + tuple->t_self); /* warn if there is a duplicate */ if (!inserted) @@ -544,7 +789,7 @@ static void load_vertex_hashtable(GRAPH_global_context *ggctx) /* end the scan and close the relation */ table_endscan(scan_desc); - table_close(graph_vertex_label, ShareLock); + table_close(graph_vertex_label, AccessShareLock); } } @@ -601,7 +846,7 @@ static void load_edge_hashtable(GRAPH_global_context *ggctx) edge_label_table_oid = get_relname_relid(edge_label_name, graph_namespace_oid); /* open the relation (table) and begin the scan */ - graph_edge_label = table_open(edge_label_table_oid, ShareLock); + graph_edge_label = table_open(edge_label_table_oid, AccessShareLock); scan_desc = table_beginscan(graph_edge_label, snapshot, 0, NULL); /* get the tupdesc - we don't need to release this one */ tupdesc = RelationGetDescr(graph_edge_label); @@ -619,7 +864,6 @@ static void load_edge_hashtable(GRAPH_global_context *ggctx) graphid edge_id; graphid edge_vertex_start_id; graphid edge_vertex_end_id; - Datum edge_properties; bool inserted = false; /* something is wrong if this isn't true */ @@ -643,15 +887,9 @@ static void load_edge_hashtable(GRAPH_global_context *ggctx) 2, "end_id", GRAPHIDOID, true)); - /* get the edge properties datum */ - edge_properties = column_get_datum(tupdesc, tuple, 3, "properties", - AGTYPEOID, true); - /* we need to make a copy of the properties datum */ - edge_properties = datumCopy(edge_properties, false, -1); - - /* insert edge into edge hashtable */ - inserted = insert_edge_entry(ggctx, edge_id, edge_properties, + /* insert edge into edge hashtable with TID (no property copy) */ + inserted = insert_edge_entry(ggctx, edge_id, tuple->t_self, edge_vertex_start_id, edge_vertex_end_id, edge_label_table_oid); @@ -678,7 +916,7 @@ static void load_edge_hashtable(GRAPH_global_context *ggctx) /* end the scan and close the relation */ table_endscan(scan_desc); - table_close(graph_edge_label, ShareLock); + table_close(graph_edge_label, AccessShareLock); } } @@ -690,7 +928,7 @@ static void load_edge_hashtable(GRAPH_global_context *ggctx) static void freeze_GRAPH_global_hashtables(GRAPH_global_context *ggctx) { hash_freeze(ggctx->vertex_hashtable); - hash_freeze(ggctx->edge_hashtable); + agehash_freeze(ggctx->edge_table); } /* @@ -700,7 +938,6 @@ static void freeze_GRAPH_global_hashtables(GRAPH_global_context *ggctx) static bool free_specific_GRAPH_global_context(GRAPH_global_context *ggctx) { GraphIdNode *curr_vertex = NULL; - GraphIdNode *curr_edge = NULL; /* don't do anything if NULL */ if (ggctx == NULL) @@ -740,70 +977,33 @@ static bool free_specific_GRAPH_global_context(GRAPH_global_context *ggctx) return false; } - /* free the vertex's datumCopy properties */ - pfree_if_not_null(DatumGetPointer(value->vertex_properties)); - value->vertex_properties = 0; - - /* free the edge list associated with this vertex */ - free_ListGraphId(value->edges_in); - free_ListGraphId(value->edges_out); - free_ListGraphId(value->edges_self); - - value->edges_in = NULL; - value->edges_out = NULL; - value->edges_self = NULL; + /* free the edge arrays associated with this vertex */ + vea_free(&value->edges_in); + vea_free(&value->edges_out); + vea_free(&value->edges_self); /* move to the next vertex */ curr_vertex = next_vertex; } - /* free the edge properties, starting with the head */ - curr_edge = peek_stack_head(ggctx->edges); - while (curr_edge != NULL) - { - GraphIdNode *next_edge = NULL; - edge_entry *value = NULL; - bool found = false; - graphid edge_id; - - /* get the next edge in the list, if any */ - next_edge = next_GraphIdNode(curr_edge); - - /* get the current edge id */ - edge_id = get_graphid(curr_edge); - - /* retrieve the edge entry */ - value = (edge_entry *)hash_search(ggctx->edge_hashtable, - (void *)&edge_id, HASH_FIND, - &found); - /* this is bad if it isn't found, but leave that to the caller */ - if (found == false) - { - return false; - } - - /* free the edge's datumCopy properties */ - pfree_if_not_null(DatumGetPointer(value->edge_properties)); - value->edge_properties = 0; - - /* move to the next edge */ - curr_edge = next_edge; - } - /* free the vertices list */ free_ListGraphId(ggctx->vertices); ggctx->vertices = NULL; - /* free the edges list */ - free_ListGraphId(ggctx->edges); - ggctx->edges = NULL; - /* free the hashtables */ hash_destroy(ggctx->vertex_hashtable); - hash_destroy(ggctx->edge_hashtable); + /* + * The edge_table and all of its slots live entirely inside + * edge_table_mcxt, so a single MemoryContextDelete reclaims them. + */ + if (ggctx->edge_table_mcxt != NULL) + { + MemoryContextDelete(ggctx->edge_table_mcxt); + } ggctx->vertex_hashtable = NULL; - ggctx->edge_hashtable = NULL; + ggctx->edge_table = NULL; + ggctx->edge_table_mcxt = NULL; /* free the context */ pfree_if_not_null(ggctx); @@ -843,12 +1043,10 @@ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, * 5) One or more other contexts do exist but, one or more are invalid. */ - /* lock the global contexts list */ - pthread_mutex_lock(&global_graph_contexts_container.mutex_lock); /* free the invalidated GRAPH global contexts first */ prev_ggctx = NULL; - curr_ggctx = global_graph_contexts_container.contexts; + curr_ggctx = global_graph_contexts; while (curr_ggctx != NULL) { GRAPH_global_context *next_ggctx = curr_ggctx->next; @@ -865,7 +1063,7 @@ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, */ if (prev_ggctx == NULL) { - global_graph_contexts_container.contexts = next_ggctx; + global_graph_contexts = next_ggctx; } else { @@ -878,8 +1076,6 @@ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, /* if it wasn't successfull, there was a missing vertex entry */ if (!success) { - /* unlock the mutex so we don't get a deadlock */ - pthread_mutex_unlock(&global_graph_contexts_container.mutex_lock); ereport(ERROR, (errcode(ERRCODE_DATA_EXCEPTION), errmsg("missing vertex or edge entry during free"))); @@ -895,7 +1091,7 @@ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, } /* find our graph's context. if it exists, we are done */ - curr_ggctx = global_graph_contexts_container.contexts; + curr_ggctx = global_graph_contexts; while (curr_ggctx != NULL) { if (curr_ggctx->graph_oid == graph_oid) @@ -903,8 +1099,6 @@ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, /* switch our context back */ MemoryContextSwitchTo(oldctx); - /* we are done unlock the global contexts list */ - pthread_mutex_unlock(&global_graph_contexts_container.mutex_lock); return curr_ggctx; } @@ -914,9 +1108,9 @@ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, /* otherwise, we need to create one and possibly attach it */ new_ggctx = palloc0(sizeof(GRAPH_global_context)); - if (global_graph_contexts_container.contexts != NULL) + if (global_graph_contexts != NULL) { - new_ggctx->next = global_graph_contexts_container.contexts; + new_ggctx->next = global_graph_contexts; } else { @@ -924,29 +1118,28 @@ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, } /* set the global context variable */ - global_graph_contexts_container.contexts = new_ggctx; + global_graph_contexts = new_ggctx; /* set the graph name and oid */ new_ggctx->graph_name = pstrdup(graph_name); new_ggctx->graph_oid = graph_oid; - /* set the transaction ids */ + /* set the graph version counter for cache invalidation */ + new_ggctx->graph_version = get_graph_version(graph_oid); + + /* set snapshot fields for SNAPSHOT fallback mode */ new_ggctx->xmin = GetActiveSnapshot()->xmin; new_ggctx->xmax = GetActiveSnapshot()->xmax; new_ggctx->curcid = GetActiveSnapshot()->curcid; /* initialize our vertices list */ new_ggctx->vertices = NULL; - /* initialize our edges list */ - new_ggctx->edges = NULL; /* build the hashtables for this graph */ create_GRAPH_global_hashtables(new_ggctx); load_GRAPH_global_hashtables(new_ggctx); freeze_GRAPH_global_hashtables(new_ggctx); - /* unlock the global contexts list */ - pthread_mutex_unlock(&global_graph_contexts_container.mutex_lock); /* switch back to the previous memory context */ MemoryContextSwitchTo(oldctx); @@ -958,18 +1151,16 @@ GRAPH_global_context *manage_GRAPH_global_contexts(char *graph_name, * Helper function to delete all of the global graph contexts used by the * process. When done the global global_graph_contexts will be NULL. * - * NOTE: Function uses a MUTEX global_graph_contexts_mutex + * */ static bool delete_GRAPH_global_contexts(void) { GRAPH_global_context *curr_ggctx = NULL; bool retval = false; - /* lock contexts list */ - pthread_mutex_lock(&global_graph_contexts_container.mutex_lock); /* get the first context, if any */ - curr_ggctx = global_graph_contexts_container.contexts; + curr_ggctx = global_graph_contexts; /* free all GRAPH global contexts */ while (curr_ggctx != NULL) @@ -983,8 +1174,6 @@ static bool delete_GRAPH_global_contexts(void) /* if it wasn't successfull, there was a missing vertex entry */ if (!success) { - /* unlock the mutex so we don't get a deadlock */ - pthread_mutex_unlock(&global_graph_contexts_container.mutex_lock); ereport(ERROR, (errcode(ERRCODE_DATA_EXCEPTION), errmsg("missing vertex or edge entry during free"))); @@ -997,10 +1186,8 @@ static bool delete_GRAPH_global_contexts(void) } /* reset the head of the contexts to NULL */ - global_graph_contexts_container.contexts = NULL; + global_graph_contexts = NULL; - /* unlock the global contexts list */ - pthread_mutex_unlock(&global_graph_contexts_container.mutex_lock); return retval; } @@ -1023,11 +1210,9 @@ static bool delete_specific_GRAPH_global_contexts(char *graph_name) /* get the graph oid */ graph_oid = get_graph_oid(graph_name); - /* lock the global contexts list */ - pthread_mutex_lock(&global_graph_contexts_container.mutex_lock); /* get the first context, if any */ - curr_ggctx = global_graph_contexts_container.contexts; + curr_ggctx = global_graph_contexts; /* find the specified GRAPH global context */ while (curr_ggctx != NULL) @@ -1044,7 +1229,7 @@ static bool delete_specific_GRAPH_global_contexts(char *graph_name) */ if (prev_ggctx == NULL) { - global_graph_contexts_container.contexts = next_ggctx; + global_graph_contexts = next_ggctx; } else { @@ -1054,8 +1239,6 @@ static bool delete_specific_GRAPH_global_contexts(char *graph_name) /* free the current graph context */ success = free_specific_GRAPH_global_context(curr_ggctx); - /* unlock the global contexts list */ - pthread_mutex_unlock(&global_graph_contexts_container.mutex_lock); /* if it wasn't successfull, there was a missing vertex entry */ if (!success) @@ -1073,8 +1256,6 @@ static bool delete_specific_GRAPH_global_contexts(char *graph_name) curr_ggctx = next_ggctx; } - /* unlock the global contexts list */ - pthread_mutex_unlock(&global_graph_contexts_container.mutex_lock); /* we didn't find it, return false */ return false; @@ -1096,17 +1277,33 @@ vertex_entry *get_vertex_entry(GRAPH_global_context *ggctx, graphid vertex_id) return ve; } -/* helper function to retrieve an edge_entry from the graph's edge hash table */ +/* helper function to retrieve an edge_entry from the graph's edge table */ edge_entry *get_edge_entry(GRAPH_global_context *ggctx, graphid edge_id) { - edge_entry *ee = NULL; - bool found = false; + edge_entry *ee; - /* retrieve the current edge entry */ - ee = (edge_entry *)hash_search(ggctx->edge_hashtable, (void *)&edge_id, - HASH_FIND, &found); + ee = (edge_entry *) agehash_lookup(ggctx->edge_table, (void *) &edge_id); /* it should be found, otherwise we have problems */ - Assert(found); + Assert(ee != NULL); + + return ee; +} + +/* + * Variant of get_edge_entry that uses a precomputed hash value to skip the + * agehash internal hash callback. The caller is responsible for ensuring + * hashvalue == graphid_hash(&edge_id, sizeof(int64)). Used by the VLE DFS + * hot loop where the same edge_id is also looked up in edge_state_hashtable. + */ +edge_entry *get_edge_entry_with_hash(GRAPH_global_context *ggctx, + graphid edge_id, uint32 hashvalue) +{ + edge_entry *ee; + + ee = (edge_entry *) agehash_lookup_with_hash(ggctx->edge_table, + (void *) &edge_id, + hashvalue); + Assert(ee != NULL); return ee; } @@ -1119,19 +1316,15 @@ GRAPH_global_context *find_GRAPH_global_context(Oid graph_oid) { GRAPH_global_context *ggctx = NULL; - /* lock the global contexts lists */ - pthread_mutex_lock(&global_graph_contexts_container.mutex_lock); /* get the root */ - ggctx = global_graph_contexts_container.contexts; + ggctx = global_graph_contexts; while(ggctx != NULL) { /* if we found it return it */ if (ggctx->graph_oid == graph_oid) { - /* unlock the global contexts lists */ - pthread_mutex_unlock(&global_graph_contexts_container.mutex_lock); return ggctx; } @@ -1140,8 +1333,6 @@ GRAPH_global_context *find_GRAPH_global_context(Oid graph_oid) ggctx = ggctx->next; } - /* unlock the global contexts lists */ - pthread_mutex_unlock(&global_graph_contexts_container.mutex_lock); /* we did not find it so return NULL */ return NULL; @@ -1159,19 +1350,19 @@ graphid get_vertex_entry_id(vertex_entry *ve) return ve->vertex_id; } -ListGraphId *get_vertex_entry_edges_in(vertex_entry *ve) +VertexEdgeArray *get_vertex_entry_edges_in_array(vertex_entry *ve) { - return ve->edges_in; + return &ve->edges_in; } -ListGraphId *get_vertex_entry_edges_out(vertex_entry *ve) +VertexEdgeArray *get_vertex_entry_edges_out_array(vertex_entry *ve) { - return ve->edges_out; + return &ve->edges_out; } -ListGraphId *get_vertex_entry_edges_self(vertex_entry *ve) +VertexEdgeArray *get_vertex_entry_edges_self_array(vertex_entry *ve) { - return ve->edges_self; + return &ve->edges_self; } @@ -1180,15 +1371,73 @@ Oid get_vertex_entry_label_table_oid(vertex_entry *ve) return ve->vertex_label_table_oid; } +/* + * Fetch vertex properties on demand from the heap via stored TID. + * + * Returns a datumCopy of the properties in the current memory context. + * The caller does not need to free the result explicitly — it will be + * freed when the memory context is reset (typically the SRF multi-call + * context for VLE, which is cleaned up when the SRF completes). + * + * If the tuple is no longer visible (e.g., concurrent mutation between + * cache build and fetch), the version counter should have invalidated + * the cache. If we get here with a stale TID, it indicates a bug in + * the invalidation logic. + */ Datum get_vertex_entry_properties(vertex_entry *ve) { - return ve->vertex_properties; + Relation rel; + HeapTupleData tuple; + Buffer buffer; + Datum result = (Datum) 0; + + rel = table_open(ve->vertex_label_table_oid, AccessShareLock); + tuple.t_self = ve->tid; + + if (heap_fetch(rel, GetActiveSnapshot(), &tuple, &buffer, true)) + { + TupleDesc tupdesc = RelationGetDescr(rel); + bool isnull; + Datum props; + + /* properties is column 2 (1-indexed) */ + props = heap_getattr(&tuple, 2, tupdesc, &isnull); + if (!isnull) + { + result = datumCopy(props, false, -1); + } + + ReleaseBuffer(buffer); + } + + table_close(rel, AccessShareLock); + + /* + * If heap_fetch failed, the tuple is no longer visible. This should + * not happen under normal operation because the version counter + * invalidates the cache when the graph is mutated. + */ + if (result == (Datum) 0) + { + elog(ERROR, "get_vertex_entry_properties: stale TID - " + "vertex entry references a tuple that is no longer visible"); + } + + return result; } /* edge_entry accessor functions */ graphid get_edge_entry_id(edge_entry *ee) { - return ee->edge_id; + /* + * The edge_id is stored as the agehash slot key, immediately preceding + * the payload pointer we hand back as `edge_entry *`. Recover it via + * the public agehash_key_from_payload helper to avoid a redundant + * 8-byte field on every entry (saves ~400MB on SF3, ~1.4GB on SF10). + */ + graphid k; + memcpy(&k, agehash_key_from_payload(ee, sizeof(graphid)), sizeof(graphid)); + return k; } Oid get_edge_entry_label_table_oid(edge_entry *ee) @@ -1196,9 +1445,45 @@ Oid get_edge_entry_label_table_oid(edge_entry *ee) return ee->edge_label_table_oid; } +/* + * Fetch edge properties on demand from the heap via stored TID. + * See get_vertex_entry_properties for memory and safety notes. + */ Datum get_edge_entry_properties(edge_entry *ee) { - return ee->edge_properties; + Relation rel; + HeapTupleData tuple; + Buffer buffer; + Datum result = (Datum) 0; + + rel = table_open(ee->edge_label_table_oid, AccessShareLock); + tuple.t_self = ee->tid; + + if (heap_fetch(rel, GetActiveSnapshot(), &tuple, &buffer, true)) + { + TupleDesc tupdesc = RelationGetDescr(rel); + bool isnull; + Datum props; + + /* properties is column 4 (1-indexed) */ + props = heap_getattr(&tuple, 4, tupdesc, &isnull); + if (!isnull) + { + result = datumCopy(props, false, -1); + } + + ReleaseBuffer(buffer); + } + + table_close(rel, AccessShareLock); + + if (result == (Datum) 0) + { + elog(ERROR, "get_edge_entry_properties: stale TID - " + "edge entry references a tuple that is no longer visible"); + } + + return result; } graphid get_edge_entry_start_vertex_id(edge_entry *ee) @@ -1259,7 +1544,7 @@ Datum age_vertex_stats(PG_FUNCTION_ARGS) { GRAPH_global_context *ggctx = NULL; vertex_entry *ve = NULL; - ListGraphId *edges = NULL; + VertexEdgeArray *edges = NULL; agtype_value *agtv_vertex = NULL; agtype_value *agtv_temp = NULL; agtype_value agtv_integer; @@ -1339,24 +1624,24 @@ Datum age_vertex_stats(PG_FUNCTION_ARGS) agtv_temp->val.int_value = 0; /* get and store the self_loops */ - edges = get_vertex_entry_edges_self(ve); - self_loops = (edges != NULL) ? get_list_size(edges) : 0; + edges = get_vertex_entry_edges_self_array(ve); + self_loops = edges->size; agtv_temp->val.int_value = self_loops; result.res = push_agtype_value(&result.parse_state, WAGT_KEY, string_to_agtype_value("self_loops")); result.res = push_agtype_value(&result.parse_state, WAGT_VALUE, agtv_temp); /* get and store the in_degree */ - edges = get_vertex_entry_edges_in(ve); - degree = (edges != NULL) ? get_list_size(edges) : 0; + edges = get_vertex_entry_edges_in_array(ve); + degree = edges->size; agtv_temp->val.int_value = degree + self_loops; result.res = push_agtype_value(&result.parse_state, WAGT_KEY, string_to_agtype_value("in_degree")); result.res = push_agtype_value(&result.parse_state, WAGT_VALUE, agtv_temp); /* get and store the out_degree */ - edges = get_vertex_entry_edges_out(ve); - degree = (edges != NULL) ? get_list_size(edges) : 0; + edges = get_vertex_entry_edges_out_array(ve); + degree = edges->size; agtv_temp->val.int_value = degree + self_loops; result.res = push_agtype_value(&result.parse_state, WAGT_KEY, string_to_agtype_value("out_degree")); @@ -1450,3 +1735,286 @@ Datum age_graph_stats(PG_FUNCTION_ARGS) PG_RETURN_POINTER(agtype_value_to_agtype(result.res)); } + +/* + * ============================================================================ + * Graph Version Counter Implementation + * + * Provides per-graph monotonic version counters in shared memory for + * cross-backend VLE cache invalidation. Three modes are supported: + * + * DSM (PG 17+): Uses GetNamedDSMSegment — works without shared_preload_libs + * SHMEM (PG <17): Uses shmem_request/startup hooks — needs shared_preload_libs + * SNAPSHOT: Falls back to original snapshot-based invalidation + * ============================================================================ + */ + +#if PG_VERSION_NUM >= 170000 +/* + * DSM path: GetNamedDSMSegment init callback. + * Called once when the DSM segment is first created. + */ +static void age_dsm_init_callback(void *ptr) +{ + GraphVersionState *state = (GraphVersionState *) ptr; + + LWLockInitialize(&state->lock, + LWLockNewTrancheId()); + LWLockRegisterTranche(state->lock.tranche, "age_graph_version"); + state->num_entries = 0; + memset(state->entries, 0, sizeof(state->entries)); +} + +/* + * Get the shared GraphVersionState via DSM registry. + * The segment is created on first access and persists until server shutdown. + */ +static GraphVersionState *get_version_state_dsm(void) +{ + bool found; + + return (GraphVersionState *) + GetNamedDSMSegment("age_graph_versions", + sizeof(GraphVersionState), + age_dsm_init_callback, + &found); +} +#endif /* PG_VERSION_NUM >= 170000 */ + +/* + * SHMEM path: request and startup hooks for PG < 17. + * These are registered in _PG_init when shared_preload_libraries is used. + * On PG 17+, DSM is used instead and these functions are not called. + */ +#if PG_VERSION_NUM < 170000 +void age_graph_version_shmem_request(void) +{ + RequestAddinShmemSpace(MAXALIGN(sizeof(GraphVersionState))); +} + +void age_graph_version_shmem_startup(void) +{ + bool found; + + LWLockAcquire(AddinShmemInitLock, LW_EXCLUSIVE); + + shmem_version_state = + (GraphVersionState *) ShmemInitStruct("AGE Graph Version State", + sizeof(GraphVersionState), + &found); + if (!found) + { + LWLockInitialize(&shmem_version_state->lock, + LWLockNewTrancheId()); + LWLockRegisterTranche(shmem_version_state->lock.tranche, + "age_graph_version"); + shmem_version_state->num_entries = 0; + memset(shmem_version_state->entries, 0, + sizeof(shmem_version_state->entries)); + } + + LWLockRelease(AddinShmemInitLock); +} +#endif /* PG_VERSION_NUM < 170000 */ + +/* + * Detect which version mode to use. Called once per backend on first access. + * Emits a DEBUG1 log message indicating the chosen mode. + */ +static void detect_version_mode(void) +{ +#if PG_VERSION_NUM >= 170000 + version_mode = VERSION_MODE_DSM; + elog(DEBUG1, "AGE: VLE cache using DSM version counter"); +#else + if (shmem_version_state != NULL) + { + version_mode = VERSION_MODE_SHMEM; + elog(DEBUG1, "AGE: VLE cache using SHMEM version counter"); + } + else + { + version_mode = VERSION_MODE_SNAPSHOT; + elog(DEBUG1, "AGE: VLE cache using snapshot-based invalidation " + "(add AGE to shared_preload_libraries for better caching)"); + } +#endif +} + +/* + * Get a pointer to the GraphVersionState, regardless of mode. + * Returns NULL only in SNAPSHOT mode (no shared memory available). + */ +static GraphVersionState *get_version_state(void) +{ + if (version_mode == VERSION_MODE_UNKNOWN) + { + detect_version_mode(); + } + +#if PG_VERSION_NUM >= 170000 + if (version_mode == VERSION_MODE_DSM) + { + return get_version_state_dsm(); + } +#endif + + if (version_mode == VERSION_MODE_SHMEM) + { + return shmem_version_state; + } + + return NULL; +} + +/* + * Get the current version counter for a graph. + * Returns 0 if the graph has never been tracked or if shared memory + * is not available. Lock-free read via pg_atomic_read_u64. + */ +uint64 get_graph_version(Oid graph_oid) +{ + GraphVersionState *state = get_version_state(); + int i; + + if (state == NULL) + { + return 0; + } + + /* lock-free scan of the array */ + for (i = 0; i < state->num_entries; i++) + { + if (state->entries[i].graph_oid == graph_oid) + { + return pg_atomic_read_u64(&state->entries[i].version); + } + } + + return 0; +} + +/* + * Increment the version counter for a graph. + * Called after any graph mutation (Cypher or SQL trigger). + * Lock-free for existing entries; acquires LWLock only to allocate new slots. + */ +void increment_graph_version(Oid graph_oid) +{ + GraphVersionState *state = get_version_state(); + int i; + + if (state == NULL) + { + return; + } + + /* try to find existing entry (lock-free) */ + for (i = 0; i < state->num_entries; i++) + { + if (state->entries[i].graph_oid == graph_oid) + { + pg_atomic_fetch_add_u64(&state->entries[i].version, 1); + return; + } + } + + /* new graph — need lock to allocate slot */ + LWLockAcquire(&state->lock, LW_EXCLUSIVE); + + /* re-check after acquiring lock (another backend may have added it) */ + for (i = 0; i < state->num_entries; i++) + { + if (state->entries[i].graph_oid == graph_oid) + { + LWLockRelease(&state->lock); + pg_atomic_fetch_add_u64(&state->entries[i].version, 1); + return; + } + } + + /* add new entry */ + if (state->num_entries < AGE_MAX_GRAPHS) + { + int idx = state->num_entries; + + state->entries[idx].graph_oid = graph_oid; + pg_atomic_init_u64(&state->entries[idx].version, 1); + + /* + * Write barrier ensures the entry fields are fully visible to + * other backends before num_entries is incremented. This prevents + * readers on weak memory-ordering architectures (e.g., ARM) from + * seeing the incremented count before the entry is initialized. + */ + pg_write_barrier(); + state->num_entries++; + } + else + { + elog(WARNING, "AGE: graph version counter table full (%d graphs)", + AGE_MAX_GRAPHS); + } + + LWLockRelease(&state->lock); +} + +/* + * Helper function to look up the graph OID for a given label table OID. + * Uses AGE's label relation cache for fast lookup. + * Returns InvalidOid if the table is not a graph label table. + */ +Oid get_graph_oid_for_table(Oid table_oid) +{ + label_cache_data *lcd = NULL; + + lcd = search_label_relation_cache(table_oid); + + if (lcd != NULL) + { + return lcd->graph; + } + + return InvalidOid; +} + +/* + * SQL-callable trigger function for VLE cache invalidation. + * Installed on graph label tables (AFTER INSERT/UPDATE/DELETE FOR EACH STATEMENT). + * Looks up which graph the triggering table belongs to and increments + * that graph's version counter. + */ +PG_FUNCTION_INFO_V1(age_invalidate_graph_cache); + +Datum age_invalidate_graph_cache(PG_FUNCTION_ARGS) +{ + TriggerData *trigdata; + Oid table_oid; + Oid graph_oid; + + /* verify called as trigger */ + if (!CALLED_AS_TRIGGER(fcinfo)) + { + ereport(ERROR, + (errcode(ERRCODE_E_R_I_E_TRIGGER_PROTOCOL_VIOLATED), + errmsg("age_invalidate_graph_cache: not called as trigger"))); + } + + trigdata = (TriggerData *) fcinfo->context; + table_oid = RelationGetRelid(trigdata->tg_relation); + + /* look up which graph this label table belongs to */ + graph_oid = get_graph_oid_for_table(table_oid); + + if (OidIsValid(graph_oid)) + { + increment_graph_version(graph_oid); + } + + /* + * Trigger protocol: return a null pointer without setting fcinfo->isnull. + * PG_RETURN_NULL() sets isnull=true, which violates the trigger protocol + * and causes "trigger function returned null value" errors during COPY. + */ + PG_RETURN_POINTER(NULL); +} diff --git a/src/backend/utils/adt/age_graphid_ds.c b/src/backend/utils/adt/age_graphid_ds.c index 625a6947c..cee53bd83 100644 --- a/src/backend/utils/adt/age_graphid_ds.c +++ b/src/backend/utils/adt/age_graphid_ds.c @@ -258,3 +258,94 @@ graphid pop_graphid_stack(ListGraphId *stack) /* return the id */ return id; } + +/* + * ============================================================================ + * GraphIdStack — Array-based stack for VLE DFS traversal + * + * Uses a flat graphid array with size/capacity tracking. Push appends to + * the end (doubling capacity when full), pop decrements size. No per-element + * allocation — just sequential array access. + * + * This is intentionally separate from ListGraphId to avoid the lifetime + * and complexity issues that arise from modifying linked-list behavior. + * ============================================================================ + */ + +#define GID_STACK_INITIAL_CAPACITY 64 + +GraphIdStack *new_gid_stack(void) +{ + GraphIdStack *s = palloc(sizeof(GraphIdStack)); + + s->array = palloc(sizeof(graphid) * GID_STACK_INITIAL_CAPACITY); + s->size = 0; + s->capacity = GID_STACK_INITIAL_CAPACITY; + return s; +} + +void free_gid_stack(GraphIdStack *stack) +{ + if (stack == NULL) + { + return; + } + + if (stack->array != NULL) + { + pfree(stack->array); + } + + pfree(stack); +} + +void gid_stack_push(GraphIdStack *s, graphid id) +{ + Assert(s != NULL); + + /* double capacity if full */ + if (s->size >= s->capacity) + { + s->capacity *= 2; + s->array = repalloc(s->array, sizeof(graphid) * s->capacity); + } + + s->array[s->size] = id; + s->size++; +} + +graphid gid_stack_pop(GraphIdStack *s) +{ + Assert(s != NULL); + Assert(s->size > 0); + + s->size--; + return s->array[s->size]; +} + +graphid gid_stack_peek(GraphIdStack *s) +{ + Assert(s != NULL); + Assert(s->size > 0); + + return s->array[s->size - 1]; +} + +bool gid_stack_is_empty(GraphIdStack *s) +{ + return s->size == 0; +} + +int64 gid_stack_size(GraphIdStack *s) +{ + return s->size; +} + +/* Access element by index — 0 is bottom, size-1 is top */ +graphid gid_stack_get(GraphIdStack *s, int64 i) +{ + Assert(s != NULL); + Assert(i >= 0 && i < s->size); + + return s->array[i]; +} diff --git a/src/backend/utils/adt/age_vle.c b/src/backend/utils/adt/age_vle.c index f9e4c70b8..cb036b154 100644 --- a/src/backend/utils/adt/age_vle.c +++ b/src/backend/utils/adt/age_vle.c @@ -17,10 +17,56 @@ * under the License. */ +/* + * VLE (Variable-Length Edge) semantics and cost model + * --------------------------------------------------- + * + * This file implements variable-length relationship matching for Cypher + * patterns of the form (a)-[*min..max]->(b). The semantics and cost model + * are often misunderstood; this note exists to prevent future + * misdiagnoses (see issue #2349). + * + * Semantics: edge-isomorphism (openCypher-mandated) + * + * A path is valid iff no edge appears in it more than once. Vertices MAY + * recur. This is "edge-isomorphism" (a.k.a. relationship-uniqueness) per + * the openCypher specification; it is NOT vertex-isomorphism. + * + * Example: in the triangle A-[e1]->B-[e2]->C-[e3]->A, the query + * MATCH (a)-[*3]->(b) WHERE id(a) = id(A) + * MUST return the path (A, e1, B, e2, C, e3, A) with b = A. Switching + * to vertex-isomorphism would silently drop this path and violate the + * spec. Any "optimization" that tracks visited vertices as a filter + * rather than visited edges is therefore incorrect, not merely faster. + * + * Cost model + * + * With E total edges in the traversal-reachable subgraph and a bounded + * pattern [*min..max], the number of enumerated paths is bounded by + * sum_{k=min..max} P(E, k) where P(E, k) = E! / (E - k)! -- polynomial + * in E for fixed max, but factorial in the depth bound. + * + * Unbounded patterns ([*], [*1..]) have no termination guarantee other + * than edge-uniqueness depletion. On a cycle-rich graph the worst case + * is O(E!). This is inherent to edge-isomorphic path enumeration and + * cannot be reduced by algorithm change without changing semantics. + * Users who want reachability (not full enumeration) should bound the + * upper length or use a dedicated function such as shortestPath(). + * + * Implementation pointer + * + * Cycle prevention is enforced by edge_state_entry.used_in_path, set + * and cleared during DFS traversal in dfs_find_a_path_between() and + * dfs_find_a_path_from(). The helper is_edge_in_path() inspects the + * current path stack. See those functions for the enforcement site. + */ + #include "postgres.h" #include "common/hashfn.h" #include "funcapi.h" +#include "miscadmin.h" +#include "nodes/pg_list.h" #include "utils/datum.h" #include "utils/lsyscache.h" @@ -79,9 +125,9 @@ typedef struct VLE_local_context bool uidx_infinite; /* flag if the upper bound is omitted */ cypher_rel_dir edge_direction; /* the direction of the edge */ HTAB *edge_state_hashtable; /* local state hashtable for our edges */ - ListGraphId *dfs_vertex_stack; /* dfs stack for vertices */ - ListGraphId *dfs_edge_stack; /* dfs stack for edges */ - ListGraphId *dfs_path_stack; /* dfs stack containing the path */ + GraphIdStack *dfs_vertex_stack; /* dfs stack for vertices (array-based) */ + GraphIdStack *dfs_edge_stack; /* dfs stack for edges (array-based) */ + GraphIdStack *dfs_path_stack; /* dfs stack containing the path (array-based) */ VLE_path_function path_function; /* which path function to use */ GraphIdNode *next_vertex; /* for VLE_FUNCTION_PATHS_TO */ int64 vle_grammar_node_id; /* the unique VLE grammar assigned node id */ @@ -96,6 +142,36 @@ typedef struct VLE_local_context * structure is set up to contains a BINARY container that can be accessed by * functions that need to process the path. */ +/* + * Layout (offsets, with int64 alignment): + * + * 0: vl_len_[4] varlena length header (int32 + pad) + * 4: header AGT_FBINARY | AGT_FBINARY_TYPE_VLE_PATH + * 8: graph_oid source graph oid + * 12: (4 bytes pad) int64 alignment + * 16: graphid_array_size number of graphids in the path + * 24: container_size_bytes total bytes of this container + * 32: start_vid redundant cache of graphid_array[0] + * 40: end_vid redundant cache of + * graphid_array[graphid_array_size - 1] + * 48: graphid_array_data flexible array start + * + * start_vid / end_vid are populated whenever the container is built and let + * downstream consumers (the age_vle SRF's start_id/end_id output columns) + * read the join endpoints without traversing the (potentially toasted) + * variadic payload. + * + * Persistence note: VLE_path_container is a transient SRF output. It is + * consumed within the same query that produces it (by the planner-emitted + * endpoint equalities and by age_materialize_vle_path / _vle_edges) and is + * never written back to disk. Because no on-disk instance of this layout + * can exist, adding fields to the struct does not require a binary + * version bump or a backward-compatible decoder. If a future change ever + * makes a VLE container persistable (e.g. by allowing it to be returned + * directly as agtype and stored in a column), the AGT_FBINARY_TYPE_VLE_PATH + * tag must be versioned and the readers (GET_GRAPHID_ARRAY_FROM_CONTAINER + * etc.) must branch on the version. + */ typedef struct VLE_path_container { char vl_len_[4]; /* Do not touch this field! */ @@ -103,6 +179,8 @@ typedef struct VLE_path_container uint32 graph_oid; int64 graphid_array_size; int64 container_size_bytes; + graphid start_vid; + graphid end_vid; graphid graphid_array_data; } VLE_path_container; @@ -119,8 +197,9 @@ static VLE_local_context *build_local_vle_context(FunctionCallInfo fcinfo, static void create_VLE_local_state_hashtable(VLE_local_context *vlelctx); static void free_VLE_local_context(VLE_local_context *vlelctx); /* VLE graph traversal functions */ -static edge_state_entry *get_edge_state(VLE_local_context *vlelctx, - graphid edge_id); +static edge_state_entry *get_edge_state_with_hash(VLE_local_context *vlelctx, + graphid edge_id, + uint32 hashvalue); /* graphid data structures */ static void load_initial_dfs_stacks(VLE_local_context *vlelctx); static bool dfs_find_a_path_between(VLE_local_context *vlelctx); @@ -314,7 +393,7 @@ static void create_VLE_local_state_hashtable(VLE_local_context *vlelctx) MemSet(&edge_state_ctl, 0, sizeof(edge_state_ctl)); edge_state_ctl.keysize = sizeof(int64); edge_state_ctl.entrysize = sizeof(edge_state_entry); - edge_state_ctl.hash = tag_hash; + edge_state_ctl.hash = graphid_hash; vlelctx->edge_state_hashtable = hash_create(eshn, EDGE_STATE_HTAB_INITIAL_SIZE, &edge_state_ctl, @@ -340,6 +419,20 @@ static bool is_an_edge_match(VLE_local_context *vlelctx, edge_entry *ee) /* get the number of conditions from the prototype edge */ num_edge_property_constraints = AGT_ROOT_COUNT(vlelctx->edge_property_constraint); + /* + * Issue #2382: If the user asked for a specific edge label but that label + * does not exist in the graph (edge_label_name_oid == InvalidOid while + * edge_label_name is non-NULL), no real edge can match. Returning false + * here ensures that for VLE patterns like [:NOEXIST*0..N] we do not + * traverse arbitrary other-label edges. Zero-hop self-binding is handled + * separately via build_VLE_zero_container() so this does not break it. + */ + if (vlelctx->edge_label_name != NULL && + vlelctx->edge_label_name_oid == InvalidOid) + { + return false; + } + /* * We only care about verifying that we have all of the property conditions. * We don't care about extra unmatched properties. If there aren't any edge @@ -364,54 +457,70 @@ static bool is_an_edge_match(VLE_local_context *vlelctx, edge_entry *ee) return false; } - /* get our edge's properties */ - edge_property = DATUM_GET_AGTYPE_P(get_edge_entry_properties(ee)); - /* get the containers */ - agtc_edge_property_constraint = &vlelctx->edge_property_constraint->root; - agtc_edge_property = &edge_property->root; - /* get the number of properties in the edge to be matched */ - num_edge_properties = AGTYPE_CONTAINER_SIZE(agtc_edge_property); - /* - * Check to see if the edge_properties object has AT LEAST as many pairs - * to compare as the edge_property_constraint object has pairs. If not, it - * can't possibly match. + * Fast path: if the label matched (or wasn't constrained) and there + * are no property constraints, the edge is a match. This avoids + * accessing edge properties entirely for label-only VLE patterns + * like [:KNOWS*1..3] which are the common case. */ - if (num_edge_property_constraints > num_edge_properties) + if (num_edge_property_constraints == 0) { - return false; + return true; } /* - * If the number of constraints are the same as the number of properties, - * then the datums would be the same if they match. + * Fetch edge properties once and cache locally. With thin entries, + * get_edge_entry_properties() does a heap_fetch, so we avoid calling + * it multiple times for the same edge. */ - if (num_edge_property_constraints == num_edge_properties) { - Datum edge_props = get_edge_entry_properties(ee); - uint32 edge_props_hash = datum_image_hash(edge_props, false, -1); + Datum edge_props_datum = get_edge_entry_properties(ee); + + edge_property = DATUM_GET_AGTYPE_P(edge_props_datum); + agtc_edge_property_constraint = &vlelctx->edge_property_constraint->root; + agtc_edge_property = &edge_property->root; + num_edge_properties = AGTYPE_CONTAINER_SIZE(agtc_edge_property); + + /* + * Check to see if the edge_properties object has AT LEAST as many + * pairs to compare as the edge_property_constraint object has pairs. + * If not, it can't possibly match. + */ + if (num_edge_property_constraints > num_edge_properties) + { + return false; + } - /* check the hash first */ - if (vlelctx->edge_property_constraint_hash == edge_props_hash) + /* + * If the number of constraints are the same as the number of + * properties, then the datums would be the same if they match. + */ + if (num_edge_property_constraints == num_edge_properties) { - /* if the hashes match, check the datum images */ - if (datum_image_eq(vlelctx->edge_property_constraint_datum, - edge_props, false, -1)) + uint32 edge_props_hash = datum_image_hash(edge_props_datum, + false, -1); + /* check the hash first */ + if (vlelctx->edge_property_constraint_hash == edge_props_hash) { - return true; + /* if the hashes match, check the datum images */ + if (datum_image_eq(vlelctx->edge_property_constraint_datum, + edge_props_datum, false, -1)) + { + return true; + } } - } - /* if we got here they aren't the same */ - return false; - } + /* if we got here they aren't the same */ + return false; + } - /* get the iterators */ - constraint_it = agtype_iterator_init(agtc_edge_property_constraint); - property_it = agtype_iterator_init(agtc_edge_property); + /* get the iterators */ + constraint_it = agtype_iterator_init(agtc_edge_property_constraint); + property_it = agtype_iterator_init(agtc_edge_property); - /* return the value of deep contains */ - return agtype_deep_contains(&property_it, &constraint_it, false); + /* return the value of deep contains */ + return agtype_deep_contains(&property_it, &constraint_it, false); + } } /* @@ -449,22 +558,23 @@ static void free_VLE_local_context(VLE_local_context *vlelctx) vlelctx->edge_state_hashtable = NULL; /* - * We need to free the contents of our stacks if the context is not dirty. - * These stacks are created in a more volatile memory context. If the - * process was interrupted, they will be garbage collected by PG. The only - * time we will ever clean them here is if the cache isn't being used. + * Free the DFS stacks. When is_dirty is false, the stacks are in the + * current context and need explicit cleanup. When is_dirty is true + * (cached context), only free the containers — the contents were + * allocated in a volatile SRF context that was already cleaned up. */ - if (vlelctx->is_dirty == false) + if (vlelctx->dfs_vertex_stack != NULL) { - free_graphid_stack(vlelctx->dfs_vertex_stack); - free_graphid_stack(vlelctx->dfs_edge_stack); - free_graphid_stack(vlelctx->dfs_path_stack); + free_gid_stack(vlelctx->dfs_vertex_stack); + } + if (vlelctx->dfs_edge_stack != NULL) + { + free_gid_stack(vlelctx->dfs_edge_stack); + } + if (vlelctx->dfs_path_stack != NULL) + { + free_gid_stack(vlelctx->dfs_path_stack); } - - /* free the containers */ - pfree_if_not_null(vlelctx->dfs_vertex_stack); - pfree_if_not_null(vlelctx->dfs_edge_stack); - pfree_if_not_null(vlelctx->dfs_path_stack); vlelctx->dfs_vertex_stack = NULL; vlelctx->dfs_edge_stack = NULL; vlelctx->dfs_path_stack = NULL; @@ -561,6 +671,11 @@ static VLE_local_context *build_local_vle_context(FunctionCallInfo fcinfo, /* get and update the start vertex id */ if (PG_ARGISNULL(1) || is_agtype_null(AG_GET_ARG_AGTYPE_P(1))) { + /* if there are no more vertices to process, return NULL */ + if (vlelctx->next_vertex == NULL) + { + return NULL; + } vlelctx->vsid = get_graphid(vlelctx->next_vertex); /* increment to the next vertex */ vlelctx->next_vertex = next_GraphIdNode(vlelctx->next_vertex); @@ -807,9 +922,9 @@ static VLE_local_context *build_local_vle_context(FunctionCallInfo fcinfo, create_VLE_local_state_hashtable(vlelctx); /* initialize the dfs stacks */ - vlelctx->dfs_vertex_stack = new_graphid_stack(); - vlelctx->dfs_edge_stack = new_graphid_stack(); - vlelctx->dfs_path_stack = new_graphid_stack(); + vlelctx->dfs_vertex_stack = new_gid_stack(); + vlelctx->dfs_edge_stack = new_gid_stack(); + vlelctx->dfs_path_stack = new_gid_stack(); /* load in the starting edge(s) */ load_initial_dfs_stacks(vlelctx); @@ -834,23 +949,24 @@ static VLE_local_context *build_local_vle_context(FunctionCallInfo fcinfo, } /* - * Helper function to get the specified edge's state. If it does not find it, it - * creates and initializes it. + * Helper function to get the specified edge's state, using a precomputed hash + * value. Callers can compute graphid_hash() once and reuse it for lookups in + * both the dynahash edge_state_hashtable here and the agehash-backed + * edge_table on the global-graph lookup path elsewhere. */ -static edge_state_entry *get_edge_state(VLE_local_context *vlelctx, - graphid edge_id) +static edge_state_entry *get_edge_state_with_hash(VLE_local_context *vlelctx, + graphid edge_id, + uint32 hashvalue) { edge_state_entry *ese = NULL; bool found = false; - /* retrieve the edge_state_entry from the edge state hashtable */ - ese = (edge_state_entry *)hash_search(vlelctx->edge_state_hashtable, - (void *)&edge_id, HASH_ENTER, &found); - - /* if it isn't found, it needs to be created and initialized */ + ese = (edge_state_entry *)hash_search_with_hash_value( + vlelctx->edge_state_hashtable, + (void *)&edge_id, hashvalue, + HASH_ENTER, &found); if (!found) { - /* the edge id is also the hash key for resolving collisions */ ese->edge_id = edge_id; ese->used_in_path = false; ese->has_been_matched = false; @@ -880,7 +996,7 @@ static graphid get_next_vertex(VLE_local_context *vlelctx, edge_entry *ee) case CYPHER_REL_DIR_NONE: { - ListGraphId *vertex_stack = NULL; + GraphIdStack *vertex_stack = NULL; graphid parent_vertex_id; vertex_stack = vlelctx->dfs_vertex_stack; @@ -889,7 +1005,7 @@ static graphid get_next_vertex(VLE_local_context *vlelctx, edge_entry *ee) * as un-directional, where we go to next depends on where we came * from. This is because we can go against an edge. */ - parent_vertex_id = PEEK_GRAPHID_STACK(vertex_stack); + parent_vertex_id = gid_stack_peek(vertex_stack); /* find the terminal vertex */ if (get_edge_entry_start_vertex_id(ee) == parent_vertex_id) { @@ -928,9 +1044,9 @@ static graphid get_next_vertex(VLE_local_context *vlelctx, edge_entry *ee) */ static bool dfs_find_a_path_between(VLE_local_context *vlelctx) { - ListGraphId *vertex_stack = NULL; - ListGraphId *edge_stack = NULL; - ListGraphId *path_stack = NULL; + GraphIdStack *vertex_stack = NULL; + GraphIdStack *edge_stack = NULL; + GraphIdStack *path_stack = NULL; graphid end_vertex_id; Assert(vlelctx != NULL); @@ -942,18 +1058,33 @@ static bool dfs_find_a_path_between(VLE_local_context *vlelctx) end_vertex_id = vlelctx->veid; /* while we have edges to process */ - while (!(IS_GRAPHID_STACK_EMPTY(edge_stack))) + while (!(gid_stack_is_empty(edge_stack))) { graphid edge_id; graphid next_vertex_id; edge_state_entry *ese = NULL; edge_entry *ee = NULL; bool found = false; + uint32 edge_hashvalue; + + /* + * Allow this traversal to be cancelled (e.g. by a user Ctrl-C or a + * statement_timeout). On a large or densely connected graph this DFS + * can run for a long time, so we must yield to interrupt processing + * on every iteration. + */ + CHECK_FOR_INTERRUPTS(); /* get an edge, but leave it on the stack for now */ - edge_id = PEEK_GRAPHID_STACK(edge_stack); + edge_id = gid_stack_peek(edge_stack); + /* + * Compute the hash for edge_id once and reuse it for both the + * edge_state_hashtable lookup and (later) the edge_hashtable lookup. + * Both tables key on graphid using graphid_hash(). + */ + edge_hashvalue = graphid_hash(&edge_id, sizeof(int64)); /* get the edge's state */ - ese = get_edge_state(vlelctx, edge_id); + ese = get_edge_state_with_hash(vlelctx, edge_id, edge_hashvalue); /* * If the edge is already in use, it means that the edge is in the path. * So, we need to see if it is the last path entry (we are backing up - @@ -967,18 +1098,18 @@ static bool dfs_find_a_path_between(VLE_local_context *vlelctx) graphid path_edge_id; /* get the edge id on the top of the path stack (last edge) */ - path_edge_id = PEEK_GRAPHID_STACK(path_stack); + path_edge_id = gid_stack_peek(path_stack); /* * If the ids are the same, we're backing up. So, remove it from the * path stack and reset used_in_path. */ if (edge_id == path_edge_id) { - pop_graphid_stack(path_stack); + gid_stack_pop(path_stack); ese->used_in_path = false; } /* now remove it from the edge stack */ - pop_graphid_stack(edge_stack); + gid_stack_pop(edge_stack); /* * Remove its source vertex, if we are looking at edges as * un-directional. We only maintain the vertex stack when the @@ -987,7 +1118,7 @@ static bool dfs_find_a_path_between(VLE_local_context *vlelctx) */ if (vlelctx->edge_direction == CYPHER_REL_DIR_NONE) { - pop_graphid_stack(vertex_stack); + gid_stack_pop(vertex_stack); } /* move to the next edge */ continue; @@ -998,10 +1129,10 @@ static bool dfs_find_a_path_between(VLE_local_context *vlelctx) * the edge stack as it is already there. */ ese->used_in_path = true; - push_graphid_stack(path_stack, edge_id); + gid_stack_push(path_stack, edge_id); /* now get the edge entry so we can get the next vertex to move to */ - ee = get_edge_entry(vlelctx->ggctx, edge_id); + ee = get_edge_entry_with_hash(vlelctx->ggctx, edge_id, edge_hashvalue); next_vertex_id = get_next_vertex(vlelctx, ee); /* @@ -1009,9 +1140,9 @@ static bool dfs_find_a_path_between(VLE_local_context *vlelctx) * within the bounds specified? */ if (next_vertex_id == end_vertex_id && - get_stack_size(path_stack) >= vlelctx->lidx && + gid_stack_size(path_stack) >= vlelctx->lidx && (vlelctx->uidx_infinite || - get_stack_size(path_stack) <= vlelctx->uidx)) + gid_stack_size(path_stack) <= vlelctx->uidx)) { /* we found one */ found = true; @@ -1023,14 +1154,14 @@ static bool dfs_find_a_path_between(VLE_local_context *vlelctx) */ if (next_vertex_id == end_vertex_id && !vlelctx->uidx_infinite && - get_stack_size(path_stack) > vlelctx->uidx) + gid_stack_size(path_stack) > vlelctx->uidx) { continue; } /* add in the edges for the next vertex if we won't exceed the bounds */ if (vlelctx->uidx_infinite || - get_stack_size(path_stack) < vlelctx->uidx) + gid_stack_size(path_stack) < vlelctx->uidx) { add_valid_vertex_edges(vlelctx, next_vertex_id); } @@ -1058,9 +1189,9 @@ static bool dfs_find_a_path_between(VLE_local_context *vlelctx) */ static bool dfs_find_a_path_from(VLE_local_context *vlelctx) { - ListGraphId *vertex_stack = NULL; - ListGraphId *edge_stack = NULL; - ListGraphId *path_stack = NULL; + GraphIdStack *vertex_stack = NULL; + GraphIdStack *edge_stack = NULL; + GraphIdStack *path_stack = NULL; Assert(vlelctx != NULL); @@ -1070,18 +1201,33 @@ static bool dfs_find_a_path_from(VLE_local_context *vlelctx) path_stack = vlelctx->dfs_path_stack; /* while we have edges to process */ - while (!(IS_GRAPHID_STACK_EMPTY(edge_stack))) + while (!(gid_stack_is_empty(edge_stack))) { graphid edge_id; graphid next_vertex_id; edge_state_entry *ese = NULL; edge_entry *ee = NULL; bool found = false; + uint32 edge_hashvalue; + + /* + * Allow this traversal to be cancelled (e.g. by a user Ctrl-C or a + * statement_timeout). On a large or densely connected graph this DFS + * can run for a long time, so we must yield to interrupt processing + * on every iteration. + */ + CHECK_FOR_INTERRUPTS(); /* get an edge, but leave it on the stack for now */ - edge_id = PEEK_GRAPHID_STACK(edge_stack); + edge_id = gid_stack_peek(edge_stack); + /* + * Compute the hash for edge_id once and reuse it for both the + * edge_state_hashtable lookup and (later) the edge_hashtable lookup. + * Both tables key on graphid using graphid_hash(). + */ + edge_hashvalue = graphid_hash(&edge_id, sizeof(int64)); /* get the edge's state */ - ese = get_edge_state(vlelctx, edge_id); + ese = get_edge_state_with_hash(vlelctx, edge_id, edge_hashvalue); /* * If the edge is already in use, it means that the edge is in the path. * So, we need to see if it is the last path entry (we are backing up - @@ -1095,18 +1241,18 @@ static bool dfs_find_a_path_from(VLE_local_context *vlelctx) graphid path_edge_id; /* get the edge id on the top of the path stack (last edge) */ - path_edge_id = PEEK_GRAPHID_STACK(path_stack); + path_edge_id = gid_stack_peek(path_stack); /* * If the ids are the same, we're backing up. So, remove it from the * path stack and reset used_in_path. */ if (edge_id == path_edge_id) { - pop_graphid_stack(path_stack); + gid_stack_pop(path_stack); ese->used_in_path = false; } /* now remove it from the edge stack */ - pop_graphid_stack(edge_stack); + gid_stack_pop(edge_stack); /* * Remove its source vertex, if we are looking at edges as * un-directional. We only maintain the vertex stack when the @@ -1115,7 +1261,7 @@ static bool dfs_find_a_path_from(VLE_local_context *vlelctx) */ if (vlelctx->edge_direction == CYPHER_REL_DIR_NONE) { - pop_graphid_stack(vertex_stack); + gid_stack_pop(vertex_stack); } /* move to the next edge */ continue; @@ -1126,19 +1272,19 @@ static bool dfs_find_a_path_from(VLE_local_context *vlelctx) * the edge stack as it is already there. */ ese->used_in_path = true; - push_graphid_stack(path_stack, edge_id); + gid_stack_push(path_stack, edge_id); /* now get the edge entry so we can get the next vertex to move to */ - ee = get_edge_entry(vlelctx->ggctx, edge_id); + ee = get_edge_entry_with_hash(vlelctx->ggctx, edge_id, edge_hashvalue); next_vertex_id = get_next_vertex(vlelctx, ee); /* * Is this a path that meets our requirements? Is its length within the * bounds specified? */ - if (get_stack_size(path_stack) >= vlelctx->lidx && + if (gid_stack_size(path_stack) >= vlelctx->lidx && (vlelctx->uidx_infinite || - get_stack_size(path_stack) <= vlelctx->uidx)) + gid_stack_size(path_stack) <= vlelctx->uidx)) { /* we found one */ found = true; @@ -1146,7 +1292,7 @@ static bool dfs_find_a_path_from(VLE_local_context *vlelctx) /* add in the edges for the next vertex if we won't exceed the bounds */ if (vlelctx->uidx_infinite || - get_stack_size(path_stack) < vlelctx->uidx) + gid_stack_size(path_stack) < vlelctx->uidx) { add_valid_vertex_edges(vlelctx, next_vertex_id); } @@ -1168,20 +1314,16 @@ static bool dfs_find_a_path_from(VLE_local_context *vlelctx) */ static bool is_edge_in_path(VLE_local_context *vlelctx, graphid edge_id) { - GraphIdNode *edge = NULL; - - /* start at the top of the stack */ - edge = peek_stack_head(vlelctx->dfs_path_stack); + GraphIdStack *stack = vlelctx->dfs_path_stack; + int64 i; - /* go through the path stack, return true if we find the edge */ - while (edge != NULL) + /* scan the array-based path stack */ + for (i = 0; i < gid_stack_size(stack); i++) { - if (get_graphid(edge) == edge_id) + if (gid_stack_get(stack, i) == edge_id) { return true; } - /* get the next stack element */ - edge = next_GraphIdNode(edge); } /* we didn't find it if we get here */ return false; @@ -1197,16 +1339,51 @@ static bool is_edge_in_path(VLE_local_context *vlelctx, graphid edge_id) * * Note: The vertex must exist. */ +/* + * Batched candidate buffer size for the adjacency lookup pipeline below. + * 8 was chosen because it comfortably fits within the OoO window and the + * per-core L1 MSHR count of modern Xeons (12+), so the K back-to-back + * hashtable misses overlap in a single MLP wave. + */ +#define VLE_LOOKUP_BATCH 8 + static void add_valid_vertex_edges(VLE_local_context *vlelctx, graphid vertex_id) { - ListGraphId *vertex_stack = NULL; - ListGraphId *edge_stack = NULL; - ListGraphId *edges = NULL; + GraphIdStack *vertex_stack = NULL; + GraphIdStack *edge_stack = NULL; vertex_entry *ve = NULL; - GraphIdNode *edge_in = NULL; - GraphIdNode *edge_out = NULL; - GraphIdNode *edge_self = NULL; + /* + * Three flat-array adjacency lists, walked in parallel via integer + * indices. An empty (or direction-disabled) list has size == 0 so its + * branch never fires. This replaces the previous GraphIdNode pointer + * walk with a contiguous-memory traversal — significantly better for + * cache and branch-predictor behaviour on the DFS hot path. + */ + graphid *arr_out = NULL; + int32 sz_out = 0; + int32 idx_out = 0; + graphid *arr_in = NULL; + int32 sz_in = 0; + int32 idx_in = 0; + graphid *arr_self = NULL; + int32 sz_self = 0; + int32 idx_self = 0; + VertexEdgeArray *vea = NULL; + + /* + * Per-batch scratch arrays for the MLP lookup pipeline. Each iteration + * gathers up to VLE_LOOKUP_BATCH not-already-in-path candidate edges, + * then issues their edge_table (agehash) and edge_state_hashtable + * (dynahash) lookups in two tight back-to-back loops. The CPU's + * out-of-order engine overlaps the K independent cache misses inside + * each loop, hiding memory latency that the original one-edge-at-a-time + * loop serialized. + */ + graphid batch_eids[VLE_LOOKUP_BATCH]; + uint32 batch_hashes[VLE_LOOKUP_BATCH]; + edge_entry *batch_ee[VLE_LOOKUP_BATCH]; + edge_state_entry *batch_ese[VLE_LOOKUP_BATCH]; /* get the vertex entry */ ve = get_vertex_entry(vlelctx->ggctx, vertex_id); @@ -1220,82 +1397,128 @@ static void add_valid_vertex_edges(VLE_local_context *vlelctx, vertex_stack = vlelctx->dfs_vertex_stack; edge_stack = vlelctx->dfs_edge_stack; - /* set to the first edge for each edge list for the specified direction */ + /* set up walked arrays for the requested direction(s) */ if (vlelctx->edge_direction == CYPHER_REL_DIR_RIGHT || vlelctx->edge_direction == CYPHER_REL_DIR_NONE) { - edges = get_vertex_entry_edges_out(ve); - edge_out = (edges != NULL) ? get_list_head(edges) : NULL; + vea = get_vertex_entry_edges_out_array(ve); + arr_out = vea->array; + sz_out = vea->size; } if (vlelctx->edge_direction == CYPHER_REL_DIR_LEFT || vlelctx->edge_direction == CYPHER_REL_DIR_NONE) { - edges = get_vertex_entry_edges_in(ve); - edge_in = (edges != NULL) ? get_list_head(edges) : NULL; + vea = get_vertex_entry_edges_in_array(ve); + arr_in = vea->array; + sz_in = vea->size; } - /* set to the first selfloop edge */ - edges = get_vertex_entry_edges_self(ve); - edge_self = (edges != NULL) ? get_list_head(edges) : NULL; + /* selfloops are always traversed */ + vea = get_vertex_entry_edges_self_array(ve); + arr_self = vea->array; + sz_self = vea->size; - /* add in valid vertex edges */ - while (edge_out != NULL || edge_in != NULL || edge_self != NULL) + /* + * Outer loop: drain the three flat arrays via a 5-phase pipeline. + * 1. Gather: pull up to VLE_LOOKUP_BATCH next edge_ids that survive + * the cheap is_edge_in_path() early-skip. + * 2. Hash: compute graphid_hash for the batch (pure compute). + * 3. Lookup: K back-to-back edge_table (agehash) lookups via + * get_edge_entry_with_hash() — MLP window 1 (the CPU overlaps + * the K slot misses). + * 4. State: K back-to-back edge_state_hashtable (dynahash) HASH_ENTER + * calls — MLP window 2 (different table, different bucket misses). + * 5. Apply: per-edge match/state-update/stack-push, now operating + * on cache-warm ee/ese pointers. + * Phase 5 preserves the exact processing order of the original loop + * (out direction first, then in, then self), so DFS stack ordering and + * therefore path enumeration are identical to the previous version. + */ + while (idx_out < sz_out || idx_in < sz_in || idx_self < sz_self) { - edge_entry *ee = NULL; - edge_state_entry *ese = NULL; - graphid edge_id; + int batch_n = 0; + int i; - /* get the edge_id from the next available edge*/ - if (edge_out != NULL) - { - edge_id = get_graphid(edge_out); - } - else if (edge_in != NULL) - { - edge_id = get_graphid(edge_in); - } - else + /* Phase 1: gather */ + while (batch_n < VLE_LOOKUP_BATCH && + (idx_out < sz_out || idx_in < sz_in || idx_self < sz_self)) { - edge_id = get_graphid(edge_self); - } + graphid edge_id; - /* - * This is a fast existence check, relative to the hash search, for when - * the path stack is small. If the edge is in the path, we skip it. - */ - if (get_stack_size(vlelctx->dfs_path_stack) < 10 && - is_edge_in_path(vlelctx, edge_id)) - { - /* set to the next available edge */ - if (edge_out != NULL) + if (idx_out < sz_out) { - edge_out = next_GraphIdNode(edge_out); + edge_id = arr_out[idx_out++]; } - else if (edge_in != NULL) + else if (idx_in < sz_in) { - edge_in = next_GraphIdNode(edge_in); + edge_id = arr_in[idx_in++]; } else { - edge_self = next_GraphIdNode(edge_self); + edge_id = arr_self[idx_self++]; } - continue; + + /* + * Fast early-skip when the path stack is small: avoids two + * hashtable lookups for edges already on the path. + */ + if (gid_stack_size(vlelctx->dfs_path_stack) < 10 && + is_edge_in_path(vlelctx, edge_id)) + { + continue; + } + + batch_eids[batch_n++] = edge_id; } - /* get the edge entry */ - ee = get_edge_entry(vlelctx->ggctx, edge_id); - /* it better exist */ - if (ee == NULL) + if (batch_n == 0) { - elog(ERROR, "add_valid_vertex_edges: no edge found"); + break; } - /* get its state */ - ese = get_edge_state(vlelctx, edge_id); - /* - * Don't add any edges that we have already seen because they will - * cause a loop to form. - */ - if (!ese->used_in_path) + + /* Phase 2: compute hashes (pure compute, no misses) */ + for (i = 0; i < batch_n; i++) + { + batch_hashes[i] = graphid_hash(&batch_eids[i], sizeof(int64)); + } + + /* Phase 3: K back-to-back edge_table (agehash) lookups (MLP wave 1) */ + for (i = 0; i < batch_n; i++) { + batch_ee[i] = get_edge_entry_with_hash(vlelctx->ggctx, + batch_eids[i], + batch_hashes[i]); + } + + /* Phase 4: K back-to-back edge_state_hashtable lookups (MLP wave 2) */ + for (i = 0; i < batch_n; i++) + { + batch_ese[i] = get_edge_state_with_hash(vlelctx, + batch_eids[i], + batch_hashes[i]); + } + + /* Phase 5: process the batch sequentially */ + for (i = 0; i < batch_n; i++) + { + edge_entry *ee = batch_ee[i]; + edge_state_entry *ese = batch_ese[i]; + graphid edge_id = batch_eids[i]; + + /* it better exist */ + if (ee == NULL) + { + elog(ERROR, "add_valid_vertex_edges: no edge found"); + } + + /* + * Don't add any edges that we have already seen because they + * will cause a loop to form. + */ + if (ese->used_in_path) + { + continue; + } + /* validate the edge if it hasn't been already */ if (!ese->has_been_matched && is_an_edge_match(vlelctx, ee)) { @@ -1307,37 +1530,25 @@ static void add_valid_vertex_edges(VLE_local_context *vlelctx, ese->has_been_matched = true; ese->matched = false; } + /* if it is a match, add it */ if (ese->has_been_matched && ese->matched) { /* - * We need to maintain our source vertex for each edge added - * if the edge_direction is CYPHER_REL_DIR_NONE. This is due - * to the edges having a fixed direction and the dfs + * We need to maintain our source vertex for each edge + * added if the edge_direction is CYPHER_REL_DIR_NONE. This + * is due to the edges having a fixed direction and the dfs * algorithm working strictly through edges. With an * un-directional VLE edge, you don't know the vertex that * you just came from. So, we need to store it. */ - if (vlelctx->edge_direction == CYPHER_REL_DIR_NONE) - { - push_graphid_stack(vertex_stack, get_vertex_entry_id(ve)); - } - push_graphid_stack(edge_stack, edge_id); + if (vlelctx->edge_direction == CYPHER_REL_DIR_NONE) + { + gid_stack_push(vertex_stack, get_vertex_entry_id(ve)); + } + gid_stack_push(edge_stack, edge_id); } } - /* get the next working edge */ - if (edge_out != NULL) - { - edge_out = next_GraphIdNode(edge_out); - } - else if (edge_in != NULL) - { - edge_in = next_GraphIdNode(edge_in); - } - else - { - edge_self = next_GraphIdNode(edge_self); - } } } @@ -1359,9 +1570,11 @@ static VLE_path_container *create_VLE_path_container(int64 path_size) * One for both the header and graph oid (they are both 32 bits). * One for the size of the graphid_array_size. * One for the container_size_bytes. + * One for start_vid (Stage 1: inline endpoint cache). + * One for end_vid (Stage 1: inline endpoint cache). * */ - container_size_bytes = sizeof(graphid) * (path_size + 4); + container_size_bytes = sizeof(graphid) * (path_size + 6); /* allocate the container */ vpc = palloc0(container_size_bytes); @@ -1407,13 +1620,13 @@ static VLE_path_container *create_VLE_path_container(int64 path_size) static VLE_path_container *build_VLE_path_container(VLE_local_context *vlelctx) { - ListGraphId *stack = vlelctx->dfs_path_stack; + GraphIdStack *stack = vlelctx->dfs_path_stack; VLE_path_container *vpc = NULL; graphid *graphid_array = NULL; - GraphIdNode *edge = NULL; graphid vid = 0; int index = 0; int ssize = 0; + int j = 0; if (stack == NULL) { @@ -1421,7 +1634,7 @@ static VLE_path_container *build_VLE_path_container(VLE_local_context *vlelctx) } /* allocate the graphid array */ - ssize = get_stack_size(stack); + ssize = gid_stack_size(stack); /* * Create the container. Note that the path size will always be 2 times the @@ -1439,25 +1652,21 @@ static VLE_path_container *build_VLE_path_container(VLE_local_context *vlelctx) vid = vlelctx->vsid; graphid_array[0] = vid; - /* get the head of the stack */ - edge = peek_stack_head(stack); - /* - * We need to fill in the array from the back to the front. This is due - * to the order of the path stack - last in first out. Remember that the - * last entry is a vertex. + * Fill in edge entries from the back to the front. The path stack + * is array-based with index 0 = bottom (first pushed) and + * index size-1 = top (last pushed). We iterate from top to bottom + * to fill the graphid_array from back to front. */ index = vpc->graphid_array_size - 2; - /* copy while we have an edge to copy */ - while (edge != NULL) + for (j = ssize - 1; j >= 0; j--) { /* 0 is the vsid, we should never get here */ Assert(index > 0); - /* store and set to the next edge */ - graphid_array[index] = get_graphid(edge); - edge = next_GraphIdNode(edge); + /* store the edge from stack position j */ + graphid_array[index] = gid_stack_get(stack, j); /* we need to skip over the interior vertices */ index -= 2; @@ -1475,6 +1684,13 @@ static VLE_path_container *build_VLE_path_container(VLE_local_context *vlelctx) graphid_array[index+1] = vid; } + /* + * Stage 1: cache endpoints in the fixed header so the join qual can read + * them without touching the (possibly toasted) graphid array. + */ + vpc->start_vid = graphid_array[0]; + vpc->end_vid = graphid_array[vpc->graphid_array_size - 1]; + /* return the container */ return vpc; } @@ -1482,13 +1698,13 @@ static VLE_path_container *build_VLE_path_container(VLE_local_context *vlelctx) /* helper function to build a VPC for just the start vertex */ static VLE_path_container *build_VLE_zero_container(VLE_local_context *vlelctx) { - ListGraphId *stack = vlelctx->dfs_path_stack; + GraphIdStack *stack = vlelctx->dfs_path_stack; VLE_path_container *vpc = NULL; graphid *graphid_array = NULL; graphid vid = 0; /* we should have an empty stack */ - if (get_stack_size(stack) != 0) + if (gid_stack_size(stack) != 0) { ereport(ERROR, (errcode(ERRCODE_DATA_EXCEPTION), @@ -1511,6 +1727,12 @@ static VLE_path_container *build_VLE_zero_container(VLE_local_context *vlelctx) vid = vlelctx->vsid; graphid_array[0] = vid; + /* + * Stage 1: zero-edge container; start and end are both the start vertex. + */ + vpc->start_vid = vid; + vpc->end_vid = vid; + return vpc; } @@ -1730,9 +1952,39 @@ Datum age_vle(PG_FUNCTION_ARGS) /* create a function context for cross-call persistence */ funcctx = SRF_FIRSTCALL_INIT(); + /* + * S4: capture the result tuple descriptor. age_vle now emits a + * composite (edges, start_id, end_id) row, so we need a blessed + * TupleDesc that survives across SRF calls. + */ + { + TupleDesc tupdesc; + MemoryContext tdesc_oldctx; + + tdesc_oldctx = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx); + if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE) + { + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("age_vle: function returning record called in context that cannot accept type record"))); + } + funcctx->tuple_desc = BlessTupleDesc(tupdesc); + MemoryContextSwitchTo(tdesc_oldctx); + } + /* build the local vle context */ vlelctx = build_local_vle_context(fcinfo, funcctx); + /* + * If the context is NULL, there are no paths to find. + * This can happen when a cached VLE context has exhausted + * its vertex list (e.g., from a NULL OPTIONAL MATCH variable). + */ + if (vlelctx == NULL) + { + SRF_RETURN_DONE(funcctx); + } + /* * Point the function call context's user pointer to the local VLE * context just created @@ -1852,8 +2104,24 @@ Datum age_vle(PG_FUNCTION_ARGS) vpc = build_VLE_zero_container(vlelctx); } - /* return the result and signal that the function is not yet done */ - SRF_RETURN_NEXT(funcctx, PointerGetDatum(vpc)); + /* + * S4: emit a composite (edges, start_id, end_id) row. The + * scalar endpoint columns let the cypher transformer (S5) + * rewrite terminal-edge quals as integer equalities, removing + * the per-row age_match_vle_terminal_edge function call. + */ + { + Datum values[3]; + bool nulls[3] = {false, false, false}; + HeapTuple tuple; + + values[0] = PointerGetDatum(vpc); + values[1] = GRAPHID_GET_DATUM(vpc->start_vid); + values[2] = GRAPHID_GET_DATUM(vpc->end_vid); + + tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls); + SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple)); + } } /* otherwise, we are done and we need to cleanup and signal done */ else @@ -1924,56 +2192,31 @@ agtype_value *agtv_materialize_vle_path(agtype *agt_arg_vpc) return agtv_path; } -/* PG function to match 2 VLE edges */ +/* + * age_match_two_vle_edges and age_match_vle_terminal_edge are retained as + * stub C symbols only. The cypher transformer no longer emits calls to + * either function: terminal-edge match quals are now plain graphid + * equalities on the age_vle SRF's start_id/end_id output columns + * (Stages S4/S5/S6 of the VLE terminal-qual rewrite). + * + * The corresponding SQL declarations have been removed from fresh + * installs (sql/agtype_typecast.sql) and are DROP'd by the upgrade + * script (age--1.7.0--y.y.y.sql). These C entry points exist solely so + * the upgrade-test machinery, which loads an older "1.7.0_initial" SQL + * snapshot against the current age.so, can resolve the symbols before + * the immediate ALTER EXTENSION UPDATE drops them. They should never + * be reachable from any committed SQL path. + */ PG_FUNCTION_INFO_V1(age_match_two_vle_edges); Datum age_match_two_vle_edges(PG_FUNCTION_ARGS) { - agtype *agt_arg_vpc = NULL; - VLE_path_container *left_path = NULL, *right_path = NULL; - graphid *left_array, *right_array; - int left_array_size; - - /* get the VLE_path_container argument */ - agt_arg_vpc = AG_GET_ARG_AGTYPE_P(0); - - if (!AGT_ROOT_IS_BINARY(agt_arg_vpc) || - AGT_ROOT_BINARY_FLAGS(agt_arg_vpc) != AGT_FBINARY_TYPE_VLE_PATH) - { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("argument 1 of age_match_two_vle_edges must be a VLE_Path_Container"))); - } - - /* cast argument as a VLE_Path_Container and extract graphid array */ - left_path = (VLE_path_container *)agt_arg_vpc; - left_array_size = left_path->graphid_array_size; - left_array = GET_GRAPHID_ARRAY_FROM_CONTAINER(left_path); - - PG_FREE_IF_COPY(agt_arg_vpc, 0); - - agt_arg_vpc = AG_GET_ARG_AGTYPE_P(1); - - if (!AGT_ROOT_IS_BINARY(agt_arg_vpc) || - AGT_ROOT_BINARY_FLAGS(agt_arg_vpc) != AGT_FBINARY_TYPE_VLE_PATH) - { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("argument 2 of age_match_two_vle_edges must be a VLE_Path_Container"))); - } - - /* cast argument as a VLE_Path_Container and extract graphid array */ - right_path = (VLE_path_container *)agt_arg_vpc; - right_array = GET_GRAPHID_ARRAY_FROM_CONTAINER(right_path); - - PG_FREE_IF_COPY(agt_arg_vpc, 1); - - if (left_array[left_array_size - 1] != right_array[0]) - { - PG_RETURN_BOOL(false); - } - - PG_RETURN_BOOL(true); + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("age_match_two_vle_edges() is removed; " + "VLE endpoint matching is now handled by the planner via " + "the age_vle SRF's start_id/end_id output columns"))); + PG_RETURN_BOOL(false); } /* @@ -1986,10 +2229,6 @@ PG_FUNCTION_INFO_V1(age_match_vle_edge_to_id_qual); Datum age_match_vle_edge_to_id_qual(PG_FUNCTION_ARGS) { - int nargs = 0; - Datum *args = NULL; - bool *nulls = NULL; - Oid *types = NULL; agtype *agt_arg_vpc = NULL; agtype *edge_id = NULL; agtype *pos_agt = NULL; @@ -1998,26 +2237,27 @@ Datum age_match_vle_edge_to_id_qual(PG_FUNCTION_ARGS) graphid *array = NULL; bool vle_is_on_left = false; graphid gid = 0; + Oid type1; - /* extract argument values */ - nargs = extract_variadic_args(fcinfo, 0, true, &args, &types, &nulls); - - if (nargs != 3) + /* check argument count */ + if (PG_NARGS() != 3) { ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("age_match_vle_edge_to_id_qual() invalid number of arguments"))); } - /* the arguments cannot be NULL */ - if (nulls[0] || nulls[1] || nulls[2]) + /* + * If any argument is NULL, return FALSE. This can occur in + * OPTIONAL MATCH (LEFT JOIN) contexts where a preceding clause + * produced no results. + */ + if (PG_ARGISNULL(0) || PG_ARGISNULL(1) || PG_ARGISNULL(2)) { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("age_match_vle_edge_to_id_qual() arguments must be non NULL"))); + PG_RETURN_BOOL(false); } /* get the VLE_path_container argument */ - agt_arg_vpc = DATUM_GET_AGTYPE_P(args[0]); + agt_arg_vpc = DATUM_GET_AGTYPE_P(PG_GETARG_DATUM(0)); if (!AGT_ROOT_IS_BINARY(agt_arg_vpc) || AGT_ROOT_BINARY_FLAGS(agt_arg_vpc) != AGT_FBINARY_TYPE_VLE_PATH) @@ -2031,11 +2271,23 @@ Datum age_match_vle_edge_to_id_qual(PG_FUNCTION_ARGS) vle_path = (VLE_path_container *)agt_arg_vpc; array = GET_GRAPHID_ARRAY_FROM_CONTAINER(vle_path); - if (types[1] == AGTYPEOID) + /* + * Get arg type for argument 1 — cache in fn_extra to avoid + * repeated expression type resolution. + */ + if (fcinfo->flinfo->fn_extra == NULL) + { + Oid *cached_type = MemoryContextAlloc(fcinfo->flinfo->fn_mcxt, + sizeof(Oid)); + *cached_type = get_fn_expr_argtype(fcinfo->flinfo, 1); + fcinfo->flinfo->fn_extra = cached_type; + } + type1 = *(Oid *)fcinfo->flinfo->fn_extra; + + if (type1 == AGTYPEOID) { /* Get the edge id we are checking the end of the list too */ edge_id = AG_GET_ARG_AGTYPE_P(1); - if (!AGT_ROOT_IS_SCALAR(edge_id)) { ereport(ERROR, @@ -2054,11 +2306,9 @@ Datum age_match_vle_edge_to_id_qual(PG_FUNCTION_ARGS) gid = id->val.int_value; } - else if (types[1] == GRAPHIDOID) + else if (type1 == GRAPHIDOID) { - - gid = DATUM_GET_GRAPHID(args[1]); - + gid = DATUM_GET_GRAPHID(PG_GETARG_DATUM(1)); } else { @@ -2204,185 +2454,62 @@ Datum age_materialize_vle_path(PG_FUNCTION_ARGS) PG_RETURN_POINTER(agt_materialize_vle_path(agt_arg_vpc)); } -/* - * PG function to take a VLE_path_container and return whether the supplied end - * vertex (target/veid) matches against the last edge in the VLE path. The VLE - * path is encoded in a BINARY container. - */ +/* Stub: see comment on age_match_two_vle_edges above. */ PG_FUNCTION_INFO_V1(age_match_vle_terminal_edge); Datum age_match_vle_terminal_edge(PG_FUNCTION_ARGS) { - int nargs = 0; - Datum *args = NULL; - bool *nulls = NULL; - Oid *types = NULL; - VLE_path_container *vpc = NULL; - agtype *agt_arg_vsid = NULL; - agtype *agt_arg_veid = NULL; - agtype *agt_arg_path = NULL; + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("age_match_vle_terminal_edge() is removed; " + "VLE endpoint matching is now handled by the planner via " + "the age_vle SRF's start_id/end_id output columns"))); + PG_RETURN_BOOL(false); +} + +/* PG helper function to build an agtype (Datum) edge for matching */ +PG_FUNCTION_INFO_V1(age_build_vle_match_edge); + +Datum age_build_vle_match_edge(PG_FUNCTION_ARGS) +{ + agtype_in_state result; + agtype_value agtv_zero; + agtype_value agtv_nstr; agtype_value *agtv_temp = NULL; - graphid vsid = 0; - graphid veid = 0; - graphid *gida = NULL; - int gidasize = 0; - /* extract argument values */ - nargs = extract_variadic_args(fcinfo, 0, true, &args, &types, &nulls); + /* create an agtype_value integer 0 */ + agtv_zero.type = AGTV_INTEGER; + agtv_zero.val.int_value = 0; - if (nargs != 3) - { - ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("age_match_terminal_edge() invalid number of arguments"))); - } + /* create an agtype_value null string */ + agtv_nstr.type = AGTV_STRING; + agtv_nstr.val.string.len = 0; + agtv_nstr.val.string.val = NULL; + + /* zero the state */ + memset(&result, 0, sizeof(agtype_in_state)); - /* the arguments cannot be NULL */ - if (nulls[0] || nulls[1] || nulls[2]) + /* start the object */ + result.res = push_agtype_value(&result.parse_state, WAGT_BEGIN_OBJECT, + NULL); + /* create dummy graph id */ + result.res = push_agtype_value(&result.parse_state, WAGT_KEY, + string_to_agtype_value("id")); + result.res = push_agtype_value(&result.parse_state, WAGT_VALUE, &agtv_zero); + /* process the label */ + result.res = push_agtype_value(&result.parse_state, WAGT_KEY, + string_to_agtype_value("label")); + if (!PG_ARGISNULL(0)) { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("match_vle_terminal_edge() arguments cannot be NULL"))); + agtv_temp = get_agtype_value("build_vle_match_edge", + AG_GET_ARG_AGTYPE_P(0), AGTV_STRING, true); + result.res = push_agtype_value(&result.parse_state, WAGT_VALUE, + agtv_temp); } - - /* get the vpc */ - agt_arg_path = DATUM_GET_AGTYPE_P(args[2]); - - /* it cannot be NULL */ - if (is_agtype_null(agt_arg_path)) + else { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("match_vle_terminal_edge() argument 3 cannot be NULL"))); - } - - /* - * The vpc (path) must be a binary container and the type of the object in - * the container must be an AGT_FBINARY_TYPE_VLE_PATH. - */ - Assert(AGT_ROOT_IS_BINARY(agt_arg_path)); - Assert(AGT_ROOT_BINARY_FLAGS(agt_arg_path) == AGT_FBINARY_TYPE_VLE_PATH); - - /* get the container */ - vpc = (VLE_path_container *)agt_arg_path; - - /* get the graphid array from the container */ - gida = GET_GRAPHID_ARRAY_FROM_CONTAINER(vpc); - - /* get the gida array size */ - gidasize = vpc->graphid_array_size; - - /* verify the minimum size is 3 or 1 */ - Assert(gidasize >= 3 || gidasize == 1); - - /* get the vsid */ - if (types[0] == AGTYPEOID) - { - agt_arg_vsid = DATUM_GET_AGTYPE_P(args[0]); - - if (!is_agtype_null(agt_arg_vsid)) - { - - agtv_temp = - get_ith_agtype_value_from_container(&agt_arg_vsid->root, 0); - - Assert(agtv_temp->type == AGTV_INTEGER); - vsid = agtv_temp->val.int_value; - } - else - { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("match_vle_terminal_edge() argument 1 must be non NULL"))); - } - } - else if (types[0] == GRAPHIDOID) - { - vsid = DATUM_GET_GRAPHID(args[0]); - } - else - { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("match_vle_terminal_edge() argument 1 must be an agtype integer or a graphid"))); - } - - /* get the veid */ - if (types[1] == AGTYPEOID) - { - agt_arg_veid = DATUM_GET_AGTYPE_P(args[1]); - - if (!is_agtype_null(agt_arg_veid)) - { - agtv_temp = get_ith_agtype_value_from_container(&agt_arg_veid->root, - 0); - Assert(agtv_temp->type == AGTV_INTEGER); - veid = agtv_temp->val.int_value; - } - else - { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("match_vle_terminal_edge() argument 2 must be non NULL"))); - } - } - else if (types[1] == GRAPHIDOID) - { - veid = DATUM_GET_GRAPHID(args[1]); - } - else - { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("match_vle_terminal_edge() argument 2 must be an agtype integer or a graphid"))); - } - - /* compare the path beginning or end points */ - PG_RETURN_BOOL(gida[0] == vsid && veid == gida[gidasize - 1]); -} - -/* PG helper function to build an agtype (Datum) edge for matching */ -PG_FUNCTION_INFO_V1(age_build_vle_match_edge); - -Datum age_build_vle_match_edge(PG_FUNCTION_ARGS) -{ - agtype_in_state result; - agtype_value agtv_zero; - agtype_value agtv_nstr; - agtype_value *agtv_temp = NULL; - - /* create an agtype_value integer 0 */ - agtv_zero.type = AGTV_INTEGER; - agtv_zero.val.int_value = 0; - - /* create an agtype_value null string */ - agtv_nstr.type = AGTV_STRING; - agtv_nstr.val.string.len = 0; - agtv_nstr.val.string.val = NULL; - - /* zero the state */ - memset(&result, 0, sizeof(agtype_in_state)); - - /* start the object */ - result.res = push_agtype_value(&result.parse_state, WAGT_BEGIN_OBJECT, - NULL); - /* create dummy graph id */ - result.res = push_agtype_value(&result.parse_state, WAGT_KEY, - string_to_agtype_value("id")); - result.res = push_agtype_value(&result.parse_state, WAGT_VALUE, &agtv_zero); - /* process the label */ - result.res = push_agtype_value(&result.parse_state, WAGT_KEY, - string_to_agtype_value("label")); - if (!PG_ARGISNULL(0)) - { - agtv_temp = get_agtype_value("build_vle_match_edge", - AG_GET_ARG_AGTYPE_P(0), AGTV_STRING, true); - result.res = push_agtype_value(&result.parse_state, WAGT_VALUE, - agtv_temp); - } - else - { - result.res = push_agtype_value(&result.parse_state, WAGT_VALUE, - &agtv_nstr); + result.res = push_agtype_value(&result.parse_state, WAGT_VALUE, + &agtv_nstr); } /* create dummy end_id */ result.res = push_agtype_value(&result.parse_state, WAGT_KEY, @@ -2521,7 +2648,7 @@ Datum _ag_enforce_edge_uniqueness(PG_FUNCTION_ARGS) MemSet(&exists_ctl, 0, sizeof(exists_ctl)); exists_ctl.keysize = sizeof(int64); exists_ctl.entrysize = sizeof(int64); - exists_ctl.hash = tag_hash; + exists_ctl.hash = graphid_hash; /* create exists_hash table */ exists_hash = hash_create(EXISTS_HTAB_NAME, EXISTS_HTAB_NAME_INITIAL_SIZE, @@ -2665,3 +2792,1106 @@ Datum _ag_enforce_edge_uniqueness(PG_FUNCTION_ARGS) hash_destroy(exists_hash); PG_RETURN_BOOL(true); } + +/* + * --------------------------------------------------------------------------- + * Shortest path / all shortest paths + * --------------------------------------------------------------------------- + * + * Plain (non-grammar) set-returning functions that compute the unweighted + * (hop-count) shortest path between two vertices, built directly on top of the + * cached global graph (GRAPH_global_context) and its flat-array adjacency + * (VertexEdgeArray). These do NOT go through the VLE grammar/transform path; + * they are user-callable helpers: + * + * ag_catalog.age_shortest_path(graph, start, end + * [, edge_types [, direction [, min_hops [, max_hops]]]]) + * ag_catalog.age_all_shortest_paths(graph, start, end + * [, edge_types [, direction [, min_hops [, max_hops]]]]) + * + * Both perform a breadth-first search from the start vertex. age_shortest_path + * returns a single path (0 or 1 rows); age_all_shortest_paths returns every + * path whose length equals the minimum hop count (one row per path), by + * recording a predecessor multiset during the BFS and enumerating the + * resulting shortest-path DAG. + * + * Because BFS depth strictly increases, every emitted path is simple (no + * repeated vertex and therefore no repeated edge), satisfying openCypher + * edge-isomorphism for these fixed-length results. + */ + +/* Simple FIFO queue of graphids for the BFS frontier. */ +typedef struct sp_queue +{ + graphid *data; + int64 head; + int64 tail; + int64 cap; +} sp_queue; + +/* One predecessor edge on a shortest path (all-shortest-paths mode). */ +typedef struct sp_pred +{ + graphid edge; + graphid parent_vertex; +} sp_pred; + +/* Per-vertex BFS bookkeeping, keyed by vertex_id in the visited hashtable. */ +typedef struct sp_visit_entry +{ + graphid vertex_id; /* hash key — must be first */ + int64 depth; /* BFS depth from the source vertex */ + graphid parent_edge; /* single-path reconstruction */ + graphid parent_vertex; /* single-path reconstruction */ + List *preds; /* sp_pred * list for all-shortest-paths mode */ +} sp_visit_entry; + +/* Cross-call SRF state: the precomputed result paths streamed one per call. */ +typedef struct sp_srf_state +{ + Datum *paths; + int64 npaths; + int64 next; +} sp_srf_state; + +static void sp_queue_init(sp_queue *q) +{ + q->cap = 1024; + q->head = 0; + q->tail = 0; + q->data = palloc(sizeof(graphid) * q->cap); +} + +static void sp_queue_push(sp_queue *q, graphid v) +{ + if (q->tail == q->cap) + { + q->cap = q->cap * 2; + q->data = repalloc(q->data, sizeof(graphid) * q->cap); + } + q->data[q->tail] = v; + q->tail = q->tail + 1; +} + +static bool sp_queue_is_empty(sp_queue *q) +{ + return q->head == q->tail; +} + +static graphid sp_queue_pop(sp_queue *q) +{ + graphid v = q->data[q->head]; + + q->head = q->head + 1; + return v; +} + +/* Resolve a vertex argument (a vertex agtype or an integer id) to a graphid. */ +static graphid sp_agtype_to_graphid(agtype *agt, char *fname, + const char *argname) +{ + agtype_value *agtv = NULL; + + agtv = get_agtype_value(fname, agt, AGTV_VERTEX, false); + + if (agtv != NULL && agtv->type == AGTV_VERTEX) + { + agtv = GET_AGTYPE_VALUE_OBJECT_VALUE(agtv, "id"); + } + else if (agtv == NULL || agtv->type != AGTV_INTEGER) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("%s argument must be a vertex or the integer id", + argname))); + } + + return agtv->val.int_value; +} + +/* Resolve the optional direction argument; NULL defaults to undirected. */ +static cypher_rel_dir sp_agtype_to_direction(agtype *agt, char *fname) +{ + agtype_value *agtv = NULL; + char *s = NULL; + cypher_rel_dir dir = CYPHER_REL_DIR_NONE; + + if (agt == NULL) + { + return CYPHER_REL_DIR_NONE; + } + + agtv = get_agtype_value(fname, agt, AGTV_STRING, true); + s = pnstrdup(agtv->val.string.val, agtv->val.string.len); + + if (pg_strcasecmp(s, "out") == 0) + { + dir = CYPHER_REL_DIR_RIGHT; + } + else if (pg_strcasecmp(s, "in") == 0) + { + dir = CYPHER_REL_DIR_LEFT; + } + else if (pg_strcasecmp(s, "any") == 0) + { + dir = CYPHER_REL_DIR_NONE; + } + else + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("%s: direction argument must be one of 'out', 'in', or 'any'", + fname))); + } + + pfree_if_not_null(s); + return dir; +} + +/* + * Wrap an interleaved [vertex, edge, vertex, ... , vertex] graphid array in a + * VLE_path_container and materialize it as an AGTV_PATH agtype Datum. + */ +static Datum sp_build_path_datum(Oid graph_oid, graphid *alt, int64 alt_len) +{ + VLE_path_container *vpc = NULL; + graphid *arr = NULL; + agtype_value *agtv_path = NULL; + agtype *agt = NULL; + + vpc = create_VLE_path_container(alt_len); + vpc->graph_oid = graph_oid; + + arr = GET_GRAPHID_ARRAY_FROM_CONTAINER(vpc); + memcpy(arr, alt, sizeof(graphid) * alt_len); + + vpc->start_vid = alt[0]; + vpc->end_vid = alt[alt_len - 1]; + + agtv_path = build_path(vpc); + agt = agtype_value_to_agtype(agtv_path); + + return AGTYPE_P_GET_DATUM(agt); +} + +/* + * Breadth-first search from source toward target over the flat-array + * adjacency. Returns the visited hashtable; sets *out_found and (if found) + * *out_target_depth (the shortest hop count). In all-shortest-paths mode + * (collect_all) every shortest-path predecessor is recorded per vertex. + */ +static HTAB *sp_run_bfs(GRAPH_global_context *ggctx, graphid source, + graphid target, Oid *label_oids, int n_label_oids, + cypher_rel_dir dir, int64 max_hops, bool collect_all, + int64 *out_target_depth, bool *out_found) +{ + HASHCTL ctl; + HTAB *visited = NULL; + sp_queue q; + sp_visit_entry *se = NULL; + bool found = false; + int64 target_depth = -1; + bool dir_out = (dir == CYPHER_REL_DIR_RIGHT || dir == CYPHER_REL_DIR_NONE); + bool dir_in = (dir == CYPHER_REL_DIR_LEFT || dir == CYPHER_REL_DIR_NONE); + + /* visited hashtable: graphid -> sp_visit_entry */ + MemSet(&ctl, 0, sizeof(ctl)); + ctl.keysize = sizeof(int64); + ctl.entrysize = sizeof(sp_visit_entry); + ctl.hash = graphid_hash; + visited = hash_create("age shortest path visited", 1024, &ctl, + HASH_ELEM | HASH_FUNCTION); + + /* + * A path can only exist between vertices that actually exist in the graph. + * If either endpoint is missing we are done: report "not found" and return + * the (empty) visited table. This guard is critical: without it a source + * that equals a non-existent target would be matched at depth 0 (see the + * "u == target" check below), and path reconstruction would then try to + * materialize a vertex that does not exist, dereferencing invalid memory + * and crashing the backend. + */ + if (get_vertex_entry(ggctx, source) == NULL || + get_vertex_entry(ggctx, target) == NULL) + { + *out_target_depth = -1; + *out_found = false; + return visited; + } + + sp_queue_init(&q); + + /* seed the frontier with the source vertex at depth 0 */ + se = (sp_visit_entry *) hash_search(visited, &source, HASH_ENTER, NULL); + se->vertex_id = source; + se->depth = 0; + se->parent_edge = 0; + se->parent_vertex = source; + se->preds = NIL; + sp_queue_push(&q, source); + + while (!sp_queue_is_empty(&q)) + { + graphid u = sp_queue_pop(&q); + sp_visit_entry *ue = NULL; + vertex_entry *ve = NULL; + int64 du = 0; + int pass = 0; + + /* + * Allow this search to be cancelled (e.g. by a user Ctrl-C or a + * statement_timeout). On a large graph the BFS frontier can grow very + * large, so we must yield to interrupt processing on every iteration. + */ + CHECK_FOR_INTERRUPTS(); + + ue = (sp_visit_entry *) hash_search(visited, &u, HASH_FIND, NULL); + du = ue->depth; + + /* target reached: record its (shortest) depth */ + if (u == target) + { + found = true; + if (target_depth < 0) + { + target_depth = du; + } + /* single-path mode: the first discovery is sufficient */ + if (!collect_all) + { + break; + } + } + + /* never expand at or beyond the shortest target depth */ + if (target_depth >= 0 && du >= target_depth) + { + continue; + } + + /* respect the optional upper hop bound */ + if (max_hops >= 0 && du >= max_hops) + { + continue; + } + + ve = get_vertex_entry(ggctx, u); + if (ve == NULL) + { + continue; + } + + /* pass 0 = outgoing edges, pass 1 = incoming edges */ + for (pass = 0; pass < 2; pass++) + { + VertexEdgeArray *edges = NULL; + int32 i = 0; + + if (pass == 0) + { + if (!dir_out) + { + continue; + } + edges = get_vertex_entry_edges_out_array(ve); + } + else + { + if (!dir_in) + { + continue; + } + edges = get_vertex_entry_edges_in_array(ve); + } + + if (edges == NULL || edges->array == NULL) + { + continue; + } + + for (i = 0; i < edges->size; i++) + { + graphid eid = edges->array[i]; + edge_entry *ee = NULL; + graphid v = 0; + sp_visit_entry *vse = NULL; + bool was_present = false; + + ee = get_edge_entry(ggctx, eid); + if (ee == NULL) + { + continue; + } + + /* + * Optional edge label filter. When a label filter is active + * (n_label_oids > 0) we keep only edges whose label table oid + * is one of the requested relationship types. A requested type + * that does not exist in this graph resolves to InvalidOid; + * since no real edge can have an InvalidOid label table, such a + * type contributes no matches and simply drops out of the set, + * while edges of any of the other (known) requested types still + * match. Only when every requested type is unknown does the + * filter match no edges, leaving just the zero-length + * (start == end) path -- matching the openCypher semantics that + * an unknown relationship type matches no relationships. + */ + if (n_label_oids > 0) + { + Oid ee_label_oid = get_edge_entry_label_table_oid(ee); + bool label_match = false; + int li = 0; + + for (li = 0; li < n_label_oids; li++) + { + if (label_oids[li] == ee_label_oid) + { + label_match = true; + break; + } + } + if (!label_match) + { + continue; + } + } + + /* the neighbor depends on which side of the edge u is on */ + if (pass == 0) + { + v = get_edge_entry_end_vertex_id(ee); + } + else + { + v = get_edge_entry_start_vertex_id(ee); + } + + /* self loops never shorten a path to a different vertex */ + if (v == u) + { + continue; + } + + vse = (sp_visit_entry *) hash_search(visited, &v, HASH_ENTER, + &was_present); + if (!was_present) + { + vse->vertex_id = v; + vse->depth = du + 1; + vse->parent_edge = eid; + vse->parent_vertex = u; + vse->preds = NIL; + + if (collect_all) + { + sp_pred *p = palloc(sizeof(sp_pred)); + + p->edge = eid; + p->parent_vertex = u; + vse->preds = lappend(vse->preds, p); + } + + sp_queue_push(&q, v); + } + else if (collect_all && vse->depth == du + 1) + { + /* another equally-short predecessor of v */ + sp_pred *p = palloc(sizeof(sp_pred)); + + p->edge = eid; + p->parent_vertex = u; + vse->preds = lappend(vse->preds, p); + } + } + } + } + + *out_target_depth = target_depth; + *out_found = found; + return visited; +} + +/* + * Maximum number of result paths age_all_shortest_paths will materialize + * before raising an error. The shortest-path DAG can contain exponentially + * many equal-length paths (grid-like or multi-edge graphs), and they are all + * built up front in the SRF's memory context, so this is a backstop against + * unbounded memory growth. CHECK_FOR_INTERRUPTS() in sp_enumerate still allows + * cancellation, but a fast explosion can outrun a statement_timeout. + */ +#define SP_MAX_RESULT_PATHS 1000000 + +/* + * Recursively enumerate every shortest path by walking the predecessor DAG + * from target back to source. Each completed path is appended to *out as a + * freshly allocated interleaved graphid array of length alt_len. The running + * total is capped at SP_MAX_RESULT_PATHS to bound peak memory. + */ +static void sp_enumerate(HTAB *visited, graphid source, graphid cur, + graphid *alt, int64 alt_len, int64 pos, + char *fname, List **out) +{ + sp_visit_entry *e = NULL; + ListCell *lc = NULL; + + /* + * Enumerating every shortest path can be combinatorially expensive, so + * allow the user to cancel (Ctrl-C / statement_timeout) at each step. + */ + CHECK_FOR_INTERRUPTS(); + + alt[pos] = cur; + + if (cur == source) + { + /* a complete path only when we have consumed the whole array */ + if (pos == 0) + { + graphid *copy = palloc(sizeof(graphid) * alt_len); + + memcpy(copy, alt, sizeof(graphid) * alt_len); + *out = lappend(*out, copy); + + /* + * Bound the number of materialized paths. Without a ceiling, a + * combinatorial shortest-path DAG could exhaust memory before the + * first row is returned. + */ + if (list_length(*out) > SP_MAX_RESULT_PATHS) + { + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("%s: shortest path count exceeded %d", + fname, SP_MAX_RESULT_PATHS), + errhint("Narrow the search with a relationship type or a maximum hop count, or use age_shortest_path for a single path."))); + } + } + return; + } + + e = (sp_visit_entry *) hash_search(visited, &cur, HASH_FIND, NULL); + if (e == NULL) + { + return; + } + + foreach(lc, e->preds) + { + sp_pred *p = (sp_pred *) lfirst(lc); + + alt[pos - 1] = p->edge; + sp_enumerate(visited, source, p->parent_vertex, alt, alt_len, pos - 2, + fname, out); + } +} + +/* + * Maximum number of distinct paths the minimum-hop fallback will enumerate + * before giving up. The exhaustive DFS used for a minimum hop count greater + * than the shortest distance can explode on dense graphs, so this acts as a + * safety valve alongside CHECK_FOR_INTERRUPTS()/statement_timeout in the DFS. + */ +#define SP_MINHOPS_MAX_PATHS 1000000 + +/* + * Fallback for the "minimum hop count greater than the shortest distance" + * regime, which plain BFS cannot satisfy (it requires longer, vertex-revisiting + * paths under relationship-uniqueness). This reuses the VLE depth-first engine + * directly: it builds a VLE_local_context by hand (no fcinfo), enumerates every + * path whose length is within [min_hops, max_hops], and keeps only those of the + * smallest qualifying length. For shortest_path one such path is returned; for + * all_shortest_paths every tie at that length is returned. Returns NULL with + * *out_count == 0 when no qualifying path exists. + * + * The VLE engine matches a single edge label oid only, so a multi-type filter + * is rejected by the caller before reaching here. A single label_oid of + * InvalidOid means "any edge label". + */ +static Datum *sp_minhops_fallback(GRAPH_global_context *ggctx, Oid graph_oid, + const char *graph_name, char *fname, + graphid source, graphid target, Oid label_oid, + cypher_rel_dir dir, int64 min_hops, + int64 max_hops, bool collect_all, + int64 *out_count) +{ + MemoryContext oldctx = CurrentMemoryContext; + MemoryContext tmpctx = NULL; + VLE_local_context *vlelctx = NULL; + agtype_value av_empty; + agtype *empty_constraint = NULL; + List *best = NIL; + ListCell *lc = NULL; + int64 best_len = PG_INT64_MAX; + int64 examined = 0; + int64 result_len = 0; + int64 n = 0; + int64 idx = 0; + Datum *paths = NULL; + + *out_count = 0; + + /* do the VLE enumeration in a private context we can throw away at the end */ + tmpctx = AllocSetContextCreate(oldctx, "age shortest path minhops", + ALLOCSET_DEFAULT_SIZES); + MemoryContextSwitchTo(tmpctx); + + /* an empty property constraint object: every edge satisfies it */ + av_empty.type = AGTV_OBJECT; + av_empty.val.object.num_pairs = 0; + av_empty.val.object.pairs = NULL; + empty_constraint = agtype_value_to_agtype(&av_empty); + + /* build the VLE local context by hand (no fcinfo, no caching) */ + vlelctx = palloc0(sizeof(VLE_local_context)); + vlelctx->graph_name = pnstrdup(graph_name, strlen(graph_name)); + vlelctx->graph_oid = graph_oid; + vlelctx->ggctx = ggctx; + vlelctx->path_function = VLE_FUNCTION_PATHS_BETWEEN; + vlelctx->next_vertex = NULL; + vlelctx->vsid = source; + vlelctx->veid = target; + vlelctx->edge_property_constraint = empty_constraint; + vlelctx->edge_property_constraint_datum = + AGTYPE_P_GET_DATUM(empty_constraint); + vlelctx->edge_property_constraint_hash = + datum_image_hash(vlelctx->edge_property_constraint_datum, false, -1); + vlelctx->edge_label_name = NULL; + vlelctx->edge_label_name_oid = label_oid; + vlelctx->lidx = (min_hops > 0) ? min_hops : 1; + if (max_hops < 0) + { + vlelctx->uidx_infinite = true; + vlelctx->uidx = 0; + } + else + { + vlelctx->uidx_infinite = false; + vlelctx->uidx = max_hops; + } + vlelctx->edge_direction = dir; + vlelctx->use_cache = false; + vlelctx->vle_grammar_node_id = 0; + vlelctx->next = NULL; + vlelctx->is_dirty = true; + + create_VLE_local_state_hashtable(vlelctx); + vlelctx->dfs_vertex_stack = new_gid_stack(); + vlelctx->dfs_edge_stack = new_gid_stack(); + vlelctx->dfs_path_stack = new_gid_stack(); + load_initial_dfs_stacks(vlelctx); + + /* + * Enumerate qualifying paths, keeping only those of the smallest length + * seen. The DFS yields paths in no particular length order, so a strictly + * shorter path resets the kept set. + */ + while (dfs_find_a_path_between(vlelctx)) + { + int64 hops = gid_stack_size(vlelctx->dfs_path_stack); + bool take = false; + bool reset = false; + + examined = examined + 1; + if (examined > SP_MINHOPS_MAX_PATHS) + { + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("%s: minimum hop count search exceeded %d candidate paths", + fname, SP_MINHOPS_MAX_PATHS), + errhint("Provide a maximum hop count to bound the search."))); + } + + if (hops < best_len) + { + take = true; + reset = true; + } + else if (hops == best_len && collect_all) + { + take = true; + } + + if (take) + { + VLE_path_container *vpc = NULL; + graphid *garr = NULL; + int64 arrlen = 0; + + vpc = build_VLE_path_container(vlelctx); + garr = GET_GRAPHID_ARRAY_FROM_CONTAINER(vpc); + arrlen = vpc->graphid_array_size; + + /* copy the path into the surviving context and record it */ + MemoryContextSwitchTo(oldctx); + if (reset) + { + list_free_deep(best); + best = NIL; + best_len = hops; + } + { + graphid *copy = palloc(sizeof(graphid) * arrlen); + + memcpy(copy, garr, sizeof(graphid) * arrlen); + best = lappend(best, copy); + } + MemoryContextSwitchTo(tmpctx); + + pfree(vpc); + } + } + + /* tear down the VLE engine state, then drop the whole scratch context */ + free_VLE_local_context(vlelctx); + MemoryContextSwitchTo(oldctx); + MemoryContextDelete(tmpctx); + + n = list_length(best); + if (n == 0) + { + return NULL; + } + + /* every kept path has the same (minimum qualifying) length */ + result_len = (2 * best_len) + 1; + paths = palloc(sizeof(Datum) * n); + foreach(lc, best) + { + graphid *a = (graphid *) lfirst(lc); + + paths[idx] = sp_build_path_datum(graph_oid, a, result_len); + idx = idx + 1; + } + + list_free_deep(best); + *out_count = n; + return paths; +} + +/* + * Resolve arguments, run the BFS, and materialize the result path(s) as an + * array of AGTV_PATH agtype Datums. Returns NULL with *out_count == 0 when no + * path exists. Caller must run in a context that survives the SRF. + */ +static Datum *sp_compute_paths(agtype *graph_name_agt, agtype *start_agt, + agtype *end_agt, agtype *label_agt, + agtype *dir_agt, agtype *minhops_agt, + agtype *maxhops_agt, char *fname, + bool collect_all, int64 *out_count) +{ + agtype_value *agtv_temp = NULL; + char *graph_name = NULL; + Oid graph_oid = InvalidOid; + GRAPH_global_context *ggctx = NULL; + graphid source = 0; + graphid target = 0; + Oid *label_oids = NULL; + int n_label_oids = 0; + cypher_rel_dir dir = CYPHER_REL_DIR_NONE; + int64 min_hops = 0; + int64 max_hops = -1; + HTAB *visited = NULL; + int64 target_depth = -1; + bool found = false; + Datum *paths = NULL; + MemoryContext oldctx = CurrentMemoryContext; + MemoryContext scratch = NULL; + + *out_count = 0; + + /* the graph name is required */ + if (graph_name_agt == NULL) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("%s: graph name cannot be NULL", fname))); + } + + agtv_temp = get_agtype_value(fname, graph_name_agt, + AGTV_STRING, true); + graph_name = pnstrdup(agtv_temp->val.string.val, + agtv_temp->val.string.len); + graph_oid = get_graph_oid(graph_name); + + /* + * A NULL start or end vertex yields no rows, matching Cypher semantics + * where a null endpoint simply produces no match (it is not an error). + */ + if (start_agt == NULL || end_agt == NULL) + { + pfree_if_not_null(graph_name); + return NULL; + } + + source = sp_agtype_to_graphid(start_agt, fname, "start vertex"); + target = sp_agtype_to_graphid(end_agt, fname, "end vertex"); + + /* + * Optional edge type filter. A relationship type may be supplied as a + * bare string, or one or more types may be supplied as an array of + * strings. Each (non-empty) type name is resolved to its edge label table + * oid; an edge is kept when its label oid is one of the requested set. An + * empty string, an empty array, or NULL means no filter (every edge is + * traversed). An unknown type resolves to InvalidOid and so matches no + * edges. + */ + if (label_agt != NULL) + { + char *label_name = NULL; + + if (AGT_ROOT_IS_ARRAY(label_agt) && !AGT_ROOT_IS_SCALAR(label_agt)) + { + int nelems = AGT_ROOT_COUNT(label_agt); + int i = 0; + + if (nelems > 0) + { + label_oids = palloc(sizeof(Oid) * nelems); + } + + for (i = 0; i < nelems; i++) + { + agtv_temp = get_ith_agtype_value_from_container( + &label_agt->root, i); + if (agtv_temp->type != AGTV_STRING) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("%s: relationship type must be a string", + fname))); + } + /* skip empty type names; they impose no constraint */ + if (agtv_temp->val.string.len != 0) + { + label_name = pnstrdup(agtv_temp->val.string.val, + agtv_temp->val.string.len); + label_oids[n_label_oids] = + get_label_relation(label_name, graph_oid); + n_label_oids = n_label_oids + 1; + + /* the resolved oid is all we keep; free the type name */ + pfree(label_name); + label_name = NULL; + } + } + } + else + { + agtv_temp = get_agtype_value(fname, label_agt, + AGTV_STRING, true); + if (agtv_temp->val.string.len != 0) + { + label_name = pnstrdup(agtv_temp->val.string.val, + agtv_temp->val.string.len); + label_oids = palloc(sizeof(Oid)); + label_oids[0] = get_label_relation(label_name, graph_oid); + n_label_oids = 1; + + /* the resolved oid is all we keep; free the type name */ + pfree(label_name); + label_name = NULL; + } + } + } + + /* optional direction (defaults to undirected) */ + dir = sp_agtype_to_direction(dir_agt, fname); + + /* + * Optional minimum hop count (NULL or negative means none). A minimum + * that does not exceed the true shortest distance imposes no additional + * constraint, so it is handled directly by the BFS result below. A + * minimum greater than the shortest distance requires enumerating longer, + * vertex-revisiting paths, which plain BFS cannot do; that case falls + * back to the VLE depth-first engine after the search (see below). + */ + if (minhops_agt != NULL) + { + agtv_temp = get_agtype_value(fname, minhops_agt, + AGTV_INTEGER, true); + min_hops = agtv_temp->val.int_value; + if (min_hops < 0) + { + min_hops = 0; + } + } + + /* optional upper hop bound (NULL or negative means unbounded) */ + if (maxhops_agt != NULL) + { + agtv_temp = get_agtype_value(fname, maxhops_agt, + AGTV_INTEGER, true); + max_hops = agtv_temp->val.int_value; + if (max_hops < 0) + { + max_hops = -1; + } + } + + /* build / fetch the global graph cache for this graph */ + ggctx = manage_GRAPH_global_contexts(graph_name, graph_oid); + if (ggctx == NULL) + { + pfree_if_not_null(graph_name); + pfree_if_not_null(label_oids); + return NULL; + } + + /* + * Run the search and reconstruct the result path(s) in a private scratch + * context. The BFS bookkeeping (visited table, frontier queue, predecessor + * multiset) and the intermediate path arrays are only needed while we + * compute; the surviving result Datums are built in the caller's + * (SRF-lifetime) context and copied out before the scratch context is + * deleted. This bounds peak memory to the result set plus one search, + * rather than retaining the whole search state for the life of the SRF. + */ + scratch = AllocSetContextCreate(oldctx, "age shortest path scratch", + ALLOCSET_DEFAULT_SIZES); + MemoryContextSwitchTo(scratch); + + /* run the breadth-first search */ + visited = sp_run_bfs(ggctx, source, target, label_oids, n_label_oids, + dir, max_hops, collect_all, &target_depth, &found); + + if (!found) + { + MemoryContextSwitchTo(oldctx); + MemoryContextDelete(scratch); + pfree_if_not_null(graph_name); + pfree_if_not_null(label_oids); + return NULL; + } + + /* + * A minimum hop count greater than the true shortest distance can only be + * satisfied by longer, vertex-revisiting paths (Neo4j's exhaustive search + * regime). Plain BFS cannot produce those, so fall back to the VLE + * depth-first engine for that case. When min_hops <= target_depth the + * bound imposes no additional constraint and the shortest path(s) already + * found are returned unchanged. + * + * The VLE engine matches a single edge label only, so a multi-type filter + * combined with this regime is still unsupported. + */ + if (min_hops > 0 && target_depth < min_hops) + { + Oid fallback_label_oid = InvalidOid; + + /* the BFS scratch is no longer needed; the fallback uses its own */ + MemoryContextSwitchTo(oldctx); + MemoryContextDelete(scratch); + + if (n_label_oids > 1) + { + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("%s: a minimum hop count greater than the shortest path length is not supported with multiple relationship types", + fname))); + } + + if (n_label_oids == 1) + { + fallback_label_oid = label_oids[0]; + } + + /* + * The fallback duplicates graph_name internally and only needs the + * resolved label oid, so the temporaries are freed here once its + * result is captured rather than retained for the SRF's lifetime. + */ + { + Datum *fb_paths; + + fb_paths = sp_minhops_fallback(ggctx, graph_oid, graph_name, fname, + source, target, fallback_label_oid, + dir, min_hops, max_hops, collect_all, + out_count); + pfree_if_not_null(graph_name); + pfree_if_not_null(label_oids); + return fb_paths; + } + } + + if (!collect_all) + { + /* reconstruct the single shortest path from the parent pointers */ + int64 alt_len = (2 * target_depth) + 1; + graphid *alt = palloc(sizeof(graphid) * alt_len); + int64 pos = alt_len - 1; + graphid cur = target; + + alt[pos] = cur; + pos = pos - 1; + while (cur != source) + { + sp_visit_entry *e = NULL; + + e = (sp_visit_entry *) hash_search(visited, &cur, HASH_FIND, NULL); + alt[pos] = e->parent_edge; + pos = pos - 1; + alt[pos] = e->parent_vertex; + pos = pos - 1; + cur = e->parent_vertex; + } + + /* build the surviving result Datum in the caller's context */ + MemoryContextSwitchTo(oldctx); + paths = palloc(sizeof(Datum)); + paths[0] = sp_build_path_datum(graph_oid, alt, alt_len); + *out_count = 1; + } + else + { + /* enumerate every equal-length shortest path */ + int64 alt_len = (2 * target_depth) + 1; + graphid *alt = palloc(sizeof(graphid) * alt_len); + List *arrays = NIL; + ListCell *lc = NULL; + int64 n = 0; + int64 idx = 0; + + sp_enumerate(visited, source, target, alt, alt_len, alt_len - 1, + fname, &arrays); + + n = list_length(arrays); + + /* build the surviving result Datums in the caller's context */ + MemoryContextSwitchTo(oldctx); + paths = palloc(sizeof(Datum) * (n > 0 ? n : 1)); + foreach(lc, arrays) + { + graphid *a = (graphid *) lfirst(lc); + + paths[idx] = sp_build_path_datum(graph_oid, a, alt_len); + idx = idx + 1; + } + *out_count = n; + } + + /* results are copied out; drop the BFS/enumeration scratch */ + MemoryContextSwitchTo(oldctx); + MemoryContextDelete(scratch); + pfree_if_not_null(graph_name); + pfree_if_not_null(label_oids); + return paths; +} + +/* + * Shared SRF driver for age_shortest_path / age_all_shortest_paths. The first + * call computes every result path up front and stores them; subsequent calls + * stream them one per row. + */ +static Datum sp_srf_impl(FunctionCallInfo fcinfo, bool collect_all) +{ + FuncCallContext *funcctx = NULL; + sp_srf_state *state = NULL; + + if (SRF_IS_FIRSTCALL()) + { + MemoryContext oldctx; + agtype *a_graph = NULL; + agtype *a_start = NULL; + agtype *a_end = NULL; + agtype *a_label = NULL; + agtype *a_dir = NULL; + agtype *a_min = NULL; + agtype *a_max = NULL; + + funcctx = SRF_FIRSTCALL_INIT(); + oldctx = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx); + + /* + * Argument order mirrors the Cypher shortestPath() pattern + * (a)-[:type*min_hops..max_hops]->(b): + * 0 graph, 1 start, 2 end, 3 edge_types, 4 direction, + * 5 min_hops, 6 max_hops + */ + a_graph = PG_ARGISNULL(0) ? NULL : AG_GET_ARG_AGTYPE_P(0); + a_start = PG_ARGISNULL(1) ? NULL : AG_GET_ARG_AGTYPE_P(1); + a_end = PG_ARGISNULL(2) ? NULL : AG_GET_ARG_AGTYPE_P(2); + a_label = PG_ARGISNULL(3) ? NULL : AG_GET_ARG_AGTYPE_P(3); + a_dir = PG_ARGISNULL(4) ? NULL : AG_GET_ARG_AGTYPE_P(4); + a_min = PG_ARGISNULL(5) ? NULL : AG_GET_ARG_AGTYPE_P(5); + a_max = PG_ARGISNULL(6) ? NULL : AG_GET_ARG_AGTYPE_P(6); + + /* treat an explicit agtype null the same as a SQL NULL */ + if (a_start != NULL && is_agtype_null(a_start)) + { + a_start = NULL; + } + if (a_end != NULL && is_agtype_null(a_end)) + { + a_end = NULL; + } + if (a_label != NULL && is_agtype_null(a_label)) + { + a_label = NULL; + } + if (a_dir != NULL && is_agtype_null(a_dir)) + { + a_dir = NULL; + } + if (a_min != NULL && is_agtype_null(a_min)) + { + a_min = NULL; + } + if (a_max != NULL && is_agtype_null(a_max)) + { + a_max = NULL; + } + + state = palloc0(sizeof(sp_srf_state)); + state->next = 0; + state->paths = sp_compute_paths(a_graph, a_start, a_end, a_label, + a_dir, a_min, a_max, + collect_all ? "age_all_shortest_paths" + : "age_shortest_path", + collect_all, &state->npaths); + funcctx->user_fctx = state; + + MemoryContextSwitchTo(oldctx); + } + + funcctx = SRF_PERCALL_SETUP(); + state = (sp_srf_state *) funcctx->user_fctx; + + if (state->next < state->npaths) + { + Datum d = state->paths[state->next]; + + state->next = state->next + 1; + SRF_RETURN_NEXT(funcctx, d); + } + + SRF_RETURN_DONE(funcctx); +} + +/* + * age_shortest_path(graph_name, start, end [, edge_types [, direction + * [, min_hops [, max_hops]]]]) -> SETOF agtype + * + * Returns the single unweighted shortest path (as an AGTV_PATH) between the + * start and end vertices, or no rows if unreachable. + */ +PG_FUNCTION_INFO_V1(age_shortest_path); + +Datum age_shortest_path(PG_FUNCTION_ARGS) +{ + return sp_srf_impl(fcinfo, false); +} + +/* + * age_all_shortest_paths(graph_name, start, end [, edge_types [, direction + * [, min_hops [, max_hops]]]]) -> SETOF agtype + * + * Returns every unweighted shortest path (one AGTV_PATH per row) between the + * start and end vertices, i.e. all paths whose length equals the minimum hop + * count, or no rows if unreachable. + */ +PG_FUNCTION_INFO_V1(age_all_shortest_paths); + +Datum age_all_shortest_paths(PG_FUNCTION_ARGS) +{ + return sp_srf_impl(fcinfo, true); +} diff --git a/src/backend/utils/adt/agtype.c b/src/backend/utils/adt/agtype.c index c552727d8..cc9cc7717 100644 --- a/src/backend/utils/adt/agtype.c +++ b/src/backend/utils/adt/agtype.c @@ -37,7 +37,9 @@ #include "access/genam.h" #include "access/heapam.h" +#include "access/htup_details.h" #include "catalog/namespace.h" +#include "catalog/pg_am_d.h" #include "catalog/pg_collation_d.h" #include "catalog/pg_operator_d.h" #include "funcapi.h" @@ -45,8 +47,11 @@ #include "miscadmin.h" #include "parser/parse_coerce.h" #include "nodes/nodes.h" +#include "nodes/nodeFuncs.h" +#include "executor/executor.h" #include "utils/acl.h" #include "utils/builtins.h" +#include "utils/datum.h" #include "executor/cypher_utils.h" #include "utils/float.h" #include "utils/lsyscache.h" @@ -58,6 +63,7 @@ #include "utils/agtype_raw.h" #include "catalog/ag_graph.h" #include "catalog/ag_label.h" +#include "utils/ag_func.h" /* State structure for Percentile aggregate functions */ typedef struct PercentileGroupAggState @@ -160,7 +166,6 @@ static bool is_array_path(agtype_value *agtv); /* graph entity retrieval */ static Datum get_vertex(const char *graph, const char *vertex_label, int64 graphid); -static char *get_label_name(const char *graph_name, graphid element_graphid); static float8 get_float_compatible_arg(Datum arg, Oid type, char *funcname, bool *is_null); static Numeric get_numeric_compatible_arg(Datum arg, Oid type, char *funcname, @@ -245,6 +250,43 @@ void clear_global_Oids_AGTYPE(void) g_AGTYPEARRAYOID = InvalidOid; } +/* global storage of OID for vertex and edge composite types */ +static Oid g_VERTEXOID = InvalidOid; +static Oid g_EDGEOID = InvalidOid; + +/* helper function to quickly set, if necessary, and retrieve VERTEXOID */ +Oid get_VERTEXOID(void) +{ + if (g_VERTEXOID == InvalidOid) + { + g_VERTEXOID = GetSysCacheOid2(TYPENAMENSP, Anum_pg_type_oid, + CStringGetDatum("vertex"), + ObjectIdGetDatum(ag_catalog_namespace_id())); + } + + return g_VERTEXOID; +} + +/* helper function to quickly set, if necessary, and retrieve EDGEOID */ +Oid get_EDGEOID(void) +{ + if (g_EDGEOID == InvalidOid) + { + g_EDGEOID = GetSysCacheOid2(TYPENAMENSP, Anum_pg_type_oid, + CStringGetDatum("edge"), + ObjectIdGetDatum(ag_catalog_namespace_id())); + } + + return g_EDGEOID; +} + +/* helper function to clear the VERTEX/EDGE OIDs after a drop extension */ +void clear_global_Oids_VERTEX_EDGE(void) +{ + g_VERTEXOID = InvalidOid; + g_EDGEOID = InvalidOid; +} + /* fast helper function to test for AGTV_NULL in an agtype */ bool is_agtype_null(agtype *agt_arg) { @@ -1440,17 +1482,22 @@ static void agtype_categorize_type(Oid typoid, agt_type_category *tcategory, break; default: - /* Check for arrays and composites */ if (typoid == AGTYPEOID) { *tcategory = AGT_TYPE_AGTYPE; } + else if (typoid == VERTEXOID || typoid == EDGEOID) + { + *tcategory = AGT_TYPE_AGTYPE; + *outfuncoid = (typoid == VERTEXOID) ? + get_ag_func_oid("vertex_to_agtype", 1, VERTEXOID) : + get_ag_func_oid("edge_to_agtype", 1, EDGEOID); + } else if (OidIsValid(get_element_type(typoid)) || typoid == ANYARRAYOID || typoid == RECORDARRAYOID) { *tcategory = AGT_TYPE_ARRAY; } - /* includes RECORDOID */ else if (type_is_rowtype(typoid)) { *tcategory = AGT_TYPE_COMPOSITE; @@ -1681,14 +1728,17 @@ static void datum_to_agtype(Datum val, bool is_null, agtype_in_state *result, case AGT_TYPE_AGTYPE: case AGT_TYPE_JSONB: { - agtype *jsonb = DATUM_GET_AGTYPE_P(val); + agtype *jsonb; agtype_iterator *it; + if (OidIsValid(outfuncoid)) + val = OidFunctionCall1(outfuncoid, val); + /* * val is actually jsonb datum but we can handle it as an agtype * datum because agtype is currently an extension of jsonb. */ - + jsonb = DATUM_GET_AGTYPE_P(val); it = agtype_iterator_init(&jsonb->root); if (AGT_ROOT_IS_SCALAR(jsonb)) @@ -2285,12 +2335,14 @@ Datum make_path(List *path) PG_FUNCTION_INFO_V1(_agtype_build_vertex); /* - * SQL function agtype_build_vertex(graphid, cstring, agtype) + * SQL function agtype_build_vertex(graphid, agtype, agtype) */ Datum _agtype_build_vertex(PG_FUNCTION_ARGS) { graphid id; char *label; + agtype *label_agtype; + agtype_value *label_value; agtype *properties; agtype_build_state *bstate; agtype *rawscalar; @@ -2311,7 +2363,25 @@ Datum _agtype_build_vertex(PG_FUNCTION_ARGS) } id = AG_GETARG_GRAPHID(0); - label = PG_GETARG_CSTRING(1); + label_agtype = AG_GET_ARG_AGTYPE_P(1); + + /* Extract the string from the agtype label */ + if (!AGT_ROOT_IS_SCALAR(label_agtype)) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("_agtype_build_vertex() label must be a scalar string"))); + } + + label_value = get_ith_agtype_value_from_container(&label_agtype->root, 0); + if (label_value->type != AGTV_STRING) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("_agtype_build_vertex() label must be a string"))); + } + + label = pnstrdup(label_value->val.string.val, label_value->val.string.len); if (fcinfo->args[2].isnull) { @@ -2346,7 +2416,8 @@ Datum _agtype_build_vertex(PG_FUNCTION_ARGS) rawscalar = build_agtype(bstate); pfree_agtype_build_state(bstate); - PG_FREE_IF_COPY(label, 1); + pfree(label); + PG_FREE_IF_COPY(label_agtype, 1); PG_FREE_IF_COPY(properties, 2); PG_RETURN_POINTER(rawscalar); @@ -2360,7 +2431,7 @@ Datum make_vertex(Datum id, Datum label, Datum properties) PG_FUNCTION_INFO_V1(_agtype_build_edge); /* - * SQL function agtype_build_edge(graphid, graphid, graphid, cstring, agtype) + * SQL function agtype_build_edge(graphid, graphid, graphid, agtype, agtype) */ Datum _agtype_build_edge(PG_FUNCTION_ARGS) { @@ -2368,6 +2439,8 @@ Datum _agtype_build_edge(PG_FUNCTION_ARGS) agtype *edge, *rawscalar; graphid id, start_id, end_id; char *label; + agtype *label_agtype; + agtype_value *label_value; agtype *properties; /* process graph id */ @@ -2384,10 +2457,28 @@ Datum _agtype_build_edge(PG_FUNCTION_ARGS) if (fcinfo->args[3].isnull) { ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("_agtype_build_vertex() label cannot be NULL"))); + errmsg("_agtype_build_edge() label cannot be NULL"))); + } + + label_agtype = AG_GET_ARG_AGTYPE_P(3); + + /* Extract the string from the agtype label */ + if (!AGT_ROOT_IS_SCALAR(label_agtype)) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("_agtype_build_edge() label must be a scalar string"))); + } + + label_value = get_ith_agtype_value_from_container(&label_agtype->root, 0); + if (label_value->type != AGTV_STRING) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("_agtype_build_edge() label must be a string"))); } - label = PG_GETARG_CSTRING(3); + label = pnstrdup(label_value->val.string.val, label_value->val.string.len); /* process end_id */ if (fcinfo->args[2].isnull) @@ -2449,7 +2540,8 @@ Datum _agtype_build_edge(PG_FUNCTION_ARGS) rawscalar = build_agtype(bstate); pfree_agtype_build_state(bstate); - PG_FREE_IF_COPY(label, 3); + pfree(label); + PG_FREE_IF_COPY(label_agtype, 3); PG_FREE_IF_COPY(properties, 4); PG_RETURN_POINTER(rawscalar); @@ -2462,6 +2554,308 @@ Datum make_edge(Datum id, Datum startid, Datum endid, Datum label, properties); } +PG_FUNCTION_INFO_V1(vertex_to_agtype); + +/* + * Cast function: vertex -> agtype + * Vertex: (id graphid, label agtype, properties agtype) + */ +Datum vertex_to_agtype(PG_FUNCTION_ARGS) +{ + HeapTupleHeader rec; + TupleDesc tupdesc; + HeapTupleData tuple; + Datum *values; + bool *nulls; + graphid id; + agtype *label; + agtype *properties; + Datum result; + + rec = PG_GETARG_HEAPTUPLEHEADER(0); + + tupdesc = lookup_rowtype_tupdesc(HeapTupleHeaderGetTypeId(rec), + HeapTupleHeaderGetTypMod(rec)); + + tuple.t_len = HeapTupleHeaderGetDatumLength(rec); + tuple.t_data = rec; + + values = (Datum *) palloc(3 * sizeof(Datum)); + nulls = (bool *) palloc(3 * sizeof(bool)); + heap_deform_tuple(&tuple, tupdesc, values, nulls); + + if (nulls[0]) + ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("vertex id cannot be NULL"))); + if (nulls[1]) + ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("vertex label cannot be NULL"))); + + id = DatumGetInt64(values[0]); + label = DATUM_GET_AGTYPE_P(values[1]); + properties = nulls[2] ? NULL : DATUM_GET_AGTYPE_P(values[2]); + + if (properties == NULL) + { + agtype_build_state *bstate = init_agtype_build_state(0, AGT_FOBJECT); + properties = build_agtype(bstate); + pfree_agtype_build_state(bstate); + } + + result = make_vertex(Int64GetDatum(id), PointerGetDatum(label), + PointerGetDatum(properties)); + + ReleaseTupleDesc(tupdesc); + pfree(values); + pfree(nulls); + + return result; +} + +PG_FUNCTION_INFO_V1(edge_to_agtype); + +/* + * Cast function: edge -> agtype + * Edge: (id graphid, label agtype, end_id graphid, start_id graphid, properties agtype) + */ +Datum edge_to_agtype(PG_FUNCTION_ARGS) +{ + HeapTupleHeader rec; + TupleDesc tupdesc; + HeapTupleData tuple; + Datum *values; + bool *nulls; + graphid id, start_id, end_id; + agtype *label; + agtype *properties; + Datum result; + + rec = PG_GETARG_HEAPTUPLEHEADER(0); + + tupdesc = lookup_rowtype_tupdesc(HeapTupleHeaderGetTypeId(rec), + HeapTupleHeaderGetTypMod(rec)); + + tuple.t_len = HeapTupleHeaderGetDatumLength(rec); + tuple.t_data = rec; + + values = (Datum *) palloc(5 * sizeof(Datum)); + nulls = (bool *) palloc(5 * sizeof(bool)); + heap_deform_tuple(&tuple, tupdesc, values, nulls); + + if (nulls[0]) + ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("edge id cannot be NULL"))); + if (nulls[1]) + ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("edge label cannot be NULL"))); + if (nulls[2]) + ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("edge end_id cannot be NULL"))); + if (nulls[3]) + ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("edge start_id cannot be NULL"))); + + id = DatumGetInt64(values[0]); + label = DATUM_GET_AGTYPE_P(values[1]); + end_id = DatumGetInt64(values[2]); + start_id = DatumGetInt64(values[3]); + properties = nulls[4] ? NULL : DATUM_GET_AGTYPE_P(values[4]); + + if (properties == NULL) + { + agtype_build_state *bstate = init_agtype_build_state(0, AGT_FOBJECT); + properties = build_agtype(bstate); + pfree_agtype_build_state(bstate); + } + + result = make_edge(Int64GetDatum(id), Int64GetDatum(start_id), + Int64GetDatum(end_id), PointerGetDatum(label), + PointerGetDatum(properties)); + + ReleaseTupleDesc(tupdesc); + pfree(values); + pfree(nulls); + + return result; +} + +/* + * Helper function to build JSON string from vertex composite type + */ +static char *vertex_to_json_string(HeapTupleHeader rec) +{ + TupleDesc tupdesc; + HeapTupleData tuple; + Datum *values; + bool *nulls; + graphid id; + agtype *label; + agtype *properties; + StringInfoData buf; + char *label_str; + char *props_str; + + tupdesc = lookup_rowtype_tupdesc(HeapTupleHeaderGetTypeId(rec), + HeapTupleHeaderGetTypMod(rec)); + + tuple.t_len = HeapTupleHeaderGetDatumLength(rec); + tuple.t_data = rec; + + values = (Datum *) palloc(3 * sizeof(Datum)); + nulls = (bool *) palloc(3 * sizeof(bool)); + heap_deform_tuple(&tuple, tupdesc, values, nulls); + + if (nulls[0]) + ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("vertex id cannot be NULL"))); + if (nulls[1]) + ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("vertex label cannot be NULL"))); + + id = DatumGetInt64(values[0]); + label = DATUM_GET_AGTYPE_P(values[1]); + properties = nulls[2] ? NULL : DATUM_GET_AGTYPE_P(values[2]); + + label_str = agtype_to_cstring(NULL, &label->root, VARSIZE(label)); + + if (properties != NULL) + { + props_str = agtype_to_cstring_worker(NULL, &properties->root, + VARSIZE(properties), false, false); + } + else + { + props_str = "{}"; + } + + initStringInfo(&buf); + appendStringInfo(&buf, "{\"id\": " INT64_FORMAT ", \"label\": %s, \"properties\": %s}", + id, label_str, props_str); + + ReleaseTupleDesc(tupdesc); + pfree(values); + pfree(nulls); + + return buf.data; +} + +/* + * Helper function to build JSON string from edge composite type + * Edge: (id graphid, label agtype, end_id graphid, start_id graphid, properties agtype) + */ +static char *edge_to_json_string(HeapTupleHeader rec) +{ + TupleDesc tupdesc; + HeapTupleData tuple; + Datum *values; + bool *nulls; + graphid id, start_id, end_id; + agtype *label; + agtype *properties; + StringInfoData buf; + char *label_str; + char *props_str; + + tupdesc = lookup_rowtype_tupdesc(HeapTupleHeaderGetTypeId(rec), + HeapTupleHeaderGetTypMod(rec)); + + tuple.t_len = HeapTupleHeaderGetDatumLength(rec); + tuple.t_data = rec; + + values = (Datum *) palloc(5 * sizeof(Datum)); + nulls = (bool *) palloc(5 * sizeof(bool)); + heap_deform_tuple(&tuple, tupdesc, values, nulls); + + if (nulls[0]) + ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("edge id cannot be NULL"))); + if (nulls[1]) + ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("edge label cannot be NULL"))); + if (nulls[2]) + ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("edge end_id cannot be NULL"))); + if (nulls[3]) + ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), + errmsg("edge start_id cannot be NULL"))); + + id = DatumGetInt64(values[0]); + label = DATUM_GET_AGTYPE_P(values[1]); + end_id = DatumGetInt64(values[2]); + start_id = DatumGetInt64(values[3]); + properties = nulls[4] ? NULL : DATUM_GET_AGTYPE_P(values[4]); + + label_str = agtype_to_cstring(NULL, &label->root, VARSIZE(label)); + + if (properties != NULL) + { + props_str = agtype_to_cstring_worker(NULL, &properties->root, + VARSIZE(properties), false, false); + } + else + { + props_str = "{}"; + } + + initStringInfo(&buf); + appendStringInfo(&buf, "{\"id\": " INT64_FORMAT ", \"label\": %s, \"end_id\": " INT64_FORMAT ", \"start_id\": " INT64_FORMAT ", \"properties\": %s}", + id, label_str, end_id, start_id, props_str); + + ReleaseTupleDesc(tupdesc); + pfree(values); + pfree(nulls); + + return buf.data; +} + +PG_FUNCTION_INFO_V1(vertex_to_json); + +Datum vertex_to_json(PG_FUNCTION_ARGS) +{ + char *json_str = vertex_to_json_string(PG_GETARG_HEAPTUPLEHEADER(0)); + Datum result = DirectFunctionCall1(json_in, CStringGetDatum(json_str)); + + pfree_if_not_null(json_str); + + PG_RETURN_DATUM(result); +} + +PG_FUNCTION_INFO_V1(vertex_to_jsonb); + +Datum vertex_to_jsonb(PG_FUNCTION_ARGS) +{ + char *json_str = vertex_to_json_string(PG_GETARG_HEAPTUPLEHEADER(0)); + Datum result = DirectFunctionCall1(jsonb_in, CStringGetDatum(json_str)); + + pfree_if_not_null(json_str); + + PG_RETURN_DATUM(result); +} + +PG_FUNCTION_INFO_V1(edge_to_json); + +Datum edge_to_json(PG_FUNCTION_ARGS) +{ + char *json_str = edge_to_json_string(PG_GETARG_HEAPTUPLEHEADER(0)); + Datum result = DirectFunctionCall1(json_in, CStringGetDatum(json_str)); + + pfree_if_not_null(json_str); + + PG_RETURN_DATUM(result); +} + +PG_FUNCTION_INFO_V1(edge_to_jsonb); + +Datum edge_to_jsonb(PG_FUNCTION_ARGS) +{ + char *json_str = edge_to_json_string(PG_GETARG_HEAPTUPLEHEADER(0)); + Datum result = DirectFunctionCall1(jsonb_in, CStringGetDatum(json_str)); + + pfree_if_not_null(json_str); + + PG_RETURN_DATUM(result); +} + static agtype_value *agtype_build_map_as_agtype_value(FunctionCallInfo fcinfo) { int nargs; @@ -4101,6 +4495,115 @@ Datum agtype_access_operator(PG_FUNCTION_ARGS) agtype *result = NULL; int i = 0; + /* + * Fast path for the common 2-argument case (object.property or + * array[index]). Avoids extract_variadic_args overhead which + * includes exprType, get_call_expr_argtype, and memory allocation + * on every call. + */ + if (PG_NARGS() == 2) + { + agtype *key = NULL; + + /* check for NULLs */ + if (PG_ARGISNULL(0) || PG_ARGISNULL(1)) + { + PG_RETURN_NULL(); + } + + /* get the container argument */ + container = DATUM_GET_AGTYPE_P(PG_GETARG_DATUM(0)); + + /* handle binary container (VLE vpc) */ + if (AGT_ROOT_IS_BINARY(container)) + { + if (AGT_ROOT_BINARY_FLAGS(container) == AGT_FBINARY_TYPE_VLE_PATH) + { + container_value = agtv_materialize_vle_edges(container); + container = NULL; + } + else + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("binary container must be a VLE vpc"))); + } + } + /* handle scalar (vertex or edge) */ + else if (AGT_ROOT_IS_SCALAR(container)) + { + container_value = get_ith_agtype_value_from_container( + &container->root, 0); + if (container_value->type != AGTV_EDGE && + container_value->type != AGTV_VERTEX) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("scalar object must be a vertex or edge"))); + } + container = NULL; + } + + /* get the key */ + key = DATUM_GET_AGTYPE_P(PG_GETARG_DATUM(1)); + + if (!(AGT_ROOT_IS_SCALAR(key))) + { + ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("key must resolve to a scalar value"))); + } + + /* extract properties from vertex/edge */ + if (container_value != NULL && + (container_value->type == AGTV_EDGE || + container_value->type == AGTV_VERTEX)) + { + container_value = (container_value->type == AGTV_EDGE) + ? &container_value->val.object.pairs[4].value + : &container_value->val.object.pairs[2].value; + } + + /* map access */ + if ((container_value != NULL && + (container_value->type == AGTV_OBJECT || + (container_value->type == AGTV_BINARY && + AGTYPE_CONTAINER_IS_OBJECT(container_value->val.binary.data)))) || + (container != NULL && AGT_ROOT_IS_OBJECT(container))) + { + container_value = execute_map_access_operator(container, + container_value, key); + } + /* array access */ + else if ((container_value != NULL && + (container_value->type == AGTV_ARRAY || + (container_value->type == AGTV_BINARY && + AGTYPE_CONTAINER_IS_ARRAY(container_value->val.binary.data)))) || + (container != NULL && AGT_ROOT_IS_ARRAY(container))) + { + container_value = execute_array_access_operator(container, + container_value, + key); + } + else + { + ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("container must be an array or object"))); + } + + if (container_value == NULL || container_value->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + + result = agtype_value_to_agtype(container_value); + return AGTYPE_P_GET_DATUM(result); + } + + /* + * Standard variadic path for 3+ arguments (chained access like a.b.c) + * or edge cases. + */ + /* extract our args, we need at least 2 */ nargs = extract_variadic_args_min(fcinfo, 0, true, &args, &types, &nulls, 2); @@ -4323,11 +4826,14 @@ Datum agtype_access_slice(PG_FUNCTION_ARGS) { agt_lidx = AG_GET_ARG_AGTYPE_P(1); lidx_value = get_ith_agtype_value_from_container(&agt_lidx->root, 0); - /* adjust for AGTV_NULL */ + /* + * Under Cypher null-propagation semantics, list[a..b] is null when + * either bound is null. Return null directly instead of silently + * treating AGTV_NULL as an omitted bound. + */ if (lidx_value->type == AGTV_NULL) { - lower_index = 0; - lidx_value = NULL; + PG_RETURN_NULL(); } } @@ -4340,11 +4846,10 @@ Datum agtype_access_slice(PG_FUNCTION_ARGS) { agt_uidx = AG_GET_ARG_AGTYPE_P(2); uidx_value = get_ith_agtype_value_from_container(&agt_uidx->root, 0); - /* adjust for AGTV_NULL */ + /* Symmetric to the lower bound: null propagates to a null result. */ if (uidx_value->type == AGTV_NULL) { - upper_index = array_size; - uidx_value = NULL; + PG_RETURN_NULL(); } } @@ -4759,6 +5264,22 @@ Datum agtype_hash_cmp(PG_FUNCTION_ARGS) agtype_iterator_token tok; agtype_value *r; uint64 seed = 0xF0F0F0F0; + /* + * Stack of "is the current open array a raw_scalar pseudo-array?" so + * that the WAGT_END_ARRAY case can match the choice we made at + * WAGT_BEGIN_ARRAY. The iterator does not populate *val on END tokens + * (see comment above agtype_iterator_next), so r->val.array.raw_scalar + * is uninitialized at that point and reading it as a bool is undefined + * behavior (UBSan flagged values like 127 here). + * + * Arrays in agtype are bounded by AGTYPE_CONTAINER_SIZE which fits in + * AGT_CMASK; a fixed-size stack of 64 levels is far more than any real + * agtype graph value will ever produce. Bail out with an error if we + * exceed it rather than silently corrupting the hash. + */ +#define HASH_RAW_SCALAR_STACK_DEPTH 64 + bool raw_scalar_stack[HASH_RAW_SCALAR_STACK_DEPTH]; + int raw_scalar_top = -1; /* this function returns INTEGER which is 32 bits */ if (PG_ARGISNULL(0)) @@ -4775,18 +5296,51 @@ Datum agtype_hash_cmp(PG_FUNCTION_ARGS) { if (IS_A_AGTYPE_SCALAR(r) && AGTYPE_ITERATOR_TOKEN_IS_HASHABLE(tok)) agtype_hash_scalar_value_extended(r, &hash, seed); - else if (tok == WAGT_BEGIN_ARRAY && !r->val.array.raw_scalar) - seed = LEFT_ROTATE(seed, 4); + else if (tok == WAGT_BEGIN_ARRAY) + { + /* push raw_scalar state for the matching END token */ + raw_scalar_top++; + if (raw_scalar_top >= HASH_RAW_SCALAR_STACK_DEPTH) + { + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("agtype_hash_cmp: array nesting depth exceeds %d", + HASH_RAW_SCALAR_STACK_DEPTH))); + } + raw_scalar_stack[raw_scalar_top] = r->val.array.raw_scalar; + if (!r->val.array.raw_scalar) + { + seed = LEFT_ROTATE(seed, 4); + } + } else if (tok == WAGT_BEGIN_OBJECT) seed = LEFT_ROTATE(seed, 6); - else if (tok == WAGT_END_ARRAY && !r->val.array.raw_scalar) - seed = RIGHT_ROTATE(seed, 4); + else if (tok == WAGT_END_ARRAY) + { + bool was_raw_scalar; + + /* pop matching BEGIN's raw_scalar state */ + if (raw_scalar_top < 0) + { + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("agtype_hash_cmp: WAGT_END_ARRAY without matching WAGT_BEGIN_ARRAY"))); + } + was_raw_scalar = raw_scalar_stack[raw_scalar_top]; + raw_scalar_top--; + if (!was_raw_scalar) + { + seed = RIGHT_ROTATE(seed, 4); + } + } else if (tok == WAGT_END_OBJECT) seed = RIGHT_ROTATE(seed, 4); seed = LEFT_ROTATE(seed, 1); } +#undef HASH_RAW_SCALAR_STACK_DEPTH + pfree_if_not_null(r); PG_FREE_IF_COPY(agt, 0); @@ -5146,6 +5700,7 @@ Datum agtype_typecast_vertex(PG_FUNCTION_ARGS) agtype *arg_agt; agtype_value agtv_key; agtype_value *agtv_graphid, *agtv_label, *agtv_properties; + agtype *label_agtype; Datum result; int count; @@ -5204,9 +5759,10 @@ Datum agtype_typecast_vertex(PG_FUNCTION_ARGS) errmsg("vertex typecast object has invalid or missing properties"))); /* Hand it off to the build vertex routine */ + label_agtype = agtype_value_to_agtype(agtv_label); result = DirectFunctionCall3(_agtype_build_vertex, Int64GetDatum(agtv_graphid->val.int_value), - CStringGetDatum(agtv_label->val.string.val), + PointerGetDatum(label_agtype), PointerGetDatum(agtype_value_to_agtype(agtv_properties))); return result; } @@ -5221,6 +5777,7 @@ Datum agtype_typecast_edge(PG_FUNCTION_ARGS) agtype_value agtv_key; agtype_value *agtv_graphid, *agtv_label, *agtv_properties, *agtv_startid, *agtv_endid; + agtype *label_agtype; Datum result; int count; @@ -5297,11 +5854,12 @@ Datum agtype_typecast_edge(PG_FUNCTION_ARGS) errmsg("edge typecast object has an invalid or missing end_id"))); /* Hand it off to the build edge routine */ + label_agtype = agtype_value_to_agtype(agtv_label); result = DirectFunctionCall5(_agtype_build_edge, Int64GetDatum(agtv_graphid->val.int_value), Int64GetDatum(agtv_startid->val.int_value), Int64GetDatum(agtv_endid->val.int_value), - CStringGetDatum(agtv_label->val.string.val), + PointerGetDatum(label_agtype), PointerGetDatum(agtype_value_to_agtype(agtv_properties))); return result; } @@ -5559,84 +6117,31 @@ Datum column_get_datum(TupleDesc tupdesc, HeapTuple tuple, int column, return result; } -/* - * Function to retrieve a label name, given the graph name and graphid of the - * node or edge. The function returns a pointer to a duplicated string that - * needs to be freed when you are finished using it. - */ -static char *get_label_name(const char *graph_name, graphid element_graphid) -{ - ScanKeyData scan_keys[2]; - Relation ag_label; - SysScanDesc scan_desc; - HeapTuple tuple; - TupleDesc tupdesc; - char *result = NULL; - bool column_is_null = false; - Oid graph_oid = get_graph_oid(graph_name); - int32 label_id = get_graphid_label_id(element_graphid); - - /* scankey for first match in ag_label, column 2, graphoid, BTEQ, OidEQ */ - ScanKeyInit(&scan_keys[0], Anum_ag_label_graph, BTEqualStrategyNumber, - F_OIDEQ, ObjectIdGetDatum(graph_oid)); - /* scankey for second match in ag_label, column 3, label id, BTEQ, Int4EQ */ - ScanKeyInit(&scan_keys[1], Anum_ag_label_id, BTEqualStrategyNumber, - F_INT4EQ, Int32GetDatum(label_id)); - - ag_label = table_open(ag_label_relation_id(), ShareLock); - scan_desc = systable_beginscan(ag_label, ag_label_graph_oid_index_id(), true, - NULL, 2, scan_keys); - - tuple = systable_getnext(scan_desc); - if (!HeapTupleIsValid(tuple)) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_SCHEMA), - errmsg("graphid %lu does not exist", element_graphid))); - } - - /* get the tupdesc - we don't need to release this one */ - tupdesc = RelationGetDescr(ag_label); - - /* bail if the number of columns differs */ - if (tupdesc->natts != Natts_ag_label) - { - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_TABLE), - errmsg("Invalid number of attributes for ag_catalog.ag_label"))); - } - - /* get the label name */ - result = NameStr(*DatumGetName(heap_getattr(tuple, Anum_ag_label_name, - tupdesc, &column_is_null))); - /* duplicate it */ - result = pstrdup(result); - - /* end the scan and close the relation */ - systable_endscan(scan_desc); - table_close(ag_label, ShareLock); - - return result; -} - static Datum get_vertex(const char *graph, const char *vertex_label, int64 graphid) { ScanKeyData scan_keys[1]; Relation graph_vertex_label; - TableScanDesc scan_desc; - HeapTuple tuple; + TableScanDesc scan_desc = NULL; + HeapTuple tuple = NULL; TupleDesc tupdesc; Datum id, properties, result; AclResult aclresult; + TupleTableSlot *slot; + Oid index_oid; + bool should_free_tuple = false; + agtype *label_agtype; + Oid graph_namespace_oid; + Oid vertex_label_table_oid; + Snapshot snapshot; /* get the specific graph namespace (schema) */ - Oid graph_namespace_oid = get_namespace_oid(graph, false); + graph_namespace_oid = get_namespace_oid(graph, false); /* get the specific vertex label table (schema.vertex_label) */ - Oid vertex_label_table_oid = get_relname_relid(vertex_label, - graph_namespace_oid); + vertex_label_table_oid = get_relname_relid(vertex_label, + graph_namespace_oid); /* get the active snapshot */ - Snapshot snapshot = GetActiveSnapshot(); + snapshot = GetActiveSnapshot(); /* check for SELECT permission on the table */ aclresult = pg_class_aclcheck(vertex_label_table_oid, GetUserId(), @@ -5646,19 +6151,54 @@ static Datum get_vertex(const char *graph, const char *vertex_label, aclcheck_error(aclresult, OBJECT_TABLE, vertex_label); } - /* initialize the scan key */ - ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, F_OIDEQ, - Int64GetDatum(graphid)); - - /* open the relation (table), begin the scan, and get the tuple */ + /* open the relation (table) */ graph_vertex_label = table_open(vertex_label_table_oid, ShareLock); - scan_desc = table_beginscan(graph_vertex_label, snapshot, 1, scan_keys); - tuple = heap_getnext(scan_desc, ForwardScanDirection); + + index_oid = find_usable_btree_index_for_attr(graph_vertex_label, 1); + + if (OidIsValid(index_oid)) + { + IndexScanDesc index_scan_desc; + Relation index_rel; + + index_rel = index_open(index_oid, ShareLock); + slot = table_slot_create(graph_vertex_label, NULL); + + /* initialize the scan key using GRAPHIDEQ for index */ + ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, + F_GRAPHIDEQ, Int64GetDatum(graphid)); + + index_scan_desc = index_beginscan(graph_vertex_label, index_rel, + snapshot, NULL, 1, 0); + index_rescan(index_scan_desc, scan_keys, 1, NULL, 0); + + if (index_getnext_slot(index_scan_desc, ForwardScanDirection, slot)) + { + tuple = ExecCopySlotHeapTuple(slot); + should_free_tuple = true; + } + + index_endscan(index_scan_desc); + index_close(index_rel, ShareLock); + ExecDropSingleTupleTableSlot(slot); + } + else + { + /* fallback to sequential scan */ + ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, F_GRAPHIDEQ, + GRAPHID_GET_DATUM(graphid)); + + scan_desc = table_beginscan(graph_vertex_label, snapshot, 1, scan_keys); + tuple = heap_getnext(scan_desc, ForwardScanDirection); + } /* bail if the tuple isn't valid */ if (!HeapTupleIsValid(tuple)) { - table_endscan(scan_desc); + if (scan_desc) + { + table_endscan(scan_desc); + } table_close(graph_vertex_label, ShareLock); ereport(ERROR, (errcode(ERRCODE_UNDEFINED_TABLE), @@ -5668,7 +6208,15 @@ static Datum get_vertex(const char *graph, const char *vertex_label, /* Check RLS policies - error if filtered out */ if (!check_rls_for_tuple(graph_vertex_label, tuple, CMD_SELECT)) { - table_endscan(scan_desc); + if (scan_desc) + { + table_endscan(scan_desc); + } + if (should_free_tuple && tuple != NULL) + { + heap_freetuple(tuple); + } + table_close(graph_vertex_label, ShareLock); ereport(ERROR, (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), @@ -5691,10 +6239,20 @@ static Datum get_vertex(const char *graph, const char *vertex_label, properties = column_get_datum(tupdesc, tuple, 1, "properties", AGTYPEOID, true); /* reconstruct the vertex */ + label_agtype = DATUM_GET_AGTYPE_P(string_to_agtype((char *)vertex_label)); result = DirectFunctionCall3(_agtype_build_vertex, id, - CStringGetDatum(vertex_label), properties); - /* end the scan and close the relation */ - table_endscan(scan_desc); + PointerGetDatum(label_agtype), properties); + + /* end the scan and close the relation with new cleanup logic */ + if (scan_desc) + { + table_endscan(scan_desc); + } + if (should_free_tuple && tuple != NULL) + { + heap_freetuple(tuple); + } + table_close(graph_vertex_label, ShareLock); /* return the vertex datum */ return result; @@ -5710,6 +6268,7 @@ Datum age_startnode(PG_FUNCTION_ARGS) char *graph_name = NULL; char *label_name = NULL; graphid start_id; + int label_id; Datum result; /* we need the graph name */ @@ -5754,7 +6313,9 @@ Datum age_startnode(PG_FUNCTION_ARGS) start_id = agtv_value->val.int_value; /* get the label */ - label_name = get_label_name(graph_name, start_id); + label_id = get_graphid_label_id(start_id); + label_name = get_label_name(label_id, get_graph_oid(graph_name)); + /* it must not be null and must be a string */ Assert(label_name != NULL); @@ -5773,6 +6334,7 @@ Datum age_endnode(PG_FUNCTION_ARGS) char *graph_name = NULL; char *label_name = NULL; graphid end_id; + int32 label_id; Datum result; /* we need the graph name */ @@ -5817,7 +6379,9 @@ Datum age_endnode(PG_FUNCTION_ARGS) end_id = agtv_value->val.int_value; /* get the label */ - label_name = get_label_name(graph_name, end_id); + label_id = get_graphid_label_id(end_id); + label_name = get_label_name(label_id, get_graph_oid(graph_name)); + /* it must not be null and must be a string */ Assert(label_name != NULL); @@ -5826,6 +6390,42 @@ Datum age_endnode(PG_FUNCTION_ARGS) return result; } +PG_FUNCTION_INFO_V1(_get_vertex_by_graphid); + +/* + * Helper function for optimized startNode/endNode + * Fetches a vertex given graph name and vertex graphid + */ +Datum _get_vertex_by_graphid(PG_FUNCTION_ARGS) +{ + char *graph_name = NULL; + graphid vertex_id; + int32 label_id; + char *label_name = NULL; + Datum result; + + /* check for nulls */ + if (PG_ARGISNULL(0) || PG_ARGISNULL(1)) + PG_RETURN_NULL(); + + /* get the graph name from text */ + graph_name = text_to_cstring(PG_GETARG_TEXT_PP(0)); + + /* get the vertex graphid */ + vertex_id = PG_GETARG_INT64(1); + + /* get the label name */ + label_id = get_graphid_label_id(vertex_id); + label_name = get_label_name(label_id, get_graph_oid(graph_name)); + if (label_name == NULL) + PG_RETURN_NULL(); + + /* fetch and return the vertex */ + result = get_vertex(graph_name, label_name, vertex_id); + + return result; +} + PG_FUNCTION_INFO_V1(age_head); Datum age_head(PG_FUNCTION_ARGS) @@ -6000,11 +6600,11 @@ Datum age_tail(PG_FUNCTION_ARGS) count = AGT_ROOT_COUNT(agt_arg); - /* if we have an empty list or only one element in the list, return null */ - if (count <= 1) - { - PG_RETURN_NULL(); - } + /* + * For an empty or singleton list, tail() returns an empty list. The loop + * below already produces that result (i starts at 1 so nothing is pushed + * when count <= 1), so we do not special-case the count here. + */ /* clear the result structure */ MemSet(&agis_result, 0, sizeof(agtype_in_state)); @@ -6499,8 +7099,6 @@ Datum age_tofloatlist(PG_FUNCTION_ARGS) int count; int i; bool is_valid = false; - float8 float_num; - char buffer[64]; /* check for null */ if (PG_ARGISNULL(0)) @@ -6560,11 +7158,16 @@ Datum age_tofloatlist(PG_FUNCTION_ARGS) case AGTV_FLOAT: + /* + * The element is already a float8, so assign it directly. The + * previous approach formatted it to a string with sprintf() and + * re-parsed it: that both overflowed a fixed 64-byte stack buffer + * for large magnitudes (e.g. 1.0e308 needs ~317 chars) and lost + * precision, since "%f" truncates to 6 fractional digits. Direct + * assignment avoids both problems. + */ float_elem.type = AGTV_FLOAT; - float_num = elem->val.float_value; - sprintf(buffer, "%f", float_num); - string = buffer; - float_elem.val.float_value = float8in_internal_null(string, NULL, "double precision", string, &is_valid); + float_elem.val.float_value = elem->val.float_value; agis_result.res = push_agtype_value(&agis_result.parse_state, WAGT_ELEM, &float_elem); break; @@ -6597,24 +7200,52 @@ Datum age_tointeger(PG_FUNCTION_ARGS) Oid type; int64 result; - /* extract argument values */ - nargs = extract_variadic_args(fcinfo, 0, true, &args, &types, &nulls); + /* + * Fast path: toInteger() always takes exactly 1 argument. + * Avoid extract_variadic_args overhead by accessing the arg directly + * and caching the type via fn_extra. + */ + if (PG_NARGS() == 1) + { + if (PG_ARGISNULL(0)) + { + PG_RETURN_NULL(); + } - /* check number of args */ - if (nargs > 1) - ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("toInteger() only supports one argument"))); + arg = PG_GETARG_DATUM(0); - /* check for null */ - if (nargs < 0 || nulls[0]) - PG_RETURN_NULL(); + /* cache the arg type on first call */ + if (fcinfo->flinfo->fn_extra == NULL) + { + Oid *cached = MemoryContextAlloc(fcinfo->flinfo->fn_mcxt, + sizeof(Oid)); + *cached = get_fn_expr_argtype(fcinfo->flinfo, 0); + fcinfo->flinfo->fn_extra = cached; + } + type = *(Oid *)fcinfo->flinfo->fn_extra; + nargs = 1; + } + else + { + /* fallback variadic path */ + nargs = extract_variadic_args(fcinfo, 0, true, &args, &types, &nulls); - /* - * toInteger() supports integer, float, numeric, text, cstring, or the - * agtype integer, float, numeric, and string input - */ - arg = args[0]; - type = types[0]; + /* check number of args */ + if (nargs > 1) + { + ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("toInteger() only supports one argument"))); + } + + /* check for null */ + if (nargs < 0 || nulls[0]) + { + PG_RETURN_NULL(); + } + + arg = args[0]; + type = types[0]; + } if (type != AGTYPEOID) { @@ -7498,21 +8129,16 @@ Datum age_tostringlist(PG_FUNCTION_ARGS) for (i = 0; i < count; i++) { /* TODO: check element's type, it's value, and convert it to string if possible. */ + enum agtype_value_type elem_type; + elem = get_ith_agtype_value_from_container(&agt_arg->root, i); string_elem.type = AGTV_STRING; + elem_type = elem ? elem->type : AGTV_NULL; - switch (elem->type) + switch (elem_type) { case AGTV_STRING: - if(!elem) - { - string_elem.type = AGTV_NULL; - - agis_result.res = push_agtype_value(&agis_result.parse_state, - WAGT_ELEM, &string_elem); - } - string_elem.val.string.val = elem->val.string.val; string_elem.val.string.len = elem->val.string.len; @@ -7523,7 +8149,7 @@ Datum age_tostringlist(PG_FUNCTION_ARGS) case AGTV_FLOAT: - sprintf(buffer, "%.*g", DBL_DIG, elem->val.float_value); + snprintf(buffer, sizeof(buffer), "%.*g", DBL_DIG, elem->val.float_value); string_elem.val.string.val = pstrdup(buffer); string_elem.val.string.len = strlen(buffer); @@ -7534,7 +8160,7 @@ Datum age_tostringlist(PG_FUNCTION_ARGS) case AGTV_INTEGER: - sprintf(buffer, "%ld", elem->val.int_value); + snprintf(buffer, sizeof(buffer), "%ld", elem->val.int_value); string_elem.val.string.val = pstrdup(buffer); string_elem.val.string.len = strlen(buffer); @@ -7653,8 +8279,7 @@ Datum age_reverse(PG_FUNCTION_ARGS) else { ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("reverse() unsupported argument type %d", - type))); + errmsg("reverse() unsupported argument type"))); } } else @@ -7690,8 +8315,7 @@ Datum age_reverse(PG_FUNCTION_ARGS) else { ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("reverse() unsupported argument agtype %d", - agtv_value->type))); + errmsg("reverse() unsupported argument agtype"))); } } else if (AGT_ROOT_IS_ARRAY(agt_arg)) @@ -8588,8 +9212,15 @@ Datum age_substring(PG_FUNCTION_ARGS) PG_RETURN_NULL(); } - /* neither offset or length can be null if there is a valid string */ - if ((nargs == 2 && nulls[1]) || + /* + * neither offset nor length may be null when there is a valid string. + * Both arg positions must be checked whenever they are supplied; the + * previous condition missed the `start is null, length is provided` + * case (nargs == 3 && nulls[1]), which fell through to the numeric + * parser below and dereferenced an undefined Datum - crashing the + * backend (#2386). + */ + if ((nargs >= 2 && nulls[1]) || (nargs == 3 && nulls[2])) { ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), @@ -10950,6 +11581,250 @@ Datum age_float8_stddev_pop_aggfinalfn(PG_FUNCTION_ARGS) PG_RETURN_POINTER(agtype_value_to_agtype(&agtv_float)); } +/* + * Per-aggregate-group evaluation state for reduce(). Caches the compiled + * fold-body expression and a standalone ExprContext whose PARAM_EXEC slots + * are rebound on every element. Slot 0 = accumulator, slot 1 = current + * element, and slots 2 .. nparams-1 = captured loop-invariant outer values + * (outer-query variables and cypher() parameters referenced by the body). + */ +typedef struct reduce_eval_ctx +{ + ExprState *body_state; /* compiled fold-body expression */ + ExprContext *econtext; /* eval context carrying the param slots */ + ParamExecData *params; /* [0]=accumulator, [1]=element, [2..]=outer refs */ + int nparams; /* total param slots = 2 + number of captures */ +} reduce_eval_ctx; + +/* Build an agtype 'null' Datum (a real agtype value, not a SQL NULL). */ +static Datum reduce_agtype_null(void) +{ + agtype_value agtv; + + agtv.type = AGTV_NULL; + return AGTYPE_P_GET_DATUM(agtype_value_to_agtype(&agtv)); +} + +/* + * age_reduce_transfn(state agtype, init agtype, body text, element agtype, + * extras agtype[]) + * + * Transition function for the age_reduce aggregate that implements the Cypher + * reduce(acc = init, var IN list | body) fold. The fold body is compiled by + * transform_cypher_reduce() with the accumulator and element rewritten to + * PARAM_EXEC params 0 and 1, then serialized into the `body` text argument. + * Any loop-invariant outer-query variable or cypher() parameter referenced by + * the body is captured into the `extras` agtype array and rewritten to a + * PARAM_EXEC param 2, 3, ... in body order; those slots are bound from the + * array here. + * + * On the first element of a group the accumulator is seeded from `init` + * (the running state is NULL because the aggregate uses no initcond); on + * every element the body is evaluated with the params rebound, and the result + * becomes the next accumulator state. + * + * The accumulator and element are normalized to a non-NULL agtype 'null' + * before evaluation so that (a) the fold body sees agtype values and Cypher + * null semantics apply, and (b) the running state is never a SQL NULL, which + * keeps PG_ARGISNULL(0) a reliable "first element of the group" signal even + * when the fold legitimately produces null. + */ +PG_FUNCTION_INFO_V1(age_reduce_transfn); + +Datum age_reduce_transfn(PG_FUNCTION_ARGS) +{ + MemoryContext aggcontext; + MemoryContext oldctx; + reduce_eval_ctx *rc; + Datum acc; + Datum element; + Datum result; + bool result_isnull; + + if (!AggCheckCallContext(fcinfo, &aggcontext)) + { + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("age_reduce_transfn called in a non-aggregate context"))); + } + + /* the fold can run over a large list; stay responsive to cancellation */ + CHECK_FOR_INTERRUPTS(); + + /* + * One-time per-FmgrInfo setup: deserialize and compile the fold body, and + * build the standalone ExprContext plus its two PARAM_EXEC slots. The body + * text is a query constant, so caching the compiled state across groups is + * correct. + */ + rc = (reduce_eval_ctx *) fcinfo->flinfo->fn_extra; + if (rc == NULL) + { + text *body_txt; + char *body_str; + Node *body_node; + int n_extras = 0; + + if (PG_ARGISNULL(2)) + { + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("age_reduce: missing fold expression"))); + } + + /* + * The number of captured outer values is fixed for this aggregate + * call (the body's structure does not change between rows), so it is + * read once here to size the param array. Their values are bound per + * row below because a correlated capture changes between groups. + * + * The PG_NARGS() guard lets the function tolerate being reached + * through an older 4-argument aggregate definition (for example a + * stale catalog paired with a newer age.so): a missing extras + * argument is simply treated as zero captures. + */ + if (PG_NARGS() > 4 && !PG_ARGISNULL(4)) + { + ArrayType *extras_arr = PG_GETARG_ARRAYTYPE_P(4); + + n_extras = ArrayGetNItems(ARR_NDIM(extras_arr), + ARR_DIMS(extras_arr)); + } + + oldctx = MemoryContextSwitchTo(fcinfo->flinfo->fn_mcxt); + rc = (reduce_eval_ctx *) palloc0(sizeof(reduce_eval_ctx)); + body_txt = PG_GETARG_TEXT_PP(2); + body_str = text_to_cstring(body_txt); + body_node = (Node *) stringToNode(body_str); + + /* + * age_reduce() is SQL-callable, so the serialized body argument is + * not guaranteed to have come from transform_cypher_reduce(). The + * running state is stored as an agtype varlena (the datumCopy() below + * uses typbyval=false, typlen=-1), so a body that evaluates to a + * by-value type (e.g. a bare boolean or integer) would have its Datum + * misread as a pointer and could crash the backend. Reject any body + * whose result type is not agtype. transform_cypher_reduce() always + * normalizes the fold body to agtype, so a planner-generated reduce() + * is never rejected here. + */ + if (exprType(body_node) != AGTYPEOID) + { + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("age_reduce: fold expression must return agtype"))); + } + + rc->body_state = ExecInitExpr((Expr *) body_node, NULL); + rc->econtext = CreateStandaloneExprContext(); + rc->nparams = 2 + n_extras; + rc->params = (ParamExecData *) palloc0(sizeof(ParamExecData) * + rc->nparams); + rc->econtext->ecxt_param_exec_vals = rc->params; + fcinfo->flinfo->fn_extra = rc; + MemoryContextSwitchTo(oldctx); + } + + /* + * Seed the accumulator. The aggregate declares no initcond, so on the + * first element the running state (arg 0) is NULL and we use `init` + * (arg 1); thereafter the accumulator is the prior state. A NULL init is + * normalized to agtype 'null'. + */ + if (PG_ARGISNULL(0)) + { + acc = PG_ARGISNULL(1) ? reduce_agtype_null() : PG_GETARG_DATUM(1); + } + else + { + acc = PG_GETARG_DATUM(0); + } + + /* a NULL element is likewise normalized to agtype 'null' */ + element = PG_ARGISNULL(3) ? reduce_agtype_null() : PG_GETARG_DATUM(3); + + /* evaluate the fold body for this element */ + ResetExprContext(rc->econtext); + + /* bind PARAM_EXEC 0 = accumulator, 1 = current element */ + rc->params[0].value = acc; + rc->params[0].isnull = false; + rc->params[0].execPlan = NULL; + rc->params[1].value = element; + rc->params[1].isnull = false; + rc->params[1].execPlan = NULL; + + /* + * Bind the captured loop-invariant outer values to params 2 .. The values + * are pulled from the extras array every row because correlated captures + * differ between groups; the per-row deconstruction is done in the + * econtext's per-tuple memory (reset above) so it does not leak. A NULL + * array element is normalized to agtype 'null' like the accumulator and + * element. + * + * Every slot 2 .. nparams-1 is rebound on every row, so a slot never + * retains a value from a previous row -- which, after the per-tuple reset + * above, would be a dangling pointer. If the extras array supplies fewer + * values than there are capture slots (only reachable through a direct SQL + * call with a varying-length array), the unsupplied slots are filled with + * agtype 'null'. The PG_NARGS() guard keeps the arg-4 access safe under an + * older 4-argument signature. + */ + if (rc->nparams > 2 && PG_NARGS() > 4 && !PG_ARGISNULL(4)) + { + ArrayType *extras_arr = PG_GETARG_ARRAYTYPE_P(4); + Oid elemtype = ARR_ELEMTYPE(extras_arr); + int16 typlen; + bool typbyval; + char typalign; + Datum *ex_vals; + bool *ex_nulls; + int ex_n; + int i; + MemoryContext per_tuple = rc->econtext->ecxt_per_tuple_memory; + MemoryContext save = MemoryContextSwitchTo(per_tuple); + + get_typlenbyvalalign(elemtype, &typlen, &typbyval, &typalign); + deconstruct_array(extras_arr, elemtype, typlen, typbyval, typalign, + &ex_vals, &ex_nulls, &ex_n); + + for (i = 0; (2 + i) < rc->nparams; i++) + { + if (i < ex_n && !ex_nulls[i]) + { + rc->params[2 + i].value = ex_vals[i]; + } + else + { + rc->params[2 + i].value = reduce_agtype_null(); + } + rc->params[2 + i].isnull = false; + rc->params[2 + i].execPlan = NULL; + } + + MemoryContextSwitchTo(save); + } + + result = ExecEvalExpr(rc->body_state, rc->econtext, &result_isnull); + + /* + * Never let the running state become a SQL NULL: a null fold result is + * stored as agtype 'null' so the next element is not mistaken for the + * first one (see PG_ARGISNULL(0) above). + */ + if (result_isnull) + { + result = reduce_agtype_null(); + } + + /* the new state must survive in the aggregate context across elements */ + oldctx = MemoryContextSwitchTo(aggcontext); + result = datumCopy(result, false, -1); + MemoryContextSwitchTo(oldctx); + + PG_RETURN_DATUM(result); +} + PG_FUNCTION_INFO_V1(age_agtype_larger_aggtransfn); Datum age_agtype_larger_aggtransfn(PG_FUNCTION_ARGS) @@ -12025,6 +12900,40 @@ static int64 get_int64_from_int_datums(Datum d, Oid type, char *funcname, return result; } +/* + * Helper function to find a valid index for a specific attribute. + * Returns the OID of the index, or InvalidOid if none is found. + */ +Oid find_usable_btree_index_for_attr(Relation rel, AttrNumber attnum) +{ +List *index_list = RelationGetIndexList(rel); + ListCell *ilc; + Oid index_oid = InvalidOid; + + foreach(ilc, index_list) + { + Oid curr_idx_oid = lfirst_oid(ilc); + Relation curr_idx_rel = index_open(curr_idx_oid, AccessShareLock); + + if (curr_idx_rel->rd_index->indisvalid && + curr_idx_rel->rd_index->indnatts >= 1 && + curr_idx_rel->rd_index->indkey.values[0] == attnum && + curr_idx_rel->rd_rel->relam == BTREE_AM_OID && + RelationGetIndexPredicate(curr_idx_rel) == NIL) + { + index_oid = curr_idx_oid; + index_close(curr_idx_rel, AccessShareLock); + break; + } + + index_close(curr_idx_rel, AccessShareLock); + } + + list_free(index_list); + + return index_oid; +} + PG_FUNCTION_INFO_V1(age_range); /* * Execution function to implement openCypher range() function @@ -12194,12 +13103,28 @@ Datum age_unnest(PG_FUNCTION_ARGS) HeapTuple tuple; Datum values[1]; bool nulls[1] = {false}; - agtype *val = agtype_value_to_agtype(&v); /* use the tmp context so we can clean up after each tuple is done */ old_cxt = MemoryContextSwitchTo(tmp_cxt); - values[0] = PointerGetDatum(val); + /* + * Emit an agtype AGTV_NULL element as a SQL-NULL row so that + * `IS NULL` / `IS NOT NULL` on the unwound variable match + * openCypher's null semantics (issue #2393). Wrapping it as a + * non-SQL-NULL agtype container would leave SQL's IS NULL blind + * to it, dropping `WHERE x IS NULL` filters and passing through + * `WHERE x IS NOT NULL` filters. + */ + if (v.type == AGTV_NULL) + { + values[0] = (Datum) 0; + nulls[0] = true; + } + else + { + agtype *val = agtype_value_to_agtype(&v); + values[0] = PointerGetDatum(val); + } tuple = heap_form_tuple(ret_tdesc, values, nulls); @@ -12263,11 +13188,12 @@ Datum agtype_volatile_wrapper(PG_FUNCTION_ARGS) agtv_result.type = AGTV_BOOL; agtv_result.val.boolean = DatumGetBool(arg); } - else if (type == INT2OID || type == INT4OID || type == INT8OID) + else if (type == INT2OID || type == INT4OID || + type == INT8OID || type == GRAPHIDOID) { agtv_result.type = AGTV_INTEGER; - if (type == INT8OID) + if (type == INT8OID || type == GRAPHIDOID) { agtv_result.val.int_value = DatumGetInt64(arg); } @@ -12310,6 +13236,14 @@ Datum agtype_volatile_wrapper(PG_FUNCTION_ARGS) agtv_result.val.string.val = text_to_cstring(DatumGetTextPP(arg)); agtv_result.val.string.len = strlen(agtv_result.val.string.val); } + else if (type == VERTEXOID) + { + PG_RETURN_DATUM(DirectFunctionCall1(vertex_to_agtype, arg)); + } + else if (type == EDGEOID) + { + PG_RETURN_DATUM(DirectFunctionCall1(edge_to_agtype, arg)); + } else { ereport(ERROR, diff --git a/src/backend/utils/adt/agtype_ext.c b/src/backend/utils/adt/agtype_ext.c index 7a0ea991d..e4a38e3d1 100644 --- a/src/backend/utils/adt/agtype_ext.c +++ b/src/backend/utils/adt/agtype_ext.c @@ -57,7 +57,14 @@ bool ag_serialize_extended_type(StringInfo buffer, agtentry *agtentry, /* copy in the int_value data */ numlen = sizeof(int64); offset = reserve_from_buffer(buffer, numlen); - *((int64 *)(buffer->data + offset)) = scalar_val->val.int_value; + /* + * Use memcpy because the AGT_HEADER (4 bytes) leaves the buffer + * write position 4-byte-aligned but not necessarily 8-byte-aligned. + * Direct *((int64 *) ...) = ... is undefined behavior under strict + * alignment rules (UBSan flags it; works in practice on x86_64). + */ + memcpy(buffer->data + offset, &scalar_val->val.int_value, + sizeof(int64)); *agtentry = AGTENTRY_IS_AGTYPE | (padlen + numlen + AGT_HEADER_SIZE); break; @@ -68,7 +75,8 @@ bool ag_serialize_extended_type(StringInfo buffer, agtentry *agtentry, /* copy in the float_value data */ numlen = sizeof(scalar_val->val.float_value); offset = reserve_from_buffer(buffer, numlen); - *((float8 *)(buffer->data + offset)) = scalar_val->val.float_value; + memcpy(buffer->data + offset, &scalar_val->val.float_value, + sizeof(float8)); *agtentry = AGTENTRY_IS_AGTYPE | (padlen + numlen + AGT_HEADER_SIZE); break; @@ -154,12 +162,17 @@ void ag_deserialize_extended_type(char *base_addr, uint32 offset, { case AGT_HEADER_INTEGER: result->type = AGTV_INTEGER; - result->val.int_value = *((int64 *)(base + AGT_HEADER_SIZE)); + /* See comment in ag_serialize_extended_type. The 4-byte AGT_HEADER + * leaves the int64 at a 4-byte-aligned but not 8-byte-aligned + * address, so a direct typed load is undefined behavior. */ + memcpy(&result->val.int_value, base + AGT_HEADER_SIZE, + sizeof(int64)); break; case AGT_HEADER_FLOAT: result->type = AGTV_FLOAT; - result->val.float_value = *((float8 *)(base + AGT_HEADER_SIZE)); + memcpy(&result->val.float_value, base + AGT_HEADER_SIZE, + sizeof(float8)); break; case AGT_HEADER_VERTEX: diff --git a/src/backend/utils/adt/agtype_ops.c b/src/backend/utils/adt/agtype_ops.c index d831447b0..f7f45b467 100644 --- a/src/backend/utils/adt/agtype_ops.c +++ b/src/backend/utils/adt/agtype_ops.c @@ -165,6 +165,12 @@ Datum agtype_add(PG_FUNCTION_ARGS) agtv_lhs = get_ith_agtype_value_from_container(&lhs->root, 0); agtv_rhs = get_ith_agtype_value_from_container(&rhs->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_lhs->type == AGTV_NULL || agtv_rhs->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + /* * One or both values is a string OR one is a string and the other is * either an integer, float, or numeric. If so, concatenate them. @@ -525,6 +531,12 @@ Datum agtype_sub(PG_FUNCTION_ARGS) agtv_lhs = get_ith_agtype_value_from_container(&lhs->root, 0); agtv_rhs = get_ith_agtype_value_from_container(&rhs->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_lhs->type == AGTV_NULL || agtv_rhs->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + if (agtv_lhs->type == AGTV_INTEGER && agtv_rhs->type == AGTV_INTEGER) { agtv_result.type = AGTV_INTEGER; @@ -615,6 +627,12 @@ Datum agtype_neg(PG_FUNCTION_ARGS) agtv_value = get_ith_agtype_value_from_container(&v->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_value->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + if (agtv_value->type == AGTV_INTEGER) { agtv_result.type = AGTV_INTEGER; @@ -666,6 +684,12 @@ Datum agtype_mul(PG_FUNCTION_ARGS) agtv_lhs = get_ith_agtype_value_from_container(&lhs->root, 0); agtv_rhs = get_ith_agtype_value_from_container(&rhs->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_lhs->type == AGTV_NULL || agtv_rhs->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + if (agtv_lhs->type == AGTV_INTEGER && agtv_rhs->type == AGTV_INTEGER) { agtv_result.type = AGTV_INTEGER; @@ -756,6 +780,12 @@ Datum agtype_div(PG_FUNCTION_ARGS) agtv_lhs = get_ith_agtype_value_from_container(&lhs->root, 0); agtv_rhs = get_ith_agtype_value_from_container(&rhs->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_lhs->type == AGTV_NULL || agtv_rhs->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + if (agtv_lhs->type == AGTV_INTEGER && agtv_rhs->type == AGTV_INTEGER) { if (agtv_rhs->val.int_value == 0) @@ -874,6 +904,12 @@ Datum agtype_mod(PG_FUNCTION_ARGS) agtv_lhs = get_ith_agtype_value_from_container(&lhs->root, 0); agtv_rhs = get_ith_agtype_value_from_container(&rhs->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_lhs->type == AGTV_NULL || agtv_rhs->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + if (agtv_lhs->type == AGTV_INTEGER && agtv_rhs->type == AGTV_INTEGER) { agtv_result.type = AGTV_INTEGER; @@ -964,6 +1000,12 @@ Datum agtype_pow(PG_FUNCTION_ARGS) agtv_lhs = get_ith_agtype_value_from_container(&lhs->root, 0); agtv_rhs = get_ith_agtype_value_from_container(&rhs->root, 0); + /* openCypher: arithmetic over null yields null. */ + if (agtv_lhs->type == AGTV_NULL || agtv_rhs->type == AGTV_NULL) + { + PG_RETURN_NULL(); + } + if (agtv_lhs->type == AGTV_INTEGER && agtv_rhs->type == AGTV_INTEGER) { agtv_result.type = AGTV_FLOAT; diff --git a/src/backend/utils/adt/agtype_raw.c b/src/backend/utils/adt/agtype_raw.c index d8bad3d24..3d56d94d5 100644 --- a/src/backend/utils/adt/agtype_raw.c +++ b/src/backend/utils/adt/agtype_raw.c @@ -197,9 +197,20 @@ void write_graphid(agtype_build_state *bstate, graphid graphid) write_const(AGT_HEADER_INTEGER, AGT_HEADER_TYPE); length += AGT_HEADER_SIZE; - /* graphid value */ - write_const(graphid, int64); - length += sizeof(int64); + /* + * graphid value (int64). The 4-byte AGT_HEADER above leaves the buffer + * write position 4-byte-aligned but not 8-byte-aligned, so the typed + * write done by write_const(graphid, int64) is undefined behavior under + * strict alignment rules. Use memcpy. (UBSan flags the typed write as + * "store to misaligned address ... requires 8 byte alignment".) + */ + { + int numlen = sizeof(int64); + int g_offset = BUFFER_RESERVE(numlen); + + memcpy(bstate->buffer->data + g_offset, &graphid, sizeof(int64)); + length += numlen; + } /* agtentry */ write_agt(AGTENTRY_IS_AGTYPE | length); @@ -214,8 +225,15 @@ void write_container(agtype_build_state *bstate, agtype *agtype) /* padding */ length += BUFFER_WRITE_PAD(); - /* varlen data */ - length += write_ptr((char *) &agtype->root, VARSIZE(agtype)); + /* + * Copy the inner agtype_container only, NOT the outer varlena header. + * VARSIZE(agtype) reports the total varlena size (including the 4-byte + * vl_len_ header), but we are starting our copy at &agtype->root, which + * is already past that header. Subtracting VARHDRSZ avoids reading + * VARHDRSZ bytes past the source allocation (caught by ASan as + * heap-buffer-overflow in __interceptor_memcpy from write_pointer). + */ + length += write_ptr((char *) &agtype->root, VARSIZE(agtype) - VARHDRSZ); /* agtentry */ write_agt(AGTENTRY_IS_CONTAINER | length); diff --git a/src/backend/utils/adt/agtype_util.c b/src/backend/utils/adt/agtype_util.c index b39723413..90e540708 100644 --- a/src/backend/utils/adt/agtype_util.c +++ b/src/backend/utils/adt/agtype_util.c @@ -103,6 +103,66 @@ static int get_type_sort_priority(enum agtype_value_type type); static void pfree_iterator_agtype_value_token(agtype_iterator_token token, agtype_value *agtv); +/* + * Per-call agtype build arena. + * + * Hot Datum functions in agtype.c build an agtype_value tree, serialize it + * via agtype_value_to_agtype(), then recursively free it with + * pfree_agtype_value_content(). The recursive free is O(N) in the number of + * tree nodes; the arena helpers below let callers replace it with an O(K) + * block reset (K = number of allocated AllocSet blocks; typically 1 for + * small trees) by allocating the entire tree inside a short-lived + * MemoryContext. + * + * The arena is sized for typical agtype_value trees; it grows on demand. + * Cost of creating and deleting the context is amortized against the + * eliminated per-node pfree walk and per-node AllocSetFree calls. + * + * agt_arena_end() is a no-op on NULL, allowing simple cleanup paths. + * + * The AGT_ARENA_BEGIN_SHARED() macro (declared in agtype.h) returns a + * context parented under CacheMemoryContext (process lifetime) so the + * caller may stash it in a file-static variable for reuse across many + * invocations. The first caller pays AllocSetContextCreate cost; + * subsequent callers reuse the context and pay only MemoryContextReset + * (cheap; ~100 ns when nothing was allocated since last reset). Use this + * for hot inner loops (sort comparators, per-tuple deep-copy workspaces). + */ +MemoryContext agt_arena_begin(void) +{ + /* + * ALLOCSET_SMALL_SIZES (initial=1KB, max=8KB). Most agtype value trees + * fit in a single 1KB block. + */ + return AllocSetContextCreate(CurrentMemoryContext, + "agtype build arena", + ALLOCSET_SMALL_SIZES); +} + +/* + * Pattern B (shared, long-lived arena) is exposed as the macro + * AGT_ARENA_BEGIN_SHARED in agtype.h, because PG's AllocSetContextCreate + * enforces a compile-time-constant name via StaticAssertStmt. The macro + * inlines the AllocSet creation at the call site so the name literal is + * visible to the StaticAssert. + */ + +void agt_arena_reset(MemoryContext arena) +{ + if (arena != NULL) + { + MemoryContextReset(arena); + } +} + +void agt_arena_end(MemoryContext arena) +{ + if (arena != NULL) + { + MemoryContextDelete(arena); + } +} + /* * Turn an in-memory agtype_value into an agtype for on-disk storage. * @@ -834,12 +894,18 @@ static void fill_agtype_value_no_copy(agtype_container *container, int index, { case AGT_HEADER_INTEGER: result->type = AGTV_INTEGER; - result->val.int_value = *((int64 *)(base + AGT_HEADER_SIZE)); + /* See ag_serialize_extended_type: the 4-byte AGT_HEADER leaves + * the int64 at a 4-byte-aligned but not 8-byte-aligned address. + * Use memcpy to avoid undefined behavior on strict alignment + * platforms (caught by UBSan). */ + memcpy(&result->val.int_value, base + AGT_HEADER_SIZE, + sizeof(int64)); break; case AGT_HEADER_FLOAT: result->type = AGTV_FLOAT; - result->val.float_value = *((float8 *)(base + AGT_HEADER_SIZE)); + memcpy(&result->val.float_value, base + AGT_HEADER_SIZE, + sizeof(float8)); break; default: @@ -880,8 +946,122 @@ static void fill_agtype_value_no_copy(agtype_container *container, int index, * of the full iterator machinery. It extracts the scalar values using no-copy * fill and compares them directly. * + * For composite types (VERTEX, EDGE, PATH) the no-copy fill still + * deserializes into newly-allocated memory. To avoid the per-call + * pfree_agtype_value_content recursive walk on every sort comparator + * invocation, those allocations are routed through a long-lived shared + * arena that is reset (O(1)) at the end of each call. The arena is + * created lazily on the first call that needs it (i.e. the first + * VERTEX/EDGE/PATH comparison) and reused across all subsequent calls in + * the process. + * + * Additional fast path (A2): when both containers are VERTEX or EDGE of + * the same type, the comparator only needs the graphid `id` field + * (compare_agtype_scalar_values does this; all other fields are ignored). + * Skip the full ag_deserialize_composite agtype_value tree build entirely + * and walk the binary representation directly to read just the int64 id. + * This is the dominant cost of sort-bound queries (notably IC4) since the + * tree build is O(properties + label) per row when only one int64 is + * needed for ordering. + * * Returns: negative if a < b, 0 if a == b, positive if a > b */ +static MemoryContext compare_scalar_arena = NULL; + +/* + * Fast int64-id extraction from a binary VERTEX or EDGE container. + * + * Layout of an extended VERTEX/EDGE entry (set up by ag_serialize_extended_type + * with convert_extended_object): a 4-byte AGT_HEADER followed by a standard + * agtype_container holding an object whose first field (sorted by key length + * ascending) is "id". The id is itself stored as an extended AGTV_INTEGER: + * a 4-byte AGT_HEADER_INTEGER followed by an int64. + * + * Pair layout in an object container: children[0..N-1] are the keys, then + * children[N..2N-1] are the values; data follows. The "id" key is at index 0 + * because "id" (length 2) is the shortest key in both VERTEX (id, label, + * properties) and EDGE (id, label, end_id, start_id, properties), and the + * agtype object representation sorts pairs by key length ascending. + * + * On success, writes the extracted graphid through *out and returns true. + * Returns false (leaving *out untouched) if the id field is not a well-formed + * extended AGTV_INTEGER, signaling the caller to fall back to the slow path. + * A bool result is used instead of a sentinel return value because a valid + * graphid can legitimately take any int64 value (e.g. label_id 0x8000 with + * entry_id 0 equals PG_INT64_MIN), so no in-band sentinel is safe. + * + * The caller is responsible for ensuring the input container actually holds a + * VERTEX or EDGE (i.e. this is only called after AGTE_IS_AGTYPE indicates an + * extended type and the AGT_HEADER matches AGT_HEADER_VERTEX or + * AGT_HEADER_EDGE). + */ +static bool extract_composite_id_fast(const agtype_container *outer_scalar, + graphid *out) +{ + const char *base_addr; + const agtype_container *obj; + int n_pairs; + const agtentry *children; + uint32 id_value_offset; + char *id_value_base; + char *id_extended_base; + AGT_HEADER_TYPE id_header; + int id_value_index; + + /* outer_scalar is a single-element pseudo-array; element 0 is our extended type */ + base_addr = (const char *)&outer_scalar->children[1]; + + /* + * Element 0 is an extended type. Its data starts at base_addr + 0 and + * begins with AGT_HEADER_VERTEX or AGT_HEADER_EDGE; the inner + * agtype_container follows immediately. + */ + obj = (const agtype_container *)((const char *)base_addr + AGT_HEADER_SIZE); + n_pairs = AGTYPE_CONTAINER_SIZE(obj); + children = obj->children; + + /* + * Pair value 0 ("id") lives at child index n_pairs (values come after + * keys). Compute its offset within the object's data area. + */ + id_value_index = n_pairs; + id_value_offset = get_agtype_offset(obj, id_value_index); + + /* Data area for this object starts after children[2*n_pairs] */ + id_value_base = (char *)&children[2 * n_pairs]; + + /* + * The id value is an extended AGTV_INTEGER: AGT_HEADER (aligned) followed + * by the int64. INTALIGN matches what ag_deserialize_extended_type does. + */ + id_extended_base = id_value_base + INTALIGN(id_value_offset); + /* + * The header is uint32 (4-byte) and lives at a 4-byte-aligned address, + * so a typed load is fine. The int64 that follows starts at offset + * AGT_HEADER_SIZE = 4 from id_extended_base, so it is 4-byte- but not + * necessarily 8-byte-aligned; use memcpy to avoid alignment UB. + */ + memcpy(&id_header, id_extended_base, sizeof(AGT_HEADER_TYPE)); + + /* + * Defensive: if the value isn't actually an extended integer, fall back + * to the slow deserialize path by signaling. AGT_HEADER_INTEGER is the + * only valid header for the id field of a well-formed VERTEX/EDGE. + */ + if (id_header != AGT_HEADER_INTEGER) + { + return false; + } + + { + graphid id; + + memcpy(&id, id_extended_base + AGT_HEADER_SIZE, sizeof(int64)); + *out = id; + return true; + } +} + static int compare_agtype_scalar_containers(agtype_container *a, agtype_container *b) { @@ -892,6 +1072,7 @@ static int compare_agtype_scalar_containers(agtype_container *a, int result; bool need_free_a = false; bool need_free_b = false; + MemoryContext saved_ctx = NULL; Assert(AGTYPE_CONTAINER_IS_SCALAR(a)); Assert(AGTYPE_CONTAINER_IS_SCALAR(b)); @@ -900,13 +1081,77 @@ static int compare_agtype_scalar_containers(agtype_container *a, base_addr_a = (char *)&a->children[1]; base_addr_b = (char *)&b->children[1]; + /* + * A2 fast path: when both sides are extended-type scalars holding the + * same composite kind (VERTEX or EDGE), we only need the int64 id field + * to determine ordering (see compare_agtype_scalar_values' AGTV_VERTEX + * and AGTV_EDGE cases). Skip the full agtype_value tree build entirely. + */ + if (AGTE_IS_AGTYPE(a->children[0]) && AGTE_IS_AGTYPE(b->children[0])) + { + AGT_HEADER_TYPE ha; + AGT_HEADER_TYPE hb; + + /* + * The header is uint32 at an INTALIGN'd (4-byte) address, so a typed + * load would be safe, but use memcpy for consistency with the rest of + * this comparator (extract_composite_id_fast reads the same header via + * memcpy) and to stay robust if AGT_HEADER_TYPE is ever widened. + */ + memcpy(&ha, + base_addr_a + INTALIGN(get_agtype_offset(a, 0)), + sizeof(AGT_HEADER_TYPE)); + memcpy(&hb, + base_addr_b + INTALIGN(get_agtype_offset(b, 0)), + sizeof(AGT_HEADER_TYPE)); + + if (ha == hb && + (ha == AGT_HEADER_VERTEX || ha == AGT_HEADER_EDGE)) + { + graphid ida; + graphid idb; + + /* + * extract_composite_id_fast returns false on a malformed id + * field; fall through to the slow path in that rare case rather + * than producing a wrong comparison. + */ + if (extract_composite_id_fast(a, &ida) && + extract_composite_id_fast(b, &idb)) + { + if (ida == idb) + { + return 0; + } + return (ida > idb) ? 1 : -1; + } + } + } + + /* + * Peek at the entry types without filling, so we can route any composite + * allocations into the shared arena instead of CurrentMemoryContext. + * This avoids the recursive pfree walk after every comparison. + */ + if (AGTE_IS_AGTYPE(a->children[0]) || AGTE_IS_AGTYPE(b->children[0])) + { + if (compare_scalar_arena == NULL) + { + compare_scalar_arena = + AGT_ARENA_BEGIN_SHARED("agtype scalar comparator"); + } + saved_ctx = MemoryContextSwitchTo(compare_scalar_arena); + } + /* Use no-copy fill to avoid allocations for simple types */ fill_agtype_value_no_copy(a, 0, base_addr_a, 0, &va); fill_agtype_value_no_copy(b, 0, base_addr_b, 0, &vb); /* - * Check if we need to free the values after comparison. - * Only VERTEX, EDGE, and PATH types allocate memory in no-copy mode. + * Track which sides allocated composite content (VERTEX/EDGE/PATH). + * For the arena-backed allocations we still need this so we know to + * reset the arena at the end; for the (rare) caller-context-backed + * fallback path we still need to recursively free. */ if (va.type == AGTV_VERTEX || va.type == AGTV_EDGE || va.type == AGTV_PATH) { @@ -936,14 +1181,17 @@ static int compare_agtype_scalar_containers(agtype_container *a, get_type_sort_priority(vb.type)) ? -1 : 1; } - /* Free any allocated memory from composite types */ - if (need_free_a) - { - pfree_agtype_value_content(&va); - } - if (need_free_b) + /* + * Cleanup. If we allocated into the shared arena, reset it (O(1)); + * otherwise the no-copy fill made no allocations to free. + */ + if (saved_ctx != NULL) { - pfree_agtype_value_content(&vb); + MemoryContextSwitchTo(saved_ctx); + if (need_free_a || need_free_b) + { + agt_arena_reset(compare_scalar_arena); + } } return result; @@ -2127,7 +2375,17 @@ int reserve_from_buffer(StringInfo buffer, int len) static void copy_to_buffer(StringInfo buffer, int offset, const char *data, int len) { - memcpy(buffer->data + offset, data, len); + /* + * Guard against memcpy(dst, NULL, 0). Some callers (notably empty PATH + * scalars with no element bytes) reach here with data == NULL and len + * == 0; the C standard says memcpy with a NULL pointer is undefined even + * when len == 0. UBSan flags this as "null pointer passed as argument 2, + * which is declared to never be null". + */ + if (len > 0) + { + memcpy(buffer->data + offset, data, len); + } } /* diff --git a/src/backend/utils/cache/agehash.c b/src/backend/utils/cache/agehash.c new file mode 100644 index 000000000..2f3f256e0 --- /dev/null +++ b/src/backend/utils/cache/agehash.c @@ -0,0 +1,756 @@ +/* + * 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. + */ + +/* + * agehash.c - Robin Hood open-addressing hashtable for AGE. + * + * See agehash.h for the public contract. This file implements the INLINE + * mode only. + * + * Internal slot layout (INLINE): + * + * bytes 0..1 uint16 probe_dist (AGEHASH_EMPTY = 0xFFFF marks empty) + * bytes 2..3 uint16 reserved (future tombstone / flag bits) + * bytes 4..7 uint32 pad (forces key to 8-byte alignment) + * bytes 8..K+7 key + * bytes K+8.. payload + * + * slot_size = MAXALIGN(8 + key_size + payload_size). + */ + +#include "postgres.h" + +#include "fmgr.h" +#include "utils/agehash.h" +#include "utils/builtins.h" +#include "utils/memutils.h" + +/* ------------------------------------------------------------------------- */ + +struct AgeHashTable +{ + /* Slot array: capacity * slot_size bytes, palloc'd in mcxt. */ + char *slots; + uint32 capacity; /* always a power of two */ + uint32 capacity_mask; /* capacity - 1 */ + uint32 size; /* live entries */ + uint32 max_size; /* size at which we grow */ + uint32 slot_size; /* total bytes per slot */ + uint32 key_size; + uint32 payload_size; + uint32 payload_offset; /* AGEHASH_SLOT_KEY_OFFSET + key_size */ + AgeHashMode mode; + bool frozen; + agehash_hash_fn hash_fn; + agehash_keyeq_fn keyeq_fn; + MemoryContext mcxt; +}; + +/* ------------------------------------------------------------------------- */ +/* Slot accessors. */ + +static inline char * +slot_at(AgeHashTable *t, uint32 idx) +{ + return t->slots + (Size) idx * t->slot_size; +} + +static inline uint16 +slot_probe_dist(const char *slot) +{ + uint16 d; + memcpy(&d, slot, sizeof(uint16)); + return d; +} + +static inline void +slot_set_probe_dist(char *slot, uint16 d) +{ + memcpy(slot, &d, sizeof(uint16)); +} + +static inline char * +slot_key_ptr(AgeHashTable *t, char *slot) +{ + (void) t; + return slot + AGEHASH_SLOT_KEY_OFFSET; +} + +static inline char * +slot_payload_ptr(AgeHashTable *t, char *slot) +{ + return slot + t->payload_offset; +} + +/* ------------------------------------------------------------------------- */ +/* Construction. */ + +static uint32 +next_pow2(uint32 v) +{ + uint32 p = 1; + while (p < v) + p <<= 1; + return p; +} + +AgeHashTable * +agehash_create_inline(MemoryContext mcxt, + Size key_size, + Size payload_size, + uint32 capacity_hint, + agehash_hash_fn hash_fn, + agehash_keyeq_fn keyeq_fn) +{ + AgeHashTable *t; + MemoryContext oldctx; + uint32 min_cap; + uint32 cap; + + Assert(mcxt != NULL); + Assert(key_size > 0 && key_size <= 64); + Assert(payload_size > 0 && payload_size <= 4096); + Assert(hash_fn != NULL); + Assert(keyeq_fn != NULL); + + /* + * Runtime enforcement of the on-stack carrier limits used by the inline + * Robin Hood path (carry_key[64], carry_payload[4096]). Asserts above + * give early diagnostics in debug builds; this elog covers production + * builds where Asserts compile out and the same caller would otherwise + * trigger a stack-buffer overflow during insert. + */ + if (key_size == 0 || key_size > 64) + { + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("agehash inline key size %zu out of range (must be 1..64)", + (size_t) key_size))); + } + if (payload_size == 0 || payload_size > 4096) + { + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("agehash inline payload size %zu out of range (must be 1..4096)", + (size_t) payload_size))); + } + + oldctx = MemoryContextSwitchTo(mcxt); + + t = palloc0(sizeof(AgeHashTable)); + t->mcxt = mcxt; + t->mode = AGEHASH_INLINE; + t->frozen = false; + t->hash_fn = hash_fn; + t->keyeq_fn = keyeq_fn; + t->key_size = (uint32) key_size; + t->payload_size = (uint32) payload_size; + /* + * MAXALIGN payload_offset so that the typed payload pointer returned + * by slot_payload_ptr() is suitably aligned for any C type the caller + * might cast to. Without this, a key_size that is not a multiple of + * MAXIMUM_ALIGNOF (e.g. a 12-byte key) would leave the payload at a + * misaligned address — undefined behavior under strict alignment rules + * even though it works in practice on x86_64. + */ + t->payload_offset = MAXALIGN(AGEHASH_SLOT_KEY_OFFSET + (uint32) key_size); + t->slot_size = MAXALIGN(t->payload_offset + (uint32) payload_size); + /* + * agehash_key_from_payload() recovers the key as (payload - key_size), + * which is only valid when the payload abuts the key with no MAXALIGN + * padding between them. That holds iff key_size is a multiple of + * MAXIMUM_ALIGNOF. Enforce the invariant here so a future non-aligned + * key trips in DEBUG builds rather than silently handing the macro a + * wrong pointer. + */ + if (key_size % MAXIMUM_ALIGNOF != 0) + { + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("agehash inline key size %zu must be a multiple of %d for key recovery", + (size_t) key_size, MAXIMUM_ALIGNOF))); + } + Assert(t->payload_offset == AGEHASH_SLOT_KEY_OFFSET + (uint32) key_size); + + /* + * Capacity floor of 64 keeps tiny tables out of degenerate-load territory + * and avoids a flurry of grows on the first few inserts. + */ + if (capacity_hint == 0) + min_cap = 64; + else + { + /* size capacity_hint at MAX_LOAD so we don't immediately grow */ + min_cap = (uint32) ((double) capacity_hint / AGEHASH_MAX_LOAD) + 1; + if (min_cap < 64) + min_cap = 64; + } + cap = next_pow2(min_cap); + Assert((cap & (cap - 1)) == 0); + + t->capacity = cap; + t->capacity_mask = cap - 1; + t->size = 0; + t->max_size = (uint32) ((double) cap * AGEHASH_MAX_LOAD); + /* + * The slot array can comfortably exceed 1 GiB on production graphs + * (the SF3 ldbc_snb edge_table is multiple GiB at the 0.85 load + * factor). Use the HUGE allocator to bypass the standard MaxAllocSize + * check. + */ + t->slots = (char *) MemoryContextAllocHuge(mcxt, + (Size) cap * t->slot_size); + + /* Mark every slot empty. */ + { + uint32 i; + for (i = 0; i < cap; i++) + slot_set_probe_dist(slot_at(t, i), AGEHASH_EMPTY); + } + + MemoryContextSwitchTo(oldctx); + return t; +} + +/* ------------------------------------------------------------------------- */ +/* Insert. Robin Hood with rich-poor swap. */ + +static void agehash_grow(AgeHashTable *t); + +static void * +agehash_insert_internal(AgeHashTable *t, const void *key, uint32 hashvalue, + bool *found) +{ + uint32 i; + uint16 d; + /* + * Carrier for the entry currently being placed. Starts as the caller's + * key with a fresh, zero-filled payload; gets overwritten on each + * Robin Hood swap. + */ + char carry_key[64]; + char carry_payload[4096]; + void *result_payload = NULL; + bool placed_caller = false; + + /* + * agehash_freeze() is documented to make subsequent inserts/grows fail + * loudly. The Assert below catches violations in debug builds; the + * runtime check guarantees the contract in production builds where + * Asserts compile out, preventing silent slot reordering that would + * invalidate any payload pointers callers consider stable post-freeze. + */ + if (t->frozen) + { + elog(ERROR, "agehash: insert into frozen table"); + } + Assert(!t->frozen); + Assert(t->key_size <= sizeof(carry_key)); + Assert(t->payload_size <= sizeof(carry_payload)); + + /* Grow before insert if at threshold. */ + if (t->size >= t->max_size) + agehash_grow(t); + + /* Initialize carry buffers with the caller's key and an empty payload. */ + memcpy(carry_key, key, t->key_size); + memset(carry_payload, 0, t->payload_size); + + i = hashvalue & t->capacity_mask; + d = 0; + + for (;;) + { + char *slot = slot_at(t, i); + uint16 sd = slot_probe_dist(slot); + + if (sd == AGEHASH_EMPTY) + { + /* Place the carrier here and we're done. */ + slot_set_probe_dist(slot, d); + memcpy(slot_key_ptr(t, slot), carry_key, t->key_size); + memcpy(slot_payload_ptr(t, slot), carry_payload, t->payload_size); + t->size++; + if (!placed_caller) + { + /* The caller's slot landed here. */ + if (found != NULL) + *found = false; + return slot_payload_ptr(t, slot); + } + /* + * The caller was placed earlier via a swap; result_payload + * already points at their final slot. + */ + Assert(result_payload != NULL); + return result_payload; + } + + if (sd == d && + !placed_caller && + t->keyeq_fn(slot_key_ptr(t, slot), carry_key, t->key_size)) + { + /* + * Existing entry with the caller's key. Note: this match check + * is only relevant before we've performed a swap; once we've + * placed the caller into a slot, the key in `carry` is some + * displaced entry that, by RH invariant on insert from a fresh + * key, cannot already exist in the table. + */ + if (found != NULL) + *found = true; + return slot_payload_ptr(t, slot); + } + + if (sd < d) + { + /* + * Rich-poor swap: this slot's owner is closer to its ideal + * bucket than we are. Take its place and continue with the + * displaced entry. If we have not yet placed the caller, this + * is where they end up; remember the pointer so we can return + * it once the displaced chain finishes. + */ + char tmp_key[64]; + char tmp_payload[4096]; + uint16 tmp_d = sd; + + memcpy(tmp_key, slot_key_ptr(t, slot), t->key_size); + memcpy(tmp_payload, slot_payload_ptr(t, slot), t->payload_size); + + slot_set_probe_dist(slot, d); + memcpy(slot_key_ptr(t, slot), carry_key, t->key_size); + memcpy(slot_payload_ptr(t, slot), carry_payload, t->payload_size); + + if (!placed_caller) + { + placed_caller = true; + result_payload = slot_payload_ptr(t, slot); + /* Notify caller: this insert is a fresh entry. */ + if (found != NULL) + { + *found = false; + found = NULL; /* don't write again */ + } + } + + /* Continue with the displaced entry as the new carrier. */ + memcpy(carry_key, tmp_key, t->key_size); + memcpy(carry_payload, tmp_payload, t->payload_size); + d = tmp_d; + } + + i = (i + 1) & t->capacity_mask; + d++; + + /* + * Probe distance overflow guard. With AGEHASH_MAX_LOAD = 0.85 and a + * non-degenerate hash function, max probe is expected to remain far + * below this limit in practice. The 0xFE00 ceiling reserves + * headroom while leaving probe_dist well clear of the AGEHASH_EMPTY + * sentinel. + */ + Assert(d < 0xFE00); + if (unlikely(d >= 0xFE00)) + elog(ERROR, "agehash: probe distance overflow (likely a bad hash function)"); + } +} + +void * +agehash_insert(AgeHashTable *t, const void *key, bool *found) +{ + uint32 h = t->hash_fn(key, t->key_size); + return agehash_insert_internal(t, key, h, found); +} + +void * +agehash_insert_with_hash(AgeHashTable *t, const void *key, + uint32 hashvalue, bool *found) +{ + return agehash_insert_internal(t, key, hashvalue, found); +} + +/* ------------------------------------------------------------------------- */ +/* Grow: double the capacity and rehash. */ + +static void +agehash_grow(AgeHashTable *t) +{ + char *old_slots; + uint32 old_cap; + uint32 old_slot_size; + uint32 new_cap; + MemoryContext oldctx; + uint32 i; + + old_slots = t->slots; + old_cap = t->capacity; + old_slot_size = t->slot_size; + + if (t->frozen) + { + elog(ERROR, "agehash: grow on frozen table"); + } + Assert(!t->frozen); + if (old_cap > (UINT32_MAX >> 1)) + { + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("agehash capacity overflow: cannot grow beyond %u slots", + old_cap))); + } + new_cap = old_cap << 1; + Assert(new_cap > old_cap); /* overflow guard */ + + oldctx = MemoryContextSwitchTo(t->mcxt); + + t->capacity = new_cap; + t->capacity_mask = new_cap - 1; + t->max_size = (uint32) ((double) new_cap * AGEHASH_MAX_LOAD); + /* HUGE allocator: see agehash_create_inline for the rationale. */ + t->slots = (char *) MemoryContextAllocHuge(t->mcxt, + (Size) new_cap * t->slot_size); + for (i = 0; i < new_cap; i++) + slot_set_probe_dist(slot_at(t, i), AGEHASH_EMPTY); + + /* Reset size; we re-insert below (which will increment it). */ + t->size = 0; + for (i = 0; i < old_cap; i++) + { + char *src = old_slots + (Size) i * old_slot_size; + if (slot_probe_dist(src) != AGEHASH_EMPTY) + { + void *src_key = src + AGEHASH_SLOT_KEY_OFFSET; + void *src_payload = src + t->payload_offset; + uint32 h = t->hash_fn(src_key, t->key_size); + void *dst_payload = agehash_insert_internal(t, src_key, h, NULL); + memcpy(dst_payload, src_payload, t->payload_size); + } + } + + pfree(old_slots); + MemoryContextSwitchTo(oldctx); +} + +/* ------------------------------------------------------------------------- */ +/* Lookup. */ + +void * +agehash_lookup_with_hash(AgeHashTable *t, const void *key, uint32 hashvalue) +{ + uint32 i = hashvalue & t->capacity_mask; + uint16 d = 0; + + for (;;) + { + char *slot = slot_at(t, i); + uint16 sd = slot_probe_dist(slot); + + if (sd == AGEHASH_EMPTY) + return NULL; + /* + * Robin Hood invariant: probe_dist values along a probe sequence + * are non-increasing as we move from an entry's home slot. If the + * slot we land on has a smaller probe_dist than ours, the key + * we're looking for can't be anywhere later in the sequence. + */ + if (sd < d) + return NULL; + if (t->keyeq_fn(slot_key_ptr(t, slot), key, t->key_size)) + return slot_payload_ptr(t, slot); + + i = (i + 1) & t->capacity_mask; + d++; + Assert(d < 0xFE00); + } +} + +void * +agehash_lookup(AgeHashTable *t, const void *key) +{ + uint32 h = t->hash_fn(key, t->key_size); + return agehash_lookup_with_hash(t, key, h); +} + +/* ------------------------------------------------------------------------- */ +/* Misc accessors. */ + +void +agehash_freeze(AgeHashTable *t) +{ + t->frozen = true; +} + +bool +agehash_is_frozen(const AgeHashTable *t) +{ + return t->frozen; +} + +uint32 +agehash_size(const AgeHashTable *t) +{ + return t->size; +} + +uint32 +agehash_capacity(const AgeHashTable *t) +{ + return t->capacity; +} + +void +agehash_iter_init(AgeHashTable *t, AgeHashIter *it) +{ + it->t = t; + it->idx = 0; + it->key = NULL; + it->payload = NULL; +} + +bool +agehash_iter_next(AgeHashIter *it) +{ + AgeHashTable *t = it->t; + while (it->idx < t->capacity) + { + char *slot = slot_at(t, it->idx); + uint32 idx = it->idx++; + (void) idx; + if (slot_probe_dist(slot) != AGEHASH_EMPTY) + { + it->key = slot_key_ptr(t, slot); + it->payload = slot_payload_ptr(t, slot); + return true; + } + } + it->key = NULL; + it->payload = NULL; + return false; +} + +/* ------------------------------------------------------------------------- */ +/* Self-test. Exercises insert / lookup / grow / iterate at small + medium + * sizes and verifies invariants. Returns a string in CurrentMemoryContext. */ + +/* MurmurHash3 fmix64, identical to graphid_hash. */ +static uint32 +selftest_hash(const void *key, Size keysize) +{ + uint64 k; + Assert(keysize == sizeof(uint64)); + memcpy(&k, key, sizeof(uint64)); + k ^= k >> 33; + k *= UINT64CONST(0xff51afd7ed558ccd); + k ^= k >> 33; + k *= UINT64CONST(0xc4ceb9fe1a85ec53); + k ^= k >> 33; + return (uint32) k; +} + +static bool +selftest_keyeq(const void *a, const void *b, Size keysize) +{ + return memcmp(a, b, keysize) == 0; +} + +typedef struct selftest_payload +{ + uint64 mirror_key; + uint64 marker; +} selftest_payload; + +static const char * +selftest_run_one(MemoryContext parent, uint32 n, uint32 hint) +{ + MemoryContext mcxt; + AgeHashTable *t; + selftest_payload *p; + bool found; + uint32 i; + uint32 seen; + AgeHashIter it; + + mcxt = AllocSetContextCreate(parent, "agehash selftest", ALLOCSET_DEFAULT_SIZES); + t = agehash_create_inline(mcxt, sizeof(uint64), sizeof(selftest_payload), + hint, selftest_hash, selftest_keyeq); + + /* Insert n keys. */ + for (i = 0; i < n; i++) + { + uint64 k = ((uint64) 0xa5a5 << 48) | (i + 1); + p = (selftest_payload *) agehash_insert(t, &k, &found); + if (found) + { + MemoryContextDelete(mcxt); + return psprintf("FAIL: duplicate insert at i=%u", i); + } + p->mirror_key = k; + p->marker = (uint64) 0xdeadbeef00000000ULL | i; + } + if (agehash_size(t) != n) + { + MemoryContextDelete(mcxt); + return psprintf("FAIL: size %u != %u after inserts", + agehash_size(t), n); + } + + /* Lookup all n keys. */ + for (i = 0; i < n; i++) + { + uint64 k = ((uint64) 0xa5a5 << 48) | (i + 1); + p = (selftest_payload *) agehash_lookup(t, &k); + if (p == NULL) + { + MemoryContextDelete(mcxt); + return psprintf("FAIL: lookup miss at i=%u", i); + } + if (p->mirror_key != k || + p->marker != ((uint64) 0xdeadbeef00000000ULL | i)) + { + MemoryContextDelete(mcxt); + return psprintf("FAIL: payload corruption at i=%u", i); + } + } + + /* Lookup n keys that should not exist. */ + for (i = 0; i < n; i++) + { + uint64 k = ((uint64) 0xb6b6 << 48) | (i + 1); + p = (selftest_payload *) agehash_lookup(t, &k); + if (p != NULL) + { + MemoryContextDelete(mcxt); + return psprintf("FAIL: phantom lookup hit at i=%u", i); + } + } + + /* Re-insert (HASH_ENTER semantics) — should report found = true. */ + for (i = 0; i < n; i++) + { + uint64 k = ((uint64) 0xa5a5 << 48) | (i + 1); + p = (selftest_payload *) agehash_insert(t, &k, &found); + if (!found) + { + MemoryContextDelete(mcxt); + return psprintf("FAIL: re-insert reported !found at i=%u", i); + } + if (p->mirror_key != k) + { + MemoryContextDelete(mcxt); + return psprintf("FAIL: re-insert payload mismatch at i=%u", i); + } + } + if (agehash_size(t) != n) + { + MemoryContextDelete(mcxt); + return psprintf("FAIL: size %u != %u after re-inserts", + agehash_size(t), n); + } + + /* Iterate and count. */ + seen = 0; + agehash_iter_init(t, &it); + while (agehash_iter_next(&it)) + { + selftest_payload *pp = it.payload; + uint64 k; + memcpy(&k, it.key, sizeof(uint64)); + if (pp->mirror_key != k) + { + MemoryContextDelete(mcxt); + return psprintf("FAIL: iter payload mismatch at seen=%u", seen); + } + seen++; + } + if (seen != n) + { + MemoryContextDelete(mcxt); + return psprintf("FAIL: iter saw %u of %u", seen, n); + } + + /* Freeze and confirm lookups still work. */ + agehash_freeze(t); + if (!agehash_is_frozen(t)) + { + MemoryContextDelete(mcxt); + return "FAIL: agehash_is_frozen returned false after freeze"; + } + { + uint64 k = ((uint64) 0xa5a5 << 48) | 1; + p = (selftest_payload *) agehash_lookup(t, &k); + if (p == NULL) + { + MemoryContextDelete(mcxt); + return "FAIL: lookup failed after freeze"; + } + } + + MemoryContextDelete(mcxt); + return NULL; /* OK */ +} + +const char * +agehash_self_test(void) +{ + static const struct { uint32 n; uint32 hint; } cases[] = { + { 1, 0 }, + { 7, 0 }, + { 8, 0 }, + { 9, 0 }, + { 63, 0 }, + { 64, 0 }, + { 65, 0 }, + { 1023, 0 }, /* forces grow from 64 floor */ + { 1024, 0 }, + { 1025, 0 }, + { 10000, 0 }, /* forces multiple grows */ + { 10000, 8192 }, /* with capacity hint, no grow expected */ + { 50000, 0 }, /* larger; multiple grows */ + { 1000000, 0 }, /* exercises grow at multi-MB allocations */ + /* + * NOTE: this set is bounded so 'make installcheck' completes + * quickly. The library has been manually verified up to 256M + * entries (multi-GiB slot arrays via MemoryContextAllocHuge). + */ + }; + const size_t ncases = sizeof(cases) / sizeof(cases[0]); + size_t i; + + for (i = 0; i < ncases; i++) + { + const char *r = selftest_run_one(CurrentMemoryContext, + cases[i].n, cases[i].hint); + if (r != NULL) + return psprintf("%s [n=%u hint=%u]", r, cases[i].n, cases[i].hint); + } + return "OK"; +} + +/* ------------------------------------------------------------------------- */ +/* SQL-callable wrapper: SELECT ag_catalog._agehash_self_test(); */ + +PG_FUNCTION_INFO_V1(_agehash_self_test); + +Datum +_agehash_self_test(PG_FUNCTION_ARGS) +{ + const char *r = agehash_self_test(); + PG_RETURN_TEXT_P(cstring_to_text(r)); +} diff --git a/src/backend/utils/load/ag_load_edges.c b/src/backend/utils/load/ag_load_edges.c index c05bf3352..01585bab0 100644 --- a/src/backend/utils/load/ag_load_edges.c +++ b/src/backend/utils/load/ag_load_edges.c @@ -56,6 +56,24 @@ static void process_edge_row(char **fields, int nfields, char *end_vertex_type; agtype *edge_properties; + /* + * Guard the fixed fields[0..3] accesses below and the header[i]/fields[i] + * pairing in create_agtype_from_list_i() against out-of-bounds reads on + * malformed or mis-delimited rows. A row must have at least the 4 fixed + * columns and no more columns than the header (rows with fewer trailing + * property columns than the header are allowed, matching existing + * behavior). A single-column row from a non-comma-delimited file is + * rejected here (previously it segfaulted). + */ + if (nfields < 4 || nfields > header_count) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("edge file row has %d columns; expected at least 4 " + "and no more than the header's %d columns", + nfields, header_count))); + } + /* Generate edge ID */ entry_id = nextval_internal(label_seq_relid, true); edge_id = make_graphid(label_id, entry_id); @@ -219,6 +237,27 @@ int create_edges_from_csv_file(char *file_path, header[i] = trim_whitespace(fields[i]); } + /* + * Edge files require the four fixed columns start_id, + * start_vertex_type, end_id and end_vertex_type. A smaller + * count almost always means the file is not comma-delimited + * (COPY defaults to comma). Fail clearly here instead of + * reading past the parsed fields in process_edge_row(), which + * previously caused a segfault. + */ + if (header_count < 4) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("edge file must have at least 4 columns " + "(start_id, start_vertex_type, end_id, " + "end_vertex_type), but the header has %d", + header_count), + errhint("load_edges_from_file expects a " + "comma-delimited CSV; check the file's " + "delimiter."))); + } + is_first_row = false; } else diff --git a/src/backend/utils/load/ag_load_labels.c b/src/backend/utils/load/ag_load_labels.c index 5b11f68b8..236d47a1d 100644 --- a/src/backend/utils/load/ag_load_labels.c +++ b/src/backend/utils/load/ag_load_labels.c @@ -46,6 +46,23 @@ static void process_vertex_row(char **fields, int nfields, TupleTableSlot *slot; agtype *vertex_properties; + /* + * Guard the header[i]/fields[i] pairing in create_agtype_from_list() + * against out-of-bounds reads on malformed rows that have more fields + * than the header. Rows with fewer fields than the header are allowed + * (matching existing behavior). Note: a file delimited by something + * other than comma is parsed as a single column throughout, so header + * and rows still match and the data lands in properties verbatim -- + * specifying the delimiter is the separate fix for that. + */ + if (nfields > header_count) + { + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("label file row has %d columns, more than the " + "header's %d columns", nfields, header_count))); + } + /* Generate or use provided entry_id */ if (id_field_exists) { diff --git a/src/include/catalog/ag_catalog.h b/src/include/catalog/ag_catalog.h index 56aa84700..a9ced279d 100644 --- a/src/include/catalog/ag_catalog.h +++ b/src/include/catalog/ag_catalog.h @@ -24,6 +24,8 @@ #include "utils/agtype.h" +bool is_age_extension_exists(void); + void object_access_hook_init(void); void object_access_hook_fini(void); diff --git a/src/include/catalog/ag_label.h b/src/include/catalog/ag_label.h index 0a8480b1a..e6b9a1ca7 100644 --- a/src/include/catalog/ag_label.h +++ b/src/include/catalog/ag_label.h @@ -74,7 +74,7 @@ Oid get_label_relation(const char *label_name, Oid graph_oid); char *get_label_relation_name(const char *label_name, Oid graph_oid); char get_label_kind(const char *label_name, Oid label_graph); char *get_label_seq_relation_name(const char *label_name); - +char *get_label_name(int32 label_id, Oid graph_oid); bool label_id_exists(Oid graph_oid, int32 label_id); RangeVar *get_label_range_var(char *graph_name, Oid graph_oid, diff --git a/src/include/executor/cypher_utils.h b/src/include/executor/cypher_utils.h index fc4067455..8b65bc964 100644 --- a/src/include/executor/cypher_utils.h +++ b/src/include/executor/cypher_utils.h @@ -108,8 +108,18 @@ typedef struct cypher_merge_custom_scan_state bool found_a_path; CommandId base_currentCommandId; struct created_path *created_paths_list; + List *eager_tuples; + int eager_tuples_index; + bool eager_buffer_filled; + cypher_update_information *on_match_set_info; /* NULL if not specified */ + cypher_update_information *on_create_set_info; /* NULL if not specified */ } cypher_merge_custom_scan_state; +/* Reusable SET logic callable from MERGE executor */ +void apply_update_list(CustomScanState *node, + cypher_update_information *set_info); + +void clear_entity_slot(TupleTableSlot *elemTupleSlot); TupleTableSlot *populate_vertex_tts(TupleTableSlot *elemTupleSlot, agtype_value *id, agtype_value *properties); TupleTableSlot *populate_edge_tts( @@ -149,4 +159,10 @@ typedef struct RLSCacheEntry List *withCheckOptionExprs; } RLSCacheEntry; +/* Hash table entry for caching index OIDs per label */ +typedef struct IndexCacheEntry { + Oid relid; /* hash key */ + Oid index_oid; +} IndexCacheEntry; + #endif diff --git a/src/include/nodes/ag_nodes.h b/src/include/nodes/ag_nodes.h index 121832c01..57b2deabc 100644 --- a/src/include/nodes/ag_nodes.h +++ b/src/include/nodes/ag_nodes.h @@ -75,7 +75,11 @@ typedef enum ag_node_tag /* delete data structures */ cypher_delete_information_t, cypher_delete_item_t, - cypher_merge_information_t + cypher_merge_information_t, + /* predicate functions */ + cypher_predicate_function_t, + /* reduce */ + cypher_reduce_t } ag_node_tag; extern const char *node_names[]; diff --git a/src/include/nodes/cypher_copyfuncs.h b/src/include/nodes/cypher_copyfuncs.h index d7ed7eff7..fcad598b7 100644 --- a/src/include/nodes/cypher_copyfuncs.h +++ b/src/include/nodes/cypher_copyfuncs.h @@ -52,4 +52,12 @@ void copy_cypher_delete_item(ExtensibleNode *newnode, /* merge data structure */ void copy_cypher_merge_information(ExtensibleNode *newnode, const ExtensibleNode *from); + +/* predicate function data structure */ +void copy_cypher_predicate_function(ExtensibleNode *newnode, + const ExtensibleNode *from); + +/* reduce data structure */ +void copy_cypher_reduce(ExtensibleNode *newnode, + const ExtensibleNode *from); #endif diff --git a/src/include/nodes/cypher_nodes.h b/src/include/nodes/cypher_nodes.h index db47eb313..5efbe95f7 100644 --- a/src/include/nodes/cypher_nodes.h +++ b/src/include/nodes/cypher_nodes.h @@ -124,6 +124,8 @@ typedef struct cypher_merge { ExtensibleNode extensible; Node *path; + List *on_match; /* List of cypher_set_item, or NIL */ + List *on_create; /* List of cypher_set_item, or NIL */ } cypher_merge; /* @@ -224,6 +226,45 @@ typedef struct cypher_map_projection Node *mapping_expr; } cypher_list_comprehension; +/* + * Predicate function kinds for all(), any(), none(), single(). + * These take the form: func(variable IN list WHERE predicate) + */ +typedef enum cypher_predicate_function_kind +{ + CPFK_ALL = 0, + CPFK_ANY, + CPFK_NONE, + CPFK_SINGLE +} cypher_predicate_function_kind; + +typedef struct cypher_predicate_function +{ + ExtensibleNode extensible; + cypher_predicate_function_kind kind; + char *varname; + Node *expr; /* the list to iterate over */ + Node *where; /* the predicate to test */ +} cypher_predicate_function; + +/* + * reduce(acc = init, var IN list | body) + * + * Folds `body` over `list`, threading an accumulator `acc` (seeded with + * `init`) across the elements in list order, binding each element to `var`. + * Transformed into a correlated scalar subquery over an ordered aggregate + * by transform_cypher_reduce() in cypher_clause.c. + */ +typedef struct cypher_reduce +{ + ExtensibleNode extensible; + char *acc_varname; /* accumulator variable name */ + Node *init_expr; /* initial accumulator value */ + char *elem_varname; /* per-element variable name */ + Node *list_expr; /* the list to fold over */ + Node *body_expr; /* the fold expression evaluated per element */ +} cypher_reduce; + typedef enum cypher_map_projection_element_type { PROPERTY_SELECTOR = 0, /* map_var { .key } */ @@ -451,6 +492,9 @@ typedef struct cypher_update_item List *qualified_name; bool remove_item; bool is_add; + Node *prop_expr; /* SET value expression, used by MERGE ON CREATE/MATCH SET + * where the expression is not in the plan's target list */ + ExprState *prop_expr_state; /* initialized at plan init, not per-row */ } cypher_update_item; typedef struct cypher_delete_information @@ -477,6 +521,8 @@ typedef struct cypher_merge_information uint32 graph_oid; AttrNumber merge_function_attr; cypher_create_path *path; + cypher_update_information *on_match_set_info; /* NULL if no ON MATCH SET */ + cypher_update_information *on_create_set_info; /* NULL if no ON CREATE SET */ } cypher_merge_information; /* grammar node for typecasts */ diff --git a/src/include/nodes/cypher_outfuncs.h b/src/include/nodes/cypher_outfuncs.h index 418d35f4e..fc2a830b9 100644 --- a/src/include/nodes/cypher_outfuncs.h +++ b/src/include/nodes/cypher_outfuncs.h @@ -49,6 +49,8 @@ void out_cypher_map(StringInfo str, const ExtensibleNode *node); void out_cypher_map_projection(StringInfo str, const ExtensibleNode *node); void out_cypher_list(StringInfo str, const ExtensibleNode *node); void out_cypher_list_comprehension(StringInfo str, const ExtensibleNode *node); +void out_cypher_predicate_function(StringInfo str, const ExtensibleNode *node); +void out_cypher_reduce(StringInfo str, const ExtensibleNode *node); /* comparison expression */ void out_cypher_comparison_aexpr(StringInfo str, const ExtensibleNode *node); diff --git a/src/include/nodes/cypher_readfuncs.h b/src/include/nodes/cypher_readfuncs.h index 5792332ac..2f27f91cd 100644 --- a/src/include/nodes/cypher_readfuncs.h +++ b/src/include/nodes/cypher_readfuncs.h @@ -51,4 +51,10 @@ void read_cypher_delete_item(struct ExtensibleNode *node); void read_cypher_merge_information(struct ExtensibleNode *node); +/* predicate function data structure */ +void read_cypher_predicate_function(struct ExtensibleNode *node); + +/* reduce data structure */ +void read_cypher_reduce(struct ExtensibleNode *node); + #endif diff --git a/src/include/parser/cypher_clause.h b/src/include/parser/cypher_clause.h index 461a664ca..d0fc3ca22 100644 --- a/src/include/parser/cypher_clause.h +++ b/src/include/parser/cypher_clause.h @@ -21,6 +21,7 @@ #define AG_CYPHER_CLAUSE_H #include "parser/cypher_parse_node.h" +#include "parser/cypher_transform_entity.h" typedef struct cypher_clause cypher_clause; @@ -39,4 +40,12 @@ Query *cypher_parse_sub_analyze(Node *parseTree, CommonTableExpr *parentCTE, bool locked_from_parent, bool resolve_unknowns); + +/* + * Helper to get field info for vertex/edge entity field access + */ +void get_record_field_info(char *field_name, Oid entity_type, + AttrNumber *fieldnum, Oid *fieldtype); +FieldSelect *make_field_select(Var *var, AttrNumber fieldnum, Oid resulttype); + #endif diff --git a/src/include/parser/cypher_expr.h b/src/include/parser/cypher_expr.h index cde40cc87..e069abc6d 100644 --- a/src/include/parser/cypher_expr.h +++ b/src/include/parser/cypher_expr.h @@ -29,5 +29,10 @@ Node *transform_cypher_expr(cypher_parsestate *cpstate, Node *expr, ParseExprKind expr_kind); +Node *make_properties_expr(Node *prop_var); +bool is_vertex_or_edge(Node *node); +Node *extract_field_from_record(Node *node, char *field_name); +Node *coerce_entity_to_agtype(ParseState *pstate, Node *node); +void coerce_target_entities_to_agtype(ParseState *pstate, List *target_list); #endif diff --git a/src/include/parser/cypher_kwlist.h b/src/include/parser/cypher_kwlist.h index e4c4437ba..909b5d272 100644 --- a/src/include/parser/cypher_kwlist.h +++ b/src/include/parser/cypher_kwlist.h @@ -1,6 +1,7 @@ PG_KEYWORD("all", ALL, RESERVED_KEYWORD) PG_KEYWORD("analyze", ANALYZE, RESERVED_KEYWORD) PG_KEYWORD("and", AND, RESERVED_KEYWORD) +PG_KEYWORD("any", ANY_P, RESERVED_KEYWORD) PG_KEYWORD("as", AS, RESERVED_KEYWORD) PG_KEYWORD("asc", ASC, RESERVED_KEYWORD) PG_KEYWORD("ascending", ASCENDING, RESERVED_KEYWORD) @@ -27,15 +28,19 @@ PG_KEYWORD("is", IS, RESERVED_KEYWORD) PG_KEYWORD("limit", LIMIT, RESERVED_KEYWORD) PG_KEYWORD("match", MATCH, RESERVED_KEYWORD) PG_KEYWORD("merge", MERGE, RESERVED_KEYWORD) +PG_KEYWORD("none", NONE, RESERVED_KEYWORD) PG_KEYWORD("not", NOT, RESERVED_KEYWORD) PG_KEYWORD("null", NULL_P, RESERVED_KEYWORD) +PG_KEYWORD("on", ON, RESERVED_KEYWORD) PG_KEYWORD("operator", OPERATOR, RESERVED_KEYWORD) PG_KEYWORD("optional", OPTIONAL, RESERVED_KEYWORD) PG_KEYWORD("or", OR, RESERVED_KEYWORD) PG_KEYWORD("order", ORDER, RESERVED_KEYWORD) +PG_KEYWORD("reduce", REDUCE, RESERVED_KEYWORD) PG_KEYWORD("remove", REMOVE, RESERVED_KEYWORD) PG_KEYWORD("return", RETURN, RESERVED_KEYWORD) PG_KEYWORD("set", SET, RESERVED_KEYWORD) +PG_KEYWORD("single", SINGLE, RESERVED_KEYWORD) PG_KEYWORD("skip", SKIP, RESERVED_KEYWORD) PG_KEYWORD("starts", STARTS, RESERVED_KEYWORD) PG_KEYWORD("then", THEN, RESERVED_KEYWORD) diff --git a/src/include/parser/cypher_transform_entity.h b/src/include/parser/cypher_transform_entity.h index d24d4c372..d0492210f 100644 --- a/src/include/parser/cypher_transform_entity.h +++ b/src/include/parser/cypher_transform_entity.h @@ -75,6 +75,17 @@ typedef struct * declared by itself or a previous clause. */ bool declared_in_current_clause; + + /* + * S5: for ENT_VLE_EDGE entities, the auto-generated alias of the + * RangeFunction added to the FROM clause for the age_vle SRF. + * Used to build ColumnRefs for the SRF's start_id/end_id output + * columns when emitting the terminal-edge join quals as plain + * graphid equality A_Exprs (replacing the per-row + * age_match_vle_terminal_edge function call). NULL for non-VLE + * entities. + */ + char *vle_alias; /* The parse data structure that we transformed */ union { diff --git a/src/include/utils/age_global_graph.h b/src/include/utils/age_global_graph.h index 2b336a411..d68530a91 100644 --- a/src/include/utils/age_global_graph.h +++ b/src/include/utils/age_global_graph.h @@ -22,6 +22,25 @@ #include "utils/age_graphid_ds.h" +/* + * Flat dynamic-array adjacency container for vertex edges. Replaces a + * linked-list (ListGraphId) of GraphIdNodes for vertex_entry::edges_*. + * + * Storage: a single palloc'd graphid array, doubled on growth. The struct + * itself is embedded by value in vertex_entry so that the (array, size, + * capacity) triple lives in the same cache line as the surrounding entry + * fields, saving one indirection on the DFS hot path. + * + * Empty arrays carry array == NULL, size == 0, capacity == 0 and incur no + * allocation until the first append. + */ +typedef struct VertexEdgeArray +{ + graphid *array; /* contiguous edge graphid array; NULL when empty */ + int32 size; /* number of edges currently stored */ + int32 capacity; /* allocated capacity (in graphid slots) */ +} VertexEdgeArray; + /* * We declare the graph nodes and edges here, and in this way, so that it may be * used elsewhere. However, we keep the contents private by defining it in @@ -46,17 +65,53 @@ ListGraphId *get_graph_vertices(GRAPH_global_context *ggctx); vertex_entry *get_vertex_entry(GRAPH_global_context *ggctx, graphid vertex_id); edge_entry *get_edge_entry(GRAPH_global_context *ggctx, graphid edge_id); + +/* + * Variant of get_edge_entry that accepts a precomputed hash value, allowing + * the same hash to be reused across multiple lookups of the same graphid + * (e.g. edge_state_hashtable + edge_hashtable in the VLE DFS hot loop). + */ +edge_entry *get_edge_entry_with_hash(GRAPH_global_context *ggctx, + graphid edge_id, uint32 hashvalue); /* vertex entry accessor functions*/ graphid get_vertex_entry_id(vertex_entry *ve); -ListGraphId *get_vertex_entry_edges_in(vertex_entry *ve); -ListGraphId *get_vertex_entry_edges_out(vertex_entry *ve); -ListGraphId *get_vertex_entry_edges_self(vertex_entry *ve); Oid get_vertex_entry_label_table_oid(vertex_entry *ve); Datum get_vertex_entry_properties(vertex_entry *ve); + +/* + * Flat-array adjacency accessors. Returned pointer is into the entry's + * embedded VertexEdgeArray and is therefore non-NULL for a valid entry, + * but the underlying VertexEdgeArray::array may be NULL when size == 0. + */ +VertexEdgeArray *get_vertex_entry_edges_out_array(vertex_entry *ve); +VertexEdgeArray *get_vertex_entry_edges_in_array(vertex_entry *ve); +VertexEdgeArray *get_vertex_entry_edges_self_array(vertex_entry *ve); /* edge entry accessor functions */ graphid get_edge_entry_id(edge_entry *ee); Oid get_edge_entry_label_table_oid(edge_entry *ee); Datum get_edge_entry_properties(edge_entry *ee); graphid get_edge_entry_start_vertex_id(edge_entry *ee); graphid get_edge_entry_end_vertex_id(edge_entry *ee); + +/* Graph version counter functions — shared memory (DSM or shmem) */ +uint64 get_graph_version(Oid graph_oid); +void increment_graph_version(Oid graph_oid); +Oid get_graph_oid_for_table(Oid table_oid); + +/* + * Fast hash function for graphid (int64) keys used in dynahash tables. + * Replaces tag_hash with the MurmurHash3 fmix64 finalizer for better + * distribution and lower instruction count on modern x86_64. + */ +uint32 graphid_hash(const void *key, Size keysize); + +/* Equality predicate for graphid (int64) keys; agehash_keyeq_fn signature. */ +bool graphid_keyeq(const void *a, const void *b, Size keysize); + +/* Shared memory initialization for PG < 17 (shmem_request_hook path) */ +#if PG_VERSION_NUM < 170000 +void age_graph_version_shmem_request(void); +void age_graph_version_shmem_startup(void); +#endif + #endif diff --git a/src/include/utils/age_graphid_ds.h b/src/include/utils/age_graphid_ds.h index a5bb5273f..ebd1290d0 100644 --- a/src/include/utils/age_graphid_ds.h +++ b/src/include/utils/age_graphid_ds.h @@ -40,6 +40,21 @@ typedef struct GraphIdNode GraphIdNode; /* declare the ListGraphId container */ typedef struct ListGraphId ListGraphId; +/* + * Array-based stack for graphid values. Used by VLE DFS traversal + * for the vertex, edge, and path stacks. Provides O(1) push/pop + * with sequential memory access (no linked-list pointer chasing). + * + * This is intentionally separate from ListGraphId, which is used + * for vertex adjacency lists in the global graph context. + */ +typedef struct GraphIdStack +{ + graphid *array; /* contiguous graphid array */ + int64 size; /* current number of elements */ + int64 capacity; /* allocated capacity */ +} GraphIdStack; + /* GraphIdNode access functions */ GraphIdNode *next_GraphIdNode(GraphIdNode *node); graphid get_graphid(GraphIdNode *node); @@ -73,4 +88,14 @@ GraphIdNode *get_list_head(ListGraphId *list); /* get the size of the passed list */ int64 get_list_size(ListGraphId *list); +/* GraphIdStack functions — array-based stack for VLE DFS */ +GraphIdStack *new_gid_stack(void); +void free_gid_stack(GraphIdStack *stack); +void gid_stack_push(GraphIdStack *s, graphid id); +graphid gid_stack_pop(GraphIdStack *s); +graphid gid_stack_peek(GraphIdStack *s); +bool gid_stack_is_empty(GraphIdStack *s); +int64 gid_stack_size(GraphIdStack *s); +graphid gid_stack_get(GraphIdStack *s, int64 i); + #endif diff --git a/src/include/utils/agehash.h b/src/include/utils/agehash.h new file mode 100644 index 000000000..9f0ba2793 --- /dev/null +++ b/src/include/utils/agehash.h @@ -0,0 +1,216 @@ +/* + * 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. + */ + +/* + * agehash.h - Robin Hood open-addressing hashtable for AGE's hot-path caches. + * + * This is an internal utility used by the global graph cache (vertex and edge + * tables) to replace dynahash on the lookup-dominated read-only-after-build + * path. Each lookup traverses a single contiguous slot array with no chain + * pointer-chasing, which on AGE workloads roughly halves lookup latency + * relative to dynahash (see Stage 5 microbench). + * + * Mode: only AGEHASH_INLINE is supported by this initial revision. INLINE + * stores the payload directly in the slot, suitable for tables that are + * never mutated after the build phase. A future revision will add an + * AGEHASH_INDIRECT mode for tables that support insert and pointer-stable + * payloads during queries. + * + * Memory: every allocation lives in a caller-supplied MemoryContext. Free is + * a single MemoryContextDelete by the caller; agehash itself never frees + * piecewise. This makes leak-on-elog impossible: PG unwinds the context. + * + * Capacity: always a power of two; grown by doubling when size exceeds + * AGEHASH_MAX_LOAD * capacity. After agehash_freeze() is called, all + * insert paths are forbidden (asserted in DEBUG builds), which guarantees + * that lookups can never observe a partially-rehashed structure. + */ + +#ifndef AG_AGEHASH_H +#define AG_AGEHASH_H + +#include "postgres.h" +#include "utils/memutils.h" +#include "utils/palloc.h" + +/* Sentinel probe distance marking an empty slot. */ +#define AGEHASH_EMPTY 0xFFFFu + +/* + * Load factor above which we grow. + * + * 0.85 balances three goals on AGE's hot tables: + * - Memory: the edge_table on SF3 is ~52M entries; at 0.85 the slot array + * is ~67M slots which is roughly the same total bytes as the dynahash + * bucket array + per-entry HASHELEMENT chain headers it replaces. + * - Probe distance: Robin Hood at 0.85 still keeps average probes near 1 + * and max probes well below the 0xFE00 overflow guard. + * - Grow cadence: a higher threshold means fewer doublings during the + * edge cache build (each doubling rehashes the entire table). + * + * If you change this, re-run the rh_microbench harness on the VM and the + * SF3 paired benchmark; both are sensitive to the load factor. + */ +#define AGEHASH_MAX_LOAD 0.85 + +/* + * Caller-supplied hash callback. keysize is constant for a given table; we + * still pass it so callers can reuse one function across multiple tables + * with different key types if desired. + */ +typedef uint32 (*agehash_hash_fn)(const void *key, Size keysize); + +/* Caller-supplied key-equality callback. Returns true iff a == b. */ +typedef bool (*agehash_keyeq_fn)(const void *a, const void *b, Size keysize); + +/* + * Layout mode. Only INLINE is implemented in this revision; INDIRECT is + * declared so the public enum values stay stable when it lands. + */ +typedef enum AgeHashMode +{ + AGEHASH_INLINE = 0, + AGEHASH_INDIRECT = 1 +} AgeHashMode; + +/* Opaque table handle. */ +typedef struct AgeHashTable AgeHashTable; + +/* + * Slot layout (INLINE mode), packed: + * + * offset 0 : uint16 probe_dist (AGEHASH_EMPTY == empty) + * offset 2 : uint16 _reserved (future flags / tombstones) + * offset 4 : uint32 _pad (force key to 8-byte alignment) + * offset 8 : key (key_size bytes) + * offset 8+key_size : payload (payload_size bytes) + * + * The header is 8 bytes; total slot bytes = 8 + key_size + payload_size, + * rounded up to MAXIMUM_ALIGNOF. + */ + +#define AGEHASH_SLOT_HDR_BYTES 8 +#define AGEHASH_SLOT_KEY_OFFSET AGEHASH_SLOT_HDR_BYTES + +/* + * Recover a key pointer from a payload pointer. INLINE-mode tables store + * the key immediately before the payload, so this is pure pointer + * arithmetic and does not need the table handle. The caller must know the + * key size at this site; this is the case for every AGE caller (each table + * has a single fixed key type). + * + * Invariant: this is correct only when the payload directly abuts the key, + * i.e. when payload_offset == AGEHASH_SLOT_KEY_OFFSET + key_size with no + * MAXALIGN padding. That holds iff key_size is a multiple of + * MAXIMUM_ALIGNOF (all current AGE callers use an 8-byte graphid key). + * agehash_create_inline() asserts this invariant in DEBUG builds. + */ +#define agehash_key_from_payload(payload, key_size) \ + ((const void *) ((const char *) (payload) - (Size) (key_size))) + +/* + * Construction. capacity_hint is a number of entries; the actual capacity + * will be the next power of two >= capacity_hint / AGEHASH_MAX_LOAD, with a + * floor of 64 slots. Pass 0 to let the table start at the floor. + */ +extern AgeHashTable *agehash_create_inline(MemoryContext mcxt, + Size key_size, + Size payload_size, + uint32 capacity_hint, + agehash_hash_fn hash_fn, + agehash_keyeq_fn keyeq_fn); + +/* + * Reserve / find. If the key is not present, allocates a fresh slot + * (rebalancing via Robin Hood swaps), zero-fills the payload, sets + * *found = false, and returns a pointer to the payload region. The caller + * fills it in. If the key is present, sets *found = true and returns the + * existing payload pointer. + * + * The returned payload pointer is *not* stable across subsequent + * agehash_insert calls in INLINE mode (a later insert may swap this slot). + * Callers requiring stable pointers must use INDIRECT mode (future). + * + * Asserts that the table has not been frozen (DEBUG builds). + */ +extern void *agehash_insert(AgeHashTable *t, const void *key, bool *found); + +/* Variant that accepts a precomputed hash value, skipping the hash callback. */ +extern void *agehash_insert_with_hash(AgeHashTable *t, const void *key, + uint32 hashvalue, bool *found); + +/* + * Lookup. Returns a pointer to the payload region, or NULL if absent. + * The pointer is stable as long as no further insert touches the table. + */ +extern void *agehash_lookup(AgeHashTable *t, const void *key); + +/* Variant accepting a precomputed hash value. */ +extern void *agehash_lookup_with_hash(AgeHashTable *t, const void *key, + uint32 hashvalue); + +/* + * Freeze the table: subsequent insert/grow attempts are an Assert failure + * in DEBUG and an elog(ERROR) in production. This is the contract that + * lets read-only-after-build callers hand out long-lived payload pointers. + */ +extern void agehash_freeze(AgeHashTable *t); + +/* True after agehash_freeze(); useful for caller-side asserts. */ +extern bool agehash_is_frozen(const AgeHashTable *t); + +/* Live entry count. */ +extern uint32 agehash_size(const AgeHashTable *t); + +/* Allocated slot count (capacity). */ +extern uint32 agehash_capacity(const AgeHashTable *t); + +/* + * Iteration. Usage: + * + * AgeHashIter it; + * for (agehash_iter_init(t, &it); agehash_iter_next(&it); ) + * { + * graphid k = *(graphid *) it.key; + * my_payload *p = it.payload; + * ... + * } + * + * Iteration order is unspecified. Modifying the table during iteration is + * undefined behaviour. + */ +typedef struct AgeHashIter +{ + AgeHashTable *t; + uint32 idx; + void *key; + void *payload; +} AgeHashIter; + +extern void agehash_iter_init(AgeHashTable *t, AgeHashIter *it); +extern bool agehash_iter_next(AgeHashIter *it); + +/* + * Internal self-test. Returns a NUL-terminated diagnostic string allocated + * in CurrentMemoryContext: "OK" on success, "FAIL: " on failure. + * Used by the agehash regression test. + */ +extern const char *agehash_self_test(void); + +#endif /* AG_AGEHASH_H */ diff --git a/src/include/utils/agtype.h b/src/include/utils/agtype.h index ec9125073..4bddbe4f6 100644 --- a/src/include/utils/agtype.h +++ b/src/include/utils/agtype.h @@ -32,6 +32,7 @@ #define AG_AGTYPE_H #include "utils/array.h" +#include "utils/memutils.h" #include "utils/numeric.h" #include "utils/graphid.h" @@ -581,6 +582,64 @@ agtype_iterator_token agtype_iterator_next(agtype_iterator **it, agtype *agtype_value_to_agtype(agtype_value *val); bool agtype_deep_contains(agtype_iterator **val, agtype_iterator **m_contained, bool skip_nested); + +/* + * Per-call agtype build arena. + * + * Many top-level Datum functions in agtype.c follow the pattern: + * 1. Allocate / build an agtype_value tree + * 2. Serialize it via agtype_value_to_agtype() + * 3. Recursively pfree the tree + * + * Step 3 (pfree_agtype_value_content) is an O(N) walk that frees each tree + * node individually. By building the tree inside a dedicated AllocSet + * MemoryContext and resetting the context after serialization, we trade + * the O(N) walk for an O(K) block reset (K << N) and eliminate per-node + * AllocSetFree overhead. The AllocSetAlloc cost is also reduced because + * the arena uses a small initial block size and grows on demand. + * + * Two usage patterns: + * + * Pattern A — disposable per-call arena (one-shot): + * MemoryContext arena = agt_arena_begin(); + * MemoryContext caller = MemoryContextSwitchTo(arena); + * ... build tree ... + * MemoryContextSwitchTo(caller); + * result = agtype_value_to_agtype(val); // copies into caller + * agt_arena_end(arena); // resets / discards + * + * Pattern B — long-lived shared arena (sort comparator, hot inner loop): + * static MemoryContext shared_arena = NULL; + * if (shared_arena == NULL) + * { + * shared_arena = AGT_ARENA_BEGIN_SHARED("comparator"); + * } + * MemoryContext caller = MemoryContextSwitchTo(shared_arena); + * ... allocate / use ... + * MemoryContextSwitchTo(caller); + * agt_arena_reset(shared_arena); // O(1) cheap reset + * + * Pattern A creates a fresh context per call (microseconds). Pattern B + * amortizes the create cost across many calls (single AllocSetCreate at + * the very first call), and pays only a MemoryContextReset (~100 ns) per + * subsequent call. Use Pattern B when the function is invoked many times + * per query (e.g. a sort comparator called O(N log N) times). + */ +MemoryContext agt_arena_begin(void); +/* + * Pattern-B shared-arena creator. Implemented as a macro because PG's + * AllocSetContextCreate enforces memory-context names be compile-time + * constants via a StaticAssert. Use with a string literal: + * static MemoryContext my_arena = NULL; + * if (my_arena == NULL) + * { + * my_arena = AGT_ARENA_BEGIN_SHARED("my comparator workspace"); + * } + */ +#define AGT_ARENA_BEGIN_SHARED(_name) \ + AllocSetContextCreate(CacheMemoryContext, (_name), ALLOCSET_SMALL_SIZES) +void agt_arena_reset(MemoryContext arena); +void agt_arena_end(MemoryContext arena); void agtype_hash_scalar_value(const agtype_value *scalar_val, uint32 *hash); void agtype_hash_scalar_value_extended(const agtype_value *scalar_val, uint64 *hash, uint64 seed); @@ -593,6 +652,7 @@ bool is_numeric_result(agtype_value *lhs, agtype_value *rhs); void copy_agtype_value(agtype_parse_state* pstate, agtype_value* original_agtype_value, agtype_value **copied_agtype_value, bool is_top_level); +Oid find_usable_btree_index_for_attr(Relation rel, AttrNumber attnum); /* agtype.c support functions */ /* @@ -668,4 +728,11 @@ void clear_global_Oids_AGTYPE(void); #define AGTYPEOID get_AGTYPEOID() #define AGTYPEARRAYOID get_AGTYPEARRAYOID() +/* Oid accessors for vertex and edge composite types */ +Oid get_VERTEXOID(void); +Oid get_EDGEOID(void); +void clear_global_Oids_VERTEX_EDGE(void); +#define VERTEXOID get_VERTEXOID() +#define EDGEOID get_EDGEOID() + #endif