This is an automated email from the ASF dual-hosted git repository.
lixueclaire pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-graphar.git
The following commit(s) were added to refs/heads/main by this push:
new eb1af90d feat(c++): support build multi-properties in high-level API
(#722)
eb1af90d is described below
commit eb1af90d7c2e1a22d6e8b0e9c447b876c9aaff9b
Author: Xiaokang Yang <[email protected]>
AuthorDate: Tue Aug 5 14:54:02 2025 +0800
feat(c++): support build multi-properties in high-level API (#722)
---
cpp/examples/high_level_writer_example.cc | 13 ++-
cpp/src/graphar/arrow/chunk_writer.cc | 11 ++-
cpp/src/graphar/high-level/vertices_builder.cc | 129 +++++++++++++++---------
cpp/src/graphar/high-level/vertices_builder.h | 92 +++++++++++++++++
cpp/src/graphar/types.h | 13 ++-
cpp/test/test_multi_property.cc | 130 ++++++++++++++++++++++++-
testing | 2 +-
7 files changed, 338 insertions(+), 52 deletions(-)
diff --git a/cpp/examples/high_level_writer_example.cc
b/cpp/examples/high_level_writer_example.cc
index 0bd040fa..c61bd1f4 100644
--- a/cpp/examples/high_level_writer_example.cc
+++ b/cpp/examples/high_level_writer_example.cc
@@ -29,7 +29,7 @@
void vertices_builder() {
// construct vertices builder
std::string vertex_meta_file =
- GetTestingResourceRoot() + "/ldbc_sample/parquet/" + "person.vertex.yml";
+ GetTestingResourceRoot() + "/ldbc/parquet/" + "person.vertex.yml";
auto vertex_meta = graphar::Yaml::LoadFile(vertex_meta_file).value();
auto vertex_info = graphar::VertexInfo::Load(vertex_meta).value();
graphar::IdType start_index = 0;
@@ -45,11 +45,16 @@ void vertices_builder() {
// prepare vertex data
int vertex_count = 3;
std::vector<std::string> property_names = {"id", "firstName", "lastName",
- "gender"};
+ "gender", "emails"};
std::vector<int64_t> id = {0, 1, 2};
std::vector<std::string> firstName = {"John", "Jane", "Alice"};
std::vector<std::string> lastName = {"Smith", "Doe", "Wonderland"};
std::vector<std::string> gender = {"male", "famale", "famale"};
+ std::vector<std::vector<std::string>> emails = {
+ {"[email protected]", "[email protected]"},
+ {"[email protected]"},
+ {"[email protected]", "[email protected]",
+ "[email protected]"}};
// add vertices
for (int i = 0; i < vertex_count; i++) {
@@ -58,6 +63,10 @@ void vertices_builder() {
v.AddProperty(property_names[1], firstName[i]);
v.AddProperty(property_names[2], lastName[i]);
v.AddProperty(property_names[3], gender[i]);
+ for (const auto& email : emails[i]) {
+ v.AddProperty(graphar::Cardinality::LIST, property_names[4],
+ email); // Multi-property
+ }
ASSERT(builder.AddVertex(v).ok());
}
diff --git a/cpp/src/graphar/arrow/chunk_writer.cc
b/cpp/src/graphar/arrow/chunk_writer.cc
index c8612383..ba949bd2 100644
--- a/cpp/src/graphar/arrow/chunk_writer.cc
+++ b/cpp/src/graphar/arrow/chunk_writer.cc
@@ -23,6 +23,7 @@
#include "arrow/api.h"
#include "arrow/compute/api.h"
+#include "graphar/fwd.h"
#include "graphar/writer_util.h"
#if defined(ARROW_VERSION) && ARROW_VERSION >= 12000000
#include "arrow/acero/exec_plan.h"
@@ -193,10 +194,16 @@ Status VertexPropertyWriter::validate(
" does not exist in the input table.");
}
auto field = schema->field(indice);
- if (DataType::ArrowDataTypeToDataType(field->type()) != property.type) {
+ auto schema_data_type = DataType::DataTypeToArrowDataType(property.type);
+ if (property.cardinality != Cardinality::SINGLE) {
+ schema_data_type = arrow::list(schema_data_type);
+ }
+ if (!DataType::ArrowDataTypeToDataType(field->type())
+ ->Equals(DataType::ArrowDataTypeToDataType(schema_data_type))) {
return Status::TypeError(
"The data type of property: ", property.name, " is ",
- property.type->ToTypeName(), ", but got ",
+ DataType::ArrowDataTypeToDataType(schema_data_type)->ToTypeName(),
+ ", but got ",
DataType::ArrowDataTypeToDataType(field->type())->ToTypeName(),
".");
}
diff --git a/cpp/src/graphar/high-level/vertices_builder.cc
b/cpp/src/graphar/high-level/vertices_builder.cc
index fa93fa61..fd0208be 100644
--- a/cpp/src/graphar/high-level/vertices_builder.cc
+++ b/cpp/src/graphar/high-level/vertices_builder.cc
@@ -18,8 +18,14 @@
*/
#include "graphar/high-level/vertices_builder.h"
+#include <any>
+#include <iterator>
+#include <vector>
#include "graphar/convert_to_arrow_type.h"
+#include "graphar/fwd.h"
#include "graphar/graph_info.h"
+#include "graphar/label.h"
+#include "graphar/status.h"
namespace graphar::builder {
@@ -66,59 +72,62 @@ Status VerticesBuilder::validate(const Vertex& v, IdType
index,
bool invalid_type = false;
switch (type->id()) {
case Type::BOOL:
- if (property.second.type() !=
- typeid(typename TypeToArrowType<Type::BOOL>::CType)) {
- invalid_type = true;
- }
+ GAR_RETURN_NOT_OK(
+ v.ValidatePropertyType<typename
TypeToArrowType<Type::BOOL>::CType>(
+ property.first,
+ vertex_info_->GetPropertyCardinality(property.first).value()));
break;
case Type::INT32:
- if (property.second.type() !=
- typeid(typename TypeToArrowType<Type::INT32>::CType)) {
- invalid_type = true;
- }
+ GAR_RETURN_NOT_OK(v.ValidatePropertyType<
+ typename TypeToArrowType<Type::INT32>::CType>(
+ property.first,
+ vertex_info_->GetPropertyCardinality(property.first).value()));
break;
case Type::INT64:
- if (property.second.type() !=
- typeid(typename TypeToArrowType<Type::INT64>::CType)) {
- invalid_type = true;
- }
+ GAR_RETURN_NOT_OK(v.ValidatePropertyType<
+ typename TypeToArrowType<Type::INT64>::CType>(
+ property.first,
+ vertex_info_->GetPropertyCardinality(property.first).value()));
break;
case Type::FLOAT:
- if (property.second.type() !=
- typeid(typename TypeToArrowType<Type::FLOAT>::CType)) {
- invalid_type = true;
- }
+ GAR_RETURN_NOT_OK(v.ValidatePropertyType<
+ typename TypeToArrowType<Type::FLOAT>::CType>(
+ property.first,
+ vertex_info_->GetPropertyCardinality(property.first).value()));
break;
case Type::DOUBLE:
- if (property.second.type() !=
- typeid(typename TypeToArrowType<Type::DOUBLE>::CType)) {
- invalid_type = true;
- }
+ GAR_RETURN_NOT_OK(v.ValidatePropertyType<
+ typename TypeToArrowType<Type::DOUBLE>::CType>(
+ property.first,
+ vertex_info_->GetPropertyCardinality(property.first).value()));
break;
case Type::STRING:
- if (property.second.type() !=
- typeid(typename TypeToArrowType<Type::STRING>::CType)) {
- invalid_type = true;
- }
+ GAR_RETURN_NOT_OK(v.ValidatePropertyType<
+ typename TypeToArrowType<Type::STRING>::CType>(
+ property.first,
+ vertex_info_->GetPropertyCardinality(property.first).value()));
break;
case Type::DATE:
// date is stored as int32_t
- if (property.second.type() !=
- typeid(typename TypeToArrowType<Type::DATE>::CType::c_type)) {
- invalid_type = true;
- }
+ GAR_RETURN_NOT_OK(v.ValidatePropertyType<
+ typename TypeToArrowType<Type::DATE>::CType::c_type>(
+ property.first,
+ vertex_info_->GetPropertyCardinality(property.first).value()));
break;
case Type::TIMESTAMP:
// timestamp is stored as int64_t
- if (property.second.type() !=
- typeid(typename TypeToArrowType<Type::TIMESTAMP>::CType::c_type)) {
- invalid_type = true;
- }
+ GAR_RETURN_NOT_OK(
+ v.ValidatePropertyType<
+ typename TypeToArrowType<Type::TIMESTAMP>::CType::c_type>(
+ property.first,
+ vertex_info_->GetPropertyCardinality(property.first).value()));
break;
default:
return Status::TypeError("Unsupported property type.");
}
- if (invalid_type) {
+ if (invalid_type &&
+ Cardinality::SINGLE ==
+ vertex_info_->GetPropertyCardinality(property.first).value()) {
return Status::TypeError(
"Invalid data type for property ", property.first + ", defined as
",
type->ToTypeName(), ", but got ", property.second.type().name());
@@ -134,16 +143,41 @@ Status VerticesBuilder::tryToAppend(
std::shared_ptr<arrow::Array>& array) { // NOLINT
using CType = typename TypeToArrowType<type>::CType;
arrow::MemoryPool* pool = arrow::default_memory_pool();
- typename TypeToArrowType<type>::BuilderType builder(pool);
- for (auto& v : vertices_) {
- if (v.Empty() || !v.ContainProperty(property_name)) {
- RETURN_NOT_ARROW_OK(builder.AppendNull());
- } else {
- RETURN_NOT_ARROW_OK(
- builder.Append(std::any_cast<CType>(v.GetProperty(property_name))));
+ auto builder =
+ std::make_shared<typename TypeToArrowType<type>::BuilderType>(pool);
+ auto cardinality =
+ vertex_info_->GetPropertyCardinality(property_name).value();
+ if (cardinality != Cardinality::SINGLE) {
+ arrow::ListBuilder list_builder(pool, builder);
+ for (auto& v : vertices_) {
+ RETURN_NOT_ARROW_OK(list_builder.Append());
+ if (v.Empty() || !v.ContainProperty(property_name)) {
+ RETURN_NOT_ARROW_OK(builder->AppendNull());
+ } else {
+ if (!v.IsMultiProperty(property_name)) {
+ RETURN_NOT_ARROW_OK(builder->Append(
+ std::any_cast<CType>(v.GetProperty(property_name))));
+ } else {
+ auto property_value_list = std::any_cast<std::vector<std::any>>(
+ v.GetProperty(property_name));
+ for (auto& value : property_value_list) {
+ RETURN_NOT_ARROW_OK(builder->Append(std::any_cast<CType>(value)));
+ }
+ }
+ }
+ }
+ array = list_builder.Finish().ValueOrDie();
+ } else {
+ for (auto& v : vertices_) {
+ if (v.Empty() || !v.ContainProperty(property_name)) {
+ RETURN_NOT_ARROW_OK(builder->AppendNull());
+ } else {
+ RETURN_NOT_ARROW_OK(builder->Append(
+ std::any_cast<CType>(v.GetProperty(property_name))));
+ }
}
+ array = builder->Finish().ValueOrDie();
}
- array = builder.Finish().ValueOrDie();
return Status::OK();
}
@@ -219,11 +253,18 @@ Result<std::shared_ptr<arrow::Table>>
VerticesBuilder::convertToTable() {
for (auto& property_group : property_groups) {
for (auto& property : property_group->GetProperties()) {
// add a column to schema
- schema_vector.push_back(arrow::field(
- property.name, DataType::DataTypeToArrowDataType(property.type)));
+ if (vertex_info_->GetPropertyCardinality(property.name).value() !=
+ Cardinality::SINGLE) {
+ schema_vector.push_back(arrow::field(
+ property.name,
+ arrow::list(DataType::DataTypeToArrowDataType(property.type))));
+ } else {
+ schema_vector.push_back(arrow::field(
+ property.name, DataType::DataTypeToArrowDataType(property.type)));
+ }
// add a column to data
std::shared_ptr<arrow::Array> array;
- appendToArray(property.type, property.name, array);
+ GAR_RETURN_NOT_OK(appendToArray(property.type, property.name, array));
arrays.push_back(array);
}
}
diff --git a/cpp/src/graphar/high-level/vertices_builder.h
b/cpp/src/graphar/high-level/vertices_builder.h
index 40c73d57..b044c59a 100644
--- a/cpp/src/graphar/high-level/vertices_builder.h
+++ b/cpp/src/graphar/high-level/vertices_builder.h
@@ -20,16 +20,21 @@
#pragma once
#include <any>
+#include <cassert>
#include <cstddef>
#include <memory>
#include <string>
#include <unordered_map>
+#include <unordered_set>
#include <utility>
#include <vector>
#include "graphar/arrow/chunk_writer.h"
+#include "graphar/fwd.h"
#include "graphar/graph_info.h"
#include "graphar/result.h"
+#include "graphar/status.h"
+#include "graphar/types.h"
#include "graphar/writer_util.h"
// forward declaration
@@ -88,6 +93,28 @@ class Vertex {
properties_[name] = val;
}
+ inline void AddProperty(const Cardinality cardinality,
+ const std::string& name, const std::any& val) {
+ if (cardinality == Cardinality::SINGLE) {
+ cardinalities_[name] = Cardinality::SINGLE;
+ AddProperty(name, val);
+ return;
+ }
+ empty_ = false;
+ if (cardinalities_.find(name) != cardinalities_.end()) {
+ assert(cardinalities_[name] == cardinality);
+ auto property_value_list =
+ std::any_cast<std::vector<std::any>>(properties_[name]);
+ property_value_list.push_back(val);
+ properties_[name] = property_value_list;
+ } else {
+ auto property_value_list = std::vector<std::any>();
+ property_value_list.push_back(val);
+ properties_[name] = property_value_list;
+ }
+ cardinalities_[name] = cardinality;
+ }
+
/**
* @brief Get a property of the vertex.
*
@@ -118,10 +145,75 @@ class Vertex {
return (properties_.find(property) != properties_.end());
}
+ inline bool IsMultiProperty(const std::string& property) const {
+ return (cardinalities_.find(property) != cardinalities_.end() &&
+ cardinalities_.at(property) != Cardinality::SINGLE);
+ }
+
+ template <typename T>
+ Status ValidatePropertyType(const std::string& property,
+ const Cardinality cardinality) const {
+ if (cardinality == Cardinality::SINGLE && IsMultiProperty(property)) {
+ return Status::TypeError(
+ "Invalid data cardinality for property ", property,
+ ", defined as SINGLE but got ",
+ cardinalities_.at(property) == Cardinality::LIST ? "LIST" : "SET");
+ }
+ if (IsMultiProperty(property) &&
+ (cardinality == Cardinality::SET ||
+ cardinalities_.at(property) == Cardinality::SET)) {
+ GAR_RETURN_NOT_OK(ValidateMultiPropertySet<T>(property));
+ }
+ if (IsMultiProperty(property)) {
+ auto value_list =
+ std::any_cast<std::vector<std::any>>(properties_.at(property));
+ for (auto value : value_list) {
+ auto& value_type = value.type();
+ if (value_type != typeid(T)) {
+ return Status::TypeError("Invalid data type for property ", property,
+ ", defined as ", typeid(T).name(),
+ ", but got ", value_type.name());
+ }
+ }
+ } else {
+ auto& value_type = properties_.at(property).type();
+ if (value_type != typeid(T)) {
+ return Status::TypeError("Invalid data type for property ", property,
+ ", defined as ", typeid(T).name(),
+ ", but got ", value_type.name());
+ }
+ }
+ return Status::OK();
+ }
+
+ template <typename T>
+ Status ValidateMultiProperty(const std::string& property) const {
+ if (IsMultiProperty(property) &&
+ cardinalities_.at(property) == Cardinality::SET) {
+ GAR_RETURN_NOT_OK(ValidateMultiPropertySet<T>(property));
+ }
+ return Status::OK();
+ }
+
+ template <typename T>
+ Status ValidateMultiPropertySet(const std::string& property) const {
+ auto vec = std::any_cast<std::vector<std::any>>(properties_.at(property));
+ std::unordered_set<T> seen;
+ for (const auto& item : vec) {
+ if (!seen.insert(std::any_cast<T>(item)).second) {
+ return Status::KeyError(
+ "Duplicate values exist in set type multi-property key: ",
property,
+ " value: ", std::any_cast<T>(item));
+ }
+ }
+ return Status::OK();
+ }
+
private:
IdType id_;
bool empty_;
std::unordered_map<std::string, std::any> properties_;
+ std::unordered_map<std::string, Cardinality> cardinalities_;
};
/**
diff --git a/cpp/src/graphar/types.h b/cpp/src/graphar/types.h
index ac318ca4..54f322ee 100644
--- a/cpp/src/graphar/types.h
+++ b/cpp/src/graphar/types.h
@@ -100,8 +100,17 @@ class DataType {
inline DataType& operator=(const DataType& other) = default;
bool Equals(const DataType& other) const {
- return id_ == other.id_ &&
- user_defined_type_name_ == other.user_defined_type_name_;
+ if (id_ != other.id_ ||
+ user_defined_type_name_ != other.user_defined_type_name_) {
+ return false;
+ }
+ if (child_ == nullptr && other.child_ == nullptr) {
+ return true;
+ }
+ if (child_ != nullptr && other.child_ != nullptr) {
+ return child_->Equals(other.child_);
+ }
+ return false;
}
bool Equals(const std::shared_ptr<DataType>& other) const {
diff --git a/cpp/test/test_multi_property.cc b/cpp/test/test_multi_property.cc
index f82fc608..fc07fe62 100644
--- a/cpp/test/test_multi_property.cc
+++ b/cpp/test/test_multi_property.cc
@@ -24,13 +24,18 @@
#include <ostream>
#include <string>
#include "arrow/api.h"
+#include "arrow/filesystem/api.h"
#include "examples/config.h"
#include "graphar/arrow/chunk_reader.h"
#include "graphar/arrow/chunk_writer.h"
+#include "graphar/fwd.h"
#include "graphar/graph_info.h"
#include "graphar/types.h"
#include "parquet/arrow/writer.h"
+#include "graphar/api/high_level_writer.h"
+#include "graphar/writer_util.h"
+
#include "./util.h"
#include <catch2/catch_test_macros.hpp>
@@ -58,7 +63,7 @@ std::shared_ptr<arrow::Table> read_csv_to_table(const
std::string& filename) {
}
namespace graphar {
-TEST_CASE_METHOD(GlobalFixture, "read from csv file") {
+TEST_CASE_METHOD(GlobalFixture, "read multi-properties from csv file") {
// read labels csv file as arrow table
auto person_table = read_csv_to_table(test_data_dir +
"/ldbc/person_0_0.csv");
auto seed = static_cast<unsigned int>(time(NULL));
@@ -158,4 +163,127 @@ TEST_CASE_METHOD(GlobalFixture, "read from csv file") {
std::cout << emails << std::endl;
ASSERT(expected_emails == emails);
}
+TEST_CASE_METHOD(GlobalFixture, "TestMultiProperty high level builder") {
+ int vertex_count = 3;
+ std::vector<std::string> property_names = {"id", "emails"};
+ std::vector<int64_t> id = {0, 1, 2};
+ std::vector<std::vector<std::string>> emails = {
+ {"[email protected]", "[email protected]"},
+ {"[email protected]"},
+ {"[email protected]", "[email protected]",
+ "[email protected]"}};
+ std::string vertex_meta_file =
+ GetTestingResourceRoot() + "/ldbc/parquet/" + "person.vertex.yml";
+ auto vertex_meta = graphar::Yaml::LoadFile(vertex_meta_file).value();
+ auto vertex_info = graphar::VertexInfo::Load(vertex_meta).value();
+ graphar::IdType start_index = 0;
+ SECTION("add sample values to set property") {
+ graphar::builder::VerticesBuilder builder(
+ vertex_info, "/tmp/", start_index, nullptr,
+ graphar::ValidateLevel::strong_validate);
+ // prepare vertex data
+ int vertex_count = 1;
+ std::vector<std::vector<std::string>> emails = {
+ {"[email protected]", "[email protected]"}};
+ // add vertices
+ for (int i = 0; i < vertex_count; i++) {
+ graphar::builder::Vertex v;
+ for (const auto& email : emails[i]) {
+ v.AddProperty(graphar::Cardinality::SET, "emails",
+ email); // Multi-property
+ }
+ ASSERT(builder.AddVertex(v).IsKeyError());
+ }
+ }
+ SECTION("test add single values to set property") {
+ graphar::builder::VerticesBuilder builder(
+ vertex_info, "/tmp/", start_index, nullptr,
+ graphar::ValidateLevel::strong_validate);
+ // prepare vertex data
+ int vertex_count = 3;
+ std::vector<std::string> emails = {"[email protected]", "[email protected]",
+ "[email protected]"};
+ for (int i = 0; i < vertex_count; i++) {
+ graphar::builder::Vertex v;
+ v.AddProperty("emails", emails[i]);
+ ASSERT(builder.AddVertex(v).ok());
+ }
+ }
+ SECTION("test add multi values to single property") {
+ auto single_email =
+ CreatePropertyGroup({Property("single_email", string(), false)},
+ FileType::PARQUET, "single_email/");
+ auto test_vertex_info =
vertex_info->AddPropertyGroup(single_email).value();
+ graphar::builder::VerticesBuilder builder(
+ test_vertex_info, "/tmp/", start_index, nullptr,
+ graphar::ValidateLevel::strong_validate);
+ for (int i = 0; i < vertex_count; i++) {
+ graphar::builder::Vertex v;
+ v.AddProperty(graphar::Cardinality::LIST, "single_email", emails[i]);
+ ASSERT(builder.AddVertex(v).IsTypeError());
+ }
+ }
+ SECTION("test add multi values to set property") {
+ auto set_email = CreatePropertyGroup(
+ {Property("set_email", string(), false, true, Cardinality::SET)},
+ FileType::PARQUET, "set_email/");
+ auto test_vertex_info = vertex_info->AddPropertyGroup(set_email).value();
+ graphar::builder::VerticesBuilder builder(
+ test_vertex_info, "/tmp/", start_index, nullptr,
+ graphar::ValidateLevel::strong_validate);
+ int vertex_count = 1;
+ std::vector<std::vector<std::string>> emails = {
+ {"[email protected]", "[email protected]"}};
+ // add vertices
+ for (int i = 0; i < vertex_count; i++) {
+ graphar::builder::Vertex v;
+ for (const auto& email : emails[i]) {
+ v.AddProperty(graphar::Cardinality::LIST, "set_email",
+ email); // Multi-property
+ }
+ ASSERT(builder.AddVertex(v).IsKeyError());
+ }
+ }
+ SECTION("test write to file") {
+ graphar::builder::VerticesBuilder builder(
+ vertex_info, "/tmp/", start_index, nullptr,
+ graphar::ValidateLevel::strong_validate);
+ // prepare vertex data
+ // add vertices
+ for (int i = 0; i < vertex_count; i++) {
+ graphar::builder::Vertex v;
+ v.AddProperty(property_names[0], id[i]);
+ for (const auto& email : emails[i]) {
+ v.AddProperty(graphar::Cardinality::SET, property_names[1], email);
+ }
+ ASSERT(builder.AddVertex(v).ok());
+ }
+ auto st = builder.Dump();
+ std::cout << st.message() << std::endl;
+ ASSERT(st.ok());
+ }
+ SECTION("test read from file") {
+ // read from file
+ auto maybe_reader = graphar::VertexPropertyArrowChunkReader::Make(
+ vertex_info, vertex_info->GetPropertyGroup(property_names[1]),
"/tmp/");
+ assert(maybe_reader.status().ok());
+ auto reader = maybe_reader.value();
+ assert(reader->seek(0).ok());
+ auto table_result = reader->GetChunk();
+ ASSERT(table_result.status().ok());
+ auto table = table_result.value();
+ auto index = table->schema()->GetFieldIndex(property_names[1]);
+ auto emails_col = table->column(index)->chunk(0);
+ auto result = std::static_pointer_cast<arrow::ListArray>(
+ emails_col->View(arrow::list(arrow::large_utf8())).ValueOrDie());
+ ASSERT(result->length() == 3);
+ for (int i = 0; i < result->length(); i++) {
+ auto email_result = std::static_pointer_cast<arrow::LargeStringArray>(
+ result->value_slice(i));
+ for (int j = 0; j < email_result->length(); j++) {
+ ASSERT(emails[i][j] == email_result->GetString(j));
+ }
+ }
+ }
+}
} // namespace graphar
diff --git a/testing b/testing
index 955596c3..12b4b175 160000
--- a/testing
+++ b/testing
@@ -1 +1 @@
-Subproject commit 955596c325ceba7b607e285738e3dd0ce4ff424e
+Subproject commit 12b4b17561ca3e414366b176a8760b7ee825f7d9
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]