Skip to content
Ameen Abozaid
PublishedBEG-055-L03

Querying and mapping rows to objects

  • Lesson 3 of 7
  • 10 min read
  • Beginner
On This Lesson 11 topics

Lesson Overview

You are connected. Now you ask the librarian a question and write down the answer. In JDBC the question is a PreparedStatement, the answer is a ResultSet, and your job is to turn each row of the answer into a Java object, a step called mapping.

By the end you will have findAll, findById (returning Optional), and an UPDATE method, all with ? placeholders. The ? slots also matter for security, and lesson 4 shows why in a live attack.

This lesson uses the Db helper from lesson 2, a books table like the one from BEG-051 (id, title, isbn, copies, plus a nullable published_year), Optional and collections from earlier modules, and a record for the Book class (BEG-026 previewed records; a plain class with a constructor and getters works the same way).

Learning Objectives

By the end of this lesson you will be able to:

  • Create a PreparedStatement with ? placeholders and bind values with setString and setInt.
  • Explain that parameter positions start at 1.
  • Run a SELECT with executeQuery() and an INSERT, UPDATE, or DELETE with executeUpdate().
  • Loop over a ResultSet with while (rs.next()) and read columns by name.
  • Map a row to a Book and return List<Book> or Optional<Book>.
  • Handle SQL NULL safely.

Written Explanation

Prepare, bind, execute

java
String sql = "SELECT id, title, isbn, copies FROM books WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setInt(1, 2);
    try (ResultSet rs = ps.executeQuery()) {
        // read rows
    }
}

Three ideas:

  1. The SQL text contains ? placeholders for values. Never type the value into the string.
  2. setInt(1, 2) fills the first placeholder with 2. Positions start at 1, not 0; the Java documentation says the first parameter is 1, the second is 2.
  3. Choose the setter that matches the SQL type: setInt for INTEGER, setString for text. The driver sends the value separately from the SQL text. (Why that matters is lesson 4's whole story.)

A placeholder stands for a value only. You cannot use ? for a table or column name, and you must not put quotes around it: title = '?' contains a literal question mark inside text, not a placeholder.

executeQuery and executeUpdate

MethodUse forReturns
executeQuery()SELECTA ResultSet (never null)
executeUpdate()INSERT, UPDATE, DELETEThe number of rows changed (an int)

Using the wrong one fails. In our run, executeQuery() on an UPDATE threw No results were returned by the query. and executeUpdate() on a SELECT threw A result was returned when none was expected. One more surprise: with auto-commit on (the default, see lesson 5), the UPDATE had already been executed by the time the exception was thrown. A wrong method choice can still change data.

Reading the ResultSet

A ResultSet is a cursor that starts before the first row. Each call to next() moves to the next row and returns true, or returns false when the rows run out. That gives the standard loop:

java
while (rs.next()) {
    int id = rs.getInt("id");
    String title = rs.getString("title");
}

Read columns by name (getString("title")) so reordering the SELECT does not break your code. Names are case-insensitive. If you select an expression without a name, such as COUNT(*), read it by index, which also starts at 1: rs.getInt(1).

Reading before the first next(), or reading a column that is not in the query, throws SQLException. We saw ResultSet not positioned properly, perhaps you need to call next. and The column name nope was not found in this ResultSet.

Mapping a row to an object

Put the mapping in one small method so every query reuses it:

java
private static Book map(ResultSet rs) throws SQLException {
    return new Book(rs.getInt("id"), rs.getString("title"),
            rs.getString("isbn"), rs.getInt("copies"));
}

With Book as a record:

java
public record Book(int id, String title, String isbn, int copies) { }

Then a method that returns many rows builds a list, and one that returns at most one row returns an Optional.

Order and column choice

A SELECT without ORDER BY returns rows in no promised order, so add ORDER BY id whenever the order matters, as findAll does. Also name the columns you need instead of SELECT *: the query then keeps working if someone adds a column to the table, and your map method documents exactly what it reads. For a table with millions of rows, findAll would be a bad idea; a LIMIT ? parameter (Level C) keeps results bounded.

Zero or one row: Optional

findById has two honest outcomes: a book, or no book. Returning null hides the second outcome and invites a NullPointerException. Optional<Book> makes the caller deal with it. Code that finds nothing returns Optional.empty().

SQL NULL

A column that allows NULL can come back empty. getString returns null for a NULL. But getInt returns 0 for NULL, which looks exactly like a real zero. Two safe ways: call rs.wasNull() immediately after the getter, or read with getObject("col", Integer.class), which gives null. Example 3 shows both.

Examples

Example 1: findAll (simple)

java
public List<Book> findAll(Connection conn) throws SQLException {
    String sql = "SELECT id, title, isbn, copies FROM books ORDER BY id";
    List<Book> books = new ArrayList<>();
    try (PreparedStatement ps = conn.prepareStatement(sql);
         ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            books.add(map(rs));
        }
    }
    return books;
}

