Skip to content

Commit c07b8a6

Browse files
author
MPCoreDeveloper
committed
feat(functional): add functional SQL syntax with Option-aware filtering and docs
1 parent 192ea7e commit c07b8a6

5 files changed

Lines changed: 790 additions & 2 deletions

File tree

docs/FUNCTIONAL_NULL_SAFETY.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,62 @@ Database operations have inherently unpredictable data. Foreign keys reference d
159159

160160
---
161161

162+
## Functional SQL Syntax (v1.7.0)
163+
164+
SharpCoreDB.Functional now supports a functional SQL layer that maps directly to `Option<T>` semantics.
165+
166+
### Example syntax
167+
168+
```sql
169+
SELECT Id, Name, Email OPTIONALLY FROM Users
170+
WHERE Email IS SOME;
171+
```
172+
173+
### Supported functional SQL keywords
174+
175+
| Keyword | Meaning |
176+
|---|---|
177+
| `OPTIONALLY FROM` | Returns a `Seq<Option<T>>` result shape from `ExecuteFunctionalSqlAsync<T>` |
178+
| `IS SOME` | Semantic non-null check (filters out `null`, empty string, and `"NULL"`) |
179+
| `IS NONE` | Semantic null check (matches `null`, empty string, and `"NULL"`) |
180+
| `MATCH SOME column` | Alias of `column IS SOME` |
181+
| `MATCH NONE column` | Alias of `column IS NONE` |
182+
| `UNWRAP Column AS Alias DEFAULT 'x'` | Projects a value with default fallback metadata |
183+
184+
### C# usage
185+
186+
```csharp
187+
var functional = db.Functional();
188+
189+
var rows = await functional.ExecuteFunctionalSqlAsync<UserDto>(
190+
"SELECT Id, Name, Email OPTIONALLY FROM Users WHERE Email IS SOME");
191+
192+
foreach (var row in rows)
193+
{
194+
var email = row
195+
.Map(u => u.Email)
196+
.IfNone("no-email");
197+
198+
Console.WriteLine(email);
199+
}
200+
```
201+
202+
### Verify functional SQL behavior yourself
203+
204+
Run the dedicated functional SQL tests:
205+
206+
```bash
207+
dotnet test tests/SharpCoreDB.Functional.Tests --filter "FullyQualifiedName~FunctionalSqlSyntaxTests" --verbosity normal
208+
```
209+
210+
Test source:
211+
212+
- [`tests/SharpCoreDB.Functional.Tests/FunctionalSqlSyntaxTests.cs`](../tests/SharpCoreDB.Functional.Tests/FunctionalSqlSyntaxTests.cs)
213+
214+
This suite validates parser translation, `OPTIONALLY FROM`, `IS SOME` / `IS NONE`, match aliases, unwrap mapping, and end-to-end filtering behavior.
215+
216+
---
217+
162218
## Verify It Yourself
163219

164220
All claims above are backed by **12 passing tests** you can run right now.

src/SharpCoreDB.Functional/FunctionalDb.cs

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,158 @@ public Task<Seq<T>> QueryAsync<T>(
131131
return Task.FromResult(toSeq(list));
132132
}
133133

