diff --git a/src/datajoint/expression.py b/src/datajoint/expression.py index 5ca7fdaa5..d90e363eb 100644 --- a/src/datajoint/expression.py +++ b/src/datajoint/expression.py @@ -580,29 +580,78 @@ def aggr(self, group, *attributes, exclude_nonmatching=False, **named_attributes aggregate = aggr # alias for aggr # ---------- Fetch operators -------------------- - @property - def fetch(self): + def fetch( + self, + *attrs, + offset=None, + limit=None, + order_by=None, + format=None, + as_dict=None, + squeeze=False, + ): + """ + Fetch data from the table (backward-compatible with DataJoint 0.14). + + .. deprecated:: 2.0 + Use the new explicit output methods instead: + - ``to_dicts()`` for list of dictionaries + - ``to_pandas()`` for pandas DataFrame + - ``to_arrays()`` for numpy structured array + - ``to_arrays('a', 'b')`` for tuple of arrays + - ``keys()`` for primary keys + + Parameters + ---------- + *attrs : str + Attributes to fetch. If empty, fetches all. + offset : int, optional + Number of tuples to skip. + limit : int, optional + Maximum number of tuples to return. + order_by : str or list, optional + Attribute(s) for ordering results. + format : str, optional + Output format: 'array' or 'frame' (pandas DataFrame). + as_dict : bool, optional + Return as list of dicts instead of structured array. + squeeze : bool, optional + Remove extra dimensions from arrays. Default False. + + Returns + ------- + np.recarray, list[dict], or pd.DataFrame + Query results in requested format. """ - The fetch() method has been removed in DataJoint 2.0. + import warnings - Use the new explicit output methods instead: - - table.to_dicts() # list of dictionaries - - table.to_pandas() # pandas DataFrame - - table.to_arrays() # numpy structured array - - table.to_arrays('a', 'b') # tuple of numpy arrays - - table.keys() # primary keys as list[dict] - - table.to_polars() # polars DataFrame (requires pip install datajoint[polars]) - - table.to_arrow() # PyArrow Table (requires pip install datajoint[arrow]) + warnings.warn( + "fetch() is deprecated in DataJoint 2.0. " "Use to_dicts(), to_pandas(), to_arrays(), or keys() instead.", + DeprecationWarning, + stacklevel=2, + ) - For single-row fetch, use fetch1() which is unchanged. + # Handle format='frame' -> to_pandas() + if format == "frame": + if attrs or as_dict is not None: + raise DataJointError("format='frame' cannot be combined with attrs or as_dict") + return self.to_pandas(order_by=order_by, limit=limit, offset=offset, squeeze=squeeze) - See migration guide: https://docs.datajoint.com/how-to/migrate-from-0x/ - """ - raise AttributeError( - "fetch() has been removed in DataJoint 2.0. " - "Use to_dicts(), to_pandas(), to_arrays(), or keys() instead. " - "See table.fetch.__doc__ for details." - ) + # Handle specific attributes requested + if attrs: + if as_dict or as_dict is None: + # fetch('col1', 'col2', as_dict=True) or fetch('col1', 'col2') + return self.proj(*attrs).to_dicts(order_by=order_by, limit=limit, offset=offset, squeeze=squeeze) + else: + # fetch('col1', 'col2', as_dict=False) -> tuple of arrays + return self.to_arrays(*attrs, order_by=order_by, limit=limit, offset=offset, squeeze=squeeze) + + # Handle as_dict=True -> to_dicts() + if as_dict: + return self.to_dicts(order_by=order_by, limit=limit, offset=offset, squeeze=squeeze) + + # Default: return structured array (legacy behavior) + return self.to_arrays(order_by=order_by, limit=limit, offset=offset, squeeze=squeeze) def fetch1(self, *attrs, squeeze=False): """ diff --git a/src/datajoint/heading.py b/src/datajoint/heading.py index 99d7246a4..c8486021a 100644 --- a/src/datajoint/heading.py +++ b/src/datajoint/heading.py @@ -467,7 +467,7 @@ def _init_from_database(self) -> None: if original_type.startswith("external"): raise DataJointError( f"Legacy datatype `{original_type}`. See migration guide: " - "https://docs.datajoint.com/how-to/migrate-from-0x/" + "https://docs.datajoint.com/how-to/migrate-to-v20/" ) # Not a special type - that's fine, could be native passthrough category = None diff --git a/src/datajoint/version.py b/src/datajoint/version.py index acc17bb66..d22e580ef 100644 --- a/src/datajoint/version.py +++ b/src/datajoint/version.py @@ -1,4 +1,4 @@ # version bump auto managed by Github Actions: # label_prs.yaml(prep), release.yaml(bump), post_release.yaml(edit) # manually set this version will be eventually overwritten by the above actions -__version__ = "2.0.0a26" +__version__ = "2.0.0a27" diff --git a/tests/unit/test_fetch_compat.py b/tests/unit/test_fetch_compat.py new file mode 100644 index 000000000..05e9621cf --- /dev/null +++ b/tests/unit/test_fetch_compat.py @@ -0,0 +1,106 @@ +"""Tests for backward-compatible fetch() method.""" + +import warnings +from unittest.mock import MagicMock + +import numpy as np +import pytest + + +class TestFetchBackwardCompat: + """Test backward-compatible fetch() emits deprecation warning and delegates correctly.""" + + @pytest.fixture + def mock_expression(self): + """Create a mock QueryExpression with mocked output methods.""" + from datajoint.expression import QueryExpression + + expr = MagicMock(spec=QueryExpression) + # Make fetch() callable by using the real implementation + expr.fetch = QueryExpression.fetch.__get__(expr, QueryExpression) + + # Mock the output methods + expr.to_arrays = MagicMock(return_value=np.array([(1, "a"), (2, "b")])) + expr.to_dicts = MagicMock(return_value=[{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]) + expr.to_pandas = MagicMock() + expr.proj = MagicMock(return_value=expr) + + return expr + + def test_fetch_emits_deprecation_warning(self, mock_expression): + """fetch() should emit a DeprecationWarning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + mock_expression.fetch() + + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "fetch() is deprecated" in str(w[0].message) + + def test_fetch_default_returns_arrays(self, mock_expression): + """fetch() with no args should call to_arrays().""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mock_expression.fetch() + + mock_expression.to_arrays.assert_called_once_with(order_by=None, limit=None, offset=None, squeeze=False) + + def test_fetch_as_dict_true(self, mock_expression): + """fetch(as_dict=True) should call to_dicts().""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mock_expression.fetch(as_dict=True) + + mock_expression.to_dicts.assert_called_once_with(order_by=None, limit=None, offset=None, squeeze=False) + + def test_fetch_with_attrs_returns_dicts(self, mock_expression): + """fetch('col1', 'col2') should call proj().to_dicts().""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mock_expression.fetch("col1", "col2") + + mock_expression.proj.assert_called_once_with("col1", "col2") + mock_expression.to_dicts.assert_called_once() + + def test_fetch_with_attrs_as_dict_false(self, mock_expression): + """fetch('col1', 'col2', as_dict=False) should call to_arrays('col1', 'col2').""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mock_expression.fetch("col1", "col2", as_dict=False) + + mock_expression.to_arrays.assert_called_once_with( + "col1", "col2", order_by=None, limit=None, offset=None, squeeze=False + ) + + def test_fetch_format_frame(self, mock_expression): + """fetch(format='frame') should call to_pandas().""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mock_expression.fetch(format="frame") + + mock_expression.to_pandas.assert_called_once_with(order_by=None, limit=None, offset=None, squeeze=False) + + def test_fetch_format_frame_with_attrs_raises(self, mock_expression): + """fetch(format='frame') with attrs should raise error.""" + from datajoint.errors import DataJointError + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + with pytest.raises(DataJointError, match="format='frame' cannot be combined"): + mock_expression.fetch("col1", format="frame") + + def test_fetch_passes_order_by_limit_offset(self, mock_expression): + """fetch() should pass order_by, limit, offset to output methods.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mock_expression.fetch(order_by="id", limit=10, offset=5) + + mock_expression.to_arrays.assert_called_once_with(order_by="id", limit=10, offset=5, squeeze=False) + + def test_fetch_passes_squeeze(self, mock_expression): + """fetch(squeeze=True) should pass squeeze to output methods.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mock_expression.fetch(squeeze=True) + + mock_expression.to_arrays.assert_called_once_with(order_by=None, limit=None, offset=None, squeeze=True)