Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion src/impls/translator_impls/array.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
//! for a NULL operand, or for an array containing NULL in the `ALL` case.
//! Both exclude the row from a `WHERE` clause, which is the only place the
//! difference is observable.
//! * An element stored as a BLOB is refused, because SQLite's JSON functions
//! take no BLOB argument. Only a declared or cast type is read, so a function
//! returning a BLOB still fails in SQLite.

#[cfg(not(feature = "std"))]
#[allow(unused_imports)]
Expand Down Expand Up @@ -111,6 +114,67 @@ pub(crate) fn no_json_equivalent(construct: &str, hint: &str) -> Error {
Error::forward_refusal(no_json_message(construct, hint))
}

/// True when a value of `data_type` lands as a BLOB on the replica.
#[must_use]
pub(crate) fn stored_as_blob(
data_type: &DataType,
schema: &ParserDB,
options: &crate::options::TranslationContext<'_>,
) -> bool {
matches!(data_type.translate_with_warnings(schema, options, &mut |_| {}), Ok(DataType::Blob(_)))
}

/// The refusal an array of `element` earns when `element` lands as a BLOB.
///
/// The replica builds every array with `json_array` or `json_group_array`, and
/// both answer `JSON cannot hold BLOB values` at run time for a BLOB argument.
#[must_use]
pub(crate) fn blob_element_refusal(element: &DataType) -> Error {
let uuid_hint = if super::uuid::is_uuid_data_type(element) {
", or store UUIDs as text with UuidRepresentation::Text"
} else {
""
};
Error::forward_refusal(format!(
"{element}[] cannot be translated: the replica stores every array as JSON, and a JSON \
array cannot hold the BLOB {element} is stored as. Keep the elements in rows of their \
own, or convert each one to text first{uuid_hint}."
))
}

/// Refuses `element` as an array element when its declared or cast type lands
/// as a BLOB.
///
/// # Errors
///
/// Returns [`blob_element_refusal`] for a BLOB-stored element, and propagates
/// the unresolved reference the scope answers for a column no relation in it
/// declares.
#[cfg_attr(feature = "std", recursive::recursive)]
pub(crate) fn refuse_blob_element(
element: &Expr,
schema: &ParserDB,
options: &crate::options::TranslationContext<'_>,
) -> Result<(), Error> {
let declared;
let element_type = match element {
Expr::Nested(inner) => return refuse_blob_element(inner, schema, options),
Expr::Cast { data_type, .. } => data_type,
Expr::TypedString(typed) => &typed.data_type,
_ => {
declared = crate::impls::reverse_translator_impls::storage_shape::declared_data_type(
element, schema, options,
)?;
let Some(declared) = &declared else { return Ok(()) };
declared
}
};
if stored_as_blob(element_type, schema, options) {
return Err(blob_element_refusal(element_type));
}
Ok(())
}