134+
/// <summary>
135+
/// Executes a Functional SQL statement that uses SharpCoreDB's extended syntax.
136+
/// Supports <c>OPTIONALLY FROM</c>, <c>IS SOME</c>, <c>IS NONE</c>,
137+
/// <c>UNWRAP column AS alias</c>, and <c>MATCH SOME/NONE</c> keywords.
138+
/// </summary>
139+
/// <typeparam name="T">Row model type.</typeparam>
140+
/// <param name="functionalSql">The Functional SQL statement.</param>
141+
/// <param name="parameters">Optional query parameters.</param>
142+
/// <param name="cancellationToken">Cancellation token.</param>
143+
/// <returns>
144+
/// When <c>OPTIONALLY FROM</c> is used, returns a sequence of <see cref="Option{T}"/>
145+
/// (each row wrapped). Otherwise returns <c>Some</c> rows only.
146+
/// </returns>
147+
/// <example>
148+
/// <code>
149+
/// // Get users who have an email, each wrapped in Option
150+
/// var users = await fdb.ExecuteFunctionalSqlAsync&lt;UserDto&gt;(
151+
/// "SELECT Id, Name, Email OPTIONALLY FROM Users WHERE Email IS SOME");
152+
///
153+
/// foreach (var userOpt in users)
154+
/// {
155+
/// var name = userOpt.Map(u => u.Name).IfNone("unknown");
156+
/// }
157+
/// </code>
158+
/// </example>
159+
public Task<Seq<Option<T>>> ExecuteFunctionalSqlAsync<T>(
160+
string functionalSql,
161+
Dictionary<string, object?>? parameters = null,
162+
CancellationToken cancellationToken = default)
163+
where T : class, new()
164+
{
165+
ArgumentException.ThrowIfNullOrWhiteSpace(functionalSql);
166+
cancellationToken.ThrowIfCancellationRequested();
167+
168+
var translator = new FunctionalSqlTranslator();
169+
var result = translator.Translate(functionalSql);
170+
171+
var rows = _inner.ExecuteQuery(result.StandardSql, parameters);
172+
if (rows.Count == 0)
173+
{
174+
return Task.FromResult(Seq<Option<T>>.Empty);
175+
}
176+
177+
var list = new List<Option<T>>(rows.Count);
178+
foreach (var row in rows)
179+
{
180+
// Apply UNWRAP defaults: if column is null/empty, substitute default
181+
foreach (var unwrap in result.UnwrapMappings)
182+
{
183+
if (TryGetValueIgnoreCase(row, unwrap.Column, out var val)
184+
&& (val is null || (val is string s && string.IsNullOrEmpty(s)))
185+
&& unwrap.DefaultValue is not null)
186+
{
187+
row[unwrap.Column] = unwrap.DefaultValue;
188+
}
189+
}
190+
191+
// Apply post-filter for SOME columns (defense-in-depth beyond SQL translation)
192+
if (result.SomeColumns.Count > 0 && !PassesSomeFilter(row, result.SomeColumns))
193+
{
194+
continue;
195+
}
196+
197+
// Apply post-filter for NONE columns (defense-in-depth beyond SQL translation)
198+
if (result.NoneColumns.Count > 0 && !PassesNoneFilter(row, result.NoneColumns))
199+
{
200+
continue;
201+
}
202+
203+
var mapped = TryMapRow<T>(row);
204+
list.Add(mapped);
205+
}
206+
207+
return Task.FromResult(toSeq(list));
208+
}
209+
210+
/// <summary>
211+
/// Executes a Functional SQL query returning only successfully mapped rows
212+
/// (unwrapped from <c>Option&lt;T&gt;</c>). Convenience method when you want
213+
/// a flat sequence instead of <c>Seq&lt;Option&lt;T&gt;&gt;</c>.
214+
/// </summary>
215+
/// <typeparam name="T">Row model type.</typeparam>
216+
/// <param name="functionalSql">The Functional SQL statement.</param>
217+
/// <param name="parameters">Optional query parameters.</param>
218+
/// <param name="cancellationToken">Cancellation token.</param>
219+
/// <returns>A flat <see cref="Seq{T}"/> of successfully mapped rows.</returns>
220+
public async Task<Seq<T>> ExecuteFunctionalSqlUnwrappedAsync<T>(
221+
string functionalSql,
222+
Dictionary<string, object?>? parameters = null,
223+
CancellationToken cancellationToken = default)
224+
where T : class, new()
225+
{
226+
var results = await ExecuteFunctionalSqlAsync<T>(functionalSql, parameters, cancellationToken)
227+
.ConfigureAwait(false);
228+
229+
var list = new List<T>(results.Count);
230+
foreach (var opt in results)
231+
{
232+
opt.IfSome(list.Add);
233+
}
234+
235+
return toSeq(list);
236+
}
237+
238+
private static bool PassesSomeFilter(Dictionary<string, object> row, IReadOnlyList<string> someColumns)
239+
{
240+
foreach (var col in someColumns)
241+
{
242+
if (!TryGetValueIgnoreCase(row, col, out var val))
243+
{
244+
return false;
245+
}
246+
247+
if (IsSemanticNone(val))
248+
{
249+
return false;
250+
}
251+
}
252+
253+
return true;
254+
}
255+
256+
private static bool PassesNoneFilter(Dictionary<string, object> row, IReadOnlyList<string> noneColumns)
257+
{
258+
foreach (var col in noneColumns)
259+
{
260+
if (!TryGetValueIgnoreCase(row, col, out var val))
261+
{
262+
continue;
263+
}
264+
265+
if (!IsSemanticNone(val))
266+
{
267+
return false;
268+
}
269+
}
270+
271+
return true;
272+
}
273+
274+
private static bool IsSemanticNone(object? value)
275+
{
276+
return value switch
277+
{
278+
null => true,
279+
DBNull => true,
280+
string s when string.IsNullOrWhiteSpace(s) => true,
281+
string s when string.Equals(s, "NULL", StringComparison.OrdinalIgnoreCase) => true,
282+
_ => false
283+
};
284+
}
285+
134286
/// <summary>
135287
/// Inserts an entity into a table.
136288
/// </summary>
@@ -378,7 +530,20 @@ private static bool TryGetValueIgnoreCase(Dictionary<string, object> row, string
378530
ArgumentNullException.ThrowIfNull(value);
379531
ArgumentNullException.ThrowIfNull(targetType);
380532

381-
var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType;
533+
if (value == DBNull.Value)
534+
{
535+
return Nullable.GetUnderlyingType(targetType) is not null || !targetType.IsValueType
536+
? null
537+
: throw new InvalidOperationException($"Cannot map DBNull to non-nullable type '{targetType.Name}'.");
538+
}
539+
540+
var nullableUnderlying = Nullable.GetUnderlyingType(targetType);
541+
var underlying = nullableUnderlying ?? targetType;
542+
543+
if (value is string textValue && string.IsNullOrEmpty(textValue) && nullableUnderlying is not null)
544+
{
545+
return null;
546+
}
382547

383548
if (underlying.IsInstanceOfType(value))
384549
{

0 commit comments

Comments
 (0)