0

I have three distinct well-defined entities, Foo, Bar, Baz, on one side of the relationship, and a single entity these all need to be tied to on the other side, lets call it LogEntry. I have the relationship working as far as sql is concerned, but I'm at a loss at how to define it within the DbContext modelBuilder and get the navigation props to work both directions when loading from the db using .Include().

Requirements:

LogEntry must be related to exactly one Foo, Bar, or Baz

Foo, Bar, or Baz may only be related to zero or one LogEntrys

Desired usage:

I'd like to have a navigation property both ways.

.Include(x => x.LogEntry)... when retrieving all Foo, Bar, or Bazs

and

db.LogEntries.Include(x => x.Foo).Include(x => x.Bar).Include(x => x.Baz)...

What I've tried:

We're using code-first, but we generate the tables from .sql scripts.

--LogEntry.sql rough details

LogEntry UNIQUEIDENTIFIER NONCLUSTERED PRIMARY KEY NOT NULL,
FooID UNIQUEIDENTIFIER NULL
BarID UNIQUEIDENTIFIER NULL
BazID UNIQUEIDENTIFIER NULL

-- Other Fields, etc...

-- CONSTRAINT FKs to the Foo,Bar,Baz tables

-- CONSTRAINT CHECK (FooID IS NOT NULL AND BarID IS NULL AND BazID IS NULL or ...etc) 
-- to make sure each LogEntry is related to one entity regardless of type

-- CONSTRAINT UNIQUE for each Foo, Bar, Baz ID that isn't null

I've tried a few different ways to define the relationship with the DbContext ModelBuilder, but nothing I've tried populates the nav prop when .Include()ed.

// Example
modelBuilder.Entity<Foo>
    .HasOptional(f => f.LogEntry)
    .WithRequired(l => l.Foo);

The first approach was to put a nullable FK on each Foo, Bar, or Baz and have Lists of Foo, Bar, or Baz nav props on LogEntry. We would just rely on code to never add more than one Foo, Bar, or Baz to a LogEntry. This made the entity relationship trivial, but it didn't match how they were going to be used and would lead to dev confusion. The other issue being we can't implement a unique check on the LogEntry FKs across Foo, Bar, or Baz tables.

In summary:

Is there a way to define this relationship in entity framework to get working navigation properties for both sides?

Jecoms
  • 2,558
  • 4
  • 20
  • 31
  • Can a logentry have a foo and a bar at the same time? – Mate Aug 19 '18 at 03:58
  • No. Only one of them. – Jecoms Aug 19 '18 at 03:59
  • Have foo, bar and baz a similar structure, maybe 2 or more props? – Mate Aug 19 '18 at 04:00
  • No, unfortunately, they are quite distinct. They share some similar FKs and a few similar props that map to LogEntry props, but there isn't a common entity that I can extract from each to then FK to logEntry from. – Jecoms Aug 19 '18 at 04:08
  • usually one of this three different approaches can help you: Table per Hierarchy, Table per Type, Table per Concrete Class, http://www.entityframeworktutorial.net/code-first/inheritance-strategy-in-code-first.aspx – Mate Aug 19 '18 at 04:14
  • are you sure you are unable for them to all inherit from a base class? For example if a resident can have 1 pet but dogs, birds and fish have no common properties besides being pets – el_M Aug 19 '18 at 07:04

1 Answers1

0

1) You have the solution.

Foo, Bar, and Baz should have an ICollection<LogEntry> Navigation property, and LogEntry should have a Navigation property for each of Foo, Bar, and Baz. It's annoying, but that's the model you chose.

EF code-first won't generate the check constraint for you, but you can add that in a Migration, or manage the database-first and reverse-engineer the Entities from there.

If you don't need to query LogEntries across Foo, Bar and Baz, then perhaps you would prefer three different LogEntry tables.

2) Are you sure you want to use UNIQUEIDENTIFIER for all your keys? Even for the LogEntry? That can be expensive, especially if you don't generate sequential values with NEWSEQUENTIALID().

Here's an example, including client-side sequential GUID generation:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Data.Entity;
using System.Linq;


namespace ef62test
{
    public class SQLGuidUtil
    {
        [System.Runtime.InteropServices.DllImport("rpcrt4.dll", SetLastError = true)]
        static extern int UuidCreateSequential(out Guid guid);

