Skip to content

Commit 0c988b6

Browse files
committed
- checkpoint: extend LINQ support
1 parent 8621a79 commit 0c988b6

22 files changed

Lines changed: 1960 additions & 36 deletions

docs/linq-querying.md

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,20 @@ Internally, `Query<T>()` creates a template instance (`conn.Create(typeof(T))`),
7474
| `Where(x => cond1 \|\| cond2)` | `OrTerm` | |
7575
| `Where(x => !cond)` | `NotTerm` | |
7676
| `Where(x => list.Contains(x.Field))` | `InTerm` | |
77+
| `Where(x => x.Field.StartsWith(v))` | `LikeTerm` (`v%`) | |
78+
| `Where(x => x.Field.EndsWith(v))` | `LikeTerm` (`%v`) | |
79+
| `Where(x => x.BoolField)` | `EqualTerm` with `true` | Evaluates raw boolean fields natively |
7780
| `OrderBy(x => x.Field)` | `OrderAscending` | |
7881
| `OrderByDescending(x => x.Field)` | `OrderDescending` | |
7982
| `ThenBy(x => x.Field)` | Appended `OrderAscending` | Composites with primary sort |
8083
| `ThenByDescending(x => x.Field)` | Appended `OrderDescending` | |
8184
| `Take(n)` | `pageSize` parameter | |
8285
| `Skip(n)` | `start` parameter in `QueryPage` | |
86+
| `Count()`, `LongCount()` | `DataConnection.QueryCount` | Executes scalar COUNT immediately |
87+
| `First()`, `FirstOrDefault()` | `DataConnection.QueryFirst` | Executes scalar SELECT TOP 1 immediately |
88+
| `Single()`, `SingleOrDefault()`| `DataConnection.QueryFirst` | Throws if multiple results |
89+
| `Any()` | `DataConnection.QueryFirst` | Evaluates existence query directly |
90+
| `Select(x => new { ... })` | FieldSubset projection | Constructs partial SELECTs dynamically |
8391

8492
---
8593

@@ -129,6 +137,27 @@ conn.Query<Product>().Where(p => names.Contains(p.Name))
129137
// → WHERE Name IN (@IN_Name0, @IN_Name1)
130138
```
131139

140+
### String Wildcards
141+
142+
```csharp
143+
conn.Query<Product>().Where(p => p.Name.StartsWith("App"))
144+
// → WHERE Name LIKE 'App%'
145+
146+
conn.Query<Product>().Where(p => p.Name.EndsWith("inc"))
147+
// → WHERE Name LIKE '%inc'
148+
149+
// Note: MongoDB handles these as regular expressions (.*pattern or pattern.*) automatically.
150+
```
151+
152+
### Implicit Booleans
153+
154+
You can natively pass `TBool` fields directly into the lambda constraint, mimicking standard .NET semantics.
155+
156+
```csharp
157+
conn.Query<Product>().Where(p => p.IsActive)
158+
// → WHERE IsActive = 1
159+
```
160+
132161
### Captured local variables
133162

134163
```csharp
@@ -176,6 +205,40 @@ Without `Skip`/`Take`, execution uses `conn.LazyQueryAll(...)` for memory-effici
176205

177206
---
178207

208+
## Scalar & Terminal Methods
209+
210+
ActiveForge supports invoking terminal scalar executors directly on the query, compiling immediately and sending a constrained scalar demand to the DB.
211+
212+
```csharp
213+
// Returns scalar INT directly
214+
int count = conn.Query<Product>().Where(p => p.IsActive).Count();
215+
216+
// Returns scalar Bool (Exists check)
217+
bool hasAny = conn.Query<Product>().Where(p => p.Price > 100).Any();
218+
219+
// Retrieves the TOP 1 entity
220+
var topItem = conn.Query<Product>().OrderBy(p => p.Price).FirstOrDefault();
221+
```
222+
223+
---
224+
225+
## Projections (Select)
226+
227+
Anonymous type projection parses requested properties to prune the retrieved columns securely at the database level by evaluating a tailored `FieldSubset`.
228+
229+
```csharp
230+
// The SQL executed will ONLY 'SELECT p.Id, p.Name FROM Products p'
231+
var lightweightList = conn.Query<Product>()
232+
.Where(p => p.IsActive)
233+
.Select(p => new {
234+
p.ID,
235+
p.Name
236+
})
237+
.ToList();
238+
```
239+
240+
---
241+
179242
## Lazy Enumeration
180243

