mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-27 16:38:31 +00:00
6ed3074d4c
A permutation stores `_rowid`s, which are row addresses unless stable row ids are enabled. Nothing in the data loader pinned a table version, so a compaction between building a permutation and reading it can resolve those ids to different rows. The exposure differs by backend but exists on both: - Remote never pins. `prepare_query_bodies` stamps `"version": current_version()` on every request, but `current_version()` is `None` unless `checkout` was called, so every request means "latest". - Native pins implicitly by holding an `Arc<Dataset>` under `ConsistencyMode::Lazy`, but `StreamingDataset.__setstate__` reopens the table in each DataLoader worker, so each worker pins to whatever is latest at fork time. ## Changes `Table::at_version` returns an independent handle pinned to a version without mutating the receiver. `checkout` cannot serve this: on remote the version cell is an `Arc<RwLock<Option<u64>>>` shared across clones, so pinning through it would silently pin the caller's table too. `PermutationBuilder::build` pins for the whole build and records the version in the permutation table's schema metadata, alongside the existing split names. `PermutationReader` pins the base table to that version before any take. Because the reader pins on construction, the Python worker fork is covered without touching the pickle format — `Permutation.__setstate__` drops the reader and `_ensure_open` rebuilds it, which re-pins. ## Behaviour change A permutation is now bound to the version it was built against, so rows appended to the base table afterwards are not visible through an existing permutation. That is the intended semantics — the permutation only addresses rows that existed when it was built — but it is a change worth flagging. Permutations written before this carry no version key and read exactly as they did before.
1301 lines
44 KiB
Python
1301 lines
44 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
import pyarrow as pa
|
|
import math
|
|
import pytest
|
|
|
|
from lancedb import DBConnection, Table, connect
|
|
from lancedb.background_loop import LOOP
|
|
from lancedb.permutation import Permutation, Permutations, permutation_builder
|
|
from utils import (
|
|
MockPermutationServer,
|
|
assert_server_safe_row_id_requests,
|
|
mock_remote_table,
|
|
)
|
|
|
|
|
|
def test_split_random_ratios(mem_db):
|
|
"""Test random splitting with ratios."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"x": range(100), "y": range(100)})
|
|
)
|
|
permutation_tbl = permutation_builder(tbl).split_random(ratios=[0.3, 0.7]).execute()
|
|
|
|
# Check that the table was created and has data
|
|
assert permutation_tbl.count_rows() == 100
|
|
|
|
# Check that split_id column exists and has correct values
|
|
data = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
split_ids = data["split_id"]
|
|
assert set(split_ids) == {0, 1}
|
|
|
|
# Check approximate split sizes (allowing for rounding)
|
|
split_0_count = split_ids.count(0)
|
|
split_1_count = split_ids.count(1)
|
|
assert 25 <= split_0_count <= 35 # ~30% ± tolerance
|
|
assert 65 <= split_1_count <= 75 # ~70% ± tolerance
|
|
|
|
|
|
def test_execute_does_not_reenter_background_loop(tmp_path, monkeypatch):
|
|
import threading
|
|
|
|
db = connect(tmp_path)
|
|
tbl = db.create_table("test_table", pa.table({"x": range(10)}))
|
|
original_run = LOOP.run
|
|
|
|
def fail_on_reentry(future):
|
|
assert threading.current_thread() is not LOOP.thread
|
|
return original_run(future)
|
|
|
|
monkeypatch.setattr(LOOP, "run", fail_on_reentry)
|
|
|
|
permutation_tbl = permutation_builder(tbl).execute()
|
|
|
|
assert permutation_tbl.count_rows() == 10
|
|
assert permutation_tbl._conn.read_consistency_interval is None
|
|
|
|
|
|
def test_pickled_permutation_reads_pinned_version(tmp_path):
|
|
"""An unpickled copy must still read the pinned version, which also covers the
|
|
version surviving the ``to_arrow()`` round trip in ``__getstate__``."""
|
|
import pickle
|
|
|
|
db = connect(tmp_path)
|
|
tbl = db.create_table("base", pa.table({"idx": range(20)}))
|
|
permutation_tbl = permutation_builder(tbl).execute()
|
|
perm = Permutation.from_tables(tbl, permutation_tbl)
|
|
|
|
payload = pickle.dumps(perm)
|
|
|
|
# Compact so the stored row addresses no longer describe these rows at latest.
|
|
tbl.delete("true")
|
|
tbl.optimize()
|
|
assert tbl.count_rows() == 0
|
|
|
|
# Unpickle after the mutation: __setstate__ reopens at latest, so this only
|
|
# passes if the recorded version is applied on reopen.
|
|
restored = pickle.loads(payload)
|
|
assert len(restored) == 20
|
|
rows = restored.__getitems__(list(range(20)))
|
|
assert sorted(row["idx"] for row in rows) == list(range(20))
|
|
|
|
|
|
def test_split_random_counts(mem_db):
|
|
"""Test random splitting with absolute counts."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"x": range(100), "y": range(100)})
|
|
)
|
|
permutation_tbl = permutation_builder(tbl).split_random(counts=[20, 30]).execute()
|
|
|
|
# Check that we have exactly the requested counts
|
|
assert permutation_tbl.count_rows() == 50
|
|
|
|
data = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
split_ids = data["split_id"]
|
|
assert split_ids.count(0) == 20
|
|
assert split_ids.count(1) == 30
|
|
|
|
|
|
def test_split_random_fixed(mem_db):
|
|
"""Test random splitting with fixed number of splits."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"x": range(100), "y": range(100)})
|
|
)
|
|
permutation_tbl = permutation_builder(tbl).split_random(fixed=4).execute()
|
|
|
|
# Check that we have 4 splits with 25 rows each
|
|
assert permutation_tbl.count_rows() == 100
|
|
|
|
data = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
split_ids = data["split_id"]
|
|
assert set(split_ids) == {0, 1, 2, 3}
|
|
|
|
for split_id in range(4):
|
|
assert split_ids.count(split_id) == 25
|
|
|
|
|
|
def test_split_random_with_seed(mem_db):
|
|
"""Test that seeded random splits are reproducible."""
|
|
tbl = mem_db.create_table("test_table", pa.table({"x": range(50), "y": range(50)}))
|
|
|
|
# Create two identical permutations with same seed
|
|
perm1 = permutation_builder(tbl).split_random(ratios=[0.6, 0.4], seed=42).execute()
|
|
|
|
perm2 = permutation_builder(tbl).split_random(ratios=[0.6, 0.4], seed=42).execute()
|
|
|
|
# Results should be identical
|
|
data1 = perm1.search(None).to_arrow().to_pydict()
|
|
data2 = perm2.search(None).to_arrow().to_pydict()
|
|
|
|
assert data1["row_id"] == data2["row_id"]
|
|
assert data1["split_id"] == data2["split_id"]
|
|
|
|
|
|
def test_split_hash(mem_db):
|
|
"""Test hash-based splitting."""
|
|
tbl = mem_db.create_table(
|
|
"test_table",
|
|
pa.table(
|
|
{
|
|
"id": range(100),
|
|
"category": (["A", "B", "C"] * 34)[:100], # Repeating pattern
|
|
"value": range(100),
|
|
}
|
|
),
|
|
)
|
|
|
|
permutation_tbl = (
|
|
permutation_builder(tbl)
|
|
.split_hash(["category"], [1, 1], discard_weight=0)
|
|
.execute()
|
|
)
|
|
|
|
# Should have all 100 rows (no discard)
|
|
assert permutation_tbl.count_rows() == 100
|
|
|
|
data = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
split_ids = data["split_id"]
|
|
assert set(split_ids) == {0, 1}
|
|
|
|
# Verify that each split has roughly 50 rows (allowing for hash variance)
|
|
split_0_count = split_ids.count(0)
|
|
split_1_count = split_ids.count(1)
|
|
assert 30 <= split_0_count <= 70 # ~50 ± 20 tolerance for hash distribution
|
|
assert 30 <= split_1_count <= 70 # ~50 ± 20 tolerance for hash distribution
|
|
|
|
# Hash splits should be deterministic - same category should go to same split
|
|
# Let's verify by creating another permutation and checking consistency
|
|
perm2 = (
|
|
permutation_builder(tbl)
|
|
.split_hash(["category"], [1, 1], discard_weight=0)
|
|
.execute()
|
|
)
|
|
|
|
data2 = perm2.search(None).to_arrow().to_pydict()
|
|
assert data["split_id"] == data2["split_id"] # Should be identical
|
|
|
|
|
|
def test_split_hash_with_discard(mem_db):
|
|
"""Test hash-based splitting with discard weight."""
|
|
tbl = mem_db.create_table(
|
|
"test_table",
|
|
pa.table({"id": range(100), "category": ["A", "B"] * 50, "value": range(100)}),
|
|
)
|
|
|
|
permutation_tbl = (
|
|
# Hash a high-cardinality column: "category" has only two distinct
|
|
# values, so whether anything is discarded would hinge on where those
|
|
# two hashes land rather than on the discard weight.
|
|
permutation_builder(tbl)
|
|
.split_hash(["id"], [1, 1], discard_weight=2) # Should discard ~50%
|
|
.execute()
|
|
)
|
|
|
|
# Should have fewer than 100 rows due to discard
|
|
row_count = permutation_tbl.count_rows()
|
|
assert row_count < 100
|
|
assert row_count > 0 # But not empty
|
|
|
|
|
|
def test_split_sequential(mem_db):
|
|
"""Test sequential splitting."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"x": range(100), "y": range(100)})
|
|
)
|
|
|
|
permutation_tbl = (
|
|
permutation_builder(tbl).split_sequential(counts=[30, 40]).execute()
|
|
)
|
|
|
|
assert permutation_tbl.count_rows() == 70
|
|
|
|
data = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
row_ids = data["row_id"]
|
|
split_ids = data["split_id"]
|
|
|
|
# Sequential should maintain order
|
|
assert row_ids == sorted(row_ids)
|
|
|
|
# First 30 should be split 0, next 40 should be split 1
|
|
assert split_ids[:30] == [0] * 30
|
|
assert split_ids[30:] == [1] * 40
|
|
|
|
|
|
def test_split_calculated(mem_db):
|
|
"""Test calculated splitting."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(100), "value": range(100)})
|
|
)
|
|
|
|
permutation_tbl = (
|
|
permutation_builder(tbl)
|
|
.split_calculated("id % 3") # Split based on id modulo 3
|
|
.execute()
|
|
)
|
|
|
|
assert permutation_tbl.count_rows() == 100
|
|
|
|
data = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
row_ids = data["row_id"]
|
|
split_ids = data["split_id"]
|
|
|
|
# Verify the calculation: each row's split_id should equal row_id % 3
|
|
for i, (row_id, split_id) in enumerate(zip(row_ids, split_ids)):
|
|
assert split_id == row_id % 3
|
|
|
|
|
|
def test_split_error_cases(mem_db):
|
|
"""Test error handling for invalid split parameters."""
|
|
tbl = mem_db.create_table("test_table", pa.table({"x": range(10), "y": range(10)}))
|
|
|
|
# Test split_random with no parameters
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="Exactly one of 'ratios', 'counts', or 'fixed' must be provided",
|
|
):
|
|
permutation_builder(tbl).split_random().execute()
|
|
|
|
# Test split_random with multiple parameters
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="Exactly one of 'ratios', 'counts', or 'fixed' must be provided",
|
|
):
|
|
permutation_builder(tbl).split_random(
|
|
ratios=[0.5, 0.5], counts=[5, 5]
|
|
).execute()
|
|
|
|
# Test split_sequential with no parameters
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="Exactly one of 'ratios', 'counts', or 'fixed' must be provided",
|
|
):
|
|
permutation_builder(tbl).split_sequential().execute()
|
|
|
|
# Test split_sequential with multiple parameters
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="Exactly one of 'ratios', 'counts', or 'fixed' must be provided",
|
|
):
|
|
permutation_builder(tbl).split_sequential(ratios=[0.5, 0.5], fixed=2).execute()
|
|
|
|
|
|
def test_shuffle_no_seed(mem_db):
|
|
"""Test shuffling without a seed."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(100), "value": range(100)})
|
|
)
|
|
|
|
# Create a permutation with shuffling (no seed)
|
|
permutation_tbl = permutation_builder(tbl).shuffle().execute()
|
|
|
|
assert permutation_tbl.count_rows() == 100
|
|
|
|
data = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
row_ids = data["row_id"]
|
|
|
|
# Row IDs should not be in sequential order due to shuffling
|
|
# This is probabilistic but with 100 rows, it's extremely unlikely they'd stay
|
|
# in order
|
|
assert row_ids != list(range(100))
|
|
|
|
|
|
def test_shuffle_with_seed(mem_db):
|
|
"""Test that shuffling with a seed is reproducible."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(50), "value": range(50)})
|
|
)
|
|
|
|
# Create two identical permutations with same shuffle seed
|
|
perm1 = permutation_builder(tbl).shuffle(seed=42).execute()
|
|
|
|
perm2 = permutation_builder(tbl).shuffle(seed=42).execute()
|
|
|
|
# Results should be identical due to same seed
|
|
data1 = perm1.search(None).to_arrow().to_pydict()
|
|
data2 = perm2.search(None).to_arrow().to_pydict()
|
|
|
|
assert data1["row_id"] == data2["row_id"]
|
|
assert data1["split_id"] == data2["split_id"]
|
|
|
|
|
|
def test_shuffle_with_clump_size(mem_db):
|
|
"""Test shuffling with clump size."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(100), "value": range(100)})
|
|
)
|
|
|
|
# Create a permutation with shuffling using clumps
|
|
permutation_tbl = (
|
|
permutation_builder(tbl)
|
|
.shuffle(clump_size=10) # 10-row clumps
|
|
.execute()
|
|
)
|
|
|
|
assert permutation_tbl.count_rows() == 100
|
|
|
|
data = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
row_ids = data["row_id"]
|
|
|
|
for i in range(10):
|
|
start = row_ids[i * 10]
|
|
assert row_ids[i * 10 : (i + 1) * 10] == list(range(start, start + 10))
|
|
|
|
|
|
def test_shuffle_different_seeds(mem_db):
|
|
"""Test that different seeds produce different shuffle orders."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(50), "value": range(50)})
|
|
)
|
|
|
|
# Create two permutations with different shuffle seeds
|
|
perm1 = permutation_builder(tbl).split_random(fixed=2).shuffle(seed=42).execute()
|
|
|
|
perm2 = permutation_builder(tbl).split_random(fixed=2).shuffle(seed=123).execute()
|
|
|
|
# Results should be different due to different seeds
|
|
data1 = perm1.search(None).to_arrow().to_pydict()
|
|
data2 = perm2.search(None).to_arrow().to_pydict()
|
|
|
|
# Row order should be different
|
|
assert data1["row_id"] != data2["row_id"]
|
|
|
|
|
|
def test_shuffle_combined_with_splits(mem_db):
|
|
"""Test shuffling combined with different split strategies."""
|
|
tbl = mem_db.create_table(
|
|
"test_table",
|
|
pa.table(
|
|
{
|
|
"id": range(100),
|
|
"category": (["A", "B", "C"] * 34)[:100],
|
|
"value": range(100),
|
|
}
|
|
),
|
|
)
|
|
|
|
# Test shuffle with random splits
|
|
perm_random = (
|
|
permutation_builder(tbl)
|
|
.split_random(ratios=[0.6, 0.4], seed=42)
|
|
.shuffle(seed=123, clump_size=None)
|
|
.execute()
|
|
)
|
|
|
|
# Test shuffle with hash splits
|
|
perm_hash = (
|
|
permutation_builder(tbl)
|
|
.split_hash(["category"], [1, 1], discard_weight=0)
|
|
.shuffle(seed=456, clump_size=5)
|
|
.execute()
|
|
)
|
|
|
|
# Test shuffle with sequential splits
|
|
perm_sequential = (
|
|
permutation_builder(tbl)
|
|
.split_sequential(counts=[40, 35])
|
|
.shuffle(seed=789, clump_size=None)
|
|
.execute()
|
|
)
|
|
|
|
# Verify all permutations work and have expected properties
|
|
assert perm_random.count_rows() == 100
|
|
assert perm_hash.count_rows() == 100
|
|
assert perm_sequential.count_rows() == 75
|
|
|
|
# Verify shuffle affected the order
|
|
data_random = perm_random.search(None).to_arrow().to_pydict()
|
|
data_sequential = perm_sequential.search(None).to_arrow().to_pydict()
|
|
|
|
assert data_random["row_id"] != list(range(100))
|
|
assert data_sequential["row_id"] != list(range(75))
|
|
|
|
|
|
def test_no_shuffle_maintains_order(mem_db):
|
|
"""Test that not calling shuffle maintains the original order."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(50), "value": range(50)})
|
|
)
|
|
|
|
# Create permutation without shuffle (should maintain some order)
|
|
permutation_tbl = (
|
|
permutation_builder(tbl)
|
|
.split_sequential(counts=[25, 25]) # Sequential maintains order
|
|
.execute()
|
|
)
|
|
|
|
assert permutation_tbl.count_rows() == 50
|
|
|
|
data = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
row_ids = data["row_id"]
|
|
|
|
# With sequential splits and no shuffle, should maintain order
|
|
assert row_ids == list(range(50))
|
|
|
|
|
|
def test_filter_basic(mem_db):
|
|
"""Test basic filtering functionality."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(100), "value": range(100, 200)})
|
|
)
|
|
|
|
# Filter to only include rows where id < 50
|
|
permutation_tbl = permutation_builder(tbl).filter("id < 50").execute()
|
|
|
|
assert permutation_tbl.count_rows() == 50
|
|
|
|
data = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
row_ids = data["row_id"]
|
|
|
|
# All row_ids should be less than 50
|
|
assert all(row_id < 50 for row_id in row_ids)
|
|
|
|
|
|
def test_filter_with_splits(mem_db):
|
|
"""Test filtering combined with split strategies."""
|
|
tbl = mem_db.create_table(
|
|
"test_table",
|
|
pa.table(
|
|
{
|
|
"id": range(100),
|
|
"category": (["A", "B", "C"] * 34)[:100],
|
|
"value": range(100),
|
|
}
|
|
),
|
|
)
|
|
|
|
# Filter to only category A and B, then split
|
|
permutation_tbl = (
|
|
permutation_builder(tbl)
|
|
.filter("category IN ('A', 'B')")
|
|
.split_random(ratios=[0.5, 0.5])
|
|
.execute()
|
|
)
|
|
|
|
# Should have fewer than 100 rows due to filtering
|
|
row_count = permutation_tbl.count_rows()
|
|
assert row_count == 67
|
|
|
|
# Verify the permutation table only contains row_id and split_id
|
|
assert set(permutation_tbl.schema.names) == {"row_id", "split_id"}
|
|
|
|
row_ids = permutation_tbl.search(None).to_arrow().to_pydict()["row_id"]
|
|
data = tbl.take_row_ids(row_ids).to_arrow().to_pydict()
|
|
categories = data["category"]
|
|
|
|
# All categories should be A or B
|
|
assert all(cat in ("A", "B") for cat in categories)
|
|
|
|
|
|
def test_filter_with_shuffle(mem_db):
|
|
"""Test filtering combined with shuffling."""
|
|
tbl = mem_db.create_table(
|
|
"test_table",
|
|
pa.table(
|
|
{
|
|
"id": range(100),
|
|
"category": (["A", "B", "C", "D"] * 25)[:100],
|
|
"value": range(100),
|
|
}
|
|
),
|
|
)
|
|
|
|
# Filter and shuffle
|
|
permutation_tbl = (
|
|
permutation_builder(tbl)
|
|
.filter("category IN ('A', 'C')")
|
|
.shuffle(seed=42)
|
|
.execute()
|
|
)
|
|
|
|
row_count = permutation_tbl.count_rows()
|
|
assert row_count == 50 # Should have 50 rows (A and C categories)
|
|
|
|
data = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
row_ids = data["row_id"]
|
|
|
|
assert row_ids != sorted(row_ids)
|
|
|
|
|
|
def test_filter_empty_result(mem_db):
|
|
"""Test filtering that results in empty set."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(10), "value": range(10)})
|
|
)
|
|
|
|
# Filter that matches nothing
|
|
permutation_tbl = (
|
|
permutation_builder(tbl)
|
|
.filter("value > 100") # No values > 100 in our data
|
|
.execute()
|
|
)
|
|
|
|
assert permutation_tbl.count_rows() == 0
|
|
|
|
|
|
@pytest.fixture
|
|
def mem_db() -> DBConnection:
|
|
return connect("memory:///")
|
|
|
|
|
|
@pytest.fixture
|
|
def some_table(mem_db: DBConnection) -> Table:
|
|
data = pa.table(
|
|
{
|
|
"id": range(1000),
|
|
"value": range(1000),
|
|
}
|
|
)
|
|
return mem_db.create_table("some_table", data)
|
|
|
|
|
|
def test_no_split_names(some_table: Table):
|
|
perm_tbl = (
|
|
permutation_builder(some_table).split_sequential(counts=[500, 500]).execute()
|
|
)
|
|
permutations = Permutations(some_table, perm_tbl)
|
|
assert permutations.split_names == []
|
|
assert permutations.split_dict == {}
|
|
assert permutations[0].num_rows == 500
|
|
assert permutations[1].num_rows == 500
|
|
|
|
|
|
def test_permutations_metadata_without_split_names_key(mem_db: DBConnection):
|
|
"""Regression: schema metadata present but missing split_names key must not crash.
|
|
|
|
Previously, `.get(b"split_names", None).decode()` was called unconditionally,
|
|
so any permutation table whose metadata dict had other keys but no split_names
|
|
raised AttributeError: 'NoneType' has no attribute 'decode'.
|
|
"""
|
|
base = mem_db.create_table("base_nosplit", pa.table({"x": range(10)}))
|
|
|
|
# Build a permutation-like table that carries some metadata but NOT split_names.
|
|
raw = pa.table(
|
|
{
|
|
"row_id": pa.array(range(10), type=pa.uint64()),
|
|
"split_id": pa.array([0] * 10, type=pa.uint32()),
|
|
}
|
|
).replace_schema_metadata({b"other_key": b"other_value"})
|
|
perm_tbl = mem_db.create_table("perm_nosplit", raw)
|
|
|
|
permutations = Permutations(base, perm_tbl)
|
|
assert permutations.split_names == []
|
|
assert permutations.split_dict == {}
|
|
|
|
|
|
def test_from_tables_string_split_missing_names_key(mem_db: DBConnection):
|
|
"""Regression: from_tables() with a string split must raise ValueError, not
|
|
AttributeError.
|
|
|
|
Previously, `.get(b"split_names", None).decode()` crashed with AttributeError
|
|
when the metadata dict existed but had no split_names key.
|
|
"""
|
|
base = mem_db.create_table("base_strsplit", pa.table({"x": range(10)}))
|
|
|
|
raw = pa.table(
|
|
{
|
|
"row_id": pa.array(range(10), type=pa.uint64()),
|
|
"split_id": pa.array([0] * 10, type=pa.uint32()),
|
|
}
|
|
).replace_schema_metadata({b"other_key": b"other_value"})
|
|
perm_tbl = mem_db.create_table("perm_strsplit", raw)
|
|
|
|
with pytest.raises(ValueError, match="no split names are defined"):
|
|
Permutation.from_tables(base, perm_tbl, split="train")
|
|
|
|
|
|
@pytest.fixture
|
|
def some_perm_table(some_table: Table) -> Table:
|
|
return (
|
|
permutation_builder(some_table)
|
|
.split_random(ratios=[0.95, 0.05], seed=42, split_names=["train", "test"])
|
|
.shuffle(seed=42)
|
|
.execute()
|
|
)
|
|
|
|
|
|
def test_nonexistent_split(some_table: Table, some_perm_table: Table):
|
|
# Reference by name and name does not exist
|
|
with pytest.raises(ValueError, match="split `nonexistent` is not defined"):
|
|
Permutation.from_tables(some_table, some_perm_table, "nonexistent")
|
|
|
|
# Reference by ordinal and there are no rows
|
|
with pytest.raises(ValueError, match="No rows found"):
|
|
Permutation.from_tables(some_table, some_perm_table, 5)
|
|
|
|
|
|
def test_permutations(some_table: Table, some_perm_table: Table):
|
|
permutations = Permutations(some_table, some_perm_table)
|
|
assert permutations.split_names == ["train", "test"]
|
|
assert permutations.split_dict == {"train": 0, "test": 1}
|
|
assert permutations["train"].num_rows == 950
|
|
assert permutations[0].num_rows == 950
|
|
assert permutations["test"].num_rows == 50
|
|
assert permutations[1].num_rows == 50
|
|
|
|
with pytest.raises(ValueError, match="No split named `nonexistent` found"):
|
|
permutations["nonexistent"]
|
|
with pytest.raises(ValueError, match="No rows found"):
|
|
permutations[5]
|
|
|
|
|
|
@pytest.fixture
|
|
def some_permutation(some_table: Table, some_perm_table: Table) -> Permutation:
|
|
return Permutation.from_tables(some_table, some_perm_table)
|
|
|
|
|
|
def test_num_rows(some_permutation: Permutation):
|
|
assert some_permutation.num_rows == 950
|
|
|
|
|
|
def test_num_columns(some_permutation: Permutation):
|
|
assert some_permutation.num_columns == 2
|
|
|
|
|
|
def test_column_names(some_permutation: Permutation):
|
|
assert some_permutation.column_names == ["id", "value"]
|
|
|
|
|
|
def test_shape(some_permutation: Permutation):
|
|
assert some_permutation.shape == (950, 2)
|
|
|
|
|
|
def test_schema(some_permutation: Permutation):
|
|
assert some_permutation.schema == pa.schema(
|
|
[("id", pa.int64()), ("value", pa.int64())]
|
|
)
|
|
|
|
|
|
def test_limit_offset(some_permutation: Permutation):
|
|
assert some_permutation.with_take(100).num_rows == 100
|
|
assert some_permutation.with_skip(100).num_rows == 850
|
|
assert some_permutation.with_take(100).with_skip(100).num_rows == 100
|
|
|
|
with pytest.raises(Exception):
|
|
some_permutation.with_take(1000000).num_rows
|
|
with pytest.raises(Exception):
|
|
some_permutation.with_skip(1000000).num_rows
|
|
with pytest.raises(Exception):
|
|
some_permutation.with_take(500).with_skip(500).num_rows
|
|
with pytest.raises(Exception):
|
|
some_permutation.with_skip(500).with_take(500).num_rows
|
|
|
|
|
|
def test_remove_columns(some_permutation: Permutation):
|
|
assert some_permutation.remove_columns(["value"]).schema == pa.schema(
|
|
[("id", pa.int64())]
|
|
)
|
|
# Should not modify the original permutation
|
|
assert some_permutation.schema.names == ["id", "value"]
|
|
# Cannot remove all columns
|
|
with pytest.raises(ValueError, match="Cannot remove all columns"):
|
|
some_permutation.remove_columns(["id", "value"])
|
|
|
|
|
|
def test_rename_column(some_permutation: Permutation):
|
|
assert some_permutation.rename_column("value", "new_value").schema == pa.schema(
|
|
[("id", pa.int64()), ("new_value", pa.int64())]
|
|
)
|
|
# Should not modify the original permutation
|
|
assert some_permutation.schema.names == ["id", "value"]
|
|
# Cannot rename to an existing column
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="a column with that name already exists",
|
|
):
|
|
some_permutation.rename_column("value", "id")
|
|
# Cannot rename a non-existent column
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="does not exist",
|
|
):
|
|
some_permutation.rename_column("non_existent", "new_value")
|
|
|
|
|
|
def test_rename_columns(some_permutation: Permutation):
|
|
assert some_permutation.rename_columns({"value": "new_value"}).schema == pa.schema(
|
|
[("id", pa.int64()), ("new_value", pa.int64())]
|
|
)
|
|
# Should not modify the original permutation
|
|
assert some_permutation.schema.names == ["id", "value"]
|
|
# Cannot rename to an existing column
|
|
with pytest.raises(ValueError, match="a column with that name already exists"):
|
|
some_permutation.rename_columns({"value": "id"})
|
|
|
|
|
|
def test_select_columns(some_permutation: Permutation):
|
|
assert some_permutation.select_columns(["id"]).schema == pa.schema(
|
|
[("id", pa.int64())]
|
|
)
|
|
# Should not modify the original permutation
|
|
assert some_permutation.schema.names == ["id", "value"]
|
|
# Cannot select a non-existent column
|
|
with pytest.raises(ValueError, match="does not exist"):
|
|
some_permutation.select_columns(["non_existent"])
|
|
# Empty selection is not allowed
|
|
with pytest.raises(ValueError, match="select at least one column"):
|
|
some_permutation.select_columns([])
|
|
|
|
|
|
def test_iter_basic(some_permutation: Permutation):
|
|
"""Test basic iteration with custom batch size."""
|
|
batch_size = 100
|
|
batches = list(some_permutation.iter(batch_size, skip_last_batch=False))
|
|
|
|
# Check that we got the expected number of batches
|
|
expected_batches = (950 + batch_size - 1) // batch_size # ceiling division
|
|
assert len(batches) == expected_batches
|
|
|
|
# Check that all batches are lists of dicts (default python format)
|
|
assert all(isinstance(batch, list) for batch in batches)
|
|
|
|
# Check that batches have the correct structure
|
|
for batch in batches:
|
|
assert "id" in batch[0]
|
|
assert "value" in batch[0]
|
|
|
|
# Check that all batches except the last have the correct size
|
|
for batch in batches[:-1]:
|
|
assert len(batch) == batch_size
|
|
|
|
# Last batch might be smaller
|
|
assert len(batches[-1]) <= batch_size
|
|
|
|
|
|
def test_iter_skip_last_batch(some_permutation: Permutation):
|
|
"""Test iteration with skip_last_batch=True."""
|
|
batch_size = 300
|
|
batches_with_skip = list(some_permutation.iter(batch_size, skip_last_batch=True))
|
|
batches_without_skip = list(
|
|
some_permutation.iter(batch_size, skip_last_batch=False)
|
|
)
|
|
|
|
# With skip_last_batch=True, we should have fewer batches if the last one is partial
|
|
num_full_batches = 950 // batch_size
|
|
assert len(batches_with_skip) == num_full_batches
|
|
|
|
# Without skip_last_batch, we should have one more batch if there's a remainder
|
|
if 950 % batch_size != 0:
|
|
assert len(batches_without_skip) == num_full_batches + 1
|
|
# Last batch should be smaller
|
|
assert len(batches_without_skip[-1]) == 950 % batch_size
|
|
|
|
# All batches with skip_last_batch should be full size
|
|
for batch in batches_with_skip:
|
|
assert len(batch) == batch_size
|
|
|
|
|
|
def test_iter_different_batch_sizes(some_permutation: Permutation):
|
|
"""Test iteration with different batch sizes."""
|
|
|
|
# Test with small batch size
|
|
small_batches = list(some_permutation.iter(100, skip_last_batch=False))
|
|
assert len(small_batches) == 10 # ceiling(950 / 100)
|
|
|
|
# Test with large batch size
|
|
large_batches = list(some_permutation.iter(400, skip_last_batch=False))
|
|
assert len(large_batches) == 3 # ceiling(950 / 400)
|
|
|
|
# Test with batch size equal to total rows
|
|
single_batch = list(some_permutation.iter(950, skip_last_batch=False))
|
|
assert len(single_batch) == 1
|
|
assert len(single_batch[0]) == 950
|
|
|
|
# Test with batch size larger than total rows
|
|
oversized_batch = list(some_permutation.iter(10000, skip_last_batch=False))
|
|
assert len(oversized_batch) == 1
|
|
assert len(oversized_batch[0]) == 950
|
|
|
|
|
|
def test_dunder_iter(some_permutation: Permutation):
|
|
"""Test the __iter__ method."""
|
|
# __iter__ should use DEFAULT_BATCH_SIZE (100) and skip_last_batch=True
|
|
batches = list(some_permutation)
|
|
|
|
# With DEFAULT_BATCH_SIZE=100 and skip_last_batch=True, we should get 9 batches
|
|
assert len(batches) == 9 # ceiling(950 / 100)
|
|
|
|
# All batches should be full size
|
|
for batch in batches:
|
|
assert len(batch) == 100
|
|
|
|
some_permutation = some_permutation.with_batch_size(400)
|
|
batches = list(some_permutation)
|
|
assert len(batches) == 2 # floor(950 / 400) since skip_last_batch=True
|
|
for batch in batches:
|
|
assert len(batch) == 400
|
|
|
|
|
|
def test_iter_with_different_formats(some_permutation: Permutation):
|
|
"""Test iteration with different output formats."""
|
|
batch_size = 100
|
|
|
|
# Test with arrow format
|
|
arrow_perm = some_permutation.with_format("arrow")
|
|
arrow_batches = list(arrow_perm.iter(batch_size, skip_last_batch=False))
|
|
assert all(isinstance(batch, pa.RecordBatch) for batch in arrow_batches)
|
|
|
|
# Test with python format (default)
|
|
python_perm = some_permutation.with_format("python")
|
|
python_batches = list(python_perm.iter(batch_size, skip_last_batch=False))
|
|
assert all(isinstance(batch, list) for batch in python_batches)
|
|
|
|
# Test with pandas format
|
|
pandas_perm = some_permutation.with_format("pandas")
|
|
pandas_batches = list(pandas_perm.iter(batch_size, skip_last_batch=False))
|
|
# Import pandas to check the type
|
|
import pandas as pd
|
|
|
|
assert all(isinstance(batch, pd.DataFrame) for batch in pandas_batches)
|
|
|
|
|
|
def test_iter_with_column_selection(some_permutation: Permutation):
|
|
"""Test iteration after column selection."""
|
|
# Select only the id column
|
|
id_only = some_permutation.select_columns(["id"])
|
|
batches = list(id_only.iter(100, skip_last_batch=False))
|
|
|
|
# Check that batches only contain the id column
|
|
for batch in batches:
|
|
assert "id" in batch[0]
|
|
assert "value" not in batch[0]
|
|
|
|
|
|
def test_iter_with_column_rename(some_permutation: Permutation):
|
|
"""Test iteration after renaming columns."""
|
|
renamed = some_permutation.rename_column("value", "data")
|
|
batches = list(renamed.iter(100, skip_last_batch=False))
|
|
|
|
# Check that batches have the renamed column
|
|
for batch in batches:
|
|
assert "id" in batch[0]
|
|
assert "data" in batch[0]
|
|
assert "value" not in batch[0]
|
|
|
|
|
|
def test_iter_with_limit_offset(some_permutation: Permutation):
|
|
"""Test iteration with limit and offset."""
|
|
# Test with offset
|
|
offset_perm = some_permutation.with_skip(100)
|
|
offset_batches = list(offset_perm.iter(100, skip_last_batch=False))
|
|
# Should have 850 rows (950 - 100)
|
|
expected_batches = math.ceil(850 / 100)
|
|
assert len(offset_batches) == expected_batches
|
|
|
|
# Test with limit
|
|
limit_perm = some_permutation.with_take(500)
|
|
limit_batches = list(limit_perm.iter(100, skip_last_batch=False))
|
|
# Should have 5 batches (500 / 100)
|
|
assert len(limit_batches) == 5
|
|
|
|
no_skip = some_permutation.iter(101, skip_last_batch=False)
|
|
row_100 = next(no_skip)[100]["id"]
|
|
|
|
# Test with both limit and offset
|
|
limited_perm = some_permutation.with_skip(100).with_take(300)
|
|
limited_batches = list(limited_perm.iter(100, skip_last_batch=False))
|
|
# Should have 3 batches (300 / 100)
|
|
assert len(limited_batches) == 3
|
|
assert limited_batches[0][0]["id"] == row_100
|
|
|
|
|
|
def test_iter_empty_permutation(mem_db):
|
|
"""Test iteration over an empty permutation."""
|
|
# Create a table and filter it to be empty
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(10), "value": range(10)})
|
|
)
|
|
permutation_tbl = permutation_builder(tbl).filter("value > 100").execute()
|
|
with pytest.raises(ValueError, match="No rows found"):
|
|
Permutation.from_tables(tbl, permutation_tbl)
|
|
|
|
|
|
def test_iter_single_row(mem_db):
|
|
"""Test iteration over a permutation with a single row."""
|
|
tbl = mem_db.create_table("test_table", pa.table({"id": [42], "value": [100]}))
|
|
permutation_tbl = permutation_builder(tbl).execute()
|
|
perm = Permutation.from_tables(tbl, permutation_tbl)
|
|
|
|
# With skip_last_batch=False, should get one batch
|
|
batches = list(perm.iter(10, skip_last_batch=False))
|
|
assert len(batches) == 1
|
|
assert len(batches[0]) == 1
|
|
|
|
# With skip_last_batch=True, should skip the single row (since it's < batch_size)
|
|
batches_skip = list(perm.iter(10, skip_last_batch=True))
|
|
assert len(batches_skip) == 0
|
|
|
|
|
|
def test_identity_permutation(mem_db):
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(10), "value": range(10)})
|
|
)
|
|
permutation = Permutation.identity(tbl)
|
|
|
|
assert permutation.num_rows == 10
|
|
assert permutation.num_columns == 2
|
|
|
|
batches = list(permutation.iter(10, skip_last_batch=False))
|
|
assert len(batches) == 1
|
|
assert len(batches[0]) == 10
|
|
|
|
permutation = permutation.remove_columns(["value"])
|
|
assert permutation.num_columns == 1
|
|
assert permutation.schema == pa.schema([("id", pa.int64())])
|
|
assert permutation.column_names == ["id"]
|
|
assert permutation.shape == (10, 1)
|
|
|
|
|
|
def test_transform_fn(mem_db):
|
|
import numpy as np
|
|
import pandas as pd
|
|
import polars as pl
|
|
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(10), "value": range(10)})
|
|
)
|
|
permutation = Permutation.identity(tbl)
|
|
|
|
np_result = list(permutation.with_format("numpy").iter(10, skip_last_batch=False))[
|
|
0
|
|
]
|
|
assert np_result.shape == (10, 2)
|
|
assert np_result.dtype == np.int64
|
|
assert isinstance(np_result, np.ndarray)
|
|
|
|
pd_result = list(permutation.with_format("pandas").iter(10, skip_last_batch=False))[
|
|
0
|
|
]
|
|
assert pd_result.shape == (10, 2)
|
|
assert pd_result.dtypes.tolist() == [np.int64, np.int64]
|
|
assert isinstance(pd_result, pd.DataFrame)
|
|
|
|
pl_result = list(permutation.with_format("polars").iter(10, skip_last_batch=False))[
|
|
0
|
|
]
|
|
assert pl_result.shape == (10, 2)
|
|
assert pl_result.dtypes == [pl.Int64, pl.Int64]
|
|
assert isinstance(pl_result, pl.DataFrame)
|
|
|
|
py_result = list(permutation.with_format("python").iter(10, skip_last_batch=False))[
|
|
0
|
|
]
|
|
assert len(py_result) == 10
|
|
assert "id" in py_result[0]
|
|
assert "value" in py_result[0]
|
|
assert isinstance(py_result, list)
|
|
|
|
try:
|
|
import torch
|
|
|
|
# "torch" returns a list of per-row dicts. Default DataLoader collate
|
|
# stacks the per-row dicts back into a dict of batched tensors.
|
|
torch_perm = permutation.with_format("torch")
|
|
torch_batch = list(torch_perm.iter(10, skip_last_batch=False))[0]
|
|
assert isinstance(torch_batch, list)
|
|
assert len(torch_batch) == 10
|
|
assert isinstance(torch_batch[0], dict)
|
|
assert set(torch_batch[0].keys()) == {"id", "value"}
|
|
assert isinstance(torch_batch[0]["id"], torch.Tensor)
|
|
assert torch_batch[0]["id"].dtype == torch.int64
|
|
|
|
rows = torch_perm.__getitems__([0, 1, 2])
|
|
assert isinstance(rows, list)
|
|
assert len(rows) == 3
|
|
assert isinstance(rows[0], dict)
|
|
assert set(rows[0].keys()) == {"id", "value"}
|
|
assert isinstance(rows[0]["id"], torch.Tensor)
|
|
|
|
# "torch_row" returns a list of tensors, one per row.
|
|
torch_rows = list(
|
|
permutation.with_format("torch_row").iter(10, skip_last_batch=False)
|
|
)[0]
|
|
assert isinstance(torch_rows, list)
|
|
assert len(torch_rows) == 10
|
|
assert isinstance(torch_rows[0], torch.Tensor)
|
|
assert torch_rows[0].shape == (2,)
|
|
assert torch_rows[0].dtype == torch.int64
|
|
|
|
# "torch_col" stacks columns into a single 2D tensor.
|
|
torch_col = list(
|
|
permutation.with_format("torch_col").iter(10, skip_last_batch=False)
|
|
)[0]
|
|
assert isinstance(torch_col, torch.Tensor)
|
|
assert torch_col.shape == (2, 10)
|
|
assert torch_col.dtype == torch.int64
|
|
except ImportError:
|
|
# Skip check if torch is not installed
|
|
pass
|
|
|
|
arrow_result = list(
|
|
permutation.with_format("arrow").iter(10, skip_last_batch=False)
|
|
)[0]
|
|
assert arrow_result.shape == (10, 2)
|
|
assert arrow_result.schema == pa.schema([("id", pa.int64()), ("value", pa.int64())])
|
|
assert isinstance(arrow_result, pa.RecordBatch)
|
|
|
|
|
|
def test_custom_transform(mem_db):
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(10), "value": range(10)})
|
|
)
|
|
permutation = Permutation.identity(tbl)
|
|
|
|
def transform(batch: pa.RecordBatch) -> pa.RecordBatch:
|
|
return batch.select(["id"])
|
|
|
|
transformed = permutation.with_transform(transform)
|
|
batches = list(transformed.iter(10, skip_last_batch=False))
|
|
assert len(batches) == 1
|
|
batch = batches[0]
|
|
|
|
assert batch == pa.record_batch([range(10)], ["id"])
|
|
|
|
|
|
def test_getitems_basic(some_permutation: Permutation):
|
|
"""Test __getitems__ returns correct rows by offset."""
|
|
result = some_permutation.__getitems__([0, 1, 2])
|
|
assert isinstance(result, list)
|
|
assert "id" in result[0]
|
|
assert "value" in result[0]
|
|
assert len(result) == 3
|
|
|
|
|
|
def test_getitems_single_index(some_permutation: Permutation):
|
|
"""Test __getitems__ with a single index."""
|
|
result = some_permutation.__getitems__([0])
|
|
assert len(result) == 1
|
|
|
|
|
|
def test_getitems_preserves_order(some_permutation: Permutation):
|
|
"""Test __getitems__ returns rows in the requested order."""
|
|
# Get rows in forward order
|
|
forward = some_permutation.__getitems__([0, 1, 2, 3, 4])
|
|
# Get the same rows in reverse order
|
|
reverse = some_permutation.__getitems__([4, 3, 2, 1, 0])
|
|
|
|
assert [r["id"] for r in forward] == list(reversed([r["id"] for r in reverse]))
|
|
assert [r["value"] for r in forward] == list(
|
|
reversed([r["value"] for r in reverse])
|
|
)
|
|
|
|
|
|
def test_getitems_non_contiguous(some_permutation: Permutation):
|
|
"""Test __getitems__ with non-contiguous indices."""
|
|
result = some_permutation.__getitems__([0, 10, 50, 100, 500])
|
|
assert len(result) == 5
|
|
|
|
# Each id/value pair should match what we'd get individually
|
|
for i, offset in enumerate([0, 10, 50, 100, 500]):
|
|
single = some_permutation.__getitems__([offset])
|
|
assert result[i]["id"] == single[0]["id"]
|
|
assert result[i]["value"] == single[0]["value"]
|
|
|
|
|
|
def test_getitems_with_column_selection(some_permutation: Permutation):
|
|
"""Test __getitems__ respects column selection."""
|
|
id_only = some_permutation.select_columns(["id"])
|
|
result = id_only.__getitems__([0, 1, 2])
|
|
assert "id" in result[0]
|
|
assert "value" not in result[0]
|
|
assert len(result) == 3
|
|
|
|
|
|
def test_getitems_with_column_rename(some_permutation: Permutation):
|
|
"""Test __getitems__ respects column renames."""
|
|
renamed = some_permutation.rename_column("value", "data")
|
|
result = renamed.__getitems__([0, 1])
|
|
assert "data" in result[0]
|
|
assert "value" not in result[0]
|
|
assert len(result) == 2
|
|
|
|
|
|
def test_getitems_with_format(some_permutation: Permutation):
|
|
"""Test __getitems__ applies the transform function."""
|
|
arrow_perm = some_permutation.with_format("arrow")
|
|
result = arrow_perm.__getitems__([0, 1, 2])
|
|
assert isinstance(result, pa.RecordBatch)
|
|
assert result.num_rows == 3
|
|
|
|
|
|
def test_getitems_with_custom_transform(some_permutation: Permutation):
|
|
"""Test __getitems__ with a custom transform."""
|
|
|
|
def transform(batch: pa.RecordBatch) -> list:
|
|
return batch.column("id").to_pylist()
|
|
|
|
custom = some_permutation.with_transform(transform)
|
|
result = custom.__getitems__([0, 1, 2])
|
|
assert isinstance(result, list)
|
|
assert len(result) == 3
|
|
|
|
|
|
def test_getitems_identity_permutation(mem_db):
|
|
"""Test __getitems__ on an identity permutation."""
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(10), "value": range(10)})
|
|
)
|
|
perm = Permutation.identity(tbl)
|
|
|
|
result = perm.__getitems__([0, 5, 9])
|
|
assert [r["id"] for r in result] == [0, 5, 9]
|
|
assert [r["value"] for r in result] == [0, 5, 9]
|
|
|
|
|
|
def test_getitems_with_limit_offset(some_permutation: Permutation):
|
|
"""Test __getitems__ on a permutation with skip/take applied."""
|
|
limited = some_permutation.with_skip(100).with_take(200)
|
|
|
|
# Should be able to access offsets within the limited range
|
|
result = limited.__getitems__([0, 1, 199])
|
|
assert len(result) == 3
|
|
|
|
# The first item of the limited permutation should match offset 100 of original
|
|
full_result = some_permutation.__getitems__([100])
|
|
limited_result = limited.__getitems__([0])
|
|
assert limited_result[0]["id"] == full_result[0]["id"]
|
|
|
|
|
|
def test_getitems_invalid_offset(some_permutation: Permutation):
|
|
"""Test __getitems__ with an out-of-range offset raises an error."""
|
|
with pytest.raises(Exception):
|
|
some_permutation.__getitems__([999999])
|
|
|
|
|
|
def test_take_offsets(some_permutation: Permutation):
|
|
result = some_permutation.take_offsets([0, 1, 2])
|
|
|
|
assert isinstance(result, list)
|
|
assert "id" in result[0]
|
|
assert "value" in result[0]
|
|
assert len(result) == 3
|
|
|
|
|
|
def test_take_offsets_empty_identity_permutation(mem_db):
|
|
tbl = mem_db.create_table(
|
|
"test_table", pa.table({"id": range(10), "value": range(10)})
|
|
)
|
|
permutation = Permutation.identity(tbl)
|
|
|
|
result = permutation.take_offsets([])
|
|
|
|
assert result == []
|
|
|
|
|
|
def test_take_offsets_empty_permutation(some_permutation: Permutation):
|
|
result = some_permutation.take_offsets([])
|
|
|
|
assert result == []
|
|
|
|
|
|
def test_select_rowid(some_permutation: Permutation):
|
|
"""Test that _rowid can be selected alongside regular columns."""
|
|
perm_with_rowid = some_permutation.select_columns(["_rowid", "id"])
|
|
assert "_rowid" in perm_with_rowid.column_names
|
|
batches = list(perm_with_rowid.iter(100, skip_last_batch=False))
|
|
for batch in batches:
|
|
assert "_rowid" in batch[0]
|
|
|
|
|
|
def test_select_rowid_only(some_permutation: Permutation):
|
|
"""Test that _rowid can be selected as the sole column."""
|
|
perm_rowid_only = some_permutation.select_columns(["_rowid"])
|
|
assert perm_rowid_only.column_names == ["_rowid"]
|
|
batches = list(perm_rowid_only.iter(100, skip_last_batch=False))
|
|
assert len(batches) > 0
|
|
for batch in batches:
|
|
assert list(batch[0].keys()) == ["_rowid"]
|
|
|
|
|
|
def test_select_rowid_not_in_default(some_permutation: Permutation):
|
|
"""Test that _rowid is NOT in the default column_names or schema."""
|
|
assert "_rowid" not in some_permutation.column_names
|
|
assert "_rowid" not in some_permutation.schema.names
|
|
|
|
|
|
def test_select_rowid_identity_permutation(mem_db):
|
|
"""Test that _rowid works with an identity permutation."""
|
|
tbl = mem_db.create_table(
|
|
"test_rowid_identity", pa.table({"id": range(10), "value": range(10)})
|
|
)
|
|
perm = Permutation.identity(tbl)
|
|
perm_with_rowid = perm.select_columns(["_rowid", "id"])
|
|
batches = list(perm_with_rowid.iter(10, skip_last_batch=False))
|
|
assert len(batches) == 1
|
|
assert "_rowid" in batches[0][0]
|
|
|
|
|
|
def test_rename_rowid(some_permutation: Permutation):
|
|
"""Test that _rowid can be selected and then renamed."""
|
|
perm_with_rowid = some_permutation.select_columns(["_rowid", "id"])
|
|
renamed = perm_with_rowid.rename_column("_rowid", "my_row_id")
|
|
assert "my_row_id" in renamed.column_names
|
|
assert "_rowid" not in renamed.column_names
|
|
batches = list(renamed.iter(100, skip_last_batch=False))
|
|
for batch in batches:
|
|
assert "my_row_id" in batch[0]
|
|
assert "_rowid" not in batch[0]
|
|
|
|
|
|
def test_remove_rowid_after_select(some_permutation: Permutation):
|
|
"""Test that _rowid can be selected and then removed."""
|
|
perm_with_rowid = some_permutation.select_columns(["_rowid", "id"])
|
|
assert "_rowid" in perm_with_rowid.column_names
|
|
perm_without_rowid = perm_with_rowid.remove_columns(["_rowid"])
|
|
assert "_rowid" not in perm_without_rowid.column_names
|
|
assert perm_without_rowid.column_names == ["id"]
|
|
|
|
|
|
def test_permutation_is_stable_when_remote_scan_order_varies():
|
|
"""Splits are assigned by scan position, and every rank builds its own
|
|
permutation, so two ranks seeing different scan orders must still agree."""
|
|
server = MockPermutationServer(num_rows=16, vary_scan_order=True)
|
|
|
|
def split_of_each_row(permutation_tbl):
|
|
# Sequential splits are assigned by position, so a reversed scan would put
|
|
# the last rows in split 0. Compare the mapping rather than the table order,
|
|
# which the split-id sort does not pin down.
|
|
rows = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
return dict(zip(rows["row_id"], rows["split_id"]))
|
|
|
|
with mock_remote_table(server) as table:
|
|
first = split_of_each_row(
|
|
permutation_builder(table).split_sequential(fixed=2).execute()
|
|
)
|
|
second = split_of_each_row(
|
|
permutation_builder(table).split_sequential(fixed=2).execute()
|
|
)
|
|
|
|
assert server.scan_calls == 2, "both builds must have scanned"
|
|
assert first == second
|
|
assert first[0] == 0 and first[server.num_rows - 1] == 1, first
|
|
|
|
|
|
def test_permutation_over_remote_table():
|
|
"""The permutation API accepts a remote table, addressing rows by `_rowid` just
|
|
as `take_row_ids` does. Also pins the request shapes sent to the server.
|
|
"""
|
|
server = MockPermutationServer()
|
|
|
|
with mock_remote_table(server) as table:
|
|
permutation_tbl = permutation_builder(table).split_sequential(fixed=2).execute()
|
|
assert permutation_tbl.count_rows() == server.num_rows
|
|
|
|
permutation = Permutation.from_tables(table, permutation_tbl, 0)
|
|
assert permutation.num_rows == server.num_rows // 2
|
|
|
|
# Compare against the permutation's own order; the split-id sort is not stable.
|
|
rows = permutation_tbl.search(None).to_arrow().to_pydict()
|
|
split0 = [
|
|
row_id
|
|
for row_id, split in zip(rows["row_id"], rows["split_id"])
|
|
if not split
|
|
]
|
|
# The mock table's `id` equals its `_rowid`.
|
|
assert permutation.take_offsets([2, 0]) == [
|
|
{"id": split0[2]},
|
|
{"id": split0[0]},
|
|
]
|
|
|
|
assert_server_safe_row_id_requests(server)
|