Output (our test data):

text
Book[id=1, title=Clean Code, isbn=111, copies=2]
Book[id=2, title=Effective Java, isbn=222, copies=1]
Book[id=3, title=Java Basics, isbn=333, copies=0]

The statement and the result set are both declared in the try header; they close in reverse order. There is no ? here because there is no value to bind.

Example 2: findById with Optional (practical)

java
public Optional<Book> findById(Connection conn, int id) throws SQLException {
    String sql = "SELECT id, title, isbn, copies FROM books WHERE id = ?";
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setInt(1, id);
        try (ResultSet rs = ps.executeQuery()) {
            if (rs.next()) {
                return Optional.of(map(rs));
            }
            return Optional.empty();
        }
    }
}

if (rs.next()) is enough, because at most one row can match a primary key. Calling it for ids 2 and 99:

text
Optional[Book[id=2, title=Effective Java, isbn=222, copies=1]]
Optional.empty

The statement is in the outer try, the result set in an inner try, because the result set can only be created after setInt. Returning from inside try-with-resources is safe: the resources still close.

Example 3: updates and NULL (realistic)

An UPDATE returns how many rows it changed, which tells you whether the id existed:

java
public int addCopies(Connection conn, int id, int extra) throws SQLException {
    String sql = "UPDATE books SET copies = copies + ? WHERE id = ?";
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setInt(1, extra);
        ps.setInt(2, id);
        return ps.executeUpdate();
    }
}
text
rows changed: 1      (id 3 exists)
rows changed: 0      (id 99 does not exist)

Now NULL. Two books have a published_year; "Java Basics" has none:

java
int year = rs.getInt("published_year");
boolean missing = rs.wasNull();
Integer boxed = rs.getObject("published_year", Integer.class);
System.out.println(rs.getString("title") + ": getInt=" + year
        + ", wasNull=" + missing + ", getObject=" + boxed);
text
Clean Code: getInt=2008, wasNull=false, getObject=2008
Effective Java: getInt=2018, wasNull=false, getObject=2018
Java Basics: getInt=0, wasNull=true, getObject=null

getInt alone could not tell "Java Basics" apart from a book published in year 0.

Backend Insight

Almost every endpoint of a service is one of two shapes: fetch rows and map them to objects, or change rows and report how many changed. This lesson is both shapes. In real projects the mapping method is repeated in dozens of repositories, which is why frameworks later automate it. Knowing how it works by hand lets you read what the framework does and debug it.

Two design choices also start here. First, Optional for lookups that may find nothing keeps "missing" visible in the method signature. Second, a method that takes the Connection as a parameter (as ours do) lets the caller decide where the transaction begins, which is exactly what lesson 5 needs.

Common Mistakes

Mistake 1: Counting placeholders from 0

ps.setInt(0, id) throws The column index is out of range: 0, number of columns: 1. The first ? is index 1. (The message says "column" but it is the parameter position.)

Mistake 2: Forgetting a value

If you do not bind every ?, executeQuery throws No value specified for parameter 1.

Mistake 3: Quoting the placeholder

WHERE title = '?' is a text literal containing a question mark, so there is nothing to bind. Write WHERE title = ? and let setString supply the value.

Mistake 4: Reading a ResultSet after it is closed, or returning it

A ResultSet closes when its statement closes. Map the rows into objects inside the try block and return the objects, never the ResultSet.

Mistake 5: Trusting getInt on a nullable column

0 can mean "zero" or "NULL". Use wasNull() or getObject(..., Integer.class) when a column may be empty.

Exercises

Level A: Foundation

  1. Which index does the first ? have? What happens with index 0?
  2. Which method would you call for each: (a) SELECT, (b) DELETE?
  3. Why does findById return Optional<Book> instead of Book?

