Skip to content

Commit e6339e8

Browse files
committed
filter iceberg_type across all SqlCatalog table operations
1 parent 154288f commit e6339e8

2 files changed

Lines changed: 88 additions & 14 deletions

File tree

pyiceberg/catalog/sql.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
)
2525

2626
from sqlalchemy import (
27+
ColumnElement,
2728
String,
2829
create_engine,
2930
delete,
@@ -84,6 +85,7 @@
8485
DEFAULT_ECHO_VALUE = "false"
8586
DEFAULT_POOL_PRE_PING_VALUE = "false"
8687
DEFAULT_INIT_CATALOG_TABLES = "true"
88+
ICEBERG_TABLE_TYPE = "TABLE"
8789

8890

8991
class SqlCatalogBaseTable(MappedAsDataclass, DeclarativeBase):
@@ -209,9 +211,16 @@ def _create_table_row(self, namespace: str, table_name: str, metadata_location:
209211
"previous_metadata_location": None,
210212
}
211213
if self._schema_version == "v1":
212-
row["iceberg_type"] = "TABLE"
214+
row["iceberg_type"] = ICEBERG_TABLE_TYPE
213215
return row
214216

217+
def _iceberg_type_filter(self) -> ColumnElement[bool] | None:
218+
# Excludes non-table rows (e.g. views written by iceberg-java or iceberg-rust) from table
219+
# lookups. None on v0 schemas, where the iceberg_type column doesn't exist to filter on.
220+
if self._schema_version != "v1":
221+
return None
222+
return (IcebergTables.iceberg_type == ICEBERG_TABLE_TYPE) | (IcebergTables.iceberg_type.is_(None))
223+
215224
def _convert_orm_to_iceberg(self, orm_table: IcebergTables) -> Table:
216225
# Check for expected properties.
217226
if not (metadata_location := orm_table.metadata_location):
@@ -349,6 +358,8 @@ def load_table(self, identifier: str | Identifier) -> Table:
349358
IcebergTables.table_namespace == namespace,
350359
IcebergTables.table_name == table_name,
351360
)
361+
if (type_filter := self._iceberg_type_filter()) is not None:
362+
stmt = stmt.where(type_filter)
352363
result = session.scalar(stmt)
353364
if result:
354365
return self._convert_orm_to_iceberg(result)
@@ -367,29 +378,33 @@ def drop_table(self, identifier: str | Identifier) -> None:
367378
namespace_tuple = Catalog.namespace_from(identifier)
368379
namespace = Catalog.namespace_to_string(namespace_tuple)
369380
table_name = Catalog.table_name_from(identifier)
381+
type_filter = self._iceberg_type_filter()
370382
with Session(self.engine) as session:
371383
if self.engine.dialect.supports_sane_rowcount:
372-
res = session.execute(
373-
delete(IcebergTables).where(
374-
IcebergTables.catalog_name == self.name,
375-
IcebergTables.table_namespace == namespace,
376-
IcebergTables.table_name == table_name,
377-
)
384+
stmt = delete(IcebergTables).where(
385+
IcebergTables.catalog_name == self.name,
386+
IcebergTables.table_namespace == namespace,
387+
IcebergTables.table_name == table_name,
378388
)
389+
if type_filter is not None:
390+
stmt = stmt.where(type_filter)
391+
res = session.execute(stmt)
379392
if res.rowcount < 1:
380393
raise NoSuchTableError(f"Table does not exist: {namespace}.{table_name}")
381394
else:
382395
try:
383-
tbl = (
396+
query = (
384397
session.query(IcebergTables)
385398
.with_for_update(of=IcebergTables)
386399
.filter(
387400
IcebergTables.catalog_name == self.name,
388401
IcebergTables.table_namespace == namespace,
389402
IcebergTables.table_name == table_name,
390403
)
391-
.one()
392404
)
405+
if type_filter is not None:
406+
query = query.filter(type_filter)
407+
tbl = query.one()
393408
session.delete(tbl)
394409
except NoResultFound as e:
395410
raise NoSuchTableError(f"Table does not exist: {namespace}.{table_name}") from e
@@ -419,6 +434,7 @@ def rename_table(self, from_identifier: str | Identifier, to_identifier: str | I
419434
to_table_name = Catalog.table_name_from(to_identifier)
420435
if not self.namespace_exists(to_namespace):
421436
raise NoSuchNamespaceError(f"Namespace does not exist: {to_namespace}")
437+
type_filter = self._iceberg_type_filter()
422438
with Session(self.engine) as session:
423439
try:
424440
if self.engine.dialect.supports_sane_rowcount:
@@ -431,21 +447,25 @@ def rename_table(self, from_identifier: str | Identifier, to_identifier: str | I
431447
)
432448
.values(table_namespace=to_namespace, table_name=to_table_name)
433449
)
450+
if type_filter is not None:
451+
stmt = stmt.where(type_filter)
434452
result = session.execute(stmt)
435453
if result.rowcount < 1:
436454
raise NoSuchTableError(f"Table does not exist: {from_table_name}")
437455
else:
438456
try:
439-
tbl = (
457+
query = (
440458
session.query(IcebergTables)
441459
.with_for_update(of=IcebergTables)
442460
.filter(
443461
IcebergTables.catalog_name == self.name,
444462
IcebergTables.table_namespace == from_namespace,
445463
IcebergTables.table_name == from_table_name,
446464
)
447-
.one()
448465
)
466+
if type_filter is not None:
467+
query = query.filter(type_filter)
468+
tbl = query.one()
449469
tbl.table_namespace = to_namespace
450470
tbl.table_name = to_table_name
451471
except NoResultFound as e:
@@ -656,9 +676,8 @@ def list_tables(self, namespace: str | Identifier) -> list[Identifier]:
656676
IcebergTables.table_namespace == namespace,
657677
)
658678

659-
# Filter out views only if schema_version is v1
660-
if self._schema_version == "v1":
661-
stmt = stmt.where((IcebergTables.iceberg_type == "TABLE") | (IcebergTables.iceberg_type.is_(None)))
679+
if (type_filter := self._iceberg_type_filter()) is not None:
680+
stmt = stmt.where(type_filter)
662681

663682
with Session(self.engine) as session:
664683
result = session.scalars(stmt)

tests/catalog/test_sql.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
)
3434
from pyiceberg.exceptions import (
3535
NoSuchPropertyException,
36+
NoSuchTableError,
3637
TableAlreadyExistsError,
3738
)
3839
from pyiceberg.schema import Schema
@@ -333,6 +334,60 @@ def test_list_tables_filters_by_iceberg_type(warehouse: Path) -> None:
333334
assert "some_view" not in tables
334335

335336

337+
def _insert_view_row(catalog: SqlCatalog, namespace: str, table_name: str) -> None:
338+
# Simulates a view row written by iceberg-java or iceberg-rust, which SqlCatalog table
339+
# operations must not treat as a table (issue #3337).
340+
with catalog.engine.connect() as conn:
341+
conn.execute(
342+
text(
343+
"INSERT INTO iceberg_tables "
344+
"(catalog_name, table_namespace, table_name, metadata_location, previous_metadata_location, iceberg_type) "
345+
"VALUES ('test', :namespace, :table_name, 's3://fake/metadata.json', NULL, 'VIEW')"
346+
),
347+
{"namespace": namespace, "table_name": table_name},
348+
)
349+
conn.commit()
350+
351+
352+
def test_load_table_ignores_view_rows(warehouse: Path) -> None:
353+
catalog = SqlCatalog(name="test", uri="sqlite:///:memory:", warehouse=f"file://{warehouse}")
354+
catalog.create_namespace("ns")
355+
_insert_view_row(catalog, "ns", "a_view")
356+
357+
with pytest.raises(NoSuchTableError):
358+
catalog.load_table(("ns", "a_view"))
359+
360+
361+
def test_drop_table_ignores_view_rows(warehouse: Path) -> None:
362+
catalog = SqlCatalog(name="test", uri="sqlite:///:memory:", warehouse=f"file://{warehouse}")
363+
catalog.create_namespace("ns")
364+
_insert_view_row(catalog, "ns", "a_view")
365+
366+
with pytest.raises(NoSuchTableError):
367+
catalog.drop_table(("ns", "a_view"))
368+
369+
# The view row must not have been deleted.
370+
with catalog.engine.connect() as conn:
371+
row = conn.execute(text("SELECT iceberg_type FROM iceberg_tables WHERE table_name = 'a_view'")).fetchone()
372+
assert row is not None
373+
assert row[0] == "VIEW"
374+
375+
376+
def test_rename_table_ignores_view_rows(warehouse: Path) -> None:
377+
catalog = SqlCatalog(name="test", uri="sqlite:///:memory:", warehouse=f"file://{warehouse}")
378+
catalog.create_namespace("ns")
379+
_insert_view_row(catalog, "ns", "a_view")
380+
381+
with pytest.raises(NoSuchTableError):
382+
catalog.rename_table(("ns", "a_view"), ("ns", "renamed_view"))
383+
384+
# The view row must not have been renamed.
385+
with catalog.engine.connect() as conn:
386+
row = conn.execute(text("SELECT iceberg_type FROM iceberg_tables WHERE table_name = 'a_view'")).fetchone()
387+
assert row is not None
388+
assert row[0] == "VIEW"
389+
390+
336391
def test_migration_to_v1_with_property_set(warehouse: Path) -> None:
337392
uri = f"sqlite:////{warehouse}/test-v1-migrate"
338393
engine = _create_v0_db(uri)

0 commit comments

Comments
 (0)