|<<>>|201 of 274 Show listMobile Mode

Creating fluent interfaces with inheritance in C#

Published by marco on

Updated by marco on

Fluent interfaces—or “method chaining” as it’s also called—provide an elegant API for configuring objects. For example, the Quino query API provides methods to restrict (Where or WhereEquals), order (OrderBy), join (Join) and project (Select) data. The first version of this API was very traditional and applications typically contained code like the following:

var query = new Query(Person.Metadata);
query.WhereEquals(Person.Fields.Name, "Müller");
query.WhereEquals(Person.Fields.FirstName, "Hans");
query.OrderBy(Person.Fields.LastName, SortDirection.Ascending);
query.OrderBy(Person.Fields.FirstName, SortDirection.Ascending);
var contactsTable = query.Join(Person.Relations.ContactInfo);
contactsTable.Where(ContactInfo.Fields.Street, ExpressionOperator.EndsWithCI, "Strasse");

(This example gets all people named “Hans Müller” that live on a street with a name that ends in “Strasse” (case-insensitive) sorted by last name, then first name. Fields and Relations refer to constants generated from the Quino metadata model.)

Fluent Examples

The syntax above is very declarative and relatively easy-to-follow, but is a bit wordy. It would be nice to be able to chain together all of these calls and remove the repeated references to query. The local variable contactsTable also seems kind of superfluous here (it is only used once).

A fluent version of the query definition looks like this:

var query = new Query(Person.Metadata);
query.WhereEquals(Person.Fields.Name, "Müller")
  .WhereEquals(Person.Fields.FirstName, "Hans")
  .OrderBy(Person.Fields.LastName, SortDirection.Ascending)
  .OrderBy(Person.Fields.FirstName, SortDirection.Ascending)
  .Join(Person.Relations.ContactInfo)
    .Where(ContactInfo.Fields.Street, ExpressionOperator.EndsWithCI, "Strasse");

The example uses indenting to indicate that restriction after the join on the “ContactInfo” table applies to the “ContactInfo” table instead of to the “Person” table. The call to Join logically returns a reference to the joined table instead of the query itself. However, each such table also has a Query property that refers to the original query. Applications can use this to “jump” back up and apply more joins, as shown in the example below where the query only returns a person if he or she also works in the London office:

var query = new Query(Person.Metadata);
query.WhereEquals(Person.Fields.Name, "Müller")
  .WhereEquals(Person.Fields.FirstName, "Hans")
  .OrderBy(Person.Fields.LastName, SortDirection.Ascending)
  .OrderBy(Person.Fields.FirstName, SortDirection.Ascending)
  .Join(Person.Relations.ContactInfo)
    .Where(ContactInfo.Fields.Street, ExpressionOperator.EndsWithCI, "Strasse").Query
  .Join(Person.Relations.Office)
    .WhereEquals(Office.Fields.Name, "London");

A final example shows how even complex queries over multiple table levels can be chained together into one single call. The following example joins on the “ContactInfo” table to dig even deeper into the data by restricting to people whose web sites are owned by people with at least 10 years of experience:

var query = new Query(Person.Metadata);
query.WhereEquals(Person.Fields.Name, "Müller")
  .WhereEquals(Person.Fields.FirstName, "Hans")
  .OrderBy(Person.Fields.LastName, SortDirection.Ascending)
  .OrderBy(Person.Fields.FirstName, SortDirection.Ascending)
  .Join(Person.Relations.ContactInfo)
    .Where(ContactInfo.Fields.Street, ExpressionOperator.EndsWithCI, "Strasse")
    .Join(ContactInfo.Relations.WebSite)
      .Join(WebSite.Relations.Owner)
        .Where(Owner.Fields.YearsExperience, ExpressionOperator.GreaterThan, 10).Query
  .Join(Person.Relations.Office)
    .WhereEquals(Office.Fields.Name, "London");

This API might still be a bit too wordy for some (.NET 3.5 Linq would be less wordy), but it’s refactoring-friendly and it’s crystal-clear what’s going on.

