Skip to content

Commit 1b4320c

Browse files
refactor: использовать Expression<Func<Project, DateTime, bool>> в KogdaIgraMissingGamesPredicate
- GetPredicate() возвращает Expression для трансляции в SQL через LinqKit - DisableKogdaIgraMapping и Active перенесены внутрь предиката - ProjectRepository.GetKogdaIgraMissingGames использует predicate.Invoke() - Тесты обновлены: создают Project-объекты и вызывают GetPredicate().Compile()() - Добавлены тесты на DisableKogdaIgraMapping=true и Active=false - Создан docs/linq-queries.md с документацией по LinqKit-паттерну - CLAUDE.md обновлён со ссылкой на docs/linq-queries.md Co-authored-by: Leonid Tsarev <leotsarev@users.noreply.github.com>
1 parent eed1497 commit 1b4320c

5 files changed

Lines changed: 152 additions & 58 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ dotnet test src/JoinRpg.Domain.Test # Конкретный тест
6464

6565
При создании новых классов или проектов — читать [docs/structure.md](docs/structure.md).
6666

67+
### LINQ-запросы и LinqKit
68+
69+
EF-предикаты и проекции пишутся через `Expression<Func<...>>` + LinqKit.
70+
Подробнее — [docs/linq-queries.md](docs/linq-queries.md).
71+
6772
### Blazor WebAssembly (islands, ADR001)
6873

6974
Небольшие интерактивные виджеты Blazor встраиваются в Razor-страницы.
@@ -83,6 +88,10 @@ dotnet test src/JoinRpg.Domain.Test # Конкретный тест
8388

8489
Для понимания кода предпочитай **cwm-roslyn-navigator** (MCP) перед чтением файлов. Roslyn понимает семантику C#: типы, иерархии наследования, реальные использования символов. Используй `find_symbol`, `find_references`, `find_callers`, `find_implementations`, `get_type_hierarchy` и другие инструменты навигатора для навигации по коду. Чтение файлов — только когда нужен полный контекст, которого навигатор не даёт.
8590

91+
## Фикс багов
92+
93+
При исправлении бага **всегда рассматривать добавление юнит теста**, который воспроизводит баг до фикса и проходит после. Это предотвращает регрессии. Тест добавляется в соответствующий тестовый проект.
94+
8695
## Правила кода
8796

8897
- **Без regex** — по возможности избегать регулярных выражений.

