Reputation: 2569
I have simple User entity:
public class User
{
public virtual int Id { get; set; }
public virtual DateTime CreationDate { get; set; }
public virtual DateTime ModifiedDate { get; set; }
public virtual string Email { get; set; }
public virtual string Name { get; set; }
public virtual IList<Phone> Phones { get; set; }
}
public class Phone
{
public virtual string CountryCode { get; set; }
public virtual string Code { get; set; }
public virtual string Number { get; set; }
public virtual string Comment { get; set; }
}
My mappings defined as this:
public class UserMap : ClassMap<User>
{
public UserMap ()
{
this.Table ("Users");
this.Id (x => x.Id).CustomSqlType ("bigint").GeneratedBy.HiLo ("1000");
this.Map (x => x.CreationDate);
this.Map (x => x.ModifiedDate).Column ("LastUpdatedDate");
this.Map (x => x.Email).Length (255).Not.Nullable ().Unique ();
this.Map (x => x.Name).Column ("UserName").Length (255);
this.HasMany (x => x.Phones).Inverse ();
}
}
public class PhoneMap : ClassMap<Phone>
{
public PhoneMap ()
{
this.Table ("Phones");
this.Id ().GeneratedBy.Identity ();
this.Map (x => x.CountryCode).Length (5);
this.Map (x => x.Code).Length (10);
this.Map (x => x.Number).Length (50).Not.Nullable ();
this.Map (x => x.Comment).Length (255);
}
}
Additional conventions here:
PrimaryKey.Name.Is (x => "Id"),
ForeignKey.EndsWith ("Id"),
DefaultAccess.Property (),
DefaultCascade.All ()
I need to select top 100 users with Phones and whose name starts with "A". But I need to load user objects with Phones in them.
So I do this query:
var users =
(
from user in session.Query<User> ()
where
user.Name.StartsWith ("a")
&&
user.Phones.Any ()
select user
)
.Fetch (x => x.Phones)
.Take (100)
.ToArray ();
And I only got 72 users.
Why? Well, because NHibernate generates single TOP N select with left outer join and SQL returns several records for the same user entity because some users do have more that one phone. But it's all counts against TOP N - so I get 100 records of users joined with phones, but only 72 of them are unique entities.
Is there a proper way to do it?
Upvotes: 3
Views: 10786
Reputation: 13364
You have to split queries to subselects. Where inner subselect should do pagination and outer should do fetching:
var top100users =
(
from user in session.Query<User>()
where user.Name.StartsWith("a") &&
user.Phones.Any()
select user
)
.Take(100);
var users =
(
from user in session.Query<User>()
where top100users.Contains(user)
select user
)
.Fetch (x => x.Phones)
.ToArray();
And this will generate single sql query which will behave as you expect.
Upvotes: 4
Reputation: 2569
Well, the only possible workaround I came up with is to firstly remove Fetch from query so it became like this:
var users =
(
from user in session.Query<User> ()
where
user.Name.StartsWith (prefix)
&&
user.Phones.Any ()
select user
)
.Take (100)
.ToList ();
Then after that code add something like this that force to load at least one entity:
users.ForEach (x => x.Phones.Any ());
And in mappings set batch size to 100 (or at least 50):
public class UserMap : ClassMap<User>
{
public UserMap ()
{
this.Table ("Users");
this.Id (x => x.Id).CustomSqlType ("bigint").GeneratedBy.HiLo ("1000");
this.Map (x => x.CreationDate);
this.Map (x => x.ModifiedDate).Column ("LastUpdatedDate");
this.Map (x => x.Email).Length (255).Not.Nullable ().Unique ();
this.Map (x => x.Name).Column ("UserName").Length (255);
this.HasMany (x => x.Phones).Inverse ().BatchSize (50);
}
}
Or via conventions (tho it can be not so graceful for some systems):
PrimaryKey.Name.Is (x => "Id"),
ForeignKey.EndsWith ("Id"),
DefaultAccess.Property (),
DefaultCascade.All (),
DynamicUpdate.AlwaysTrue (),
new CollectionConventionBuilder ().Always (x => x.BatchSize (50))
Btw, in pure SQL the task could be solved rather simple with "for xml":
select top 100
u.Id,
u.CreationDate,
u.LastUpdatedDate,
u.Email,
u.UserName,
(
select
p.CountryCode,
p.Code,
p.Number,
p.Comment
from
dbo.Phones as p
where
p.UserId = u.Id
for xml path ('Phone'), root ('Phones'), type
) as '*'
from
dbo.Users as u
where
u.UserName like @0
and
exists (select top 1 p.Id from dbo.Phones as p where p.UserId = u.Id)
for xml path ('User'), root ('Root'), type
I wish NHibernate could load aggregate roots from "for xml" queries when ordered to.
Upvotes: 1
Reputation: 5679
You need to use a subquery (for the paging) and a transformer to get distinct users, I'm not sure if this is possible in the NHibernate Linq provider, so doing it using QueryOver:
var sub_query = QueryOver.Of<User>()
.Where (Restrictions.On<User>(x => x.Name).IsLike("a%"))
.JoinQueryOver(x => x.Phones, JoinType.InnerJoin)
.Take (100)
.Select(x => x.Id);
var users = session.QueryOver<User>()
.WithSubquery.WhereProperty (x => x.Id).In (sub_query)
.Fetch (x => x.Phones).Eager
.TransformUsing (Transformers.DistinctRootEntity)
.List ();
Upvotes: 0