再分享一個 .NET 小工具。

目的是要比對兩個 Schema 相同資料表的資料差異,其中一個是參考依據(這裡稱為 Source),一個是比較對象(稱為 Target),提供 Primary Key 進行匹配比對,找出有哪幾筆需要新增、哪幾筆要刪除,哪幾筆的哪幾個欄位需要更新?

由於比對程式不一定能連得到資料庫,所以我想採取請有權限的人員將資料表轉成 JSON,再用 JSON 進行比對的做法,如此使用上較靈活不受限。資料表轉 JSON 的工具應該不少,如果懶得找,也可參考先前文章正式台測試台 JSON + Dapper 資料搬移術,用 Dapper + JSON.NET 三行搞定:

using (var cn = new SqlConnection(cs))
{
    var data = cn.Query<Player>(@"SELECT Id,Name,RegDate,Score FROM Players").ToArray();
    json = JsonConvert.SerializeObject(data);
}

比對邏輯不複雜,將資料表轉成的 JSON 反序列化回 List<IDictionary<string, object>>,以 Primary Key 欄位組成以 "|" 串接的字串當 Key,將 轉成 Dictionary<string, IDictionary<string, object>>,之後以 LINQ 語法配合 dynamic 型別特性比對,找出新增、減少及 Source 與 Target 都有但部分欄位改變的資料。程式不多,用 .NET 6 Console 專案,全部寫在 Program.cs,不到 150 行搞定:

using Newtonsoft.Json;

//args = new string[] { @".\data\source.json", @".\data\target.json", "PK1,PK2", "IgnoreCol1,IgnoreCol2" };
if (args.Length < 3)
{
    Console.WriteLine("Syntax: JsonDataDiff source.json target.json pkCol1,pkCol2,pkCol3");
    Console.WriteLine("Syntax: JsonDataDiff source.json target.json pkCol1,pkCol2 ingoredCol1,ignoreCol2");
    return;
}
var srcPath = args[0];
var dstPath = args[1];
var pkCols = args[2].Split(',');
string[] ignoredCols = args.Length > 3 ? args[3].Split(',') : Array.Empty<string>();
try
{
    var src = JsonConvert.DeserializeObject<List<IDictionary<string, object>>>(File.ReadAllText(srcPath));
    var dst = JsonConvert.DeserializeObject<List<IDictionary<string, object>>>(File.ReadAllText(dstPath));
    var report = Compare(pkCols, src, dst, ignoredCols);
    var color = ConsoleColor.White;
    var values = string.Empty;
    foreach (var entry in report)
    {
        switch (entry.Action)
        {
            case "INSERT":
                color = ConsoleColor.Green;
                values = string.Join(",", entry.Values.Select(o => $"{o.Key}=[{o.Value}]").ToArray());
                break;
            case "DELETE":
                color = ConsoleColor.Magenta;
                values = "Key=" + entry.PrimKey;
                break;
            case "UPDATE":
                color = ConsoleColor.Cyan;
                values = string.Join(",", entry.Values.Select(o => $"{o.Key}:{o.Value}").ToArray());
                break;
        }
        Console.ForegroundColor = color;
        Console.WriteLine($"*** {entry.Action} / {entry.PrimKey} ***");
        Console.ForegroundColor = ConsoleColor.White;
        Console.WriteLine("  " + values);
        Console.ResetColor();
    }
}
catch (Exception ex)
{
    Console.ForegroundColor = ConsoleColor.Red;
    Console.WriteLine($"*** ERROR ***");
    Console.ResetColor();
    Console.WriteLine(ex.ToString());
}

