Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,28 @@ Write your DDL statements to create the tables for the database in the `task-1/s

### Task 2: Java CRUD
Write your Java code to perform CRUD in the `task-2` folder.

## Run demo (local)

1. Start Postgres and apply the schema (PowerShell):

```powershell
docker run -d --rm --name library_db -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=library_db -v "${PWD}\task-1:/schema" -p 5432:5432 postgres:16-alpine
while (-not (docker exec library_db pg_isready -U postgres -d postgres 2>$null)) { Start-Sleep -Milliseconds 100 }
docker exec library_db psql -U postgres -d library_db -v ON_ERROR_STOP=1 -f /schema/schema.sql
Comment on lines +18 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The powershell script doesn't work the first time it's run for me.
On the second run it does. Which is because of a truthy value coming from docker exec library_db pg_isready -U postgres -d postgres 2>$null
2>$null only silences the stderr channel but pg_isready prints status text to the stdout (1) channel. Even if the container hasn't started yet (Eg: rejecting connections). So this evaluates to true.

You can use the below check instead to determine if your database is ready. $LASTEXITCODE retrieves the last process exist number from powershell, docker exec command returns non-zero until the db is accepting connections.

do {
    Start-Sleep -Milliseconds 250
    docker exec library_db pg_isready -h localhost -U postgres -d library_db 2> $null
    $ready = $LASTEXITCODE -eq 0
} until ($ready)

