0

How can I query the Employees and Holidays collections so that even if no holidays are found matching their specified criteria, the employees matching their criteria are still returned?

Entities:

public class Employee
{
    public Guid Id { get; set; }
    public List<Holiday> Holidays { get; set; } = [];
}

public class Holiday
{
    public Guid Id { get; set; }
    public Guid EmployeeId { get; set; }
    public DateTime Date { get; set; }
}

Query:

I have a method that needs to:

  • Search for employee by Id
  • And either
    • Dont include any holidays
    • Include all holidays for the employee, if any exist
    • Include all historic holidays for the employee, if any exist
    • Include all future holidays for the employee, if any exist
enum IncludeHolidays { None, All, Past, Future }

Employee? GetEmployee(Guid employeeId, IncludeHolidays includeHolidays)
{
    var query = ctx.Employees
        .Where(e => e.Id == employee.Id)
        .AsQueryable();

    if (includeHoliday != IncludeHolidays.None)
    {
        query = query.Include(e => e.Holidays);

        switch (includeHoliday)
        {
            case IncludeHolidays.Future:
                query = query.Where(e => e.Holidays.Where(h => h.Date >= DateTime.UtcNow));
                break;
            case IncludeHolidays.Past:
                query = query.Where(e => e.Holidays.Where(h => h.Date < DateTime.UtcNow));
                break;
        }
    }

    return query.FirstOrDefault();
}

The above method doesnt compile because of the nested Where calls. However I cannot use Holidays.Any or Holidays.Any because these will exclude the Employee if they do not have holidays matching that criteria.

How can I query against the Holidays without affecting the Employee that's identified?

An example SQL query that would achieve what I'm trying to do could be:

select * from "Employees" e 
left join "Holidays" h on e."Id" = h."EmployeeId" and h."Date" >= current_timestamp
where e."Id" = 'employee_id';

Edit 1

Having applied the changes suggested in the answers, I'm finding the the filter on Holiday.Date doesnt seem to be applied, and the results include all holidays regardless of their date.

var employee = new Employee
{
    Id = Guid.NewGuid(),
};
var pastHol = new Holiday
{
    Id = Guid.NewGuid(),
    EmployeeId = employee.Id,
    Date = DateTime.UtcNow.AddDays(-1)
};
var futureHol = new Holiday
{
    Id = Guid.NewGuid(),
    EmployeeId = employee.Id,
    Date = DateTime.UtcNow.AddDays(+1)
};

ctx.Employees.Add(employee);
ctx.Holidays.AddRange([pastHol, futureHol]);
ctx.SaveChanges();

var result = GetEmployee(employee.Id, IncludeHolidays.Future);

// Output has two holidays, one for yesterday, one for tomorrow
Console.WriteLine(JsonSerializer.Serialize(result));


Employee? GetEmployee(Guid employeeId, IncludeHolidays includeHolidays)
{
    var query = ctx.Employees
        .Where(e => e.Id == employee.Id)
        .AsQueryable();

    if (includeHolidays != IncludeHolidays.None)
    {
        switch (includeHolidays)
        {
            case IncludeHolidays.Future:
                query = query.Include(e => e.Holidays.Where(h => h.Date >= DateTime.UtcNow));
                break;
            case IncludeHolidays.Past:
                query = query.Include(e => e.Holidays.Where(h => h.Date < DateTime.UtcNow));
                break;
        }
    }

    return query.FirstOrDefault();
}

However the generated SQL looks good and should be filtering on the date:

      SELECT t."Id", t0."Id", t0."Date", t0."EmployeeId"
      FROM (
          SELECT e."Id"
          FROM "Employees" AS e
          WHERE e."Id" = @__employee_Id_0
          LIMIT 1
      ) AS t
      LEFT JOIN (
          SELECT h."Id", h."Date", h."EmployeeId"
          FROM "Holidays" AS h
          WHERE h."Date" >= now()
      ) AS t0 ON t."Id" = t0."EmployeeId"
      ORDER BY t."Id"

Is there something else going on here within entity framework where its caching the entities I've just created and returning them even though the DB doesnt return them?

Edit 2

Just found the Filtered Include docs which mentions:

In case of tracking queries, results of Filtered Include may be unexpected due to navigation fixup. All relevant entities that have been queried for previously and have been stored in the Change Tracker will be present in the results of Filtered Include query, even if they don't meet the requirements of the filter. Consider using NoTracking queries or re-create the DbContext when using Filtered Include in those situations

Sounds like this is the reason I'm seeing results that do not match my filter criteria. And updating the query to use NoTracking solves the issue:

var query = ctx.Employees
    .Where(e => e.Id == employee.Id)
    .AsNoTracking();

// .. filters

// Only has 1 future holiday and matches the filters
return query.FirstOrDefault();

2 Answers 2

3

You messed up with Where and Include. It is two dirrent things, Include is instruction to load related entities and EF Core added possiblity to filter these related entities.

Correct your query:

Employee? GetEmployee(Guid employeeId, IncludeHolidays includeHolidays)
{
    var query = ctx.Employees
        .Where(e => e.Id == employee.Id)
        .AsNoTracking(); // to avoid fixup navigation properties

    if (includeHoliday != IncludeHolidays.None)
    {
        switch (includeHoliday)
        {
            case IncludeHolidays.Future:
                query = query.Include(e => e.Holidays.Where(h => h.Date >= DateTime.UtcNow));
                break;
            case IncludeHolidays.Past:
                query = query.Include(e => e.Holidays.Where(h => h.Date < DateTime.UtcNow));
                break;
        }
    }

    return query.FirstOrDefault();
}
Sign up to request clarification or add additional context in comments.

5 Comments

Not a fan of using AsQueryable here, would prefer just to specify the type of query, for example: IQueryable<Exmployee> query = ....
@DavidG, Removed, it is not needed here. Where returns IQueryable. Usuallly AsQueryable() used after DbSet<> property or OrderBy, Include, etc. Which has return type different from IQueryable
Thanks, I didnt realise this was possible. However I'm finding that it still doesnt actually filter the holidays by date. See my edit for an example.
Try AsNoTracking(), probably Holidays are loaded earlier and already in ChangeTracker.
@DavidG You're absolutely right, that was the problem. Thank you
2

You can pass a conditional statement into the include, and in this case you can do something like the snippet below:

query = query.Include(e => e.Holidays.Where(x => includeHoliday != IncludeHolidays.None && (includeHoliday == IncludeHolidays.All || (includeHoliday == IncludeHolidays.Past ? x.Date < DateTime.UtcNow : x.Date >= DateTime.UtcNow))));

This would allow you to remove the lines below as well.

1 Comment

Thanks for your answer. I didnt even think about applying a Where clause within the Include. I find that including inline checks in a lambda like this can get a little complicated so I tend to avoid them, but other than that I found your answer helpful.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.