static DiffEntry[] Compare(string[] primKeys, List<IDictionary<string, object>> src, List<IDictionary<string, object>> dst, string[] ignoredColNames)
{
    string[] colNames = src.First().Keys.ToArray();
    if (dst.Any() && src.First().Keys.Count != dst.First().Keys.Count) throw new ArgumentException("src 與 dst 欄位數不同");
    Func<IDictionary<string, object>, string> GetPKString = (d) => string.Join("|", primKeys.Select(o => d[o]).ToArray());
    Func<object, string> Dump = (o) => {
        if (o == null) return "null";
        if  (o is DateTime) return ((DateTime)o).ToString("yyyy-MM-dd HH:mm:ss").Replace(" 00:00:00", "");
        return o.ToString();
    };
    //產生新增資訊
    Func<IDictionary<string, object>, DiffEntry> GenInsertEntry = (d) =>
    {
        return new DiffEntry
        {
            Action = "INSERT",
            PrimKey = GetPKString(d),
            Values = colNames.Where(o => !ignoredColNames.Contains(o)).ToDictionary(c => c, c => Dump(d[c]))
        };
    };
    //產生刪除資訊
    Func<IDictionary<string, object>, DiffEntry> GenDeleteEntry = (d) =>
    {
        return new DiffEntry
        {
            Action = "DELETE",
            PrimKey = GetPKString(d),
            Values = null
        };
    };
    //比對兩值是否相等
    Func<object, object, bool> AreEqual = (a, b) =>
    {
        if (a == null && b == null) return true;
        if (a == null || b == null) return false;
        if (a is decimal && b is decimal) return a.Equals(b);
        return a.ToString() == b.ToString();
    };
    //產生異動資訊
    Func<IDictionary<string, object>, IDictionary<string, object>, DiffEntry> GenUpdateScript = (source, dest) =>
    {
        var updColNames = new List<string>(); //需要更動的欄位
                var changes = new Dictionary<string, string>();
        colNames
            .Where(colName => !ignoredColNames.Contains(colName))
            .Where(colName => !AreEqual(source[colName], dest[colName])).ToList()
            .ForEach(colName =>
            {
                changes.Add(colName, $"[{dest[colName]}] -> [{source[colName]}]");
            });
        if (!changes.Any())
            return null; //若無欄位要更新,忽略
                return new DiffEntry
        {
            Action = "UPDATE",
            PrimKey = GetPKString(source),
            Values = changes
        };
    };

    //以Primary Key欄位字串為Key,轉成Dictinoary
    var srcDict = src.ToDictionary(o => GetPKString(o), o => o);
    var dstDict = dst.ToDictionary(o => GetPKString(o), o => o);
    //找出來源有但目的沒有的,新增
    var entries = new List<DiffEntry>();
    srcDict.Keys.Except(dstDict.Keys).ToList().ForEach(pk =>
    {
        entries.Add(GenInsertEntry(srcDict[pk]));
    });
    //目的有來源無,刪除之
    dstDict.Keys.Except(srcDict.Keys).ToList().ForEach(pk =>
    {
        entries.Add(GenDeleteEntry(dstDict[pk]));
    });
    //兩邊都存在者,比對是否有異動
    srcDict.Keys.Intersect(dstDict.Keys).ToList().ForEach(pk =>
    {
        var entry = GenUpdateScript(srcDict[pk], dstDict[pk]);
        if (entry != null) entries.Add(entry);
    });
    return entries.ToArray();
}

public class DiffEntry
{
    public string Action { get; set; }
    public string PrimKey { get; set; }
    public IDictionary<string, string> Values { get; set; }
}

準備了以下兩個 JSON 實測,以 Id 為 Primary Key,Jeffrey 這筆完全相同、DateChg 的 UpdTime 欄位改變、ScoreChg 的 Score 不同,source.json 有一筆 New 是新增的,target.json 有一筆 ToRemove 在 source.json 不存在,應判定為要移除:

執行時,需傳入三個參數,Source JSON 路徑、Target JSON 路徑、Primary Key 欄位清單(以,分隔),第四個參數為忽略不比對不處理欄位清單。在以上實例中,若指定 UpdTime 不比對,則 New 時不會顯示 UpdTime,DateChg 那筆也沒被判定資料有異動。

程式已上傳到 Github,有興趣的同學可以 clone 回去玩看看。

Using Json.NET, LINQ to compare two json data file exported from database to find out which rows are new, removed or modified.


Comments

# by 余小章

哥,這裡也有幾個工具,參考看看 https://www.dotblogs.com.tw/yc421206/2022/05/14/csharp_json_compare

# by Jeffrey

to 余小章,感謝分享!

# by 小黑

借串問個,版主部落格的程式碼hightlight是用哪一種??

# by Jeffrey

to 小黑,https://highlightjs.org/

Post a comment