Skip to content

Commit c03c478

Browse files
Merge pull request #42 from cmdscale/fix/#39_support_idempotent_scripts
fix: Fix sql generation for idempotent scripts
2 parents 3e1b811 + f16b1f2 commit c03c478

4 files changed

Lines changed: 374 additions & 3 deletions

File tree

src/Eftdb/Generators/SqlBuilderHelper.cs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ public class SqlBuilderHelper(string quoteString)
77
{
88
private readonly string quoteString = quoteString;
99

10-
public static void BuildQueryString(List<string> statements, MigrationCommandListBuilder builder, bool suppressTransaction = false)
10+
public static void BuildQueryString(List<string> statements, MigrationCommandListBuilder builder, bool suppressTransaction = false, bool usePerform = false)
1111
{
1212
if (statements.Count == 0)
1313
{
@@ -39,13 +39,51 @@ public static void BuildQueryString(List<string> statements, MigrationCommandLis
3939
// Build each command group
4040
foreach (List<string> group in commandGroups)
4141
{
42-
string command = string.Join("\n", group);
42+
List<string> processedGroup = usePerform
43+
? [.. group.Select(ReplaceSelectWithPerform)]
44+
: group;
45+
46+
string command = string.Join("\n", processedGroup);
4347
builder
4448
.Append(command)
4549
.EndCommand(suppressTransaction: suppressTransaction);
4650
}
4751
}
4852

53+
/// <summary>
54+
/// Replaces a leading SELECT keyword with PERFORM for use inside PL/pgSQL blocks.
55+
/// In PL/pgSQL (e.g., idempotent migration scripts), bare SELECT statements that return
56+
/// results fail with "query has no destination for result data". PERFORM discards the
57+
/// results silently and is the standard PL/pgSQL equivalent of SELECT for this purpose.
58+
/// </summary>
59+
internal static string ReplaceSelectWithPerform(string sql)
60+
{
61+
string trimmed = sql.TrimStart();
62+
if (trimmed.StartsWith("SELECT ", StringComparison.OrdinalIgnoreCase))
63+
{
64+
int leadingWhitespaceLength = sql.Length - trimmed.Length;
65+
return string.Concat(sql.AsSpan(0, leadingWhitespaceLength), "PERFORM", trimmed.AsSpan("SELECT".Length));
66+
}
67+
68+
return sql;
69+
}
70+
71+
/// <summary>
72+
/// Applies <see cref="ReplaceSelectWithPerform"/> to each line of a multi-line SQL string.
73+
/// Handles multi-statement SQL blocks where each statement starts on its own line.
74+
/// Continuation lines (FROM, WHERE, etc.) are not affected because they don't start with SELECT.
75+
/// </summary>
76+
internal static string ReplaceSelectWithPerformMultiLine(string sql)
77+
{
78+
string[] lines = sql.Split('\n');
79+
for (int i = 0; i < lines.Length; i++)
80+
{
81+
lines[i] = ReplaceSelectWithPerform(lines[i]);
82+
}
83+
84+
return string.Join('\n', lines);
85+
}
86+
4987
public static void BuildQueryString(List<string> statements, IndentedStringBuilder builder, bool suppressTransaction = false)
5088
{
5189
if (statements.Count > 0)

src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,38 @@ protected override void Generate(
9797
return;
9898
}
9999

100-
SqlBuilderHelper.BuildQueryString(statements, builder, suppressTransaction);
100+
bool usePerform = Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent);
101+
SqlBuilderHelper.BuildQueryString(statements, builder, suppressTransaction, usePerform);
101102

102103
}
104+
105+
/// <summary>
106+
/// Handles raw SQL operations from migration files (migrationBuilder.Sql calls).
107+
/// In idempotent mode, replaces SELECT with PERFORM because the SQL is wrapped
108+
/// in a PL/pgSQL DO block where bare SELECT fails with "query has no destination for result data".
109+
/// Skips replacement for DDL statements (CREATE, ALTER, DROP) where SELECT is part of the syntax.
110+
/// </summary>
111+
protected override void Generate(SqlOperation operation, IModel? model, MigrationCommandListBuilder builder)
112+
{
113+
if (Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent)
114+
&& !IsDdlStatement(operation.Sql))
115+
{
116+
string sql = SqlBuilderHelper.ReplaceSelectWithPerformMultiLine(operation.Sql);
117+
builder.Append(sql);
118+
builder.EndCommand(suppressTransaction: operation.SuppressTransaction);
119+
return;
120+
}
121+
122+
base.Generate(operation, model, builder);
123+
}
124+
125+
private static bool IsDdlStatement(string sql)
126+
{
127+
string trimmed = sql.TrimStart();
128+
return trimmed.StartsWith("CREATE ", StringComparison.OrdinalIgnoreCase)
129+
|| trimmed.StartsWith("ALTER ", StringComparison.OrdinalIgnoreCase)
130+
|| trimmed.StartsWith("DROP ", StringComparison.OrdinalIgnoreCase);
131+
}
103132
}
104133
#pragma warning disable IDE0079
105134
}

