Skip to content

Commit c8ac4b1

Browse files
Copilotnnhy
andcommitted
feat: 支持 MERGE INTO 语法,冲突时自动更新非键列
Co-authored-by: nnhy <506367+nnhy@users.noreply.github.com>
1 parent 3645358 commit c8ac4b1

8 files changed

Lines changed: 362 additions & 0 deletions

File tree

NewLife.NovaDb/Sql/SqlAst.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ public enum SqlStatementType
3131
TruncateTable,
3232
/// <summary>UPSERT(INSERT ... ON DUPLICATE KEY UPDATE)</summary>
3333
Upsert,
34+
/// <summary>MERGE INTO(冲突时自动更新)</summary>
35+
Merge,
3436
/// <summary>EXPLAIN 查询计划</summary>
3537
Explain
3638
}
@@ -227,6 +229,22 @@ public class InsertStatement : SqlStatement
227229
public List<List<SqlExpression>> ValuesList { get; set; } = [];
228230
}
229231

232+
/// <summary>MERGE INTO 语句(冲突时自动更新非键列)</summary>
233+
public class MergeStatement : SqlStatement
234+
{
235+
/// <summary>语句类型</summary>
236+
public override SqlStatementType StatementType => SqlStatementType.Merge;
237+
238+
/// <summary>表名</summary>
239+
public String TableName { get; set; } = String.Empty;
240+
241+
/// <summary>列名列表(可选)</summary>
242+
public List<String>? Columns { get; set; }
243+
244+
/// <summary>值列表(多行插入)</summary>
245+
public List<List<SqlExpression>> ValuesList { get; set; } = [];
246+
}
247+
230248
/// <summary>UPSERT 语句(INSERT ... ON DUPLICATE KEY UPDATE)</summary>
231249
public class UpsertStatement : SqlStatement
232250
{

NewLife.NovaDb/Sql/SqlEngine.DML.cs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,5 +186,118 @@ private SqlResult ExecuteUpsert(UpsertStatement stmt, Dictionary<String, Object?
186186
return new SqlResult { AffectedRows = affectedRows };
187187
}
188188

189+
private SqlResult ExecuteMerge(MergeStatement stmt, Dictionary<String, Object?>? parameters)
190+
{
191+
var table = GetTable(stmt.TableName);
192+
var schema = GetSchema(stmt.TableName);
193+
var pkCol = schema.GetPrimaryKeyColumn()
194+
?? throw new NovaException(ErrorCode.InvalidArgument, "MERGE requires a table with a primary key");
195+
196+
using var tx = _txManager.BeginTransaction();
197+
var affectedRows = 0;
198+
199+
foreach (var values in stmt.ValuesList)
200+
{
201+
var row = new Object?[schema.Columns.Count];
202+
203+
if (stmt.Columns != null)
204+
{
205+
for (var i = 0; i < stmt.Columns.Count; i++)
206+
{
207+
var colIdx = schema.GetColumnIndex(stmt.Columns[i]);
208+
row[colIdx] = EvaluateExpression(values[i], null, schema, parameters);
209+
}
210+
}
211+
else
212+
{
213+
if (values.Count != schema.Columns.Count)
214+
throw new NovaException(ErrorCode.InvalidArgument,
215+
$"MERGE values count ({values.Count}) does not match column count ({schema.Columns.Count})");
216+
217+
for (var i = 0; i < values.Count; i++)
218+
{
219+
row[i] = EvaluateExpression(values[i], null, schema, parameters);
220+
}
221+
}
222+
223+
ConvertRowTypes(row, schema);
224+
225+
// 检测唯一冲突:优先检查主键,再检查唯一索引
226+
Object? existingPkValue = null;
227+
Object?[]? existingRow = null;
228+
229+
// 1. 检查主键冲突
230+
var pkValue = row[pkCol.Ordinal];
231+
if (pkValue != null)
232+
{
233+
existingRow = table.Get(tx, pkValue);
234+
if (existingRow != null)
235+
existingPkValue = pkValue;
236+
}
237+
238+
// 2. 若无主键冲突,检查唯一索引冲突
239+
if (existingRow == null)
240+
{
241+
foreach (var indexDef in schema.Indexes)
242+
{
243+
if (!indexDef.IsUnique) continue;
244+
245+
var indexKeyValues = new Object?[indexDef.Columns.Count];
246+
for (var i = 0; i < indexDef.Columns.Count; i++)
247+
{
248+
var colIdx = schema.GetColumnIndex(indexDef.Columns[i]);
249+
indexKeyValues[i] = row[colIdx];
250+
}
251+
252+
var matchedPks = table.LookupByIndex(indexDef.IndexName, indexKeyValues);
253+
if (matchedPks != null && matchedPks.Count > 0)
254+
{
255+
existingPkValue = matchedPks[0];
256+
existingRow = table.Get(tx, existingPkValue);
257+
break;
258+
}
259+
}
260+
}
261+
262+
if (existingRow != null)
263+
{
264+
// 冲突:用 VALUES 数据更新非主键列
265+
var newRow = new Object?[schema.Columns.Count];
266+
Array.Copy(existingRow, newRow, existingRow.Length);
267+
268+
if (stmt.Columns != null)
269+
{
270+
for (var i = 0; i < stmt.Columns.Count; i++)
271+
{
272+
var colIdx = schema.GetColumnIndex(stmt.Columns[i]);
273+
if (colIdx == pkCol.Ordinal) continue;
274+
newRow[colIdx] = row[colIdx];
275+
}
276+
}
277+
else
278+
{
279+
for (var i = 0; i < schema.Columns.Count; i++)
280+
{
281+
if (i == pkCol.Ordinal) continue;
282+
newRow[i] = row[i];
283+
}
284+
}
285+
286+
ConvertRowTypes(newRow, schema);
287+
table.Update(tx, existingPkValue!, newRow);
288+
}
289+
else
290+
{
291+
// 无冲突:执行 INSERT
292+
table.Insert(tx, row);
293+
}
294+
295+
affectedRows++;
296+
}
297+
298+
tx.Commit();
299+
return new SqlResult { AffectedRows = affectedRows };
300+
}
301+
189302
#endregion
190303
}

