الاستعلام وتحويل الصفوف إلى كائنات
في هذا الدرس 11 موضوعات
هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.
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
PreparedStatementwith?placeholders and bind values withsetStringandsetInt. - Explain that parameter positions start at 1.
- Run a
SELECTwithexecuteQuery()and anINSERT,UPDATE, orDELETEwithexecuteUpdate(). - Loop over a
ResultSetwithwhile (rs.next())and read columns by name. - Map a row to a
Bookand returnList<Book>orOptional<Book>. - Handle SQL
NULLsafely.
Written Explanation
Prepare, bind, execute
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:
- The SQL text contains
?placeholders for values. Never type the value into the string. 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.- Choose the setter that matches the SQL type:
setIntforINTEGER,setStringfor 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
| Method | Use for | Returns |
|---|---|---|
executeQuery() | SELECT | A ResultSet (never null) |
executeUpdate() | INSERT, UPDATE, DELETE | The 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:
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:
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:
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)
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):
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)
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:
Optional[Book[id=2, title=Effective Java, isbn=222, copies=1]]
Optional.emptyThe 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:
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();
}
}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:
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);Clean Code: getInt=2008, wasNull=false, getObject=2008
Effective Java: getInt=2018, wasNull=false, getObject=2018
Java Basics: getInt=0, wasNull=true, getObject=nullgetInt 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
- Which index does the first
?have? What happens with index 0? - Which method would you call for each: (a)
SELECT, (b)DELETE? - Why does
findByIdreturnOptional<Book>instead ofBook?
Level B: Application
- 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. - Write
int countAvailable(Connection conn)that returns how many books havecopies > 0, usingSELECT COUNT(*) .... WhichResultSetgetter do you use, and why? - 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
- Write
List<String> topTitles(Connection conn, int minCopies, int limit)where both the minimum and the limit are?parameters, ordered bycopiesdescending, then title. Check what your data returns for(1, 1). - A teammate writes
rs.getInt("published_year")and shows "year 0" on a page. Explain the bug and fix it usingInteger.
أظهر الحلول
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.
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
PreparedStatementwith?placeholders; bind with theset...methods; positions start at 1. executeQuery()runs aSELECTand returns aResultSet;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
Optionalwhen zero or one row is expected. getInthides SQLNULLas 0; usewasNull()orgetObject(..., 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
- What index does the first
?have? - What does
executeUpdate()return? - Why map rows to objects inside the try block?
- How do you read
COUNT(*)from a result set? - How can you tell a
NULLyear from a year of 0?
أظهر الإجابات
- 1.
- The number of rows the statement changed.
- The
ResultSetcloses with its statement, so it cannot be used afterwards; the objects you built stay valid. - By column index:
rs.getInt(1). - Call
wasNull()right after the getter, or read it withgetObject("col", Integer.class)and check fornull.
References
- Oracle, PreparedStatement (Java SE 21 API). It supports 1-based parameter indexes, type-matched setters, and the return values of
executeQueryandexecuteUpdate. - 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 thegetObject(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
executeQueryon anUPDATEstill 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.
التطبيق
طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.
تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.