181244
`IQueryable<T>` is lazy — the database is not queried until you start iterating:
@@ -312,8 +375,6 @@ The expression tree is traversed **at execution time**, so local variables are c
312375
|------------|-------|
313376
| No `GroupBy` | Not supported; use raw SQL or `ExecSQL`. |
314377
| No `Join` clause | Cross-join predicates and sorts work via embedded `Record` fields. See [joins.md](joins.md). |
315-
| No `Select` projection | Returns full typed `Record` instances; field subsets can be applied at the `conn.Query<T>(template)` level. |
316-
| No `Count()`, `First()`, etc. | Call the standard ORM methods (`conn.QueryCount(...)`, `conn.QueryFirst(...)`) directly. |
317378
| No async support | Use the synchronous API; async is planned for a future release. |
318379

319380
---

docs/provider-appendix.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,8 @@ public abstract class DBDataConnection : DataConnection, IDisposable
478478
///
479479
/// SQL Server: SELECT @@IDENTITY (not SCOPE_IDENTITY — see note in MEMORY.md)
480480
/// PostgreSQL: RETURNING id (implemented via RETURNING clause in INSERT SQL)
481+
/// Note: PostgreSQL row locking (ReadForUpdate) requires overriding
482+
/// GetReadForUpdateSQL to append "FOR UPDATE" to the end of the query.
481483
/// SQLite: SELECT last_insert_rowid()
482484
/// MongoDB: n/a (uses auto-increment counter collection)
483485
/// </summary>
@@ -602,6 +604,12 @@ public class MyConnection : DBDataConnection
602604
// Identity insert wrappers (if not supported, return empty strings)
603605
public override string PreInsertIdentityCommand(string sourceName) => "";
604606
public override string PostInsertIdentityCommand(string sourceName) => "";
607+
608+
// PostgreSQL ReadForUpdate requires custom placement of FOR UPDATE
609+
protected override string GetReadForUpdateSQL(Record obj, RecordBinding binding, List<FieldBinding> fieldBindingSubset, FieldSubset fieldSubset) {
610+
// ... (see PostgreSQLConnection implementation for details)
611+
return "SELECT ... FROM ... WHERE ... FOR UPDATE";
612+
}
605613
}
606614
```
607615

docs/transactions.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@ conn.ProcessActionQueue();
207207
## Read-for-update (advisory lock)
208208

209209
`ReadForUpdate` acquires a row-level update lock on SQL Server
210-
(`SELECT ... WITH (UPDLOCK)`) so that no other session can update the row
210+
(`SELECT ... WITH (UPDLOCK)`) and PostgreSQL (`SELECT ... FOR UPDATE`)
211+
so that no other session can update the row
211212
between your read and your subsequent write:
212213

213214
```csharp

docs/wiki.md

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,28 @@ conn.Query<Product>().Where(p => !(p.InStock == true)).ToList();
692692
var names = new List<string> { "Widget", "Gadget", "Gizmo" };
693693
conn.Query<Product>().Where(p => names.Contains(p.Name)).ToList();
694694

