Repository: marshmallow-code/apispec

Issue:
Marshmallow Pluck type not supported correctly?
Hi guys,

I was just trying to use Marshmallow 3 "nesting" via Pluck field (example taken from docs https://marshmallow.readthedocs.io/en/3.0/nesting.html) and swagger documentation rendered via `apispec` shows nested object in an array instead of flat array.

When using this example:

```
class UserSchema(Schema):
    name = fields.String()
    email = fields.Email()
    friends = fields.Pluck('self', 'name', many=True)
# ... create ``user`` ...
serialized_data = UserSchema().dump(user)
pprint(serialized_data)
# {
#     "name": "Steve",
#     "email": "steve@example.com",
#     "friends": ["Mike", "Joe"]
# }
deserialized_data = UserSchema().load(result)
pprint(deserialized_data)
# {
#     "name": "Steve",
#     "email": "steve@example.com",
#     "friends": [{"name": "Mike"}, {"name": "Joe"}]
# }
```

`apispec`-generated documentation says that ` "friends": ["Mike", "Joe"]` is in fact ` "friends": [{"name": "Mike"}, {"name": "Joe"}]`. So documentation is generated as for a normal nested object (without flattening it).

Is it just not supported yet or is there a bug?
Is there a plan to support this?

Discussion:
mjpieters: > I might have put the Pluck case out of the Nested case as Pluck inherits from Nested mainly for practical reasons

I actually agree, I've pulled out the change into a separate `pluck2properties` method. If only so others can override it separately in a subclass.
mjpieters: Just in case this was missed: I pushed updates for the review comments, yesterday. Because I amended my git branch and force-pushed, that may not always be apparent.

Patch:
diff --git a/src/apispec/ext/marshmallow/field_converter.py b/src/apispec/ext/marshmallow/field_converter.py
index 620ffc7..b1e4de8 100644
--- a/src/apispec/ext/marshmallow/field_converter.py
+++ b/src/apispec/ext/marshmallow/field_converter.py
@@ -96,6 +96,7 @@ class FieldConverterMixin:
             self.field2pattern,
             self.metadata2properties,
             self.nested2properties,
+            self.pluck2properties,
             self.list2properties,
             self.dict2properties,
         ]
@@ -402,7 +403,11 @@ class FieldConverterMixin:
         :param Field field: A marshmallow field.
         :rtype: dict
         """
-        if isinstance(field, marshmallow.fields.Nested):
+        # Pluck is a subclass of Nested but is in essence a single field; it
+        # is treated separately by pluck2properties.
+        if isinstance(field, marshmallow.fields.Nested) and not isinstance(
+            field, marshmallow.fields.Pluck
+        ):
             schema_dict = self.resolve_nested_schema(field.schema)
             if ret and "$ref" in schema_dict:
                 ret.update({"allOf": [schema_dict]})
@@ -410,6 +415,21 @@ class FieldConverterMixin:
                 ret.update(schema_dict)
         return ret
 
+    def pluck2properties(self, field, **kwargs):
+        """Return a dictionary of properties from :class:`Pluck <marshmallow.fields.Pluck` fields.
+
+        Pluck effectively trans-includes a field from another schema into this,
+        possibly wrapped in an array (`many=True`).
+
+        :param Field field: A marshmallow field.
+        :rtype: dict
+        """
+        if isinstance(field, marshmallow.fields.Pluck):
+            plucked_field = field.schema.fields[field.field_name]
+            ret = self.field2property(plucked_field)
+            return {"type": "array", "items": ret} if field.many else ret
+        return {}
+
     def list2properties(self, field, **kwargs):
         """Return a dictionary of properties from :class:`List <marshmallow.fields.List>` fields.
 


Test patch:
diff --git a/tests/test_ext_marshmallow_field.py b/tests/test_ext_marshmallow_field.py
index cd5b201..084b343 100644
--- a/tests/test_ext_marshmallow_field.py
+++ b/tests/test_ext_marshmallow_field.py
@@ -5,7 +5,7 @@ import pytest
 from marshmallow import fields, validate
 
 from .schemas import CategorySchema, CustomList, CustomStringField, CustomIntegerField
