0

I don't have a lot of experience with IQueryable

What I am trying to do is search for a user based on a list of passed in constraints that can either be a username, or phone number. Depending on the type I want to return limited information. I then want to combine the 3 IQueryables into one and combine entries with matching id and username to maintain the most information.

Here is what i have so far:

public IQueryable<User> Search(String[] criteria)
{
        var query = Database.Get<User>();

        IQueryable<User> phoneQuery = null;
        IQueryable<User> emailQuery = null;
        IQueryable<User> nameQuery = null;

        foreach (String str in criteria)
        {
            // Check if it is a phone number
            if (Regex.IsMatch(str, @"([0-9]{3})?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$"))
            {
                phoneQuery = query.Where(
                       u => u.PhoneNumber.ToLower() == (str))
                       .Select(i => new User
                       {
                           Id = i.Id,
                           UserName = i.Name,
                           PhoneNumber = i.PhoneNumber
                       })
            }
            // check if it is an email 
            else if (criteria.Contains("@"))
            {
                emailQuery = query.Where(
                       u => u.Email.ToLower() == (str))
                       .Select(i => new User
                       {
                           Id = i.Id,
                           UserName = i.Name,
                           Email = i.Email
                       })
            }
            else
            {
                nameQuery = query.Where(
                       u => u.UserName.ToLower() == (str))
                       .Select(i => new User
                       {
                           Id = i.Id,
                           UserName = i.Name,
                       })
            }
        }
        // Merge the 3 queries combining entries if the username and id match and maintain the maximum amount of information

        return query;

    }
nastassiar
  • 1,545
  • 2
  • 24
  • 43
  • 1
    I believe a similar question to this already has an answer to it: [How to Merge Two IQueryable Lists][1] [1]: http://stackoverflow.com/questions/4003813/how-to-merge-two-iqueryable-lists – Patrick Smith Mar 06 '15 at 18:17
  • 2
    Why not just make it one query, where you check if the input string matches phone number OR email OR name? – mbeckish Mar 06 '15 at 18:19
  • The part I don't know how to do is if I'm merging two entries maintain the information from the list with more. For example if One user only has information for username and id and another has username id and phone number I want the returned user to have username id and phone number. – nastassiar Mar 06 '15 at 18:20
  • 1
    You could simply use `IEnumerable` here, also I strongly advise you **not** to use anonymous types. – Binkan Salaryman Mar 06 '15 at 18:23
  • @mbeckish Because if they search by username I don't want to return the phone number or email. I only want to return the information they actually search by along with the id and username. – nastassiar Mar 06 '15 at 18:23
  • Binkan Salaryman can you explain more how IEnumerable would help solve the problem? – nastassiar Mar 06 '15 at 18:25
  • I didn't have to use `IQueryable` yet, but it extends `IEnumerable` and the converting call to `.AsQueryable();` seems unnecessary for me. Here's a link to read more their difference: http://blog.falafel.com/understanding-ienumerable-iqueryable-c/ – Binkan Salaryman Mar 06 '15 at 18:30
  • I think it would be a lot simpler if you just returned all of the information about a user. I don't quite understand why you only want to return the parameter that the user searched with? Also, when you call .ToList(), you're materializing your queryable and it's hitting the database before your select, so you get no performance benefit out of selecting only the data you want. – Sam Mar 06 '15 at 18:36
  • @Sam Like I said I'm new to IQueryable the toList was just what I had before to get my code working. And yes it would be easier to return all information, but that isn't what I'm trying to do. For privacy reasons only the information that is search by and the username and id can be returned. – nastassiar Mar 06 '15 at 18:41
  • Order them, and use the Zip method to merge the first with the second and the result from that with the third. Create a method that takes 2 User parameters and create a new User with the values set in the 2 objects, and pass it in as the delegate method in the call to Zip. I'm assuming this doesn't have to be translatable to a query provider. – moarboilerplate Mar 06 '15 at 21:52
  • As for the method body, you could just do something like `new User { Id = first.Id ?? second.Id ...}` assuming they're nullable. If they're not nullable then just compare to default(int) or whatever. – moarboilerplate Mar 06 '15 at 21:57

2 Answers2

2

There are a few issues with your code:

ToList() will execute the query. If you call AsQueryable() later, you simply create an object query on the local objects. This basically loses the notion of IQueryable, so you'd better delete all ToList() and AsQueryable() calls.

You can make it a single query instead of merging the three queries, like so:

Expression predicateBody = Expression.Constant(false);
Expression userParameter = Expression.Parameter("user", typeof(User));
Expression userUserName = Expression.MakeMemberAccess(...);
Expression userPhoneNumber = Expression.Cal(...);
Expression userEmail = Expression.Call(...);

