Skip to content

Fix deletion of component with null property #1755

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 4, 2018
Merged
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
10 changes: 0 additions & 10 deletions doc/reference/modules/component_mapping.xml
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,6 @@
model and persistence semantics are still slightly different.
</para>

<para>
A composite element mapping does not support null-able properties if you are using
a <literal>&lt;set&gt;</literal>. There is no separate primary key column in the
composite element table. NHibernate uses each column's value to identify a record
when deleting objects, which is not possible with null values. You have to either
use only not-null properties in a composite-element or choose a
<literal>&lt;list&gt;</literal>, <literal>&lt;map&gt;</literal>,
<literal>&lt;bag&gt;</literal> or <literal>&lt;idbag&gt;</literal>.
</para>

<para>
A special case of a composite element is a composite element with a nested
<literal>&lt;many-to-one&gt;</literal> element. This mapping allows you to map extra
Expand Down
102 changes: 102 additions & 0 deletions src/NHibernate.Test/Async/NHSpecificTest/GH1170/Fixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by AsyncGenerator.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------


using System.Linq;
using NUnit.Framework;
using NHibernate.Linq;

namespace NHibernate.Test.NHSpecificTest.GH1170
{
using System.Threading.Tasks;
[TestFixture]
public class FixtureAsync : BugTestCase
{
// Only the set case is tested, because other cases were not affected:
// - bags delete everything first.
// - indexed collections use their index, which is currently not mappable as a composite index with nullable
// column. All index columns are forced to not-nullable by mapping implementation. When using a formula in
// index, they use the element, but its columns are also forced to not-nullable.

[Test]
public async Task DeleteComponentWithNullAsync()
{
using (var session = OpenSession())
using (var tx = session.BeginTransaction())
{
var parent = await (session.Query<Parent>().SingleAsync());
Assert.That(
parent.ChildComponents,
Has.Count.EqualTo(2).And.One.Property(nameof(ChildComponent.SomeString)).Null);
parent.ChildComponents.Remove(parent.ChildComponents.Single(c => c.SomeString == null));
await (tx.CommitAsync());
}

using (var session = OpenSession())
using (var tx = session.BeginTransaction())
{
var parent = await (session.Query<Parent>().SingleAsync());
Assert.That(
parent.ChildComponents,
Has.Count.EqualTo(1).And.None.Property(nameof(ChildComponent.SomeString)).Null);
await (tx.CommitAsync());
}
}

[Test]
public async Task UpdateComponentWithNullAsync()
{
// Updates on set are indeed handled as delete/insert, so this test is not really needed.
using (var session = OpenSession())
using (var tx = session.BeginTransaction())
{
var parent = await (session.Query<Parent>().SingleAsync());
Assert.That(
parent.ChildComponents,
Has.Count.EqualTo(2).And.One.Property(nameof(ChildComponent.SomeString)).Null);
parent.ChildComponents.Single(c => c.SomeString == null).SomeString = "no more null";
await (tx.CommitAsync());
}

using (var session = OpenSession())
using (var tx = session.BeginTransaction())
{
var parent = await (session.Query<Parent>().SingleAsync());
Assert.That(
parent.ChildComponents,
Has.Count.EqualTo(2).And.None.Property(nameof(ChildComponent.SomeString)).Null);
await (tx.CommitAsync());
}
}

protected override void OnSetUp()
{
using (var session = OpenSession())
using (var tx = session.BeginTransaction())
{
var parent = new Parent();
parent.ChildComponents.Add(new ChildComponent { SomeBool = true, SomeString = "something" });
parent.ChildComponents.Add(new ChildComponent { SomeBool = false, SomeString = null });
session.Save(parent);

tx.Commit();
}
}

protected override void OnTearDown()
{
using (var session = OpenSession())
using (var tx = session.BeginTransaction())
{
session.Delete("from Parent");
tx.Commit();
}
}
}
}
90 changes: 90 additions & 0 deletions src/NHibernate.Test/NHSpecificTest/GH1170/Fixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Linq;
using NUnit.Framework;

