This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 637386b1 feat(auth): apply query-auth masks to non-BLOB columns (#1077)
637386b1 is described below
commit 637386b196da0b017557a1fdc01c518834732fb9
Author: Jiajia Li <[email protected]>
AuthorDate: Sat Oct 10 11:57:39 2026 +0800
feat(auth): apply query-auth masks to non-BLOB columns (#1077)
---
.../datafusion/src/physical_plan/scan.rs | 2 +-
crates/integrations/datafusion/tests/query_auth.rs | 145 +++-
crates/paimon/src/table/query_auth.rs | 2 +-
crates/paimon/src/table/query_auth/rules.rs | 957 ++++++++++++++++++++-
crates/paimon/src/table/read_builder.rs | 17 +-
crates/paimon/src/table/source.rs | 5 +-
crates/paimon/src/table/table_read.rs | 88 +-
crates/paimon/src/table/table_scan.rs | 96 ++-
crates/paimon/tests/rest_catalog_test.rs | 863 +++++++++++++++++--
docs/src/sql.md | 15 +-
10 files changed, 2043 insertions(+), 147 deletions(-)
diff --git a/crates/integrations/datafusion/src/physical_plan/scan.rs
b/crates/integrations/datafusion/src/physical_plan/scan.rs
index 4d47ccb1..0a9eb5ab 100644
--- a/crates/integrations/datafusion/src/physical_plan/scan.rs
+++ b/crates/integrations/datafusion/src/physical_plan/scan.rs
@@ -1193,7 +1193,7 @@ impl ExecutionPlan for PaimonTableScan {
}
fn partition_statistics(&self, partition: Option<usize>) ->
DFResult<Arc<Statistics>> {
- // They include the rows the server's rules drop.
+ // Statistics include hidden rows and unmasked values.
if self.has_query_auth_rules() {
return Ok(Arc::new(Statistics::new_unknown(&self.schema())));
}
diff --git a/crates/integrations/datafusion/tests/query_auth.rs
b/crates/integrations/datafusion/tests/query_auth.rs
index 2ef11688..7f821577 100644
--- a/crates/integrations/datafusion/tests/query_auth.rs
+++ b/crates/integrations/datafusion/tests/query_auth.rs
@@ -15,8 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-//! SQL over a `query-auth.enabled` table whose REST server sets a row filter
-//! for the current user.
+//! SQL row filtering and column masking through a REST catalog.
mod common;
@@ -54,6 +53,13 @@ fn schema(options: &[(&str, &str)]) -> Schema {
/// catalog that admits `id > 6` and applies `column_masking`.
async fn restricted_people(
column_masking: Option<HashMap<String, String>>,
+) -> (tempfile::TempDir, RESTServer, SQLContext) {
+ restricted_people_with_schema(column_masking, schema).await
+}
+
+async fn restricted_people_with_schema(
+ column_masking: Option<HashMap<String, String>>,
+ schema: impl Fn(&[(&str, &str)]) -> Schema,
) -> (tempfile::TempDir, RESTServer, SQLContext) {
let tmp = tempfile::tempdir().unwrap();
let mut fs_options = Options::new();
@@ -141,6 +147,18 @@ async fn restricted_people(
(tmp, server, context)
}
+/// Upper-cases `name` and hides `secret`.
+fn masks() -> Option<HashMap<String, String>> {
+ let name = json!({"index": 1, "name": "name", "type": "VARCHAR(255)"});
+ Some(HashMap::from([
+ (
+ "name".to_string(),
+ json!({"name": "UPPER", "inputs": [name]}).to_string(),
+ ),
+ ("secret".to_string(), json!({"name": "NULL"}).to_string()),
+ ]))
+}
+
async fn query(context: &SQLContext, sql: &str) -> Vec<RecordBatch> {
context.sql(sql).await.unwrap().collect().await.unwrap()
}
@@ -231,6 +249,129 @@ async fn
test_query_auth_where_runs_on_the_admitted_rows_only() {
);
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_query_auth_where_on_a_masked_column_matches_masked_values() {
+ let (_tmp, _server, context) = restricted_people(masks()).await;
+
+ assert_eq!(
+ id_names(&query(&context, &format!("SELECT id, name FROM
{TABLE}")).await),
+ vec![
+ (7, "GRACE".to_string()),
+ (8, "HEIDI".to_string()),
+ (9, "IVAN".to_string()),
+ (10, "JUDY".to_string()),
+ ]
+ );
+ assert_eq!(
+ id_names(
+ &query(
+ &context,
+ &format!("SELECT id, name FROM {TABLE} WHERE name = 'GRACE'")
+ )
+ .await
+ ),
+ vec![(7, "GRACE".to_string())]
+ );
+ assert!(id_names(
+ &query(
+ &context,
+ &format!("SELECT id, name FROM {TABLE} WHERE name = 'grace'")
+ )
+ .await
+ )
+ .is_empty());
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_query_auth_filters_masked_partition_values() {
+ let (_tmp, server, context) = restricted_people_with_schema(masks(),
|options| {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("name", DataType::VarChar(VarCharType::new(255).unwrap()))
+ .column("secret",
DataType::VarChar(VarCharType::new(255).unwrap()))
+ .partition_keys(["name"]);
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+ })
+ .await;
+ server.set_auth_response(
+ "default",
+ "people",
+ AuthTableQueryResponse {
+ filter: None,
+ column_masking: masks(),
+ },
+ );
+
+ assert_eq!(
+ id_names(
+ &query(
+ &context,
+ &format!("SELECT id, name FROM {TABLE} WHERE name = 'HEIDI'")
+ )
+ .await
+ ),
+ vec![(8, "HEIDI".to_string())],
+ );
+ assert!(id_names(
+ &query(
+ &context,
+ &format!("SELECT id, name FROM {TABLE} WHERE name = 'heidi'")
+ )
+ .await
+ )
+ .is_empty());
+ assert_eq!(
+ count(
+ &query(
+ &context,
+ &format!("SELECT COUNT(*) FROM {TABLE} WHERE name = 'HEIDI'")
+ )
+ .await
+ ),
+ 1,
+ );
+ let selected = single(
+ &query(
+ &context,
+ &format!("SELECT id FROM {TABLE} WHERE name = 'HEIDI' LIMIT 1"),
+ )
+ .await,
+ );
+ assert_eq!(
+ selected
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 8
+ );
+ assert_eq!(
+ id_names(
+ &query(
+ &context,
+ &format!("SELECT id, name FROM {TABLE} WHERE name = 'HEIDI' OR
id = 1")
+ )
+ .await
+ ),
+ vec![(1, "ALICE".to_string()), (8, "HEIDI".to_string())],
+ );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn
test_query_auth_aggregates_see_masked_values_not_the_manifest_statistics() {
+ let (_tmp, _server, context) = restricted_people(masks()).await;
+
+ assert_eq!(
+ count(&query(&context, &format!("SELECT COUNT(secret) FROM
{TABLE}")).await),
+ 0
+ );
+ let max = single(&query(&context, &format!("SELECT MAX(secret) FROM
{TABLE}")).await);
+ assert!(max.is_null(0), "{max:?}");
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_query_auth_explain_shows_the_restriction_but_not_the_files() {
let (_tmp, _server, context) = restricted_people(None).await;
diff --git a/crates/paimon/src/table/query_auth.rs
b/crates/paimon/src/table/query_auth.rs
index 7614c602..06625d9d 100644
--- a/crates/paimon/src/table/query_auth.rs
+++ b/crates/paimon/src/table/query_auth.rs
@@ -20,7 +20,7 @@
mod rules;
use crate::api::AuthTableQueryResponse;
-pub(crate) use rules::{filter_batch, Rules};
+pub(crate) use rules::{filter_batch, mask_batch, mask_inputs, Rules};
/// The server's answer for one user on one table; `session` ties it to the
/// handle that asked, as the response names no table or user.
diff --git a/crates/paimon/src/table/query_auth/rules.rs
b/crates/paimon/src/table/query_auth/rules.rs
index 20710390..66dc89f3 100644
--- a/crates/paimon/src/table/query_auth/rules.rs
+++ b/crates/paimon/src/table/query_auth/rules.rs
@@ -15,22 +15,30 @@
// specific language governing permissions and limitations
// under the License.
-//! The server's row filter, parsed against the schema it ruled on and applied
-//! to read batches, as Java `TableQueryAuthResult` does.
+//! Apply the server's row filters and column masks to read batches.
use super::unsupported;
use crate::api::AuthTableQueryResponse;
use crate::arrow::residual::{evaluate_exact_leaf_predicate,
sanitize_filter_mask};
-use crate::spec::{DataField, Predicate};
+use crate::spec::{DataField, Predicate, Transform, TransformInput};
use crate::Result;
+use arrow_array::cast::AsArray;
use arrow_array::{Array, ArrayRef, BooleanArray, Float32Array, Float64Array,
RecordBatch};
use std::collections::HashSet;
use std::sync::Arc;
-/// The rules of one grant; leaf indices are table-schema positions.
+/// Masks one column, by table-schema index.
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct ColumnMask {
+ pub(crate) column: usize,
+ pub(crate) transform: Transform,
+}
+
+/// One grant's rules, indexed by table schema.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct Rules {
pub(crate) filters: Vec<Predicate>,
+ pub(crate) masks: Vec<ColumnMask>,
}
impl Rules {
@@ -54,19 +62,71 @@ impl Rules {
filters.push(filter);
}
- // Not applied yet: refusing beats returning raw values.
- if response
- .column_masking
- .as_ref()
- .is_some_and(|masks| !masks.is_empty())
- {
- return Err(unsupported("this client does not apply column masking
yet"));
+ let mut masks = Vec::new();
+ for (column, json) in response.column_masking.iter().flatten() {
+ if column.is_empty() || json.is_empty() {
+ return Err(unsupported("the server sent an empty column
mask"));
+ }
+ // Unprojected system columns need no masking; `_KEY_` names are
not exempt.
+ if crate::spec::is_reserved_system_field_name(column) &&
!column.starts_with("_KEY_") {
+ // Still reject malformed masks.
+ let parsed = serde_json::from_str::<serde_json::Value>(json);
+ if !parsed.is_ok_and(|value| !value.is_null()) {
+ return Err(unsupported(&format!(
+ "cannot parse the server's mask on '{column}'"
+ )));
+ }
+ continue;
+ }
+ let target = fields
+ .iter()
+ .position(|f| f.name() == column)
+ .ok_or_else(|| {
+ unsupported(&format!(
+ "the server masks '{column}', which is not a column"
+ ))
+ })?;
+ if fields[target].data_type().is_blob_file_field() {
+ return Err(unsupported("BLOB column masks are not supported
yet"));
+ }
+ let transform = Transform::from_rest_json(json,
fields).map_err(|e| {
+ unsupported(&format!(
+ "cannot parse the server's mask on '{column}': {e}"
+ ))
+ })?;
+ check_mask_fits(&transform, &fields[target], fields)?;
+ masks.push(ColumnMask {
+ column: target,
+ transform,
+ });
}
- Ok(Self { filters })
+ masks.sort_by_key(|m| m.column);
+
+ // Reading another masked column would expose its raw value.
+ let targets: HashSet<usize> = masks.iter().map(|m| m.column).collect();
+ for mask in &masks {
+ if let Some(other) = mask_inputs(mask)
+ .into_iter()
+ .find(|i| *i != mask.column && targets.contains(i))
+ {
+ return Err(unsupported(&format!(
+ "the mask on '{}' reads '{}', which is masked too, so it
would expose the \
+ raw value",
+ fields[mask.column].name(),
+ fields[other].name()
+ )));
+ }
+ }
+ Ok(Self { filters, masks })
}
pub(crate) fn is_empty(&self) -> bool {
- self.filters.is_empty()
+ self.filters.is_empty() && self.masks.is_empty()
+ }
+
+ /// Table-schema indices of masked columns.
+ pub(crate) fn masked_columns(&self) -> HashSet<usize> {
+ self.masks.iter().map(|m| m.column).collect()
}
/// Table-schema indices the row filter reads.
@@ -79,6 +139,66 @@ impl Rules {
}
}
+/// Table-schema indices a mask reads.
+pub(crate) fn mask_inputs(mask: &ColumnMask) -> HashSet<usize> {
+ let mut out = HashSet::new();
+ mask.transform.collect_field_indices(&mut out);
+ out
+}
+
+/// Preserve the target's type and nullability.
+fn check_mask_fits(transform: &Transform, target: &DataField, fields:
&[DataField]) -> Result<()> {
+ let column_type = crate::arrow::paimon_type_to_arrow(target.data_type())?;
+ if let Some(output) = mask_output_type(transform, fields) {
+ if output != column_type {
+ return Err(unsupported(&format!(
+ "the mask on '{}' produces {output:?}, but the column is
{column_type:?}",
+ target.name()
+ )));
+ }
+ }
+ if !target.data_type().is_nullable() && mask_can_be_null(transform,
fields) {
+ return Err(unsupported(&format!(
+ "the mask on '{}' can produce null, but the column is NOT NULL",
+ target.name()
+ )));
+ }
+ if let Transform::Cast(index, to) = transform {
+ let from = fields[*index].data_type();
+ if !cast_agrees_with_java(from, to) {
+ return Err(unsupported(&format!(
+ "the mask on '{}' casts {from:?} to {to:?}, which this client
does not convert as \
+ Java does",
+ target.name()
+ )));
+ }
+ }
+ Ok(())
+}
+
+/// Arrow and Java disagree on casts such as INT -> TIMESTAMP.
+fn cast_agrees_with_java(from: &crate::spec::DataType, to:
&crate::spec::DataType) -> bool {
+ use crate::spec::DataType as Paimon;
+ let string = |t: &Paimon| matches!(t, Paimon::Char(_) |
Paimon::VarChar(_));
+ let bytes = |t: &Paimon| matches!(t, Paimon::Binary(_) |
Paimon::VarBinary(_));
+ let integer_bits = |t: &Paimon| match t {
+ Paimon::TinyInt(_) => Some(8),
+ Paimon::SmallInt(_) => Some(16),
+ Paimon::Int(_) => Some(32),
+ Paimon::BigInt(_) => Some(64),
+ _ => None,
+ };
+ let same = matches!(
+ (from.copy_with_nullable(true), to.copy_with_nullable(true)),
+ (Ok(from), Ok(to)) if from == to
+ );
+ let widening = matches!((integer_bits(from), integer_bits(to)), (Some(a),
Some(b)) if a < b);
+ same || widening
+ || string(to)
+ && (string(from) || integer_bits(from).is_some() || matches!(from,
Paimon::Boolean(_)))
+ || bytes(to) && (bytes(from) || string(from))
+}
+
fn mentioned_system_column(json: &str) -> Option<&'static str> {
[
crate::spec::ROW_ID_FIELD_NAME,
@@ -229,6 +349,288 @@ fn eval_err(e: &dyn std::fmt::Display) -> crate::Error {
unsupported(&format!("cannot evaluate the server's rules: {e}"))
}
+// Column masking
+
+/// Replace every copy of a masked column using the original batch's values.
+pub(crate) fn mask_batch(
+ batch: &RecordBatch,
+ masks: &[ColumnMask],
+ schema_fields: &[DataField],
+ batch_fields: &[DataField],
+) -> Result<RecordBatch> {
+ if masks.is_empty() {
+ return Ok(batch.clone());
+ }
+ let input = |index: usize| -> Result<ArrayRef> {
+ let field = schema_fields
+ .get(index)
+ .ok_or_else(|| unsupported("the server's mask reads an unknown
column"))?;
+ column_of(batch, field, batch_fields)
+ };
+ let mut columns = batch.columns().to_vec();
+ for mask in masks {
+ let field = &schema_fields[mask.column];
+ // By id: the caller may project the column twice.
+ let targets = batch_fields
+ .iter()
+ .enumerate()
+ .filter_map(|(position, f)| (f.id() ==
field.id()).then_some(position));
+ let Some(first) = targets.clone().next() else {
+ return Err(unsupported(&format!(
+ "the read does not carry '{}', which the server masks",
+ field.name()
+ )));
+ };
+ let target_type = batch.schema().field(first).data_type().clone();
+ let masked: ArrayRef = match &mask.transform {
+ Transform::Null => arrow_array::new_null_array(&target_type,
batch.num_rows()),
+ Transform::FieldRef(index) => input(*index)?,
+ Transform::Cast(index, to) => {
+ let cast = cast_to(&input(*index)?,
&crate::arrow::paimon_type_to_arrow(to)?)?;
+ // Arrow strings do not enforce CHAR/VARCHAR width.
+ apply_declared_width(&cast, to)
+ }
+ Transform::Upper(_)
+ | Transform::Lower(_)
+ | Transform::Concat(_)
+ | Transform::ConcatWs(_) => string_mask(batch, &mask.transform,
&input)?,
+ };
+ let masked = cast_to(&masked, &target_type)?;
+ for position in targets {
+ columns[position] = Arc::clone(&masked);
+ }
+ }
+ RecordBatch::try_new_with_options(
+ batch.schema(),
+ columns,
+
&arrow_array::RecordBatchOptions::new().with_row_count(Some(batch.num_rows())),
+ )
+ .map_err(|e| eval_err(&e))
+}
+
+fn mask_can_be_null(transform: &Transform, fields: &[DataField]) -> bool {
+ let nullable = |input: &TransformInput| match input {
+ TransformInput::Literal(literal) => literal.is_none(),
+ TransformInput::Field(index) =>
fields[*index].data_type().is_nullable(),
+ };
+ match transform {
+ Transform::Null => true,
+ Transform::FieldRef(index) | Transform::Cast(index, _) => {
+ fields[*index].data_type().is_nullable()
+ }
+ Transform::Upper(inputs) | Transform::Lower(inputs) |
Transform::Concat(inputs) => {
+ inputs.iter().any(nullable)
+ }
+ // Only a null separator makes CONCAT_WS null.
+ Transform::ConcatWs(inputs) => inputs.first().is_some_and(nullable),
+ }
+}
+
+/// `None` lets a NULL mask adopt the target's type.
+fn mask_output_type(transform: &Transform, fields: &[DataField]) ->
Option<arrow_schema::DataType> {
+ match transform {
+ Transform::Null => None,
+ Transform::FieldRef(index) => {
+ crate::arrow::paimon_type_to_arrow(fields[*index].data_type()).ok()
+ }
+ Transform::Cast(_, to) => crate::arrow::paimon_type_to_arrow(to).ok(),
+ Transform::Upper(_)
+ | Transform::Lower(_)
+ | Transform::Concat(_)
+ | Transform::ConcatWs(_) => Some(arrow_schema::DataType::Utf8),
+ }
+}
+
+/// Match Java's truncation and fixed-width padding.
+fn apply_declared_width(array: &ArrayRef, to: &crate::spec::DataType) ->
ArrayRef {
+ use crate::spec::DataType as Paimon;
+ match to {
+ Paimon::VarChar(t) => fit_strings(array, t.length() as usize, false),
+ Paimon::Char(t) => fit_strings(array, t.length(), true),
+ Paimon::VarBinary(t) => fit_bytes(array, t.length() as usize, false),
+ Paimon::Binary(t) => fit_bytes(array, t.length(), true),
+ _ => Arc::clone(array),
+ }
+}
+
+/// Counts characters, as Java does.
+fn fit_strings(array: &ArrayRef, length: usize, pad: bool) -> ArrayRef {
+ let Some(strings) =
array.as_any().downcast_ref::<arrow_array::StringArray>() else {
+ return Arc::clone(array);
+ };
+ let fitted: arrow_array::StringArray = strings
+ .iter()
+ .map(|value| {
+ value.map(|value| {
+ let chars = value.chars().count();
+ if chars > length {
+ value.chars().take(length).collect::<String>()
+ } else if pad && chars < length {
+ format!("{value}{}", " ".repeat(length - chars))
+ } else {
+ value.to_string()
+ }
+ })
+ })
+ .collect();
+ Arc::new(fitted)
+}
+
+fn fit_bytes(array: &ArrayRef, length: usize, pad: bool) -> ArrayRef {
+ let Some(bytes) =
array.as_any().downcast_ref::<arrow_array::BinaryArray>() else {
+ return Arc::clone(array);
+ };
+ let fitted: arrow_array::BinaryArray = bytes
+ .iter()
+ .map(|value| {
+ value.map(|value| {
+ let mut value = value.to_vec();
+ if value.len() > length || pad {
+ value.resize(length, 0);
+ }
+ value
+ })
+ })
+ .collect();
+ Arc::new(fitted)
+}
+
+/// Fail on invalid casts, as Java does.
+fn cast_to(array: &ArrayRef, to: &arrow_schema::DataType) -> Result<ArrayRef> {
+ if array.data_type() == to {
+ return Ok(Arc::clone(array));
+ }
+ let options = arrow_cast::CastOptions {
+ safe: false,
+ ..Default::default()
+ };
+ arrow_cast::cast_with_options(array, to, &options).map_err(|e|
eval_err(&e))
+}
+
+enum StringInput<'a> {
+ Literal(Option<&'a str>),
+ Column(arrow_array::StringArray),
+}
+
+impl StringInput<'_> {
+ fn value(&self, row: usize) -> Option<&str> {
+ match self {
+ Self::Literal(value) => *value,
+ Self::Column(values) => values.is_valid(row).then(||
values.value(row)),
+ }
+ }
+}
+
+fn string_mask(
+ batch: &RecordBatch,
+ transform: &Transform,
+ input: &dyn Fn(usize) -> Result<ArrayRef>,
+) -> Result<ArrayRef> {
+ use arrow_array::builder::StringBuilder;
+ use std::fmt::Write;
+
+ let inputs = match transform {
+ Transform::Upper(inputs) | Transform::Lower(inputs) if inputs.len() ==
1 => inputs,
+ Transform::Concat(inputs) => inputs,
+ Transform::ConcatWs(inputs) if inputs.len() >= 2 => inputs,
+ _ => return Err(unsupported("the server's string mask has the wrong
inputs")),
+ };
+ let resolved: Vec<StringInput<'_>> = inputs
+ .iter()
+ .map(|i| match i {
+ TransformInput::Literal(literal) =>
Ok(StringInput::Literal(literal.as_deref())),
+ TransformInput::Field(index) => Ok(StringInput::Column(
+ cast_to(&input(*index)?, &arrow_schema::DataType::Utf8)?
+ .as_string::<i32>()
+ .clone(),
+ )),
+ })
+ .collect::<Result<_>>()?;
+ if matches!(transform, Transform::Upper(_) | Transform::Lower(_)) {
+ let upper = matches!(transform, Transform::Upper(_));
+ let literal = match &resolved[0] {
+ StringInput::Column(array) => return
Ok(Arc::new(string_case(array, upper))),
+ StringInput::Literal(value) => value,
+ };
+ let mut values = StringBuilder::with_capacity(batch.num_rows(), 0);
+ match literal {
+ Some(value) => values.append_value_n(
+ if upper {
+ value.to_uppercase()
+ } else {
+ value.to_lowercase()
+ },
+ batch.num_rows(),
+ ),
+ None => values.append_nulls(batch.num_rows()),
+ }
+ return Ok(Arc::new(values.finish()));
+ }
+ let mut values = StringBuilder::with_capacity(batch.num_rows(), 0);
+ for row in 0..batch.num_rows() {
+ let (parts, separator) = if matches!(transform,
Transform::ConcatWs(_)) {
+ let Some(separator) = resolved[0].value(row) else {
+ values.append_null();
+ continue;
+ };
+ (&resolved[1..], separator)
+ } else {
+ if resolved.iter().any(|value| value.value(row).is_none()) {
+ values.append_null();
+ continue;
+ }
+ (resolved.as_slice(), "")
+ };
+ let mut first = true;
+ for part in parts.iter().filter_map(|value| value.value(row)) {
+ if !first {
+ values
+ .write_str(separator)
+ .expect("infallible string write");
+ }
+ values.write_str(part).expect("infallible string write");
+ first = false;
+ }
+ values.append_value("");
+ }
+ Ok(Arc::new(values.finish()))
+}
+
+fn string_case(array: &arrow_array::StringArray, upper: bool) ->
arrow_array::StringArray {
+ let offsets = array.value_offsets();
+ let start = offsets[0];
+ let bytes = &array.value_data()[start as usize..offsets[array.len()] as
usize];
+ if bytes.is_ascii() {
+ let mut values = bytes.to_vec();
+ if upper {
+ values.make_ascii_uppercase();
+ } else {
+ values.make_ascii_lowercase();
+ }
+ let offsets = if start == 0 {
+ array.offsets().clone()
+ } else {
+ arrow_buffer::OffsetBuffer::new(
+ offsets.iter().map(|o| o - start).collect::<Vec<_>>().into(),
+ )
+ };
+ return arrow_array::StringArray::new(offsets, values.into(),
array.nulls().cloned());
+ }
+ // Unicode lowercasing needs context, e.g. the final Greek sigma.
+ array
+ .iter()
+ .map(|value| {
+ value.map(|value| {
+ if upper {
+ value.to_uppercase()
+ } else {
+ value.to_lowercase()
+ }
+ })
+ })
+ .collect()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -237,8 +639,8 @@ mod tests {
IntType, PredicateBuilder, PredicateOperator, TimestampType,
VarBinaryType, VarCharType,
};
use arrow_array::{
- Date32Array, Decimal128Array, Int32Array, Int64Array,
RecordBatchOptions, StringArray,
- TimestampMillisecondArray,
+ BinaryArray, Date32Array, Decimal128Array, Int16Array, Int32Array,
Int64Array, Int8Array,
+ RecordBatchOptions, StringArray, TimestampMillisecondArray,
};
use arrow_schema::{Field, Schema};
@@ -303,11 +705,36 @@ mod tests {
)
}
+ /// A Java `FieldRef`, as a transform input.
+ fn input(field: &str) -> String {
+ format!(r#"{{"index":0,"name":"{field}","type":"STRING"}}"#)
+ }
+
+ fn field_ref(field: &str) -> String {
+ format!(r#"{{"name":"FIELD_REF","fieldRef":{}}}"#, input(field))
+ }
+
+ fn cast(field: &str, to: &str) -> String {
+ format!(
+ r#"{{"name":"CAST","fieldRef":{},"type":"{to}"}}"#,
+ input(field)
+ )
+ }
+
+ fn string_transform(name: &str, inputs: &[&str]) -> String {
+ format!(r#"{{"name":"{name}","inputs":[{}]}}"#, inputs.join(","))
+ }
+
fn ids(batch: &RecordBatch) -> Vec<i32> {
let ids = batch.column(0).as_any().downcast_ref::<Int32Array>();
ids.unwrap().values().to_vec()
}
+ fn strings(batch: &RecordBatch, column: usize) -> Vec<Option<&str>> {
+ let strings =
batch.column(column).as_any().downcast_ref::<StringArray>();
+ strings.unwrap().iter().collect()
+ }
+
// ---------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------
@@ -345,9 +772,26 @@ mod tests {
}
#[test]
- fn test_any_mask_is_refused() {
- let message = refusal(parse(&[], &[("name", NULL)], &rule_fields()));
- assert!(message.contains("column masking"), "{message}");
+ fn test_parse_reads_the_masks() {
+ let fields = rule_fields();
+ let upper = string_transform("UPPER", &[&input("name")]);
+ let rules = parse(&[], &[("name", &upper), ("id", NULL)],
&fields).unwrap();
+ // Masks follow schema order.
+ assert_eq!(
+ rules.masks,
+ vec![
+ ColumnMask {
+ column: 0,
+ transform: Transform::Null
+ },
+ ColumnMask {
+ column: 1,
+ transform: Transform::Upper(vec![TransformInput::Field(1)])
+ },
+ ]
+ );
+ assert_eq!(rules.masked_columns(), HashSet::from([0, 1]));
+ assert_eq!(mask_inputs(&rules.masks[1]), HashSet::from([1]));
}
#[test]
@@ -370,6 +814,49 @@ mod tests {
assert!(refusal(parse(&[&good, ""], &[], &fields)).contains("empty row
filter"));
}
+ #[test]
+ fn test_blob_masks_are_not_supported_yet() {
+ use crate::spec::{ArrayType, BlobType, MapType};
+
+ let blob = DataType::Blob(BlobType::new());
+ for data_type in [
+ blob.clone(),
+ DataType::Array(ArrayType::new(blob.clone())),
+ DataType::Map(MapType::new(string(), blob)),
+ ] {
+ let fields = vec![field(0, "payload", data_type.clone())];
+ for mask in [
+ NULL.to_string(),
+ field_ref("payload"),
+ cast("payload", &data_type.to_string()),
+ ] {
+ let message = refusal(parse(&[], &[("payload", &mask)],
&fields));
+ assert!(
+ message.contains("BLOB column masks are not supported
yet"),
+ "{message}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn test_parse_refuses_a_bad_mask() {
+ let fields = rule_fields();
+ let missing_input = field_ref("missing");
+ for (column, mask, reason) in [
+ ("", NULL, "empty column mask"),
+ ("name", "", "empty column mask"),
+ ("name", "null", "cannot parse"),
+ ("name", "{", "cannot parse"),
+ ("name", r#"{"name":"ROT13"}"#, "cannot parse"),
+ ("missing", NULL, "'missing', which is not a column"),
+ ("name", &missing_input, "unknown field `missing`"),
+ ] {
+ let message = refusal(parse(&[], &[(column, mask)], &fields));
+ assert!(message.contains(reason), "{column:?} {mask:?}:
{message}");
+ }
+ }
+
#[test]
fn test_a_filter_on_a_system_column_is_refused() {
let fields = rule_fields();
@@ -382,6 +869,98 @@ mod tests {
}
}
+ #[test]
+ fn test_a_mask_on_a_system_column_is_inert() {
+ let fields = rule_fields();
+ for column in [
+ "_ROW_ID",
+ "_SEQUENCE_NUMBER",
+ "_VALUE_KIND",
+ "_LEVEL",
+ "rowkind",
+ ] {
+ let rules = parse(&[], &[(column, NULL)], &fields).unwrap();
+ assert!(rules.is_empty(), "{column}");
+ }
+ // `_KEY_` names are not exempt system fields.
+ let message = refusal(parse(&[], &[("_KEY_id", NULL)], &fields));
+ assert!(
+ message.contains("'_KEY_id', which is not a column"),
+ "{message}"
+ );
+ // Skipped masks must still parse.
+ for mask in ["null", "{"] {
+ let message = refusal(parse(&[], &[("_ROW_ID", mask)], &fields));
+ assert!(message.contains("cannot parse"), "{mask}: {message}");
+ }
+ }
+
+ #[test]
+ fn test_a_mask_must_keep_the_column_type() {
+ let fields = rule_fields();
+ for (column, mask) in [
+ ("id", string_transform("UPPER", &[&input("name")])),
+ ("id", string_transform("CONCAT", &[r#""x""#])),
+ ("id", field_ref("name")),
+ ("id", cast("id", "STRING")),
+ ("id", cast("id", "BIGINT")),
+ ("name", cast("name", "BINARY(3)")),
+ ] {
+ let message = refusal(parse(&[], &[(column, &mask)], &fields));
+ assert!(message.contains("produces"), "{mask}: {message}");
+ }
+ for (column, mask) in [
+ ("id", NULL.to_string()),
+ ("name", cast("id", "VARCHAR(2)")),
+ ("bin", cast("name", "BINARY(3)")),
+ ] {
+ assert!(parse(&[], &[(column, &mask)], &fields).is_ok(), "{mask}");
+ }
+ }
+
+ #[test]
+ fn test_a_not_null_column_takes_only_a_mask_that_cannot_be_null() {
+ let fields = rule_fields();
+ let (name, nn) = (input("name"), input("nn"));
+ for (mask, fits) in [
+ (NULL.to_string(), false),
+ (field_ref("name"), false),
+ (field_ref("nn"), true),
+ (cast("name", "STRING"), false),
+ (cast("nn", "VARCHAR(3)"), true),
+ (string_transform("UPPER", &[&nn]), true),
+ (string_transform("CONCAT", &[&nn, "null"]), false),
+ (string_transform("CONCAT_WS", &["null", &nn]), false),
+ // Only the separator can make CONCAT_WS null.
+ (string_transform("CONCAT_WS", &[r#""-""#, &nn, &name]), true),
+ ] {
+ let parsed = parse(&[], &[("nn", &mask)], &fields);
+ if fits {
+ assert!(parsed.is_ok(), "{mask}: {parsed:?}");
+ } else {
+ assert!(refusal(parsed).contains("can produce null"),
"{mask}");
+ }
+ }
+ }
+
+ #[test]
+ fn test_a_mask_may_not_read_another_masked_column() {
+ let fields = rule_fields();
+ let upper_name = string_transform("UPPER", &[&input("name")]);
+ assert!(parse(&[], &[("name", &upper_name)], &fields).is_ok());
+ assert!(parse(&[], &[("name", &upper_name), ("id", NULL)],
&fields).is_ok());
+ // `alias` would expose the unmasked `name`.
+ let message = refusal(parse(
+ &[],
+ &[("name", NULL), ("alias", &upper_name)],
+ &fields,
+ ));
+ assert!(
+ message.contains("reads 'name', which is masked too"),
+ "{message}"
+ );
+ }
+
// ---------------------------------------------------------------------
// Row filter
// ---------------------------------------------------------------------
@@ -696,4 +1275,346 @@ mod tests {
3
);
}
+
+ // Column masking
+
+ fn mask_rows() -> RecordBatch {
+ let bytes: Vec<Option<&[u8]>> = vec![Some(&[1, 2, 3, 4, 5, 6]),
Some(&[1, 2]), None];
+ batch(
+ &rule_fields(),
+ vec![
+ Arc::new(Int32Array::from(vec![Some(12345), Some(7), None])),
+ Arc::new(StringArray::from(vec![
+ Some("supersecret"),
+ Some("ab"),
+ None,
+ ])),
+ Arc::new(StringArray::from(vec![Some("MiXed"), None,
Some("x")])),
+ Arc::new(BinaryArray::from(bytes)),
+ Arc::new(StringArray::from(vec!["héllo wörld", "q", "z"])),
+ ],
+ )
+ }
+
+ fn masked(column: &str, mask: &str) -> RecordBatch {
+ let fields = rule_fields();
+ let rules = parse(&[], &[(column, mask)], &fields).unwrap();
+ mask_batch(&mask_rows(), &rules.masks, &fields, &fields).unwrap()
+ }
+
+ #[test]
+ fn test_string_masks_propagate_nulls_as_java_does() {
+ let (name, alias) = (input("name"), input("alias"));
+ let cases = [
+ (NULL.to_string(), vec![None, None, None]),
+ (field_ref("alias"), vec![Some("MiXed"), None, Some("x")]),
+ (
+ string_transform("UPPER", &[&alias]),
+ vec![Some("MIXED"), None, Some("X")],
+ ),
+ (
+ string_transform("LOWER", &[&alias]),
+ vec![Some("mixed"), None, Some("x")],
+ ),
+ (
+ string_transform("UPPER", &[r#""Straße""#]),
+ vec![Some("STRASSE"); 3],
+ ),
+ (string_transform("LOWER", &[r#""ΟΣ""#]), vec![Some("ος"); 3]),
+ (string_transform("UPPER", &["null"]), vec![None; 3]),
+ (string_transform("CONCAT", &[]), vec![Some(""); 3]),
+ (
+ string_transform("CONCAT", &[&name, r#""-""#, &alias]),
+ vec![Some("supersecret-MiXed"), None, None],
+ ),
+ (
+ string_transform("CONCAT", &[r#""****""#]),
+ vec![Some("****"); 3],
+ ),
+ (string_transform("CONCAT", &[&name, "null"]), vec![None; 3]),
+ // CONCAT_WS skips null payloads and is null on a null separator.
+ (
+ string_transform("CONCAT_WS", &[r#""-""#, r#""x""#, &name,
"null", &alias]),
+ vec![Some("x-supersecret-MiXed"), Some("x-ab"), Some("x-x")],
+ ),
+ (
+ string_transform("CONCAT_WS", &[&alias, &name, r#""y""#]),
+ vec![Some("supersecretMiXedy"), None, Some("y")],
+ ),
+ (
+ string_transform("CONCAT_WS", &[r#""-""#, "null", "null"]),
+ vec![Some(""); 3],
+ ),
+ ];
+ for (mask, expected) in cases {
+ let out = masked("name", &mask);
+ assert_eq!(strings(&out, 1), expected, "{mask}");
+ assert_eq!(strings(&out, 2), [Some("MiXed"), None, Some("x")],
"{mask}");
+ }
+ }
+
+ #[test]
+ fn test_a_cast_java_converts_differently_is_refused() {
+ let fields = vec![
+ field(0, "n", DataType::BigInt(crate::spec::BigIntType::new())),
+ field(
+ 1,
+ "ts",
+
DataType::Timestamp(crate::spec::TimestampType::new(3).unwrap()),
+ ),
+ field(2, "d", DataType::Double(crate::spec::DoubleType::new())),
+ field(3, "s", string()),
+ field(4, "b",
DataType::VarBinary(VarBinaryType::new(10).unwrap())),
+ field(5, "i", DataType::Int(IntType::new())),
+ ];
+ for (target, mask) in [
+ ("ts", cast("n", "TIMESTAMP(3)")),
+ ("s", cast("d", "VARCHAR(10)")),
+ ("s", cast("b", "VARCHAR(10)")),
+ // Java trims the string first.
+ ("n", cast("s", "BIGINT")),
+ ("i", cast("n", "INT")),
+ ] {
+ let message = refusal(parse(&[], &[(target, &mask)], &fields));
+ assert!(message.contains("as Java does"), "{mask}: {message}");
+ }
+ for (target, mask) in [
+ ("s", cast("n", "VARCHAR(10)")),
+ ("b", cast("s", "VARBINARY(10)")),
+ ("ts", cast("ts", "TIMESTAMP(3)")),
+ ] {
+ assert!(parse(&[], &[(target, &mask)], &fields).is_ok(), "{mask}");
+ }
+ }
+
+ #[test]
+ fn test_integer_cast_masks_widen_exactly() {
+ let tiny: ArrayRef = Arc::new(Int8Array::from(vec![Some(i8::MIN),
None, Some(i8::MAX)]));
+ let small: ArrayRef =
+ Arc::new(Int16Array::from(vec![Some(i16::MIN), None,
Some(i16::MAX)]));
+ let int: ArrayRef = Arc::new(Int32Array::from(vec![Some(i32::MIN),
None, Some(i32::MAX)]));
+ let cases: [(&str, &str, ArrayRef, ArrayRef); 6] = [
+ (
+ "TINYINT",
+ "SMALLINT",
+ tiny.clone(),
+ Arc::new(Int16Array::from(vec![Some(-128), None, Some(127)])),
+ ),
+ (
+ "TINYINT",
+ "INT",
+ tiny.clone(),
+ Arc::new(Int32Array::from(vec![Some(-128), None, Some(127)])),
+ ),
+ (
+ "TINYINT",
+ "BIGINT",
+ tiny,
+ Arc::new(Int64Array::from(vec![Some(-128), None, Some(127)])),
+ ),
+ (
+ "SMALLINT",
+ "INT",
+ small.clone(),
+ Arc::new(Int32Array::from(vec![Some(-32768), None,
Some(32767)])),
+ ),
+ (
+ "SMALLINT",
+ "BIGINT",
+ small,
+ Arc::new(Int64Array::from(vec![Some(-32768), None,
Some(32767)])),
+ ),
+ (
+ "INT",
+ "BIGINT",
+ int,
+ Arc::new(Int64Array::from(vec![
+ Some(-2147483648),
+ None,
+ Some(2147483647),
+ ])),
+ ),
+ ];
+ for (from, to, values, expected) in cases {
+ let fields = vec![
+ field(
+ 0,
+ "input",
+ serde_json::from_value(serde_json::json!(from)).unwrap(),
+ ),
+ field(
+ 1,
+ "wide",
+ serde_json::from_value(serde_json::json!(to)).unwrap(),
+ ),
+ ];
+ let target =
+ arrow_cast::cast(&Int64Array::from(vec![999; 3]),
expected.data_type()).unwrap();
+ let rows = batch(&fields, vec![values, target]);
+ let mask = serde_json::json!({"name":"CAST", "fieldRef": {
+ "index":0, "name":"input", "type":from,
+ }, "type":to})
+ .to_string();
+ let rules = parse(&[], &[("wide", &mask)], &fields).unwrap();
+ let out = mask_batch(&rows, &rules.masks, &fields,
&fields).unwrap();
+ assert_eq!(out.column(1), &expected, "{from} -> {to}");
+ assert_eq!(out.column(0), rows.column(0));
+ }
+ }
+
+ #[test]
+ fn test_string_masks_preserve_unicode_and_sliced_inputs() {
+ let fields = vec![field(0, "value", string())];
+ let input = serde_json::json!({"index":0, "name":"value",
"type":"STRING"}).to_string();
+ for (values, upper, lower) in [
+ (
+ vec![
+ Some("prefix"),
+ Some("aZ"),
+ None,
+ Some(""),
+ Some("q"),
+ Some("suffix"),
+ ],
+ vec![Some("AZ"), None, Some(""), Some("Q")],
+ vec![Some("az"), None, Some(""), Some("q")],
+ ),
+ (
+ vec![
+ Some("prefix"),
+ Some("ΟΣ"),
+ Some("Straße"),
+ None,
+ Some("İ"),
+ Some("suffix"),
+ ],
+ vec![Some("ΟΣ"), Some("STRASSE"), None, Some("İ")],
+ vec![Some("ος"), Some("straße"), None, Some("i\u{307}")],
+ ),
+ (vec![None; 6], vec![None; 4], vec![None; 4]),
+ ] {
+ let rows = batch(
+ &fields,
+ vec![Arc::new(StringArray::from(values).slice(1, 4))],
+ );
+ for (operation, expected) in [("UPPER", upper), ("LOWER", lower)] {
+ let mask = string_transform(operation, &[&input]);
+ let rules = parse(&[], &[("value", &mask)], &fields).unwrap();
+ let out = mask_batch(&rows, &rules.masks, &fields,
&fields).unwrap();
+ assert_eq!(strings(&out, 0), expected);
+ let empty = mask_batch(&rows.slice(2, 0), &rules.masks,
&fields, &fields).unwrap();
+ assert_eq!(empty.num_rows(), 0);
+ }
+ }
+ }
+
+ #[test]
+ fn test_a_cast_mask_applies_the_declared_width() {
+ for (mask, expected) in [
+ (
+ cast("name", "VARCHAR(3)"),
+ vec![Some("sup"), Some("ab"), None],
+ ),
+ (
+ cast("name", "CHAR(5)"),
+ vec![Some("super"), Some("ab "), None],
+ ),
+ // Characters, not bytes.
+ (
+ cast("nn", "VARCHAR(3)"),
+ vec![Some("hél"), Some("q"), Some("z")],
+ ),
+ (cast("id", "VARCHAR(2)"), vec![Some("12"), Some("7"), None]),
+ ] {
+ assert_eq!(strings(&masked("name", &mask), 1), expected, "{mask}");
+ }
+ let cases: [(String, Vec<Option<&[u8]>>); 3] = [
+ (
+ cast("bin", "BINARY(4)"),
+ vec![Some(&[1, 2, 3, 4]), Some(&[1, 2, 0, 0]), None],
+ ),
+ (
+ cast("bin", "VARBINARY(4)"),
+ vec![Some(&[1, 2, 3, 4]), Some(&[1, 2]), None],
+ ),
+ (
+ cast("name", "BINARY(3)"),
+ vec![Some(b"sup"), Some(b"ab\0"), None],
+ ),
+ ];
+ for (mask, expected) in cases {
+ let out = masked("bin", &mask);
+ let bytes = out.column(3).as_any().downcast_ref::<BinaryArray>();
+ assert_eq!(
+ bytes.unwrap().iter().collect::<Vec<_>>(),
+ expected,
+ "{mask}"
+ );
+ }
+ }
+
+ #[test]
+ fn test_every_copy_of_a_masked_column_is_masked() {
+ let fields = rule_fields();
+ let rows = mask_rows().project(&[1, 0, 1]).unwrap();
+ let rows_fields = [fields[1].clone(), fields[0].clone(),
fields[1].clone()];
+ let mask = string_transform("CONCAT", &[&input("name"), r#""!""#]);
+ let rules = parse(&[], &[("name", &mask)], &fields).unwrap();
+ let out = mask_batch(&rows, &rules.masks, &fields,
&rows_fields).unwrap();
+ for column in [0, 2] {
+ assert_eq!(
+ strings(&out, column),
+ [Some("supersecret!"), Some("ab!"), None]
+ );
+ }
+ assert_eq!(out.column(1), rows.column(1));
+ }
+
+ #[test]
+ fn test_masks_read_the_batch_as_read() {
+ // Bypass parsing to verify both masks read the original batch.
+ let fields = rule_fields();
+ let masks = [
+ ColumnMask {
+ column: 0,
+ transform: Transform::Null,
+ },
+ ColumnMask {
+ column: 1,
+ transform: Transform::Cast(0, string()),
+ },
+ ];
+ let out = mask_batch(&mask_rows(), &masks, &fields, &fields).unwrap();
+ assert_eq!(out.column(0).null_count(), 3);
+ assert_eq!(strings(&out, 1), [Some("12345"), Some("7"), None]);
+ }
+
+ #[test]
+ fn test_a_mask_refuses_a_batch_without_its_columns() {
+ let fields = rule_fields();
+ let rows = mask_rows();
+ let mask = field_ref("alias");
+ let masks = parse(&[], &[("name", &mask)], &fields).unwrap().masks;
+ let without_target = rows.project(&[0, 2]).unwrap();
+ let message = refusal(mask_batch(
+ &without_target,
+ &masks,
+ &fields,
+ &[fields[0].clone(), fields[2].clone()],
+ ));
+ assert!(message.contains("does not carry 'name'"), "{message}");
+ let without_input = rows.project(&[0, 1]).unwrap();
+ let message = refusal(mask_batch(&without_input, &masks, &fields,
&fields[..2]));
+ assert!(message.contains("does not carry 'alias'"), "{message}");
+ }
+
+ #[test]
+ fn test_no_mask_keeps_a_zero_column_batch_whole() {
+ let options = RecordBatchOptions::new().with_row_count(Some(3));
+ let empty =
+ RecordBatch::try_new_with_options(Arc::new(Schema::empty()),
Vec::new(), &options)
+ .unwrap();
+ let out = mask_batch(&empty, &[], &rule_fields(), &[]).unwrap();
+ assert_eq!(out.num_rows(), 3);
+ }
}
diff --git a/crates/paimon/src/table/read_builder.rs
b/crates/paimon/src/table/read_builder.rs
index ea327b31..42ee2f3b 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -46,7 +46,7 @@ struct NormalizedFilter {
/// Exact filters are fully enforced by paimon-core scan planning using only
/// partition-owned semantics, without requiring residual filtering above the
/// scan.
-fn is_exact_filter_pushdown_for_schema(
+pub(super) fn is_exact_filter_pushdown_for_schema(
fields: &[DataField],
partition_keys: &[String],
filter: &Predicate,
@@ -65,7 +65,8 @@ pub(super) fn split_scan_predicates(
filter: Predicate,
) -> (Option<Predicate>, Vec<Predicate>) {
let partition_keys = table.schema().partition_keys();
- if partition_keys.is_empty() {
+ // Auth rules may mask partition keys; retain the full read predicate.
+ if partition_keys.is_empty() ||
table.schema().core_options().query_auth_enabled() {
(None, filter.split_and())
} else {
split_partition_and_data_predicates(filter, table.schema().fields(),
partition_keys)
@@ -448,11 +449,13 @@ impl<'a> PaimonReadBuilder<'a> {
/// Exact filters are fully enforced by paimon-core scan planning, without
/// requiring residual filtering above the scan.
pub fn is_exact_filter_pushdown(&self, filter: &Predicate) -> bool {
- is_exact_filter_pushdown_for_schema(
- self.table.schema().fields(),
- self.table.schema().partition_keys(),
- filter,
- )
+ // Masked partition predicates may need a read-side residual.
+ !self.table.schema().core_options().query_auth_enabled()
+ && is_exact_filter_pushdown_for_schema(
+ self.table.schema().fields(),
+ self.table.schema().partition_keys(),
+ filter,
+ )
}
/// Set row ID ranges `[from, to]` (inclusive). An empty vector selects no
rows.
diff --git a/crates/paimon/src/table/source.rs
b/crates/paimon/src/table/source.rs
index d68ff2a3..11d6bc3f 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -1679,7 +1679,10 @@ mod tests {
Some(Arc::new(crate::table::query_auth::QueryAuthGrant::new(
table.query_auth_session().unwrap(),
None,
- crate::table::query_auth::Rules { filters },
+ crate::table::query_auth::Rules {
+ filters,
+ masks: Vec::new(),
+ },
)))
};
let s = || split(vec![file("a", 10, None)], true);
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index 183984bd..94c1ae8b 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -1069,20 +1069,35 @@ impl<'a> PaimonTableRead<'a> {
}
}
- /// Reads what the row filter needs, filters on stored values and projects
- /// back (Java `doAuth`).
+ /// Filter rows, apply masks, then evaluate predicates on masked columns.
fn read_restricted(
&self,
data_splits: &[DataSplit],
grant: &super::query_auth::QueryAuthGrant,
) -> crate::Result<ArrowRecordBatchStream> {
- use super::query_auth::{filter_batch, unsupported};
+ use super::query_auth::{filter_batch, mask_batch, mask_inputs,
unsupported};
+ use crate::arrow::format::FilePredicates;
+ use crate::arrow::residual::{filter_record_batch_by_predicates,
widen_scan_fields};
+ use std::collections::HashSet;
let schema_fields = self.table.schema().fields().to_vec();
let rules = grant.rules();
super::query_auth::reject_throwing_extractions(&self.read_type)?;
+
+ let masked = rules.masked_columns();
+ let columns_of = |predicate: &Predicate| {
+ let mut columns = HashSet::new();
+ predicate.collect_leaf_field_indices(&mut columns);
+ columns
+ };
+ let (after_mask, raw): (Vec<Predicate>, Vec<Predicate>) = self
+ .data_predicates
+ .iter()
+ .cloned()
+ .partition(|p| !columns_of(p).is_disjoint(&masked));
+ let after_mask_columns: HashSet<usize> =
after_mask.iter().flat_map(columns_of).collect();
let index_of = |field: &DataField| schema_fields.iter().position(|s|
s.id() == field.id());
- let needed = rules.filter_columns();
+ let mut needed = rules.filter_columns();
let core_options = self.table.schema.core_options();
let view_fields = core_options.blob_view_fields();
if core_options.blob_view_resolve_enabled() &&
self.table.rest_env().is_some() {
@@ -1098,14 +1113,28 @@ impl<'a> PaimonTableRead<'a> {
)));
}
}
- // A partly projected column would feed the filter a partial value
(Java
- // `validateReadType`).
+ let visible: HashSet<usize> = self
+ .read_type
+ .iter()
+ .filter_map(index_of)
+ .chain(after_mask_columns.iter().copied())
+ .collect();
+ let masks: Vec<_> = rules
+ .masks
+ .iter()
+ .filter(|m| visible.contains(&m.column))
+ .cloned()
+ .collect();
+ needed.extend(&after_mask_columns);
+ masks.iter().for_each(|m| needed.extend(mask_inputs(m)));
+ // Rules require complete input columns.
for field in &self.read_type {
if let Some(index) = index_of(field) {
- if needed.contains(&index) && field.data_type() !=
schema_fields[index].data_type()
+ if (needed.contains(&index) || masked.contains(&index))
+ && field.data_type() != schema_fields[index].data_type()
{
return Err(unsupported(&format!(
- "the server's row filter reads '{}', which the read
projects only in part",
+ "the server's rules involve '{}', which the read
projects only in part",
field.name()
)));
}
@@ -1121,16 +1150,25 @@ impl<'a> PaimonTableRead<'a> {
physical.push(field.clone());
}
}
- let predicates = crate::arrow::format::FilePredicates {
+ let mut predicates = FilePredicates {
predicates: self.data_predicates.clone(),
row_filter_factory: None,
file_fields: schema_fields.clone(),
};
- physical = crate::arrow::residual::widen_scan_fields(&physical,
Some(&predicates));
+ physical = widen_scan_fields(&physical, Some(&predicates));
let mut inner = self.clone();
inner.read_type = physical.clone();
+ inner.data_predicates = raw;
inner.limit = None;
let has_blobs = physical.iter().any(|f|
f.data_type().is_blob_file_field());
+ let (before_mask, pending): (Vec<_>, Vec<_>) =
+ predicates.predicates.into_iter().partition(|predicate| {
+ has_blobs
+ && columns_of(predicate).iter().all(|i| {
+ !masked.contains(i) &&
!schema_fields[*i].data_type().is_blob_file_field()
+ })
+ });
+ predicates.predicates = before_mask;
let unresolved_table;
if has_blobs {
// Keep payload predicates above authorization too: they can open
@@ -1148,6 +1186,11 @@ impl<'a> PaimonTableRead<'a> {
let filters = rules.filters.clone();
let batch_fields = physical.clone();
+ let after_mask = FilePredicates {
+ predicates: after_mask,
+ row_filter_factory: None,
+ file_fields: schema_fields.clone(),
+ };
let stream = stream.map(move |batch| {
let batch = batch?;
let names_match = batch.num_columns() == physical.len()
@@ -1162,11 +1205,19 @@ impl<'a> PaimonTableRead<'a> {
"the read returned columns the rules cannot address",
));
}
- filter_batch(&batch, &filters, &schema_fields, &physical)
+ let batch = filter_batch(&batch, &filters, &schema_fields,
&physical)?;
+ // Safe ordinary conjuncts avoid masking rows the caller excludes.
+ let batch = filter_record_batch_by_predicates(batch, &predicates,
&physical)?;
+ let batch = mask_batch(&batch, &masks, &schema_fields, &physical)?;
+ if has_blobs {
+ Ok(batch)
+ } else {
+ filter_record_batch_by_predicates(batch, &after_mask,
&physical)
+ }
});
let stream: ArrowRecordBatchStream = Box::pin(stream);
if has_blobs {
- return self.finish_authorized_blobs(stream, &batch_fields,
&core_options);
+ return self.finish_authorized_blobs(stream, &batch_fields,
&pending, &core_options);
}
let stream = project_authorized_stream(stream, self.read_type.len());
Ok(match self.limit {
@@ -1179,6 +1230,7 @@ impl<'a> PaimonTableRead<'a> {
&self,
stream: ArrowRecordBatchStream,
batch_fields: &[DataField],
+ predicates: &[Predicate],
core_options: &CoreOptions<'_>,
) -> crate::Result<ArrowRecordBatchStream> {
use super::managed_blob_reader::{resolve_primary_key_blob_stream,
ManagedBlobReadPlan};
@@ -1188,7 +1240,7 @@ impl<'a> PaimonTableRead<'a> {
&& self.table.rest_env().is_some()
&& batch_fields.iter().any(|f| view_fields.contains(f.name()));
let predicate_views: std::collections::HashSet<_> =
- super::query_auth::leaf_names(&self.data_predicates)
+ super::query_auth::leaf_names(predicates)
.intersection(&view_fields)
.cloned()
.collect();
@@ -1212,7 +1264,7 @@ impl<'a> PaimonTableRead<'a> {
// The ordinary Blob plan must not decode those payloads a second time.
let blob_plan = ManagedBlobReadPlan::new(
&self.read_type,
- &self.data_predicates,
+ predicates,
self.table.schema().fields(),
core_options,
);
@@ -1241,7 +1293,7 @@ impl<'a> PaimonTableRead<'a> {
}
None => {
let predicates = crate::arrow::format::FilePredicates {
- predicates: self.data_predicates.clone(),
+ predicates: predicates.to_vec(),
row_filter_factory: None,
file_fields: self.table.schema().fields().to_vec(),
};
@@ -2741,6 +2793,7 @@ mod tests {
} else {
Vec::new()
},
+ masks: Vec::new(),
},
)
}
@@ -2998,7 +3051,10 @@ mod tests {
let grant = crate::table::query_auth::QueryAuthGrant::new(
table.query_auth_session().unwrap(),
Some(Vec::new()),
- crate::table::query_auth::Rules { filters },
+ crate::table::query_auth::Rules {
+ filters,
+ masks: Vec::new(),
+ },
);
let split = split_with_grant(Some(grant));
let narrow = TableRead::new(&table, Vec::new(), Vec::new());
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index b56bf4a4..dd2fc4bb 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -1272,6 +1272,7 @@ impl<'a> TableScan<'a> {
/// `seed` must render as a decimal integer. Accepting any `ToString` seed
/// keeps ordinary Rust integer calls ergonomic while allowing language
/// bindings to preserve arbitrary-precision integer seeds.
+ /// Only predicates on unmasked partition keys are supported.
pub fn with_chunk_shuffle(self, seed: impl ToString, chunk_size: u64) ->
crate::Result<Self> {
let config = ChunkShuffle::from_decimal_seed(&seed.to_string(),
chunk_size)?;
match self.0 {
@@ -1293,7 +1294,13 @@ impl<'a> TableScan<'a> {
.to_string(),
});
}
- if !scan.data_predicates.is_empty() {
+ if !scan.data_predicates.is_empty()
+ &&
!super::read_builder::is_exact_filter_pushdown_for_schema(
+ scan.table.schema().fields(),
+ scan.table.schema().partition_keys(),
+ &Predicate::and(scan.data_predicates.clone()),
+ )
+ {
return Err(crate::Error::Unsupported {
message: "chunk_shuffle only supports partition
predicates".to_string(),
});
@@ -1720,10 +1727,7 @@ impl<'a> PaimonTableScan<'a> {
/// `scan.snapshot-id` / `scan.tag-name` handling.
pub async fn plan(&self) -> crate::Result<Plan> {
let grant = self.authorize_query().await?;
- let restricted = grant
- .as_deref()
- .filter(|grant| !grant.is_unrestricted())
- .map(|grant| self.restricted_by(grant));
+ let restricted = grant.as_deref().map(|grant|
self.restricted_by(grant));
let scan = restricted.as_ref().unwrap_or(self);
if let Some((start, end)) = scan
.table
@@ -1750,10 +1754,7 @@ impl<'a> PaimonTableScan<'a> {
/// Plan the full scan and return metadata-pruning trace counters.
pub async fn plan_with_trace(&self) -> crate::Result<(Plan, ScanTrace)> {
let grant = self.authorize_query().await?;
- let restricted = grant
- .as_deref()
- .filter(|grant| !grant.is_unrestricted())
- .map(|grant| self.restricted_by(grant));
+ let restricted = grant.as_deref().map(|grant|
self.restricted_by(grant));
let scan = restricted.as_ref().unwrap_or(self);
let mut trace = ScanTrace {
limit: scan.limit,
@@ -1793,20 +1794,54 @@ impl<'a> PaimonTableScan<'a> {
Ok((plan.planned(grant), trace))
}
- /// The scan a restricted grant plans: a limit cannot count rows the row
- /// filter drops.
+ /// Keep pruning off masked columns and apply limits after row filtering.
fn restricted_by(&self, grant: &super::query_auth::QueryAuthGrant) -> Self
{
let rules = grant.rules();
+ let fields = self.table.schema().fields();
+ let masked: HashSet<&str> = rules
+ .masks
+ .iter()
+ .map(|m| fields[m.column].name())
+ .collect();
+ // By name: the bucket predicate is indexed by bucket-key position.
+ let touches_masked = |predicate: &Predicate| {
+ super::query_auth::leaf_names(std::slice::from_ref(predicate))
+ .iter()
+ .any(|name| masked.contains(name.as_str()))
+ };
let mut scan = self.clone();
- if !rules.filters.is_empty() {
+ scan.data_predicates.retain(|p| !touches_masked(p));
+ if scan.bucket_predicate.as_ref().is_some_and(touches_masked) {
+ scan.bucket_predicate = None;
+ }
+ let dropped = scan.data_predicates.len() != self.data_predicates.len();
+ if !rules.filters.is_empty() || dropped {
scan.limit = scan.limit.filter(|limit| *limit == 0);
}
- // Column-slice pruning must keep the files holding the filter's
columns.
+ // Derive partition pruning only after excluding masked predicates.
+ if scan.partition_filter.is_none()
+ && !scan.data_predicates.is_empty()
+ && !self.table.schema().partition_keys().is_empty()
+ {
+ let (partition, data) =
super::bucket_filter::split_partition_and_data_predicates(
+ Predicate::and(std::mem::take(&mut scan.data_predicates)),
+ fields,
+ self.table.schema().partition_keys(),
+ );
+ scan.partition_filter = partition.map(|predicate| {
+ PartitionFilter::from_predicate(predicate,
&self.table.schema().partition_fields())
+ });
+ scan.data_predicates = data;
+ }
+ // Keep files for every column used by the rules.
if let Some(ids) = scan.projected_read_field_ids.as_mut() {
- let fields = self.table.schema().fields();
+ let mut columns = rules.filter_columns();
+ for mask in &rules.masks {
+ columns.insert(mask.column);
+ columns.extend(super::query_auth::mask_inputs(mask));
+ }
ids.extend(
- rules
- .filter_columns()
+ columns
.into_iter()
.filter_map(|i| fields.get(i))
.map(|f| f.id()),
@@ -1942,6 +1977,35 @@ impl<'a> PaimonTableScan<'a> {
.table
.authorize_read(query_auth, self.query_auth_select())
.await?;
+ if let Some(grant) = &grant {
+ let fields = self.table.schema().fields();
+ let masked: Vec<&str> = grant
+ .rules()
+ .masks
+ .iter()
+ .map(|m| fields[m.column].name())
+ .collect();
+ if self.chunk_shuffle().is_some()
+ && super::query_auth::leaf_names(&self.data_predicates)
+ .iter()
+ .any(|name| masked.contains(&name.as_str()))
+ {
+ return Err(super::query_auth::unsupported(
+ "chunk_shuffle only supports unmasked partition
predicates",
+ ));
+ }
+ // Explicit partition selectors cannot be reapplied after masking.
+ if let Some(key) = self
+ .partition_filter_columns()
+ .into_iter()
+ .find(|key| masked.contains(&key.as_str()))
+ {
+ return Err(super::query_auth::unsupported(&format!(
+ "the partition key '{key}' is masked, so a filter on it
would match the raw \
+ value"
+ )));
+ }
+ }
Ok(grant)
}
diff --git a/crates/paimon/tests/rest_catalog_test.rs
b/crates/paimon/tests/rest_catalog_test.rs
index 36c1a35f..c6882fb5 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -2931,63 +2931,71 @@ async fn test_query_auth_unrestricted_user_can_read() {
);
}
+#[cfg(not(windows))]
+fn blob_payloads() -> (tempfile::TempDir, Vec<u8>, Vec<u8>) {
+ let source = tempfile::tempdir().unwrap();
+ std::fs::write(source.path().join("good.blob"), b"good").unwrap();
+ let descriptor = |file| {
+ paimon::spec::BlobDescriptor::new(
+ format!("file://{}/{file}", source.path().display()),
+ 0,
+ 4,
+ )
+ .serialize()
+ };
+ let (good, missing) = (descriptor("good.blob"),
descriptor("missing.blob"));
+ (source, good, missing)
+}
+
+#[cfg(not(windows))]
+async fn blob_mask_rows(primary_key: bool, payloads: Vec<Option<&[u8]>>) ->
Guarded {
+ let labels = StringArray::from_iter_values((1..=payloads.len()).map(|row|
format!("row{row}")));
+ let batch = RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from_iter_values(1..=payloads.len() as i32))
+ as arrow_array::ArrayRef,
+ ),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from(payloads)) as
arrow_array::ArrayRef,
+ ),
+ ("label", Arc::new(labels) as arrow_array::ArrayRef),
+ ])
+ .unwrap();
+ written(
+ "auth_blob_order",
+ |options| {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::with_nullable(false)))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .column("label",
DataType::VarChar(VarCharType::new(255).unwrap()))
+ .option("blob-descriptor-field", "payload");
+ if primary_key {
+ builder = builder.primary_key(["id"]).option("bucket", "1");
+ } else {
+ builder = builder
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true");
+ }
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+ },
+ vec![batch],
+ )
+ .await
+}
+
/// Authorization must select rows before output or predicate BLOB payloads
open.
#[cfg(not(windows))]
#[tokio::test]
async fn test_query_auth_filters_before_resolving_blob_payloads() {
- let source = tempfile::tempdir().unwrap();
- let good_uri = format!("file://{}/allowed.blob", source.path().display());
- std::fs::write(source.path().join("allowed.blob"), b"good").unwrap();
- let good = paimon::spec::BlobDescriptor::new(good_uri, 0, 4).serialize();
- let missing = paimon::spec::BlobDescriptor::new(
- format!("file://{}/denied.blob", source.path().display()),
- 0,
- 4,
- )
- .serialize();
+ let (_source, good, missing) = blob_payloads();
for primary_key in [true, false] {
- let batch = RecordBatch::try_from_iter([
- (
- "id",
- Arc::new(Int32Array::from(vec![1, 2, 3])) as
arrow_array::ArrayRef,
- ),
- (
- "payload",
- Arc::new(LargeBinaryArray::from(vec![
- None,
- Some(missing.as_slice()),
- Some(good.as_slice()),
- ])) as arrow_array::ArrayRef,
- ),
- ])
- .unwrap();
- let g = written(
- "auth_blob_order",
- |options| {
- let mut builder = Schema::builder()
- .column("id", DataType::Int(IntType::with_nullable(false)))
- .column("payload", DataType::Blob(BlobType::new()))
- .option("blob-descriptor-field", "payload");
- if primary_key {
- builder = builder.primary_key(["id"]).option("bucket",
"1");
- } else {
- builder = builder
- .option("data-evolution.enabled", "true")
- .option("row-tracking.enabled", "true");
- }
- for (key, value) in options {
- builder = builder.option(*key, *value);
- }
- builder.build().unwrap()
- },
- vec![batch],
- )
- .await;
- g.ctx.server.set_auth_response(
- "default",
- "auth_blob_order",
- rules(&[int_leaf(0, "id", "NOT_EQUAL", 2)], &[]),
- );
+ let g = blob_mask_rows(primary_key, vec![None, Some(&missing),
Some(&good)]).await;
+ g.set_rules(&[int_leaf(0, "id", "NOT_EQUAL", 2)], &[]);
let mut id_only = g.table.new_read_builder();
id_only.with_projection(&["id"]).unwrap();
@@ -3040,6 +3048,33 @@ async fn
test_query_auth_filters_before_resolving_blob_payloads() {
}
}
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_refuses_blob_masks() {
+ for primary_key in [true, false] {
+ let g = blob_mask_rows(primary_key, vec![None]).await;
+ for mask in [
+ serde_json::json!({"name": "NULL"}),
+ field_ref_mask(1, "payload", "BLOB"),
+ serde_json::json!({"name": "CAST", "fieldRef": {
+ "index": 1, "name": "payload", "type": "BLOB",
+ }, "type": "BLOB"}),
+ ] {
+ g.set_rules(&[], &[("payload", mask)]);
+ for projection in [vec!["payload"], vec!["id"], vec![]] {
+ let mut read = g.table.new_read_builder();
+ read.with_projection(&projection).unwrap();
+ let error = read.new_scan().plan().await.unwrap_err();
+ assert!(
+ matches!(error, paimon::Error::Unsupported { ref message }
+ if message.contains("BLOB column masks are not supported
yet")),
+ "{error:?}"
+ );
+ }
+ }
+ }
+}
+
fn schema_of(columns: &[&str], options: &[(&str, &str)]) -> Schema {
let mut builder = Schema::builder();
for name in columns {
@@ -3060,6 +3095,16 @@ struct Guarded {
_tmp: tempfile::TempDir,
}
+impl Guarded {
+ fn set_rules(&self, filters: &[serde_json::Value], masks: &[(&str,
serde_json::Value)]) {
+ self.ctx.server.set_auth_response(
+ self.identifier.database(),
+ self.identifier.object(),
+ rules(filters, masks),
+ );
+ }
+}
+
async fn guarded(name: &str, columns: &[&str]) -> Guarded {
let ctx = setup_catalog(vec!["default"]).await;
let tmp = tempfile::tempdir().unwrap();
@@ -3132,6 +3177,20 @@ fn int_leaf(index: usize, column: &str, function: &str,
literal: i32) -> serde_j
})
}
+fn field_ref_mask(index: usize, column: &str, data_type: &str) ->
serde_json::Value {
+ serde_json::json!({"name": "FIELD_REF", "fieldRef": {
+ "index": index, "name": column, "type": data_type,
+ }})
+}
+
+fn string_ref(index: usize, column: &str) -> serde_json::Value {
+ serde_json::json!({"index": index, "name": column, "type": "VARCHAR(255)"})
+}
+
+fn upper(index: usize, column: &str) -> serde_json::Value {
+ serde_json::json!({"name": "UPPER", "inputs": [string_ref(index, column)]})
+}
+
#[tokio::test]
async fn
test_query_auth_rules_this_client_cannot_parse_fail_closed_at_planning() {
let g = guarded("unparseable", &["id"]).await;
@@ -3558,11 +3617,7 @@ async fn
test_query_auth_row_filter_returns_only_the_rows_it_admits() {
vec![people_rows(1..=5), people_rows(6..=10)],
)
.await;
- g.ctx.server.set_auth_response(
- "default",
- "filtered",
- rules(&[int_leaf(0, "id", "GREATER_THAN", 6)], &[]),
- );
+ g.set_rules(&[int_leaf(0, "id", "GREATER_THAN", 6)], &[]);
let rows = id_names(&read_all(&g.table.new_read_builder()).await.unwrap());
let expected: Vec<_> = (7..=10)
@@ -3620,15 +3675,9 @@ async fn
test_query_auth_refuses_an_engine_decoder_filter() {
let mut id_only = g.table.new_read_builder();
id_only.with_projection(&["id"]).unwrap();
// Under a row filter, and under a column permission alone.
- g.ctx.server.set_auth_response(
- "default",
- "decoded",
- rules(&[int_leaf(0, "id", "GREATER_THAN", 6)], &[]),
- );
+ g.set_rules(&[int_leaf(0, "id", "GREATER_THAN", 6)], &[]);
let under_rules = id_only.new_scan().plan().await.unwrap();
- g.ctx
- .server
- .set_auth_response("default", "decoded", rules(&[], &[]));
+ g.set_rules(&[], &[]);
g.ctx
.server
.set_column_auth("default", "decoded", vec!["id".to_string()]);
@@ -3651,6 +3700,660 @@ async fn
test_query_auth_refuses_an_engine_decoder_filter() {
}
}
+#[tokio::test]
+async fn test_query_auth_refuses_a_mask_that_reads_another_masked_column() {
+ let g = guarded("chained", &["id", "a", "b"]).await;
+ let field_ref = |index, column: &str| field_ref_mask(index, column, "INT");
+ let hidden = serde_json::json!({"name": "NULL"});
+
+ g.set_rules(&[], &[("a", hidden.clone()), ("b", field_ref(0, "id"))]);
+ g.table
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .expect("a mask may read an unmasked column");
+
+ g.set_rules(&[], &[("a", hidden), ("b", field_ref(1, "a"))]);
+ assert_refused(plan_err(&g.table, "'b' would publish the raw value of
'a'").await);
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_widens_an_integer_mask() {
+ let schema = |options: &[(&str, &str)]| {
+ let mut builder = Schema::builder()
+ .column("input", DataType::Int(IntType::new()))
+ .column("wide", DataType::BigInt(BigIntType::new()));
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+ };
+ let batch = RecordBatch::try_from_iter(vec![
+ (
+ "input",
+ Arc::new(Int32Array::from(vec![Some(i32::MIN), None,
Some(i32::MAX)]))
+ as Arc<dyn Array>,
+ ),
+ (
+ "wide",
+ Arc::new(Int64Array::from(vec![999; 3])) as Arc<dyn Array>,
+ ),
+ ])
+ .unwrap();
+ let g = written("widened", schema, vec![batch]).await;
+ g.set_rules(
+ &[],
+ &[(
+ "wide",
+ serde_json::json!({
+ "name": "CAST", "fieldRef": {"index": 0, "name": "input",
"type": "INT"},
+ "type": "BIGINT",
+ }),
+ )],
+ );
+ let mut builder = g.table.new_read_builder();
+ builder.with_projection(&["wide"]).unwrap();
+ let batches = read_all(&builder).await.unwrap();
+ assert!(batches.iter().all(|batch| batch.num_columns() == 1));
+ let values: Vec<_> = batches
+ .iter()
+ .flat_map(|batch| column::<Int64Array>(batch, "wide").iter())
+ .collect();
+ assert_eq!(
+ values,
+ vec![Some(i64::from(i32::MIN)), None, Some(i64::from(i32::MAX))]
+ );
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_filters_and_masks_the_same_column() {
+ for (primary_key, evolution, blob) in [
+ (false, false, false),
+ (true, false, false),
+ (false, true, false),
+ (true, false, true),
+ (false, true, true),
+ ] {
+ let schema = |options: &[(&str, &str)]| {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("name",
DataType::VarChar(VarCharType::new(255).unwrap()));
+ if primary_key {
+ builder = builder.primary_key(["id"]).option("bucket", "1");
+ }
+ if evolution {
+ builder = builder
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true");
+ }
+ if blob {
+ builder = builder.column("payload",
DataType::Blob(BlobType::new()));
+ }
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+ };
+ let mut columns = vec![
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![1, 2, 3, 4])) as Arc<dyn Array>,
+ ),
+ (
+ "name",
+ Arc::new(StringArray::from(vec![
+ Some("alice"),
+ Some("ALICE"),
+ Some("alice"),
+ None,
+ ])) as Arc<dyn Array>,
+ ),
+ ];
+ if blob {
+ columns.push((
+ "payload",
+ Arc::new(LargeBinaryArray::from(vec![None::<&[u8]>; 4])),
+ ));
+ }
+ let g = written(
+ "same_column",
+ schema,
+ vec![RecordBatch::try_from_iter(columns).unwrap()],
+ )
+ .await;
+ g.set_rules(
+ &[serde_json::json!({
+ "kind": "LEAF", "function": "EQUAL", "literals": ["alice"],
+ "transform": {"name": "FIELD_REF", "fieldRef": string_ref(1,
"name")},
+ })],
+ &[("name", upper(1, "name"))],
+ );
+ assert_eq!(
+ id_names(&read_all(&g.table.new_read_builder()).await.unwrap()),
+ vec![
+ (1, Some("ALICE".to_string())),
+ (3, Some("ALICE".to_string()))
+ ],
+ );
+ let predicates = PredicateBuilder::new(g.table.schema().fields());
+ for (value, expected) in [("ALICE", vec![3]), ("alice", vec![])] {
+ let mut builder = g.table.new_read_builder();
+ builder.with_filter(Predicate::And(vec![
+ predicates.greater_than("id", Datum::Int(1)).unwrap(),
+ predicates
+ .equal("name", Datum::String(value.to_string()))
+ .unwrap(),
+ ]));
+ builder.with_limit(1);
+ if !blob {
+ builder.with_projection(&["id"]).unwrap();
+ }
+ assert_eq!(ids(&read_all(&builder).await.unwrap()), expected);
+ }
+ }
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_keeps_a_mask_source_in_another_partial_file() {
+ let schema = |options: &[(&str, &str)]| {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("visible",
DataType::VarChar(VarCharType::new(255).unwrap()))
+ .column("source",
DataType::VarChar(VarCharType::new(255).unwrap()))
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true");
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+ };
+ let g = written("partial_mask", schema, vec![]).await;
+ let mut options = Options::new();
+ options.set(
+ CatalogOptions::WAREHOUSE,
+ format!("file://{}", g._tmp.path().display()),
+ );
+ let catalog = FileSystemCatalog::new(options).unwrap();
+ let plain = catalog.get_table(&g.identifier).await.unwrap();
+ let writer = plain.new_write_builder();
+ let mut write = writer.new_write().unwrap();
+ write
+ .with_write_type(vec!["id".into(), "visible".into()])
+ .unwrap();
+ write
+ .write_arrow_batch(
+ &RecordBatch::try_from_iter(vec![
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![1, 2])) as Arc<dyn Array>,
+ ),
+ (
+ "visible",
+ Arc::new(StringArray::from(vec!["raw1", "raw2"])) as
Arc<dyn Array>,
+ ),
+ ])
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ writer
+ .new_commit()
+ .commit(write.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let update = RecordBatch::try_from_iter(vec![
+ (
+ "_ROW_ID",
+ Arc::new(Int64Array::from(vec![0, 1])) as Arc<dyn Array>,
+ ),
+ (
+ "source",
+ Arc::new(StringArray::from(vec!["alice", "bob"])) as Arc<dyn
Array>,
+ ),
+ ])
+ .unwrap();
+ let messages = writer
+ .new_update()
+ .unwrap()
+ .update_by_arrow_with_row_id(vec![update])
+ .await
+ .unwrap();
+ writer.new_commit().commit(messages).await.unwrap();
+
+ let plan = plain.new_read_builder().new_scan().plan().await.unwrap();
+ let files: Vec<_> = plan
+ .splits()
+ .iter()
+ .flat_map(|split| split.data_files())
+ .collect();
+ assert_eq!(files.len(), 2);
+ assert!(files
+ .iter()
+ .any(|file| file.write_cols == Some(vec!["source".to_string()])));
+ let mut projected = plain.new_read_builder();
+ projected.with_projection(&["visible"]).unwrap();
+ let plan = projected.new_scan().plan().await.unwrap();
+ assert_eq!(
+ plan.splits()
+ .iter()
+ .map(|split| split.data_files().len())
+ .sum::<usize>(),
+ 1
+ );
+ g.set_rules(
+ &[],
+ &[("visible", field_ref_mask(2, "source", "VARCHAR(255)"))],
+ );
+ for filtered in [false, true] {
+ let mut builder = g.table.new_read_builder();
+ builder.with_projection(&["visible"]).unwrap();
+ if filtered {
+ builder.with_filter(
+ PredicateBuilder::new(g.table.schema().fields())
+ .equal("visible", Datum::String("bob".to_string()))
+ .unwrap(),
+ );
+ builder.with_limit(1);
+ }
+ let batches = read_all(&builder).await.unwrap();
+ assert!(batches.iter().all(|batch| batch.num_columns() == 1));
+ let values: Vec<_> = batches
+ .iter()
+ .flat_map(|batch| column::<StringArray>(batch, "visible").iter())
+ .collect();
+ assert_eq!(
+ values,
+ if filtered {
+ vec![Some("bob")]
+ } else {
+ vec![Some("alice"), Some("bob")]
+ }
+ );
+ }
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_masks_a_column_and_leaves_the_others_raw() {
+ let g = written(
+ "masked",
+ people,
+ vec![people_batch(&[(1, "alice"), (2, "carol")])],
+ )
+ .await;
+ let name = || string_ref(1, "name");
+ for (mask, expected) in [
+ (serde_json::json!({"name": "NULL"}), [None, None]),
+ (upper(1, "name"), [Some("ALICE"), Some("CAROL")]),
+ // Arrow strings do not enforce VARCHAR width.
+ (
+ serde_json::json!({"name": "CAST", "fieldRef": name(), "type":
"VARCHAR(3)"}),
+ [Some("ali"), Some("car")],
+ ),
+ (
+ serde_json::json!({"name": "CONCAT", "inputs": ["user-", name()]}),
+ [Some("user-alice"), Some("user-carol")],
+ ),
+ ] {
+ g.set_rules(&[], &[("name", mask.clone())]);
+ assert_eq!(
+ id_names(&read_all(&g.table.new_read_builder()).await.unwrap()),
+ vec![
+ (1, expected[0].map(String::from)),
+ (2, expected[1].map(String::from))
+ ],
+ "{mask}"
+ );
+ }
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_filters_a_masked_column_on_its_masked_value() {
+ let g = written(
+ "upper",
+ people,
+ vec![people_batch(&[(1, "alice"), (2, "bob")])],
+ )
+ .await;
+ g.set_rules(&[], &[("name", upper(1, "name"))]);
+ let name_is = |value: &str| {
+ PredicateBuilder::new(g.table.schema().fields())
+ .equal("name", Datum::String(value.to_string()))
+ .unwrap()
+ };
+
+ // Raw bounds exclude 'ALICE'; masked predicates must not prune on them.
+ let mut masked = g.table.new_read_builder();
+ masked.with_filter(name_is("ALICE"));
+ assert_eq!(
+ id_names(&read_all(&masked).await.unwrap()),
+ vec![(1, Some("ALICE".to_string()))]
+ );
+ masked.with_projection(&["id"]).unwrap();
+ assert_eq!(ids(&read_all(&masked).await.unwrap()), vec![1]);
+
+ let mut raw = g.table.new_read_builder();
+ raw.with_filter(name_is("alice"));
+ assert!(read_all(&raw)
+ .await
+ .unwrap()
+ .iter()
+ .all(|b| b.num_rows() == 0));
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_does_not_prune_buckets_on_a_masked_bucket_key() {
+ // Bucket index 0 names `name`, while schema index 0 names `id`.
+ let schema = |options: &[(&str, &str)]| {
+ let mut all = vec![("bucket", "4"), ("bucket-key", "name")];
+ all.extend_from_slice(options);
+ people(&all)
+ };
+ let g = written("bucketed", schema, vec![people_rows(1..=10)]).await;
+ g.set_rules(&[], &[("name", upper(1, "name"))]);
+ for (id, name) in (1..=10).zip(NAMES) {
+ let mut builder = g.table.new_read_builder();
+ builder.with_filter(
+ PredicateBuilder::new(g.table.schema().fields())
+ .equal("name", Datum::String(name.to_uppercase()))
+ .unwrap(),
+ );
+ assert_eq!(
+ id_names(&read_all(&builder).await.unwrap()),
+ vec![(id, Some(name.to_uppercase()))],
+ "the raw value's bucket is not the masked value's"
+ );
+ }
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_chunk_shuffle_with_partition_filters() {
+ for evolution in [false, true] {
+ let schema = |options: &[(&str, &str)]| {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("name",
DataType::VarChar(VarCharType::new(255).unwrap()))
+ .partition_keys(["name"]);
+ if evolution {
+ builder = builder
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true");
+ }
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+ };
+ let g = written(
+ "partition_shuffle",
+ schema,
+ vec![people_batch(&[
+ (1, "eng"),
+ (2, "eng"),
+ (3, "ops"),
+ (4, "eng"),
+ (5, "ops"),
+ (6, "eng"),
+ ])],
+ )
+ .await;
+ let predicates = PredicateBuilder::new(g.table.schema().fields());
+ let partition = predicates
+ .equal("name", Datum::String("eng".into()))
+ .unwrap();
+ for (filters, masks, expected) in [
+ (vec![], vec![], vec![1, 2, 4, 6]),
+ (
+ vec![],
+ vec![("id", field_ref_mask(0, "id", "INT"))],
+ vec![1, 2, 4, 6],
+ ),
+ (
+ vec![int_leaf(0, "id", "GREATER_THAN", 1)],
+ vec![],
+ vec![2, 4, 6],
+ ),
+ ] {
+ g.set_rules(&filters, &masks);
+ let mut read = g.table.new_read_builder();
+ read.with_projection(&["id"])
+ .unwrap()
+ .with_filter(partition.clone());
+ let scan = read
+ .new_scan()
+ .with_chunk_shuffle(7, 2)
+ .expect("partition-only filters must allow chunk shuffle");
+ for traced in [false, true] {
+ let plan = if traced {
+ scan.plan_with_trace().await.unwrap().0
+ } else {
+ scan.plan().await.unwrap()
+ };
+ assert_eq!(plan.splits().len(), 2);
+ assert!(plan
+ .splits()
+ .iter()
+ .all(|split| split.partition().get_string(0).unwrap() ==
"eng"));
+ let batches: Vec<RecordBatch> = read
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ ids(&batches),
+ expected,
+ "evolution={evolution}, traced={traced}"
+ );
+ }
+ }
+
+ let data = predicates.greater_than("id", Datum::Int(2)).unwrap();
+ for filter in [
+ data.clone(),
+ Predicate::and(vec![partition.clone(), data.clone()]),
+ Predicate::or(vec![partition.clone(), data]),
+ ] {
+ let mut read = g.table.new_read_builder();
+ read.with_filter(filter);
+ let error = read.new_scan().with_chunk_shuffle(7, 2).unwrap_err();
+ assert!(
+ matches!(error, paimon::Error::Unsupported { ref message }
+ if message.contains("only supports partition predicates")),
+ "{error:?}"
+ );
+ }
+
+ g.set_rules(&[], &[("name", upper(1, "name"))]);
+ let mut read = g.table.new_read_builder();
+ read.with_filter(
+ predicates
+ .equal("name", Datum::String("ENG".into()))
+ .unwrap(),
+ );
+ let scan = read.new_scan().with_chunk_shuffle(7, 2).unwrap();
+ for traced in [false, true] {
+ let result = if traced {
+ scan.plan_with_trace().await.map(|(plan, _)| plan)
+ } else {
+ scan.plan().await
+ };
+ let error = result.unwrap_err();
+ assert!(
+ matches!(error, paimon::Error::Unsupported { ref message }
+ if message.contains("unmasked partition predicates")),
+ "{error:?}"
+ );
+ }
+ }
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_filters_masked_partition_values() {
+ for data_evolution in [false, true] {
+ let schema = |options: &[(&str, &str)]| {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("dept",
DataType::VarChar(VarCharType::new(255).unwrap()))
+ .column("src",
DataType::VarChar(VarCharType::new(255).unwrap()))
+ .column("region",
DataType::VarChar(VarCharType::new(255).unwrap()))
+ .partition_keys(["dept", "region"]);
+ if data_evolution {
+ builder = builder
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true");
+ }
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build().unwrap()
+ };
+ let batch = RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![1, 2, 3, 4])) as
arrow_array::ArrayRef,
+ ),
+ (
+ "dept",
+ Arc::new(StringArray::from(vec![
+ Some("eng"),
+ Some("ops"),
+ Some("eng"),
+ None,
+ ])) as arrow_array::ArrayRef,
+ ),
+ (
+ "src",
+ Arc::new(StringArray::from(vec![
+ Some("OPS"),
+ Some("ENG"),
+ Some("HR"),
+ None,
+ ])) as arrow_array::ArrayRef,
+ ),
+ (
+ "region",
+ Arc::new(StringArray::from(vec!["east", "east", "west",
"east"]))
+ as arrow_array::ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let g = written("by_dept", schema, vec![batch]).await;
+ let predicates = PredicateBuilder::new(g.table.schema().fields());
+ let equals = |column: &str, value: &str| {
+ predicates
+ .equal(column, Datum::String(value.into()))
+ .unwrap()
+ };
+ for (mask, expected, nulls) in [
+ (upper(1, "dept"), vec![1, 3], vec![4]),
+ (field_ref_mask(2, "src", "VARCHAR(255)"), vec![2], vec![4]),
+ (
+ serde_json::json!({"name": "NULL"}),
+ vec![],
+ vec![1, 2, 3, 4],
+ ),
+ ] {
+ g.set_rules(&[], &[("dept", mask)]);
+ for projection in [vec!["id", "dept"], vec!["id"]] {
+ let mut read = g.table.new_read_builder();
+ read.with_projection(&projection)
+ .unwrap()
+ .with_filter(equals("dept", "ENG"));
+ let batches = read_all(&read).await.unwrap();
+ assert_eq!(ids(&batches), expected);
+ if projection.len() == 2 {
+ for batch in &batches {
+ assert!(column::<StringArray>(batch, "dept")
+ .iter()
+ .all(|value| value == Some("ENG")));
+ }
+ }
+ }
+ let mut disjunction = expected.clone();
+ disjunction.push(3);
+ disjunction.sort_unstable();
+ disjunction.dedup();
+ for (predicate, expected) in [
+ (equals("dept", "eng"), vec![]),
+ (predicates.is_null("dept").unwrap(), nulls),
+ (
+ paimon::spec::Predicate::or(vec![
+ equals("dept", "ENG"),
+ equals("region", "west"),
+ ]),
+ disjunction,
+ ),
+ ] {
+ let mut read = g.table.new_read_builder();
+ read.with_projection(&["id"])
+ .unwrap()
+ .with_filter(predicate);
+ assert_eq!(ids(&read_all(&read).await.unwrap()), expected);
+ }
+
+ let mut limited = g.table.new_read_builder();
+ limited
+ .with_projection(&["id"])
+ .unwrap()
+ .with_filter(paimon::spec::Predicate::and(vec![
+ equals("dept", "ENG"),
+ equals("region", "east"),
+ ]))
+ .with_limit(1);
+ let (plan, trace) =
limited.new_scan().plan_with_trace().await.unwrap();
+ assert_eq!(trace.limit, None);
+ assert!(plan
+ .splits()
+ .iter()
+ .all(|split| split.partition().get_string(1).unwrap() ==
"east"));
+ let rows: Vec<RecordBatch> = limited
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let expected: Vec<_> = expected.into_iter().filter(|id| *id !=
3).take(1).collect();
+ assert_eq!(ids(&rows), expected);
+ limited.with_limit(0);
+ assert!(ids(&read_all(&limited).await.unwrap()).is_empty());
+ }
+
+ // Unrestricted users still get raw partition pruning and its limit.
+ g.set_rules(&[], &[]);
+ let mut plain = g.table.new_read_builder();
+ plain
+ .with_projection(&["id"])
+ .unwrap()
+ .with_filter(equals("dept", "eng"));
+ assert_eq!(ids(&read_all(&plain).await.unwrap()), vec![1, 3]);
+ plain.with_limit(1);
+ let (plan, trace) = plain.new_scan().plan_with_trace().await.unwrap();
+ assert_eq!(trace.limit, Some(1));
+ assert!(!plan.splits().is_empty());
+ let rows = ids(&read_all(&plain).await.unwrap());
+ assert_eq!(rows.len(), 1);
+ assert!([1, 3].contains(&rows[0]));
+
+ g.ctx
+ .server
+ .set_column_auth("default", "by_dept", vec!["id".to_string()]);
+ assert_forbidden(read_all(&plain).await.unwrap_err());
+ }
+}
+
#[cfg(not(windows))]
#[tokio::test]
async fn
test_query_auth_asks_about_the_projection_and_the_columns_filters_read() {
@@ -3712,15 +4415,9 @@ async fn
test_query_auth_refuses_a_read_wider_than_its_plan() {
let wider = [g.table.new_read_builder(), filtered];
// Scoped once under a row filter, once by a column permission alone.
- g.ctx.server.set_auth_response(
- "default",
- "narrow",
- rules(&[int_leaf(0, "id", "GREATER_THAN", 6)], &[]),
- );
+ g.set_rules(&[int_leaf(0, "id", "GREATER_THAN", 6)], &[]);
let under_rules = planned.new_scan().plan().await.unwrap();
- g.ctx
- .server
- .set_auth_response("default", "narrow", rules(&[], &[]));
+ g.set_rules(&[], &[]);
g.ctx
.server
.set_column_auth("default", "narrow", vec!["id".to_string()]);
@@ -3904,11 +4601,7 @@ fn score_rows(batches: &[RecordBatch]) -> Vec<(i32,
String, i32)> {
#[tokio::test]
async fn test_query_auth_filters_the_merged_primary_key_row() {
let g = scores().await;
- g.ctx.server.set_auth_response(
- "default",
- "scores",
- rules(&[int_leaf(2, "score", "GREATER_THAN", 30)], &[]),
- );
+ g.set_rules(&[int_leaf(2, "score", "GREATER_THAN", 30)], &[]);
// Filtering each commit before the merge would bring back bob's first row.
assert_eq!(
score_rows(&read_all(&g.table.new_read_builder()).await.unwrap()),
@@ -3916,6 +4609,20 @@ async fn
test_query_auth_filters_the_merged_primary_key_row() {
);
}
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_masks_the_merged_primary_key_row() {
+ let g = scores().await;
+ g.set_rules(
+ &[int_leaf(2, "score", "GREATER_THAN", 30)],
+ &[("name", upper(1, "name"))],
+ );
+ assert_eq!(
+ score_rows(&read_all(&g.table.new_read_builder()).await.unwrap()),
+ vec![(1, "ALICIA".to_string(), 80), (3, "CAROL".to_string(), 90)]
+ );
+}
+
#[tokio::test]
async fn test_rest_catalog_manages_permissions_end_to_end() {
let ctx = setup_catalog(vec!["default"]).await;
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 5bcd622f..b9a2e64f 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1646,13 +1646,14 @@ nullness can differ from its upstream value. Other BLOB
payloads and output-only
BLOB views are resolved after row authorization.
When a table in a REST catalog has `'query-auth.enabled' = 'true'`, planning a
-query asks the catalog what the current user may read, and the row filters it
-returns (see `create_policy`) are applied to the result:
-
-- Rows are filtered on their stored values.
-- A user the catalog also masks columns for is refused.
-- Statistics are not used under a row filter, which also turns off `LIMIT`
- pushdown, so such queries read the data.
+query asks the catalog what the current user may read, and the row filters and
+column masks it returns (see `create_policy`) are applied to the result:
+
+- Rows are filtered on their stored values; masked columns return masked
values.
+- BLOB column masks are not yet supported.
+- Query filters on masked columns, including partition keys, match masked
values.
+- Statistics are not used under a row filter or a mask, and a row filter turns
+ off `LIMIT` pushdown, so such queries read the data.
- Time travel, branches, system tables, incremental and audit-log reads, and
search are refused on such tables.