docs/linq-queries.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# LINQ-запросы и LinqKit
2+
3+
## Зачем LinqKit
4+
5+
Entity Framework транслирует LINQ-выражения (`Expression<Func<...>>`) в SQL.
6+
Обычные методы C# (`Func<...>`) — **не транслируются**: они выполняются в памяти после загрузки всех данных.
7+
8+
LinqKit позволяет **переиспользовать** Expression-предикаты и проекции в EF-запросах:
9+
- `AsExpandable()` — расширяет `IQueryable` для поддержки `Invoke`
10+
- `.Invoke(args...)` — вызывает Expression внутри другого Expression; LinqKit встраивает тело предиката в запрос
11+
12+
## Шаблон использования
13+
14+
### 1. Определить предикат как Expression
15+
16+
```csharp
17+
internal static class MyPredicate
18+
{
19+
public static Expression<Func<MyEntity, bool>> GetPredicate(DateTime now)
20+
{
21+
return entity => entity.Active && entity.EndDate >= now;
22+
}
23+
}
24+
```
25+
26+
Если нужно передать несколько параметров (например, сущность + вычисленное значение из join):
27+
28+
```csharp
29+
public static Expression<Func<Project, DateTime, bool>> GetPredicate(DateTime now)
30+
{
31+
var threshold = now.AddDays(-60);
32+
return (project, lastUpdated) =>
33+
project.Active && lastUpdated > threshold;
34+
}
35+
```
36+
37+
### 2. Использовать предикат в EF-запросе
38+
39+
```csharp
40+
// Обязательно: AsExpandable() на IQueryable
41+
private IQueryable<Project> AllProjects => Ctx.ProjectsSet.AsExpandable();
42+
43+
private async Task<List<MyDto>> GetFiltered()
44+
{
45+
var predicate = MyPredicate.GetPredicate(DateTime.Now);
46+
47+
var query = from project in AllProjects
48+
join update in GetLastUpdateQuery() on project.ProjectId equals update.ProjectId
49+
where predicate.Invoke(project, update.LastUpdated) // Invoke = встраивание в SQL
50+
select new MyDto { ... };
51+
52+
return await query.ToListAsync();
53+
}
54+
```
55+
56+
### 3. Тестировать предикат напрямую
57+
58+
```csharp
59+
[Fact]
60+
public void ActiveProject_ShouldMatch()
61+
{
62+
var project = new Project { Active = true, ... };
63+
var result = MyPredicate.GetPredicate(Now).Compile()(project, RecentlyUpdated);
64+
result.ShouldBeTrue();
65+
}
66+
```
67+
68+
`.Compile()` превращает Expression в обычный делегат для выполнения в тестах (без EF).
69+
70+
## Правила
71+
72+
- Все предикаты и проекции для EF-запросов — только `Expression<Func<...>>`, **не** `Func<...>`
73+
- Если метод возвращает `bool` (не Expression) — он выполняется **в памяти**, EF его не видит
74+
- `AsExpandable()` ставится один раз на `IQueryable` (обычно в свойстве репозитория)
75+
- LinqKit подключён в `JoinRpg.Dal.Impl` как `LinqKit.EntityFramework`
76+
77+
## Примеры в коде
78+
79+
- `KogdaIgraMissingGamesPredicate` — предикат с двумя параметрами (Project + DateTime)
80+
- `ProjectPredicates`, `ClaimPredicates`, `CharacterPredicates` — предикаты по сущностям
81+
- `ProjectRepository.GetLastUpdateQuery` — проекция с `Expression<Func<T, DateTime>>`
Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1-
using JoinRpg.DataModel.Projects;
2-
31
namespace JoinRpg.Dal.Impl.Repositories;
42

53
internal static class KogdaIgraMissingGamesPredicate
64
{
75
/// <summary>
8-
/// Определяет, нуждается ли проект в привязке к КогдаИгра.
6+
/// Возвращает Expression-предикат для фильтрации проектов, нуждающихся в привязке к КогдаИгра.
7+
/// Используется с LinqKit (.AsExpandable() + .Invoke()) для трансляции запроса в SQL.
8+
/// См. docs/linq-queries.md
9+
///
910
/// Проект попадает в выборку если:
10-
/// 1. Нет активных КИ-привязок вообще, ИЛИ
11-
/// 2. Все привязанные КИ-игры уже завершились (End &lt; now) И проект недавно обновлялся (&lt;60 дней)
11+
/// 1. Проект активен и не отключена привязка к КогдаИгра
12+
/// 2. Нет активных КИ-привязок вообще, ИЛИ
13+
/// все привязанные КИ-игры уже завершились (End &lt; now) И проект недавно обновлялся (&lt;60 дней)
1214
/// </summary>
13-
public static bool NeedsBinding(
14-
IEnumerable<KogdaIgraGame> games,
15-
DateTime lastUpdated,
16-
DateTime now)
15+
public static Expression<Func<Project, DateTime, bool>> GetPredicate(DateTime now)
1716
{
1817
var lastUpdateMax = now.AddDays(-60);
19-
var hasActiveOrFutureGame = games.Any(g => g.Active && g.End >= now);
20-
var hasAnyGame = games.Any(g => g.Active);
21-
return !hasActiveOrFutureGame && (!hasAnyGame || lastUpdated > lastUpdateMax);
18+
return (project, lastUpdated) =>
19+
project.Active &&
20+
!project.Details.DisableKogdaIgraMapping &&
21+
!project.KogdaIgraGames.Any(g => g.Active && g.End >= now) &&
22+
(!project.KogdaIgraGames.Any(g => g.Active) || lastUpdated > lastUpdateMax);
2223
}
2324
}