/// `json_each(<array>)` as a `FROM` item.
#[must_use]
fn json_each_factor(array: Expr) -> TableFactor {
Expand Down Expand Up @@ -339,7 +403,10 @@ pub(crate) fn translate_array_literal(
}
let translated = elements
.iter()
.map(|e| e.translate_with_warnings(schema, options, emit))
.map(|e| {
refuse_blob_element(e, schema, options)?;
e.translate_with_warnings(schema, options, emit)
})
.collect::<Result<Vec<_>, Error>>()?;
Ok(json_array_call(translated))
}
Expand Down Expand Up @@ -510,6 +577,7 @@ pub(crate) fn translate_array_function(
if !is_replayable(raw[0], options) {
return Err(reject_duplicated_operand(kind.name(), raw[0]));
}
refuse_blob_element(raw[2], schema, options)?;
let [array, from, to] = translated_args(args, kind, schema, options, emit)?;
return Ok(array_replace(array, from, to));
}
Expand All @@ -527,6 +595,7 @@ pub(crate) fn translate_array_function(
return Ok(array_cat_expr(a, b));
}
if kind == ArrayFunction::Prepend {
refuse_blob_element(extract_exactly(args, 2, kind.name())?[0], schema, options)?;
let [element, array] = translated_args(args, kind, schema, options, emit)?;
// `json_each(NULL)` yields no rows, so NULL arrays collapse naturally.
return Ok(array_concat(json_array_call(vec![element]), array));
Expand All @@ -543,6 +612,9 @@ pub(crate) fn translate_array_function(
{
return Err(reject_duplicated_operand(kind.name(), operand));
}
if kind == ArrayFunction::Append {
refuse_blob_element(raw[1], schema, options)?;
}
let [array, second] = translated_args(args, kind, schema, options, emit)?;
match kind {
ArrayFunction::Length | ArrayFunction::Lower | ArrayFunction::Upper => {
Expand Down
14 changes: 9 additions & 5 deletions src/impls/translator_impls/data_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ impl crate::traits::translator::TranslatorWithContext for DataType {

fn translate_with_warnings(
&self,
_schema: &sql_traits::structs::ParserDB,
schema: &sql_traits::structs::ParserDB,
options: &crate::options::TranslationContext<'_>,
_emit: &mut dyn FnMut(crate::warnings::TranslationWarning),
) -> Result<Self::SQLiteEntry, crate::errors::Error> {
Expand Down Expand Up @@ -361,11 +361,15 @@ DataType::Array(elem) => {
a separate table."
)));
}
if super::array::is_json_array_representation(options) {
Ok(DataType::Text)
} else {
Err(super::array::representation_required(&format!("The array type {self}")))
if !super::array::is_json_array_representation(options) {
return Err(super::array::representation_required(&format!("The array type {self}")));
}
if let Some(inner) = inner
&& super::array::stored_as_blob(inner, schema, options)
{
return Err(super::array::blob_element_refusal(inner));
}
Ok(DataType::Text)
}
DataType::Uuid => {
match options.get_uuid_representation() {
Expand Down
16 changes: 8 additions & 8 deletions src/impls/translator_impls/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ use crate::{
translator_impls::{
array::{
Quantifier, array_concat, array_overlap, is_json_array_representation,
json_array_call, representation_required, translate_array_literal,
translate_array_subscript, translate_quantified_over_array,
json_array_call, refuse_blob_element, representation_required,
translate_array_literal, translate_array_subscript,
translate_quantified_over_array,
},
data_type::{MAX_NUMERIC_PRECISION, exact_numeric_info, numeric_precision_and_scale},
function::cube_root_closed_form,
Expand Down Expand Up @@ -2818,12 +2819,11 @@ fn translate_binary_op(
// array expands to exactly that element, so both spellings reuse the
// array shape rather than needing their own.
let mut as_array = |side: &Expr| -> Result<Expr, crate::errors::Error> {
let translated = side.translate_with_warnings(schema, options, emit)?;
Ok(if is_array_expression(side, schema, options)? {
translated
} else {
json_array_call(vec![translated])
})
if is_array_expression(side, schema, options)? {
return side.translate_with_warnings(schema, options, emit);
}
refuse_blob_element(side, schema, options)?;
Ok(json_array_call(vec![side.translate_with_warnings(schema, options, emit)?]))
};
let concatenated = array_concat(as_array(left)?, as_array(right)?);

Expand Down
5 changes: 5 additions & 0 deletions src/impls/translator_impls/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2647,6 +2647,11 @@ impl crate::traits::translator::TranslatorWithContext for Function {
// wrapping since array_agg accumulates regular
// scalar values, not JSON documents. The filter
// is kept natively (3.46 floor).
array::refuse_blob_element(
extract_exactly(&func.args, 1, "array_agg")?[0],
schema,
options,
)?;
let translated_args_list =
translate_function_arguments::<Forward>(&func.args, schema, options, emit)?;
let translated_params = translate_function_arguments::<Forward>(
Expand Down
4 changes: 3 additions & 1 deletion src/impls/translator_impls/statement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1952,7 +1952,9 @@ impl crate::traits::translator::TranslatorWithContext for Statement {
Self::Grant(_) | Self::GrantRole(_) => {
drop_with_warning("GRANT", REASON_ACCESS_CONTROL, emit)
}
Self::Revoke(_) => drop_with_warning("REVOKE", REASON_ACCESS_CONTROL, emit),
Self::Revoke(_) | Self::RevokeRole(_) => {
drop_with_warning("REVOKE", REASON_ACCESS_CONTROL, emit)
}
Self::Deny { .. } => drop_with_warning("DENY", REASON_ACCESS_CONTROL, emit),
Self::CreateType { .. } => {
drop_with_warning("CREATE TYPE", REASON_TYPE_DEFINITION, emit)
Expand Down
60 changes: 60 additions & 0 deletions tests/test_type_fidelity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,66 @@ fn numeric_scaled_array_column_is_refused() {
);
}

// ── Arrays of a type stored as BLOB ──────────────────────────────────────────

fn blob_uuid_array_opts() -> Pg2SqliteOptions {
array_opts().with_uuid_representation(UuidRepresentation::Blob)
}

#[test]
fn array_columns_of_blob_stored_types_are_refused() {
// Every element lands as a BLOB (unhex(...), vec_f32(...)), and SQLite's
// json_array refuses one, so each write of a non-empty array would fail
// at run time.
for (ddl, opts, element) in [
("CREATE TABLE b (id INT PRIMARY KEY, blobs BYTEA[]);", array_opts(), "BYTEA"),
("CREATE TABLE u (id INT PRIMARY KEY, ids UUID[]);", blob_uuid_array_opts(), "UUID"),
("CREATE TABLE v (id INT PRIMARY KEY, e vector(3)[]);", array_opts(), "vector(3)"),
] {
expect_refusal(
ddl,
&opts,
&format!("a JSON array cannot hold the BLOB {element} is stored as"),
);
}
}

#[test]
fn arrays_built_from_blob_stored_elements_are_refused() {
let schema = "CREATE TABLE t (id INT PRIMARY KEY, b BYTEA, u UUID, tags TEXT[]);";
for (query, element) in [
("SELECT ARRAY[b] FROM t;", "BYTEA"),
("SELECT ARRAY['\\x0102'::bytea] FROM t;", "BYTEA"),
("SELECT ARRAY[(u)] FROM t;", "UUID"),
("SELECT array_agg(u) FROM t;", "UUID"),
("SELECT array_append(tags, b) FROM t;", "BYTEA"),
("SELECT array_prepend(b, tags) FROM t;", "BYTEA"),
("SELECT tags || b FROM t;", "BYTEA"),
("SELECT array_replace(tags, NULL, b) FROM t;", "BYTEA"),
("SELECT CAST(NULL AS UUID[]) FROM t;", "UUID"),
] {
expect_refusal(
&format!("{schema} {query}"),
&blob_uuid_array_opts(),
&format!("a JSON array cannot hold the BLOB {element} is stored as"),
);
}
}

#[test]
fn uuid_array_column_round_trips_when_uuids_are_text() {
let opts = array_opts().with_uuid_representation(UuidRepresentation::Text);
assert_eq!(
query_rows(
"CREATE TABLE u (id INT PRIMARY KEY, ids UUID[]);
INSERT INTO u VALUES (1, ARRAY['00010203-0405-0607-0809-0a0b0c0d0e0f'::uuid]);
SELECT ids FROM u;",
&opts,
),
vec![Some(r#"["00010203-0405-0607-0809-0a0b0c0d0e0f"]"#.to_string())]
);
}

// ── Finding 6: string literal cast to array type stores wrong format
// ──────────

Expand Down