Skip to content

Commit 1ee0f7e

Browse files
vkuttypclaude
andcommitted
release: 2.4.0 — CASE WHEN + function-call DEFAULT
Closes the two gaps that forced CosmoMailServer's Phase D to work around the driver: CASE WHEN derivation in INSERT/SELECT/WHERE, and non-literal DEFAULT clauses (GETUTCDATE(), NEWID(), …) that round-trip through the catalog as canonical SQL text. Adds NEWID() to the scalar dispatch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7608854 commit 1ee0f7e

10 files changed

Lines changed: 462 additions & 18 deletions

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
<Authors>vkuttyp</Authors>
88
<PackageLicenseExpression>MIT</PackageLicenseExpression>
99
<RepositoryUrl>https://github.com/vkuttyp/CosmoSQLClient-Dotnet</RepositoryUrl>
10-
<Version>2.3.0</Version>
10+
<Version>2.4.0</Version>
1111
</PropertyGroup>
1212
</Project>

src/CosmoSQLClient.CosmoKv/Ast/Statement.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,18 @@ internal sealed record CountStarExpression : Expression;
181181
/// </summary>
182182
internal sealed record CastExpression(Expression Operand, Schema.SqlColumnType TargetType) : Expression;
183183

184+
/// <summary>
185+
/// Searched <c>CASE WHEN pred THEN val [WHEN pred THEN val …] [ELSE val] END</c>.
186+
/// The simple form (<c>CASE expr WHEN val THEN …</c>) isn't supported — the
187+
/// migration consumer's flag-bit derivation uses only the searched form, and
188+
/// nothing else surfaced a need.
189+
/// </summary>
190+
internal sealed record CaseExpression(
191+
IReadOnlyList<CaseBranch> Branches,
192+
Expression? Else) : Expression;
193+
194+
internal sealed record CaseBranch(Expression When, Expression Then);
195+
184196
internal enum BinaryOperator
185197
{
186198
And, Or,
@@ -192,3 +204,65 @@ internal enum UnaryOperator
192204
{
193205
Not, Negate,
194206
}
207+
208+
/// <summary>
209+
/// Render a subset of the <see cref="Expression"/> AST back to T-SQL text.
210+
/// Currently used by the parser to capture non-literal <c>DEFAULT</c>
211+
/// clauses (v2.4 task #34) — those need to round-trip through the catalog's
212+
/// JSON serialization but the AST itself is polymorphic and doesn't
213+
/// serialize cleanly. Producing a canonical SQL string sidesteps the
214+
/// problem; we re-parse on INSERT.
215+
/// </summary>
216+
internal static class ExpressionToSql
217+
{
218+
/// <summary>Render an expression to canonical T-SQL. Throws for forms
219+
/// that aren't valid in <c>DEFAULT</c> position (column refs, *, etc).</summary>
220+
public static string Render(Expression e) => e switch
221+
{
222+
LiteralExpression l => RenderLiteral(l.Value),
223+
FunctionCallExpression fc => $"{fc.Name}({string.Join(", ", fc.Args.Select(Render))})",
224+
CastExpression c => $"CAST({Render(c.Operand)} AS {c.TargetType})",
225+
UnaryExpression u when u.Op == UnaryOperator.Negate => $"-{Render(u.Operand)}",
226+
UnaryExpression u when u.Op == UnaryOperator.Not => $"NOT {Render(u.Operand)}",
227+
BinaryExpression b => $"({Render(b.Left)} {OpToSql(b.Op)} {Render(b.Right)})",
228+
ParameterExpression p => p.Name.StartsWith('@') ? p.Name : "@" + p.Name,
229+
_ => throw new InvalidOperationException(
230+
$"Cannot render {e.GetType().Name} to SQL text (not valid in DEFAULT)."),
231+
};
232+
233+
private static string RenderLiteral(Core.SqlValue v)
234+
{
235+
if (v.IsNull) return "NULL";
236+
return v.Kind switch
237+
{
238+
Core.SqlValueKind.Bool => v.BoolValue ? "1" : "0",
239+
Core.SqlValueKind.Int8 or Core.SqlValueKind.Int16 or
240+
Core.SqlValueKind.Int32 or Core.SqlValueKind.Int64
241+
=> v.AsInt()!.Value.ToString(System.Globalization.CultureInfo.InvariantCulture),
242+
Core.SqlValueKind.Float or
243+
Core.SqlValueKind.Double => (v.AsDouble() ?? 0).ToString("R", System.Globalization.CultureInfo.InvariantCulture),
244+
Core.SqlValueKind.Decimal => (v.AsDecimal() ?? 0).ToString(System.Globalization.CultureInfo.InvariantCulture),
245+
Core.SqlValueKind.Text or
246+
Core.SqlValueKind.EmptyString => "'" + (v.AsString() ?? "").Replace("'", "''") + "'",
247+
_ => throw new InvalidOperationException($"Cannot render SqlValue kind {v.Kind} as a SQL literal."),
248+
};
249+
}
250+
251+
private static string OpToSql(BinaryOperator op) => op switch
252+
{
253+
BinaryOperator.And => "AND",
254+
BinaryOperator.Or => "OR",
255+
BinaryOperator.Eq => "=",
256+
BinaryOperator.Ne => "<>",
257+
BinaryOperator.Lt => "<",
258+
BinaryOperator.Le => "<=",
259+
BinaryOperator.Gt => ">",
260+
BinaryOperator.Ge => ">=",
261+
BinaryOperator.Add => "+",
262+
BinaryOperator.Sub => "-",
263+
BinaryOperator.Mul => "*",
264+
BinaryOperator.Div => "/",
265+
BinaryOperator.Mod => "%",
266+
_ => throw new InvalidOperationException($"Unknown operator {op}"),
267+
};
268+
}

src/CosmoSQLClient.CosmoKv/Execution/Executor.cs

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,35 @@ public void SetActiveAccess(IKvAccess? kv)
5151
/// </summary>
5252
public bool IdentityInsertEnabled { get; set; }
5353

54+
// Cache of parsed DEFAULT expressions keyed by source SQL — the same
55+
// string fires once per row inserted, parser is cheap but constant
56+
// overhead adds up under bulk loads.
57+
private readonly Dictionary<string, Ast.Expression> _defaultExprCache = new();
58+
59+
/// <summary>
60+
/// Evaluate a column's <c>DefaultExpressionSql</c> (v2.4 non-literal
61+
/// DEFAULT). Cached on first use; the expression has no row context
62+
/// (DEFAULT can't reference columns) so we evaluate against an empty
63+
/// dummy table.
64+
/// </summary>
65+
private SqlValue EvalDefaultExpression(string defExprSql)
66+
{
67+
if (!_defaultExprCache.TryGetValue(defExprSql, out var expr))
68+
{
69+
expr = Parsing.TSqlParser.ParseExpressionString(defExprSql);
70+
_defaultExprCache[defExprSql] = expr;
71+
}
72+
var dummyTable = new TableSchema(
73+
Name: "(default-expr)",
74+
Columns: Array.Empty<ColumnSchema>(),
75+
PrimaryKeyColumnIndex: null,
76+
IdentityColumnIndex: null);
77+
var ctx = new ExpressionEvaluator.RowContext(
78+
dummyTable, ReadOnlySpan<SqlValue>.Empty,
79+
new Dictionary<string, SqlValue>());
80+
return ExpressionEvaluator.Evaluate(expr, ctx);
81+
}
82+
5483
// ── Public entry points ─────────────────────────────────────────────────
5584

5685
public async Task<int> ExecuteAsync(
@@ -225,7 +254,12 @@ private async Task<InsertResult> ExecuteInsertCore(
225254
for (int i = 0; i < table.Columns.Count; i++)
226255
{
227256
var col = table.Columns[i];
228-
record[i] = col.DefaultValue ?? SqlValue.Null_;
257+
// v2.4: non-literal DEFAULT (e.g. GETUTCDATE()) is stored
258+
// as a SQL string; parse + evaluate at INSERT time.
259+
if (col.DefaultExpressionSql is { } defExprSql)
260+
record[i] = EvalDefaultExpression(defExprSql);
261+
else
262+
record[i] = col.DefaultValue ?? SqlValue.Null_;
229263
}
230264

231265
// Track whether the caller supplied an explicit IDENTITY value
@@ -1387,17 +1421,30 @@ private async Task<DeleteResult> ExecuteDeleteCore(
13871421
private static SqlValue EvalLiteralOrBound(
13881422
Expression expr, IReadOnlyDictionary<string, SqlValue> bound)
13891423
{
1390-
return expr switch
1424+
// Fast paths for the overwhelmingly common cases — literal and
1425+
// parameter dispatch without building a RowContext.
1426+
switch (expr)
13911427
{
1392-
LiteralExpression l => l.Value,
1393-
ParameterExpression p => bound.TryGetValue(p.Name, out var v)
1394-
? v
1395-
: throw new InvalidOperationException(
1396-
$"Parameter '{p.Name}' was not bound."),
1397-
UnaryExpression { Op: UnaryOperator.Negate } u => NegateLiteral(u.Operand, bound),
1398-
_ => throw new InvalidOperationException(
1399-
$"Expression {expr.GetType().Name} is not a constant — INSERT supports only literals and parameters."),
1400-
};
1428+
case LiteralExpression l:
1429+
return l.Value;
1430+
case ParameterExpression p:
1431+
return bound.TryGetValue(p.Name, out var v)
1432+
? v
1433+
: throw new InvalidOperationException($"Parameter '{p.Name}' was not bound.");
1434+
case UnaryExpression { Op: UnaryOperator.Negate } u:
1435+
return NegateLiteral(u.Operand, bound);
1436+
}
1437+
// General expression in INSERT VALUES position — CASE WHEN, function
1438+
// calls, arithmetic. Evaluate against a dummy empty-row context so
1439+
// column refs throw cleanly (no source row at INSERT time).
1440+
var dummyTable = new TableSchema(
1441+
Name: "(insert-values)",
1442+
Columns: Array.Empty<ColumnSchema>(),
1443+
PrimaryKeyColumnIndex: null,
1444+
IdentityColumnIndex: null);
1445+
var ctx = new ExpressionEvaluator.RowContext(
1446+
dummyTable, ReadOnlySpan<SqlValue>.Empty, bound);
1447+
return ExpressionEvaluator.Evaluate(expr, ctx);
14011448
}
14021449

14031450
private static SqlValue NegateLiteral(

src/CosmoSQLClient.CosmoKv/Execution/ExpressionEvaluator.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ public static SqlValue Evaluate(Expression expr, in RowContext ctx)
9292
case InExpression i: return EvalIn(i, ctx);
9393
case FunctionCallExpression fc: return EvalFunctionCall(fc, ctx);
9494
case CastExpression cast: return EvalCast(cast, ctx);
95+
case CaseExpression ce: return EvalCase(ce, ctx);
9596
case CountStarExpression:
9697
throw new InvalidOperationException(
9798
"COUNT(*) is only valid in an aggregating SELECT.");
@@ -453,6 +454,25 @@ private static SqlValue EvalIsNull(IsNullExpression n, in RowContext ctx)
453454
return SqlValue.From(n.Negated ? !v.IsNull : v.IsNull);
454455
}
455456

457+
// ── CASE WHEN ───────────────────────────────────────────────────────────
458+
459+
/// <summary>
460+
/// Evaluate a searched <c>CASE WHEN p1 THEN v1 [WHEN p2 THEN v2 …]
461+
/// [ELSE ve] END</c>. Branches are tested in declaration order; the
462+
/// first branch whose predicate evaluates to TRUE wins. UNKNOWN /
463+
/// FALSE branches fall through. With no winning branch, the ELSE
464+
/// value is returned; with no ELSE, the result is NULL.
465+
/// </summary>
466+
private static SqlValue EvalCase(CaseExpression ce, in RowContext ctx)
467+
{
468+
foreach (var branch in ce.Branches)
469+
{
470+
if (EvaluatePredicate(branch.When, ctx))
471+
return Evaluate(branch.Then, ctx);
472+
}
473+
return ce.Else is null ? SqlValue.Null_ : Evaluate(ce.Else, ctx);
474+
}
475+
456476
// ── LIKE ────────────────────────────────────────────────────────────────
457477

458478
private static SqlValue EvalLike(LikeExpression lk, in RowContext ctx)

src/CosmoSQLClient.CosmoKv/Execution/ScalarFunctions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ private static SqlValue Fn_GETDATE(SqlValue[] args)
4343
return SqlValue.From(DateTime.Now);
4444
}
4545

46+
private static SqlValue Fn_NEWID(SqlValue[] args)
47+
{
48+
if (args.Length != 0) throw new InvalidOperationException("NEWID takes no arguments.");
49+
return SqlValue.From(Guid.NewGuid());
50+
}
51+
4652
private static SqlValue Fn_DATEADD(SqlValue[] args)
4753
{
4854
if (args.Length != 3)
@@ -353,6 +359,7 @@ private static long DiffPart(DateTime a, DateTime b, DatePart part)
353359
{
354360
["GETUTCDATE"] = Fn_GETUTCDATE,
355361
["GETDATE"] = Fn_GETDATE,
362+
["NEWID"] = Fn_NEWID,
356363
["DATEADD"] = Fn_DATEADD,
357364
["DATEDIFF"] = Fn_DATEDIFF,
358365
["DATEPART"] = Fn_DATEPART,

src/CosmoSQLClient.CosmoKv/Parsing/TSqlParser.cs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@ internal static Statement Parse(string sql)
5151
return stmt;
5252
}
5353

54+
/// <summary>
55+
/// Parse a single expression (no surrounding statement). Used by the
56+
/// executor to re-parse <see cref="Schema.ColumnSchema.DefaultExpressionSql"/>
57+
/// at INSERT time for non-literal column defaults (v2.4 task #34).
58+
/// </summary>
59+
internal static Expression ParseExpressionString(string sql)
60+
{
61+
var lexer = new TSqlLexer(sql);
62+
var tokens = lexer.Tokenize();
63+
var parser = new TSqlParser(tokens);
64+
var expr = parser.ParseExpression();
65+
parser.Expect(TokenKind.EndOfInput);
66+
return expr;
67+
}
68+
5469
// ── Statement dispatch ──────────────────────────────────────────────────
5570

5671
internal Statement ParseStatement()
@@ -172,6 +187,7 @@ private ColumnSchema ParseColumnDef(int columnIndex, ref int? pkIndex, ref int?
172187
bool isPk = false;
173188
bool isUnique = false;
174189
SqlValue? defaultVal = null;
190+
string? defaultExprSql = null;
175191

176192
while (true)
177193
{
@@ -220,7 +236,12 @@ private ColumnSchema ParseColumnDef(int columnIndex, ref int? pkIndex, ref int?
220236
if (t.Kind == TokenKind.Default)
221237
{
222238
Advance();
223-
defaultVal = ParseLiteralExpr().Value;
239+
// v2.4: DEFAULT accepts any expression, not just literals.
240+
// Literals → SqlValue?, function calls / arithmetic →
241+
// canonical SQL string that round-trips through the catalog.
242+
var expr = ParseExpression();
243+
if (expr is LiteralExpression lit) defaultVal = lit.Value;
244+
else defaultExprSql = ExpressionToSql.Render(expr);
224245
continue;
225246
}
226247
if (t.Kind == TokenKind.Unique)
@@ -233,7 +254,8 @@ private ColumnSchema ParseColumnDef(int columnIndex, ref int? pkIndex, ref int?
233254
}
234255

235256
return new ColumnSchema(
236-
colName, type, nullable, isIdentity, seed, inc, isPk, defaultVal, isUnique);
257+
colName, type, nullable, isIdentity, seed, inc, isPk, defaultVal, isUnique,
258+
DefaultExpressionSql: defaultExprSql);
237259
}
238260

239261
private SqlColumnType ParseTypeName()
@@ -956,6 +978,33 @@ private Expression ParseAtom()
956978
Advance();
957979
return new ParameterExpression(t.Text);
958980
}
981+
// CASE WHEN <pred> THEN <val> [WHEN <pred> THEN <val> …] [ELSE <val>] END
982+
// Searched form only — the simple form `CASE expr WHEN val THEN …`
983+
// isn't accepted; reach for nested CASE WHEN expr = val THEN … instead.
984+
if (t.Kind == TokenKind.Case)
985+
{
986+
Advance();
987+
var branches = new List<CaseBranch>();
988+
while (Current().Kind == TokenKind.When)
989+
{
990+
Advance();
991+
var when = ParseExpression();
992+
Expect(TokenKind.Then);
993+
var then = ParseExpression();
994+
branches.Add(new CaseBranch(when, then));
995+
}
996+
if (branches.Count == 0)
997+
throw new InvalidOperationException("CASE expression requires at least one WHEN branch.");
998+
Expression? elseExpr = null;
999+
if (Current().Kind == TokenKind.Else)
1000+
{
1001+
Advance();
1002+
elseExpr = ParseExpression();
1003+
}
1004+
Expect(TokenKind.End);
1005+
return new CaseExpression(branches, elseExpr);
1006+
}
1007+
9591008
// Special-form keyword functions: CAST(expr AS type), CONVERT(type, expr [, style]).
9601009
if (t.Kind == TokenKind.Cast)
9611010
{

src/CosmoSQLClient.CosmoKv/Schema/ColumnSchema.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,13 @@ internal sealed record ColumnSchema(
1616
long IdentityIncrement,
1717
bool IsPrimaryKey,
1818
SqlValue? DefaultValue,
19-
bool IsUnique = false);
19+
bool IsUnique = false,
20+
/// <summary>
21+
/// Non-literal DEFAULT expression as canonical SQL text, e.g.
22+
/// <c>"GETUTCDATE()"</c>. Persisted alongside the literal
23+
/// <see cref="DefaultValue"/>; exactly one is set per column. Evaluated
24+
/// at INSERT time by re-parsing the small expression; cost is
25+
/// negligible next to the row write. Pre-v2.4 catalogs only have
26+
/// DefaultValue set; everything keeps working.
27+
/// </summary>
28+
string? DefaultExpressionSql = null);

src/CosmoSQLClient.CosmoKv/Storage/Catalog.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,19 +167,21 @@ private sealed record ColumnSchemaDto(
167167
bool Nullable,
168168
bool IsIdentity, long IdentitySeed, long IdentityIncrement,
169169
bool IsPrimaryKey,
170-
SqlValue? DefaultValue)
170+
SqlValue? DefaultValue,
171+
bool IsUnique = false,
172+
string? DefaultExpressionSql = null)
171173
{
172174
public ColumnSchema ToColumn()
173175
=> new(Name,
174176
new SqlColumnType(Family, Length, Precision, Scale, LengthMax),
175177
Nullable, IsIdentity, IdentitySeed, IdentityIncrement,
176-
IsPrimaryKey, DefaultValue);
178+
IsPrimaryKey, DefaultValue, IsUnique, DefaultExpressionSql);
177179

178180
public static ColumnSchemaDto From(ColumnSchema c)
179181
=> new(c.Name, c.Type.Family, c.Type.Length, c.Type.Precision,
180182
c.Type.Scale, c.Type.LengthMax, c.Nullable,
181183
c.IsIdentity, c.IdentitySeed, c.IdentityIncrement,
182-
c.IsPrimaryKey, c.DefaultValue);
184+
c.IsPrimaryKey, c.DefaultValue, c.IsUnique, c.DefaultExpressionSql);
183185
}
184186

185187
/// <summary>

0 commit comments

Comments
 (0)