Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 + '.')) {
Expand Down Expand Up @@ -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<String> names = source.getPropertyNames()
if (!names.any { String name -> name =~ MULTIPLE_INDEXERS_REGEX }) {
return source
}
Map<String, Object> grouped = new LinkedHashMap<String, Object>()
Map<String, Map<String, Object>> elements = new LinkedHashMap<String, Map<String, Object>>()
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A parameter named ignored[0][first name] used to be skipped. It matches the outer pattern and fails the inner one, because the key has a space. group(1) then throws, and the whole bind stops. If the inner pattern does not match, skip that name.

if (key.size() > 2 && ((key.startsWith("'") && key.endsWith("'")) || (key.startsWith('"') && key.endsWith('"')))) {
key = key[1..-2]
}
elements.computeIfAbsent(matcher.group(1)) { new LinkedHashMap<String, Object>() }.put(key + rest.group(2), source.getPropertyValue(name))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A list already holds [data: old]. The request is columns[0][data]=new. The selected map should become [data: new]. It stays [data: old]. The grouped value is bound as bean properties, and a map has none named data, so the entry is ignored. Write the grouped entries into the selected map.

} else {
grouped.put(name, source.getPropertyValue(name))
}
}
elements.each { String element, Map<String, Object> entries ->
def existing = grouped.get(element)
if (existing instanceof Map) {
entries.putAll((Map) existing)
}
grouped.put(element, entries)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The source has factories[0] set to an existing factory, and factories[0][isActive] set to true. The existing factory should keep its identity and its other fields. This replaces it with a new map, so the name and the instance are gone. Keep a supplied object that is not a map, and apply the grouped entries to it.

}
new SimpleMapDataBindingSource(grouped)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caller set dataSourceAware to false. Adding an unrelated name such as ignored[0][name] replaces the source with one that has it true. A later property can then load and bind an existing instance. Copy the caller's setting onto the grouped source.

}

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 + '.') })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -778,6 +836,11 @@ class DateCollection {
List<Date> dates
}

class IndexedTable {
List<Map> columns
List<Factory> factories
}

class Comment {
Set<Attachment> attachments
}
Expand Down
14 changes: 14 additions & 0 deletions grails-doc/src/en/guide/theWebLayer/controllers/dataBinding.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map> 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]
Expand Down
Loading