namespace NHibernate.Test.NHSpecificTest.GH1170
{
[TestFixture]
public class Fixture : BugTestCase
{
// Only the set case is tested, because other cases were not affected:
// - bags delete everything first.
// - indexed collections use their index, which is currently not mappable as a composite index with nullable
// column. All index columns are forced to not-nullable by mapping implementation. When using a formula in
// index, they use the element, but its columns are also forced to not-nullable.
Copy link
Member Author

Choose a reason for hiding this comment

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

This "forced to not-nullable" thing seems to me a bit undue, as this impacts only ddl generation, forbidding to test such cases (unless tweaking the generated schema afterward), but not forbidding users not using schema generation to map such things, and insert null. Then these cases will have the delete issue too, and also an issue on update. But no one seems to ask for nullable support on those cases, so I have not handled them.


[Test]
public void DeleteComponentWithNull()
{
using (var session = OpenSession())
using (var tx = session.BeginTransaction())
{
var parent = session.Query<Parent>().Single();
Assert.That(
parent.ChildComponents,
Has.Count.EqualTo(2).And.One.Property(nameof(ChildComponent.SomeString)).Null);
parent.ChildComponents.Remove(parent.ChildComponents.Single(c => c.SomeString == null));
tx.Commit();
}

using (var session = OpenSession())
using (var tx = session.BeginTransaction())
{
var parent = session.Query<Parent>().Single();
Assert.That(
parent.ChildComponents,
Has.Count.EqualTo(1).And.None.Property(nameof(ChildComponent.SomeString)).Null);
tx.Commit();
}
}

[Test]
public void UpdateComponentWithNull()
{
// Updates on set are indeed handled as delete/insert, so this test is not really needed.
using (var session = OpenSession())
using (var tx = session.BeginTransaction())
{
var parent = session.Query<Parent>().Single();
Assert.That(
parent.ChildComponents,
Has.Count.EqualTo(2).And.One.Property(nameof(ChildComponent.SomeString)).Null);
parent.ChildComponents.Single(c => c.SomeString == null).SomeString = "no more null";
tx.Commit();
}

using (var session = OpenSession())
using (var tx = session.BeginTransaction())
{
var parent = session.Query<Parent>().Single();
Assert.That(
parent.ChildComponents,
Has.Count.EqualTo(2).And.None.Property(nameof(ChildComponent.SomeString)).Null);
tx.Commit();
}
}

protected override void OnSetUp()
{
using (var session = OpenSession())
using (var tx = session.BeginTransaction())
{
var parent = new Parent();
parent.ChildComponents.Add(new ChildComponent { SomeBool = true, SomeString = "something" });
parent.ChildComponents.Add(new ChildComponent { SomeBool = false, SomeString = null });
session.Save(parent);

tx.Commit();
}
}

protected override void OnTearDown()
{
using (var session = OpenSession())
using (var tx = session.BeginTransaction())
{
session.Delete("from Parent");
tx.Commit();
}
}
}
}
17 changes: 17 additions & 0 deletions src/NHibernate.Test/NHSpecificTest/GH1170/Mappings.hbm.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2"
assembly="NHibernate.Test"
namespace="NHibernate.Test.NHSpecificTest.GH1170">
<class name="Parent">
<id name="Id" column="id_column_of_parent">
<generator class="guid" />
</id>
<set name="ChildComponents">
<key column="key_column_of_child" />
<composite-element class="ChildComponent">
<property name="SomeBool" />
<property name="SomeString" />
</composite-element>
</set>
</class>
</hibernate-mapping>
37 changes: 37 additions & 0 deletions src/NHibernate.Test/NHSpecificTest/GH1170/Model.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;