Implementation

When there’s only one class involved, it’s not that hard to conceive of how this API is implemented: each method just returns a reference to this when it has finished modifying the query. For example, the WhereEquals method would look like this:

IQuery WhereEquals(IMetaProperty prop, object value);
{
  Where(CreateExpression(prop, value);

  return this;
}

This isn’t rocket science and the job is quickly done.

However, what if things in the inheritance hierarchy aren’t that simple? What if, for reasons known to the Quino framework architects, IQuery actually inherits from IQueryCondition, which defines all of the restriction and ordering operations. The IQuery provides projection and joining operations, which can easily just return this, but what type should the operations in IQueryCondition return?

The problem area is indicated with question marks in the example below:

public interface IQueryCondition
{
  ??? WhereEquals(IMetaProperty prop, object value);
}

public interface IQueryTable : IQueryCondition
{
  IQueryTable Join(IMetaRelation relation);
}

public interface IQuery : IQueryTable
{
  IQueryTable SelectDefaultForAllTables();
}

The IQueryCondition can’t simply return IQueryTable because it might be used elsewhere[1], but it can’t return IQueryCondition because then the table couldn’t perform a join after a restriction because applying the restriction would have restricted the fluent interface to an IQueryCondition instead of an IQueryTable.

The solution is to make IQueryCondition generic and pass it the type that it should return instead of hard-coding it.

public interface IQueryCondition<TSelf>
{
  TSelf WhereEquals(IMetaProperty prop, object value);
}

public interface IQueryTable : IQueryCondition<IQueryTable>
{
  IQueryTable Join(IMetaRelation relation);
}

public interface IQuery : IQueryTable
{
  IQueryTable SelectDefaultForAllTables();
}

That takes care of the interfaces, on to the implementation. The standard implementation runs into a small problem when returning the generic type:

public class QueryCondition<TSelf> : IQueryCondition<TSelf>
{
  TSelf WhereEquals(IMetaProperty prop, object value)
  {
    // Apply restriction

    return (TSelf)this; // causes a compile error
  }
}

public class QueryTable : QueryCondition<IQueryTable>, IQueryTable
{
  IQueryTable Join(IMetaRelation relation) 
  {
    // Perform the join

    return result;
  }
}

public class Query : IQuery
{
  IQueryTable SelectDefaultForAllTables()
  {
    // Perform the select

    return this;
  }
}

One simple solution to the problem is to cast down to object and back up to TSelf, but this is pretty bad practice as it short-circuits the static checker in the compiler and defers the problem to a potential runtime one.

public class QueryCondition<TSelf> : IQueryCondition<TSelf>
{
  TSelf WhereEquals(IMetaProperty prop, object value)
  {
    // Apply restriction

    return (TSelf)(object)this;
  }
}

In this case, it’s guaranteed by the implementation that this is compliant with TSelf, but it would be even better to solve the problem without resorting to the double-cast above. As it turns out, there is a simple and quite elegant solution, using an abstract method called ThisAsTSelf, as illustrated below:

public abstract class QueryCondition<TSelf> : IQueryCondition<TSelf>
{
  TSelf WhereEquals(IMetaProperty prop, object value)
  {
    // Apply restriction

    return ThisAsTSelf();
  }

  protected abstract TSelf ThisAsTSelf();
}

public class Query : IQuery
{
  protected override TSelf ThisAsTSelf()
  {
    return this;
  }
}

The compiler is now happy without a single cast at all because Query returns this, which the compiler knows conforms to TSelf. The power of a fluent API is now at your disposal without restricting inheritance hierarchies or making end-runs around the compiler. Naturally, the concept extends to multiple levels of inheritance (e.g. if all calls had to return IQuery instead of IQueryTable), but it gets much uglier, as it requires nested generic types in the return types, which makes it much more difficult to understand. With a single level, as in the example above, the complexity is still relatively low and the resulting API is very powerful.


[1] And, in Quino, it is used elsewhere, for the IQueryJoinCondition.