Skip to content

Commit 18e9792

Browse files
committed
Add UPSERT support to query builder and SQL generators
1 parent 4fb38e3 commit 18e9792

18 files changed

Lines changed: 1605 additions & 0 deletions

src/FluentCommand/Query/Generators/IQueryGenerator.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ public interface IQueryGenerator
1919
/// <returns>A SQL INSERT statement string.</returns>
2020
string BuildInsert(InsertStatement insertStatement);
2121

22+
/// <summary>
23+
/// Builds a SQL UPSERT statement from the specified <see cref="UpsertStatement"/>.
24+
/// </summary>
25+
/// <param name="upsertStatement">The <see cref="UpsertStatement"/> containing the UPSERT statement configuration.</param>
26+
/// <returns>A SQL UPSERT statement string.</returns>
27+
string BuildUpsert(UpsertStatement upsertStatement);
28+
2229
/// <summary>
2330
/// Builds a SQL SELECT statement from the specified <see cref="SelectStatement"/>.
2431
/// </summary>

src/FluentCommand/Query/Generators/PostgresqlGenerator.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,55 @@ public override string BuildInsert(InsertStatement insertStatement)
6969
return StringBuilderCache.ToString(insertBuilder);
7070
}
7171

72+
/// <summary>
73+
/// Builds a SQL UPSERT statement for PostgreSQL using ON CONFLICT syntax, including support for RETURNING and comments.
74+
/// </summary>
75+
/// <param name="upsertStatement">The <see cref="UpsertStatement"/> containing the UPSERT statement configuration.</param>
76+
/// <returns>A SQL UPSERT statement string for PostgreSQL.</returns>
77+
/// <exception cref="ArgumentNullException">Thrown if <paramref name="upsertStatement"/> is <c>null</c>.</exception>
78+
/// <exception cref="ArgumentException">Thrown if the table, values, keys, or update values are not specified.</exception>
79+
public override string BuildUpsert(UpsertStatement upsertStatement)
80+
{
81+
ValidateUpsert(upsertStatement);
82+
83+
var upsertBuilder = StringBuilderCache.Acquire();
84+
85+
if (upsertStatement.CommentExpressions?.Count > 0)
86+
{
87+
upsertBuilder
88+
.AppendJoin(Environment.NewLine, upsertStatement.CommentExpressions)
89+
.AppendLine();
90+
}
91+
92+
var table = TableExpression(upsertStatement.TableExpression);
93+
upsertBuilder
94+
.Append("INSERT INTO ")
95+
.Append(table)
96+
.Append(" (")
97+
.AppendJoin(", ", upsertStatement.ColumnExpressions.Select(ColumnExpression))
98+
.AppendLine(")")
99+
.Append("VALUES (")
100+
.AppendJoin(", ", upsertStatement.ValueExpressions)
101+
.AppendLine(")")
102+
.Append("ON CONFLICT (")
103+
.AppendJoin(", ", upsertStatement.KeyExpressions.Select(ColumnExpression))
104+
.AppendLine(") DO UPDATE")
105+
.Append("SET ")
106+
.AppendJoin(", ", upsertStatement.UpdateExpressions.Select(u => $"{ColumnExpression(u)} = EXCLUDED.{ColumnExpression(u)}"));
107+
108+
if (upsertStatement.OutputExpressions?.Count > 0)
109+
{
110+
upsertBuilder
111+
.AppendLine()
112+
.Append("RETURNING ")
113+
.AppendJoin(", ", upsertStatement.OutputExpressions.Select(ColumnExpression));
114+
}
115+
116+
upsertBuilder.AppendLine(";");
117+
118+
return StringBuilderCache.ToString(upsertBuilder);
119+
}
120+
72121
/// <summary>
73122
/// Builds a SQL UPDATE statement for PostgreSQL, including support for FROM, JOIN, WHERE, RETURNING, and comments.
74123
/// </summary>

