-3

Instances of SqlDataReader can be indexed by both integers and strings (see below: reader[col]). So, I want the wrapping function to accept both List<string> and List<int> as an argument. I really don't want type-checking at runtime, though, and, as C# doesn't have something like type unions, I assume overloading is the best way to allow a function to accept different types without involving generics and at-runtime type-checking.

So... I can easily just copy the method with List<int> instead of List<string> and overload it that way, but I don't want (and shouldn't) duplicate code like this. There must be a better way.

public List<List<object>> Query(string query, List<string> relevantColumns)
    {
        var rows = new List<List<object>>();

        using (SqlConnection connection = 
            new SqlConnection(this.ConnectionString))
        {
            var command = new SqlCommand(query, connection);
            command.Connection.Open();

            using (SqlDataReader reader = command.ExecuteReader())
            {
                while (reader.Read())
                {
                    List<object> row = (from col
                                        in relevantColumns
                                        select reader[col]).ToList();
                    rows.Add(row);
                }
            }
        }
        return rows;
    }
m_ocean
  • 327
  • 3
  • 11
  • Storing as `List` means ever single thing will need to be cast back to the actual type and result in confusing code where those type are assumed. A `DataTable` on the other hand is easy to fill, stores columns as properly typed data and is in fact itself a collection – Ňɏssa Pøngjǣrdenlarp Jun 24 '22 at 19:21
  • @Achtung Thanks but no! As I understand, using generics would require at-runtime type-checking in that case to cast the argument to a certain type so that it can be used as `reader[arg]`. I can't index `reader` with `T`. – m_ocean Jun 24 '22 at 19:21
  • 3
    @m_ocean Generics don't involve casting in C# (but they do in Java). But they won't help you here anyway. Also, why do you care so much about efficiency when you're already talking to a database? The type checking won't be your performance bottleneck. – Etienne de Martel Jun 24 '22 at 19:23
  • 2
    why don't just use an ORM? something like `Dapper` or `EntityFrameowrk` or `LinqToDB`, it would be much safer and better. – iSR5 Jun 24 '22 at 19:24
  • @Etienne de Martel Misleading wording... I removed that part from the title – m_ocean Jun 24 '22 at 19:25
  • @iSR5 Thanks but the question is about C# in general. – m_ocean Jun 24 '22 at 19:25
  • 4
    Other problems aside, you seem to think any duplicating of similar code is the end of the world, and it just...isn't. This is a very small amount of code to duplicate and isn't likely to change frequently. Having some generalized abstraction to avoid the duplication is likely to be more code than just copying the code would be, and would result in code that's more complex and *more* likely to have mistakes in it than the code you're trying to avoid. – Servy Jun 24 '22 at 19:27
  • 1
    @m_ocean and those `ORM`s are in **C#** as well. – iSR5 Jun 24 '22 at 19:28
  • @ŇɏssaPøngjǣrdenlarp Thanks but it's tangential to the question I asked – m_ocean Jun 24 '22 at 19:28
  • 4
    @m_ocean This *isn't* about C# in general though. It's about you wanting to refactor one specific method. If you wanted to ask how to avoid duplicating of code *in general* for any arbitrary implementation, then that would be *far* too broad for an SO question. – Servy Jun 24 '22 at 19:29
  • `IEnumerable` > `IList` – Joel Coehoorn Jun 27 '22 at 14:48
  • Also, this function will practically force you to write code that is horribly vulnerable to sql injection attacks. You must also have a method to accept parameter information that is separate from the core SQL command string. – Joel Coehoorn Jun 27 '22 at 14:49
  • 1
    Also, the need for this `relevantColumns` set at all tells us you are writing `SELECT *`, instead of `SELECT `, which is [not considered](https://stackoverflow.com/questions/3639861/why-is-select-considered-harmful) [good practice.](https://sqlblog.org/2009/10/10/bad-habits-to-kick-using-select-omitting-the-column-list) – Joel Coehoorn Jun 27 '22 at 14:55

2 Answers2

2

Instead of asking for columns in a List<object>, we can take this a step further and create a collection of the specific target type. For example, let's say you are querying an Employee table, and you need to fill an Employee object with columns for EmployeeNumber, FirstName, and LastName. The class looks like this:

public class Employee
{
    public int EmployeeNumber {get; set;}
    public string FirstName {get;set;}
    public string LastName {get;set;}
}

And you have a query like this:

SELECT * 
FROM Employee 
WHERE HireDate<=current_timestamp and COALESCE(TermDate,'29991231') > current_timestamp

We can make a basic method like this:

private IEnumerable<T> GetData<T>(string SQL, Func<IDataRecord, T> translate, Action<SqlParameterCollection> addParams)
{
    using var con = new SqlConnection(this.ConnectionString);
    using var cmd = new SqlCommand(SQL, con);

    if(addParams is object) addParams(cmd.Parameters);

    con.Open();
    using var rdr = cmd.ExecuteReader();
    while(rdr.Read())
    {
        yield return translate(rdr);
    }    
    rdr.Close();
}

Notice the method is private. Now we also add a public method like this:

public IEnumerable<Employee> GetEmployees()
{
    string SQL = @"
SELECT EmployeeNumber, FirstName, LastName 
FROM Employee 
WHERE HireDate<=current_timestamp and COALESCE(TermDate,'29991231') > current_timestamp";

    return GetData(SQL, row => new Employee {
            EmployeeNumber = row["EmployeeNumber"],
            FirstName = row["FirstName"],
            LastName = row["LastName"]        
        }, null);

}

Now we have pushed the column mapping into the translate() lambda, and we get back typed results, instead of just an Object. Notice I also specified the column list in the SQL. You should be doing this with every query.

Let's look at one more example that uses the parameter lambda:

public Employee GetEmployeeByNumber(int EmployeeNumber)
{
    string SQL = @"
SELECT EmployeeNumber, FirstName, LastName 
FROM Employee 
WHERE EmployeeNumber = @EmployeeNumber";

    return GetData(SQL, row => new Employee {
            EmployeeNumber = row["EmployeeNumber"],
            FirstName = row["FirstName"],
            LastName = row["LastName"]        
        }, p => {
            p.Add("@EmployeeNumber", SqlDbType.Int).Value = EmployeeNumber;
        }).FirstOrDefault();
}

We see how this also lets us safely include parameter data for the query. The original method would have practically forced you to write unsafe code.

Joel Coehoorn
  • 399,467
  • 113
  • 570
  • 794
  • This is a much better answer than mine, and should be the accepted answer. Though, I'm personally not a big fan of `yield` in database access; in my experience someone will eventually do something that causes the query to execute multiple times because they aren't aware that each time they iterate the resulting `IEnumerable`, the query executes again. – Joshua Robinson Jun 27 '22 at 15:59
0

You could have a third overload which takes a Func<SqlDataReader, List<object>>. Then call the other two overloads in terms of that overload. Something like...

public static class SqlUtilities
{
    public static List<List<object>> Query(string query, List<string> relevantColumns)
    {
        return Query(query, reader => (from col in relevantColumns select reader[col]).ToList());
    }

    public static List<List<object>> Query(string query, List<int> relevantColumns)
    {
        return Query(query, reader => (from col in relevantColumns select reader[col]).ToList());
    }

    private static List<List<object>> Query(
        string query, 
        Func<SqlDataReader, List<object>> selector)
    {
        var rows = new List<List<object>>();

        using (SqlConnection connection = 
            new SqlConnection(this.ConnectionString))
        {
            var command = new SqlCommand(query, connection);
            command.Connection.Open();

            using (SqlDataReader reader = command.ExecuteReader())
            {
                while (reader.Read())
                {
                    List<object> row = selector(reader);
                    rows.Add(row);
                }
            }
        }
        return rows;
    }
}

In this specific case, however, my opinion is that a better way to solve it would be to rewrite query if that is an option. relevantColumns as a list of names or a list of ordinals is unnecessary if query is only selecting the relevant columns.

Joshua Robinson
  • 3,399
  • 7
  • 22
  • Hm, that's pretty clean! Thank you for suggestion! – m_ocean Jun 25 '22 at 19:27
  • Better, but we can improve on it further by making the method generic and adding a `translator()` lambda to also convert each row in the results to a typed object instance, and in this way avoid stuffing everything into `object`. We can also use an iterator block to `yield` each of these instead of converting a memory-efficient datareader into a memory-inefficient list. Finally, this really **NEEDS** a way to accept query parameters separate from the core SQL command string, to avoid injection issues. – Joel Coehoorn Jun 27 '22 at 14:53