Dapper 使用 QueryMultiple 为动态结果集提供默认名称

Dapper provide default name for dynamic result sets with QueryMultiple

TLDR;有没有一种方法(使用类型映射或其他解决方案)为 dynamic 结果集提供默认名称,例如在未提供列名称时 Dapper 中的“(无列名称)”?

我正在编写一个查询编辑器,允许用户针对 MS SQL 服务器数据库编写和 运行 用户提供的查询。我一直在使用 Dapper 进行所有查询,它可以很好地满足我们 99% 的需求。虽然我遇到了障碍,但我希望有人有解决方案。

查询编辑器类似于 SSMS。我事先不知道脚本会是什么样子,结果集的形状或类型是什么,甚至不知道会返回多少结果集。出于这个原因,我一直在批处理脚本并使用 Dapper 的 QueryMultipleGridReader 读取 dynamic 结果。然后将结果发送到第三方 UI 数据网格 (WPF)。数据网格知道如何使用动态数据,它唯一需要显示给定行的是至少一对具有非空但不一定是唯一键和可为空值的键值对。到目前为止,还不错。

Dapper 调用的简化版本如下所示:

        public async Task<IEnumerable<IEnumerable<T>>> QueryMultipleAsync<T>(string sql, 
                                                                             object parameters, 
                                                                             string connectionString,
                                                                             CommandType commandType = CommandType.Text, 
                                                                             CancellationTokenSource cancellationTokenSource = null)
        {
            using (IDbConnection con = _dbConnectionFactory.GetConnection(connectionString))
            {

                con.Open();
                var transaction = con.BeginTransaction();

                var sqlBatches = sql
                    .ToUpperInvariant()
                    .Split(new[] { " GO ", "\r\nGO ", "\n\nGO ", "\nGO\n", "\tGO ", "\rGO "}, StringSplitOptions.RemoveEmptyEntries);

                var batches = new List<CommandDefinition>();

                foreach(var batch in sqlBatches)
                {
                    batches.Add(new CommandDefinition(batch, parameters, transaction, null, commandType, CommandFlags.Buffered, cancellationTokenSource.Token));
                }

                var resultSet = new List<List<T>>();

                foreach (var commandDefinition in batches)
                {
                    using (GridReader reader = await con.QueryMultipleAsync(commandDefinition))
                    {
                        while (!reader.IsConsumed)
                        {
                            try
                            {
                                var result = (await reader.ReadAsync<T>()).AsList();
                                if (result.FirstOrDefault() is IDynamicMetaObjectProvider)
                                {
                                    (result as List<dynamic>).ConvertNullKeysToNoColumnName();
                                }
                                resultSet.Add(result);
                            }
                            catch(Exception e)
                            {
                                if(e.Message.Equals("No columns were selected"))
                                {
                                    break;
                                }
                                else
                                {
                                    throw;
                                }
                            }
                        }
                    }
                }
                try
                {
                    transaction.Commit();
                }
                catch (Exception ex)
                {
                    Trace.WriteLine(ex.ToString());
                    if (transaction != null)
                    {
                        transaction.Rollback();
                    }
                }

                return resultSet;
            }
        }

public static IEnumerable<dynamic> ConvertNullKeysToNoColumnName<dynamic>(this IEnumerable<dynamic> rows)
        {
            foreach (var row in rows)
            {
                if (row is IDictionary<string, object> rowDictionary)
                {
                    if (rowDictionary == null) continue;

                    rowDictionary.Where(x => string.IsNullOrEmpty(x.Key)).ToList().ForEach(x =>
                    {
                        var val = rowDictionary[x.Key];

                        if (x.Value == val)
                        {
                            rowDictionary.Remove(x);
                            rowDictionary.Add("(No Column Name)", val);
                        }
                        else
                        {
                            Trace.WriteLine("Something went wrong");
                        }
                    });
                }
            }
            return rows;
        }  

这适用于大多数查询(以及只有一个未命名结果列的查询),但是当用户编写一个包含多个未命名列的查询时,问题就会出现,如下所示:

select COUNT(*), MAX(create_date) from sys.databases.

在这种情况下,Dapper returns 看起来像这样的 DapperRow:

{DapperRow, = '9', = '2/14/2020 9:51:54 AM'}

所以结果集正是用户要求的(即没有名称或别名的值)但我需要为网格中的所有数据提供(非唯一)键...

我的第一个想法是简单地将 DapperRow 对象中的空键更改为默认值(如“(无列名)”),因为它似乎针对存储进行了优化,因此 table 键只在对象中存储一次(这很好,并且可以为具有大量结果集的查询提供不错的性能奖励)。 DapperRow 类型是私有的。四处搜索后,我发现我可以将 DapperRow 转换为 IDictionary<string, object> 以访问对象的键和值,甚至设置和删除值。这就是 ConvertNullKeysToNoColumnName 扩展方法的来源。它有效……但只有一次。

为什么?好吧,看起来当你在 DapperRow 中有多个 null 或空键被转换为 IDictionary<string,object> 并且你调用 Remove(x) 函数(其中 x 是整个项目或只是具有空键或空键的任何单个项目的键),所有后续尝试通过索引器 item[key] 解析具有空键或空键的其他值都无法检索到值——即使附加键值对仍然存在在对象中。

换句话说,在删除第一个空键后,我无法删除或替换后续的空键。

我是不是遗漏了什么明显的东西?我是否只需要通过反射更改 DapperRow 并希望它没有任何奇怪的副作用或者底层数据结构以后不会改变?或者我是否将 performance/memory 命中并将整个可能较大的结果集 copy/map 放入一个新序列中,以便在 运行 时为空键提供默认值?

我怀疑这是因为动态 DapperRow 对象实际上不是 'normal' 字典。它可以有多个具有相同密钥的条目。如果您在调试器中检查对象,您可以看到这一点。

当您引用 rowDictionary[x.Key] 时,我怀疑您将始终获得第一个未命名的列。

如果您调用 rowDictionary.Remove(""); rowDictionary.Remove("");,您实际上只删除了第一个条目 - 第二个条目仍然存在,即使 rowDictionary.ContainsKey("") returns false。

可以 Clear() 并重建整个词典。 在这一点上,使用动态对象实际上并没有带来太多好处。

if (row is IDictionary<string, object>)
{
    var rowDictionary = row as IDictionary<string, object>;
    if (rowDictionary.ContainsKey(""))
    {
        var kvs = rowDictionary.ToList();
        rowDictionary.Clear();

        for (var i = 0; i < kvs.Count; ++i)
        {
            var kv = kvs[i];

            var key = kv.Key == ""? $"(No Column <{i + 1}>)" : kv.Key;
            rowDictionary.Add(key, kv.Value);
        }
    }
}

由于您正在使用未知的结果结构,并且只想将其传递给网格视图,因此我会考虑改用 DataTable。

您仍然可以保留 Dapper 进行参数处理:

foreach (var commandDefinition in batches)
{
    using(var reader = await con.ExecuteReaderAsync(commandDefinition)) {
        while(!reader.IsClosed) {
            var table = new DataTable();
            table.Load(reader);
            resultSet.Add(table);
        }
    }
}