src/FluentCommand/Query/Generators/QueryExpressions.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,25 @@ public record InsertStatement(
162162
IReadOnlyCollection<string> ValueExpressions,
163163
IReadOnlyCollection<string> CommentExpressions);
164164

165+
/// <summary>
166+
/// Represents a complete SQL UPSERT statement, including table, insert columns, values, key columns, update columns, output, and comments.
167+
/// </summary>
168+
/// <param name="TableExpression">The table to insert into or update.</param>
169+
/// <param name="ColumnExpressions">The columns to insert values into.</param>
170+
/// <param name="ValueExpressions">The values to insert.</param>
171+
/// <param name="KeyExpressions">The columns used to determine whether a row already exists.</param>
172+
/// <param name="UpdateExpressions">The columns to update when a matching row exists.</param>
173+
/// <param name="OutputExpressions">The columns to return/output after upsert (optional).</param>
174+
/// <param name="CommentExpressions">The comment expressions to include in the statement.</param>
175+
public record UpsertStatement(
176+
TableExpression TableExpression,
177+
IReadOnlyCollection<ColumnExpression> ColumnExpressions,
178+
IReadOnlyCollection<string> ValueExpressions,
179+
IReadOnlyCollection<ColumnExpression> KeyExpressions,
180+
IReadOnlyCollection<UpdateExpression> UpdateExpressions,
181+
IReadOnlyCollection<ColumnExpression> OutputExpressions,
182+
IReadOnlyCollection<string> CommentExpressions);
183+
165184
/// <summary>
166185
/// Represents a complete SQL UPDATE statement, including table, assignments, output, joins, where, and comments.
167186
/// </summary>

src/FluentCommand/Query/Generators/SqlServerGenerator.cs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,67 @@ public virtual string BuildInsert(InsertStatement insertStatement)
149149
return StringBuilderCache.ToString(insertBuilder);
150150
}
151151

152+
/// <summary>
153+
/// Builds a SQL UPSERT statement for SQL Server using MERGE syntax, including support for OUTPUT and comments.
154+
/// </summary>
155+
/// <param name="upsertStatement">The <see cref="UpsertStatement"/> containing the UPSERT statement configuration.</param>
156+
/// <returns>A SQL UPSERT statement string for SQL Server.</returns>
157+
/// <exception cref="ArgumentNullException">Thrown if <paramref name="upsertStatement"/> is <c>null</c>.</exception>
158+
/// <exception cref="ArgumentException">Thrown if the table, values, keys, or update values are not specified.</exception>
159+
public virtual string BuildUpsert(UpsertStatement upsertStatement)
160+
{
161+
ValidateUpsert(upsertStatement);
162+
163+
var upsertBuilder = StringBuilderCache.Acquire();
164+
165+
if (upsertStatement.CommentExpressions?.Count > 0)
166+
{
167+
upsertBuilder
168+
.AppendJoin(Environment.NewLine, upsertStatement.CommentExpressions)
169+
.AppendLine();
170+
}
171+
172+
var table = TableExpression(upsertStatement.TableExpression);
173+
var sourceColumns = upsertStatement.ColumnExpressions.Select(c => ColumnExpression(c)).ToArray();
174+
175+
upsertBuilder
176+
.Append("MERGE INTO ")
177+
.Append(table)
178+
.AppendLine(" AS TARGET")
179+
.Append("USING (VALUES (")
180+
.AppendJoin(", ", upsertStatement.ValueExpressions)
181+
.AppendLine(")) AS SOURCE")
182+
.Append("(")
183+
.AppendJoin(", ", sourceColumns)
184+
.AppendLine(")")
185+
.Append("ON ")
186+
.AppendJoin(" AND ", upsertStatement.KeyExpressions.Select(k => $"TARGET.{ColumnExpression(k)} = SOURCE.{ColumnExpression(k)}"))
187+
.AppendLine()
188+
.AppendLine("WHEN MATCHED THEN")
189+
.Append("UPDATE SET ")
190+
.AppendJoin(", ", upsertStatement.UpdateExpressions.Select(u => $"TARGET.{ColumnExpression(u)} = SOURCE.{ColumnExpression(u)}"))
191+
.AppendLine()
192+
.AppendLine("WHEN NOT MATCHED THEN")
193+
.Append("INSERT (")
194+
.AppendJoin(", ", sourceColumns)
195+
.AppendLine(")")
196+
.Append("VALUES (")
197+
.AppendJoin(", ", sourceColumns.Select(c => $"SOURCE.{c}"))
198+
.Append(')');
199+
200+
if (upsertStatement.OutputExpressions?.Count > 0)
201+
{
202+
upsertBuilder
203+
.AppendLine()
204+
.Append("OUTPUT ")
205+
.AppendJoin(", ", upsertStatement.OutputExpressions.Select(c => ColumnExpression(c, "INSERTED")));
206+
}
207+
208+
upsertBuilder.AppendLine(";");
209+
210+
return StringBuilderCache.ToString(upsertBuilder);
211+
}
212+
152213
/// <summary>
153214
/// Builds a SQL UPDATE statement for SQL Server, including support for OUTPUT, FROM, JOIN, WHERE, and comments.
154215
/// </summary>
@@ -584,6 +645,33 @@ public virtual string UpdateExpression(UpdateExpression updateExpression)
584645
return $"{quotedName} = {updateExpression.ParameterName}";
585646
}
586647