foreach (String str in criteria)
{
    // Check if it is a phone number
    if (Regex.IsMatch(str, @"([0-9]{3})?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$"))
    {
         predicateBody = Expression.Or(predicateBody, Expression.Equals(userPhoneNumber, Expression.Constant(str)));
    }
    // check if it is an email 
    else if (criteria.Contains("@"))
    {
         predicateBody = Expression.Or(predicateBody, Expression.Equals(userEmail, Expression.Constant(str)));
    }
    else
    {
         predicateBody = Expression.Or(predicateBody, Expression.Equals(userUserName, Expression.Constant(str)));
    }
}

return query.Where(Expression.Lambda<Func<User, bool>>(predicateBody, userParameter))
     .GroupBy(u => u.Id)
     .Select(users => new User()
          {
               Id = users.Key,
               UserName = users.Select(u => u.UserName).Intersect(criteria).FirstOrDefault(),
               Email = users.Select(u => u.Email).Intersect(criteria).FirstOrDefault(),
               PhoneNumber = users.Select(u => u.PhoneNumber).Intersect(criteria).FirstOrDefault()
          });

Edit Sorry, I misunderstood the merging problem.

Edit2 If the criterias are sorted in advance, there is also a solution that does not require to manually creating the expression tree.

Edit3 I see, I forgot the part with the limited information.

var phoneNumbers = new List<string>();
var emails = new List<string>();
var userNames = new List<string>();

foreach (var str in criteria)
{
    // Check if it is a phone number
    if (Regex.IsMatch(str, @"([0-9]{3})?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$"))
    {
         phoneNumbers.Add(criteria);
    }
    // check if it is an email 
    else if (criteria.Contains("@"))
    {
         emails.Add(crietria);
    }
    else
    {
         userNames.Add(criteria);
    }
}

return query.Where(u => phoneNumbers.Contains(u.PhoneNumber)
       || emails.Contains(u.Email)
       || userNames.Contains(u.UserName))
       .GroupBy(u => u.Id)
       .Select(users => new User()
              {
                   Id = users.Key,
                   UserName = users.Select(u => u.UserName).Intersect(userNames).FirstOrDefault(),
                   Email = users.Select(u => u.Email).Intersect(emails).FirstOrDefault(),
                   PhoneNumber = users.Select(u => u.PhoneNumber).Intersect(phoneNumbers).FirstOrDefault()
              });
