Databases
Problem
A catalog in a file
The store's catalog is a CSV file, as in the files module. While there are a hundred products, it works. Then there are a hundred thousand, and problems show up:
- to find one product, you read the whole file;
- "everything under €5, sorted by price" is a loop, an
ifand a sort, written by hand; - two cash registers write to the file at the same time, and one record gets lost or the file gets damaged;
- the power goes out in the middle of a write, and half the file is gone.
That's why the data of almost every real program lives in a database: a program whose job is to keep data, find it fast and never lose it.
The idea
Tables and SQL
A relational database keeps data in tables. A table has columns (name, type) and rows (one for each product). The id column is the primary key: its value is different for each row, so it finds exactly one row.
You talk to the database in SQL. The five most common commands:
CREATE TABLE products ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2));
INSERT INTO products VALUES (1, 'Coffee', 7.50);SELECT name, price FROM products WHERE price < 5 ORDER BY price;UPDATE products SET price = 6.90 WHERE id = 1;DELETE FROM products WHERE id = 3;CREATE TABLEmakes a table,INSERTadds a row,UPDATEchanges rows andDELETEremoves them.SELECTreads: which columns, from which table, under what condition (WHERE) and in what order (ORDER BY).
You say what you want, and the database decides how to find it fast.
More precisely, if you are curious
- Well-known relational databases are PostgreSQL, MySQL, Oracle and SQL Server. They run as separate servers. Here we use H2: a small database written in Java that can live entirely in the program's memory. It's handy for examples and tests: every run starts with an empty database.
- Text in SQL goes in single quotes:
'Coffee'.DECIMAL(8, 2)is an exact number with 2 digits after the decimal point, which suits money.
See it
JDBC: connection, query, result
Connection, the query goes through a Statement, and the result is a ResultSet. The setup method makes the table and adds three products:static void setup(Connection conn) throws SQLException { try (Statement st = conn.createStatement()) { st.executeUpdate("CREATE TABLE products (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2))"); st.executeUpdate("INSERT INTO products VALUES (1, 'Coffee', 7.50), (2, 'Tea', 3.20), (3, 'Chocolate', 2.40)"); }}record Product(int id, String name, double price):try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:shop")) { setup(conn); List<Product> products = new ArrayList<>(); try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT id, name, price FROM products ORDER BY price")) { while (rs.next()) { products.add(new Product(rs.getInt("id"), rs.getString("name"), rs.getDouble("price"))); } } for (Product p : products) { System.out.println(p); }}Product[id=3, name=Chocolate, price=2.4]Product[id=2, name=Tea, price=3.2]Product[id=1, name=Coffee, price=7.5]DriverManager.getConnection(address)opens the connection. The address says which database: here, H2 in memory, namedshop.executeUpdateis for commands that change things (CREATE,INSERT,UPDATE,DELETE), andexecuteQueryis forSELECT.rs.next()moves to the next row and returnsfalsewhen the rows run out.getInt,getStringandgetDoubleget a column by name.- The connection, the statement and the result have to be closed: that's why they're in try-with-resources.
- Almost every JDBC method throws the checked
SQLException. Here,mainandsetupdeclare it withthrows.
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:shop")) { setup(conn); List<Product> products = new ArrayList<>(); try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT id, name, price FROM products ORDER BY price")) { while (rs.next()) { products.add(new Product(rs.getInt("id"), rs.getString("name"), rs.getDouble("price"))); } } for (Product p : products) { System.out.println(p); }}Step by step, in words
- A connection to the database in memory. setup makes the products table and adds 3 rows.
- An empty list that the objects will go into.
- SELECT with ORDER BY price: the database returns the rows as a ResultSet, cheapest first.
- rs.next() moves to row 1. getInt, getString and getDouble get the columns, and new Product makes an object from them: Chocolate.
- rs.next() moves to row 2. getInt, getString and getDouble get the columns, and new Product makes an object from them: Tea.
- rs.next() moves to row 3. getInt, getString and getDouble get the columns, and new Product makes an object from them: Coffee.
- rs.next() returns false: the rows ran out and the loop stops.
- The list holds ordinary Java objects. Printing shows the record with its fields.
More precisely, if you are curious
- With a real database on a server, the address is different and there's a user and a password. You also need the database's driver as a dependency in the project (for MySQL:
mysql-connector-j):
String url = "jdbc:mysql://localhost:3306/shop";try (Connection conn = DriverManager.getConnection(url, "shop_app", "secret")) { System.out.println("Connected to the database!");}- The password isn't written in the code. It's read from the settings or from an environment variable.
- The prices here are
doubleto keep it short. In a real store, aDECIMALcolumn is read withrs.getBigDecimal("price"), so there are no rounding errors.
Try it
What will it print?
The products are Coffee €7.50, Tea €3.20 and Chocolate €2.40. What will the program print?
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:shop")) { setup(conn); try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT name FROM products WHERE price < 5 ORDER BY price DESC")) { while (rs.next()) { System.out.println(rs.getString("name")); } }}WHERE price < 5 keeps Tea and Chocolate. ORDER BY price DESC sorts them by price from the top down (DESC is descending, and ASC is the default): first Tea for €3.20, then Chocolate for €2.40.
Problem
The search that shows everything
The website searches for a product by the name the user types. The query is put together with +:
static void search(Connection conn, String name) throws SQLException { String sql = "SELECT name, price FROM products WHERE name = '" + name + "'"; try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) { while (rs.next()) { System.out.println(" " + rs.getString("name") + ": €" + rs.getDouble("price")); } }}try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:shop")) { setup(conn); System.out.println("Searching: Tea"); search(conn, "Tea"); System.out.println("Searching: x' OR '1'='1"); search(conn, "x' OR '1'='1");}Searching: Tea Tea: €3.2Searching: x' OR '1'='1 Coffee: €7.5 Tea: €3.2 Chocolate: €2.4With "Tea" it works. With x' OR '1'='1 the query becomes ... WHERE name = 'x' OR '1'='1'. The condition is always true, and the whole table comes out. The user's input has become part of the SQL. This is SQL injection, one of the most common attacks on websites. With the same trick, someone can log in without a password or delete a table.
The idea
PreparedStatement
PreparedStatement has ? in place of the values, and you pass them with setString, setInt and setDouble (the numbers start at 1):static void search(Connection conn, String name) throws SQLException { String sql = "SELECT name, price FROM products WHERE name = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, name); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { System.out.println(" " + rs.getString("name") + ": €" + rs.getDouble("price")); } } }}Searching: Tea Tea: €3.2Searching: x' OR '1'='1Now x' OR '1'='1 is just text that is searched for as a name. There's no such product, and nothing comes out. Whatever the user types, it can't become SQL.
The rule is simple: a value from the user, from a file or from the network is never glued into SQL. Always use ?.
Try it
Coffee with an apostrophe
Coffee 'Arabica' the program stops with Syntax error in SQL statement "UPDATE products SET name = 'Coffee '[*]Arabica'' WHERE id = 1". Which line has the bug?Pick the line with the bug.
'Arabica' closes the text too early, and the rest is meaningless SQL ([*] in the message points at the spot). An attacker would put their own query there. The fix is a PreparedStatement: "UPDATE products SET name = ? WHERE id = ?", then ps.setString(1, newName) and ps.setInt(2, id). Then the name is saved exactly as it is, with the apostrophes.The idea
Transaction: all or nothing
Ana transfers €150 to Boris. That's two queries: add to Boris, subtract from Ana. The table has the condition CHECK (balance >= 0), so the second query fails: Ana has only €100.
static void openAccounts(Connection conn) throws SQLException { try (Statement st = conn.createStatement()) { st.executeUpdate("CREATE TABLE accounts (id INT PRIMARY KEY, owner VARCHAR(50), balance INT CONSTRAINT no_debt CHECK (balance >= 0))"); st.executeUpdate("INSERT INTO accounts VALUES (1, 'Ana', 100), (2, 'Boris', 50)"); }}
static void move(Connection conn, int id, int amount) throws SQLException { try (PreparedStatement ps = conn.prepareStatement("UPDATE accounts SET balance = balance + ? WHERE id = ?")) { ps.setInt(1, amount); ps.setInt(2, id); ps.executeUpdate(); }}
static void printBalances(Connection conn) throws SQLException { try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT owner, balance FROM accounts ORDER BY id")) { while (rs.next()) { System.out.println(" " + rs.getString("owner") + ": €" + rs.getInt("balance")); } }}static void transfer(Connection conn, int from, int to, int amount) throws SQLException { move(conn, to, amount); move(conn, from, -amount);}
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:bank")) { openAccounts(conn); try { transfer(conn, 1, 2, 150); } catch (SQLException e) { System.out.println("The transfer of €150 was refused."); } printBalances(conn);}The transfer of €150 was refused. Ana: €100 Boris: €200static void transfer(Connection conn, int from, int to, int amount) throws SQLException { conn.setAutoCommit(false); try { move(conn, to, amount); move(conn, from, -amount); conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); }}The transfer of €150 was refused. Ana: €100 Boris: €50setAutoCommit(false)starts a transaction: the changes aren't final yet.commit()saves them all at once.rollback()undoes them all if something fails. The exception goes on up, so the caller finds out too.finallyputs the connection back in the normal mode.
More precisely, if you are curious
- H2's message for the failed query is
Check constraint violation: "NO_DEBT: ".NO_DEBTis the name of the condition: in the table it'sbalance INT CONSTRAINT no_debt CHECK (balance >= 0).
- Databases promise four things about transactions, known as ACID: all or nothing (atomicity), the data stays correct according to the table rules (consistency), transactions running at the same time don't get in each other's way (isolation), and what's saved isn't lost even if the power goes out (durability).
The idea
ORM: objects instead of rows
@Entity@Table(name = "products")class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false, length = 50) private String name;
private double price;
protected Product() { }
public Product(String name, double price) { this.name = name; this.price = price; }
public String getName() { return name; }
public double getPrice() { return price; }}@Entitysays "this class is a table", and@Tablegives its name.@Idis the primary key, and@GeneratedValuelets the database give it a value.@Columnsets details for the column. Fields without an annotation become columns too.- The empty constructor is required: Hibernate uses it to make the object before filling in the fields.
Saving and reading go through the EntityManager:
static void jpa() { EntityManagerFactory factory = Persistence.createEntityManagerFactory("shop"); EntityManager em = factory.createEntityManager();
em.getTransaction().begin(); em.persist(new Product("Coffee", 7.50)); em.persist(new Product("Tea", 3.20)); em.getTransaction().commit();
List<Product> cheap = em.createQuery( "SELECT p FROM Product p WHERE p.price < :max ORDER BY p.price", Product.class) .setParameter("max", 5.0) .getResultList(); for (Product p : cheap) { System.out.println(p.getName() + ": €" + p.getPrice()); }
em.close(); factory.close();}persist saves the object, and it does so in a transaction, as in JDBC. The query is in JPQL: it looks like SQL, but it talks about the Product class and its price field, not about the table. The result is a list of ready objects. The driver and the address are set in META-INF/persistence.xml:
<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence" version="2.2"> <persistence-unit name="shop"> <class>Product</class> <properties> <property name="javax.persistence.jdbc.url" value="jdbc:h2:mem:shop"/> <property name="hibernate.hbm2ddl.auto" value="create"/> </properties> </persistence-unit></persistence>Here on the site, this code is only compiled: to run it, you need Hibernate with all its libraries, and a configuration.
More precisely, if you are curious
- With Hibernate's own interface, the same thing goes through a
Session, and the queries are in HQL (Hibernate's language, which JPQL grew out of):
static void hibernate() { SessionFactory factory = new Configuration().configure().buildSessionFactory(); try (Session session = factory.openSession()) { Transaction tx = session.beginTransaction(); session.save(new Product("Chocolate", 2.40)); tx.commit();
List<Product> cheap = session.createQuery("from Product where price < :max", Product.class) .setParameter("max", 5.0) .list(); System.out.println("Cheap: " + cheap.size()); } factory.close();}- You can also build a query with code, without text: the Criteria API. It's handy when the conditions are put together from the user's choices:
static List<Product> named(EntityManager em, String name) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Product> query = cb.createQuery(Product.class); Root<Product> p = query.from(Product.class); query.select(p).where(cb.equal(p.get("name"), name)); return em.createQuery(query).getResultList();}- A transaction with
EntityManager, undone on an error:
EntityTransaction tx = em.getTransaction();try { tx.begin(); // changes to the entities tx.commit();} catch (RuntimeException e) { if (tx.isActive()) { tx.rollback(); } throw e;} finally { em.close();}- Hibernate 5 and JPA 2.2 use the
javax.persistencepackage. From JPA 3 (Hibernate 6) on, the package isjakarta.persistence, and the rest is almost the same.
Exercise
A store with a database
com.h2database:h2) and the address jdbc:h2:mem:....1. Catalog. Make a products table and the methods add, setPrice, remove and list, all with PreparedStatement except list. Add Coffee (€7.50), Tea (€3.20) and Chocolate (€2.40), print them, change the price of the coffee to €6.90, delete the chocolate and print again:1. Coffee: €7.52. Tea: €3.23. Chocolate: €2.4---1. Coffee: €6.92. Tea: €3.2find(conn, text) finds all products whose name contains the text, ignoring upper and lower case: WHERE LOWER(name) LIKE ? with the value "%" + text.toLowerCase() + "%". Try "CO", "éclair" and an injection attempt:Searching for "CO": Cocoa: €4.8 Coffee: €7.5Searching for "éclair": Éclair: €2.4Searching for "' OR '1'='1": no products foundtransfer(conn, from, to, amount) first subtracts from from, then adds to to. If executeUpdate returns 0 (no such account), throw SQLException("No account " + id). Everything is in a transaction. Transfer €30 from 1 to 2, then €40 from 1 to the account 99, which doesn't exist:Transferred €30 from 1 to 2Refused: No account 99 Ana: €70 Boris: €80The second transfer took €40 from Ana before it failed, but rollback gave it back.
Test