648+
/// <summary>
649+
/// Validates that an UPSERT statement has the required table, values, keys, and update expressions.
650+
/// </summary>
651+
/// <param name="upsertStatement">The <see cref="UpsertStatement"/> to validate.</param>
652+
/// <exception cref="ArgumentNullException">Thrown if <paramref name="upsertStatement"/> is <c>null</c>.</exception>
653+
/// <exception cref="ArgumentException">Thrown if required upsert configuration is missing.</exception>
654+
protected static void ValidateUpsert(UpsertStatement upsertStatement)
655+
{
656+
if (upsertStatement is null)
657+
throw new ArgumentNullException(nameof(upsertStatement));
658+
659+
if (upsertStatement.TableExpression == null)
660+
throw new ArgumentException("No table specified to upsert into", nameof(upsertStatement));
661+
662+
if (upsertStatement.ColumnExpressions == null || upsertStatement.ColumnExpressions.Count == 0)
663+
throw new ArgumentException("No columns specified for upsert", nameof(upsertStatement));
664+
665+
if (upsertStatement.ValueExpressions == null || upsertStatement.ValueExpressions.Count == 0)
666+
throw new ArgumentException("No values specified for upsert", nameof(upsertStatement));
667+
668+
if (upsertStatement.KeyExpressions == null || upsertStatement.KeyExpressions.Count == 0)
669+
throw new ArgumentException("No keys specified for upsert", nameof(upsertStatement));
670+
671+
if (upsertStatement.UpdateExpressions == null || upsertStatement.UpdateExpressions.Count == 0)
672+
throw new ArgumentException("No update values specified for upsert", nameof(upsertStatement));
673+
}
674+
587675
/// <summary>
588676
/// Builds a SQL JOIN expression from the specified <see cref="JoinExpression"/>.
589677
/// </summary>

src/FluentCommand/Query/Generators/SqliteGenerator.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,55 @@ public override string BuildInsert(InsertStatement insertStatement)
6969
return StringBuilderCache.ToString(insertBuilder);
7070
}
7171

