Service Operations for Any and All

As I wrote about a while ago, quantifiers such as 'any' and 'all' are not supported in WCF Data Services.

One way to work around this if you have some specific need is to create a service operation, which is what we'll walk through today.

The Scenario
I'm going to write a small WCF Data Service from scratch to keep a catalog of songs and their genre. Now, I'm a firm believer that you can't always classify a song in a single genre; sometimes it'll be a mix of things, and presumably your catalog should reflect this rather than force you to pick one genre over another.

The Basic Server
So we'll start with some class declarations in an ASP.NET Web Application project to which I've added a data service item.

public class Song
{
    public Song() { }
    public Song(int id, string name, params Genre[] genres)
    {
        this.ID = id; this.Name = name; this.Genres = genres.ToList();
    }

    public int ID { get; set; }
    public string Name { get; set; }
    public List<Genre> Genres { get; set; }
}

public class Genre
{
    public Genre() {}
    public Genre(int id, string name, string description)
    {
        this.ID = id; this.Name = name; this.Description = description;
    }

    public int ID { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
}

These are some simple placeholders for data with some specialized constructors for convenience. Let's use these to build our data context.

public class SongGenreContext
{
    private static List<Song> songs;
    private static List<Genre> genres;

    static SongGenreContext()
    {
        Genre rock = new Genre(1, "Rock", "Makes you move in alternating directions.");
        Genre jazz = new Genre(2, "Smooth Jazz", "Like regular jazz, but without the bumps.");
        Genre blues = new Genre(3, "Blues", "The eternal enemies of Reds.");
        genres = new List<Genre>() { rock, jazz, blues };

        songs = new List<Song>()
        {
            new Song(1, "Rocking Blues of Love", rock, blues),
            new Song(2, "Sax in the Rain", jazz, blues),
            new Song(3, "Bluer than blue", blues),
            new Song(4, "Rocker than rock", rock),
        };
    }

    public IQueryable<Genre> Genres { get { return genres.AsQueryable(); } }
    public IQueryable<Song> Songs { get { return songs.AsQueryable(); } }
}

Now I can fill in that data service class that was created from the template item.

public class SongGenreService : DataService<SongGenreContext>
{
    public static void InitializeService(DataServiceConfiguration config)
    {
        config.SetEntitySetAccessRule("*",
          EntitySetRights.AllRead);
        config.SetServiceOperationAccessRule("*",
          ServiceOperationRights.All);
        config.DataServiceBehavior.MaxProtocolVersion = DataServiceProtocolVersion.V2;
        config.UseVerboseErrors = true;
    }
}

The Basic Client
To try this out, I'm writing a console application that has a reference to the server, connects and prints out the results of a query.

var serviceRoot = new Uri("https://localhost:60100/SongGenreService.svc/");
var service = new SongGenreContext(serviceRoot);

var q = from s in service.Songs select s;
foreach (var s in q) Console.WriteLine(s.Name);

Not bad for a few straightforward lines of code, but now what happens if we want all the songs that belong to a few specific genres?

Creating the Service Operation
To pull this trick off, we're going to write a service operation in the SongGenreService class.

[WebGet]
public IQueryable<Song> SongsWithGenre(string tags)
{
    if (String.IsNullOrEmpty(tags))
    {
        return new Song[0].AsQueryable();
    }

    string[] tagsInArray = tags.Split(',');
    return this.CurrentDataSource.Songs.Where(
        s => s.Genres.Any(g => tagsInArray.Contains(g.Name)));
}

This data service takes a parameter 'tags' and breaks it up into separate genre names, assuming these are comma-delimited.

It then filters the songs to only those that have a genre with a name that is contained within the ones the client provided.

Note that the result is IQueryable<Song>, so the client is free to do additional querying over these results.

Using the Service Operation
What I like to do on the client is make use of the fact that the code generated by adding a reference declares the class as partial, so even though it doesn't add support for service operations, we can do it ourselves.

So in the main file, I can add the following code:

public partial class SongGenreContext
{
    public IQueryable<Song> SongsWithGenre(params string[] tags)
    {
        if (tags == null || tags.Length == 0)
        {
            throw new InvalidOperationException("no tags specified");
        }

        string optionValue = string.Join(",", tags);
        optionValue = "'" + optionValue.Replace("'", "''") + "'";
        return this.CreateQuery<Song>("SongsWithGenre")
            .AddQueryOption("tags", optionValue);
    }
}

Now we can change the code to query only for songs that have a Rock or Smooth Jazz genre associated (or both, for that matter), and we can do further composition like filtering out those that include 'Love', because that's just how we feel like today.

var q = from s in service.SongsWithGenre("Rock", "Smooth Jazz")
        where !s.Name.Contains("Love") select s;
foreach (var s in q) Console.WriteLine(s.Name);

This will display the following songs:

Sax in the Rain
Rocker than Rock

'Bluer than blue' is filtered out by the service operation, because it's a Blues-only song, and 'Rocking Blues of Love' is filter by the additional filter request the client is specifying.

Enjoy!

PS: For more information on doing 'WHERE IN'-style queries in EF, check out Alex's great post at https://blogs.msdn.com/alexj/archive/2009/03/26/tip-8-writing-where-in-style-queries-using-linq-to-entities.aspx.