NewLife.NovaDb/Sql/SqlEngine.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ public SqlResult Execute(String sql, Dictionary<String, Object?>? parameters = n
120120
// DML 语句
121121
InsertStatement insert => TrackInsert(ExecuteInsert(insert, parameters), sql),
122122
UpsertStatement upsert => TrackInsert(ExecuteUpsert(upsert, parameters), sql),
123+
MergeStatement merge => TrackInsert(ExecuteMerge(merge, parameters), sql),
123124
UpdateStatement update => TrackUpdate(ExecuteUpdate(update, parameters), sql),
124125
DeleteStatement delete => TrackDelete(ExecuteDelete(delete, parameters), sql),
125126

NewLife.NovaDb/Sql/SqlLexer.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ public class SqlLexer
7676
["TRUNCATE"] = SqlTokenType.Truncate,
7777
["DUPLICATE"] = SqlTokenType.Duplicate,
7878
["EXPLAIN"] = SqlTokenType.Explain,
79+
["MERGE"] = SqlTokenType.Merge,
7980
};
8081

8182
/// <summary>创建词法分析器</summary>

NewLife.NovaDb/Sql/SqlParser.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public SqlStatement Parse()
2828
{
2929
SqlTokenType.Select => (SqlStatement)ParseSelect(),
3030
SqlTokenType.Insert => ParseInsert(),
31+
SqlTokenType.Merge => ParseMerge(),
3132
SqlTokenType.Update => ParseUpdate(),
3233
SqlTokenType.Delete => ParseDelete(),
3334
SqlTokenType.Create => ParseCreate(),
@@ -460,6 +461,57 @@ private SqlStatement ParseInsert()
460461
};
461462
}
462463

