From 1de04621125e48def3f12d141f8f8d0c731aea47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esteban=20Zim=C3=A1nyi?= Date: Mon, 5 Oct 2026 10:14:44 +0200 Subject: [PATCH] Call the topological and position functions by their class-prefixed names, unquoted The topological and position functions of the generated SQL surface take the prefix of the class whose values or bounding boxes they compare, the names the portable dialect chapter of the MobilityDB manual gives: set, span, spanset, tbox, stbox and tpcbox, and the base type for same (cbufferSame, npointSame, poseSame, posechainSame). Flink's parser takes each unquoted. GeneratedSqlSurfaceTest.theClassPrefixedNameIsCalledUnquoted calls spanOverlaps over two float spans built with floatspanFromText. TopologicalPositionSurfaceTest checks that the 31 topological names are registered, calls every overload of the two families the surface registers, over values read by their readers (FromText, FromHexWKB for a point cloud value, FromHexEWKB for a geometry and a geography, a value with Z for the operators reading the third dimension, and the box of the value beside it for a box argument), and checks that each answers a Boolean; it checks the known answer of each operator over two spans, two time spans and two spatiotemporal boxes. Witness: since MobilityDB 762b3501cc the engines call the overlap of two spans spanOverlaps, so the generated surface registers no function overlaps and the test's call of `overlaps`(floatspan, floatspan) fails validation: No match found for function signature overlaps(FloatSpan, FloatSpan). Measured over MobilityDB master 5e7da7e8d9 with the commits #2980, #2981 and #2984 merge, the catalog of MEOS-API 18a1e6d53c, and JMEOS main with the commits #153, #154 and #155 merge: the surface registers 999 SQL functions and 7709 overloads, the two families 95 names and 1809 overloads; the binding module passes its 21 tests and the benchmark module its 12, and the build prints no warning. Why: a binding calls a function by the name the portable dialect gives it, and every overload of the surface answers. --- .../flink/sql/GeneratedSqlSurfaceTest.java | 6 +- .../sql/TopologicalPositionSurfaceTest.java | 399 ++++++++++++++++++ 2 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 binding/src/test/java/org/mobilitydb/flink/sql/TopologicalPositionSurfaceTest.java diff --git a/binding/src/test/java/org/mobilitydb/flink/sql/GeneratedSqlSurfaceTest.java b/binding/src/test/java/org/mobilitydb/flink/sql/GeneratedSqlSurfaceTest.java index cebc5cb..7a7e415 100644 --- a/binding/src/test/java/org/mobilitydb/flink/sql/GeneratedSqlSurfaceTest.java +++ b/binding/src/test/java/org/mobilitydb/flink/sql/GeneratedSqlSurfaceTest.java @@ -129,9 +129,9 @@ void textConstructorsAndOutput() throws Exception { } @Test - void aReservedNameIsCalledQuoted() throws Exception { - assertEquals(true, - scalar("SELECT `overlaps`(floatspan_in('[1, 3]'), floatspan_in('[2, 4]'))")); + void theClassPrefixedNameIsCalledUnquoted() throws Exception { + assertEquals(true, scalar( + "SELECT spanOverlaps(floatspanFromText('[1, 3]'), floatspanFromText('[2, 4]'))")); } @Test diff --git a/binding/src/test/java/org/mobilitydb/flink/sql/TopologicalPositionSurfaceTest.java b/binding/src/test/java/org/mobilitydb/flink/sql/TopologicalPositionSurfaceTest.java new file mode 100644 index 0000000..01b9239 --- /dev/null +++ b/binding/src/test/java/org/mobilitydb/flink/sql/TopologicalPositionSurfaceTest.java @@ -0,0 +1,399 @@ +/***************************************************************************** + * + * This MobilityDB code is provided under The PostgreSQL License. + * Copyright (c) 2020-2026, Université libre de Bruxelles and MobilityDB + * contributors + * + * Permission to use, copy, modify, and distribute this software and its + * documentation for any purpose, without fee, and without a written + * agreement is hereby granted, provided that the above copyright notice and + * this paragraph and the following two paragraphs appear in all copies. + * + * IN NO EVENT SHALL UNIVERSITE LIBRE DE BRUXELLES BE LIABLE TO ANY PARTY FOR + * DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING + * LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, + * EVEN IF UNIVERSITE LIBRE DE BRUXELLES HAS BEEN ADVISED OF THE POSSIBILITY + * OF SUCH DAMAGE. + * + * UNIVERSITE LIBRE DE BRUXELLES SPECIFICALLY DISCLAIMS ANY WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON + * AN "AS IS" BASIS, AND UNIVERSITE LIBRE DE BRUXELLES HAS NO OBLIGATIONS TO + * PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + * + *****************************************************************************/ + +package org.mobilitydb.flink.sql; + +import functions.GeneratedFunctions; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.flink.table.api.EnvironmentSettings; +import org.apache.flink.table.api.TableEnvironment; +import org.apache.flink.types.Row; +import org.apache.flink.util.CloseableIterator; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Calls every topological and position function of the generated Flink SQL surface by its + * class-prefixed name, unquoted, on the mini-cluster and libmeos #GeneratedSqlSurfaceTest runs + * the surface on. The topological functions take the prefix of the class whose values or + * bounding boxes they compare, as the position functions do, the names the portable dialect + * chapter of the MobilityDB manual gives: set, span, spanset, tbox, stbox and tpcbox, and a + * base type for same. Each overload the surface registers is called once over values read + * from their text, a box argument being the box of the value beside it, and answers a + * Boolean; each operator answers its known value over two spans, two time spans and two + * spatiotemporal boxes. + */ +class TopologicalPositionSurfaceTest { + + private static TableEnvironment tEnv; + + /** Each name the surface registers, with its function class. */ + private static final Map> REGISTERED = new LinkedHashMap<>(); + + /** The topological names, the operators each prefix takes. */ + private static final Map> TOPOLOGICAL = new LinkedHashMap<>(); + + static { + List all = List.of("Overlaps", "Contains", "Contained", "Same", "Adjacent"); + TOPOLOGICAL.put("stbox", all); + TOPOLOGICAL.put("tbox", all); + TOPOLOGICAL.put("span", all); + TOPOLOGICAL.put("tpcbox", all); + TOPOLOGICAL.put("set", List.of("Overlaps", "Contains", "Contained")); + TOPOLOGICAL.put("spanset", List.of("Overlaps", "Contains", "Contained", "Adjacent")); + for (String base : List.of("cbuffer", "npoint", "pose", "posechain")) { + TOPOLOGICAL.put(base, List.of("Same")); + } + } + + private static final String OPS = "Overlaps|Contains|Contained|Same|Adjacent" + + "|Left|Overleft|Right|Overright|Below|Overbelow|Above|Overabove" + + "|Front|Overfront|Back|Overback|Before|Overbefore|After|Overafter"; + + private static final Pattern FAMILY = Pattern.compile( + "(set|span|spanset|tbox|stbox|tpcbox|cbuffer|npoint|pose|posechain)(" + OPS + ")"); + + /** The position operators reading the third dimension, called over values having one. */ + private static final Set Z_OPS = Set.of("Front", "Overfront", "Back", "Overback"); + + private static final String PCPOINT = "010100000064000000C80000002C010000"; + private static final String PCPATCH = + "01010000000000000002000000640000006400000064000000C8000000C8000000C8000000"; + + /** The text of a value of each SQL type, as the reader of #READER reads it. */ + private static final Map TEXT = new LinkedHashMap<>(); + + /** + * The reader of a SQL type whose text is not read by its FromText: the hex Well-Known + * Binary of a point cloud value and the hex extended one of a geometry and a geography. + */ + private static final Map READER = Map.of( + "pcpoint", "pcpointFromHexWKB", "pcpatch", "pcpatchFromHexWKB", + "geometry", "geometryFromHexEWKB", "geography", "geographyFromHexEWKB"); + + /** The text of a spatial value with a third dimension, where it differs. */ + private static final Map TEXT_Z = new LinkedHashMap<>(); + + static { + TEXT.put("intset", "{1, 2}"); + TEXT.put("bigintset", "{1, 2}"); + TEXT.put("floatset", "{1.5, 2.5}"); + TEXT.put("textset", "{\"a\", \"b\"}"); + TEXT.put("dateset", "{2020-01-01, 2020-01-02}"); + TEXT.put("tstzset", "{2020-01-01, 2020-01-02}"); + TEXT.put("geomset", "{Point(1 1), Point(2 2)}"); + TEXT.put("geogset", "{Point(1 1), Point(2 2)}"); + TEXT.put("cbufferset", "{\"Cbuffer(Point(1 1),0.5)\", \"Cbuffer(Point(2 2),0.5)\"}"); + TEXT.put("npointset", "{\"Npoint(1,0.5)\", \"Npoint(2,0.5)\"}"); + TEXT.put("poseset", "{\"Pose(Point(1 1),0.5)\", \"Pose(Point(2 2),0.5)\"}"); + TEXT.put("posechainset", "{\"PoseChain(Pose(Point(0 0), 0), Pose(Point(10 0), 0))\"}"); + TEXT.put("jsonbset", "{\"{\\\"a\\\": 1}\"}"); + TEXT.put("h3indexset", "{8a2a1072b59ffff}"); + TEXT.put("quadbinset", "{4813ffffffffffff, 4817ffffffffffff}"); + TEXT.put("s2cellset", "{47c3c444c000000c, 47c3c444c0000004}"); + TEXT.put("pcpointset", "{\"" + PCPOINT + "\"}"); + TEXT.put("pcpatchset", "{\"" + PCPATCH + "\"}"); + TEXT.put("intspan", "[1, 3]"); + TEXT.put("bigintspan", "[1, 3]"); + TEXT.put("floatspan", "[1.5, 3.5]"); + TEXT.put("datespan", "[2020-01-01, 2020-01-03]"); + TEXT.put("tstzspan", "[2020-01-01, 2020-01-03]"); + TEXT.put("intspanset", "{[1, 3]}"); + TEXT.put("bigintspanset", "{[1, 3]}"); + TEXT.put("floatspanset", "{[1.5, 3.5]}"); + TEXT.put("datespanset", "{[2020-01-01, 2020-01-03]}"); + TEXT.put("tstzspanset", "{[2020-01-01, 2020-01-03]}"); + TEXT.put("tbox", "TBOXFLOAT XT([1, 3],[2020-01-01, 2020-01-03])"); + TEXT.put("stbox", "STBOX XT(((1,1),(3,3)),[2020-01-01, 2020-01-03])"); + TEXT.put("tpcbox", "TPCBOX(XT(((0,0),(10,10)),[2020-01-01, 2020-01-31]), 1)"); + TEXT.put("cbuffer", "Cbuffer(Point(1 1), 0.5)"); + TEXT.put("npoint", "Npoint(1, 0.5)"); + TEXT.put("nsegment", "NSegment(1, 0.2, 0.6)"); + TEXT.put("pose", "Pose(Point(1 1), 0.5)"); + TEXT.put("posechain", "PoseChain(Pose(Point(0 0), 0), Pose(Point(10 0), 0))"); + TEXT.put("tint", "[1@2020-01-01, 2@2020-01-02]"); + TEXT.put("tbigint", "[1@2020-01-01, 2@2020-01-02]"); + TEXT.put("tfloat", "[1.5@2020-01-01, 2.5@2020-01-02]"); + TEXT.put("tbool", "[t@2020-01-01, f@2020-01-02]"); + TEXT.put("ttext", "[\"a\"@2020-01-01, \"b\"@2020-01-02]"); + TEXT.put("tjsonb", "\"{\\\"a\\\": 1}\"@2020-01-01"); + TEXT.put("tgeompoint", "[Point(1 1)@2020-01-01, Point(2 2)@2020-01-02]"); + TEXT.put("tgeogpoint", "[Point(1 1)@2020-01-01, Point(2 2)@2020-01-02]"); + TEXT.put("tgeometry", "Point(1 1)@2020-01-01"); + TEXT.put("tgeography", "Point(1 1)@2020-01-01"); + TEXT.put("tcbuffer", "Cbuffer(Point(1 1), 0.5)@2020-01-01"); + TEXT.put("tnpoint", "Npoint(1, 0.5)@2020-01-01"); + TEXT.put("tpose", "Pose(Point(1 1), 0.1)@2020-01-01"); + TEXT.put("tposechain", "PoseChain(Pose(Point(1 2), 0.1))@2020-01-01"); + TEXT.put("trgeometry", "Polygon((0 0, 1 0, 1 1, 0 1, 0 0));Pose(Point(5 5),0)@2020-01-01"); + TEXT.put("th3index", "831c02fffffffff@2020-01-01"); + TEXT.put("tquadbin", "480fffffffffffff@2020-01-01"); + TEXT.put("ts2cell", "47c3c3@2020-01-01"); + TEXT.put("tpcpoint", PCPOINT + "@2020-01-01"); + TEXT.put("tpcpatch", PCPATCH + "@2020-01-01"); + TEXT.put("geometry", "0101000000000000000000F03F000000000000F03F"); + TEXT.put("geography", "0101000020E6100000000000000000F03F000000000000F03F"); + TEXT.put("jsonb", "{\"a\": 1}"); + TEXT.put("h3index", "8a2a1072b59ffff"); + TEXT.put("quadbin", "4813ffffffffffff"); + TEXT.put("s2cell", "47c3c444c000000c"); + TEXT.put("pcpoint", PCPOINT); + TEXT.put("pcpatch", PCPATCH); + + TEXT_Z.put("stbox", "STBOX ZT(((1,1,1),(3,3,3)),[2020-01-01, 2020-01-03])"); + TEXT_Z.put("tpcbox", "TPCBOX(ZT(((0,0,0),(10,10,10)),[2020-01-01, 2020-01-31]), 1)"); + TEXT_Z.put("geomset", "{Point(1 1 1), Point(2 2 2)}"); + TEXT_Z.put("geogset", "{Point(1 1 1), Point(2 2 2)}"); + TEXT_Z.put("tgeompoint", "[Point(1 1 1)@2020-01-01, Point(2 2 2)@2020-01-02]"); + TEXT_Z.put("tgeogpoint", "[Point(1 1 1)@2020-01-01, Point(2 2 2)@2020-01-02]"); + TEXT_Z.put("tgeometry", "Point(1 1 1)@2020-01-01"); + TEXT_Z.put("tgeography", "Point(1 1 1)@2020-01-01"); + TEXT_Z.put("tpose", "Pose(Point Z(1 1 1), 1, 0, 0, 0)@2020-01-01"); + TEXT_Z.put("tposechain", "PoseChain(Pose(Point Z(1 1 1), 1, 0, 0, 0))@2020-01-01"); + TEXT_Z.put("trgeometry", + "Polygon Z((0 0 0,2 0 0,2 1 0,0 1 0,0 0 0));Pose(Point Z(1 1 0), 1, 0, 0, 0)@2020-01-01"); + } + + /** + * States the schema of pcid 1 and registers the surface. MEOS is set up by the generated + * wrappers themselves, which install the handler that turns a MEOS error into the + * exception each call below expects, so this test installs none of its own. + */ + @BeforeAll + static void init() { + // The point cloud values name pcid 1, whose schema the libmeos installation ships + // beside its library directory, the one surefire puts on the load path. + Path lib = Paths.get(System.getenv("LD_LIBRARY_PATH").split(":")[0]); + GeneratedFunctions.meos_set_pointcloud_schemas_xml( + lib.resolveSibling("share").resolve("pointcloud_schemas.xml").toString()); + tEnv = TableEnvironment.create(EnvironmentSettings.inStreamingMode()); + // #registerAll registers through the environment it is given; a proxy forwarding every + // call records each name with its function class, as the registrar states them + TableEnvironment recording = (TableEnvironment) Proxy.newProxyInstance( + TableEnvironment.class.getClassLoader(), new Class[] {TableEnvironment.class}, + (proxy, method, args) -> { + if (method.getName().equals("createTemporaryFunction") && args.length == 2 + && args[0] instanceof String && args[1] instanceof Class) { + REGISTERED.put((String) args[0], (Class) args[1]); + } + try { + return method.invoke(tEnv, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }); + MobilityFlinkSql.registerAll(recording); + } + + /** The first field of the first row, as #GeneratedSqlSurfaceTest reads an answer. */ + private static Object scalar(String sql) throws Exception { + try (CloseableIterator it = tEnv.executeSql(sql).collect()) { + return it.next().getField(0); + } + } + + /** The SQL type a generated value class carries, its class name in lower case. */ + private static String sqlType(Class c) { + return c.getSimpleName().toLowerCase(); + } + + /** True when the surface converts a value of class {@code from} to the box class {@code box}. */ + private static boolean converts(Class box, Class from) { + String type = sqlType(box); + try { + Class fn = Class.forName("org.mobilitydb.flink.sql.functions." + + type.substring(0, 1).toUpperCase() + type.substring(1)); + return Arrays.stream(fn.getMethods()).anyMatch(m -> m.getName().equals("eval") + && m.getParameterCount() == 1 && m.getParameterTypes()[0] == from); + } catch (ClassNotFoundException e) { + return false; + } + } + + /** The SQL expression of an argument of class {@code c} beside one of class {@code other}. */ + private static String arg(Class c, Class other, boolean z) { + if (c == Integer.class) { + return "CAST(2 AS INT)"; + } + if (c == Long.class) { + return "CAST(2 AS BIGINT)"; + } + if (c == Double.class) { + return "CAST(2.5 AS DOUBLE)"; + } + if (c == java.time.Instant.class) { + return "TO_TIMESTAMP_LTZ(1577923200000, 3)"; + } + if (c == java.time.LocalDate.class) { + return "DATE '2020-01-02'"; + } + if (c == String.class) { + return "'a'"; + } + if (c == Boolean.class) { + return "TRUE"; + } + String type = sqlType(c); + if ((type.equals("stbox") || type.equals("tbox")) && other != null && converts(c, other)) { + return type + "(" + arg(other, null, z) + ")"; + } + String text = z && TEXT_Z.containsKey(type) ? TEXT_Z.get(type) : TEXT.get(type); + if (text == null) { + throw new IllegalStateException("no text stated for the SQL type " + type); + } + return READER.getOrDefault(type, type + "FromText") + "('" + text + "')"; + } + + /** The registered names of the two families, as #registerAll registers them. */ + private static Set familyNames() { + Set names = new TreeSet<>(); + for (String n : REGISTERED.keySet()) { + if (FAMILY.matcher(n).matches()) { + names.add(n); + } + } + return names; + } + + @Test + void everyTopologicalNameIsRegistered() { + List missing = new ArrayList<>(); + for (Map.Entry> e : TOPOLOGICAL.entrySet()) { + for (String op : e.getValue()) { + if (!REGISTERED.containsKey(e.getKey() + op)) { + missing.add(e.getKey() + op); + } + } + } + assertTrue(missing.isEmpty(), "topological names not registered: " + missing); + } + + @Test + void everyOverloadAnswersUnquoted() throws Exception { + Set names = familyNames(); + int calls = 0; + List failures = new ArrayList<>(); + for (String name : names) { + Matcher m = FAMILY.matcher(name); + assertTrue(m.matches()); + boolean z = Z_OPS.contains(m.group(2)); + Class fn = REGISTERED.get(name); + for (Method eval : fn.getMethods()) { + if (!eval.getName().equals("eval") || eval.getParameterCount() != 2) { + continue; + } + Class[] p = eval.getParameterTypes(); + String sql = name + Arrays.toString(p); + try { + sql = "SELECT " + name + "(" + arg(p[0], p[1], z) + ", " + + arg(p[1], p[0], z) + ")"; + Object answer = scalar(sql); + if (!(answer instanceof Boolean)) { + failures.add(sql + " -> " + answer); + } + } catch (Exception e) { + Throwable root = e; + while (root.getCause() != null) { + root = root.getCause(); + } + failures.add(sql + " -> " + String.valueOf(root.getMessage()).split("\n")[0]); + } + calls++; + } + } + assertTrue(calls > 0, "no overload of the two families is registered"); + assertTrue(failures.isEmpty(), failures.size() + " of " + calls + + " overloads of " + names.size() + " names failed:\n" + + String.join("\n", failures)); + } + + @Test + void eachOperatorAnswersItsValue() throws Exception { + // Two spans that overlap, the first ending before the second does + String a = "floatspanFromText('[1, 3]')"; + String b = "floatspanFromText('[2, 4]')"; + Map span = new LinkedHashMap<>(); + span.put("spanOverlaps", true); + span.put("spanContains", false); + span.put("spanContained", false); + span.put("spanAdjacent", false); + span.put("spanLeft", false); + span.put("spanOverleft", true); + span.put("spanRight", false); + span.put("spanOverright", false); + for (Map.Entry e : span.entrySet()) { + assertEquals(e.getValue(), scalar("SELECT " + e.getKey() + "(" + a + ", " + b + ")"), + e.getKey()); + } + // A time span ending before the other starts + String t1 = "tstzspanFromText('[2020-01-01, 2020-01-02]')"; + String t2 = "tstzspanFromText('[2020-01-03, 2020-01-04]')"; + assertEquals(true, scalar("SELECT spanBefore(" + t1 + ", " + t2 + ")")); + assertEquals(true, scalar("SELECT spanOverbefore(" + t1 + ", " + t2 + ")")); + assertEquals(false, scalar("SELECT spanAfter(" + t1 + ", " + t2 + ")")); + assertEquals(false, scalar("SELECT spanOverafter(" + t1 + ", " + t2 + ")")); + // A temporal Boolean whose time span is the time span it is compared with + assertEquals(true, scalar("SELECT spanSame(" + t1 + + ", tboolFromText('[t@2020-01-01, f@2020-01-02]'))")); + // A box left of, below, in front of and before the other + String s1 = "stboxFromText('STBOX ZT(((1,1,1),(2,2,2)),[2020-01-01, 2020-01-02])')"; + String s2 = "stboxFromText('STBOX ZT(((3,3,3),(4,4,4)),[2020-01-03, 2020-01-04])')"; + Map box = new LinkedHashMap<>(); + box.put("stboxLeft", true); + box.put("stboxRight", false); + box.put("stboxBelow", true); + box.put("stboxOverbelow", true); + box.put("stboxAbove", false); + box.put("stboxOverabove", false); + box.put("stboxFront", true); + box.put("stboxOverfront", true); + box.put("stboxBack", false); + box.put("stboxOverback", false); + box.put("stboxBefore", true); + box.put("stboxAfter", false); + box.put("stboxOverlaps", false); + box.put("stboxSame", false); + for (Map.Entry e : box.entrySet()) { + assertEquals(e.getValue(), scalar("SELECT " + e.getKey() + "(" + s1 + ", " + s2 + ")"), + e.getKey()); + } + } +}