Georg
  • 5,626
  • 1
  • 23
  • 44
  • Read the comments on the question; the OP specifically said he can't do this. Also, there's no reason whatsoever to build the expression manually; you could save yourself a ton of effort and make the code a ton more readable/maintainable by just using a lambda; that is, if this was actually a viable approach in the first place (it of course is not). – Servy Mar 06 '15 at 18:55
  • @Servy You cannot use a lamda here because the where clause actually is a conjunction of multiple clauses that you don't know at compile-time. At leats if you don't want to concat the query multiple times which may cause serious performance issues. If you really have a better option that works, go ahead and post it. – Georg Mar 06 '15 at 19:07
  • But they *are* known at compile time. You *can* write it out with lambdas. – Servy Mar 06 '15 at 19:09
  • @Servy No, because you don't know the criteria at runtime. You can create a lambda for each criterion but there is no way to connect multiple lambdas with an or. I mean, it is a straight forward library function to implement but it is not included in the .NET framework. – Georg Mar 06 '15 at 19:30
  • The OP shows how to do it right in the question. He has lambdas for all of the filers, you perform all of them and then merge all of the results. That would work fine, except that he wants the values merged in a special way (that your answer also doesn't handle). – Servy Mar 06 '15 at 19:44
  • @Servy I edited my post so that it supports the merging in the way that was requested by the OP (the GroupBy part). Furthermore, the OP performed the base query three or n times (depending on how to interpret the OP) whereas my solution only evaluates the query once. This can be a huge difference as I said two comments ago. – Georg Mar 06 '15 at 23:51
  • No, you aren't grouping the data as is requested by the OP. You're fetching all three fields no matter what; the OP requested only fetching fields that were filtered on. The OP's code also does *not* require 3 separate queries, but rather can do it with *one* (as he has since removed the inappropriate `ToList` calls`). – Servy Mar 09 '15 at 14:07
  • @Servy Sorry for the misunderstanding, when I said three queries, I meant three query execution per execution of the filtered query unless the query engine is intelligent enough to recognize that the query is used multiple times which I doubt. Regarding the fact that I fetch all three fields, I was assuming that each entry only contains one information and then the merge is done correctly by using FirstOrDefault. Otherwise the situation seems a bit awkward to me but it would nevertheless be possible to implement another merge logic more intelligent than FirstOrDefault. – Georg Mar 09 '15 at 19:12
  • `unless the query engine is intelligent enough to recognize that the query is used multiple times` That's exactly what it's going to do. As to the merging; all three subqueries come from the same list, and none do any projecting, so no, they don't all have different fields. They all have exactly the same fields. That's the problem. You're doing exactly what the query is doing, except you're working a lot harder to do the same thing, with the same problem. – Servy Mar 09 '15 at 19:14
  • @Servy Do you know the query provider? No, there was no information about it, so I wouldn't be as sure. But if query was a database query you might be right as at least the database engine will do this optimization. In that case you could simply use a Concat to merge the queries. However, if you don't know this, I would say you cannot be as sure as you pretend. The subqueries make assertions on the same fields but still there is no way to make an or between multiple lambdas. However, I think you may be right and there is a solution using usual C# instead of manually creating the expression. – Georg Mar 09 '15 at 20:36
  • I've yet to come across a query provider that *couldn't* do this. Every one I've ever seen supports sub-queries being used. If the OP *specifically* said that it was a problem and that the query provider failed to translate the query he has, then looking for solutions that avoided it would be merited, but until that happens, it's a pretty safe assumption to make. – Servy Mar 09 '15 at 20:38
  • Once again, you're still not properly projecting the results. You're just comping up with more and more ways of doing what the OP has already done, and not doing the piece that he has yet to figure out. You're projecting out the values for all three properties, unconditionally. You need to not do that; you need to only project out values for properties that the item passed the (property specific) filter for. – Servy Mar 09 '15 at 20:46
  • @Servy You haven't come across the EnumerableQuery? Because in fact, it does *not* recognize subqueries and would definitely execute the object query three times. And I still don't understand how you understood the merging problem. – Georg Mar 09 '15 at 20:51
  • It's pretty clear that it's not an `IEnumerable` given what's shown, but rather that it's a query representing a database query. If, on the other hand, it's not, and it's entirely manipulating in-memory objects, then the multiple enumeration *wouldn't be a problem* as you wouldn't have multiple DB round trips. As for understanding the problem, I read the question, looked at the code, and read the comments. It is rather straightforward that this doesn't do what he wants. I don't understand why you *don't* understand the problem. – Servy Mar 09 '15 at 20:55
-1

This is what I ended up going with.

public IQueryable<User> Search(String[] criteria)
        {
            var query = Database.Get<User>();
            List<User> res = new List<User>(); 
            foreach (String str in criteria)
            {
                // Check if it is a phone number

                if (Regex.IsMatch(str, @"([0-9]{3})?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$"))
                {
                    var users = query.Where(
                           u => u.PhoneNumber.ToLower() == (str))
                           .Select(i => new User
                           {
                               Id = i.Id,
                               UserName = i.Name,
                               Email = null,
                               PhoneNumber = i.PhoneNumber
                           });

                    // Multiple users can have the same phone so add all results
                    foreach (User u in users)
                    {
                        if (u != null) { res.Add(u); }
                    }

                }
                // Check if it is an email match 
                else if (str.Contains("@"))
                {
                    var user = query.Where(
                           u => u.Email.ToLower() == (str))
                           .Select(i => new User
                           {
                           Id = i.Id,
                           UserName = i.Name,
                           Email = i.Email,
                           PhoneNumber = null
                       }).SingleOrDefault<User>(); // Only one user can use an email

                if (user != null) { res.Add(user); }
            }
            // Otherwise it is a username
            // NOTE: If a username is all digits and dashes it won't be 
            // searched for because it is interpreted as a phone number!
            else
            {
                var user = query.Where(
                       u => u.UserName.ToLower() == (str))
                       .Select(i => new User
                       {
                           Id = i.Id,
                           UserName = i.Name,
                           Email = null,
                           PhoneNumber = null
                       }).SingleOrDefault<User>(); // Only one user can use an email

                if (user != null) { res.Add(user); }
            }
        }

        query = res.AsQueryable(); 

        // Group the results by username and id and return all information that was found
        query = from u in query
                group u by new
                {
                    u.Id,
                    u.UserName
                } into g
                select new User()
                {
                    Id = g.Key.Id,
                    UserName = g.Key.UserName,
                    Email = g.Select(m => m.Email).SkipWhile(string.IsNullOrEmpty).FirstOrDefault(), 
                    PhoneNumber = g.Select(m => m.PhoneNumber).SkipWhile(string.IsNullOrEmpty).FirstOrDefault()
                };

        return query;
    }
nastassiar
  • 1,545
  • 2
  • 24
  • 43