diff --git a/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy b/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy index 3f3ed13bae3..4b3d3c7b584 100755 --- a/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy +++ b/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy @@ -94,6 +94,9 @@ class SimpleDataBinder implements DataBinder { static final INDEXED_PROPERTY_REGEX = /(.*)\[\s*([^\s]*)\s*\]\s*$/ + /** A name with more than one indexer, split after the first one: {@code columns[0]} and {@code [data]}. */ + static final MULTIPLE_INDEXERS_REGEX = /^([^\[\]]+\[[^\[\]]*\])(\[[^\[\]]*\].*)$/ + int autoGrowCollectionLimit = 256 SimpleDataBinder() { @@ -221,6 +224,7 @@ class SimpleDataBinder implements DataBinder { protected void doBind(obj, DataBindingSource source, String filter, List whiteList, List blackList, DataBindingListener listener, errors) { + source = groupMultipleIndexers(source) def keys = source.getPropertyNames() for (String key in keys) { if (!filter || key.startsWith(filter + '.')) { @@ -268,6 +272,44 @@ class SimpleDataBinder implements DataBinder { } } + /** + * Binds the first indexer of a name that has several, so {@code columns[0][data]} sets the + * {@code data} entry of the element at {@code columns[0]}, the same as {@code columns[0].data} + * does. The entries of one element are gathered into a map under {@code columns[0]}. + * + * @return the source itself when no name has more than one indexer, else a copy where those names are grouped + */ + protected DataBindingSource groupMultipleIndexers(DataBindingSource source) { + Set names = source.getPropertyNames() + if (!names.any { String name -> name =~ MULTIPLE_INDEXERS_REGEX }) { + return source + } + Map grouped = new LinkedHashMap() + Map> elements = new LinkedHashMap>() + for (String name in names) { + def matcher = name =~ MULTIPLE_INDEXERS_REGEX + if (matcher) { + def rest = matcher.group(2) =~ /^\[\s*([^\s\]]*)\s*\](.*)$/ + rest.matches() + String key = rest.group(1) + if (key.size() > 2 && ((key.startsWith("'") && key.endsWith("'")) || (key.startsWith('"') && key.endsWith('"')))) { + key = key[1..-2] + } + elements.computeIfAbsent(matcher.group(1)) { new LinkedHashMap() }.put(key + rest.group(2), source.getPropertyValue(name)) + } else { + grouped.put(name, source.getPropertyValue(name)) + } + } + elements.each { String element, Map entries -> + def existing = grouped.get(element) + if (existing instanceof Map) { + entries.putAll((Map) existing) + } + grouped.put(element, entries) + } + new SimpleMapDataBindingSource(grouped) + } + protected boolean isOkToBind(String propName, List whiteList, List blackList) { 'class' != propName && 'classLoader' != propName && 'protectionDomain' != propName && 'metaClass' != propName && 'metaPropertyValues' != propName && 'properties' != propName && !blackList?.contains(propName) && (!whiteList || whiteList.contains(propName) || whiteList.find { it -> it?.toString()?.startsWith(propName + '.') }) } diff --git a/grails-databinding-core/src/test/groovy/grails/databinding/SimpleDataBinderSpec.groovy b/grails-databinding-core/src/test/groovy/grails/databinding/SimpleDataBinderSpec.groovy index 67c2e7cff3c..26e7e95429c 100755 --- a/grails-databinding-core/src/test/groovy/grails/databinding/SimpleDataBinderSpec.groovy +++ b/grails-databinding-core/src/test/groovy/grails/databinding/SimpleDataBinderSpec.groovy @@ -587,6 +587,64 @@ class SimpleDataBinderSpec extends Specification { widget.factories.find { it.name == 'Tres' } } + @Issue('https://github.com/apache/grails-core/issues/15064') + void 'A name with two indexers binds the first as the index and the second as a key of that element'() { + given: + def binder = new SimpleDataBinder() + def table = new IndexedTable() + + when: + binder.bind table, new SimpleMapDataBindingSource([ + 'columns[0][data]': 'id', 'columns[0][name]': 'Id', 'columns[1][data]': 'title', + 'columns[1][search][value]': 'x' + ]) + + then: + table.columns.size() == 2 + table.columns[0] == [data: 'id', name: 'Id'] + table.columns[1].data == 'title' + table.columns[1]['search[value]'] == 'x' + } + + @Issue('https://github.com/apache/grails-core/issues/15064') + void 'A name with two indexers binds the properties of a typed element'() { + given: + def binder = new SimpleDataBinder() + def table = new IndexedTable() + + when: + binder.bind table, new SimpleMapDataBindingSource(['factories[1][name]': 'Dos', 'factories[0][name]': 'Uno']) + + then: + table.factories*.name == ['Uno', 'Dos'] + } + + @Issue('https://github.com/apache/grails-core/issues/15064') + void 'A name with two indexers adds to an element given as a map and quoted keys work'() { + given: + def binder = new SimpleDataBinder() + def table = new IndexedTable() + + when: + binder.bind table, new SimpleMapDataBindingSource(['columns[0]': [data: 'id'], "columns[0]['name']": 'Id']) + + then: + table.columns == [[data: 'id', name: 'Id']] + } + + @Issue('https://github.com/apache/grails-core/issues/15064') + void 'names with one indexer are bound as before'() { + given: + def binder = new SimpleDataBinder() + def widget = new Widget() + + when: + binder.bind widget, new SimpleMapDataBindingSource(['integers[1]': 2, 'integers[0]': 1]) + + then: + widget.integers == [1, 2] + } + @Issue('GRAILS-10865') void 'Test binding to an inherited typed collection'() { given: @@ -778,6 +836,11 @@ class DateCollection { List dates } +class IndexedTable { + List columns + List factories +} + class Comment { Set attachments } diff --git a/grails-doc/src/en/guide/theWebLayer/controllers/dataBinding.adoc b/grails-doc/src/en/guide/theWebLayer/controllers/dataBinding.adoc index 4b90e07e9e2..2e7ee211e01 100644 --- a/grails-doc/src/en/guide/theWebLayer/controllers/dataBinding.adoc +++ b/grails-doc/src/en/guide/theWebLayer/controllers/dataBinding.adoc @@ -128,6 +128,20 @@ assert band.albums[1].numberOfTracks == 7 That code would work in the same way if `albums` were an array instead of a `List`. +A name may carry a second index, as form libraries that send `columns[0][data]` do. The first index selects the element and the second one is a key inside that element, so `columns[0][data]` binds the same as `columns[0].data` does. Names that share the first index are gathered into one element: + +[source,groovy] +---- +class Table { + List columns +} + +def table = new Table() +table.properties = ['columns[0][data]': 'id', 'columns[0][name]': 'Id'] + +assert table.columns[0] == [data: 'id', name: 'Id'] +---- + Note that when binding to a `Set` the structure of the `Map` being bound to the `Set` is the same as that of a `Map` being bound to a `List` but since a `Set` is unordered, the indexes don't necessarily correspond to the order of elements in the `Set`. In the code example above, if `albums` were a `Set` instead of a `List`, the `bindingMap` could look exactly the same but 'Foxtrot' might be the first album in the `Set` or it might be the second. When updating existing elements in a `Set` the `Map` being assigned to the `Set` must have `id` elements in it which represent the element in the `Set` being updated, as in the following example: [source,groovy]