Search code examples
nhibernatenhibernate-mapping-by-code

One-to-one relationship in NHibernate using composite key


I'm trying to figure out the proper way to model a one-to-one (or one-to-zero) relationship in NHibernate, or indeed to learn categorically whether such a thing can be done.

Currently I have two models, Document and ScriptDocument whereby there should be a bidirectional relationship between the two, defined by a composite primary key made up of two properties that are shared/duplicated across both tables. A Document may have zero or one associated ScriptDocument and every ScriptDocument will have an associated Document. They both have a shared primary key made up of two properties: a string ("key") and int ("userref").

Currently I've set up my models and mappings as follows:

    public class Document
    {
        public virtual string Key { get; set; }
        public virtual int UserRef { get; set; }

        public virtual ScriptDocument ScriptDocument { get; set; }

        // ... other properties ...

        public override bool Equals(object obj)
        {
            return obj is Document document &&
                   Key == document.Key &&
                   UserRef == document.UserRef;
        }

        public override int GetHashCode()
        {
            return HashCode.Combine(Key, UserRef);
        }
    }

    public class DocumentMap : ClassMapping<Document>
    {
        public DocumentMap()
        {
            Schema("Documents");
            Table("Documents");

            ComposedId(m =>
            {
                m.Property(x => x.Key);
                m.Property(x => x.UserRef, m => m.Column("User_Ref"));
                // the PK fields are named slightly differently across the two tables. Same data types though and same names in the models.
            });

            OneToOne(x => x.ScriptDocument, m => { 
                m.Cascade(Cascade.All);
                m.Constrained(false);
            });

            // ... other property mappings ...
        }
    }


    public class ScriptDocument
    {
        public virtual string Key { get; set; }
        public virtual int UserRef { get; set; }

        public virtual Document Document { get; set; }

        // ... other properties ...

        public override bool Equals(object obj)
        {
            return obj is ScriptDocument sd &&
                   Key == sd.Key &&
                   UserRef == sd.UserRef;
        }

        public override int GetHashCode()
        {
            return HashCode.Combine(Key, UserRef);
        }
    }

    public class ScriptDocumentMap : ClassMapping<ScriptDocument>
    {
        public ScriptDocumentMap()
        {
            Table("Script_Document");

            ComposedId(m =>
            {
                m.Property(x => x.Key, m => m.Column("DocKey"));
                m.Property(x => x.UserRef);
            });
                
            OneToOne(x => x.Document, m => m.Constrained(true));

            // ... other property mappings ...
        }
    }

At this point, NHibernate seems happy with these models and mapping definitions, but the problem is that the relationships seem to be effectively ignored. When loading one or more Document entities, they all have a null ScriptDocument property and the same is true of the Document property on any ScriptDocument entities.

As far as I can tell, NHibernate isn't even attempting to fill those properties in any cases. I therefore assume one of two things is happening:

  • I've done something wrong (probably in the mappings). I'm sort of hoping there's just one or two little things I've missed, but I can't for the life of me work out what that might be.
  • This can't actually be done. My understanding is that this approach should be just fine if we had a single shared primary key but I'm not sure whether the shared composite key is something we can do. I can't find any comparable examples.

Note about this approach: you definitely don't need to tell me how unorthodox this is 😅 I'm painfully aware. But I'm working within the constraints of pre-existing systems. Unless this absolutely, categorically, isn't possible, this is the approach that I'd like to continue with at this point.


Solution

  • So the key to solving this seemed to be to use a component composite ID.

    I added the following class to define the composite primary key for both tables:

    [Serializable]
    public class DocumentIdentifyingKey
    {
        public virtual string Key { get; set; }
        public virtual int UserRef { get; set; }
    
        public override bool Equals(object obj)
        {
            return obj is DocumentIdentifyingKey key &&
                   Key == key.Key &&
                   UserRef == key.UserRef;
        }
    
        public override int GetHashCode()
        {
            return HashCode.Combine(Key, UserRef);
        }
    
        public override string ToString()
        {
            return $"{UserRef}/{Key}";
        }
    }
    

    And was then able to update the entity model classes and associated mappings as follows, using ComponentAsId defining the actual database fields for the identities for each of the two classes/tables:

    public class Document
    {
        public virtual DocumentIdentifyingKey Identity { get; set; }
    
        public virtual ScriptDocument ScriptDocument { get; set; }
    
        // ... other properties ...
    
        public override bool Equals(object obj)
        {
            return obj is Document document &&
                   Identity == document.Identity;
        }
    
        public override int GetHashCode()
        {
            return Identity.GetHashCode();
        }
    }
    
    public class DocumentMap : ClassMapping<Document>
    {
        public DocumentMap()
        {
            Schema("Documents");
            Table("Documents");
    
            ComponentAsId(x => x.Identity, m => {
                m.Property(i => i.Key);
                m.Property(i => i.UserRef, m => m.Column("User_Ref"));
            });
    
            OneToOne(x => x.ScriptMetadata, m => { 
                m.Cascade(Cascade.All);
                m.Constrained(false);
                m.Fetch(FetchKind.Join);
                m.Lazy(LazyRelation.NoLazy);
            });
    
            // ... other property mappings ...
        }
    }
    
    
    public class ScriptMetadata
    {
        public virtual DocumentIdentifyingKey Identity { get; set; }
    
        public virtual Document Document { get; set; }
    
        // ... other properties ...
    
        public override bool Equals(object obj)
        {
            return obj is ScriptMetadata sd &&
                   Identity == sd.Identity;
        }
    
        public override int GetHashCode()
        {
            return Identity.GetHashCode();
        }
    }
    
    public class ScriptDocumentMap : ClassMapping<ScriptMetadata>
    {
        public ScriptDocumentMap()
        {
            Table("Script_Document");
    
            ComponentAsId(x => x.Identity, m =>
            {
                m.Property(i => i.Key, m => m.Column("DocKey"));
                m.Property(i => i.UserRef);
            });
                
            OneToOne(x => x.Document, m => {
                m.Constrained(true);
                m.Fetch(FetchKind.Join);
                m.Lazy(LazyRelation.NoLazy);
            });
    
            // ... other property mappings ...
        }
    }
    

    I'm not entirely sure why this worked but having the identity of the document expressed as an instance of an object rather than just the combination of the two fields on each class seemed to be the key incantation which allowed NHibernate to understand what I was getting at.

    Note: in this solution, I've added Fetch and Lazy calls to the two OneToOne relationships. These are not specifically part of this solution but instead were added to better instruct NHibernate what load behaviour would be preferred.