Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6309f18
Add opt-in nullMissing support to bindData for stale-data clearing
jamesfredley Jul 9, 2026
018220f
Merge deny-by-default binding into nullMissing branch
jamesfredley Jul 17, 2026
7383989
Merge origin/8.0.x into fix/binddata-null-missing-stale-data
jamesfredley Jul 17, 2026
990a79d
Merge fix/binddata-mass-assignment into fix/binddata-null-missing-sta…
jamesfredley Jul 17, 2026
62d4aad
Merge branch 'fix/binddata-mass-assignment' into fix/binddata-null-mi…
jamesfredley Jul 29, 2026
59ec123
Merge branch 'fix/binddata-mass-assignment' into fix/binddata-null-mi…
jamesfredley Aug 2, 2026
1f56694
Merge branch 'fix/binddata-mass-assignment' into fix/binddata-null-mi…
jamesfredley Aug 2, 2026
cbfd385
Consolidate framework-managed data binding properties
jamesfredley Aug 2, 2026
dc719ce
Extract nullMissing property clearing
jamesfredley Aug 2, 2026
2b53efd
Fix domain binding spec cleanup lifecycle
jamesfredley Aug 2, 2026
50714be
Keep Grails-managed domain properties bindable when opted in
jamesfredley Aug 2, 2026
9cd1bde
Merge branch 'fix/binddata-mass-assignment' into fix/binddata-null-mi…
jamesfredley Aug 5, 2026
69ed46e
Merge binding security updates into nullMissing work
jamesfredley Aug 6, 2026
bcb05af
Merge branch 'fix/binddata-mass-assignment' into fix/binddata-null-mi…
jamesfredley Aug 6, 2026
8cc9cee
Rename `nullMissing` to `clearMissing`
matrei Aug 11, 2026
c4e58c2
docs: clarify behavior of clearMissing in FrameworkPropertyNames
matrei Aug 11, 2026
3352c00
test: improve clarity of test names for clearMissing behavior
matrei Aug 11, 2026
e133a02
docs: enhance bindData documentation with wildcard usage and clearMis…
matrei Aug 11, 2026
0c87633
test: improve clarity of bindData test names and enhance clearMissing…
matrei Aug 11, 2026
1309165
feat: implement wildcard expansion for included properties in clearMi…
matrei Aug 11, 2026
4633c67
docs: clarify behavior of clearMissing for Grails-managed properties
matrei Aug 11, 2026
ecff934
chore: cleanup `DefaultASTDatabindingHelperDomainClassSpecialProperti…
matrei Aug 14, 2026
a6f4846
Merge branch '8.0.x' into fix/binddata-null-missing-stale-data
jamesfredley Aug 15, 2026
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'() {
Comment thread
jamesfredley marked this conversation as resolved.
Outdated
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