src/JoinRpg.Dal.Impl/Repositories/ProjectRepository.cs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -233,17 +233,11 @@ async Task<ProjectShortInfo[]> IProjectRepository.GetProjectsBySpecification(Pro
233233
private async Task<ProjectShortInfo[]> GetKogdaIgraMissingGames()
234234
{
235235
var projection = GetProjectListProjection();
236-
237-
var now = DateTime.Now;
238-
var lastUpdateMax = now.AddDays(-60);
236+
var predicate = KogdaIgraMissingGamesPredicate.GetPredicate(DateTime.Now);
239237

240238
var query = from project in AllProjects
241239
join update in GetProjectWithLastUpdateQuery() on project.ProjectId equals update.ProjectId
242-
where project.Active && !project.Details.DisableKogdaIgraMapping
243-
let hasActiveOrFutureGame = project.KogdaIgraGames.Any(g => g.Active && g.End >= now)
244-
let hasAnyGame = project.KogdaIgraGames.Any(g => g.Active)
245-
where !hasActiveOrFutureGame
246-
where !hasAnyGame || update.LastUpdated > lastUpdateMax
240+
where predicate.Invoke(project, update.LastUpdated)
247241
select projection.Invoke(project, update);
248242

249243
var result = await query.ToListAsync();

src/JoinRpg.DataModel.Mocks/KogdaIgraMissingGamesPredicateTest.cs

Lines changed: 47 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -35,119 +35,128 @@ public class KogdaIgraMissingGamesPredicateTest
3535
Name = "Inactive Game",
3636
};
3737

38+
private static Project CreateProject(
39+
IEnumerable<KogdaIgraGame>? games = null,
40+
bool active = true,
41+
bool disableKogdaIgraMapping = false) => new()
42+
{
43+
Active = active,
44+
Details = new ProjectDetails { DisableKogdaIgraMapping = disableKogdaIgraMapping },
45+
KogdaIgraGames = [.. (games ?? [])],
46+
};
47+
48+
private static bool TestPredicate(Project project, DateTime lastUpdated)
49+
=> KogdaIgraMissingGamesPredicate.GetPredicate(Now).Compile()(project, lastUpdated);
50+
3851
// --- Нет привязок ---
3952

4053
[Fact]
4154
public void NoGames_RecentlyUpdated_ShouldNeedBinding()
42-
=> KogdaIgraMissingGamesPredicate.NeedsBinding([], RecentlyUpdated, Now)
43-
.ShouldBeTrue();
55+
=> TestPredicate(CreateProject(), RecentlyUpdated).ShouldBeTrue();
4456

4557
[Fact]
4658
public void NoGames_StaleProject_ShouldNeedBinding()
4759
// Даже устаревший проект без привязки — кандидат (нужна привязка)
48-
=> KogdaIgraMissingGamesPredicate.NeedsBinding([], StaleUpdated, Now)
49-
.ShouldBeTrue();
60+
=> TestPredicate(CreateProject(), StaleUpdated).ShouldBeTrue();
5061

5162
// --- Будущие игры (ещё не прошли) ---
5263

5364
[Fact]
5465
public void FutureGame_RecentlyUpdated_ShouldNotNeedBinding()
55-
=> KogdaIgraMissingGamesPredicate.NeedsBinding([GameEndingIn(30)], RecentlyUpdated, Now)
56-
.ShouldBeFalse();
66+
=> TestPredicate(CreateProject([GameEndingIn(30)]), RecentlyUpdated).ShouldBeFalse();
5767

5868
[Fact]
5969
public void FutureGame_StaleProject_ShouldNotNeedBinding()
6070
// Привязан к будущей игре — не нужна новая привязка
61-
=> KogdaIgraMissingGamesPredicate.NeedsBinding([GameEndingIn(30)], StaleUpdated, Now)
62-
.ShouldBeFalse();
71+
=> TestPredicate(CreateProject([GameEndingIn(30)]), StaleUpdated).ShouldBeFalse();
6372

6473
[Fact]
6574
public void GameEndingToday_ShouldNotNeedBinding()
6675
// End == Now: граница, игра «ещё не закончилась»
67-
=> KogdaIgraMissingGamesPredicate.NeedsBinding([GameEndingIn(0)], RecentlyUpdated, Now)
68-
.ShouldBeFalse();
76+
=> TestPredicate(CreateProject([GameEndingIn(0)]), RecentlyUpdated).ShouldBeFalse();
6977

7078
// --- Прошедшие игры ---
7179

7280
[Fact]
7381
public void PastGame_RecentlyUpdated_ShouldNeedBinding()
7482
// Сериал: игра прошла, проект активен → надо привязать следующую
75-
=> KogdaIgraMissingGamesPredicate.NeedsBinding([GameEndingIn(-1)], RecentlyUpdated, Now)
76-
.ShouldBeTrue();
83+
=> TestPredicate(CreateProject([GameEndingIn(-1)]), RecentlyUpdated).ShouldBeTrue();
7784

7885
[Fact]
7986
public void PastGame_StaleProject_ShouldNotNeedBinding()
8087
// Игра прошла, проект неактивен — не показывать в выборке
81-
=> KogdaIgraMissingGamesPredicate.NeedsBinding([GameEndingIn(-1)], StaleUpdated, Now)
82-
.ShouldBeFalse();
88+
=> TestPredicate(CreateProject([GameEndingIn(-1)]), StaleUpdated).ShouldBeFalse();
8389

8490
[Fact]
8591
public void PastGame_UpdatedExactlyAtBoundary_ShouldNotNeedBinding()
8692
{
8793
// Граница: обновлено ровно 60 дней назад — не попадает в выборку
8894
var updatedAt60DaysAgo = Now.AddDays(-60);
89-
KogdaIgraMissingGamesPredicate.NeedsBinding([GameEndingIn(-1)], updatedAt60DaysAgo, Now)
90-
.ShouldBeFalse();
95+
TestPredicate(CreateProject([GameEndingIn(-1)]), updatedAt60DaysAgo).ShouldBeFalse();
9196
}
9297

9398
[Fact]
9499
public void PastGame_UpdatedJustUnderBoundary_ShouldNeedBinding()
95100
{
96101
// Граница: обновлено 59 дней назад — попадает в выборку
97102
var updatedAt59DaysAgo = Now.AddDays(-59);
98-
KogdaIgraMissingGamesPredicate.NeedsBinding([GameEndingIn(-1)], updatedAt59DaysAgo, Now)
99-
.ShouldBeTrue();
103+
TestPredicate(CreateProject([GameEndingIn(-1)]), updatedAt59DaysAgo).ShouldBeTrue();
100104
}
101105

102106
// --- Неактивные привязки ---
103107

104108
[Fact]
105109
public void OnlyInactiveGame_RecentlyUpdated_ShouldNeedBinding()
106110
// Привязка помечена как неактивная — игнорируется, как нет привязки
107-
=> KogdaIgraMissingGamesPredicate.NeedsBinding([InactiveGame(30)], RecentlyUpdated, Now)
108-
.ShouldBeTrue();
111+
=> TestPredicate(CreateProject([InactiveGame(30)]), RecentlyUpdated).ShouldBeTrue();
109112

110113
[Fact]
111114
public void OnlyInactiveGame_StaleProject_ShouldNeedBinding()
112-
=> KogdaIgraMissingGamesPredicate.NeedsBinding([InactiveGame(30)], StaleUpdated, Now)
113-
.ShouldBeTrue();
115+
=> TestPredicate(CreateProject([InactiveGame(30)]), StaleUpdated).ShouldBeTrue();
114116

115117
// --- Несколько игр (сериал) ---
116118

117119
[Fact]
118120
public void MixedGames_OnePastOneFuture_ShouldNotNeedBinding()
119121
// Есть и прошедшая и будущая игра — привязан, не нуждается
120-
=> KogdaIgraMissingGamesPredicate.NeedsBinding(
121-
[GameEndingIn(-30), GameEndingIn(30)],
122-
RecentlyUpdated, Now)
123-
.ShouldBeFalse();
122+
=> TestPredicate(CreateProject([GameEndingIn(-30), GameEndingIn(30)]), RecentlyUpdated).ShouldBeFalse();
124123

125124
[Fact]
126125
public void MultiplePastGames_RecentlyUpdated_ShouldNeedBinding()
127126
// Все игры прошли, проект активен → нужна новая привязка
128-
=> KogdaIgraMissingGamesPredicate.NeedsBinding(
129-
[GameEndingIn(-60), GameEndingIn(-30), GameEndingIn(-1)],
130-
RecentlyUpdated, Now)
131-
.ShouldBeTrue();
127+
=> TestPredicate(
128+
CreateProject([GameEndingIn(-60), GameEndingIn(-30), GameEndingIn(-1)]),
129+
RecentlyUpdated).ShouldBeTrue();
132130

133131
[Fact]
134132
public void MultiplePastGames_StaleProject_ShouldNotNeedBinding()
135-
=> KogdaIgraMissingGamesPredicate.NeedsBinding(
136-
[GameEndingIn(-60), GameEndingIn(-30), GameEndingIn(-1)],
137-
StaleUpdated, Now)
138-
.ShouldBeFalse();
133+
=> TestPredicate(
134+
CreateProject([GameEndingIn(-60), GameEndingIn(-30), GameEndingIn(-1)]),
135+
StaleUpdated).ShouldBeFalse();
139136

140137
// --- Граничный случай: End = null ---
141138

142139
[Fact]
143140
public void GameWithNullEnd_RecentlyUpdated_ShouldNeedBinding()
144141
// Игра без даты окончания: End=null → null >= now = false → hasActiveOrFutureGame=false
145142
// Но hasAnyGame=true, поэтому проверяется активность проекта
146-
=> KogdaIgraMissingGamesPredicate.NeedsBinding([GameWithNullEnd()], RecentlyUpdated, Now)
147-
.ShouldBeTrue();
143+
=> TestPredicate(CreateProject([GameWithNullEnd()]), RecentlyUpdated).ShouldBeTrue();
148144

149145
[Fact]
150146
public void GameWithNullEnd_StaleProject_ShouldNotNeedBinding()
151-
=> KogdaIgraMissingGamesPredicate.NeedsBinding([GameWithNullEnd()], StaleUpdated, Now)
152-
.ShouldBeFalse();
147+
=> TestPredicate(CreateProject([GameWithNullEnd()]), StaleUpdated).ShouldBeFalse();
148+
149+
// --- Проект с DisableKogdaIgraMapping ---
150+
151+
[Fact]
152+
public void DisableKogdaIgraMapping_ShouldNotNeedBinding()
153+
// Проект с отключённой привязкой к КогдаИгра — не попадает в выборку
154+
=> TestPredicate(CreateProject(disableKogdaIgraMapping: true), RecentlyUpdated).ShouldBeFalse();
155+
156+
// --- Неактивный проект ---
157+
158+
[Fact]
159+
public void InactiveProject_ShouldNotNeedBinding()
160+
// Неактивный проект — не попадает в выборку
161+
=> TestPredicate(CreateProject(active: false), RecentlyUpdated).ShouldBeFalse();
153162
}

0 commit comments

Comments
 (0)