blob: 31e86996208262e90b01747c0f09cb34daa9de96 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Existing calendar authorization masks through Native and Python readers."""
import json
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import Schema
from pypaimon.api.api_response import AuthTableQueryResponse
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.read.split_serializer import deserialize_split_v1, serialize_split_v1
from pypaimon.schema.schema_change import SchemaChange
from pypaimon.tests import native_query_auth_test
rest_catalog = native_query_auth_test.rest_catalog
_read = native_query_auth_test._read
pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
not native_runtime_available(), reason='Rust reader required')]
_PARTS = ['YEAR', 'MONTH', 'DAY', 'HOUR', 'MINUTE', 'SECOND', 'QUARTER', 'DAY_OF_YEAR',
'WEEKDAY', 'ISO_DAY_OF_WEEK', 'DAY_OF_WEEK', 'WEEK', 'YEAR_OF_WEEK']
@pytest.fixture(params=[
('append', {}), ('primary-key', {'bucket': '1'}),
('data-evolution', {'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'}),
], ids=lambda mode: mode[0])
def calendar_table(rest_catalog, request):
catalog, server = rest_catalog
mode, options = request.param
schema = pa.schema([('id', pa.int32()), ('n', pa.int32()), ('d', pa.date32()),
('ts', pa.timestamp('ms')), ('us', pa.timestamp('us')), ('ns', pa.timestamp('ns'))])
catalog.create_table('default.calendar', Schema.from_pyarrow_schema(
schema, primary_keys=['id'] if mode == 'primary-key' else None, options=options), False)
table = catalog.get_table('default.calendar')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
# Leap day, ISO year boundary, pre-epoch fractional second, NULL.
days = [19782, 20087, -1, None]
millis = [1709251199123, 1735516800000, -1, None]
writer.write_arrow(pa.table({
'id': [1, 2, 3, 4], 'n': [0] * 4, 'd': days, 'ts': millis,
'us': [value * 1000 + 456 if value is not None else None for value in millis],
'ns': [value * 1000000 + 456789 if value is not None else None for value in millis],
}, schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
catalog.alter_table(table.identifier, [SchemaChange.set_option('query-auth.enabled', 'true')])
return catalog.get_table('default.calendar'), server
def _rule(part, field, type_):
return json.dumps({'name': part, 'fieldRef': {'name': field, 'index': 99, 'type': type_}})
@pytest.mark.parametrize('field,type_', [('d', 'DATE'), ('ts', 'TIMESTAMP(3)'),
('us', 'TIMESTAMP(6)'), ('ns', 'TIMESTAMP(9)')])
def test_all_calendar_masks_read_hidden_operands_natively(calendar_table, field, type_):
table, server = calendar_table
# Expected Java values for DATE midnight and TIMESTAMP's wall clock.
expected = [
[2024, 2024, 1969, None], [2, 12, 12, None], [29, 30, 31, None],
[0, 0, 0, None], [0, 0, 0, None], [0, 0, 0, None], [1, 4, 4, None],
[60, 365, 365, None], [3, 0, 2, None], [4, 1, 3, None], [5, 2, 4, None],
[9, 1, 1, None], [2024, 2025, 1970, None],
]
if field != 'd':
expected[3:6] = [[23, 0, 23, None], [59, 0, 59, None], [59, 0, 59, None]]
for part, values in zip(_PARTS, expected):
server.table_query_auth_store[table.identifier.get_full_name()] = AuthTableQueryResponse(
column_masking={'n': _rule(part, field, type_)})
for native in [False, True]:
result = _read(table, native, ['id', 'n']).sort_by([('id', 'ascending')])
assert result.column('n').type == pa.int32()
assert result.column('n').to_pylist() == values, (part, field, native)
@pytest.mark.parametrize('mode', ['wire', 'setup-failure', 'disable-native'])
def test_calendar_fallback_keeps_hidden_dependency_and_captured_rule(calendar_table, mode):
table, server = calendar_table
server.table_query_auth_store[table.identifier.get_full_name()] = AuthTableQueryResponse(
column_masking={'n': _rule('YEAR_OF_WEEK', 'd', 'DATE')})
table = table.copy({'read.native.enabled': 'true', 'scan.native-plan.enabled': 'true'})
builder = (table.new_read_builder().with_projection(['id']).with_limit(1)
.with_filter(PredicateBuilder(table.fields).equal('n', 2025)))
scan = builder.new_scan()
with patch(
'pypaimon.execution.python.read.scanner.file_scanner.FileScanner.scan',
side_effect=AssertionError('Native plan fell back')):
splits = scan.plan().splits()
server.table_query_auth_store[table.identifier.get_full_name()] = AuthTableQueryResponse()
if mode == 'wire':
splits = [deserialize_split_v1(serialize_split_v1(split), []) for split in splits]
if mode == 'disable-native':
builder = (table.copy({'read.native.enabled': 'false'}).new_read_builder().with_projection(['id'])
.with_filter(PredicateBuilder(table.fields).equal('n', 2025)).with_limit(1))
if mode == 'setup-failure':
with patch('pypaimon.execution.native.read.table_read.native_read', side_effect=RuntimeError('setup failed')):
rows = builder.new_read().to_arrow(splits).to_pylist()
else:
rows = builder.new_read().to_arrow(splits).to_pylist()
assert rows == [{'id': 2}]
@pytest.mark.parametrize('native', [False, True])
def test_calendar_mask_cannot_expose_another_masked_operand(calendar_table, native):
table, server = calendar_table
server.table_query_auth_store[table.identifier.get_full_name()] = AuthTableQueryResponse(
column_masking={'n': _rule('YEAR', 'd', 'DATE'), 'd': json.dumps({'name': 'NULL'})})
table = table.copy({'scan.native-plan.enabled': str(native).lower()})
with pytest.raises(ValueError, match="masked too"):
table.new_read_builder().with_projection(['n']).new_scan().plan()
@pytest.mark.parametrize('native', [False, True])
def test_stale_calendar_operand_fails_closed(calendar_table, native):
table, server = calendar_table
server.table_query_auth_store[table.identifier.get_full_name()] = AuthTableQueryResponse(
column_masking={'n': _rule('YEAR', 'missing', 'DATE')})
table = table.copy({'scan.native-plan.enabled': str(native).lower()})
with pytest.raises(ValueError, match="missing"):
table.new_read_builder().with_projection(['n']).new_scan().plan()
@pytest.mark.parametrize('field,type_', [('d', 'TIMESTAMP(6)'), ('ts', 'DATE')])
def test_calendar_mask_rebinds_stored_temporal_family(calendar_table, field, type_):
table, server = calendar_table
# Java validates the stored temporal family, then copies the FieldRef from
# the current schema. The actual column decides the executed calendar value.
server.table_query_auth_store[table.identifier.get_full_name()] = AuthTableQueryResponse(
column_masking={'n': _rule('YEAR_OF_WEEK', field, type_)})
for native in [False, True]:
result = _read(table, native, ['n', 'id']).sort_by([('id', 'ascending')])
assert result.column('n').to_pylist() == [2024, 2025, 1970, None]