Skip to content
Closed
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 @@ -333,13 +333,19 @@ class SimpleDataBinder implements DataBinder {
}

if (propertyType.isArray()) {
def index = Integer.parseInt(indexedPropertyReferenceDescriptor.index)
Integer index = parseIndexedPropertyIndex(obj, indexedPropertyReferenceDescriptor, val, listener, errors)
if (index == null) {
return
}
def array = initializeArray(obj, propName, propertyType.componentType, index)
if (array != null) {
addElementToArrayAt(array, index, val)
}
} else if (Collection.isAssignableFrom(propertyType)) {
def index = Integer.parseInt(indexedPropertyReferenceDescriptor.index)
Integer index = parseIndexedPropertyIndex(obj, indexedPropertyReferenceDescriptor, val, listener, errors)
if (index == null) {
return
}
Collection collectionInstance = initializeCollection(obj, propName, propertyType)
def indexedInstance = null
if (!(Set.isAssignableFrom(propertyType))) {
Expand Down Expand Up @@ -394,6 +400,22 @@ class SimpleDataBinder implements DataBinder {
}
}

protected Integer parseIndexedPropertyIndex(obj, IndexedPropertyReferenceDescriptor indexedPropertyReferenceDescriptor,

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.

Rejecting negative indexes is the correct behavior: the JavaBeans specification (v1.01, section 7.2) defines indexed properties as array-typed properties with paired int-indexed accessors, where an invalid index may throw ArrayIndexOutOfBoundsException. Grails' indexed binding to collections is an extension of that model, and this change aligns the extension with the spec's array semantics - the prior [-1] behavior was Groovy list semantics leaking through, never valid under the beans model. Please reference the spec section in this method's groovydoc and in the PR description so the rationale is on record.

val, DataBindingListener listener, errors) {

try {
Integer index = Integer.parseInt(indexedPropertyReferenceDescriptor.index)
if (index < 0) {
throw new NumberFormatException(indexedPropertyReferenceDescriptor.index)

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.

Since this path binds untrusted request data, the reported binding error must stay generic - indistinguishable from the malformed-index case - so the source cannot tell we handle negative indexes explicitly. The current shape achieves that, but only by accident of throwing NumberFormatException as control flow. Please add a code comment documenting that the uniform error is intentional, so a future cleanup does not "improve" it into a distinct, more descriptive message.

}
index
}
catch (NumberFormatException e) {
addBindingError(obj, indexedPropertyReferenceDescriptor.toString(), val, e, listener, errors)
null
}
}

@CompileStatic(TypeCheckingMode.SKIP)
protected initializeArray(obj, String propertyName, Class arrayType, int index) {
Object[] array = obj[propertyName]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
*/
package grails.databinding

import grails.databinding.SimpleDataBinder;
import grails.databinding.SimpleMapDataBindingSource;
import grails.databinding.errors.BindingError
import grails.databinding.events.DataBindingListenerAdapter
import spock.lang.Specification

class CollectionBindingSpec extends Specification {
Expand Down Expand Up @@ -111,6 +111,60 @@ class CollectionBindingSpec extends Specification {
company.departments[2].numberOfEmployees == 99
}

void 'Test negative indexed binding to a List is rejected'() {
given:
def binder = new SimpleDataBinder()
def company = new Company()
def listener = new CollectionBindingListener()

when:
binder.bind company, new SimpleMapDataBindingSource([
'departments[-1]': [name: 'Bad Department'],
'departments[0]': [name: 'Department Zero']]), listener

then:
company.departments.size() == 1
company.departments[0].name == 'Department Zero'
listener.bindingErrors.size() == 1
listener.bindingErrors[0].propertyName == 'departments[-1]'
listener.bindingErrors[0].rejectedValue == [name: 'Bad Department']
}

void 'Test malformed indexed binding to a List is rejected'() {
given:
def binder = new SimpleDataBinder()
def company = new Company()
def listener = new CollectionBindingListener()

when:
binder.bind company, new SimpleMapDataBindingSource([
'departments[bad]': [name: 'Bad Department'],
'departments[0]': [name: 'Department Zero']]), listener

then:
company.departments.size() == 1
company.departments[0].name == 'Department Zero'
listener.bindingErrors.size() == 1
listener.bindingErrors[0].propertyName == 'departments[bad]'
listener.bindingErrors[0].rejectedValue == [name: 'Bad Department']
}

void 'Test negative indexed binding to an array is rejected'() {
given:
def binder = new SimpleDataBinder()
def library = new Library()
def listener = new CollectionBindingListener()

when:
binder.bind library, new SimpleMapDataBindingSource(['codes[-1]': 'bad', 'codes[0]': 'good']), listener

then:
library.codes as List == ['good']
listener.bindingErrors.size() == 1
listener.bindingErrors[0].propertyName == 'codes[-1]'
listener.bindingErrors[0].rejectedValue == 'bad'
}

void 'Test binding to an untyped List'() {
given:
def binder = new SimpleDataBinder()
Expand Down Expand Up @@ -156,9 +210,22 @@ class Company {
List<Department> departments
}

class Library {
String[] codes
}

class Department {
String name
Integer numberOfEmployees
List listOfCodes
Set setOfCodes
}

class CollectionBindingListener extends DataBindingListenerAdapter {

List<BindingError> bindingErrors = []

void bindingError(BindingError error, errors) {
bindingErrors << error
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,15 @@ assert band.albums[1].numberOfTracks == 7

That code would work in the same way if `albums` were an array instead of a `List`.

NOTE: When binding to an array, a `Collection`, or a many-ended domain association by index, the value inside square brackets must be a non-negative integer. Entries such as `albums[-1]` or `albums[bogus]` are rejected as binding errors. The error field name includes the offending indexed segment, that binding path is skipped, and the target array, collection, or association is not changed by that entry. Map keys are not interpreted as numeric indexes, so keys such as `players[guitar]` remain valid map keys.

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.

State the rationale here rather than presenting this as an arbitrary rule: array and positional collection binding follows the JavaBeans indexed-property model (spec v1.01, section 7.2), which only defines non-negative int indexes with array semantics. Note the spec does not cover Set binding at all - see my comment on the Set branch in GrailsWebDataBinder. If Set keys remain arbitrary grouping keys per the existing documented contract, the change below to "non-negative integers that only need to be unique" overstates the restriction and should be reverted for the Set case.


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]
----
/*
* The value of the indexes 0 and 1 in albums[0] and albums[1] are arbitrary
* values that can be anything as long as they are unique within the Map.
* non-negative integers that only need to be unique within the Map.
* They do not correspond to the order of elements in albums because albums
* is a Set.
*/
Expand Down Expand Up @@ -516,7 +518,7 @@ class AccountingController {
==== Data binding and type conversion errors


Sometimes when performing data binding it is not possible to convert a particular String into a particular target type. This results in a type conversion error. Grails will retain type conversion errors inside the link:{domainClassesRef}errors.html[errors] property of a Grails domain class. For example:
Sometimes when performing data binding it is not possible to convert a particular String into a particular target type. This results in a type conversion error. Grails will retain type conversion errors inside the link:{domainClassesRef}errors.html[errors] property of a Grails domain class. Invalid indexed array, collection, and many-ended association binding paths, such as negative or non-integer indexes, are also retained as binding errors, and the field name identifies the indexed path that was rejected. For example:

[source,groovy]
----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,66 @@ class GrailsWebDataBinderSpec extends Specification implements DataTest {
publisher.publications[0].title == 'Definitive Guide To Grails 2'
}

void 'Test negative indexed domain association binding to a List is rejected'() {

given:
def bindingErrors = [] as List<BindingError>
def listener = new DataBindingListenerAdapter() {
@Override void bindingError(BindingError error, Object errors) {
bindingErrors << error
}
}
def publication = new Publication(title: 'Definitive Guide To Grails', author: new Author(name: 'Author Name'))
def publisher = new Publisher(name: 'Apress').save()
publisher.addToPublications(publication)
publisher.save(flush: true)

when:
binder.bind(publisher, new SimpleMapDataBindingSource([
'publications[-1]': [
id: publication.id,
title: 'Definitive Guide To Grails 2'
]
]), listener)

then:
publisher.publications.size() == 1
publisher.publications[0].title == 'Definitive Guide To Grails'
bindingErrors.size() == 1
bindingErrors[0].propertyName == 'publications[-1]'
bindingErrors[0].rejectedValue == [id: publication.id, title: 'Definitive Guide To Grails 2']
}

void 'Test malformed indexed domain association binding to a List is rejected'() {

given:
def bindingErrors = [] as List<BindingError>
def listener = new DataBindingListenerAdapter() {
@Override void bindingError(BindingError error, Object errors) {
bindingErrors << error
}
}
def publication = new Publication(title: 'Definitive Guide To Grails', author: new Author(name: 'Author Name'))
def publisher = new Publisher(name: 'Apress').save()
publisher.addToPublications(publication)
publisher.save(flush: true)

when:
binder.bind(publisher, new SimpleMapDataBindingSource([
'publications[bad]': [
id: publication.id,
title: 'Definitive Guide To Grails 2'
]
]), listener)

then:
publisher.publications.size() == 1
publisher.publications[0].title == 'Definitive Guide To Grails'
bindingErrors.size() == 1
bindingErrors[0].propertyName == 'publications[bad]'
bindingErrors[0].rejectedValue == [id: publication.id, title: 'Definitive Guide To Grails 2']
}

void 'Test using @BindUsing to initialize property with a type other than the declared type'() {

given:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,10 @@ class GrailsWebDataBinder extends SimpleDataBinder {
if (referencedType != null && isDomainClass(referencedType)) {
needsBinding = false
if (Set.isAssignableFrom(metaProperty.type)) {
Integer index = parseIndexedPropertyIndex(obj, indexedPropertyReferenceDescriptor, val, listener, errors)

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.

This changes documented behavior that the JavaBeans rationale does not cover in either direction: spec indexed properties are array-typed with int accessors, so Set binding keys have no spec standing at all - the arbitrary-unique-key convention here is purely Grails' own documented contract (the guide says the values "can be anything as long as they are unique within the Map"). The old code only reached Integer.parseInt in the add-queried-instance path, so updating an existing Set element by id (e.g. albums[foo]: [id: 1, ...]) never parsed the index and worked with non-numeric keys; parsing at the top of the branch now rejects those previously valid paths. Requiring a non-negative integer here also falsely implies the key is positional when a Set has no positions. Either defer the parse to the addElementToCollectionAt call as before, or treat Set keys like map keys.

if (index == null) {
return
}
def collection = initializeCollection(obj, propName, metaProperty.type)
def instance
if (collection != null) {
Expand All @@ -448,7 +452,7 @@ class GrailsWebDataBinder extends SimpleDataBinder {
Exception e = new IllegalArgumentException(message)
addBindingError(obj, propName, idValue, e, listener, errors)
} else {
addElementToCollectionAt(obj, propName, collection, Integer.parseInt(indexedPropertyReferenceDescriptor.index), instance)
addElementToCollectionAt(obj, propName, collection, index, instance)
}
}
if (instance != null) {
Expand All @@ -459,8 +463,11 @@ class GrailsWebDataBinder extends SimpleDataBinder {
}
}
} else if (Collection.isAssignableFrom(metaProperty.type)) {
Integer idx = parseIndexedPropertyIndex(obj, indexedPropertyReferenceDescriptor, val, listener, errors)
if (idx == null) {
return
}
def collection = initializeCollection(obj, propName, metaProperty.type)
def idx = Integer.parseInt(indexedPropertyReferenceDescriptor.index)
if ('null' == idValue) {
if (idx < collection.size()) {
def element = collection[idx]
Expand Down
Loading