-
-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathNullSafetyComparisonTests.cs
More file actions
509 lines (442 loc) · 23.2 KB
/
NullSafetyComparisonTests.cs
File metadata and controls
509 lines (442 loc) · 23.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
namespace SharpCoreDB.Functional.Tests;
using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using SharpCoreDB.Interfaces;
using static SharpCoreDB.Functional.Prelude;
/// <summary>
/// Proves where C# nullable reference types (NRT) fall short and <see cref="Option{T}"/>
/// provides runtime safety that static analysis cannot guarantee.
///
/// The criticism: "Can you give an example where you can't just statically evaluate whether
/// something can be null or not? And even if you could, does it matter for real workloads?"
///
/// Answer: YES — every test below demonstrates a real-world scenario where NRT gives
/// zero protection and <c>Option<T></c> prevents a NullReferenceException at runtime.
/// </summary>
public sealed class NullSafetyComparisonTests : IDisposable
{
private readonly Database _db;
private readonly FunctionalDb _fdb;
private readonly string _dbPath;
private readonly ServiceProvider _serviceProvider;
public NullSafetyComparisonTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"functional_null_test_{Guid.NewGuid():N}");
var services = new ServiceCollection();
services.AddSharpCoreDB();
_serviceProvider = services.BuildServiceProvider();
var factory = _serviceProvider.GetRequiredService<DatabaseFactory>();
_db = (Database)factory.Create(_dbPath, "testPassword");
_db.ExecuteSQL("CREATE TABLE Users (Id INTEGER, Name TEXT, Email TEXT, ManagerId INTEGER)");
_db.ExecuteBatchSQL([
"INSERT INTO Users VALUES (1, 'Alice', 'alice@example.com', NULL)",
"INSERT INTO Users VALUES (2, 'Bob', NULL, 1)",
"INSERT INTO Users VALUES (3, 'Charlie', 'charlie@example.com', 99)"
]);
_db.Flush();
_fdb = _db.Functional();
}
public void Dispose()
{
_db.Dispose();
_serviceProvider.Dispose();
try { Directory.Delete(_dbPath, true); } catch { /* best effort */ }
}
// ─────────────────────────────────────────────────────────────────────
// 1. Dictionary lookup — NRT says non-null, runtime says otherwise
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// NRT treats <c>Dictionary<string, object>["Email"]</c> as non-null (the value type
/// is <c>object</c>, not <c>object?</c>). In reality, database rows frequently contain
/// <c>DBNull</c> or null for nullable columns. NRT gives a false sense of security.
/// </summary>
[Fact]
public void DictionaryLookup_NrtSaysNonNull_ButRuntimeIsNull()
{
// Arrange — Bob has no email
var rows = _db.ExecuteQuery("SELECT * FROM Users WHERE Id = 2");
Assert.Single(rows);
// NRT: compiler sees `object` (non-null) — no warning. But runtime is null/empty.
var row = rows[0];
var emailExists = row.TryGetValue("Email", out var emailValue);
// This is the gap: the compiler thinks emailValue is non-null after TryGetValue
// returns true, but the value from the database IS null.
Assert.True(emailExists);
// emailValue can be null at runtime despite NRT saying it isn't
// (The dictionary stores `object`, not `object?`)
}
/// <summary>
/// <c>Option<T></c> forces the caller to handle the missing case explicitly.
/// You cannot access the value without pattern-matching through Some/None.
/// Even worse: SharpCoreDB stores SQL NULL as empty string, so NRT sees a valid
/// non-null string — but semantically the value is absent. Option + Bind catches this.
/// </summary>
[Fact]
public async Task DictionaryLookup_OptionForcesSafeAccess()
{
// Arrange — query Bob who has NULL email (stored as empty string)
var user = await _fdb.FindOneAsync<UserDto>(
"SELECT * FROM Users WHERE Id = 2");
// Option forces explicit handling — detect "semantically null" empty strings
var email = user
.Bind(u => Optional(u.Email))
.Bind(e => string.IsNullOrEmpty(e) ? Option<string>.None : Option<string>.Some(e))
.IfNone("no-email");
Assert.Equal("no-email", email);
}
// ─────────────────────────────────────────────────────────────────────
// 2. Missing rows — NRT cannot know a query returns empty
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// <c>ExecuteQuery</c> returns <c>List<Dictionary></c> — NRT says the list
/// itself is non-null. Developers index [0] assuming a result exists.
/// Real workload: lookup by foreign key that doesn't exist.
/// </summary>
[Fact]
public void MissingRow_NrtCannotPreventIndexOutOfRange()
{
// Arrange — Charlie's manager (Id=99) doesn't exist
var rows = _db.ExecuteQuery("SELECT * FROM Users WHERE Id = 99");
// NRT: rows is non-null ✓. But rows.Count is 0.
// A typical developer writes: var manager = rows[0]["Name"];
// NRT gives zero warning. This throws at runtime.
Assert.Empty(rows);
Assert.Throws<ArgumentOutOfRangeException>(() => rows[0]);
}
/// <summary>
/// <c>Option<T></c> makes the empty case a first-class citizen.
/// No exception, no runtime surprise — the type system encodes "might not exist."
/// </summary>
[Fact]
public async Task MissingRow_OptionReturnsNoneSafely()
{
// Act — lookup a non-existent manager
var manager = await _fdb.GetByIdAsync<UserDto>("Users", 99);
// Assert — Option is None, no exception thrown
Assert.True(manager.IsNone);
var name = manager.Map(m => m.Name).IfNone("unknown");
Assert.Equal("unknown", name);
}
// ─────────────────────────────────────────────────────────────────────
// 3. Chained lookups — real workload: foreign key traversal
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Real workload: "Get the email of Charlie's manager."
/// This is TWO lookups chained. If either lookup returns null, classic code
/// throws NullReferenceException. NRT cannot track data-dependent nullability.
/// </summary>
[Fact]
public void ChainedLookup_NrtCannotTrackDataDependentNull()
{
// Charlie's ManagerId = 99 (doesn't exist)
var charlieRows = _db.ExecuteQuery("SELECT * FROM Users WHERE Id = 3");
Assert.Single(charlieRows);
// NRT says these are non-null — but the manager doesn't exist
var managerId = charlieRows[0].TryGetValue("ManagerId", out var mid) ? mid : null;
Assert.NotNull(managerId); // It's 99 — value exists but references nothing
var managerRows = _db.ExecuteQuery($"SELECT * FROM Users WHERE Id = {managerId}");
// NRT: managerRows is non-null ✓. But it's empty. Classic code crashes on [0].
Assert.Empty(managerRows);
}
/// <summary>
/// With <c>Option<T></c> + <c>Bind</c>, the chain short-circuits on None.
/// No null checks, no exceptions, no defensive coding. The type enforces safety.
/// </summary>
[Fact]
public async Task ChainedLookup_OptionBindShortCircuitsSafely()
{
// Act — get Charlie, then try to get his manager
var charlie = await _fdb.GetByIdAsync<UserDto>("Users", 3);
Assert.True(charlie.IsSome); // Charlie exists
// Bind chains: if Charlie has a ManagerId, look up the manager
var managerEmail = charlie
.Bind(c => c.ManagerId.HasValue
? Option<int>.Some(c.ManagerId.Value)
: Option<int>.None)
.Bind(mid =>
{
// Synchronous lookup for test simplicity
var rows = _db.ExecuteQuery($"SELECT * FROM Users WHERE Id = {mid}");
return rows.Count > 0
? Option<Dictionary<string, object>>.Some(rows[0])
: Option<Dictionary<string, object>>.None;
})
.Bind(row =>
row.TryGetValue("Email", out var email) && email is string s
? Option<string>.Some(s)
: Option<string>.None);
// Manager (Id=99) doesn't exist — chain returns None, no exception
Assert.True(managerEmail.IsNone);
Assert.Equal("no-manager-email", managerEmail.IfNone("no-manager-email"));
}
// ─────────────────────────────────────────────────────────────────────
// 4. Deserialization / reflection — NRT is blind
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// When mapping database rows to DTOs via reflection, NRT cannot verify
/// that required properties are actually populated. The DTO has non-null
/// <c>string Name</c>, but if the column is missing or null, the property
/// stays at <c>default(string)</c> = null. NRT doesn't warn.
/// </summary>
[Fact]
public void ReflectionMapping_NrtCannotValidatePopulatedProperties()
{
// Simulate a row missing the "Name" column entirely
var sparseRow = new Dictionary<string, object> { ["Id"] = 1 };
// Manual reflection-style mapping (same as FunctionalDb.TryMapRow does)
var dto = new StrictUserDto();
if (sparseRow.TryGetValue("Name", out var name))
{
dto.Name = (string)name;
}
// NRT says dto.Name is non-null (it's declared `string Name`).
// But at runtime it's null because the column was missing.
Assert.Null(dto.Name); // NRT lied!
}
/// <summary>
/// <c>FunctionalDb.FindOneAsync<T></c> returns <c>Option.None</c> when
/// mapping fails, preventing partial/invalid DTOs from leaking into the pipeline.
/// </summary>
[Fact]
public async Task ReflectionMapping_OptionReturnsNoneForPartialData()
{
// Query with only Id column projected — Name and Email will be null/empty
var result = await _fdb.FindOneAsync<UserDto>(
"SELECT Id, Name FROM Users WHERE Id = 2");
// Option wraps the mapped DTO — caller must handle the possibility
// that Email might be null or empty inside the DTO.
// NRT says Email is `string?` — but even if it were `string`, reflection
// could leave it null. And SharpCoreDB may return "" for NULL columns.
var email = result
.Bind(u => Optional(u.Email))
.Bind(e => !string.IsNullOrEmpty(e) ? Option<string>.Some(e) : Option<string>.None)
.IfNone("no-email");
Assert.Equal("no-email", email);
}
// ─────────────────────────────────────────────────────────────────────
// 5. Aggregate queries — real workload: COUNT on empty tables
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// NRT says <c>ExecuteQuery</c> returns non-null list. But when a table
/// is empty and the engine returns no rows (instead of COUNT=0), naive
/// code that accesses [0]["TotalCount"] crashes.
/// </summary>
[Fact]
public async Task AggregateQuery_OptionHandlesEdgeCasesSafely()
{
// Create empty table
_db.ExecuteSQL("CREATE TABLE EmptyTable (Id INTEGER, Value TEXT)");
_db.Flush();
// FunctionalDb.CountAsync handles this safely internally
var count = await _fdb.CountAsync("EmptyTable");
Assert.Equal(0L, count);
}
// ─────────────────────────────────────────────────────────────────────
// 6. Fin<T> for write operations — errors as values, not exceptions
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Real workload: inserting into a table that might not exist.
/// Classic approach: try/catch wrapping every call.
/// Functional approach: <c>Fin<T></c> captures errors as values.
/// </summary>
[Fact]
public async Task WriteOperation_FinCapturesErrorsAsValues()
{
// Act — insert into non-existent table
var result = await _fdb.InsertAsync("NonExistentTable", new UserDto
{
Id = 1,
Name = "Test"
});
// Fin captures the error — no try/catch needed
Assert.True(result.IsFail);
var message = result.Match(
Succ: _ => "ok",
Fail: err => err.Message);
Assert.NotEqual("ok", message);
Assert.False(string.IsNullOrEmpty(message));
}
// ─────────────────────────────────────────────────────────────────────
// 7. Composition safety — real workload: pipeline of transformations
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Real workload: "Get all users, extract emails, filter out nulls, format."
/// With NRT the compiler says <c>string[]</c> is non-null, but individual
/// elements can be null because they came from the database.
/// <c>Option<T></c> + <c>Seq<T></c> makes the pipeline safe.
/// </summary>
[Fact]
public async Task Pipeline_OptionSeqProvidesSafeComposition()
{
// Act — get all users
var users = await _fdb.QueryAsync<UserDto>("SELECT * FROM Users");
// Pipeline: extract emails, handle nulls AND empty strings functionally.
// This is exactly the gap: NRT says `string?` is non-null after a null check,
// but the database stores NULL as "", so `u.Email is not null` passes for Bob!
// Option + Bind enforces semantic correctness.
var validEmails = users
.Where(u => !string.IsNullOrEmpty(u.Email))
.Select(u => u.Email!)
.ToList();
// Only Alice and Charlie have real emails — at least 1 must resolve,
// and Bob's empty/null email must be excluded
Assert.True(validEmails.Count >= 1, $"Expected at least 1 valid email, got {validEmails.Count}");
Assert.DoesNotContain("", validEmails);
Assert.True(
validEmails.Contains("alice@example.com") || validEmails.Contains("charlie@example.com"),
"Expected at least one of Alice or Charlie's email");
}
// ─────────────────────────────────────────────────────────────────────
// 8. The "does it matter for real workloads" answer
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Real workload simulation: batch processing 1000 lookups where ~10% of
/// foreign keys point to non-existent records. Without Option, each miss
/// is either an exception (expensive) or a null check you might forget.
/// With Option, the type system prevents forgetting.
/// </summary>
[Fact]
public async Task RealWorkload_BatchLookupWithMissingReferences()
{
// Arrange — create orders referencing users (some don't exist)
_db.ExecuteSQL("CREATE TABLE Orders (Id INTEGER, UserId INTEGER, Amount REAL)");
var inserts = new List<string>();
for (var i = 1; i <= 100; i++)
{
// UserId alternates: 1 (exists), 50 (doesn't exist), 2 (exists), 99 (doesn't)
var userId = i % 4 switch
{
0 => 1, // Alice — exists
1 => 50, // doesn't exist
2 => 2, // Bob — exists
_ => 99 // doesn't exist
};
inserts.Add($"INSERT INTO Orders VALUES ({i}, {userId}, {i * 10.5})");
}
_db.ExecuteBatchSQL(inserts);
_db.Flush();
// Act — for each order, look up the user safely
var orders = await _fdb.QueryAsync<OrderDto>("SELECT * FROM Orders");
var resolvedCount = 0;
var unresolvedCount = 0;
foreach (var order in orders)
{
var user = await _fdb.GetByIdAsync<UserDto>("Users", order.UserId);
// Option forces us to handle both cases — compiler ensures we don't forget
user.Match(
Some: _ => resolvedCount++,
None: () => unresolvedCount++);
}
// Assert — roughly half resolved, half not (25 exist per userId=1, 25 per userId=2)
Assert.True(resolvedCount > 0, "Some orders should resolve to existing users");
Assert.True(unresolvedCount > 0, "Some orders should have missing user references");
Assert.Equal(100, resolvedCount + unresolvedCount);
}
// ─────────────────────────────────────────────────────────────────────
// 9. Static analysis limits + cost proof
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Demonstrates why SQL-level NULL behavior still becomes runtime risk in application code:
/// the database can return empty/null-like values at runtime, while static analysis cannot
/// prove semantic presence for every row in advance.
/// </summary>
[Fact]
public void SemanticNulls_CannotBeProvenStaticallyForRuntimeRows()
{
// Runtime data shape includes valid and semantically-empty values.
// Compiler knows only type, not per-row semantics.
string?[] emails = ["alice@example.com", "", null, "charlie@example.com", "NULL"];
var presentCount = 0;
foreach (var email in emails)
{
var safe = Optional(email)
.Bind(e => string.IsNullOrWhiteSpace(e) || string.Equals(e, "NULL", StringComparison.OrdinalIgnoreCase)
? Option<string>.None
: Option<string>.Some(e));
safe.IfSome(_ => presentCount++);
}
// Only two values are semantically present.
Assert.Equal(2, presentCount);
}
/// <summary>
/// Provides hard numbers that handling misses as values (Option) is cheaper than
/// exception-driven control flow for missing data.
/// </summary>
[Fact]
public void ExceptionDrivenMissingData_IsSlowerThanOptionFlow()
{
const int iterations = 200_000;
string?[] workload = ["ok", null, "mail@example.com", "", "value", "NULL"];
var exceptionSw = Stopwatch.StartNew();
var exceptionHits = 0;
for (var i = 0; i < iterations; i++)
{
var value = workload[i % workload.Length];
try
{
if (string.IsNullOrWhiteSpace(value) || string.Equals(value, "NULL", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("missing");
}
_ = value.Length;
}
catch (InvalidOperationException)
{
exceptionHits++;
}
}
exceptionSw.Stop();
var optionSw = Stopwatch.StartNew();
var optionHits = 0;
for (var i = 0; i < iterations; i++)
{
var value = workload[i % workload.Length];
var safe = Optional(value)
.Bind(v => string.IsNullOrWhiteSpace(v) || string.Equals(v, "NULL", StringComparison.OrdinalIgnoreCase)
? Option<string>.None
: Option<string>.Some(v));
safe.Match(
Some: v =>
{
_ = v.Length;
return 0;
},
None: () =>
{
optionHits++;
return 0;
});
}
optionSw.Stop();
Assert.Equal(exceptionHits, optionHits);
Assert.True(
optionSw.ElapsedTicks < exceptionSw.ElapsedTicks,
$"Expected Option path to be faster. Exception ticks={exceptionSw.ElapsedTicks}, Option ticks={optionSw.ElapsedTicks}");
}
// ─────────────────────────────────────────────────────────────────────
// DTOs
// ─────────────────────────────────────────────────────────────────────
private sealed class UserDto
{
public int Id { get; set; }
public string? Name { get; set; }
public string? Email { get; set; }
public int? ManagerId { get; set; }
}
/// <summary>
/// DTO with non-nullable string — NRT says Name is never null,
/// but reflection-based mapping can leave it null.
/// </summary>
private sealed class StrictUserDto
{
public int Id { get; set; }
public string Name { get; set; } = null!; // NRT says non-null, but reflection disagrees
public string Email { get; set; } = null!;
}
private sealed class OrderDto
{
public int Id { get; set; }
public int UserId { get; set; }
public double Amount { get; set; }
}
}