Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .claude/commands/pre-commit.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ Before running the checklist, run `git fetch origin`. If the current branch is b
Run the pre-commit checklist for this project:

1. Update `CHANGELOG.md` `[Unreleased]` section — add an entry under the appropriate subsection (Added / Changed / Fixed / Removed) describing the changes made, referencing the issue number.
2. Run `./mvnw clean install` — must succeed with no compilation warnings and all tests passing.
3. Remind me to open `target/site/jacoco/index.html` to verify coverage after the build completes.
4. If Docker is running, run `docker compose build` — must succeed with no
2. Run `./mvnw clean install` — must succeed with no compilation warnings, all tests passing, and the JaCoCo check reporting `All coverage checks have been met.` (80% instruction and branch coverage enforced by the build).
3. If Docker is running, run `docker compose build` — must succeed with no
errors. Skip this step with a note if Docker Desktop is not running.
5. If `coderabbit` CLI is installed, run `coderabbit review --type uncommitted --prompt-only`:
4. If `coderabbit` CLI is installed, run `coderabbit review --type uncommitted --prompt-only`:
- If actionable/serious findings are reported, stop and address them before proposing the commit.
- If only nitpick-level findings, report them and continue to the commit proposal.
- If `coderabbit` is not installed, skip this step with a note.

Run steps 1–4, report the results clearly, then run step 5 (CodeRabbit review) if available, then propose a branch name and commit message for my approval using the format `type(scope): description (#issue)` (max 80 chars; types: `feat` `fix` `chore` `docs` `test` `refactor` `ci` `perf`). Do not create the branch or commit until I explicitly confirm.
Run steps 1–3, report the results clearly, then run step 4 (CodeRabbit review) if available, then propose a branch name and commit message for my approval using the format `type(scope): description (#issue)` (max 80 chars; types: `feat` `fix` `chore` `docs` `test` `refactor` `ci` `perf`). Do not create the branch or commit until I explicitly confirm.
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,27 @@ Release names follow the **historic football clubs** naming convention (A–Z):

## [Unreleased]

### Changed