```

2. Compile and run the Java demo:

```powershell
mvn -f task-2\pom.xml compile
mvn -f task-2\pom.xml exec:java
```

3. When finished, stop the container:

```powershell
docker rm -f library_db
```

Expected output: two `BorrowingRecord[...]` lines printed to console from the demo.
72 changes: 71 additions & 1 deletion task-1/schema.sql
Original file line number Diff line number Diff line change
@@ -1 +1,71 @@
-- Write here your DDL statements to create the tables for the database.
CREATE TABLE members (
member_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
member_email TEXT NOT NULL UNIQUE,
member_name TEXT NOT NULL,
member_phone TEXT NOT NULL,
member_address TEXT NOT NULL
);

CREATE TABLE library_cards (
member_id BIGINT PRIMARY KEY REFERENCES members(member_id) ON DELETE CASCADE,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice structural pattern to prevent a second card for the same member. Also proper use of ON DELETE CASCADE. Well done.

card_number TEXT NOT NULL UNIQUE,
card_issued_on DATE NOT NULL,
card_expires_on DATE NOT NULL,
CONSTRAINT library_cards_dates_chk CHECK (card_expires_on > card_issued_on)
);

CREATE TABLE publishers (
publisher_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
publisher_name TEXT NOT NULL UNIQUE
);

CREATE TABLE books (
book_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
book_isbn TEXT NOT NULL UNIQUE,
book_title TEXT NOT NULL,
publisher_id BIGINT NOT NULL REFERENCES publishers(publisher_id) ON DELETE RESTRICT
);

CREATE TABLE authors (
author_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
author_name TEXT NOT NULL UNIQUE
);

CREATE TABLE book_authors (
book_id BIGINT NOT NULL REFERENCES books(book_id) ON DELETE CASCADE,
author_id BIGINT NOT NULL REFERENCES authors(author_id) ON DELETE CASCADE,
PRIMARY KEY (book_id, author_id)
);

CREATE TABLE tags (
tag_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tag_name TEXT NOT NULL UNIQUE
);

CREATE TABLE book_tags (
book_id BIGINT NOT NULL REFERENCES books(book_id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(tag_id) ON DELETE CASCADE,
PRIMARY KEY (book_id, tag_id)
);

CREATE TABLE copies (
copy_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
copy_barcode TEXT NOT NULL UNIQUE,
book_id BIGINT NOT NULL REFERENCES books(book_id) ON DELETE RESTRICT,
shelf_code TEXT NOT NULL
);

CREATE TABLE borrowings (
borrowing_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
member_id BIGINT NOT NULL REFERENCES members(member_id) ON DELETE RESTRICT,
copy_id BIGINT NOT NULL REFERENCES copies(copy_id) ON DELETE RESTRICT,
borrowed_at TIMESTAMP NOT NULL,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIMESTAMP stores the time given to it without accounting for timezone which can cause mismatch in timing if you have servers running in different timezones or you have libraries in different timezones.
The type TIMESTAMPTZ converts the value to UTC and standardizes how time is stored on your database. Then any library that views this can decide to show UTC, local time or both times.

due_date TIMESTAMP NOT NULL,
returned_at TIMESTAMP NULL,
fine_eur NUMERIC(10, 2) NOT NULL DEFAULT 0,
CONSTRAINT borrowings_dates_chk CHECK (due_date > borrowed_at),
CONSTRAINT borrowings_returned_chk CHECK (returned_at IS NULL OR returned_at >= borrowed_at),
CONSTRAINT borrowings_fine_chk CHECK (fine_eur >= 0)
);


200 changes: 199 additions & 1 deletion task-2/src/main/java/org/hyf/Main.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,205 @@
package org.hyf;

import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

public class Main {

private static final String URL = "jdbc:postgresql://localhost:5432/library_db";
private static final String USER = "postgres";
private static final String PASSWORD = "postgres";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can understand hardcoded local credentials in an assignment. Just a hint, the next professional step is to read connection details from environment variables so secrets do not live in source code:

private static final String URL = System.getenv().getOrDefault(
        "DB_URL",
        "jdbc:postgresql://localhost:5432/library_db"
);
private static final String USER = System.getenv().getOrDefault("DB_USER", "postgres");
private static final String PASSWORD = System.getenv("DB_PASSWORD");


public static void main(String[] args) {
System.out.println("You code goes here :) You can overwrite this entire method.");
try {
insertData();
List<BorrowingRecord> records = fetchData();
for (BorrowingRecord r : records) {
System.out.println(r);
}
} catch (SQLException e) {
e.printStackTrace();
}
}

public static void insertData() throws SQLException {
try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD)) {
conn.setAutoCommit(false);
try {
long tomId = insertMember(conn, "tom@example.com", "Tom", "+31645678901", "Biltstraat 88, Utrecht");
long samId = insertMember(conn, "sam@example.com", "Sam", "+31612345678", "Oudegracht 10, Utrecht");

insertLibraryCard(conn, tomId, "C-1004", LocalDate.of(2023,11,20), LocalDate.of(2026,11,20));
insertLibraryCard(conn, samId, "C-1001", LocalDate.of(2024,1,10), LocalDate.of(2027,1,10));

long addisonId = insertPublisher(conn, "Addison-Wesley");
long oreillyId = insertPublisher(conn, "O'Reilly");

long ejId = insertBook(conn, "9780134685991", "Effective Java", addisonId);
long hfId = insertBook(conn, "9780596009205", "Head First Java", oreillyId);

long jb = insertAuthor(conn, "Joshua Bloch");
long ks = insertAuthor(conn, "Kathy Sierra");

long tagJava = insertTag(conn, "java");
long tagBeg = insertTag(conn, "beginners");

insertBookAuthor(conn, ejId, jb);
insertBookAuthor(conn, hfId, ks);

insertBookTag(conn, ejId, tagJava);
insertBookTag(conn, hfId, tagBeg);

long c1 = insertCopy(conn, "BC-0001", ejId, "A-01");
long c2 = insertCopy(conn, "BC-0006", hfId, "B-03");

insertBorrowing(conn, tomId, c1, LocalDateTime.of(2026,3,9,3,18), LocalDateTime.of(2026,3,30,3,18), LocalDateTime.of(2026,3,28,3,18), new BigDecimal("0.00"));
insertBorrowing(conn, samId, c2, LocalDateTime.of(2025,12,9,9,8), LocalDateTime.of(2025,12,30,9,8), LocalDateTime.of(2026,1,4,9,8), new BigDecimal("1.25"));

conn.commit();
} catch (SQLException ex) {
conn.rollback();
throw ex;
}
}
}

public static List<BorrowingRecord> fetchData() throws SQLException {
String sql = "SELECT m.member_name, m.member_email, b.book_title, c.copy_barcode, br.borrowed_at, br.due_date, br.returned_at, br.fine_eur " +
"FROM borrowings br " +
"JOIN members m ON br.member_id = m.member_id " +
"JOIN copies c ON br.copy_id = c.copy_id " +
"JOIN books b ON c.book_id = b.book_id " +
"ORDER BY br.borrowed_at";

Comment on lines +81 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use a text block for the SQL statements. Also recommend setting your statement to a final constant:

""";

private static  final String BORROW_DATA_SQL = """
    SELECT m.member_name, m.member_email, b.book_title, c.copy_barcode, br.borrowed_at, br.due_date, br.returned_at, br.fine_eur
    FROM borrowings br
    JOIN members m ON br.member_id = m.member_id
    JOIN copies c ON br.copy_id = c.copy_id
    JOIN books b ON c.book_id = b.book_id
    ORDER BY br.borrowed_at
    """;

try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD);
PreparedStatement preparedStatement = conn.prepareStatement(sql);
ResultSet resultSet = preparedStatement.executeQuery()) {

List<BorrowingRecord> list = new ArrayList<>();
while (resultSet.next()) {
Timestamp retTs = resultSet.getTimestamp("returned_at");
list.add(new BorrowingRecord(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Transforming a ResultSet into your domain type is something that will most likely be reused, is a concept and is more a concern of your record BorrowingRecord.
You can add a static from method in your record that parses from a ResultSet instead.

import org.hyf.Main;

import java.sql.ResultSet;

record BorrowingRecord(String memberName, String memberEmail, String bookTitle, String copyBarcode,
                       LocalDateTime borrowedAt, LocalDateTime dueDate, LocalDateTime returnedAt, BigDecimal fine) {

  public static BorrowingRecord from(ResultSet fetchedData) throws SQLException {
    return new BorrowingRecord(
        fetchedData.getString("member_name"),
        fetchedData.getString("member_email"),
        fetchedData.getString("book_title"),
        fetchedData.getString("copy_barcode"),
        fetchedData.getObject("borrowed_at", LocalDateTime.class),
        fetchedData.getObject("due_date", LocalDateTime.class),
        fetchedData.getObject("returned_at", LocalDateTime.class),
        fetchedData.getObject("fine_eur", BigDecimal.class)
    );
  }
}

Then you could use it like so:

list.add(BorrowingRecord.from(resultSet));

While it's possible to move the while block also in the record's static method, that could harm readability and reusability. Because then the record is worrying about arranging the data and is less reusable for different fetch scenarios which need different arrangment including having just a single record.

resultSet.getString("member_name"),
resultSet.getString("member_email"),
resultSet.getString("book_title"),
resultSet.getString("copy_barcode"),
resultSet.getTimestamp("borrowed_at").toLocalDateTime(),
resultSet.getTimestamp("due_date").toLocalDateTime(),
retTs == null ? null : retTs.toLocalDateTime(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use the getObject() method with LocalDateTime.class type (See my other comment on fetchData). Then no null-check is needed.

resultSet.getBigDecimal("fine_eur")
));
}
return list;
}
}

// Helper insert methods using PreparedStatement everywhere
private static long insertMember(Connection conn, String email, String name, String phone, String address) throws SQLException {
String sql = "INSERT INTO members (member_email, member_name, member_phone, member_address) VALUES (?, ?, ?, ?) ON CONFLICT (member_email) DO UPDATE SET member_name=EXCLUDED.member_name RETURNING member_id";
try (PreparedStatement preparedStatement = conn.prepareStatement(sql)) {
preparedStatement.setString(1, email);
preparedStatement.setString(2, name);
preparedStatement.setString(3, phone);
preparedStatement.setString(4, address);
try (ResultSet resultSet = preparedStatement.executeQuery()) { resultSet.next(); return resultSet.getLong(1); }
}
Comment on lines +113 to +114

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this is not too many lines of code. It is still repeated a few times. Extracting it out can make refactoring the code later easier and gives you an opportunity to document the code with code by naming the method.

Please be aware though, not every piece of code should be extracted as it can reduce readability. Only extract when you see a clear, reusable concept (like "execute an insert that returns an ID"). If extracting would split business logic across multiple methods and make the flow harder to follow, it’s better to keep it together.

In this case, the extraction is justified because:

  • The pattern appears multiple times
  • The concept ("run this SQL and give me back the generated ID") is cohesive

}

private static void insertLibraryCard(Connection conn, long memberId, String cardNumber, LocalDate issued, LocalDate expires) throws SQLException {
String sql = "INSERT INTO library_cards (member_id, card_number, card_issued_on, card_expires_on) VALUES (?, ?, ?, ?) ON CONFLICT (member_id) DO UPDATE SET card_number=EXCLUDED.card_number";
try (PreparedStatement preparedStatement = conn.prepareStatement(sql)) {
preparedStatement.setLong(1, memberId);
preparedStatement.setString(2, cardNumber);
preparedStatement.setObject(3, issued);
preparedStatement.setObject(4, expires);
preparedStatement.executeUpdate();
}
}

private static long insertPublisher(Connection conn, String name) throws SQLException {
String sql = "INSERT INTO publishers (publisher_name) VALUES (?) ON CONFLICT (publisher_name) DO UPDATE SET publisher_name=EXCLUDED.publisher_name RETURNING publisher_id";
try (PreparedStatement preparedStatement = conn.prepareStatement(sql)) {
preparedStatement.setString(1, name);
try (ResultSet resultSet = preparedStatement.executeQuery()) { resultSet.next(); return resultSet.getLong(1); }
}
}

private static long insertBook(Connection conn, String isbn, String title, long publisherId) throws SQLException {
String sql = "INSERT INTO books (book_isbn, book_title, publisher_id) VALUES (?, ?, ?) ON CONFLICT (book_isbn) DO UPDATE SET book_title=EXCLUDED.book_title RETURNING book_id";
try (PreparedStatement preparedStatement = conn.prepareStatement(sql)) {
preparedStatement.setString(1, isbn);
preparedStatement.setString(2, title);
preparedStatement.setLong(3, publisherId);
try (ResultSet resultSet = preparedStatement.executeQuery()) { resultSet.next(); return resultSet.getLong(1); }
}
}

private static long insertAuthor(Connection conn, String name) throws SQLException {
String sql = "INSERT INTO authors (author_name) VALUES (?) ON CONFLICT (author_name) DO UPDATE SET author_name=EXCLUDED.author_name RETURNING author_id";
try (PreparedStatement preparedStatement = conn.prepareStatement(sql)) {
preparedStatement.setString(1, name);
try (ResultSet resultSet = preparedStatement.executeQuery()) { resultSet.next(); return resultSet.getLong(1); }
}
}

private static long insertTag(Connection conn, String name) throws SQLException {
String sql = "INSERT INTO tags (tag_name) VALUES (?) ON CONFLICT (tag_name) DO UPDATE SET tag_name=EXCLUDED.tag_name RETURNING tag_id";
try (PreparedStatement preparedStatement = conn.prepareStatement(sql)) {
preparedStatement.setString(1, name);
try (ResultSet resultSet = preparedStatement.executeQuery()) { resultSet.next(); return resultSet.getLong(1); }
}
}

private static void insertBookAuthor(Connection conn, long bookId, long authorId) throws SQLException {
String sql = "INSERT INTO book_authors (book_id, author_id) VALUES (?, ?) ON CONFLICT DO NOTHING";
try (PreparedStatement preparedStatement = conn.prepareStatement(sql)) {
preparedStatement.setLong(1, bookId);
preparedStatement.setLong(2, authorId);
preparedStatement.executeUpdate();
}
}

private static void insertBookTag(Connection conn, long bookId, long tagId) throws SQLException {
String sql = "INSERT INTO book_tags (book_id, tag_id) VALUES (?, ?) ON CONFLICT DO NOTHING";
try (PreparedStatement preparedStatement = conn.prepareStatement(sql)) {
preparedStatement.setLong(1, bookId);
preparedStatement.setLong(2, tagId);
preparedStatement.executeUpdate();
}
}

private static long insertCopy(Connection conn, String barcode, long bookId, String shelfCode) throws SQLException {
String sql = "INSERT INTO copies (copy_barcode, book_id, shelf_code) VALUES (?, ?, ?) ON CONFLICT (copy_barcode) DO UPDATE SET book_id=EXCLUDED.book_id RETURNING copy_id";
try (PreparedStatement preparedStatement = conn.prepareStatement(sql)) {
preparedStatement.setString(1, barcode);
preparedStatement.setLong(2, bookId);
preparedStatement.setString(3, shelfCode);
try (ResultSet resultSet = preparedStatement.executeQuery()) { resultSet.next(); return resultSet.getLong(1); }
}
}

private static void insertBorrowing(Connection conn, long memberId, long copyId, LocalDateTime borrowedAt, LocalDateTime dueDate, LocalDateTime returnedAt, BigDecimal fine) throws SQLException {
String sql = "INSERT INTO borrowings (member_id, copy_id, borrowed_at, due_date, returned_at, fine_eur) VALUES (?, ?, ?, ?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, memberId);
ps.setLong(2, copyId);
ps.setTimestamp(3, Timestamp.valueOf(borrowedAt));
ps.setTimestamp(4, Timestamp.valueOf(dueDate));
ps.setTimestamp(5, returnedAt == null ? null : Timestamp.valueOf(returnedAt));
ps.setBigDecimal(6, fine);
ps.executeUpdate();
}
}

public static record BorrowingRecord(String memberName, String memberEmail, String bookTitle, String copyBarcode, LocalDateTime borrowedAt, LocalDateTime dueDate, LocalDateTime returnedAt, BigDecimal fine) {}

}