695-
// Local variable capture (evaluated at translation time)
695+
### String Wildcards
696+
697+
```csharp
698+
conn.Query<Product>().Where(p => p.Name.StartsWith("App")).ToList();
699+
// → WHERE Name LIKE 'App%'
700+
701+
conn.Query<Product>().Where(p => p.Name.EndsWith("inc")).ToList();
702+
// → WHERE Name LIKE '%inc'
703+
```
704+
705+
### Implicit Booleans
706+
707+
You can natively pass `TBool` fields directly into the lambda constraint:
708+
709+
```csharp
710+
conn.Query<Product>().Where(p => p.IsActive).ToList();
711+
// → WHERE IsActive = 1
712+
```
713+
714+
### Captured local variables
715+
716+
```csharp
696717
string target = "Widget";
697718
decimal minP = 10m;
698719
conn.Query<Product>().Where(p => p.Name == target && p.Price >= minP).ToList();
@@ -771,24 +792,60 @@ List<Product> results = conn.Query<Product>()
771792
| `a \|\| b` | `OrTerm` | |
772793
| `!a` | `NotTerm` | |
773794
| `list.Contains(x.F)` | `InTerm` | Use `List<T>`, not arrays |
795+
| `x.F.StartsWith(v)` | `LikeTerm` (`v%`) | |
796+
| `x.F.EndsWith(v)` | `LikeTerm` (`%v`) | |
797+
| `x.BoolField` | `EqualTerm` with `true` | Evaluates raw boolean fields natively |
774798
| `OrderBy` | `OrderAscending` | |
775799
| `OrderByDescending` | `OrderDescending` | |
776800
| `ThenBy` | Appended ascending | |
777801
| `ThenByDescending` | Appended descending | |
778802
| `Take(n)` | `pageSize` | |
779803
| `Skip(n)` | `startRecord` | |
804+
| `Count()`, `LongCount()` | `DataConnection.QueryCount` | Executes scalar COUNT immediately |
805+
| `First()`, `FirstOrDefault()` | `DataConnection.QueryFirst` | Executes scalar SELECT TOP 1 immediately |
806+
| `Single()`, `SingleOrDefault()`| `DataConnection.QueryFirst` | Throws if multiple results |
807+
| `Any()` | `DataConnection.QueryFirst` | Evaluates existence query directly |
808+
| `Select(x => new { ... })` | FieldSubset projection | Constructs partial SELECTs dynamically |
809+
810+
### 9.8 Scalar & Terminal Methods
780811

781-
### 9.8 Limitations
812+
ActiveForge supports invoking terminal scalar executors directly on the query, compiling immediately and sending a constrained scalar demand to the DB.
813+
814+
```csharp
815+
// Returns scalar INT directly
816+
int count = conn.Query<Product>().Where(p => p.IsActive).Count();
817+
818+
// Returns scalar Bool (Exists check)
819+
bool hasAny = conn.Query<Product>().Where(p => p.Price > 100).Any();
820+
821+
// Retrieves the TOP 1 entity
822+
var topItem = conn.Query<Product>().OrderBy(p => p.Price).FirstOrDefault();
823+
```
824+
825+
### 9.9 Projections (Select)
826+
827+
Anonymous type projection parses requested properties to prune the retrieved columns securely at the database level by evaluating a tailored `FieldSubset`.
828+
829+
```csharp
830+
// The SQL executed will ONLY 'SELECT p.Id, p.Name FROM Products p'
831+
var lightweightList = conn.Query<Product>()
832+
.Where(p => p.IsActive)
833+
.Select(p => new {
834+
p.ID,
835+
p.Name
836+
})
837+
.ToList();
838+
```
839+
840+
### 9.10 Limitations
782841

783842
| Limitation | Workaround |
784843
|------------|-----------|
785844
| No `GroupBy` | Use raw SQL (`ExecSQL`) |
786845
| No `Join` | Use embedded `Record` fields |
787-
| No `Select` projection | Use `FieldSubset` on template |
788-
| No `Count()`, `First()` | Use `conn.QueryCount()`, `conn.QueryFirst()` |
789846
| No async | Async planned for a future release |
790847

791-
### 9.9 Mixing LINQ with QueryTerm
848+
### 9.11 Mixing LINQ with QueryTerm
792849

793850
```csharp
794851
OrmQueryable<Product> orm = (OrmQueryable<Product>)conn.Query<Product>()
@@ -1411,7 +1468,7 @@ foreach (Shape s in results)
14111468

14121469
---
14131470

1414-
## 17. Optimistic Locking
1471+
### 17.1 Optimistic Locking
14151472

14161473
`RecordLock.UpdateOption` controls what happens when another process has modified the row:
14171474

