Analyzer rule
Spec: LC037 - Raw SQL String Construction
EF Core LINQ performance analyzer and Roslyn analyzer for catching query issues at compile time.
Spec: LC037 - Raw SQL String Construction
Goal
Detect string-built SQL before it reaches FromSqlRaw(...), ExecuteSqlRaw(...), or SqlQueryRaw<T>(...) (the EF7+ scalar/keyless raw-SQL query on DbContext.Database).
The Problem
Concatenation, string.Format(...), string.Concat(...), and StringBuilder SQL assembly make it easy to smuggle unchecked values into raw SQL text.
Example Violation
var sql = "SELECT * FROM Users WHERE Name = '" + name + "'";
var users = db.Users.FromSqlRaw(sql).ToList();
LC037 also catches construction that is hidden behind common helper APIs:
var sql = string.Format("SELECT * FROM Users WHERE Name = '{0}'", name);
var users = db.Users.FromSqlRaw(sql).ToList();
var sql = string.Concat("DELETE FROM Users WHERE Name = '", name, "'");
db.Database.ExecuteSqlRaw(sql);
var sql = new StringBuilder()
.Append("SELECT * FROM Users WHERE Name = '")
.Append(name)
.Append("'")
.ToString();
var names = db.Database.SqlQueryRaw<string>(sql);
Statement-based builders are in scope too:
var builder = new StringBuilder();
builder.Append("DELETE FROM Users WHERE Name = '");
builder.Append(name);
builder.Append("'");
db.Database.ExecuteSqlRaw(builder.ToString());
Safer Shape
Keep dynamic values out of the SQL string and pass them as parameters.
var users = db.Users.FromSqlRaw("SELECT * FROM Users WHERE Name = {0}", name).ToList();
The same pattern applies to raw execution and scalar/keyless raw SQL:
db.Database.ExecuteSqlRaw("DELETE FROM Users WHERE Name = {0}", name);
var names = db.Database.SqlQueryRaw<string>("SELECT Name FROM Users WHERE Name = {0}", name);
Analyzer Logic
ID: LC037
Category: Security
Severity: Warning
Notes
This v1 surface is analyzer-only. It intentionally avoids speculative rewrites for complex SQL-building code.
The rule reports where the constructed string reaches the raw SQL API. The fix depends on the SQL shape: remove embedded quotes around values, keep the command/query text constant, and pass values through EF Core parameters instead of assembling them into the SQL string.
Rule Boundary
- LC018 owns direct interpolated strings and direct non-constant
+concatenation passed to query APIs (FromSqlRaw(...)andSqlQueryRaw<T>(...)). - LC034 owns direct interpolated strings and direct non-constant
+concatenation passed to raw execution APIs (ExecuteSqlRaw(...)andExecuteSqlRawAsync(...)). - LC037 intentionally yields for those direct call-site patterns so a single raw SQL expression is not double-reported.
- LC037 still reports broader constructed-SQL shapes such as
string.Format(...),string.Concat(...), fluent or statement-basedStringBuilder.Append(...), and local alias / variable flow into all three raw SQL sink families:FromSqlRaw(...),ExecuteSqlRaw(...)/ExecuteSqlRawAsync(...), andSqlQueryRaw<T>(...). SqlQueryRaw<T>is split deliberately: direct interpolation and direct+concatenation are LC018’s territory, whilestring.Format(...),string.Concat(...),StringBuilder, and aliased local construction are LC037’s.- For simple local variables, LC037 resolves the latest guaranteed declaration or assignment before the raw SQL call, so an earlier constructed value overwritten unconditionally by a constant is ignored while later constructed overwrites are still reported.
- For statement-based
StringBuilderlocals, LC037 follows append statements beforeToString()and reports when a non-constant appended value can still flow into the raw SQL string, including null-conditional appends, local dynamic values, method-call values, loop-carried and compound-assigned append locals, caught-throw continuations with exact, alias, ordinary base, user-defined base, and framework base exception catches, constructor copies from tainted builders, and conditional builder aliases. Constant-only appends, variable-capacity constructors, per-iteration constant append-local resets, path-dominated constant append-local overwrites, constant compound assignments, terminating-branch local writes, safe alias clears, short-circuit reset attempts, only-surviving-branch clears, same-loop branch exits, try/catch-contained branch clears, catch-exiting throws, guaranteedfinallyclears, and guaranteed fluent or directClear()resets are handled deliberately. - Self-referential construction such as
sql = sql + idremains in scope: LC037 reports the raw SQL sink, but local resolution only follows writes that completed before the value currently being inspected so recursive aliases cannot abort compiler analysis. - Conditional overwrites are treated conservatively: a branch-only constant assignment does not suppress an earlier constructed SQL value, and a branch-only constructed assignment remains suspicious unless a later guaranteed assignment overwrites it.