Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nested record containing a query param of type List not being resolved correctly #32379

Closed
anthsup opened this issue Mar 5, 2024 · 6 comments
Labels
in: core Issues in core modules (aop, beans, core, context, expression) status: superseded An issue that has been superseded by another type: enhancement A general enhancement

Comments

@anthsup
Copy link

anthsup commented Mar 5, 2024

Affects: \every version up to 3.3.0-SNAPSHOT (tested with both 3.2.3 and 3.3.0-SNAPSHOT)

Hello!

I came across an issue with nested records used as query parameters. Here is what my Controller looks like:

@RestController
@RequestMapping("/api")
public class DemoController {

    @GetMapping
    public void test(RecordParamHolder paramHolder) {
        System.out.println("Record: " + paramHolder.recordParamName()); // not working when passing indexed param list
        System.out.println("POJO: " + paramHolder.pojoParamName()); // always working
    }
}

RecordParamHolder looks like this (as the names state 1st one is a POJO and the 2nd one is a record):

public record RecordParamHolder(
        PojoListParam pojoParamName,
        RecordListParam recordParamName
) {}

Both PojoListParam and RecordListParam contain one field of type List<String>. And so when I try to pass a list of params like this: GET localhost:8080/api?recordParamName.in%5B0%5D=abc (square brackets escaped) I see that the list is not being resolved and is NULL. However, similar request works fine for nested POJO class – GET localhost:8080/api?pojoParamName.in%5B0%5D=abc.

I know that I can pass a list like this: paramName=value1,value2,etc., but this approach doesn't work when there is a comma in any of the values (see this issue for example #29411). I know I can also pass it like this paramName=value1&paramName=value2&etc, but I was told it's inconvenient to build such request from JavaScript because paramName is being overwritten and only the last one is passed.

Anyway, I believe it should work properly for nested records just as it works for nested POJOs. Attaching a Minimal Reproducible Example.

Thanks in advance!

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Mar 5, 2024
@quaff
Copy link
Contributor

quaff commented Mar 6, 2024

Actually It failed if param name contains index (auto-growing) and no corresponding setter method, no matter it's nested or not, no matter it's record or not.

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.junit.jupiter.api.Test;

import org.springframework.beans.MutablePropertyValues;
import org.springframework.core.ResolvableType;
import org.springframework.validation.DataBinder;
import org.springframework.web.bind.WebDataBinder;

import static org.assertj.core.api.Assertions.assertThat;

class WebDataBindTests {

	@Test
	void nested() {
		Map<String, String> data = new HashMap<>();
		data.put("pojo.list[0]", "test");
		data.put("record.list[0]", "test");
		data.put("pojoWithoutSetter.list[0]", "test");

		WebDataBinder binder = new WebDataBinder(null, "test");
		binder.setTargetType(ResolvableType.forClass(Holder.class));
		binder.construct(new DataBinder.ValueResolver() {
			@Override
			public Object resolveValue(String name, Class<?> type) {
				return data.get(name);
			}

			@Override
			public Set<String> getNames() {
				return data.keySet();
			}
		});
		binder.bind(new MutablePropertyValues(data));

		Holder holder = (Holder) binder.getTarget();

		assertThat(holder.pojo.getList()).first().isEqualTo("test"); // works
		assertThat(holder.record.list()).first().isEqualTo("test"); // failed
		assertThat(holder.pojoWithoutSetter.getList()).first().isEqualTo("test"); // failed
	}

	@Test
	void topLevelRecord() {
		Map<String, String> data = new HashMap<>();
		data.put("list[0]", "test");
		WebDataBinder binder = new WebDataBinder(null, "test");
		binder.setTargetType(ResolvableType.forClass(Record.class));
		binder.construct(new DataBinder.ValueResolver() {
			@Override
			public Object resolveValue(String name, Class<?> type) {
				return data.get(name);
			}

			@Override
			public Set<String> getNames() {
				return data.keySet();
			}
		});
		binder.bind(new MutablePropertyValues(data));

		Record record = (Record) binder.getTarget();
		assertThat(record.list()).first().isEqualTo("test");
	}

	@Test
	void topLevelPojoWithoutSetter() {
		Map<String, String> data = new HashMap<>();
		data.put("list[0]", "test");
		WebDataBinder binder = new WebDataBinder(null, "test");
		binder.setTargetType(ResolvableType.forClass(PojoWithoutSetter.class));
		binder.construct(new DataBinder.ValueResolver() {
			@Override
			public Object resolveValue(String name, Class<?> type) {
				return data.get(name);
			}

			@Override
			public Set<String> getNames() {
				return data.keySet();
			}
		});
		binder.bind(new MutablePropertyValues(data));

		PojoWithoutSetter pojoWithoutSetter = (PojoWithoutSetter) binder.getTarget();
		assertThat(pojoWithoutSetter.getList()).first().isEqualTo("test");
	}

	record Holder(Pojo pojo, Record record, PojoWithoutSetter pojoWithoutSetter) {

	}

	record Record(List<String> list) {

	}

	static class Pojo {

		private List<String> list;

		public List<String> getList() {
			return this.list;
		}

		public void setList(List<String> list) {
			this.list = list;
		}

	}

	static class PojoWithoutSetter {

		private final List<String> list;

		PojoWithoutSetter(List<String> list) {
			this.list = list;
		}

		public List<String> getList() {
			return this.list;
		}

	}

}

@anthsup
Copy link
Author

anthsup commented Mar 6, 2024

@quaff Thanks for the info! So are you saying it can't possibly work with records because of their immutability?

@snicoll
Copy link
Member

snicoll commented Mar 6, 2024

The POJO is mutable so every parameter that is handled mutate the existing instance. Your record needs to be build with the list of things so you can't by desing provide indexed values. You would be able to call an API on your record to do the same thing if you did this manually.

@snicoll snicoll closed this as not planned Won't fix, can't repro, duplicate, stale Mar 6, 2024
@snicoll snicoll added status: invalid An issue that we don't feel is valid and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Mar 6, 2024
@quaff
Copy link
Contributor

quaff commented Mar 7, 2024

I think it's possible to support that since list is present with ValueResolver::getNames, then DataBinder should create an empty ArrayList instead of null passing into the constructor, and everything works as expected now, I've created PR #32386 to fix it. WDYT @rstoyanchev

Here is the workaround, @anthsup You can check null in record constructor.

	record Record(List<String> list) {
		public Record {
			if (list == null) {
				list = new ArrayList<>();
			}
		}
	}


	static class PojoWithoutSetter {

		private final List<String> list;

		PojoWithoutSetter(List<String> list) {
			if (list == null)
				list = new ArrayList<>();
			this.list = list;
		}

		public List<String> getList() {
			return this.list;
		}

	}

quaff added a commit to quaff/spring-framework that referenced this issue Mar 7, 2024
@sbrannen
Copy link
Member

@sbrannen sbrannen added in: core Issues in core modules (aop, beans, core, context, expression) type: enhancement A general enhancement status: superseded An issue that has been superseded by another and removed status: invalid An issue that we don't feel is valid labels Mar 11, 2024
@rstoyanchev
Copy link
Contributor

rstoyanchev commented Mar 12, 2024

Actually superseded by #32426.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: core Issues in core modules (aop, beans, core, context, expression) status: superseded An issue that has been superseded by another type: enhancement A general enhancement
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants