Skip to content

Commit 8c582ac

Browse files
committed
Speed up inserts with MySqlDataAdapter. Fixes #1124
1 parent 91d4e38 commit 8c582ac

File tree

2 files changed

+239
-1
lines changed

2 files changed

+239
-1
lines changed

src/MySqlConnector/MySqlDataAdapter.cs

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using System.Text.RegularExpressions;
4+
using MySqlConnector.Core;
5+
16
namespace MySqlConnector;
27

38
public sealed class MySqlDataAdapter : DbDataAdapter
@@ -96,7 +101,134 @@ protected override int AddToBatch(IDbCommand command)
96101

97102
protected override void ClearBatch() => m_batch!.BatchCommands.Clear();
98103

99-
protected override int ExecuteBatch() => m_batch!.ExecuteNonQuery();
104+
protected override int ExecuteBatch()
105+
{
106+
if (TryConvertToCommand(m_batch!) is MySqlCommand command)
107+
{
108+
command.Connection = m_batch!.Connection;
109+
command.Transaction = m_batch.Transaction;
110+
return command.ExecuteNonQuery();
111+
}
112+
else
113+
{
114+
return m_batch!.ExecuteNonQuery();
115+
}
116+
}
117+
118+
// Detects if the commands in 'batch' are all "INSERT" commands that can be combined into one large value list;
119+
// returns a MySqlCommand with the combined SQL if so.
120+
internal static MySqlCommand? TryConvertToCommand(MySqlBatch batch)
121+
{
122+
// ensure there are at least two commands
123+
if (batch.BatchCommands.Count < 1)
124+
return null;
125+
126+
// check for a parameterized command
127+
var firstCommand = batch.BatchCommands[0];
128+
if (firstCommand.Parameters.Count == 0)
129+
return null;
130+
firstCommand.Batch = batch;
131+
132+
// check that all commands have the same SQL
133+
var sql = firstCommand.CommandText;
134+
for (var i = 1; i < batch.BatchCommands.Count; i++)
135+
{
136+
if (batch.BatchCommands[i].CommandText != sql)
137+
return null;
138+
}
139+
140+
// check that it's an INSERT statement
141+
if (!sql.StartsWith("INSERT INTO ", StringComparison.OrdinalIgnoreCase))
142+
return null;
143+
144+
// check for "VALUES(...)" clause
145+
var match = Regex.Match(sql, @"\bVALUES\s*\([^)]+\)\s*;?\s*$", RegexOptions.Singleline | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
146+
if (!match.Success)
147+
return null;
148+
149+
// extract the parameters
150+
var parser = new InsertSqlParser(firstCommand);
151+
parser.Parse(sql);
152+
153+
// record the parameter indexes that were found
154+
foreach (var parameterIndex in parser.ParameterIndexes)
155+
{
156+
if (parameterIndex < 0 || parameterIndex >= firstCommand.Parameters.Count)
157+
return null;
158+
}
159+
160+
// ensure that the VALUES(...) clause contained only parameters, and that all were consumed
161+
var remainingValues = parser.CommandText.Substring(match.Index + 6).Trim();
162+
remainingValues = remainingValues.TrimEnd(';').Trim().TrimStart('(').TrimEnd(')');
163+
remainingValues = remainingValues.Replace(",", "");
164+
if (!string.IsNullOrWhiteSpace(remainingValues))
165+
return null;
166+
167+
// build one INSERT statement with concatenated VALUES
168+
var combinedCommand = new MySqlCommand();
169+
var sqlBuilder = new StringBuilder(sql.Substring(0, match.Index + 6));
170+
var combinedParameterIndex = 0;
171+
for (var i = 0; i < batch.BatchCommands.Count; i++)
172+
{
173+
var command = batch.BatchCommands[i];
174+
if (i != 0)
175+
sqlBuilder.Append(',');
176+
sqlBuilder.Append('(');
177+
178+
for (var parameterIndex = 0; parameterIndex < parser.ParameterIndexes.Count; parameterIndex++)
179+
{
180+
if (parameterIndex != 0)
181+
sqlBuilder.Append(',');
182+
var parameterName = "@p" + combinedParameterIndex.ToString(CultureInfo.InvariantCulture);
183+
sqlBuilder.Append(parameterName);
184+
combinedParameterIndex++;
185+
var parameter = command.Parameters[parser.ParameterIndexes[parameterIndex]].Clone();
186+
parameter.ParameterName = parameterName;
187+
combinedCommand.Parameters.Add(parameter);
188+
}
189+
190+
sqlBuilder.Append(')');
191+
}
192+
sqlBuilder.Append(';');
193+
194+
combinedCommand.CommandText = sqlBuilder.ToString();
195+
return combinedCommand;
196+
}
197+
198+
internal sealed class InsertSqlParser : SqlParser
199+
{
200+
public InsertSqlParser(IMySqlCommand command)
201+
: base(new StatementPreparer(command.CommandText!, null, command.CreateStatementPreparerOptions()))
202+
{
203+
CommandText = command.CommandText!;
204+
m_parameters = command.RawParameters;
205+
ParameterIndexes = new();
206+
}
207+
208+
public List<int> ParameterIndexes { get; }
209+
210+
public string CommandText { get; private set; }
211+
212+
protected override void OnNamedParameter(int index, int length)
213+
{
214+
var name = CommandText.Substring(index, length);
215+
var parameterIndex = m_parameters?.NormalizedIndexOf(name) ?? -1;
216+
ParameterIndexes.Add(parameterIndex);
217+
218+
// overwrite the parameter name with spaces
219+
CommandText = CommandText.Substring(0, index) + new string(' ', length) + CommandText.Substring(index + length);
220+
}
221+
222+
protected override void OnPositionalParameter(int index)
223+
{
224+
ParameterIndexes.Add(ParameterIndexes.Count);
225+
226+
// overwrite the parameter placeholder with a space
227+
CommandText = CommandText.Substring(0, index) + " " + CommandText.Substring(index + 1);
228+
}
229+
230+
readonly MySqlParameterCollection? m_parameters;
231+
}
100232

101233
MySqlBatch? m_batch;
102234
}

tests/SideBySide/DataAdapterTests.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Globalization;
2+
13
namespace SideBySide;
24

35
public class DataAdapterTests : IClassFixture<DatabaseFixture>, IDisposable
@@ -235,5 +237,109 @@ public void BatchInsert()
235237
Assert.Equal(new[] { null, "", "one", "two", "three", "four" }, m_connection.Query<string>("SELECT text_value FROM data_adapter ORDER BY id"));
236238
}
237239

240+
#if !BASELINE
241+
[Theory]
242+
[InlineData("INSERT INTO table(col1, col2) VALUES(@col1, @col2);", "@col1,@col2", "0,1")]
243+
[InlineData("INSERT INTO table(col1, col2) VALUES(@col1, @col2);", "@col2,@col1", "1,0")]
244+
[InlineData("INSERT INTO table(col1, col2) VALUES(@col1, @col2);", "@col1,@col2,@col3", "0,1")]
245+
[InlineData("INSERT INTO table(col1, col2) VALUES(@col2, @col3);", "@col1,@col2,@col3", "1,2")]
246+
[InlineData("INSERT INTO table(col1, col2) VALUES(?, ?);", "@col1,@col2", "0,1")]
247+
[InlineData("INSERT INTO table(col1, col2)\nVALUES(@col1, @col2);", "@col1,@col2", "0,1")]
248+
public void ExtractParameterIndexes(string sql, string parameterNames, string expectedIndexes)
249+
{
250+
var command = new MySqlCommand(sql, m_connection);
251+
foreach (var parameterName in parameterNames.Split(','))
252+
command.Parameters.Add(new MySqlParameter(parameterName, MySqlDbType.Int32));
253+
var parser = new MySqlDataAdapter.InsertSqlParser(command);
254+
parser.Parse(sql);
255+
Assert.Equal(expectedIndexes.Split(',').Select(x => int.Parse(x, CultureInfo.InvariantCulture)), parser.ParameterIndexes);
256+
}
257+
258+
[Theory]
259+
[InlineData("SELECT * FROM table;", 2, 2, null)]
260+
[InlineData("SELECT * FROM table VALUES;", 2, 2, null)]
261+
[InlineData("INSERT INTO table VALUES(@param0, @param1)", 2, 2, "INSERT INTO table VALUES(@p0,@p1),(@p2,@p3);")]
262+
[InlineData("INSERT INTO table VALUES(@param0, @param1)", 3, 2, "INSERT INTO table VALUES(@p0,@p1),(@p2,@p3),(@p4,@p5);")]
263+
[InlineData("INSERT INTO table VALUES(@param0, @param1)", 2, 3, "INSERT INTO table VALUES(@p0,@p1),(@p2,@p3);")]
264+
[InlineData("INSERT INTO table VALUES(@param0, @param1, @param2)", 2, 3, "INSERT INTO table VALUES(@p0,@p1,@p2),(@p3,@p4,@p5);")]
265+
[InlineData("INSERT INTO table VALUES(@param0, @param1);", 2, 2, "INSERT INTO table VALUES(@p0,@p1),(@p2,@p3);")]
266+
[InlineData("INSERT INTO table VALUES(?, ?)", 2, 2, "INSERT INTO table VALUES(@p0,@p1),(@p2,@p3);")]
267+
[InlineData("INSERT INTO table VALUES ( @param0 , \n @param1 ) ; ", 2, 2, "INSERT INTO table VALUES(@p0,@p1),(@p2,@p3);")]
268+
[InlineData("INSERT INTO table VALUES(@param0, @param2);", 2, 2, null)]
269+
[InlineData("INSERT INTO table\nVALUES\n(@param0, @param1);", 2, 2, "INSERT INTO table\nVALUES(@p0,@p1),(@p2,@p3);")]
270+
[InlineData("INSERT INTO `table` VALUES (@\"param0\", @\"param1\");", 2, 2, "INSERT INTO `table` VALUES(@p0,@p1),(@p2,@p3);")]
271+
[InlineData("INSERT\nINTO\ntable\nVALUES\n(@param0, @param1);", 2, 2, null)] // ideally should work but \n not supported
272+
[InlineData("INSERT INTO table VALUES(1, @param0, @param1);", 2, 2, null)]
273+
public void ConvertBatchToCommand(string insertSql, int commandCount, int parameterCount, string expected)
274+
{
275+
var batch = new MySqlBatch(m_connection);
276+
for (var i = 0; i < commandCount; i++)
277+
{
278+
var batchCommand = new MySqlBatchCommand(insertSql);
279+
for (var j = 0; j < parameterCount; j++)
280+
{
281+
batchCommand.Parameters.Add(new MySqlParameter($"@param{j}", MySqlDbType.Int32));
282+
}
283+
batch.BatchCommands.Add(batchCommand);
284+
}
285+
286+
var command = MySqlDataAdapter.TryConvertToCommand(batch);
287+
if (expected is null)
288+
{
289+
Assert.Null(command);
290+
}
291+
else
292+
{
293+
Assert.NotNull(command);
294+
Assert.Equal(expected, command.CommandText);
295+
}
296+
}
297+
298+
[Fact]
299+
public void ConvertBatchToCommandParameters()
300+
{
301+
var insertSql = "INSERT INTO table(col1, col2, col3) VALUES(@c1, @c2, @c1);";
302+
var batch = new MySqlBatch(m_connection)
303+
{
304+
BatchCommands =
305+
{
306+
new MySqlBatchCommand(insertSql)
307+
{
308+
Parameters =
309+
{
310+
new MySqlParameter("@c1", 1),
311+
new MySqlParameter("@c2", 2),
312+
},
313+
},
314+
new MySqlBatchCommand(insertSql)
315+
{
316+
Parameters =
317+
{
318+
new MySqlParameter("@c1", 3),
319+
new MySqlParameter("@c2", 4),
320+
},
321+
},
322+
},
323+
};
324+
325+
var command = MySqlDataAdapter.TryConvertToCommand(batch);
326+
Assert.NotNull(command);
327+
Assert.Equal("INSERT INTO table(col1, col2, col3) VALUES(@p0,@p1,@p2),(@p3,@p4,@p5);", command.CommandText);
328+
Assert.Equal(6, command.Parameters.Count);
329+
Assert.Equal("@p0", command.Parameters[0].ParameterName);
330+
Assert.Equal(1, command.Parameters[0].Value);
331+
Assert.Equal("@p1", command.Parameters[1].ParameterName);
332+
Assert.Equal(2, command.Parameters[1].Value);
333+
Assert.Equal("@p2", command.Parameters[2].ParameterName);
334+
Assert.Equal(1, command.Parameters[2].Value);
335+
Assert.Equal("@p3", command.Parameters[3].ParameterName);
336+
Assert.Equal(3, command.Parameters[3].Value);
337+
Assert.Equal("@p4", command.Parameters[4].ParameterName);
338+
Assert.Equal(4, command.Parameters[4].Value);
339+
Assert.Equal("@p5", command.Parameters[5].ParameterName);
340+
Assert.Equal(3, command.Parameters[5].Value);
341+
}
342+
#endif
343+
238344
readonly MySqlConnection m_connection;
239345
}

0 commit comments

Comments
 (0)