@@ -1429,6 +1486,23 @@ product.Update(RecordLock.UpdateOption.ReleaseLock);
14291486
product.Update(RecordLock.UpdateOption.RetainLock);
14301487
```
14311488

1489+
### 17.2 Pessimistic Locking (ReadForUpdate)
1490+
1491+
Acquires a row-level update lock (SQL Server `UPDLOCK`, PostgreSQL `FOR UPDATE`) within a transaction to block other writers until commit.
1492+
1493+
```csharp
1494+
using var tx = conn.BeginTransaction();
1495+
1496+
var product = new Product(conn);
1497+
product.ID.SetValue(42);
1498+
conn.ReadForUpdate(product, null); // Blocks other sessions
1499+
1500+
product.Price.SetValue(20.00m);
1501+
product.Update();
1502+
1503+
conn.CommitTransaction(tx); // Lock released
1504+
```
1505+
14321506
Handle lock conflicts:
14331507

14341508
```csharp
@@ -1473,28 +1547,42 @@ foreach (Product p in conn.Query<Product>().Where(p => p.InStock == true))
14731547

14741548
## 19. Raw SQL and Stored Procedures
14751549

1476-
### 19.1 ExecSQL
1550+
### 19.1 ExecSQL (Direct Results)
14771551

1478-
```csharp
1479-
// Returns DataTable:
1480-
DataTable table = conn.ExecSQL(
1481-
"SELECT Name, SUM(Qty) AS Total FROM OrderLines GROUP BY Name",
1482-
null);
1552+
Executes raw SQL and returns a `ReaderBase` for manual iteration.
14831553

1484-
foreach (DataRow row in table.Rows)
1485-
Console.WriteLine($"{row["Name"]} — {row["Total"]}");
1554+
```csharp
1555+
using var reader = conn.ExecSQL("SELECT COUNT(*) FROM Products");
1556+
if (reader.Read())
1557+
{
1558+
int count = (int)reader.ColumnValue(0);
1559+
}
14861560
```
14871561

1488-
### 19.2 Stored Procedures
1562+
### 19.2 ExecSQL (Typed Mapping)
1563+
1564+
Maps raw SQL results directly to `Record` instances using a template.
14891565

14901566
```csharp
1491-
var parameters = new Dictionary<string, object>
1567+
var template = new Product(conn);
1568+
var results = conn.ExecSQL(template, "SELECT * FROM Products WHERE Price > 100");
1569+
1570+
foreach (Product p in results)
14921571
{
1493-
["@CategoryId"] = 5,
1494-
["@MaxPrice"] = 100m
1495-
};
1572+
Console.WriteLine(p.Name.GetValue());
1573+
}
1574+
```
1575+
1576+
### 19.3 Stored Procedures
1577+
1578+
Executes a command set to `CommandType.StoredProcedure`.
14961579

1497-
DataTable result = conn.ExecStoredProcedure("usp_GetProductsByCategory", parameters);
1580+
```csharp
1581+
var pCategoryId = new Record.SPParameter { Name = "CategoryId", Value = 5 };
1582+
var pMaxPrice = new Record.SPParameter { Name = "MaxPrice", Value = 100m };
1583+
1584+
var template = new Product(conn);
1585+
var results = conn.ExecStoredProcedure(template, "GetProductsByCategory", 0, 0, pCategoryId, pMaxPrice);
14981586
```
14991587

15001588
---

src/ActiveForge.MongoDB/Internal/MongoQueryTranslator.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,7 @@ private static FilterDefinition<BsonDocument> TranslateLeaf(
108108
GreaterOrEqualTerm _ => Builders<BsonDocument>.Filter.Gte(fieldName, MongoMapper.ClrToBson(value)),
109109
LessThanTerm _ => Builders<BsonDocument>.Filter.Lt(fieldName, MongoMapper.ClrToBson(value)),
110110
LessOrEqualTerm _ => Builders<BsonDocument>.Filter.Lte(fieldName, MongoMapper.ClrToBson(value)),
111-
LikeTerm _ => Builders<BsonDocument>.Filter.Regex(fieldName,
112-
new BsonRegularExpression(
113-
System.Text.RegularExpressions.Regex.Escape(value?.ToString() ?? ""), "i")),
111+
LikeTerm _ => TranslateLike(fieldName, value?.ToString() ?? ""),
114112
EqualTerm _ => Builders<BsonDocument>.Filter.Eq(fieldName, MongoMapper.ClrToBson(value)),
115113
_ => Builders<BsonDocument>.Filter.Eq(fieldName, MongoMapper.ClrToBson(value)),
116114
};
@@ -124,6 +122,21 @@ private static FilterDefinition<BsonDocument> TranslateIn(string fieldName, ILis
124122
return Builders<BsonDocument>.Filter.In(fieldName, bsonValues);
125123
}
126124

125+
private static FilterDefinition<BsonDocument> TranslateLike(string fieldName, string pattern)
126+
{
127+
// Convert SQL LIKE pattern (%) to Regex pattern (.*)
128+
// SQL LIKE matches the whole string.
129+
// Note: Regex.Escape does NOT escape % or _, which are SQL wildcards.
130+
string regex = System.Text.RegularExpressions.Regex.Escape(pattern)
131+
.Replace("%", ".*")
132+
.Replace("_", ".");
133+
134+
// Anchors for full string match
135+
regex = "^" + regex + "$";
136+
137+
return Builders<BsonDocument>.Filter.Regex(fieldName, new BsonRegularExpression(regex, "i"));
138+
}
139+
127140
// ── Sort translation ──────────────────────────────────────────────────────────
128141

129142
public static SortDefinition<BsonDocument>? TranslateSort(SortOrder? sortOrder, Record obj,

src/ActiveForge.PostgreSQL/PostgreSQLConnection.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,40 @@ protected override string PopulateIdentity(Record obj, RecordBinding binding, Co
125125
return id;
126126
}
127127

128+
protected override string GetReadForUpdateSQL(Record obj, RecordBinding binding, List<FieldBinding> fieldBindingSubset, FieldSubset fieldSubset)
129+
{
130+
var fields = new System.Text.StringBuilder();
131+
string criteria = "";
132+
string joins = GetJoinSQL(binding, fieldSubset, true);
133+
134+
foreach (var fb in fieldBindingSubset)
135+
{
136+
var tfi = fb.Info;
137+
var node = fb.MapNode;
138+
if (node == null) continue;
139+
140+
if (tfi.IsInPK && binding.UseAsPK(tfi) && binding.UpdateTableAliases.Contains(node.Alias))
141+
{
142+
if (criteria.Length > 0) criteria += " AND ";
143+
if (node.Alias.Length > 0)
144+
criteria += node.Alias + GetSourceNameSeparator();
145+
criteria += QuoteName(tfi.TargetName) + "=" + GetParameterMark() + tfi.TargetName;
146+
}
147+
else
148+
{
149+
if (fields.Length > 0) fields.Append(',');
150+
if (node.Alias.Length > 0)
151+
fields.Append(node.Alias).Append(GetSourceNameSeparator());
152+
fields.Append(QuoteName(tfi.TargetName));
153+
if (fb.Alias.Length > 0)
154+
fields.Append(' ').Append(fb.Alias);
155+
}
156+
}
157+
158+
if (fields.Length == 0) fields.Append('*');
159+
return $"SELECT {fields} FROM {ResolveFullyQualifiedName(binding.SourceName, binding.Function)} {binding.GetRootAlias()}{joins} WHERE {criteria} FOR UPDATE";
160+
}
161+
128162
// ── Schema introspection ──────────────────────────────────────────────────────
129163

130164
/// <summary>

src/ActiveForge/DBDataConnection.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1440,7 +1440,7 @@ protected string GetReadSQL(Record obj, RecordBinding binding, List<FieldBinding
14401440
return $"SELECT {fields} FROM {ResolveFullyQualifiedName(binding.SourceName, binding.Function)} {binding.GetRootAlias()}{joins} WHERE {criteria}";
14411441
}
14421442

1443-
protected string GetReadForUpdateSQL(Record obj, RecordBinding binding, List<FieldBinding> fieldBindingSubset, FieldSubset fieldSubset)
1443+
protected virtual string GetReadForUpdateSQL(Record obj, RecordBinding binding, List<FieldBinding> fieldBindingSubset, FieldSubset fieldSubset)
14441444
{
14451445
var fields = new StringBuilder();
14461446
string criteria = "";

0 commit comments

Comments
 (0)