- `Player` entity: `id` (UUID) is the database primary key — `@Id` with
`GenerationType.UUID`; `squadNumber` (Integer) carries `@Column(unique=true)`
and serves as the natural-key route identifier for `PUT` and `DELETE` (#268)
- `PlayerDTO`: `id` type changed from `Long` to `UUID` (#268)
- `PlayersRepository`: keyed on `UUID`; `findBySquadNumber(Integer)` derived
query resolves the UUID PK before update/delete operations (#268)
- `PlayersService`: `retrieveById` accepts `UUID`; `update` and
`deleteBySquadNumber` use `findBySquadNumber` to look up the UUID PK (#268)
- `PUT /players/{squadNumber}` and `DELETE /players/{squadNumber}`: path
variable changed from `Long id` to `Integer squadNumber` (#268)
- `GET /players/{id}`: path variable changed from `Long` to `UUID` (#268)
- `storage/players-sqlite3.db`: schema migrated to `id VARCHAR(36) PRIMARY KEY`,
`squadNumber INTEGER NOT NULL UNIQUE`; 25 players preserved (#268)
- `ddl.sql` and `dml.sql`: test schema and seed data updated for new structure (#268)

### Added

- JaCoCo `check` goal added to Maven build enforcing 80% instruction and branch
coverage — replaces manual HTML report step in `/pre-commit` (#268)
- `.sonarcloud.properties`: SonarCloud Automatic Analysis configuration —
sources, tests, coverage exclusions aligned with `codecov.yml` (#293)
- `.dockerignore`: added `.claude/`, `CLAUDE.md`, `.coderabbit.yaml`,
Expand Down
25 changes: 25 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,31 @@
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import java.net.URI;
import java.util.List;
import java.util.UUID;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -37,12 +38,12 @@
* <h3>Base Path:</h3>
* <ul>
* <li><b>GET</b> {@code /players} - Retrieve all players</li>
* <li><b>GET</b> {@code /players/{id}} - Retrieve player by ID</li>
* <li><b>GET</b> {@code /players/{id}} - Retrieve player by UUID (admin/internal use)</li>
* <li><b>GET</b> {@code /players/search/league/{league}} - Search players by league name</li>
* <li><b>GET</b> {@code /players/squadnumber/{number}} - Retrieve player by squad number</li>
* <li><b>GET</b> {@code /players/squadnumber/{squadNumber}} - Retrieve player by squad number</li>
* <li><b>POST</b> {@code /players} - Create a new player</li>
* <li><b>PUT</b> {@code /players/{id}} - Update an existing player</li>
* <li><b>DELETE</b> {@code /players/{id}} - Delete a player by ID</li>
* <li><b>PUT</b> {@code /players/{squadNumber}} - Update an existing player by squad number</li>
* <li><b>DELETE</b> {@code /players/{squadNumber}} - Delete a player by squad number</li>
* </ul>
*
* <h3>Response Codes:</h3>
Expand Down Expand Up @@ -74,12 +75,8 @@ public class PlayersController {
/**
* Creates a new player resource.
* <p>
* Validates the request body and creates a new player in the database. Returns a 201 Created response with a Location
* header pointing to the new resource.
* </p>
* <p>
* <b>Conflict Detection:</b> If a player with the same squad number already exists, returns 409 Conflict.
* Squad numbers must be unique (jersey numbers like Messi's #10).
* Validates the request body and creates a new player. Returns 201 Created with a Location
* header pointing to the new resource (UUID-based path).
* </p>
*
* @param playerDTO the player data to create (validated with JSR-380 constraints)
Expand Down Expand Up @@ -115,9 +112,6 @@ public ResponseEntity<Void> post(@RequestBody @Valid PlayerDTO playerDTO) {

/**
* Retrieves all players in the squad.
* <p>
* Returns the complete Argentina 2022 FIFA World Cup squad (26 players).
* </p>
*
* @return 200 OK with array of all players (empty array if none found)
*/
Expand All @@ -132,28 +126,27 @@ public ResponseEntity<List<PlayerDTO>> getAll() {
}

/**
* Retrieves a single player by their unique identifier.
* Retrieves a single player by their surrogate UUID (admin/internal use only).
*
* @param id the unique identifier of the player
* @param id the UUID surrogate key of the player
* @return 200 OK with player data, or 404 Not Found if player doesn't exist
*/
@GetMapping("/players/{id}")
@Operation(summary = "Retrieves a player by ID")
@Operation(summary = "Retrieves a player by UUID (admin/internal use)")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PlayerDTO.class))),
@ApiResponse(responseCode = "404", description = "Not Found", content = @Content)
})
public ResponseEntity<PlayerDTO> getById(@PathVariable Long id) {
public ResponseEntity<PlayerDTO> getById(@PathVariable UUID id) {
PlayerDTO playerDTO = playersService.retrieveById(id);
return (playerDTO != null)
? ResponseEntity.status(HttpStatus.OK).body(playerDTO)
: ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}

/**
* Retrieves a player by their squad number (unique identifier).
* Retrieves a player by their squad number.
* <p>
* Squad numbers are unique jersey numbers (e.g., Messi is #10). This is a direct lookup similar to getById().
* Example: {@code /players/squadnumber/10} returns Lionel Messi
* </p>
*
Expand Down Expand Up @@ -199,30 +192,29 @@ public ResponseEntity<List<PlayerDTO>> searchByLeague(@PathVariable String leagu
*/

/**
* Updates an existing player resource (full update).
* Updates an existing player resource (full update) identified by squad number.
* <p>
* Performs a complete replacement of the player entity. The ID in the path must match the ID in the request body.
* Performs a complete replacement of the player entity. The squad number in the path
* must match the squad number in the request body (if provided).
* </p>
*
* @param id the unique identifier of the player to update
* @param squadNumber the squad number (natural key) of the player to update
* @param playerDTO the complete player data (must pass validation)
* @return 204 No Content if successful, 404 Not Found if player doesn't exist, or 400 Bad Request if validation fails or
* ID mismatch
* @return 204 No Content if successful, 404 Not Found if player doesn't exist, or 400 Bad Request if validation fails
*/
@PutMapping("/players/{id}")
@Operation(summary = "Updates (entirely) a player by ID")
@PutMapping("/players/{squadNumber}")
@Operation(summary = "Updates (entirely) a player by squad number")
@ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "No Content", content = @Content),
@ApiResponse(responseCode = "400", description = "Bad Request", content = @Content),
@ApiResponse(responseCode = "404", description = "Not Found", content = @Content)
})
public ResponseEntity<Void> put(@PathVariable Long id, @RequestBody @Valid PlayerDTO playerDTO) {
// Ensure path ID matches body ID
if (playerDTO.getId() != null && !playerDTO.getId().equals(id)) {
public ResponseEntity<Void> put(@PathVariable Integer squadNumber, @RequestBody @Valid PlayerDTO playerDTO) {
if (!playerDTO.getSquadNumber().equals(squadNumber)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
playerDTO.setId(id); // Set ID from path to ensure consistency
boolean updated = playersService.update(playerDTO);
playerDTO.setSquadNumber(squadNumber);
boolean updated = playersService.update(squadNumber, playerDTO);
return (updated)
? ResponseEntity.status(HttpStatus.NO_CONTENT).build()
: ResponseEntity.status(HttpStatus.NOT_FOUND).build();
Expand All @@ -235,19 +227,19 @@ public ResponseEntity<Void> put(@PathVariable Long id, @RequestBody @Valid Playe
*/

/**
* Deletes a player resource by their unique identifier.
* Deletes a player resource by their squad number.
*
* @param id the unique identifier of the player to delete
* @param squadNumber the squad number of the player to delete
* @return 204 No Content if successful, or 404 Not Found if player doesn't exist
*/
@DeleteMapping("/players/{id}")
@Operation(summary = "Deletes a player by ID")
@DeleteMapping("/players/{squadNumber}")
@Operation(summary = "Deletes a player by squad number")
@ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "No Content", content = @Content),
@ApiResponse(responseCode = "404", description = "Not Found", content = @Content)
})
public ResponseEntity<Void> delete(@PathVariable Long id) {
boolean deleted = playersService.delete(id);
public ResponseEntity<Void> delete(@PathVariable Integer squadNumber) {
boolean deleted = playersService.deleteBySquadNumber(squadNumber);
return (deleted)
? ResponseEntity.status(HttpStatus.NO_CONTENT).build()
: ResponseEntity.status(HttpStatus.NOT_FOUND).build();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ar.com.nanotaboada.java.samples.spring.boot.models;

import java.time.LocalDate;
import java.util.UUID;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
Expand Down Expand Up @@ -28,9 +29,9 @@
*
* <h3>Key Features:</h3>
* <ul>
* <li>Auto-generated ID using IDENTITY strategy</li>
* <li>ISO-8601 date storage for SQLite compatibility
* ({@link IsoDateConverter})</li>
* <li>UUID primary key — generated at application level via {@code GenerationType.UUID}</li>
* <li>Squad number natural key — unique domain identifier, used as path variable for mutations</li>
* <li>ISO-8601 date storage for SQLite compatibility ({@link IsoDateConverter})</li>
* <li>JSON serialization support for LocalDate fields</li>
* </ul>
*
Expand All @@ -44,9 +45,22 @@
@NoArgsConstructor
@AllArgsConstructor
public class Player {

/**
* Primary key — UUID generated at application level.
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", nullable = false, updatable = false, columnDefinition = "VARCHAR(36)")
private UUID id;

/**
* Natural key — unique domain identifier, path variable for PUT and DELETE.
* Squad number (jersey number) is unique per team and stable.
*/
@Column(name = "squadNumber", nullable = false, unique = true, updatable = false)
private Integer squadNumber;

private String firstName;
private String middleName;
private String lastName;
Expand All @@ -59,16 +73,6 @@ public class Player {
@Convert(converter = IsoDateConverter.class)
private LocalDate dateOfBirth;

/**
* Squad number (jersey number) - unique natural key.
* <p>
* Used for player lookups via /players/search/squadnumber/{squadNumber}.
* Database constraint enforces uniqueness.
* </p>
*/
@Column(unique = true)
private Integer squadNumber;

private String position;
private String abbrPosition;
private String team;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@

import java.time.LocalDate;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;

import java.util.UUID;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Past;
Expand Down Expand Up @@ -46,7 +50,9 @@
*/
@Data
public class PlayerDTO {
private Long id;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@Schema(accessMode = Schema.AccessMode.READ_ONLY)
private UUID id;
@NotBlank
private String firstName;
private String middleName;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.util.List;
import java.util.Optional;
import java.util.UUID;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
Expand All @@ -12,56 +13,39 @@
* Spring Data JPA Repository for {@link Player} entities.
* <p>
* Provides data access methods for the {@code players} table using Spring Data's repository abstraction.
* Extends {@link JpaRepository} for CRUD operations, batch operations, and query methods.
* Extends {@link JpaRepository} for CRUD operations keyed on the UUID primary key.
* </p>
*
* <h3>Provided Methods:</h3>
* <ul>
* <li><b>Inherited from JpaRepository:</b> save, findAll (returns List), findById, delete, flush, etc.</li>
* <li><b>Custom Query Methods:</b> League search with case-insensitive wildcard matching</li>
* <li><b>Derived Queries:</b> findBySquadNumber (method name conventions)</li>
* </ul>
*
* <h3>Query Strategies:</h3>
* <ul>
* <li><b>Derived Queries:</b> Spring Data derives queries from method names (findBySquadNumber,
* findByLeagueContainingIgnoreCase)</li>
* <li><b>Inherited from JpaRepository:</b> save, findAll, findById(UUID), existsById, deleteById, etc.</li>
* <li><b>Derived Queries:</b> findBySquadNumber, findByLeagueContainingIgnoreCase</li>
* </ul>
*
* @see Player
* @see org.springframework.data.jpa.repository.JpaRepository
* @see <a href=
* "https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.query-methods.query-creation">Query
* Creation from Method Names</a>
* @since 4.0.2025
*/
@Repository
public interface PlayersRepository extends JpaRepository<Player, Long> {
public interface PlayersRepository extends JpaRepository<Player, UUID> {

/**
* Finds a player by their squad number (exact match).
* <p>
* This is a derived query method - Spring Data JPA generates the query automatically.
* Squad numbers are jersey numbers that users recognize (e.g., Messi is #10).
* This demonstrates Spring Data's method name query derivation with a natural key.
* Squad numbers are unique jersey numbers (e.g., Messi is #10).
* Used as the natural key for mutation endpoints (PUT, DELETE).
* </p>
*
* @param squadNumber the squad number to search for (jersey number, typically
* 1-99)
* @param squadNumber the squad number to search for (jersey number, typically 1-99)
* @return an Optional containing the player if found, empty Optional otherwise
*/
Optional<Player> findBySquadNumber(Integer squadNumber);

/**
* Finds players by league name using case-insensitive wildcard matching.
* <p>
* This method uses Spring Data's derived query mechanism to perform partial matching.
* For example, searching for "Premier" will match "Premier League".
* </p>
*
* @param league the league name to search for (partial matches allowed)
* @return a list of players whose league name contains the search term (empty
* list if none found)
* @return a list of players whose league name contains the search term
*/
List<Player> findByLeagueContainingIgnoreCase(String league);
}
Loading
Loading