From 76125b94e1bc28176a48339f598a33cb64e7cb30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esteban=20Zim=C3=A1nyi?= Date: Fri, 28 Aug 2026 07:20:39 +0200 Subject: [PATCH] Answer the PostGIS operators soundly from a multi-entry key The operator class carries the overlaps operator, which PostGIS answers on the bounding box of a geometry, so the whole geometry is the first key a value extracts and every operator answered on that box is witnessed by a key. Each member of a multi-part geometry is a key of its own, kept whatever its dimension, and the type decides which geometries have members, so the rings of a curve polygon and the arcs of a compound curve stay part of the single geometry they bound. A value that extracts one key hands back a palloc'd array holding it. The regression tests state the counts a multi-entry scan and a sequential scan give for the operator over geometries whose members lie far apart, and read the sequential count as positive so the comparison cannot hold vacuously. --- contrib/postgis/CMakeLists.txt | 30 ++++++++ contrib/postgis/README.md | 2 +- .../expected/01_create_extension.test.out | 55 +++++++++++++++ .../expected/02_geometry_topops.test.out | 65 +++++++++++++++++ contrib/postgis/postgis_mest.c | 46 +++++++++--- .../postgis/sql/01_create_extension.test.sql | 62 ++++++++++++++++ .../postgis/sql/02_geometry_topops.test.sql | 70 +++++++++++++++++++ 7 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 contrib/postgis/expected/01_create_extension.test.out create mode 100644 contrib/postgis/expected/02_geometry_topops.test.out create mode 100644 contrib/postgis/sql/01_create_extension.test.sql create mode 100644 contrib/postgis/sql/02_geometry_topops.test.sql diff --git a/contrib/postgis/CMakeLists.txt b/contrib/postgis/CMakeLists.txt index efc3f71..f514fa5 100755 --- a/contrib/postgis/CMakeLists.txt +++ b/contrib/postgis/CMakeLists.txt @@ -39,3 +39,33 @@ install( FILES "${CMAKE_SOURCE_DIR}/postgis_mest.control" "${CMAKE_SOURCE_DIR}/postgis_mest--1.0.sql" DESTINATION "${POSTGRESQL_SHARE_DIR}/extension") install(TARGETS postgis_mest DESTINATION "${POSTGRESQL_DYNLIB_DIR}") + +#------------------------------------- +# Regression tests +#------------------------------------- + +execute_process( + COMMAND ${POSTGRESQL_PG_CONFIG} --pgxs + OUTPUT_STRIP_TRAILING_WHITESPACE + OUTPUT_VARIABLE POSTGRESQL_PGXS) +get_filename_component(POSTGRESQL_PGXS_DIR "${POSTGRESQL_PGXS}" DIRECTORY) +find_program(PG_REGRESS NAMES pg_regress + HINTS "${POSTGRESQL_PGXS_DIR}/../test/regress") + +file(GLOB TEST_FILES "${CMAKE_SOURCE_DIR}/sql/*.test.sql") +set(TEST_NAMES "") +foreach(TEST_FILE ${TEST_FILES}) + get_filename_component(TEST_NAME "${TEST_FILE}" NAME) + string(REGEX REPLACE "\\.sql$" "" TEST_NAME "${TEST_NAME}") + list(APPEND TEST_NAMES "${TEST_NAME}") +endforeach() +list(SORT TEST_NAMES) + +# Runs the tests against a server already holding the installed extension, +# taking PGHOST, PGPORT and PGUSER from the environment +add_custom_target(installcheck + COMMAND ${PG_REGRESS} --inputdir=${CMAKE_SOURCE_DIR} + --outputdir=${CMAKE_CURRENT_BINARY_DIR} + --bindir=${POSTGRESQL_BIN_DIR} + --dbname=contrib_regression ${TEST_NAMES} + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) diff --git a/contrib/postgis/README.md b/contrib/postgis/README.md index 4926271..9f28529 100644 --- a/contrib/postgis/README.md +++ b/contrib/postgis/README.md @@ -3,7 +3,7 @@ Multi-Entry GiST Indexing for PostGIS This directory contains an implementation of Multi-Entry GiST indexes for the PostGIS geometry type. -Contrary to the traditional GiST index for PostGIS, MGiST will index collection types with one bounding box per element in the collection. This index will thus mainly benefit datasets containing collections such as multi-points or multi-polygons that are spread out over a large area. Tuples containing single geometries will be indexed using a single bounding box as usual. +Contrary to the traditional GiST index for PostGIS, MGiST will index collection types with one bounding box per element in the collection, together with the bounding box of the whole geometry. The operators the operator class carries are answered on that whole box, so it is itself one of the entries, and every element of a collection is an entry whatever its dimension. This index will thus mainly benefit datasets containing collections such as multi-points or multi-polygons that are spread out over a large area. Tuples containing single geometries will be indexed using a single bounding box as usual. The MGiST index for geometries currently provides speedups for overlaps `&&` and distance `<->` operators. However, the PostGIS support functions are not yet implemented for MGiST indexes, so the index will not be used for queries using the `ST_Intersects` or `ST_Contains` functions. To provide speedup for these functions, you will need to add an explicit overlaps test to the query. diff --git a/contrib/postgis/expected/01_create_extension.test.out b/contrib/postgis/expected/01_create_extension.test.out new file mode 100644 index 0000000..e13402d --- /dev/null +++ b/contrib/postgis/expected/01_create_extension.test.out @@ -0,0 +1,55 @@ +------------------------------------------------------------------------------- +-- +-- 01_create_extension.test.sql +-- +-- Multi-Entry Search Trees for PostGIS +-- +------------------------------------------------------------------------------- +CREATE EXTENSION IF NOT EXISTS postgis_mest CASCADE; +NOTICE: installing required extension "postgis" +NOTICE: installing required extension "mest" +------------------------------------------------------------------------------- +-- The geometries every test reads. The multi-part rows leave a wide gap between +-- their members, which is where a key covering only a member stops witnessing +-- an operator the bounding box answers. +------------------------------------------------------------------------------- +DROP TABLE IF EXISTS tbl_geometry; +NOTICE: table "tbl_geometry" does not exist, skipping +CREATE TABLE tbl_geometry(k integer PRIMARY KEY, g geometry); +-- two-part multipolygons, the parts twenty units apart +INSERT INTO tbl_geometry +SELECT i, ST_Collect(ST_MakeEnvelope(i, i, i + 1, i + 1), + ST_MakeEnvelope(i + 20, i + 20, i + 21, i + 21)) +FROM generate_series(1, 60) i; +-- collections holding members of different dimensions +INSERT INTO tbl_geometry +SELECT 100 + i, ST_Collect(ST_Point(i + 7.5, i + 7.5), + ST_MakeEnvelope(i, i, i + 1, i + 1)) +FROM generate_series(1, 60) i; +-- single-part geometries +INSERT INTO tbl_geometry +SELECT 200 + i, ST_MakeEnvelope(i, i, i + 2, i + 2) FROM generate_series(1, 40) i; +INSERT INTO tbl_geometry +SELECT 300 + i, ST_MakePoint(i, i) FROM generate_series(1, 40) i; +-- multipoints and multilinestrings with separated members +INSERT INTO tbl_geometry +SELECT 400 + i, ST_Collect(ST_Point(i, i), ST_Point(i + 15, i + 15)) +FROM generate_series(1, 40) i; +INSERT INTO tbl_geometry +SELECT 500 + i, ST_Collect( + ST_MakeLine(ST_Point(i, i), ST_Point(i + 1, i + 1)), + ST_MakeLine(ST_Point(i + 25, i + 25), ST_Point(i + 26, i + 26))) +FROM generate_series(1, 40) i; +------------------------------------------------------------------------------- +-- The query geometries. +------------------------------------------------------------------------------- +DROP TABLE IF EXISTS tbl_geomquery; +NOTICE: table "tbl_geomquery" does not exist, skipping +CREATE TABLE tbl_geomquery(k integer PRIMARY KEY, g geometry); +INSERT INTO tbl_geomquery +SELECT i, ST_MakeEnvelope(x, y, x + 3, y + 3) +FROM (SELECT i, (i * 7 % 80)::float8 AS x, (i * 13 % 80)::float8 AS y + FROM generate_series(1, 200) i) s; +ANALYZE tbl_geometry; +ANALYZE tbl_geomquery; +------------------------------------------------------------------------------- diff --git a/contrib/postgis/expected/02_geometry_topops.test.out b/contrib/postgis/expected/02_geometry_topops.test.out new file mode 100644 index 0000000..71a4908 --- /dev/null +++ b/contrib/postgis/expected/02_geometry_topops.test.out @@ -0,0 +1,65 @@ +------------------------------------------------------------------------------- +-- +-- 02_geometry_topops.test.sql +-- +-- Multi-Entry Search Trees for PostGIS +-- +-- The multi-entry index answers the same rows as a sequential scan. The +-- operator the class carries is answered on the bounding box of a geometry, so +-- every row whose box meets the query must be reached through a key of that +-- row. +-- +------------------------------------------------------------------------------- +DROP INDEX IF EXISTS tbl_geometry_mrtree_idx; +NOTICE: index "tbl_geometry_mrtree_idx" does not exist, skipping +DROP TABLE IF EXISTS test_topops; +NOTICE: table "test_topops" does not exist, skipping +CREATE TABLE test_topops( + op CHAR(3), + leftarg TEXT, + rightarg TEXT, + no_idx BIGINT, + mrtree_idx BIGINT +); +------------------------------------------------------------------------------- +-- The counts a sequential scan gives +------------------------------------------------------------------------------- +INSERT INTO test_topops(op, leftarg, rightarg, no_idx) +SELECT '&&', 'geometry', 'geometry', COUNT(*) +FROM tbl_geomquery q, tbl_geometry g WHERE g.g && q.g; +INSERT INTO test_topops(op, leftarg, rightarg, no_idx) +SELECT '&&', 'geometry', 'self', COUNT(*) +FROM tbl_geometry g1, tbl_geometry g2 WHERE g1.g && g2.g; +------------------------------------------------------------------------------- +-- The counts the multi-entry R-tree gives +------------------------------------------------------------------------------- +CREATE INDEX tbl_geometry_mrtree_idx ON tbl_geometry USING mgist(g); +SET enable_seqscan = off; +UPDATE test_topops SET mrtree_idx = ( SELECT COUNT(*) +FROM tbl_geomquery q, tbl_geometry g WHERE g.g && q.g ) +WHERE op = '&&' AND leftarg = 'geometry' AND rightarg = 'geometry'; +UPDATE test_topops SET mrtree_idx = ( SELECT COUNT(*) +FROM tbl_geometry g1, tbl_geometry g2 WHERE g1.g && g2.g ) +WHERE op = '&&' AND leftarg = 'geometry' AND rightarg = 'self'; +RESET enable_seqscan; +DROP INDEX tbl_geometry_mrtree_idx; +------------------------------------------------------------------------------- +-- A count of zero would let every row above agree vacuously +------------------------------------------------------------------------------- +SELECT op, leftarg, rightarg, no_idx > 0 AS no_idx_is_positive +FROM test_topops ORDER BY op, leftarg, rightarg; + op | leftarg | rightarg | no_idx_is_positive +-----+----------+----------+-------------------- + && | geometry | geometry | t + && | geometry | self | t +(2 rows) + +SELECT * FROM test_topops +WHERE no_idx <> mrtree_idx OR no_idx IS NULL OR mrtree_idx IS NULL +ORDER BY op, leftarg, rightarg; + op | leftarg | rightarg | no_idx | mrtree_idx +----+---------+----------+--------+------------ +(0 rows) + +DROP TABLE test_topops; +------------------------------------------------------------------------------- diff --git a/contrib/postgis/postgis_mest.c b/contrib/postgis/postgis_mest.c index e41811d..77e2559 100755 --- a/contrib/postgis/postgis_mest.c +++ b/contrib/postgis/postgis_mest.c @@ -28,7 +28,32 @@ PG_MODULE_MAGIC; * M(SP-)GiST extract methods *****************************************************************************/ +/** + * @brief Return true if the members of a geometry of the type are the parts it + * is composed of + * + * A curve polygon and a compound curve answer true to lwtype_is_collection + * while their members are the rings and the arcs of a single geometry, so the + * type is what decides this, never that predicate. + */ +static bool +geometry_type_is_multipart(uint32_t type) +{ + return (type == MULTIPOINTTYPE || type == MULTILINETYPE || + type == MULTIPOLYGONTYPE || type == COLLECTIONTYPE || + type == MULTICURVETYPE || type == MULTISURFACETYPE || + type == POLYHEDRALSURFACETYPE || type == TINTYPE); +} + PG_FUNCTION_INFO_V1(geometry_mest_extract); +/** + * @brief Multi-Entry GiST extract method for geometries + * + * The geometry itself is the first key, so that the operators the operator + * class answers on the bounding box are witnessed by a key of every value, and + * each member of a multi-part geometry is a key of its own. Every member is + * kept, whatever its dimension, so that no part of the value goes unindexed. + */ Datum geometry_mest_extract(PG_FUNCTION_ARGS) { @@ -37,29 +62,32 @@ geometry_mest_extract(PG_FUNCTION_ARGS) // bool **nullFlags = (bool **) PG_GETARG_POINTER(2); uint32_t gstype = gserialized_get_type(gs); - if (! lwtype_is_collection(gstype)) + Datum *keys; + + if (! geometry_type_is_multipart(gstype)) { + keys = palloc(sizeof(Datum)); + keys[0] = PointerGetDatum(gs); *nkeys = 1; - PG_RETURN_POINTER(&gs); + PG_RETURN_POINTER(keys); } LWGEOM *lwgeom = lwgeom_from_gserialized(gs); - LWCOLLECTION *lwcoll = lwcollection_extract((LWCOLLECTION *) lwgeom, 0); + LWCOLLECTION *lwcoll = lwgeom_as_lwcollection(lwgeom); - *nkeys = lwcoll->ngeoms; - Datum *keys = palloc(sizeof(Datum) * lwcoll->ngeoms); - for (int i = 0; i < lwcoll->ngeoms; ++i) + *nkeys = (int32) lwcoll->ngeoms + 1; + keys = palloc(sizeof(Datum) * (*nkeys)); + keys[0] = PointerGetDatum(gs); + for (uint32_t i = 0; i < lwcoll->ngeoms; ++i) { size_t size; GSERIALIZED *g = gserialized_from_lwgeom(lwcoll->geoms[i], &size); SET_VARSIZE(g, size); - keys[i] = PointerGetDatum(g); + keys[i + 1] = PointerGetDatum(g); } lwgeom_free(lwgeom); - lwcollection_free(lwcoll); - PG_FREE_IF_COPY(gs, 0); PG_RETURN_POINTER(keys); } diff --git a/contrib/postgis/sql/01_create_extension.test.sql b/contrib/postgis/sql/01_create_extension.test.sql new file mode 100644 index 0000000..f5850b1 --- /dev/null +++ b/contrib/postgis/sql/01_create_extension.test.sql @@ -0,0 +1,62 @@ +------------------------------------------------------------------------------- +-- +-- 01_create_extension.test.sql +-- +-- Multi-Entry Search Trees for PostGIS +-- +------------------------------------------------------------------------------- + +CREATE EXTENSION IF NOT EXISTS postgis_mest CASCADE; + +------------------------------------------------------------------------------- +-- The geometries every test reads. The multi-part rows leave a wide gap between +-- their members, which is where a key covering only a member stops witnessing +-- an operator the bounding box answers. +------------------------------------------------------------------------------- + +DROP TABLE IF EXISTS tbl_geometry; +CREATE TABLE tbl_geometry(k integer PRIMARY KEY, g geometry); + +-- two-part multipolygons, the parts twenty units apart +INSERT INTO tbl_geometry +SELECT i, ST_Collect(ST_MakeEnvelope(i, i, i + 1, i + 1), + ST_MakeEnvelope(i + 20, i + 20, i + 21, i + 21)) +FROM generate_series(1, 60) i; + +-- collections holding members of different dimensions +INSERT INTO tbl_geometry +SELECT 100 + i, ST_Collect(ST_Point(i + 7.5, i + 7.5), + ST_MakeEnvelope(i, i, i + 1, i + 1)) +FROM generate_series(1, 60) i; + +-- single-part geometries +INSERT INTO tbl_geometry +SELECT 200 + i, ST_MakeEnvelope(i, i, i + 2, i + 2) FROM generate_series(1, 40) i; +INSERT INTO tbl_geometry +SELECT 300 + i, ST_MakePoint(i, i) FROM generate_series(1, 40) i; + +-- multipoints and multilinestrings with separated members +INSERT INTO tbl_geometry +SELECT 400 + i, ST_Collect(ST_Point(i, i), ST_Point(i + 15, i + 15)) +FROM generate_series(1, 40) i; +INSERT INTO tbl_geometry +SELECT 500 + i, ST_Collect( + ST_MakeLine(ST_Point(i, i), ST_Point(i + 1, i + 1)), + ST_MakeLine(ST_Point(i + 25, i + 25), ST_Point(i + 26, i + 26))) +FROM generate_series(1, 40) i; + +------------------------------------------------------------------------------- +-- The query geometries. +------------------------------------------------------------------------------- + +DROP TABLE IF EXISTS tbl_geomquery; +CREATE TABLE tbl_geomquery(k integer PRIMARY KEY, g geometry); +INSERT INTO tbl_geomquery +SELECT i, ST_MakeEnvelope(x, y, x + 3, y + 3) +FROM (SELECT i, (i * 7 % 80)::float8 AS x, (i * 13 % 80)::float8 AS y + FROM generate_series(1, 200) i) s; + +ANALYZE tbl_geometry; +ANALYZE tbl_geomquery; + +------------------------------------------------------------------------------- diff --git a/contrib/postgis/sql/02_geometry_topops.test.sql b/contrib/postgis/sql/02_geometry_topops.test.sql new file mode 100644 index 0000000..c46c6cb --- /dev/null +++ b/contrib/postgis/sql/02_geometry_topops.test.sql @@ -0,0 +1,70 @@ +------------------------------------------------------------------------------- +-- +-- 02_geometry_topops.test.sql +-- +-- Multi-Entry Search Trees for PostGIS +-- +-- The multi-entry index answers the same rows as a sequential scan. The +-- operator the class carries is answered on the bounding box of a geometry, so +-- every row whose box meets the query must be reached through a key of that +-- row. +-- +------------------------------------------------------------------------------- + +DROP INDEX IF EXISTS tbl_geometry_mrtree_idx; + +DROP TABLE IF EXISTS test_topops; +CREATE TABLE test_topops( + op CHAR(3), + leftarg TEXT, + rightarg TEXT, + no_idx BIGINT, + mrtree_idx BIGINT +); + +------------------------------------------------------------------------------- +-- The counts a sequential scan gives +------------------------------------------------------------------------------- + +INSERT INTO test_topops(op, leftarg, rightarg, no_idx) +SELECT '&&', 'geometry', 'geometry', COUNT(*) +FROM tbl_geomquery q, tbl_geometry g WHERE g.g && q.g; + +INSERT INTO test_topops(op, leftarg, rightarg, no_idx) +SELECT '&&', 'geometry', 'self', COUNT(*) +FROM tbl_geometry g1, tbl_geometry g2 WHERE g1.g && g2.g; + +------------------------------------------------------------------------------- +-- The counts the multi-entry R-tree gives +------------------------------------------------------------------------------- + +CREATE INDEX tbl_geometry_mrtree_idx ON tbl_geometry USING mgist(g); + +SET enable_seqscan = off; + +UPDATE test_topops SET mrtree_idx = ( SELECT COUNT(*) +FROM tbl_geomquery q, tbl_geometry g WHERE g.g && q.g ) +WHERE op = '&&' AND leftarg = 'geometry' AND rightarg = 'geometry'; + +UPDATE test_topops SET mrtree_idx = ( SELECT COUNT(*) +FROM tbl_geometry g1, tbl_geometry g2 WHERE g1.g && g2.g ) +WHERE op = '&&' AND leftarg = 'geometry' AND rightarg = 'self'; + +RESET enable_seqscan; + +DROP INDEX tbl_geometry_mrtree_idx; + +------------------------------------------------------------------------------- +-- A count of zero would let every row above agree vacuously +------------------------------------------------------------------------------- + +SELECT op, leftarg, rightarg, no_idx > 0 AS no_idx_is_positive +FROM test_topops ORDER BY op, leftarg, rightarg; + +SELECT * FROM test_topops +WHERE no_idx <> mrtree_idx OR no_idx IS NULL OR mrtree_idx IS NULL +ORDER BY op, leftarg, rightarg; + +DROP TABLE test_topops; + +-------------------------------------------------------------------------------