feat(s3-select): stream JSON document input (#6980)

This commit is contained in:
GatewayJ
2026-09-01 20:17:14 +08:00
committed by GitHub
parent 43450df589
commit 833cc51534
9 changed files with 3975 additions and 1095 deletions
Generated
+2
View File
@@ -10615,6 +10615,8 @@ dependencies = [
"rustfs-s3select-api", "rustfs-s3select-api",
"rustfs-test-utils", "rustfs-test-utils",
"s3s", "s3s",
"serde",
"serde_json",
"tokio", "tokio",
"tracing", "tracing",
] ]
+3 -3
View File
@@ -326,13 +326,13 @@ fn invalid_gzip_header_error() -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, CompressionFormat::Gzip.invalid_header_error()) io::Error::new(io::ErrorKind::InvalidData, CompressionFormat::Gzip.invalid_header_error())
} }
struct BlockingChannelReader { pub(crate) struct BlockingChannelReader {
receiver: mpsc::Receiver<io::Result<Bytes>>, receiver: mpsc::Receiver<io::Result<Bytes>>,
current: Bytes, current: Bytes,
} }
impl BlockingChannelReader { impl BlockingChannelReader {
fn new(receiver: mpsc::Receiver<io::Result<Bytes>>) -> Self { pub(crate) fn new(receiver: mpsc::Receiver<io::Result<Bytes>>) -> Self {
Self { Self {
receiver, receiver,
current: Bytes::new(), current: Bytes::new(),
@@ -639,7 +639,7 @@ fn error_chain_contains<T: StdError + 'static>(error: &(dyn StdError + 'static))
find_error_source::<T>(error).is_some() find_error_source::<T>(error).is_some()
} }
fn find_error_source<'a, T: StdError + 'static>(error: &'a (dyn StdError + 'static)) -> Option<&'a T> { pub(crate) fn find_error_source<'a, T: StdError + 'static>(error: &'a (dyn StdError + 'static)) -> Option<&'a T> {
let mut current = Some(error); let mut current = Some(error);
for _ in 0..MAX_ERROR_SOURCE_DEPTH { for _ in 0..MAX_ERROR_SOURCE_DEPTH {
let Some(error) = current else { let Some(error) = current else {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -51,6 +51,8 @@ derive_builder = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
parking_lot = { workspace = true } parking_lot = { workspace = true }
s3s = { workspace = true, features = ["minio"] } s3s = { workspace = true, features = ["minio"] }
serde.workspace = true
serde_json.workspace = true
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "sync", "time"] } tokio = { workspace = true, features = ["fs", "rt-multi-thread", "sync", "time"] }
tracing = { workspace = true } tracing = { workspace = true }
@@ -0,0 +1,494 @@
// Copyright 2024 RustFS Team
//
// Licensed 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.
use datafusion::{
arrow::{
datatypes::{DataType, Field, Schema},
error::ArrowError,
},
common::{DataFusionError, Result as DFResult},
};
use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor};
use std::{
collections::HashMap,
fmt,
io::{self, Cursor},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
const CANCELLATION_CHECK_ITEMS: usize = 1024;
const READER_BUFFER_BYTES: usize = 64 * 1024;
pub(super) fn infer_schema(
bytes: &[u8],
expected_records: usize,
cancellation: &AtomicBool,
max_schema_bytes: usize,
) -> DFResult<(Schema, usize, usize)> {
let mut context = InferenceContext {
cancellation,
items_since_check: 0,
};
let mut fields = InferredObject::default();
let mut records = 0;
for row in bytes.split_inclusive(|byte| *byte == b'\n') {
if row.is_empty() {
continue;
}
context.check_now()?;
let reader = io::BufReader::with_capacity(
READER_BUFFER_BYTES,
CancellableReader {
inner: Cursor::new(row),
cancellation,
},
);
let mut deserializer = serde_json::Deserializer::from_reader(reader);
let inferred = InferSeed { context: &mut context }
.deserialize(&mut deserializer)
.map_err(|error| schema_parse_error(error, cancellation))?;
deserializer.end().map_err(|error| schema_parse_error(error, cancellation))?;
let InferredType::Object(record) = inferred else {
return Err(DataFusionError::Execution(
"JSON DOCUMENT schema inference expected an object row".to_string(),
));
};
if record.retained_size() > max_schema_bytes {
return Err(schema_complexity_error());
}
fields.merge(record, &mut context)?;
if fields.retained_size() > max_schema_bytes {
return Err(schema_complexity_error());
}
records += 1;
}
if records != expected_records {
return Err(DataFusionError::Execution(format!(
"JSON DOCUMENT schema prefix contained {expected_records} records but inference read {records}"
)));
}
let schema = Schema::new(fields.into_fields(&mut context)?);
let schema_bytes = std::mem::size_of::<Schema>()
.checked_add(schema.fields().size())
.ok_or_else(schema_complexity_error)?;
if schema_bytes > max_schema_bytes {
return Err(schema_complexity_error());
}
Ok((schema, records, schema_bytes))
}
fn schema_parse_error(error: serde_json::Error, cancellation: &AtomicBool) -> DataFusionError {
if cancellation.load(Ordering::Acquire) {
DataFusionError::Execution("JSON DOCUMENT schema inference canceled".to_string())
} else {
schema_json_error(format!("JSON DOCUMENT schema inference failed: {error}"))
}
}
fn schema_json_error(message: impl Into<String>) -> DataFusionError {
DataFusionError::ArrowError(Box::new(ArrowError::JsonError(message.into())), None)
}
fn schema_complexity_error() -> DataFusionError {
DataFusionError::ResourcesExhausted("JSON DOCUMENT schema exceeds the inference complexity limit".to_string())
}
struct CancellableReader<'a> {
inner: Cursor<&'a [u8]>,
cancellation: &'a AtomicBool,
}
impl io::Read for CancellableReader<'_> {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
if self.cancellation.load(Ordering::Acquire) {
return Err(io::Error::new(io::ErrorKind::Interrupted, "JSON DOCUMENT schema inference canceled"));
}
io::Read::read(&mut self.inner, buffer)
}
}
struct InferenceContext<'a> {
cancellation: &'a AtomicBool,
items_since_check: usize,
}
impl InferenceContext<'_> {
fn checkpoint<E: de::Error>(&mut self) -> Result<(), E> {
self.items_since_check += 1;
if self.items_since_check < CANCELLATION_CHECK_ITEMS {
return Ok(());
}
self.items_since_check = 0;
if self.cancellation.load(Ordering::Acquire) {
Err(E::custom("JSON DOCUMENT schema inference canceled"))
} else {
Ok(())
}
}
fn check_now(&self) -> DFResult<()> {
if self.cancellation.load(Ordering::Acquire) {
Err(DataFusionError::Execution("JSON DOCUMENT schema inference canceled".to_string()))
} else {
Ok(())
}
}
fn checkpoint_df(&mut self) -> DFResult<()> {
self.items_since_check += 1;
if self.items_since_check < CANCELLATION_CHECK_ITEMS {
return Ok(());
}
self.items_since_check = 0;
self.check_now()
}
}
struct InferSeed<'a, 'b> {
context: &'a mut InferenceContext<'b>,
}
impl<'de> DeserializeSeed<'de> for InferSeed<'_, '_> {
type Value = InferredType;
fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(InferVisitor { context: self.context })
}
}
struct InferVisitor<'a, 'b> {
context: &'a mut InferenceContext<'b>,
}
impl<'de> Visitor<'de> for InferVisitor<'_, '_> {
type Value = InferredType;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a JSON value")
}
fn visit_unit<E>(self) -> Result<Self::Value, E> {
Ok(InferredType::Any)
}
fn visit_none<E>(self) -> Result<Self::Value, E> {
Ok(InferredType::Any)
}
fn visit_bool<E>(self, _value: bool) -> Result<Self::Value, E> {
Ok(InferredType::Scalar(ScalarKinds::BOOLEAN))
}
fn visit_i64<E>(self, _value: i64) -> Result<Self::Value, E> {
Ok(InferredType::Scalar(ScalarKinds::INTEGER))
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
Ok(InferredType::Scalar(if i64::try_from(value).is_ok() {
ScalarKinds::INTEGER
} else {
ScalarKinds::FLOAT
}))
}
fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E> {
Ok(InferredType::Scalar(ScalarKinds::FLOAT))
}
fn visit_str<E>(self, _value: &str) -> Result<Self::Value, E> {
Ok(InferredType::Scalar(ScalarKinds::STRING))
}
fn visit_string<E>(self, _value: String) -> Result<Self::Value, E> {
Ok(InferredType::Scalar(ScalarKinds::STRING))
}
fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut inner = InferredType::Any;
let mut first = true;
while let Some(value) = sequence.next_element_seed(InferSeed { context: self.context })? {
self.context.checkpoint()?;
if first && matches!(value, InferredType::Any) {
inner = InferredType::Scalar(ScalarKinds::default());
} else {
inner.merge_array_element(value, self.context).map_err(de::Error::custom)?;
}
first = false;
}
Ok(InferredType::Array(Box::new(inner)))
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut object = InferredObject::default();
while let Some(key) = map.next_key::<String>()? {
self.context.checkpoint()?;
let value = map.next_value_seed(InferSeed { context: self.context })?;
object.insert_record_field(key, value);
}
Ok(InferredType::Object(object))
}
}
#[derive(Clone, Copy, Debug, Default)]
struct ScalarKinds(u8);
impl ScalarKinds {
const BOOLEAN: Self = Self(1 << 0);
const INTEGER: Self = Self(1 << 1);
const FLOAT: Self = Self(1 << 2);
const STRING: Self = Self(1 << 3);
fn merge(&mut self, other: Self) {
self.0 |= other.0;
}
fn data_type(self) -> DataType {
match self.0 {
value if value == Self::BOOLEAN.0 => DataType::Boolean,
value if value == Self::INTEGER.0 => DataType::Int64,
value if value == Self::FLOAT.0 || value == (Self::INTEGER.0 | Self::FLOAT.0) => DataType::Float64,
value if value == Self::STRING.0 => DataType::Utf8,
_ => DataType::Utf8,
}
}
}
#[derive(Debug)]
enum InferredType {
Scalar(ScalarKinds),
Array(Box<InferredType>),
Object(InferredObject),
Any,
}
impl InferredType {
fn merge(&mut self, other: Self, context: &mut InferenceContext<'_>) -> DFResult<()> {
match (self, other) {
(Self::Array(current), Self::Array(other)) => current.merge(*other, context),
(Self::Scalar(current), Self::Scalar(other)) => {
current.merge(other);
Ok(())
}
(Self::Object(current), Self::Object(other)) => current.merge(other, context),
(current @ Self::Any, value) => {
*current = value;
Ok(())
}
(_, Self::Any) => Ok(()),
(Self::Array(inner), scalar @ Self::Scalar(_)) => inner.merge(scalar, context),
(current @ Self::Scalar(_), Self::Array(mut inner)) => {
let scalar = std::mem::replace(current, Self::Any);
inner.merge(scalar, context)?;
*current = Self::Array(inner);
Ok(())
}
(current, other) => Err(schema_json_error(format!(
"incompatible JSON types during schema inference: {} and {}",
current.kind(),
other.kind()
))),
}
}
fn merge_array_element(&mut self, other: Self, context: &mut InferenceContext<'_>) -> DFResult<()> {
match (self, other) {
(current @ Self::Any, value) => {
*current = value;
Ok(())
}
(_, Self::Any) => Ok(()),
(Self::Scalar(current), Self::Scalar(other)) => {
current.merge(other);
Ok(())
}
(Self::Object(current), Self::Object(other)) => current.merge(other, context),
(Self::Array(current), Self::Array(other)) => current.merge(*other, context),
(current, other) => Err(schema_json_error(format!(
"incompatible JSON array elements during schema inference: {} and {}",
current.kind(),
other.kind()
))),
}
}
fn kind(&self) -> &'static str {
match self {
Self::Scalar(_) => "scalar",
Self::Array(_) => "array",
Self::Object(_) => "object",
Self::Any => "null",
}
}
fn into_data_type(self, context: &mut InferenceContext<'_>) -> DFResult<DataType> {
context.check_now()?;
match self {
Self::Scalar(kinds) => Ok(kinds.data_type()),
Self::Array(inner) => Ok(DataType::List(Arc::new(Field::new_list_field(inner.into_data_type(context)?, true)))),
Self::Object(object) => Ok(DataType::Struct(object.into_fields(context)?.into())),
Self::Any => Ok(DataType::Null),
}
}
fn retained_size(&self) -> usize {
std::mem::size_of::<Self>()
+ match self {
Self::Array(inner) => inner.retained_size(),
Self::Object(object) => object.retained_size(),
Self::Scalar(_) | Self::Any => 0,
}
}
}
#[derive(Debug, Default)]
struct InferredObject {
indexes: HashMap<Arc<str>, usize>,
fields: Vec<(Arc<str>, InferredType)>,
}
impl InferredObject {
fn insert_record_field(&mut self, key: String, value: InferredType) {
if let Some(index) = self.indexes.get(key.as_str()).copied() {
self.fields[index].1 = value;
return;
}
let key: Arc<str> = Arc::from(key);
let index = self.fields.len();
self.indexes.insert(Arc::clone(&key), index);
self.fields.push((key, value));
}
fn merge(&mut self, other: Self, context: &mut InferenceContext<'_>) -> DFResult<()> {
for (key, value) in other.fields {
context.checkpoint_df()?;
if let Some(index) = self.indexes.get(key.as_ref()).copied() {
self.fields[index].1.merge(value, context)?;
} else {
let index = self.fields.len();
self.indexes.insert(Arc::clone(&key), index);
self.fields.push((key, value));
}
}
Ok(())
}
fn into_fields(self, context: &mut InferenceContext<'_>) -> DFResult<Vec<Field>> {
self.fields
.into_iter()
.map(|(name, inferred)| {
context.check_now()?;
Ok(Field::new(name.as_ref(), inferred.into_data_type(context)?, true))
})
.collect()
}
fn retained_size(&self) -> usize {
let index_entry = std::mem::size_of::<(Arc<str>, usize)>() + 16;
let mut bytes = std::mem::size_of::<Self>()
.saturating_add(self.indexes.capacity().saturating_mul(index_entry))
.saturating_add(
self.fields
.capacity()
.saturating_mul(std::mem::size_of::<(Arc<str>, InferredType)>()),
);
for (name, inferred) in &self.fields {
bytes = bytes.saturating_add(name.len()).saturating_add(inferred.retained_size());
}
bytes
}
}
#[cfg(test)]
mod tests {
use super::*;
use datafusion::arrow::json::reader::infer_json_schema as arrow_infer_json_schema;
const TEST_SCHEMA_LIMIT: usize = 4 * 1024 * 1024;
fn assert_matches_arrow(input: &[u8]) {
let records = input.iter().filter(|byte| **byte == b'\n').count();
let (expected, expected_records) = arrow_infer_json_schema(io::BufReader::new(input), Some(records))
.expect("Arrow should infer the compatibility fixture");
let (actual, actual_records, _) = infer_schema(input, records, &AtomicBool::new(false), TEST_SCHEMA_LIMIT)
.expect("streaming inference should accept the compatibility fixture");
assert_eq!(actual_records, expected_records);
assert_eq!(actual, expected);
}
#[test]
fn inference_matches_arrow_for_nested_and_coerced_types() {
assert_matches_arrow(
br#"{"a":1,"values":[1,2],"nested":{"enabled":true},"nullable":null}
{"a":1.5,"values":3,"nested":{"name":"ok"},"nullable":"set"}
"#,
);
assert_matches_arrow(
br#"{"matrix":[[1,2],[3]],"objects":[{"id":1},{"name":"two"}],"empty":[]}
{"matrix":[[4.5]],"objects":[],"empty":[null]}
"#,
);
}
#[test]
fn inference_matches_arrow_for_null_first_arrays_and_duplicate_keys() {
assert_matches_arrow(
br#"{"values":[null,1,2],"duplicate":1,"duplicate":"last"}
"#,
);
let input = br#"{"values":[null,{"id":1}]}
"#;
let records = 1;
assert!(arrow_infer_json_schema(io::BufReader::new(input.as_slice()), Some(records)).is_err());
assert!(infer_schema(input, records, &AtomicBool::new(false), TEST_SCHEMA_LIMIT).is_err());
}
#[test]
fn inference_checkpoints_observe_cancellation() {
let cancellation = AtomicBool::new(true);
let mut context = InferenceContext {
cancellation: &cancellation,
items_since_check: CANCELLATION_CHECK_ITEMS - 1,
};
let error = context
.checkpoint::<serde_json::Error>()
.expect_err("the item checkpoint must observe cancellation");
assert!(error.to_string().contains("schema inference canceled"));
}
#[test]
fn incompatible_types_preserve_arrow_json_error_classification() {
let error = infer_schema(b"{\"a\":{}}\n{\"a\":1}\n", 2, &AtomicBool::new(false), TEST_SCHEMA_LIMIT)
.expect_err("object and scalar fields must remain incompatible");
assert!(matches!(
error,
DataFusionError::ArrowError(source, None) if matches!(*source, ArrowError::JsonError(_))
));
}
}
File diff suppressed because it is too large Load Diff
@@ -61,7 +61,7 @@ use tokio::{
}; };
use crate::{ use crate::{
dispatcher::parquet_table::ParquetSelectTable, dispatcher::{json_document_table::JsonDocumentTable, parquet_table::ParquetSelectTable},
execution::factory::QueryExecutionFactoryRef, execution::factory::QueryExecutionFactoryRef,
instance::{DEFAULT_MAX_CONCURRENT_QUERIES, DEFAULT_QUERY_TIMEOUT_SECS}, instance::{DEFAULT_MAX_CONCURRENT_QUERIES, DEFAULT_QUERY_TIMEOUT_SECS},
metadata::{ContextProviderExtension, MetadataProvider, TableHandleProviderRef, base_table::BaseTableProvider}, metadata::{ContextProviderExtension, MetadataProvider, TableHandleProviderRef, base_table::BaseTableProvider},
@@ -181,7 +181,9 @@ impl QueryDispatcher for SimpleQueryDispatcher {
Ok(logical_plan) Ok(logical_plan)
}) })
.await?; .await?;
if !is_json_document_input(&self.input) {
query_state_machine.query.input_metrics().reset(); query_state_machine.query.input_metrics().reset();
}
if !query_tracker.mark_planned(&self.query_execution_owner) { if !query_tracker.mark_planned(&self.query_execution_owner) {
drop(logical_plan); drop(logical_plan);
return Err(self.query_tracker_error(&query_tracker)); return Err(self.query_tracker_error(&query_tracker));
@@ -415,6 +417,17 @@ impl SimpleQueryDispatcher {
return Ok(metadata_provider); return Ok(metadata_provider);
} }
if is_json_document_input(&self.input) {
let provider = JsonDocumentTable::try_new(session.inner(), &self.input.bucket, &self.input.key).await?;
let current_session_table_provider = self.build_table_handle_provider()?;
return Ok(MetadataProvider::new(
provider,
current_session_table_provider,
self.func_manager.clone(),
session.clone(),
));
}
let path = format!("s3://{}/{}", self.input.bucket, self.input.key); let path = format!("s3://{}/{}", self.input.bucket, self.input.key);
let table_path = ListingTableUrl::parse(path)?; let table_path = ListingTableUrl::parse(path)?;
let compressed_input = self let compressed_input = self
@@ -542,19 +555,24 @@ impl SimpleQueryDispatcher {
} }
} }
fn is_json_document_input(input: &SelectObjectContentInput) -> bool {
input
.request
.input_serialization
.json
.as_ref()
.and_then(|json| json.type_.as_ref())
.is_some_and(|json_type| json_type.as_str() == JSONType::DOCUMENT)
}
fn validate_json_source_path_input(input: &SelectObjectContentInput, source_path: &[JsonPathSegment]) -> QueryResult<()> { fn validate_json_source_path_input(input: &SelectObjectContentInput, source_path: &[JsonPathSegment]) -> QueryResult<()> {
if source_path.is_empty() { if source_path.is_empty() {
return Ok(()); return Ok(());
} }
let Some(json) = input.request.input_serialization.json.as_ref() else { if input.request.input_serialization.json.is_none() {
return Err(SelectError::DataSourcePathUnsupported.into()); return Err(SelectError::DataSourcePathUnsupported.into());
}; }
if !source_path_requires_expansion(source_path) if !source_path_requires_expansion(source_path) || is_json_document_input(input) {
|| json
.type_
.as_ref()
.is_some_and(|json_type| json_type.as_str() == JSONType::DOCUMENT)
{
return Ok(()); return Ok(());
} }
Err(SelectError::DataSourcePathUnsupported.into()) Err(SelectError::DataSourcePathUnsupported.into())
@@ -1803,6 +1821,63 @@ mod tests {
} }
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn json_document_schema_prefix_is_counted_once() {
const DOCUMENT: &[u8] = br#"["one","two"]"#;
let mut input = test_input();
input.bucket = "s3select-json-document-metrics".to_string();
input.key = "input.json".to_string();
input.request.expression = "SELECT _1 FROM S3Object[*]".to_string();
input.request.input_serialization = InputSerialization {
json: Some(JSONInput {
type_: Some(JSONType::from_static(JSONType::DOCUMENT)),
}),
..Default::default()
};
input.request.output_serialization = OutputSerialization {
json: Some(JSONOutput::default()),
..Default::default()
};
let input = Arc::new(input);
let env = snapshot_test_env().await;
env.make_bucket(&input.bucket, false).await;
env.put_object_bytes(&input.bucket, &input.key, DOCUMENT.to_vec()).await;
let snapshot = env.prepare_select_object_snapshot(&input.bucket, &input.key).await;
let dispatcher = production_dispatcher(Arc::clone(&input));
let query = Query::new_with_snapshot(
QueryContext {
input: Arc::clone(&input),
},
input.request.expression.clone(),
snapshot,
);
let state_machine = dispatcher
.build_query_state_machine(query)
.await
.expect("build tracked JSON DOCUMENT query");
let input_metrics = Arc::clone(state_machine.query.input_metrics());
let logical_plan = dispatcher
.build_logical_plan(Arc::clone(&state_machine))
.await
.expect("JSON DOCUMENT query should plan")
.expect("SELECT should produce a logical plan");
let expected_bytes = u64::try_from(DOCUMENT.len()).expect("fixture size should fit in u64");
assert_eq!(input_metrics.snapshot().bytes_scanned, expected_bytes);
assert_eq!(input_metrics.snapshot().bytes_processed, expected_bytes);
let values = collect_utf8_output(
dispatcher
.execute_logical_plan(logical_plan, state_machine)
.await
.expect("JSON DOCUMENT query should execute"),
)
.await;
assert_eq!(values, ["one", "two"]);
assert_eq!(input_metrics.snapshot().bytes_scanned, expected_bytes);
assert_eq!(input_metrics.snapshot().bytes_processed, expected_bytes);
}
#[tokio::test] #[tokio::test]
async fn staged_query_rejects_unbound_session() { async fn staged_query_rejects_unbound_session() {
let admission = Arc::new(Semaphore::new(1)); let admission = Arc::new(Semaphore::new(1));
@@ -12,5 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
mod json_document_schema;
mod json_document_table;
pub mod manager; pub mod manager;
mod parquet_table; mod parquet_table;