464+
private MergeStatement ParseMerge()
465+
{
466+
Expect(SqlTokenType.Merge);
467+
Expect(SqlTokenType.Into);
468+
469+
var tableName = ExpectIdentifier();
470+
471+
// 可选的列名列表
472+
List<String>? columns = null;
473+
if (Peek().Type == SqlTokenType.LeftParen)
474+
{
475+
Advance();
476+
columns = [];
477+
478+
do
479+
{
480+
columns.Add(ExpectIdentifier());
481+
}
482+
while (TryConsume(SqlTokenType.Comma));
483+
484+
Expect(SqlTokenType.RightParen);
485+
}
486+
487+
Expect(SqlTokenType.Values);
488+
489+
// 解析一组或多组值
490+
var valuesList = new List<List<SqlExpression>>();
491+
do
492+
{
493+
Expect(SqlTokenType.LeftParen);
494+
var values = new List<SqlExpression>();
495+
496+
do
497+
{
498+
values.Add(ParseExpression());
499+
}
500+
while (TryConsume(SqlTokenType.Comma));
501+
502+
Expect(SqlTokenType.RightParen);
503+
valuesList.Add(values);
504+
}
505+
while (TryConsume(SqlTokenType.Comma));
506+
507+
return new MergeStatement
508+
{
509+
TableName = tableName,
510+
Columns = columns,
511+
ValuesList = valuesList
512+
};
513+
}
514+
463515
private UpdateStatement ParseUpdate()
464516
{
465517
Expect(SqlTokenType.Update);

NewLife.NovaDb/Sql/SqlToken.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ public enum SqlTokenType
132132
Duplicate,
133133
/// <summary>EXPLAIN</summary>
134134
Explain,
135+
/// <summary>MERGE</summary>
136+
Merge,
135137

136138
// 标识符与字面量
137139
/// <summary>标识符</summary>

XUnitTest/Sql/SqlEngineFullTests.cs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,4 +716,136 @@ public void UpsertTrackedAsInsert()
716716
}
717717

718718
#endregion
719+
720+
#region MERGE INTO 测试
721+
722+
[Fact(DisplayName = "MERGE INTO 行不存在时执行插入")]
723+
public void MergeInsertWhenNotExists()
724+
{
725+
CreateUsersTable();
726+
727+
var result = _engine.Execute("MERGE INTO users (id, name, age) VALUES (1, 'Alice', 25)");
728+
Assert.Equal(1, result.AffectedRows);
729+
730+
var select = _engine.Execute("SELECT * FROM users WHERE id = 1");
731+
Assert.Single(select.Rows!);
732+
Assert.Equal("Alice", select.Rows![0][1]);
733+
Assert.Equal(25, select.Rows![0][2]);
734+
}
735+
736+
[Fact(DisplayName = "MERGE INTO 主键冲突时执行更新")]
737+
public void MergeUpdateWhenPkConflict()
738+
{
739+
SeedUsers();
740+
741+
var result = _engine.Execute("MERGE INTO users VALUES (1, 'AliceUpdated', 99)");
742+
Assert.Equal(1, result.AffectedRows);
743+
744+
var select = _engine.Execute("SELECT * FROM users WHERE id = 1");
745+
Assert.Single(select.Rows!);
746+
Assert.Equal("AliceUpdated", select.Rows![0][1]);
747+
Assert.Equal(99, select.Rows![0][2]);
748+
}
749+
750+
[Fact(DisplayName = "MERGE INTO 不指定列名")]
751+
public void MergeWithoutColumns()
752+
{
753+
SeedUsers();
754+
755+
_engine.Execute("MERGE INTO users VALUES (1, 'Updated', 88)");
756+
757+
var select = _engine.Execute("SELECT * FROM users WHERE id = 1");
758+
Assert.Single(select.Rows!);
759+
Assert.Equal("Updated", select.Rows![0][1]);
760+
Assert.Equal(88, select.Rows![0][2]);
761+
}
762+
763+
[Fact(DisplayName = "MERGE INTO 指定部分列名")]
764+
public void MergeWithPartialColumns()
765+
{
766+
SeedUsers();
767+
768+
_engine.Execute("MERGE INTO users (id, name) VALUES (1, 'NewName')");
769+
770+
var select = _engine.Execute("SELECT * FROM users WHERE id = 1");
771+
Assert.Single(select.Rows!);
772+
Assert.Equal("NewName", select.Rows![0][1]);
773+
// age 保持原值
774+
Assert.Equal(25, select.Rows![0][2]);
775+
}
776+
777+
[Fact(DisplayName = "MERGE INTO 多行混合插入和更新")]
778+
public void MergeMultipleRowsMixed()
779+
{
780+
SeedUsers();
781+
782+
// id=1 已存在 → 更新;id=10 不存在 → 插入
783+
var result = _engine.Execute("MERGE INTO users VALUES (1, 'AliceNew', 50), (10, 'Dave', 40)");
784+
Assert.Equal(2, result.AffectedRows);
785+
786+
var select1 = _engine.Execute("SELECT * FROM users WHERE id = 1");
787+
Assert.Equal("AliceNew", select1.Rows![0][1]);
788+
Assert.Equal(50, select1.Rows![0][2]);
789+
790+
var select2 = _engine.Execute("SELECT * FROM users WHERE id = 10");
791+
Assert.Single(select2.Rows!);
792+
Assert.Equal("Dave", select2.Rows![0][1]);
793+
}
794+
795+
[Fact(DisplayName = "MERGE INTO 唯一索引冲突时执行更新")]
796+
public void MergeUpdateOnUniqueIndexConflict()
797+
{
798+
CreateUsersTable();
799+
_engine.Execute("CREATE UNIQUE INDEX idx_name ON users (name)");
800+
_engine.Execute("INSERT INTO users VALUES (1, 'Alice', 25)");
801+
802+
// 唯一索引冲突:name='Alice' 已存在
803+
var result = _engine.Execute("MERGE INTO users VALUES (99, 'Alice', 99)");
804+
Assert.Equal(1, result.AffectedRows);
805+
806+
// 应该更新已有行的 age,而非插入新行
807+
var select = _engine.Execute("SELECT * FROM users WHERE id = 1");
808+
Assert.Single(select.Rows!);
809+
Assert.Equal("Alice", select.Rows![0][1]);
810+
Assert.Equal(99, select.Rows![0][2]);
811+
812+
// 不应有 id=99 的行
813+
var select2 = _engine.Execute("SELECT * FROM users WHERE id = 99");
814+
Assert.Empty(select2.Rows!);
815+
}
816+
817+
[Fact(DisplayName = "MERGE INTO 使用参数化值")]
818+
public void MergeWithParameters()
819+
{
820+
SeedUsers();
821+
822+
var parameters = new Dictionary<String, Object?>
823+
{
824+
["@id"] = 1,
825+
["@name"] = "ParamAlice",
826+
["@age"] = 77
827+
};
828+
829+
var result = _engine.Execute(
830+
"MERGE INTO users (id, name, age) VALUES (@id, @name, @age)",
831+
parameters);
832+
Assert.Equal(1, result.AffectedRows);
833+
834+
var select = _engine.Execute("SELECT * FROM users WHERE id = 1");
835+
Assert.Equal("ParamAlice", select.Rows![0][1]);
836+
Assert.Equal(77, select.Rows![0][2]);
837+
}
838+
839+
[Fact(DisplayName = "MERGE INTO 计入 InsertCount")]
840+
public void MergeTrackedAsInsert()
841+
{
842+
CreateUsersTable();
843+
var beforeInsert = _engine.Metrics.InsertCount;
844+
845+
_engine.Execute("MERGE INTO users VALUES (1, 'Alice', 25)");
846+
847+
Assert.Equal(beforeInsert + 1, _engine.Metrics.InsertCount);
848+
}
849+
850+
#endregion
719851
}

0 commit comments

Comments
 (0)