This is an automated email from the ASF dual-hosted git repository.

lidavidm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-adbc.git


The following commit(s) were added to refs/heads/main by this push:
     new 6747003deb fix(c/driver/postgresql): check child nulls in ingest 
(#4848)
6747003deb is described below

commit 6747003deb76eaf2d90d2c4ad5095b64505adf8c
Author: David Li <[email protected]>
AuthorDate: Sun Oct 4 16:31:27 2026 -0700

    fix(c/driver/postgresql): check child nulls in ingest (#4848)
    
    Closes #4846.
    
    Generated-by: GPT-6 Astra
---
 .../postgresql/copy/postgres_copy_writer_test.cc   | 148 +++++++++++++++
 c/driver/postgresql/copy/writer.h                  |  17 +-
 .../queries/ingest/fixed_size_list.txtcase         | 201 +++++++++++++++++++++
 .../validation/queries/ingest/list.txtcase         |  80 ++++++++
 compose.yaml                                       |   2 +-
 5 files changed, 445 insertions(+), 3 deletions(-)

diff --git a/c/driver/postgresql/copy/postgres_copy_writer_test.cc 
b/c/driver/postgresql/copy/postgres_copy_writer_test.cc
index e3dcb2966a..801489efa9 100644
--- a/c/driver/postgresql/copy/postgres_copy_writer_test.cc
+++ b/c/driver/postgresql/copy/postgres_copy_writer_test.cc
@@ -1837,6 +1837,154 @@ TEST_F(PostgresCopyTest, 
PostgresCopyWriteFixedSizeListSlicedMatchesDirect) {
   }
 }
 
+// COPY (SELECT CAST("col" AS INTEGER ARRAY) AS "col" FROM (VALUES 
('{NULL,42}'),
+// ('{0,7}'), ('{NULL,NULL}'), (NULL)) AS drvd("col")) TO STDOUT WITH (FORMAT 
binary);
+static const uint8_t kTestPgCopyNullIntegerArray[] = {
+    0x50, 0x47, 0x43, 0x4f, 0x50, 0x59, 0x0a, 0xff, 0x0d, 0x0a, 0x00, 0x00, 
0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x20, 
0x00,
+    0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x17, 0x00, 
0x00,
+    0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 
0x00,
+    0x04, 0x00, 0x00, 0x00, 0x2a, 0x00, 0x01, 0x00, 0x00, 0x00, 0x24, 0x00, 
0x00,
+    0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x17, 0x00, 0x00, 
0x00,
+    0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 
0x00,
+    0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x07, 0x00, 0x01, 0x00, 0x00, 
0x00,
+    0x1c, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 
0x17,
+    0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 
0xff,
+    0xff, 0xff, 0xff, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff};
+
+// Regression for GH-4846: handle null elements inside lists
+TEST_F(PostgresCopyTest, PostgresCopyWriteNullListElements) {
+  for (const auto type :
+       {NANOARROW_TYPE_LIST, NANOARROW_TYPE_LARGE_LIST, 
NANOARROW_TYPE_FIXED_SIZE_LIST}) {
+    for (const bool sliced : {false, true}) {
+      adbc_validation::Handle<struct ArrowSchema> schema;
+      adbc_validation::Handle<struct ArrowArray> array;
+      ASSERT_EQ(ArrowSchemaInitFromType(&schema.value, NANOARROW_TYPE_STRUCT), 
0);
+      ASSERT_EQ(ArrowSchemaAllocateChildren(&schema.value, 1), 0);
+      ArrowSchemaInit(schema->children[0]);
+      if (type == NANOARROW_TYPE_FIXED_SIZE_LIST) {
+        ASSERT_EQ(ArrowSchemaSetTypeFixedSize(schema->children[0], type, 2), 
0);
+      } else {
+        ASSERT_EQ(ArrowSchemaSetType(schema->children[0], type), 0);
+      }
+      ASSERT_EQ(ArrowSchemaSetName(schema->children[0], "col"), 0);
+      ASSERT_EQ(
+          ArrowSchemaSetType(schema->children[0]->children[0], 
NANOARROW_TYPE_INT32), 0);
+      ASSERT_EQ(ArrowArrayInitFromSchema(&array.value, &schema.value, 
nullptr), 0);
+      ASSERT_EQ(ArrowArrayStartAppending(&array.value), 0);
+      using Row = std::optional<std::vector<std::optional<int32_t>>>;
+      const std::vector<Row> rows = {
+          std::vector<std::optional<int32_t>>{std::nullopt, 42},
+          std::vector<std::optional<int32_t>>{0, 7},
+          std::vector<std::optional<int32_t>>{std::nullopt, std::nullopt}, 
std::nullopt};
+      auto append_row = [&](const Row& row) {
+        auto* list = array->children[0];
+        if (!row.has_value()) {
+          ASSERT_EQ(ArrowArrayAppendNull(list, 1), 0);
+        } else {
+          for (const auto& value : *row) {
+            if (value.has_value()) {
+              ASSERT_EQ(ArrowArrayAppendInt(list->children[0], *value), 0);
+            } else {
+              ASSERT_EQ(ArrowArrayAppendNull(list->children[0], 1), 0);
+            }
+          }
+          ASSERT_EQ(ArrowArrayFinishElement(list), 0);
+        }
+        ASSERT_EQ(ArrowArrayFinishElement(&array.value), 0);
+      };
+      if (sliced) {
+        
ASSERT_NO_FATAL_FAILURE(append_row(std::vector<std::optional<int32_t>>{8, 9}));
+      }
+      for (const auto& row : rows) {
+        ASSERT_NO_FATAL_FAILURE(append_row(row));
+      }
+      ASSERT_EQ(ArrowArrayFinishBuildingDefault(&array.value, nullptr), 0);
+      if (sliced) {
+        array->length = rows.size();
+        array->children[0]->offset = 1;
+        array->children[0]->length = rows.size();
+      }
+
+      PostgresCopyStreamWriteTester tester;
+      ASSERT_EQ(tester.Init(&schema.value, &array.value, *type_resolver_), 0);
+      ASSERT_EQ(tester.WriteAll(nullptr), ENODATA);
+      const struct ArrowBuffer buf = tester.WriteBuffer();
+      // The last 2 bytes of a message can be transmitted via PQputCopyData
+      // so no need to test those bytes from the Writer
+      constexpr size_t buf_size = sizeof(kTestPgCopyNullIntegerArray) - 2;
+      ASSERT_EQ(buf.size_bytes, buf_size);
+      for (size_t i = 0; i < buf_size; i++) {
+        ASSERT_EQ(buf.data[i], kTestPgCopyNullIntegerArray[i])
+            << "failure at index " << i;
+      }
+    }
+  }
+}
+
+TEST_F(PostgresCopyTest, PostgresCopyWriteNullDictionaryListElements) {
+  for (const auto type : {NANOARROW_TYPE_STRING, NANOARROW_TYPE_BINARY}) {
+    SCOPED_TRACE(type);
+    std::vector<uint8_t> reference;
+    for (const bool dictionary : {false, true}) {
+      adbc_validation::Handle<struct ArrowSchema> schema;
+      adbc_validation::Handle<struct ArrowArray> array;
+      ASSERT_EQ(
+          adbc_validation::MakeSchema(
+              &schema.value, {adbc_validation::SchemaField::Nested(
+                                 "col", NANOARROW_TYPE_LIST,
+                                 {{"item", dictionary ? NANOARROW_TYPE_INT8 : 
type}})}),
+          ADBC_STATUS_OK);
+      if (dictionary) {
+        auto* child = schema->children[0]->children[0];
+        ASSERT_EQ(ArrowSchemaAllocateDictionary(child), 0);
+        ASSERT_EQ(ArrowSchemaInitFromType(child->dictionary, type), 0);
+      }
+      ASSERT_EQ(ArrowArrayInitFromSchema(&array.value, &schema.value, 
nullptr), 0);
+      ASSERT_EQ(ArrowArrayStartAppending(&array.value), 0);
+      auto* list = array->children[0];
+      auto* child = list->children[0];
+      ArrowBufferView value;
+      value.data.as_char = "x";
+      value.size_bytes = 1;
+      if (dictionary) {
+        ASSERT_EQ(ArrowArrayAppendBytes(child->dictionary, value), 0);
+        ASSERT_EQ(ArrowArrayAppendNull(child->dictionary, 1), 0);
+        ASSERT_EQ(ArrowArrayFinishBuildingDefault(child->dictionary, nullptr), 
0);
+      }
+      for (int row = 0; row < 3; ++row) {
+        // Separate null indices from null dictionary values so each must set
+        // the header flag. A null index's default zero payload points to "x".
+        if (row == 0 || (!dictionary && row == 1)) {
+          ASSERT_EQ(ArrowArrayAppendNull(child, 1), 0);
+        } else if (dictionary) {
+          ASSERT_EQ(ArrowArrayAppendInt(child, row == 1 ? 1 : 0), 0);
+        } else {
+          ASSERT_EQ(ArrowArrayAppendBytes(child, value), 0);
+        }
+        if (dictionary) {
+          ASSERT_EQ(ArrowArrayAppendInt(child, 0), 0);
+        } else {
+          ASSERT_EQ(ArrowArrayAppendBytes(child, value), 0);
+        }
+        ASSERT_EQ(ArrowArrayFinishElement(list), 0);
+        ASSERT_EQ(ArrowArrayFinishElement(&array.value), 0);
+      }
+      ASSERT_EQ(ArrowArrayFinishBuildingDefault(&array.value, nullptr), 0);
+      PostgresCopyStreamWriteTester tester;
+      ASSERT_EQ(tester.Init(&schema.value, &array.value, *type_resolver_), 0);
+      ASSERT_EQ(tester.WriteAll(nullptr), ENODATA);
+      const auto buf = tester.WriteBuffer();
+      const std::vector<uint8_t> actual(buf.data, buf.data + buf.size_bytes);
+      if (dictionary) {
+        EXPECT_EQ(actual, reference);
+      } else {
+        reference = actual;
+      }
+    }
+  }
+}
+
 TEST_F(PostgresCopyTest, PostgresCopyWriteMultiBatch) {
   // Regression test for https://github.com/apache/arrow-adbc/issues/1310
   adbc_validation::Handle<struct ArrowSchema> schema;
diff --git a/c/driver/postgresql/copy/writer.h 
b/c/driver/postgresql/copy/writer.h
index a5f9ead1d5..040d55f77e 100644
--- a/c/driver/postgresql/copy/writer.h
+++ b/c/driver/postgresql/copy/writer.h
@@ -872,7 +872,7 @@ class PostgresCopyListFieldWriter : public 
PostgresCopyFieldWriter {
     }
 
     constexpr int32_t ndim = 1;
-    constexpr int32_t has_null_flags = 0;
+    int32_t has_null_flags = 0;
 
     // TODO: the LARGE_LIST should use 64 bit indexes
     int32_t start, end;
@@ -899,8 +899,21 @@ class PostgresCopyListFieldWriter : public 
PostgresCopyFieldWriter {
     // directly to our buffer
     nanoarrow::UniqueBuffer tmp;
     ArrowBufferInit(tmp.get());
+    const auto* child_view = array_view_->children[0];
     for (auto i = start; i < end; ++i) {
-      NANOARROW_RETURN_NOT_OK(child_->Write(tmp.get(), i, error));
+      bool is_null = ArrowArrayViewIsNull(child_view, i);
+      if (!is_null && child_view->dictionary != nullptr) {
+        const int64_t dict_index = ArrowArrayViewGetIntUnsafe(child_view, i);
+        is_null = ArrowArrayViewIsNull(child_view->dictionary, dict_index);
+      }
+      if (is_null) {
+        has_null_flags = 1;
+        constexpr int32_t field_size_bytes = -1;
+        NANOARROW_RETURN_NOT_OK(
+            WriteChecked<int32_t>(tmp.get(), field_size_bytes, error));
+      } else {
+        NANOARROW_RETURN_NOT_OK(child_->Write(tmp.get(), i, error));
+      }
     }
     const int32_t field_size_bytes = sizeof(ndim) + sizeof(has_null_flags) +
                                      sizeof(child_oid_) + sizeof(dim) * ndim +
diff --git 
a/c/driver/postgresql/validation/queries/ingest/fixed_size_list.txtcase 
b/c/driver/postgresql/validation/queries/ingest/fixed_size_list.txtcase
new file mode 100644
index 0000000000..25d8325a6b
--- /dev/null
+++ b/c/driver/postgresql/validation/queries/ingest/fixed_size_list.txtcase
@@ -0,0 +1,201 @@
+// 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.
+
+// part: metadata
+
+[tags]
+sql-type-name = "ARRAY"
+field-type-name = ["_bool", "_int8", "_text", "_bool", "_int8", "_text"]
+
+// part: input_schema
+
+{
+    "format": "+s",
+    "children": [
+        {
+            "name": "idx",
+            "format": "l",
+            "flags": ["nullable"]
+        },
+        {
+            "name": "bools",
+            "format": "+w:3",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "b",
+                    "flags": ["nullable"]
+                }
+            ]
+        },
+        {
+            "name": "ints",
+            "format": "+w:3",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "l",
+                    "flags": ["nullable"]
+                }
+            ]
+        },
+        {
+            "name": "strings",
+            "format": "+w:3",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "u",
+                    "flags": ["nullable"]
+                }
+            ]
+        },
+        {
+            "name": "empty_bools",
+            "format": "+w:0",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "b",
+                    "flags": ["nullable"]
+                }
+            ]
+        },
+        {
+            "name": "empty_ints",
+            "format": "+w:0",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "l",
+                    "flags": ["nullable"]
+                }
+            ]
+        },
+        {
+            "name": "empty_strings",
+            "format": "+w:0",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "u",
+                    "flags": ["nullable"]
+                }
+            ]
+        }
+    ]
+}
+
+// part: input
+
+{"idx": 0, "bools": null, "ints": null, "strings": null, "empty_bools": null, 
"empty_ints": null, "empty_strings": null}
+{"idx": 1, "bools": [true, false, true], "ints": [-9223372036854775808, 0, 
9223372036854775807], "strings": ["", "hello", "NULL"], "empty_bools": [], 
"empty_ints": [], "empty_strings": []}
+{"idx": 2, "bools": [true, null, false], "ints": [-1, null, 42], "strings": 
["hello", null, ""], "empty_bools": [], "empty_ints": [], "empty_strings": []}
+{"idx": 3, "bools": [null, null, null], "ints": [null, null, null], "strings": 
[null, null, null], "empty_bools": [], "empty_ints": [], "empty_strings": []}
+{"idx": 4, "bools": [false, true, false], "ints": [0, 1, -1], "strings": ["", 
"NULL", "world"], "empty_bools": [], "empty_ints": [], "empty_strings": []}
+
+// PostgreSQL returns variable-size lists for array columns.
+// part: expected_schema
+
+{
+    "format": "+s",
+    "children": [
+        {
+            "name": "idx",
+            "format": "l",
+            "flags": ["nullable"]
+        },
+        {
+            "name": "bools",
+            "format": "+l",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "b",
+                    "flags": ["nullable"]
+                }
+            ]
+        },
+        {
+            "name": "ints",
+            "format": "+l",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "l",
+                    "flags": ["nullable"]
+                }
+            ]
+        },
+        {
+            "name": "strings",
+            "format": "+l",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "u",
+                    "flags": ["nullable"]
+                }
+            ]
+        },
+        {
+            "name": "empty_bools",
+            "format": "+l",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "b",
+                    "flags": ["nullable"]
+                }
+            ]
+        },
+        {
+            "name": "empty_ints",
+            "format": "+l",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "l",
+                    "flags": ["nullable"]
+                }
+            ]
+        },
+        {
+            "name": "empty_strings",
+            "format": "+l",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "u",
+                    "flags": ["nullable"]
+                }
+            ]
+        }
+    ]
+}
diff --git a/c/driver/postgresql/validation/queries/ingest/list.txtcase 
b/c/driver/postgresql/validation/queries/ingest/list.txtcase
new file mode 100644
index 0000000000..40e83a659e
--- /dev/null
+++ b/c/driver/postgresql/validation/queries/ingest/list.txtcase
@@ -0,0 +1,80 @@
+// 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.
+
+// part: metadata
+
+[tags]
+sql-type-name = "ARRAY"
+field-type-name = ["_bool", "_int8", "_text"]
+
+// part: input_schema
+
+{
+    "format": "+s",
+    "children": [
+        {
+            "name": "idx",
+            "format": "l",
+            "flags": ["nullable"]
+        },
+        {
+            "name": "bools",
+            "format": "+l",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "b",
+                    "flags": ["nullable"]
+                }
+            ]
+        },
+        {
+            "name": "ints",
+            "format": "+l",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "l",
+                    "flags": ["nullable"]
+                }
+            ]
+        },
+        {
+            "name": "strings",
+            "format": "+l",
+            "flags": ["nullable"],
+            "children": [
+                {
+                    "name": "item",
+                    "format": "u",
+                    "flags": ["nullable"]
+                }
+            ]
+        }
+    ]
+}
+
+// part: input
+
+{"idx": 0, "bools": null, "ints": null, "strings": null}
+{"idx": 1, "bools": [], "ints": [], "strings": []}
+{"idx": 2, "bools": [true, false, true], "ints": [-9223372036854775808, 0, 
9223372036854775807], "strings": ["", "hello", "NULL"]}
+{"idx": 3, "bools": [true, null, false], "ints": [-1, null, 42], "strings": 
["hello", null, ""]}
+{"idx": 4, "bools": [null, null, null], "ints": [null, null, null], "strings": 
[null, null, null]}
+{"idx": 5, "bools": [false, true, false], "ints": [0, 1, -1], "strings": ["", 
"NULL", "world"]}
diff --git a/compose.yaml b/compose.yaml
index 4a56ea449b..90ffbb5265 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -357,7 +357,7 @@ services:
 
   cedardb-test:
     container_name: adbc_cedardb_test
-    image: cedardb/cedardb:v2026-08-13
+    image: cedardb/cedardb:v2026-09-03
     environment:
       CEDAR_PASSWORD: CedarDB2026!
     healthcheck:

Reply via email to