tests/Eftdb.Tests/Generators/SqlBuilderHelperTests.cs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,165 @@ public void BuildQueryString_MigrationCommandListBuilder_WritesNothingForEmptyLi
164164
mockBuilder.Verify(b => b.Append(It.IsAny<string>()), Times.Never);
165165
mockBuilder.Verify(b => b.EndCommand(It.IsAny<bool>()), Times.Never);
166166
}
167+
#region ReplaceSelectWithPerform
168+
169+
[Fact]
170+
public void ReplaceSelectWithPerform_ReplacesLeadingSelect()
171+
{
172+
string input = "SELECT create_hypertable('public.\"Events\"', 'CapturedAt');";
173+
string result = SqlBuilderHelper.ReplaceSelectWithPerform(input);
174+
Assert.Equal("PERFORM create_hypertable('public.\"Events\"', 'CapturedAt');", result);
175+
}
176+
177+
[Fact]
178+
public void ReplaceSelectWithPerform_PreservesLeadingWhitespace()
179+
{
180+
string input = " SELECT add_dimension('public.\"Events\"', by_range('sensor_id'));";
181+
string result = SqlBuilderHelper.ReplaceSelectWithPerform(input);
182+
Assert.Equal(" PERFORM add_dimension('public.\"Events\"', by_range('sensor_id'));", result);
183+
}
184+
185+
[Fact]
186+
public void ReplaceSelectWithPerform_PreservesNonSelectStatements()
187+
{
188+
string input = "ALTER TABLE \"public\".\"Events\" SET (timescaledb.compress = true);";
189+
string result = SqlBuilderHelper.ReplaceSelectWithPerform(input);
190+
Assert.Equal(input, result);
191+
}
192+
193+
[Fact]
194+
public void ReplaceSelectWithPerform_PreservesDoBlocks()
195+
{
196+
string input = "DO $$ BEGIN EXECUTE 'SELECT 1'; END $$;";
197+
string result = SqlBuilderHelper.ReplaceSelectWithPerform(input);
198+
Assert.Equal(input, result);
199+
}
200+
201+
[Fact]
202+
public void ReplaceSelectWithPerform_IsCaseInsensitive()
203+
{
204+
string input = "select remove_retention_policy('public.\"Events\"', if_exists => true);";
205+
string result = SqlBuilderHelper.ReplaceSelectWithPerform(input);
206+
Assert.StartsWith("PERFORM", result);
207+
}
208+
209+
[Fact]
210+
public void ReplaceSelectWithPerform_HandlesMultiLineAlterJob()
211+
{
212+
string input = @"
213+
SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days')
214+
FROM timescaledb_information.jobs
215+
WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable';".Trim();
216+
217+
string result = SqlBuilderHelper.ReplaceSelectWithPerform(input);
218+
219+
Assert.StartsWith("PERFORM alter_job", result);
220+
Assert.Contains("FROM timescaledb_information.jobs", result);
221+
Assert.DoesNotContain("SELECT", result);
222+
}
223+
224+
[Fact]
225+
public void ReplaceSelectWithPerformMultiLine_ReplacesAllSelectStatements()
226+
{
227+
string input = """
228+
SELECT create_hypertable('public."Events"', 'Time');
229+
SELECT add_dimension('public."Events"', by_range('sensor_id', 100));
230+
SELECT alter_job(job_id, schedule_interval => INTERVAL '1 day')
231+
FROM timescaledb_information.jobs
232+
WHERE proc_name = 'policy_retention';
233+
""";
234+
235+
string result = SqlBuilderHelper.ReplaceSelectWithPerformMultiLine(input);
236+
237+
Assert.DoesNotContain("SELECT create_hypertable", result);
238+
Assert.DoesNotContain("SELECT add_dimension", result);
239+
Assert.DoesNotContain("SELECT alter_job", result);
240+
Assert.Contains("PERFORM create_hypertable", result);
241+
Assert.Contains("PERFORM add_dimension", result);
242+
Assert.Contains("PERFORM alter_job", result);
243+
Assert.Contains("FROM timescaledb_information.jobs", result);
244+
}
245+
246+
[Fact]
247+
public void ReplaceSelectWithPerformMultiLine_PreservesDoBlocks()
248+
{
249+
string input = """
250+
SELECT create_hypertable('public."Events"', 'Time');
251+
DO $$
252+
BEGIN
253+
EXECUTE 'SELECT enable_chunk_skipping(...)';
254+
END $$;
255+
""";
256+
257+
string result = SqlBuilderHelper.ReplaceSelectWithPerformMultiLine(input);
258+
259+
Assert.Contains("PERFORM create_hypertable", result);
260+
Assert.Contains("DO $$", result);
261+
Assert.Contains("EXECUTE 'SELECT enable_chunk_skipping(...)'", result);
262+
}
263+
264+
#endregion
265+
266+
#region BuildQueryString_MigrationCommandListBuilder_UsePerform
267+
268+
[Fact]
269+
public void BuildQueryString_MigrationCommandListBuilder_UsePerform_ReplacesSelectWithPerform()
270+
{
271+
// Arrange
272+
List<string> statements = ["SELECT create_hypertable('public.\"Events\"', 'Time');"];
273+
MigrationsSqlGeneratorDependencies dependencies = new(
274+
Mock.Of<IRelationalCommandBuilderFactory>(),
275+
Mock.Of<IUpdateSqlGenerator>(),
276+
Mock.Of<ISqlGenerationHelper>(),
277+
Mock.Of<IRelationalTypeMappingSource>(),
278+
Mock.Of<ICurrentDbContext>(),
279+
Mock.Of<IModificationCommandFactory>(),
280+
Mock.Of<ILoggingOptions>(),
281+
Mock.Of<IRelationalCommandDiagnosticsLogger>(),
282+
Mock.Of<IDiagnosticsLogger<DbLoggerCategory.Migrations>>()
283+
);
284+
285+
Mock<MigrationCommandListBuilder> mockBuilder = new(dependencies);
286+
mockBuilder.Setup(b => b.Append(It.IsAny<string>())).Returns(mockBuilder.Object);
287+
mockBuilder.Setup(b => b.EndCommand(It.IsAny<bool>())).Returns(mockBuilder.Object);
288+
289+
// Act
290+
SqlBuilderHelper.BuildQueryString(statements, mockBuilder.Object, usePerform: true);
291+
292+
// Assert
293+
mockBuilder.Verify(b => b.Append(It.Is<string>(s => s.StartsWith("PERFORM"))), Times.Once);
294+
mockBuilder.Verify(b => b.Append(It.Is<string>(s => s.StartsWith("SELECT"))), Times.Never);
295+
}
296+
297+
[Fact]
298+
public void BuildQueryString_MigrationCommandListBuilder_UsePerform_False_PreservesSelect()
299+
{
300+
// Arrange
301+
List<string> statements = ["SELECT create_hypertable('public.\"Events\"', 'Time');"];
302+
MigrationsSqlGeneratorDependencies dependencies = new(
303+
Mock.Of<IRelationalCommandBuilderFactory>(),
304+
Mock.Of<IUpdateSqlGenerator>(),
305+
Mock.Of<ISqlGenerationHelper>(),
306+
Mock.Of<IRelationalTypeMappingSource>(),
307+
Mock.Of<ICurrentDbContext>(),
308+
Mock.Of<IModificationCommandFactory>(),
309+
Mock.Of<ILoggingOptions>(),
310+
Mock.Of<IRelationalCommandDiagnosticsLogger>(),
311+
Mock.Of<IDiagnosticsLogger<DbLoggerCategory.Migrations>>()
312+
);
313+
314+
Mock<MigrationCommandListBuilder> mockBuilder = new(dependencies);
315+
mockBuilder.Setup(b => b.Append(It.IsAny<string>())).Returns(mockBuilder.Object);
316+
mockBuilder.Setup(b => b.EndCommand(It.IsAny<bool>())).Returns(mockBuilder.Object);
317+
318+
// Act
319+
SqlBuilderHelper.BuildQueryString(statements, mockBuilder.Object, usePerform: false);
320+
321+
// Assert
322+
mockBuilder.Verify(b => b.Append(It.Is<string>(s => s.StartsWith("SELECT"))), Times.Once);
323+
}
324+
325+
#endregion
167326
}
168327
#pragma warning restore EF1001 // Internal EF Core API usage.
169328
}

0 commit comments

Comments
 (0)