Skip to content

Commit 9c2df9e

Browse files
authored
Merge pull request #426 from nanotaboada/fix/squad-number-uniqueness-rule-set
fix(validators): scope BeUniqueSquadNumber to "Create" rule set (#424)
2 parents e7af849 + 90383a5 commit 9c2df9e

File tree

6 files changed

+308
-53
lines changed

6 files changed

+308
-53
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
name: Bug report
3+
about: Report a bug or unexpected behavior
4+
title: "[BUG]"
5+
labels: bug, .NET
6+
assignees: ''
7+
8+
---
9+
10+
## Description
11+
12+
A clear and concise description of what the bug is.
13+
14+
## Steps to Reproduce
15+
16+
1. Step 1
17+
2. Step 2
18+
3. Step 3
19+
20+
## Expected Behavior
21+
22+
What you expected to happen.
23+
24+
## Actual Behavior
25+
26+
What actually happened.
27+
28+
## Environment
29+
30+
- **.NET SDK version:** (output of `dotnet --version`)
31+
- **ASP.NET Core version:** (from `*.csproj`)
32+
- **OS:** (e.g., macOS 15.0, Ubuntu 24.04, Windows 11)
33+
34+
## Additional Context
35+
36+
Add any other context about the problem here (logs, screenshots, etc.).
37+
38+
## Possible Solution
39+
40+
(Optional) Suggest a fix or workaround if you have one.

sonar-project.properties

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# =============================================================================
2+
# SonarCloud configuration
3+
# https://docs.sonarsource.com/sonarcloud/advanced-setup/analysis-parameters/
4+
#
5+
# Project key and organization can be verified in SonarCloud under:
6+
# Administration → General Settings
7+
# =============================================================================
8+
9+
sonar.projectKey=nanotaboada_Dotnet.Samples.AspNetCore.WebApi
10+
sonar.organization=nanotaboada
11+
12+
# Source encoding
13+
sonar.sourceEncoding=UTF-8
14+
15+
# =============================================================================
16+
# Sources and tests
17+
# =============================================================================
18+
19+
sonar.sources=src/
20+
sonar.tests=test/
21+
sonar.test.inclusions=test/**/*.cs
22+
23+
# =============================================================================
24+
# Global exclusions
25+
# Keeps analysis focused on hand-written production code.
26+
# =============================================================================
27+
28+
sonar.exclusions=\
29+
**/obj/**,\
30+
**/bin/**,\
31+
**/Migrations/**
32+
33+
# =============================================================================
34+
# Coverage exclusions
35+
# Test files and generated/infrastructure code should not count against
36+
# production code coverage metrics.
37+
# =============================================================================
38+
39+
sonar.coverage.exclusions=\
40+
test/**/*.cs,\
41+
**/Migrations/**,\
42+
**/Data/PlayerDbContext.cs
43+
44+
# =============================================================================
45+
# Duplicate code (CPD) exclusions
46+
# NOTE: // NOSONAR suppresses rule-based issues (bugs, code smells, security
47+
# hotspots) but has no effect on CPD metrics. Files must be listed here to be
48+
# excluded from duplicate code detection.
49+
#
50+
# test/**/*.cs — Fakes, Mocks, and Stubs are intentionally repetitive by
51+
# design; similarity between arrange/act/assert blocks across tests is
52+
# expected and harmless.
53+
#
54+
# **/Validators/PlayerRequestModelValidator.cs — the "Create" and "Update"
55+
# rule sets share common rules by design; the duplication is intentional to
56+
# keep each operation's validation self-contained and readable.
57+
# =============================================================================
58+
59+
sonar.cpd.exclusions=\
60+
test/**/*.cs,\
61+
**/Validators/PlayerRequestModelValidator.cs

src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ IValidator<PlayerRequestModel> validator
3636
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
3737
public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
3838
{
39-
var validation = await validator.ValidateAsync(player);
39+
// Use the "Create" rule set, which includes BeUniqueSquadNumber.
40+
var validation = await validator.ValidateAsync(
41+
player,
42+
options => options.IncludeRuleSets("Create")
43+
);
4044

4145
if (!validation.IsValid)
4246
{
@@ -199,7 +203,13 @@ public async Task<IResult> PutAsync(
199203
[FromBody] PlayerRequestModel player
200204
)
201205
{
202-
var validation = await validator.ValidateAsync(player);
206+
// Use the "Update" rule set, which omits BeUniqueSquadNumber.
207+
// The player being updated already exists in the database, so a
208+
// uniqueness check on its own squad number would always fail.
209+
var validation = await validator.ValidateAsync(
210+
player,
211+
options => options.IncludeRuleSets("Update")
212+
);
203213
if (!validation.IsValid)
204214
{
205215
var errors = validation

src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs

Lines changed: 94 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,24 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Validators;
1111
/// PlayerRequestModel.
1212
/// </summary>
1313
/// <remarks>
14-
/// This class is part of the FluentValidation library, which provides a fluent
15-
/// interface for building validation rules.
14+
/// Rules are organized into CRUD-named rule sets to make their intent explicit.
15+
/// This prevents <c>BeUniqueSquadNumber</c> from running on PUT requests, where
16+
/// the player's squad number already exists in the database by definition.
17+
///
18+
/// <list type="bullet">
19+
/// <item><description>
20+
/// <c>"Create"</c> — used by <c>POST /players</c>; includes all rules plus
21+
/// the uniqueness check for <c>SquadNumber</c>.
22+
/// </description></item>
23+
/// <item><description>
24+
/// <c>"Update"</c> — used by <c>PUT /players/squadNumber/{n}</c>; same
25+
/// rules, but <c>BeUniqueSquadNumber</c> is intentionally omitted.
26+
/// </description></item>
27+
/// </list>
28+
///
29+
/// Controllers pass <c>opts.IncludeRuleSets("Create")</c> or
30+
/// <c>opts.IncludeRuleSets("Update")</c> so that only the appropriate rule
31+
/// set runs for each operation.
1632
/// </remarks>
1733
public class PlayerRequestModelValidator : AbstractValidator<PlayerRequestModel>
1834
{
@@ -26,35 +42,88 @@ public PlayerRequestModelValidator(
2642
_playerRepository = playerRepository;
2743
var clock = timeProvider ?? TimeProvider.System;
2844

29-
RuleFor(player => player.FirstName).NotEmpty().WithMessage("FirstName is required.");
45+
// "Create" rule set — POST /players
46+
// Includes BeUniqueSquadNumber to prevent duplicate squad numbers on insert.
47+
RuleSet(
48+
"Create",
49+
() =>
50+
{
51+
RuleFor(player => player.FirstName)
52+
.NotEmpty()
53+
.WithMessage("FirstName is required.");
54+
55+
RuleFor(player => player.LastName).NotEmpty().WithMessage("LastName is required.");
3056

31-
RuleFor(player => player.LastName).NotEmpty().WithMessage("LastName is required.");
57+
RuleFor(player => player.SquadNumber)
58+
.NotEmpty()
59+
.WithMessage("SquadNumber is required.")
60+
.GreaterThan(0)
61+
.WithMessage("SquadNumber must be greater than 0.")
62+
.MustAsync(BeUniqueSquadNumber)
63+
.WithMessage("SquadNumber must be unique.");
3264

33-
RuleFor(player => player.SquadNumber)
34-
.NotEmpty()
35-
.WithMessage("SquadNumber is required.")
36-
.GreaterThan(0)
37-
.WithMessage("SquadNumber must be greater than 0.")
38-
.MustAsync(BeUniqueSquadNumber)
39-
.WithMessage("SquadNumber must be unique.");
65+
RuleFor(player => player.AbbrPosition)
66+
.NotEmpty()
67+
.WithMessage("AbbrPosition is required.")
68+
.Must(Position.IsValidAbbr)
69+
.WithMessage("AbbrPosition is invalid.");
4070

41-
RuleFor(player => player.AbbrPosition)
42-
.NotEmpty()
43-
.WithMessage("AbbrPosition is required.")
44-
.Must(Position.IsValidAbbr)
45-
.WithMessage("AbbrPosition is invalid.");
71+
When(
72+
player => player.DateOfBirth.HasValue,
73+
() =>
74+
{
75+
RuleFor(player => player.DateOfBirth)
76+
.Must(date => date!.Value.Date < clock.GetUtcNow().Date)
77+
.WithMessage("DateOfBirth must be a date in the past.")
78+
.Must(date =>
79+
date!.Value.Date
80+
>= new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc)
81+
)
82+
.WithMessage("DateOfBirth must be on or after January 1, 1900.");
83+
}
84+
);
85+
}
86+
);
4687

47-
When(
48-
player => player.DateOfBirth.HasValue,
88+
// "Update" rule set — PUT /players/squadNumber/{n}
89+
// BeUniqueSquadNumber is intentionally omitted: on PUT the player being
90+
// updated already exists in the database, so the check would always fail.
91+
RuleSet(
92+
"Update",
4993
() =>
5094
{
51-
RuleFor(player => player.DateOfBirth)
52-
.Must(date => date!.Value.Date < clock.GetUtcNow().Date)
53-
.WithMessage("DateOfBirth must be a date in the past.")
54-
.Must(date =>
55-
date!.Value.Date >= new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc)
56-
)
57-
.WithMessage("DateOfBirth must be on or after January 1, 1900.");
95+
RuleFor(player => player.FirstName)
96+
.NotEmpty()
97+
.WithMessage("FirstName is required.");
98+
99+
RuleFor(player => player.LastName).NotEmpty().WithMessage("LastName is required.");
100+
101+
RuleFor(player => player.SquadNumber)
102+
.NotEmpty()
103+
.WithMessage("SquadNumber is required.")
104+
.GreaterThan(0)
105+
.WithMessage("SquadNumber must be greater than 0.");
106+
107+
RuleFor(player => player.AbbrPosition)
108+
.NotEmpty()
109+
.WithMessage("AbbrPosition is required.")
110+
.Must(Position.IsValidAbbr)
111+
.WithMessage("AbbrPosition is invalid.");
112+
113+
When(
114+
player => player.DateOfBirth.HasValue,
115+
() =>
116+
{
117+
RuleFor(player => player.DateOfBirth)
118+
.Must(date => date!.Value.Date < clock.GetUtcNow().Date)
119+
.WithMessage("DateOfBirth must be a date in the past.")
120+
.Must(date =>
121+
date!.Value.Date
122+
>= new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc)
123+
)
124+
.WithMessage("DateOfBirth must be on or after January 1, 1900.");
125+
}
126+
);
58127
}
59128
);
60129
}

0 commit comments

Comments
 (0)