Level B: Application

  1. Write List<Book> findByTitle(Connection conn, String title) using an exact match with ?. Test it with a title that exists and one that does not.
  2. Write int countAvailable(Connection conn) that returns how many books have copies > 0, using SELECT COUNT(*) .... Which ResultSet getter do you use, and why?
  3. Write int setCopies(Connection conn, int id, int copies) and call it for an existing and a missing id. What does it return in each case?

Level C: Challenge

  1. Write List<String> topTitles(Connection conn, int minCopies, int limit) where both the minimum and the limit are ? parameters, ordered by copies descending, then title. Check what your data returns for (1, 1).
  2. A teammate writes rs.getInt("published_year") and shows "year 0" on a page. Explain the bug and fix it using Integer.
Show solutions

A1. Index 1. Index 0 throws an SQLException (out of range).

A2. (a) executeQuery(). (b) executeUpdate().

A3. A lookup can legitimately find nothing. Optional forces the caller to handle that, where null would invite a NullPointerException.

B1.

java
String sql = "SELECT id, title, isbn, copies FROM books WHERE title = ?";
// setString(1, title); while (rs.next()) list.add(map(rs));

Our run: for "Clean Code" a list of one book; for "none" an empty list.

B2. try (PreparedStatement ps = conn.prepareStatement("SELECT COUNT(*) FROM books WHERE copies > 0"); ResultSet rs = ps.executeQuery()) { rs.next(); return rs.getInt(1); }. The expression has no column name, so you read by index 1. With our data it returned 2.

B3. UPDATE books SET copies = ? WHERE id = ?, binding the two ints in that order, returning executeUpdate(). It returns 1 for an existing id and 0 for a missing one.

C1. SELECT title FROM books WHERE copies >= ? ORDER BY copies DESC, title LIMIT ? with setInt(1, minCopies) and setInt(2, limit). With (1, 1) our data returned [Clean Code].

C2. getInt returns 0 for SQL NULL. Use Integer year = rs.getObject("published_year", Integer.class); (or wasNull()), keep the field as Integer, and show "unknown" when it is null.

Mini Challenge

Add findByIsbn(Connection conn, String isbn) returning Optional<Book>, and write a small main that prints the result for one ISBN that exists and one that does not. Then add a second main line that calls findById with a loop over ids 1 to 4 and prints either the title or not found.

Check your work: every value goes through a ?, every Statement and ResultSet is inside try-with-resources, and nothing returns null.

Summary & Next Lesson

Key takeaways

  • Use PreparedStatement with ? placeholders; bind with the set... methods; positions start at 1.
  • executeQuery() runs a SELECT and returns a ResultSet; executeUpdate() runs a change and returns the row count.
  • Loop with while (rs.next()), read by column name, and map each row to an object.
  • Return Optional when zero or one row is expected.
  • getInt hides SQL NULL as 0; use wasNull() or getObject(..., Integer.class).

Next lesson

The next lesson, "SQL injection, live", shows what goes wrong when a value is glued into the SQL text instead of going through a ?, and proves the fix with a real attack.

Self-check

  1. What index does the first ? have?
  2. What does executeUpdate() return?
  3. Why map rows to objects inside the try block?
  4. How do you read COUNT(*) from a result set?
  5. How can you tell a NULL year from a year of 0?
Show answers
  1. 1.
  2. The number of rows the statement changed.
  3. The ResultSet closes with its statement, so it cannot be used afterwards; the objects you built stay valid.
  4. By column index: rs.getInt(1).
  5. Call wasNull() right after the getter, or read it with getObject("col", Integer.class) and check for null.

References

  • Oracle, PreparedStatement (Java SE 21 API). It supports 1-based parameter indexes, type-matched setters, and the return values of executeQuery and executeUpdate.
  • Oracle, ResultSet (Java SE 21 API). It supports the cursor starting before the first row, next(), case-insensitive column labels, wasNull(), and closing together with the statement. I read only the first part of this long page, and the getObject(label, Class) call was confirmed by running it, not by the page.
  • Oracle, JDBC Basics. It gives the overview of statements and result sets. All error messages and outputs above come from real runs on PostgreSQL 18 with driver 42.7.8. That executeQuery on an UPDATE still applied the change under auto-commit is our observation with this driver; I did not find it documented, so do not rely on it across drivers.

Practice

Apply this lesson with the module's lab, exercises and quiz.

Lab and exercises Quiz

Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.

Continue to next lesson

Type to search levels, phases, modules and lessons.