-from .utils import build_ref
+from .utils import build_ref, get_schemas
 
 
 def test_field2choices_preserving_order(openapi):
@@ -318,6 +318,52 @@ def test_nested_field_with_property(spec_fixture):
     }
 
 
+class TestField2PropertyPluck:
+    @pytest.fixture(autouse=True)
+    def _setup(self, spec_fixture):
+        self.field2property = spec_fixture.openapi.field2property
+
+        self.spec = spec_fixture.spec
+        self.spec.components.schema("Category", schema=CategorySchema)
+        self.unplucked = get_schemas(self.spec)["Category"]["properties"]["breed"]
+
+    def test_spec(self, spec_fixture):
+        breed = fields.Pluck(CategorySchema, "breed")
+        assert self.field2property(breed) == self.unplucked
+
+    def test_with_property(self):
+        breed = fields.Pluck(CategorySchema, "breed", dump_only=True)
+        assert self.field2property(breed) == {**self.unplucked, "readOnly": True}
+
+    def test_metadata(self):
+        breed = fields.Pluck(
+            CategorySchema,
+            "breed",
+            metadata={
+                "description": "Category breed",
+                "invalid_property": "not in the result",
+                "x_extension": "A great extension",
+            },
+        )
+        assert self.field2property(breed) == {
+            **self.unplucked,
+            "description": "Category breed",
+            "x-extension": "A great extension",
+        }
+
+    def test_many(self):
+        breed = fields.Pluck(CategorySchema, "breed", many=True)
+        assert self.field2property(breed) == {"type": "array", "items": self.unplucked}
+
+    def test_many_with_property(self):
+        breed = fields.Pluck(CategorySchema, "breed", many=True, dump_only=True)
+        assert self.field2property(breed) == {
+            "items": self.unplucked,
+            "type": "array",
+            "readOnly": True,
+        }
+
+
 def test_custom_properties_for_custom_fields(spec_fixture):
     def custom_string2properties(self, field, **kwargs):
         ret = {}
diff --git a/tests/test_ext_marshmallow_openapi.py b/tests/test_ext_marshmallow_openapi.py
index 4df2aaa..72ab8c6 100644
--- a/tests/test_ext_marshmallow_openapi.py
+++ b/tests/test_ext_marshmallow_openapi.py
@@ -391,6 +391,16 @@ class TestNesting:
         assert ("i" in props) == (modifier == "only")
         assert ("j" not in props) == (modifier == "only")
 
+    def test_schema2jsonschema_with_plucked_field(self, spec_fixture):
+        class PetSchema(Schema):
+            breed = fields.Pluck(CategorySchema, "breed")
+
+        category_schema = spec_fixture.openapi.schema2jsonschema(CategorySchema)
+        pet_schema = spec_fixture.openapi.schema2jsonschema(PetSchema)
+        assert (
+            pet_schema["properties"]["breed"] == category_schema["properties"]["breed"]
+        )
+
     def test_schema2jsonschema_with_nested_fields_with_adhoc_changes(
         self, spec_fixture
     ):
@@ -414,6 +424,20 @@ class TestNesting:
             CategorySchema
         )
 
+    def test_schema2jsonschema_with_plucked_fields_with_adhoc_changes(
+        self, spec_fixture
+    ):
+        category_schema = CategorySchema()
+        category_schema.fields["breed"].dump_only = True
+
+        class PetSchema(Schema):
+            breed = fields.Pluck(category_schema, "breed", many=True)
+
+        spec_fixture.spec.components.schema("Pet", schema=PetSchema)
+        props = get_schemas(spec_fixture.spec)["Pet"]["properties"]
+
+        assert props["breed"]["items"]["readOnly"] is True
+
     def test_schema2jsonschema_with_nested_excluded_fields(self, spec):
         category_schema = CategorySchema(exclude=("breed",))