        public static Guid NewSequentialId()
        {
            Guid guid;
            UuidCreateSequential(out guid);
            var s = guid.ToByteArray();
            var t = new byte[16];
            t[3] = s[0];
            t[2] = s[1];
            t[1] = s[2];
            t[0] = s[3];
            t[5] = s[4];
            t[4] = s[5];
            t[7] = s[6];
            t[6] = s[7];
            t[8] = s[8];
            t[9] = s[9];
            t[10] = s[10];
            t[11] = s[11];
            t[12] = s[12];
            t[13] = s[13];
            t[14] = s[14];
            t[15] = s[15];
            return new Guid(t);
        }
    }


    class Program
    {

        public class LogEntry
        {

            public Guid LogEntryId { get; set; } = SQLGuidUtil.NewSequentialId();

            public Guid? FooId { get; set; }
            public Guid? BarId { get; set; }
            public Guid? BazId { get; set; }
            public virtual Foo Foo { get; set; }
            public virtual Bar Bar { get; set; }
            public virtual Baz Baz { get; set; }
        }
        public class Foo
        {
            public Guid FooId { get; set; } = SQLGuidUtil.NewSequentialId();
            public ICollection<LogEntry> LogEntries { get; } = new HashSet<LogEntry>();
        }
        public class Bar
        {
            public Guid BarId { get; set; } = SQLGuidUtil.NewSequentialId();
            public ICollection<LogEntry> LogEntries { get; } = new HashSet<LogEntry>();
        }
        public class Baz
        {
            public Guid BazId { get; set; } = SQLGuidUtil.NewSequentialId();
            public ICollection<LogEntry> LogEntries { get; } = new HashSet<LogEntry>();
        }


        public class MyDbContext : DbContext
        {
            public MyDbContext(string constr) : base(constr)
            { }
            public DbSet<Foo> Foos { get; set; }
            public DbSet<Bar> Bars { get; set; }
            public DbSet<Baz> Bazs { get; set; }
            public DbSet<LogEntry> LogEntries { get; set; }
        }


        static void Main(string[] args)
        {
            Database.SetInitializer(new DropCreateDatabaseAlways<MyDbContext>());

            using (var db = new MyDbContext("Server=.;database=ef62test;integrated security=true"))
            {

                db.Database.Initialize(true);

                db.Database.Log = s => Console.WriteLine(s);

                var foo = new Foo();
                foo.LogEntries.Add(new LogEntry());

                var bar = new Bar();
                bar.LogEntries.Add(new LogEntry());

                var baz = new Baz();
                baz.LogEntries.Add(new LogEntry());

                db.Foos.Add(foo);
                db.Bars.Add(bar);
                db.Bazs.Add(baz);
                db.SaveChanges();
            }

            using (var db = new MyDbContext("Server=.;database=ef62test;integrated security=true"))
            {
                db.Configuration.LazyLoadingEnabled = false;
                db.Database.Log = s => Console.WriteLine(s);

                var logs = db.LogEntries
                           .Include(l => l.Foo)
                           .Include(l => l.Bar)
                           .Include(l => l.Baz)
                           .ToList();

                foreach (var log in logs)
                {
                    Console.WriteLine($"Foo:{log.Foo?.FooId} Bar:{log.Bar?.BarId} Baz:{log.Baz?.BazId}");
                }            

            }
            Console.WriteLine("Hit any key to exit.");
            Console.ReadKey();
        }
    }
}
David Browne - Microsoft
  • 80,331
  • 6
  • 39
  • 67
  • 1) The way it's set up works, but it looks like we may just need to load the related entities manually through an intermediate anonymous `.Select()` for example. The navigation props aren't working with `.Include()`. The relationship is enforced by sql constraints; we are using a `.dacpac` (no migrations). 2) `Guid`s are a business requirement. I hadn't heard of NEWSEQUENTIALID(), but I feel like ordered guids defeats the purpose. I'll look into that function for curiousity's sake, though. – Jecoms Aug 19 '18 at 15:41
  • Ordered GUIDs are as unique as random ones, and in fact they came first. A random GUID is just an ordered GUID with some cryptography applied. But the point is that inserting rows with a random GUID key sprays the rows randomly across the primary key index, which is more expensive than filling contiguous pages with new rows. So especially for an insert-heavy table random GUID keys are a bad practice. – David Browne - Microsoft Aug 19 '18 at 15:52
  • My concern was with required initial records existing in a table and being able to seed the sequentialIDs. We also tend to favor setting PK/FKs in code to prepare all entities to save before committing. There are tables that will use NEWID(), so I'll bring up the sequential function at our next stand-up.The project is not currently live, so I don't know how the production db will be deployed for sure. – Jecoms Aug 19 '18 at 16:02
  • You can assign the sequential GUIDs at the client. And the Include()'s should work fine. See the sample added to the answer. – David Browne - Microsoft Aug 19 '18 at 16:19