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
3 changes: 3 additions & 0 deletions grails-doc/src/en/guide/upgrading/upgrading80x.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,9 @@ The Spring annotations still work, so this is non-blocking, but new code should
* **Namespaced link generation is namespace-aware.**
When a link, form action, pagination link, sortable column link, redirect, chain, or include targets a controller without an explicit `namespace`, Grails now resolves the namespace automatically. In the normal case, where only one controller has the target name, `controller` and `action` generate the correct namespaced or non-namespaced URL. Ambiguity only occurs when multiple controllers share the same name. In that case, specify `namespace` to choose the target explicitly. Pass `namespace: null` from Groovy code or `namespace=""` in a GSP tag to target the non-namespaced controller explicitly.

* **`bindData` can clear omitted included fields.**
`bindData(target, source, [include: [...], nullMissing: true])` now assigns `null` to included properties that are absent from the binding source. The behavior is opt-in, requires an `include` list, and does not apply globally. Existing `bindData` calls without `nullMissing: true` keep omitted fields unchanged.

==== 21. Tag Library Test Cleanup Changes

Grails 8 removes the `purgeTagLibMetaClass` test hook used by some web and TagLib unit tests.
Expand Down
7 changes: 6 additions & 1 deletion grails-doc/src/en/ref/Controllers/bindData.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ bindData(target, params, [exclude: ['firstName', 'lastName']], "author")

// using inclusive map
bindData(target, params, [include: ['firstName', 'lastName']], "author")

// clear included properties omitted from the source
bindData(target, params, [include: ['firstName', 'lastName'], nullMissing: true])
----


Expand All @@ -57,11 +60,13 @@ Arguments:

* `target` - The target object to bind to
* `params` - A `Map` of source parameters, often the link:params.html[params] object when used in a controller
* `includesExcludes` - (Optional) A map with 'include' and/or 'exclude' lists containing the names of properties to either include or exclude.
* `includesExcludes` - (Optional) A map with 'include' and/or 'exclude' lists containing the names of properties to either include or exclude. Set `nullMissing: true` with an `include` list to assign `null` to included properties that are omitted from the binding source.
* `prefix` - (Optional) A string representing a prefix to use to filter parameters. The method will automatically append a '.' when matching the prefix to parameters, so you can use 'author' to filter for parameters such as 'author.name'.

NOTE: Note that if an empty List or no List is provided as a value for the `include` parameter then all statically typed instance properties will be subject to binding if they are not explicitly excluded. See the link:{constraintsRefFromRef}bindable.html[bindable] constraint documentation for more information on how to control what is bindable and what is not.

`nullMissing` is opt-in and only applies when an `include` list is provided. This is useful for update forms where an omitted allowed field should clear an existing value instead of leaving stale persisted data. Excluded properties are not cleared.

The underlying implementation uses Spring's Data Binding framework. If the target is a domain class, type conversion errors are stored in the `errors` property of the domain class.

Refer to the section on link:{guidePath}theWebLayer.html#dataBinding[data binding] in the user guide for more information.
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,124 @@ class BindDataMethodTests extends Specification implements ControllerUnitTest<Bi
target.name == 'Lee Butts'
target.email == null
}

void 'Test bindData With Null Missing Clears Omitted Included Field'() {
when:
def model = controller.bindWithNullMissing()
def target = model.target

then:
target.name == 'Updated Name'
target.email == null
}

void 'Test bindData Without Null Missing Leaves Omitted Included Field'() {
when:
def model = controller.bindWithoutNullMissing()
def target = model.target

then:
target.name == 'Updated Name'
target.email == 'existing@example.com'
}

void 'Test bindData With Null Missing Without Include Leaves Omitted Field'() {
when:
def model = controller.bindWithNullMissingAndNoInclude()
def target = model.target

then:
target.name == 'Updated Name'
target.email == 'existing@example.com'
}

void 'Test bindData With Null Missing Leaves Excluded Omitted Field'() {
when:
def model = controller.bindWithNullMissingAndExclude()
def target = model.target

then:
target.name == 'Updated Name'
target.email == 'existing@example.com'
}

void 'Test bindData With Null Missing Leaves Root Excluded Nested Field'() {
when:
def model = controller.bindWithNullMissingAndRootExclude()
def target = model.target

then:
target.address.country == 'Existing Country'
}

void 'Test bindData With Null Missing Accepts Blank Included Field'() {
when:
def model = controller.bindWithNullMissingAndBlankValue()
def target = model.target

then:
target.name == 'Updated Name'
target.email == null
}

void 'Test bindData With Null Missing Handles Nested Indexed Paths'() {
when:
def model = controller.bindWithNullMissingAndIndexedPath()
def target = model.target

then:
target.children[0].name == null
target.children[1].name == 'Second Child'
}

void 'Test bindData With Null Missing Handles Prefixed Nested Indexed Paths'() {
when:
def model = controller.bindWithNullMissingAndPrefixedIndexedPath()
def target = model.target

then:
target.children[0].name == null
target.children[1].name == 'Second Child'
}

void 'Test bindData With Null Missing Handles Map Indexed Paths'() {
when:
def model = controller.bindWithNullMissingAndMapPath()
def target = model.target

then:
target.contacts.home.value == null
}

void 'Test bindData With Null Missing Honors Bindable False'() {
when:
def model = controller.bindWithNullMissingAndBindableFalse()
def target = model.target

then:
target.visible == 'updated'
target.protectedValue == 'keep-me'
}

