Repository: elastic/elasticsearch-dsl-py (language: python)

Issue:
Terms lookup feature no longer supported due to recent release
A recent release appears to have broken the ability to perform a "terms lookup" query which is a special subset of terms query which allows you to specify the index, id, and field so you can limit terms to those found in a specific document in another index:

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-query.html#query-dsl-terms-lookup

I used to be able to create a "terms lookup" query like so:

```
Q('terms', **{'_id': {
    'index': 'my-other-index',
    'id': 'my-document-id',
    'path': 'my-field'
}})
```

Now, however, the query generated flattens the dictionary into a list of keys, so that what actually gets built is this:

```
"terms": {
    "_id": [
        "index",
        "id",
        "path"
    ]
}
```

This seems to be an unintended side-effect of a recent release. Please advise.


Discussion / hints:
I can confirm that reverting back to version 8.15.0 allows the query to be generated appropriately, i.e.:

```
"terms": {
    "_id": {
        "index": "my-other-index",
        "id": "my-document-id",
        "path": "my-field"
    }
}
```
Yes, this is another regression introduced by #1887 in 8.15.1. There are lots of variations that were not considered. I have addressed a couple already, but missed the passing of a dict as an argument.

Fix patch:
diff --git a/elasticsearch_dsl/query.py b/elasticsearch_dsl/query.py
index 607df3ff..4d131d1b 100644
--- a/elasticsearch_dsl/query.py
+++ b/elasticsearch_dsl/query.py
@@ -2647,7 +2647,7 @@ def __init__(
 
     def _setattr(self, name: str, value: Any) -> None:
         # here we convert any iterables that are not strings to lists
-        if hasattr(value, "__iter__") and not isinstance(value, (str, list)):
+        if hasattr(value, "__iter__") and not isinstance(value, (str, list, dict)):
             value = list(value)
         super()._setattr(name, value)
 
diff --git a/utils/templates/query.py.tpl b/utils/templates/query.py.tpl
index 23b23809..ca95f5a0 100644
--- a/utils/templates/query.py.tpl
+++ b/utils/templates/query.py.tpl
@@ -365,7 +365,7 @@ EMPTY_QUERY = MatchAll()
     {% elif k.name == "Terms" %}
     def _setattr(self, name: str, value: Any) -> None:
         # here we convert any iterables that are not strings to lists
-        if hasattr(value, "__iter__") and not isinstance(value, (str, list)):
+        if hasattr(value, "__iter__") and not isinstance(value, (str, list, dict)):
             value = list(value)
         super()._setattr(name, value)
 


Test patch:
diff --git a/tests/test_query.py b/tests/test_query.py
index 6be9a80d..35fe957a 100644
--- a/tests/test_query.py
+++ b/tests/test_query.py
@@ -84,6 +84,14 @@ def test_terms_to_dict() -> None:
     assert {"terms": {"_type": ["article", "section"], "boost": 1.1}} == query.Terms(
         _type=("article", "section"), boost=1.1
     ).to_dict()
+    assert {"terms": {"_type": "article", "boost": 1.1}} == query.Terms(
+        _type="article", boost=1.1
+    ).to_dict()
+    assert {
+        "terms": {"_id": {"index": "my-other-index", "id": "my-id"}, "boost": 1.1}
+    } == query.Terms(
+        _id={"index": "my-other-index", "id": "my-id"}, boost=1.1
+    ).to_dict()
 
 
 def test_bool_to_dict() -> None: