Add library-management module: console app evolved into a Spring Boot 4 REST API
Stage 2 console classes and the shared LoanPolicy, then a Spring Boot 4.1.2 + JPA API (H2, @Version optimistic locking, problem+json errors). Tests write output/ transcripts while asserting the same numbers; scripts regenerate everything. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b4DPL6dtpFg4U4LHc2
This commit is contained in:
@@ -21,6 +21,7 @@ One module per article. This repository currently holds:
|
||||
| [`dynamic-programming`](dynamic-programming/) | Dynamic Programming in Java for Beginners: Memoization vs Tabulation (10 Classic Problems) | this commit |
|
||||
| [`fresher-qa`](fresher-qa/) | Top 50 Java Interview Questions for Freshers (2026) with Simple Answers | this commit |
|
||||
| [`stack-queue`](stack-queue/) | Stack and Queue Problems in Java: Balanced Parentheses, Next Greater Element, Min Stack | this commit |
|
||||
| [`library-management`](library-management/) | Library Management System in Java: Console Version to Spring Boot REST API | this commit |
|
||||
|
||||
## `collections` module
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# library-management
|
||||
|
||||
Companion code for the ankurm.com article **Library Management System in Java: Console Version
|
||||
to Spring Boot REST API**. One module, three stages: the same lending rules, first in plain Java,
|
||||
then split into classes, then served over HTTP with Spring Boot 4 and JPA.
|
||||
|
||||
Every number, status code and SQL line quoted in the article comes from a file in
|
||||
[`output/`](output/). The tests write those files while asserting the same numbers, so the
|
||||
transcripts cannot drift from the code.
|
||||
|
||||
## Versions
|
||||
|
||||
| Component | Version | Verified against |
|
||||
|---|---|---|
|
||||
| JDK | **25.0.5** (Temurin, LTS) | `java -version` |
|
||||
| Spring Boot | **4.1.2** (4.1.0 went GA on 2026-06-10) | `maven-metadata.xml` on Maven Central |
|
||||
| Spring Framework | 7.0.10 | `mvn dependency:list` |
|
||||
| Hibernate ORM | 7.4.13.Final | `mvn dependency:list` |
|
||||
| Spring Data JPA | 4.1.2 | `mvn dependency:list` |
|
||||
| Tomcat (embedded) | 11.0.27 | `mvn dependency:list` |
|
||||
| H2 | 2.4.240 | `mvn dependency:list` |
|
||||
| Maven | 3.9.11 | `mvn -v` |
|
||||
|
||||
## The three stages
|
||||
|
||||
| Stage | Where | Run it |
|
||||
|---|---|---|
|
||||
| 1. One file, no build tool | `LibraryConsole.java` (attached to the article, not stored here) | `java LibraryConsole.java < scripts/demo-input.txt` |
|
||||
| 2. Split into classes, rules extracted | `src/main/java/com/ankurm/library/console/` + `core/LoanPolicy.java` | `mvn -q compile && java -cp target/classes com.ankurm.library.console.ConsoleApp < scripts/demo-input.txt` |
|
||||
| 3. Spring Boot REST API with JPA | `src/main/java/com/ankurm/library/api/` + `core/LoanPolicy.java` | `scripts/run.sh` |
|
||||
|
||||
`core/LoanPolicy.java` (14-day loans, 3 active loans, Rs 2 a day fine) is shared by stages 2 and 3
|
||||
and did not change when the app moved to a database.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/path/to/jdk-25 && export PATH="$JAVA_HOME/bin:$PATH"
|
||||
mvn test # 6 tests; also regenerates output/02 to output/06
|
||||
scripts/run.sh # API on http://localhost:8080 (in-memory H2, clean library per start)
|
||||
scripts/api-demo.sh # in another terminal: curl walk-through
|
||||
```
|
||||
|
||||
The first Maven run must be online so the plugins are cached; `-o` works afterwards.
|
||||
`scripts/run-all.sh` regenerates every file in `output/`.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method and path | Purpose | Refusals |
|
||||
|---|---|---|
|
||||
| `POST /api/books` | add a book with N copies | 400 bad fields, 409 duplicate ISBN |
|
||||
| `GET /api/books` | list books with copies available | |
|
||||
| `POST /api/members` | register a member | 400 bad fields, 409 duplicate email |
|
||||
| `POST /api/loans` | borrow: `{"memberId":1,"bookId":2}` | 404 unknown id, 409 no copies, 422 limit or duplicate loan |
|
||||
| `POST /api/loans/{id}/return` | return, optional `{"returnedOn":"2026-11-20"}` | 409 already returned, 422 before borrow date |
|
||||
| `GET /api/loans` | all loans, one SQL statement | |
|
||||
|
||||
Errors are RFC 9457 `application/problem+json`.
|
||||
|
||||
## Captured output
|
||||
|
||||
| File | What it shows | Produced by |
|
||||
|---|---|---|
|
||||
| `01-console-structured.txt` | stage 2 run of `scripts/demo-input.txt` (identical to stage 1) | `run-all.sh` |
|
||||
| `02-loan-policy.txt` | the fine table | `LoanPolicyTest` |
|
||||
| `03-rest-api.txt` | every success and refusal over MockMvc | `LibraryApiTest` |
|
||||
| `04-query-count.txt` | 11 SQL statements versus 1 for the same list | `QueryCountTest` |
|
||||
| `05-race-with-version.txt` | 8 threads, last copy, `@Version`: one winner | `BorrowRaceTest` |
|
||||
| `06-race-without-version.txt` | same race without `@Version`: 8 loans for 1 copy | `BorrowRaceTest` |
|
||||
| `07-curl-demo.txt` | the same API on a real Tomcat, driven with curl (dates are the day it ran) | `scripts/api-demo.sh` |
|
||||
| `08-open-in-view-warning.txt` | the Boot default this module overrides | `scripts/open-in-view-warning.sh` |
|
||||
| `09-borrow-sql.txt` | the exact SQL one borrow sends, including `where id=? and version=?` | `scripts/sql-trace.sh` |
|
||||
|
||||
Timing-dependent detail: in `05` the 7 refused borrows split between "optimistic-lock failure"
|
||||
(a thread read the book before the winner committed) and "any other reason" (a thread started late
|
||||
and found no copy left). Over repeated runs I saw splits ranging from 7/0 to 4/3, which is why the
|
||||
transcript counts them separately. The invariants (1 winner, 1 loan, 0 copies left) are asserted.
|
||||
@@ -0,0 +1,33 @@
|
||||
> add-book 978-0134685991|Effective Java|Joshua Bloch|2
|
||||
Added book: Effective Java (2 copies)
|
||||
> add-book 978-0596009205|Head First Java|Kathy Sierra|1
|
||||
Added book: Head First Java (1 copy)
|
||||
> add-book 978-0134685991|Effective Java|Joshua Bloch|2
|
||||
ERROR: ISBN 978-0134685991 already exists
|
||||
> add-member Asha
|
||||
Added member #1: Asha
|
||||
> add-member Ravi
|
||||
Added member #2: Ravi
|
||||
> borrow 978-0596009205 1 2026-11-01
|
||||
Loan #1: Asha borrowed 'Head First Java', due 2026-11-15
|
||||
> borrow 978-0596009205 2 2026-11-01
|
||||
ERROR: no copies of 'Head First Java' are available
|
||||
> borrow 978-0134685991 1 2026-11-01
|
||||
Loan #2: Asha borrowed 'Effective Java', due 2026-11-15
|
||||
> report
|
||||
Effective Java by Joshua Bloch: 1 available
|
||||
Head First Java by Kathy Sierra: 0 available
|
||||
ON LOAN #1: 'Head First Java' -> Asha, due 2026-11-15
|
||||
ON LOAN #2: 'Effective Java' -> Asha, due 2026-11-15
|
||||
> return 1 2026-11-20
|
||||
Loan #1 returned on 2026-11-20, fine Rs 10
|
||||
> return 1 2026-11-21
|
||||
ERROR: loan #1 was already returned on 2026-11-20
|
||||
> borrow 978-0596009205 2 2026-11-21
|
||||
Loan #3: Ravi borrowed 'Head First Java', due 2026-12-05
|
||||
> report
|
||||
Effective Java by Joshua Bloch: 1 available
|
||||
Head First Java by Kathy Sierra: 0 available
|
||||
ON LOAN #2: 'Effective Java' -> Asha, due 2026-11-15
|
||||
ON LOAN #3: 'Head First Java' -> Ravi, due 2026-12-05
|
||||
> quit
|
||||
@@ -0,0 +1,6 @@
|
||||
borrowed 2026-11-01, due 2026-11-15 (14 days)
|
||||
returned 2026-11-12 ( -3 days vs due) -> fine Rs 0
|
||||
returned 2026-11-15 ( +0 days vs due) -> fine Rs 0
|
||||
returned 2026-11-16 ( +1 days vs due) -> fine Rs 2
|
||||
returned 2026-11-20 ( +5 days vs due) -> fine Rs 10
|
||||
returned 2026-12-15 (+30 days vs due) -> fine Rs 60
|
||||
@@ -0,0 +1,57 @@
|
||||
> POST /api/books {"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2}
|
||||
< 201 {"id":1,"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","totalCopies":2,"availableCopies":2}
|
||||
|
||||
> POST /api/books {"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2}
|
||||
< 409 {"detail":"ISBN 978-0134685991 already exists","instance":"/api/books","status":409,"title":"Conflict"}
|
||||
|
||||
> POST /api/books {"isbn":"978-0000000000","title":"","author":"Nobody","copies":0}
|
||||
< 400 {"detail":"request body failed validation","instance":"/api/books","status":400,"title":"Bad Request","errors":{"copies":"must be greater than or equal to 1","title":"must not be blank"}}
|
||||
|
||||
> POST /api/books {"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","copies":1}
|
||||
< 201 {"id":2,"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","totalCopies":1,"availableCopies":1}
|
||||
|
||||
> POST /api/books {"isbn":"978-0132350884","title":"Clean Code","author":"Robert Martin","copies":1}
|
||||
< 201 {"id":3,"isbn":"978-0132350884","title":"Clean Code","author":"Robert Martin","totalCopies":1,"availableCopies":1}
|
||||
|
||||
> POST /api/books {"isbn":"978-0321356680","title":"Effective Java 2e","author":"Joshua Bloch","copies":1}
|
||||
< 201 {"id":4,"isbn":"978-0321356680","title":"Effective Java 2e","author":"Joshua Bloch","totalCopies":1,"availableCopies":1}
|
||||
|
||||
> POST /api/members {"name":"Asha","email":"[email protected]"}
|
||||
< 201 {"id":1,"name":"Asha","email":"[email protected]"}
|
||||
|
||||
> POST /api/members {"name":"Ravi","email":"[email protected]"}
|
||||
< 201 {"id":2,"name":"Ravi","email":"[email protected]"}
|
||||
|
||||
> POST /api/loans {"memberId":1,"bookId":2}
|
||||
< 201 {"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0}
|
||||
|
||||
> POST /api/loans {"memberId":2,"bookId":2}
|
||||
< 409 {"detail":"no copies of 'Head First Java' are available","instance":"/api/loans","status":409,"title":"Conflict"}
|
||||
|
||||
> POST /api/loans {"memberId":99,"bookId":1}
|
||||
< 404 {"detail":"no member with id 99","instance":"/api/loans","status":404,"title":"Not Found"}
|
||||
|
||||
> POST /api/loans/1/return {"returnedOn":"2026-11-20"}
|
||||
< 200 {"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":"2026-11-20","fine":10}
|
||||
|
||||
> POST /api/loans/1/return {"returnedOn":"2026-11-21"}
|
||||
< 409 {"detail":"loan 1 was already returned on 2026-11-20","instance":"/api/loans/1/return","status":409,"title":"Conflict"}
|
||||
|
||||
> POST /api/loans {"memberId":1,"bookId":1}
|
||||
< 201 {"id":2,"bookTitle":"Effective Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0}
|
||||
|
||||
> POST /api/loans {"memberId":1,"bookId":3}
|
||||
< 201 {"id":3,"bookTitle":"Clean Code","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0}
|
||||
|
||||
> POST /api/loans {"memberId":1,"bookId":4}
|
||||
< 201 {"id":4,"bookTitle":"Effective Java 2e","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0}
|
||||
|
||||
> POST /api/loans {"memberId":1,"bookId":2}
|
||||
< 422 {"detail":"Asha already has 3 active loans (max 3)","instance":"/api/loans","status":422,"title":"Unprocessable Content"}
|
||||
|
||||
> GET /api/books
|
||||
< 200 [{"id":1,"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","totalCopies":2,"availableCopies":1},{"id":2,"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","totalCopies":1,"availableCopies":1},{"id":3,"isbn":"978-0132350884","title":"Clean Code","author":"Robert Martin","totalCopies":1,"availableCopies":0},{"id":4,"isbn":"978-0321356680","title":"Effective Java 2e","author":"Joshua Bloch","totalCopies":1,"availableCopies":0}]
|
||||
|
||||
> GET /api/loans
|
||||
< 200 [{"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":"2026-11-20","fine":10},{"id":2,"bookTitle":"Effective Java","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0},{"id":3,"bookTitle":"Clean Code","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0},{"id":4,"bookTitle":"Effective Java 2e","memberName":"Asha","borrowedOn":"2026-11-01","dueOn":"2026-11-15","returnedOn":null,"fine":0}]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
5 loans, each with its own book and its own member
|
||||
findAllByOrderByIdAsc() then read book title + member name: 11 SQL statements
|
||||
findAllWithDetails() (join fetch) then the same reads: 1 SQL statement
|
||||
LoanService.listLoans() as called by GET /api/loans: 1 SQL statement
|
||||
@@ -0,0 +1,6 @@
|
||||
8 threads borrow the last copy at once (Book has @Version)
|
||||
successful borrows : 1
|
||||
refused by optimistic-lock failure : 6
|
||||
refused for any other reason : 1
|
||||
loan rows in DB : 1
|
||||
availableCopies : 0
|
||||
@@ -0,0 +1,4 @@
|
||||
8 threads borrow the last copy at once (read, check, write: no @Version)
|
||||
successful borrows : 8
|
||||
loan rows in DB : 8 <- one copy, 8 loans
|
||||
availableCopies : 0 <- the counter says everything is fine
|
||||
@@ -0,0 +1,40 @@
|
||||
> POST /books {"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2}
|
||||
{"id":1,"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","totalCopies":2,"availableCopies":2}
|
||||
< HTTP 201
|
||||
|
||||
> POST /books {"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","copies":1}
|
||||
{"id":2,"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","totalCopies":1,"availableCopies":1}
|
||||
< HTTP 201
|
||||
|
||||
> POST /books {"isbn":"","title":"","author":"Nobody","copies":0}
|
||||
{"detail":"request body failed validation","instance":"/api/books","status":400,"title":"Bad Request","errors":{"copies":"must be greater than or equal to 1","isbn":"must not be blank","title":"must not be blank"}}
|
||||
< HTTP 400
|
||||
|
||||
> POST /members {"name":"Asha","email":"[email protected]"}
|
||||
{"id":1,"name":"Asha","email":"[email protected]"}
|
||||
< HTTP 201
|
||||
|
||||
> POST /members {"name":"Ravi","email":"not-an-email"}
|
||||
{"detail":"request body failed validation","instance":"/api/members","status":400,"title":"Bad Request","errors":{"email":"must be a well-formed email address"}}
|
||||
< HTTP 400
|
||||
|
||||
> POST /members {"name":"Ravi","email":"[email protected]"}
|
||||
{"id":2,"name":"Ravi","email":"[email protected]"}
|
||||
< HTTP 201
|
||||
|
||||
> POST /loans {"memberId":1,"bookId":2}
|
||||
{"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-14","dueOn":"2026-11-28","returnedOn":null,"fine":0}
|
||||
< HTTP 201
|
||||
|
||||
> POST /loans {"memberId":2,"bookId":2}
|
||||
{"detail":"no copies of 'Head First Java' are available","instance":"/api/loans","status":409,"title":"Conflict"}
|
||||
< HTTP 409
|
||||
|
||||
> POST /loans/1/return
|
||||
{"id":1,"bookTitle":"Head First Java","memberName":"Asha","borrowedOn":"2026-11-14","dueOn":"2026-11-28","returnedOn":"2026-11-14","fine":0}
|
||||
< HTTP 200
|
||||
|
||||
> GET /books
|
||||
[{"id":1,"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","totalCopies":2,"availableCopies":2},{"id":2,"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","totalCopies":1,"availableCopies":1}]
|
||||
< HTTP 200
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
WARN 2278 --- [ main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
|
||||
INFO 2278 --- [ main] c.ankurm.library.api.LibraryApplication : Started LibraryApplication in 5.911 seconds (process running for 6.599)
|
||||
@@ -0,0 +1,6 @@
|
||||
select m1_0.id,m1_0.email,m1_0.name from member m1_0 where m1_0.id=?
|
||||
select b1_0.id,b1_0.author,b1_0.available_copies,b1_0.isbn,b1_0.title,b1_0.total_copies,b1_0.version from book b1_0 where b1_0.id=?
|
||||
select l1_0.id from loan l1_0 where l1_0.member_id=? and l1_0.book_id=? and l1_0.returned_on is null fetch first ? rows only
|
||||
select count(l1_0.id) from loan l1_0 where l1_0.member_id=? and l1_0.returned_on is null
|
||||
insert into loan (book_id,borrowed_on,due_on,fine,member_id,returned_on,id) values (?,?,?,?,?,?,default)
|
||||
update book set author=?,available_copies=?,isbn=?,title=?,total_copies=?,version=? where id=? and version=?
|
||||
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.2</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm.interviewlab</groupId>
|
||||
<artifactId>library-management</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>library-management</name>
|
||||
<description>Library management: a plain-Java console app evolved into a Spring Boot 4 + JPA REST API</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Drives a running API (scripts/run.sh --detach) with curl: the same story as the console demo,
|
||||
# against a real servlet container. Dates in the output are "today", so they differ per run.
|
||||
set -uo pipefail
|
||||
BASE=${BASE:-http://localhost:8080/api}
|
||||
call() { # method path [json]
|
||||
echo "> $1 $2${3:+ $3}"
|
||||
if [ -n "${3:-}" ]; then
|
||||
curl -s -X "$1" "$BASE$2" -H 'Content-Type: application/json' -d "$3" -w '\n< HTTP %{http_code}\n\n'
|
||||
else
|
||||
curl -s -X "$1" "$BASE$2" -w '\n< HTTP %{http_code}\n\n'
|
||||
fi
|
||||
}
|
||||
call POST /books '{"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2}'
|
||||
call POST /books '{"isbn":"978-0596009205","title":"Head First Java","author":"Kathy Sierra","copies":1}'
|
||||
call POST /books '{"isbn":"","title":"","author":"Nobody","copies":0}'
|
||||
call POST /members '{"name":"Asha","email":"[email protected]"}'
|
||||
call POST /members '{"name":"Ravi","email":"not-an-email"}'
|
||||
call POST /members '{"name":"Ravi","email":"[email protected]"}'
|
||||
call POST /loans '{"memberId":1,"bookId":2}'
|
||||
call POST /loans '{"memberId":2,"bookId":2}'
|
||||
call POST /loans/1/return
|
||||
call GET /books
|
||||
@@ -0,0 +1,14 @@
|
||||
add-book 978-0134685991|Effective Java|Joshua Bloch|2
|
||||
add-book 978-0596009205|Head First Java|Kathy Sierra|1
|
||||
add-book 978-0134685991|Effective Java|Joshua Bloch|2
|
||||
add-member Asha
|
||||
add-member Ravi
|
||||
borrow 978-0596009205 1 2026-11-01
|
||||
borrow 978-0596009205 2 2026-11-01
|
||||
borrow 978-0134685991 1 2026-11-01
|
||||
report
|
||||
return 1 2026-11-20
|
||||
return 1 2026-11-21
|
||||
borrow 978-0596009205 2 2026-11-21
|
||||
report
|
||||
quit
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starts the API with NO application.properties, to capture the warning Spring Boot prints when
|
||||
# spring.jpa.open-in-view is left at its default. Our real config sets it to false explicitly.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mvn -B -q -DskipTests package
|
||||
mkdir -p /tmp/empty-config
|
||||
timeout 25 java -jar target/library-management-1.0.0.jar --spring.config.location=file:/tmp/empty-config/ --server.port=0 2>&1 \
|
||||
| grep -E 'open-in-view|Started LibraryApplication' | sed -E 's/^[0-9T:.+-]+ +//' || true
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file in output/. Needs JDK 25 and Maven; the first run must be online.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
rm -f output/*.txt
|
||||
|
||||
# 01: the single-file console version (the copy attached to the article lives in the WordPress media library)
|
||||
mkdir -p /tmp/lm && cp scripts/demo-input.txt /tmp/lm/
|
||||
echo "01 console (structured) ..."
|
||||
mvn -B -q -DskipTests compile
|
||||
java -cp target/classes com.ankurm.library.console.ConsoleApp < scripts/demo-input.txt 2>/dev/null > output/01-console-structured.txt
|
||||
|
||||
# 02-06: written by the tests themselves, so each file is also an assertion
|
||||
echo "02-06 tests ..."
|
||||
mvn -B -q test
|
||||
|
||||
# 07: the real server, driven with curl
|
||||
echo "07 curl demo ..."
|
||||
scripts/run.sh --detach
|
||||
scripts/api-demo.sh > output/07-curl-demo.txt
|
||||
scripts/stop.sh
|
||||
|
||||
# 08: the Boot default we override
|
||||
echo "08 open-in-view warning ..."
|
||||
scripts/open-in-view-warning.sh > output/08-open-in-view-warning.txt
|
||||
|
||||
# 09: the SQL one borrow produces
|
||||
echo "09 borrow SQL ..."
|
||||
scripts/sql-trace.sh > output/09-borrow-sql.txt
|
||||
ls output
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starts the REST API on :8080 (in-memory H2, so every start is a clean library).
|
||||
# Usage: scripts/run.sh foreground
|
||||
# scripts/run.sh --detach background, waits until /api/books answers
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mvn -B -q -DskipTests package
|
||||
if [ "${1:-}" = "--detach" ]; then
|
||||
setsid nohup java -jar target/library-management-1.0.0.jar > /tmp/library-api.log 2>&1 < /dev/null &
|
||||
for _ in $(seq 1 60); do curl -s -o /dev/null localhost:8080/api/books && exit 0; sleep 1; done
|
||||
echo "API did not start; see /tmp/library-api.log" >&2; exit 1
|
||||
fi
|
||||
exec java -jar target/library-management-1.0.0.jar
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starts the API with Hibernate SQL logging, performs one borrow, and prints only the SQL that
|
||||
# the borrow produced. Look for "version" in the UPDATE: that is optimistic locking at work.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mvn -B -q -DskipTests package
|
||||
LOG=/tmp/library-sql.log
|
||||
setsid nohup java -jar target/library-management-1.0.0.jar --server.port=8081 --logging.level.org.hibernate.SQL=DEBUG > "$LOG" 2>&1 < /dev/null &
|
||||
for _ in $(seq 1 60); do curl -s -o /dev/null localhost:8081/api/books && break; sleep 1; done
|
||||
J='Content-Type: application/json'
|
||||
curl -s -o /dev/null -H "$J" -d '{"isbn":"978-0134685991","title":"Effective Java","author":"Joshua Bloch","copies":2}' localhost:8081/api/books
|
||||
curl -s -o /dev/null -H "$J" -d '{"name":"Asha","email":"[email protected]"}' localhost:8081/api/members
|
||||
START=$(wc -l < "$LOG")
|
||||
curl -s -o /dev/null -H "$J" -d '{"memberId":1,"bookId":1}' localhost:8081/api/loans
|
||||
sleep 1
|
||||
tail -n +"$((START + 1))" "$LOG" | grep 'org.hibernate.SQL' | sed -E 's/^.*org.hibernate.SQL +: //'
|
||||
scripts/stop.sh
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stops the API started by run.sh --detach. Only processes whose command is "java" match, so
|
||||
# this script can never match (and kill) the shell that is running it.
|
||||
for p in $(ps -eo pid,comm,args | awk '$2 == "java" && /library-management-1.0.0.jar/ {print $1}'); do kill "$p"; done
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import com.ankurm.library.api.Exceptions.*;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.orm.ObjectOptimisticLockingFailureException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** Turns exceptions into RFC 9457 problem+json bodies instead of stack traces. */
|
||||
@RestControllerAdvice
|
||||
class ApiExceptionHandler {
|
||||
|
||||
/** 400: names each bad field. Without this the 400 has an empty body in MockMvc. */
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
ProblemDetail invalid(MethodArgumentNotValidException e) {
|
||||
Map<String, String> errors = new TreeMap<>();
|
||||
e.getBindingResult().getFieldErrors().forEach(f -> errors.putIfAbsent(f.getField(), f.getDefaultMessage()));
|
||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "request body failed validation");
|
||||
problem.setProperty("errors", errors);
|
||||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(NotFoundException.class)
|
||||
ProblemDetail notFound(NotFoundException e) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConflictException.class)
|
||||
ProblemDetail conflict(ConflictException e) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(RuleViolationException.class)
|
||||
ProblemDetail rule(RuleViolationException e) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, e.getMessage());
|
||||
}
|
||||
|
||||
/** Two people changed the same Book row at once; the loser is told to retry. */
|
||||
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
|
||||
ProblemDetail raced(ObjectOptimisticLockingFailureException e) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
|
||||
"someone else changed this book at the same moment, please retry");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
* A title in the catalogue with a count of copies. The {@code @Version} column is what stops two
|
||||
* people borrowing the last copy at the same moment: see BorrowRaceTest.
|
||||
*/
|
||||
@Entity
|
||||
public class Book {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
private String isbn;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String author;
|
||||
|
||||
private int totalCopies;
|
||||
private int availableCopies;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
protected Book() {
|
||||
}
|
||||
|
||||
public Book(String isbn, String title, String author, int copies) {
|
||||
this.isbn = isbn;
|
||||
this.title = title;
|
||||
this.author = author;
|
||||
this.totalCopies = copies;
|
||||
this.availableCopies = copies;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public String getIsbn() { return isbn; }
|
||||
public String getTitle() { return title; }
|
||||
public String getAuthor() { return author; }
|
||||
public int getTotalCopies() { return totalCopies; }
|
||||
public int getAvailableCopies() { return availableCopies; }
|
||||
public long getVersion() { return version; }
|
||||
|
||||
void checkOut() { availableCopies--; }
|
||||
void checkIn() { availableCopies++; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** Request and response shapes. Entities never leave the service layer. */
|
||||
public final class Dtos {
|
||||
|
||||
private Dtos() {
|
||||
}
|
||||
|
||||
public record BookRequest(@NotBlank String isbn, @NotBlank String title, @NotBlank String author,
|
||||
@Min(1) int copies) {
|
||||
}
|
||||
|
||||
public record BookView(Long id, String isbn, String title, String author, int totalCopies,
|
||||
int availableCopies) {
|
||||
static BookView of(Book b) {
|
||||
return new BookView(b.getId(), b.getIsbn(), b.getTitle(), b.getAuthor(), b.getTotalCopies(),
|
||||
b.getAvailableCopies());
|
||||
}
|
||||
}
|
||||
|
||||
public record MemberRequest(@NotBlank String name, @NotBlank @Email String email) {
|
||||
}
|
||||
|
||||
public record MemberView(Long id, String name, String email) {
|
||||
static MemberView of(Member m) {
|
||||
return new MemberView(m.getId(), m.getName(), m.getEmail());
|
||||
}
|
||||
}
|
||||
|
||||
public record BorrowRequest(@NotNull Long memberId, @NotNull Long bookId) {
|
||||
}
|
||||
|
||||
/** returnedOn is optional: omit it to return "today", supply it to back-date a return. */
|
||||
public record ReturnRequest(LocalDate returnedOn) {
|
||||
}
|
||||
|
||||
public record LoanView(Long id, String bookTitle, String memberName, LocalDate borrowedOn, LocalDate dueOn,
|
||||
LocalDate returnedOn, int fine) {
|
||||
static LoanView of(Loan l) {
|
||||
return new LoanView(l.getId(), l.getBook().getTitle(), l.getMember().getName(), l.getBorrowedOn(),
|
||||
l.getDueOn(), l.getReturnedOn(), l.getFine());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
/** Business-rule failures, each mapped to an HTTP status in ApiExceptionHandler. */
|
||||
public final class Exceptions {
|
||||
|
||||
private Exceptions() {
|
||||
}
|
||||
|
||||
/** 404: the thing the caller named does not exist. */
|
||||
public static class NotFoundException extends RuntimeException {
|
||||
public NotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 409: the request is fine, but the current state of the library says no. */
|
||||
public static class ConflictException extends RuntimeException {
|
||||
public ConflictException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 422: the member is not allowed to do this under the lending rules. */
|
||||
public static class RuleViolationException extends RuntimeException {
|
||||
public RuleViolationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import java.time.Clock;
|
||||
|
||||
/** Stage 3 entry point: {@code mvn spring-boot:run} serves the REST API on port 8080. */
|
||||
@SpringBootApplication
|
||||
public class LibraryApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(LibraryApplication.class, args);
|
||||
}
|
||||
|
||||
/** Injected rather than calling LocalDate.now() so tests can control "today". */
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Clock.systemDefaultZone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import com.ankurm.library.api.Dtos.*;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class LibraryController {
|
||||
|
||||
private final LoanService service;
|
||||
|
||||
LibraryController(LoanService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@PostMapping("/books")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
BookView addBook(@Valid @RequestBody BookRequest request) {
|
||||
return service.addBook(request);
|
||||
}
|
||||
|
||||
@GetMapping("/books")
|
||||
List<BookView> books() {
|
||||
return service.listBooks();
|
||||
}
|
||||
|
||||
@PostMapping("/members")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
MemberView addMember(@Valid @RequestBody MemberRequest request) {
|
||||
return service.addMember(request);
|
||||
}
|
||||
|
||||
@PostMapping("/loans")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
LoanView borrow(@Valid @RequestBody BorrowRequest request) {
|
||||
return service.borrow(request);
|
||||
}
|
||||
|
||||
@PostMapping("/loans/{id}/return")
|
||||
LoanView giveBack(@PathVariable Long id, @RequestBody(required = false) ReturnRequest request) {
|
||||
return service.giveBack(id, request == null ? null : request.returnedOn());
|
||||
}
|
||||
|
||||
@GetMapping("/loans")
|
||||
List<LoanView> loans() {
|
||||
return service.listLoans();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** One member borrowing one copy of one book. Both associations are LAZY on purpose. */
|
||||
@Entity
|
||||
public class Loan {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
private Book book;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
private Member member;
|
||||
|
||||
private LocalDate borrowedOn;
|
||||
private LocalDate dueOn;
|
||||
private LocalDate returnedOn;
|
||||
private int fine;
|
||||
|
||||
protected Loan() {
|
||||
}
|
||||
|
||||
public Loan(Book book, Member member, LocalDate borrowedOn, LocalDate dueOn) {
|
||||
this.book = book;
|
||||
this.member = member;
|
||||
this.borrowedOn = borrowedOn;
|
||||
this.dueOn = dueOn;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public Book getBook() { return book; }
|
||||
public Member getMember() { return member; }
|
||||
public LocalDate getBorrowedOn() { return borrowedOn; }
|
||||
public LocalDate getDueOn() { return dueOn; }
|
||||
public LocalDate getReturnedOn() { return returnedOn; }
|
||||
public int getFine() { return fine; }
|
||||
|
||||
void markReturned(LocalDate on, int fine) {
|
||||
this.returnedOn = on;
|
||||
this.fine = fine;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import com.ankurm.library.api.Dtos.*;
|
||||
import com.ankurm.library.api.Exceptions.*;
|
||||
import com.ankurm.library.core.LoanPolicy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/** The same four operations as the console Library, now on top of a database. */
|
||||
@Service
|
||||
public class LoanService {
|
||||
|
||||
private final BookRepository books;
|
||||
private final MemberRepository members;
|
||||
private final LoanRepository loans;
|
||||
private final Clock clock;
|
||||
|
||||
LoanService(BookRepository books, MemberRepository members, LoanRepository loans, Clock clock) {
|
||||
this.books = books;
|
||||
this.members = members;
|
||||
this.loans = loans;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BookView addBook(BookRequest r) {
|
||||
if (books.existsByIsbn(r.isbn())) {
|
||||
throw new ConflictException("ISBN " + r.isbn() + " already exists");
|
||||
}
|
||||
return BookView.of(books.save(new Book(r.isbn(), r.title(), r.author(), r.copies())));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MemberView addMember(MemberRequest r) {
|
||||
if (members.existsByEmail(r.email())) {
|
||||
throw new ConflictException("email " + r.email() + " is already registered");
|
||||
}
|
||||
return MemberView.of(members.save(new Member(r.name(), r.email())));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<BookView> listBooks() {
|
||||
return books.findAll().stream().map(BookView::of).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LoanView borrow(BorrowRequest r) {
|
||||
Member member = members.findById(r.memberId())
|
||||
.orElseThrow(() -> new NotFoundException("no member with id " + r.memberId()));
|
||||
Book book = books.findById(r.bookId())
|
||||
.orElseThrow(() -> new NotFoundException("no book with id " + r.bookId()));
|
||||
if (book.getAvailableCopies() == 0) {
|
||||
throw new ConflictException("no copies of '" + book.getTitle() + "' are available");
|
||||
}
|
||||
if (loans.existsByMemberIdAndBookIdAndReturnedOnIsNull(member.getId(), book.getId())) {
|
||||
throw new RuleViolationException(member.getName() + " already has '" + book.getTitle() + "' on loan");
|
||||
}
|
||||
long active = loans.countByMemberIdAndReturnedOnIsNull(member.getId());
|
||||
if (!LoanPolicy.mayBorrow((int) active)) {
|
||||
throw new RuleViolationException(member.getName() + " already has " + active
|
||||
+ " active loans (max " + LoanPolicy.MAX_ACTIVE_LOANS + ")");
|
||||
}
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
book.checkOut(); // dirty checking turns this into UPDATE book ... WHERE id=? AND version=?
|
||||
return LoanView.of(loans.save(new Loan(book, member, today, LoanPolicy.dueOn(today))));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LoanView giveBack(Long loanId, LocalDate returnedOn) {
|
||||
Loan loan = loans.findById(loanId).orElseThrow(() -> new NotFoundException("no loan with id " + loanId));
|
||||
if (loan.getReturnedOn() != null) {
|
||||
throw new ConflictException("loan " + loanId + " was already returned on " + loan.getReturnedOn());
|
||||
}
|
||||
LocalDate on = returnedOn != null ? returnedOn : LocalDate.now(clock);
|
||||
if (on.isBefore(loan.getBorrowedOn())) {
|
||||
throw new RuleViolationException("cannot return on " + on + ", before it was borrowed on "
|
||||
+ loan.getBorrowedOn());
|
||||
}
|
||||
loan.getBook().checkIn();
|
||||
loan.markReturned(on, LoanPolicy.fine(loan.getDueOn(), on));
|
||||
return LoanView.of(loan);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<LoanView> listLoans() {
|
||||
return loans.findAllWithDetails().stream().map(LoanView::of).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
public class Member {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
private String email;
|
||||
|
||||
protected Member() {
|
||||
}
|
||||
|
||||
public Member(String name, String email) {
|
||||
this.name = name;
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public String getName() { return name; }
|
||||
public String getEmail() { return email; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
interface BookRepository extends JpaRepository<Book, Long> {
|
||||
boolean existsByIsbn(String isbn);
|
||||
}
|
||||
|
||||
interface MemberRepository extends JpaRepository<Member, Long> {
|
||||
boolean existsByEmail(String email);
|
||||
}
|
||||
|
||||
interface LoanRepository extends JpaRepository<Loan, Long> {
|
||||
|
||||
long countByMemberIdAndReturnedOnIsNull(Long memberId);
|
||||
|
||||
boolean existsByMemberIdAndBookIdAndReturnedOnIsNull(Long memberId, Long bookId);
|
||||
|
||||
/** The innocent-looking version: loans only, book and member are lazy proxies. */
|
||||
List<Loan> findAllByOrderByIdAsc();
|
||||
|
||||
/** The same list in one SQL statement: JOIN FETCH pulls book and member in with each loan. */
|
||||
@Query("select l from Loan l join fetch l.book join fetch l.member order by l.id")
|
||||
List<Loan> findAllWithDetails();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ankurm.library.console;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Scanner;
|
||||
|
||||
/**
|
||||
* Stage 2 entry point. Reads one command per line from stdin, so a demo run is reproducible:
|
||||
* {@code java -cp target/classes com.ankurm.library.console.ConsoleApp < scripts/demo-input.txt}
|
||||
*
|
||||
* <pre>
|
||||
* add-book isbn|title|author|copies
|
||||
* add-member name
|
||||
* borrow isbn memberId [yyyy-mm-dd]
|
||||
* return loanId [yyyy-mm-dd]
|
||||
* report
|
||||
* quit
|
||||
* </pre>
|
||||
*/
|
||||
public class ConsoleApp {
|
||||
|
||||
public static void main(String[] args) {
|
||||
Library library = new Library();
|
||||
Scanner in = new Scanner(System.in);
|
||||
while (in.hasNextLine()) {
|
||||
String line = in.nextLine().trim();
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
System.out.println("> " + line);
|
||||
String[] cmd = line.split(" ", 2);
|
||||
String rest = cmd.length > 1 ? cmd[1] : "";
|
||||
try {
|
||||
switch (cmd[0]) {
|
||||
case "add-book" -> {
|
||||
String[] p = rest.split("\\|");
|
||||
System.out.println(library.addBook(p[0], p[1], p[2], Integer.parseInt(p[3])));
|
||||
}
|
||||
case "add-member" -> System.out.println(library.addMember(rest));
|
||||
case "borrow" -> {
|
||||
String[] p = rest.split(" ");
|
||||
System.out.println(library.borrow(p[0], Integer.parseInt(p[1]), dateOrToday(p, 2)));
|
||||
}
|
||||
case "return" -> {
|
||||
String[] p = rest.split(" ");
|
||||
System.out.println(library.giveBack(Integer.parseInt(p[0]), dateOrToday(p, 1)));
|
||||
}
|
||||
case "report" -> library.report().forEach(System.out::println);
|
||||
case "quit" -> {
|
||||
return;
|
||||
}
|
||||
default -> System.out.println("ERROR: unknown command '" + cmd[0] + "'");
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
System.out.println("ERROR: bad input (" + e.getClass().getSimpleName() + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDate dateOrToday(String[] parts, int index) {
|
||||
return parts.length > index ? LocalDate.parse(parts[index]) : LocalDate.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.ankurm.library.console;
|
||||
|
||||
import com.ankurm.library.core.LoanPolicy;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Stage 2: the console app's domain, split out of the single-file version.
|
||||
* Everything lives in memory in plain collections; the rules come from {@link LoanPolicy}.
|
||||
*/
|
||||
public class Library {
|
||||
|
||||
public static class Book {
|
||||
final String isbn;
|
||||
final String title;
|
||||
final String author;
|
||||
int availableCopies;
|
||||
|
||||
Book(String isbn, String title, String author, int copies) {
|
||||
this.isbn = isbn;
|
||||
this.title = title;
|
||||
this.author = author;
|
||||
this.availableCopies = copies;
|
||||
}
|
||||
}
|
||||
|
||||
public record Member(int id, String name) {
|
||||
}
|
||||
|
||||
public static class Loan {
|
||||
final int id;
|
||||
final Book book;
|
||||
final Member member;
|
||||
final LocalDate borrowedOn;
|
||||
final LocalDate dueOn;
|
||||
LocalDate returnedOn;
|
||||
|
||||
Loan(int id, Book book, Member member, LocalDate borrowedOn) {
|
||||
this.id = id;
|
||||
this.book = book;
|
||||
this.member = member;
|
||||
this.borrowedOn = borrowedOn;
|
||||
this.dueOn = LoanPolicy.dueOn(borrowedOn);
|
||||
}
|
||||
|
||||
boolean active() {
|
||||
return returnedOn == null;
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<String, Book> books = new LinkedHashMap<>();
|
||||
private final Map<Integer, Member> members = new LinkedHashMap<>();
|
||||
private final List<Loan> loans = new ArrayList<>();
|
||||
|
||||
public String addBook(String isbn, String title, String author, int copies) {
|
||||
if (books.containsKey(isbn)) {
|
||||
return "ERROR: ISBN " + isbn + " already exists";
|
||||
}
|
||||
books.put(isbn, new Book(isbn, title, author, copies));
|
||||
return "Added book: " + title + " (" + copies + (copies == 1 ? " copy)" : " copies)");
|
||||
}
|
||||
|
||||
public String addMember(String name) {
|
||||
int id = members.size() + 1;
|
||||
members.put(id, new Member(id, name));
|
||||
return "Added member #" + id + ": " + name;
|
||||
}
|
||||
|
||||
public String borrow(String isbn, int memberId, LocalDate on) {
|
||||
Book book = books.get(isbn);
|
||||
Member member = members.get(memberId);
|
||||
if (book == null) {
|
||||
return "ERROR: no such book " + isbn;
|
||||
}
|
||||
if (member == null) {
|
||||
return "ERROR: no such member #" + memberId;
|
||||
}
|
||||
if (book.availableCopies == 0) {
|
||||
return "ERROR: no copies of '" + book.title + "' are available";
|
||||
}
|
||||
long active = loans.stream().filter(l -> l.active() && l.member.equals(member)).count();
|
||||
if (!LoanPolicy.mayBorrow((int) active)) {
|
||||
return "ERROR: " + member.name() + " already has " + active + " active loans (max "
|
||||
+ LoanPolicy.MAX_ACTIVE_LOANS + ")";
|
||||
}
|
||||
Loan loan = new Loan(loans.size() + 1, book, member, on);
|
||||
loans.add(loan);
|
||||
book.availableCopies--;
|
||||
return "Loan #" + loan.id + ": " + member.name() + " borrowed '" + book.title + "', due " + loan.dueOn;
|
||||
}
|
||||
|
||||
public String giveBack(int loanId, LocalDate on) {
|
||||
if (loanId < 1 || loanId > loans.size()) {
|
||||
return "ERROR: no such loan #" + loanId;
|
||||
}
|
||||
Loan loan = loans.get(loanId - 1);
|
||||
if (!loan.active()) {
|
||||
return "ERROR: loan #" + loanId + " was already returned on " + loan.returnedOn;
|
||||
}
|
||||
loan.returnedOn = on;
|
||||
loan.book.availableCopies++;
|
||||
int fine = LoanPolicy.fine(loan.dueOn, on);
|
||||
return "Loan #" + loanId + " returned on " + on + (fine > 0 ? ", fine Rs " + fine : ", no fine");
|
||||
}
|
||||
|
||||
public List<String> report() {
|
||||
List<String> lines = new ArrayList<>();
|
||||
books.values().forEach(b -> lines.add(" " + b.title + " by " + b.author + ": " + b.availableCopies
|
||||
+ " available"));
|
||||
loans.stream().filter(Loan::active).forEach(l -> lines.add(" ON LOAN #" + l.id + ": '" + l.book.title
|
||||
+ "' -> " + l.member.name() + ", due " + l.dueOn));
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ankurm.library.core;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
/**
|
||||
* The library's lending rules, as pure functions with no framework in sight.
|
||||
*
|
||||
* <p>This is the one class that survives the move from the console app to the Spring Boot API
|
||||
* completely unchanged. Both {@code console.Library} and {@code api.LoanService} call it.
|
||||
*/
|
||||
public final class LoanPolicy {
|
||||
|
||||
public static final int LOAN_DAYS = 14;
|
||||
public static final int MAX_ACTIVE_LOANS = 3;
|
||||
public static final int FINE_PER_DAY = 2; // rupees
|
||||
|
||||
private LoanPolicy() {
|
||||
}
|
||||
|
||||
public static LocalDate dueOn(LocalDate borrowedOn) {
|
||||
return borrowedOn.plusDays(LOAN_DAYS);
|
||||
}
|
||||
|
||||
/** Fine in rupees: zero on or before the due date, then FINE_PER_DAY for each day late. */
|
||||
public static int fine(LocalDate dueOn, LocalDate returnedOn) {
|
||||
long daysLate = ChronoUnit.DAYS.between(dueOn, returnedOn);
|
||||
return daysLate > 0 ? (int) daysLate * FINE_PER_DAY : 0;
|
||||
}
|
||||
|
||||
public static boolean mayBorrow(int activeLoans) {
|
||||
return activeLoans < MAX_ACTIVE_LOANS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
spring.application.name=library-management
|
||||
spring.datasource.url=jdbc:h2:mem:library;DB_CLOSE_DELAY=-1
|
||||
spring.jpa.hibernate.ddl-auto=create
|
||||
spring.jpa.open-in-view=false
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import com.ankurm.library.api.Dtos.BorrowRequest;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Eight people try to borrow the one remaining copy at the same instant.
|
||||
* First with the real LoanService (Book has @Version), then with a version-less
|
||||
* read-check-write that does exactly what a JPA entity without @Version would do.
|
||||
*/
|
||||
@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:race")
|
||||
class BorrowRaceTest {
|
||||
|
||||
private static final int THREADS = 8;
|
||||
|
||||
@Autowired BookRepository books;
|
||||
@Autowired MemberRepository members;
|
||||
@Autowired LoanService service;
|
||||
@Autowired JdbcClient jdbc;
|
||||
@Autowired TransactionTemplate tx;
|
||||
|
||||
@Test
|
||||
void withVersionColumnExactlyOneBorrowerWins() throws Exception {
|
||||
Book book = books.save(new Book("race-1", "Last Copy", "Somebody", 1));
|
||||
List<Long> memberIds = seedMembers("v");
|
||||
|
||||
List<String> outcomes = runConcurrently(i -> service.borrow(new BorrowRequest(memberIds.get(i), book.getId())));
|
||||
|
||||
long wins = outcomes.stream().filter(o -> o.equals("OK")).count();
|
||||
long lost = outcomes.stream().filter(o -> o.equals("ObjectOptimisticLockingFailureException")).count();
|
||||
long loansInDb = jdbc.sql("select count(*) from loan where book_id = ?").param(book.getId()).query(Long.class).single();
|
||||
int available = books.findById(book.getId()).orElseThrow().getAvailableCopies();
|
||||
|
||||
assertEquals(1, wins);
|
||||
assertEquals(1, loansInDb);
|
||||
assertEquals(0, available);
|
||||
|
||||
new Transcript("05-race-with-version.txt")
|
||||
.line(THREADS + " threads borrow the last copy at once (Book has @Version)")
|
||||
.line(row("successful borrows", wins))
|
||||
.line(row("refused by optimistic-lock failure", lost))
|
||||
.line(row("refused for any other reason", THREADS - wins - lost))
|
||||
.line(row("loan rows in DB", loansInDb))
|
||||
.line(row("availableCopies", available))
|
||||
.save();
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutVersionEveryoneWinsAndTheCopyIsLent() throws Exception {
|
||||
long bookId = jdbc.sql("insert into book (isbn, title, author, total_copies, available_copies, version) "
|
||||
+ "values ('race-2', 'Naive Copy', 'Somebody', 1, 1, 0)").update() == 1
|
||||
? jdbc.sql("select id from book where isbn = 'race-2'").query(Long.class).single() : -1;
|
||||
List<Long> memberIds = seedMembers("n");
|
||||
CyclicBarrier readDone = new CyclicBarrier(THREADS);
|
||||
|
||||
List<String> outcomes = runConcurrently(i -> tx.executeWithoutResult(s -> {
|
||||
int available = jdbc.sql("select available_copies from book where id = ?").param(bookId).query(Integer.class).single();
|
||||
if (available == 0) {
|
||||
throw new IllegalStateException("none left");
|
||||
}
|
||||
await(readDone); // everyone has now read available = 1
|
||||
jdbc.sql("update book set available_copies = ? where id = ?").params(available - 1, bookId).update();
|
||||
jdbc.sql("insert into loan (book_id, member_id, borrowed_on, due_on, fine) values (?, ?, ?, ?, 0)")
|
||||
.params(bookId, memberIds.get(i), LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 15)).update();
|
||||
}));
|
||||
|
||||
long wins = outcomes.stream().filter(o -> o.equals("OK")).count();
|
||||
long loansInDb = jdbc.sql("select count(*) from loan where book_id = ?").param(bookId).query(Long.class).single();
|
||||
int available = jdbc.sql("select available_copies from book where id = ?").param(bookId).query(Integer.class).single();
|
||||
|
||||
assertEquals(THREADS, wins);
|
||||
assertEquals(THREADS, loansInDb);
|
||||
assertEquals(0, available);
|
||||
|
||||
new Transcript("06-race-without-version.txt")
|
||||
.line(THREADS + " threads borrow the last copy at once (read, check, write: no @Version)")
|
||||
.line("successful borrows : " + wins)
|
||||
.line("loan rows in DB : " + loansInDb + " <- one copy, " + loansInDb + " loans")
|
||||
.line("availableCopies : " + available + " <- the counter says everything is fine")
|
||||
.save();
|
||||
}
|
||||
|
||||
private static String row(String label, long value) {
|
||||
return String.format("%-36s: %d", label, value);
|
||||
}
|
||||
|
||||
private List<Long> seedMembers(String prefix) {
|
||||
List<Long> ids = new ArrayList<>();
|
||||
for (int i = 0; i < THREADS; i++) {
|
||||
ids.add(members.save(new Member(prefix + i, prefix + i + "@example.com")).getId());
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static void await(CyclicBarrier barrier) {
|
||||
try {
|
||||
barrier.await(10, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private interface Action {
|
||||
void run(int index) throws Exception;
|
||||
}
|
||||
|
||||
private List<String> runConcurrently(Action action) throws Exception {
|
||||
ExecutorService pool = Executors.newFixedThreadPool(THREADS);
|
||||
CyclicBarrier start = new CyclicBarrier(THREADS);
|
||||
List<Future<String>> futures = new ArrayList<>();
|
||||
for (int i = 0; i < THREADS; i++) {
|
||||
int index = i;
|
||||
futures.add(pool.submit(() -> {
|
||||
await(start);
|
||||
try {
|
||||
action.run(index);
|
||||
return "OK";
|
||||
} catch (Throwable e) {
|
||||
return e.getClass().getSimpleName();
|
||||
}
|
||||
}));
|
||||
}
|
||||
List<String> outcomes = new ArrayList<>();
|
||||
for (Future<String> f : futures) {
|
||||
outcomes.add(f.get(30, TimeUnit.SECONDS));
|
||||
}
|
||||
pool.shutdown();
|
||||
return outcomes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
||||
/** The console demo's story, replayed over HTTP against the real controllers, service and H2. */
|
||||
@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:api")
|
||||
@AutoConfigureMockMvc
|
||||
class LibraryApiTest {
|
||||
|
||||
/** Freezes "today" at 2026-11-01 so due dates in the transcript never drift. */
|
||||
@TestConfiguration
|
||||
static class FixedClock {
|
||||
@Bean
|
||||
@Primary
|
||||
Clock fixedClock() {
|
||||
return Clock.fixed(Instant.parse("2026-11-01T06:00:00Z"), ZoneId.of("Asia/Kolkata"));
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
private final Transcript t = new Transcript("03-rest-api.txt");
|
||||
|
||||
private MvcResult call(String method, String path, String json, int expectedStatus) throws Exception {
|
||||
var request = method.equals("GET") ? get(path) : post(path);
|
||||
if (json != null) {
|
||||
request = request.contentType(MediaType.APPLICATION_JSON).content(json);
|
||||
}
|
||||
MvcResult result = mvc.perform(request).andReturn();
|
||||
String body = result.getResponse().getContentAsString();
|
||||
t.line("> " + method + " " + path + (json != null ? " " + json : ""));
|
||||
t.line("< " + result.getResponse().getStatus() + " " + (body.isEmpty() ? "(no body)" : body));
|
||||
t.line("");
|
||||
assertEquals(expectedStatus, result.getResponse().getStatus(), path + " -> " + body);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Test
|
||||
void borrowReturnFineAndEveryWayItCanBeRefused() throws Exception {
|
||||
call("POST", "/api/books", "{\"isbn\":\"978-0134685991\",\"title\":\"Effective Java\",\"author\":\"Joshua Bloch\",\"copies\":2}", 201);
|
||||
call("POST", "/api/books", "{\"isbn\":\"978-0134685991\",\"title\":\"Effective Java\",\"author\":\"Joshua Bloch\",\"copies\":2}", 409);
|
||||
call("POST", "/api/books", "{\"isbn\":\"978-0000000000\",\"title\":\"\",\"author\":\"Nobody\",\"copies\":0}", 400);
|
||||
call("POST", "/api/books", "{\"isbn\":\"978-0596009205\",\"title\":\"Head First Java\",\"author\":\"Kathy Sierra\",\"copies\":1}", 201);
|
||||
call("POST", "/api/books", "{\"isbn\":\"978-0132350884\",\"title\":\"Clean Code\",\"author\":\"Robert Martin\",\"copies\":1}", 201);
|
||||
call("POST", "/api/books", "{\"isbn\":\"978-0321356680\",\"title\":\"Effective Java 2e\",\"author\":\"Joshua Bloch\",\"copies\":1}", 201);
|
||||
call("POST", "/api/members", "{\"name\":\"Asha\",\"email\":\"[email protected]\"}", 201);
|
||||
call("POST", "/api/members", "{\"name\":\"Ravi\",\"email\":\"[email protected]\"}", 201);
|
||||
|
||||
call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":2}", 201);
|
||||
call("POST", "/api/loans", "{\"memberId\":2,\"bookId\":2}", 409);
|
||||
call("POST", "/api/loans", "{\"memberId\":99,\"bookId\":1}", 404);
|
||||
call("POST", "/api/loans/1/return", "{\"returnedOn\":\"2026-11-20\"}", 200);
|
||||
call("POST", "/api/loans/1/return", "{\"returnedOn\":\"2026-11-21\"}", 409);
|
||||
|
||||
call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":1}", 201);
|
||||
call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":3}", 201);
|
||||
call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":4}", 201);
|
||||
call("POST", "/api/loans", "{\"memberId\":1,\"bookId\":2}", 422);
|
||||
|
||||
MvcResult books = call("GET", "/api/books", null, 200);
|
||||
assertTrue(books.getResponse().getContentAsString().contains("\"availableCopies\":1"));
|
||||
call("GET", "/api/loans", null, 200);
|
||||
t.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.stat.Statistics;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/** Counts the SQL statements Hibernate actually sends. LAZY does not mean "free". */
|
||||
@SpringBootTest(properties = {"spring.datasource.url=jdbc:h2:mem:count",
|
||||
"spring.jpa.properties.hibernate.generate_statistics=true"})
|
||||
class QueryCountTest {
|
||||
|
||||
private static final int LOANS = 5;
|
||||
|
||||
@Autowired BookRepository books;
|
||||
@Autowired MemberRepository members;
|
||||
@Autowired LoanRepository loans;
|
||||
@Autowired LoanService service;
|
||||
@Autowired EntityManagerFactory emf;
|
||||
@Autowired TransactionTemplate tx;
|
||||
|
||||
@Test
|
||||
void listingLoansCostsOnePlusTwoNLazilyAndOneWithJoinFetch() {
|
||||
for (int i = 1; i <= LOANS; i++) {
|
||||
Book b = books.save(new Book("isbn-" + i, "Book " + i, "Author " + i, 1));
|
||||
Member m = members.save(new Member("Member " + i, "m" + i + "@example.com"));
|
||||
loans.save(new Loan(b, m, LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 15)));
|
||||
}
|
||||
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
|
||||
|
||||
stats.clear();
|
||||
tx.executeWithoutResult(s -> loans.findAllByOrderByIdAsc()
|
||||
.forEach(l -> {
|
||||
l.getBook().getTitle();
|
||||
l.getMember().getName();
|
||||
}));
|
||||
long lazy = stats.getPrepareStatementCount();
|
||||
|
||||
stats.clear();
|
||||
tx.executeWithoutResult(s -> loans.findAllWithDetails()
|
||||
.forEach(l -> {
|
||||
l.getBook().getTitle();
|
||||
l.getMember().getName();
|
||||
}));
|
||||
long fetched = stats.getPrepareStatementCount();
|
||||
|
||||
stats.clear();
|
||||
service.listLoans();
|
||||
long viaService = stats.getPrepareStatementCount();
|
||||
|
||||
assertEquals(1 + 2 * LOANS, lazy);
|
||||
assertEquals(1, fetched);
|
||||
assertEquals(1, viaService);
|
||||
|
||||
new Transcript("04-query-count.txt")
|
||||
.line(LOANS + " loans, each with its own book and its own member")
|
||||
.line("findAllByOrderByIdAsc() then read book title + member name: " + lazy + " SQL statements")
|
||||
.line("findAllWithDetails() (join fetch) then the same reads: " + fetched + " SQL statement")
|
||||
.line("LoanService.listLoans() as called by GET /api/loans: " + viaService + " SQL statement")
|
||||
.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ankurm.library.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes {@code output/NN-name.txt}. Every test that produces a number quoted in the article
|
||||
* also asserts it, so the transcript and the assertion can never disagree.
|
||||
*/
|
||||
public final class Transcript {
|
||||
|
||||
private final StringBuilder text = new StringBuilder();
|
||||
private final String file;
|
||||
|
||||
public Transcript(String file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public Transcript line(String s) {
|
||||
text.append(s).append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
public void save() {
|
||||
try {
|
||||
Path out = Path.of("output", file);
|
||||
Files.createDirectories(out.getParent());
|
||||
Files.writeString(out, text.toString());
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ankurm.library.core;
|
||||
|
||||
import com.ankurm.library.api.Transcript;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LoanPolicyTest {
|
||||
|
||||
@Test
|
||||
void fineIsTwoRupeesForEachDayAfterTheDueDate() {
|
||||
LocalDate borrowed = LocalDate.of(2026, 11, 1);
|
||||
LocalDate due = LoanPolicy.dueOn(borrowed);
|
||||
assertEquals(LocalDate.of(2026, 11, 15), due);
|
||||
|
||||
Transcript t = new Transcript("02-loan-policy.txt");
|
||||
t.line("borrowed " + borrowed + ", due " + due + " (" + LoanPolicy.LOAN_DAYS + " days)");
|
||||
int[][] cases = {{-3, 0}, {0, 0}, {1, 2}, {5, 10}, {30, 60}};
|
||||
for (int[] c : cases) {
|
||||
LocalDate returned = due.plusDays(c[0]);
|
||||
int fine = LoanPolicy.fine(due, returned);
|
||||
assertEquals(c[1], fine);
|
||||
t.line(String.format("returned %s (%+3d days vs due) -> fine Rs %d", returned, c[0], fine));
|
||||
}
|
||||
t.save();
|
||||
}
|
||||
|
||||
@Test
|
||||
void thirdLoanIsAllowedFourthIsNot() {
|
||||
assertTrue(LoanPolicy.mayBorrow(0));
|
||||
assertTrue(LoanPolicy.mayBorrow(2));
|
||||
assertFalse(LoanPolicy.mayBorrow(3));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user