Repository: docarray/docarray

Issue:
fix(array): fix getting docs by two ids
The `DocumentArray` method `__getitem__`  is incorrect in the case two ids are passed:

Minimal working example to showcase bug:

```python
In [2]: from docarray import Document, DocumentArray
   ...: 
   ...: da = DocumentArray([Document(id='1'),
   ...:                     Document(id='2'),
   ...:                     Document(id='3')])
```

Currently selecting with two doc ids produces 

```python
In [4]: da['1','2']

---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/var/folders/05/h71x7gh54sx_5y43ppkq9_dw0000gq/T/ipykernel_8535/2066379862.py in <module>
----> 1 da['1','2']

~/Documents/jina_stuff/docarray/docarray/array/document.py in __getitem__(self, index)
    151                 if isinstance(_attrs, str):
    152                     _attrs = (index[1],)
--> 153                 return _docs._get_attributes(*_attrs)
    154             elif isinstance(index[0], bool):
    155                 return DocumentArray(itertools.compress(self._data, index))

~/Documents/jina_stuff/docarray/docarray/document/mixins/attribute.py in _get_attributes(self, *fields)
     20                 value = dunder_get(self, k)
     21             else:
---> 22                 value = getattr(self, k)
     23 
     24             ret.append(value)

AttributeError: 'Document' object has no attribute '2'
```

This PR solves this and produces
```python
In [3]: da['1']
Out[3]: <Document ('id',) at 1>

In [4]: da['1','2']
Out[4]: <DocumentArray (length=2) at 140552956821312>

In [5]: da['1','2','3']
Out[5]: <DocumentArray (length=3) at 140552956819056>
```





