From 8dc51330f8765a173f781952b4eee129da2b19da Mon Sep 17 00:00:00 2001 From: Bborie Park Date: Wed, 17 Oct 2012 16:08:16 +0000 Subject: [PATCH] Addition of ST_DumpValues() and regression tests. Ticket #2011 git-svn-id: http://svn.osgeo.org/postgis/trunk@10457 b70326c6-7e19-0410-871a-916f4a2858ee --- raster/rt_pg/rt_pg.c | 417 +++++++++++++++++++++ raster/rt_pg/rtpostgis.sql.in.c | 16 + raster/test/regress/Makefile.in | 1 + raster/test/regress/rt_dumpvalues.sql | 99 +++++ raster/test/regress/rt_dumpvalues_expected | 45 +++ 5 files changed, 578 insertions(+) create mode 100644 raster/test/regress/rt_dumpvalues.sql create mode 100644 raster/test/regress/rt_dumpvalues_expected diff --git a/raster/rt_pg/rt_pg.c b/raster/rt_pg/rt_pg.c index 2c8c4efde..19e5cd219 100644 --- a/raster/rt_pg/rt_pg.c +++ b/raster/rt_pg/rt_pg.c @@ -242,6 +242,7 @@ Datum RASTER_setBandNoDataValue(PG_FUNCTION_ARGS); /* Get pixel value */ Datum RASTER_getPixelValue(PG_FUNCTION_ARGS); +Datum RASTER_dumpValues(PG_FUNCTION_ARGS); /* Set pixel value(s) */ Datum RASTER_setPixelValue(PG_FUNCTION_ARGS); @@ -2433,6 +2434,422 @@ Datum RASTER_getPixelValue(PG_FUNCTION_ARGS) PG_RETURN_FLOAT8(pixvalue); } +/* ---------------------------------------------------------------- */ +/* ST_DumpValue function */ +/* ---------------------------------------------------------------- */ + +typedef struct rtpg_dumpvalues_arg_t *rtpg_dumpvalues_arg; +struct rtpg_dumpvalues_arg_t { + int numbands; + int rows; + int columns; + + int *nbands; /* 0-based */ + Datum **values; + bool **nodata; +}; + +static rtpg_dumpvalues_arg rtpg_dumpvalues_arg_init() { + rtpg_dumpvalues_arg arg = NULL; + + arg = palloc(sizeof(struct rtpg_dumpvalues_arg_t)); + if (arg == NULL) { + elog(ERROR, "rtpg_dumpvalues_arg_init: Unable to allocate memory for arguments"); + return NULL; + } + + arg->numbands = 0; + arg->rows = 0; + arg->columns = 0; + + arg->nbands = NULL; + arg->values = NULL; + arg->nodata = NULL; + + return arg; +} + +static void rtpg_dumpvalues_arg_destroy(rtpg_dumpvalues_arg arg) { + int i = 0; + + if (arg->numbands) { + if (arg->nbands != NULL) + pfree(arg->nbands); + + for (i = 0; i < arg->numbands; i++) { + if (arg->values[i] != NULL) + pfree(arg->values[i]); + + if (arg->nodata[i] != NULL) + pfree(arg->nodata[i]); + } + + if (arg->values != NULL) + pfree(arg->values); + if (arg->nodata != NULL) + pfree(arg->nodata); + } + + pfree(arg); +} + +PG_FUNCTION_INFO_V1(RASTER_dumpValues); +Datum RASTER_dumpValues(PG_FUNCTION_ARGS) +{ + FuncCallContext *funcctx; + TupleDesc tupdesc; + int call_cntr; + int max_calls; + int i = 0; + int x = 0; + int y = 0; + int z = 0; + + int16 typlen; + bool typbyval; + char typalign; + + rtpg_dumpvalues_arg arg1 = NULL; + rtpg_dumpvalues_arg arg2 = NULL; + + /* stuff done only on the first call of the function */ + if (SRF_IS_FIRSTCALL()) { + MemoryContext oldcontext; + rt_pgraster *pgraster = NULL; + rt_raster raster = NULL; + rt_band band = NULL; + int numbands = 0; + int j = 0; + bool exclude_nodata_value = TRUE; + + ArrayType *array; + Oid etype; + Datum *e; + bool *nulls; + + double val = 0; + int hasnodata = 0; + double nodataval = 0; + + POSTGIS_RT_DEBUG(2, "RASTER_dumpValues first call"); + + /* create a function context for cross-call persistence */ + funcctx = SRF_FIRSTCALL_INIT(); + + /* switch to memory context appropriate for multiple function calls */ + oldcontext = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx); + + /* Get input arguments */ + if (PG_ARGISNULL(0)) { + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + pgraster = (rt_pgraster *) PG_DETOAST_DATUM(PG_GETARG_DATUM(0)); + + raster = rt_raster_deserialize(pgraster, FALSE); + if (!raster) { + PG_FREE_IF_COPY(pgraster, 0); + ereport(ERROR, ( + errcode(ERRCODE_OUT_OF_MEMORY), + errmsg("Could not deserialize raster") + )); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + + /* check that raster is not empty */ + if (rt_raster_is_empty(raster)) { + elog(NOTICE, "Raster provided is empty"); + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + + /* raster has bands */ + numbands = rt_raster_get_num_bands(raster); + if (!numbands) { + elog(NOTICE, "Raster provided has no bands"); + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + + /* initialize arg1 */ + arg1 = rtpg_dumpvalues_arg_init(); + if (arg1 == NULL) { + elog(ERROR, "RASTER_dumpValues: Unable to initialize argument structure"); + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + + /* nband, array */ + if (!PG_ARGISNULL(1)) { + array = PG_GETARG_ARRAYTYPE_P(1); + etype = ARR_ELEMTYPE(array); + get_typlenbyvalalign(etype, &typlen, &typbyval, &typalign); + + switch (etype) { + case INT2OID: + case INT4OID: + break; + default: + elog(ERROR, "RASTER_dumpValues: Invalid data type for band indexes"); + rtpg_dumpvalues_arg_destroy(arg1); + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + break; + } + + deconstruct_array(array, etype, typlen, typbyval, typalign, &e, &nulls, &(arg1->numbands)); + + arg1->nbands = palloc(sizeof(int) * arg1->numbands); + if (arg1->nbands == NULL) { + elog(ERROR, "RASTER_dumpValues: Unable to allocate memory for pixel values"); + rtpg_dumpvalues_arg_destroy(arg1); + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + + for (i = 0, j = 0; i < arg1->numbands; i++) { + if (nulls[i]) continue; + + switch (etype) { + case INT2OID: + arg1->nbands[j] = DatumGetInt16(e[i]) - 1; + break; + case INT4OID: + arg1->nbands[j] = DatumGetInt32(e[i]) - 1; + break; + } + + j++; + } + + if (j < arg1->numbands) { + arg1->nbands = repalloc(arg1->nbands, sizeof(int) * j); + if (arg1->nbands == NULL) { + elog(ERROR, "RASTER_dumpValues: Unable to reallocate memory for pixel values"); + rtpg_dumpvalues_arg_destroy(arg1); + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + + arg1->numbands = j; + } + + /* validate nbands */ + for (i = 0; i < arg1->numbands; i++) { + if (!rt_raster_has_band(raster, arg1->nbands[i])) { + elog(NOTICE, "Band at index %d not found in raster", arg1->nbands[i] + 1); + rtpg_dumpvalues_arg_destroy(arg1); + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + } + + } + else { + arg1->numbands = numbands; + arg1->nbands = palloc(sizeof(int) * arg1->numbands); + + if (arg1->nbands == NULL) { + elog(ERROR, "RASTER_dumpValues: Unable to allocate memory for pixel values"); + rtpg_dumpvalues_arg_destroy(arg1); + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + + for (i = 0; i < arg1->numbands; i++) { + arg1->nbands[i] = i; + POSTGIS_RT_DEBUGF(4, "arg1->nbands[%d] = %d", arg1->nbands[i], i); + } + } + + arg1->rows = rt_raster_get_height(raster); + arg1->columns = rt_raster_get_width(raster); + + /* exclude_nodata_value */ + if (!PG_ARGISNULL(2)) + exclude_nodata_value = PG_GETARG_BOOL(2); + POSTGIS_RT_DEBUGF(4, "exclude_nodata_value = %d", exclude_nodata_value); + + /* allocate memory for each band's values and nodata flags */ + arg1->values = palloc(sizeof(Datum *) * arg1->numbands); + arg1->nodata = palloc(sizeof(bool *) * arg1->numbands); + if (arg1->values == NULL || arg1->nodata == NULL) { + elog(ERROR, "RASTER_dumpValues: Unable to allocate memory for pixel values"); + rtpg_dumpvalues_arg_destroy(arg1); + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + memset(arg1->values, 0, sizeof(Datum *) * arg1->numbands); + memset(arg1->nodata, 0, sizeof(bool *) * arg1->numbands); + + /* get each band and dump data */ + for (z = 0; z < arg1->numbands; z++) { + band = rt_raster_get_band(raster, arg1->nbands[z]); + if (!band) { + elog(ERROR, "RASTER_dumpValues: Unable to get band at index %d", arg1->nbands[z] + 1); + rtpg_dumpvalues_arg_destroy(arg1); + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + + /* band's hasnodata and nodataval */ + hasnodata = rt_band_get_hasnodata_flag(band); + if (hasnodata) + nodataval = rt_band_get_nodata(band); + POSTGIS_RT_DEBUGF(4, "(hasnodata, nodataval) = (%d, %f)", hasnodata, nodataval); + + /* allocate memory for values and nodata flags */ + arg1->values[z] = palloc(sizeof(Datum) * arg1->rows * arg1->columns); + arg1->nodata[z] = palloc(sizeof(bool) * arg1->rows * arg1->columns); + if (arg1->values[z] == NULL || arg1->nodata[z] == NULL) { + elog(ERROR, "RASTER_dumpValues: Unable to allocate memory for pixel values"); + rtpg_dumpvalues_arg_destroy(arg1); + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + memset(arg1->values[z], 0, sizeof(Datum) * arg1->rows * arg1->columns); + memset(arg1->nodata[z], 0, sizeof(bool) * arg1->rows * arg1->columns); + + i = 0; + for (y = 0; y < arg1->rows; y++) { + for (x = 0; x < arg1->columns; x++) { + /* get pixel */ + if (rt_band_get_pixel(band, x, y, &val) != 0) { + elog(ERROR, "RASTER_dumpValues: Unable to pixel (%d, %d) of band %d", x, y, arg1->nbands[z] + 1); + rtpg_dumpvalues_arg_destroy(arg1); + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + MemoryContextSwitchTo(oldcontext); + SRF_RETURN_DONE(funcctx); + } + + arg1->values[z][i] = Float8GetDatum(val); + POSTGIS_RT_DEBUGF(5, "arg1->values[z][i] = %f", DatumGetFloat8(arg1->values[z][i])); + if (hasnodata) { + POSTGIS_RT_DEBUGF(5, "FLT_EQ?: %d", FLT_EQ(val, nodataval) ? 1 : 0); + } + POSTGIS_RT_DEBUGF(5, "clamped is?: %d", rt_band_clamped_value_is_nodata(band, val)); + + if ( + exclude_nodata_value && + hasnodata && ( + FLT_EQ(val, nodataval) || + rt_band_clamped_value_is_nodata(band, val) == 1 + ) + ) { + arg1->nodata[z][i] = TRUE; + POSTGIS_RT_DEBUG(5, "nodata = 1"); + } + else + POSTGIS_RT_DEBUG(5, "nodata = 0"); + + i++; + } + } + } + + /* cleanup */ + rt_raster_destroy(raster); + PG_FREE_IF_COPY(pgraster, 0); + + /* Store needed information */ + funcctx->user_fctx = arg1; + + /* total number of tuples to be returned */ + funcctx->max_calls = arg1->numbands; + + /* Build a tuple descriptor for our result type */ + if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE) { + MemoryContextSwitchTo(oldcontext); + ereport(ERROR, ( + errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg( + "function returning record called in context " + "that cannot accept type record" + ) + )); + } + + BlessTupleDesc(tupdesc); + funcctx->tuple_desc = tupdesc; + + MemoryContextSwitchTo(oldcontext); + } + + /* stuff done on every call of the function */ + funcctx = SRF_PERCALL_SETUP(); + + call_cntr = funcctx->call_cntr; + max_calls = funcctx->max_calls; + tupdesc = funcctx->tuple_desc; + arg2 = funcctx->user_fctx; + + /* do when there is more left to send */ + if (call_cntr < max_calls) { + int values_length = 2; + Datum values[values_length]; + bool nulls[values_length]; + HeapTuple tuple; + Datum result; + ArrayType *mdValues = NULL; + int dim[2] = {arg2->rows, arg2->columns}; + int lbound[2] = {1, 1}; + + POSTGIS_RT_DEBUGF(3, "call number %d", call_cntr); + POSTGIS_RT_DEBUGF(4, "dim = %d, %d", dim[0], dim[1]); + + memset(nulls, FALSE, sizeof(bool) * values_length); + + values[0] = Int32GetDatum(arg2->nbands[call_cntr] + 1); + + /* info about the type of item in the multi-dimensional array (float8). */ + get_typlenbyvalalign(FLOAT8OID, &typlen, &typbyval, &typalign); + + /* assemble 3-dimension array of values */ + mdValues = construct_md_array( + arg2->values[call_cntr], arg2->nodata[call_cntr], + 2, dim, lbound, + FLOAT8OID, + typlen, typbyval, typalign + ); + values[1] = PointerGetDatum(mdValues); + + /* build a tuple and datum */ + tuple = heap_form_tuple(tupdesc, values, nulls); + result = HeapTupleGetDatum(tuple); + + SRF_RETURN_NEXT(funcctx, result); + } + /* do when there is no more left */ + else { + rtpg_dumpvalues_arg_destroy(arg2); + SRF_RETURN_DONE(funcctx); + } +} + /** * Write value of raster sample on given position and in specified band. */ diff --git a/raster/rt_pg/rtpostgis.sql.in.c b/raster/rt_pg/rtpostgis.sql.in.c index 23fe8b188..aaf49a06e 100644 --- a/raster/rt_pg/rtpostgis.sql.in.c +++ b/raster/rt_pg/rtpostgis.sql.in.c @@ -4119,6 +4119,22 @@ CREATE OR REPLACE FUNCTION st_dumpaspolygons(rast raster, band integer DEFAULT 1 AS 'MODULE_PATHNAME','RASTER_dumpAsPolygons' LANGUAGE 'c' IMMUTABLE STRICT; +----------------------------------------------------------------------- +-- ST_DumpValues +----------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION st_dumpvalues( + rast raster, nband integer[] DEFAULT NULL, exclude_nodata_value boolean DEFAULT TRUE, + OUT nband integer, OUT valarray double precision[][] +) + RETURNS SETOF record + AS 'MODULE_PATHNAME','RASTER_dumpValues' + LANGUAGE 'c' IMMUTABLE; + +CREATE OR REPLACE FUNCTION st_dumpvalues(rast raster, nband integer, exclude_nodata_value boolean DEFAULT TRUE) + RETURNS double precision[][] + AS $$ SELECT valarray FROM st_dumpvalues($1, ARRAY[$2]::integer[], $3) $$ + LANGUAGE 'sql' IMMUTABLE STRICT; + ----------------------------------------------------------------------- -- ST_Polygon ----------------------------------------------------------------------- diff --git a/raster/test/regress/Makefile.in b/raster/test/regress/Makefile.in index c41deb05a..d507b46d1 100644 --- a/raster/test/regress/Makefile.in +++ b/raster/test/regress/Makefile.in @@ -88,6 +88,7 @@ TEST_UTILITY = \ rt_reclass \ rt_resample \ rt_asraster \ + rt_dumpvalues TEST_MAPALGEBRA = \ rt_mapalgebraexpr \ diff --git a/raster/test/regress/rt_dumpvalues.sql b/raster/test/regress/rt_dumpvalues.sql new file mode 100644 index 000000000..a754f8e55 --- /dev/null +++ b/raster/test/regress/rt_dumpvalues.sql @@ -0,0 +1,99 @@ +SET client_min_messages TO warning; +DROP TABLE IF EXISTS raster_dumpvalues; +CREATE TABLE raster_dumpvalues ( + rid integer, + rast raster +); +CREATE OR REPLACE FUNCTION make_raster( + rast raster DEFAULT NULL, + pixtype text DEFAULT '8BUI', + rows integer DEFAULT 3, + columns integer DEFAULT 3, + nodataval double precision DEFAULT 0, + start_val double precision DEFAULT 1, + step double precision DEFAULT 1, + skip_expr text DEFAULT NULL +) + RETURNS raster + AS $$ + DECLARE + x int; + y int; + value double precision; + values double precision[][][]; + result boolean; + expr text; + _rast raster; + nband int; + BEGIN + IF rast IS NULL THEN + nband := 1; + _rast := ST_AddBand(ST_MakeEmptyRaster(columns, rows, 0, 0, 1, -1, 0, 0, 0), nband, pixtype, 0, nodataval); + ELSE + nband := ST_NumBands(rast) + 1; + _rast := ST_AddBand(rast, nband, pixtype, 0, nodataval); + END IF; + + value := start_val; + values := array_fill(NULL::double precision, ARRAY[columns, rows]); + + FOR y IN 1..columns LOOP + FOR x IN 1..rows LOOP + IF skip_expr IS NULL OR length(skip_expr) < 1 THEN + result := TRUE; + ELSE + expr := replace(skip_expr, '[v]'::text, value::text); + EXECUTE 'SELECT (' || expr || ')::boolean' INTO result; + END IF; + + IF result IS TRUE THEN + values[y][x] := value; + END IF; + + value := value + step; + END LOOP; + END LOOP; + + _rast := ST_SetValues(_rast, nband, 1, 1, values); + RETURN _rast; + END; + $$ LANGUAGE 'plpgsql'; + +INSERT INTO raster_dumpvalues + SELECT 1, make_raster(NULL, '8BSI', 3, 3, 0, 1) UNION ALL + SELECT 2, make_raster(NULL, '8BSI', 3, 3, 0, -1) UNION ALL + SELECT 3, make_raster(NULL, '8BSI', 3, 3, 0, 1) UNION ALL + SELECT 4, make_raster(NULL, '8BSI', 3, 3, 0, -2) UNION ALL + SELECT 5, make_raster(NULL, '8BSI', 3, 3, 0, 2) +; + +INSERT INTO raster_dumpvalues + SELECT + rid + 10, + make_raster(rast, '16BSI', 3, 3, rid, (rid / 2)::integer) + FROM raster_dumpvalues + WHERE rid <= 10; + +INSERT INTO raster_dumpvalues + SELECT + rid + 10, + make_raster(rast, '32BSI', 3, 3, rid, (rid / 2)::integer) + FROM raster_dumpvalues + WHERE rid BETWEEN 11 AND 20; + +DROP FUNCTION IF EXISTS make_raster(raster, text, integer, integer, double precision, double precision, double precision, text); + +SELECT + rid, + (ST_DumpValues(rast)).* +FROM raster_dumpvalues +ORDER BY rid; + +SELECT + rid, + (ST_DumpValues(rast, ARRAY[3,2,1])).* +FROM raster_dumpvalues +WHERE rid > 20 +ORDER BY rid; + +DROP TABLE IF EXISTS raster_dumpvalues; diff --git a/raster/test/regress/rt_dumpvalues_expected b/raster/test/regress/rt_dumpvalues_expected new file mode 100644 index 000000000..b31b5be4b --- /dev/null +++ b/raster/test/regress/rt_dumpvalues_expected @@ -0,0 +1,45 @@ +1|1|{{1,2,3},{4,5,6},{7,8,9}} +2|1|{{-1,NULL,1},{2,3,4},{5,6,7}} +3|1|{{1,2,3},{4,5,6},{7,8,9}} +4|1|{{-2,-1,NULL},{1,2,3},{4,5,6}} +5|1|{{2,3,4},{5,6,7},{8,9,10}} +11|1|{{1,2,3},{4,5,6},{7,8,9}} +11|2|{{0,NULL,2},{3,4,5},{6,7,8}} +12|1|{{-1,NULL,1},{2,3,4},{5,6,7}} +12|2|{{1,NULL,3},{4,5,6},{7,8,9}} +13|1|{{1,2,3},{4,5,6},{7,8,9}} +13|2|{{1,2,NULL},{4,5,6},{7,8,9}} +14|1|{{-2,-1,NULL},{1,2,3},{4,5,6}} +14|2|{{2,3,NULL},{5,6,7},{8,9,10}} +15|1|{{2,3,4},{5,6,7},{8,9,10}} +15|2|{{2,3,4},{NULL,6,7},{8,9,10}} +21|1|{{1,2,3},{4,5,6},{7,8,9}} +21|2|{{0,NULL,2},{3,4,5},{6,7,8}} +21|3|{{5,6,7},{8,9,10},{NULL,12,13}} +22|1|{{-1,NULL,1},{2,3,4},{5,6,7}} +22|2|{{1,NULL,3},{4,5,6},{7,8,9}} +22|3|{{6,7,8},{9,10,11},{NULL,13,14}} +23|1|{{1,2,3},{4,5,6},{7,8,9}} +23|2|{{1,2,NULL},{4,5,6},{7,8,9}} +23|3|{{6,7,8},{9,10,11},{12,NULL,14}} +24|1|{{-2,-1,NULL},{1,2,3},{4,5,6}} +24|2|{{2,3,NULL},{5,6,7},{8,9,10}} +24|3|{{7,8,9},{10,11,12},{13,NULL,15}} +25|1|{{2,3,4},{5,6,7},{8,9,10}} +25|2|{{2,3,4},{NULL,6,7},{8,9,10}} +25|3|{{7,8,9},{10,11,12},{13,14,NULL}} +21|3|{{5,6,7},{8,9,10},{NULL,12,13}} +21|2|{{0,NULL,2},{3,4,5},{6,7,8}} +21|1|{{1,2,3},{4,5,6},{7,8,9}} +22|3|{{6,7,8},{9,10,11},{NULL,13,14}} +22|2|{{1,NULL,3},{4,5,6},{7,8,9}} +22|1|{{-1,NULL,1},{2,3,4},{5,6,7}} +23|3|{{6,7,8},{9,10,11},{12,NULL,14}} +23|2|{{1,2,NULL},{4,5,6},{7,8,9}} +23|1|{{1,2,3},{4,5,6},{7,8,9}} +24|3|{{7,8,9},{10,11,12},{13,NULL,15}} +24|2|{{2,3,NULL},{5,6,7},{8,9,10}} +24|1|{{-2,-1,NULL},{1,2,3},{4,5,6}} +25|3|{{7,8,9},{10,11,12},{13,14,NULL}} +25|2|{{2,3,4},{NULL,6,7},{8,9,10}} +25|1|{{2,3,4},{5,6,7},{8,9,10}} -- 2.40.0