namespace NHibernate.Test.NHSpecificTest.GH1170
{
public class Parent
{
public virtual ICollection<ChildComponent> ChildComponents { get; set; } = new List<ChildComponent>();
public virtual Guid Id { get; set; }
}

public class ChildComponent
{
public virtual bool SomeBool { get; set; }
public virtual string SomeString { get; set; }

protected bool Equals(ChildComponent other)
{
return SomeBool == other.SomeBool && string.Equals(SomeString, other.SomeString);
}

public override bool Equals(object obj)
Copy link
Member Author

@fredericDelaporte fredericDelaporte Jun 25, 2018

Choose a reason for hiding this comment

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

Not overriding it causes the set to always delete all its elements, causing the update test to be even more moot. (It is anyway somewhat moot, as with set, updates are handled by delete/insert.)

Copy link
Member

Choose a reason for hiding this comment

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

I think this behaviour needs to be changed (in a separate PR).

Copy link
Member Author

Choose a reason for hiding this comment

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

Do you mean, if the component reference has not changed, update it instead of deleting/inserting it? But the persistence context loaded state does not keep track of component references and so, does not allow to check for that condition.
Or do you mean we should somewhat "recycle" components to delete by updating them with data of components to add in order to replace as most as possible deletes/inserts by single updates? That would cause actual removals combined with adds to be replaced by updates, which could be quite unexpected for users, and breaking for custom auditing logic (like trigger based auditing).

Copy link
Member

Choose a reason for hiding this comment

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

I'm talking about this behaviour: "Not overriding it causes the set to always delete all its elements" it shouldn't be like this.

Copy link
Member Author

@fredericDelaporte fredericDelaporte Jul 4, 2018

Choose a reason for hiding this comment

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

From here:

Note: if you define an ISet of composite elements, it is very important to implement Equals() and GetHashCode() correctly.

It is in fact an error to map a set of components without a proper equal semantic. The set cannot enforce its semantic in such case: better use a bag instead.

Of course the side effect this error has about flushes, causing the whole set to be purged and recreated, could still be fixed. But that would be fixing a case resulting anyway from a bad mapping/implementation on the user side. Why not if this can be done in a simple way with very few impacts on normal uses, but otherwise, I do not think it should be fixed.

Fixing it will likely require to extract the set content as component values arrays, then searching for each array of values if we can find the same array in the loaded state. This looks to me as a non-negligible overhead, compared to just using the set Contains method. Moreover it would then ignore equals implementations having another semantic, which will be a possible breaking change.

{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
return obj is ChildComponent other && Equals(other);
}

public override int GetHashCode()
{
unchecked
{
return (SomeBool.GetHashCode() * 397) ^ (SomeString != null ? SomeString.GetHashCode() : 0);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Data;
using System.Data.Common;
Expand All @@ -32,7 +33,7 @@
using NHibernate.SqlTypes;
using NHibernate.Type;
using NHibernate.Util;
using Array=NHibernate.Mapping.Array;
using Array = NHibernate.Mapping.Array;

namespace NHibernate.Persister.Collection
{
Expand Down Expand Up @@ -138,7 +139,7 @@ protected async Task<int> WriteIndexAsync(DbCommand st, object idx, int i, ISess
return i + ArrayHelper.CountTrue(indexColumnIsSettable);
}

protected Task<int> WriteElementToWhereAsync(DbCommand st, object elt, int i, ISessionImplementor session, CancellationToken cancellationToken)
protected Task<int> WriteElementToWhereAsync(DbCommand st, object elt, bool[] columnNullness, int i, ISessionImplementor session, CancellationToken cancellationToken)
{
if (elementIsPureFormula)
{
Expand All @@ -152,11 +153,26 @@ protected Task<int> WriteElementToWhereAsync(DbCommand st, object elt, int i, IS
async Task<int> InternalWriteElementToWhereAsync()
{

await (ElementType.NullSafeSetAsync(st, elt, i, elementColumnIsInPrimaryKey, session, cancellationToken)).ConfigureAwait(false);
return i + elementColumnAliases.Length;
var settable = Combine(elementColumnIsInPrimaryKey, columnNullness);

await (ElementType.NullSafeSetAsync(st, elt, i, settable, session, cancellationToken)).ConfigureAwait(false);
return i + settable.Count(s => s);
}
}

// Since v5.2
[Obsolete("Use overload with columnNullness instead")]
protected Task<int> WriteElementToWhereAsync(DbCommand st, object elt, int i, ISessionImplementor session, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled<int>(cancellationToken);
}
return WriteElementToWhereAsync(st, elt, null, i, session, cancellationToken);
}

// No column nullness handling here: although a composite index could have null columns, the mapping
// current implementation forbirds this by forcing not-null to true on all columns.
protected Task<int> WriteIndexToWhereAsync(DbCommand st, object index, int i, ISessionImplementor session, CancellationToken cancellationToken)
{
if (indexContainsFormula)
Expand Down Expand Up @@ -333,19 +349,18 @@ public async Task DeleteRowsAsync(IPersistentCollection collection, object id, I
DbCommand st;
var expectation = Expectations.AppropriateExpectation(deleteCheckStyle);
//var callable = DeleteCallable;
var commandInfo = GetDeleteCommand(deleteByIndex, entry, out var columnNullness);

var useBatch = expectation.CanBeBatched;
if (useBatch)
{
st =
await (session.Batcher.PrepareBatchCommandAsync(SqlDeleteRowString.CommandType, SqlDeleteRowString.Text,
SqlDeleteRowString.ParameterTypes, cancellationToken)).ConfigureAwait(false);
st = await (session.Batcher.PrepareBatchCommandAsync(
commandInfo.CommandType, commandInfo.Text, commandInfo.ParameterTypes, cancellationToken)).ConfigureAwait(false);
}
else
{
st =
await (session.Batcher.PrepareCommandAsync(SqlDeleteRowString.CommandType, SqlDeleteRowString.Text,
SqlDeleteRowString.ParameterTypes, cancellationToken)).ConfigureAwait(false);
st = await (session.Batcher.PrepareCommandAsync(
commandInfo.CommandType, commandInfo.Text, commandInfo.ParameterTypes, cancellationToken)).ConfigureAwait(false);
}
try
{
Expand All @@ -364,7 +379,7 @@ public async Task DeleteRowsAsync(IPersistentCollection collection, object id, I
}
else
{
await (WriteElementToWhereAsync(st, entry, loc, session, cancellationToken)).ConfigureAwait(false);
await (WriteElementToWhereAsync(st, entry, columnNullness, loc, session, cancellationToken)).ConfigureAwait(false);
}
}
if (useBatch)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@
using NHibernate.Loader.Collection;
using NHibernate.Persister.Entity;
using NHibernate.SqlCommand;
using NHibernate.SqlTypes;
using NHibernate.Type;
using NHibernate.Util;
using System.Collections.Generic;
using NHibernate.SqlTypes;

namespace NHibernate.Persister.Collection
{
Expand Down Expand Up @@ -85,7 +85,10 @@ protected override async Task<int> DoUpdateRowsAsync(object id, IPersistentColle
}
else
{
await (WriteElementToWhereAsync(st, collection.GetSnapshotElement(entry, i), loc, session, cancellationToken)).ConfigureAwait(false);
// No nullness handled on update: updates does not occurs with sets or bags, and
// indexed collections allowing formula (maps) force their element columns to
// not-nullable.
await (WriteElementToWhereAsync(st, collection.GetSnapshotElement(entry, i), null, loc, session, cancellationToken)).ConfigureAwait(false);
}
}

Expand Down
Loading