Discussion:
# [Codecov](https://codecov.io/gh/jina-ai/docarray/pull/29?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai) Report
> Merging [#29](https://codecov.io/gh/jina-ai/docarray/pull/29?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai) (0fd2c7c) into [main](https://codecov.io/gh/jina-ai/docarray/commit/79ad2362df1e67d401dac19e1af5156c7959aaea?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai) (79ad236) will **increase** coverage by `0.18%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/jina-ai/docarray/pull/29/graphs/tree.svg?width=650&height=150&src=pr&token=9WGcGyyqtI&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai)](https://codecov.io/gh/jina-ai/docarray/pull/29?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai)

```diff
@@            Coverage Diff             @@
##             main      #29      +/-   ##
==========================================
+ Coverage   83.09%   83.27%   +0.18%     
==========================================
  Files          67       67              
  Lines        3135     3151      +16     
==========================================
+ Hits         2605     2624      +19     
+ Misses        530      527       -3     
```

| Flag | Coverage Δ | |
|---|---|---|
| docarray | `83.27% <100.00%> (+0.18%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/jina-ai/docarray/pull/29?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai) | Coverage Δ | |
|---|---|---|
| [docarray/array/document.py](https://codecov.io/gh/jina-ai/docarray/pull/29/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai#diff-ZG9jYXJyYXkvYXJyYXkvZG9jdW1lbnQucHk=) | `85.77% <100.00%> (+1.52%)` | :arrow_up: |
| [docarray/proto/io/ndarray.py](https://codecov.io/gh/jina-ai/docarray/pull/29/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai#diff-ZG9jYXJyYXkvcHJvdG8vaW8vbmRhcnJheS5weQ==) | `94.78% <0.00%> (+0.09%)` | :arrow_up: |
| [docarray/array/mixins/io/binary.py](https://codecov.io/gh/jina-ai/docarray/pull/29/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai#diff-ZG9jYXJyYXkvYXJyYXkvbWl4aW5zL2lvL2JpbmFyeS5weQ==) | `95.55% <0.00%> (+0.31%)` | :arrow_up: |
| [docarray/document/mixins/porting.py](https://codecov.io/gh/jina-ai/docarray/pull/29/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai#diff-ZG9jYXJyYXkvZG9jdW1lbnQvbWl4aW5zL3BvcnRpbmcucHk=) | `92.59% <0.00%> (+0.92%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/jina-ai/docarray/pull/29?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/jina-ai/docarray/pull/29?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai). Last update [79ad236...0fd2c7c](https://codecov.io/gh/jina-ai/docarray/pull/29?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=jina-ai).

I think `da['1','2']` is confusing. It is not clear whether we want to select by `id` or to select attributes. Anyway, the part of selecting by ids does not work. I suggest remove this part for the time being. 

![image](https://user-images.githubusercontent.com/4329072/149073652-28eda642-ebfc-42f0-92b0-b6732f50ca6b.png)

MWE:
```python
from docarray import Document, DocumentArray

da = DocumentArray([Document(id='1'),
                    Document(id='2'),
                    Document(id='3')])

da['1', '2']
```

leads to the error,

```bash
Traceback (most recent call last):
  File "/Users/nanwang/Codes/jina-ai/docarray/toys/toy2.py", line 7, in <module>
    print(da['1', '2'])
  File "/Users/nanwang/Codes/jina-ai/docarray/docarray/array/document.py", line 154, in __getitem__
    return _docs._get_attributes(*_attrs)
  File "/Users/nanwang/Codes/jina-ai/docarray/docarray/document/mixins/attribute.py", line 22, in _get_attributes
    value = getattr(self, k)
AttributeError: 'Document' object has no attribute '2'
```

One principle we can stick to is to use the first arg to select ids\index\slices and the second arg to select the attributes. If we follow this rule, the grammar for selecting by integers should be refactored to 

```diff
- da[1, 2, 3]
+ da[[1, 2, 3]]
```

This pattern is similar as the pandas `DataFrame.loc`. **The `id` in DocArray is equivalent to `index` in `pandas.DataFrame`.** Here is an example
```python
import pandas as pd

df = pd.DataFrame([['a', 1, 'xx'], ['b', 2, 'xx'], ['c', 3, 'xx']], columns=['name', 'age', 'place'])
df.set_index('name', inplace=True)

# select by one id
print(df.loc['a'])

"""output
age       1
place    xx
Name: a, dtype: object
      age place
"""

# select by multiple ids
print(df.loc[['a', 'b']])

"""output
name           
a       1    xx
b       2    xx
1
"""

# select by one id & one attribute
print(df.loc['a', 'age'])

"""output
1
"""

# select by multiple ids & one attribute
print(df.loc[['a', 'b'], 'age'])

"""output
name
a    1
b    2
Name: age, dtype: int64
"""

# select by multiple ids & multiple attribute
print(df.loc[['a', 'b'], ['age', 'place']])

"""output
      age place
name           
a       1    xx
b       2    xx
"""
```
This error that you point out @nan-wang  is precisely what I explain in the example on top of the PR and should be solved by the PR.



Resolving patch:
diff --git a/docarray/array/document.py b/docarray/array/document.py
--- a/docarray/array/document.py
+++ b/docarray/array/document.py
@@ -146,12 +146,18 @@ def __getitem__(
                 and len(index) == 2
                 and isinstance(index[0], (slice, Sequence))
             ):
-                _docs = self[index[0]]
-                _attrs = index[1]
-                if isinstance(_attrs, str):
-                    _attrs = (index[1],)
-
-                return _docs._get_attributes(*_attrs)
+                if isinstance(index[0], str) and isinstance(index[1], str):
+                    # ambiguity only comes from the second string
+                    if index[1] in self._id2offset:
+                        return DocumentArray([self[index[0]], self[index[1]]])
+                    else:
+                        return getattr(self[index[0]], index[1])
+                elif isinstance(index[0], (slice, Sequence)):
+                    _docs = self[index[0]]
+                    _attrs = index[1]
+                    if isinstance(_attrs, str):
+                        _attrs = (index[1],)
+                    return _docs._get_attributes(*_attrs)
             elif isinstance(index[0], bool):
                 return DocumentArray(itertools.compress(self._data, index))
             elif isinstance(index[0], int):
@@ -231,31 +237,45 @@ def __setitem__(
                 and len(index) == 2
                 and isinstance(index[0], (slice, Sequence))
             ):
-                _docs = self[index[0]]
-                _attrs = index[1]
-
-                if isinstance(_attrs, str):
-                    # a -> [a]
-                    # [a, a] -> [a, a]
-                    _attrs = (index[1],)
-                if isinstance(value, (list, tuple)) and not any(
-                    isinstance(el, (tuple, list)) for el in value
-                ):
-                    # [x] -> [[x]]
-                    # [[x], [y]] -> [[x], [y]]
-                    value = (value,)
-                if not isinstance(value, (list, tuple)):
-                    # x -> [x]
-                    value = (value,)
-
-                for _a, _v in zip(_attrs, value):
-                    if _a == 'blob':
-                        _docs.blobs = _v
-                    elif _a == 'embedding':
-                        _docs.embeddings = _v
+                if isinstance(index[0], str) and isinstance(index[1], str):
+                    # ambiguity only comes from the second string
+                    if index[1] in self._id2offset:
+                        for _d, _v in zip((self[index[0]], self[index[1]]), value):
+                            _d._data = _v._data
+                        self._rebuild_id2offset()
+                    elif hasattr(self[index[0]], index[1]):
+                        setattr(self[index[0]], index[1], value)
                     else:
-                        for _d, _vv in zip(_docs, _v):
-                            setattr(_d, _a, _vv)
+                        # to avoid accidentally add new unsupport attribute
+                        raise ValueError(
+                            f'`{index[1]}` is neither a valid id nor attribute name'
+                        )
+                elif isinstance(index[0], (slice, Sequence)):
+                    _docs = self[index[0]]
+                    _attrs = index[1]
+
+                    if isinstance(_attrs, str):
+                        # a -> [a]
+                        # [a, a] -> [a, a]
+                        _attrs = (index[1],)
+                    if isinstance(value, (list, tuple)) and not any(
+                        isinstance(el, (tuple, list)) for el in value
+                    ):
+                        # [x] -> [[x]]
+                        # [[x], [y]] -> [[x], [y]]
+                        value = (value,)
+                    if not isinstance(value, (list, tuple)):
+                        # x -> [x]
+                        value = (value,)
+
+                    for _a, _v in zip(_attrs, value):
+                        if _a == 'blob':
+                            _docs.blobs = _v
+                        elif _a == 'embedding':
+                            _docs.embeddings = _v
+                        else:
+                            for _d, _vv in zip(_docs, _v):
+                                setattr(_d, _a, _vv)
             elif isinstance(index[0], bool):
                 if len(index) != len(self._data):
                     raise IndexError(
@@ -313,12 +333,20 @@ def __delitem__(self, index: 'DocumentArrayIndexType'):
                 and len(index) == 2
                 and isinstance(index[0], (slice, Sequence))
             ):
-                _docs = self[index[0]]
-                _attrs = index[1]
-                if isinstance(_attrs, str):
-                    _attrs = (index[1],)
-                for _d in _docs:
-                    _d.pop(*_attrs)
+                if isinstance(index[0], str) and isinstance(index[1], str):
+                    # ambiguity only comes from the second string
+                    if index[1] in self._id2offset:
+                        del self[index[0]]
+                        del self[index[1]]
+                    else:
+                        self[index[0]].pop(index[1])
+                elif isinstance(index[0], (slice, Sequence)):
+                    _docs = self[index[0]]
+                    _attrs = index[1]
+                    if isinstance(_attrs, str):
+                        _attrs = (index[1],)
+                    for _d in _docs:
+                        _d.pop(*_attrs)
             elif isinstance(index[0], bool):
                 self._data = list(
                     itertools.compress(self._data, (not _i for _i in index))


Test patch:
diff --git a/tests/unit/array/test_advance_indexing.py b/tests/unit/array/test_advance_indexing.py
--- a/tests/unit/array/test_advance_indexing.py
+++ b/tests/unit/array/test_advance_indexing.py
@@ -222,8 +222,6 @@ def test_advance_selector_mixed():
 
 
 def test_single_boolean_and_padding():
-    from docarray import DocumentArray
-
     da = DocumentArray.empty(3)
 
     with pytest.raises(IndexError):
@@ -237,3 +235,41 @@ def test_single_boolean_and_padding():
 
     assert len(da[True, False]) == 1
     assert len(da[False, False]) == 0
+
+
+def test_edge_case_two_strings():
+    # getitem
+    da = DocumentArray([Document(id='1'), Document(id='2'), Document(id='3')])
+    assert da['1', 'id'] == '1'
+    assert len(da['1', '2']) == 2
+    assert isinstance(da['1', '2'], DocumentArray)
+    with pytest.raises(KeyError):
+        da['hello', '2']
+    with pytest.raises(AttributeError):
+        da['1', 'hello']
+    assert len(da['1', '2', '3']) == 3
+    assert isinstance(da['1', '2', '3'], DocumentArray)
+
+    # delitem
+    del da['1', '2']
+    assert len(da) == 1
+
+    da = DocumentArray([Document(id='1'), Document(id='2'), Document(id='3')])
+    del da['1', 'id']
+    assert len(da) == 3
+    assert not da[0].id
+
+    del da['2', 'hello']
+
+    # setitem
+    da = DocumentArray([Document(id='1'), Document(id='2'), Document(id='3')])
+    da['1', '2'] = DocumentArray.empty(2)
+    assert da[0].id != '1'
+    assert da[1].id != '2'
+
+    da = DocumentArray([Document(id='1'), Document(id='2'), Document(id='3')])
+    da['1', 'text'] = 'hello'
+    assert da['1'].text == 'hello'
+
+    with pytest.raises(ValueError):
+        da['1', 'hellohello'] = 'hello'