72+
/// <summary>
73+
/// Builds a SQL UPSERT statement for SQLite using ON CONFLICT syntax, including support for RETURNING and comments.
74+
/// </summary>
75+
/// <param name="upsertStatement">The <see cref="UpsertStatement"/> containing the UPSERT statement configuration.</param>
76+
/// <returns>A SQL UPSERT statement string for SQLite.</returns>
77+
/// <exception cref="ArgumentNullException">Thrown if <paramref name="upsertStatement"/> is <c>null</c>.</exception>
78+
/// <exception cref="ArgumentException">Thrown if the table, values, keys, or update values are not specified.</exception>
79+
public override string BuildUpsert(UpsertStatement upsertStatement)
80+
{
81+
ValidateUpsert(upsertStatement);
82+
83+
var upsertBuilder = StringBuilderCache.Acquire();
84+
85+
if (upsertStatement.CommentExpressions?.Count > 0)
86+
{
87+
upsertBuilder
88+
.AppendJoin(Environment.NewLine, upsertStatement.CommentExpressions)
89+
.AppendLine();
90+
}
91+
92+
var table = TableExpression(upsertStatement.TableExpression);
93+
upsertBuilder
94+
.Append("INSERT INTO ")
95+
.Append(table)
96+
.Append(" (")
97+
.AppendJoin(", ", upsertStatement.ColumnExpressions.Select(ColumnExpression))
98+
.AppendLine(")")
99+
.Append("VALUES (")
100+
.AppendJoin(", ", upsertStatement.ValueExpressions)
101+
.AppendLine(")")
102+
.Append("ON CONFLICT (")
103+
.AppendJoin(", ", upsertStatement.KeyExpressions.Select(ColumnExpression))
104+
.AppendLine(") DO UPDATE")
105+
.Append("SET ")
106+
.AppendJoin(", ", upsertStatement.UpdateExpressions.Select(u => $"{ColumnExpression(u)} = EXCLUDED.{ColumnExpression(u)}"));
107+
108+
if (upsertStatement.OutputExpressions?.Count > 0)
109+
{
110+
upsertBuilder
111+
.AppendLine()
112+
.Append("RETURNING ")
113+
.AppendJoin(", ", upsertStatement.OutputExpressions.Select(ColumnExpression));
114+
}
115+
116+
upsertBuilder.AppendLine(";");
117+
118+
return StringBuilderCache.ToString(upsertBuilder);
119+
}
120+
72121
/// <summary>
73122
/// Builds a SQL UPDATE statement for SQLite, including support for FROM, JOIN, WHERE, RETURNING, and comments.
74123
/// </summary>

src/FluentCommand/Query/QueryBuilder.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,39 @@ public InsertBuilder Insert()
124124

125125
}
126126

127+
/// <summary>
128+
/// Starts a new UPSERT statement builder for a specific entity type and adds it to the query.
129+
/// </summary>
130+
/// <typeparam name="TEntity">The type of the entity to insert or update.</typeparam>
131+
/// <returns>
132+
/// A new <see cref="UpsertEntityBuilder{TEntity}"/> instance for building an UPSERT statement.
133+
/// </returns>
134+
public UpsertEntityBuilder<TEntity> Upsert<TEntity>()
135+
where TEntity : class
136+
{
137+
var builder = new UpsertEntityBuilder<TEntity>(QueryGenerator, Parameters);
138+
139+
_builderQueue.Enqueue(builder);
140+
141+
return builder;
142+
}
143+
144+
/// <summary>
145+
/// Starts a new UPSERT statement builder and adds it to the query.
146+
/// </summary>
147+
/// <returns>
148+
/// A new <see cref="UpsertBuilder"/> instance for building an UPSERT statement.
149+
/// </returns>
150+
public UpsertBuilder Upsert()
151+
{
152+
var builder = new UpsertBuilder(QueryGenerator, Parameters);
153+
154+
_builderQueue.Enqueue(builder);
155+
156+
return builder;
157+
158+
}
159+
127160
/// <summary>
128161
/// Starts a new UPDATE statement builder for a specific entity type and adds it to the query.
129162
/// </summary>

0 commit comments

Comments
 (0)