Sunday, March 13, 2011

LINQ To LDAP: Paging

Let's talk about paging. The first thing you have to understand is paging with LDAP is done very differently than paging with RDBMS. Typically you'd have something like this:
var paged = context.Users.Skip(0).Take(10)
where the skip amount is the index of the first result and the take amount is the page size. LINQ to Objects works with this as well as most LINQ to SQL implementations. This supports forwards and backwards paging pretty easily since you can always control the position from which you start paging.

LDAP, however, treats paging a little differently. Here's an example:
private byte[] _nextPage;
private int _pageSize = 2;
private void Page()
{    
    var control = new PageResultRequestControl(_pageSize) {Cookie = _nextPage};

    var request = new SearchRequest
                        {
                            DistinguishedName = "CN=Users,CN=Employees,DC=Northwind,DC=local",
                            Filter = "objectClass=user",
                            Scope = SearchScope.Subtree
                        };
    request.Controls.Add(control);
    using (var connection = new LdapConnection("localhost"))
    {
        var response = connection.SendRequest(request) as SearchResponse;
        var next = response.Controls[0] as PageResultResponseControl;
        _nextPage = next.Cookie;
    }
}

You set the page size and you use a paging control to let the server know the position of the page. The server will generate a new cookie that will give you the next page. From the RFC:
The client MUST consider the cookie to be an opaque structure and make no assumptions about its internal organization or value. When the client wants to retrieve more entries for the result set, it MUST send to the server a searchRequest with all values identical to the initial request with the exception of the messageID, the cookie, and optionally a modified pageSize. The cookie MUST be the octet string on the last searchResultDone response returned by the server. Returning cookies from previous searchResultDone responses besides the last one is undefined, as the server implementation may restrict cookies from being reused.
If you know that the LDAP server you're using will work with backwards paging, you can implement client side tracking of the previous cookies, but I can't support it since it's unpredictable between servers. Either way, here's how I added paging support.

So let's start with how to page:
//start paging
var oldPage = context.Query<User>()
   .Where(u => u.FirstName.StartsWith("A"))
   .Select(u => u.DistinguishedName)
   .ToPage(10);

//go to next page
if (oldPage.HasNextPage)
{
   var nextPage = context.Query<User>()
      .Where(u => u.FirstName.StartsWith("A"))
      .Select(u => u.DistinguishedName)
      .ToPage(oldPage.PageSize, oldPage.NextPage);
}

ToPage is an extension method that returns a custom collection I created to allow you to get some basic information about the page and easily move to the next page. Here's PagedCollection:
public interface IPagedCollection<out T> : IEnumerable<T>
{
    int Count { get; }
    int PageSize { get; }
    bool HasNextPage { get; }
    byte[] NextPage { get; }
}

public class PagedCollection<T> : ReadOnlyCollection<T>, IPagedCollection<T>
{
    public PagedCollection(int pageSize, byte[] nextPage, object enumerator)
        : this(pageSize, nextPage, GetList((IEnumerator<T>)enumerator))
    {
    }

    public PagedCollection(int pageSize, byte[] nextPage, IList<T> page)
        : base(page)
    {
        PageSize = pageSize;
        NextPage = nextPage;

        HasNextPage = NextPage != null && NextPage.Length > 0;
    }

    private static IList<T> GetList(IEnumerator<T> enumerator)
    {
        var list = new List<T>();

        while (enumerator.MoveNext()) 
            list.Add(enumerator.Current);

        return list;
    }

    public int PageSize { get; private set; }
    public bool HasNextPage { get; private set; }
    public byte[] NextPage { get; private set; }
}

I've committed these changes to the trunk and updated the sample project. Go ahead and try it out. Let me know if there's anything I can add / change.