Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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)

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 is the other half of the Set issue I raised on #15804, and it is still open. Set reaches this branch (Collection.isAssignableFrom(Set) is true), so a Set-typed property still requires a non-negative integer key here — even though the parsed index is dead for Sets: isOkToAddElementAt ignores index when the collection is a Set (it only checks collection.size() < autoGrowCollectionLimit), and addElementToCollectionAt then calls collection.add(val). Nothing downstream consumes the value.

The consequence is that the fix in GrailsWebDataBinder only covers the update-existing-by-id path. When the map has no id, getIdentifierValueFrom(val) returns null, needsBinding stays true, and the call falls through to this branch. So the same association accepts an arbitrary grouping key for an update and rejects it for an insert:

authors[foo]: [id: <existing>, name: 'Renamed']   -> binds (your fix)
authors[foo]: [name: 'Brand New Author']          -> binding error, nothing added
authors[0]:   [name: 'Brand New Author']          -> binds

A plain non-domain Set is rejected outright: setOfCodes[foo] yields a binding error and leaves the property null.

Please move the parse inside the non-Set path so the index is only required where it is actually used, e.g.:

} else if (Collection.isAssignableFrom(propertyType)) {
    boolean isSet = Set.isAssignableFrom(propertyType)
    Integer index = 0
    if (!isSet) {
        index = parseIndexedPropertyIndex(obj, indexedPropertyReferenceDescriptor, val, listener, errors)
        if (index == null) {
            return
        }
    }
    Collection collectionInstance = initializeCollection(obj, propName, propertyType)
    def indexedInstance = null
    if (!isSet) {
        indexedInstance = collectionInstance[index]
    }

That makes the core binder agree with the Set branch in GrailsWebDataBinder and with the guide text this PR adds.

if (index == null) {
return
}
Collection collectionInstance = initializeCollection(obj, propName, propertyType)
def indexedInstance = null
if (!(Set.isAssignableFrom(propertyType))) {
Expand Down Expand Up @@ -394,6 +400,37 @@ class SimpleDataBinder implements DataBinder {
}
}

/**
* Parses an indexed binding path segment as a non-negative integer.
* <p>
* Indexed properties follow the JavaBeans model (spec v1.01, section 7.2):
* array-typed properties with paired {@code int}-indexed accessors. Grails
* extends that model to positional collection binding; negative indexes are
* rejected because they are not part of the beans model (the prior
* {@code [-1]} behavior was Groovy list semantics leaking through the binder).
* </p>
*
* @return the parsed index, or {@code null} when the segment is rejected as a binding error
*/
protected Integer parseIndexedPropertyIndex(obj, IndexedPropertyReferenceDescriptor indexedPropertyReferenceDescriptor,
val, DataBindingListener listener, errors) {

try {
Integer index = Integer.parseInt(indexedPropertyReferenceDescriptor.index)
if (index < 0) {
// Intentional: report the same generic NumberFormatException as a
// malformed index so untrusted request data cannot distinguish that
// negative indexes are handled explicitly.
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.

The comment is the right idea but the code does not deliver what it claims, so as written it will mislead a future reader into leaving a real gap in place.

Integer.parseInt builds its own message, and this throw supplies a different one, so the two rejections are distinguishable by message:

listOfCodes[-1]  -> NumberFormatException, message: -1
listOfCodes[bad] -> NumberFormatException, message: For input string: "bad"

That difference is not internal: GrailsWebDataBindingListener line 55 does def defaultMessage = error.cause?.message ?: 'Data Binding Failed', so the message becomes the FieldError's defaultMessage and is rendered whenever no message code resolves. The shape of the message therefore reveals that the index parsed successfully and was rejected by a separate check. As a side effect, a rejected [-1] currently surfaces to the user as the bare text -1, which is not a usable error message either.

Construct the exception identically on both paths rather than inheriting parseInt's message — then the comment's guarantee actually holds:

protected Integer parseIndexedPropertyIndex(obj, IndexedPropertyReferenceDescriptor indexedPropertyReferenceDescriptor,
    val, DataBindingListener listener, errors) {

    String rawIndex = indexedPropertyReferenceDescriptor.index
    Integer index = null
    try {
        index = Integer.parseInt(rawIndex)
    }
    catch (NumberFormatException ignored) {
        // handled below, with the same error as a negative index
    }
    if (index == null || index < 0) {
        // Intentional: malformed and negative indexes are reported identically, so
        // untrusted request data cannot tell that negative indexes are handled by a
        // separate check. Do not "improve" this into two distinct messages.
        addBindingError(obj, indexedPropertyReferenceDescriptor.toString(), val,
                new NumberFormatException("For input string: \"${rawIndex}\""), listener, errors)
        return null
    }
    index
}

This also drops the throw-as-control-flow, which is what made the original shape fragile.

}
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) {

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.

Missing @Override. DataBindingListenerAdapter.bindingError(BindingError, Object) is a Java method, so this compiles either way, but a typo in the name or arity would silently stop collecting errors and the assertions would pass against an always-empty list. The equivalent listener added in GrailsWebDataBinderSpec in this same PR does annotate it — worth matching:

@Override
void bindingError(BindingError error, errors) {
    bindingErrors << error
}

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: Array and positional collection binding follow the JavaBeans indexed-property model (spec v1.01, section 7.2), which only defines non-negative `int` indexes with array semantics. 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. The JavaBeans specification does not cover `Set` binding; `Set` association keys remain arbitrary grouping keys (see below). 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.

This is much better than the previous wording, and the map-key sentence is accurate — I confirmed players[guitar] and even a literal -1 map key still bind as keys.

But "Set association keys remain arbitrary grouping keys" overstates what the code does today, in two ways:

  • It is only true on the update-existing-by-id path. Adding a new element with no id falls through to SimpleDataBinder, which still requires a non-negative integer — see my comment on the Collection branch there.
  • It is scoped to association keys, but a reader will reasonably apply it to the generic Set paragraph immediately below. A plain Set<String> property is not an association and is rejected: setOfCodes[foo] produces a binding error.

If you take the SimpleDataBinder fix, this sentence becomes true as written and nothing here needs to change. If you would rather keep the current behavior, then this sentence has to say that arbitrary keys apply only when updating an existing element of a Set association by id — which is a contract worth avoiding, so I would prefer the code fix.


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.
* The value of the indexes used in albums[0] and albums[1] are
* arbitrary values 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, List, and positional 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 @@ -800,6 +800,34 @@ class GrailsWebDataBinderSpec extends Specification implements DataTest {
updatedA3.name == 'Author Tres'
}

void 'Test updating Set elements by id with non-numeric grouping 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.

Good test, and it does pin the regression I flagged. Two gaps that matter for keeping it pinned:

  1. Nothing covers a negative key on the Set path. authors[-1]: [id: <existing>, ...] is accepted today (I verified: it binds, no error), and that acceptance is deliberate — a Set key is not a position. But in a PR whose entire purpose is rejecting [-1], an untested acceptance is the first thing a future "consistency" cleanup will remove. Please add a case asserting authors[-1] with an id still binds and raises no binding error.

  2. Nothing covers adding a new element to a Set with a non-numeric key. That case currently fails (authors[foo]: [name: 'Brand New Author'] -> binding error, nothing added). Once the SimpleDataBinder branch is fixed it should bind, so a test here would both drive that fix and lock it in.

A plain non-domain Set case belongs in CollectionBindingSpec alongside the new List/array tests, for the same reason.


given:
def publisher = new Publisher(name: 'Some Publisher')

when:
def a1 = new Author(name: 'Author One').save()
def a2 = new Author(name: 'Author Two').save()
publisher.addToAuthors(a1)
publisher.addToAuthors(a2)

then:
a1.id != null
a2.id != null

when:
binder.bind(publisher, new SimpleMapDataBindingSource([
'authors[foo]': [id: a2.id, name: 'Author Dos'],
'authors[bar]': [id: a1.id, name: 'Author Uno']
]))
def updatedA1 = publisher.authors.find { it.id == a1.id }
def updatedA2 = publisher.authors.find { it.id == a2.id }

then:
updatedA1.name == 'Author Uno'
updatedA2.name == 'Author Dos'
}

void 'Test updating Set elements by id'() {

given:
Expand Down Expand Up @@ -957,6 +985,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,8 @@ class GrailsWebDataBinder extends SimpleDataBinder {
if (referencedType != null && isDomainClass(referencedType)) {
needsBinding = false
if (Set.isAssignableFrom(metaProperty.type)) {
// Set association keys are grouping keys (not positions); do not
// require a numeric index. Selection is by id.
def collection = initializeCollection(obj, propName, metaProperty.type)
def instance
if (collection != null) {
Expand All @@ -448,7 +450,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, 0, instance)

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 behavior is right, but a bare 0 in an argument position named index reads as "add at the front" and invites someone to "fix" it back to the parsed key. Worth stating why any value is inert here: this branch is guarded by Set.isAssignableFrom(metaProperty.type), and for a Set addElementToCollectionAt never uses the index — isOkToAddElementAt only checks collection.size() < autoGrowCollectionLimit and the add is collection.add(val).

Folding that into the comment you already added at the top of the branch would be enough, e.g. "...Selection is by id, and addElementToCollectionAt ignores the index for Sets, so the value passed below is inert."

}
}
if (instance != null) {
Expand All @@ -459,8 +461,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