void 'Test bindData With Null Missing Honors Nested Bindable False'() {
when:
def model = controller.bindWithNullMissingAndNestedBindableFalse()
def target = model.target

then:
target.protectedAddress.country == null
target.protectedAddress.secret == 'keep-me'
}

void 'Test bindData With Null Missing Honors Collection Element Bindable False'() {
when:
def model = controller.bindWithNullMissingAndCollectionElementBindableFalse()
def target = model.target

then:
target.protectedChildren[0].name == 'Existing Child'
target.protectedChildren[0].secret == 'keep-me'
}
}

@Artefact('Controller')
Expand Down Expand Up @@ -183,14 +301,121 @@ class BindingController {
bindData target, [ 'mark.name' : 'Marc Palmer', 'mark.email' : 'dontwantthis', 'lee.name': 'Lee Butts', 'lee.email': 'lee@mail.com'], disallowed, filter
[target: target]
}

def bindWithNullMissing() {
def target = new CommandObject(name: 'Existing Name', email: 'existing@example.com')
bindData target, [name: 'Updated Name'], [include: ['name', 'email'], nullMissing: true]
[target: target]
}

def bindWithoutNullMissing() {
def target = new CommandObject(name: 'Existing Name', email: 'existing@example.com')
bindData target, [name: 'Updated Name'], [include: ['name', 'email']]
[target: target]
}

def bindWithNullMissingAndNoInclude() {
def target = new CommandObject(name: 'Existing Name', email: 'existing@example.com')
bindData target, [name: 'Updated Name'], [nullMissing: true]
[target: target]
}

def bindWithNullMissingAndExclude() {
def target = new CommandObject(name: 'Existing Name', email: 'existing@example.com')
bindData target, [name: 'Updated Name'], [include: ['name', 'email'], exclude: ['email'], nullMissing: true]
[target: target]
}

def bindWithNullMissingAndRootExclude() {
def target = new CommandObject(address: new Address(country: 'Existing Country'))
bindData target, [name: 'Updated Name'], [include: ['address.country'], exclude: ['address'], nullMissing: true]
[target: target]
}

def bindWithNullMissingAndBlankValue() {
def target = new CommandObject(name: 'Existing Name', email: 'existing@example.com')
bindData target, [name: 'Updated Name', email: ''], [include: ['name', 'email'], nullMissing: true]
[target: target]
}

def bindWithNullMissingAndIndexedPath() {
def target = new CommandObject(children: [new Child(name: 'First Child'), new Child(name: 'Second Child')])
bindData target, ['children[0].age': '7'], [include: ['children.name'], nullMissing: true]
[target: target]
}

def bindWithNullMissingAndPrefixedIndexedPath() {
def target = new CommandObject(children: [new Child(name: 'First Child'), new Child(name: 'Second Child')])
bindData target, ['lee.children[0].age': '7'], [include: ['children.name'], nullMissing: true], 'lee'
[target: target]
}

def bindWithNullMissingAndMapPath() {
def target = new CommandObject(contacts: [home: new Contact(value: '555-1234')])
bindData target, ['contacts[home].type': 'phone'], [include: ['contacts[home].value'], nullMissing: true]
[target: target]
}

def bindWithNullMissingAndBindableFalse() {
def target = new ProtectedCommandObject(visible: 'old', protectedValue: 'keep-me')
bindData target, [visible: 'updated'], [include: ['visible', 'protectedValue'], nullMissing: true]
[target: target]
}

def bindWithNullMissingAndNestedBindableFalse() {
def target = new CommandObject(protectedAddress: new ProtectedAddress(country: 'Existing Country', secret: 'keep-me'))
bindData target, [name: 'Updated Name'], [include: ['protectedAddress.country', 'protectedAddress.secret'], nullMissing: true]
[target: target]
}

def bindWithNullMissingAndCollectionElementBindableFalse() {
def target = new CommandObject(protectedChildren: [new ProtectedChild(name: 'Existing Child', secret: 'keep-me')])
bindData target, ['protectedChildren[0].name': 'Updated Child'], [include: ['protectedChildren.name', 'protectedChildren.secret'], nullMissing: true]
[target: target]
}
}

class CommandObject {
String name
String email
Address address = new Address()
List<Child> children = []
Map<String, Contact> contacts = [:]
ProtectedAddress protectedAddress = new ProtectedAddress()
List<ProtectedChild> protectedChildren = []
}

class Address {
String country
}

class Child {
String name
Integer age
}

class Contact {
String type
String value
}

class ProtectedCommandObject {
public static final List<String> $defaultDatabindingWhiteList = ['visible']

String visible
String protectedValue
}

class ProtectedAddress {
public static final List<String> $defaultDatabindingWhiteList = ['country']

String country
String secret
}

class ProtectedChild {
public static final List<String> $defaultDatabindingWhiteList = ['name']

String name
String secret
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ trait DataBinder {
BindingResult bindData(target, bindingSource, Map includeExclude, String filter) {
List includeList = convertToListIfCharSequence(includeExclude?.include)
List excludeList = convertToListIfCharSequence(includeExclude?.exclude)
DataBindingUtils.bindObjectToInstance(target, bindingSource, includeList, excludeList, filter)
boolean nullMissing = includeExclude?.nullMissing == true
DataBindingUtils.bindObjectToInstance(target, bindingSource, includeList, excludeList, filter, nullMissing)
}

@Generated
Expand Down
Loading
Loading