From c642a18e86df677b16c2ca1d82ae5628b79fb61f Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 28 Oct 2026 22:42:06 +0100 Subject: [PATCH] Refuse arrays whose elements are stored as BLOB --- src/impls/translator_impls/array.rs | 74 ++++++++++++++++++++++++- src/impls/translator_impls/data_type.rs | 14 +++-- src/impls/translator_impls/expr.rs | 16 +++--- src/impls/translator_impls/function.rs | 5 ++ src/impls/translator_impls/statement.rs | 4 +- tests/test_type_fidelity.rs | 60 ++++++++++++++++++++ 6 files changed, 158 insertions(+), 15 deletions(-) diff --git a/src/impls/translator_impls/array.rs b/src/impls/translator_impls/array.rs index 6288cd0ab..01e4711a6 100644 --- a/src/impls/translator_impls/array.rs +++ b/src/impls/translator_impls/array.rs @@ -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)] @@ -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()` as a `FROM` item. #[must_use] fn json_each_factor(array: Expr) -> TableFactor { @@ -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::, Error>>()?; Ok(json_array_call(translated)) } @@ -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)); } @@ -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)); @@ -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 => { diff --git a/src/impls/translator_impls/data_type.rs b/src/impls/translator_impls/data_type.rs index 8fe41de76..b9b461bbe 100644 --- a/src/impls/translator_impls/data_type.rs +++ b/src/impls/translator_impls/data_type.rs @@ -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 { @@ -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() { diff --git a/src/impls/translator_impls/expr.rs b/src/impls/translator_impls/expr.rs index a3caa1dda..b0f066cea 100644 --- a/src/impls/translator_impls/expr.rs +++ b/src/impls/translator_impls/expr.rs @@ -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, @@ -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 { - 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)?); diff --git a/src/impls/translator_impls/function.rs b/src/impls/translator_impls/function.rs index 0d69e2776..eb816956e 100644 --- a/src/impls/translator_impls/function.rs +++ b/src/impls/translator_impls/function.rs @@ -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::(&func.args, schema, options, emit)?; let translated_params = translate_function_arguments::( diff --git a/src/impls/translator_impls/statement.rs b/src/impls/translator_impls/statement.rs index b93c77734..627193846 100644 --- a/src/impls/translator_impls/statement.rs +++ b/src/impls/translator_impls/statement.rs @@ -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) diff --git a/tests/test_type_fidelity.rs b/tests/test_type_fidelity.rs index dd7603ca3..ab3ac5714 100644 --- a/tests/test_type_fidelity.rs +++ b/tests/test_type_fidelity.rs @@ -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 // ──────────